Files
devplacepy/devplacepy/services/game/CLAUDE.md
T
retoor 7f17d69f5c Update Code Farm documentation
Document the current Code Farm mechanics across the docs: raids and
steal windows, prestige and refactor, defense upkeep and downgrade,
infrastructure and cosmetics, the community treasury and weekly grant,
market saturation, mastery, and the Era boards - in README.md, the root
and routers CLAUDE.md, and the game service CLAUDE.md.
2026-07-26 16:46:41 +02:00

49 KiB

Code Farm game (devplacepy/services/game/, devplacepy/routers/game/)

This file documents the Code Farm idle game. Claude Code auto-loads it when a file under devplacepy/services/game/ is read or edited.

Overview

game/ package (routers, mounted at /game) is 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,grant,perk,prestige,legacy,mastery,quests/claim}, POST /game/{defense/upgrade,defense/downgrade,infrastructure/buy,cosmetics/buy,cosmetics/equip}, plus social GET /game/farm/{username} and POST /game/farm/{username}/{water,steal}. Customer-facing documentation is the docs_api.py Code Farm group (docs_api/groups/game.py, enum options on every key param) and the prose guide templates/docs/code-farm.html (/docs/code-farm.html - the complete rules, every exact formula and catalog table, the full farm/plot field reference, the JSON error shape, and a stdlib automation client); keep BOTH in lockstep with economy.py whenever a constant, formula, catalog entry, field, or endpoint changes.

A cooperative-and-competitive idle game (Farmville-style) mounted at /game, member-only to play, public to view another farm. Cooperative loop = watering a neighbour's growing build; competitive loop = stealing a neighbour's ready build.

Data layer

Data layer is pure + timestamp-driven (no background tick). services/game/economy.py holds every constant and formula as frozen dataclasses/functions: the CROPS tuple (key/name/icon/cost/grow_seconds/reward_coins/reward_xp/min_level, plus the trailing defaulted min_mastery/steal_immune/era_key), CI_TIERS (speed multiplier + upgrade cost), level thresholds (xp_threshold/level_for_xp/level_progress), plot_cost (doubles per extra plot), and watering bonus. The services/game/store/ package is the only DB access (common.py shared helpers/credit_farm/conditional_update_farm, farm.py farm+plots+leaderboards, actions.py plant/harvest/water/steal/quests/daily/perks/legacy/prestige/fertilize, serialize.py, market.py, quests.py, treasury.py, era.py, infrastructure.py, defense.py, cosmetics.py, mastery.py; store/__init__.py re-exports the public surface). A plot's state is derived from ready_at vs now, never stored - growing crops finish purely by the clock, so there is no reconciler/service. serialize_farm(farm, viewer=, owner=) computes plot states, remaining seconds, can_water (viewer is not owner, build growing, viewer not already in the per-cycle watered_by JSON list, under MAX_WATERS_PER_PLOT), can_steal/steal_coins (viewer is not owner, build ready, and now >= ready_at + STEAL_GRACE_SECONDS so the owner gets a protection window), level progress, and the plantable crop list. serialize_plot takes the owner farm's yield_level/prestige (threaded from serialize_farm) only to compute the steal payout. Mutations (plant/harvest/buy_plot/upgrade_ci/water/steal) raise GameError on any invalid op (insufficient coins, locked crop, wrong state, still-protected harvest); the routers translate that to a 400 JSON error or a redirect.

Tables

game_farms (one per user, coins/xp/level/ci_tier/plot_count/total_harvests, plus prestige/streak/last_daily_at/last_grant_week, the four perk_* columns, the endgame stars + six legacy_* columns including legacy_carryover, the three mastery_* upgrade columns + mastery_points/mastery_points_earned_total, the three infra_* booleans, defense_level/defense_last_upkeep_at, active_title, the boost stamps underdog_boost_until/contract_boost_until, the lifetime/weekly counters lifetime_coins_earned/lifetime_harvests/harvests_week/harvests_week_start, the kernel-race columns prestiged_at/last_kernel_harvest_prestige/time_to_kernel_seconds, and the Era shadow counters era_coins/era_harvests/era_joined_at), game_plots (farm_uid/slot_index/crop_key/planted_at/ready_at/watered_by), game_quests (per-day daily quests + per-ISO-week contracts, scope/kind/goal/progress/reward_*/claimed), game_steals (thief_uid/owner_uid/slot_index/crop_key/coins/stolen_at, the per-pair steal-cooldown ledger), game_treasury (a single row uid="treasury-main" holding balance/collected_total/granted_total, the refactor-fee redistribution ledger), game_market_ticks (per-crop hourly harvest counts, unique on (crop_key, hour_bucket)), game_cosmetics (owned cosmetics per user), game_eras (at most one active=1 row), and game_era_results (the permanent per-user Era history) are not soft-deletable - they are mutable game state consumed by state transitions, not user content. Columns + indexes are ensured in database.init_db (unique idx_game_farms_user, idx_game_farms_rank, idx_game_plots_farm, idx_game_steals_pair on (thief_uid, owner_uid, stolen_at)); the _uid_index loop adds the uid index. ensure_farm(user_uid) lazily creates a farm + starting plots on first access (idempotent), so there is no signup hook.

