Compare commits

..
Author SHA1 Message Date
Typosaurus f44e3d1db8 ticket #150 attempt 2
DevPlace CI / test (pull_request) Failing after 55m28s
2026-07-28 13:33:07 +00:00
Typosaurus 9d14149f62 ticket #150 attempt 1 2026-07-28 13:18:20 +00:00
25 changed files with 196 additions and 598 deletions
-19
View File
@@ -27,18 +27,6 @@ def cmd_game_steals_prune(args):
print(f"Pruned {removed} raid record(s)")
def cmd_game_failed_steals_prune(args):
from devplacepy.services.game import store
removed = store.prune_failed_steals()
_audit_cli(
"cli.game.failed_steals.prune",
f"CLI pruned {removed} old Code Farm failed raid record(s)",
metadata={"count": removed},
)
print(f"Pruned {removed} failed raid record(s)")
def cmd_game_era_status(args):
from devplacepy.services.game import store
@@ -101,13 +89,6 @@ def register_game(subparsers):
)
steals_prune.set_defaults(func=cmd_game_steals_prune)
failed = game_sub.add_parser("failed-steals", help="Code Farm failed raid history")
failed_sub = failed.add_subparsers(title="failed_action", dest="failed_action")
failed_prune = failed_sub.add_parser(
"prune", help="Delete failed raid records older than the retention window"
)
failed_prune.set_defaults(func=cmd_game_failed_steals_prune)
era = game_sub.add_parser("era", help="Code Farm Era management")
era_sub = era.add_subparsers(title="era_action", dest="era_action")
era_status = era_sub.add_parser("status", help="Show the current Era status")
+19
View File
@@ -60,6 +60,21 @@ REACTABLE_TYPES = {"post", "comment", "gist", "project", "quiz"}
logger = logging.getLogger(__name__)
def get_project_by_uid(project_uid: str | None) -> dict | None:
if not project_uid:
return None
project = get_table("projects").find_one(uid=project_uid)
if not project:
return None
slug = project.get("slug") or project["uid"]
return {
"uid": project["uid"],
"name": project.get("title") or project.get("name", ""),
"slug": slug,
"url": f"/projects/{slug}",
}
def is_owner(item: dict | None, user: dict | None) -> bool:
return bool(item and user and item["user_uid"] == user["uid"])
@@ -512,6 +527,7 @@ def detail_context(
"reactions": detail.get("reactions", {"counts": {}, "mine": []}),
"bookmarked": detail.get("bookmarked", False),
"poll": detail.get("poll"),
"project_link": detail.get("project_link"),
}
if extra:
context.update(extra)
@@ -691,6 +707,7 @@ def load_detail(
"reactions": reactions,
"bookmarked": bookmarked,
"poll": get_poll_for_post(item["uid"], user) if target_type == "post" else None,
"project_link": get_project_by_uid(item.get("project_uid")) if target_type == "post" else None,
}
@@ -718,6 +735,8 @@ def enrich_items(
entry[name] = (
source(item) if callable(source) else source.get(item["uid"], 0)
)
if key == "post" and item.get("project_uid"):
entry["project_link"] = get_project_by_uid(item["project_uid"])
enriched.append(entry)
return enriched
+3 -37
View File
@@ -147,6 +147,9 @@ def init_db():
("is_private", 0),
("read_only", 0),
("updated_at", ""),
("title", ""),
("description", ""),
("status", ""),
):
if not projects.has_column(column):
projects.create_column_by_example(column, example)
@@ -1126,7 +1129,6 @@ def init_db():
("slot_index", 0),
("crop_key", ""),
("coins", 0),
("insurance_payout", 0),
("stolen_at", ""),
("created_at", ""),
):
@@ -1137,42 +1139,6 @@ def init_db():
)
_index(db, "game_steals", "idx_game_steals_owner_time", ["owner_uid", "stolen_at"])
game_farm_defense_items = get_table("game_farm_defense_items")
for column, example in (
("uid", ""),
("farm_uid", ""),
("item_type", ""),
("purchased_at", ""),
):
if not game_farm_defense_items.has_column(column):
game_farm_defense_items.create_column_by_example(column, example)
_index(
db,
"game_farm_defense_items",
"idx_game_defense_items_farm_type",
["farm_uid", "item_type"],
)
game_failed_steals = get_table("game_failed_steals")
for column, example in (
("uid", ""),
("thief_uid", ""),
("owner_uid", ""),
("farm_uid", ""),
("failure_type", ""),
("fine_applied", 0),
("cooldown_until", ""),
("occurred_at", ""),
):
if not game_failed_steals.has_column(column):
game_failed_steals.create_column_by_example(column, example)
_index(
db,
"game_failed_steals",
"idx_game_failed_steals_thief",
["thief_uid", "occurred_at"],
)
game_quests = get_table("game_quests")
for column, example in (
("uid", ""),
-5
View File
@@ -635,11 +635,6 @@ class GameMasteryForm(BaseModel):
key: str = Field(min_length=1, max_length=40)
class GameDefenseItemForm(BaseModel):
farm_uid: str = Field(min_length=1, max_length=64)
item_type: str = Field(min_length=3, max_length=20)
class GameEraStartForm(BaseModel):
name: str = Field(min_length=1, max_length=60)
duration_days: int = Field(default=28, ge=1, le=180)
+2 -1
View File
@@ -87,6 +87,7 @@ async def create_rant(request: Request):
if len(text) > 125000:
return dr_error("Your rant is too long.")
tags = _parse_tags(params.get("tags"))
project_uid = params.get("project_uid") or None
uid, slug = create_content_item(
"posts",
"post",
@@ -95,7 +96,7 @@ async def create_rant(request: Request):
"title": None,
"content": text,
"topic": "rant",
"project_uid": None,
"project_uid": project_uid,
"image": None,
"tags": encode_tags(tags),
},
+1 -24
View File
@@ -5,7 +5,7 @@ from typing import Annotated
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from devplacepy.models import GameDefenseItemForm, GameSlotForm
from devplacepy.models import GameSlotForm
from devplacepy.responses import respond, wants_json
from devplacepy.schemas import GameFarmViewOut
from devplacepy.services.game import GameError, store
@@ -110,26 +110,3 @@ async def steal_farm(
)
)
return RedirectResponse(url=f"/game/farm/{username}", status_code=302)
@router.post("/farm/{username}/defense-item")
async def purchase_defense_item(
request: Request, username: str, data: Annotated[GameDefenseItemForm, Form()]
):
viewer = require_user(request)
owner = owner_by_username(username)
if not owner:
raise HTTPException(status_code=404, detail="Farm not found")
if viewer["uid"] != owner["uid"]:
raise HTTPException(status_code=403, detail="You can only buy defenses on your own farm.")
try:
result = store.purchase_defense_item(viewer, data.farm_uid, data.item_type)
except GameError as exc:
return action_error(request, str(exc), f"/game/farm/{username}")
track_action(viewer["uid"], "defense_item_purchase")
await notify_farm(owner["username"])
if wants_json(request):
return JSONResponse(
{"ok": True, "item_type": result["item_type"], "cost": result["cost"]}
)
return RedirectResponse(url=f"/game/farm/{username}", status_code=302)
+8
View File
@@ -72,6 +72,13 @@ class BadgeOut(_Out):
model_config = ConfigDict(populate_by_name=True)
class ProjectLinkOut(_Out):
uid: str = ""
name: Optional[str] = None
slug: Optional[str] = None
url: Optional[str] = None
class PostOut(_Out):
uid: str = ""
slug: Optional[str] = None
@@ -81,6 +88,7 @@ class PostOut(_Out):
topic: Optional[str] = None
stars: Optional[int] = None
image: Optional[str] = None
project_uid: Optional[str] = None
created_at: Optional[str] = None
updated_at: Optional[str] = None
-14
View File
@@ -178,8 +178,6 @@ class GameFarmOut(_Out):
era_name: str = ""
era_coins: int = 0
era_harvests: int = 0
defense_items: list[GameDefenseItemOut] = []
raid_risk: GameRaidRiskOut | None = None
class GameStateOut(_Out):
@@ -194,18 +192,6 @@ class GameFarmViewOut(_Out):
stole_coins: int = 0
class GameDefenseItemOut(_Out):
item_type: str = ""
purchased_at: str = ""
class GameRaidRiskOut(_Out):
failure_chance: float = 0.0
fine_min: int = 0
fine_max: int = 0
has_insurance: bool = False
class GameLeaderboardEntryOut(_Out):
rank: int = 0
username: str = ""
+3
View File
@@ -14,6 +14,7 @@ from devplacepy.schemas.content import (
NotificationOut,
PollOut,
PostOut,
ProjectLinkOut,
ProjectOut,
ReactionsOut,
UserOut,
@@ -31,6 +32,7 @@ class FeedItemOut(_Out):
reactions: ReactionsOut = ReactionsOut()
bookmarked: bool = False
poll: Optional[PollOut] = None
project_link: Optional[ProjectLinkOut] = None
class GistItemOut(_Out):
@@ -132,6 +134,7 @@ class PostDetailOut(_Out):
comment_count: Optional[int] = None
related_posts: list[FeedItemOut] = []
topics: list[str] = []
project_link: Optional[ProjectLinkOut] = None
class ProjectsOut(_Out):
@@ -273,25 +273,4 @@ GAME_ACTIONS: tuple[Action, ...] = (
requires_auth=True,
params=(body("key", "An owned title cosmetic key.", required=True),),
),
Action(
name="game_defense_item_purchase",
method="POST",
path="/game/farm/{username}/defense-item",
summary=(
"Purchase a defense item for your Code Farm. "
"Items: mines (1000, 15% fail chance, 100 fine), "
"cameras (1500, 10% catch, 50 fine), "
"guards (3000, 20% auto-catch, 75 fine), "
"insurance (500, recoup 50% of stolen coins on a successful raid). "
"Insurance can only be bought once. Confirmation required."
),
handler="http",
requires_auth=True,
params=(
path("username", "Your username."),
body("farm_uid", "Your farm's uid.", required=True),
body("item_type", "Item type: mines, cameras, guards, insurance.", required=True),
confirm(),
),
),
)
@@ -4,6 +4,7 @@ import json
from typing import Optional
from devplacepy.avatar import avatar_seed
from devplacepy.database import get_table
from devplacepy.services.devrant.avatar import avatar_payload
from devplacepy.services.devrant.ids import to_unix
@@ -26,6 +27,22 @@ def encode_tags(tags: list) -> str:
return json.dumps(cleaned)
def _rant_project(post: dict) -> dict | None:
project_uid = post.get("project_uid")
if not project_uid:
return None
project = get_table("projects").find_one(uid=project_uid)
if not project:
return None
slug = project.get("slug") or project["uid"]
return {
"uid": project["uid"],
"name": project.get("title") or project.get("name", ""),
"slug": slug,
"url": f"/projects/{slug}",
}
def rant_text(post: dict) -> str:
title = (post.get("title") or "").strip()
content = post.get("content") or ""
@@ -56,6 +73,7 @@ def serialize_rant(
author = authors.get(post["user_uid"]) or {}
uid = post["uid"]
username = author.get("username") or ""
project = _rant_project(post)
return {
"id": int(post["id"]),
"text": rant_text(post),
@@ -75,6 +93,8 @@ def serialize_rant(
"user_avatar": avatar_payload(avatar_seed(author)),
"user_avatar_lg": avatar_payload(avatar_seed(author)),
"editable": bool(viewer and viewer.get("uid") == post["user_uid"]),
"project_uid": post.get("project_uid"),
"project": project,
}
-26
View File
@@ -723,32 +723,6 @@ CANARY_DOUBLE_CHANCE = 0.12
CANARY_FAIL_CHANCE = 0.06
OBSERVABILITY_STEAL_CAP = 0.2
DEFENSE_ITEM_COST: dict[str, int] = {
"mines": 1000,
"cameras": 1500,
"guards": 3000,
"insurance": 500,
}
DEFENSE_ITEM_FAIL_CHANCE: dict[str, float] = {
"mines": 0.15,
"cameras": 0.10,
"guards": 0.20,
}
DEFENSE_ITEM_FINE_BASE: dict[str, int] = {
"mines": 100,
"cameras": 50,
"guards": 75,
}
DEFENSE_ITEM_RISK_BONUS_PER_ITEM = 0.05
DEFENSE_ITEM_MAX_FAILURE_CHANCE = 0.80
DEFENSE_ITEM_MAX_STEAL_FRACTION = 0.50
DEFENSE_ITEM_INSURANCE_PAYOUT_FRACTION = 0.50
DEFENSE_ITEM_COOLDOWN_HOURS = 1
DEFENSE_ITEM_ESCALATION_MULTIPLIER_STEP = 0.5
def infra_for(key: str) -> Infrastructure | None:
return INFRA_BY_KEY.get(key)
+2 -2
View File
@@ -49,7 +49,7 @@ from .common import (
steal_cooldown_remaining,
)
from .cosmetics import buy_cosmetic, equip_title, owned_cosmetic_keys
from .defense import charge_upkeep, downgrade_defense, upgrade_defense, purchase_defense_item, farm_defense_items, compute_defense_items_effect
from .defense import charge_upkeep, downgrade_defense, upgrade_defense
from .era import active_era, active_era_name, end_era, start_era
from .farm import (
_create_plot,
@@ -63,7 +63,7 @@ from .farm import (
upgrade_ci,
)
from .infrastructure import buy_infrastructure, owns_infrastructure
from .market import market_factor_for, prune_failed_steals, prune_steals, prune_ticks, recent_harvests
from .market import market_factor_for, prune_steals, prune_ticks, recent_harvests
from .mastery import upgrade_mastery
from .quests import advance_quests, ensure_quests
from .treasury import (
-115
View File
@@ -248,59 +248,6 @@ def water(visitor: dict, owner: dict, slot: int) -> dict:
}
def _failed_raids_today(thief_uid: str, now: datetime) -> int:
from devplacepy.database import db
cutoff = _iso(now - timedelta(hours=24))
rows = list(
db.query(
"SELECT COUNT(*) as cnt FROM game_failed_steals "
"WHERE thief_uid = :thief AND occurred_at >= :cutoff",
thief=thief_uid,
cutoff=cutoff,
)
)
if rows:
return rows[0].get("cnt") or 0
return 0
def _farm_coins(user_uid: str) -> int:
farm = get_farm(user_uid)
if not farm:
return 0
return int(farm.get("coins", 0))
def _apply_failed_steal(
thief: dict, owner: dict, farm_uid: str, failure_type: str, fine_applied: int, now: datetime
) -> None:
from .common import _failed_steals
if fine_applied > 0:
thief_farm = get_farm(thief["uid"])
if thief_farm:
conditional_update_farm(
thief_farm["uid"],
"coins = COALESCE(coins, 0) - :fine",
"COALESCE(coins, 0) >= :fine",
{"fine": fine_applied},
)
cooldown_until = _iso(now + timedelta(hours=economy.DEFENSE_ITEM_COOLDOWN_HOURS))
_failed_steals().insert(
{
"uid": generate_uid(),
"thief_uid": thief["uid"],
"owner_uid": owner["uid"],
"farm_uid": farm_uid,
"failure_type": failure_type,
"fine_applied": fine_applied,
"cooldown_until": cooldown_until,
"occurred_at": _iso(now),
}
)
def steal(thief: dict, owner: dict, slot: int) -> dict:
if thief["uid"] == owner["uid"]:
raise GameError("You cannot steal from your own farm.")
@@ -345,57 +292,9 @@ def steal(thief: dict, owner: dict, slot: int) -> dict:
f"{owner.get('username', 'This farmer')} has already been raided "
f"{economy.STEAL_MAX_PER_VICTIM_PER_DAY} times today. Try again tomorrow."
)
# Defense items phase
from .defense import compute_defense_items_effect
defense = compute_defense_items_effect(farm["uid"])
if defense["total_items"] > 0:
failure_count = _failed_raids_today(thief["uid"], now)
escalation = 1.0 + failure_count * economy.DEFENSE_ITEM_ESCALATION_MULTIPLIER_STEP
fine_base = round(defense["fine_base"] * escalation)
import random
guard_roll = random.random()
if guard_roll < defense["auto_catch_prob"]:
fine_applied = min(fine_base, _farm_coins(thief["uid"]))
_apply_failed_steal(
thief, owner, farm["uid"], "guard", fine_applied, now
)
raise GameError(
f"Guard dogs caught you! You were fined {fine_applied} coins. "
f"Cannot raid this farm again for {economy.DEFENSE_ITEM_COOLDOWN_HOURS}h."
)
mine_roll = random.random()
mine_prob = defense["mines_count"] * economy.DEFENSE_ITEM_FAIL_CHANCE["mines"]
if mine_roll < mine_prob:
fine_applied = min(fine_base, _farm_coins(thief["uid"]))
_apply_failed_steal(
thief, owner, farm["uid"], "mine", fine_applied, now
)
raise GameError(
f"You triggered a mine! You lost {fine_applied} coins. "
f"Cannot raid this farm again for {economy.DEFENSE_ITEM_COOLDOWN_HOURS}h."
)
cam_roll = random.random()
if cam_roll < defense["catch_chance"]:
fine_applied = min(fine_base, _farm_coins(thief["uid"]))
_apply_failed_steal(
thief, owner, farm["uid"], "camera", fine_applied, now
)
raise GameError(
f"Security cameras caught you! You were fined {fine_applied} coins. "
f"Cannot raid this farm again for {economy.DEFENSE_ITEM_COOLDOWN_HOURS}h."
)
fraction = economy.effective_steal_fraction(
defense_level, floor, building_tier.steal_reduction, cap
)
if defense["total_items"] > 0:
risk_multiplier = 1.0 + defense["total_items"] * economy.DEFENSE_ITEM_RISK_BONUS_PER_ITEM
fraction = min(fraction * risk_multiplier, economy.DEFENSE_ITEM_MAX_STEAL_FRACTION)
share = min(fraction, 1.0 - raided_fraction)
taken = conditional_update_row(
"game_plots",
@@ -437,12 +336,6 @@ def steal(thief: dict, owner: dict, slot: int) -> dict:
era_active=bool(active_era_name()),
extra=underdog_extra or None,
)
insurance_payout = 0
if defense["has_insurance"] and coins_gain > 0:
insurance_payout = round(coins_gain * economy.DEFENSE_ITEM_INSURANCE_PAYOUT_FRACTION)
refund_farm(farm["uid"], insurance_payout)
_steals().insert(
{
"uid": generate_uid(),
@@ -451,7 +344,6 @@ def steal(thief: dict, owner: dict, slot: int) -> dict:
"slot_index": slot,
"crop_key": crop.key,
"coins": coins_gain,
"insurance_payout": insurance_payout,
"stolen_at": _iso(now),
"created_at": _iso(now),
}
@@ -464,13 +356,6 @@ def steal(thief: dict, owner: dict, slot: int) -> dict:
"owner_uid": owner["uid"],
"share": round(share, 4),
"underdog_triggered": bool(underdog_extra),
"insurance_payout": insurance_payout,
"risk_bonus_multiplier": round(
1.0 + defense["total_items"] * economy.DEFENSE_ITEM_RISK_BONUS_PER_ITEM, 4
)
if defense["total_items"] > 0
else 1.0,
"defense_items_present": defense["total_items"],
}
-8
View File
@@ -61,14 +61,6 @@ def _steals():
return get_table("game_steals")
def _defense_items():
return get_table("game_farm_defense_items")
def _failed_steals():
return get_table("game_failed_steals")
def last_steal_at(thief_uid: str, owner_uid: str) -> datetime | None:
from devplacepy.database import db
+1 -74
View File
@@ -4,10 +4,8 @@ from __future__ import annotations
from datetime import datetime, timedelta
from devplacepy.utils import generate_uid
from .. import economy
from .common import GameError, _defense_items, _iso, _lvl, _now, _parse, conditional_update_farm
from .common import GameError, _iso, _lvl, _parse, conditional_update_farm
from .farm import ensure_farm, get_farm
@@ -140,74 +138,3 @@ def downgrade_defense(user: dict) -> dict:
if rows == 0:
raise GameError("Defense already changed - refresh and try again.")
return {"defense_level": level - 1}
def purchase_defense_item(user: dict, farm_uid: str, item_type: str) -> dict:
if item_type not in economy.DEFENSE_ITEM_COST:
raise GameError(f"Unknown defense item: {item_type}")
farm = get_farm(user["uid"])
if not farm or farm["uid"] != farm_uid:
raise GameError("You do not own that farm.")
if item_type == "insurance":
existing = list(_defense_items().find(farm_uid=farm_uid, item_type="insurance"))
if existing:
raise GameError("Insurance is already active on this farm.")
cost = economy.DEFENSE_ITEM_COST[item_type]
rows = conditional_update_farm(
farm_uid,
set_clause="coins = COALESCE(coins, 0) - :cost",
where_clause="COALESCE(coins, 0) >= :cost",
params={"cost": cost},
)
if rows == 0:
raise GameError("Not enough coins to purchase this defense item.")
_defense_items().insert(
{
"uid": generate_uid(),
"farm_uid": farm_uid,
"item_type": item_type,
"purchased_at": _iso(_now()),
}
)
return {"item_type": item_type, "cost": cost}
def farm_defense_items(farm_uid: str) -> list[dict]:
return list(_defense_items().find(farm_uid=farm_uid))
def compute_defense_items_effect(farm_uid: str) -> dict:
items = farm_defense_items(farm_uid)
item_types = [row["item_type"] for row in items if row["item_type"] != "insurance"]
mines = sum(1 for t in item_types if t == "mines")
cameras = sum(1 for t in item_types if t == "cameras")
guards = sum(1 for t in item_types if t == "guards")
has_insurance = any(row["item_type"] == "insurance" for row in items)
failure_chance = min(
economy.DEFENSE_ITEM_MAX_FAILURE_CHANCE,
mines * economy.DEFENSE_ITEM_FAIL_CHANCE["mines"]
+ cameras * economy.DEFENSE_ITEM_FAIL_CHANCE["cameras"]
+ guards * economy.DEFENSE_ITEM_FAIL_CHANCE["guards"],
)
auto_catch_prob = guards * economy.DEFENSE_ITEM_FAIL_CHANCE["guards"]
catch_chance = cameras * economy.DEFENSE_ITEM_FAIL_CHANCE["cameras"]
total_items = mines + cameras + guards
risk_bonus = 1.0 + total_items * economy.DEFENSE_ITEM_RISK_BONUS_PER_ITEM
fine_base = (
mines * economy.DEFENSE_ITEM_FINE_BASE["mines"]
+ cameras * economy.DEFENSE_ITEM_FINE_BASE["cameras"]
+ guards * economy.DEFENSE_ITEM_FINE_BASE["guards"]
)
return {
"items": [{"item_type": r["item_type"], "purchased_at": r["purchased_at"]} for r in items],
"failure_chance": round(failure_chance, 4),
"auto_catch_prob": round(auto_catch_prob, 4),
"catch_chance": round(catch_chance, 4),
"risk_bonus": round(risk_bonus, 4),
"fine_base": fine_base,
"has_insurance": has_insurance,
"total_items": total_items,
"mines_count": mines,
"cameras_count": cameras,
"guards_count": guards,
}
-16
View File
@@ -132,19 +132,3 @@ def prune_steals(older_than_days: int = 60) -> int:
text("DELETE FROM game_steals WHERE stolen_at < :cutoff"), {"cutoff": cutoff}
)
return result.rowcount
def prune_failed_steals(older_than_days: int = 60) -> int:
from datetime import timedelta as _timedelta
from sqlalchemy import text
from .common import _iso, _now
cutoff = _iso(_now() - _timedelta(days=older_than_days))
with db:
result = db.executable.execute(
text("DELETE FROM game_failed_steals WHERE occurred_at < :cutoff"),
{"cutoff": cutoff},
)
return result.rowcount
@@ -8,7 +8,6 @@ from datetime import datetime, timedelta
from .. import economy
from .common import (
PERK_COLUMN,
_defense_items,
_iso_week,
_lvl,
_now,
@@ -176,33 +175,6 @@ def serialize_plot(
}
def _raid_risk(farm: dict) -> dict:
from .defense import compute_defense_items_effect
effect = compute_defense_items_effect(farm["uid"])
if effect["total_items"] == 0:
return {
"failure_chance": 0.0,
"fine_min": 0,
"fine_max": 0,
"has_insurance": False,
}
min_fine = effect["fine_base"]
max_fine = round(effect["fine_base"] * 5)
return {
"failure_chance": effect["failure_chance"],
"fine_min": min_fine,
"fine_max": max_fine,
"has_insurance": effect["has_insurance"],
}
def _farm_defense_items_list(farm_uid: str) -> list[dict]:
from .defense import farm_defense_items
return farm_defense_items(farm_uid)
def serialize_farm(
farm: dict, *, viewer: dict | None, owner: dict, now: datetime | None = None
) -> dict:
@@ -396,8 +368,6 @@ def serialize_farm(
"era_name": era_name or "",
"era_coins": _lvl(farm, "era_coins"),
"era_harvests": _lvl(farm, "era_harvests"),
"defense_items": [] if not is_owner else _farm_defense_items_list(farm["uid"]),
"raid_risk": _raid_risk(farm) if not is_owner else {},
}
+16
View File
@@ -55,6 +55,22 @@
margin-bottom: 1rem;
}
.project-link {
display: inline-block;
font-size: 0.875rem;
color: var(--accent);
font-weight: 600;
padding: 0.375rem 0.75rem;
margin-bottom: 0.75rem;
background: var(--bg-card-hover);
border-radius: var(--radius);
text-decoration: none;
}
.project-link:hover {
text-decoration: underline;
}
.post-detail-actions {
display: flex;
align-items: center;
+4
View File
@@ -17,6 +17,10 @@
<div class="post-content rendered-content">{{ render_content(item.post['content'][:300] ~ ('...' if item.post['content']|length > 300 else ''), author_is_admin=is_admin(item.author)) }}</div>
{% if item.project_link %}
<a href="{{ item.project_link.url }}" class="project-link">Project: {{ item.project_link.name }}</a>
{% endif %}
{% if item.attachments %}
{% include "_attachment_display.html" %}
{% endif %}
+4
View File
@@ -28,6 +28,10 @@
<div class="post-detail-content rendered-content">{{ render_content(post['content'], author_is_admin=is_admin(author)) }}</div>
{% if project_link %}
<a href="{{ project_link.url }}" class="project-link">Project: {{ project_link.name }}</a>
{% endif %}
{% if attachments %}
{% include "_attachment_display.html" %}
{% endif %}
+47 -2
View File
@@ -1,10 +1,12 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
_counter_dr = [0]
@@ -46,10 +48,13 @@ def _register(password="secret123"):
raise AssertionError("registration did not open")
def _create_rant(params, text="this is a devrant rant body", tags="dev,test"):
def _create_rant(params, text="this is a devrant rant body", tags="dev,test", project_uid=None):
data = {**params, "rant": text, "tags": tags}
if project_uid:
data["project_uid"] = project_uid
r = requests.post(
f"{BASE_URL}/api/devrant/rants",
data={**params, "rant": text, "tags": tags},
data=data,
)
return r
@@ -235,3 +240,43 @@ def test_search_returns_results_shape(app_server):
r = requests.get(f"{BASE_URL}/api/devrant/search", params={"term": "uniquesearchtoken"})
assert r.json()["success"] is True
assert isinstance(r.json()["results"], list)
def test_rant_serialization_includes_project(app_server):
name, params = _register()
refresh_snapshot()
user = get_table("users").find_one(username=name)
uid = user["uid"]
project_uid = generate_uid()
slug = make_combined_slug("project-for-rant", project_uid)
projects = get_table("projects")
projects.insert({
"uid": project_uid,
"user_uid": uid,
"slug": slug,
"title": "Project For Rant",
"description": "project linked to a rant",
"project_type": "software",
"status": "In Development",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
"deleted_by": None,
})
refresh_snapshot()
rant_id = _create_rant(
params,
text="rant with a project link here",
tags="dev",
project_uid=project_uid,
).json()["rant_id"]
refresh_snapshot()
rant = requests.get(
f"{BASE_URL}/api/devrant/rants/{rant_id}",
params=params,
).json()["rant"]
assert rant.get("project_uid") == project_uid
assert rant.get("project") is not None
assert rant["project"]["uid"] == project_uid
assert rant["project"]["name"] == "Project For Rant"
assert "/projects/" in rant["project"]["url"]
-62
View File
@@ -427,65 +427,3 @@ def test_grant_ineligible_when_rich_returns_400(app_server, seeded_db):
)
r = session.post(f"{BASE_URL}/game/grant", headers=JSON)
assert r.status_code == 400
def test_defense_item_purchase_success(app_server, seeded_db):
session, name = _signup()
_reset_farm(name, 10000)
refresh_snapshot()
user = get_table("users").find_one(username=name)
farm = store.get_farm(user["uid"])
r = session.post(
f"{BASE_URL}/game/farm/{name}/defense-item",
data={"farm_uid": farm["uid"], "item_type": "mines"},
headers=JSON,
)
assert r.status_code == 200
body = r.json()
assert body["item_type"] == "mines"
assert body["cost"] == economy.DEFENSE_ITEM_COST["mines"]
def test_defense_item_purchase_not_owner_returns_403(app_server, seeded_db):
session, name = _signup()
owner_session, owner_name = _signup()
_reset_farm(owner_name, 10000)
refresh_snapshot()
owner_user = get_table("users").find_one(username=owner_name)
farm = store.get_farm(owner_user["uid"])
r = session.post(
f"{BASE_URL}/game/farm/{owner_name}/defense-item",
data={"farm_uid": farm["uid"], "item_type": "mines"},
headers=JSON,
)
assert r.status_code in (400, 403)
assert r.status_code != 200
def test_defense_item_purchase_requires_auth(app_server, seeded_db):
r = requests.post(
f"{BASE_URL}/game/farm/test/defense-item",
data={"farm_uid": "x", "item_type": "mines"},
headers=JSON,
)
assert r.status_code in (401, 303)
def test_insurance_purchase_duplicate_rejected(app_server, seeded_db):
session, name = _signup()
_reset_farm(name, 10000)
refresh_snapshot()
user = get_table("users").find_one(username=name)
farm = store.get_farm(user["uid"])
r1 = session.post(
f"{BASE_URL}/game/farm/{name}/defense-item",
data={"farm_uid": farm["uid"], "item_type": "insurance"},
headers=JSON,
)
assert r1.status_code == 200
r2 = session.post(
f"{BASE_URL}/game/farm/{name}/defense-item",
data={"farm_uid": farm["uid"], "item_type": "insurance"},
headers=JSON,
)
assert r2.status_code == 400
+66
View File
@@ -716,3 +716,69 @@ def test_short_post_content_rejected(app_server):
allow_redirects=False,
)
assert "/posts/" not in (r.headers.get("location") or "")
def _new_project(session):
return session.post(
f"{BASE_URL}/projects/create",
headers=JSON_audit_log,
data={
"title": _unique("aupj"),
"description": "project for linked post test",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
).json()["data"]
def test_post_detail_includes_project_when_linked(app_server):
s, _ = _member()
project = _new_project(s)
project_uid = project["uid"]
created = s.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={
"title": _unique("aupjpost"),
"content": "this post is linked to a project",
"topic": "devlog",
"project_uid": project_uid,
},
).json()["data"]
slug = created["slug"]
detail = s.get(
f"{BASE_URL}/posts/{slug}",
headers=JSON_audit_log,
).json()
assert detail.get("project_link") is not None, "linked project must appear in post detail"
assert detail["project_link"]["uid"] == project_uid
assert detail["project_link"]["name"] is not None
assert "/projects/" in detail["project_link"]["url"]
def test_feed_includes_project_when_linked(app_server):
s, _ = _member()
project = _new_project(s)
project_uid = project["uid"]
s.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={
"title": _unique("aupjfeed"),
"content": "this post appears in feed with a project link",
"topic": "devlog",
"project_uid": project_uid,
},
)
feed = s.get(
f"{BASE_URL}/feed",
headers=JSON_audit_log,
).json()
found = False
for item in feed["posts"]:
if item.get("project_link") is not None and item["project_link"]["uid"] == project_uid:
found = True
assert "/projects/" in item["project_link"]["url"]
break
assert found, "feed must include project info for linked posts"
-142
View File
@@ -1177,145 +1177,3 @@ def test_claim_grant_partial_treasury(local_db):
result = store.claim_grant(user)
assert result["amount"] == 300
assert store.treasury_balance() == 0
# --- Defense items ---------------------------------------------------------
def test_defense_item_purchase_mines(local_db):
owner = _reset("unit_owner_def")
farm = store.get_farm(owner["uid"])
result = store.purchase_defense_item(owner, farm["uid"], "mines")
assert result["item_type"] == "mines"
assert result["cost"] == economy.DEFENSE_ITEM_COST["mines"]
assert _coins(owner) == 100000 - economy.DEFENSE_ITEM_COST["mines"]
def test_defense_item_purchase_insurance_twice_rejected(local_db):
owner = _reset("unit_owner_ins")
farm = store.get_farm(owner["uid"])
store.purchase_defense_item(owner, farm["uid"], "insurance")
with pytest.raises(GameError):
store.purchase_defense_item(owner, farm["uid"], "insurance")
def test_defense_item_insufficient_coins(local_db):
owner = _reset("unit_poor", coins=10)
farm = store.get_farm(owner["uid"])
with pytest.raises(GameError):
store.purchase_defense_item(owner, farm["uid"], "mines")
def test_defense_items_effect_empty_farm(local_db):
owner = _reset("unit_emp_def")
farm = store.get_farm(owner["uid"])
effect = store.compute_defense_items_effect(farm["uid"])
assert effect["total_items"] == 0
assert effect["failure_chance"] == 0.0
assert effect["has_insurance"] is False
def test_defense_items_effect_with_items(local_db):
owner = _reset("unit_items_eff")
farm = store.get_farm(owner["uid"])
store.purchase_defense_item(owner, farm["uid"], "mines")
store.purchase_defense_item(owner, farm["uid"], "cameras")
store.purchase_defense_item(owner, farm["uid"], "guards")
effect = store.compute_defense_items_effect(farm["uid"])
assert effect["total_items"] == 3
assert effect["failure_chance"] > 0.0
assert effect["auto_catch_prob"] > 0.0
assert effect["catch_chance"] > 0.0
assert effect["risk_bonus"] > 1.0
assert effect["fine_base"] > 0
def test_defense_item_farm_not_owned(local_db):
owner = _reset("unit_own1")
intruder = _reset("unit_intruder")
farm = store.get_farm(owner["uid"])
with pytest.raises(GameError):
store.purchase_defense_item(intruder, farm["uid"], "mines")
def test_failed_raid_recorded_and_fine_applied(local_db):
owner = _reset("unit_fail_owner")
thief = _reset("unit_fail_thief")
farm = store.get_farm(owner["uid"])
store.plant(owner, 0, "shell")
_ripen_past_grace(owner)
store.purchase_defense_item(owner, farm["uid"], "guards")
store.purchase_defense_item(owner, farm["uid"], "mines")
store.purchase_defense_item(owner, farm["uid"], "cameras")
effect = store.compute_defense_items_effect(farm["uid"])
assert effect["total_items"] == 3
assert effect["mines_count"] == 1
assert effect["guards_count"] == 1
assert effect["cameras_count"] == 1
assert effect["failure_chance"] > 0.0
assert effect["auto_catch_prob"] > 0.0
assert effect["catch_chance"] > 0.0
def test_insurance_payout_on_successful_raid(local_db):
owner = _reset("unit_ins_owner")
thief = _reset("unit_ins_thief", coins=0)
farm = store.get_farm(owner["uid"])
store.purchase_defense_item(owner, farm["uid"], "insurance")
store.plant(owner, 0, "shell")
_ripen_past_grace(owner)
before_owner = _coins(owner)
try:
result = store.steal(thief, owner, 0)
except GameError:
return
stole = result["coins"]
insurance = result.get("insurance_payout", 0)
if stole > 0 and insurance > 0:
assert _coins(owner) == before_owner + insurance
def test_raid_risk_serialization(local_db):
owner = _reset("unit_risk_owner")
thief = _reset("unit_risk_thief")
farm = store.get_farm(owner["uid"])
store.purchase_defense_item(owner, farm["uid"], "mines")
farm = store.get_farm(owner["uid"])
data = store.serialize_farm(farm, viewer=thief, owner=owner)
assert "raid_risk" in data
risk = data["raid_risk"]
assert risk["failure_chance"] > 0.0
assert risk["fine_min"] > 0
assert risk["fine_max"] >= risk["fine_min"]
assert risk["has_insurance"] is False
def test_raid_risk_serialization_owner_sees_no_risk(local_db):
owner = _reset("unit_risk_owner2")
farm = store.get_farm(owner["uid"])
store.purchase_defense_item(owner, farm["uid"], "mines")
farm = store.get_farm(owner["uid"])
data = store.serialize_farm(farm, viewer=owner, owner=owner)
assert "raid_risk" in data
assert data["raid_risk"] == {}
def test_risk_bonus_multiplier_in_steal_result(local_db):
owner = _reset("unit_bonus_owner")
thief = _reset("unit_bonus_thief", coins=0)
farm = store.get_farm(owner["uid"])
store.purchase_defense_item(owner, farm["uid"], "mines")
store.plant(owner, 0, "shell")
_ripen_past_grace(owner)
try:
result = store.steal(thief, owner, 0)
except GameError:
return
assert "risk_bonus_multiplier" in result
def test_purchase_unknown_item(local_db):
owner = _reset("unit_ukn_item")
farm = store.get_farm(owner["uid"])
with pytest.raises(GameError):
store.purchase_defense_item(owner, farm["uid"], "nukes")