Code Farm Economy Rebalance — Implementation Plan

Status: planning document only, no code changed yet. Target: execute phase by phase against the live devplacepy repository. The game is in production; every phase below is additive-only (new columns/tables/functions with safe defaults, no renames, no destructive migrations) so a partially-completed rollout never breaks an existing farm, and init_db stays idempotent as today.

Ground rules for whoever executes this plan

  1. Never rename or remove an existing column, table, function signature, route, or schema field. Extend with new optional fields/params carrying safe defaults.
  2. Every new game_farms/game_plots column must default to a value that reproduces today's behavior for a legacy row that has never touched the new feature0 for counters/booleans, "" for timestamps, matching the existing store/common.py::_lvl() convention (int(farm.get(key) or 0)).
  3. Reuse the established patterns before inventing new ones. This codebase already has four proven shapes for "a shop of upgrades": CI_TIERS (tiered, coin-priced, single column), PERKS (leveled, coin-priced, resets on prestige), LEGACY_UPGRADES (leveled, star-priced, survives prestige), and the atomic-upsert-counter pattern in database._add_usage. Every new system below is explicitly modeled on one of these four — do not invent a fifth shape without a documented reason.
  4. No new background service, no new tick. The game's hard architectural invariant is "pure + timestamp-driven, no background tick" (services/game/CLAUDE.md). Every new mechanic below is either (a) computed lazily inside serialize_farm/store calls the same way _auto_harvest already is, or (b) an explicit admin/user action. None of them run on a scheduler.
  5. Follow the "four faces of one route" workflow from the root CLAUDE.md for every new route: HTML + JSON via respond(..., model=XOut), a Devii Action in services/devii/actions/catalog/game.py, a docs_api entry in the Code Farm group, and a badge/achievement hook where it fits the existing pattern.
  6. Validate, never test-run automatically. After each phase: python -c "from devplacepy.main import app", grep the touched files for em-dash characters/entities, and manually check JS/CSS/HTML balance. Do not run make test unless the user explicitly asks — write new tests in the matching tier (tests/unit/services/game/, tests/api/game/, tests/e2e/game/) so the user can run them before deploying.
  7. Update devplacepy/services/game/CLAUDE.md, README.md, and this repo's docs (docs_api, /docs prose if relevant) at the end of every phase, per the root CLAUDE.md feature workflow — do not defer documentation to the end of the whole plan.

Why a plan this size, and what is deliberately scoped down

The critique is being taken in full. Six places in it ask for something the current codebase has no state for at all (raid "attempts," real-time raid scouting, weekly long-term goals, cross-user alliances, automatic timed resets). Building those literally, as new bespoke systems, would multiply the surface area and the risk. Instead each of those is folded into or approximated with a mechanism the codebase already has proven safe, and every such decision is called out explicitly below in "Scope decisions and deviations" so it can be reviewed before Phase 5+6 ship. Nothing is dropped — everything is mapped to a concrete, buildable primitive.


Phase order

Phase Content Depends on Risk
0 Foundations: split economy.py into a package, introduce a single coin/xp credit choke point none low (mechanical, behavior-preserving)
1 Market Saturation 0 low
2 Coin sinks: Infrastructure tiers, upkeep Defense building, Cosmetics/titles 0 medium
3 Mastery track + new crop families 0, 2 (cosmetics reused for era rewards later) medium
4 Secondary leaderboards 0, 3 (harvests_week, mastery) low
5 Era/season system 0, 1, 2, 3, 4 high — recommend a second pass
6 Engagement loops: underdog bonus, weekly contracts, notification polish, (Alliance/Coop sketched, deferred) 0, 3 medium

This mirrors the critique's own "Implementation order" section, with Phase 0 inserted first because Phases 1, 3, 4, and 5 all add a shadow counter next to every coin credit — doing that refactor once, up front, removes the single biggest source of "forgot one call site" bugs in the whole plan.


Phase 0 — Foundations (no gameplay change)

0.1 Split services/game/economy.py into a package