Routes (routers/game/)

index.py is the base router - GET /game (own farm page), GET /game/state (own farm JSON), GET /game/leaderboard, and every own-farm action POST (plant/harvest/buy-plot/upgrade/fertilize/daily/grant/perk/prestige/legacy/mastery/quests/claim/defense/upgrade/infrastructure/buy/cosmetics/buy/cosmetics/equip). farm.py adds GET /game/farm/{username} (view), POST /game/farm/{username}/water, and POST /game/farm/{username}/steal. Every action returns the full updated farm as JSON (so the client refreshes in one round trip) or redirects for no-JS, via the shared _respond_action choke (the farm.py water/steal handlers inline the same shape). Harvest awards site XP (award_rewards) and the harvest/water achievements (track_action). All action handlers are require_user; reads of other farms/the leaderboard are public.

Leaderboard ranking is a composite economy.farm_score(farm) (one integer per row, computed in memory over the already-loaded _farms().find() set, so it stays fast): it sums weighted contributions from every tracked factor - xp, prestige * SCORE_PRESTIGE (5000, dominant since a refactor is a full completed cycle), total_harvests * SCORE_HARVEST, coins // SCORE_COIN_DIVISOR, (ci_tier-1) * SCORE_CI, (plot_count-STARTING_PLOTS) * SCORE_PLOT, the summed perk levels * SCORE_PERK, and min(streak, SCORE_STREAK_CAP) * SCORE_STREAK - so a player who refactored (which resets xp/level/coins/ci/plots/perks) is no longer buried below a never-refactored higher-level player. The weights are module-level constants in economy.py for tuning; the leaderboard entry carries score and prestige (surfaced on GameLeaderboardEntryOut) and GameFarm.js renders the score next to Lv X.

Live + frontend

After any mutation the handler awaits _shared.notify_farm(username) which publishes a nudge to the public.game.farm.{username} pub/sub topic; subscribed clients re-fetch their viewer-specific state (keeps can_water correct without broadcasting per-viewer payloads). static/js/GameFarm.js (app.gameFarm, auto-detects [data-game-root]) renders the grid/HUD/leaderboard, ticks plot countdowns client-side every second, delegates the data-game-action forms through Http.send, subscribes to the farm topic, and keeps a 20s Poller fallback. The server renders a full no-JS fallback grid (templates/_game_grid.html, shared by game.html and game_farm.html with progressive-enhancement POST forms).

Fan-out

Devii plays via the game_* http actions in actions/catalog.py (state/leaderboard/view public, the rest requires_auth); the API reference has a Code Farm group in docs_api.py; pages are noindex,follow (interactive, user-specific) so they are intentionally not in the sitemap; badges live in utils.BADGE_CATALOG under the Code Farm group with harvest/water/harvest_stolen/got_stolen_from ACHIEVEMENTS (the steal pair awards Cat Burglar to the thief and Robbed to the victim, both threshold 1).

Stealing (competitive loop, backwards compatible)

store.steal(thief, owner, slot) mirrors water: it requires the owner plot to be ready AND past the protection window economy.effective_steal_grace(owner_defense_level) (base STEAL_GRACE_SECONDS 60s, +30s per owner Branch Protection level) measured from ready_at, AND that the thief is off cooldown for this victim (steal_cooldown_remaining(thief, owner, now) == 0, i.e. no row in game_steals for the pair within STEAL_COOLDOWN_SECONDS = 3600 - you can raid a given neighbour only once per hour); it takes a share of the build rather than destroying it: conditional_update_row adds the share to the plot's raided_fraction (new game_plots REAL column, default 0, COALESCEd everywhere) and the crop stays in place, so the owner still harvests 1 - raided_fraction of its value (_harvest_crop takes a raided_fraction argument). A build is raidable until the shares reach 1.0, after which steal_reason is "stripped". The thief's payout is realizable_harvest_coins(...) * share, clamped to the un-raided remainder so repeated raids can never mint coins (a property sweep proved the un-clamped max(1, round(...)) overshot by 1 coin on a fully stripped build). Raids are additionally capped at STEAL_MAX_PER_VICTIM_PER_DAY (3) per victim per day via raids_against_today over the new idx_game_steals_owner_time index, so an offline player cannot be stripped by an unlimited queue of raiders. Payout is coins only - no XP, no total_harvests, so the leaderboard stays earned by real farming - then inserts a game_steals row stamping the cooldown. The route POST /game/farm/{username}/steal (reusing GameSlotForm) then track_actions both sides, fires create_notification(owner, "harvest_stolen", "Someone raided your Code Farm...", thief_uid, "/game") (the message never names the thief; related_uid is internal), and await notify_farms both the owner and the thief usernames so both farms refresh live. The new harvest_stolen notification type rides the existing in-app relay (live toast, no new wiring). No DB migration: grace + payout are computed from existing ready_at/perk/legacy columns, the game_steals table is created by the ensure-block (absent rows -> cooldown 0 -> first steal always allowed), and the new GamePlotOut.can_steal/steal_coins/steal_cooldown_seconds/steal_reason + GameFarmOut.steal_cooldown_seconds + GameFarmViewOut.stole_coins schema fields default safe. The per-pair cooldown is computed once per farm in serialize_farm (when the viewer is not the owner) and threaded into every serialize_plot as steal_locked_until, so can_steal is false and steal_reason is "cooldown"/"protected" accordingly. Display must equal enforcement: serialize_farm also threads the owner's Defense building into every serialize_plot (steal_grace_bonus = the tier's grace_bonus, steal_floor = the tier's steal_fraction_floor raised to OBSERVABILITY_STEAL_FLOOR when the owner owns Observability), so the serialized can_steal/steal_coins match exactly what store/actions.py::steal() will enforce and pay - a client acting on can_steal=true can never get a "still protected" 400, and steal_coins is the exact payout. Never let serialize_plot and steal() compute grace or payout from different inputs. Frontend: the ready/not-owner branch of _game_grid.html and GameFarm.js._plotHtml render a btn-danger Steal button (data-game-action="steal", data-confirm gated like prestige) carrying steal_coins, or a disabled "Raid again in " label when on cooldown; on success GameFarm.js._submit toasts the payout from data.stole_coins.

