diff --git a/CLAUDE.md b/CLAUDE.md index 417e531a..b4a4cefb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -283,6 +283,17 @@ Required patterns: every `page.goto(...)`/`page.wait_for_url(...)` MUST pass `wa **Never run tests unless the user explicitly asks for it.** Not the full suite, not a single file. Validate with a clean import (`python -c "from devplacepy.main import app"`) and per-language manual checks instead. +## Rigorous correctness verification (money, state machines, concurrency) + +The persisted test suite (`tests/unit`/`api`/`e2e`) is example-based: it proves the specific inputs someone wrote down behave correctly. It is structurally blind to three classes of bug - a formula wrong at an input nobody tried, an invariant violated only after a long sequence of actions, and a race that only two nearly-simultaneous requests trigger. **Any feature that touches a spendable resource (coins, credits, quotas), a bounded state machine (levels, tiers, counters with a floor/ceiling), or a read-then-write mutation reachable from more than one request path applies this procedure in addition to, not instead of, the normal persisted tests.** A simple CRUD toggle doesn't need it; an economy, inventory, ledger, or scoring feature does. This was built out fully for the Code Farm economy rebalance (see `devplacepy/services/game/CLAUDE.md`, "Every purchase/upgrade is atomic..." - the worked example, including every bug it actually caught) and is the standing procedure for anything shaped like it going forward. + +Four escalating layers, run in order, as disposable Python scripts that call the real functions directly against a temp DB (`DEVPLACE_DATABASE_URL`/`DEVPLACE_DATA_DIR` pointed at a scratch file) - not pytest files, unless the user separately asks for persisted tests too: + +1. **Property/invariant checks on every pure function, across the full input domain.** For each formula (a reward, a cost curve, a score, a discount), assert the mathematical property it must have - monotonicity, bounds, non-negativity, idempotency, additivity over a partition of its domain - by iterating the real range (hundreds to thousands of values), not a handful of hand-picked spot checks. A monotonicity bug at input 347 is invisible if only 0, 1, and 10 are tried. +2. **Stateful fuzzing.** Build N independent entities, fire a long randomized sequence of every mutating action across all of them against a live temp DB, and re-check invariants after every single action: balances never negative, counters that should only increase never decrease, levels/tiers never exceed their declared bounds. Catch the domain's expected exception type and continue - the goal is catching invariant violations and *unexpected* exceptions, not asserting every action succeeds. This proves safety (nothing bad happens) but not liveness (legitimate actions actually succeed) - a bug that wrongly blocks a valid action just raises a well-formed expected exception and sails through a pure fuzz test undetected. Layer 3 exists because of exactly this gap. +3. **Concurrency: prove races are closed, never infer it from luck.** Any read-then-write mutation reachable from more than one worker process (`uvicorn --workers N`) is a TOCTOU race until proven otherwise. Test with **real separate OS processes**, not threads in one process - `dataset` gives each thread its own pooled connection, and enough threads exhausts that pool and produces `database is locked` noise that is a test-harness artifact, not a finding about the application. Set up genuinely fresh, production-representative state before racing - **never pre-seed or zero a column that the real code path leaves unset/NULL.** This was the single most expensive mistake made building this procedure: a first race-safety pass "passed" only because its own setup script had artificially pre-zeroed columns that a real fresh row leaves as SQL `NULL` - silently hiding the exact bug the fix was supposed to prevent (`NULL = 0` evaluates to `NULL`, not true, in a SQL `WHERE` clause; any column not written in the row's original `INSERT` needs `COALESCE(column, 0)` in every later precondition and every arithmetic `SET`, not a bare comparison). Fire many concurrent attempts at the same resource, assert the exact right number succeed, and verify the final state matches the hand-computed expected total exactly (currency spent, levels advanced) - not just "the others were blocked." Fix a real race with a single atomic conditional SQL statement at the exact chokepoint (`UPDATE ... SET ... WHERE `, checked via the driver's real `rowcount` - `dataset`'s wrapped `db.query()` does not expose it, use `db.executable.execute(sqlalchemy.text(...), params).rowcount` inside `with db:`), not a client-side lock or an optimistic-locking library this codebase doesn't otherwise use. +4. **Static analysis beyond `py_compile`.** A clean `python -m py_compile` and a clean `from devplacepy.main import app` only prove syntax and module-load order - neither catches a missing import inside a function body, which is a runtime `NameError` invisible until that exact line executes. Run `pyflakes`/`ruff check` on every touched file before calling a change done; it is nearly free and catches an entire class of bug that layers 1-3 can each individually miss if they don't happen to exercise the broken line. + ## Feature workflow Every feature in DevPlace is **one data source fanning out into several consumers**. DevPlace has no isolated change - internalize this before editing. A single handler in `routers/{area}.py` is simultaneously the **four faces of one route**: @@ -300,7 +311,7 @@ A new public read almost always needs all four. The cardinal failure mode is cha 4. **View layer.** Extend `base.html`; page CSS in `{% block extra_head %}`, page JS in `{% block extra_js %}`. Reuse partials and template globals. JS is ES6 modules, one class per file, instantiated on `app`. 5. **Agent + docs layer (the most-forgotten step - do not skip).** `services/devii/actions/catalog.py` - add an `Action` tool if a user could ask Devii to do this (`requires_auth=False` for public reads). `docs_api.py` - every public/auth endpoint gets an `endpoint()` entry with params and a `sample_response`. `routers/docs/pages.py` `DOCS_PAGES` for prose pages. SEO (`seo.py`) - `base_seo_context` and any JSON-LD for a new public page; add to `routers/seo.py` sitemap if indexable. 6. **Document.** `README.md` (product-facing, routes/config/dependencies/user-visible features), the relevant nested `CLAUDE.md` (any new mechanic, helper, pitfall, or cross-layer wiring), this root `CLAUDE.md` only when a NEW architectural rule or workflow step is introduced (not per feature). -7. **Validate.** Confirm a clean import (`python -c "from devplacepy.main import app"`), check each touched language manually, grep for em-dash in touched files (neither the character nor its HTML entity - use a hyphen). **Never run the test suite unless the user explicitly asks.** Write new tests in the matching tier/path when asked, following the required patterns above. +7. **Validate.** Confirm a clean import (`python -c "from devplacepy.main import app"`), check each touched language manually, grep for em-dash in touched files (neither the character nor its HTML entity - use a hyphen), and run `pyflakes`/`ruff check` on every touched file. **If the feature touches a spendable resource, a bounded state machine, or a read-then-write mutation reachable from more than one request path, also run the four-layer procedure in "Rigorous correctness verification" above** - it is the default for that shape of feature, not an optional extra. **Never run the persisted test suite unless the user explicitly asks.** Write new tests in the matching tier/path when asked, following the required patterns above. Failures at any implementation step block the workflow - never skip a failed step. diff --git a/README.md b/README.md index 6513aff4..5d785ad2 100644 --- a/README.md +++ b/README.md @@ -129,10 +129,16 @@ The **Code Farm** (`/game`) is a cooperative idle game in the spirit of Farmvill - **Stars and Legacy (endgame).** Stars buy permanent **Legacy** upgrades that survive every refactor, unlike perks: **CI Bot** (auto-collects ready builds when you open your farm), **Tech Debt Payoff** (+coins, stacks with prestige), **Bare-Metal** (+base build speed), **Monorepo** (+starting plots after each refactor), and **Branch Protection** (longer steal grace and a smaller steal cut). This is the infinite progression for maxed farms, and CI Bot makes the game playable hands-off. - **Golden builds.** A small share of plantings come out golden (marked with a sparkle); harvesting a golden build pays several times the coins. - **Visit and water friends.** Open another member's farm at `/game/farm/{username}` and water their growing builds to speed them up - you earn coins for helping, and the owner sees the help live. This is the social loop that makes the game cooperative. -- **Steal a harvest.** A ready build on someone else's farm can be stolen once a protection window passes - the owner gets that grace period (longer if they invested in Branch Protection) to harvest it first. A successful steal pays the thief half the build's coin value (the owner loses the whole build) and earns the **Cat Burglar** badge; the victim gets the **Robbed** badge and a live notification that someone raided their farm (the thief is never named). You can raid any given neighbour only **once per hour**, so no one has to babysit their farm against constant theft. Stealing pays coins only, so the leaderboard stays earned by real farming. This is the competitive counterpart to watering. -- **Leaderboard.** Top farmers are ranked by a composite achievement score that weighs every factor the game tracks - refactor (prestige) count, XP, lifetime harvests, current coins, CI tier, plots bought, perk levels, and login streak - so total accomplishment decides position rather than just the current post-refactor cycle. The score is shown alongside your own farm next to each player's level. +- **Steal a harvest.** A ready build on someone else's farm can be stolen once a protection window passes - the owner gets that grace period (longer if they invested in Branch Protection or a Defense building) to harvest it first. A successful steal pays the thief a fraction of the build's coin value (the owner loses the whole build) and earns the **Cat Burglar** badge; the victim gets the **Robbed** badge and a live notification that someone raided their farm (the thief is never named). You can raid any given neighbour only **once per hour**. Raiding a farm with 10x your own coins grants a 24-hour **Underdog** boost (+25% coin gain) and the **David vs Goliath** badge. Stealing pays coins only, so the harvest-based leaderboards stay earned by real farming. +- **Market Saturation.** The last 48 hours of league-wide harvests of each crop are tracked; when a crop is over-farmed its payout drops in steps (down to 40%), while the four starter crops get a relief buff (up to +15%) while the market is saturated - printing one crop nonstop is throttled, diversity is rewarded. The shop shows a live "Saturated" / "Boosted" label per crop. +- **Infrastructure.** Permanent, expensive, prestige-gated buildings and coin sinks: **Private Registry** (faster Rust/Compiler/Kernel builds), **Canary Deployments** (a chance to double or only refund a harvest), and **Observability Suite** (raises the minimum you keep when raided). +- **Defense.** An upgradeable building that reduces raid losses and adds steal grace - but costs an ongoing daily coin upkeep (proportional to your coin balance, so it scales with wealth); if unpaid, the tier decays automatically. +- **Cosmetics.** Purely cosmetic titles and plot skins, bought with coins - zero gameplay effect, pure status. An equipped title shows next to your name on the leaderboard. +- **Mastery (endgame beyond prestige).** From prestige 50 onward, every 10 more prestige earns a permanent Mastery point (spendable, and the milestone itself never re-locks). Mastery upgrades open new gameplay instead of bigger numbers: **Continuous Delivery** (auto-replant after harvest), **Farm Analytics** (lifetime stats on your HUD), and **Legacy Contracts** (a weekly long-term contract slot paying Stars and a temporary coin boost). Reaching Mastery also unlocks three new high-tier crop families (Distributed System, ML Pipeline, Security Fortress - the last one immune to raids). +- **Leaderboards.** Several boards, selectable from the game page: **Overall score** (a composite weighing refactor/prestige count, XP, lifetime harvests, coins, CI tier, plots, perks, and streak), **Prestige**, **Harvests this week**, **Raid efficiency** (average coins per successful raid), **Fastest to Kernel** (time since your last refactor), **Fair play** (rewards recent activity over hoarding), and (when running) the current **Era** board. +- **Eras (admin-managed seasons).** Administrators can start a Era at `/admin/game`: every farm's *visible* Era coins/harvests counters reset to zero, but real coin balances, prestige, Stars, Legacy, and Mastery are never touched. Ending an Era ranks farms by Era score (which gives prestige only partial weight, so veterans keep an edge without it being insurmountable), awards Stars to the top 10, and permanently records the results. -The farm refreshes live over the pub/sub bus (a watered build appears on the owner's screen at once) and every plot countdown ticks client-side. Every endpoint also answers JSON, and Devii can play the game on the member's behalf via the `game_*` tools (`game_state`, `game_plant`, `game_harvest`, `game_buy_plot`, `game_upgrade_ci`, `game_water`, `game_steal`, `game_view_farm`, `game_leaderboard`, `game_upgrade_perk`, `game_upgrade_legacy`, `game_prestige`). See the API reference group **Code Farm**. +The farm refreshes live over the pub/sub bus (a watered build appears on the owner's screen at once) and every plot countdown ticks client-side. Every endpoint also answers JSON, and Devii can play the game on the member's behalf via the `game_*` tools (`game_state`, `game_plant`, `game_harvest`, `game_buy_plot`, `game_upgrade_ci`, `game_water`, `game_steal`, `game_view_farm`, `game_leaderboard`, `game_upgrade_perk`, `game_upgrade_legacy`, `game_prestige`, `game_upgrade_mastery`, `game_buy_infrastructure`, `game_upgrade_defense`, `game_buy_cosmetic`, `game_equip_cosmetic`). See the API reference group **Code Farm**. ## Engagement diff --git a/devplacepy/cli/game.py b/devplacepy/cli/game.py new file mode 100644 index 00000000..5d9dc436 --- /dev/null +++ b/devplacepy/cli/game.py @@ -0,0 +1,84 @@ +# retoor + +from devplacepy.cli._shared import _audit_cli + + +def cmd_game_market_prune(args): + from devplacepy.services.game import store + + removed = store.prune_ticks() + _audit_cli( + "cli.game.market.prune", + f"CLI pruned {removed} stale Code Farm market tick(s)", + metadata={"count": removed}, + ) + print(f"Pruned {removed} stale market tick bucket(s)") + + +def cmd_game_era_status(args): + from devplacepy.services.game import store + + era = store.active_era() + if not era: + print("No Era is currently running.") + return + print(f"Era {era['era_number']}: {era['name']}") + print(f"Started: {era['started_at']}") + print(f"Scheduled end: {era['ends_at']}") + + +def cmd_game_era_start(args): + from devplacepy.services.game import GameError, store + + try: + era = store.start_era(args.name, args.duration_days) + except GameError as exc: + print(f"Error: {exc}") + return + _audit_cli( + "cli.game.era.start", + f"CLI started Code Farm Era {era['era_number']}: {era['name']}", + metadata={"era_number": era["era_number"], "name": era["name"]}, + ) + print(f"Started Era {era['era_number']}: {era['name']}") + + +def cmd_game_era_end(args): + from devplacepy.services.game import GameError, store + + try: + result = store.end_era() + except GameError as exc: + print(f"Error: {exc}") + return + _audit_cli( + "cli.game.era.end", + f"CLI ended Code Farm Era {result['era_number']}", + metadata=result, + ) + print(f"Ended Era {result['era_number']} ({result['participants']} participant(s) ranked)") + + +def register_game(subparsers): + game = subparsers.add_parser("game", help="Code Farm management") + game_sub = game.add_subparsers(title="action", dest="action") + + market = game_sub.add_parser("market", help="Code Farm market saturation data") + market_sub = market.add_subparsers(title="market_action", dest="market_action") + market_prune = market_sub.add_parser( + "prune", help="Delete market tick buckets older than the tracking window" + ) + market_prune.set_defaults(func=cmd_game_market_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") + era_status.set_defaults(func=cmd_game_era_status) + era_start = era_sub.add_parser("start", help="Start a new Era") + era_start.add_argument("name", help="Era name") + era_start.add_argument( + "--days", dest="duration_days", type=int, default=28, help="Planned Era length in days" + ) + era_start.set_defaults(func=cmd_game_era_start) + era_end = era_sub.add_parser("end", help="End the currently running Era") + era_end.set_defaults(func=cmd_game_era_end) diff --git a/devplacepy/cli/main.py b/devplacepy/cli/main.py index 9861271c..fe7e28a3 100644 --- a/devplacepy/cli/main.py +++ b/devplacepy/cli/main.py @@ -12,6 +12,7 @@ from devplacepy.cli.jobs import register_jobs from devplacepy.cli.backups import register_backups from devplacepy.cli.containers import register_containers from devplacepy.cli.migrate import register_migrate +from devplacepy.cli.game import register_game def build_parser(): @@ -28,6 +29,7 @@ def build_parser(): register_backups(sub) register_containers(sub) register_migrate(sub) + register_game(sub) return parser diff --git a/devplacepy/database/schema.py b/devplacepy/database/schema.py index c5845de8..30fbabc0 100644 --- a/devplacepy/database/schema.py +++ b/devplacepy/database/schema.py @@ -1014,6 +1014,29 @@ def init_db(): ("legacy_speed", 0), ("legacy_plots", 0), ("legacy_defense", 0), + ("prestiged_at", ""), + ("mastery_points", 0), + ("mastery_points_earned_total", 0), + ("mastery_autoreplant", 0), + ("mastery_analytics", 0), + ("mastery_contracts", 0), + ("lifetime_coins_earned", 0), + ("lifetime_harvests", 0), + ("infra_registry", 0), + ("infra_canary", 0), + ("infra_observability", 0), + ("defense_level", 0), + ("defense_last_upkeep_at", ""), + ("active_title", ""), + ("underdog_boost_until", ""), + ("contract_boost_until", ""), + ("harvests_week", 0), + ("harvests_week_start", ""), + ("last_kernel_harvest_prestige", 0), + ("time_to_kernel_seconds", 0), + ("era_coins", 0), + ("era_harvests", 0), + ("era_joined_at", ""), ("created_at", ""), ("updated_at", ""), ): @@ -1045,6 +1068,7 @@ def init_db(): ("farm_uid", ""), ("user_uid", ""), ("day", ""), + ("scope", "daily"), ("slot_index", 0), ("kind", ""), ("label", ""), @@ -1052,13 +1076,17 @@ def init_db(): ("progress", 0), ("reward_coins", 0), ("reward_xp", 0), + ("reward_stars", 0), ("claimed", 0), ("created_at", ""), ("updated_at", ""), ): if not game_quests.has_column(column): game_quests.create_column_by_example(column, example) + with db: + db.query("UPDATE game_quests SET scope = 'daily' WHERE scope IS NULL OR scope = ''") _index(db, "game_quests", "idx_game_quests_farm_day", ["farm_uid", "day"]) + _index(db, "game_quests", "idx_game_quests_farm_day_scope", ["farm_uid", "day", "scope"]) game_plots = get_table("game_plots") for column, example in ( @@ -1077,6 +1105,72 @@ def init_db(): game_plots.create_column_by_example(column, example) _index(db, "game_plots", "idx_game_plots_farm", ["farm_uid", "slot_index"]) + game_market_ticks = get_table("game_market_ticks") + for column, example in ( + ("uid", ""), + ("crop_key", ""), + ("hour_bucket", ""), + ("harvests", 0), + ("updated_at", ""), + ): + if not game_market_ticks.has_column(column): + game_market_ticks.create_column_by_example(column, example) + _index( + db, + "game_market_ticks", + "idx_game_market_ticks_bucket", + ["crop_key", "hour_bucket"], + unique=True, + ) + + game_cosmetics = get_table("game_cosmetics") + for column, example in ( + ("uid", ""), + ("user_uid", ""), + ("cosmetic_key", ""), + ("purchased_at", ""), + ("created_at", ""), + ): + if not game_cosmetics.has_column(column): + game_cosmetics.create_column_by_example(column, example) + _index( + db, + "game_cosmetics", + "idx_game_cosmetics_owner", + ["user_uid", "cosmetic_key"], + unique=True, + ) + + game_eras = get_table("game_eras") + for column, example in ( + ("uid", ""), + ("era_number", 0), + ("name", ""), + ("started_at", ""), + ("ends_at", ""), + ("active", 0), + ("created_at", ""), + ): + if not game_eras.has_column(column): + game_eras.create_column_by_example(column, example) + _index(db, "game_eras", "idx_game_eras_active", ["active"]) + + game_era_results = get_table("game_era_results") + for column, example in ( + ("uid", ""), + ("era_number", 0), + ("user_uid", ""), + ("rank", 0), + ("era_score", 0), + ("era_coins_final", 0), + ("reward_stars", 0), + ("reward_cosmetic_key", ""), + ("created_at", ""), + ): + if not game_era_results.has_column(column): + game_era_results.create_column_by_example(column, example) + _index(db, "game_era_results", "idx_game_era_results_era", ["era_number", "rank"]) + _index(db, "posts", "idx_posts_user_created", ["user_uid", "created_at"]) _index( db, diff --git a/devplacepy/docs_api/groups/admin.py b/devplacepy/docs_api/groups/admin.py index df707d9c..dc229c07 100644 --- a/devplacepy/docs_api/groups/admin.py +++ b/devplacepy/docs_api/groups/admin.py @@ -830,5 +830,37 @@ four ways to sign requests. params=[field("uid", "path", "string", True, "", "Schedule uid.")], sample_response={"ok": True, "redirect": "/admin/backups"}, ), + endpoint( + id="admin-game", + method="GET", + path="/admin/game", + title="Code Farm Era management", + summary="View the current Code Farm Era status.", + auth="admin", + sample_response={"era_active": False, "era_name": ""}, + ), + endpoint( + id="admin-game-era-start", + method="POST", + path="/admin/game/era/start", + title="Start an Era", + summary="Start a new Code Farm Era: resets every farm's visible Era coins/harvests counters to zero. Real coins, prestige, stars, Legacy, and Mastery are never touched.", + auth="admin", + params=[ + field("name", "form", "string", True, "Genesis", "Era name."), + field("duration_days", "form", "int", False, "28", "Planned Era length in days."), + ], + sample_response={"ok": True, "redirect": "/admin/game"}, + ), + endpoint( + id="admin-game-era-end", + method="POST", + path="/admin/game/era/end", + title="End the running Era", + summary="End the current Era: ranks every participating farm by Era score, awards Stars to the top 10 (and an Era-exclusive cosmetic when available), and permanently records the results.", + auth="admin", + destructive=True, + sample_response={"ok": True, "redirect": "/admin/game"}, + ), ], } diff --git a/devplacepy/docs_api/groups/game.py b/devplacepy/docs_api/groups/game.py index 08d13f9c..9d0b10e9 100644 --- a/devplacepy/docs_api/groups/game.py +++ b/devplacepy/docs_api/groups/game.py @@ -50,9 +50,10 @@ client can refresh without a second request. method="GET", path="/game/leaderboard", title="Farm leaderboard", - summary="Top farmers ranked by level, XP, and harvests.", + summary="Top farmers on a chosen board: score (default), prestige, harvests (this week), raids (avg coins per successful raid, min 3 raids), time_to_kernel, fair_play, or era (current Era only, empty when none is running).", auth="public", - sample_response={"entries": [{"rank": 1, "username": "alice", "level": 4}]}, + params=[field("board", "query", "string", False, "score", "Leaderboard board key.")], + sample_response={"entries": [{"rank": 1, "username": "alice", "level": 4, "score": 1000}]}, ), endpoint( id="game-view-farm", @@ -166,9 +167,12 @@ client can refresh without a second request. method="POST", path="/game/quests/claim", title="Claim a quest", - summary="Claim a completed daily quest reward by its kind.", + summary="Claim a completed daily quest, or (with scope=weekly, requires the Legacy Contracts Mastery upgrade) the weekly contract, which pays Stars plus a temporary coin boost instead of coins/XP.", auth="user", - params=[field("quest", "form", "string", True, "harvest", "Quest kind.")], + params=[ + field("quest", "form", "string", True, "harvest", "Quest kind."), + field("scope", "form", "string", False, "daily", "daily (default) or weekly."), + ], sample_response={"ok": True, "farm": {"coins": 130}}, ), endpoint( @@ -176,7 +180,7 @@ client can refresh without a second request. method="POST", path="/game/prestige", title="Refactor (prestige)", - summary="Reset the farm at level 10+ for a permanent +25% coin bonus and earn Stars to spend on Legacy upgrades.", + summary="Reset the farm at level 10+ for a permanent +25% coin bonus and earn Stars to spend on Legacy upgrades. From prestige 50 onward, every 10 more prestige also earns a permanent Mastery point.", auth="user", destructive=True, sample_response={"ok": True, "farm": {"prestige": 1}}, @@ -191,5 +195,54 @@ client can refresh without a second request. params=[field("key", "form", "string", True, "multiplier", "Legacy upgrade key.")], sample_response={"ok": True, "farm": {"stars": 1}}, ), + endpoint( + id="game-mastery", + method="POST", + path="/game/mastery", + title="Buy a Mastery upgrade", + summary="Spend Mastery points (earned every 10 prestige past 50) on a permanent Mastery upgrade: autoreplant, analytics, or contracts.", + auth="user", + params=[field("key", "form", "string", True, "autoreplant", "Mastery upgrade key.")], + sample_response={"ok": True, "farm": {"mastery_points": 0}}, + ), + endpoint( + id="game-infrastructure-buy", + method="POST", + path="/game/infrastructure/buy", + title="Buy Infrastructure", + summary="Buy a permanent, expensive, prestige-gated Infrastructure building: registry (faster rare crops), canary (double/refund harvest chance), or observability (raises the minimum you keep when raided).", + auth="user", + params=[field("key", "form", "string", True, "registry", "Infrastructure key.")], + sample_response={"ok": True, "farm": {"coins": 0}}, + ), + endpoint( + id="game-defense-upgrade", + method="POST", + path="/game/defense/upgrade", + title="Upgrade Defense", + summary="Buy the next Defense tier. Reduces raid losses and adds steal grace, but adds an ongoing daily coin upkeep (proportional to your coin balance) - if unpaid, the tier decays.", + auth="user", + sample_response={"ok": True, "farm": {"defense_level": 1}}, + ), + endpoint( + id="game-cosmetics-buy", + method="POST", + path="/game/cosmetics/buy", + title="Buy a cosmetic", + summary="Buy a purely cosmetic title or plot skin with coins. No gameplay effect.", + auth="user", + params=[field("key", "form", "string", True, "title_architect", "Cosmetic key.")], + sample_response={"ok": True, "farm": {"coins": 0}}, + ), + endpoint( + id="game-cosmetics-equip", + method="POST", + path="/game/cosmetics/equip", + title="Equip a title", + summary="Equip an owned title cosmetic so it shows next to your name on the leaderboard.", + auth="user", + params=[field("key", "form", "string", True, "title_architect", "An owned title cosmetic key.")], + sample_response={"ok": True, "farm": {"active_title": "title_architect"}}, + ), ], } diff --git a/devplacepy/models.py b/devplacepy/models.py index 5f00fd92..b57555ff 100644 --- a/devplacepy/models.py +++ b/devplacepy/models.py @@ -591,7 +591,25 @@ class GamePerkForm(BaseModel): class GameQuestForm(BaseModel): quest: str = Field(min_length=1, max_length=40) + scope: str = Field(default="daily", min_length=1, max_length=10) class GameLegacyForm(BaseModel): key: str = Field(min_length=1, max_length=40) + + +class GameInfraForm(BaseModel): + key: str = Field(min_length=1, max_length=40) + + +class GameCosmeticForm(BaseModel): + key: str = Field(min_length=1, max_length=40) + + +class GameMasteryForm(BaseModel): + key: str = Field(min_length=1, max_length=40) + + +class GameEraStartForm(BaseModel): + name: str = Field(min_length=1, max_length=60) + duration_days: int = Field(default=28, ge=1, le=180) diff --git a/devplacepy/routers/CLAUDE.md b/devplacepy/routers/CLAUDE.md index c3bfbecf..23ab0743 100644 --- a/devplacepy/routers/CLAUDE.md +++ b/devplacepy/routers/CLAUDE.md @@ -25,7 +25,7 @@ Prefixes are wired in `main.py`: | `/follow` | follow.py | | (none) | relations.py - per-user block/mute relations: `POST /block/{username}`, `/block/unblock/{username}`, `/mute/{username}`, `/mute/unmute/{username}` (soft-deletable `user_relations` rows) | | `/leaderboard` | leaderboard.py - `GET /leaderboard` XP/stars leaderboard page | -| `/admin` | admin/ package - one leaf per sub-resource (`index`, `users`, `aiusage`, `aiquota`, `media`, `trash`, `settings`, `notifications`, `news`, `auditlog`, `backups`) plus the folded-in `services.py` and `containers.py` (mounted with `/services` and `/containers` sub-prefixes). `main.py` mounts the whole `/admin` tree from this one package. The `backups` leaf is the admin **Backups** dashboard (`BackupService`, kind `backup`): storage usage, backup archives, and interval/cron backup schedules (CRUD + rotation). **Archive download is restricted to the primary administrator** (the earliest-created Admin, resolved by `database.get_primary_admin_uid` / `utils.is_primary_admin`): `GET /admin/backups/{uid}/download` 403s every other admin, the `download_url` field is withheld from them at every endpoint (`can_download = is_primary_admin(admin)`, surfaced as `BackupDashboardOut.can_download_backups`), and `BackupMonitor.js` renders their Download control as a disabled button tooltipped `Not available`. See `devplacepy/services/backup/CLAUDE.md` | +| `/admin` | admin/ package - one leaf per sub-resource (`index`, `users`, `aiusage`, `aiquota`, `media`, `trash`, `settings`, `notifications`, `news`, `auditlog`, `backups`, `game`) plus the folded-in `services.py` and `containers.py` (mounted with `/services` and `/containers` sub-prefixes). `main.py` mounts the whole `/admin` tree from this one package. The `backups` leaf is the admin **Backups** dashboard (`BackupService`, kind `backup`): storage usage, backup archives, and interval/cron backup schedules (CRUD + rotation). **Archive download is restricted to the primary administrator** (the earliest-created Admin, resolved by `database.get_primary_admin_uid` / `utils.is_primary_admin`): `GET /admin/backups/{uid}/download` 403s every other admin, the `download_url` field is withheld from them at every endpoint (`can_download = is_primary_admin(admin)`, surfaced as `BackupDashboardOut.can_download_backups`), and `BackupMonitor.js` renders their Download control as a disabled button tooltipped `Not available`. See `devplacepy/services/backup/CLAUDE.md`. The `game` leaf (`/admin/game`) is the Code Farm Era admin page: `GET /admin/game` (status), `POST /admin/game/era/start` and `/era/end` - see `devplacepy/services/game/CLAUDE.md` | | `/admin/services` | admin/services.py | | `/issues` | issues/ package - issue tracker backed by Gitea (no local issue store): `index.py` (list `?state=`/`?page=`, detail `/{number}` with comments), `create.py` (async AI-enhanced filing `/create` enqueues a `issue_create` job, status at `/jobs/{uid}`), `comment.py` (synchronous, pushes to Gitea + notifies admins), `status.py` (admin open/closed), `attachments.py` (file attachments on open issues + comments, mirrored to Gitea native assets; add/list/delete with owner-or-admin + open-state guards) | | `/gists` | gists.py | @@ -43,7 +43,7 @@ Prefixes are wired in `main.py`: | `/xmlrpc` | xmlrpc.py - reverse-proxies XML-RPC calls to the forking XML-RPC bridge (`services/xmlrpc/`, supervised by `XmlrpcService` on loopback `config.XMLRPC_PORT`), which generates one XML-RPC method per documented REST endpoint from `docs_api.API_GROUPS` (`posts.create`, `feed.list`, ...). One struct of named params per call; auth via in-band `api_key`, `X-API-KEY`/`Bearer` header, or `http://user:pass@host/xmlrpc` Basic. Full introspection + `system.multicall`; REST errors become XML-RPC faults. Exempt from the rate limiter (enforced on the forwarded internal hop). See `devplacepy/services/xmlrpc/CLAUDE.md` | | `/api` | devrant/ package - devRant-compatible REST protocol (`auth.py`, `rants.py`, `comments.py`, `notifs.py`). Translates devRant requests onto DevPlace data: rants<->posts, comments/votes onto the native engagement layer, devRant integer ids onto each table's auto-increment `id`. Token auth via `devrant_tokens`. See `routers/devrant/CLAUDE.md` for the deep detail on this tree | | `/dbapi` | dbapi/ package - **primary-administrator-only, strictly READ-ONLY** generic database API over `dataset` (never inserts/updates/deletes/restores; the write surface was removed because it bypassed every per-route admin safeguard). `tables.py` (`GET /tables`, `GET /{table}/schema`), `crud.py` (read only: `GET /{table}` + `GET /{table}/{key}/{value}`, `?include_deleted`), `query.py` (`POST /query` hard SELECT-only validated read-only run; `POST /query/async` + `GET /query/{uid}` + `WS /query/{uid}/ws` via `DbApiJobService` kind `dbquery`), `nl.py` (`POST /nl` natural-language->SQL via the AI gateway, returns validated SELECT, `execute=true` runs it read-only). **Auth = the PRIMARY administrator only** (the earliest-created Admin, `utils.is_primary_admin` / `database.get_primary_admin_uid` - the same identity that gates backup downloads) by session/api_key (`services/dbapi/policy.py`); every other administrator is refused like a member, and there is **no internal-key path** - the gateway `internal_gateway_key()` is NOT accepted (no service-to-service access). SQL validated by `services/dbapi/validate.py` (sqlglot classify + suspicious-flag + read-only `EXPLAIN` dry-run). Devii tools `db_*` are read-only (list/get/query/nl; no write tools) and flagged `requires_primary_admin=True`, so they are added to the LLM tool list ONLY for the primary administrator (every other session never sees them and Devii is unaware they exist). `services/pubsub/policy.py` resolves its own admin/internal actor and does NOT reuse `dbapi.policy.caller_for`, so the primary-admin restriction does not leak onto the pub/sub bus. See `devplacepy/services/dbapi/CLAUDE.md` | -| `/game` | game/ package - the **Code Farm** idle game (`index.py` + `farm.py`): `GET /game` (page), `GET /game/state`, `GET /game/leaderboard`, `POST /game/{plant,harvest,buy-plot,upgrade,fertilize,daily,perk,prestige,legacy,quests/claim}`, plus social `GET /game/farm/{username}` and `POST /game/farm/{username}/{water,steal}` | +| `/game` | game/ package - the **Code Farm** idle game (`index.py` + `farm.py`): `GET /game` (page), `GET /game/state`, `GET /game/leaderboard?board=`, `POST /game/{plant,harvest,buy-plot,upgrade,fertilize,daily,perk,prestige,legacy,mastery,quests/claim}`, `POST /game/{defense/upgrade,infrastructure/buy,cosmetics/buy,cosmetics/equip}`, plus social `GET /game/farm/{username}` and `POST /game/farm/{username}/{water,steal}`. See `devplacepy/services/game/CLAUDE.md` | | (none) | push.py - web push + PWA: `GET`/`POST /push.json` (VAPID public key / subscription register), `GET /service-worker.js`, `GET /manifest.json` | | (none) | docs.py (docs/ package) - the documentation site (prose pages + API reference). See `routers/docs/CLAUDE.md` for the deep detail on this tree (`DOCS_PAGES`, audience tiers, prose rendering pipeline) | | `/pubsub` | pubsub.py - **database-free** publish/subscribe bus. `WS /pubsub/ws` (subscribe/unsubscribe/publish frames, `foo.*` wildcards), `POST /pubsub/publish` + `GET /pubsub/topics` (admin/internal). Lock-owner-gated WS (`4013` retry) so subscribers converge on one worker; topic authz in `services/pubsub/policy.py` (`user.{uid}.*` private, `public.*` shared, admin/internal anywhere, guests opt-in). In-process `services.pubsub.publish(topic, data)` for backends; frontend `app.pubsub`. See `devplacepy/services/pubsub/CLAUDE.md` | diff --git a/devplacepy/routers/admin/__init__.py b/devplacepy/routers/admin/__init__.py index da07ba55..59f0e1e6 100644 --- a/devplacepy/routers/admin/__init__.py +++ b/devplacepy/routers/admin/__init__.py @@ -8,6 +8,7 @@ from devplacepy.routers.admin import ( backups, bots, containers, + game, gateway_configs, issues, media, @@ -36,5 +37,6 @@ router.include_router(auditlog.router) router.include_router(backups.router) router.include_router(bots.router) router.include_router(gateway_configs.router) +router.include_router(game.router) router.include_router(services.router, prefix="/services") router.include_router(containers.router, prefix="/containers") diff --git a/devplacepy/routers/admin/game.py b/devplacepy/routers/admin/game.py new file mode 100644 index 00000000..c41b8978 --- /dev/null +++ b/devplacepy/routers/admin/game.py @@ -0,0 +1,97 @@ +# retoor + +import logging +from typing import Annotated +from fastapi import APIRouter, Form, Request +from fastapi.responses import HTMLResponse +from devplacepy.models import GameEraStartForm +from devplacepy.responses import respond, action_result, json_error, wants_json +from devplacepy.schemas import AdminGameOut +from devplacepy.seo import base_seo_context, site_url, website_schema +from devplacepy.services.audit import record as audit +from devplacepy.services.game import GameError, store +from devplacepy.utils import require_admin + +logger = logging.getLogger(__name__) +router = APIRouter() + + +def _era_context() -> dict: + era = store.active_era() + return { + "era_active": bool(era), + "era_name": era["name"] if era else "", + "era_number": int(era["era_number"]) if era else 0, + "era_started_at": era["started_at"] if era else "", + "era_ends_at": era["ends_at"] if era else "", + } + + +@router.get("/game", response_class=HTMLResponse) +async def admin_game(request: Request): + admin = require_admin(request) + base = site_url(request) + seo_ctx = base_seo_context( + request, + title="Code Farm - Admin", + description="Manage Code Farm Eras.", + robots="noindex,nofollow", + breadcrumbs=[ + {"name": "Home", "url": "/feed"}, + {"name": "Admin", "url": "/admin"}, + {"name": "Code Farm", "url": "/admin/game"}, + ], + schemas=[website_schema(base)], + ) + return respond( + request, + "admin_game.html", + { + **seo_ctx, + "request": request, + "user": admin, + "admin_section": "game", + **_era_context(), + }, + model=AdminGameOut, + ) + + +@router.post("/game/era/start") +async def admin_game_era_start(request: Request, data: Annotated[GameEraStartForm, Form()]): + admin = require_admin(request) + try: + era = store.start_era(data.name, data.duration_days) + except GameError as exc: + logger.warning(f"Admin {admin['username']} failed to start Era: {exc}") + if wants_json(request): + return json_error(400, str(exc)) + return action_result(request, "/admin/game") + audit.record( + request, + "admin.game.era_start", + user=admin, + metadata={"era_number": era["era_number"], "name": era["name"]}, + summary=f"admin {admin['username']} started Era {era['name']}", + ) + return action_result(request, "/admin/game") + + +@router.post("/game/era/end") +async def admin_game_era_end(request: Request): + admin = require_admin(request) + try: + result = store.end_era() + except GameError as exc: + logger.warning(f"Admin {admin['username']} failed to end Era: {exc}") + if wants_json(request): + return json_error(400, str(exc)) + return action_result(request, "/admin/game") + audit.record( + request, + "admin.game.era_end", + user=admin, + metadata=result, + summary=f"admin {admin['username']} ended Era {result['era_number']}", + ) + return action_result(request, "/admin/game") diff --git a/devplacepy/routers/game/farm.py b/devplacepy/routers/game/farm.py index d217b006..32553b41 100644 --- a/devplacepy/routers/game/farm.py +++ b/devplacepy/routers/game/farm.py @@ -88,6 +88,8 @@ async def steal_farm( return RedirectResponse(url=f"/game/farm/{username}", status_code=302) track_action(viewer["uid"], "harvest_stolen") track_action(owner["uid"], "got_stolen_from") + if result.get("underdog_triggered"): + track_action(viewer["uid"], "underdog_raid") create_notification( owner["uid"], "harvest_stolen", diff --git a/devplacepy/routers/game/index.py b/devplacepy/routers/game/index.py index 56ecda4a..17dfe6bb 100644 --- a/devplacepy/routers/game/index.py +++ b/devplacepy/routers/game/index.py @@ -6,7 +6,10 @@ from fastapi import APIRouter, Form, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from devplacepy.models import ( + GameCosmeticForm, + GameInfraForm, GameLegacyForm, + GameMasteryForm, GamePerkForm, GamePlantForm, GameQuestForm, @@ -49,9 +52,9 @@ async def game_state(request: Request): @router.get("/leaderboard") -async def game_leaderboard(request: Request): +async def game_leaderboard(request: Request, board: str = "score"): get_current_user(request) - entries = store.leaderboard(25) + entries = store.leaderboard_for(board, 25) return JSONResponse( GameLeaderboardOut(entries=entries).model_dump(mode="json") ) @@ -149,5 +152,55 @@ async def game_claim_quest(request: Request, data: Annotated[GameQuestForm, Form award_rewards(user["uid"], result.get("reward_xp", 0)) return await _respond_action( - request, user, lambda: store.claim_quest(user, data.quest), reward + request, user, lambda: store.claim_quest(user, data.quest, data.scope), reward + ) + + +@router.post("/defense/upgrade") +async def game_upgrade_defense(request: Request): + user = require_user(request) + + def reward(result): + track_action(user["uid"], "defense_upgraded") + + return await _respond_action(request, user, lambda: store.upgrade_defense(user), reward) + + +@router.post("/infrastructure/buy") +async def game_buy_infrastructure(request: Request, data: Annotated[GameInfraForm, Form()]): + user = require_user(request) + + def reward(result): + track_action(user["uid"], "infra_bought") + + return await _respond_action( + request, user, lambda: store.buy_infrastructure(user, data.key), reward + ) + + +@router.post("/mastery") +async def game_upgrade_mastery(request: Request, data: Annotated[GameMasteryForm, Form()]): + user = require_user(request) + return await _respond_action( + request, user, lambda: store.upgrade_mastery(user, data.key) + ) + + +@router.post("/cosmetics/buy") +async def game_buy_cosmetic(request: Request, data: Annotated[GameCosmeticForm, Form()]): + user = require_user(request) + + def reward(result): + track_action(user["uid"], "cosmetic_bought") + + return await _respond_action( + request, user, lambda: store.buy_cosmetic(user, data.key), reward + ) + + +@router.post("/cosmetics/equip") +async def game_equip_cosmetic(request: Request, data: Annotated[GameCosmeticForm, Form()]): + user = require_user(request) + return await _respond_action( + request, user, lambda: store.equip_title(user, data.key) ) diff --git a/devplacepy/schemas/__init__.py b/devplacepy/schemas/__init__.py index f710f887..2551e8ce 100644 --- a/devplacepy/schemas/__init__.py +++ b/devplacepy/schemas/__init__.py @@ -99,6 +99,7 @@ from devplacepy.schemas.backups import ( BackupStoragePathOut, ) from devplacepy.schemas.admin import ( + AdminGameOut, AdminMediaItemOut, AdminMediaOut, AdminNewsItemOut, diff --git a/devplacepy/schemas/admin.py b/devplacepy/schemas/admin.py index 1d6ba9f8..600e2cca 100644 --- a/devplacepy/schemas/admin.py +++ b/devplacepy/schemas/admin.py @@ -72,3 +72,12 @@ class AdminTrashOut(_Out): tables: list[dict] = [] pagination: Optional[Any] = None admin_section: Optional[str] = None + + +class AdminGameOut(_Out): + era_active: bool = False + era_name: str = "" + era_number: int = 0 + era_started_at: str = "" + era_ends_at: str = "" + admin_section: Optional[str] = None diff --git a/devplacepy/schemas/game.py b/devplacepy/schemas/game.py index 07cbead2..8698e96c 100644 --- a/devplacepy/schemas/game.py +++ b/devplacepy/schemas/game.py @@ -15,6 +15,7 @@ class GameCropOut(_Out): min_level: int = 1 grow_seconds: int = 0 locked: bool = False + market_state: str = "normal" class GamePlotOut(_Out): @@ -62,6 +63,38 @@ class GameLegacyOut(_Out): effect: str = "" +class GameMasteryOut(_Out): + key: str = "" + name: str = "" + icon: str = "" + description: str = "" + level: int = 0 + max_level: int = 0 + cost: int = 0 + maxed: bool = False + effect: str = "" + + +class GameInfrastructureOut(_Out): + key: str = "" + name: str = "" + icon: str = "" + description: str = "" + cost: int = 0 + min_prestige: int = 0 + owned: bool = False + + +class GameCosmeticOut(_Out): + key: str = "" + name: str = "" + icon: str = "" + description: str = "" + cost_coins: int = 0 + kind: str = "" + owned: bool = False + + class GameQuestOut(_Out): kind: str = "" label: str = "" @@ -71,6 +104,8 @@ class GameQuestOut(_Out): reward_xp: int = 0 claimed: bool = False can_claim: bool = False + scope: str = "daily" + reward_stars: int = 0 class GameFarmOut(_Out): @@ -107,6 +142,25 @@ class GameFarmOut(_Out): stars: int = 0 legacy: list[GameLegacyOut] = [] steal_cooldown_seconds: int = 0 + mastery_points: int = 0 + mastery_points_earned_total: int = 0 + mastery: list[GameMasteryOut] = [] + infrastructure: list[GameInfrastructureOut] = [] + defense_level: int = 0 + defense_tier_name: str = "" + defense_upkeep_daily: int = 0 + defense_next_cost: int = 0 + cosmetics: list[GameCosmeticOut] = [] + active_title: str = "" + underdog_boost_seconds_remaining: int = 0 + mastery_analytics_unlocked: bool = False + lifetime_coins_earned: int = 0 + lifetime_harvests: int = 0 + harvests_week: int = 0 + era_active: bool = False + era_name: str = "" + era_coins: int = 0 + era_harvests: int = 0 class GameStateOut(_Out): @@ -130,6 +184,9 @@ class GameLeaderboardEntryOut(_Out): total_harvests: int = 0 prestige: int = 0 score: int = 0 + raid_avg: float = 0.0 + time_to_kernel_seconds: int = 0 + title: str = "" class GameLeaderboardOut(_Out): diff --git a/devplacepy/services/devii/actions/catalog/game.py b/devplacepy/services/devii/actions/catalog/game.py index 542ab785..479f054b 100644 --- a/devplacepy/services/devii/actions/catalog/game.py +++ b/devplacepy/services/devii/actions/catalog/game.py @@ -3,7 +3,7 @@ from __future__ import annotations from ..spec import Action, Param -from ._shared import body, path +from ._shared import body, path, query GAME_ACTIONS: tuple[Action, ...] = ( @@ -24,10 +24,18 @@ GAME_ACTIONS: tuple[Action, ...] = ( name="game_leaderboard", method="GET", path="/game/leaderboard", - summary="List the top Code Farm players", + summary="List the top Code Farm players on a given board", + description=( + "Boards: score (default, overall composite score), prestige, harvests " + "(this week), raids (avg coins per successful raid, min 3 raids), " + "time_to_kernel (fastest since last refactor), fair_play (rewards recent " + "activity over hoarding), era (current Era-only leaderboard, empty when no " + "Era is running)." + ), handler="http", requires_auth=False, read_only=True, + params=(query("board", "Leaderboard board key, defaults to score."),), ), Action( name="game_view_farm", @@ -136,11 +144,16 @@ GAME_ACTIONS: tuple[Action, ...] = ( name="game_claim_quest", method="POST", path="/game/quests/claim", - summary="Claim a completed daily Code Farm quest reward", + summary="Claim a completed daily or weekly Code Farm quest/contract reward", handler="http", requires_auth=True, params=( body("quest", "Quest kind: plant, harvest, water, or earn.", required=True), + body( + "scope", + "daily (default) or weekly (requires the Legacy Contracts Mastery upgrade).", + required=False, + ), ), ), Action( @@ -166,4 +179,52 @@ GAME_ACTIONS: tuple[Action, ...] = ( ), ), ), + Action( + name="game_upgrade_mastery", + method="POST", + path="/game/mastery", + summary="Spend Mastery points on a permanent Mastery upgrade (unlocked at prestige 50+): autoreplant, analytics, or contracts", + handler="http", + requires_auth=True, + params=( + body("key", "Mastery key: autoreplant, analytics, or contracts.", required=True), + ), + ), + Action( + name="game_buy_infrastructure", + method="POST", + path="/game/infrastructure/buy", + summary="Buy a permanent Infrastructure building (registry, canary, observability); expensive, prestige-gated, big coin sinks", + handler="http", + requires_auth=True, + params=( + body("key", "Infrastructure key: registry, canary, or observability.", required=True), + ), + ), + Action( + name="game_upgrade_defense", + method="POST", + path="/game/defense/upgrade", + summary="Upgrade your farm's Defense tier: reduces raid losses and adds steal grace, but costs an ongoing daily coin upkeep proportional to your wealth", + handler="http", + requires_auth=True, + ), + Action( + name="game_buy_cosmetic", + method="POST", + path="/game/cosmetics/buy", + summary="Buy a purely cosmetic title or plot skin with coins (no gameplay effect)", + handler="http", + requires_auth=True, + params=(body("key", "Cosmetic key from the farm's cosmetics list.", required=True),), + ), + Action( + name="game_equip_cosmetic", + method="POST", + path="/game/cosmetics/equip", + summary="Equip an owned cosmetic title so it shows on the leaderboard", + handler="http", + requires_auth=True, + params=(body("key", "An owned title cosmetic key.", required=True),), + ), ) diff --git a/devplacepy/services/game/CLAUDE.md b/devplacepy/services/game/CLAUDE.md index 79bdadb0..ab0e42cf 100644 --- a/devplacepy/services/game/CLAUDE.md +++ b/devplacepy/services/game/CLAUDE.md @@ -55,3 +55,53 @@ The infinite progression for maxed farms. Six new default-0 `game_farms` columns **Auto-harvest** is lazy and tick-free: `store._auto_harvest(farm, owner_uid, now)` runs at the top of `serialize_farm` ONLY when the viewer is the owner AND `legacy_autoharvest > 0`; it **clears each ready plot first then credits** the pre-clear crop's coins/xp/`total_harvests` in one `_update_farm`, advances quests, and re-reads the farm - clear-then-credit is idempotent (a second read sees empty plots), and it never fires on a visitor's `GET /game/farm/{username}` view or the leaderboard, honouring the no-background-service invariant (both `GET /game` and `GET /game/state` go through `state_payload` with viewer=owner, so both auto-collect). **Golden builds** are deterministic and storage-free: `economy.is_golden(plot_uid, planted_at)` (sha256, ~`GOLDEN_CHANCE`) marks a planting golden for its life; `harvest`/`_auto_harvest` multiply **coins only** by `GOLDEN_MULTIPLIER` (XP unscaled to keep level pacing), and `serialize_plot.is_golden` surfaces a sparkle badge (`.game-plot-golden`). New schema fields (`GameLegacyOut`, `GameFarmOut.stars`/`legacy`, `GamePlotOut.is_golden`) all default safe; no DB migration. + +## The economy rebalance (Market Saturation, Infrastructure, Defense, Cosmetics, Mastery, secondary leaderboards, Eras, Underdog, weekly Contracts - all backwards compatible) + +A second large layer added on top of everything above, aimed at flattening runaway whale inequality (unbounded multiplicative prestige against finite content) and giving the game long-term excitement beyond "hit the Kernel + high-prestige wall." Every mechanic below defaults to a no-op for a farm that has never touched it - new `game_farms` columns are `0`/`""`, new tables start empty, and the Era system in particular is fully dormant (byte-identical to pre-rebalance behavior) until an admin starts the first Era. + +**One shared choke point underlies all the new counters.** `store/common.py::credit_farm(farm, *, coins=0, xp=0, harvests=0, era_active=False, extra=None)` is the single place that updates `coins`/`xp`/`level`/`total_harvests` **and** the new shadow counters (`lifetime_coins_earned`, `lifetime_harvests`, and - only when `era_active` - `era_coins`/`era_harvests`) together, so no future coin-crediting feature has to remember to touch five counters at five call sites. `harvest`, `_auto_harvest`, `steal` (thief side), `claim_daily`, and `claim_quest` all route through it instead of their old inline `_update_farm` math. `credit_farm` floors both `coins` and `lifetime_coins_earned` at zero (`max(0, ...)`) as a defense-in-depth guard - no current caller passes a negative `coins` delta, but this is the shared choke every future coin-crediting feature is meant to route through, so the floor costs nothing and forecloses a whole class of future bug. + +### Every purchase/upgrade is atomic against concurrent requests (`store/common.py::conditional_update_farm`) + +`uvicorn --workers N` means two nearly-simultaneous requests for the same user (a double-click, or two requests hashed to different workers) can run truly in parallel across separate processes - a plain "read the farm, check a precondition in Python, then write" is a classic TOCTOU race under that model. Every mutation that spends a resource against a precondition - `buy_plot`, `upgrade_ci` (`store/farm.py`), `upgrade_perk`, `upgrade_legacy`, `prestige` (`store/actions.py`), `buy_infrastructure`, `upgrade_defense`, `charge_upkeep` (lazy, runs on every `serialize_farm`), `buy_cosmetic`, and `upgrade_mastery` - goes through `conditional_update_farm(farm_uid, set_clause, where_clause, params)`: one raw SQL `UPDATE game_farms SET ... WHERE uid = :farm_uid AND ()`, returning the affected row count via `db.executable.execute(...).rowcount` (not `dataset`'s wrapped `db.query()`, which does not expose `rowcount`). The precondition and the write happen as a single atomic statement, so two concurrent attempts against the same stale precondition can never both succeed - the loser's `rowcount` is `0`, and the function re-reads the farm to produce the correct, specific `GameError` (already owned / maxed out / insufficient funds / state changed - refresh and retry). + +**Load-bearing gotcha: every precondition column must be `COALESCE`d.** None of `prestige`, `perk_*`, `legacy_*`, `stars`, `mastery_*`, `infra_*`, or `defense_level` are set in `ensure_farm`'s insert - a real, never-yet-touched farm has them as SQL `NULL`, not `0`, and `NULL = 0` evaluates to `NULL` (false) in a `WHERE` clause. A first version of this fix compared bare `column = :current_level` and was verified "safe" only because the verification script had incorrectly pre-seeded those columns to `0` - against a genuinely fresh farm it silently rejected every legitimate first purchase. Every precondition and every arithmetic `SET` on a column that is not set at farm creation must read `COALESCE(column, 0)`; `coins`, `plot_count`, and `ci_tier` are the only farm columns set in `ensure_farm` and are the only ones safe to compare bare. + +`buy_plot` additionally reserves the plot slot atomically (`WHERE plot_count = :current_count AND coins >= :cost`) **before** inserting the `game_plots` row - `idx_game_plots_farm (farm_uid, slot_index)` is not a unique index, so without the reservation two concurrent buys could insert two rows at the same `slot_index` (silent data corruption, not just a rejected purchase). `prestige` reserves the whole refactor transition atomically first (`WHERE COALESCE(prestige, 0) = :old_prestige`, in the same statement as every other reset field) and only mutates `game_plots` after that reservation wins, so a losing concurrent `prestige` call touches no plot data at all. Verified by forcing real concurrent OS processes (not threads - `dataset` gives each thread its own pooled connection, and enough of them exhausts the pool and produces `database is locked` noise that has nothing to do with the game logic) against genuinely fresh, un-seeded farm state; every purchase's final coins/stars/level exactly matches hand-computed expected totals under contention, never a partial or double charge. + +### 1. Market Saturation (`economy.py` + `store/market.py`) + +A new, genuinely new table `game_market_ticks` (`crop_key`, `hour_bucket` = UTC `"YYYY-MM-DDTHH"`, `harvests` count, unique on `(crop_key, hour_bucket)`) tracks the last `MARKET_WINDOW_HOURS` (48h) of production **per crop, globally** - recorded only for owner harvests/auto-harvests (`store/actions.py` calls `market.record_harvest_tick` after a successful clear), deliberately **not** for steals (a raid moves already-produced value, it does not print new supply, so counting it would double-count). `market.recent_harvests(crop_key, window_hours)` sums the trailing buckets via a raw `SELECT SUM` (cached 30s in a plain `TTLCache`, cosmetic/economic staleness only, no `cache_state` wiring needed). `economy.market_saturation_factor(recent_harvests)` steps the payout down through `MARKET_SATURATION_TIERS` (100% -> 40% past 600 recent harvests); `economy.market_buff_factor(crop_key, saturation_factor)` gives the four starter crops (`MARKET_BUFFED_CROPS`) up to `MARKET_BUFF_CAP` (+15%) relief while the market is saturated. `market.market_factor_for(crop_key)` composes both into one multiplier, threaded as the new `market_factor` parameter on `economy.effective_reward_coins`/`steal_reward_coins`/`crop_payload` (default `1.0`, so any caller that omits it is unaffected). `GameCropOut.market_state` (`"normal"`/`"saturated"`/`"boosted"`) surfaces the live signal in the shop list before planting - both the server-rendered `_game_grid.html` plant `