Files
devplacepy/devplacepy/services/game/CLAUDE.md
T
2026-07-07 16:09:28 +02:00

14 KiB

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

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

Overview

game/ package (routers, mounted at /game) is the Code Farm idle game (index.py + farm.py): GET /game (page), GET /game/state, GET /game/leaderboard, POST /game/{plant,harvest,buy-plot,upgrade,fertilize,daily,perk,prestige,legacy,quests/claim}, plus social GET /game/farm/{username} and POST /game/farm/{username}/{water,steal}.

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

Data layer

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

Tables

game_farms (one per user, coins/xp/level/ci_tier/plot_count/total_harvests, plus prestige/streak/last_daily_at, the four perk_* columns, and the endgame stars + five legacy_* columns), game_plots (farm_uid/slot_index/crop_key/planted_at/ready_at/watered_by), and game_steals (thief_uid/owner_uid/slot_index/crop_key/coins/stolen_at, the per-pair steal-cooldown ledger) are not soft-deletable - they are mutable game state consumed by state transitions, not user content. Columns + indexes are ensured in database.init_db (unique idx_game_farms_user, idx_game_farms_rank, idx_game_plots_farm, idx_game_steals_pair on (thief_uid, owner_uid, stolen_at)); the _uid_index loop adds the uid index. ensure_farm(user_uid) lazily creates a farm + starting plots on first access (idempotent), so there is no signup hook.

Routes (routers/game/)

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

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

Live + frontend

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

Fan-out

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

Stealing (competitive loop, backwards compatible)

store.steal(thief, owner, slot) mirrors water: it requires the owner plot to be ready AND past the protection window economy.effective_steal_grace(owner_defense_level) (base STEAL_GRACE_SECONDS 60s, +30s per owner Branch Protection level) measured from ready_at, AND that the thief is off cooldown for this victim (steal_cooldown_remaining(thief, owner, now) == 0, i.e. no row in game_steals for the pair within STEAL_COOLDOWN_SECONDS = 3600 - you can raid a given neighbour only once per hour); it clears the plot exactly like a harvest (owner gets nothing), credits the thief's farm economy.steal_reward_coins (the owner's yield/prestige/legacy-multiplier realized value times effective_steal_fraction(owner_defense_level), base STEAL_FRACTION 0.5, -5% per defense level, floored at 0.1) and coins only - no XP, no total_harvests, so the leaderboard stays earned by real farming - then inserts a game_steals row stamping the cooldown. The route POST /game/farm/{username}/steal (reusing GameSlotForm) then track_actions both sides, fires create_notification(owner, "harvest_stolen", "Someone raided your Code Farm...", thief_uid, "/game") (the message never names the thief; related_uid is internal), and await notify_farms both the owner and the thief usernames so both farms refresh live. The new harvest_stolen notification type rides the existing in-app relay (live toast, no new wiring). No DB migration: grace + payout are computed from existing ready_at/perk/legacy columns, the game_steals table is created by the ensure-block (absent rows -> cooldown 0 -> first steal always allowed), and the new GamePlotOut.can_steal/steal_coins/steal_cooldown_seconds/steal_reason + GameFarmOut.steal_cooldown_seconds + GameFarmViewOut.stole_coins schema fields default safe. The per-pair cooldown is computed once per farm in serialize_farm (when the viewer is not the owner) and threaded into every serialize_plot as steal_locked_until, so can_steal is false and steal_reason is "cooldown"/"protected" accordingly. Frontend: the ready/not-owner branch of _game_grid.html and GameFarm.js._plotHtml render a btn-danger Steal button (data-game-action="steal", data-confirm gated like prestige) carrying steal_coins, or a disabled "Raid again in " label when on cooldown; on success GameFarm.js._submit toasts the payout from data.stole_coins.

Extended mechanics (all backwards compatible)

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

  1. Daily bonus (POST /game/daily, store.claim_daily): once per UTC day, consecutive days grow streak (reward economy.daily_reward, capped at DAILY_STREAK_CAP).
  2. Daily quests (game_quests table, POST /game/quests/claim): economy.daily_quests(user_uid, day) deterministically (sha256 of user:day) picks 3 of {plant, harvest, water, earn} with goals/rewards; store.ensure_quests lazily materializes the day's rows, store.advance_quests(user_uid, kind, amount) is called inside plant/harvest/water (wrapped in try/except so a quest write never breaks the action), claim_quest pays out when progress >= goal.
  3. Perks (POST /game/perk, store.upgrade_perk): four permanent upgrades (economy.PERKS) with escalating perk_cost; applied in the economy formulas - effective_plant_cost (discount), grow_seconds_for(crop, ci_tier, growth_level) via farm_speed (growth), effective_reward_coins (yield + prestige), effective_reward_xp (xp).
  4. Fertilizer (POST /game/fertilize, store.fertilize): cut a growing plot's ready_at by FERTILIZE_FRACTION for economy.fertilize_click_cost(eff_reward, reduce_seconds, full_grow_seconds) = ceil(eff_reward * reduce/full_grow * FERTILIZE_TAX) (TAX 1.05). The cost is priced against the build's realized harvest value (effective_reward_coins, which already carries yield/prestige/legacy multipliers), not raw grow-seconds, so the prestige dependence cancels and fully fertilizing a crop always costs >= its harvest - fertilize is a pure time-skip and can NEVER be a profit at any prestige. (This replaced the old grow-seconds-based fertilize_cost, which was an unbounded money pump at high prestige.)
  5. Prestige/Refactor (POST /game/prestige, store.prestige): at PRESTIGE_MIN_LEVEL resets coins/xp/level/ci/perks, sets plot_count = economy.prestige_base_plots(legacy_plots_level) (keeps/recreates plot rows up to that base, deletes the rest), increments prestige for a permanent prestige_multiplier (+25% coins each), and awards economy.stars_for_refactor(level, prestige) Stars (the legacy_*/stars columns are omitted from the reset dict so they survive every refactor, the established pattern).

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

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

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

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

Auto-harvest is lazy and tick-free: store._auto_harvest(farm, owner_uid, now) runs at the top of serialize_farm ONLY when the viewer is the owner AND legacy_autoharvest > 0; it clears each ready plot first then credits the pre-clear crop's coins/xp/total_harvests in one _update_farm, advances quests, and re-reads the farm - clear-then-credit is idempotent (a second read sees empty plots), and it never fires on a visitor's GET /game/farm/{username} view or the leaderboard, honouring the no-background-service invariant (both GET /game and GET /game/state go through state_payload with viewer=owner, so both auto-collect).

Golden builds are deterministic and storage-free: economy.is_golden(plot_uid, planted_at) (sha256, ~GOLDEN_CHANCE) marks a planting golden for its life; harvest/_auto_harvest multiply coins only by GOLDEN_MULTIPLIER (XP unscaled to keep level pacing), and serialize_plot.is_golden surfaces a sparkle badge (.game-plot-golden). New schema fields (GameLegacyOut, GameFarmOut.stars/legacy, GamePlotOut.is_golden) all default safe; no DB migration.