Extended mechanics (all backwards compatible)

Five further systems layer onto the base loop, every one defaulting gracefully for pre-existing game_farms/game_plots rows: new game_farms columns (prestige, streak, last_daily_at, perk_yield/perk_growth/perk_discount/perk_xp) are added in the init_db ensure-block, and store._lvl(farm, key) reads every one as int(farm.get(key) or 0) so a legacy NULL row behaves as level 0 / no streak / no perks.

  1. Daily bonus (POST /game/daily, store.claim_daily): once per UTC day, consecutive days grow streak (reward economy.daily_reward, capped at DAILY_STREAK_CAP).

  2. Daily quests (game_quests table, POST /game/quests/claim): economy.daily_quests(user_uid, day) deterministically (sha256 of user:day) picks 3 of {plant, harvest, water, earn} with goals/rewards; store.ensure_quests lazily materializes the day's rows, store.advance_quests(user_uid, kind, amount) is called inside plant/harvest/water (wrapped in try/except so a quest write never breaks the action), claim_quest pays out when progress >= goal.

  3. Perks (POST /game/perk, store.upgrade_perk): four permanent upgrades (economy.PERKS) with escalating perk_cost; applied in the economy formulas - effective_plant_cost (discount), grow_seconds_for(crop, ci_tier, growth_level) via farm_speed (growth), effective_reward_coins (yield + prestige), effective_reward_xp (xp).

  4. Fertilizer (POST /game/fertilize, store.fertilize): cut a growing plot's ready_at by FERTILIZE_FRACTION for economy.fertilize_click_cost(realizable_coins, reduce_seconds, full_grow_seconds) = ceil(realizable_coins * reduce/full_grow * FERTILIZE_TAX) (TAX 1.05).

    economy.realizable_harvest_coins(...) is the single source of truth for what a specific build pays, and BOTH the payout and the fertilize price must come from it. It composes, in the exact order _harvest_crop pays out: effective_reward_coins (yield perk, prestige, legacy multiplier, market factor, underdog) then GOLDEN_MULTIPLIER, then WEEKLY_CONTRACT_BOOST_MULTIPLIER, then the Canary upside 1 + CANARY_DOUBLE_CHANCE. Pricing against the base reward instead was a live money pump: golden builds (visible to the owner while growing via serialize_plot.is_golden) paid 5x while costing 1.05x, and even blind fertilizing turned profitable from ~prestige 5 on E[golden] = 1.2 > 1.05. Verified by a property sweep over every crop x prestige 0-200 x perk/legacy/market/boost combination: full-skip cost >= payout everywhere, and >= expected payout for the random Canary roll. If you add any new harvest multiplier, it MUST be added to realizable_harvest_coins - adding it to _harvest_crop alone reopens the pump.

  5. Prestige/Refactor (POST /game/prestige, store.prestige): at PRESTIGE_MIN_LEVEL resets coins/xp/level/ci/perks, sets plot_count = economy.prestige_base_plots(legacy_plots_level) (keeps/recreates plot rows up to that base, deletes the rest), increments prestige for a permanent prestige_multiplier (+25% coins each), and awards economy.stars_for_refactor(level, prestige) Stars (the legacy_*/stars columns are omitted from the reset dict so they survive every refactor, the established pattern). Refactoring costs a dynamic coin fee - see "Refactor fee, carry-over, treasury, and community grant" below.

