pdate
Some checks failed
DevPlace CI / test (push) Has been cancelled

This commit is contained in:
retoor 2026-07-23 01:14:10 +02:00
parent 64c3983c9f
commit 582e37d176
24 changed files with 549 additions and 36 deletions

View File

@ -125,8 +125,9 @@ The **Code Farm** (`/game`) is a cooperative idle game in the spirit of Farmvill
- **Daily bonus.** Claim a coin bonus once per day; consecutive days build a streak that grows the reward (capped at seven days).
- **Daily quests.** Three quests rotate every day (plant, harvest, water, or earn goals), tracked automatically as you play; claim each one for coins and XP when complete.
- **Perks.** Spend coins on four permanent upgrades - Optimizer (+harvest coins), Build Cache (+build speed), Bulk Licenses (-planting cost), and Mentorship (+harvest XP) - each levelling up with escalating cost.
- **Refactor (prestige).** At level 10 you can refactor: the farm resets (coins, level, CI, extra plots, perks) in exchange for a permanent +25% coin bonus that stacks with every refactor. Each refactor also awards **Stars** (scaled by the level and prestige you reached) to spend in the Legacy shop.
- **Stars and Legacy (endgame).** Stars buy permanent **Legacy** upgrades that survive every refactor, unlike perks: **CI Bot** (auto-collects ready builds when you open your farm), **Tech Debt Payoff** (+coins, stacks with prestige), **Bare-Metal** (+base build speed), **Monorepo** (+starting plots after each refactor), and **Branch Protection** (longer steal grace and a smaller steal cut). This is the infinite progression for maxed farms, and CI Bot makes the game playable hands-off.
- **Refactor (prestige).** At level 10 you can refactor: the farm resets (coins, level, CI, extra plots, perks) in exchange for a permanent +25% coin bonus that stacks with every refactor. Each refactor also awards **Stars** (scaled by the level and prestige you reached) to spend in the Legacy shop. Refactoring costs a **dynamic coin fee** that grows with your prestige and current wealth, so you must farm well past level 10 to afford each one - and 10% of what remains after the fee (more with the Golden Parachute Legacy upgrade, up to 35%) carries over into the new run.
- **Community treasury and weekly grant.** Every refactor fee flows into a shared treasury. Active low-balance, low-prestige farms (at least five harvests this week, under 10,000 coins, at most prestige 5) can claim a capped grant from it once per week - a direct wealth transfer from the farms refactoring at the top to the farms building at the bottom.
- **Stars and Legacy (endgame).** Stars buy permanent **Legacy** upgrades that survive every refactor, unlike perks: **CI Bot** (auto-collects ready builds when you open your farm), **Tech Debt Payoff** (+coins, stacks with prestige), **Bare-Metal** (+base build speed), **Monorepo** (+starting plots after each refactor), **Branch Protection** (longer steal grace and a smaller steal cut), and **Golden Parachute** (a larger refactor coin carry-over). This is the infinite progression for maxed farms, and CI Bot makes the game playable hands-off.
- **Golden builds.** A small share of plantings come out golden (marked with a sparkle); harvesting a golden build pays several times the coins.
- **Visit and water friends.** Open another member's farm at `/game/farm/{username}` and water their growing builds to speed them up - you earn coins for helping, and the owner sees the help live. This is the social loop that makes the game cooperative.
- **Steal a harvest.** A ready build on someone else's farm can be stolen once a protection window passes - the owner gets that grace period (longer if they invested in Branch Protection or a Defense building) to harvest it first. A successful steal pays the thief a fraction of the build's coin value (the owner loses the whole build) and earns the **Cat Burglar** badge; the victim gets the **Robbed** badge and a live notification that someone raided their farm (the thief is never named). You can raid any given neighbour only **once per hour**. Raiding a farm with 10x your own coins grants a 24-hour **Underdog** boost (+25% coin gain) and the **David vs Goliath** badge. Stealing pays coins only, so the harvest-based leaderboards stay earned by real farming.
@ -138,7 +139,7 @@ The **Code Farm** (`/game`) is a cooperative idle game in the spirit of Farmvill
- **Leaderboards.** Several boards, selectable from the game page: **Overall score** (a composite weighing refactor/prestige count, XP, lifetime harvests, coins, CI tier, plots, perks, and streak), **Prestige**, **Harvests this week**, **Raid efficiency** (average coins per successful raid), **Fastest to Kernel** (time since your last refactor), **Fair play** (rewards recent activity over hoarding), and (when running) the current **Era** board.
- **Eras (admin-managed seasons).** Administrators can start a Era at `/admin/game`: every farm's *visible* Era coins/harvests counters reset to zero, but real coin balances, prestige, Stars, Legacy, and Mastery are never touched. Ending an Era ranks farms by Era score (which gives prestige only partial weight, so veterans keep an edge without it being insurmountable), awards Stars to the top 10, and permanently records the results.
The farm refreshes live over the pub/sub bus (a watered build appears on the owner's screen at once) and every plot countdown ticks client-side. Every endpoint also answers JSON, and Devii can play the game on the member's behalf via the `game_*` tools (`game_state`, `game_plant`, `game_harvest`, `game_buy_plot`, `game_upgrade_ci`, `game_water`, `game_steal`, `game_view_farm`, `game_leaderboard`, `game_upgrade_perk`, `game_upgrade_legacy`, `game_prestige`, `game_upgrade_mastery`, `game_buy_infrastructure`, `game_upgrade_defense`, `game_buy_cosmetic`, `game_equip_cosmetic`). See the API reference group **Code Farm**.
The farm refreshes live over the pub/sub bus (a watered build appears on the owner's screen at once) and every plot countdown ticks client-side. Every endpoint also answers JSON, and Devii can play the game on the member's behalf via the `game_*` tools (`game_state`, `game_plant`, `game_harvest`, `game_buy_plot`, `game_upgrade_ci`, `game_water`, `game_steal`, `game_view_farm`, `game_leaderboard`, `game_upgrade_perk`, `game_upgrade_legacy`, `game_prestige`, `game_claim_grant`, `game_upgrade_mastery`, `game_buy_infrastructure`, `game_upgrade_defense`, `game_buy_cosmetic`, `game_equip_cosmetic`). See the API reference group **Code Farm**.
## Engagement

View File

@ -1043,6 +1043,8 @@ def init_db():
("legacy_speed", 0),
("legacy_plots", 0),
("legacy_defense", 0),
("legacy_carryover", 0),
("last_grant_week", ""),
("prestiged_at", ""),
("mastery_points", 0),
("mastery_points_earned_total", 0),
@ -1170,6 +1172,17 @@ def init_db():
unique=True,
)
game_treasury = get_table("game_treasury")
for column, example in (
("uid", ""),
("balance", 0),
("collected_total", 0),
("granted_total", 0),
("updated_at", ""),
):
if not game_treasury.has_column(column):
game_treasury.create_column_by_example(column, example)
game_eras = get_table("game_eras")
for column, example in (
("uid", ""),

View File

@ -12,6 +12,9 @@ The Code Farm is a cooperative idle game. Each member owns a farm of plots, plan
projects that build over real time, harvests them for coins and XP, upgrades their CI tier for
faster builds, and waters other members' growing builds to speed them up and earn coins.
Refactoring (prestige) costs a dynamic coin fee that grows with prestige and current wealth;
the fees fill a community treasury from which active low-balance farms can claim a weekly grant.
All endpoints negotiate HTML or JSON. The action endpoints return the full farm state so a
client can refresh without a second request.
""",
@ -180,17 +183,26 @@ client can refresh without a second request.
method="POST",
path="/game/prestige",
title="Refactor (prestige)",
summary="Reset the farm at level 10+ for a permanent +25% coin bonus and earn Stars to spend on Legacy upgrades. From prestige 50 onward, every 10 more prestige also earns a permanent Mastery point.",
summary="Reset the farm at level 10+ for a permanent +25% coin bonus and earn Stars to spend on Legacy upgrades. Refactoring costs a coin fee that scales with prestige and current wealth (the farm state's refactor_cost); the fee funds the community treasury and a fraction of the remaining coins (10% base, more with the Golden Parachute Legacy upgrade) carries over. From prestige 50 onward, every 10 more prestige also earns a permanent Mastery point.",
auth="user",
destructive=True,
sample_response={"ok": True, "farm": {"prestige": 1}},
sample_response={"ok": True, "farm": {"prestige": 1, "coins": 6550}},
),
endpoint(
id="game-grant",
method="POST",
path="/game/grant",
title="Claim the community grant",
summary="Claim the weekly community grant, paid from the treasury filled by refactor fees. Eligible farms are active (5+ harvests this week), below 10000 coins, and at most prestige 5.",
auth="user",
sample_response={"ok": True, "farm": {"coins": 2550}},
),
endpoint(
id="game-legacy",
method="POST",
path="/game/legacy",
title="Buy a Legacy upgrade",
summary="Spend Stars on a permanent Legacy upgrade that survives every refactor: autoharvest, multiplier, speed, plots, or defense.",
summary="Spend Stars on a permanent Legacy upgrade that survives every refactor: autoharvest, multiplier, speed, plots, defense, or carryover (Golden Parachute, raises the refactor coin carry-over).",
auth="user",
params=[field("key", "form", "string", True, "multiplier", "Legacy upgrade key.")],
sample_response={"ok": True, "farm": {"stars": 1}},

View File

@ -43,7 +43,7 @@ Prefixes are wired in `main.py`:
| `/xmlrpc` | xmlrpc.py - reverse-proxies XML-RPC calls to the forking XML-RPC bridge (`services/xmlrpc/`, supervised by `XmlrpcService` on loopback `config.XMLRPC_PORT`), which generates one XML-RPC method per documented REST endpoint from `docs_api.API_GROUPS` (`posts.create`, `feed.list`, ...). One struct of named params per call; auth via in-band `api_key`, `X-API-KEY`/`Bearer` header, or `http://user:pass@host/xmlrpc` Basic. Full introspection + `system.multicall`; REST errors become XML-RPC faults. Exempt from the rate limiter (enforced on the forwarded internal hop). See `devplacepy/services/xmlrpc/CLAUDE.md` |
| `/api` | devrant/ package - devRant-compatible REST protocol (`auth.py`, `rants.py`, `comments.py`, `notifs.py`). Translates devRant requests onto DevPlace data: rants<->posts, comments/votes onto the native engagement layer, devRant integer ids onto each table's auto-increment `id`. Token auth via `devrant_tokens`. See `routers/devrant/CLAUDE.md` for the deep detail on this tree |
| `/dbapi` | dbapi/ package - **primary-administrator-only, strictly READ-ONLY** generic database API over `dataset` (never inserts/updates/deletes/restores; the write surface was removed because it bypassed every per-route admin safeguard). `tables.py` (`GET /tables`, `GET /{table}/schema`), `crud.py` (read only: `GET /{table}` + `GET /{table}/{key}/{value}`, `?include_deleted`), `query.py` (`POST /query` hard SELECT-only validated read-only run; `POST /query/async` + `GET /query/{uid}` + `WS /query/{uid}/ws` via `DbApiJobService` kind `dbquery`), `nl.py` (`POST /nl` natural-language->SQL via the AI gateway, returns validated SELECT, `execute=true` runs it read-only). **Auth = the PRIMARY administrator only** (the earliest-created Admin, `utils.is_primary_admin` / `database.get_primary_admin_uid` - the same identity that gates backup downloads) by session/api_key (`services/dbapi/policy.py`); every other administrator is refused like a member, and there is **no internal-key path** - the gateway `internal_gateway_key()` is NOT accepted (no service-to-service access). SQL validated by `services/dbapi/validate.py` (sqlglot classify + suspicious-flag + read-only `EXPLAIN` dry-run). Devii tools `db_*` are read-only (list/get/query/nl; no write tools) and flagged `requires_primary_admin=True`, so they are added to the LLM tool list ONLY for the primary administrator (every other session never sees them and Devii is unaware they exist). `services/pubsub/policy.py` resolves its own admin/internal actor and does NOT reuse `dbapi.policy.caller_for`, so the primary-admin restriction does not leak onto the pub/sub bus. See `devplacepy/services/dbapi/CLAUDE.md` |
| `/game` | game/ package - the **Code Farm** idle game (`index.py` + `farm.py`): `GET /game` (page), `GET /game/state`, `GET /game/leaderboard?board=`, `POST /game/{plant,harvest,buy-plot,upgrade,fertilize,daily,perk,prestige,legacy,mastery,quests/claim}`, `POST /game/{defense/upgrade,infrastructure/buy,cosmetics/buy,cosmetics/equip}`, plus social `GET /game/farm/{username}` and `POST /game/farm/{username}/{water,steal}`. See `devplacepy/services/game/CLAUDE.md` |
| `/game` | game/ package - the **Code Farm** idle game (`index.py` + `farm.py`): `GET /game` (page), `GET /game/state`, `GET /game/leaderboard?board=`, `POST /game/{plant,harvest,buy-plot,upgrade,fertilize,daily,grant,perk,prestige,legacy,mastery,quests/claim}`, `POST /game/{defense/upgrade,infrastructure/buy,cosmetics/buy,cosmetics/equip}`, plus social `GET /game/farm/{username}` and `POST /game/farm/{username}/{water,steal}`. See `devplacepy/services/game/CLAUDE.md` |
| (none) | push.py - web push + PWA: `GET`/`POST /push.json` (VAPID public key / subscription register), `GET /service-worker.js`, `GET /manifest.json` |
| (none) | docs.py (docs/ package) - the documentation site (prose pages + API reference). See `routers/docs/CLAUDE.md` for the deep detail on this tree (`DOCS_PAGES`, audience tiers, prose rendering pipeline) |
| `/pubsub` | pubsub.py - **database-free** publish/subscribe bus. `WS /pubsub/ws` (subscribe/unsubscribe/publish frames, `foo.*` wildcards), `POST /pubsub/publish` + `GET /pubsub/topics` (admin/internal). Lock-owner-gated WS (`4013` retry) so subscribers converge on one worker; topic authz in `services/pubsub/policy.py` (`user.{uid}.*` private, `public.*` shared, admin/internal anywhere, guests opt-in). In-process `services.pubsub.publish(topic, data)` for backends; frontend `app.pubsub`. See `devplacepy/services/pubsub/CLAUDE.md` |
@ -188,7 +188,7 @@ News articles have an internal detail page at `/news/{slug}` with full comment s
The home route (`main.py` `landing()`) never redirects - it renders `templates/landing.html` for everyone, branching on `user`:
- **Guests** get the marketing hero (`Join DevPlace Free` CTA + features grid).
- **Signed-in users** get a personalized hero (`.landing-hero-user`): avatar, "Welcome back, {username}", a `Go to your feed` CTA, a Posts/Stars/Level stat strip (`user_post_count` + the user dict's `stars`/`level`), and quick links. Styles live in `.landing-hero-user`/`.landing-welcome`/`.landing-stats`/`.landing-quicklinks` in `static/css/landing.css`.
- **Signed-in users** get a personalized dashboard hero (`.dashboard-welcome`): avatar, "Welcome back, {username}", quicklink buttons (`.dashboard-btn`, with `New Post` -> `/feed` as `.dashboard-btn-primary`, plus Code Farm/Projects/Gists), and a Posts/Stars/Level stat strip (`.dashboard-stats`, `user_post_count` + the user dict's `stars`/`level`). Styles live in the `.dashboard-*` classes in `static/css/landing.css`.
- Both states share the Latest Posts + Developer News + "Build With Us" sections. The **Build With Us** section is static HTML/CSS (`.landing-help-*` in `landing.css`): four cards linking to `/docs/index.html` (Documentation), `/swagger` + `/openapi.json` (API Reference), `/issues` (Contribute & Report), and Devii. The Devii card's `Launch Devii` button is a plain `<button data-devii-open>` that opens the globally mounted `DeviiTerminal` (`app.devii`) in place - no extra JS, route, schema, or Devii action; a secondary link points to `/devii/` for the full terminal page.
- Context adds `user`, `is_authenticated`, `user_post_count`; `LandingOut` carries `is_authenticated`/`user_post_count` for the JSON form. `GET /` is documented in `docs_api.py` (id `home`, mapped to `LandingOut`).

View File

@ -122,6 +122,12 @@ async def game_daily(request: Request):
return await _respond_action(request, user, lambda: store.claim_daily(user))
@router.post("/grant")
async def game_claim_grant(request: Request):
user = require_user(request)
return await _respond_action(request, user, lambda: store.claim_grant(user))
@router.post("/perk")
async def game_perk(request: Request, data: Annotated[GamePerkForm, Form()]):
user = require_user(request)

View File

@ -134,6 +134,14 @@ class GameFarmOut(_Out):
prestige_multiplier: float = 1.0
prestige_min_level: int = 0
prestige_available: bool = False
refactor_cost: int = 0
refactor_affordable: bool = False
refactor_carryover_pct: int = 0
refactor_carryover_preview: int = 0
grant_available: bool = False
grant_amount: int = 0
grant_reason: str = ""
treasury_balance: int = 0
streak: int = 0
daily_available: bool = False
daily_reward: int = 0

View File

@ -3,7 +3,7 @@
from __future__ import annotations
from ..spec import Action, Param
from ._shared import body, path, query
from ._shared import body, confirm, path, query
GAME_ACTIONS: tuple[Action, ...] = (
@ -160,7 +160,25 @@ GAME_ACTIONS: tuple[Action, ...] = (
name="game_prestige",
method="POST",
path="/game/prestige",
summary="Refactor (prestige) your Code Farm for a permanent coin bonus and Stars (requires level 10)",
summary=(
"Refactor (prestige) your Code Farm for a permanent coin bonus and Stars. "
"Requires level 10 AND a coin fee that scales with prestige and wealth "
"(shown as refactor_cost in game_state); the fee funds the community "
"treasury and a fraction of the remaining coins carries over. "
"Irreversible, confirmation required"
),
handler="http",
requires_auth=True,
params=(confirm(),),
),
Action(
name="game_claim_grant",
method="POST",
path="/game/grant",
summary=(
"Claim the weekly Code Farm community grant, paid from the treasury of "
"refactor fees (active low-balance, low-prestige farms only)"
),
handler="http",
requires_auth=True,
),
@ -168,13 +186,13 @@ GAME_ACTIONS: tuple[Action, ...] = (
name="game_upgrade_legacy",
method="POST",
path="/game/legacy",
summary="Spend Stars on a permanent Legacy upgrade that survives refactor (autoharvest, multiplier, speed, plots, defense)",
summary="Spend Stars on a permanent Legacy upgrade that survives refactor (autoharvest, multiplier, speed, plots, defense, carryover)",
handler="http",
requires_auth=True,
params=(
body(
"key",
"Legacy key: autoharvest, multiplier, speed, plots, or defense.",
"Legacy key: autoharvest, multiplier, speed, plots, defense, or carryover.",
required=True,
),
),

View File

@ -61,6 +61,7 @@ CONFIRM_REQUIRED = {
"gateway_quota_rule_delete",
"email_account_delete",
"email_delete_message",
"game_prestige",
}
CONDITIONAL_CONFIRM = {

View File

@ -4,7 +4,7 @@ This file documents the Code Farm idle game. Claude Code auto-loads it when a fi
## 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}`.
`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,grant,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.
@ -14,7 +14,7 @@ A cooperative-and-competitive idle game (Farmville-style) mounted at `/game`, me
## 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.
`game_farms` (one per user, `coins`/`xp`/`level`/`ci_tier`/`plot_count`/`total_harvests`, plus `prestige`/`streak`/`last_daily_at`/`last_grant_week`, the four `perk_*` columns, and the endgame `stars` + six `legacy_*` columns including `legacy_carryover`), `game_plots` (`farm_uid`/`slot_index`/`crop_key`/`planted_at`/`ready_at`/`watered_by`), `game_steals` (`thief_uid`/`owner_uid`/`slot_index`/`crop_key`/`coins`/`stolen_at`, the per-pair steal-cooldown ledger), and `game_treasury` (a single row `uid="treasury-main"` holding `balance`/`collected_total`/`granted_total`, the refactor-fee redistribution 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/`)
@ -42,7 +42,7 @@ Five further systems layer onto the base loop, every one defaulting gracefully f
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).
5. **Prestige/Refactor** (`POST /game/prestige`, `store.prestige`): at `PRESTIGE_MIN_LEVEL` resets coins/xp/level/ci/perks, sets `plot_count = economy.prestige_base_plots(legacy_plots_level)` (keeps/recreates plot rows up to that base, deletes the rest), increments `prestige` for a permanent `prestige_multiplier` (+25% coins each), and awards `economy.stars_for_refactor(level, prestige)` Stars (the `legacy_*`/`stars` columns are **omitted from the reset dict** so they survive every refactor, the established pattern). **Refactoring costs a dynamic coin fee** - see "Refactor fee, carry-over, treasury, and community grant" below.
`serialize_farm` exposes all of this (`perks`, `quests`, `streak`, `daily_available`/`daily_reward`, `prestige*`, `stars`, `legacy`, `steal_cooldown_seconds`) only to the owner; the existing `crop_payload`/`serialize_plot` now carry perk- and legacy-adjusted costs/rewards and per-plot value-based `fertilize_cost`. Frontend hosts (`[data-shop-host]`/`[data-perk-host]`/`[data-legacy-host]`/`[data-daily-host]`/`[data-quest-host]`) are server-rendered from partials (`_game_shop.html`/`_game_perks.html`/`_game_legacy.html`/`_game_daily.html`/`_game_quests.html`) and fully re-rendered by `GameFarm.js` builders; the generic `data-game-action` form delegation handles the new actions, with `data-confirm` gating the prestige reset.
@ -102,6 +102,16 @@ Three new high-tier crops (`distsys`/`mlpipe`/`secfort` in `economy.CROPS`) exte
**"Crops ready" notifications are client-side only** (`GameFarm.js`'s existing 1-second ticker fires a toast/desktop `Notification` when a countdown hits zero while the tab is open) - this respects the hard "no background tick" architectural invariant, since nothing server-side needs to detect the transition. **"Someone is scouting you" was not built** - like Observability's real-time alert, it would require inventing a scouting/detection mechanic that does not exist. **Alliance/Farm Coop was deliberately deferred** to its own follow-up plan - it is a new multiplayer social feature (group membership, shared resource pooling, its own invite/leave/kick UX), qualitatively different from every economy-tuning change here, and was judged to deserve its own design pass rather than being folded into this rebalance.
### Refactor fee, carry-over, treasury, and community grant (`store/treasury.py`)
Refactoring is no longer free at level 10 - it is the economy's progressive wealth sink, and the sink funds a redistribution loop. All backwards compatible: new `game_farms` columns (`legacy_carryover`, `last_grant_week`) default NULL and are `COALESCE`d, and the `game_treasury` table starts empty (lazily seeded by `ensure_treasury`).
- **Dynamic fee** (`economy.refactor_cost(prestige, coins)` = `REFACTOR_BASE_COST` (20k) `* (1 + prestige) * prestige_multiplier(prestige) + REFACTOR_WEALTH_PCT` (15%) `* coins`): the polynomial prestige term makes time-to-afford grow linearly with prestige (a soft cap on prestige cycling - income grows only linearly, so an exponential fee would wall off Mastery), and the wealth term is a progressive tax a hoarder cannot dodge. At prestige 0 the fee means a level-10 farm must keep farming (~23.5k coins minimum) before its first refactor - instant level-10 cycling is dead.
- **Carry-over** (`economy.refactor_carryover(coins, fee, carryover_level)`): after the fee, `REFACTOR_CARRYOVER_BASE` (10%) of the remainder survives the reset, raised +5%/level by the new **Golden Parachute** Legacy upgrade (`legacy_carryover`, max 5 -> 35%). Always strictly below 100% so a refactor can never be net-profitable (same invariant class as the fertilize fix). `prestige()` pins the balance in the atomic WHERE (`AND coins = :coins_now`) so a concurrent harvest yields a "balance just changed - refresh" `GameError` instead of a wrong charge, then credits the fee to the treasury.
- **Treasury** (`game_treasury`, single row `uid="treasury-main"`, `balance`/`collected_total`/`granted_total`): every refactor fee is credited atomically (`credit_treasury`, raw conditional SQL like `conditional_update_farm`). The ledger invariant `balance == collected_total - granted_total` is fuzz-verified.
- **Community grant** (`POST /game/grant`, `store.claim_grant`): once per ISO week (`last_grant_week`), an **active low-wealth, low-prestige** farm (coins < `GRANT_WEALTH_CEILING` 10k, `harvests_week >= GRANT_MIN_WEEK_HARVESTS` 5 - the activity gate blocks idle alts, prestige <= `GRANT_MAX_PRESTIGE` 5 - so post-refactor whales with a briefly low balance never draw welfare) claims `economy.grant_amount(balance)` (capped `GRANT_CAP` 2,500). Order of operations: atomic treasury debit first (`WHERE balance >= :amount`), then the atomic farm credit+week-stamp; a losing farm update refunds the treasury. Race-verified with real OS processes: concurrent claims by one farm pay exactly once, and two farms racing an underfunded treasury never overdraw it.
- **Fan-out:** `serialize_farm` exposes `refactor_cost`/`refactor_affordable`/`refactor_carryover_pct`/`refactor_carryover_preview` plus the owner-only `grant_*`/`treasury_balance`; the shop row and `GameFarm.js._shopHtml` show the fee and hide the button until affordable; the sidebar **Community grant** section is `_game_grant.html`/`data-grant-host`/`_grantHtml`. Devii: `game_prestige` is now in `CONFIRM_REQUIRED` (large irreversible spend) with a declared `confirm` param; `game_claim_grant` is a plain auth tool. Docs: `game-prestige` updated + `game-grant` added.
### Admin surface
`/admin/game` (`routers/admin/game.py`, `admin_game.html`, sidebar entry between Bot Monitor and AI usage) shows the current Era status and the start/end forms; `AdminGameOut` is the response schema. `devplace game market prune` and `devplace game era {status,start,end}` mirror the same actions from the CLI (`cli/game.py`).

View File

@ -269,6 +269,12 @@ PERK_BY_KEY = {perk.key: perk for perk in PERKS}
PRESTIGE_MIN_LEVEL = 10
PRESTIGE_BONUS = 0.25
REFACTOR_BASE_COST = 20_000
REFACTOR_WEALTH_PCT = 0.15
REFACTOR_CARRYOVER_BASE = 0.10
REFACTOR_CARRYOVER_MAX = 0.95
LEGACY_CARRYOVER_STEP = 0.05
STAR_BASE = 1
LEGACY_SPEED_STEP = 0.05
LEGACY_MULT_STEP = 0.10
@ -321,6 +327,15 @@ LEGACY_UPGRADES: tuple[LegacyUpgrade, ...] = (
2,
1.8,
),
LegacyUpgrade(
"carryover",
"Golden Parachute",
"🪂",
"+5% of your post-fee coins carried through each refactor per level",
5,
2,
1.8,
),
)
LEGACY_BY_KEY = {up.key: up for up in LEGACY_UPGRADES}
@ -363,6 +378,9 @@ def legacy_value_text(up: LegacyUpgrade, level: int) -> str:
return f"+{round(LEGACY_SPEED_STEP * level * 100)}% build speed"
if up.key == "plots":
return f"+{level} starting plots"
if up.key == "carryover":
pct = round(refactor_carryover_fraction(level) * 100)
return f"{pct}% refactor carry-over"
grace = LEGACY_DEFENSE_GRACE * level
loss = round(LEGACY_DEFENSE_FRACTION * level * 100)
return f"+{grace}s grace, -{loss}% steal loss"
@ -405,6 +423,33 @@ def prestige_multiplier(prestige: int) -> float:
return 1 + PRESTIGE_BONUS * max(0, prestige)
def refactor_cost(prestige: int, coins: int) -> int:
scaled = REFACTOR_BASE_COST * (1 + max(0, prestige)) * prestige_multiplier(prestige)
return round(scaled + REFACTOR_WEALTH_PCT * max(0, coins))
def refactor_carryover_fraction(carryover_level: int = 0) -> float:
return min(
REFACTOR_CARRYOVER_MAX,
REFACTOR_CARRYOVER_BASE + LEGACY_CARRYOVER_STEP * max(0, carryover_level),
)
def refactor_carryover(coins: int, fee: int, carryover_level: int = 0) -> int:
remainder = max(0, coins - fee)
return int(remainder * refactor_carryover_fraction(carryover_level))
GRANT_WEALTH_CEILING = 10_000
GRANT_MIN_WEEK_HARVESTS = 5
GRANT_MAX_PRESTIGE = 5
GRANT_CAP = 2_500
def grant_amount(treasury_balance: int) -> int:
return max(0, min(GRANT_CAP, treasury_balance))
UNDERDOG_COIN_RATIO = 10
UNDERDOG_DURATION_HOURS = 24
UNDERDOG_MULTIPLIER = 1.25

View File

@ -59,6 +59,7 @@ from .infrastructure import buy_infrastructure, owns_infrastructure
from .market import market_factor_for, prune_ticks, recent_harvests
from .mastery import upgrade_mastery
from .quests import advance_quests, ensure_quests
from .treasury import claim_grant, ensure_treasury, grant_status, treasury_balance
from .serialize import (
_daily_available,
_plot_state,

View File

@ -488,6 +488,8 @@ def upgrade_legacy(user: dict, key: str) -> dict:
def prestige(user: dict) -> dict:
from .treasury import credit_treasury
farm = ensure_farm(user["uid"])
level = economy.level_for_xp(int(farm.get("xp", 0)))
if level < economy.PRESTIGE_MIN_LEVEL:
@ -496,6 +498,13 @@ def prestige(user: dict) -> dict:
)
old_prestige = _lvl(farm, "prestige")
new_prestige = old_prestige + 1
coins_now = int(farm.get("coins", 0))
fee = economy.refactor_cost(old_prestige, coins_now)
if coins_now < fee:
raise GameError(
f"Refactoring costs {fee} coins right now - keep farming to afford it."
)
carried = economy.refactor_carryover(coins_now, fee, _lvl(farm, "legacy_carryover"))
stars_award = economy.stars_for_refactor(level, old_prestige)
mastery_award = economy.mastery_points_awarded(old_prestige, new_prestige)
base_plots = economy.prestige_base_plots(_lvl(farm, "legacy_plots"))
@ -503,7 +512,7 @@ def prestige(user: dict) -> dict:
now = _iso(now_dt)
set_parts = [
"coins = :starting_coins",
"coins = :post_coins",
"xp = 0",
"level = 1",
"ci_tier = 1",
@ -515,13 +524,14 @@ def prestige(user: dict) -> dict:
"prestiged_at = :now",
]
params = {
"starting_coins": economy.STARTING_COINS,
"post_coins": economy.STARTING_COINS + carried,
"base_plots": base_plots,
"new_prestige": new_prestige,
"stars_award": stars_award,
"mastery_award": mastery_award,
"now": now,
"old_prestige": old_prestige,
"coins_now": coins_now,
}
for perk in economy.PERKS:
column = PERK_COLUMN[perk.key]
@ -530,16 +540,19 @@ def prestige(user: dict) -> dict:
rows = conditional_update_farm(
farm["uid"],
set_clause=", ".join(set_parts),
where_clause="COALESCE(prestige, 0) = :old_prestige",
where_clause="COALESCE(prestige, 0) = :old_prestige AND coins = :coins_now",
params=params,
)
if rows == 0:
farm = get_farm(user["uid"])
if _lvl(farm, "prestige") != old_prestige:
raise GameError("Your farm already refactored - refresh and try again.")
if int(farm.get("coins", 0)) != coins_now:
raise GameError("Your coin balance just changed - refresh and try again.")
raise GameError(
f"Reach level {economy.PRESTIGE_MIN_LEVEL} to refactor (prestige)."
)
credit_treasury(fee)
kept_slots = set()
for plot in get_plots(farm["uid"]):
@ -561,7 +574,13 @@ def prestige(user: dict) -> dict:
for slot_index in range(base_plots):
if slot_index not in kept_slots:
_create_plot(farm["uid"], user["uid"], slot_index, now)
return {"prestige": new_prestige, "stars_awarded": stars_award, "mastery_awarded": mastery_award}
return {
"prestige": new_prestige,
"stars_awarded": stars_award,
"mastery_awarded": mastery_award,
"fee": fee,
"carried": carried,
}
def fertilize(user: dict, slot: int) -> dict:

View File

@ -137,6 +137,7 @@ def serialize_farm(
) -> dict:
from .actions import _auto_harvest
from .defense import charge_upkeep
from .treasury import grant_status, treasury_balance
now = now or _now()
viewer_uid = viewer["uid"] if viewer else ""
@ -193,6 +194,14 @@ def serialize_farm(
)
building_defense_level = _lvl(farm, "defense_level")
defense_tier_info = economy.defense_tier(building_defense_level)
coins = int(farm.get("coins", 0))
refactor_fee = economy.refactor_cost(prestige, coins)
carryover_level = _lvl(farm, "legacy_carryover")
grant = (
grant_status(farm, now)
if is_owner
else {"available": False, "amount": 0, "reason": ""}
)
return {
"owner_username": owner.get("username", ""),
"owner_uid": owner_uid,
@ -240,6 +249,20 @@ def serialize_farm(
"prestige_multiplier": economy.prestige_multiplier(prestige),
"prestige_min_level": economy.PRESTIGE_MIN_LEVEL,
"prestige_available": is_owner and level >= economy.PRESTIGE_MIN_LEVEL,
"refactor_cost": refactor_fee,
"refactor_affordable": is_owner
and level >= economy.PRESTIGE_MIN_LEVEL
and coins >= refactor_fee,
"refactor_carryover_pct": round(
economy.refactor_carryover_fraction(carryover_level) * 100
),
"refactor_carryover_preview": economy.refactor_carryover(
coins, refactor_fee, carryover_level
),
"grant_available": grant["available"],
"grant_amount": grant["amount"],
"grant_reason": grant["reason"],
"treasury_balance": treasury_balance() if is_owner else 0,
"streak": streak,
"daily_available": daily_available,
"daily_reward": economy.daily_reward(streak + 1 if daily_available else streak),

View File

@ -87,6 +87,7 @@ export class GameFarm {
this._setHost("[data-analytics-host]", this._analyticsHtml(farm));
this._setHost("[data-cosmetics-host]", this._cosmeticsHtml(farm));
this._setHost("[data-daily-host]", this._dailyHtml(farm));
this._setHost("[data-grant-host]", this._grantHtml(farm));
this._setHost("[data-quest-host]", this._questsHtml(farm));
}
this._tick();
@ -128,12 +129,17 @@ export class GameFarm {
const ciText = farm.ci_next_tier
? `Upgrade to ${farm.ci_next_label} for faster builds`
: "CI is at the top tier.";
const prestigeBtn = farm.prestige_available
? `<form method="post" action="/game/prestige" data-game-action="prestige"><button type="submit" class="btn btn-sm btn-primary" data-confirm="Refactor resets coins, level, CI, extra plots, and perks for a permanent +25% coin bonus. Continue?">Refactor</button></form>`
const prestigeBtn = farm.refactor_affordable
? `<form method="post" action="/game/prestige" data-game-action="prestige"><button type="submit" class="btn btn-sm btn-primary" data-confirm="Refactoring costs ${farm.refactor_cost}c and resets coins, level, CI, extra plots, and perks for a permanent +25% coin bonus. You carry over ${farm.refactor_carryover_preview}c and keep your Stars and Legacy upgrades. Continue?">Refactor (${farm.refactor_cost}c)</button></form>`
: "";
const prestigeText = farm.prestige_available
? "Reset your farm for a permanent +25% coin bonus."
: `Reach level ${farm.prestige_min_level} to refactor.`;
let prestigeText;
if (!farm.prestige_available) {
prestigeText = `Reach level ${farm.prestige_min_level} to refactor.`;
} else if (farm.refactor_affordable) {
prestigeText = `Pay ${farm.refactor_cost}c to reset your farm for a permanent +25% coin bonus. You keep ${farm.refactor_carryover_pct}% of what remains (${farm.refactor_carryover_preview}c) plus your Stars and Legacy upgrades.`;
} else {
prestigeText = `Refactoring costs ${farm.refactor_cost}c right now - keep farming to afford it.`;
}
return (
`<div class="shop-row"><div class="shop-info"><strong>CI upgrade</strong><span>${ciText}</span></div>${ciUpgrade}</div>` +
`<div class="shop-row"><div class="shop-info"><strong>Refactor (prestige ${farm.prestige})</strong><span>${prestigeText}</span></div>${prestigeBtn}</div>`
@ -226,6 +232,13 @@ export class GameFarm {
.join("");
}
_grantHtml(farm) {
const action = farm.grant_available
? `<form method="post" action="/game/grant" data-game-action="grant"><button type="submit" class="btn btn-sm btn-primary">Claim +${farm.grant_amount}c</button></form>`
: `<span class="daily-claimed">${farm.grant_reason}</span>`;
return `<p class="daily-streak">Treasury: <strong>${farm.treasury_balance}</strong>c collected from refactor fees</p>${action}`;
}
_dailyHtml(farm) {
const action = farm.daily_available
? `<form method="post" action="/game/daily" data-game-action="daily"><button type="submit" class="btn btn-sm btn-primary">Claim +${farm.daily_reward}c</button></form>`

View File

@ -12,11 +12,11 @@
<div class="shop-row">
<div class="shop-info">
<strong>Refactor (prestige {{ farm.prestige }})</strong>
<span>{% if farm.prestige_available %}Reset your farm for a permanent +25% coin bonus and earn Stars to spend on Legacy upgrades.{% else %}Reach level {{ farm.prestige_min_level }} to refactor.{% endif %}</span>
<span>{% if not farm.prestige_available %}Reach level {{ farm.prestige_min_level }} to refactor.{% elif farm.refactor_affordable %}Pay {{ farm.refactor_cost }}c to reset your farm for a permanent +25% coin bonus. You keep {{ farm.refactor_carryover_pct }}% of what remains ({{ farm.refactor_carryover_preview }}c) plus your Stars and Legacy upgrades.{% else %}Refactoring costs {{ farm.refactor_cost }}c right now - keep farming to afford it.{% endif %}</span>
</div>
{% if farm.prestige_available %}
{% if farm.refactor_affordable %}
<form method="post" action="/game/prestige" data-game-action="prestige">
<button type="submit" class="btn btn-sm btn-primary" data-confirm="Refactor resets coins, level, CI, extra plots, and perks for a permanent +25% coin bonus. You keep your Stars and Legacy upgrades and earn more Stars. Continue?">Refactor</button>
<button type="submit" class="btn btn-sm btn-primary" data-confirm="Refactoring costs {{ farm.refactor_cost }}c and resets coins, level, CI, extra plots, and perks for a permanent +25% coin bonus. You carry over {{ farm.refactor_carryover_preview }}c and keep your Stars and Legacy upgrades. Continue?">Refactor ({{ farm.refactor_cost }}c)</button>
</form>
{% endif %}
</div>

View File

@ -195,13 +195,26 @@ multiplies with the Optimizer perk. Refactoring is the long-term progression and
largest factor in the leaderboard. The state reports `prestige`, `prestige_multiplier`, and
`prestige_available`.
Refactoring is not free: it costs a **coin fee** that grows with both your prestige count and your
current coin balance (a wealth tax), so each refactor takes longer to afford than the last. The
live state reports the exact `refactor_cost` and whether you can pay it (`refactor_affordable`).
After the fee, **10%** of your remaining coins carry over into the new run - more with the Golden
Parachute Legacy upgrade (up to 35%), previewed as `refactor_carryover_preview`.
Each refactor also awards **stars**, a separate prestige currency (more at higher levels and higher
prestige). Stars are never reset and are spent on permanent **Legacy upgrades**.
## Community treasury and weekly grant
Every refactor fee flows into a shared **community treasury**. Once per week, an active farm that
is still building up - at least five harvests this week, fewer than 10,000 coins, and at most
prestige 5 - can claim a grant of up to 2,500 coins from it with `POST /game/grant`. The state
reports `grant_available`, `grant_amount`, `grant_reason`, and the current `treasury_balance`.
## Legacy upgrades
Stars buy Legacy upgrades, permanent boosts that persist through every future refactor. There are
five:
six:
| Legacy upgrade | Effect | Max level |
|----------------|--------|:---------:|
@ -210,6 +223,7 @@ five:
| 🏎️ Bare-Metal | +5% base build speed per level | 8 |
| 🗂️ Monorepo | +1 starting plot after each refactor per level | 4 |
| 🛡️ Branch Protection | +30s steal grace and -5% steal loss per level | 5 |
| 🪂 Golden Parachute | +5% refactor coin carry-over per level | 5 |
The live state reports your `stars` balance and each legacy upgrade's current level and the exact
star cost of its next level.
@ -273,7 +287,8 @@ your **API key** in an `X-API-KEY` header (or `Authorization: Bearer`). Your key
| `POST` | `/game/perk` | Upgrade `perk` (`yield`, `growth`, `discount`, `xp`). |
| `POST` | `/game/daily` | Claim the daily bonus. |
| `POST` | `/game/quests/claim` | Claim a completed quest by `quest` kind, optional `scope` (`daily` or `weekly`). |
| `POST` | `/game/prestige` | Refactor at level 10 or above. |
| `POST` | `/game/prestige` | Refactor at level 10 or above; costs the current `refactor_cost` in coins. |
| `POST` | `/game/grant` | Claim the weekly community grant from the treasury. |
| `POST` | `/game/legacy` | Buy a legacy upgrade (`key`) with stars. |
| `POST` | `/game/mastery` | Buy a Mastery upgrade (`key`) with Mastery points. |
| `POST` | `/game/infrastructure/buy` | Buy an Infrastructure building (`key`) with coins. |

View File

@ -100,6 +100,11 @@
<div data-daily-host>{% include "_game_daily.html" %}</div>
</section>
<section class="game-grant">
<h2 class="game-section-title">Community grant</h2>
<div data-grant-host>{% include "_game_grant.html" %}</div>
</section>
<section class="game-quests">
<h2 class="game-section-title">Quests &amp; contracts</h2>
<ul class="quest-list" data-quest-host>{% include "_game_quests.html" %}</ul>

View File

@ -6,7 +6,7 @@ from datetime import datetime, timezone, timedelta
import requests
from devplacepy.database import get_table, refresh_snapshot
from devplacepy.services.game import store
from devplacepy.services.game import economy, store
from tests.conftest import BASE_URL
JSON = {"Accept": "application/json"}
@ -337,3 +337,91 @@ def test_steal_cooldown_blocks_second_raid(app_server, seeded_db):
f"{BASE_URL}/game/farm/{owner}/steal", data={"slot": 0}, headers=JSON
)
assert second.status_code == 400
def test_prestige_insufficient_coins_returns_400(app_server, seeded_db):
session, name = _signup()
_reset_farm(name, coins=100)
_set_farm(name, xp=economy.xp_threshold(economy.PRESTIGE_MIN_LEVEL))
r = session.post(f"{BASE_URL}/game/prestige", headers=JSON)
assert r.status_code == 400
assert "costs" in r.json()["error"]["message"]
def test_prestige_charges_fee_and_carries_over(app_server, seeded_db):
session, name = _signup()
_reset_farm(name, coins=100000)
_set_farm(name, xp=economy.xp_threshold(economy.PRESTIGE_MIN_LEVEL))
fee = economy.refactor_cost(0, 100000)
carried = economy.refactor_carryover(100000, fee, 0)
r = session.post(f"{BASE_URL}/game/prestige", headers=JSON)
assert r.status_code == 200
farm = r.json()["farm"]
assert farm["prestige"] == 1
assert farm["coins"] == economy.STARTING_COINS + carried
refresh_snapshot()
assert store.treasury_balance() >= fee
def test_state_exposes_refactor_and_grant_fields(app_server, seeded_db):
session, name = _signup()
_reset_farm(name)
r = session.get(f"{BASE_URL}/game/state", headers=JSON)
assert r.status_code == 200
farm = r.json()["farm"]
for key in (
"refactor_cost",
"refactor_affordable",
"refactor_carryover_pct",
"refactor_carryover_preview",
"grant_available",
"grant_amount",
"grant_reason",
"treasury_balance",
):
assert key in farm
assert farm["refactor_cost"] == economy.refactor_cost(0, farm["coins"])
def test_grant_requires_auth(app_server, seeded_db):
r = requests.post(f"{BASE_URL}/game/grant", headers=JSON)
assert r.status_code in (401, 303)
def test_grant_claim_flow(app_server, seeded_db):
session, name = _signup()
_reset_farm(name, coins=100)
refresh_snapshot()
store.ensure_treasury()
get_table("game_treasury").update(
{"uid": "treasury-main", "balance": 100000}, ["uid"]
)
week = store._iso_week(store._now())
_set_farm(
name,
harvests_week=economy.GRANT_MIN_WEEK_HARVESTS,
harvests_week_start=week,
)
r = session.post(f"{BASE_URL}/game/grant", headers=JSON)
assert r.status_code == 200
assert r.json()["farm"]["coins"] == 100 + economy.GRANT_CAP
second = session.post(f"{BASE_URL}/game/grant", headers=JSON)
assert second.status_code == 400
def test_grant_ineligible_when_rich_returns_400(app_server, seeded_db):
session, name = _signup()
_reset_farm(name, coins=economy.GRANT_WEALTH_CEILING + 1)
refresh_snapshot()
store.ensure_treasury()
get_table("game_treasury").update(
{"uid": "treasury-main", "balance": 100000}, ["uid"]
)
week = store._iso_week(store._now())
_set_farm(
name,
harvests_week=economy.GRANT_MIN_WEEK_HARVESTS,
harvests_week_start=week,
)
r = session.post(f"{BASE_URL}/game/grant", headers=JSON)
assert r.status_code == 400

View File

@ -54,7 +54,7 @@ def test_admin_sees_revoke_button(alice, bob):
admin_page, _ = alice
member_page, _ = bob
admin_page.goto(f"{BASE_URL}/profile/bob_test?tab=awards", wait_until="domcontentloaded")
expect(admin_page.locator(AWARD_REVOKE)).to_be_visible()
expect(admin_page.locator(AWARD_REVOKE).first).to_be_visible()
member_page.goto(f"{BASE_URL}/profile/bob_test?tab=awards", wait_until="domcontentloaded")
expect(member_page.locator(AWARD_REVOKE)).to_have_count(0)

View File

@ -97,7 +97,7 @@ def test_member_sees_only_quota_percentage(bob):
assert (
page.locator(".ai-quota-only .ai-usage-title", has_text="AI quota").count() == 1
)
assert "%" in page.locator(".ai-quota-only .ai-quota-pct").inner_text()
assert "%" in page.locator(".ai-quota-only .ai-quota-pct").first.inner_text()
assert page.locator("[data-ai-usage]").count() == 0

View File

@ -324,7 +324,7 @@ def test_landing_authenticated_shows_dashboard(page, app_server):
assert page.url.rstrip("/") == BASE_URL.rstrip("/")
assert page.is_visible("text=Welcome back")
assert page.is_visible("text=home_user")
feed_cta = page.locator("a.landing-cta:has-text('Go to your feed')")
feed_cta = page.locator("a.dashboard-btn-primary:has-text('New Post')")
assert feed_cta.is_visible()
assert feed_cta.get_attribute("href") == "/feed"
assert not page.is_visible("text=Join DevPlace Free")

View File

@ -232,6 +232,8 @@ def test_prestige_resets_farm(alice):
reset_farm("alice_test")
set_farm_xp("alice_test", economy.xp_threshold(economy.PRESTIGE_MIN_LEVEL))
fee = economy.refactor_cost(0, 100000)
carried = economy.refactor_carryover(100000, fee, 0)
open_game(page)
prestige = page.locator("form[data-game-action='prestige'] button")
prestige.wait_for(state="visible")
@ -240,10 +242,49 @@ def test_prestige_resets_farm(alice):
confirm_btn.wait_for(state="visible")
confirm_btn.click()
expect(page.locator("[data-hud-prestige]")).to_have_text("1")
expect(page.locator("[data-hud-coins]")).to_have_text("50")
expect(page.locator("[data-hud-coins]")).to_have_text(
str(economy.STARTING_COINS + carried)
)
assert page.locator("form[data-game-action='prestige']").count() == 0
def test_prestige_unaffordable_shows_cost_without_button(alice):
page, _ = alice
from devplacepy.services.game import economy
reset_farm("alice_test", coins=100)
set_farm_xp("alice_test", economy.xp_threshold(economy.PRESTIGE_MIN_LEVEL))
open_game(page)
assert page.locator("form[data-game-action='prestige']").count() == 0
assert page.is_visible("text=keep farming to afford it")
def test_grant_section_claim(alice):
page, _ = alice
from devplacepy.database import get_table
from devplacepy.services.game import economy, store
from devplacepy.services.game.store import _iso_week, _now
reset_farm("alice_test", coins=100)
store.ensure_treasury()
get_table("game_treasury").update(
{"uid": "treasury-main", "balance": 100000}, ["uid"]
)
set_farm(
"alice_test",
harvests_week=economy.GRANT_MIN_WEEK_HARVESTS,
harvests_week_start=_iso_week(_now()),
)
open_game(page)
claim = page.locator("form[data-game-action='grant'] button")
claim.wait_for(state="visible")
claim.click()
expect(page.locator("[data-hud-coins]")).to_have_text(
str(100 + economy.GRANT_CAP)
)
assert page.locator("form[data-game-action='grant']").count() == 0
def test_leaderboard_panel_populates(alice):
page, _ = alice
reset_farm("alice_test")

View File

@ -399,3 +399,67 @@ def test_weekly_contract_deterministic():
assert first == second
assert first["kind"] in economy.QUEST_DEFS
assert first["reward_stars"] > 0
# --- refactor fee, carry-over, and grants -------------------------------------------
def test_refactor_cost_monotonic_in_prestige():
previous = 0
for prestige in range(0, 200):
cost = economy.refactor_cost(prestige, 0)
assert cost > previous
previous = cost
def test_refactor_cost_monotonic_in_wealth():
previous = -1
for coins in range(0, 2_000_000, 10_000):
cost = economy.refactor_cost(0, coins)
assert cost >= previous
previous = cost
def test_refactor_cost_floor_is_base():
assert economy.refactor_cost(0, 0) == economy.REFACTOR_BASE_COST
assert economy.refactor_cost(-5, -100) == economy.REFACTOR_BASE_COST
def test_refactor_cost_includes_wealth_tax():
coins = 100_000
expected = round(
economy.REFACTOR_BASE_COST + economy.REFACTOR_WEALTH_PCT * coins
)
assert economy.refactor_cost(0, coins) == expected
def test_refactor_carryover_fraction_bounds():
for level in range(0, 50):
fraction = economy.refactor_carryover_fraction(level)
assert economy.REFACTOR_CARRYOVER_BASE <= fraction <= economy.REFACTOR_CARRYOVER_MAX
def test_refactor_carryover_never_exceeds_remainder():
for coins in range(0, 500_000, 7_777):
for level in (0, 3, 5):
fee = economy.refactor_cost(0, coins)
carried = economy.refactor_carryover(coins, fee, level)
assert 0 <= carried <= max(0, coins - fee)
def test_refactor_carryover_zero_when_unaffordable():
assert economy.refactor_carryover(100, 20_000, 5) == 0
def test_grant_amount_capped_and_bounded():
assert economy.grant_amount(0) == 0
assert economy.grant_amount(-10) == 0
assert economy.grant_amount(100) == 100
assert economy.grant_amount(10**9) == economy.GRANT_CAP
def test_legacy_carryover_upgrade_registered():
upgrade = economy.legacy_for("carryover")
assert upgrade is not None
assert upgrade.max_level == 5
assert "carry-over" in economy.legacy_value_text(upgrade, 2)

View File

@ -45,6 +45,7 @@ def _reset(username, coins=100000):
get_table("game_steals").delete(owner_uid=user["uid"])
get_table("game_cosmetics").delete(user_uid=user["uid"])
get_table("game_market_ticks").delete()
get_table("game_treasury").delete()
_clear_game_caches()
farm = store.ensure_farm(user["uid"])
get_table("game_farms").update({"uid": farm["uid"], "coins": coins}, ["uid"])
@ -493,10 +494,15 @@ def test_prestige_resets_farm_and_increments(local_db):
ci_tier=3,
)
store.buy_plot(user)
coins_before = _coins(user)
fee = economy.refactor_cost(0, coins_before)
carried = economy.refactor_carryover(coins_before, fee, 0)
result = store.prestige(user)
assert result["prestige"] == 1
assert result["fee"] == fee
assert result["carried"] == carried
farm = store.get_farm(user["uid"])
assert farm["coins"] == economy.STARTING_COINS
assert farm["coins"] == economy.STARTING_COINS + carried
assert farm["xp"] == 0
assert farm["ci_tier"] == 1
assert farm["perk_growth"] == 0
@ -504,6 +510,45 @@ def test_prestige_resets_farm_and_increments(local_db):
assert len(store.get_plots(farm["uid"])) == economy.STARTING_PLOTS
def test_prestige_insufficient_coins_blocked(local_db):
user = _reset("unit_a", coins=100)
_set(user, xp=economy.xp_threshold(economy.PRESTIGE_MIN_LEVEL))
with pytest.raises(GameError):
store.prestige(user)
farm = store.get_farm(user["uid"])
assert int(farm["prestige"] or 0) == 0
assert int(farm["coins"]) == 100
def test_prestige_fee_funds_treasury(local_db):
user = _reset("unit_a")
_set(user, xp=economy.xp_threshold(economy.PRESTIGE_MIN_LEVEL))
fee = economy.refactor_cost(0, _coins(user))
store.prestige(user)
assert store.treasury_balance() == fee
def test_prestige_carryover_scales_with_legacy(local_db):
user = _reset("unit_a")
_set(
user,
xp=economy.xp_threshold(economy.PRESTIGE_MIN_LEVEL),
legacy_carryover=5,
)
coins_before = _coins(user)
fee = economy.refactor_cost(0, coins_before)
carried = economy.refactor_carryover(coins_before, fee, 5)
assert carried > economy.refactor_carryover(coins_before, fee, 0)
store.prestige(user)
farm = store.get_farm(user["uid"])
assert farm["coins"] == economy.STARTING_COINS + carried
def test_prestige_fee_rises_with_prestige_and_wealth(local_db):
assert economy.refactor_cost(1, 0) > economy.refactor_cost(0, 0)
assert economy.refactor_cost(0, 1_000_000) > economy.refactor_cost(0, 0)
# --- legacy upgrades ---------------------------------------------------------
@ -851,7 +896,8 @@ def test_farm_analytics_tracks_lifetime_totals_once_unlocked(local_db):
def test_prestige_awards_mastery_at_threshold(local_db):
user = _reset("unit_a")
_set(user, xp=economy.xp_threshold(economy.PRESTIGE_MIN_LEVEL), prestige=49)
coins = economy.refactor_cost(49, 0) * 2
_set(user, xp=economy.xp_threshold(economy.PRESTIGE_MIN_LEVEL), prestige=49, coins=coins)
result = store.prestige(user)
assert result["mastery_awarded"] == 1
farm = store.get_farm(user["uid"])
@ -946,3 +992,87 @@ def test_weekly_contract_claim_pays_stars_and_boost(local_db):
farm = store.get_farm(user["uid"])
assert int(farm["stars"]) == result["reward_stars"]
assert farm.get("contract_boost_until")
# --- treasury and community grant --------------------------------------------
def _seed_treasury(amount):
store.ensure_treasury()
get_table("game_treasury").update(
{"uid": "treasury-main", "balance": amount}, ["uid"]
)
def _make_grant_eligible(user):
_set(
user,
coins=100,
harvests_week=economy.GRANT_MIN_WEEK_HARVESTS,
harvests_week_start=store._iso_week(store._now()),
)
def test_claim_grant_pays_from_treasury(local_db):
user = _reset("unit_a")
_seed_treasury(100_000)
_make_grant_eligible(user)
result = store.claim_grant(user)
assert result["amount"] == economy.GRANT_CAP
assert _coins(user) == 100 + economy.GRANT_CAP
assert store.treasury_balance() == 100_000 - economy.GRANT_CAP
def test_claim_grant_once_per_week(local_db):
user = _reset("unit_a")
_seed_treasury(100_000)
_make_grant_eligible(user)
store.claim_grant(user)
_set(user, coins=100)
with pytest.raises(GameError):
store.claim_grant(user)
def test_claim_grant_requires_activity(local_db):
user = _reset("unit_a")
_seed_treasury(100_000)
_set(user, coins=100, harvests_week=economy.GRANT_MIN_WEEK_HARVESTS - 1)
with pytest.raises(GameError):
store.claim_grant(user)
def test_claim_grant_wealth_ceiling(local_db):
user = _reset("unit_a")
_seed_treasury(100_000)
_set(
user,
coins=economy.GRANT_WEALTH_CEILING,
harvests_week=economy.GRANT_MIN_WEEK_HARVESTS,
)
with pytest.raises(GameError):
store.claim_grant(user)
def test_claim_grant_prestige_cap(local_db):
user = _reset("unit_a")
_seed_treasury(100_000)
_make_grant_eligible(user)
_set(user, prestige=economy.GRANT_MAX_PRESTIGE + 1)
with pytest.raises(GameError):
store.claim_grant(user)
def test_claim_grant_empty_treasury(local_db):
user = _reset("unit_a")
_make_grant_eligible(user)
with pytest.raises(GameError):
store.claim_grant(user)
def test_claim_grant_partial_treasury(local_db):
user = _reset("unit_a")
_seed_treasury(300)
_make_grant_eligible(user)
result = store.claim_grant(user)
assert result["amount"] == 300
assert store.treasury_balance() == 0