# 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 feature**`0` 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.py``Crop`, `CROPS`, `CROP_BY_KEY`, `crop_for`, `unlocked_crops`, `crop_payload`.
- `economy/ci.py``CiTier`, `CI_TIERS`, `CI_BY_TIER`, `MAX_CI_TIER`, `ci_speed`, `next_ci_tier`, `farm_speed`, `grow_seconds_for`, `water_bonus_seconds`.
- `economy/leveling.py``MAX_LEVEL`, `xp_threshold`, `level_for_xp`, `level_progress`, `STARTING_COINS`, `STARTING_PLOTS`, `MAX_PLOTS`, `plot_cost`.
- `economy/perks.py``Perk`, `PERKS`, `PERK_BY_KEY`, `perk_for`, `perk_cost`, `perk_value_text`.
- `economy/legacy.py``LegacyUpgrade`, `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.py``PRESTIGE_MIN_LEVEL`, `PRESTIGE_BONUS`, `prestige_multiplier`, `STAR_BASE`, `stars_for_refactor`.
- `economy/rewards.py``effective_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.py``DAILY_BASE`, `DAILY_STREAK_STEP`, `DAILY_STREAK_CAP`, `daily_reward`, `FERTILIZE_FRACTION`, `FERTILIZE_TAX`, `fertilize_click_cost`.
- `economy/quests.py``QuestDef`, `QUEST_DEFS`, `QUEST_KINDS`, `DAILY_QUEST_COUNT`, `daily_quests`.
- `economy/scoring.py``SCORE_*` 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) -> int``SELECT 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.py``market_saturation_factor`/`market_buff_factor` pure-function cases. `tests/unit/services/game/store.py``record_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) -> dict``GameError` 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:
- `registry``economy/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).
- `observability``economy/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.
- `analytics``GameFarmOut` 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.