serialize_farm exposes all of this (perks, quests, streak, daily_available/daily_reward, prestige*, stars, legacy, steal_cooldown_seconds) only to the owner; the existing crop_payload/serialize_plot now carry perk- and legacy-adjusted costs/rewards and per-plot value-based fertilize_cost. Frontend hosts ([data-shop-host]/[data-perk-host]/[data-legacy-host]/[data-daily-host]/[data-quest-host]) are server-rendered from partials (_game_shop.html/_game_perks.html/_game_legacy.html/_game_daily.html/_game_quests.html) and fully re-rendered by GameFarm.js builders; the generic data-game-action form delegation handles the new actions, with data-confirm gating the prestige reset.

Endgame: Stars, Legacy upgrades, auto-harvest, golden builds (all backwards compatible)

The infinite progression for maxed farms. Six new default-0 game_farms columns (stars, legacy_autoharvest, legacy_multiplier, legacy_speed, legacy_plots, legacy_defense), all read via _lvl.

Stars are a meta-currency earned only on Refactor (stars_for_refactor), spent via POST /game/legacy (store.upgrade_legacy, GameLegacyForm, Devii game_upgrade_legacy) on economy.LEGACY_UPGRADES (escalating legacy_cost in Stars) that survive prestige unlike perks: autoharvest (CI Bot), multiplier (+10% coins/lvl via legacy_multiplier, folded into effective_reward_coins), speed (+5% base build speed/lvl via farm_speed/grow_seconds_for/water_bonus_seconds), plots (+1 base plot after refactor via prestige_base_plots), defense (steal grace/fraction via effective_steal_grace/effective_steal_fraction).

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 (<precondition>), 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 COALESCEd. 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.

The core loop is atomic too, not just purchases. The original atomicity pass covered only spend-precondition mutations; the read-then-write core actions were still lost-update/double-credit races under --workers N (a 4-way concurrent steal of one plot really did pay 4x and insert 4 cooldown rows before this was closed - caught by the layer-3 race verification, real OS processes). The closed set, all via the store/common.py primitives (conditional_update_row - the generic form of conditional_update_farm - plus clear_plot and refund_farm):

  • credit_farm is a single atomic increment UPDATE (SET coins = MAX(0, COALESCE(coins,0) + :delta), xp = COALESCE(xp,0) + :delta, ... for every counter incl. stars, era_*, lifetime_*, and the harvests_week CASE), never a read-modify-write of absolute values - two concurrent credits can no longer overwrite each other. level is derived afterwards by _refresh_level (bounded retry: read row, compute level_for_xp, stamp WHERE COALESCE(xp,0) = :xp_seen so only a writer that saw the final xp stamps). extra keys remain absolute assignments (timestamps/stamps, last-writer-wins by nature). stars is a first-class increment param (claim_quest uses it; never pass a computed absolute star total through extra - that was a lost-update).
  • harvest/steal/_auto_harvest reserve the plot via clear_plot(plot_uid, expected_planted_at) (one conditional UPDATE, WHERE crop_key != '' AND planted_at = :expected) BEFORE crediting anything; a rowcount of 0 means someone else already took it (raise / skip). The planted_at match also protects an autoreplanted fresh crop from a stale clear.
  • plant/_try_autoreplant charge-then-claim with compensation: atomic coin charge (WHERE coins >= :cost), then atomic plot claim (WHERE crop_key = '' OR crop_key IS NULL); a lost claim refunds via refund_farm and errors. Two concurrent plants can never double-plant a slot or get a lost coin deduction.
  • water CASes the plot row (WHERE COALESCE(watered_by,'[]') = :expected AND crop_key = :crop) so the same visitor double-clicking cannot be paid twice and two visitors' waterings cannot lose one; the visitor reward goes through credit_farm.
  • fertilize charges atomically then CASes ready_at (WHERE ready_at = :expected), refunding on a lost CAS.
  • claim_daily is one conditional UPDATE (WHERE COALESCE(last_daily_at,'') = '' OR substr(last_daily_at,1,10) != :today) folding the credit, streak, and stamp into the guard - use substr(...,1,10) for the date compare, NOT SQLite date(), which chokes on the full-microsecond ISO strings stored here.
  • claim_quest CASes the claimed flag (WHERE COALESCE(claimed,0) = 0) before paying; advance_quests increments progress atomically (SET progress = MIN(COALESCE(goal,0), COALESCE(progress,0) + :amount)).