The codebase already has the exact precedent for this: store.py was previously one file and is now the store/ package (actions.py, common.py, farm.py, quests.py, serialize.py) re-exported from store/__init__.py. economy.py is about to roughly triple in size (crops, CI, leveling, perks, legacy, market, infrastructure, defense, mastery, cosmetics, leaderboard scoring, era — 12 concern areas), so apply the same split mechanically, before any new content is added:

  • devplacepy/services/game/economy/__init__.py — re-exports every name from the submodules below, so from devplacepy.services.game import economy; economy.CROPS keeps working unchanged everywhere (store/*.py, routers/game/*.py, tests).
  • economy/crops.pyCrop, CROPS, CROP_BY_KEY, crop_for, unlocked_crops, crop_payload.
  • economy/ci.pyCiTier, CI_TIERS, CI_BY_TIER, MAX_CI_TIER, ci_speed, next_ci_tier, farm_speed, grow_seconds_for, water_bonus_seconds.
  • economy/leveling.pyMAX_LEVEL, xp_threshold, level_for_xp, level_progress, STARTING_COINS, STARTING_PLOTS, MAX_PLOTS, plot_cost.
  • economy/perks.pyPerk, PERKS, PERK_BY_KEY, perk_for, perk_cost, perk_value_text.
  • economy/legacy.pyLegacyUpgrade, LEGACY_UPGRADES, LEGACY_BY_KEY, legacy_for, legacy_cost, legacy_multiplier, legacy_value_text, prestige_base_plots, effective_steal_grace, effective_steal_fraction, LEGACY_SPEED_STEP, LEGACY_MULT_STEP, LEGACY_DEFENSE_GRACE, LEGACY_DEFENSE_FRACTION.
  • economy/prestige.pyPRESTIGE_MIN_LEVEL, PRESTIGE_BONUS, prestige_multiplier, STAR_BASE, stars_for_refactor.
  • economy/rewards.pyeffective_plant_cost, effective_reward_coins, effective_reward_xp, steal_reward_coins, is_golden, GOLDEN_CHANCE, GOLDEN_MULTIPLIER, WATER_BONUS_PCT, MAX_WATERS_PER_PLOT, WATER_REWARD_COINS, WATER_REWARD_XP, STEAL_GRACE_SECONDS, STEAL_FRACTION, STEAL_COOLDOWN_SECONDS.
  • economy/daily.pyDAILY_BASE, DAILY_STREAK_STEP, DAILY_STREAK_CAP, daily_reward, FERTILIZE_FRACTION, FERTILIZE_TAX, fertilize_click_cost.
  • economy/quests.pyQuestDef, QUEST_DEFS, QUEST_KINDS, DAILY_QUEST_COUNT, daily_quests.
  • economy/scoring.pySCORE_* constants, farm_score.
  • economy/market.py, economy/infrastructure.py, economy/defense.py, economy/mastery.py, economy/cosmetics.py, economy/era.py — empty stub modules created now, populated in Phases 1-6.

This is a pure mechanical move: cut/paste function bodies verbatim, no formula changes. Verify with python -c "from devplacepy.main import app" and a full-text grep -rn "from devplacepy.services.game import economy" devplacepy/ tests/ to confirm every caller still resolves every name it uses (the __init__.py re-export must be exhaustive — build it by grep -oP '^(def|class) \w+' economy/*.py and export every one).

0.2 Single coin/xp credit choke point

Today, store/actions.py updates game_farms.coins/xp/total_harvests inline, separately, at five call sites: harvest, _auto_harvest, steal (thief side), claim_daily, claim_quest, and water (visitor reward). Phases 1, 3, 4, and 5 each need to add one more shadow counter next to every coin credit (market-tick recording, lifetime_coins_earned, harvests_week, era_coins). Doing that at five scattered sites five times is exactly the kind of duplication the project's DRY convention forbids. Introduce one function now, before any of those counters exist, so every later phase touches one place:

In store/common.py, add:

def credit_farm(farm: dict, *, coins: int = 0, xp: int = 0, harvests: int = 0, is_kernel_harvest: bool = False, extra: dict | None = None) -> dict:
  • Computes the new coins, xp (re-deriving level via economy.level_for_xp), total_harvests exactly as the current inline code does at each site today.
  • Merges any extra fields (a plain dict of additional column updates) into the same single _update_farm call — this is the extension point every later phase hooks into instead of adding a sixth ad-hoc update site.
  • Returns the updated farm dict (mirrors what each call site already does by re-reading/merging locally).
  • is_kernel_harvest is accepted now (unused) so Phase 4's time-to-Kernel tracking has a stable signature to fill in later without touching every call site again.

Refactor harvest, _auto_harvest, steal, claim_daily, claim_quest, water in store/actions.py to call credit_farm(...) instead of their current inline _update_farm math. This step must be behaviorally invisible — same coins, same xp, same level, same total_harvests as before, verified by re-reading the diff against the current formulas rather than by running the suite (per the "never run tests unless asked" rule), and by asking the user to run tests/unit/services/game/store.py + tests/api/game/mutations.py before this phase is considered done, since they already assert exact reward amounts.


Phase 1 — Market Saturation

Design

A global per-crop production tracker computed from real harvest events (not a fabricated log — a genuinely new table, since today's harvest path never persists an event, only the aggregate total_harvests counter). Recent-harvest volume per crop reduces that crop's payout; low-tier crops get a small relief buff while the market is saturated. No existing column or table is touched.

Deliberate scope decision: only owner-initiated harvests and auto-harvests count toward saturation, not steals. A steal moves an already-grown build's value from owner to thief; it does not add new supply to the economy. Counting it would double-count the same unit of production. This is a real simplification of "calculated from existing harvest logs" (there is no existing log; a new lightweight one is added) but keeps the causal story of the feature honest: it throttles printing, not raiding.

New table

game_market_ticks (added to the ensure-block in devplacepy/database/schema.py right after the existing game_quests block, same non-soft-deletable treatment as the other game_* tables):

column type/default notes
uid str generate_uid(), gets the standard idx_game_market_ticks_uid via the _uid_index loop
crop_key str, ""
hour_bucket str, "" UTC "YYYY-MM-DDTHH"
harvests int, 0
updated_at str, ""

Unique index idx_game_market_ticks_bucket on (crop_key, hour_bucket) — this is both the query index and the ON CONFLICT target for the upsert below.

New module services/game/store/market.py (added to the store/ package, exported from store/__init__.py)

  • _ticks()get_table("game_market_ticks").
  • _hour_bucket(now: datetime) -> str.
  • record_harvest_tick(crop_key: str, now: datetime) -> None — atomic upsert, modeled exactly on database._add_usage's pattern:
    with db:
        db.query(
            "INSERT INTO game_market_ticks (uid, crop_key, hour_bucket, harvests, updated_at) "
            "VALUES (:uid, :crop_key, :hour_bucket, 1, :now) "
            "ON CONFLICT(crop_key, hour_bucket) DO UPDATE SET harvests = harvests + 1, updated_at = :now",
            uid=generate_uid(), crop_key=crop_key, hour_bucket=_hour_bucket(now), now=_iso(now),
        )
    
    The with db: wrapper is load-bearing (memory: dataset-dbquery-no-autocommit — a raw db.query write that does not commit holds the SQLite write lock).
  • _saturation_cache = TTLCache(ttl=30, max_size=32) keyed by crop_key — a display/economic cache, not a correctness cache, same class as the existing _leaderboard_cache; no cache_state version wiring needed (matches the documented rule: cross-worker version-sync is reserved for correctness-critical caches, this one only affects a rolling 48h window's precision by up to 30s).
  • recent_harvests(crop_key: str, window_hours: int) -> intSELECT COALESCE(SUM(harvests), 0) FROM game_market_ticks WHERE crop_key = :crop_key AND hour_bucket >= :cutoff, cutoff = _hour_bucket(now - window_hours).
  • prune_ticks(older_than_hours: int = 96) -> int — deletes buckets older than the cutoff, returns rows removed. Wired into a new CLI command (see below) so the table never grows unbounded, matching the existing prune convention (zips prune, forks prune, seo prune, etc.).

economy/market.py

MARKET_WINDOW_HOURS = 48
MARKET_SATURATION_TIERS: tuple[tuple[int, float], ...] = (
    (0, 1.00), (40, 0.85), (120, 0.70), (300, 0.55), (600, 0.40),
)
MARKET_BUFFED_CROPS = ("shell", "python", "webapp", "api")
MARKET_BUFF_CAP = 1.15

def market_saturation_factor(recent_harvests: int) -> float: ...
def market_buff_factor(crop_key: str, saturation_factor: float) -> float: ...

Wiring into economy/rewards.py

Add an optional trailing parameter, default preserves old behavior for any caller (tests, future code) that omits it:

def effective_reward_coins(crop, yield_level=0, prestige=0, legacy_mult_level=0, market_factor=1.0) -> int:
    ...factor *= market_factor...

def steal_reward_coins(crop, yield_level=0, prestige=0, legacy_mult_level=0, defense_level=0, market_factor=1.0) -> int:
    ...passes market_factor through to effective_reward_coins...

economy/crops.py::crop_payload(...) also gains market_factor: float = 1.0 so the shop preview shows the live, saturation-adjusted number before the player plants (avoids "why did I get less than shown").

Wiring into store/actions.py

At harvest, _auto_harvest, and steal, before computing the coin reward: recent = market.recent_harvests(crop.key, economy.MARKET_WINDOW_HOURS), sat = economy.market_saturation_factor(recent), factor = sat * economy.market_buff_factor(crop.key, sat), pass market_factor=factor into the reward call. After a successful harvest/_auto_harvest (not steal, per the scope decision above), call market.record_harvest_tick(crop.key, now).

store/serialize.py::serialize_farm computes the same factor per crop when building economy.crop_payload(...) for the shop list.

Schema

GameCropOut gains market_state: str = "normal" ("normal"/"saturated"/"boosted", derived from whether market_factor is <1, >1, or ==1). This is the only new field; reward_coins already reflects the real payout since it now includes the factor.

Frontend

_game_grid.html / _game_shop.html and GameFarm.js::_shopHtml/_gridHtml render a small inline label next to a crop's reward when market_state != "normal" ("Saturated -30%" / "Boosted +15%"), no new CSS classes beyond one small modifier reusing existing badge styling patterns.

CLI

devplace game market prune → calls store.market.prune_ticks(), added to devplacepy/cli/ next to the other prune subcommands, documented in the root CLAUDE.md Commands table.

Devii / docs

No new route, so no new Devii action — game_state/game_view_farm/game_plant responses automatically carry the new market_state field once it exists on GameCropOut. Add one sentence to the Code Farm docs_api group intro describing the field.

Tests

tests/unit/services/game/economy.pymarket_saturation_factor/market_buff_factor pure-function cases. tests/unit/services/game/store.pyrecord_harvest_tick/recent_harvests roundtrip. tests/api/game/mutations.py — harvesting the same crop repeatedly in a short window measurably reduces payout past the first saturation threshold.


Phase 2 — Coin sinks

Three sub-systems, each modeled on an existing shape.

2a. Infrastructure tiers (one-time purchases, modeled on LEGACY_UPGRADES's "owned" framing but boolean, per the critique's literal "new upgrade table, default owned = false")

economy/infrastructure.py:

@dataclass(frozen=True)
class Infrastructure:
    key: str
    name: str
    icon: str
    description: str
    cost: int
    min_prestige: int

INFRASTRUCTURE: tuple[Infrastructure, ...] = (
    Infrastructure("registry", "Private Registry", "📦",
        "Rust, Compiler, and Kernel crops grow 15% faster", 25_000_000, 3),
    Infrastructure("canary", "Canary Deployments", "🐤",
        "Every harvest has a 12% chance to double and a 6% chance to only refund its planting cost", 75_000_000, 8),
    Infrastructure("observability", "Observability Suite", "🔭",
        "Raises the minimum coins you keep when raided from 10% to 30%", 150_000_000, 15),
)
INFRA_BY_KEY = {i.key: i for i in INFRASTRUCTURE}

Scope decision: the critique's "see raid attempts in real time" for Observability is dropped — there is no scouting/detection mechanic anywhere in the game, and inventing one (who counts as "attempting," how it's surfaced, whether it needs a new pub/sub topic) is a distinct feature, not a coin sink. Observability instead gets a second, coin-preserving effect (raising the steal-fraction floor) that serves the same "stop feeling helpless against whales' raids" goal without new state.

New game_farms columns: infra_registry=0, infra_canary=0, infra_observability=0 (int 0/1).

New store/infrastructure.py:

  • buy_infrastructure(user, key) -> dictGameError if unknown key, already owned, prestige < INFRA_BY_KEY[key].min_prestige, or insufficient coins; deducts cost (credit_farm(farm, coins=-cost, extra={f"infra_{key}": 1})); returns {"key", "spent"}.

Wiring:

  • registryeconomy/ci.py::farm_speed/grow_seconds_for gain a registry_boost: bool = False param, applied only for crop keys {"rust", "haskell", "kernel"}, threaded from store the same way legacy_speed_level already is.
  • canary → in harvest()/_auto_harvest(), if farm["infra_canary"], roll random.random() once per harvest (plain random, not the deterministic is_golden hash — canary is a fresh roll every time, not a per-planting property, so determinism has no purpose here) against CANARY_DOUBLE_CHANCE=0.12 then CANARY_FAIL_CHANCE=0.06, adjusting the coin gain accordingly (double, or floor at the crop's effective_plant_cost refund, or normal).
  • observabilityeconomy/legacy.py::effective_steal_fraction's floor (max(0.1, ...)) becomes max(0.3 if owner_has_observability else 0.1, ...), threaded as a new observability: bool = False parameter alongside defense_level.

2b. Upkeep Defense building (the wealth-proportional sink — the critique's most important ask)

economy/defense.py:

@dataclass(frozen=True)
class DefenseTier:
    level: int
    name: str
    upgrade_cost: int
    upkeep_daily: int
    steal_fraction_floor: float
    grace_bonus: int

DEFENSE_TIERS: tuple[DefenseTier, ...] = (
    DefenseTier(0, "Undefended", 0, 0, 0.10, 0),
    DefenseTier(1, "Firewall", 5_000, 500, 0.10, 15),
    DefenseTier(2, "WAF", 40_000, 2_500, 0.08, 30),
    DefenseTier(3, "SOC Monitoring", 300_000, 15_000, 0.06, 60),
    DefenseTier(4, "Zero Trust Mesh", 2_000_000, 100_000, 0.04, 120),
)
UPKEEP_WEALTH_PCT = 0.002        # 0.2% of current coin balance per day, whichever is larger than the flat fee
UPKEEP_GRACE_DAYS = 2            # unpaid days tolerated before the tier decays by one level

def daily_upkeep(tier: DefenseTier, coins: int) -> int:
    return max(tier.upkeep_daily, round(coins * UPKEEP_WEALTH_PCT))

The max(flat, wealth_pct) formula is what makes this a genuine whale sink: a 282M-coin balance owes max(100_000, 564_000) = 564_000/day at tier 4, not a trivial flat fee, while a small farm pays the cheap flat floor.

New game_farms columns: defense_level=0 (int), defense_last_upkeep_at="" (str timestamp).

New store/defense.py:

  • upgrade_defense(user) -> dict — one-time purchase of the next tier, mirrors upgrade_perk's shape exactly (raises on max tier / insufficient coins).
  • charge_upkeep(farm: dict, now: datetime) -> dict — called lazily at the very top of serialize_farm, in the same place and spirit as _auto_harvest (lazy, timestamp-driven, no tick): if defense_level > 0 and at least one full day elapsed since defense_last_upkeep_at, charge daily_upkeep(tier, coins) per elapsed day (capped at UPKEEP_GRACE_DAYS days of arrears); if coins are insufficient to cover even one day, decrement defense_level by one instead of going negative, and reset the timer — this implements "if you don't pay, protection weakens" literally. Returns the updated farm (or the original if nothing was due), same idiom as _auto_harvest's return contract.

Wiring: defense_level's steal_fraction_floor/grace_bonus combine additively with the existing legacy_defense level inside effective_steal_fraction/effective_steal_grace (both already take a defense_level int; extend the call site in store to pass legacy_defense_level + defense_level-equivalent inputs, or add a second explicit parameter — pick whichever keeps the function signatures readable; document the final choice in the code, not just here).

Schema: GameFarmOut gains defense_level: int = 0, defense_tier_name: str = "", defense_upkeep_daily: int = 0, defense_next_cost: int = 0.

Route: POST /game/defense/upgrade (no body — mirrors POST /game/upgrade for CI), added to routers/game/index.py through the same _respond_action choke. Devii action game_upgrade_defense. docs_api entry. Template: new _game_defense.html partial + data-defense-host in game.html, GameFarm.js::_defenseHtml.

2c. Cosmetics and titles (pure status, coins or coins+stars)

New table game_cosmetics (own table, not flat columns, because this catalog is meant to grow over time and later feed Era rewards in Phase 5 — unlike the fixed five-slot Infrastructure/Defense tiers, this is an open-ended, appendable list):

column type/default
uid str
user_uid str
cosmetic_key str
purchased_at str
created_at str

Unique index idx_game_cosmetics_owner on (user_uid, cosmetic_key).

economy/cosmetics.py:

@dataclass(frozen=True)
class Cosmetic:
    key: str
    name: str
    icon: str
    description: str
    cost_coins: int
    era_key: str | None = None   # None = always purchasable; set in Phase 5 for era-exclusive items

COSMETICS: tuple[Cosmetic, ...] = (
    Cosmetic("title_architect", "The Architect", "🏛️", "A permanent title shown on the leaderboard", 500_000),
    Cosmetic("title_refactorer", "Serial Refactorer", "♻️", "Requires prestige >= 3 to purchase", 250_000),
    Cosmetic("title_kernel_hacker", "Kernel Hacker", "⚙️", "Requires having harvested a Kernel", 1_000_000),
    Cosmetic("skin_neon", "Neon Terminal", "🌈", "A cosmetic plot skin, no gameplay effect", 750_000),
)
COSMETIC_BY_KEY = {c.key: c for c in COSMETICS}

store/cosmetics.py: buy_cosmetic(user, key), owned_cosmetic_keys(user_uid) -> set[str], equip_title(user, key) (raises if not owned).

New game_farms column active_title="" (str, one of COSMETIC_BY_KEY keys of kind title, or empty).

Routes: POST /game/cosmetics/buy (GameCosmeticForm{key}), POST /game/cosmetics/equip (GameCosmeticForm{key}). Schema: GameFarmOut gains owned_cosmetics: list[str] = [], active_title: str = ""; GameLeaderboardEntryOut gains title: str = "".

Scope boundary, stated explicitly: the active title renders on the Code Farm leaderboard and farm-view page only for v1. It does not touch _avatar_link.html, the profile page, or any other sitewide identity surface — that would be a much larger, riskier change (global avatar/identity partial touched by every render site in the app) for a feature that is currently scoped to one subsystem.

Phase 2 badges

New ACHIEVEMENTS entries (group "Code Farm"): infra_bought -> [(1, "Enterprise Ready")], defense_upgraded -> [(1, "Fort Knox")], cosmetic_bought -> [(1, "Style Points")]. track_action calls at the three new success points.

Phase 2 tests

Unit tests for daily_upkeep, infra cost/gating, cosmetic gating. API tests for each new route's success/failure paths (insufficient coins, already owned, prestige gate). E2E test for the new shop panel appearing and a purchase flowing through.


Phase 3 — Mastery track and new crop families

Design

An orthogonal permanent track, unlocked once a farm's prestige has crossed a threshold, spent on a small tree of upgrades that open new gameplay rather than bigger numbers, per the critique's stated design philosophy. Modeled directly on LEGACY_UPGRADES (leveled, survives prestige, its own currency).

Two-counter design, load-bearing: Mastery needs both a spendable balance (mastery_points, decreases when spent) and a lifetime-earned total (mastery_points_earned_total, never decreases) because the new crop families must stay unlocked even after a player spends their mastery points down to zero. Gating unlocks on the spendable balance would re-lock content the moment it's spent — a real bug if not caught here.

economy/prestige.py additions

MASTERY_UNLOCK_PRESTIGE = 50
MASTERY_PRESTIGE_STEP = 10

def mastery_points_awarded(old_prestige: int, new_prestige: int) -> int:
    if new_prestige < MASTERY_UNLOCK_PRESTIGE:
        return 0
    baseline = max(old_prestige, MASTERY_UNLOCK_PRESTIGE - 1)
    return (new_prestige - MASTERY_UNLOCK_PRESTIGE) // MASTERY_PRESTIGE_STEP - max(0, baseline - MASTERY_UNLOCK_PRESTIGE) // MASTERY_PRESTIGE_STEP

(Prestige increments by exactly 1 per refactor, so in practice this awards 1 point whenever the new prestige is a multiple of 10 at or above 50 — written as a range difference so it is also correct if a future change ever lets prestige jump by more than 1.)

economy/mastery.py

@dataclass(frozen=True)
class MasteryUpgrade:
    key: str
    name: str
    icon: str
    description: str
    max_level: int
    base_cost: int
    cost_growth: float

MASTERY_UPGRADES: tuple[MasteryUpgrade, ...] = (
    MasteryUpgrade("autoreplant", "Continuous Delivery", "🔁",
        "Automatically replant the same crop right after harvest, if affordable", 1, 3, 1.0),
    MasteryUpgrade("analytics", "Farm Analytics", "📊",
        "Unlocks lifetime stats on your farm HUD", 1, 2, 1.0),
    MasteryUpgrade("contracts", "Legacy Contracts", "📜",
        "Unlocks a weekly long-term contract slot for Stars and a temporary boost", 1, 4, 1.0),
)
MASTERY_BY_KEY = {m.key: m for m in MASTERY_UPGRADES}

def mastery_cost(m: MasteryUpgrade, level: int) -> int:
    return round(m.base_cost * (m.cost_growth ** level))

DRY consolidation, called out explicitly: the critique lists "Legacy Contracts" under Mastery (Section 2) and "Daily/Weekly contracts" under engagement loops (Section 6) as if they were two systems. They are implemented as one mechanism here: the mastery_contracts upgrade unlocks a fourth, weekly-cadence slot in the existing game_quests engine (see Phase 6). Building two parallel long-goal systems would violate the project's DRY convention for no gameplay benefit.

game_farms new columns

mastery_points=0, mastery_points_earned_total=0, mastery_autoreplant=0, mastery_analytics=0, mastery_contracts=0, lifetime_coins_earned=0, lifetime_harvests=0.

lifetime_coins_earned/lifetime_harvests are accumulate-only counters incremented via the Phase 0 credit_farm choke point (one line added there, not five). They start at 0 for every existing farm on the day this ships — that under-counts a veteran's true lifetime total, which is an accepted, explicitly-noted trade-off (the alternative, backfilling from total_harvests/no historical coin ledger, is not possible since no such ledger exists; total_harvests already exists and is NOT reset, so the analytics panel should show that pre-existing counter alongside the new since-Mastery ones, clearly labeled).

store/actions.py::prestige() change

After computing new_prestige, add mastery_points_awarded(old_prestige, new_prestige) to both mastery_points and mastery_points_earned_total in the same reset/update dict — both columns are, like stars and legacy_*, deliberately excluded from the fields that get zeroed on refactor.

New store/mastery.py

  • upgrade_mastery(user, key) -> dict — spends mastery_points (not coins), mirrors upgrade_legacy's exact shape.

Wiring:

  • autoreplant — extract a small internal helper _plant_plot(farm, plot, crop, now) -> dict out of the existing plant() body in store/actions.py (now genuinely has two callers: plant() itself and the auto-replant hook, so this is justified DRY, not premature abstraction). In harvest()/_auto_harvest(), after crediting and clearing, if farm["mastery_autoreplant"] and the same crop is still affordable and unlocked, call _plant_plot immediately.
  • analyticsGameFarmOut gains mastery_analytics_unlocked: bool = False, lifetime_coins_earned: int = 0, lifetime_harvests: int = 0 (always present on the schema per the existing "empty/zero unless unlocked" convention already used for perks/quests/legacy; template/JS render the panel only when mastery_analytics_unlocked is true).
  • contracts — see Phase 6.

New crop families

Extend the Crop dataclass (in economy/crops.py) with two new trailing, defaulted fields — safe because every existing Crop(...) construction in the CROPS tuple is positional with exactly 8 args and none of the 8 existing fields has a default, so appending defaulted fields after them is valid dataclass semantics and changes nothing for the 7 existing crops:

@dataclass(frozen=True)
class Crop:
    key: str
    name: str
    icon: str
    cost: int
    grow_seconds: int
    reward_coins: int
    reward_xp: int
    min_level: int
    min_mastery: int = 0
    steal_immune: bool = False
    era_key: str | None = None   # populated in Phase 5

New entries appended to CROPS:

Crop("distsys", "Distributed System", "🕸️", 5_000, 14_400, 9_500, 900, MAX_LEVEL, min_mastery=1),
Crop("mlpipe", "ML Pipeline", "🧠", 12_000, 21_600, 21_000, 1_800, MAX_LEVEL, min_mastery=1),
Crop("secfort", "Security Fortress", "🔐", 30_000, 28_800, 48_000, 3_200, MAX_LEVEL, min_mastery=1, steal_immune=True),

min_level=MAX_LEVEL means the level gate is always satisfied once a player is capped (a prerequisite for prestige anyway), so the real gate is min_mastery, satisfied by mastery_points_earned_total >= 1 (i.e. having reached prestige 50 at least once and earned a Mastery point — the tree does not need to be spent, only unlocked, to grow these crops; this matches the critique's framing that Mastery "opens new gameplay" independent of what's purchased).

unlocked_crops(level, mastery_earned=0) signature gains the new parameter with a default of 0 (backward compatible for any other caller), filters crop.min_level <= level and crop.min_mastery <= mastery_earned.

steal_immune wiring: in store/actions.py::steal(), if crop.steal_immune, raise GameError("This build cannot be raided.") before any grace/cooldown check; serialize_plot sets can_steal=False, steal_reason="immune" unconditionally for such a plot.

Phase 3 tests

Unit: mastery_points_awarded edge cases (crossing 50, 60, skipping — though prestige only moves by 1, still test the range-difference formula), unlocked_crops gating by mastery, steal_immune short-circuit. API: POST /game/legacy-equivalent POST /game/mastery route full cycle. E2E: new crops appear in the shop only after reaching prestige 50 in a seeded test farm (or a lower test-only threshold override, see the existing pattern for other prestige-gated e2e tests).


Phase 4 — Secondary leaderboards

Design

store/farm.py::leaderboard() today does one thing: rank by economy.farm_score over the full in-memory _farms().find() set. Add sibling ranking functions over the same already-loaded set (compute all boards from one query when a caller needs more than one, to avoid N separate full-table scans) rather than N new queries.

New game_farms columns

  • harvests_week=0, harvests_week_start="" (ISO date of the current tracking week's Monday) — incremented via credit_farm's harvests param already threaded in Phase 0; lazily reset to 0 when now's ISO week differs from harvests_week_start, checked at the same lazy point as _auto_harvest/charge_upkeep inside serialize_farm.
  • prestiged_at="" (str timestamp) — set every time prestige() runs.
  • last_kernel_harvest_prestige=-1 (int, -1 sentinel meaning "never"), time_to_kernel_seconds=0 — updated in harvest()/_auto_harvest() when the harvested crop.key == "kernel" and farm["last_kernel_harvest_prestige"] != farm["prestige"]: seconds = (now - parse(prestiged_at)).total_seconds(), store both fields.

economy/scoring.py additions

FAIR_PLAY_ACTIVITY_WEIGHT = 50
FAIR_PLAY_HOARD_DIVISOR = 200_000
MIN_RAIDS_FOR_EFFICIENCY_BOARD = 3

def fair_play_score(harvests_week: int, coins: int) -> int:
    return harvests_week * FAIR_PLAY_ACTIVITY_WEIGHT - min(coins, 10**9) // FAIR_PLAY_HOARD_DIVISOR

store/farm.py additions

  • leaderboard_prestige(limit=25) — sort by (prestige, stars) desc.
  • leaderboard_harvests_week(limit=25) — sort by harvests_week desc.
  • leaderboard_raid_efficiency(limit=25)SELECT thief_uid, COUNT(*) as raids, SUM(coins) as total FROM game_steals GROUP BY thief_uid HAVING raids >= MIN_RAIDS_FOR_EFFICIENCY_BOARD ORDER BY (total * 1.0 / raids) DESC LIMIT :limit. Scope decision, stated explicitly: this ranks average coins per successful raid, not "coins stolen / raids attempted" as literally written in the critique — failed attempts (blocked by cooldown or protection) are never persisted today, and adding a write on every failed attempt would be an easy target for script-spam with no real gameplay value. The metric is renamed "Raid Efficiency" and documented with this caveat in docs_api and the game's own leaderboard UI tooltip, so it is never silently misleading.
  • leaderboard_time_to_kernel(limit=25) — filters last_kernel_harvest_prestige == prestige and prestige > 0, sorts time_to_kernel_seconds ascending.
  • leaderboard_fair_play(limit=25) — sorts by economy.fair_play_score(harvests_week, coins) descending.

Route

Extend the existing GET /game/leaderboard (do not add a new path — the critique's "parallel boards updated live" is one endpoint with a selector, matching how admin.ai-usage.{hours} already parameterizes a topic by value rather than by new routes): add board: str = Query("score") to game_leaderboard in routers/game/index.py, dispatch through a {name: function} map, default "score" reproduces exactly today's response for any caller that doesn't pass the parameter — zero behavior change for existing Devii/docs/frontend callers that omit it.

Schema

GameLeaderboardEntryOut gains, all defaulted to a neutral value so the existing score board's JSON is unaffected: raid_avg: float = 0.0, time_to_kernel_seconds: int = 0, fair_score: int = 0, title: str = "" (from Phase 2c).

Frontend

game.html's data-game-leaderboard host gets a <select data-lb-board> control; GameFarm.js::_loadLeaderboard(board = "score") passes ?board= through, re-renders the same row template with board-specific columns shown/hidden via a small per-board column map.

Devii / docs

game_leaderboard Devii action gains an optional board parameter (backward compatible). docs_api entry documents the board enum and the Raid Efficiency caveat above.

Phase 4 tests

Unit: each new scoring/sort function against a small in-memory fixture set. API: GET /game/leaderboard?board=X for every X, plus the no-param case still matches the pre-Phase-4 response shape exactly.


Phase 5 — Era / Season system

This is, by a wide margin, the largest and riskiest phase — it is the one place in this plan recommended for a second execution pass with a fresh review, after Phases 0-4 have shipped and produced real signal about whether Market Saturation and the coin sinks already relieved enough of the inequality. It is fully specified here so it can be executed in one pass if that is still the decision, but the recommendation is explicit: pause here and confirm before flipping the first Era on in production.

Design invariant, stated up front

Real balances never move. coins, prestige, stars, legacy_*, mastery_* are the permanent economy and are never reset, scaled down, or migrated by this phase — that would directly violate "every existing coin balance, prestige number... must continue to work exactly as it does today." Everything Era-specific is a new, parallel, resettable set of counters that only affects a separate Era leaderboard's ranking, never real gameplay math.

New tables

game_eras (uid, era_number int, name str, started_at str, ends_at str, active int default 0, created_at str) — at most one row has active=1 at a time; zero active rows means the Era system is completely dormant and every other phase behaves exactly as if Phase 5 was never installed. This is the backward-compatibility anchor for the whole phase: shipping the code with no era ever started is a no-op.

game_era_results (uid, era_number int, user_uid str, rank int, era_score int, era_coins_final int, reward_stars int, reward_cosmetic_key str, created_at str) — the permanent historical record written once, at era-end, before the live counters are zeroed.

New game_farms columns

era_coins=0, era_harvests=0, era_joined_at="".

era_coins/era_harvests are incremented through the Phase 0 credit_farm choke point (again: one line added there, in the one function every coin/xp credit already flows through) whenever an era is active; when no era is active, these columns simply stay at whatever they were (never touched), keeping the no-op guarantee above exact even mid-development.

economy/era.py

ERA_PRESTIGE_CARRYOVER_PCT = 0.4   # veterans keep 40% of their prestige weight on the Era board only
ERA_REWARD_STARS_BY_RANK: tuple[int, ...] = (50, 30, 20, 15, 10, 5, 5, 5, 5, 5)  # ranks 1-10

def era_score(era_coins: int, era_harvests: int, prestige: int) -> int:
    return era_coins // 20 + era_harvests * 10 + round(prestige * SCORE_PRESTIGE * ERA_PRESTIGE_CARRYOVER_PCT)

This is a distinct scoring function from economy.scoring.farm_score — it must never replace it. The permanent/global leaderboard (Phase 4) keeps ranking by full lifetime stats; only the Era-specific board uses era_score.

Admin-triggered lifecycle — a deliberate deviation from "automatic every 4-6 weeks"

Scope decision, stated explicitly: the critique calls for an automatic timer. This plan makes Era start/end an explicit admin action instead, via a new routers/admin/game.py (require_admin, mirroring the existing routers/admin/*.py package pattern) with POST /admin/game/era/start ({name, duration_days}) and POST /admin/game/era/end. Rationale: a silent, unattended production reset of a live leaderboard is exactly the kind of surprising, hard-to-reverse action this project's own operating principles call for caution on — an admin should be present to handle the support/communication fallout of a reset, not have it fire while nobody is watching. If a genuinely scheduled cadence is wanted later, it can be added as a BaseService (the codebase already has the exact machinery for "check a condition once a tick and act" — see services/CLAUDE.md) that calls the same admin-triggered functions on a timer; that is a small, separate addition once the manual flow has been exercised at least once in production.

  • store/era.py::start_era(name, duration_days) -> dict — raises if an era is already active; inserts the game_eras row with active=1; bulk UPDATE game_farms SET era_coins=0, era_harvests=0, era_joined_at=:now (a genuine bulk reset, but of the new shadow counters only, never coins/prestige).
  • store/era.py::end_era() -> dict — raises if none active; computes era_score for every farm via the same in-memory scan pattern as leaderboard(), writes one game_era_results row per participating farm (era_coins_final > 0 or era_harvests > 0), awards ERA_REWARD_STARS_BY_RANK stars to the top 10 (added to stars, which survives forever, exactly like a Refactor's stars_for_refactor award) and grants each top-10 farm one Era-exclusive cosmetic (Phase 2c's Cosmetic.era_key field, populated for a couple of new catalog entries at this point), sets the era row active=0.

Era-exclusive crops

Reusing the Crop.era_key field added in Phase 3: unlocked_crops additionally filters crop.era_key is None or crop.era_key == active_era_name — when no era is active (the default, forever, until an admin opts in) this filter is a no-op since every existing/new crop from Phases 1-4 has era_key=None.

Route / schema / frontend

GET /game/leaderboard?board=era (folds into the Phase 4 dispatch map; a no-op / empty list when no era is active — never a 404 or error, so existing UI code paths that always render the select stay safe). GameFarmOut gains era_active: bool = False, era_name: str = "", era_ends_at: str = "", era_coins: int = 0, era_harvests: int = 0 (all zero/false when dormant). New admin template templates/admin/game.html (era start/end form + live saturation/upkeep stats dashboard, read-only aside from the two era actions) following the existing admin package template conventions.

Phase 5 tests

Unit: era_score formula, ERA_REWARD_STARS_BY_RANK payout math. API: full lifecycle — start era, harvest (era_coins increments, real coins also increment normally), end era (results row written, stars awarded, counters zeroed, real coins/prestige unchanged — assert this last point explicitly, it is the whole safety contract of the phase). E2E: admin start/end flow, era banner appears on the game page while active.


Phase 6 — Engagement loops

Underdog raid bonus (lowest risk, ship first within this phase)

New game_farms column underdog_boost_until="" (str timestamp).

economy/rewards.py: UNDERDOG_COIN_RATIO = 10, UNDERDOG_DURATION_HOURS = 24, UNDERDOG_MULTIPLIER = 1.25.

In store/actions.py::steal(), after a successful steal: if owner_farm["coins"] > thief_farm["coins"] * economy.UNDERDOG_COIN_RATIO, set the thief's underdog_boost_until = now + 24h via the same update call. In harvest()/_auto_harvest(), compute underdog = now < parse(farm["underdog_boost_until"]) and pass it into effective_reward_coins as one more optional multiplicative factor (same pattern as market_factor).

GameFarmOut gains underdog_boost_seconds_remaining: int = 0; _game_grid.html/GameFarm.js render a small HUD banner when it's positive. New badge underdog_raid -> [(1, "David vs Goliath")], track_action at the steal success point when the boost was just granted.

Weekly contracts (consolidates Section 2's "Legacy Contracts" and Section 6's "weekly contracts" into the existing quest engine, per the Phase 3 DRY note)

Extend game_quests (ensure-block in database/schema.py) with one new column: scope="daily" (str, default "daily"every existing row and every new daily row keeps this default, so nothing about today's 3-a-day quest behavior changes).

economy/quests.py::weekly_contract(user_uid: str, iso_week: str) -> dict — same deterministic sha256-seeded pick as daily_quests, but seeded on f"{user_uid}:{iso_week}", one contract only, rewards Stars instead of coins/xp, plus a contract_boost_multiplier similar in shape to the underdog boost.

store/quests.py::ensure_quests gains a second call, gated on farm["mastery_contracts"], that materializes a scope="weekly" row for the current ISO week if the Mastery upgrade is owned and none exists yet. claim_quest already claims by kind; extend its lookup to also match scope so a weekly and a daily quest of the same kind can never collide (add scope: str = "daily" to GameQuestForm, default preserves existing calls).

Alliance / Farm Coop — explicitly deferred, sketched only

Scope decision, stated explicitly: this is qualitatively different from every other item in this plan — it is a new multiplayer social feature (group membership, shared resource pooling, coop-only buildings, presumably invite/leave/kick UX and its own moderation surface) rather than an economy-tuning change. Cramming it into the same execution pass as five economy systems and a season reset risks under-designing the social/UX side, which deserves its own focused plan and review. It is recommended as a separate follow-up plan, not part of this one's "execute at once" scope. For continuity, the minimal future shape: game_coops (uid, name, owner_uid, created_at), game_coop_members (coop_uid, user_uid, joined_at) — additive, no existing table touched, consistent with every other phase's backward-compatibility posture.

Notification polish

"Crops ready" ping: implemented entirely client-side in GameFarm.js's existing 1-second ticker — when a plot's countdown reaches zero while the tab is open, fire a toast (existing AppToast) and, if the user has granted the browser Notification permission (feature-detect, do not prompt automatically), a desktop Notification. No backend change, no new scheduled check — this respects the "no background tick" invariant exactly, since nothing server-side needs to detect the transition.

"Someone is scouting you" ping — explicitly not built. Scoped out for the same reason as Phase 2's Observability real-time alert: there is no scouting/detection mechanic in the game today (a raid is a single atomic action, not a two-phase "look then strike"), and fabricating one to support a notification is out of scope for this plan. If a scouting mechanic is wanted, it needs its own design pass first.

Phase 6 tests

Unit: underdog trigger threshold, weekly-contract seeding determinism. API: underdog boost granted/applied/expires; weekly contract claim independent of daily contracts of the same kind. E2E: underdog banner renders; client-side ready notification fires (Playwright can assert the toast, desktop Notification mocking is optional).


Scope decisions and deviations — summary

Critique ask What ships Why
Saturation "from existing harvest logs" New lightweight game_market_ticks table No harvest log exists today; adding one is unavoidable and kept minimal (hourly buckets, not per-event rows)
Saturation counts "Kernel harvests" generally Only owner harvests/auto-harvests count, not steals Steals move already-produced value, they don't add new supply — avoids double-counting
Observability: "see raid attempts in real time" Raises the steal-fraction floor instead No scouting/detection mechanic exists; inventing one is a separate feature
Upkeep "proportional to total wealth or prestige" max(flat_fee, coins * 0.2%) per day Literal implementation of the ask — this is what makes it a real whale sink
"Legacy Contracts" (Mastery) + "weekly contracts" (engagement) One mechanism: a Mastery-gated weekly slot in the existing game_quests engine Avoids two redundant long-goal systems
"Best raid efficiency (coins stolen / raids attempted)" Avg coins per successful raid, min 3 raids to qualify Failed attempts are never persisted today; adding that write is a spam vector for no real value
Era reset "every 4-6 weeks" (automatic) Admin-triggered start/end An unattended live-leaderboard reset in production is exactly the kind of action this project's operating principles ask to confirm first; the manual flow can be put behind a scheduler later once exercised once
"Alliance / Farm Coop" Deferred to its own follow-up plan; minimal future table shape sketched Qualitatively a new multiplayer social feature, not an economy change — deserves its own UX design pass
"Someone is scouting you" notification Not built No scouting mechanic exists anywhere in the game; would require inventing one first
"Crops ready" notification Client-side only, tied to the existing ticker Respects the "no background tick" hard architectural invariant

Definition of done, per phase

  1. python -c "from devplacepy.main import app" imports clean.
  2. grep every touched file for em-dash (character and HTML entity) — none present, hyphens only.
  3. Every new game_farms/game_plots/game_* column has a safe zero-equivalent default and is read through store/common.py::_lvl() or an equivalent explicit or 0/or "" guard, never a bare farm["new_column"].
  4. Every new mutating route: appears in routers/game/index.py or farm.py guarded the same way its neighbors are (require_user), returns via the shared _respond_action/GameFarmViewOut pattern, has a Devii Action with matching requires_auth, has a docs_api entry, and — if it grants an achievement — a BADGE_CATALOG/ACHIEVEMENTS entry plus a track_action call.
  5. devplacepy/services/game/CLAUDE.md updated with the new mechanic in the same terse, precise style as the existing sections (this file currently documents 5 "extended mechanics" plus the "endgame" section — new phases extend that same running list, they do not replace it).
  6. README.md updated if the phase adds a user-visible feature.
  7. New tests written in the correct tier (tests/unit/services/game/, tests/api/game/, tests/e2e/game/) — not run automatically; the user runs make test (or the relevant tier target) before promoting to production.
  8. A manual smoke pass on a seeded test farm confirms: a legacy farm (created before the phase shipped) loads without error and behaves exactly as before until it opts into the new mechanic.