Known residual (accepted, tiny): two steals of two different ready plots of the same victim fired in the same instant can both pass the per-pair cooldown check (each plot still pays exactly once; only the once-per-hour rule is softened by that one overlap). Closing it would need a uniqueness constraint on the cooldown ledger for no real-world gain. 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). Supply is velocity-normalized: economy.supply_days(crop, recent_harvests) = recent * grow_seconds / 86400 converts raw counts into base-rate plot-days, so a 30s shell and a 7200s kernel saturate on the same real-terms scale (the original absolute-count tiers let a single active player permanently floor every fast crop). economy.market_saturation_factor(supply_days) steps the payout down through MARKET_SATURATION_TIERS (plot-day thresholds: 100% -> 40% past 96 plot-days, calibrated so the first tier is one dedicated 4-plot starter farm mono-cropping the full window and the floor is community-scale monocropping). Penalty XOR boost, never multiplied: market.market_factor_for(crop_key) returns the crop's own saturation factor when it is below 1.0; only an unsaturated crop in MARKET_BUFFED_CROPS gets economy.market_buff_factor(crop_key, market.market_pressure()), where pressure = 1 - min(saturation over MARKET_PRESSURE_CROPS) (rust/haskell/kernel) and the buff ramps linearly to MARKET_BUFF_CAP (+15%) at total high-tier collapse (slope derived from existing constants, no magic number). This makes the "boosted" state actually reachable (the original composition saturation * buff was capped at exactly 1.0, so "boosted" was dead code) and creates a self-balancing rotation loop: whales flooding kernel boost starter crops until those saturate themselves. The composed factor lies in [0.40, 1.15] and threads as the 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 <option> and its GameFarm.js JS mirror append a " (saturated)"/" (boosted)" text suffix to the crop label (an <option> cannot hold markup, so this is plain text, not a styled badge). market.prune_ticks(older_than_hours=96) keeps the table small; CLI devplace game market prune.

2. Coin sinks: Infrastructure, Defense, Cosmetics (economy.py + store/infrastructure.py, store/defense.py, store/cosmetics.py)

  • Infrastructure (POST /game/infrastructure/buy, store.buy_infrastructure): three one-time, boolean-owned, prestige-gated buildings (economy.INFRASTRUCTURE, new game_farms columns infra_registry/infra_canary/infra_observability). registry (+15% grow speed for Rust/Compiler/Kernel, economy.REGISTRY_BOOST_FACTOR, wired into grow_seconds_for's new registry_boost bool param and into water's bonus-seconds calc). canary (store/infrastructure.py::roll_canary, a plain random.random() roll per harvest - deliberately NOT the deterministic is_golden hash, since canary is a fresh roll every harvest, not a per-planting property - CANARY_DOUBLE_CHANCE 12% to double, CANARY_FAIL_CHANCE 6% to only refund the planting cost). observability (raises the steal-fraction floor from 0.10 to OBSERVABILITY_STEAL_FLOOR 0.30 for this owner; the original critique's "see raid attempts in real time" was dropped - no scouting/detection mechanic exists anywhere in the game, and this achieves the same "raids feel less brutal" goal without inventing one).
  • Defense (POST /game/defense/{upgrade,downgrade}, store.upgrade_defense/downgrade_defense/charge_upkeep): a leveled building (economy.DEFENSE_TIERS, columns defense_level/defense_last_upkeep_at). DefenseTier.steal_reduction is what actually works - a multiplicative cut (0/5/12/20/30%) applied inside effective_steal_fraction(defense_level, floor, building_reduction, cap). The older steal_fraction_floor is a lower bound and was mathematically inert: Legacy Branch Protection caps the subtractive term at 0.25, above every tier's floor, so the raider's share was byte-identical across all five tiers - players bought 2.3M coins of building plus 100k/day upkeep for nothing. Never reintroduce a "benefit" expressed as a floor. cap is the Observability Suite's OBSERVABILITY_STEAL_CAP (0.20); it replaced OBSERVABILITY_STEAL_FLOOR (0.30), which for a fully-defended owner raised the raider's share from 25% to 30% - a 150M coin purchase that actively harmed its buyer. Upkeep is the whale sink: economy.daily_upkeep(tier, coins) = max(tier.upkeep_daily, coins * UPKEEP_WEALTH_PCT) (0.2%/day). charge_upkeep runs lazily inside serialize_farm, same spot and spirit as _auto_harvest (no new tick): it charges every elapsed day (previously capped at UPKEEP_GRACE_DAYS, which made being absent cheaper than playing), and when the balance cannot cover it, takes MIN(coins, due) and decays one tier instead of zeroing the balance, then notifies via create_notification(..., "game_upkeep", ...). The one-time upkeep_amnesty column (set to 1 in ensure_farm for new farms, NULL/0 for pre-existing ones) stamps defense_last_upkeep_at and charges nothing on the first read after deploy, so the switch to symmetric charging never retro-bills a live player.
  • Cosmetics (POST /game/cosmetics/{buy,equip}, store/cosmetics.py, new table game_cosmetics since this catalog is meant to grow - unlike the fixed Infrastructure/Defense tiers): pure-status titles and plot skins (economy.COSMETICS), zero gameplay effect. game_farms.active_title holds the equipped title key; economy.cosmetic_title_name(key) resolves it to a display name everywhere a leaderboard entry surfaces title (never render the raw key). Title display is scoped to the Code Farm leaderboard/farm view only - it does not touch _avatar_link.html or any sitewide identity surface.

3. Mastery track and new crop families (economy.py prestige/mastery/crops sections + store/mastery.py)

Orthogonal to prestige, modeled directly on the existing Legacy shape (leveled, its own currency, survives prestige). Two-counter design, load-bearing: mastery_points (spendable, decreases on spend) vs mastery_points_earned_total (never decreases) - crop unlocks gate on the earned total so spending points can never re-lock content. economy.mastery_points_awarded(old_prestige, new_prestige) awards the FIRST Mastery point the moment prestige crosses MASTERY_UNLOCK_PRESTIGE (50) and one more every MASTERY_PRESTIGE_STEP (10) thereafter (_mastery_total_for(p) = 1 + (p-50)//10 for p>=50, difference of the before/after totals - correct even if prestige ever jumps by more than 1). store/actions.py::prestige() adds the award into the same reset dict, alongside stars, as a field that is not zeroed. POST /game/mastery (store.upgrade_mastery) spends mastery_points on economy.MASTERY_UPGRADES: autoreplant (Continuous Delivery - wired via a shared _try_autoreplant helper called from both harvest() and _auto_harvest() after crediting, replants the same crop immediately if still affordable/unlocked), analytics (Farm Analytics - GameFarmOut.mastery_analytics_unlocked gates the lifetime_coins_earned/lifetime_harvests stats panel, rendered by _game_analytics.html/data-analytics-host inside the Mastery section - both counters are accumulated by credit_farm, so they are exact from the moment the upgrade is bought, not backfilled for prior activity), contracts (Legacy Contracts - unlocks the weekly contract slot, see section 6; this deliberately consolidates the critique's "Legacy Contracts" and "weekly contracts" into ONE mechanism rather than two redundant long-goal systems).

Three new high-tier crops (distsys/mlpipe/secfort in economy.CROPS) extend the Crop dataclass with three new trailing, defaulted fields (min_mastery: int = 0, steal_immune: bool = False, era_key: str | None = None) - safe because every existing Crop(...) construction is positional over the original 8 non-defaulted fields. They set min_level=MAX_LEVEL (so, like every other crop, they re-lock after each refactor until the player re-levels to 20 - consistent with how Kernel etc. already behave post-prestige) and min_mastery=1 (gated on mastery_points_earned_total, checked both in store/actions.py::plant() - real enforcement - and in economy.crop_payload's locked flag - display). secfort (Security Fortress) is steal_immune=True: serialize_plot forces can_steal=False/steal_reason="immune" and store/actions.py::steal() raises immediately, before any grace/cooldown check, when the target crop is immune.

4. Secondary leaderboards (economy.py scoring + store/farm.py)

GET /game/leaderboard gained an optional board query param (default "score", so every existing caller/Devii/docs entry that omits it sees byte-identical output to before this shipped). store/farm.py::leaderboard_for(board, limit) dispatches through LEADERBOARD_BOARDS (score = the original farm_score board, prestige, harvests = this week's harvests_week counter, raids = average coins per successful raid with a minimum of MIN_RAIDS_FOR_EFFICIENCY_BOARD (3) qualifying raids - deliberately not "attempts," since failed steals are never persisted today and adding that write would be a spam vector for no real value, time_to_kernel = seconds between a farm's last prestiged_at and its next Kernel harvest, fair_play = economy.fair_play_score(harvests_week, coins) which rewards recent activity and penalizes hoarding) plus era (dispatches to store/era.py::leaderboard_era, always empty when no Era is running). GameFarm.js._loadLeaderboard renders a board-appropriate value per row instead of always showing the raw composite score - raid_avg (formatted "Nc/raid") for the raids board and a formatted duration for time_to_kernel, falling back to score for every other board. New game_farms columns backing these: harvests_week/harvests_week_start (incremented inside credit_farm's atomic UPDATE whenever harvests > 0 - a CASE that rolls the counter over to the new ISO week when harvests_week_start differs, else adds; also reset lazily in serialize_farm via _reset_harvests_week for idle weeks, same lazy-no-tick idiom as everything else. The increment is load-bearing: it feeds grant eligibility, the harvests board, and fair_play - it was originally missing entirely, leaving the community grant permanently unclaimable and the weekly board all zeros, a liveness bug invisible to pure-safety fuzzing and caught only by the layer-3 grant race setup), prestiged_at (stamped every prestige()), last_kernel_harvest_prestige/time_to_kernel_seconds (updated in harvest/_auto_harvest the first time a Kernel is harvested since the last refactor).

5. Eras / seasons (economy.py era scoring + store/era.py, routers/admin/game.py)

Admin-triggered, not automatic - a deliberate deviation from "reset every 4-6 weeks": an unattended live-leaderboard reset in production is exactly the kind of surprising action this project treats with caution, so POST /admin/game/era/start and /era/end (/admin/game page, require_admin) are the only way an Era starts or ends. Two new tables: game_eras (at most one row active=1 at a time - zero active rows is the permanent no-op state, everything below is dormant until the first Era is started) and game_era_results (the permanent per-user historical record written once at era-end, before counters reset). Three new game_farms columns, era_coins/era_harvests/era_joined_at, are a shadow, resettable counter completely separate from the real, permanent coins/total_harvests - start_era bulk-resets only these three to zero/now for every farm; end_era resets era_coins/era_harvests back to zero for every participant it ranks (in the same _update_farm call that awards Stars, so a farm never carries a stale Era total into the next dormant period) while never touching real balances, prestige, stars, Legacy, or Mastery. While an Era is active, game.html's Era banner shows the owner's own live era_coins/era_harvests (via data-era-coins/data-era-harvests, kept current by GameFarm.js._renderHud on every action), not just the fact that a season is running. economy.era_score(era_coins, era_harvests, prestige) is a DISTINCT scoring function from economy.farm_score - it gives prestige only ERA_PRESTIGE_CARRYOVER_PCT (40%) weight so veterans keep an edge on the Era board specifically without it being insurmountable, while the permanent leaderboards (section 4) keep ranking by full lifetime stats untouched. end_era ranks every farm with any Era activity, awards economy.ERA_REWARD_STARS_BY_RANK Stars to the top 10 (added to the permanent stars, exactly like a refactor's stars_for_refactor) and, when the active Era name matches a Cosmetic.era_key, grants an Era-exclusive cosmetic. Era-exclusive crops reuse the Crop.era_key field from section 3 (unlocked_crops/crop_payload hide them unless the active Era's name matches - a no-op for every crop shipped so far, all of which have era_key=None).

6. Engagement: Underdog raids, weekly Contracts, client-side ready pings

Underdog (economy.UNDERDOG_COIN_RATIO=10, UNDERDOG_DURATION_HOURS=24, UNDERDOG_MULTIPLIER=1.25): in store/actions.py::steal(), if the owner's coins exceed 10x the thief's, the thief's underdog_boost_until is stamped; _harvest_crop applies the +25% multiplier while that timestamp is in the future. GameFarmOut.underdog_boost_seconds_remaining drives a HUD banner; steal()'s result carries underdog_triggered so routers/game/farm.py::steal_farm can fire the David vs Goliath badge.

Weekly Contracts extend the existing daily-quest engine (game_quests table) rather than building a second system: a new scope column ("daily"/"weekly", existing/new daily rows always "daily" - backfilled on migration via UPDATE game_quests SET scope='daily' WHERE scope IS NULL OR scope='' so in-flight quests from before this shipped are never duplicated) and reward_stars. store/quests.py::ensure_weekly_contract materializes one economy.weekly_contract(user_uid, iso_week) row per ISO week (deterministic sha256 pick, same idiom as daily_quests), gated on owning the mastery_contracts upgrade; advance_quests advances both the day's rows and the current week's row in one pass. claim_quest(user, kind, scope="daily") claims either; a weekly claim pays Stars plus a contract_boost_until timestamp that grants WEEKLY_CONTRACT_BOOST_MULTIPLIER (1.2x) coins for WEEKLY_CONTRACT_BOOST_HOURS (48h), applied in _harvest_crop the same way as the Underdog boost.

"Crops ready" notifications are client-side only (GameFarm.js's existing 1-second ticker fires a toast/desktop Notification when a countdown hits zero while the tab is open) - this respects the hard "no background tick" architectural invariant, since nothing server-side needs to detect the transition. "Someone is scouting you" was not built - like Observability's real-time alert, it would require inventing a scouting/detection mechanic that does not exist. Alliance/Farm Coop was deliberately deferred to its own follow-up plan - it is a new multiplayer social feature (group membership, shared resource pooling, its own invite/leave/kick UX), qualitatively different from every economy-tuning change here, and was judged to deserve its own design pass rather than being folded into this rebalance.

Refactor fee, carry-over, treasury, and community grant (store/treasury.py)

Refactoring is no longer free at level 10 - it is the economy's progressive wealth sink, and the sink funds a redistribution loop. All backwards compatible: new game_farms columns (legacy_carryover, last_grant_week) default NULL and are COALESCEd, and the game_treasury table starts empty (lazily seeded by ensure_treasury).

  • Dynamic fee (economy.refactor_cost(prestige, coins) = REFACTOR_BASE_COST (20k) * (1 + prestige) * prestige_multiplier(prestige) + REFACTOR_WEALTH_PCT (15%) * coins): the polynomial prestige term makes time-to-afford grow linearly with prestige (a soft cap on prestige cycling - income grows only linearly, so an exponential fee would wall off Mastery), and the wealth term is a progressive tax a hoarder cannot dodge. At prestige 0 the fee means a level-10 farm must keep farming (~23.5k coins minimum) before its first refactor - instant level-10 cycling is dead.
  • Carry-over (economy.refactor_carryover(coins, fee, carryover_level)): after the fee, REFACTOR_CARRYOVER_BASE (10%) of the remainder survives the reset, raised +5%/level by the new Golden Parachute Legacy upgrade (legacy_carryover, max 5 -> 35%). Always strictly below 100% so a refactor can never be net-profitable (same invariant class as the fertilize fix). prestige() pins the balance in the atomic WHERE (AND coins = :coins_now) so a concurrent harvest yields a "balance just changed - refresh" GameError instead of a wrong charge, then credits the fee to the treasury.
  • Treasury (game_treasury, single row uid="treasury-main", balance/collected_total/granted_total): every refactor fee is credited atomically (credit_treasury, raw conditional SQL like conditional_update_farm). The ledger invariant balance == collected_total - granted_total is fuzz-verified.
  • Community grant (POST /game/grant, store.claim_grant): once per ISO week (last_grant_week), an active low-wealth, low-prestige farm (coins < GRANT_WEALTH_CEILING 10k, harvests_week >= GRANT_MIN_WEEK_HARVESTS 5 - the activity gate blocks idle alts, prestige <= GRANT_MAX_PRESTIGE 5 - so post-refactor whales with a briefly low balance never draw welfare) claims economy.grant_amount(balance) (capped GRANT_CAP 2,500). Order of operations: atomic treasury debit first (WHERE balance >= :amount), then the atomic farm credit+week-stamp; a losing farm update refunds the treasury. Race-verified with real OS processes: concurrent claims by one farm pay exactly once, and two farms racing an underfunded treasury never overdraw it.
  • Fan-out: serialize_farm exposes refactor_cost/refactor_affordable/refactor_carryover_pct/refactor_carryover_preview plus the owner-only grant_*/treasury_balance; the shop row and GameFarm.js._shopHtml show the fee and hide the button until affordable; the sidebar Community grant section is _game_grant.html/data-grant-host/_grantHtml. Devii: game_prestige is now in CONFIRM_REQUIRED (large irreversible spend) with a declared confirm param; game_claim_grant is a plain auth tool. Docs: game-prestige updated + game-grant added.

Admin surface

/admin/game (routers/admin/game.py, admin_game.html, sidebar entry between Bot Monitor and AI usage) shows the current Era status and the start/end forms; AdminGameOut is the response schema. devplace game market prune and devplace game era {status,start,end} mirror the same actions from the CLI (cli/game.py).

The 2026 fairness and economics pass (cf.md)

A full audit (cf.md at the repo root) found 28 issues; all were fixed. The load-bearing invariants that came out of it, in addition to those already stated above:

  • One quantity, one pure function. Every number a player sees (price, payout, protection, reward) is produced by exactly one function in economy.py and called with identical arguments by the serializer and the mutator. Six of the 28 findings were the same defect - a value computed in two places from different inputs (fertilize price vs payout, steal display vs enforcement, the fertilize display omitting registry_boost). serialize_plot and the action that charges/pays MUST share their inputs; thread a new parameter through both or neither.
  • Never express a player benefit as a lower bound on something the player wants smaller (see the Defense floor above). Model reductions multiplicatively and caps as caps.
  • Reward units are not goal units. weekly_contract paid goal // 40 Stars, which for the coin-denominated earn contract meant 55 Stars for a single harvest against 5 for a full refactor and 419 for the whole Legacy tree. Contract rewards are now per-kind constants (QuestDef.contract_stars/contract_xp) and the coin goal scales by the farm's own income multiplier. Never divide a reward by a goal whose unit varies.
  • Flat rewards die. Water/daily/quest payouts are scaled by economy.social_reward_scale (the earner's OWN prestige x legacy multiplier, never the target's - scaling by the target's would make watering a second raid mechanic). The flat constants remain the prestige-0 case.
  • Leaderboard coin term is capped (SCORE_COIN_CAP): uncapped, coins // 20 dominated prestige 20:1 at endgame, so the default board ranked hoarding and refactoring cost you rank while the fair_play board on the same selector penalized the same hoarding.
  • Market saturation is per-capita (supply_days(crop, recent, active_farms)): absolute thresholds let four active farms pin every crop at the 40% floor for the entire server.
  • credit_farm/conditional_update_farm are the only ways to move a counter. end_era awarded Stars with a read-modify-write (stars = _lvl(farm,"stars") + award), the exact lost-update this file already warned about for claim_quest. It is now an atomic increment.
  • Auto-harvest must award what a manual harvest awards. _auto_harvest returns a summary and routers/game/_shared.state_payload fires award_rewards/track_action from it; without that, buying the CI Bot silently forfeited all site XP and badge progress.
  • Farm XP is divided by GAME_SITE_XP_DIVISOR before becoming site XP. One Kernel harvest is 400 farm XP against 10 site XP for a published post; undivided, the idle game decided site level and every level badge.
  • Number formatting is one rule with two implementations that must agree character-for- character: templating.format_* (server) and static/js/Format.js (client). GameFarm.js re-renders the same hosts the server rendered, so any divergence is a visible flash. A unit test asserts both against a shared value table.

raided_fraction (game_plots), upkeep_amnesty (game_farms), and joined_at (game_era_results) are the only new columns; all default to a value that makes an untouched row behave exactly as before, and idx_game_steals_owner_time is the only new index.