Merge pull request 'full-test-suite-fixes' (#117) from full-test-suite-fixes into master
Some checks are pending
DevPlace CI / test (push) Waiting to run

Reviewed-on: #117
This commit is contained in:
retoor 2026-07-23 00:06:35 +02:00
commit cab603dd6b
83 changed files with 4964 additions and 215 deletions

View File

@ -30,7 +30,7 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
- **F. Verify your own work.** After writing a test module, validate it ONLY by a clean import (`python -c "import tests..."` or `python -m py_compile`). - **F. Verify your own work.** After writing a test module, validate it ONLY by a clean import (`python -c "import tests..."` or `python -m py_compile`).
## Mode ## Mode
Default to **REPORT** mode: record coverage gaps and pattern violations, do NOT write files. Apply **FIX** mode only when the invocation explicitly asks you to fix; then write the missing integration test following the required patterns. **HARD GUARDRAIL: write tests but NEVER run the suite, not the full suite and not a single file.** Validate only by a clean import of the new test module. Never perform any git write operation. Default to **REPORT** mode: record coverage gaps and pattern violations, do NOT write files. Apply **FIX** mode only when the invocation explicitly asks you to fix; then write the missing integration test following the required patterns. **HARD GUARDRAIL: write tests but NEVER launch the suite yourself, not the full suite and not a single file - the serial single-process suite cannot run concurrently with other agents, so the orchestrating session runs `make test` (all tests) after your work.** Validate only by a clean import of the new test module. Never perform any git write operation.
## Obey the rules you enforce ## Obey the rules you enforce
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create. No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.

View File

@ -1,5 +1,5 @@
--- ---
description: Run DevPlace tests - the sanctioned explicit-ask path. Run a tier, a file, or a single test with the correct flags. The agents never run tests themselves; this command is how you ask. description: Run DevPlace tests. Run a tier, a file, or a single test with the correct flags. Subagents never run tests themselves (the serial suite cannot run concurrently); the orchestrating session always runs the full suite as the final validation of every change.
argument-hint: [unit|api|e2e|all|<path::test_name>] argument-hint: [unit|api|e2e|all|<path::test_name>]
allowed-tools: Bash(make test*), Bash(python -m pytest *), Read allowed-tools: Bash(make test*), Bash(python -m pytest *), Read
--- ---
@ -12,6 +12,6 @@ Mapping:
- `all` or empty -> `make test` - `all` or empty -> `make test`
- a path like `tests/api/posts/create.py::test_x` -> `python -m pytest <that> -v --tb=line -x` - a path like `tests/api/posts/create.py::test_x` -> `python -m pytest <that> -v --tb=line -x`
Tests run serially on port 10501 with a tempfile SQLite DB and `DEVPLACE_DISABLE_SERVICES=1`. This command is the one sanctioned way to run them (the subagents and workflows never do). Tests run serially on port 10501 with a tempfile SQLite DB and `DEVPLACE_DISABLE_SERVICES=1`. Subagents and workflows never launch tests (the serial single-process suite cannot run concurrently); the orchestrating session runs the full suite (`make test`) as the mandatory final validation of every change.
Report results clearly. On a failure, show the relevant output, and if a browser (e2e) test failed, point me at the screenshot under `/tmp/devplace_test_screenshots/`. Never weaken a test to make it pass; if a test reveals a real bug, report it - do not edit the test. Report results clearly. On a failure, show the relevant output, and if a browser (e2e) test failed, point me at the screenshot under `/tmp/devplace_test_screenshots/`. Never weaken a test to make it pass; if a test reveals a real bug, report it - do not edit the test.

View File

@ -31,7 +31,7 @@ make locust-headless # Locust CLI mode for CI
The Makefile exports `PYTHONDONTWRITEBYTECODE=1` for every recipe, so no `.pyc` files or `__pycache__` directories are written by any `make` target. Keep it that way - do not add a target that re-enables bytecode writing. `make clean` removes any stray bytecode left from running Python outside make. The Makefile exports `PYTHONDONTWRITEBYTECODE=1` for every recipe, so no `.pyc` files or `__pycache__` directories are written by any `make` target. Keep it that way - do not add a target that re-enables bytecode writing. `make clean` removes any stray bytecode left from running Python outside make.
Validate code without running the suite: confirm `python -c "from devplacepy.main import app"` imports clean and check each touched language manually (Python compiles/imports, JS parses, CSS braces and HTML tags balance). Preliminary validation: confirm `python -c "from devplacepy.main import app"` imports clean and check each touched language manually (Python compiles/imports, JS parses, CSS braces and HTML tags balance). These checks are gates on the way to the real validation, never a substitute for it: **every change ends with the full test suite (`make test` - all three tiers, every test) and it must pass.**
Single test: `python -m pytest tests/e2e/feed.py::test_name -v --tb=line -x` Single test: `python -m pytest tests/e2e/feed.py::test_name -v --tb=line -x`
@ -53,10 +53,14 @@ devplace attachments prune # remove orphan attachment records/files
devplace devii reset-quota <username> # reset one user's rolling 24h AI quota devplace devii reset-quota <username> # reset one user's rolling 24h AI quota
devplace devii reset-quota --guests # reset every guest quota devplace devii reset-quota --guests # reset every guest quota
devplace devii reset-quota --all # reset every quota (users and guests) devplace devii reset-quota --all # reset every quota (users and guests)
devplace gateway quota list # list AI gateway quota rules and current 24h spend
devplace gateway quota set --limit-usd N [--owner-kind K] [--owner-id ID] [--app-reference APP] [--label L] [--uid UID]
devplace gateway quota delete <uid> # delete a quota rule
devplace zips prune # delete expired zip archives + job rows devplace zips prune # delete expired zip archives + job rows
devplace zips clear # delete every zip archive + job row devplace zips clear # delete every zip archive + job row
devplace forks prune # delete expired completed fork job rows (forked projects persist) devplace forks prune # delete expired completed fork job rows (forked projects persist)
devplace forks clear # delete every fork job row (forked projects persist) devplace forks clear # delete every fork job row (forked projects persist)
devplace messaging prune-tickets # delete expired WebSocket auth tickets (ws_tickets)
devplace seo prune # delete expired SEO audit reports + job rows devplace seo prune # delete expired SEO audit reports + job rows
devplace seo clear # delete every SEO audit report + job row devplace seo clear # delete every SEO audit report + job row
devplace seo-meta prune # delete expired SEO metadata job rows (generated metadata persists) devplace seo-meta prune # delete expired SEO metadata job rows (generated metadata persists)
@ -281,7 +285,18 @@ Fixture stack: `app_server` (session-scoped uvicorn subprocess on port 10501), `
Required patterns: every `page.goto(...)`/`page.wait_for_url(...)` MUST pass `wait_until="domcontentloaded"`; prefer `page.locator(...).wait_for(state="visible")` over `wait_for_selector`; scope ambiguous selectors (e.g. comment Delete is `.comment-action-btn:has-text('Delete')`). A test that flips a global `site_settings` value MUST restore it in `try/finally`. Full pitfalls/patterns catalogue is in `tests/CLAUDE.md`. Required patterns: every `page.goto(...)`/`page.wait_for_url(...)` MUST pass `wait_until="domcontentloaded"`; prefer `page.locator(...).wait_for(state="visible")` over `wait_for_selector`; scope ambiguous selectors (e.g. comment Delete is `.comment-action-btn:has-text('Delete')`). A test that flips a global `site_settings` value MUST restore it in `try/finally`. Full pitfalls/patterns catalogue is in `tests/CLAUDE.md`.
**Never run tests unless the user explicitly asks for it.** Not the full suite, not a single file. Validate with a clean import (`python -c "from devplacepy.main import app"`) and per-language manual checks instead. **Always run the full test suite (`make test` - unit, api, and e2e, every test) as the final validation of every change.** No tier may be skipped and no subset substituted for the whole. The clean import (`python -c "from devplacepy.main import app"`) and per-language manual checks are preliminary gates before the suite, not replacements for it. Any failure is a real signal and blocks completion until fixed.
## Rigorous correctness verification (money, state machines, concurrency)
The persisted test suite (`tests/unit`/`api`/`e2e`) is example-based: it proves the specific inputs someone wrote down behave correctly. It is structurally blind to three classes of bug - a formula wrong at an input nobody tried, an invariant violated only after a long sequence of actions, and a race that only two nearly-simultaneous requests trigger. **Any feature that touches a spendable resource (coins, credits, quotas), a bounded state machine (levels, tiers, counters with a floor/ceiling), or a read-then-write mutation reachable from more than one request path applies this procedure in addition to, not instead of, the normal persisted tests.** A simple CRUD toggle doesn't need it; an economy, inventory, ledger, or scoring feature does. This was built out fully for the Code Farm economy rebalance (see `devplacepy/services/game/CLAUDE.md`, "Every purchase/upgrade is atomic..." - the worked example, including every bug it actually caught) and is the standing procedure for anything shaped like it going forward.
Four escalating layers, run in order, as disposable Python scripts that call the real functions directly against a temp DB (`DEVPLACE_DATABASE_URL`/`DEVPLACE_DATA_DIR` pointed at a scratch file) - not pytest files, unless the user separately asks for persisted tests too:
1. **Property/invariant checks on every pure function, across the full input domain.** For each formula (a reward, a cost curve, a score, a discount), assert the mathematical property it must have - monotonicity, bounds, non-negativity, idempotency, additivity over a partition of its domain - by iterating the real range (hundreds to thousands of values), not a handful of hand-picked spot checks. A monotonicity bug at input 347 is invisible if only 0, 1, and 10 are tried.
2. **Stateful fuzzing.** Build N independent entities, fire a long randomized sequence of every mutating action across all of them against a live temp DB, and re-check invariants after every single action: balances never negative, counters that should only increase never decrease, levels/tiers never exceed their declared bounds. Catch the domain's expected exception type and continue - the goal is catching invariant violations and *unexpected* exceptions, not asserting every action succeeds. This proves safety (nothing bad happens) but not liveness (legitimate actions actually succeed) - a bug that wrongly blocks a valid action just raises a well-formed expected exception and sails through a pure fuzz test undetected. Layer 3 exists because of exactly this gap.
3. **Concurrency: prove races are closed, never infer it from luck.** Any read-then-write mutation reachable from more than one worker process (`uvicorn --workers N`) is a TOCTOU race until proven otherwise. Test with **real separate OS processes**, not threads in one process - `dataset` gives each thread its own pooled connection, and enough threads exhausts that pool and produces `database is locked` noise that is a test-harness artifact, not a finding about the application. Set up genuinely fresh, production-representative state before racing - **never pre-seed or zero a column that the real code path leaves unset/NULL.** This was the single most expensive mistake made building this procedure: a first race-safety pass "passed" only because its own setup script had artificially pre-zeroed columns that a real fresh row leaves as SQL `NULL` - silently hiding the exact bug the fix was supposed to prevent (`NULL = 0` evaluates to `NULL`, not true, in a SQL `WHERE` clause; any column not written in the row's original `INSERT` needs `COALESCE(column, 0)` in every later precondition and every arithmetic `SET`, not a bare comparison). Fire many concurrent attempts at the same resource, assert the exact right number succeed, and verify the final state matches the hand-computed expected total exactly (currency spent, levels advanced) - not just "the others were blocked." Fix a real race with a single atomic conditional SQL statement at the exact chokepoint (`UPDATE ... SET ... WHERE <precondition>`, checked via the driver's real `rowcount` - `dataset`'s wrapped `db.query()` does not expose it, use `db.executable.execute(sqlalchemy.text(...), params).rowcount` inside `with db:`), not a client-side lock or an optimistic-locking library this codebase doesn't otherwise use.
4. **Static analysis beyond `py_compile`.** A clean `python -m py_compile` and a clean `from devplacepy.main import app` only prove syntax and module-load order - neither catches a missing import inside a function body, which is a runtime `NameError` invisible until that exact line executes. Run `pyflakes`/`ruff check` on every touched file before calling a change done; it is nearly free and catches an entire class of bug that layers 1-3 can each individually miss if they don't happen to exercise the broken line.
## Feature workflow ## Feature workflow
@ -300,7 +315,7 @@ A new public read almost always needs all four. The cardinal failure mode is cha
4. **View layer.** Extend `base.html`; page CSS in `{% block extra_head %}`, page JS in `{% block extra_js %}`. Reuse partials and template globals. JS is ES6 modules, one class per file, instantiated on `app`. 4. **View layer.** Extend `base.html`; page CSS in `{% block extra_head %}`, page JS in `{% block extra_js %}`. Reuse partials and template globals. JS is ES6 modules, one class per file, instantiated on `app`.
5. **Agent + docs layer (the most-forgotten step - do not skip).** `services/devii/actions/catalog.py` - add an `Action` tool if a user could ask Devii to do this (`requires_auth=False` for public reads). `docs_api.py` - every public/auth endpoint gets an `endpoint()` entry with params and a `sample_response`. `routers/docs/pages.py` `DOCS_PAGES` for prose pages. SEO (`seo.py`) - `base_seo_context` and any JSON-LD for a new public page; add to `routers/seo.py` sitemap if indexable. 5. **Agent + docs layer (the most-forgotten step - do not skip).** `services/devii/actions/catalog.py` - add an `Action` tool if a user could ask Devii to do this (`requires_auth=False` for public reads). `docs_api.py` - every public/auth endpoint gets an `endpoint()` entry with params and a `sample_response`. `routers/docs/pages.py` `DOCS_PAGES` for prose pages. SEO (`seo.py`) - `base_seo_context` and any JSON-LD for a new public page; add to `routers/seo.py` sitemap if indexable.
6. **Document.** `README.md` (product-facing, routes/config/dependencies/user-visible features), the relevant nested `CLAUDE.md` (any new mechanic, helper, pitfall, or cross-layer wiring), this root `CLAUDE.md` only when a NEW architectural rule or workflow step is introduced (not per feature). 6. **Document.** `README.md` (product-facing, routes/config/dependencies/user-visible features), the relevant nested `CLAUDE.md` (any new mechanic, helper, pitfall, or cross-layer wiring), this root `CLAUDE.md` only when a NEW architectural rule or workflow step is introduced (not per feature).
7. **Validate.** Confirm a clean import (`python -c "from devplacepy.main import app"`), check each touched language manually, grep for em-dash in touched files (neither the character nor its HTML entity - use a hyphen). **Never run the test suite unless the user explicitly asks.** Write new tests in the matching tier/path when asked, following the required patterns above. 7. **Validate.** Confirm a clean import (`python -c "from devplacepy.main import app"`), check each touched language manually, grep for em-dash in touched files (neither the character nor its HTML entity - use a hyphen), and run `pyflakes`/`ruff check` on every touched file. **If the feature touches a spendable resource, a bounded state machine, or a read-then-write mutation reachable from more than one request path, also run the four-layer procedure in "Rigorous correctness verification" above** - it is the default for that shape of feature, not an optional extra. **Finish by running the full persisted test suite (`make test` - every tier, every test) and fixing any failure before the change is done.** Write new tests in the matching tier/path, following the required patterns above.
Failures at any implementation step block the workflow - never skip a failed step. Failures at any implementation step block the workflow - never skip a failed step.
@ -315,3 +330,4 @@ Single-host Docker Compose (`docker-compose.yml`): an **app** container (Uvicorn
- **Shared DB and files = same as dev.** The app container bind-mounts the host project root (`.:/app`) and runs as `${DEVPLACE_UID}:${DEVPLACE_GID}` (default `1000`), so it reads/writes the same `data/devplace.db`, `data/uploads/`, `data/devii_*.db`, `data/keys/` (VAPID), and `data/locks/devplace-services.lock` as `make dev`. No `DEVPLACE_DATABASE_URL` override - `config.py` resolves an absolute path under the project's `data/` dir. WAL + the `flock` on `devplace-services.lock` make concurrent dev/prod safe and keep a single background-services owner. SQLite is local-file, so prod and dev must be the **same host**. - **Shared DB and files = same as dev.** The app container bind-mounts the host project root (`.:/app`) and runs as `${DEVPLACE_UID}:${DEVPLACE_GID}` (default `1000`), so it reads/writes the same `data/devplace.db`, `data/uploads/`, `data/devii_*.db`, `data/keys/` (VAPID), and `data/locks/devplace-services.lock` as `make dev`. No `DEVPLACE_DATABASE_URL` override - `config.py` resolves an absolute path under the project's `data/` dir. WAL + the `flock` on `devplace-services.lock` make concurrent dev/prod safe and keep a single background-services owner. SQLite is local-file, so prod and dev must be the **same host**.
- **Code updates need no rebuild** (bind-mounted source); rebuild only on `pyproject.toml` dependency changes. `.env` is git-ignored; `.env.example` is the committed template. The app reads `DEVPLACE_DATABASE_URL`, never `DATABASE_URL`. - **Code updates need no rebuild** (bind-mounted source); rebuild only on `pyproject.toml` dependency changes. `.env` is git-ignored; `.env.example` is the committed template. The app reads `DEVPLACE_DATABASE_URL`, never `DATABASE_URL`.
- **nginx parity rules** (`nginx/nginx.conf.template`, rendered by `start.sh` via `envsubst` with an allow-list that preserves `$http_upgrade`): `/static/uploads/` must re-apply `nosniff` + a `Content-Disposition` via the `map $uri $upload_disposition` block (`inline` for safe image/video/audio extensions, `attachment` otherwise), mirroring `UploadStaticFiles.INLINE_MEDIA_EXTENSIONS` - an XSS control nginx would otherwise bypass, and the inline branch is what lets video play in production; `/devii/ws` needs the `map $http_upgrade $connection_upgrade` block and `Upgrade`/`Connection` headers or the Devii terminal cannot connect (every new WebSocket route needs its own nginx upgrade location - the catch-all `location /` strips upgrade headers); `client_max_body_size` comes from `NGINX_MAX_BODY_SIZE` (default `50m`) and must be `>= max_upload_size_mb` or uploads 413. nginx serves `devplacepy/static` via a read-only bind mount, so assets stay current without an image rebuild. - **nginx parity rules** (`nginx/nginx.conf.template`, rendered by `start.sh` via `envsubst` with an allow-list that preserves `$http_upgrade`): `/static/uploads/` must re-apply `nosniff` + a `Content-Disposition` via the `map $uri $upload_disposition` block (`inline` for safe image/video/audio extensions, `attachment` otherwise), mirroring `UploadStaticFiles.INLINE_MEDIA_EXTENSIONS` - an XSS control nginx would otherwise bypass, and the inline branch is what lets video play in production; `/devii/ws` needs the `map $http_upgrade $connection_upgrade` block and `Upgrade`/`Connection` headers or the Devii terminal cannot connect (every new WebSocket route needs its own nginx upgrade location - the catch-all `location /` strips upgrade headers); `client_max_body_size` comes from `NGINX_MAX_BODY_SIZE` (default `50m`) and must be `>= max_upload_size_mb` or uploads 413. nginx serves `devplacepy/static` via a read-only bind mount, so assets stay current without an image rebuild.
- **Healthcheck start period** (`start_period: 120s` in `docker-compose.yml`, `--start-period=120s` in `Dockerfile`): full startup takes ~110s (DB init, services, uvicorn workers). The start period must stay above that. Bump both files if startup grows.

View File

@ -33,7 +33,7 @@ EXPOSE 10500
ENV DEVPLACE_WEB_WORKERS=2 ENV DEVPLACE_WEB_WORKERS=2
ENV DEVPLACE_TEMPLATE_AUTO_RELOAD=0 ENV DEVPLACE_TEMPLATE_AUTO_RELOAD=0
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=10s \ HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=120s \
CMD curl -f http://localhost:10500/ || exit 1 CMD curl -f http://localhost:10500/ || exit 1
CMD ["sh", "-c", "DEVPLACE_STATIC_VERSION=${DEVPLACE_STATIC_VERSION:-$(date +%s)} exec uvicorn devplacepy.main:app --host 0.0.0.0 --port 10500 --workers 2 --backlog 8192 --proxy-headers --forwarded-allow-ips '*'"] CMD ["sh", "-c", "DEVPLACE_STATIC_VERSION=${DEVPLACE_STATIC_VERSION:-$(date +%s)} exec uvicorn devplacepy.main:app --host 0.0.0.0 --port 10500 --workers 2 --backlog 8192 --proxy-headers --forwarded-allow-ips '*'"]

View File

@ -129,10 +129,16 @@ The **Code Farm** (`/game`) is a cooperative idle game in the spirit of Farmvill
- **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. - **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.
- **Golden builds.** A small share of plantings come out golden (marked with a sparkle); harvesting a golden build pays several times the coins. - **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. - **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) to harvest it first. A successful steal pays the thief half 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**, so no one has to babysit their farm against constant theft. Stealing pays coins only, so the leaderboard stays earned by real farming. This is the competitive counterpart to watering. - **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.
- **Leaderboard.** Top farmers are ranked by a composite achievement score that weighs every factor the game tracks - refactor (prestige) count, XP, lifetime harvests, current coins, CI tier, plots bought, perk levels, and login streak - so total accomplishment decides position rather than just the current post-refactor cycle. The score is shown alongside your own farm next to each player's level. - **Market Saturation.** The last 48 hours of league-wide harvests of each crop are tracked; when a crop is over-farmed its payout drops in steps (down to 40%), while the four starter crops get a relief buff (up to +15%) while the market is saturated - printing one crop nonstop is throttled, diversity is rewarded. The shop shows a live "Saturated" / "Boosted" label per crop.
- **Infrastructure.** Permanent, expensive, prestige-gated buildings and coin sinks: **Private Registry** (faster Rust/Compiler/Kernel builds), **Canary Deployments** (a chance to double or only refund a harvest), and **Observability Suite** (raises the minimum you keep when raided).
- **Defense.** An upgradeable building that reduces raid losses and adds steal grace - but costs an ongoing daily coin upkeep (proportional to your coin balance, so it scales with wealth); if unpaid, the tier decays automatically.
- **Cosmetics.** Purely cosmetic titles and plot skins, bought with coins - zero gameplay effect, pure status. An equipped title shows next to your name on the leaderboard.
- **Mastery (endgame beyond prestige).** From prestige 50 onward, every 10 more prestige earns a permanent Mastery point (spendable, and the milestone itself never re-locks). Mastery upgrades open new gameplay instead of bigger numbers: **Continuous Delivery** (auto-replant after harvest), **Farm Analytics** (lifetime stats on your HUD), and **Legacy Contracts** (a weekly long-term contract slot paying Stars and a temporary coin boost). Reaching Mastery also unlocks three new high-tier crop families (Distributed System, ML Pipeline, Security Fortress - the last one immune to raids).
- **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`). 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_upgrade_mastery`, `game_buy_infrastructure`, `game_upgrade_defense`, `game_buy_cosmetic`, `game_equip_cosmetic`). See the API reference group **Code Farm**.
## Engagement ## Engagement

84
devplacepy/cli/game.py Normal file
View File

@ -0,0 +1,84 @@
# retoor <retoor@molodetz.nl>
from devplacepy.cli._shared import _audit_cli
def cmd_game_market_prune(args):
from devplacepy.services.game import store
removed = store.prune_ticks()
_audit_cli(
"cli.game.market.prune",
f"CLI pruned {removed} stale Code Farm market tick(s)",
metadata={"count": removed},
)
print(f"Pruned {removed} stale market tick bucket(s)")
def cmd_game_era_status(args):
from devplacepy.services.game import store
era = store.active_era()
if not era:
print("No Era is currently running.")
return
print(f"Era {era['era_number']}: {era['name']}")
print(f"Started: {era['started_at']}")
print(f"Scheduled end: {era['ends_at']}")
def cmd_game_era_start(args):
from devplacepy.services.game import GameError, store
try:
era = store.start_era(args.name, args.duration_days)
except GameError as exc:
print(f"Error: {exc}")
return
_audit_cli(
"cli.game.era.start",
f"CLI started Code Farm Era {era['era_number']}: {era['name']}",
metadata={"era_number": era["era_number"], "name": era["name"]},
)
print(f"Started Era {era['era_number']}: {era['name']}")
def cmd_game_era_end(args):
from devplacepy.services.game import GameError, store
try:
result = store.end_era()
except GameError as exc:
print(f"Error: {exc}")
return
_audit_cli(
"cli.game.era.end",
f"CLI ended Code Farm Era {result['era_number']}",
metadata=result,
)
print(f"Ended Era {result['era_number']} ({result['participants']} participant(s) ranked)")
def register_game(subparsers):
game = subparsers.add_parser("game", help="Code Farm management")
game_sub = game.add_subparsers(title="action", dest="action")
market = game_sub.add_parser("market", help="Code Farm market saturation data")
market_sub = market.add_subparsers(title="market_action", dest="market_action")
market_prune = market_sub.add_parser(
"prune", help="Delete market tick buckets older than the tracking window"
)
market_prune.set_defaults(func=cmd_game_market_prune)
era = game_sub.add_parser("era", help="Code Farm Era management")
era_sub = era.add_subparsers(title="era_action", dest="era_action")
era_status = era_sub.add_parser("status", help="Show the current Era status")
era_status.set_defaults(func=cmd_game_era_status)
era_start = era_sub.add_parser("start", help="Start a new Era")
era_start.add_argument("name", help="Era name")
era_start.add_argument(
"--days", dest="duration_days", type=int, default=28, help="Planned Era length in days"
)
era_start.set_defaults(func=cmd_game_era_start)
era_end = era_sub.add_parser("end", help="End the currently running Era")
era_end.set_defaults(func=cmd_game_era_end)

103
devplacepy/cli/gateway.py Normal file
View File

@ -0,0 +1,103 @@
# retoor <retoor@molodetz.nl>
import sys
from devplacepy.cli._shared import _audit_cli
def cmd_gateway_quota_list(args):
from devplacepy.services.openai_gateway import quota
rules = quota.quota_rule_store.list()
if not rules:
print("No quota rules. Every caller is capped by the global defaults on /admin/services/openai.")
return
for rule in rules:
spent = quota.spent_24h(rule["owner_kind"], rule["owner_id"], rule["app_reference"])
scope = ", ".join(
f"{key}={rule[key]}" for key in ("owner_kind", "owner_id", "app_reference") if rule[key]
) or "(no dimensions - invalid)"
limit = "unlimited" if rule["limit_usd"] == 0 else f"${rule['limit_usd']:.2f}/24h"
active = "active" if rule["is_active"] else "inactive"
label = f" - {rule['label']}" if rule["label"] else ""
print(f"{rule['uid']} [{scope}] {limit} spent=${spent:.4f} {active}{label}")
def cmd_gateway_quota_set(args):
from pydantic import ValidationError
from devplacepy.services.openai_gateway import quota
try:
payload = quota.QuotaRuleIn(
owner_kind=args.owner_kind,
owner_id=args.owner_id,
app_reference=args.app_reference,
limit_usd=args.limit_usd,
is_active=not args.inactive,
label=args.label or "",
)
except ValidationError as exc:
print(f"Invalid rule: {exc.errors()[0].get('msg', exc)}")
sys.exit(1)
saved = quota.quota_rule_store.set(payload, uid=args.uid, created_by="cli")
_audit_cli(
"gateway.quota_rule.update",
f"CLI saved gateway quota rule {saved['uid']}",
metadata={
"owner_kind": saved["owner_kind"],
"owner_id": saved["owner_id"],
"app_reference": saved["app_reference"],
"limit_usd": saved["limit_usd"],
},
target_type="gateway_quota_rule",
target_uid=saved["uid"],
)
print(f"Saved quota rule {saved['uid']}")
def cmd_gateway_quota_delete(args):
from devplacepy.services.openai_gateway import quota
if not quota.quota_rule_store.remove(args.uid):
print(f"Quota rule '{args.uid}' not found")
sys.exit(1)
_audit_cli(
"gateway.quota_rule.delete",
f"CLI deleted gateway quota rule {args.uid}",
target_type="gateway_quota_rule",
target_uid=args.uid,
)
print(f"Deleted quota rule {args.uid}")
def register_gateway(subparsers):
gateway = subparsers.add_parser("gateway", help="AI gateway management")
gateway_sub = gateway.add_subparsers(title="action", dest="action")
quota = gateway_sub.add_parser("quota", help="Manage rolling-24h AI gateway quota rules")
quota_sub = quota.add_subparsers(title="sub-action", dest="sub_action")
quota_list = quota_sub.add_parser("list", help="List all quota rules and their current 24h spend")
quota_list.set_defaults(func=cmd_gateway_quota_list)
quota_set = quota_sub.add_parser(
"set", help="Create or update a quota rule (scope by role/user/app, any combination)"
)
quota_set.add_argument("--uid", help="Existing rule uid to update; omit to create a new rule")
quota_set.add_argument(
"--owner-kind",
choices=("internal", "key", "user", "admin", "anonymous"),
help="Role to scope by. Omit for any role",
)
quota_set.add_argument("--owner-id", help="Specific user uid to scope by. Omit for any caller")
quota_set.add_argument("--app-reference", help="App label to scope by. Omit for any app")
quota_set.add_argument(
"--limit-usd", type=float, required=True, help="Rolling 24h USD cap (0 = unlimited)"
)
quota_set.add_argument("--label", help="Optional admin-facing note")
quota_set.add_argument("--inactive", action="store_true", help="Create the rule disabled")
quota_set.set_defaults(func=cmd_gateway_quota_set)
quota_delete = quota_sub.add_parser("delete", help="Delete a quota rule by uid")
quota_delete.add_argument("uid", help="Quota rule uid")
quota_delete.set_defaults(func=cmd_gateway_quota_delete)

View File

@ -12,6 +12,8 @@ from devplacepy.cli.jobs import register_jobs
from devplacepy.cli.backups import register_backups from devplacepy.cli.backups import register_backups
from devplacepy.cli.containers import register_containers from devplacepy.cli.containers import register_containers
from devplacepy.cli.migrate import register_migrate from devplacepy.cli.migrate import register_migrate
from devplacepy.cli.game import register_game
from devplacepy.cli.gateway import register_gateway
def build_parser(): def build_parser():
@ -28,6 +30,8 @@ def build_parser():
register_backups(sub) register_backups(sub)
register_containers(sub) register_containers(sub)
register_migrate(sub) register_migrate(sub)
register_game(sub)
register_gateway(sub)
return parser return parser

View File

@ -18,6 +18,7 @@ NOTIFICATION_TYPES = [
{"key": "reminder", "label": "Reminders", "description": "A reminder or scheduled task you asked Devii to run fires"}, {"key": "reminder", "label": "Reminders", "description": "A reminder or scheduled task you asked Devii to run fires"},
{"key": "harvest_stolen", "label": "Farm raids", "description": "Someone steals a ready build from your Code Farm"}, {"key": "harvest_stolen", "label": "Farm raids", "description": "Someone steals a ready build from your Code Farm"},
{"key": "award", "label": "Awards", "description": "Someone gives you an award on your profile"}, {"key": "award", "label": "Awards", "description": "Someone gives you an award on your profile"},
{"key": "system", "label": "System alerts", "description": "Platform infrastructure alerts (e.g. the AI gateway going down)"},
] ]

View File

@ -358,6 +358,21 @@ def init_db():
_index( _index(
db, "issue_comment_authors", "idx_issue_comment_authors_number", ["gitea_number"] db, "issue_comment_authors", "idx_issue_comment_authors_number", ["gitea_number"]
) )
ws_tickets = get_table("ws_tickets")
for column, example in (
("uid", ""),
("token", ""),
("user_uid", ""),
("created_at", ""),
("expires_at", ""),
("used_at", ""),
):
if not ws_tickets.has_column(column):
ws_tickets.create_column_by_example(column, example)
_index(db, "ws_tickets", "idx_ws_tickets_token", ["token"], unique=True)
_index(db, "ws_tickets", "idx_ws_tickets_expires", ["expires_at"])
migrate_bug_tables_to_issue_tables() migrate_bug_tables_to_issue_tables()
_index(db, "service_state", "idx_service_state_name", ["name"]) _index(db, "service_state", "idx_service_state_name", ["name"])
if "devii_conversations" in db.tables: if "devii_conversations" in db.tables:
@ -471,9 +486,19 @@ def init_db():
_index(db, "jobs", "idx_jobs_expires", ["expires_at"]) _index(db, "jobs", "idx_jobs_expires", ["expires_at"])
_index(db, "project_forks", "idx_project_forks_source", ["source_project_uid"]) _index(db, "project_forks", "idx_project_forks_source", ["source_project_uid"])
_index(db, "project_forks", "idx_project_forks_forked", ["forked_project_uid"]) _index(db, "project_forks", "idx_project_forks_forked", ["forked_project_uid"])
if "instances" in db.tables:
instances = get_table("instances") instances = get_table("instances")
for column, example in ( for column, example in (
("uid", ""),
("project_uid", ""),
("slug", ""),
("name", ""),
("status", ""),
("desired_state", ""),
("container_id", ""),
("ingress_slug", ""),
("ingress_port", 0),
("ports_json", ""),
("container_gateway", ""),
("run_as_uid", ""), ("run_as_uid", ""),
("boot_language", "none"), ("boot_language", "none"),
("boot_script", ""), ("boot_script", ""),
@ -518,6 +543,10 @@ def init_db():
from devplacepy.services.openai_gateway import routing as gateway_routing from devplacepy.services.openai_gateway import routing as gateway_routing
gateway_routing.ensure_tables() gateway_routing.ensure_tables()
from devplacepy.services.openai_gateway import quota as gateway_quota
gateway_quota.ensure_tables()
_index(db, "audit_log", "idx_audit_created_at", ["created_at"]) _index(db, "audit_log", "idx_audit_created_at", ["created_at"])
_index(db, "audit_log", "idx_audit_event_key", ["event_key"]) _index(db, "audit_log", "idx_audit_event_key", ["event_key"])
_index(db, "audit_log", "idx_audit_category", ["category"]) _index(db, "audit_log", "idx_audit_category", ["category"])
@ -1014,6 +1043,29 @@ def init_db():
("legacy_speed", 0), ("legacy_speed", 0),
("legacy_plots", 0), ("legacy_plots", 0),
("legacy_defense", 0), ("legacy_defense", 0),
("prestiged_at", ""),
("mastery_points", 0),
("mastery_points_earned_total", 0),
("mastery_autoreplant", 0),
("mastery_analytics", 0),
("mastery_contracts", 0),
("lifetime_coins_earned", 0),
("lifetime_harvests", 0),
("infra_registry", 0),
("infra_canary", 0),
("infra_observability", 0),
("defense_level", 0),
("defense_last_upkeep_at", ""),
("active_title", ""),
("underdog_boost_until", ""),
("contract_boost_until", ""),
("harvests_week", 0),
("harvests_week_start", ""),
("last_kernel_harvest_prestige", 0),
("time_to_kernel_seconds", 0),
("era_coins", 0),
("era_harvests", 0),
("era_joined_at", ""),
("created_at", ""), ("created_at", ""),
("updated_at", ""), ("updated_at", ""),
): ):
@ -1045,6 +1097,7 @@ def init_db():
("farm_uid", ""), ("farm_uid", ""),
("user_uid", ""), ("user_uid", ""),
("day", ""), ("day", ""),
("scope", "daily"),
("slot_index", 0), ("slot_index", 0),
("kind", ""), ("kind", ""),
("label", ""), ("label", ""),
@ -1052,13 +1105,17 @@ def init_db():
("progress", 0), ("progress", 0),
("reward_coins", 0), ("reward_coins", 0),
("reward_xp", 0), ("reward_xp", 0),
("reward_stars", 0),
("claimed", 0), ("claimed", 0),
("created_at", ""), ("created_at", ""),
("updated_at", ""), ("updated_at", ""),
): ):
if not game_quests.has_column(column): if not game_quests.has_column(column):
game_quests.create_column_by_example(column, example) game_quests.create_column_by_example(column, example)
with db:
db.query("UPDATE game_quests SET scope = 'daily' WHERE scope IS NULL OR scope = ''")
_index(db, "game_quests", "idx_game_quests_farm_day", ["farm_uid", "day"]) _index(db, "game_quests", "idx_game_quests_farm_day", ["farm_uid", "day"])
_index(db, "game_quests", "idx_game_quests_farm_day_scope", ["farm_uid", "day", "scope"])
game_plots = get_table("game_plots") game_plots = get_table("game_plots")
for column, example in ( for column, example in (
@ -1077,6 +1134,72 @@ def init_db():
game_plots.create_column_by_example(column, example) game_plots.create_column_by_example(column, example)
_index(db, "game_plots", "idx_game_plots_farm", ["farm_uid", "slot_index"]) _index(db, "game_plots", "idx_game_plots_farm", ["farm_uid", "slot_index"])
game_market_ticks = get_table("game_market_ticks")
for column, example in (
("uid", ""),
("crop_key", ""),
("hour_bucket", ""),
("harvests", 0),
("updated_at", ""),
):
if not game_market_ticks.has_column(column):
game_market_ticks.create_column_by_example(column, example)
_index(
db,
"game_market_ticks",
"idx_game_market_ticks_bucket",
["crop_key", "hour_bucket"],
unique=True,
)
game_cosmetics = get_table("game_cosmetics")
for column, example in (
("uid", ""),
("user_uid", ""),
("cosmetic_key", ""),
("purchased_at", ""),
("created_at", ""),
):
if not game_cosmetics.has_column(column):
game_cosmetics.create_column_by_example(column, example)
_index(
db,
"game_cosmetics",
"idx_game_cosmetics_owner",
["user_uid", "cosmetic_key"],
unique=True,
)
game_eras = get_table("game_eras")
for column, example in (
("uid", ""),
("era_number", 0),
("name", ""),
("started_at", ""),
("ends_at", ""),
("active", 0),
("created_at", ""),
):
if not game_eras.has_column(column):
game_eras.create_column_by_example(column, example)
_index(db, "game_eras", "idx_game_eras_active", ["active"])
game_era_results = get_table("game_era_results")
for column, example in (
("uid", ""),
("era_number", 0),
("user_uid", ""),
("rank", 0),
("era_score", 0),
("era_coins_final", 0),
("reward_stars", 0),
("reward_cosmetic_key", ""),
("created_at", ""),
):
if not game_era_results.has_column(column):
game_era_results.create_column_by_example(column, example)
_index(db, "game_era_results", "idx_game_era_results_era", ["era_number", "rank"])
_index(db, "posts", "idx_posts_user_created", ["user_uid", "created_at"]) _index(db, "posts", "idx_posts_user_created", ["user_uid", "created_at"])
_index( _index(
db, db,

View File

@ -613,6 +613,53 @@ four ways to sign requests.
auth="admin", auth="admin",
destructive=True, destructive=True,
), ),
endpoint(
id="admin-gateway-quota-rules",
method="GET",
path="/admin/gateway/quota-rules",
title="List AI gateway quota rules",
summary=(
"List every rolling-24h USD quota rule on /openai/v1/*, each scoped by any "
"combination of role, specific user uid, and app_reference label, plus the "
"global per-role default caps that apply when no rule matches."
),
auth="admin",
interactive=True,
),
endpoint(
id="admin-gateway-quota-rule-set",
method="POST",
path="/admin/gateway/quota-rules",
title="Create or update an AI gateway quota rule",
summary=(
"Caps rolling-24h USD spend on /openai/v1/*. At least one of owner_kind, "
"owner_id, app_reference must be set; leaving a dimension blank makes it a "
"wildcard, and the most specific active match wins over other rules and over "
"the global default. Pass uid to update an existing rule."
),
auth="admin",
params=[
field("uid", "json", "string", False, "", "Existing rule uid to update; omit to create a new rule."),
field("owner_kind", "json", "string", False, "user", "internal, key, user, admin, or anonymous. Blank = any role."),
field("owner_id", "json", "string", False, "", "Specific user uid. Blank = any caller of the matched role."),
field("app_reference", "json", "string", False, "devplace-bots-v-1-0-0", "App label (the X-App-Reference header). Blank = any app."),
field("limit_usd", "json", "number", True, "2.5", "Rolling 24h USD cap. 0 = unlimited."),
field("is_active", "json", "boolean", False, "true", "Whether the rule is enforced."),
field("label", "json", "string", False, "", "Optional admin-facing note."),
],
),
endpoint(
id="admin-gateway-quota-rule-delete",
method="DELETE",
path="/admin/gateway/quota-rules/{uid}",
title="Delete an AI gateway quota rule",
summary="Delete a quota rule; callers it covered fall back to the next most specific rule or the global default.",
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "RULE_UID", "Quota rule uid."),
],
),
endpoint( endpoint(
id="admin-bots-monitor", id="admin-bots-monitor",
method="GET", method="GET",
@ -830,5 +877,37 @@ four ways to sign requests.
params=[field("uid", "path", "string", True, "", "Schedule uid.")], params=[field("uid", "path", "string", True, "", "Schedule uid.")],
sample_response={"ok": True, "redirect": "/admin/backups"}, sample_response={"ok": True, "redirect": "/admin/backups"},
), ),
endpoint(
id="admin-game",
method="GET",
path="/admin/game",
title="Code Farm Era management",
summary="View the current Code Farm Era status.",
auth="admin",
sample_response={"era_active": False, "era_name": ""},
),
endpoint(
id="admin-game-era-start",
method="POST",
path="/admin/game/era/start",
title="Start an Era",
summary="Start a new Code Farm Era: resets every farm's visible Era coins/harvests counters to zero. Real coins, prestige, stars, Legacy, and Mastery are never touched.",
auth="admin",
params=[
field("name", "form", "string", True, "Genesis", "Era name."),
field("duration_days", "form", "int", False, "28", "Planned Era length in days."),
],
sample_response={"ok": True, "redirect": "/admin/game"},
),
endpoint(
id="admin-game-era-end",
method="POST",
path="/admin/game/era/end",
title="End the running Era",
summary="End the current Era: ranks every participating farm by Era score, awards Stars to the top 10 (and an Era-exclusive cosmetic when available), and permanently records the results.",
auth="admin",
destructive=True,
sample_response={"ok": True, "redirect": "/admin/game"},
),
], ],
} }

View File

@ -50,9 +50,10 @@ client can refresh without a second request.
method="GET", method="GET",
path="/game/leaderboard", path="/game/leaderboard",
title="Farm leaderboard", title="Farm leaderboard",
summary="Top farmers ranked by level, XP, and harvests.", summary="Top farmers on a chosen board: score (default), prestige, harvests (this week), raids (avg coins per successful raid, min 3 raids), time_to_kernel, fair_play, or era (current Era only, empty when none is running).",
auth="public", auth="public",
sample_response={"entries": [{"rank": 1, "username": "alice", "level": 4}]}, params=[field("board", "query", "string", False, "score", "Leaderboard board key.")],
sample_response={"entries": [{"rank": 1, "username": "alice", "level": 4, "score": 1000}]},
), ),
endpoint( endpoint(
id="game-view-farm", id="game-view-farm",
@ -166,9 +167,12 @@ client can refresh without a second request.
method="POST", method="POST",
path="/game/quests/claim", path="/game/quests/claim",
title="Claim a quest", title="Claim a quest",
summary="Claim a completed daily quest reward by its kind.", summary="Claim a completed daily quest, or (with scope=weekly, requires the Legacy Contracts Mastery upgrade) the weekly contract, which pays Stars plus a temporary coin boost instead of coins/XP.",
auth="user", auth="user",
params=[field("quest", "form", "string", True, "harvest", "Quest kind.")], params=[
field("quest", "form", "string", True, "harvest", "Quest kind."),
field("scope", "form", "string", False, "daily", "daily (default) or weekly."),
],
sample_response={"ok": True, "farm": {"coins": 130}}, sample_response={"ok": True, "farm": {"coins": 130}},
), ),
endpoint( endpoint(
@ -176,7 +180,7 @@ client can refresh without a second request.
method="POST", method="POST",
path="/game/prestige", path="/game/prestige",
title="Refactor (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.", 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.",
auth="user", auth="user",
destructive=True, destructive=True,
sample_response={"ok": True, "farm": {"prestige": 1}}, sample_response={"ok": True, "farm": {"prestige": 1}},
@ -191,5 +195,54 @@ client can refresh without a second request.
params=[field("key", "form", "string", True, "multiplier", "Legacy upgrade key.")], params=[field("key", "form", "string", True, "multiplier", "Legacy upgrade key.")],
sample_response={"ok": True, "farm": {"stars": 1}}, sample_response={"ok": True, "farm": {"stars": 1}},
), ),
endpoint(
id="game-mastery",
method="POST",
path="/game/mastery",
title="Buy a Mastery upgrade",
summary="Spend Mastery points (earned every 10 prestige past 50) on a permanent Mastery upgrade: autoreplant, analytics, or contracts.",
auth="user",
params=[field("key", "form", "string", True, "autoreplant", "Mastery upgrade key.")],
sample_response={"ok": True, "farm": {"mastery_points": 0}},
),
endpoint(
id="game-infrastructure-buy",
method="POST",
path="/game/infrastructure/buy",
title="Buy Infrastructure",
summary="Buy a permanent, expensive, prestige-gated Infrastructure building: registry (faster rare crops), canary (double/refund harvest chance), or observability (raises the minimum you keep when raided).",
auth="user",
params=[field("key", "form", "string", True, "registry", "Infrastructure key.")],
sample_response={"ok": True, "farm": {"coins": 0}},
),
endpoint(
id="game-defense-upgrade",
method="POST",
path="/game/defense/upgrade",
title="Upgrade Defense",
summary="Buy the next Defense tier. Reduces raid losses and adds steal grace, but adds an ongoing daily coin upkeep (proportional to your coin balance) - if unpaid, the tier decays.",
auth="user",
sample_response={"ok": True, "farm": {"defense_level": 1}},
),
endpoint(
id="game-cosmetics-buy",
method="POST",
path="/game/cosmetics/buy",
title="Buy a cosmetic",
summary="Buy a purely cosmetic title or plot skin with coins. No gameplay effect.",
auth="user",
params=[field("key", "form", "string", True, "title_architect", "Cosmetic key.")],
sample_response={"ok": True, "farm": {"coins": 0}},
),
endpoint(
id="game-cosmetics-equip",
method="POST",
path="/game/cosmetics/equip",
title="Equip a title",
summary="Equip an owned title cosmetic so it shows next to your name on the leaderboard.",
auth="user",
params=[field("key", "form", "string", True, "title_architect", "An owned title cosmetic key.")],
sample_response={"ok": True, "farm": {"active_title": "title_architect"}},
),
], ],
} }

View File

@ -134,7 +134,7 @@ for signing DevPlace's own requests.
path="/openai/v1/chat/completions", path="/openai/v1/chat/completions",
title="Chat completions", title="Chat completions",
summary="OpenAI-compatible chat completion. Supports streaming.", summary="OpenAI-compatible chat completion. Supports streaming.",
auth="public", auth="user",
encoding="json", encoding="json",
params=[ params=[
field( field(
@ -174,7 +174,7 @@ for signing DevPlace's own requests.
path="/openai/v1/embeddings", path="/openai/v1/embeddings",
title="Embeddings", title="Embeddings",
summary="OpenAI-compatible text embeddings. Request model molodetz~embed.", summary="OpenAI-compatible text embeddings. Request model molodetz~embed.",
auth="public", auth="user",
encoding="json", encoding="json",
params=[ params=[
field( field(
@ -214,7 +214,7 @@ for signing DevPlace's own requests.
path="/openai/v1/images/generations", path="/openai/v1/images/generations",
title="Image generation", title="Image generation",
summary="OpenAI-compatible image generation. Request model molodetz-img-small.", summary="OpenAI-compatible image generation. Request model molodetz-img-small.",
auth="public", auth="user",
encoding="json", encoding="json",
params=[ params=[
field( field(
@ -262,7 +262,7 @@ for signing DevPlace's own requests.
path="/openai/v1/{path}", path="/openai/v1/{path}",
title="Passthrough", title="Passthrough",
summary="Any other /v1 path is forwarded to the upstream as-is.", summary="Any other /v1 path is forwarded to the upstream as-is.",
auth="public", auth="user",
interactive=False, interactive=False,
params=[ params=[
field( field(

View File

@ -320,7 +320,7 @@ four ways to sign requests.
), ),
field( field(
"description", "description",
"body", "json",
"string", "string",
True, True,
"Great work on the release!", "Great work on the release!",

View File

@ -591,7 +591,25 @@ class GamePerkForm(BaseModel):
class GameQuestForm(BaseModel): class GameQuestForm(BaseModel):
quest: str = Field(min_length=1, max_length=40) quest: str = Field(min_length=1, max_length=40)
scope: str = Field(default="daily", min_length=1, max_length=10)
class GameLegacyForm(BaseModel): class GameLegacyForm(BaseModel):
key: str = Field(min_length=1, max_length=40) key: str = Field(min_length=1, max_length=40)
class GameInfraForm(BaseModel):
key: str = Field(min_length=1, max_length=40)
class GameCosmeticForm(BaseModel):
key: str = Field(min_length=1, max_length=40)
class GameMasteryForm(BaseModel):
key: str = Field(min_length=1, max_length=40)
class GameEraStartForm(BaseModel):
name: str = Field(min_length=1, max_length=60)
duration_days: int = Field(default=28, ge=1, le=180)

View File

@ -25,7 +25,7 @@ Prefixes are wired in `main.py`:
| `/follow` | follow.py | | `/follow` | follow.py |
| (none) | relations.py - per-user block/mute relations: `POST /block/{username}`, `/block/unblock/{username}`, `/mute/{username}`, `/mute/unmute/{username}` (soft-deletable `user_relations` rows) | | (none) | relations.py - per-user block/mute relations: `POST /block/{username}`, `/block/unblock/{username}`, `/mute/{username}`, `/mute/unmute/{username}` (soft-deletable `user_relations` rows) |
| `/leaderboard` | leaderboard.py - `GET /leaderboard` XP/stars leaderboard page | | `/leaderboard` | leaderboard.py - `GET /leaderboard` XP/stars leaderboard page |
| `/admin` | admin/ package - one leaf per sub-resource (`index`, `users`, `aiusage`, `aiquota`, `media`, `trash`, `settings`, `notifications`, `news`, `auditlog`, `backups`) plus the folded-in `services.py` and `containers.py` (mounted with `/services` and `/containers` sub-prefixes). `main.py` mounts the whole `/admin` tree from this one package. The `backups` leaf is the admin **Backups** dashboard (`BackupService`, kind `backup`): storage usage, backup archives, and interval/cron backup schedules (CRUD + rotation). **Archive download is restricted to the primary administrator** (the earliest-created Admin, resolved by `database.get_primary_admin_uid` / `utils.is_primary_admin`): `GET /admin/backups/{uid}/download` 403s every other admin, the `download_url` field is withheld from them at every endpoint (`can_download = is_primary_admin(admin)`, surfaced as `BackupDashboardOut.can_download_backups`), and `BackupMonitor.js` renders their Download control as a disabled button tooltipped `Not available`. See `devplacepy/services/backup/CLAUDE.md` | | `/admin` | admin/ package - one leaf per sub-resource (`index`, `users`, `aiusage`, `aiquota`, `media`, `trash`, `settings`, `notifications`, `news`, `auditlog`, `backups`, `game`) plus the folded-in `services.py` and `containers.py` (mounted with `/services` and `/containers` sub-prefixes). `main.py` mounts the whole `/admin` tree from this one package. The `backups` leaf is the admin **Backups** dashboard (`BackupService`, kind `backup`): storage usage, backup archives, and interval/cron backup schedules (CRUD + rotation). **Archive download is restricted to the primary administrator** (the earliest-created Admin, resolved by `database.get_primary_admin_uid` / `utils.is_primary_admin`): `GET /admin/backups/{uid}/download` 403s every other admin, the `download_url` field is withheld from them at every endpoint (`can_download = is_primary_admin(admin)`, surfaced as `BackupDashboardOut.can_download_backups`), and `BackupMonitor.js` renders their Download control as a disabled button tooltipped `Not available`. See `devplacepy/services/backup/CLAUDE.md`. The `game` leaf (`/admin/game`) is the Code Farm Era admin page: `GET /admin/game` (status), `POST /admin/game/era/start` and `/era/end` - see `devplacepy/services/game/CLAUDE.md` |
| `/admin/services` | admin/services.py | | `/admin/services` | admin/services.py |
| `/issues` | issues/ package - issue tracker backed by Gitea (no local issue store): `index.py` (list `?state=`/`?page=`, detail `/{number}` with comments), `create.py` (async AI-enhanced filing `/create` enqueues a `issue_create` job, status at `/jobs/{uid}`), `comment.py` (synchronous, pushes to Gitea + notifies admins), `status.py` (admin open/closed), `attachments.py` (file attachments on open issues + comments, mirrored to Gitea native assets; add/list/delete with owner-or-admin + open-state guards) | | `/issues` | issues/ package - issue tracker backed by Gitea (no local issue store): `index.py` (list `?state=`/`?page=`, detail `/{number}` with comments), `create.py` (async AI-enhanced filing `/create` enqueues a `issue_create` job, status at `/jobs/{uid}`), `comment.py` (synchronous, pushes to Gitea + notifies admins), `status.py` (admin open/closed), `attachments.py` (file attachments on open issues + comments, mirrored to Gitea native assets; add/list/delete with owner-or-admin + open-state guards) |
| `/gists` | gists.py | | `/gists` | gists.py |
@ -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` | | `/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 | | `/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` | | `/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`, `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` | 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` |
| (none) | push.py - web push + PWA: `GET`/`POST /push.json` (VAPID public key / subscription register), `GET /service-worker.js`, `GET /manifest.json` | | (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) | | (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` | | `/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` |

View File

@ -8,6 +8,7 @@ from devplacepy.routers.admin import (
backups, backups,
bots, bots,
containers, containers,
game,
gateway_configs, gateway_configs,
issues, issues,
media, media,
@ -36,5 +37,6 @@ router.include_router(auditlog.router)
router.include_router(backups.router) router.include_router(backups.router)
router.include_router(bots.router) router.include_router(bots.router)
router.include_router(gateway_configs.router) router.include_router(gateway_configs.router)
router.include_router(game.router)
router.include_router(services.router, prefix="/services") router.include_router(services.router, prefix="/services")
router.include_router(containers.router, prefix="/containers") router.include_router(containers.router, prefix="/containers")

View File

@ -0,0 +1,97 @@
# retoor <retoor@molodetz.nl>
import logging
from typing import Annotated
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse
from devplacepy.models import GameEraStartForm
from devplacepy.responses import respond, action_result, json_error, wants_json
from devplacepy.schemas import AdminGameOut
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.services.audit import record as audit
from devplacepy.services.game import GameError, store
from devplacepy.utils import require_admin
logger = logging.getLogger(__name__)
router = APIRouter()
def _era_context() -> dict:
era = store.active_era()
return {
"era_active": bool(era),
"era_name": era["name"] if era else "",
"era_number": int(era["era_number"]) if era else 0,
"era_started_at": era["started_at"] if era else "",
"era_ends_at": era["ends_at"] if era else "",
}
@router.get("/game", response_class=HTMLResponse)
async def admin_game(request: Request):
admin = require_admin(request)
base = site_url(request)
seo_ctx = base_seo_context(
request,
title="Code Farm - Admin",
description="Manage Code Farm Eras.",
robots="noindex,nofollow",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Admin", "url": "/admin"},
{"name": "Code Farm", "url": "/admin/game"},
],
schemas=[website_schema(base)],
)
return respond(
request,
"admin_game.html",
{
**seo_ctx,
"request": request,
"user": admin,
"admin_section": "game",
**_era_context(),
},
model=AdminGameOut,
)
@router.post("/game/era/start")
async def admin_game_era_start(request: Request, data: Annotated[GameEraStartForm, Form()]):
admin = require_admin(request)
try:
era = store.start_era(data.name, data.duration_days)
except GameError as exc:
logger.warning(f"Admin {admin['username']} failed to start Era: {exc}")
if wants_json(request):
return json_error(400, str(exc))
return action_result(request, "/admin/game")
audit.record(
request,
"admin.game.era_start",
user=admin,
metadata={"era_number": era["era_number"], "name": era["name"]},
summary=f"admin {admin['username']} started Era {era['name']}",
)
return action_result(request, "/admin/game")
@router.post("/game/era/end")
async def admin_game_era_end(request: Request):
admin = require_admin(request)
try:
result = store.end_era()
except GameError as exc:
logger.warning(f"Admin {admin['username']} failed to end Era: {exc}")
if wants_json(request):
return json_error(400, str(exc))
return action_result(request, "/admin/game")
audit.record(
request,
"admin.game.era_end",
user=admin,
metadata=result,
summary=f"admin {admin['username']} ended Era {result['era_number']}",
)
return action_result(request, "/admin/game")

View File

@ -9,7 +9,7 @@ from pydantic import ValidationError
from devplacepy.seo import base_seo_context, site_url, website_schema from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.services.audit import record as audit from devplacepy.services.audit import record as audit
from devplacepy.services.manager import service_manager from devplacepy.services.manager import service_manager
from devplacepy.services.openai_gateway import routing from devplacepy.services.openai_gateway import quota, routing
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import require_admin from devplacepy.utils import require_admin
@ -182,3 +182,92 @@ async def delete_model(request: Request, source_model: str):
summary=f"admin {admin['username']} deleted gateway model route {source_model}", summary=f"admin {admin['username']} deleted gateway model route {source_model}",
) )
return JSONResponse({"ok": True}) return JSONResponse({"ok": True})
def _quota_defaults_summary() -> dict:
svc = service_manager.get_service("openai")
cfg = svc.get_config() if svc is not None else {}
return {
"user": cfg.get(quota.FIELD_DEFAULT_USER, 0.0),
"admin": cfg.get(quota.FIELD_DEFAULT_ADMIN, 0.0),
"guest": cfg.get(quota.FIELD_DEFAULT_GUEST, 0.0),
"internal": cfg.get(quota.FIELD_DEFAULT_INTERNAL, 0.0),
"key": cfg.get(quota.FIELD_DEFAULT_KEY, 0.0),
}
def _rule_label(rule: dict) -> str:
parts = []
if rule.get("owner_kind"):
parts.append(f"role={rule['owner_kind']}")
if rule.get("owner_id"):
parts.append(f"user={rule['owner_id']}")
if rule.get("app_reference"):
parts.append(f"app={rule['app_reference']}")
return ", ".join(parts) or rule.get("uid", "")
@router.get("/gateway/quota-rules")
async def list_quota_rules(request: Request):
require_admin(request)
rules = quota.quota_rule_store.list()
for rule in rules:
rule["spent_24h_usd"] = round(
quota.spent_24h(rule["owner_kind"], rule["owner_id"], rule["app_reference"]), 6
)
return JSONResponse(
{
"rules": rules,
"count": len(rules),
"defaults": _quota_defaults_summary(),
}
)
@router.post("/gateway/quota-rules")
async def save_quota_rule(request: Request):
admin = require_admin(request)
body = await _payload(request)
uid = str(body.pop("uid", "") or "").strip() or None
try:
payload = quota.QuotaRuleIn(**body)
except ValidationError as exc:
return _validation_error(exc)
saved = quota.quota_rule_store.set(payload, uid=uid, created_by=admin["uid"])
audit.record(
request,
"gateway.quota_rule.update",
user=admin,
target_type="gateway_quota_rule",
target_uid=saved["uid"],
target_label=_rule_label(saved),
summary=f"admin {admin['username']} saved gateway quota rule ({_rule_label(saved)}) at ${saved['limit_usd']}/24h",
metadata={
"owner_kind": saved["owner_kind"],
"owner_id": saved["owner_id"],
"app_reference": saved["app_reference"],
"limit_usd": saved["limit_usd"],
"is_active": saved["is_active"],
},
)
return JSONResponse({"ok": True, "rule": saved})
@router.delete("/gateway/quota-rules/{uid}")
async def delete_quota_rule(request: Request, uid: str):
admin = require_admin(request)
existing = quota.quota_rule_store.get(uid)
label = _rule_label(existing.as_dict()) if existing else uid
existed = quota.quota_rule_store.remove(uid)
if not existed:
return JSONResponse({"ok": False, "error": "Quota rule not found"}, status_code=404)
audit.record(
request,
"gateway.quota_rule.delete",
user=admin,
target_type="gateway_quota_rule",
target_uid=uid,
target_label=label,
summary=f"admin {admin['username']} deleted gateway quota rule ({label})",
)
return JSONResponse({"ok": True})

View File

@ -88,6 +88,8 @@ async def steal_farm(
return RedirectResponse(url=f"/game/farm/{username}", status_code=302) return RedirectResponse(url=f"/game/farm/{username}", status_code=302)
track_action(viewer["uid"], "harvest_stolen") track_action(viewer["uid"], "harvest_stolen")
track_action(owner["uid"], "got_stolen_from") track_action(owner["uid"], "got_stolen_from")
if result.get("underdog_triggered"):
track_action(viewer["uid"], "underdog_raid")
create_notification( create_notification(
owner["uid"], owner["uid"],
"harvest_stolen", "harvest_stolen",

View File

@ -6,7 +6,10 @@ from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from devplacepy.models import ( from devplacepy.models import (
GameCosmeticForm,
GameInfraForm,
GameLegacyForm, GameLegacyForm,
GameMasteryForm,
GamePerkForm, GamePerkForm,
GamePlantForm, GamePlantForm,
GameQuestForm, GameQuestForm,
@ -49,9 +52,9 @@ async def game_state(request: Request):
@router.get("/leaderboard") @router.get("/leaderboard")
async def game_leaderboard(request: Request): async def game_leaderboard(request: Request, board: str = "score"):
get_current_user(request) get_current_user(request)
entries = store.leaderboard(25) entries = store.leaderboard_for(board, 25)
return JSONResponse( return JSONResponse(
GameLeaderboardOut(entries=entries).model_dump(mode="json") GameLeaderboardOut(entries=entries).model_dump(mode="json")
) )
@ -149,5 +152,55 @@ async def game_claim_quest(request: Request, data: Annotated[GameQuestForm, Form
award_rewards(user["uid"], result.get("reward_xp", 0)) award_rewards(user["uid"], result.get("reward_xp", 0))
return await _respond_action( return await _respond_action(
request, user, lambda: store.claim_quest(user, data.quest), reward request, user, lambda: store.claim_quest(user, data.quest, data.scope), reward
)
@router.post("/defense/upgrade")
async def game_upgrade_defense(request: Request):
user = require_user(request)
def reward(result):
track_action(user["uid"], "defense_upgraded")
return await _respond_action(request, user, lambda: store.upgrade_defense(user), reward)
@router.post("/infrastructure/buy")
async def game_buy_infrastructure(request: Request, data: Annotated[GameInfraForm, Form()]):
user = require_user(request)
def reward(result):
track_action(user["uid"], "infra_bought")
return await _respond_action(
request, user, lambda: store.buy_infrastructure(user, data.key), reward
)
@router.post("/mastery")
async def game_upgrade_mastery(request: Request, data: Annotated[GameMasteryForm, Form()]):
user = require_user(request)
return await _respond_action(
request, user, lambda: store.upgrade_mastery(user, data.key)
)
@router.post("/cosmetics/buy")
async def game_buy_cosmetic(request: Request, data: Annotated[GameCosmeticForm, Form()]):
user = require_user(request)
def reward(result):
track_action(user["uid"], "cosmetic_bought")
return await _respond_action(
request, user, lambda: store.buy_cosmetic(user, data.key), reward
)
@router.post("/cosmetics/equip")
async def game_equip_cosmetic(request: Request, data: Annotated[GameCosmeticForm, Form()]):
user = require_user(request)
return await _respond_action(
request, user, lambda: store.equip_title(user, data.key)
) )

View File

@ -4,6 +4,7 @@ import logging
from devplacepy.database import get_correction_usage, get_modifier_usage from devplacepy.database import get_correction_usage, get_modifier_usage
from devplacepy.services.manager import service_manager from devplacepy.services.manager import service_manager
from devplacepy.services.openai_gateway import quota as gateway_quota
from devplacepy.services.openai_gateway.analytics import user_spend_24h from devplacepy.services.openai_gateway.analytics import user_spend_24h
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -62,4 +63,22 @@ def _ai_quota(
if include_cost: if include_cost:
quota["spent_usd"] = round(spent, 4) quota["spent_usd"] = round(spent, 4)
quota["limit_usd"] = round(limit, 2) quota["limit_usd"] = round(limit, 2)
gateway_svc = service_manager.get_service("openai")
if gateway_svc is not None:
try:
owner_kind = "admin" if is_admin else "user"
cfg = gateway_svc.effective_config()
gw_limit, gw_scope, gw_rule = gateway_quota.resolve_for_owner(owner_kind, user_uid, cfg)
gw_spent = gateway_quota.spent_24h(*gw_scope)
gw_unlimited = gw_limit <= 0
quota["gateway_unlimited"] = gw_unlimited
quota["gateway_used_pct"] = (
0.0 if gw_unlimited else round(min(100.0, gw_spent / gw_limit * 100), 1)
)
if include_cost:
quota["gateway_spent_usd"] = round(gw_spent, 4)
quota["gateway_limit_usd"] = round(gw_limit, 2)
quota["gateway_pooled"] = bool(gw_rule and gw_rule.owner_id is None)
except Exception:
logger.exception("Failed to compute gateway-level AI quota for %s", user_uid)
return quota return quota

View File

@ -99,6 +99,7 @@ from devplacepy.schemas.backups import (
BackupStoragePathOut, BackupStoragePathOut,
) )
from devplacepy.schemas.admin import ( from devplacepy.schemas.admin import (
AdminGameOut,
AdminMediaItemOut, AdminMediaItemOut,
AdminMediaOut, AdminMediaOut,
AdminNewsItemOut, AdminNewsItemOut,

View File

@ -72,3 +72,12 @@ class AdminTrashOut(_Out):
tables: list[dict] = [] tables: list[dict] = []
pagination: Optional[Any] = None pagination: Optional[Any] = None
admin_section: Optional[str] = None admin_section: Optional[str] = None
class AdminGameOut(_Out):
era_active: bool = False
era_name: str = ""
era_number: int = 0
era_started_at: str = ""
era_ends_at: str = ""
admin_section: Optional[str] = None

View File

@ -15,6 +15,7 @@ class GameCropOut(_Out):
min_level: int = 1 min_level: int = 1
grow_seconds: int = 0 grow_seconds: int = 0
locked: bool = False locked: bool = False
market_state: str = "normal"
class GamePlotOut(_Out): class GamePlotOut(_Out):
@ -62,6 +63,38 @@ class GameLegacyOut(_Out):
effect: str = "" effect: str = ""
class GameMasteryOut(_Out):
key: str = ""
name: str = ""
icon: str = ""
description: str = ""
level: int = 0
max_level: int = 0
cost: int = 0
maxed: bool = False
effect: str = ""
class GameInfrastructureOut(_Out):
key: str = ""
name: str = ""
icon: str = ""
description: str = ""
cost: int = 0
min_prestige: int = 0
owned: bool = False
class GameCosmeticOut(_Out):
key: str = ""
name: str = ""
icon: str = ""
description: str = ""
cost_coins: int = 0
kind: str = ""
owned: bool = False
class GameQuestOut(_Out): class GameQuestOut(_Out):
kind: str = "" kind: str = ""
label: str = "" label: str = ""
@ -71,6 +104,8 @@ class GameQuestOut(_Out):
reward_xp: int = 0 reward_xp: int = 0
claimed: bool = False claimed: bool = False
can_claim: bool = False can_claim: bool = False
scope: str = "daily"
reward_stars: int = 0
class GameFarmOut(_Out): class GameFarmOut(_Out):
@ -107,6 +142,25 @@ class GameFarmOut(_Out):
stars: int = 0 stars: int = 0
legacy: list[GameLegacyOut] = [] legacy: list[GameLegacyOut] = []
steal_cooldown_seconds: int = 0 steal_cooldown_seconds: int = 0
mastery_points: int = 0
mastery_points_earned_total: int = 0
mastery: list[GameMasteryOut] = []
infrastructure: list[GameInfrastructureOut] = []
defense_level: int = 0
defense_tier_name: str = ""
defense_upkeep_daily: int = 0
defense_next_cost: int = 0
cosmetics: list[GameCosmeticOut] = []
active_title: str = ""
underdog_boost_seconds_remaining: int = 0
mastery_analytics_unlocked: bool = False
lifetime_coins_earned: int = 0
lifetime_harvests: int = 0
harvests_week: int = 0
era_active: bool = False
era_name: str = ""
era_coins: int = 0
era_harvests: int = 0
class GameStateOut(_Out): class GameStateOut(_Out):
@ -130,6 +184,9 @@ class GameLeaderboardEntryOut(_Out):
total_harvests: int = 0 total_harvests: int = 0
prestige: int = 0 prestige: int = 0
score: int = 0 score: int = 0
raid_avg: float = 0.0
time_to_kernel_seconds: int = 0
title: str = ""
class GameLeaderboardOut(_Out): class GameLeaderboardOut(_Out):

View File

@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from ..spec import Action, Param from ..spec import Action, Param
from ._shared import body, path from ._shared import body, path, query
GAME_ACTIONS: tuple[Action, ...] = ( GAME_ACTIONS: tuple[Action, ...] = (
@ -24,10 +24,18 @@ GAME_ACTIONS: tuple[Action, ...] = (
name="game_leaderboard", name="game_leaderboard",
method="GET", method="GET",
path="/game/leaderboard", path="/game/leaderboard",
summary="List the top Code Farm players", summary="List the top Code Farm players on a given board",
description=(
"Boards: score (default, overall composite score), prestige, harvests "
"(this week), raids (avg coins per successful raid, min 3 raids), "
"time_to_kernel (fastest since last refactor), fair_play (rewards recent "
"activity over hoarding), era (current Era-only leaderboard, empty when no "
"Era is running)."
),
handler="http", handler="http",
requires_auth=False, requires_auth=False,
read_only=True, read_only=True,
params=(query("board", "Leaderboard board key, defaults to score."),),
), ),
Action( Action(
name="game_view_farm", name="game_view_farm",
@ -136,11 +144,16 @@ GAME_ACTIONS: tuple[Action, ...] = (
name="game_claim_quest", name="game_claim_quest",
method="POST", method="POST",
path="/game/quests/claim", path="/game/quests/claim",
summary="Claim a completed daily Code Farm quest reward", summary="Claim a completed daily or weekly Code Farm quest/contract reward",
handler="http", handler="http",
requires_auth=True, requires_auth=True,
params=( params=(
body("quest", "Quest kind: plant, harvest, water, or earn.", required=True), body("quest", "Quest kind: plant, harvest, water, or earn.", required=True),
body(
"scope",
"daily (default) or weekly (requires the Legacy Contracts Mastery upgrade).",
required=False,
),
), ),
), ),
Action( Action(
@ -166,4 +179,52 @@ GAME_ACTIONS: tuple[Action, ...] = (
), ),
), ),
), ),
Action(
name="game_upgrade_mastery",
method="POST",
path="/game/mastery",
summary="Spend Mastery points on a permanent Mastery upgrade (unlocked at prestige 50+): autoreplant, analytics, or contracts",
handler="http",
requires_auth=True,
params=(
body("key", "Mastery key: autoreplant, analytics, or contracts.", required=True),
),
),
Action(
name="game_buy_infrastructure",
method="POST",
path="/game/infrastructure/buy",
summary="Buy a permanent Infrastructure building (registry, canary, observability); expensive, prestige-gated, big coin sinks",
handler="http",
requires_auth=True,
params=(
body("key", "Infrastructure key: registry, canary, or observability.", required=True),
),
),
Action(
name="game_upgrade_defense",
method="POST",
path="/game/defense/upgrade",
summary="Upgrade your farm's Defense tier: reduces raid losses and adds steal grace, but costs an ongoing daily coin upkeep proportional to your wealth",
handler="http",
requires_auth=True,
),
Action(
name="game_buy_cosmetic",
method="POST",
path="/game/cosmetics/buy",
summary="Buy a purely cosmetic title or plot skin with coins (no gameplay effect)",
handler="http",
requires_auth=True,
params=(body("key", "Cosmetic key from the farm's cosmetics list.", required=True),),
),
Action(
name="game_equip_cosmetic",
method="POST",
path="/game/cosmetics/equip",
summary="Equip an owned cosmetic title so it shows on the leaderboard",
handler="http",
requires_auth=True,
params=(body("key", "An owned title cosmetic key.", required=True),),
),
) )

View File

@ -117,4 +117,57 @@ GATEWAY_ACTIONS: tuple[Action, ...] = (
requires_admin=True, requires_admin=True,
params=(path("source_model", "Source model name."), confirm()), params=(path("source_model", "Source model name."), confirm()),
), ),
Action(
name="gateway_quota_rules",
method="GET",
path="/admin/gateway/quota-rules",
summary="List AI gateway quota rules and the current global defaults (admin only)",
description=(
"Returns JSON: every quota rule (each scoped by any combination of role/owner_kind, "
"a specific user uid, and an app_reference label) with its 24h limit, current 24h spend "
"against that exact scope, active flag, and label, plus the global per-role default caps "
"used when no rule matches a request."
),
handler="http",
requires_admin=True,
read_only=True,
),
Action(
name="gateway_quota_rule_set",
method="POST",
path="/admin/gateway/quota-rules",
summary="Create or update an AI gateway quota rule (admin only)",
description=(
"Caps rolling-24h USD spend on /openai/v1/*. Scope by any combination of owner_kind "
"(internal/key/user/admin/anonymous), a specific owner_id (user uid), and app_reference "
"(the X-App-Reference header apps send). At least one of the three must be set - an "
"unscoped cap belongs in the global default fields on the gateway service config instead. "
"Leaving a dimension blank makes it a wildcard: an app_reference-only rule pools spend "
"across every caller using that app; an owner_kind-only rule pools spend across every "
"caller of that role. Setting owner_id pins the rule to one specific caller. When several "
"rules match one request, the MOST SPECIFIC one wins (most non-blank dimensions); ties "
"break toward the smaller limit. limit_usd of 0 means unlimited for that rule. Pass uid "
"to update an existing rule instead of creating a new one."
),
handler="http",
requires_admin=True,
params=(
body("uid", "Existing rule uid to update; omit to create a new rule."),
body("owner_kind", "Role to scope by: internal, key, user, admin, or anonymous. Blank = any role."),
body("owner_id", "Specific user uid to scope by. Blank = any caller of the matched role."),
body("app_reference", "App label to scope by (the X-App-Reference header). Blank = any app."),
Param(name="limit_usd", location="body", description="Rolling 24h USD cap for this rule. 0 = unlimited.", required=True, type="number"),
Param(name="is_active", location="body", description="Whether the rule is enforced ('1' or '0'). Defaults to active.", required=False, type="boolean"),
body("label", "Optional admin-facing note describing what this rule is for."),
),
),
Action(
name="gateway_quota_rule_delete",
method="DELETE",
path="/admin/gateway/quota-rules/{uid}",
summary="Delete an AI gateway quota rule (admin only, confirmation required)",
handler="http",
requires_admin=True,
params=(path("uid", "Quota rule uid."), confirm()),
),
) )

View File

@ -58,6 +58,7 @@ CONFIRM_REQUIRED = {
"notification_reset", "notification_reset",
"gateway_provider_delete", "gateway_provider_delete",
"gateway_model_delete", "gateway_model_delete",
"gateway_quota_rule_delete",
"email_account_delete", "email_account_delete",
"email_delete_message", "email_delete_message",
} }

View File

@ -520,8 +520,8 @@ class DeviiSession:
f"{self._system_prompt}\n\n" f"{self._system_prompt}\n\n"
f"{CA_IWP_SYSTEM_FRAGMENT}\n\n" f"{CA_IWP_SYSTEM_FRAGMENT}\n\n"
f"{channel_block}\n\n" f"{channel_block}\n\n"
f"{self._clock_line()}\n\n" f"{section}\n\n"
f"{section}" f"{self._clock_line()}"
) )
def _clock_line(self) -> str: def _clock_line(self) -> str:
@ -538,7 +538,7 @@ class DeviiSession:
offset = local_now.utcoffset() offset = local_now.utcoffset()
offset_text = _format_offset(offset) offset_text = _format_offset(offset)
local = ( local = (
f" The user's local time is {local_now.strftime('%Y-%m-%d %H:%M:%S')} " f" The user's local time is {local_now.strftime('%Y-%m-%d %Hh')} "
f"({tz_name}, UTC{offset_text})." f"({tz_name}, UTC{offset_text})."
) )
except Exception: # noqa: BLE001 - unknown tz name: fall back to UTC only except Exception: # noqa: BLE001 - unknown tz name: fall back to UTC only
@ -547,16 +547,21 @@ class DeviiSession:
offset = timedelta(minutes=self._tz_offset_minutes) offset = timedelta(minutes=self._tz_offset_minutes)
local = ( local = (
f" The user's local time is " f" The user's local time is "
f"{(now + offset).strftime('%Y-%m-%d %H:%M:%S')} " f"{(now + offset).strftime('%Y-%m-%d %Hh')} "
f"(UTC{_format_offset(offset)})." f"(UTC{_format_offset(offset)})."
) )
return ( return (
f"# CURRENT TIME\n" f"# CURRENT TIME\n"
f"The current UTC time is {now.strftime('%Y-%m-%dT%H:%M:%S')}Z.{local} " f"The current UTC time is approximately {now.strftime('%Y-%m-%d %Hh')} UTC "
f"(rounded to the hour for situational awareness only).{local} "
"When the user gives a wall-clock time (for example '3pm' or 'tomorrow at 09:00'), " "When the user gives a wall-clock time (for example '3pm' or 'tomorrow at 09:00'), "
"interpret it in the user's local timezone and convert it to UTC for the run_at field. " "interpret it in the user's local timezone and convert it to UTC for the run_at field. "
"For a relative request (for example 'in 40 seconds' or 'in 2 hours'), use delay_seconds " "For a relative request (for example 'in 40 seconds' or 'in 2 hours'), use delay_seconds "
"instead and do not compute an absolute time." "instead and do not compute an absolute time - it is applied against the exact time on "
"the server when the task is created, regardless of the rounding above. If you ever need "
"the exact current time to the second - for example to compute an absolute run_at from a "
"phrase you cannot express as delay_seconds - call the current_time tool first; never "
"derive an absolute run_at from the rounded line above."
) )
def _stored_timezone(self) -> str: def _stored_timezone(self) -> str:

View File

@ -56,6 +56,23 @@ SCHEDULE_FIELDS: tuple[Param, ...] = (
) )
TASK_ACTIONS: tuple[Action, ...] = ( TASK_ACTIONS: tuple[Action, ...] = (
Action(
name="current_time",
method="LOCAL",
path="",
summary="Get the exact current UTC time, to the second",
description=(
"The CURRENT TIME line in the system prompt is rounded to the hour to stay "
"cache-friendly. Call this tool first whenever you need the precise current time - "
"in particular before computing any absolute run_at for create_task/update_task from "
"a relative phrase you cannot express via delay_seconds. For an ordinary relative "
"delay ('in 40 seconds', 'in 2 hours'), you do not need this: delay_seconds is applied "
"against the server's own clock at creation time and is always exact regardless."
),
handler="task",
requires_auth=False,
params=(),
),
Action( Action(
name="create_task", name="create_task",
method="LOCAL", method="LOCAL",

View File

@ -69,6 +69,7 @@ class TaskController:
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str: async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
handlers = { handlers = {
"current_time": self.current_time,
"create_task": self.create_task, "create_task": self.create_task,
"list_tasks": self.list_tasks, "list_tasks": self.list_tasks,
"get_task": self.get_task, "get_task": self.get_task,
@ -81,6 +82,9 @@ class TaskController:
raise ToolInputError(f"Unknown task tool: {name}") raise ToolInputError(f"Unknown task tool: {name}")
return handler(arguments) return handler(arguments)
def current_time(self, arguments: dict[str, Any]) -> str:
return json.dumps({"utc": to_iso(now_utc())}, ensure_ascii=False)
def create_task(self, arguments: dict[str, Any]) -> str: def create_task(self, arguments: dict[str, Any]) -> str:
prompt = str(arguments.get("prompt", "")).strip() prompt = str(arguments.get("prompt", "")).strip()
if not prompt: if not prompt:

View File

@ -55,3 +55,53 @@ The infinite progression for maxed farms. Six new default-0 `game_farms` columns
**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). **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. **Golden builds** are deterministic and storage-free: `economy.is_golden(plot_uid, planted_at)` (sha256, ~`GOLDEN_CHANCE`) marks a planting golden for its life; `harvest`/`_auto_harvest` multiply **coins only** by `GOLDEN_MULTIPLIER` (XP unscaled to keep level pacing), and `serialize_plot.is_golden` surfaces a sparkle badge (`.game-plot-golden`). New schema fields (`GameLegacyOut`, `GameFarmOut.stars`/`legacy`, `GamePlotOut.is_golden`) all default safe; no DB migration.
## The economy rebalance (Market Saturation, Infrastructure, Defense, Cosmetics, Mastery, secondary leaderboards, Eras, Underdog, weekly Contracts - all backwards compatible)
A second large layer added on top of everything above, aimed at flattening runaway whale inequality (unbounded multiplicative prestige against finite content) and giving the game long-term excitement beyond "hit the Kernel + high-prestige wall." Every mechanic below defaults to a no-op for a farm that has never touched it - new `game_farms` columns are `0`/`""`, new tables start empty, and the Era system in particular is fully dormant (byte-identical to pre-rebalance behavior) until an admin starts the first Era.
**One shared choke point underlies all the new counters.** `store/common.py::credit_farm(farm, *, coins=0, xp=0, harvests=0, era_active=False, extra=None)` is the single place that updates `coins`/`xp`/`level`/`total_harvests` **and** the new shadow counters (`lifetime_coins_earned`, `lifetime_harvests`, and - only when `era_active` - `era_coins`/`era_harvests`) together, so no future coin-crediting feature has to remember to touch five counters at five call sites. `harvest`, `_auto_harvest`, `steal` (thief side), `claim_daily`, and `claim_quest` all route through it instead of their old inline `_update_farm` math. `credit_farm` floors both `coins` and `lifetime_coins_earned` at zero (`max(0, ...)`) as a defense-in-depth guard - no current caller passes a negative `coins` delta, but this is the shared choke every future coin-crediting feature is meant to route through, so the floor costs nothing and forecloses a whole class of future bug.
### Every purchase/upgrade is atomic against concurrent requests (`store/common.py::conditional_update_farm`)
`uvicorn --workers N` means two nearly-simultaneous requests for the same user (a double-click, or two requests hashed to different workers) can run truly in parallel across separate processes - a plain "read the farm, check a precondition in Python, then write" is a classic TOCTOU race under that model. Every mutation that spends a resource against a precondition - `buy_plot`, `upgrade_ci` (`store/farm.py`), `upgrade_perk`, `upgrade_legacy`, `prestige` (`store/actions.py`), `buy_infrastructure`, `upgrade_defense`, `charge_upkeep` (lazy, runs on every `serialize_farm`), `buy_cosmetic`, and `upgrade_mastery` - goes through `conditional_update_farm(farm_uid, set_clause, where_clause, params)`: one raw SQL `UPDATE game_farms SET ... WHERE uid = :farm_uid AND (<precondition>)`, returning the affected row count via `db.executable.execute(...).rowcount` (not `dataset`'s wrapped `db.query()`, which does not expose `rowcount`). The precondition and the write happen as a single atomic statement, so two concurrent attempts against the same stale precondition can never both succeed - the loser's `rowcount` is `0`, and the function re-reads the farm to produce the correct, specific `GameError` (already owned / maxed out / insufficient funds / state changed - refresh and retry).
**Load-bearing gotcha: every precondition column must be `COALESCE`d.** None of `prestige`, `perk_*`, `legacy_*`, `stars`, `mastery_*`, `infra_*`, or `defense_level` are set in `ensure_farm`'s insert - a real, never-yet-touched farm has them as SQL `NULL`, not `0`, and `NULL = 0` evaluates to `NULL` (false) in a `WHERE` clause. A first version of this fix compared bare `column = :current_level` and was verified "safe" only because the verification script had incorrectly pre-seeded those columns to `0` - against a genuinely fresh farm it silently rejected every legitimate first purchase. Every precondition and every arithmetic `SET` on a column that is not set at farm creation must read `COALESCE(column, 0)`; `coins`, `plot_count`, and `ci_tier` are the only farm columns set in `ensure_farm` and are the only ones safe to compare bare.
`buy_plot` additionally reserves the plot slot atomically (`WHERE plot_count = :current_count AND coins >= :cost`) **before** inserting the `game_plots` row - `idx_game_plots_farm (farm_uid, slot_index)` is not a unique index, so without the reservation two concurrent buys could insert two rows at the same `slot_index` (silent data corruption, not just a rejected purchase). `prestige` reserves the whole refactor transition atomically first (`WHERE COALESCE(prestige, 0) = :old_prestige`, in the same statement as every other reset field) and only mutates `game_plots` after that reservation wins, so a losing concurrent `prestige` call touches no plot data at all. Verified by forcing real concurrent OS processes (not threads - `dataset` gives each thread its own pooled connection, and enough of them exhausts the pool and produces `database is locked` noise that has nothing to do with the game logic) against genuinely fresh, un-seeded farm state; every purchase's final coins/stars/level exactly matches hand-computed expected totals under contention, never a partial or double charge.
### 1. Market Saturation (`economy.py` + `store/market.py`)
A new, genuinely new table `game_market_ticks` (`crop_key`, `hour_bucket` = UTC `"YYYY-MM-DDTHH"`, `harvests` count, unique on `(crop_key, hour_bucket)`) tracks the last `MARKET_WINDOW_HOURS` (48h) of production **per crop, globally** - recorded only for owner harvests/auto-harvests (`store/actions.py` calls `market.record_harvest_tick` after a successful clear), deliberately **not** for steals (a raid moves already-produced value, it does not print new supply, so counting it would double-count). `market.recent_harvests(crop_key, window_hours)` sums the trailing buckets via a raw `SELECT SUM` (cached 30s in a plain `TTLCache`, cosmetic/economic staleness only, no `cache_state` wiring needed). `economy.market_saturation_factor(recent_harvests)` steps the payout down through `MARKET_SATURATION_TIERS` (100% -> 40% past 600 recent harvests); `economy.market_buff_factor(crop_key, saturation_factor)` gives the four starter crops (`MARKET_BUFFED_CROPS`) up to `MARKET_BUFF_CAP` (+15%) relief while the market is saturated. `market.market_factor_for(crop_key)` composes both into one multiplier, threaded as the new `market_factor` parameter on `economy.effective_reward_coins`/`steal_reward_coins`/`crop_payload` (default `1.0`, so any caller that omits it is unaffected). `GameCropOut.market_state` (`"normal"`/`"saturated"`/`"boosted"`) surfaces the live signal in the shop list before planting - both the server-rendered `_game_grid.html` plant `<option>` and its `GameFarm.js` JS mirror append a `" (saturated)"`/`" (boosted)"` text suffix to the crop label (an `<option>` cannot hold markup, so this is plain text, not a styled badge). `market.prune_ticks(older_than_hours=96)` keeps the table small; CLI `devplace game market prune`.
### 2. Coin sinks: Infrastructure, Defense, Cosmetics (`economy.py` + `store/infrastructure.py`, `store/defense.py`, `store/cosmetics.py`)
- **Infrastructure** (`POST /game/infrastructure/buy`, `store.buy_infrastructure`): three one-time, boolean-owned, prestige-gated buildings (`economy.INFRASTRUCTURE`, new `game_farms` columns `infra_registry`/`infra_canary`/`infra_observability`). `registry` (+15% grow speed for Rust/Compiler/Kernel, `economy.REGISTRY_BOOST_FACTOR`, wired into `grow_seconds_for`'s new `registry_boost` bool param and into water's bonus-seconds calc). `canary` (`store/infrastructure.py::roll_canary`, a plain `random.random()` roll per harvest - deliberately NOT the deterministic `is_golden` hash, since canary is a fresh roll every harvest, not a per-planting property - `CANARY_DOUBLE_CHANCE` 12% to double, `CANARY_FAIL_CHANCE` 6% to only refund the planting cost). `observability` (raises the steal-fraction floor from 0.10 to `OBSERVABILITY_STEAL_FLOOR` 0.30 for this owner; the original critique's "see raid attempts in real time" was dropped - no scouting/detection mechanic exists anywhere in the game, and this achieves the same "raids feel less brutal" goal without inventing one).
- **Defense** (`POST /game/defense/upgrade`, `store.upgrade_defense`/`charge_upkeep`): a leveled building (`economy.DEFENSE_TIERS`, new columns `defense_level`/`defense_last_upkeep_at`) that lowers the steal-fraction floor and raises steal grace per tier (`effective_steal_fraction`/`effective_steal_grace` both gained a `floor`/`extra_seconds` parameter, defaulting to today's values, combined additively with the pre-existing Legacy `defense_level`). Upkeep is the deliberate whale sink: `economy.daily_upkeep(tier, coins) = max(tier.upkeep_daily, coins * UPKEEP_WEALTH_PCT)` (0.2%/day) so a large balance pays real money, not a trivial flat fee. `charge_upkeep` runs **lazily inside `serialize_farm`**, in the exact same spot and spirit as `_auto_harvest` (no new tick, no background service): unpaid upkeep (past `UPKEEP_GRACE_DAYS`) decays the tier by one level instead of going negative.
- **Cosmetics** (`POST /game/cosmetics/{buy,equip}`, `store/cosmetics.py`, new table `game_cosmetics` since this catalog is meant to grow - unlike the fixed Infrastructure/Defense tiers): pure-status titles and plot skins (`economy.COSMETICS`), zero gameplay effect. `game_farms.active_title` holds the equipped title key; `economy.cosmetic_title_name(key)` resolves it to a display name everywhere a leaderboard entry surfaces `title` (never render the raw key). Title display is scoped to the Code Farm leaderboard/farm view only - it does not touch `_avatar_link.html` or any sitewide identity surface.
### 3. Mastery track and new crop families (`economy.py` prestige/mastery/crops sections + `store/mastery.py`)
Orthogonal to prestige, modeled directly on the existing Legacy shape (leveled, its own currency, survives prestige). **Two-counter design, load-bearing:** `mastery_points` (spendable, decreases on spend) vs `mastery_points_earned_total` (never decreases) - crop unlocks gate on the earned total so spending points can never re-lock content. `economy.mastery_points_awarded(old_prestige, new_prestige)` awards the FIRST Mastery point the moment prestige crosses `MASTERY_UNLOCK_PRESTIGE` (50) and one more every `MASTERY_PRESTIGE_STEP` (10) thereafter (`_mastery_total_for(p) = 1 + (p-50)//10` for `p>=50`, difference of the before/after totals - correct even if prestige ever jumps by more than 1). `store/actions.py::prestige()` adds the award into the same reset dict, alongside `stars`, as a field that is **not** zeroed. `POST /game/mastery` (`store.upgrade_mastery`) spends `mastery_points` on `economy.MASTERY_UPGRADES`: `autoreplant` (Continuous Delivery - wired via a shared `_try_autoreplant` helper called from both `harvest()` and `_auto_harvest()` after crediting, replants the same crop immediately if still affordable/unlocked), `analytics` (Farm Analytics - `GameFarmOut.mastery_analytics_unlocked` gates the `lifetime_coins_earned`/`lifetime_harvests` stats panel, rendered by `_game_analytics.html`/`data-analytics-host` inside the Mastery section - both counters are accumulated by `credit_farm`, so they are exact from the moment the upgrade is bought, not backfilled for prior activity), `contracts` (Legacy Contracts - unlocks the weekly contract slot, see section 6; this deliberately consolidates the critique's "Legacy Contracts" and "weekly contracts" into ONE mechanism rather than two redundant long-goal systems).
Three new high-tier crops (`distsys`/`mlpipe`/`secfort` in `economy.CROPS`) extend the `Crop` dataclass with three new **trailing, defaulted** fields (`min_mastery: int = 0`, `steal_immune: bool = False`, `era_key: str | None = None`) - safe because every existing `Crop(...)` construction is positional over the original 8 non-defaulted fields. They set `min_level=MAX_LEVEL` (so, like every other crop, they re-lock after each refactor until the player re-levels to 20 - consistent with how Kernel etc. already behave post-prestige) and `min_mastery=1` (gated on `mastery_points_earned_total`, checked both in `store/actions.py::plant()` - real enforcement - and in `economy.crop_payload`'s `locked` flag - display). `secfort` (Security Fortress) is `steal_immune=True`: `serialize_plot` forces `can_steal=False`/`steal_reason="immune"` and `store/actions.py::steal()` raises immediately, before any grace/cooldown check, when the target crop is immune.
### 4. Secondary leaderboards (`economy.py` scoring + `store/farm.py`)
`GET /game/leaderboard` gained an optional `board` query param (default `"score"`, so every existing caller/Devii/docs entry that omits it sees byte-identical output to before this shipped). `store/farm.py::leaderboard_for(board, limit)` dispatches through `LEADERBOARD_BOARDS` (`score` = the original `farm_score` board, `prestige`, `harvests` = this week's `harvests_week` counter, `raids` = average coins per **successful** raid with a minimum of `MIN_RAIDS_FOR_EFFICIENCY_BOARD` (3) qualifying raids - deliberately not "attempts," since failed steals are never persisted today and adding that write would be a spam vector for no real value, `time_to_kernel` = seconds between a farm's last `prestiged_at` and its next Kernel harvest, `fair_play` = `economy.fair_play_score(harvests_week, coins)` which rewards recent activity and penalizes hoarding) plus `era` (dispatches to `store/era.py::leaderboard_era`, always empty when no Era is running). `GameFarm.js._loadLeaderboard` renders a board-appropriate value per row instead of always showing the raw composite score - `raid_avg` (formatted `"Nc/raid"`) for the `raids` board and a formatted duration for `time_to_kernel`, falling back to `score` for every other board. New `game_farms` columns backing these: `harvests_week`/`harvests_week_start` (reset lazily in `serialize_farm` via `_reset_harvests_week` whenever the current ISO week - `common._iso_week` - differs from the stored one, same lazy-no-tick idiom as everything else), `prestiged_at` (stamped every `prestige()`), `last_kernel_harvest_prestige`/`time_to_kernel_seconds` (updated in `harvest`/`_auto_harvest` the first time a Kernel is harvested since the last refactor).
### 5. Eras / seasons (`economy.py` era scoring + `store/era.py`, `routers/admin/game.py`)
**Admin-triggered, not automatic** - a deliberate deviation from "reset every 4-6 weeks": an unattended live-leaderboard reset in production is exactly the kind of surprising action this project treats with caution, so `POST /admin/game/era/start` and `/era/end` (`/admin/game` page, `require_admin`) are the only way an Era starts or ends. Two new tables: `game_eras` (at most one row `active=1` at a time - **zero active rows is the permanent no-op state**, everything below is dormant until the first Era is started) and `game_era_results` (the permanent per-user historical record written once at era-end, before counters reset). Three new `game_farms` columns, `era_coins`/`era_harvests`/`era_joined_at`, are a **shadow, resettable counter** completely separate from the real, permanent `coins`/`total_harvests` - `start_era` bulk-resets only these three to zero/now for every farm; `end_era` resets `era_coins`/`era_harvests` back to zero for every participant it ranks (in the same `_update_farm` call that awards Stars, so a farm never carries a stale Era total into the next dormant period) while never touching real balances, prestige, stars, Legacy, or Mastery. While an Era is active, `game.html`'s Era banner shows the owner's own live `era_coins`/`era_harvests` (via `data-era-coins`/`data-era-harvests`, kept current by `GameFarm.js._renderHud` on every action), not just the fact that a season is running. `economy.era_score(era_coins, era_harvests, prestige)` is a DISTINCT scoring function from `economy.farm_score` - it gives prestige only `ERA_PRESTIGE_CARRYOVER_PCT` (40%) weight so veterans keep an edge on the Era board specifically without it being insurmountable, while the permanent leaderboards (section 4) keep ranking by full lifetime stats untouched. `end_era` ranks every farm with any Era activity, awards `economy.ERA_REWARD_STARS_BY_RANK` Stars to the top 10 (added to the permanent `stars`, exactly like a refactor's `stars_for_refactor`) and, when the active Era name matches a `Cosmetic.era_key`, grants an Era-exclusive cosmetic. Era-exclusive crops reuse the `Crop.era_key` field from section 3 (`unlocked_crops`/`crop_payload` hide them unless the active Era's name matches - a no-op for every crop shipped so far, all of which have `era_key=None`).
### 6. Engagement: Underdog raids, weekly Contracts, client-side ready pings
**Underdog** (`economy.UNDERDOG_COIN_RATIO`=10, `UNDERDOG_DURATION_HOURS`=24, `UNDERDOG_MULTIPLIER`=1.25): in `store/actions.py::steal()`, if the owner's coins exceed 10x the thief's, the thief's `underdog_boost_until` is stamped; `_harvest_crop` applies the +25% multiplier while that timestamp is in the future. `GameFarmOut.underdog_boost_seconds_remaining` drives a HUD banner; `steal()`'s result carries `underdog_triggered` so `routers/game/farm.py::steal_farm` can fire the **David vs Goliath** badge.
**Weekly Contracts** extend the existing daily-quest engine (`game_quests` table) rather than building a second system: a new `scope` column (`"daily"`/`"weekly"`, existing/new daily rows always `"daily"` - backfilled on migration via `UPDATE game_quests SET scope='daily' WHERE scope IS NULL OR scope=''` so in-flight quests from before this shipped are never duplicated) and `reward_stars`. `store/quests.py::ensure_weekly_contract` materializes one `economy.weekly_contract(user_uid, iso_week)` row per ISO week (deterministic sha256 pick, same idiom as `daily_quests`), gated on owning the `mastery_contracts` upgrade; `advance_quests` advances both the day's rows and the current week's row in one pass. `claim_quest(user, kind, scope="daily")` claims either; a weekly claim pays Stars plus a `contract_boost_until` timestamp that grants `WEEKLY_CONTRACT_BOOST_MULTIPLIER` (1.2x) coins for `WEEKLY_CONTRACT_BOOST_HOURS` (48h), applied in `_harvest_crop` the same way as the Underdog boost.
**"Crops ready" notifications are client-side only** (`GameFarm.js`'s existing 1-second ticker fires a toast/desktop `Notification` when a countdown hits zero while the tab is open) - this respects the hard "no background tick" architectural invariant, since nothing server-side needs to detect the transition. **"Someone is scouting you" was not built** - like Observability's real-time alert, it would require inventing a scouting/detection mechanic that does not exist. **Alliance/Farm Coop was deliberately deferred** to its own follow-up plan - it is a new multiplayer social feature (group membership, shared resource pooling, its own invite/leave/kick UX), qualitatively different from every economy-tuning change here, and was judged to deserve its own design pass rather than being folded into this rebalance.
### 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

@ -35,6 +35,9 @@ class Crop:
reward_coins: int reward_coins: int
reward_xp: int reward_xp: int
min_level: int min_level: int
min_mastery: int = 0
steal_immune: bool = False
era_key: str | None = None
CROPS: tuple[Crop, ...] = ( CROPS: tuple[Crop, ...] = (
@ -45,9 +48,14 @@ CROPS: tuple[Crop, ...] = (
Crop("rust", "Rust Engine", "\U0001f980", 200, 1800, 520, 60, 4), Crop("rust", "Rust Engine", "\U0001f980", 200, 1800, 520, 60, 4),
Crop("haskell", "Compiler", "λ", 500, 3600, 1380, 150, 6), Crop("haskell", "Compiler", "λ", 500, 3600, 1380, 150, 6),
Crop("kernel", "Kernel", "⚙️", 1200, 7200, 3600, 400, 8), Crop("kernel", "Kernel", "⚙️", 1200, 7200, 3600, 400, 8),
Crop("distsys", "Distributed System", "\U0001f578", 5000, 14400, 9500, 900, MAX_LEVEL, min_mastery=1),
Crop("mlpipe", "ML Pipeline", "\U0001f9e0", 12000, 21600, 21000, 1800, MAX_LEVEL, min_mastery=1),
Crop("secfort", "Security Fortress", "\U0001f510", 30000, 28800, 48000, 3200, MAX_LEVEL, min_mastery=1, steal_immune=True),
) )
CROP_BY_KEY = {crop.key: crop for crop in CROPS} CROP_BY_KEY = {crop.key: crop for crop in CROPS}
MARKET_TRACKED_CROPS = ("shell", "python", "webapp", "api", "rust", "haskell", "kernel")
REGISTRY_BOOST_CROPS = ("rust", "haskell", "kernel")
@dataclass(frozen=True) @dataclass(frozen=True)
@ -83,6 +91,9 @@ def next_ci_tier(tier: int) -> CiTier | None:
return CI_BY_TIER.get(tier + 1) return CI_BY_TIER.get(tier + 1)
REGISTRY_BOOST_FACTOR = 1.15
def farm_speed(ci_tier: int, growth_level: int = 0, legacy_speed_level: int = 0) -> float: def farm_speed(ci_tier: int, growth_level: int = 0, legacy_speed_level: int = 0) -> float:
return ( return (
ci_speed(ci_tier) ci_speed(ci_tier)
@ -92,11 +103,16 @@ def farm_speed(ci_tier: int, growth_level: int = 0, legacy_speed_level: int = 0)
def grow_seconds_for( def grow_seconds_for(
crop: Crop, ci_tier: int, growth_level: int = 0, legacy_speed_level: int = 0 crop: Crop,
ci_tier: int,
growth_level: int = 0,
legacy_speed_level: int = 0,
registry_boost: bool = False,
) -> int: ) -> int:
return max( speed = farm_speed(ci_tier, growth_level, legacy_speed_level)
1, round(crop.grow_seconds / farm_speed(ci_tier, growth_level, legacy_speed_level)) if registry_boost and crop.key in REGISTRY_BOOST_CROPS:
) speed *= REGISTRY_BOOST_FACTOR
return max(1, round(crop.grow_seconds / speed))
def water_bonus_seconds( def water_bonus_seconds(
@ -178,8 +194,16 @@ def farm_score(farm: dict) -> int:
) )
def unlocked_crops(level: int) -> list[Crop]: def unlocked_crops(
return [crop for crop in CROPS if crop.min_level <= level] level: int, mastery_earned: int = 0, active_era_name: str | None = None
) -> list[Crop]:
return [
crop
for crop in CROPS
if crop.min_level <= level
and crop.min_mastery <= mastery_earned
and (crop.era_key is None or crop.era_key == active_era_name)
]
def crop_payload( def crop_payload(
@ -192,19 +216,32 @@ def crop_payload(
prestige: int = 0, prestige: int = 0,
legacy_mult_level: int = 0, legacy_mult_level: int = 0,
legacy_speed_level: int = 0, legacy_speed_level: int = 0,
market_factor: float = 1.0,
mastery_earned: int = 0,
active_era_name: str | None = None,
registry_boost: bool = False,
) -> dict: ) -> dict:
era_locked = crop.era_key is not None and crop.era_key != active_era_name
market_state = "normal"
if market_factor < 1.0:
market_state = "saturated"
elif market_factor > 1.0:
market_state = "boosted"
return { return {
"key": crop.key, "key": crop.key,
"name": crop.name, "name": crop.name,
"icon": crop.icon, "icon": crop.icon,
"cost": effective_plant_cost(crop, discount_level), "cost": effective_plant_cost(crop, discount_level),
"reward_coins": effective_reward_coins( "reward_coins": effective_reward_coins(
crop, yield_level, prestige, legacy_mult_level crop, yield_level, prestige, legacy_mult_level, market_factor
), ),
"reward_xp": crop.reward_xp, "reward_xp": crop.reward_xp,
"min_level": crop.min_level, "min_level": crop.min_level,
"grow_seconds": grow_seconds_for(crop, ci_tier, growth_level, legacy_speed_level), "grow_seconds": grow_seconds_for(
"locked": crop.min_level > level, crop, ci_tier, growth_level, legacy_speed_level, registry_boost
),
"locked": crop.min_level > level or crop.min_mastery > mastery_earned or era_locked,
"market_state": market_state,
} }
@ -305,12 +342,12 @@ def stars_for_refactor(level: int, prestige: int) -> int:
return STAR_BASE + level // 5 + max(0, prestige) return STAR_BASE + level // 5 + max(0, prestige)
def effective_steal_grace(defense_level: int = 0) -> int: def effective_steal_grace(defense_level: int = 0, extra_seconds: int = 0) -> int:
return STEAL_GRACE_SECONDS + LEGACY_DEFENSE_GRACE * max(0, defense_level) return STEAL_GRACE_SECONDS + LEGACY_DEFENSE_GRACE * max(0, defense_level) + max(0, extra_seconds)
def effective_steal_fraction(defense_level: int = 0) -> float: def effective_steal_fraction(defense_level: int = 0, floor: float = 0.1) -> float:
return max(0.1, STEAL_FRACTION - LEGACY_DEFENSE_FRACTION * max(0, defense_level)) return max(floor, STEAL_FRACTION - LEGACY_DEFENSE_FRACTION * max(0, defense_level))
def prestige_base_plots(legacy_plots_level: int = 0) -> int: def prestige_base_plots(legacy_plots_level: int = 0) -> int:
@ -368,19 +405,32 @@ def prestige_multiplier(prestige: int) -> float:
return 1 + PRESTIGE_BONUS * max(0, prestige) return 1 + PRESTIGE_BONUS * max(0, prestige)
UNDERDOG_COIN_RATIO = 10
UNDERDOG_DURATION_HOURS = 24
UNDERDOG_MULTIPLIER = 1.25
def effective_plant_cost(crop: Crop, discount_level: int = 0) -> int: def effective_plant_cost(crop: Crop, discount_level: int = 0) -> int:
factor = max(0.0, 1 - PERK_BY_KEY["discount"].step * discount_level) factor = max(0.0, 1 - PERK_BY_KEY["discount"].step * discount_level)
return max(1, round(crop.cost * factor)) return max(1, round(crop.cost * factor))
def effective_reward_coins( def effective_reward_coins(
crop: Crop, yield_level: int = 0, prestige: int = 0, legacy_mult_level: int = 0 crop: Crop,
yield_level: int = 0,
prestige: int = 0,
legacy_mult_level: int = 0,
market_factor: float = 1.0,
underdog: bool = False,
) -> int: ) -> int:
factor = ( factor = (
(1 + PERK_BY_KEY["yield"].step * yield_level) (1 + PERK_BY_KEY["yield"].step * yield_level)
* prestige_multiplier(prestige) * prestige_multiplier(prestige)
* legacy_multiplier(legacy_mult_level) * legacy_multiplier(legacy_mult_level)
* market_factor
) )
if underdog:
factor *= UNDERDOG_MULTIPLIER
return round(crop.reward_coins * factor) return round(crop.reward_coins * factor)
@ -394,12 +444,14 @@ def steal_reward_coins(
prestige: int = 0, prestige: int = 0,
legacy_mult_level: int = 0, legacy_mult_level: int = 0,
defense_level: int = 0, defense_level: int = 0,
market_factor: float = 1.0,
steal_floor: float = 0.1,
) -> int: ) -> int:
fraction = effective_steal_fraction(defense_level) fraction = effective_steal_fraction(defense_level, steal_floor)
return max( return max(
1, 1,
round( round(
effective_reward_coins(crop, yield_level, prestige, legacy_mult_level) effective_reward_coins(crop, yield_level, prestige, legacy_mult_level, market_factor)
* fraction * fraction
), ),
) )
@ -472,3 +524,256 @@ def daily_quests(user_uid: str, day: str) -> list[dict]:
} }
) )
return quests return quests
WEEKLY_CONTRACT_STAR_DIVISOR = 40
WEEKLY_CONTRACT_XP_DIVISOR = 4
WEEKLY_CONTRACT_BOOST_MULTIPLIER = 1.2
WEEKLY_CONTRACT_BOOST_HOURS = 48
def weekly_contract(user_uid: str, iso_week: str) -> dict:
seed = int(hashlib.sha256(f"{user_uid}:{iso_week}".encode()).hexdigest(), 16)
kind = QUEST_KINDS[seed % len(QUEST_KINDS)]
definition = QUEST_DEFS[kind]
goal = (definition.goal_max + definition.goal_min) * 4
if kind == "earn":
goal = max(definition.goal_min, (goal // 10) * 10)
reward_stars = max(1, goal // WEEKLY_CONTRACT_STAR_DIVISOR)
reward_xp = max(1, goal // WEEKLY_CONTRACT_XP_DIVISOR)
return {
"kind": kind,
"label": f"Weekly: {definition.label.format(goal=goal)}",
"goal": goal,
"reward_stars": reward_stars,
"reward_xp": reward_xp,
}
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:
factor = MARKET_SATURATION_TIERS[0][1]
for threshold, tier_factor in MARKET_SATURATION_TIERS:
if recent_harvests >= threshold:
factor = tier_factor
return factor
def market_buff_factor(crop_key: str, saturation_factor: float) -> float:
if crop_key not in MARKET_BUFFED_CROPS:
return 1.0
relief = 1.0 - saturation_factor
return min(MARKET_BUFF_CAP, 1.0 + relief)
@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",
"\U0001f4e6",
"Rust, Compiler, and Kernel crops grow 15% faster",
25_000_000,
3,
),
Infrastructure(
"canary",
"Canary Deployments",
"\U0001f424",
"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",
"\U0001f52d",
"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}
CANARY_DOUBLE_CHANCE = 0.12
CANARY_FAIL_CHANCE = 0.06
OBSERVABILITY_STEAL_FLOOR = 0.3
def infra_for(key: str) -> Infrastructure | None:
return INFRA_BY_KEY.get(key)
@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),
)
MAX_DEFENSE_LEVEL = DEFENSE_TIERS[-1].level
DEFENSE_BY_LEVEL = {tier.level: tier for tier in DEFENSE_TIERS}
UPKEEP_WEALTH_PCT = 0.002
UPKEEP_GRACE_DAYS = 2
def defense_tier(level: int) -> DefenseTier:
return DEFENSE_BY_LEVEL.get(max(0, level), DEFENSE_TIERS[0])
def next_defense_tier(level: int) -> DefenseTier | None:
return DEFENSE_BY_LEVEL.get(level + 1)
def daily_upkeep(tier: DefenseTier, coins: int) -> int:
return max(tier.upkeep_daily, round(coins * UPKEEP_WEALTH_PCT))
@dataclass(frozen=True)
class Cosmetic:
key: str
name: str
icon: str
description: str
cost_coins: int
kind: str
era_key: str | None = None
COSMETICS: tuple[Cosmetic, ...] = (
Cosmetic("title_architect", "The Architect", "\U0001f3db", "A permanent title shown on the leaderboard", 500_000, "title"),
Cosmetic("title_refactorer", "Serial Refactorer", "", "A permanent title for the serially prestiged", 250_000, "title"),
Cosmetic("title_kernel_hacker", "Kernel Hacker", "", "A permanent title for Kernel harvesters", 1_000_000, "title"),
Cosmetic("skin_neon", "Neon Terminal", "\U0001f308", "A cosmetic plot skin, no gameplay effect", 750_000, "skin"),
)
COSMETIC_BY_KEY = {c.key: c for c in COSMETICS}
def cosmetic_for(key: str) -> Cosmetic | None:
return COSMETIC_BY_KEY.get(key)
def cosmetic_title_name(key: str) -> str:
cosmetic = COSMETIC_BY_KEY.get(key or "")
return cosmetic.name if cosmetic and cosmetic.kind == "title" else ""
MASTERY_UNLOCK_PRESTIGE = 50
MASTERY_PRESTIGE_STEP = 10
def _mastery_total_for(prestige: int) -> int:
if prestige < MASTERY_UNLOCK_PRESTIGE:
return 0
return 1 + (prestige - MASTERY_UNLOCK_PRESTIGE) // MASTERY_PRESTIGE_STEP
def mastery_points_awarded(old_prestige: int, new_prestige: int) -> int:
return max(0, _mastery_total_for(new_prestige) - _mastery_total_for(old_prestige))
@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",
"\U0001f501",
"Automatically replant the same crop right after harvest, if affordable",
1,
3,
1.0,
),
MasteryUpgrade(
"analytics",
"Farm Analytics",
"\U0001f4ca",
"Unlocks lifetime stats on your farm HUD",
1,
2,
1.0,
),
MasteryUpgrade(
"contracts",
"Legacy Contracts",
"\U0001f4dc",
"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_for(key: str) -> MasteryUpgrade | None:
return MASTERY_BY_KEY.get(key)
def mastery_cost(m: MasteryUpgrade, level: int) -> int:
return round(m.base_cost * (m.cost_growth ** level))
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
ERA_PRESTIGE_CARRYOVER_PCT = 0.4
ERA_REWARD_STARS_BY_RANK: tuple[int, ...] = (50, 30, 20, 15, 10, 5, 5, 5, 5, 5)
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)
)
def era_reward_stars(rank: int) -> int:
if rank < 1 or rank > len(ERA_REWARD_STARS_BY_RANK):
return 0
return ERA_REWARD_STARS_BY_RANK[rank - 1]

View File

@ -27,6 +27,7 @@ from .common import (
PERK_COLUMN, PERK_COLUMN,
_farms, _farms,
_iso, _iso,
_iso_week,
_lvl, _lvl,
_now, _now,
_parse, _parse,
@ -36,9 +37,13 @@ from .common import (
_steals, _steals,
_today, _today,
_update_farm, _update_farm,
credit_farm,
last_steal_at, last_steal_at,
steal_cooldown_remaining, steal_cooldown_remaining,
) )
from .cosmetics import buy_cosmetic, equip_title, owned_cosmetic_keys
from .defense import charge_upkeep, upgrade_defense
from .era import active_era, active_era_name, end_era, start_era
from .farm import ( from .farm import (
_create_plot, _create_plot,
_plot_at, _plot_at,
@ -47,8 +52,12 @@ from .farm import (
get_farm, get_farm,
get_plots, get_plots,
leaderboard, leaderboard,
leaderboard_for,
upgrade_ci, upgrade_ci,
) )
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 .quests import advance_quests, ensure_quests
from .serialize import ( from .serialize import (
_daily_available, _daily_available,

View File

@ -11,7 +11,10 @@ from .. import economy
from .common import ( from .common import (
GameError, GameError,
PERK_COLUMN, PERK_COLUMN,
conditional_update_farm,
credit_farm,
_iso, _iso,
_iso_week,
_lvl, _lvl,
_now, _now,
_parse, _parse,
@ -23,7 +26,11 @@ from .common import (
_update_farm, _update_farm,
steal_cooldown_remaining, steal_cooldown_remaining,
) )
from .era import active_era_name
from .farm import _create_plot, _plot_at, ensure_farm, get_farm, get_plots from .farm import _create_plot, _plot_at, ensure_farm, get_farm, get_plots
from .infrastructure import owns_infrastructure, roll_canary
from .market import market_factor_for, record_harvest_tick
from .mastery import MASTERY_COLUMN
from .quests import advance_quests, ensure_quests from .quests import advance_quests, ensure_quests
from .serialize import _daily_available, _plot_state, _watered_by from .serialize import _daily_available, _plot_state, _watered_by
@ -36,6 +43,10 @@ def plant(user: dict, slot: int, crop_key: str) -> dict:
progress = economy.level_progress(int(farm.get("xp", 0))) progress = economy.level_progress(int(farm.get("xp", 0)))
if crop.min_level > progress["level"]: if crop.min_level > progress["level"]:
raise GameError(f"{crop.name} unlocks at level {crop.min_level}.") raise GameError(f"{crop.name} unlocks at level {crop.min_level}.")
if crop.min_mastery > _lvl(farm, "mastery_points_earned_total"):
raise GameError(f"{crop.name} unlocks after reaching Mastery.")
if crop.era_key and crop.era_key != active_era_name():
raise GameError(f"{crop.name} is not available right now.")
plot = _plot_at(farm["uid"], slot) plot = _plot_at(farm["uid"], slot)
if not plot: if not plot:
raise GameError("That plot does not exist.") raise GameError("That plot does not exist.")
@ -48,7 +59,11 @@ def plant(user: dict, slot: int, crop_key: str) -> dict:
now = _now() now = _now()
ci_tier = int(farm.get("ci_tier", 1)) ci_tier = int(farm.get("ci_tier", 1))
grow = economy.grow_seconds_for( grow = economy.grow_seconds_for(
crop, ci_tier, _lvl(farm, "perk_growth"), _lvl(farm, "legacy_speed") crop,
ci_tier,
_lvl(farm, "perk_growth"),
_lvl(farm, "legacy_speed"),
owns_infrastructure(farm, "registry"),
) )
ready_at = now + timedelta(seconds=grow) ready_at = now + timedelta(seconds=grow)
_plots().update( _plots().update(
@ -67,6 +82,34 @@ def plant(user: dict, slot: int, crop_key: str) -> dict:
return {"slot": slot, "crop": crop.key, "spent": cost} return {"slot": slot, "crop": crop.key, "spent": cost}
def _harvest_crop(farm: dict, crop, plot_uid: str, planted_at: str, now) -> dict:
prestige = _lvl(farm, "prestige")
golden = economy.is_golden(plot_uid, planted_at)
market_factor = market_factor_for(crop.key)
underdog = bool(farm.get("underdog_boost_until")) and now < (
_parse(farm.get("underdog_boost_until") or "") or now
)
coins_gain = economy.effective_reward_coins(
crop,
_lvl(farm, "perk_yield"),
prestige,
_lvl(farm, "legacy_multiplier"),
market_factor,
underdog,
)
if golden:
coins_gain *= economy.GOLDEN_MULTIPLIER
contract_boost_until = _parse(farm.get("contract_boost_until") or "")
if contract_boost_until and now < contract_boost_until:
coins_gain = round(coins_gain * economy.WEEKLY_CONTRACT_BOOST_MULTIPLIER)
if owns_infrastructure(farm, "canary"):
plant_cost = economy.effective_plant_cost(crop, _lvl(farm, "perk_discount"))
coins_gain = roll_canary(coins_gain, plant_cost)
xp_gain = economy.effective_reward_xp(crop, _lvl(farm, "perk_xp"))
record_harvest_tick(crop.key, now)
return {"coins": coins_gain, "xp": xp_gain, "golden": golden}
def harvest(user: dict, slot: int) -> dict: def harvest(user: dict, slot: int) -> dict:
farm = ensure_farm(user["uid"]) farm = ensure_farm(user["uid"])
plot = _plot_at(farm["uid"], slot) plot = _plot_at(farm["uid"], slot)
@ -78,6 +121,7 @@ def harvest(user: dict, slot: int) -> dict:
crop = economy.crop_for(plot.get("crop_key", "")) crop = economy.crop_for(plot.get("crop_key", ""))
if not crop: if not crop:
raise GameError("Unknown crop type.") raise GameError("Unknown crop type.")
planted_at = plot.get("planted_at", "")
_plots().update( _plots().update(
{ {
"uid": plot["uid"], "uid": plot["uid"],
@ -89,26 +133,26 @@ def harvest(user: dict, slot: int) -> dict:
}, },
["uid"], ["uid"],
) )
prestige = _lvl(farm, "prestige") result = _harvest_crop(farm, crop, plot.get("uid", ""), planted_at, now)
golden = economy.is_golden(plot.get("uid", ""), plot.get("planted_at", "")) coins_gain, xp_gain, golden = result["coins"], result["xp"], result["golden"]
coins_gain = economy.effective_reward_coins( extra = {}
crop, _lvl(farm, "perk_yield"), prestige, _lvl(farm, "legacy_multiplier") if crop.key == "kernel" and _lvl(farm, "last_kernel_harvest_prestige") != _lvl(farm, "prestige"):
) prestiged_at = _parse(farm.get("prestiged_at") or "")
if golden: if prestiged_at:
coins_gain *= economy.GOLDEN_MULTIPLIER extra["time_to_kernel_seconds"] = max(0, int((now - prestiged_at).total_seconds()))
xp_gain = economy.effective_reward_xp(crop, _lvl(farm, "perk_xp")) extra["last_kernel_harvest_prestige"] = _lvl(farm, "prestige")
new_xp = int(farm.get("xp", 0)) + xp_gain farm = credit_farm(
_update_farm( farm,
farm["uid"], coins=coins_gain,
{ xp=xp_gain,
"coins": int(farm.get("coins", 0)) + coins_gain, harvests=1,
"xp": new_xp, era_active=bool(active_era_name()),
"level": economy.level_for_xp(new_xp), extra=extra or None,
"total_harvests": int(farm.get("total_harvests", 0)) + 1,
},
) )
advance_quests(user["uid"], "harvest", 1) advance_quests(user["uid"], "harvest", 1)
advance_quests(user["uid"], "earn", coins_gain) advance_quests(user["uid"], "earn", coins_gain)
if _lvl(farm, MASTERY_COLUMN["autoreplant"]):
_try_autoreplant(farm, slot, crop, now)
return { return {
"slot": slot, "slot": slot,
"crop": crop.key, "crop": crop.key,
@ -118,6 +162,35 @@ def harvest(user: dict, slot: int) -> dict:
} }
def _try_autoreplant(farm: dict, slot: int, crop, now) -> None:
plot = _plot_at(farm["uid"], slot)
if not plot or plot.get("crop_key"):
return
cost = economy.effective_plant_cost(crop, _lvl(farm, "perk_discount"))
if int(farm.get("coins", 0)) < cost:
return
grow = economy.grow_seconds_for(
crop,
int(farm.get("ci_tier", 1)),
_lvl(farm, "perk_growth"),
_lvl(farm, "legacy_speed"),
owns_infrastructure(farm, "registry"),
)
ready_at = now + timedelta(seconds=grow)
_plots().update(
{
"uid": plot["uid"],
"crop_key": crop.key,
"planted_at": _iso(now),
"ready_at": _iso(ready_at),
"watered_by": "[]",
"updated_at": _iso(now),
},
["uid"],
)
_update_farm(farm["uid"], {"coins": int(farm.get("coins", 0)) - cost})
def water(visitor: dict, owner: dict, slot: int) -> dict: def water(visitor: dict, owner: dict, slot: int) -> dict:
if visitor["uid"] == owner["uid"]: if visitor["uid"] == owner["uid"]:
raise GameError("You cannot water your own build.") raise GameError("You cannot water your own build.")
@ -138,6 +211,8 @@ def water(visitor: dict, owner: dict, slot: int) -> dict:
bonus = economy.water_bonus_seconds( bonus = economy.water_bonus_seconds(
crop, int(farm.get("ci_tier", 1)), _lvl(farm, "perk_growth"), _lvl(farm, "legacy_speed") crop, int(farm.get("ci_tier", 1)), _lvl(farm, "perk_growth"), _lvl(farm, "legacy_speed")
) )
if owns_infrastructure(farm, "registry") and crop.key in economy.REGISTRY_BOOST_CROPS:
bonus = round(bonus * economy.REGISTRY_BOOST_FACTOR)
new_ready = max(now, ready_at - timedelta(seconds=bonus)) new_ready = max(now, ready_at - timedelta(seconds=bonus))
watered.append(visitor["uid"]) watered.append(visitor["uid"])
_plots().update( _plots().update(
@ -181,9 +256,16 @@ def steal(thief: dict, owner: dict, slot: int) -> dict:
crop = economy.crop_for(plot.get("crop_key", "")) crop = economy.crop_for(plot.get("crop_key", ""))
if not crop: if not crop:
raise GameError("Unknown crop type.") raise GameError("Unknown crop type.")
if crop.steal_immune:
raise GameError("That build cannot be raided.")
defense_level = _lvl(farm, "legacy_defense") defense_level = _lvl(farm, "legacy_defense")
building_level = _lvl(farm, "defense_level")
building_tier = economy.defense_tier(building_level)
floor = building_tier.steal_fraction_floor
if owns_infrastructure(farm, "observability"):
floor = max(floor, economy.OBSERVABILITY_STEAL_FLOOR)
grace = economy.effective_steal_grace(defense_level, building_tier.grace_bonus)
ready_at = _parse(plot.get("ready_at", "")) or now ready_at = _parse(plot.get("ready_at", "")) or now
grace = economy.effective_steal_grace(defense_level)
if now < ready_at + timedelta(seconds=grace): if now < ready_at + timedelta(seconds=grace):
raise GameError("That harvest is still protected.") raise GameError("That harvest is still protected.")
cooldown = steal_cooldown_remaining(thief["uid"], owner["uid"], now) cooldown = steal_cooldown_remaining(thief["uid"], owner["uid"], now)
@ -204,17 +286,27 @@ def steal(thief: dict, owner: dict, slot: int) -> dict:
}, },
["uid"], ["uid"],
) )
market_factor = market_factor_for(crop.key)
coins_gain = economy.steal_reward_coins( coins_gain = economy.steal_reward_coins(
crop, crop,
_lvl(farm, "perk_yield"), _lvl(farm, "perk_yield"),
_lvl(farm, "prestige"), _lvl(farm, "prestige"),
_lvl(farm, "legacy_multiplier"), _lvl(farm, "legacy_multiplier"),
defense_level, defense_level,
market_factor,
floor,
) )
thief_farm = ensure_farm(thief["uid"]) thief_farm = ensure_farm(thief["uid"])
_update_farm( underdog_extra = {}
thief_farm["uid"], if int(farm.get("coins", 0)) > int(thief_farm.get("coins", 0)) * economy.UNDERDOG_COIN_RATIO:
{"coins": int(thief_farm.get("coins", 0)) + coins_gain}, underdog_extra["underdog_boost_until"] = _iso(
now + timedelta(hours=economy.UNDERDOG_DURATION_HOURS)
)
credit_farm(
thief_farm,
coins=coins_gain,
era_active=bool(active_era_name()),
extra=underdog_extra or None,
) )
_steals().insert( _steals().insert(
{ {
@ -233,24 +325,23 @@ def steal(thief: dict, owner: dict, slot: int) -> dict:
"crop": crop.key, "crop": crop.key,
"coins": coins_gain, "coins": coins_gain,
"owner_uid": owner["uid"], "owner_uid": owner["uid"],
"underdog_triggered": bool(underdog_extra),
} }
def _auto_harvest(farm: dict, owner_uid: str, now: datetime) -> dict: def _auto_harvest(farm: dict, owner_uid: str, now: datetime) -> dict:
yield_level = _lvl(farm, "perk_yield")
xp_level = _lvl(farm, "perk_xp")
prestige = _lvl(farm, "prestige")
mult_level = _lvl(farm, "legacy_multiplier")
coins_gain = 0 coins_gain = 0
xp_gain = 0 xp_gain = 0
harvested = 0 harvested = 0
replant_slots = []
extra = {}
for plot in get_plots(farm["uid"]): for plot in get_plots(farm["uid"]):
if _plot_state(plot, now) != "ready": if _plot_state(plot, now) != "ready":
continue continue
crop = economy.crop_for(plot.get("crop_key", "")) crop = economy.crop_for(plot.get("crop_key", ""))
if not crop: if not crop:
continue continue
golden = economy.is_golden(plot.get("uid", ""), plot.get("planted_at", "")) result = _harvest_crop(farm, crop, plot.get("uid", ""), plot.get("planted_at", ""), now)
_plots().update( _plots().update(
{ {
"uid": plot["uid"], "uid": plot["uid"],
@ -262,34 +353,47 @@ def _auto_harvest(farm: dict, owner_uid: str, now: datetime) -> dict:
}, },
["uid"], ["uid"],
) )
coins = economy.effective_reward_coins(crop, yield_level, prestige, mult_level) coins_gain += result["coins"]
if golden: xp_gain += result["xp"]
coins *= economy.GOLDEN_MULTIPLIER
coins_gain += coins
xp_gain += economy.effective_reward_xp(crop, xp_level)
harvested += 1 harvested += 1
replant_slots.append((int(plot.get("slot_index", 0)), crop))
if crop.key == "kernel" and _lvl(farm, "last_kernel_harvest_prestige") != _lvl(
farm, "prestige"
):
prestiged_at = _parse(farm.get("prestiged_at") or "")
if prestiged_at:
extra["time_to_kernel_seconds"] = max(0, int((now - prestiged_at).total_seconds()))
extra["last_kernel_harvest_prestige"] = _lvl(farm, "prestige")
if not harvested: if not harvested:
return farm return farm
new_xp = int(farm.get("xp", 0)) + xp_gain farm = credit_farm(
_update_farm( farm,
farm["uid"], coins=coins_gain,
{ xp=xp_gain,
"coins": int(farm.get("coins", 0)) + coins_gain, harvests=harvested,
"xp": new_xp, era_active=bool(active_era_name()),
"level": economy.level_for_xp(new_xp), extra=extra or None,
"total_harvests": int(farm.get("total_harvests", 0)) + harvested,
},
) )
advance_quests(owner_uid, "harvest", harvested) advance_quests(owner_uid, "harvest", harvested)
advance_quests(owner_uid, "earn", coins_gain) advance_quests(owner_uid, "earn", coins_gain)
if _lvl(farm, MASTERY_COLUMN["autoreplant"]):
for slot_index, crop in replant_slots:
_try_autoreplant(farm, slot_index, crop, now)
return get_farm(owner_uid) or farm return get_farm(owner_uid) or farm
def claim_quest(user: dict, kind: str) -> dict: def claim_quest(user: dict, kind: str, scope: str = "daily") -> dict:
from .quests import ensure_weekly_contract
farm = ensure_farm(user["uid"]) farm = ensure_farm(user["uid"])
now = _now()
if scope == "weekly":
day = _iso_week(now)
ensure_weekly_contract(farm, day)
else:
day = _today() day = _today()
ensure_quests(farm, day) ensure_quests(farm, day)
row = _quests().find_one(farm_uid=farm["uid"], day=day, kind=kind) row = _quests().find_one(farm_uid=farm["uid"], day=day, kind=kind, scope=scope)
if not row: if not row:
raise GameError("No such quest today.") raise GameError("No such quest today.")
if row.get("claimed"): if row.get("claimed"):
@ -299,19 +403,23 @@ def claim_quest(user: dict, kind: str) -> dict:
raise GameError("Quest not complete yet.") raise GameError("Quest not complete yet.")
reward_coins = int(row.get("reward_coins") or 0) reward_coins = int(row.get("reward_coins") or 0)
reward_xp = int(row.get("reward_xp") or 0) reward_xp = int(row.get("reward_xp") or 0)
reward_stars = int(row.get("reward_stars") or 0)
_quests().update( _quests().update(
{"uid": row["uid"], "claimed": 1, "updated_at": _iso(_now())}, ["uid"] {"uid": row["uid"], "claimed": 1, "updated_at": _iso(now)}, ["uid"]
) )
new_xp = int(farm.get("xp", 0)) + reward_xp extra = {"stars": _lvl(farm, "stars") + reward_stars} if reward_stars else None
_update_farm( if scope == "weekly":
farm["uid"], extra = extra or {}
{ extra["contract_boost_until"] = _iso(
"coins": int(farm.get("coins", 0)) + reward_coins, now + timedelta(hours=economy.WEEKLY_CONTRACT_BOOST_HOURS)
"xp": new_xp,
"level": economy.level_for_xp(new_xp),
},
) )
return {"kind": kind, "reward_coins": reward_coins, "reward_xp": reward_xp} credit_farm(farm, coins=reward_coins, xp=reward_xp, extra=extra)
return {
"kind": kind,
"reward_coins": reward_coins,
"reward_xp": reward_xp,
"reward_stars": reward_stars,
}
def claim_daily(user: dict) -> dict: def claim_daily(user: dict) -> dict:
@ -322,13 +430,11 @@ def claim_daily(user: dict) -> dict:
last = _parse_date(farm.get("last_daily_at") or "") last = _parse_date(farm.get("last_daily_at") or "")
streak = _lvl(farm, "streak") + 1 if last == now.date() - timedelta(days=1) else 1 streak = _lvl(farm, "streak") + 1 if last == now.date() - timedelta(days=1) else 1
reward = economy.daily_reward(streak) reward = economy.daily_reward(streak)
_update_farm( credit_farm(
farm["uid"], farm,
{ coins=reward,
"coins": int(farm.get("coins", 0)) + reward, era_active=bool(active_era_name()),
"streak": streak, extra={"streak": streak, "last_daily_at": _iso(now)},
"last_daily_at": _iso(now),
},
) )
return {"reward": reward, "streak": streak} return {"reward": reward, "streak": streak}
@ -343,9 +449,17 @@ def upgrade_perk(user: dict, perk_key: str) -> dict:
if level >= perk.max_level: if level >= perk.max_level:
raise GameError("That perk is maxed out.") raise GameError("That perk is maxed out.")
cost = economy.perk_cost(perk, level) cost = economy.perk_cost(perk, level)
if int(farm.get("coins", 0)) < cost: rows = conditional_update_farm(
farm["uid"],
set_clause=f"coins = coins - :cost, {column} = :new_level",
where_clause=f"COALESCE({column}, 0) = :current_level AND coins >= :cost",
params={"cost": cost, "new_level": level + 1, "current_level": level},
)
if rows == 0:
farm = get_farm(user["uid"])
if _lvl(farm, column) != level:
raise GameError("That perk already changed - refresh and try again.")
raise GameError("Not enough coins for that upgrade.") raise GameError("Not enough coins for that upgrade.")
_update_farm(farm["uid"], {"coins": int(farm.get("coins", 0)) - cost, column: level + 1})
return {"perk": perk.key, "level": level + 1, "spent": cost} return {"perk": perk.key, "level": level + 1, "spent": cost}
@ -359,11 +473,17 @@ def upgrade_legacy(user: dict, key: str) -> dict:
if level >= upgrade.max_level: if level >= upgrade.max_level:
raise GameError("That legacy upgrade is maxed out.") raise GameError("That legacy upgrade is maxed out.")
cost = economy.legacy_cost(upgrade, level) cost = economy.legacy_cost(upgrade, level)
if _lvl(farm, "stars") < cost: rows = conditional_update_farm(
raise GameError("Not enough stars for that legacy upgrade.") farm["uid"],
_update_farm( set_clause=f"stars = COALESCE(stars, 0) - :cost, {column} = :new_level",
farm["uid"], {"stars": _lvl(farm, "stars") - cost, column: level + 1} where_clause=f"COALESCE({column}, 0) = :current_level AND COALESCE(stars, 0) >= :cost",
params={"cost": cost, "new_level": level + 1, "current_level": level},
) )
if rows == 0:
farm = get_farm(user["uid"])
if _lvl(farm, column) != level:
raise GameError("That legacy upgrade already changed - refresh and try again.")
raise GameError("Not enough stars for that legacy upgrade.")
return {"key": upgrade.key, "level": level + 1, "spent": cost} return {"key": upgrade.key, "level": level + 1, "spent": cost}
@ -374,10 +494,53 @@ def prestige(user: dict) -> dict:
raise GameError( raise GameError(
f"Reach level {economy.PRESTIGE_MIN_LEVEL} to refactor (prestige)." f"Reach level {economy.PRESTIGE_MIN_LEVEL} to refactor (prestige)."
) )
new_prestige = _lvl(farm, "prestige") + 1 old_prestige = _lvl(farm, "prestige")
stars_award = economy.stars_for_refactor(level, _lvl(farm, "prestige")) new_prestige = old_prestige + 1
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")) base_plots = economy.prestige_base_plots(_lvl(farm, "legacy_plots"))
now = _iso(_now()) now_dt = _now()
now = _iso(now_dt)
set_parts = [
"coins = :starting_coins",
"xp = 0",
"level = 1",
"ci_tier = 1",
"plot_count = :base_plots",
"prestige = :new_prestige",
"stars = COALESCE(stars, 0) + :stars_award",
"mastery_points = COALESCE(mastery_points, 0) + :mastery_award",
"mastery_points_earned_total = COALESCE(mastery_points_earned_total, 0) + :mastery_award",
"prestiged_at = :now",
]
params = {
"starting_coins": economy.STARTING_COINS,
"base_plots": base_plots,
"new_prestige": new_prestige,
"stars_award": stars_award,
"mastery_award": mastery_award,
"now": now,
"old_prestige": old_prestige,
}
for perk in economy.PERKS:
column = PERK_COLUMN[perk.key]
set_parts.append(f"{column} = 0")
rows = conditional_update_farm(
farm["uid"],
set_clause=", ".join(set_parts),
where_clause="COALESCE(prestige, 0) = :old_prestige",
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.")
raise GameError(
f"Reach level {economy.PRESTIGE_MIN_LEVEL} to refactor (prestige)."
)
kept_slots = set() kept_slots = set()
for plot in get_plots(farm["uid"]): for plot in get_plots(farm["uid"]):
if int(plot.get("slot_index", 0)) >= base_plots: if int(plot.get("slot_index", 0)) >= base_plots:
@ -398,19 +561,7 @@ def prestige(user: dict) -> dict:
for slot_index in range(base_plots): for slot_index in range(base_plots):
if slot_index not in kept_slots: if slot_index not in kept_slots:
_create_plot(farm["uid"], user["uid"], slot_index, now) _create_plot(farm["uid"], user["uid"], slot_index, now)
reset = { return {"prestige": new_prestige, "stars_awarded": stars_award, "mastery_awarded": mastery_award}
"coins": economy.STARTING_COINS,
"xp": 0,
"level": 1,
"ci_tier": 1,
"plot_count": base_plots,
"prestige": new_prestige,
"stars": _lvl(farm, "stars") + stars_award,
}
for perk in economy.PERKS:
reset[PERK_COLUMN[perk.key]] = 0
_update_farm(farm["uid"], reset)
return {"prestige": new_prestige, "stars_awarded": stars_award}
def fertilize(user: dict, slot: int) -> dict: def fertilize(user: dict, slot: int) -> dict:
@ -430,10 +581,18 @@ def fertilize(user: dict, slot: int) -> dict:
if reduce_by < 1: if reduce_by < 1:
raise GameError("That build is almost ready already.") raise GameError("That build is almost ready already.")
full_grow = economy.grow_seconds_for( full_grow = economy.grow_seconds_for(
crop, int(farm.get("ci_tier", 1)), _lvl(farm, "perk_growth"), _lvl(farm, "legacy_speed") crop,
int(farm.get("ci_tier", 1)),
_lvl(farm, "perk_growth"),
_lvl(farm, "legacy_speed"),
owns_infrastructure(farm, "registry"),
) )
eff_reward = economy.effective_reward_coins( eff_reward = economy.effective_reward_coins(
crop, _lvl(farm, "perk_yield"), _lvl(farm, "prestige"), _lvl(farm, "legacy_multiplier") crop,
_lvl(farm, "perk_yield"),
_lvl(farm, "prestige"),
_lvl(farm, "legacy_multiplier"),
market_factor_for(crop.key),
) )
cost = economy.fertilize_click_cost(eff_reward, reduce_by, full_grow) cost = economy.fertilize_click_cost(eff_reward, reduce_by, full_grow)
if int(farm.get("coins", 0)) < cost: if int(farm.get("coins", 0)) < cost:

View File

@ -34,6 +34,12 @@ def _today() -> str:
return _now().date().isoformat() return _now().date().isoformat()
def _iso_week(value: datetime | None = None) -> str:
value = value or _now()
year, week, _ = value.isocalendar()
return f"{year}-W{week:02d}"
def _parse_date(value: str): def _parse_date(value: str):
parsed = _parse(value) parsed = _parse(value)
return parsed.date() if parsed else None return parsed.date() if parsed else None
@ -82,3 +88,45 @@ PERK_COLUMN = {perk.key: f"perk_{perk.key}" for perk in economy.PERKS}
def _update_farm(farm_uid: str, fields: dict) -> None: def _update_farm(farm_uid: str, fields: dict) -> None:
fields = {**fields, "uid": farm_uid, "updated_at": _iso(_now())} fields = {**fields, "uid": farm_uid, "updated_at": _iso(_now())}
_farms().update(fields, ["uid"]) _farms().update(fields, ["uid"])
def conditional_update_farm(farm_uid: str, set_clause: str, where_clause: str, params: dict) -> int:
from sqlalchemy import text
from devplacepy.database import db
sql = (
f"UPDATE game_farms SET {set_clause}, updated_at = :updated_at "
f"WHERE uid = :farm_uid AND ({where_clause})"
)
bind = {**params, "updated_at": _iso(_now()), "farm_uid": farm_uid}
with db:
result = db.executable.execute(text(sql), bind)
return result.rowcount
def credit_farm(
farm: dict,
*,
coins: int = 0,
xp: int = 0,
harvests: int = 0,
era_active: bool = False,
extra: dict | None = None,
) -> dict:
new_xp = int(farm.get("xp", 0)) + xp
fields = {
"coins": max(0, int(farm.get("coins", 0)) + coins),
"xp": new_xp,
"level": economy.level_for_xp(new_xp),
"total_harvests": int(farm.get("total_harvests", 0)) + harvests,
"lifetime_coins_earned": max(0, _lvl(farm, "lifetime_coins_earned") + max(0, coins)),
"lifetime_harvests": _lvl(farm, "lifetime_harvests") + harvests,
}
if era_active:
fields["era_coins"] = _lvl(farm, "era_coins") + max(0, coins)
fields["era_harvests"] = _lvl(farm, "era_harvests") + harvests
if extra:
fields.update(extra)
_update_farm(farm["uid"], fields)
return {**farm, **fields}

View File

@ -0,0 +1,62 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from sqlalchemy.exc import IntegrityError
from devplacepy.database import get_table
from devplacepy.utils import generate_uid
from .. import economy
from .common import GameError, _iso, _now, _update_farm, conditional_update_farm
from .farm import ensure_farm
def _cosmetics():
return get_table("game_cosmetics")
def owned_cosmetic_keys(user_uid: str) -> set[str]:
return {row["cosmetic_key"] for row in _cosmetics().find(user_uid=user_uid)}
def buy_cosmetic(user: dict, key: str) -> dict:
cosmetic = economy.cosmetic_for(key)
if not cosmetic:
raise GameError("Unknown cosmetic.")
farm = ensure_farm(user["uid"])
now = _iso(_now())
row_uid = generate_uid()
try:
_cosmetics().insert(
{
"uid": row_uid,
"user_uid": user["uid"],
"cosmetic_key": key,
"purchased_at": now,
"created_at": now,
}
)
except IntegrityError:
raise GameError("You already own that cosmetic.")
rows = conditional_update_farm(
farm["uid"],
set_clause="coins = coins - :cost",
where_clause="coins >= :cost",
params={"cost": cosmetic.cost_coins},
)
if rows == 0:
_cosmetics().delete(uid=row_uid)
raise GameError("Not enough coins for that cosmetic.")
return {"key": key, "spent": cosmetic.cost_coins}
def equip_title(user: dict, key: str) -> dict:
cosmetic = economy.cosmetic_for(key)
if not cosmetic or cosmetic.kind != "title":
raise GameError("Unknown title.")
if key not in owned_cosmetic_keys(user["uid"]):
raise GameError("You do not own that title.")
farm = ensure_farm(user["uid"])
_update_farm(farm["uid"], {"active_title": key})
return {"active_title": key}

View File

@ -0,0 +1,86 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from datetime import datetime, timedelta
from .. import economy
from .common import GameError, _iso, _lvl, _parse, conditional_update_farm
from .farm import ensure_farm, get_farm
def upgrade_defense(user: dict) -> dict:
farm = ensure_farm(user["uid"])
level = _lvl(farm, "defense_level")
next_tier = economy.next_defense_tier(level)
if not next_tier:
raise GameError("Defense is already at the top tier.")
rows = conditional_update_farm(
farm["uid"],
set_clause="coins = coins - :cost, defense_level = :new_level",
where_clause="COALESCE(defense_level, 0) = :current_level AND coins >= :cost",
params={"cost": next_tier.upgrade_cost, "new_level": next_tier.level, "current_level": level},
)
if rows == 0:
farm = get_farm(user["uid"])
current_level = _lvl(farm, "defense_level")
if current_level != level:
raise GameError("Defense already changed - refresh and try again.")
raise GameError("Not enough coins to upgrade defense.")
return {"defense_level": next_tier.level, "spent": next_tier.upgrade_cost}
def charge_upkeep(farm: dict, now: datetime) -> dict:
level = _lvl(farm, "defense_level")
if level <= 0:
return farm
last_raw = farm.get("defense_last_upkeep_at") or ""
last = _parse(last_raw)
if not last:
rows = conditional_update_farm(
farm["uid"],
set_clause="defense_last_upkeep_at = :ts",
where_clause="(defense_last_upkeep_at IS NULL OR defense_last_upkeep_at = '') AND COALESCE(defense_level, 0) = :level",
params={"ts": _iso(now), "level": level},
)
if rows:
return {**farm, "defense_last_upkeep_at": _iso(now)}
return get_farm(farm["user_uid"]) or farm
elapsed_days = int((now - last).total_seconds() // 86400)
if elapsed_days < 1:
return farm
days_due = min(elapsed_days, economy.UPKEEP_GRACE_DAYS)
tier = economy.defense_tier(level)
coins = int(farm.get("coins", 0))
total_due = economy.daily_upkeep(tier, coins) * days_due
new_timestamp = _iso(last + timedelta(days=elapsed_days))
if coins >= total_due:
rows = conditional_update_farm(
farm["uid"],
set_clause="coins = coins - :total_due, defense_last_upkeep_at = :new_ts",
where_clause="defense_last_upkeep_at = :expected_last AND COALESCE(defense_level, 0) = :expected_level AND coins >= :total_due",
params={
"total_due": total_due,
"new_ts": new_timestamp,
"expected_last": last_raw,
"expected_level": level,
},
)
if rows == 0:
return get_farm(farm["user_uid"]) or farm
return {**farm, "coins": coins - total_due, "defense_last_upkeep_at": new_timestamp}
new_level = max(0, level - 1)
rows = conditional_update_farm(
farm["uid"],
set_clause="coins = 0, defense_level = :new_level, defense_last_upkeep_at = :new_ts",
where_clause="defense_last_upkeep_at = :expected_last AND COALESCE(defense_level, 0) = :expected_level",
params={"new_level": new_level, "new_ts": new_timestamp, "expected_last": last_raw, "expected_level": level},
)
if rows == 0:
return get_farm(farm["user_uid"]) or farm
return {
**farm,
"coins": 0,
"defense_level": new_level,
"defense_last_upkeep_at": new_timestamp,
}

View File

@ -0,0 +1,148 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from devplacepy.database import get_table, get_users_by_uids
from devplacepy.utils import generate_uid
from .. import economy
from .common import GameError, _farms, _iso, _lvl, _now, _update_farm
def _eras():
return get_table("game_eras")
def _era_results():
return get_table("game_era_results")
def active_era() -> dict | None:
return _eras().find_one(active=1)
def active_era_name() -> str | None:
era = active_era()
return era["name"] if era else None
def start_era(name: str, duration_days: int) -> dict:
if active_era():
raise GameError("An Era is already running.")
now = _now()
ends_at = now.replace(microsecond=0)
from datetime import timedelta
ends_at = ends_at + timedelta(days=max(1, duration_days))
last = list(_eras().find(order_by=["-era_number"]))
era_number = (int(last[0]["era_number"]) + 1) if last else 1
row = {
"uid": generate_uid(),
"era_number": era_number,
"name": name,
"started_at": _iso(now),
"ends_at": _iso(ends_at),
"active": 1,
"created_at": _iso(now),
}
_eras().insert(row)
now_iso = _iso(now)
for farm in _farms().find():
_update_farm(
farm["uid"],
{"era_coins": 0, "era_harvests": 0, "era_joined_at": now_iso},
)
return row
def end_era() -> dict:
era = active_era()
if not era:
raise GameError("No Era is currently running.")
farms = [f for f in _farms().find() if _lvl(f, "era_coins") or _lvl(f, "era_harvests")]
ranked = sorted(
farms,
key=lambda f: economy.era_score(
_lvl(f, "era_coins"), _lvl(f, "era_harvests"), _lvl(f, "prestige")
),
reverse=True,
)
now = _iso(_now())
from .cosmetics import _cosmetics
era_cosmetics = [c for c in economy.COSMETICS if c.era_key == era["name"]]
for rank, farm in enumerate(ranked, start=1):
stars_award = economy.era_reward_stars(rank)
reward_cosmetic_key = ""
if stars_award and era_cosmetics:
reward_cosmetic_key = era_cosmetics[(rank - 1) % len(era_cosmetics)].key
if not _cosmetics().find_one(
user_uid=farm["user_uid"], cosmetic_key=reward_cosmetic_key
):
_cosmetics().insert(
{
"uid": generate_uid(),
"user_uid": farm["user_uid"],
"cosmetic_key": reward_cosmetic_key,
"purchased_at": now,
"created_at": now,
}
)
reset_fields = {"era_coins": 0, "era_harvests": 0}
if stars_award:
reset_fields["stars"] = _lvl(farm, "stars") + stars_award
_update_farm(farm["uid"], reset_fields)
_era_results().insert(
{
"uid": generate_uid(),
"era_number": era["era_number"],
"user_uid": farm["user_uid"],
"rank": rank,
"era_score": economy.era_score(
_lvl(farm, "era_coins"), _lvl(farm, "era_harvests"), _lvl(farm, "prestige")
),
"era_coins_final": _lvl(farm, "era_coins"),
"reward_stars": stars_award,
"reward_cosmetic_key": reward_cosmetic_key,
"created_at": now,
}
)
_eras().update({"uid": era["uid"], "active": 0}, ["uid"])
return {"era_number": era["era_number"], "participants": len(ranked)}
def leaderboard_era(limit: int = 25) -> list[dict]:
era = active_era()
if not era:
return []
farms = sorted(
_farms().find(),
key=lambda f: economy.era_score(
_lvl(f, "era_coins"), _lvl(f, "era_harvests"), _lvl(f, "prestige")
),
reverse=True,
)[:limit]
if not farms:
return []
users = get_users_by_uids([f["user_uid"] for f in farms])
entries = []
for rank, farm in enumerate(farms, start=1):
owner = users.get(farm["user_uid"])
if not owner:
continue
entries.append(
{
"rank": rank,
"username": owner.get("username", ""),
"level": int(farm.get("level", 1)),
"xp": int(farm.get("xp", 0)),
"coins": int(farm.get("coins", 0)),
"total_harvests": int(farm.get("total_harvests", 0)),
"prestige": int(farm.get("prestige") or 0),
"score": economy.era_score(
_lvl(farm, "era_coins"), _lvl(farm, "era_harvests"), _lvl(farm, "prestige")
),
"title": economy.cosmetic_title_name(farm.get("active_title") or ""),
}
)
return entries

View File

@ -6,7 +6,7 @@ from devplacepy.cache import TTLCache
from devplacepy.utils import generate_uid from devplacepy.utils import generate_uid
from .. import economy from .. import economy
from .common import GameError, _farms, _iso, _now, _plots, _update_farm from .common import GameError, _farms, _iso, _lvl, _now, _plots, conditional_update_farm
_leaderboard_cache = TTLCache(ttl=15, max_size=8) _leaderboard_cache = TTLCache(ttl=15, max_size=8)
@ -74,11 +74,18 @@ def buy_plot(user: dict) -> dict:
if plot_count >= economy.MAX_PLOTS: if plot_count >= economy.MAX_PLOTS:
raise GameError("All plots are already unlocked.") raise GameError("All plots are already unlocked.")
cost = economy.plot_cost(plot_count) cost = economy.plot_cost(plot_count)
coins = int(farm.get("coins", 0)) rows = conditional_update_farm(
if coins < cost: farm["uid"],
set_clause="coins = coins - :cost, plot_count = :new_count",
where_clause="plot_count = :current_count AND coins >= :cost",
params={"cost": cost, "new_count": plot_count + 1, "current_count": plot_count},
)
if rows == 0:
farm = get_farm(user["uid"])
if int(farm.get("plot_count", economy.STARTING_PLOTS)) != plot_count:
raise GameError("Plot count already changed - refresh and try again.")
raise GameError("Not enough coins for a new plot.") raise GameError("Not enough coins for a new plot.")
_create_plot(farm["uid"], user["uid"], plot_count, _iso(_now())) _create_plot(farm["uid"], user["uid"], plot_count, _iso(_now()))
_update_farm(farm["uid"], {"coins": coins - cost, "plot_count": plot_count + 1})
return {"plot_count": plot_count + 1, "spent": cost} return {"plot_count": plot_count + 1, "spent": cost}
@ -88,13 +95,17 @@ def upgrade_ci(user: dict) -> dict:
next_tier = economy.next_ci_tier(ci_tier) next_tier = economy.next_ci_tier(ci_tier)
if not next_tier: if not next_tier:
raise GameError("CI is already at the top tier.") raise GameError("CI is already at the top tier.")
coins = int(farm.get("coins", 0)) rows = conditional_update_farm(
if coins < next_tier.upgrade_cost:
raise GameError("Not enough coins to upgrade CI.")
_update_farm(
farm["uid"], farm["uid"],
{"coins": coins - next_tier.upgrade_cost, "ci_tier": next_tier.tier}, set_clause="coins = coins - :cost, ci_tier = :new_tier",
where_clause="ci_tier = :current_tier AND coins >= :cost",
params={"cost": next_tier.upgrade_cost, "new_tier": next_tier.tier, "current_tier": ci_tier},
) )
if rows == 0:
farm = get_farm(user["uid"])
if int(farm.get("ci_tier", 1)) != ci_tier:
raise GameError("CI tier already changed - refresh and try again.")
raise GameError("Not enough coins to upgrade CI.")
return {"ci_tier": next_tier.tier, "spent": next_tier.upgrade_cost} return {"ci_tier": next_tier.tier, "spent": next_tier.upgrade_cost}
@ -127,7 +138,151 @@ def leaderboard(limit: int = 25) -> list[dict]:
"total_harvests": int(farm.get("total_harvests", 0)), "total_harvests": int(farm.get("total_harvests", 0)),
"prestige": int(farm.get("prestige") or 0), "prestige": int(farm.get("prestige") or 0),
"score": economy.farm_score(farm), "score": economy.farm_score(farm),
"title": economy.cosmetic_title_name(farm.get("active_title") or ""),
} }
) )
_leaderboard_cache.set(f"top:{limit}", entries) _leaderboard_cache.set(f"top:{limit}", entries)
return entries return entries
def _entry(rank: int, farm: dict, owner: dict, score: int) -> dict:
return {
"rank": rank,
"username": owner.get("username", ""),
"level": int(farm.get("level", 1)),
"xp": int(farm.get("xp", 0)),
"coins": int(farm.get("coins", 0)),
"total_harvests": int(farm.get("total_harvests", 0)),
"prestige": int(farm.get("prestige") or 0),
"score": score,
"title": economy.cosmetic_title_name(farm.get("active_title") or ""),
}
def _ranked_entries(farms: list[dict], score_fn, limit: int) -> list[dict]:
ranked = sorted(farms, key=score_fn, reverse=True)[:limit]
if not ranked:
return []
from devplacepy.database import get_users_by_uids
users = get_users_by_uids([farm["user_uid"] for farm in ranked])
entries = []
for rank, farm in enumerate(ranked, start=1):
owner = users.get(farm["user_uid"])
if not owner:
continue
entries.append(_entry(rank, farm, owner, score_fn(farm)))
return entries
def leaderboard_prestige(limit: int = 25) -> list[dict]:
return _ranked_entries(
list(_farms().find()),
lambda f: _lvl(f, "prestige") * 1_000_000 + _lvl(f, "stars"),
limit,
)
def leaderboard_harvests_week(limit: int = 25) -> list[dict]:
return _ranked_entries(list(_farms().find()), lambda f: _lvl(f, "harvests_week"), limit)
def leaderboard_fair_play(limit: int = 25) -> list[dict]:
return _ranked_entries(
list(_farms().find()),
lambda f: economy.fair_play_score(_lvl(f, "harvests_week"), int(f.get("coins", 0))),
limit,
)
def leaderboard_time_to_kernel(limit: int = 25) -> list[dict]:
farms = [
f
for f in _farms().find()
if _lvl(f, "prestige") > 0
and _lvl(f, "last_kernel_harvest_prestige") == _lvl(f, "prestige")
]
ranked = sorted(farms, key=lambda f: _lvl(f, "time_to_kernel_seconds"))[:limit]
if not ranked:
return []
from devplacepy.database import get_users_by_uids
users = get_users_by_uids([farm["user_uid"] for farm in ranked])
entries = []
for rank, farm in enumerate(ranked, start=1):
owner = users.get(farm["user_uid"])
if not owner:
continue
entry = _entry(rank, farm, owner, _lvl(farm, "time_to_kernel_seconds"))
entry["time_to_kernel_seconds"] = _lvl(farm, "time_to_kernel_seconds")
entries.append(entry)
return entries
RAID_EFFICIENCY_WINDOW_DAYS = 30
def leaderboard_raid_efficiency(limit: int = 25) -> list[dict]:
from datetime import timedelta
from devplacepy.database import db, get_users_by_uids
cutoff = _iso(_now() - timedelta(days=RAID_EFFICIENCY_WINDOW_DAYS))
rows = db.query(
"SELECT thief_uid, COUNT(*) AS raids, SUM(coins) AS total_coins "
"FROM game_steals WHERE stolen_at >= :cutoff "
"GROUP BY thief_uid HAVING COUNT(*) >= :min_raids "
"ORDER BY (SUM(coins) * 1.0 / COUNT(*)) DESC LIMIT :limit",
cutoff=cutoff,
min_raids=economy.MIN_RAIDS_FOR_EFFICIENCY_BOARD,
limit=limit,
)
ranked = [
(row["thief_uid"], int(row["raids"]), int(row["total_coins"] or 0)) for row in rows
]
if not ranked:
return []
uids = [uid for uid, _, _ in ranked]
users = get_users_by_uids(uids)
farms_table = _farms()
farms_by_uid = {
f["user_uid"]: f for f in farms_table.find(farms_table.table.columns.user_uid.in_(uids))
}
entries = []
for rank, (uid, raids, total_coins) in enumerate(ranked, start=1):
owner = users.get(uid)
if not owner:
continue
farm = farms_by_uid.get(uid, {})
avg = total_coins / raids
entry = _entry(rank, farm, owner, round(avg))
entry["raid_avg"] = round(avg, 1)
entries.append(entry)
return entries
LEADERBOARD_BOARDS = {
"score": leaderboard,
"prestige": leaderboard_prestige,
"harvests": leaderboard_harvests_week,
"raids": leaderboard_raid_efficiency,
"time_to_kernel": leaderboard_time_to_kernel,
"fair_play": leaderboard_fair_play,
}
_board_cache = TTLCache(ttl=15, max_size=32)
def leaderboard_for(board: str, limit: int = 25) -> list[dict]:
cache_key = f"{board}:{limit}"
cached = _board_cache.get(cache_key)
if cached is not None:
return cached
if board == "era":
from .era import leaderboard_era
entries = leaderboard_era(limit)
else:
entries = LEADERBOARD_BOARDS.get(board, leaderboard)(limit)
_board_cache.set(cache_key, entries)
return entries

View File

@ -0,0 +1,51 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import random
from .. import economy
from .common import GameError, _lvl, conditional_update_farm
from .farm import ensure_farm, get_farm
INFRA_COLUMN = {i.key: f"infra_{i.key}" for i in economy.INFRASTRUCTURE}
def buy_infrastructure(user: dict, key: str) -> dict:
infra = economy.infra_for(key)
if not infra:
raise GameError("Unknown infrastructure.")
farm = ensure_farm(user["uid"])
column = INFRA_COLUMN[infra.key]
rows = conditional_update_farm(
farm["uid"],
set_clause=f"coins = coins - :cost, {column} = 1",
where_clause=(
f"COALESCE({column}, 0) = 0 AND coins >= :cost "
f"AND COALESCE(prestige, 0) >= :min_prestige"
),
params={"cost": infra.cost, "min_prestige": infra.min_prestige},
)
if rows == 0:
farm = get_farm(user["uid"])
if _lvl(farm, column):
raise GameError("You already own that infrastructure.")
if _lvl(farm, "prestige") < infra.min_prestige:
raise GameError(f"{infra.name} requires prestige {infra.min_prestige}.")
raise GameError("Not enough coins for that infrastructure.")
return {"key": infra.key, "spent": infra.cost}
def owns_infrastructure(farm: dict, key: str) -> bool:
column = INFRA_COLUMN.get(key)
return bool(column and _lvl(farm, column))
def roll_canary(coins_gain: int, plant_cost: int) -> int:
roll = random.random()
if roll < economy.CANARY_DOUBLE_CHANCE:
return coins_gain * 2
if roll < economy.CANARY_DOUBLE_CHANCE + economy.CANARY_FAIL_CHANCE:
return min(coins_gain, plant_cost)
return coins_gain

View File

@ -0,0 +1,77 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from datetime import datetime, timedelta
from devplacepy.cache import TTLCache
from devplacepy.database import db, get_table
from devplacepy.utils import generate_uid
from .. import economy
from .common import _iso
_saturation_cache = TTLCache(ttl=30, max_size=32)
def _ticks():
return get_table("game_market_ticks")
def _hour_bucket(now: datetime) -> str:
return now.strftime("%Y-%m-%dT%H")
def record_harvest_tick(crop_key: str, now: datetime) -> None:
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),
)
_saturation_cache.clear()
def recent_harvests(crop_key: str, window_hours: int) -> int:
cache_key = f"{crop_key}:{window_hours}"
cached = _saturation_cache.get(cache_key)
if cached is not None:
return cached
from .common import _now
cutoff = _hour_bucket(_now() - timedelta(hours=window_hours))
row = db.query(
"SELECT COALESCE(SUM(harvests), 0) AS total FROM game_market_ticks "
"WHERE crop_key = :crop_key AND hour_bucket >= :cutoff",
crop_key=crop_key,
cutoff=cutoff,
)
total = 0
for result in row:
total = int(result["total"] or 0)
break
_saturation_cache.set(cache_key, total)
return total
def market_factor_for(crop_key: str) -> float:
recent = recent_harvests(crop_key, economy.MARKET_WINDOW_HOURS)
saturation = economy.market_saturation_factor(recent)
return saturation * economy.market_buff_factor(crop_key, saturation)
def prune_ticks(older_than_hours: int = 96) -> int:
from .common import _now
cutoff = _hour_bucket(_now() - timedelta(hours=older_than_hours))
table = _ticks()
stale = [row["uid"] for row in table.find() if row.get("hour_bucket", "") < cutoff]
for uid in stale:
table.delete(uid=uid)
return len(stale)

View File

@ -0,0 +1,34 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from .. import economy
from .common import GameError, _lvl, conditional_update_farm
from .farm import ensure_farm, get_farm
MASTERY_COLUMN = {m.key: f"mastery_{m.key}" for m in economy.MASTERY_UPGRADES}
def upgrade_mastery(user: dict, key: str) -> dict:
upgrade = economy.mastery_for(key)
if not upgrade:
raise GameError("Unknown Mastery upgrade.")
farm = ensure_farm(user["uid"])
column = MASTERY_COLUMN[upgrade.key]
level = _lvl(farm, column)
if level >= upgrade.max_level:
raise GameError("That Mastery upgrade is maxed out.")
cost = economy.mastery_cost(upgrade, level)
rows = conditional_update_farm(
farm["uid"],
set_clause=f"mastery_points = COALESCE(mastery_points, 0) - :cost, {column} = :new_level",
where_clause=f"COALESCE({column}, 0) = :current_level AND COALESCE(mastery_points, 0) >= :cost",
params={"cost": cost, "new_level": level + 1, "current_level": level},
)
if rows == 0:
farm = get_farm(user["uid"])
if _lvl(farm, column) != level:
raise GameError("That Mastery upgrade already changed - refresh and try again.")
raise GameError("Not enough Mastery points for that upgrade.")
return {"key": upgrade.key, "level": level + 1, "spent": cost}

View File

@ -5,12 +5,15 @@ from __future__ import annotations
from devplacepy.utils import generate_uid from devplacepy.utils import generate_uid
from .. import economy from .. import economy
from .common import _iso, _now, _quests, _today from .common import _iso, _iso_week, _lvl, _now, _quests, _today
from .farm import get_farm from .farm import get_farm
WEEKLY_SLOT_INDEX = 3
def ensure_quests(farm: dict, day: str) -> None: def ensure_quests(farm: dict, day: str) -> None:
if _quests().find_one(farm_uid=farm["uid"], day=day): if _quests().find_one(farm_uid=farm["uid"], day=day, scope="daily"):
return return
now = _iso(_now()) now = _iso(_now())
for index, quest in enumerate(economy.daily_quests(farm["user_uid"], day)): for index, quest in enumerate(economy.daily_quests(farm["user_uid"], day)):
@ -20,6 +23,7 @@ def ensure_quests(farm: dict, day: str) -> None:
"farm_uid": farm["uid"], "farm_uid": farm["uid"],
"user_uid": farm["user_uid"], "user_uid": farm["user_uid"],
"day": day, "day": day,
"scope": "daily",
"slot_index": index, "slot_index": index,
"kind": quest["kind"], "kind": quest["kind"],
"label": quest["label"], "label": quest["label"],
@ -27,6 +31,36 @@ def ensure_quests(farm: dict, day: str) -> None:
"progress": 0, "progress": 0,
"reward_coins": quest["reward_coins"], "reward_coins": quest["reward_coins"],
"reward_xp": quest["reward_xp"], "reward_xp": quest["reward_xp"],
"reward_stars": 0,
"claimed": 0,
"created_at": now,
"updated_at": now,
}
)
def ensure_weekly_contract(farm: dict, iso_week: str) -> None:
if not _lvl(farm, "mastery_contracts"):
return
if _quests().find_one(farm_uid=farm["uid"], day=iso_week, scope="weekly"):
return
now = _iso(_now())
contract = economy.weekly_contract(farm["user_uid"], iso_week)
_quests().insert(
{
"uid": generate_uid(),
"farm_uid": farm["uid"],
"user_uid": farm["user_uid"],
"day": iso_week,
"scope": "weekly",
"slot_index": WEEKLY_SLOT_INDEX,
"kind": contract["kind"],
"label": contract["label"],
"goal": contract["goal"],
"progress": 0,
"reward_coins": 0,
"reward_xp": contract["reward_xp"],
"reward_stars": contract["reward_stars"],
"claimed": 0, "claimed": 0,
"created_at": now, "created_at": now,
"updated_at": now, "updated_at": now,
@ -41,15 +75,22 @@ def advance_quests(user_uid: str, kind: str, amount: int) -> None:
farm = get_farm(user_uid) farm = get_farm(user_uid)
if not farm: if not farm:
return return
now = _now()
day = _today() day = _today()
ensure_quests(farm, day) ensure_quests(farm, day)
for row in _quests().find(farm_uid=farm["uid"], day=day, kind=kind): iso_week = _iso_week(now)
ensure_weekly_contract(farm, iso_week)
rows = list(_quests().find(farm_uid=farm["uid"], day=day, kind=kind, scope="daily"))
rows += list(
_quests().find(farm_uid=farm["uid"], day=iso_week, kind=kind, scope="weekly")
)
for row in rows:
if row.get("claimed"): if row.get("claimed"):
continue continue
goal = int(row.get("goal") or 0) goal = int(row.get("goal") or 0)
progress = min(goal, int(row.get("progress") or 0) + amount) progress = min(goal, int(row.get("progress") or 0) + amount)
_quests().update( _quests().update(
{"uid": row["uid"], "progress": progress, "updated_at": _iso(_now())}, {"uid": row["uid"], "progress": progress, "updated_at": _iso(now)},
["uid"], ["uid"],
) )
except Exception: except Exception:

View File

@ -8,6 +8,7 @@ from datetime import datetime, timedelta
from .. import economy from .. import economy
from .common import ( from .common import (
PERK_COLUMN, PERK_COLUMN,
_iso_week,
_lvl, _lvl,
_now, _now,
_parse, _parse,
@ -15,7 +16,12 @@ from .common import (
_quests, _quests,
steal_cooldown_remaining, steal_cooldown_remaining,
) )
from .cosmetics import owned_cosmetic_keys
from .era import active_era_name
from .farm import get_farm, get_plots from .farm import get_farm, get_plots
from .infrastructure import INFRA_COLUMN, owns_infrastructure
from .market import market_factor_for
from .mastery import MASTERY_COLUMN
from .quests import ensure_quests from .quests import ensure_quests
@ -69,22 +75,27 @@ def serialize_plot(
) )
grace = economy.effective_steal_grace(defense_level) grace = economy.effective_steal_grace(defense_level)
protected = bool(ready_at) and now < ready_at + timedelta(seconds=grace) protected = bool(ready_at) and now < ready_at + timedelta(seconds=grace)
immune = bool(crop and crop.steal_immune)
eligible = ( eligible = (
state == "ready" state == "ready"
and not is_owner and not is_owner
and bool(viewer_uid) and bool(viewer_uid)
and bool(crop) and bool(crop)
and ready_at is not None and ready_at is not None
and not immune
) )
can_steal = eligible and not protected and steal_locked_until <= 0 can_steal = eligible and not protected and steal_locked_until <= 0
steal_reason = "" steal_reason = ""
if eligible and protected: if immune and state == "ready" and not is_owner:
steal_reason = "immune"
elif eligible and protected:
steal_reason = "protected" steal_reason = "protected"
elif eligible and steal_locked_until > 0: elif eligible and steal_locked_until > 0:
steal_reason = "cooldown" steal_reason = "cooldown"
market_factor = market_factor_for(crop.key) if crop else 1.0
steal_coins = ( steal_coins = (
economy.steal_reward_coins( economy.steal_reward_coins(
crop, yield_level, prestige, legacy_mult_level, defense_level crop, yield_level, prestige, legacy_mult_level, defense_level, market_factor
) )
if can_steal if can_steal
else 0 else 0
@ -96,7 +107,7 @@ def serialize_plot(
) )
reduce_by = int(remaining * economy.FERTILIZE_FRACTION) reduce_by = int(remaining * economy.FERTILIZE_FRACTION)
eff_reward = economy.effective_reward_coins( eff_reward = economy.effective_reward_coins(
crop, yield_level, prestige, legacy_mult_level crop, yield_level, prestige, legacy_mult_level, market_factor
) )
fertilize_cost = economy.fertilize_click_cost(eff_reward, reduce_by, full_grow) fertilize_cost = economy.fertilize_click_cost(eff_reward, reduce_by, full_grow)
return { return {
@ -125,6 +136,7 @@ def serialize_farm(
farm: dict, *, viewer: dict | None, owner: dict, now: datetime | None = None farm: dict, *, viewer: dict | None, owner: dict, now: datetime | None = None
) -> dict: ) -> dict:
from .actions import _auto_harvest from .actions import _auto_harvest
from .defense import charge_upkeep
now = now or _now() now = now or _now()
viewer_uid = viewer["uid"] if viewer else "" viewer_uid = viewer["uid"] if viewer else ""
@ -132,6 +144,9 @@ def serialize_farm(
is_owner = viewer_uid == owner_uid is_owner = viewer_uid == owner_uid
if is_owner and _lvl(farm, "legacy_autoharvest") > 0: if is_owner and _lvl(farm, "legacy_autoharvest") > 0:
farm = _auto_harvest(farm, owner_uid, now) farm = _auto_harvest(farm, owner_uid, now)
if is_owner:
farm = charge_upkeep(farm, now)
farm = _reset_harvests_week(farm, now)
prestige = _lvl(farm, "prestige") prestige = _lvl(farm, "prestige")
growth_level = _lvl(farm, "perk_growth") growth_level = _lvl(farm, "perk_growth")
discount_level = _lvl(farm, "perk_discount") discount_level = _lvl(farm, "perk_discount")
@ -169,6 +184,15 @@ def serialize_farm(
ci_entry = economy.CI_BY_TIER.get(ci_tier) ci_entry = economy.CI_BY_TIER.get(ci_tier)
streak = _lvl(farm, "streak") streak = _lvl(farm, "streak")
daily_available = is_owner and _daily_available(farm, now) daily_available = is_owner and _daily_available(farm, now)
mastery_earned = _lvl(farm, "mastery_points_earned_total")
era_name = active_era_name()
registry_boost = owns_infrastructure(farm, "registry")
underdog_until = _parse(farm.get("underdog_boost_until") or "")
underdog_seconds = (
max(0, int((underdog_until - now).total_seconds())) if underdog_until else 0
)
building_defense_level = _lvl(farm, "defense_level")
defense_tier_info = economy.defense_tier(building_defense_level)
return { return {
"owner_username": owner.get("username", ""), "owner_username": owner.get("username", ""),
"owner_uid": owner_uid, "owner_uid": owner_uid,
@ -205,6 +229,10 @@ def serialize_farm(
prestige, prestige,
legacy_mult_level, legacy_mult_level,
legacy_speed_level, legacy_speed_level,
market_factor_for(crop.key),
mastery_earned,
era_name,
registry_boost,
) )
for crop in economy.CROPS for crop in economy.CROPS
], ],
@ -220,9 +248,99 @@ def serialize_farm(
"stars": _lvl(farm, "stars"), "stars": _lvl(farm, "stars"),
"legacy": _serialize_legacy(farm) if is_owner else [], "legacy": _serialize_legacy(farm) if is_owner else [],
"steal_cooldown_seconds": steal_locked_until, "steal_cooldown_seconds": steal_locked_until,
"mastery_points": _lvl(farm, "mastery_points"),
"mastery_points_earned_total": mastery_earned,
"mastery": _serialize_mastery(farm) if is_owner else [],
"infrastructure": _serialize_infrastructure(farm) if is_owner else [],
"defense_level": building_defense_level,
"defense_tier_name": defense_tier_info.name,
"defense_upkeep_daily": economy.daily_upkeep(defense_tier_info, int(farm.get("coins", 0))),
"defense_next_cost": (
economy.next_defense_tier(building_defense_level).upgrade_cost
if economy.next_defense_tier(building_defense_level)
else 0
),
"cosmetics": _serialize_cosmetics(owner_uid) if is_owner else [],
"active_title": farm.get("active_title") or "",
"underdog_boost_seconds_remaining": underdog_seconds,
"mastery_analytics_unlocked": bool(_lvl(farm, "mastery_analytics")),
"lifetime_coins_earned": _lvl(farm, "lifetime_coins_earned"),
"lifetime_harvests": _lvl(farm, "lifetime_harvests"),
"harvests_week": _lvl(farm, "harvests_week"),
"era_active": era_name is not None,
"era_name": era_name or "",
"era_coins": _lvl(farm, "era_coins"),
"era_harvests": _lvl(farm, "era_harvests"),
} }
def _reset_harvests_week(farm: dict, now: datetime) -> dict:
from .common import _update_farm
current_week = _iso_week(now)
if farm.get("harvests_week_start") == current_week:
return farm
fields = {"harvests_week": 0, "harvests_week_start": current_week}
_update_farm(farm["uid"], fields)
return {**farm, **fields}
def _serialize_mastery(farm: dict) -> list[dict]:
upgrades = []
for m in economy.MASTERY_UPGRADES:
level = _lvl(farm, MASTERY_COLUMN[m.key])
maxed = level >= m.max_level
upgrades.append(
{
"key": m.key,
"name": m.name,
"icon": m.icon,
"description": m.description,
"level": level,
"max_level": m.max_level,
"cost": 0 if maxed else economy.mastery_cost(m, level),
"maxed": maxed,
"effect": m.description,
}
)
return upgrades
def _serialize_infrastructure(farm: dict) -> list[dict]:
rows = []
for infra in economy.INFRASTRUCTURE:
owned = bool(_lvl(farm, INFRA_COLUMN[infra.key]))
rows.append(
{
"key": infra.key,
"name": infra.name,
"icon": infra.icon,
"description": infra.description,
"cost": infra.cost,
"min_prestige": infra.min_prestige,
"owned": owned,
}
)
return rows
def _serialize_cosmetics(user_uid: str) -> list[dict]:
owned = owned_cosmetic_keys(user_uid)
return [
{
"key": c.key,
"name": c.name,
"icon": c.icon,
"description": c.description,
"cost_coins": c.cost_coins,
"kind": c.kind,
"owned": c.key in owned,
}
for c in economy.COSMETICS
if c.era_key is None
]
def _daily_available(farm: dict, now: datetime) -> bool: def _daily_available(farm: dict, now: datetime) -> bool:
last = _parse_date(farm.get("last_daily_at") or "") last = _parse_date(farm.get("last_daily_at") or "")
return last != now.date() return last != now.date()
@ -271,13 +389,22 @@ def _serialize_legacy(farm: dict) -> list[dict]:
def _serialize_quests(user_uid: str, now: datetime) -> list[dict]: def _serialize_quests(user_uid: str, now: datetime) -> list[dict]:
from .quests import ensure_weekly_contract
farm = get_farm(user_uid) farm = get_farm(user_uid)
if not farm: if not farm:
return [] return []
day = now.date().isoformat() day = now.date().isoformat()
ensure_quests(farm, day) ensure_quests(farm, day)
rows = sorted( rows = sorted(
_quests().find(farm_uid=farm["uid"], day=day), _quests().find(farm_uid=farm["uid"], day=day, scope="daily"),
key=lambda row: row.get("slot_index", 0),
)
if _lvl(farm, "mastery_contracts"):
iso_week = _iso_week(now)
ensure_weekly_contract(farm, iso_week)
rows += sorted(
_quests().find(farm_uid=farm["uid"], day=iso_week, scope="weekly"),
key=lambda row: row.get("slot_index", 0), key=lambda row: row.get("slot_index", 0),
) )
quests = [] quests = []
@ -295,6 +422,8 @@ def _serialize_quests(user_uid: str, now: datetime) -> list[dict]:
"reward_xp": int(row.get("reward_xp") or 0), "reward_xp": int(row.get("reward_xp") or 0),
"claimed": claimed, "claimed": claimed,
"can_claim": (not claimed) and goal > 0 and progress >= goal, "can_claim": (not claimed) and goal > 0 and progress >= goal,
"scope": row.get("scope", "daily"),
"reward_stars": int(row.get("reward_stars") or 0),
} }
) )
return quests return quests

View File

@ -61,6 +61,20 @@ The gateway records one row per upstream call (chat, vision, passthrough) and su
**Financial data is admin-only everywhere.** Any monetary figure (USD cost, pricing, spend, limit) is restricted to administrators; members and guests see only the percentage of quota used - this rule is enforced consistently across the profile card, `ai_correction`/`ai_modifier` usage displays, and the Devii cost tools. **Financial data is admin-only everywhere.** Any monetary figure (USD cost, pricing, spend, limit) is restricted to administrators; members and guests see only the percentage of quota used - this rule is enforced consistently across the profile card, `ai_correction`/`ai_modifier` usage displays, and the Devii cost tools.
## Quota rules (`quota.py`, admin `/admin/gateway` "Quota rules" section)
**This caps `/openai/v1/*` itself, independent of Devii's own daily cap.** Devii's `devii_user_daily_usd`/`devii_guest_daily_usd`/`devii_admin_daily_usd` (documented in `devplacepy/services/devii/CLAUDE.md`) only gate turns that go *through* Devii. A caller hitting the gateway directly with their own `api_key` bypasses that entirely - `quota.py` is the root-level enforcement that closes this, checked in `GatewayService.handle()` right after owner/`app_reference` resolution and before every billed dispatch (chat, embeddings, images, passthrough; `GET /v1/models` is exempt, it makes no upstream call).
**Two layers, same shape as provider/model routing above.** Layer A is five flat `config_fields` on `GatewayService` (`gateway_default_user_daily_usd` $1.00, `gateway_default_admin_daily_usd` $0/unlimited, `gateway_default_guest_daily_usd` $0.05, `gateway_default_internal_daily_usd` $0/unlimited, `gateway_default_key_daily_usd` $0/unlimited - group **Quota**) applied per specific caller (`owner_id`) when no rule matches; internal/key default unlimited so shipping this never starts blocking DevPlace's own news/bots/Devii-guest/correction traffic on the internal key. Layer B is the `gateway_quota_rules` table (`ensure_tables()`, called from `init_db()` alongside `routing.ensure_tables()`; hard CRUD, not in `SOFT_DELETE_TABLES`, cross-worker cache-invalidated under the `"gateway_quota"` name): each row scopes by **any combination** of `owner_kind` (internal/key/user/admin/anonymous - DevPlace's only "roles" here), a specific `owner_id`, and `app_reference` (the `X-App-Reference` label), each nullable = wildcard; a `QuotaRuleIn` Pydantic validator rejects a rule with all three blank (that belongs in Layer A). `quota.resolve(owner_kind, owner_id, app_reference, cfg)` gathers every active rule whose non-null dimensions all equal the request, picks the one with the most non-null dimensions (ties broken toward the smaller limit, unlimited `0` never wins a tie against a finite cap), and returns `(limit_usd, scope, rule)` where `scope` is the exact `(owner_kind, owner_id, app_reference)` triple - each possibly `None` - that spend must be summed over. Layer A is internally just the maximally-specific implicit scope `(owner_kind, owner_id, None)`, so one code path (`quota.spent_24h(*scope)`, a plain `SUM(cost_usd)` over `gateway_usage_ledger` filtered by whichever scope dimensions are non-null) serves both layers.
**A wildcard dimension means a shared pool, by design.** A rule scoped only by `app_reference` caps that app's combined spend across every caller using it; a rule scoped only by `owner_kind` caps that whole role's combined spend. Pin `owner_id` to get a true per-caller cap (the Layer A default's own behavior). `anonymous`/`internal`/`key` owner_ids are already fixed constants (`"anonymous"`/`"devii"`/`"access"`, from `resolve_owner()`), not per-caller identities, so any cap on those kinds is inherently pooled - there is no per-guest identity at this layer (unlike Devii's own guest-cookie-scoped ledger).
**No lock, no hold, bounded overshoot by design - this is deliberate, not an oversight.** Cost is only known after the upstream call returns, so a true atomic pre-authorization would need a reserve-then-reconcile ("hold") mechanism, and a bug in releasing a hold is exactly the kind of thing that gets a caller stuck forever. Instead this mirrors Devii's own already-shipped mechanism exactly: read the 24h sum, compare, `raise HTTPException(429, ...)` if already at/over - a single `SELECT` and a conditional raise, nothing held, nothing to leak, structurally impossible to deadlock. The tradeoff is a small, bounded overshoot (at most a few concurrent in-flight calls' worth of cost past the cap before the next request sees the updated sum and blocks) - acceptable and industry-standard for a cost whose exact size isn't known until the call finishes, and it is the property actually being enforced: once tripped, every subsequent separate request stays blocked until the 24h window rolls off or an admin adjusts the rule.
**429 body never carries a dollar figure**, admin or not (`{"detail": "AI gateway daily quota exceeded"}`) - mirrors Devii's own over-limit WS message, which likewise never states a number. The admin-only services log line and the `ai.quota.exceeded` audit row (`GatewayService._audit_quota_exceeded`, reusing `usage.audit_actor_for`) do carry the spend/limit/matched-rule-uid, since those are admin-only surfaces.
**CRUD.** Admin JSON at `/admin/gateway/quota-rules` (`routers/admin/gateway_configs.py`, list returns each rule's live `spent_24h_usd` plus the Layer A defaults for context), audited `gateway.quota_rule.update`/`gateway.quota_rule.delete` (category `ai`, both already in `events.md`), rendered in the **Quota rules** section of `/admin/gateway` (`GatewayAdmin.js`, mirrors the providers/models CRUD tables). Devii tools `gateway_quota_rules`/`gateway_quota_rule_set`/`gateway_quota_rule_delete` (`requires_admin=True`, delete is `CONFIRM_REQUIRED`) proxy the same endpoints via `handler="http"`, same as the provider/model tools. CLI: `devplace gateway quota list|set|delete`.
## Image generation ## Image generation
`POST /openai/v1/images/generations` exposes an OpenAI-compatible image-generation endpoint. Clients send the generic model `molodetz-img-small` (`config.INTERNAL_IMAGE_MODEL`), which `handle_images` remaps to `gateway_image_model` exactly like chat remaps `molodetz` -> `gateway_model` (also remapped when `gateway_force_model` is on or the model is empty or `molodetz-img`). It defaults to OpenRouter's `black-forest-labs/flux-1.1-pro` at `https://openrouter.ai/api/v1/images/generations` (`config.IMAGE_*_DEFAULT`, $0.04 per image fallback). `handle_images` mirrors `handle_embeddings`: build the payload, forward via `_send`, and record one ledger row. The config fields are the **Images** group (`gateway_image_enabled` default on, `gateway_image_url`, `gateway_image_model`, `gateway_image_key`) plus the Pricing-group `gateway_image_price_per_call`. `effective_config()` falls the image key back to `gateway_api_key` then `OPENROUTER_API_KEY`. Usage is recorded with **`backend="image"`**; `usage.compute_cost` adds an `image` branch (flat per-call, native OpenRouter `cost` still preferred via `extract_image_usage`). `routing.image_overlay` resolves per-route provider/url/key and uses `price_input_per_m` as the per-image price. `routing.seed_default_image_routes()` (from `migrate_ai_gateway_settings`) idempotently seeds `molodetz-img-small` -> Flux on the `openrouter` provider when `OPENROUTER_API_KEY` is set. When `gateway_image_enabled` is off the endpoint returns 503 with no ledger row. `POST /openai/v1/images/generations` exposes an OpenAI-compatible image-generation endpoint. Clients send the generic model `molodetz-img-small` (`config.INTERNAL_IMAGE_MODEL`), which `handle_images` remaps to `gateway_image_model` exactly like chat remaps `molodetz` -> `gateway_model` (also remapped when `gateway_force_model` is on or the model is empty or `molodetz-img`). It defaults to OpenRouter's `black-forest-labs/flux-1.1-pro` at `https://openrouter.ai/api/v1/images/generations` (`config.IMAGE_*_DEFAULT`, $0.04 per image fallback). `handle_images` mirrors `handle_embeddings`: build the payload, forward via `_send`, and record one ledger row. The config fields are the **Images** group (`gateway_image_enabled` default on, `gateway_image_url`, `gateway_image_model`, `gateway_image_key`) plus the Pricing-group `gateway_image_price_per_call`. `effective_config()` falls the image key back to `gateway_api_key` then `OPENROUTER_API_KEY`. Usage is recorded with **`backend="image"`**; `usage.compute_cost` adds an `image` branch (flat per-call, native OpenRouter `cost` still preferred via `extract_image_usage`). `routing.image_overlay` resolves per-route provider/url/key and uses `price_input_per_m` as the per-image price. `routing.seed_default_image_routes()` (from `migrate_ai_gateway_settings`) idempotently seeds `molodetz-img-small` -> Flux on the `openrouter` provider when `OPENROUTER_API_KEY` is set. When `gateway_image_enabled` is off the endpoint returns 503 with no ledger row.

View File

@ -11,6 +11,7 @@ import httpx
from fastapi.responses import JSONResponse, Response, StreamingResponse from fastapi.responses import JSONResponse, Response, StreamingResponse
from devplacepy import stealth from devplacepy import stealth
from devplacepy.services.background import background
from devplacepy.services.openai_gateway import config from devplacepy.services.openai_gateway import config
from devplacepy.services.openai_gateway.reliability import CircuitBreaker, retry_send from devplacepy.services.openai_gateway.reliability import CircuitBreaker, retry_send
from devplacepy.services.openai_gateway.routing import ( from devplacepy.services.openai_gateway.routing import (
@ -33,6 +34,19 @@ from devplacepy.services.openai_gateway.vision import VisionAugmenter, VisionCac
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _notify_gateway_status(is_open: bool) -> None:
from devplacepy.database import get_table
from devplacepy.utils import create_notification
message = (
"AI gateway circuit breaker opened - upstream calls are being rejected"
if is_open
else "AI gateway circuit breaker closed - upstream calls have resumed"
)
for admin in get_table("users").find(role="Admin"):
create_notification(admin["uid"], "system", message, "gateway", "/admin/services/openai")
def _fake_stream(data: dict, model: str, include_usage: bool = False): def _fake_stream(data: dict, model: str, include_usage: bool = False):
chunk_id = data.get("id", f"chatcmpl-{uuid.uuid4().hex[:12]}") chunk_id = data.get("id", f"chatcmpl-{uuid.uuid4().hex[:12]}")
created = data.get("created", int(time.time())) created = data.get("created", int(time.time()))
@ -219,15 +233,24 @@ class GatewayRuntime:
self.last_latency_ms = int(timing["upstream_latency_ms"]) self.last_latency_ms = int(timing["upstream_latency_ms"])
if exc is not None: if exc is not None:
self.errors += 1 self.errors += 1
was_open = self._breaker.is_open
self._breaker.record_failure() self._breaker.record_failure()
if not was_open and self._breaker.is_open:
background.submit(_notify_gateway_status, True)
log(f"{method} {url} connection failed after {attempts} attempt(s): {exc}") log(f"{method} {url} connection failed after {attempts} attempt(s): {exc}")
return None, exc, timing return None, exc, timing
self.last_status = resp.status_code self.last_status = resp.status_code
if resp.status_code >= 500: if resp.status_code >= 500:
self.errors += 1 self.errors += 1
was_open = self._breaker.is_open
self._breaker.record_failure() self._breaker.record_failure()
if not was_open and self._breaker.is_open:
background.submit(_notify_gateway_status, True)
else: else:
was_open = self._breaker.is_open
self._breaker.record_success() self._breaker.record_success()
if was_open and not self._breaker.is_open:
background.submit(_notify_gateway_status, False)
timing["retry_succeeded"] = attempts > 1 timing["retry_succeeded"] = attempts > 1
return resp, None, timing return resp, None, timing

View File

@ -0,0 +1,317 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import logging
import re
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Optional
from pydantic import BaseModel, Field, field_validator, model_validator
from devplacepy.database import bump_cache_version, db, get_table, sync_local_cache
logger = logging.getLogger(__name__)
RULES_TABLE = "gateway_quota_rules"
CACHE_NAME = "gateway_quota"
APP_REFERENCE_PATTERN = re.compile(r"^[a-zA-Z0-9_.-]{1,30}$")
OWNER_KINDS = ("internal", "key", "user", "admin", "anonymous")
FIELD_DEFAULT_USER = "gateway_default_user_daily_usd"
FIELD_DEFAULT_ADMIN = "gateway_default_admin_daily_usd"
FIELD_DEFAULT_GUEST = "gateway_default_guest_daily_usd"
FIELD_DEFAULT_INTERNAL = "gateway_default_internal_daily_usd"
FIELD_DEFAULT_KEY = "gateway_default_key_daily_usd"
_DEFAULT_FIELD_BY_KIND = {
"user": FIELD_DEFAULT_USER,
"admin": FIELD_DEFAULT_ADMIN,
"anonymous": FIELD_DEFAULT_GUEST,
"internal": FIELD_DEFAULT_INTERNAL,
"key": FIELD_DEFAULT_KEY,
}
_QUOTA_CACHE: dict = {}
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def ensure_tables() -> None:
db.query(
"CREATE TABLE IF NOT EXISTS "
+ RULES_TABLE
+ " (id INTEGER PRIMARY KEY, uid TEXT, owner_kind TEXT, owner_id TEXT, "
"app_reference TEXT, limit_usd REAL DEFAULT 0, is_active INTEGER DEFAULT 1, "
"label TEXT, created_by TEXT, created_at TEXT, updated_at TEXT)"
)
try:
db.query(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_gateway_quota_rules_uid ON "
+ RULES_TABLE
+ " (uid)"
)
db.query(
"CREATE INDEX IF NOT EXISTS idx_gateway_quota_rules_lookup ON "
+ RULES_TABLE
+ " (owner_kind, owner_id, app_reference)"
)
except Exception as exc:
logger.warning("gateway quota rule index creation failed: %s", exc)
class QuotaRuleIn(BaseModel):
owner_kind: Optional[str] = None
owner_id: Optional[str] = Field(default=None, max_length=64)
app_reference: Optional[str] = Field(default=None, max_length=30)
limit_usd: float = Field(default=0.0, ge=0)
is_active: bool = True
label: str = Field(default="", max_length=200)
@field_validator("owner_kind")
@classmethod
def _clean_owner_kind(cls, value: Optional[str]) -> Optional[str]:
if not value:
return None
value = value.strip().lower()
if value not in OWNER_KINDS:
raise ValueError(f"owner_kind must be one of {', '.join(OWNER_KINDS)}")
return value
@field_validator("owner_id")
@classmethod
def _clean_owner_id(cls, value: Optional[str]) -> Optional[str]:
if not value:
return None
return value.strip()
@field_validator("app_reference")
@classmethod
def _clean_app_reference(cls, value: Optional[str]) -> Optional[str]:
if not value:
return None
value = value.strip()
if not APP_REFERENCE_PATTERN.match(value):
raise ValueError("app_reference must match ^[a-zA-Z0-9_.-]{1,30}$")
return value
@field_validator("label")
@classmethod
def _clean_label(cls, value: str) -> str:
return (value or "").strip()
@model_validator(mode="after")
def _require_a_dimension(self) -> "QuotaRuleIn":
if self.owner_kind is None and self.owner_id is None and self.app_reference is None:
raise ValueError(
"At least one of owner_kind, owner_id, or app_reference is required - "
"an unscoped cap belongs in the global default fields, not a rule"
)
return self
@dataclass(frozen=True)
class QuotaRule:
uid: str
owner_kind: Optional[str]
owner_id: Optional[str]
app_reference: Optional[str]
limit_usd: float
is_active: bool
label: str
created_by: str
created_at: str
updated_at: str
@property
def specificity(self) -> int:
return sum(
1 for v in (self.owner_kind, self.owner_id, self.app_reference) if v is not None
)
def matches(self, owner_kind: str, owner_id: str, app_reference: str) -> bool:
if not self.is_active:
return False
if self.owner_kind is not None and self.owner_kind != owner_kind:
return False
if self.owner_id is not None and self.owner_id != owner_id:
return False
if self.app_reference is not None and self.app_reference != app_reference:
return False
return True
def as_dict(self) -> dict:
return {
"uid": self.uid,
"owner_kind": self.owner_kind,
"owner_id": self.owner_id,
"app_reference": self.app_reference,
"limit_usd": self.limit_usd,
"is_active": self.is_active,
"label": self.label,
"created_by": self.created_by,
"created_at": self.created_at,
"updated_at": self.updated_at,
"specificity": self.specificity,
}
def _row_to_rule(row: dict) -> QuotaRule:
return QuotaRule(
uid=str(row.get("uid") or ""),
owner_kind=row.get("owner_kind") or None,
owner_id=row.get("owner_id") or None,
app_reference=row.get("app_reference") or None,
limit_usd=float(row.get("limit_usd") or 0.0),
is_active=bool(row.get("is_active", 1)),
label=str(row.get("label") or ""),
created_by=str(row.get("created_by") or ""),
created_at=str(row.get("created_at") or ""),
updated_at=str(row.get("updated_at") or ""),
)
def _load() -> list[QuotaRule]:
sync_local_cache(CACHE_NAME, _QUOTA_CACHE)
if "rules" not in _QUOTA_CACHE:
rules: list[QuotaRule] = []
try:
if RULES_TABLE in db.tables:
for row in get_table(RULES_TABLE).all():
if row.get("uid"):
rules.append(_row_to_rule(row))
except Exception as exc:
logger.warning("gateway quota rule load failed: %s", exc)
_QUOTA_CACHE["rules"] = rules
return _QUOTA_CACHE["rules"]
class QuotaRuleStore:
def list(self) -> list[dict]:
rules = sorted(_load(), key=lambda r: (-r.specificity, r.created_at))
return [r.as_dict() for r in rules]
def get(self, uid: str) -> Optional[QuotaRule]:
if not uid:
return None
for rule in _load():
if rule.uid == uid:
return rule
return None
def count(self) -> int:
return len(_load())
def set(
self, payload: QuotaRuleIn, *, uid: Optional[str] = None, created_by: str = ""
) -> dict:
ensure_tables()
table = get_table(RULES_TABLE)
existing = table.find_one(uid=uid) if uid else None
record: dict = {
"owner_kind": payload.owner_kind,
"owner_id": payload.owner_id,
"app_reference": payload.app_reference,
"limit_usd": payload.limit_usd,
"is_active": 1 if payload.is_active else 0,
"label": payload.label,
"updated_at": _now(),
}
if existing:
record["uid"] = existing["uid"]
record["created_by"] = existing.get("created_by") or created_by
record["created_at"] = existing.get("created_at") or _now()
table.update({**record, "id": existing["id"]}, ["id"])
else:
record["uid"] = uid or uuid.uuid4().hex
record["created_by"] = created_by
record["created_at"] = _now()
table.insert(record)
bump_cache_version(CACHE_NAME)
_QUOTA_CACHE.clear()
saved = self.get(record["uid"])
return saved.as_dict() if saved else record
def remove(self, uid: str) -> bool:
uid = (uid or "").strip()
if not uid or RULES_TABLE not in db.tables:
return False
removed = int(get_table(RULES_TABLE).delete(uid=uid))
if removed:
bump_cache_version(CACHE_NAME)
_QUOTA_CACHE.clear()
return bool(removed)
quota_rule_store = QuotaRuleStore()
def default_limit(owner_kind: str, cfg: dict) -> float:
field = _DEFAULT_FIELD_BY_KIND.get(owner_kind, FIELD_DEFAULT_USER)
return float(cfg.get(field, 0.0) or 0.0)
def resolve(
owner_kind: str, owner_id: str, app_reference: str, cfg: dict
) -> tuple[float, tuple[Optional[str], Optional[str], Optional[str]], Optional[QuotaRule]]:
matches = [r for r in _load() if r.matches(owner_kind, owner_id, app_reference)]
if matches:
def _sort_key(rule: QuotaRule):
tie = float("inf") if rule.limit_usd == 0 else rule.limit_usd
return (-rule.specificity, tie)
best = sorted(matches, key=_sort_key)[0]
return best.limit_usd, (best.owner_kind, best.owner_id, best.app_reference), best
return default_limit(owner_kind, cfg), (owner_kind, owner_id, None), None
def resolve_for_owner(
owner_kind: str, owner_id: str, cfg: dict
) -> tuple[float, tuple[Optional[str], Optional[str], None], Optional[QuotaRule]]:
matches = [
r
for r in _load()
if r.app_reference is None and r.matches(owner_kind, owner_id, "")
]
if matches:
def _sort_key(rule: QuotaRule):
tie = float("inf") if rule.limit_usd == 0 else rule.limit_usd
return (-rule.specificity, tie)
best = sorted(matches, key=_sort_key)[0]
return best.limit_usd, (best.owner_kind, best.owner_id, None), best
return default_limit(owner_kind, cfg), (owner_kind, owner_id, None), None
def spent_24h(
owner_kind: Optional[str], owner_id: Optional[str], app_reference: Optional[str]
) -> float:
from devplacepy.services.openai_gateway.usage import GATEWAY_LEDGER
if GATEWAY_LEDGER not in db.tables:
return 0.0
clauses = ["created_at >= :cutoff"]
params: dict = {
"cutoff": (datetime.now(timezone.utc) - timedelta(hours=24)).isoformat()
}
if owner_kind is not None:
clauses.append("owner_kind = :owner_kind")
params["owner_kind"] = owner_kind
if owner_id is not None:
clauses.append("owner_id = :owner_id")
params["owner_id"] = owner_id
if app_reference is not None:
clauses.append("app_reference = :app_reference")
params["app_reference"] = app_reference
where = " AND ".join(clauses)
rows = list(
db.query(
f"SELECT COALESCE(SUM(cost_usd), 0) AS spent FROM {GATEWAY_LEDGER} WHERE {where}",
**params,
)
)
return float(rows[0].get("spent") or 0.0) if rows else 0.0

View File

@ -11,7 +11,7 @@ from fastapi.responses import JSONResponse
from devplacepy.database import get_int_setting from devplacepy.database import get_int_setting
from devplacepy.services.base import BaseService, ConfigField from devplacepy.services.base import BaseService, ConfigField
from devplacepy.services.openai_gateway import config from devplacepy.services.openai_gateway import config, quota
from devplacepy.services.openai_gateway.analytics import summary_metrics from devplacepy.services.openai_gateway.analytics import summary_metrics
from devplacepy.services.openai_gateway.gateway import GatewayRuntime from devplacepy.services.openai_gateway.gateway import GatewayRuntime
from devplacepy.services.openai_gateway.routing import model_store from devplacepy.services.openai_gateway.routing import model_store
@ -262,6 +262,58 @@ class GatewayService(BaseService):
"with this key. Clear it and restart to rotate.", "with this key. Clear it and restart to rotate.",
group="Access", group="Access",
), ),
ConfigField(
quota.FIELD_DEFAULT_USER,
"Default per-user daily cap ($)",
type="float",
default=1.0,
minimum=0,
help="Rolling 24h cap applied to a signed-in member with no matching quota rule. "
"0 = unlimited. Overridable per user/app-reference on /admin/gateway.",
group="Quota",
),
ConfigField(
quota.FIELD_DEFAULT_ADMIN,
"Default per-admin daily cap ($)",
type="float",
default=0.0,
minimum=0,
help="Rolling 24h cap applied to an administrator with no matching quota rule. "
"0 = unlimited (the default - admins are exempt unless a rule says otherwise).",
group="Quota",
),
ConfigField(
quota.FIELD_DEFAULT_GUEST,
"Default per-guest daily cap ($)",
type="float",
default=0.05,
minimum=0,
help="Rolling 24h cap applied to an unauthenticated caller with no matching quota "
"rule (only reachable when authentication is not required). 0 = unlimited.",
group="Quota",
),
ConfigField(
quota.FIELD_DEFAULT_INTERNAL,
"Default per-internal-key daily cap ($)",
type="float",
default=0.0,
minimum=0,
help="Rolling 24h cap applied to calls authenticated with the auto-generated internal "
"key (DevPlace's own services: news, bots, Devii guests, correction/modifier). "
"0 = unlimited (the default - do not cap this without a matching quota rule, or "
"internal platform traffic will start failing).",
group="Quota",
),
ConfigField(
quota.FIELD_DEFAULT_KEY,
"Default per-access-key daily cap ($)",
type="float",
default=0.0,
minimum=0,
help="Rolling 24h cap applied to calls authenticated with the static access key. "
"0 = unlimited by default (it is an admin-provisioned trusted secret).",
group="Quota",
),
ConfigField( ConfigField(
"gateway_price_cache_hit_per_m", "gateway_price_cache_hit_per_m",
"Chat price cache-hit / 1M ($)", "Chat price cache-hit / 1M ($)",
@ -463,6 +515,37 @@ class GatewayService(BaseService):
return (kind, user.get("uid") or "unknown") return (kind, user.get("uid") or "unknown")
return ("anonymous", "anonymous") return ("anonymous", "anonymous")
def _audit_quota_exceeded(
self,
owner_kind: str,
owner_id: str,
app_reference: str,
spent: float,
limit: float,
rule,
) -> None:
from devplacepy.services.audit import record as audit
from devplacepy.services.openai_gateway.usage import audit_actor_for
actor_kind, actor_uid, actor_role = audit_actor_for(owner_kind, owner_id)
audit.record_system(
"ai.quota.exceeded",
actor_kind=actor_kind,
actor_uid=actor_uid,
actor_role=actor_role,
origin="api",
result="denied",
summary=f"AI gateway call by {owner_kind}/{owner_id} blocked - 24h quota reached",
metadata={
"owner_kind": owner_kind,
"owner_id": owner_id,
"app_reference": app_reference,
"spent_usd": round(spent, 6),
"limit_usd": limit,
"rule_uid": rule.uid if rule else None,
},
)
def _models_response(self) -> JSONResponse: def _models_response(self) -> JSONResponse:
created = int(time.time()) created = int(time.time())
seen: set = set() seen: set = set()
@ -501,6 +584,17 @@ class GatewayService(BaseService):
) )
if subpath == "models" and request.method == "GET": if subpath == "models" and request.method == "GET":
return self._models_response() return self._models_response()
limit, scope, rule = quota.resolve(owner[0], owner[1], app_reference, cfg)
if limit > 0:
spent = quota.spent_24h(*scope)
if spent >= limit:
self.log(
f"Rejected {owner[0]}:{owner[1]} app={app_reference}: "
f"quota exceeded (${spent:.4f}/${limit:.4f}"
f"{' rule ' + rule.uid if rule else ' default'})"
)
self._audit_quota_exceeded(owner[0], owner[1], app_reference, spent, limit, rule)
raise HTTPException(status_code=429, detail="AI gateway daily quota exceeded")
if subpath == "chat/completions" and request.method == "POST": if subpath == "chat/completions" and request.method == "POST":
try: try:
body = await request.json() body = await request.json()

View File

@ -263,6 +263,13 @@
font-size: 0.8rem; font-size: 0.8rem;
} }
.game-lb-title {
flex-shrink: 0;
color: var(--accent);
font-size: 0.75rem;
font-style: italic;
}
.game-lb-score { .game-lb-score {
flex-shrink: 0; flex-shrink: 0;
color: var(--text-muted); color: var(--text-muted);

View File

@ -11,7 +11,9 @@ export class GameFarm {
this.username = this.root.dataset.username || ""; this.username = this.root.dataset.username || "";
this.stateUrl = this.mode === "view" ? `/game/farm/${this.username}` : "/game/state"; this.stateUrl = this.mode === "view" ? `/game/farm/${this.username}` : "/game/state";
this.farm = null; this.farm = null;
this.board = "score";
this._bindActions(); this._bindActions();
this._bindLeaderboardBoard();
this._startTicker(); this._startTicker();
this.load(); this.load();
if (this.mode === "own") this._loadLeaderboard(); if (this.mode === "own") this._loadLeaderboard();
@ -39,6 +41,15 @@ export class GameFarm {
}); });
} }
_bindLeaderboardBoard() {
const select = this.root.querySelector("[data-lb-board]");
if (!select) return;
select.addEventListener("change", () => {
this.board = select.value;
this._loadLeaderboard();
});
}
async _submit(form) { async _submit(form) {
const action = form.dataset.gameAction; const action = form.dataset.gameAction;
const params = {}; const params = {};
@ -68,8 +79,13 @@ export class GameFarm {
this._setHost("[data-game-grid-host]", this._gridHtml(farm)); this._setHost("[data-game-grid-host]", this._gridHtml(farm));
if (this.mode === "own") { if (this.mode === "own") {
this._setHost("[data-shop-host]", this._shopHtml(farm)); this._setHost("[data-shop-host]", this._shopHtml(farm));
this._setHost("[data-defense-host]", this._defenseHtml(farm));
this._setHost("[data-infra-host]", this._infraHtml(farm));
this._setHost("[data-perk-host]", this._perksHtml(farm)); this._setHost("[data-perk-host]", this._perksHtml(farm));
this._setHost("[data-legacy-host]", this._legacyHtml(farm)); this._setHost("[data-legacy-host]", this._legacyHtml(farm));
this._setHost("[data-mastery-host]", this._masteryHtml(farm));
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-daily-host]", this._dailyHtml(farm));
this._setHost("[data-quest-host]", this._questsHtml(farm)); this._setHost("[data-quest-host]", this._questsHtml(farm));
} }
@ -94,6 +110,9 @@ export class GameFarm {
set("[data-hud-prestige]", farm.prestige); set("[data-hud-prestige]", farm.prestige);
set("[data-hud-prestige-mult]", `+${Math.round(farm.prestige_multiplier * 100 - 100)}% coins`); set("[data-hud-prestige-mult]", `+${Math.round(farm.prestige_multiplier * 100 - 100)}% coins`);
set("[data-hud-stars]", farm.stars); set("[data-hud-stars]", farm.stars);
set("[data-hud-mastery]", farm.mastery_points);
set("[data-era-coins]", farm.era_coins);
set("[data-era-harvests]", farm.era_harvests);
const fill = this.root.querySelector("[data-hud-xp-fill]"); const fill = this.root.querySelector("[data-hud-xp-fill]");
if (fill) { if (fill) {
const pct = farm.level_is_max || !farm.level_span ? 100 : Math.floor((100 * farm.level_into) / farm.level_span); const pct = farm.level_is_max || !farm.level_span ? 100 : Math.floor((100 * farm.level_into) / farm.level_span);
@ -143,6 +162,70 @@ export class GameFarm {
.join(""); .join("");
} }
_masteryHtml(farm) {
return (farm.mastery || [])
.map((m) => {
const action = m.maxed
? `<span class="perk-maxed">Maxed</span>`
: `<form method="post" action="/game/mastery" data-game-action="mastery"><input type="hidden" name="key" value="${m.key}"><button type="submit" class="btn btn-sm">Upgrade (${m.cost} Mastery)</button></form>`;
return `<div class="perk-card"><span class="perk-icon" aria-hidden="true">${m.icon}</span><strong class="perk-name">${m.name}</strong><span class="perk-level">Lv ${m.level}/${m.max_level}</span><span class="perk-effect">${m.effect || m.description}</span>${action}</div>`;
})
.join("");
}
_analyticsHtml(farm) {
if (!farm.mastery_analytics_unlocked) return "";
return (
`<div class="shop-row"><div class="shop-info"><strong>Lifetime coins earned</strong><span>${farm.lifetime_coins_earned}c since Farm Analytics was unlocked.</span></div></div>` +
`<div class="shop-row"><div class="shop-info"><strong>Lifetime harvests</strong><span>${farm.lifetime_harvests} builds harvested since Farm Analytics was unlocked.</span></div></div>`
);
}
_infraHtml(farm) {
return (farm.infrastructure || [])
.map((infra) => {
let action;
if (infra.owned) {
action = `<span class="perk-maxed">Owned</span>`;
} else if (farm.prestige < infra.min_prestige) {
action = `<span class="perk-maxed">Requires prestige ${infra.min_prestige}</span>`;
} else {
action = `<form method="post" action="/game/infrastructure/buy" data-game-action="infrastructure"><input type="hidden" name="key" value="${infra.key}"><button type="submit" class="btn btn-sm" data-confirm="Buy ${infra.name} for ${infra.cost}c?">Buy (${infra.cost}c)</button></form>`;
}
return `<div class="perk-card"><span class="perk-icon" aria-hidden="true">${infra.icon}</span><strong class="perk-name">${infra.name}</strong><span class="perk-effect">${infra.description}</span>${action}</div>`;
})
.join("");
}
_defenseHtml(farm) {
const upkeep = farm.defense_upkeep_daily
? `Costs ${farm.defense_upkeep_daily}c/day upkeep - unpaid protection decays a level.`
: "No upkeep at level 0.";
const action = farm.defense_next_cost
? `<form method="post" action="/game/defense/upgrade" data-game-action="defense"><button type="submit" class="btn btn-sm btn-primary">Upgrade (${farm.defense_next_cost}c)</button></form>`
: `<span class="perk-maxed">Maxed</span>`;
return `<div class="shop-row"><div class="shop-info"><strong>Defense: ${farm.defense_tier_name} (level ${farm.defense_level})</strong><span>${upkeep}</span></div>${action}</div>`;
}
_cosmeticsHtml(farm) {
return (farm.cosmetics || [])
.map((c) => {
let action;
if (c.owned && c.kind === "title") {
action =
farm.active_title === c.key
? `<span class="perk-maxed">Equipped</span>`
: `<form method="post" action="/game/cosmetics/equip" data-game-action="cosmetic-equip"><input type="hidden" name="key" value="${c.key}"><button type="submit" class="btn btn-sm">Equip</button></form>`;
} else if (c.owned) {
action = `<span class="perk-maxed">Owned</span>`;
} else {
action = `<form method="post" action="/game/cosmetics/buy" data-game-action="cosmetic-buy"><input type="hidden" name="key" value="${c.key}"><button type="submit" class="btn btn-sm" data-confirm="Buy ${c.name} for ${c.cost_coins}c?">Buy (${c.cost_coins}c)</button></form>`;
}
return `<div class="perk-card"><span class="perk-icon" aria-hidden="true">${c.icon}</span><strong class="perk-name">${c.name}</strong><span class="perk-effect">${c.description}</span>${action}</div>`;
})
.join("");
}
_dailyHtml(farm) { _dailyHtml(farm) {
const action = farm.daily_available 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>` ? `<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>`
@ -156,13 +239,14 @@ export class GameFarm {
return quests return quests
.map((quest) => { .map((quest) => {
const pct = quest.goal ? Math.floor((100 * quest.progress) / quest.goal) : 0; const pct = quest.goal ? Math.floor((100 * quest.progress) / quest.goal) : 0;
const reward = quest.reward_stars ? `+${quest.reward_stars} &#9733;` : `+${quest.reward_coins}c`;
let foot = ""; let foot = "";
if (quest.can_claim) { if (quest.can_claim) {
foot = `<form method="post" action="/game/quests/claim" data-game-action="claim-quest"><input type="hidden" name="quest" value="${quest.kind}"><button type="submit" class="btn btn-sm btn-primary">Claim</button></form>`; foot = `<form method="post" action="/game/quests/claim" data-game-action="claim-quest"><input type="hidden" name="quest" value="${quest.kind}"><input type="hidden" name="scope" value="${quest.scope}"><button type="submit" class="btn btn-sm btn-primary">Claim</button></form>`;
} else if (quest.claimed) { } else if (quest.claimed) {
foot = `<span class="quest-claimed">Done</span>`; foot = `<span class="quest-claimed">Done</span>`;
} }
return `<li class="quest-item${quest.claimed ? " quest-done" : ""}"><div class="quest-head"><span>${quest.label}</span><span class="quest-reward">+${quest.reward_coins}c</span></div><div class="quest-bar"><span class="quest-fill" style="--quest-pct:${pct}%"></span></div><div class="quest-foot"><span class="quest-progress">${quest.progress}/${quest.goal}</span>${foot}</div></li>`; return `<li class="quest-item${quest.claimed ? " quest-done" : ""}"><div class="quest-head"><span>${quest.label}</span><span class="quest-reward">${reward}</span></div><div class="quest-bar"><span class="quest-fill" style="--quest-pct:${pct}%"></span></div><div class="quest-foot"><span class="quest-progress">${quest.progress}/${quest.goal}</span>${foot}</div></li>`;
}) })
.join(""); .join("");
} }
@ -186,7 +270,13 @@ export class GameFarm {
.map((crop) => { .map((crop) => {
const disabled = crop.locked || crop.cost > farm.coins ? " disabled" : ""; const disabled = crop.locked || crop.cost > farm.coins ? " disabled" : "";
const lock = crop.locked ? ` (Lv ${crop.min_level})` : ""; const lock = crop.locked ? ` (Lv ${crop.min_level})` : "";
return `<option value="${crop.key}"${disabled}>${crop.icon} ${crop.name} - ${crop.cost}c${lock}</option>`; const market =
crop.market_state === "saturated"
? " (saturated)"
: crop.market_state === "boosted"
? " (boosted)"
: "";
return `<option value="${crop.key}"${disabled}>${crop.icon} ${crop.name} - ${crop.cost}c${lock}${market}</option>`;
}) })
.join(""); .join("");
body = `<form class="plot-plant-form" method="post" action="/game/plant" data-game-action="plant"><input type="hidden" name="slot" value="${plot.slot}"><select name="crop" class="plot-crop-select" aria-label="Choose a project to build">${options}</select><button type="submit" class="btn btn-sm btn-primary">Plant</button></form>`; body = `<form class="plot-plant-form" method="post" action="/game/plant" data-game-action="plant"><input type="hidden" name="slot" value="${plot.slot}"><select name="crop" class="plot-crop-select" aria-label="Choose a project to build">${options}</select><button type="submit" class="btn btn-sm btn-primary">Plant</button></form>`;
@ -257,19 +347,27 @@ export class GameFarm {
const list = this.root.querySelector("[data-game-leaderboard]"); const list = this.root.querySelector("[data-game-leaderboard]");
if (!list) return; if (!list) return;
try { try {
const data = await Http.getJson("/game/leaderboard"); const data = await Http.getJson(`/game/leaderboard?board=${encodeURIComponent(this.board || "score")}`);
if (!data.entries || !data.entries.length) { if (!data.entries || !data.entries.length) {
list.innerHTML = `<li class="game-lb-empty">No farmers yet.</li>`; list.innerHTML = `<li class="game-lb-empty">No farmers yet.</li>`;
return; return;
} }
const board = this.board || "score";
list.innerHTML = data.entries list.innerHTML = data.entries
.map( .map((entry) => {
(entry) => const title = entry.title ? `<span class="game-lb-title">${entry.title}</span>` : "";
`<li class="game-lb-row${entry.username === this.username ? " game-lb-self" : ""}"><span class="game-lb-rank">#${entry.rank}</span><a class="game-lb-name" href="/game/farm/${entry.username}">${entry.username}</a><span class="game-lb-level">Lv ${entry.level}</span><span class="game-lb-score">${entry.score.toLocaleString()}</span></li>` const value = this._leaderboardValue(board, entry);
) return `<li class="game-lb-row${entry.username === this.username ? " game-lb-self" : ""}"><span class="game-lb-rank">#${entry.rank}</span><a class="game-lb-name" href="/game/farm/${entry.username}">${entry.username}</a>${title}<span class="game-lb-level">Lv ${entry.level}</span><span class="game-lb-score">${value}</span></li>`;
})
.join(""); .join("");
} catch (error) { } catch (error) {
return; return;
} }
} }
_leaderboardValue(board, entry) {
if (board === "raids") return `${entry.raid_avg.toLocaleString()}c/raid`;
if (board === "time_to_kernel") return this._format(entry.time_to_kernel_seconds);
return entry.score.toLocaleString();
}
} }

View File

@ -11,6 +11,10 @@ export class GatewayAdmin {
this.modelForm = root.querySelector("#gw-model-form"); this.modelForm = root.querySelector("#gw-model-form");
this.providerSelects = root.querySelectorAll("[data-provider-select]"); this.providerSelects = root.querySelectorAll("[data-provider-select]");
this.providers = []; this.providers = [];
this.quotaRulesBody = root.querySelector("#gw-quota-rules");
this.quotaForm = root.querySelector("#gw-quota-form");
this.quotaCancelEdit = root.querySelector("#gw-quota-cancel-edit");
this.quotaRules = [];
} }
async start() { async start() {
@ -46,6 +50,12 @@ export class GatewayAdmin {
this.modelForm.kind.addEventListener("change", () => this.syncKindFields()); this.modelForm.kind.addEventListener("change", () => this.syncKindFields());
this.providersBody.addEventListener("click", (event) => this.onProviderClick(event)); this.providersBody.addEventListener("click", (event) => this.onProviderClick(event));
this.modelsBody.addEventListener("click", (event) => this.onModelClick(event)); this.modelsBody.addEventListener("click", (event) => this.onModelClick(event));
this.quotaForm.addEventListener("submit", (event) => {
event.preventDefault();
this.saveQuotaRule();
});
this.quotaRulesBody.addEventListener("click", (event) => this.onQuotaRuleClick(event));
this.quotaCancelEdit.addEventListener("click", () => this.resetQuotaForm());
} }
notify(message, type) { notify(message, type) {
@ -57,9 +67,10 @@ export class GatewayAdmin {
async reload() { async reload() {
const providerCount = await this.loadProviders(); const providerCount = await this.loadProviders();
const modelCount = await this.loadModels(); const modelCount = await this.loadModels();
const quotaCount = await this.loadQuotaRules();
const count = this.root.querySelector("#gw-count"); const count = this.root.querySelector("#gw-count");
if (count) { if (count) {
count.textContent = `${providerCount} providers, ${modelCount} routes`; count.textContent = `${providerCount} providers, ${modelCount} routes, ${quotaCount} quota rules`;
} }
} }
@ -336,4 +347,115 @@ export class GatewayAdmin {
this.notify(err.message || "Delete failed", "error"); this.notify(err.message || "Delete failed", "error");
} }
} }
async loadQuotaRules() {
const data = await Http.getJson("/admin/gateway/quota-rules");
this.quotaRules = data.rules || [];
this.renderQuotaDefaults(data.defaults || {});
this.renderQuotaRules();
return this.quotaRules.length;
}
renderQuotaDefaults(defaults) {
const el = this.root.querySelector("#gw-quota-defaults");
if (!el) return;
const fmt = (v) => (Number(v) > 0 ? `$${Number(v).toFixed(2)}/24h` : "unlimited");
el.innerHTML = `
<strong>global defaults</strong> (from <a href="/admin/services">Services config</a>, apply per caller with no matching rule):
member <code class="gw-code">${fmt(defaults.user)}</code>,
admin <code class="gw-code">${fmt(defaults.admin)}</code>,
guest <code class="gw-code">${fmt(defaults.guest)}</code>,
internal <code class="gw-code">${fmt(defaults.internal)}</code>,
access key <code class="gw-code">${fmt(defaults.key)}</code>`;
}
scopeLabel(rule) {
const parts = [];
parts.push(rule.owner_kind ? `role=${rule.owner_kind}` : "role=any");
parts.push(rule.owner_id ? `user=${rule.owner_id}` : "user=any");
parts.push(rule.app_reference ? `app=${rule.app_reference}` : "app=any");
return parts.join(", ");
}
renderQuotaRules() {
if (!this.quotaRules.length) {
this.quotaRulesBody.innerHTML = `<tr><td colspan="6" class="admin-empty">No quota rules. Every caller is capped by the global defaults above.</td></tr>`;
return;
}
this.quotaRulesBody.innerHTML = this.quotaRules
.map((r) => {
const limit = Number(r.limit_usd) > 0 ? `$${Number(r.limit_usd).toFixed(2)}` : "unlimited";
const spent = `$${Number(r.spent_24h_usd || 0).toFixed(4)}`;
return `<tr>
<td><code class="gw-code">${this.escape(this.scopeLabel(r))}</code></td>
<td>${limit}</td>
<td>${spent}</td>
<td>${r.is_active ? "yes" : "no"}</td>
<td>${this.escape(r.label || "")}</td>
<td class="gw-actions">
<button class="admin-btn admin-btn-sm" data-edit-quota='${this.attr(JSON.stringify(r))}'>Edit</button>
<button class="admin-btn admin-btn-sm admin-btn-danger" data-del-quota="${this.attr(r.uid)}">Delete</button>
</td>
</tr>`;
})
.join("");
}
async saveQuotaRule() {
const values = this.formValues(this.quotaForm);
const payload = {
owner_kind: values.owner_kind || null,
owner_id: values.owner_id || null,
app_reference: values.app_reference || null,
limit_usd: parseFloat(values.limit_usd) || 0,
is_active: values.is_active === "1",
label: values.label || "",
};
if (values.uid) payload.uid = values.uid;
try {
await Http.postJson("/admin/gateway/quota-rules", payload);
this.resetQuotaForm();
this.notify("Quota rule saved", "success");
await this.reload();
} catch (err) {
this.notify(err.message || "Save failed", "error");
}
}
fillQuotaForm(rule) {
const form = this.quotaForm;
form.uid.value = rule.uid || "";
form.owner_kind.value = rule.owner_kind || "";
form.owner_id.value = rule.owner_id || "";
form.app_reference.value = rule.app_reference || "";
form.limit_usd.value = rule.limit_usd || 0;
form.is_active.value = rule.is_active ? "1" : "0";
form.label.value = rule.label || "";
this.quotaCancelEdit.hidden = false;
form.owner_kind.scrollIntoView({ block: "center" });
}
resetQuotaForm() {
this.quotaForm.reset();
this.quotaForm.uid.value = "";
this.quotaCancelEdit.hidden = true;
}
onQuotaRuleClick(event) {
const editRaw = event.target.dataset.editQuota;
const delUid = event.target.dataset.delQuota;
if (editRaw) this.fillQuotaForm(JSON.parse(editRaw));
if (delUid) this.deleteQuotaRule(delUid);
}
async deleteQuotaRule(uid) {
if (!(await this.confirmDelete("Delete this quota rule?"))) return;
try {
await this.remove(`/admin/gateway/quota-rules/${encodeURIComponent(uid)}`);
this.notify("Quota rule deleted", "success");
await this.reload();
} catch (err) {
this.notify(err.message || "Delete failed", "error");
}
}
} }

View File

@ -0,0 +1,14 @@
{% if farm.mastery_analytics_unlocked %}
<div class="shop-row">
<div class="shop-info">
<strong>Lifetime coins earned</strong>
<span>{{ farm.lifetime_coins_earned }}c since Farm Analytics was unlocked.</span>
</div>
</div>
<div class="shop-row">
<div class="shop-info">
<strong>Lifetime harvests</strong>
<span>{{ farm.lifetime_harvests }} builds harvested since Farm Analytics was unlocked.</span>
</div>
</div>
{% endif %}

View File

@ -0,0 +1,26 @@
{% for c in farm.cosmetics %}
<div class="perk-card">
<span class="perk-icon" aria-hidden="true">{{ c.icon }}</span>
<strong class="perk-name">{{ c.name }}</strong>
<span class="perk-effect">{{ c.description }}</span>
{% if c.owned %}
{% if c.kind == 'title' %}
{% if farm.active_title == c.key %}
<span class="perk-maxed">Equipped</span>
{% else %}
<form method="post" action="/game/cosmetics/equip" data-game-action="cosmetic-equip">
<input type="hidden" name="key" value="{{ c.key }}">
<button type="submit" class="btn btn-sm">Equip</button>
</form>
{% endif %}
{% else %}
<span class="perk-maxed">Owned</span>
{% endif %}
{% else %}
<form method="post" action="/game/cosmetics/buy" data-game-action="cosmetic-buy">
<input type="hidden" name="key" value="{{ c.key }}">
<button type="submit" class="btn btn-sm" data-confirm="Buy {{ c.name }} for {{ c.cost_coins }}c?">Buy ({{ c.cost_coins }}c)</button>
</form>
{% endif %}
</div>
{% endfor %}

View File

@ -0,0 +1,13 @@
<div class="shop-row">
<div class="shop-info">
<strong>Defense: {{ farm.defense_tier_name }} (level {{ farm.defense_level }})</strong>
<span>{% if farm.defense_upkeep_daily %}Costs {{ farm.defense_upkeep_daily }}c/day upkeep - unpaid protection decays a level.{% else %}No upkeep at level 0.{% endif %}</span>
</div>
{% if farm.defense_next_cost %}
<form method="post" action="/game/defense/upgrade" data-game-action="defense">
<button type="submit" class="btn btn-sm btn-primary">Upgrade ({{ farm.defense_next_cost }}c)</button>
</form>
{% else %}
<span class="perk-maxed">Maxed</span>
{% endif %}
</div>

View File

@ -7,7 +7,7 @@
<input type="hidden" name="slot" value="{{ plot.slot }}"> <input type="hidden" name="slot" value="{{ plot.slot }}">
<select name="crop" class="plot-crop-select" aria-label="Choose a project to build"> <select name="crop" class="plot-crop-select" aria-label="Choose a project to build">
{% for crop in farm.crops %} {% for crop in farm.crops %}
<option value="{{ crop.key }}"{% if crop.locked or crop.cost > farm.coins %} disabled{% endif %}>{{ crop.icon }} {{ crop.name }} - {{ crop.cost }}c{% if crop.locked %} (Lv {{ crop.min_level }}){% endif %}</option> <option value="{{ crop.key }}"{% if crop.locked or crop.cost > farm.coins %} disabled{% endif %}>{{ crop.icon }} {{ crop.name }} - {{ crop.cost }}c{% if crop.locked %} (Lv {{ crop.min_level }}){% endif %}{% if crop.market_state == 'saturated' %} (saturated){% elif crop.market_state == 'boosted' %} (boosted){% endif %}</option>
{% endfor %} {% endfor %}
</select> </select>
<button type="submit" class="btn btn-sm btn-primary">Plant</button> <button type="submit" class="btn btn-sm btn-primary">Plant</button>

View File

@ -0,0 +1,17 @@
{% for infra in farm.infrastructure %}
<div class="perk-card">
<span class="perk-icon" aria-hidden="true">{{ infra.icon }}</span>
<strong class="perk-name">{{ infra.name }}</strong>
<span class="perk-effect">{{ infra.description }}</span>
{% if infra.owned %}
<span class="perk-maxed">Owned</span>
{% elif farm.prestige < infra.min_prestige %}
<span class="perk-maxed">Requires prestige {{ infra.min_prestige }}</span>
{% else %}
<form method="post" action="/game/infrastructure/buy" data-game-action="infrastructure">
<input type="hidden" name="key" value="{{ infra.key }}">
<button type="submit" class="btn btn-sm" data-confirm="Buy {{ infra.name }} for {{ infra.cost }}c?">Buy ({{ infra.cost }}c)</button>
</form>
{% endif %}
</div>
{% endfor %}

View File

@ -0,0 +1,16 @@
{% for m in farm.mastery %}
<div class="perk-card">
<span class="perk-icon" aria-hidden="true">{{ m.icon }}</span>
<strong class="perk-name">{{ m.name }}</strong>
<span class="perk-level">Lv {{ m.level }}/{{ m.max_level }}</span>
<span class="perk-effect">{{ m.effect or m.description }}</span>
{% if m.maxed %}
<span class="perk-maxed">Maxed</span>
{% else %}
<form method="post" action="/game/mastery" data-game-action="mastery">
<input type="hidden" name="key" value="{{ m.key }}">
<button type="submit" class="btn btn-sm">Upgrade ({{ m.cost }} Mastery)</button>
</form>
{% endif %}
</div>
{% endfor %}

View File

@ -1,12 +1,13 @@
{% for quest in farm.quests %} {% for quest in farm.quests %}
<li class="quest-item{% if quest.claimed %} quest-done{% endif %}"> <li class="quest-item{% if quest.claimed %} quest-done{% endif %}">
<div class="quest-head"><span>{{ quest.label }}</span><span class="quest-reward">+{{ quest.reward_coins }}c</span></div> <div class="quest-head"><span>{{ quest.label }}</span><span class="quest-reward">{% if quest.reward_stars %}+{{ quest.reward_stars }} &#9733;{% else %}+{{ quest.reward_coins }}c{% endif %}</span></div>
<div class="quest-bar"><span class="quest-fill" style="--quest-pct: {% if quest.goal %}{{ (100 * quest.progress / quest.goal)|round(0, 'floor') }}{% else %}0{% endif %}%"></span></div> <div class="quest-bar"><span class="quest-fill" style="--quest-pct: {% if quest.goal %}{{ (100 * quest.progress / quest.goal)|round(0, 'floor') }}{% else %}0{% endif %}%"></span></div>
<div class="quest-foot"> <div class="quest-foot">
<span class="quest-progress">{{ quest.progress }}/{{ quest.goal }}</span> <span class="quest-progress">{{ quest.progress }}/{{ quest.goal }}</span>
{% if quest.can_claim %} {% if quest.can_claim %}
<form method="post" action="/game/quests/claim" data-game-action="claim-quest"> <form method="post" action="/game/quests/claim" data-game-action="claim-quest">
<input type="hidden" name="quest" value="{{ quest.kind }}"> <input type="hidden" name="quest" value="{{ quest.kind }}">
<input type="hidden" name="scope" value="{{ quest.scope }}">
<button type="submit" class="btn btn-sm btn-primary">Claim</button> <button type="submit" class="btn btn-sm btn-primary">Claim</button>
</form> </form>
{% elif quest.claimed %} {% elif quest.claimed %}

View File

@ -32,6 +32,9 @@
<a href="/admin/bots" class="sidebar-link {% if admin_section == 'bots' %}active{% endif %}"> <a href="/admin/bots" class="sidebar-link {% if admin_section == 'bots' %}active{% endif %}">
<span class="sidebar-icon">&#x1F916;</span> Bot Monitor <span class="sidebar-icon">&#x1F916;</span> Bot Monitor
</a> </a>
<a href="/admin/game" class="sidebar-link {% if admin_section == 'game' %}active{% endif %}">
<span class="sidebar-icon">&#x1F69C;</span> Code Farm
</a>
<a href="/admin/ai-usage" class="sidebar-link {% if admin_section == 'ai-usage' %}active{% endif %}"> <a href="/admin/ai-usage" class="sidebar-link {% if admin_section == 'ai-usage' %}active{% endif %}">
<span class="sidebar-icon">&#x1F4CA;</span> AI usage <span class="sidebar-icon">&#x1F4CA;</span> AI usage
</a> </a>

View File

@ -0,0 +1,31 @@
{% extends "admin_base.html" %}
{% block admin_content %}
<div class="admin-toolbar">
<h2>Code Farm - Eras</h2>
</div>
<p class="hint-text admin-settings-intro">Starting an Era snapshots a fresh visible leaderboard (coins/harvests this Era) for every farm. Real coin balances, prestige, stars, and Legacy/Mastery upgrades are never touched or reset. Ending an Era ranks farms by Era score, awards Stars to the top 10, and records the results permanently.</p>
{% if era_active %}
<div class="admin-field">
<p><strong>Era {{ era_number }}: {{ era_name }}</strong> is running.</p>
<p class="hint-text">Started {{ format_date(era_started_at, true) }}, scheduled to end {{ format_date(era_ends_at, true) }}.</p>
</div>
<form method="POST" action="/admin/game/era/end">
<button type="submit" class="btn btn-danger" data-confirm="End the current Era now? This ranks every farm, awards Stars to the top 10, and resets the visible Era leaderboard. Real balances are never affected.">End Era</button>
</form>
{% else %}
<p class="hint-text">No Era is currently running. The Era leaderboard board and Era-exclusive crops stay dormant until one is started.</p>
<form method="POST" action="/admin/game/era/start" class="admin-settings-form">
<div class="admin-field">
<label for="era-name">Era name</label>
<input type="text" id="era-name" name="name" maxlength="60" required placeholder="e.g. Genesis">
</div>
<div class="admin-field">
<label for="era-duration">Duration (days)</label>
<input type="number" id="era-duration" name="duration_days" min="1" max="180" value="28">
</div>
<button type="submit" class="btn btn-primary" data-confirm="Start a new Code Farm Era? This resets every farm's visible Era coins/harvests counters to zero (real coins and prestige are untouched).">Start Era</button>
</form>
{% endif %}
{% endblock %}

View File

@ -77,6 +77,46 @@
<div class="gw-form-actions"><button type="submit" class="admin-btn admin-btn-primary">Save model route</button></div> <div class="gw-form-actions"><button type="submit" class="admin-btn admin-btn-primary">Save model route</button></div>
</form> </form>
</section> </section>
<section class="gw-section">
<div class="gw-section-head">
<h3>Quota rules</h3>
</div>
<p class="gw-section-hint">Rolling 24h USD caps on <code class="gw-code">/openai/v1/*</code>. A rule scopes by any combination of role, specific user, and app label (the <code class="gw-code">X-App-Reference</code> header); the most specific active match wins, and a rule that omits a dimension pools spend across everyone matching it (e.g. an app-only rule caps that app's combined usage across all callers). With no matching rule, the global defaults below apply per caller. A limit of 0 means unlimited.</p>
<div class="gw-default" id="gw-quota-defaults" role="status" aria-live="polite"></div>
<div class="admin-table-wrap">
<table class="admin-table">
<caption class="sr-only">Quota rules</caption>
<thead>
<tr><th scope="col">Scope</th><th scope="col">Limit / 24h</th><th scope="col">Spent 24h</th><th scope="col">Active</th><th scope="col">Label</th><th scope="col" class="gw-actions">Actions</th></tr>
</thead>
<tbody id="gw-quota-rules"></tbody>
</table>
</div>
<form class="gw-form" id="gw-quota-form" autocomplete="off" role="group" aria-label="Add or update quota rule">
<p class="gw-form-title">Add or update quota rule</p>
<input type="hidden" id="gw-quota-uid" name="uid" value="">
<div class="gw-field"><label for="gw-quota-owner-kind">Role</label>
<select id="gw-quota-owner-kind" name="owner_kind">
<option value="">any</option>
<option value="user">member</option>
<option value="admin">admin</option>
<option value="anonymous">guest (unauthenticated)</option>
<option value="internal">internal (DevPlace's own services)</option>
<option value="key">static access key</option>
</select>
</div>
<div class="gw-field"><label for="gw-quota-owner-id">Specific user uid</label><input type="text" id="gw-quota-owner-id" name="owner_id" placeholder="(optional) exact uid, blank = any"></div>
<div class="gw-field"><label for="gw-quota-app-reference">App reference</label><input type="text" id="gw-quota-app-reference" name="app_reference" placeholder="(optional) devplace-bots-v-1-0-0, blank = any"></div>
<div class="gw-field"><label for="gw-quota-limit">Limit / 24h ($)</label><input type="number" id="gw-quota-limit" name="limit_usd" min="0" step="0.01" value="0" title="0 = unlimited"></div>
<div class="gw-field"><label for="gw-quota-active">Active</label><select id="gw-quota-active" name="is_active"><option value="1">Yes</option><option value="0">No</option></select></div>
<div class="gw-field"><label for="gw-quota-label">Label</label><input type="text" id="gw-quota-label" name="label" placeholder="(optional) admin note" maxlength="200"></div>
<div class="gw-form-actions">
<button type="submit" class="admin-btn admin-btn-primary">Save quota rule</button>
<button type="button" class="admin-btn admin-btn-sm" id="gw-quota-cancel-edit" hidden>Cancel edit</button>
</div>
</form>
</section>
</div> </div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}

View File

@ -41,11 +41,18 @@ and a level at which it unlocks. Slower crops pay far more per build.
| 🦀 Rust Engine | 200 | 30m | 520 | 60 | 4 | | 🦀 Rust Engine | 200 | 30m | 520 | 60 | 4 |
| λ Compiler | 500 | 1h | 1380 | 150 | 6 | | λ Compiler | 500 | 1h | 1380 | 150 | 6 |
| ⚙️ Kernel | 1200 | 2h | 3600 | 400 | 8 | | ⚙️ Kernel | 1200 | 2h | 3600 | 400 | 8 |
| 🕸️ Distributed System | 5000 | 4h | 9500 | 900 | Mastery |
| 🧠 ML Pipeline | 12000 | 6h | 21000 | 1800 | Mastery |
| 🔐 Security Fortress | 30000 | 8h | 48000 | 3200 | Mastery, raid-immune |
The three Mastery-tier crops unlock once you have earned at least one Mastery point (see
Mastery, below), on top of the usual level requirement. Security Fortress can never be raided.
Build time is the base time divided by the farm build speed (CI tier and the growth perk, below), Build time is the base time divided by the farm build speed (CI tier and the growth perk, below),
so a higher tier finishes everything proportionally faster. Coin and experience rewards shown in so a higher tier finishes everything proportionally faster. Coin and experience rewards shown in
the API already include your perks and refactor bonus, so read them from the live state rather the API already include your perks, refactor bonus, and the current Market Saturation factor, so
than from this table. read them from the live state rather than from this table. Each crop in the state also carries a
`market_state` of `normal`, `saturated`, or `boosted`.
## Plots ## Plots
@ -129,11 +136,56 @@ visitors, so popular farms fill up. Each plot reports `can_water`, `watered_coun
## Raiding a ready build (competition) ## Raiding a ready build (competition)
If an owner leaves a build **ready** without harvesting it, another member can raid it, but only If an owner leaves a build **ready** without harvesting it, another member can raid it, but only
after a **60 second protection window** has passed since the build became ready. The raider after a protection window (60 seconds by default, longer with Branch Protection or a Defense
receives **half** the build's coin value and the owner loses the build entirely. The owner is told building) has passed since the build became ready. The raider receives a fraction of the build's
their farm was raided but the raider's identity is never revealed. Each plot reports `can_steal` coin value (half by default, less with the owner's defenses, floored so a raid never pays nothing)
and `steal_coins` (the payout a raid would give), and `ready_at` lets you compute when the and the owner loses the build entirely - except a Security Fortress build, which can never be
protection window ends. Harvest your own ready builds promptly to keep them safe. raided. The owner is told their farm was raided but the raider's identity is never revealed. You
can raid a given neighbour only once per hour. Raiding a farm with **10x** your own coins grants you
a 24-hour **Underdog** boost (+25% harvest coins). Each plot reports `can_steal` and `steal_coins`
(the payout a raid would give), and `ready_at` lets you compute when the protection window ends.
Harvest your own ready builds promptly to keep them safe, or invest in Defense (below).
## Market Saturation
The game tracks the last 48 hours of league-wide harvests of each crop. When a crop is being
farmed heavily, its payout drops in steps (down to 40% of normal); while that is happening, the
four starter crops (Shell Script, Python Script, Web App, Go Service) get a relief buff of up to
+15%. This rewards diversifying what you plant instead of printing one crop nonstop. Nothing else
about the crop changes - cost, build time, and unlock level are unaffected.
## Infrastructure, Defense, and Cosmetics (coin sinks)
Once you are earning more coins than you can spend on plots and perks, three permanent systems
give large coins a purpose:
- **Infrastructure** - one-time, prestige-gated buildings bought with `POST /game/infrastructure/buy`:
**Private Registry** (Rust, Compiler, and Kernel crops grow 15% faster, prestige 3+), **Canary
Deployments** (every harvest has a 12% chance to double and a 6% chance to only refund its
planting cost, prestige 8+), and **Observability Suite** (raises the minimum coins you keep when
raided from 10% to 30%, prestige 15+).
- **Defense** - an upgradeable building (`POST /game/defense/upgrade`) that lowers your raid losses
and lengthens your protection window with every tier. Unlike a one-time purchase, it costs a
**daily coin upkeep** proportional to your balance; if you do not have enough coins to cover it,
the tier decays by one level automatically. This is the sink that scales with wealth.
- **Cosmetics** - purely cosmetic titles and plot skins bought with coins (`POST /game/cosmetics/buy`)
and equipped (`POST /game/cosmetics/equip`). Zero gameplay effect; an equipped title shows next
to your name on the leaderboard.
## Mastery (beyond prestige)
Refactoring past **prestige 50** starts earning a second currency: one Mastery point immediately,
then one more every 10 further prestige. Mastery points never expire, and once you have earned at
least one, the three Mastery-tier crops above stay unlocked even if you later spend the point.
Spend Mastery points with `POST /game/mastery` on three permanent upgrades:
| Mastery upgrade | Effect | Max level |
|------------------|--------|:---------:|
| 🔁 Continuous Delivery | Auto-replant the same crop right after harvest, if affordable | 1 |
| 📊 Farm Analytics | Unlocks lifetime stats on your farm | 1 |
| 📜 Legacy Contracts | Unlocks a weekly contract slot paying Stars and a temporary coin boost | 1 |
The state reports `mastery_points`, `mastery_points_earned_total`, and each upgrade's level and cost.
## Refactor (prestige) ## Refactor (prestige)
@ -162,10 +214,10 @@ five:
The live state reports your `stars` balance and each legacy upgrade's current level and the exact The live state reports your `stars` balance and each legacy upgrade's current level and the exact
star cost of its next level. star cost of its next level.
## Leaderboard and scoring ## Leaderboards and scoring
The leaderboard ranks the top farms by a composite score. Refactors dominate the formula, then The default leaderboard ranks the top farms by a composite score. Refactors dominate the formula,
lifetime harvests, level, and everything else: then lifetime harvests, level, and everything else:
``` ```
score = xp score = xp
@ -178,7 +230,19 @@ score = xp
+ min(streak, 30) * 15 + min(streak, 30) * 15
``` ```
Read the top farms with `GET {{ base }}/game/leaderboard` (public, no account needed). Read the top farms with `GET {{ base }}/game/leaderboard` (public, no account needed). Add
`?board=` to switch boards - `score` (default), `prestige`, `harvests` (this week only), `raids`
(average coins per successful raid over the last 30 days, minimum 3 qualifying raids), `time_to_kernel`
(fastest to harvest a Kernel after your last refactor), `fair_play` (rewards recent activity over
hoarding coins), and `era` (the current Era's board, empty when no Era is running - see below).
## Eras (seasons)
Administrators can occasionally start an **Era**: a fresh, visible "this season" leaderboard
(`board=era`) that everyone starts at zero on, while their real coins, prestige, Stars, Legacy, and
Mastery are completely untouched. When an Era ends, the top 10 by Era score earn permanent Stars
(and sometimes an Era-exclusive cosmetic). The state reports `era_active`, `era_name`, `era_coins`,
and `era_harvests` when one is running.
## Live updates ## Live updates
@ -199,7 +263,7 @@ your **API key** in an `X-API-KEY` header (or `Authorization: Bearer`). Your key
| Method | Path | Purpose | | Method | Path | Purpose |
|--------|------|---------| |--------|------|---------|
| `GET` | `/game/state` | Your full farm state (always JSON). | | `GET` | `/game/state` | Your full farm state (always JSON). |
| `GET` | `/game/leaderboard` | Top farms (public). | | `GET` | `/game/leaderboard` | Top farms (public). Accepts `?board=`. |
| `GET` | `/game/farm/{username}` | Another member's farm. | | `GET` | `/game/farm/{username}` | Another member's farm. |
| `POST` | `/game/plant` | Plant `crop` in `slot`. | | `POST` | `/game/plant` | Plant `crop` in `slot`. |
| `POST` | `/game/harvest` | Harvest the build in `slot`. | | `POST` | `/game/harvest` | Harvest the build in `slot`. |
@ -208,15 +272,21 @@ your **API key** in an `X-API-KEY` header (or `Authorization: Bearer`). Your key
| `POST` | `/game/upgrade` | Raise the CI tier. | | `POST` | `/game/upgrade` | Raise the CI tier. |
| `POST` | `/game/perk` | Upgrade `perk` (`yield`, `growth`, `discount`, `xp`). | | `POST` | `/game/perk` | Upgrade `perk` (`yield`, `growth`, `discount`, `xp`). |
| `POST` | `/game/daily` | Claim the daily bonus. | | `POST` | `/game/daily` | Claim the daily bonus. |
| `POST` | `/game/quests/claim` | Claim a completed quest by `quest` kind. | | `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. |
| `POST` | `/game/legacy` | Buy a legacy upgrade (`key`) with stars. | | `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. |
| `POST` | `/game/defense/upgrade` | Buy the next Defense tier with coins. |
| `POST` | `/game/cosmetics/buy` | Buy a cosmetic (`key`) with coins. |
| `POST` | `/game/cosmetics/equip` | Equip an owned title cosmetic (`key`). |
| `POST` | `/game/farm/{username}/water` | Water a neighbour's build in `slot`. | | `POST` | `/game/farm/{username}/water` | Water a neighbour's build in `slot`. |
| `POST` | `/game/farm/{username}/steal` | Raid a neighbour's unprotected ready build in `slot`. | | `POST` | `/game/farm/{username}/steal` | Raid a neighbour's unprotected ready build in `slot`. |
POST bodies are form encoded (`application/x-www-form-urlencoded`). Form fields are `slot` (an POST bodies are form encoded (`application/x-www-form-urlencoded`). Form fields are `slot` (an
integer plot index), `crop`, `perk`, `quest`, and `key` (a legacy upgrade) where the table notes them. Every POST returns integer plot index), `crop`, `perk`, `quest`, `scope`, and `key` (a legacy/Mastery/Infrastructure/cosmetic
the full farm under `farm`, so one call both performs the action and gives you the new state. key) where the table notes them. Every POST returns the full farm under `farm`, so one call both
performs the action and gives you the new state.
### Reading the state ### Reading the state

View File

@ -156,6 +156,10 @@ See [Code Farm](/docs/code-farm.html) for the game itself.
| 💧 | Good Neighbor | Water a neighbour's build (`water`, 1) | | 💧 | Good Neighbor | Water a neighbour's build (`water`, 1) |
| 🥷 | Cat Burglar | Steal a ready build from another farm (`harvest_stolen`, 1) | | 🥷 | Cat Burglar | Steal a ready build from another farm (`harvest_stolen`, 1) |
| 🚨 | Robbed | Have a build stolen from your farm (`got_stolen_from`, 1) | | 🚨 | Robbed | Have a build stolen from your farm (`got_stolen_from`, 1) |
| 📦 | Enterprise Ready | Buy your first Code Farm Infrastructure building (`infra_bought`, 1) |
| 🏰 | Fort Knox | Upgrade your Code Farm Defense for the first time (`defense_upgraded`, 1) |
| 💅 | Style Points | Buy your first Code Farm cosmetic (`cosmetic_bought`, 1) |
| 🪃 | David vs Goliath | Raid a farm with 10x your coins and earn an Underdog boost (`underdog_raid`, 1) |
### Levels and membership ### Levels and membership
@ -210,6 +214,10 @@ first-use badge):
| `water` | count | 1 Good Neighbor | | `water` | count | 1 Good Neighbor |
| `harvest_stolen` | count | 1 Cat Burglar | | `harvest_stolen` | count | 1 Cat Burglar |
| `got_stolen_from` | count | 1 Robbed | | `got_stolen_from` | count | 1 Robbed |
| `infra_bought` | count | 1 Enterprise Ready |
| `defense_upgraded` | count | 1 Fort Knox |
| `cosmetic_bought` | count | 1 Style Points |
| `underdog_raid` | count | 1 David vs Goliath |
## Where each badge is awarded ## Where each badge is awarded

View File

@ -42,6 +42,8 @@ Open `http://<host>:<PORT>`. Useful targets: `make docker-logs` (tail), `make do
The make targets always apply the `docker-compose.containers.yml` overlay, so the admin-only Container Manager works with no extra setup. Always update through them: a plain `docker compose up -d` drops the overlay and silently removes the docker socket and CLI the manager needs. The make targets always apply the `docker-compose.containers.yml` overlay, so the admin-only Container Manager works with no extra setup. Always update through them: a plain `docker compose up -d` drops the overlay and silently removes the docker socket and CLI the manager needs.
The healthcheck gives the app a 120s start window before any failure counts (`start_period: 120s` in `docker-compose.yml`). The full startup (DB init, background service registration, uvicorn workers) takes roughly 110s, so the start period must stay well above that. If you add heavyweight init work, verify the app boots within 120s or bump the start period to match.
## Updating ## Updating
Code is bind-mounted, so most updates need no rebuild: Code is bind-mounted, so most updates need no rebuild:

View File

@ -37,8 +37,22 @@
<span class="hud-value" data-hud-stars>{{ farm.stars }}</span> <span class="hud-value" data-hud-stars>{{ farm.stars }}</span>
<span class="hud-sub">spend on Legacy</span> <span class="hud-sub">spend on Legacy</span>
</div> </div>
{% if farm.mastery_points_earned_total %}
<div class="hud-stat">
<span class="hud-label">Mastery</span>
<span class="hud-value" data-hud-mastery>{{ farm.mastery_points }}</span>
<span class="hud-sub">spend on Mastery upgrades</span>
</div>
{% endif %}
</section> </section>
{% if farm.underdog_boost_seconds_remaining %}
<p class="daily-claimed" data-underdog-banner>Underdog boost active: +25% coins for a while after raiding a much richer farm.</p>
{% endif %}
{% if farm.era_active %}
<p class="daily-claimed" data-era-banner>Era "{{ farm.era_name }}" is running - you have earned <strong data-era-coins>{{ farm.era_coins }}</strong> Era coins and <strong data-era-harvests>{{ farm.era_harvests }}</strong> Era harvests so far.</p>
{% endif %}
<div class="game-layout"> <div class="game-layout">
<div class="game-main"> <div class="game-main">
<h2 class="game-section-title">Your plots</h2> <h2 class="game-section-title">Your plots</h2>
@ -49,6 +63,8 @@
<section class="game-shop"> <section class="game-shop">
<h2 class="game-section-title">Infrastructure</h2> <h2 class="game-section-title">Infrastructure</h2>
<div data-shop-host>{% include "_game_shop.html" %}</div> <div data-shop-host>{% include "_game_shop.html" %}</div>
<div data-defense-host>{% include "_game_defense.html" %}</div>
<div class="perk-grid" data-infra-host>{% include "_game_infra.html" %}</div>
</section> </section>
<section class="game-perks"> <section class="game-perks">
@ -61,6 +77,21 @@
<p class="game-legacy-note">Earn Stars every Refactor. Legacy upgrades are permanent and survive every refactor.</p> <p class="game-legacy-note">Earn Stars every Refactor. Legacy upgrades are permanent and survive every refactor.</p>
<div class="perk-grid" data-legacy-host>{% include "_game_legacy.html" %}</div> <div class="perk-grid" data-legacy-host>{% include "_game_legacy.html" %}</div>
</section> </section>
{% if farm.mastery_points_earned_total %}
<section class="game-mastery">
<h2 class="game-section-title">Mastery</h2>
<p class="game-legacy-note">Earned every 10 prestige past 50. Mastery upgrades open new gameplay rather than bigger numbers, and survive every refactor.</p>
<div class="perk-grid" data-mastery-host>{% include "_game_mastery.html" %}</div>
<div data-analytics-host>{% include "_game_analytics.html" %}</div>
</section>
{% endif %}
<section class="game-cosmetics">
<h2 class="game-section-title">Cosmetics</h2>
<p class="game-legacy-note">Pure status, zero power.</p>
<div class="perk-grid" data-cosmetics-host>{% include "_game_cosmetics.html" %}</div>
</section>
</div> </div>
<aside class="game-side"> <aside class="game-side">
@ -70,12 +101,21 @@
</section> </section>
<section class="game-quests"> <section class="game-quests">
<h2 class="game-section-title">Daily quests</h2> <h2 class="game-section-title">Quests &amp; contracts</h2>
<ul class="quest-list" data-quest-host>{% include "_game_quests.html" %}</ul> <ul class="quest-list" data-quest-host>{% include "_game_quests.html" %}</ul>
</section> </section>
<section class="game-lb-section"> <section class="game-lb-section">
<h2 class="game-section-title">Top farmers</h2> <h2 class="game-section-title">Leaderboard</h2>
<select data-lb-board aria-label="Leaderboard board">
<option value="score">Overall score</option>
<option value="prestige">Prestige</option>
<option value="harvests">Harvests this week</option>
<option value="raids">Raid efficiency</option>
<option value="time_to_kernel">Fastest to Kernel</option>
<option value="fair_play">Fair play</option>
<option value="era">Current Era</option>
</select>
<ol class="game-leaderboard" data-game-leaderboard> <ol class="game-leaderboard" data-game-leaderboard>
<li class="game-lb-empty">Loading.</li> <li class="game-lb-empty">Loading.</li>
</ol> </ol>

View File

@ -341,6 +341,17 @@
<span class="ai-quota-detail">${{ '%.4f'|format(ai_quota.spent_usd) }} / {% if ai_quota.unlimited %}no cap{% else %}${{ '%.2f'|format(ai_quota.limit_usd) }}{% endif %} &middot; {{ ai_quota.turns }} Devii turns</span> <span class="ai-quota-detail">${{ '%.4f'|format(ai_quota.spent_usd) }} / {% if ai_quota.unlimited %}no cap{% else %}${{ '%.2f'|format(ai_quota.limit_usd) }}{% endif %} &middot; {{ ai_quota.turns }} Devii turns</span>
</div> </div>
</div> </div>
{% if 'gateway_used_pct' in ai_quota %}
<div class="ai-quota" data-ai-quota-gateway>
<div class="ai-quota-bar">
<span class="ai-quota-fill {% if not ai_quota.gateway_unlimited and ai_quota.gateway_used_pct >= 90 %}ai-quota-danger{% endif %}" style="--quota-pct: {{ 0 if ai_quota.gateway_unlimited else ai_quota.gateway_used_pct }}%"></span>
</div>
<div class="ai-quota-meta">
<span class="ai-quota-pct">{% if ai_quota.gateway_unlimited %}No gateway daily cap{% else %}{{ ai_quota.gateway_used_pct }}% of gateway quota used{% endif %}</span>
<span class="ai-quota-detail">${{ '%.4f'|format(ai_quota.gateway_spent_usd) }} / {% if ai_quota.gateway_unlimited %}no cap{% else %}${{ '%.2f'|format(ai_quota.gateway_limit_usd) }}{% endif %} &middot; direct /openai/v1/* cap{% if ai_quota.gateway_pooled %}, shared pool{% endif %}</span>
</div>
</div>
{% endif %}
{% endif %} {% endif %}
<div class="ai-usage-body" data-ai-usage-body> <div class="ai-usage-body" data-ai-usage-body>
<p class="ai-usage-empty">Loading...</p> <p class="ai-usage-empty">Loading...</p>
@ -360,6 +371,16 @@
<span class="ai-quota-pct">{% if ai_quota.unlimited %}No daily AI spend cap{% else %}{{ ai_quota.used_pct }}% of your daily AI quota used{% endif %}</span> <span class="ai-quota-pct">{% if ai_quota.unlimited %}No daily AI spend cap{% else %}{{ ai_quota.used_pct }}% of your daily AI quota used{% endif %}</span>
</div> </div>
</div> </div>
{% if 'gateway_used_pct' in ai_quota %}
<div class="ai-quota" data-ai-quota-gateway>
<div class="ai-quota-bar">
<span class="ai-quota-fill {% if not ai_quota.gateway_unlimited and ai_quota.gateway_used_pct >= 90 %}ai-quota-danger{% endif %}" style="--quota-pct: {{ 0 if ai_quota.gateway_unlimited else ai_quota.gateway_used_pct }}%"></span>
</div>
<div class="ai-quota-meta">
<span class="ai-quota-pct">{% if ai_quota.gateway_unlimited %}No gateway daily cap{% else %}{{ ai_quota.gateway_used_pct }}% of your gateway quota used{% endif %}</span>
</div>
</div>
{% endif %}
{% else %} {% else %}
<p class="ai-usage-empty">Quota unavailable.</p> <p class="ai-usage-empty">Quota unavailable.</p>
{% endif %} {% endif %}

View File

@ -75,6 +75,10 @@ BADGE_CATALOG = {
"Good Neighbor": {"icon": "💧", "description": "Watered a neighbour's build", "group": "Code Farm"}, "Good Neighbor": {"icon": "💧", "description": "Watered a neighbour's build", "group": "Code Farm"},
"Cat Burglar": {"icon": "🥷", "description": "Stole a ready build from another farm", "group": "Code Farm"}, "Cat Burglar": {"icon": "🥷", "description": "Stole a ready build from another farm", "group": "Code Farm"},
"Robbed": {"icon": "🚨", "description": "Had a build stolen from your farm", "group": "Code Farm"}, "Robbed": {"icon": "🚨", "description": "Had a build stolen from your farm", "group": "Code Farm"},
"Enterprise Ready": {"icon": "📦", "description": "Bought your first Code Farm Infrastructure building", "group": "Code Farm"},
"Fort Knox": {"icon": "🏰", "description": "Upgraded your Code Farm Defense for the first time", "group": "Code Farm"},
"Style Points": {"icon": "💅", "description": "Bought your first Code Farm cosmetic", "group": "Code Farm"},
"David vs Goliath": {"icon": "🪃", "description": "Raided a farm with 10x your coins and earned an Underdog boost", "group": "Code Farm"},
} }
BADGE_GROUPS = [ BADGE_GROUPS = [

View File

@ -191,6 +191,10 @@ ACHIEVEMENTS = {
"water": [(1, "Good Neighbor")], "water": [(1, "Good Neighbor")],
"harvest_stolen": [(1, "Cat Burglar")], "harvest_stolen": [(1, "Cat Burglar")],
"got_stolen_from": [(1, "Robbed")], "got_stolen_from": [(1, "Robbed")],
"infra_bought": [(1, "Enterprise Ready")],
"defense_upgraded": [(1, "Fort Knox")],
"cosmetic_bought": [(1, "Style Points")],
"underdog_raid": [(1, "David vs Goliath")],
} }
UNIQUE_ACTIONS = {"docs.read"} UNIQUE_ACTIONS = {"docs.read"}

View File

@ -24,7 +24,7 @@ services:
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 10s start_period: 120s
nginx: nginx:
build: build:

589
fplan.md Normal file
View File

@ -0,0 +1,589 @@
# 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.

View File

@ -1,14 +1,12 @@
# CLAUDE.md # CLAUDE.md
This file documents detailed testing patterns, fixtures, and pitfalls for devplacepy/tests/ (Playwright + unit tests across api/e2e/unit tiers). Claude Code loads it automatically whenever a file under this directory is read or edited. Never run the test suite unless the user explicitly asks - validate with imports and manual checks instead. This file documents detailed testing patterns, fixtures, and pitfalls for devplacepy/tests/ (Playwright + unit tests across api/e2e/unit tiers). Claude Code loads it automatically whenever a file under this directory is read or edited. Every change ends with the full suite: `make test` runs all tests across all three tiers, and it must pass.
## Never run tests unless explicitly asked (critical guardrail) ## Always run the full suite (critical guardrail)
**NEVER run tests unless specifically asked by user.** Not the full suite, not a single file - do not run any tests unless the user explicitly requests it. Validate each touched language manually instead: Python (compile + import), JS (parse / bracket matching), CSS (brace matching), HTML (tag matching). Zero tolerance. A clean `python -c "from devplacepy.main import app"` import is the baseline check. **Every change is validated by running the full test suite - ALL tests, all three tiers, via `make test`.** No tier skipped, no subset substituted for the whole. Preliminary per-language checks (Python compile + import, JS parse, CSS brace matching, HTML tag matching) and a clean `python -c "from devplacepy.main import app"` import come first, but they never replace the suite. A failure is a real signal and blocks completion until fixed.
**Never run tests unless the user explicitly asks for it.** Not the full suite, not a single file. Validate code with a clean import (`python -c "from devplacepy.main import app"`) and per-language manual checks instead. During iteration a single test may be run for a fast feedback loop: `python -m pytest tests/e2e/feed.py::test_name -v --tb=line -x` - but the change is finished only when the complete suite is green.
If you do need to run a single test on explicit request: `python -m pytest tests/e2e/feed.py::test_name -v --tb=line -x`.
## Fixture stack ## Fixture stack

View File

@ -107,6 +107,7 @@ def test_restore_revives_post_and_records_audit(seeded_db):
def test_restore_revoked_award_recomputes_stats(seeded_db): def test_restore_revoked_award_recomputes_stats(seeded_db):
from datetime import datetime, timezone from datetime import datetime, timezone
from devplacepy.database import get_table
from devplacepy.database.awards import revoke_award from devplacepy.database.awards import revoke_award
from devplacepy.utils import generate_uid, make_combined_slug from devplacepy.utils import generate_uid, make_combined_slug

View File

@ -115,7 +115,6 @@ def _seed_news_audit_log():
) )
refresh_snapshot() refresh_snapshot()
return uid return uid
from devplacepy.database import get_table
_counter_polls = [0] _counter_polls = [0]
AJAX_polls = {"X-Requested-With": "fetch"} AJAX_polls = {"X-Requested-With": "fetch"}
def _session_polls(): def _session_polls():
@ -363,7 +362,18 @@ def test_feed_avatar_has_presence_dot(app_server):
def test_feed_shows_online_now_section(app_server): def test_feed_shows_online_now_section(app_server):
s, name = _member() name = _unique("0onl")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
html = s.get(f"{BASE_URL}/feed").text html = s.get(f"{BASE_URL}/feed").text
assert "online-users" in html assert "online-users" in html
assert "data-online-users-list" in html assert "data-online-users-list" in html

View File

@ -36,6 +36,7 @@ def _reset_farm(username, coins=100000):
get_table("game_plots").delete(farm_uid=farm["uid"]) get_table("game_plots").delete(farm_uid=farm["uid"])
get_table("game_quests").delete(farm_uid=farm["uid"]) get_table("game_quests").delete(farm_uid=farm["uid"])
get_table("game_farms").delete(uid=farm["uid"]) get_table("game_farms").delete(uid=farm["uid"])
get_table("game_market_ticks").delete()
farm = store.ensure_farm(user["uid"]) farm = store.ensure_farm(user["uid"])
get_table("game_farms").update({"uid": farm["uid"], "coins": coins}, ["uid"]) get_table("game_farms").update({"uid": farm["uid"], "coins": coins}, ["uid"])
return user return user
@ -86,15 +87,23 @@ def test_leaderboard_entries_carry_score_and_prestige(app_server, seeded_db):
session, name = _signup() session, name = _signup()
_reset_farm(name) _reset_farm(name)
_set_farm(name, prestige=50, total_harvests=20, ci_tier=3) _set_farm(name, prestige=50, total_harvests=20, ci_tier=3)
deadline = time.time() + 20
while True:
response = requests.get(f"{BASE_URL}/game/leaderboard", headers=JSON) response = requests.get(f"{BASE_URL}/game/leaderboard", headers=JSON)
assert response.status_code == 200 assert response.status_code == 200
entries = response.json()["entries"] entries = response.json()["entries"]
mine = next(
(entry for entry in entries if entry["username"] == name), None
)
if mine is not None or time.time() >= deadline:
break
time.sleep(0.5)
assert entries assert entries
scores = [entry["score"] for entry in entries] scores = [entry["score"] for entry in entries]
assert scores == sorted(scores, reverse=True) assert scores == sorted(scores, reverse=True)
for entry in entries: for entry in entries:
assert "score" in entry and "prestige" in entry assert "score" in entry and "prestige" in entry
mine = next(entry for entry in entries if entry["username"] == name) assert mine is not None
assert mine["prestige"] == 50 assert mine["prestige"] == 50
assert mine["score"] == economy.farm_score(store.get_farm( assert mine["score"] == economy.farm_score(store.get_farm(
get_table("users").find_one(username=name)["uid"] get_table("users").find_one(username=name)["uid"]

View File

@ -37,6 +37,7 @@ def _reset_farm(username, coins=100000):
if farm: if farm:
get_table("game_plots").delete(farm_uid=farm["uid"]) get_table("game_plots").delete(farm_uid=farm["uid"])
get_table("game_farms").delete(uid=farm["uid"]) get_table("game_farms").delete(uid=farm["uid"])
get_table("game_market_ticks").delete()
farm = store.ensure_farm(user["uid"]) farm = store.ensure_farm(user["uid"])
get_table("game_farms").update({"uid": farm["uid"], "coins": coins}, ["uid"]) get_table("game_farms").update({"uid": farm["uid"], "coins": coins}, ["uid"])
refresh_snapshot() refresh_snapshot()
@ -189,6 +190,130 @@ def _ripen_owner_plot(owner, slot=0):
return plot return plot
def _set_farm(username, **fields):
refresh_snapshot()
user = get_table("users").find_one(username=username)
farm = store.get_farm(user["uid"])
get_table("game_farms").update({"uid": farm["uid"], **fields}, ["uid"])
refresh_snapshot()
def test_defense_upgrade_requires_auth(app_server, seeded_db):
r = requests.post(f"{BASE_URL}/game/defense/upgrade", headers=JSON)
assert r.status_code in (401, 303)
def test_defense_upgrade_success(app_server, seeded_db):
session, name = _signup()
_reset_farm(name)
r = session.post(f"{BASE_URL}/game/defense/upgrade", headers=JSON)
assert r.status_code == 200, r.text[:200]
assert r.json()["farm"]["defense_level"] == 1
def test_defense_upgrade_insufficient_coins_returns_400(app_server, seeded_db):
session, name = _signup()
_reset_farm(name, coins=0)
r = session.post(f"{BASE_URL}/game/defense/upgrade", headers=JSON)
assert r.status_code == 400
def test_infrastructure_buy_requires_prestige(app_server, seeded_db):
session, name = _signup()
_reset_farm(name, coins=100_000_000)
r = session.post(
f"{BASE_URL}/game/infrastructure/buy", data={"key": "registry"}, headers=JSON
)
assert r.status_code == 400
def test_infrastructure_buy_success(app_server, seeded_db):
session, name = _signup()
_reset_farm(name, coins=100_000_000)
_set_farm(name, prestige=5)
r = session.post(
f"{BASE_URL}/game/infrastructure/buy", data={"key": "registry"}, headers=JSON
)
assert r.status_code == 200, r.text[:200]
farm = r.json()["farm"]
owned = next(i for i in farm["infrastructure"] if i["key"] == "registry")
assert owned["owned"] is True
def test_infrastructure_buy_unknown_key_returns_400(app_server, seeded_db):
session, name = _signup()
_reset_farm(name)
r = session.post(
f"{BASE_URL}/game/infrastructure/buy", data={"key": "does_not_exist"}, headers=JSON
)
assert r.status_code == 400
def test_cosmetics_buy_requires_auth(app_server, seeded_db):
r = requests.post(
f"{BASE_URL}/game/cosmetics/buy", data={"key": "title_architect"}, headers=JSON
)
assert r.status_code in (401, 303)
def test_cosmetics_buy_and_equip(app_server, seeded_db):
session, name = _signup()
_reset_farm(name, coins=1_000_000)
r = session.post(
f"{BASE_URL}/game/cosmetics/buy", data={"key": "title_architect"}, headers=JSON
)
assert r.status_code == 200, r.text[:200]
r = session.post(
f"{BASE_URL}/game/cosmetics/equip", data={"key": "title_architect"}, headers=JSON
)
assert r.status_code == 200, r.text[:200]
assert r.json()["farm"]["active_title"] == "title_architect"
def test_cosmetics_buy_insufficient_coins_returns_400(app_server, seeded_db):
session, name = _signup()
_reset_farm(name, coins=0)
r = session.post(
f"{BASE_URL}/game/cosmetics/buy", data={"key": "title_architect"}, headers=JSON
)
assert r.status_code == 400
def test_mastery_upgrade_requires_auth(app_server, seeded_db):
r = requests.post(
f"{BASE_URL}/game/mastery", data={"key": "autoreplant"}, headers=JSON
)
assert r.status_code in (401, 303)
def test_mastery_upgrade_insufficient_points_returns_400(app_server, seeded_db):
session, name = _signup()
_reset_farm(name)
r = session.post(
f"{BASE_URL}/game/mastery", data={"key": "autoreplant"}, headers=JSON
)
assert r.status_code == 400
def test_mastery_upgrade_success(app_server, seeded_db):
session, name = _signup()
_reset_farm(name)
_set_farm(name, mastery_points=1000)
r = session.post(
f"{BASE_URL}/game/mastery", data={"key": "autoreplant"}, headers=JSON
)
assert r.status_code == 200, r.text[:200]
upgrade = next(m for m in r.json()["farm"]["mastery"] if m["key"] == "autoreplant")
assert upgrade["level"] == 1
def test_leaderboard_boards_all_return_200(app_server, seeded_db):
for board in ("score", "prestige", "harvests", "raids", "time_to_kernel", "fair_play", "era"):
r = requests.get(f"{BASE_URL}/game/leaderboard", params={"board": board}, headers=JSON)
assert r.status_code == 200, (board, r.text[:200])
assert "entries" in r.json()
def test_steal_cooldown_blocks_second_raid(app_server, seeded_db): def test_steal_cooldown_blocks_second_raid(app_server, seeded_db):
owner_session, owner = _signup() owner_session, owner = _signup()
_reset_farm(owner, coins=100000) _reset_farm(owner, coins=100000)

View File

@ -2,10 +2,8 @@
import time import time
import requests import requests
from datetime import datetime, timezone
from tests.conftest import BASE_URL from tests.conftest import BASE_URL
from devplacepy.db_client import get_table from devplacepy.db_client import get_table
from devplacepy.utils import generate_uid, make_combined_slug
JSON = {"Accept": "application/json"} JSON = {"Accept": "application/json"}
_counter = [0] _counter = [0]
@ -177,7 +175,7 @@ def test_valid_post_creates_pending_row_and_job(app_server):
assert job and job.get("kind") == "award" assert job and job.get("kind") == "award"
def test_award_give_audit_recorded(app_server): def test_award_give_audit_recorded(app_server, seeded_db):
giver, _ = _signup() giver, _ = _signup()
_, receiver_name = _signup() _, receiver_name = _signup()
r = giver.post( r = giver.post(
@ -186,5 +184,6 @@ def test_award_give_audit_recorded(app_server):
json={"description": "Audit me"}, json={"description": "Audit me"},
) )
assert r.status_code == 200 assert r.status_code == 200
entry = _audit_find(giver, "award.give", _uid(receiver_name)) admin = _login(seeded_db, "alice")
entry = _audit_find(admin, "award.give", _uid(receiver_name))
assert entry is not None assert entry is not None

View File

@ -3,7 +3,7 @@
import time import time
import requests import requests
from tests.conftest import BASE_URL from tests.conftest import BASE_URL
from devplacepy.database import get_table from devplacepy.database import get_primary_admin_uid, get_table, invalidate_admins_cache
from devplacepy.utils import clear_user_cache from devplacepy.utils import clear_user_cache
_counter_cv = [0] _counter_cv = [0]
@ -70,11 +70,14 @@ def test_admin_private_project_containers_hidden_from_other_admin(app_server):
assert _containers_data_status(owner_key, slug) == 200 assert _containers_data_status(owner_key, slug) == 200
def test_member_private_project_containers_visible_to_admin(app_server): def test_member_private_project_containers_primary_admin_only(app_server):
_, _, owner_key = _signup() _, _, owner_key = _signup()
_, _, admin_key = _make_admin() _, _, admin_key = _make_admin()
slug = _create_project(owner_key, "Member Container Hidden", is_private=True) slug = _create_project(owner_key, "Member Container Hidden", is_private=True)
assert _containers_data_status(admin_key, slug) == 200 assert _containers_data_status(admin_key, slug) == 404
invalidate_admins_cache()
primary_key = get_table("users").find_one(uid=get_primary_admin_uid())["api_key"]
assert _containers_data_status(primary_key, slug) == 200
def test_public_project_containers_visible_to_any_admin(app_server): def test_public_project_containers_visible_to_any_admin(app_server):

View File

@ -68,4 +68,4 @@ def _seed_target_user():
def test_admin_ai_usage_page_loads(alice): def test_admin_ai_usage_page_loads(alice):
page, _ = alice page, _ = alice
page.goto(f"{BASE_URL}/admin/ai-usage", wait_until="domcontentloaded") page.goto(f"{BASE_URL}/admin/ai-usage", wait_until="domcontentloaded")
assert page.is_visible("text=AI usage") or page.is_visible("text=AI Usage") page.locator("h2:has-text('AI usage')").wait_for(state="visible")

64
tests/e2e/admin/game.py Normal file
View File

@ -0,0 +1,64 @@
# retoor <retoor@molodetz.nl>
from playwright.sync_api import expect
from tests.conftest import BASE_URL
def end_any_active_era():
from devplacepy.services.game import store
if store.active_era():
store.end_era()
def test_admin_game_redirect_unauth(page, app_server):
page.goto(f"{BASE_URL}/admin/game", wait_until="domcontentloaded")
assert page.url.startswith(f"{BASE_URL}/auth/login")
def test_admin_game_page_loads_dormant(alice):
page, _ = alice
end_any_active_era()
page.goto(f"{BASE_URL}/admin/game", wait_until="domcontentloaded")
assert page.is_visible("text=Code Farm - Eras")
assert page.is_visible("text=No Era is currently running")
assert page.locator("#era-name").is_visible()
def test_admin_game_start_and_end_era(alice):
page, _ = alice
end_any_active_era()
page.goto(f"{BASE_URL}/admin/game", wait_until="domcontentloaded")
page.fill("#era-name", "PlaywrightEra")
page.fill("#era-duration", "14")
page.click("button:has-text('Start Era')")
confirm_btn = page.locator(".dialog-confirm")
confirm_btn.wait_for(state="visible")
confirm_btn.click()
page.wait_for_url("**/admin/game", wait_until="domcontentloaded")
expect(page.locator("text=PlaywrightEra")).to_be_visible()
assert page.locator("button:has-text('End Era')").is_visible()
page.click("button:has-text('End Era')")
confirm_btn = page.locator(".dialog-confirm")
confirm_btn.wait_for(state="visible")
confirm_btn.click()
page.wait_for_url("**/admin/game", wait_until="domcontentloaded")
assert page.is_visible("text=No Era is currently running")
def test_admin_game_start_form_hidden_while_era_active(alice):
page, _ = alice
end_any_active_era()
page.goto(f"{BASE_URL}/admin/game", wait_until="domcontentloaded")
page.fill("#era-name", "FirstEra")
page.click("button:has-text('Start Era')")
confirm_btn = page.locator(".dialog-confirm")
confirm_btn.wait_for(state="visible")
confirm_btn.click()
page.wait_for_url("**/admin/game", wait_until="domcontentloaded")
page.goto(f"{BASE_URL}/admin/game", wait_until="domcontentloaded")
assert page.locator("#era-name").count() == 0
end_any_active_era()

View File

@ -18,6 +18,9 @@ def reset_farm(username, coins=100000):
get_table("game_plots").delete(farm_uid=farm["uid"]) get_table("game_plots").delete(farm_uid=farm["uid"])
get_table("game_quests").delete(farm_uid=farm["uid"]) get_table("game_quests").delete(farm_uid=farm["uid"])
get_table("game_farms").delete(uid=farm["uid"]) get_table("game_farms").delete(uid=farm["uid"])
get_table("game_cosmetics").delete(user_uid=uid)
get_table("game_market_ticks").delete()
clear_game_caches()
farm = store.ensure_farm(uid) farm = store.ensure_farm(uid)
get_table("game_farms").update({"uid": farm["uid"], "coins": coins}, ["uid"]) get_table("game_farms").update({"uid": farm["uid"], "coins": coins}, ["uid"])
return {"uid": uid, "username": username} return {"uid": uid, "username": username}
@ -46,6 +49,24 @@ def set_farm_xp(username, xp):
get_table("game_farms").update({"uid": farm["uid"], "xp": xp}, ["uid"]) get_table("game_farms").update({"uid": farm["uid"], "xp": xp}, ["uid"])
def set_farm(username, **fields):
from devplacepy.database import get_table
from devplacepy.services.game import store
user = get_table("users").find_one(username=username)
farm = store.get_farm(user["uid"])
get_table("game_farms").update({"uid": farm["uid"], **fields}, ["uid"])
def clear_game_caches():
from devplacepy.services.game.store import farm as farm_store
from devplacepy.services.game.store import market as market_store
farm_store._leaderboard_cache.clear()
farm_store._board_cache.clear()
market_store._saturation_cache.clear()
def open_game(page): def open_game(page):
page.goto(f"{BASE_URL}/game", wait_until="domcontentloaded") page.goto(f"{BASE_URL}/game", wait_until="domcontentloaded")
page.locator("[data-game-grid]").first.wait_for(state="visible") page.locator("[data-game-grid]").first.wait_for(state="visible")
@ -231,6 +252,164 @@ def test_leaderboard_panel_populates(alice):
assert page.is_visible("a.game-lb-name:has-text('alice_test')") assert page.is_visible("a.game-lb-name:has-text('alice_test')")
def test_infrastructure_hidden_below_prestige_requirement(alice):
page, _ = alice
reset_farm("alice_test", coins=100_000_000)
open_game(page)
assert page.is_visible("text=Requires prestige 3")
assert page.locator("form[data-game-action='infrastructure']").count() == 0
def test_infrastructure_buy_succeeds_when_eligible(alice):
page, _ = alice
reset_farm("alice_test", coins=100_000_000)
set_farm("alice_test", prestige=5)
open_game(page)
buy = page.locator("form[data-game-action='infrastructure'] button").first
buy.wait_for(state="visible")
buy.click()
confirm_btn = page.locator(".dialog-confirm")
confirm_btn.wait_for(state="visible")
confirm_btn.click()
expect(page.locator("form[data-game-action='infrastructure']")).to_have_count(0)
assert coins(page) == 100_000_000 - 25_000_000
def test_defense_upgrade_spends_coins(alice):
page, _ = alice
reset_farm("alice_test")
open_game(page)
upgrade = page.locator("form[data-game-action='defense'] button").first
upgrade.wait_for(state="visible")
upgrade.click()
expect(page.locator("[data-hud-coins]")).to_have_text("95000")
def test_cosmetics_buy_and_equip_flow(alice):
page, _ = alice
reset_farm("alice_test", coins=1_000_000)
open_game(page)
buy = page.locator("form[data-game-action='cosmetic-buy']").first
buy.locator("button").wait_for(state="visible")
buy.locator("button").click()
confirm_btn = page.locator(".dialog-confirm")
confirm_btn.wait_for(state="visible")
confirm_btn.click()
equip = page.locator("form[data-game-action='cosmetic-equip'] button").first
equip.wait_for(state="visible")
equip.click()
expect(page.locator(".game-cosmetics")).to_contain_text("Equipped")
def test_mastery_panel_hidden_without_mastery_points(alice):
page, _ = alice
reset_farm("alice_test")
open_game(page)
assert page.locator(".game-mastery").count() == 0
def test_mastery_panel_visible_and_functional_once_unlocked(alice):
page, _ = alice
reset_farm("alice_test")
set_farm("alice_test", mastery_points=1000, mastery_points_earned_total=1)
open_game(page)
upgrade = page.locator(
"form[data-game-action='mastery']:has(input[value='autoreplant']) button"
)
upgrade.wait_for(state="visible")
upgrade.click()
expect(
page.locator(".perk-card:has-text('Continuous Delivery') .perk-level")
).to_have_text("Lv 1/1")
def test_leaderboard_board_selector_switches_boards(alice):
page, _ = alice
reset_farm("alice_test")
set_farm("alice_test", prestige=7)
open_game(page)
page.locator(".game-lb-row").first.wait_for(state="visible")
page.select_option("[data-lb-board]", "prestige")
page.wait_for_timeout(400)
page.locator(".game-lb-row").first.wait_for(state="visible")
assert page.is_visible("a.game-lb-name:has-text('alice_test')")
def test_underdog_banner_shows_after_raiding_a_much_richer_farm(bob):
page, _ = bob
from devplacepy.database import get_table
from devplacepy.services.game import store
reset_farm("alice_test", coins=1_000_000)
reset_farm("bob_test", coins=0)
alice_user = get_table("users").find_one(username="alice_test")
bob_user = get_table("users").find_one(username="bob_test")
store.plant({"uid": alice_user["uid"], "username": "alice_test"}, 0, "shell")
past = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
farm = store.get_farm(alice_user["uid"])
plot = store._plot_at(farm["uid"], 0)
get_table("game_plots").update(
{"uid": plot["uid"], "planted_at": past, "ready_at": past}, ["uid"]
)
store.steal({"uid": bob_user["uid"], "username": "bob_test"}, alice_user, 0)
page.goto(f"{BASE_URL}/game", wait_until="domcontentloaded")
page.locator("[data-underdog-banner]").wait_for(state="visible")
def test_weekly_contract_appears_and_can_be_claimed(alice):
page, _ = alice
from devplacepy.database import get_table
from devplacepy.services.game import store
from devplacepy.services.game.store.common import _iso_week
from devplacepy.services.game.store.quests import ensure_weekly_contract
reset_farm("alice_test")
set_farm("alice_test", mastery_points=1000)
user = get_table("users").find_one(username="alice_test")
store.upgrade_mastery({"uid": user["uid"], "username": "alice_test"}, "contracts")
farm = store.get_farm(user["uid"])
iso_week = _iso_week(datetime.now(timezone.utc))
ensure_weekly_contract(farm, iso_week)
row = get_table("game_quests").find_one(
farm_uid=farm["uid"], day=iso_week, scope="weekly"
)
get_table("game_quests").update(
{"uid": row["uid"], "progress": row["goal"]}, ["uid"]
)
open_game(page)
claim = page.locator(
"form[data-game-action='claim-quest']:has(input[value='weekly']) button"
)
claim.wait_for(state="visible")
claim.click()
page.wait_for_timeout(400)
farm = store.get_farm(user["uid"])
assert int(farm["stars"]) > 0
def test_crop_shows_saturated_label_when_overfarmed(alice):
page, _ = alice
from devplacepy.database import get_table
from devplacepy.services.game.store import market as market_store
from devplacepy.utils import generate_uid
reset_farm("alice_test")
now = datetime.now(timezone.utc)
get_table("game_market_ticks").insert(
{
"uid": generate_uid(),
"crop_key": "shell",
"hour_bucket": market_store._hour_bucket(now),
"harvests": 600,
"updated_at": now.isoformat(),
}
)
open_game(page)
option = page.locator("form[data-game-action='plant'] select option[value='shell']").first
option.wait_for(state="attached")
assert "saturated" in option.inner_text()
def test_mobile_no_horizontal_overflow(mobile_page): def test_mobile_no_horizontal_overflow(mobile_page):
page, _ = mobile_page page, _ = mobile_page
reset_farm("alice_test") reset_farm("alice_test")

View File

@ -225,3 +225,177 @@ def test_is_golden_deterministic_and_bounded():
def test_is_golden_requires_both_args(): def test_is_golden_requires_both_args():
assert economy.is_golden("", "2026-06-20T00:00:00+00:00") is False assert economy.is_golden("", "2026-06-20T00:00:00+00:00") is False
assert economy.is_golden("plot-7", "") is False assert economy.is_golden("plot-7", "") is False
# --- market saturation --------------------------------------------------------
def test_market_saturation_factor_is_full_when_fresh():
assert economy.market_saturation_factor(0) == 1.0
def test_market_saturation_factor_steps_down():
assert economy.market_saturation_factor(40) < 1.0
assert economy.market_saturation_factor(600) == 0.40
assert economy.market_saturation_factor(40) > economy.market_saturation_factor(600)
def test_market_buff_factor_only_applies_to_starter_crops():
assert economy.market_buff_factor("kernel", 0.40) == 1.0
assert economy.market_buff_factor("shell", 1.0) == 1.0
assert economy.market_buff_factor("shell", 0.40) > 1.0
assert economy.market_buff_factor("shell", 0.0) <= economy.MARKET_BUFF_CAP
def test_effective_reward_coins_applies_market_factor():
crop = economy.crop_for("shell")
full = economy.effective_reward_coins(crop, 0, 0, 0, 1.0)
saturated = economy.effective_reward_coins(crop, 0, 0, 0, 0.5)
assert saturated < full
assert saturated == round(crop.reward_coins * 0.5)
def test_effective_reward_coins_underdog_multiplier():
crop = economy.crop_for("shell")
plain = economy.effective_reward_coins(crop, 0, 0, 0, 1.0, False)
boosted = economy.effective_reward_coins(crop, 0, 0, 0, 1.0, True)
assert boosted == round(crop.reward_coins * economy.UNDERDOG_MULTIPLIER)
assert boosted > plain
# --- infrastructure and defense ------------------------------------------------
def test_infra_for_known_and_unknown():
assert economy.infra_for("registry") is not None
assert economy.infra_for("does_not_exist") is None
def test_registry_boost_speeds_only_named_crops():
kernel = economy.crop_for("kernel")
shell = economy.crop_for("shell")
assert economy.grow_seconds_for(kernel, 1, 0, 0, True) < economy.grow_seconds_for(
kernel, 1, 0, 0, False
)
assert economy.grow_seconds_for(shell, 1, 0, 0, True) == economy.grow_seconds_for(
shell, 1, 0, 0, False
)
def test_defense_tier_lookup_and_progression():
assert economy.defense_tier(0).name == "Undefended"
assert economy.defense_tier(0).upkeep_daily == 0
top = economy.defense_tier(economy.MAX_DEFENSE_LEVEL)
assert economy.next_defense_tier(economy.MAX_DEFENSE_LEVEL) is None
assert economy.next_defense_tier(0) is not None
assert top.steal_fraction_floor < economy.defense_tier(0).steal_fraction_floor
def test_daily_upkeep_scales_with_wealth():
tier = economy.defense_tier(1)
assert economy.daily_upkeep(tier, 0) == tier.upkeep_daily
rich = economy.daily_upkeep(tier, 10_000_000)
assert rich > tier.upkeep_daily
assert rich == round(10_000_000 * economy.UPKEEP_WEALTH_PCT)
def test_effective_steal_fraction_floor_overridden_by_building():
assert economy.effective_steal_fraction(99, floor=0.30) == 0.30
assert economy.effective_steal_fraction(0, floor=0.30) == economy.STEAL_FRACTION
def test_effective_steal_grace_extra_seconds_from_building():
base = economy.effective_steal_grace(0, 0)
with_building = economy.effective_steal_grace(0, 30)
assert with_building == base + 30
# --- cosmetics ------------------------------------------------------------------
def test_cosmetic_for_known_and_unknown():
assert economy.cosmetic_for("title_architect") is not None
assert economy.cosmetic_for("does_not_exist") is None
def test_cosmetic_title_name_only_resolves_titles():
assert economy.cosmetic_title_name("title_architect") == "The Architect"
assert economy.cosmetic_title_name("skin_neon") == ""
assert economy.cosmetic_title_name("") == ""
assert economy.cosmetic_title_name("does_not_exist") == ""
# --- mastery --------------------------------------------------------------------
def test_mastery_points_awarded_first_point_at_unlock():
assert economy.mastery_points_awarded(49, 50) == 1
assert economy.mastery_points_awarded(0, 49) == 0
assert economy.mastery_points_awarded(55, 56) == 0
assert economy.mastery_points_awarded(59, 60) == 1
assert economy.mastery_points_awarded(50, 70) == 2
def test_mastery_for_known_and_unknown():
assert economy.mastery_for("autoreplant") is not None
assert economy.mastery_for("does_not_exist") is None
def test_mastery_cost_grows_with_level():
m = economy.mastery_for("contracts")
assert economy.mastery_cost(m, 0) == m.base_cost
def test_unlocked_crops_gates_on_mastery_and_level():
base = economy.unlocked_crops(economy.MAX_LEVEL, mastery_earned=0)
with_mastery = economy.unlocked_crops(economy.MAX_LEVEL, mastery_earned=1)
assert "distsys" not in {c.key for c in base}
assert "distsys" in {c.key for c in with_mastery}
def test_crop_payload_locked_by_mastery():
distsys = economy.crop_for("distsys")
assert economy.crop_payload(distsys, 1, economy.MAX_LEVEL, mastery_earned=0)["locked"] is True
assert economy.crop_payload(distsys, 1, economy.MAX_LEVEL, mastery_earned=1)["locked"] is False
def test_secfort_crop_is_steal_immune():
secfort = economy.crop_for("secfort")
assert secfort.steal_immune is True
assert economy.crop_for("shell").steal_immune is False
# --- secondary leaderboard scoring -----------------------------------------------
def test_fair_play_score_rewards_activity_and_penalizes_hoarding():
active = economy.fair_play_score(harvests_week=20, coins=0)
hoarder = economy.fair_play_score(harvests_week=0, coins=10_000_000)
assert active > hoarder
# --- era --------------------------------------------------------------------------
def test_era_score_gives_prestige_partial_weight():
veteran = economy.era_score(era_coins=0, era_harvests=0, prestige=10)
rookie = economy.era_score(era_coins=0, era_harvests=0, prestige=0)
assert veteran > rookie
assert veteran == round(10 * economy.SCORE_PRESTIGE * economy.ERA_PRESTIGE_CARRYOVER_PCT)
def test_era_reward_stars_by_rank():
assert economy.era_reward_stars(1) == economy.ERA_REWARD_STARS_BY_RANK[0]
assert economy.era_reward_stars(len(economy.ERA_REWARD_STARS_BY_RANK) + 1) == 0
assert economy.era_reward_stars(0) == 0
# --- weekly contracts ---------------------------------------------------------------
def test_weekly_contract_deterministic():
first = economy.weekly_contract("user-xyz", "2026-W10")
second = economy.weekly_contract("user-xyz", "2026-W10")
assert first == second
assert first["kind"] in economy.QUEST_DEFS
assert first["reward_stars"] > 0

View File

@ -25,6 +25,15 @@ def _user(username):
return row return row
def _clear_game_caches():
from devplacepy.services.game.store import farm as farm_store
from devplacepy.services.game.store import market as market_store
farm_store._leaderboard_cache.clear()
farm_store._board_cache.clear()
market_store._saturation_cache.clear()
def _reset(username, coins=100000): def _reset(username, coins=100000):
user = _user(username) user = _user(username)
farm = store.get_farm(user["uid"]) farm = store.get_farm(user["uid"])
@ -34,6 +43,9 @@ def _reset(username, coins=100000):
get_table("game_farms").delete(uid=farm["uid"]) get_table("game_farms").delete(uid=farm["uid"])
get_table("game_steals").delete(thief_uid=user["uid"]) get_table("game_steals").delete(thief_uid=user["uid"])
get_table("game_steals").delete(owner_uid=user["uid"]) get_table("game_steals").delete(owner_uid=user["uid"])
get_table("game_cosmetics").delete(user_uid=user["uid"])
get_table("game_market_ticks").delete()
_clear_game_caches()
farm = store.ensure_farm(user["uid"]) farm = store.ensure_farm(user["uid"])
get_table("game_farms").update({"uid": farm["uid"], "coins": coins}, ["uid"]) get_table("game_farms").update({"uid": farm["uid"], "coins": coins}, ["uid"])
return user return user
@ -644,3 +656,293 @@ def test_leaderboard_entry_exposes_score_and_prestige(local_db):
assert entry["prestige"] == 3 assert entry["prestige"] == 3
assert entry["score"] == economy.farm_score(farm) assert entry["score"] == economy.farm_score(farm)
assert {"rank", "username", "level", "xp", "coins", "total_harvests", "prestige", "score"} <= set(entry) assert {"rank", "username", "level", "xp", "coins", "total_harvests", "prestige", "score"} <= set(entry)
# --- market saturation ---------------------------------------------------------
def test_record_and_recent_harvests_roundtrip(local_db):
_reset("unit_a")
from devplacepy.services.game.store import market as market_store
now = datetime.now(timezone.utc)
market_store.record_harvest_tick("shell", now)
market_store.record_harvest_tick("shell", now)
assert store.recent_harvests("shell", economy.MARKET_WINDOW_HOURS) == 2
assert store.recent_harvests("python", economy.MARKET_WINDOW_HOURS) == 0
def test_harvest_records_market_tick_and_saturates_reward(local_db):
user = _reset("unit_a")
from devplacepy.services.game.store import market as market_store
from devplacepy.utils import generate_uid
now = datetime.now(timezone.utc)
worst_tier_count = economy.MARKET_SATURATION_TIERS[-1][0]
get_table("game_market_ticks").insert(
{
"uid": generate_uid(),
"crop_key": "shell",
"hour_bucket": market_store._hour_bucket(now),
"harvests": worst_tier_count,
"updated_at": now.isoformat(),
}
)
store.plant(user, 0, "shell")
_warp(user)
crop = economy.crop_for("shell")
result = store.harvest(user, 0)
assert result["coins"] < crop.reward_coins
def test_harvest_records_a_market_tick_for_its_own_crop(local_db):
user = _reset("unit_a")
store.plant(user, 0, "shell")
_warp(user)
store.harvest(user, 0)
assert store.recent_harvests("shell", economy.MARKET_WINDOW_HOURS) == 1
# --- infrastructure --------------------------------------------------------------
def test_buy_infrastructure_success(local_db):
cost = economy.infra_for("registry").cost
user = _reset("unit_a", coins=cost + 100000)
_set(user, prestige=5)
result = store.buy_infrastructure(user, "registry")
assert result["key"] == "registry"
farm = store.get_farm(user["uid"])
assert int(farm["infra_registry"]) == 1
assert _coins(user) == 100000
def test_buy_infrastructure_requires_prestige(local_db):
user = _reset("unit_a")
with pytest.raises(GameError):
store.buy_infrastructure(user, "registry")
def test_buy_infrastructure_already_owned_blocked(local_db):
cost = economy.infra_for("registry").cost
user = _reset("unit_a", coins=cost * 2)
_set(user, prestige=5)
store.buy_infrastructure(user, "registry")
with pytest.raises(GameError):
store.buy_infrastructure(user, "registry")
def test_buy_infrastructure_unknown_key(local_db):
user = _reset("unit_a")
with pytest.raises(GameError):
store.buy_infrastructure(user, "does_not_exist")
# --- defense -----------------------------------------------------------------------
def test_upgrade_defense_success(local_db):
user = _reset("unit_a")
result = store.upgrade_defense(user)
assert result["defense_level"] == 1
assert int(store.get_farm(user["uid"])["defense_level"]) == 1
def test_upgrade_defense_insufficient_coins(local_db):
user = _reset("unit_a", coins=0)
with pytest.raises(GameError):
store.upgrade_defense(user)
def test_charge_upkeep_decays_when_unaffordable(local_db):
user = _reset("unit_a", coins=0)
_set(user, defense_level=1, defense_last_upkeep_at=(datetime.now(timezone.utc) - timedelta(days=3)).isoformat())
farm = store.get_farm(user["uid"])
updated = store.charge_upkeep(farm, datetime.now(timezone.utc))
assert int(updated["defense_level"]) == 0
assert int(updated["coins"]) == 0
def test_charge_upkeep_deducts_coins_when_affordable(local_db):
user = _reset("unit_a", coins=100000)
_set(user, defense_level=1, defense_last_upkeep_at=(datetime.now(timezone.utc) - timedelta(days=1)).isoformat())
farm = store.get_farm(user["uid"])
updated = store.charge_upkeep(farm, datetime.now(timezone.utc))
assert int(updated["defense_level"]) == 1
assert int(updated["coins"]) < 100000
# --- cosmetics ---------------------------------------------------------------------
def test_buy_and_equip_cosmetic(local_db):
user = _reset("unit_a", coins=economy.cosmetic_for("title_architect").cost_coins * 2)
store.buy_cosmetic(user, "title_architect")
assert "title_architect" in store.owned_cosmetic_keys(user["uid"])
result = store.equip_title(user, "title_architect")
assert result["active_title"] == "title_architect"
assert store.get_farm(user["uid"])["active_title"] == "title_architect"
def test_buy_cosmetic_twice_blocked(local_db):
user = _reset("unit_a", coins=economy.cosmetic_for("title_architect").cost_coins * 2)
store.buy_cosmetic(user, "title_architect")
with pytest.raises(GameError):
store.buy_cosmetic(user, "title_architect")
def test_equip_unowned_cosmetic_blocked(local_db):
user = _reset("unit_a")
with pytest.raises(GameError):
store.equip_title(user, "title_architect")
def test_equip_non_title_cosmetic_blocked(local_db):
user = _reset("unit_a", coins=economy.cosmetic_for("skin_neon").cost_coins * 2)
store.buy_cosmetic(user, "skin_neon")
with pytest.raises(GameError):
store.equip_title(user, "skin_neon")
# --- mastery -------------------------------------------------------------------------
def test_upgrade_mastery_success(local_db):
user = _reset("unit_a")
_set(user, mastery_points=10)
result = store.upgrade_mastery(user, "autoreplant")
assert result["level"] == 1
farm = store.get_farm(user["uid"])
assert int(farm["mastery_autoreplant"]) == 1
assert int(farm["mastery_points"]) == 10 - economy.mastery_cost(economy.mastery_for("autoreplant"), 0)
def test_upgrade_mastery_insufficient_points(local_db):
user = _reset("unit_a")
with pytest.raises(GameError):
store.upgrade_mastery(user, "autoreplant")
def test_upgrade_mastery_unknown_key(local_db):
user = _reset("unit_a")
_set(user, mastery_points=1000)
with pytest.raises(GameError):
store.upgrade_mastery(user, "does_not_exist")
def test_farm_analytics_hidden_until_unlocked(local_db):
user = _reset("unit_a")
state = store.serialize_farm(store.get_farm(user["uid"]), viewer=user, owner=user)
assert state["mastery_analytics_unlocked"] is False
def test_farm_analytics_tracks_lifetime_totals_once_unlocked(local_db):
user = _reset("unit_a")
_set(user, mastery_points=1000)
store.upgrade_mastery(user, "analytics")
store.plant(user, 0, "shell")
_warp(user)
result = store.harvest(user, 0)
state = store.serialize_farm(store.get_farm(user["uid"]), viewer=user, owner=user)
assert state["mastery_analytics_unlocked"] is True
assert state["lifetime_coins_earned"] == result["coins"]
assert state["lifetime_harvests"] == 1
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)
result = store.prestige(user)
assert result["mastery_awarded"] == 1
farm = store.get_farm(user["uid"])
assert int(farm["mastery_points"]) == 1
assert int(farm["mastery_points_earned_total"]) == 1
def test_prestige_below_mastery_threshold_awards_nothing(local_db):
user = _reset("unit_a")
_set(user, xp=economy.xp_threshold(economy.PRESTIGE_MIN_LEVEL), prestige=0)
result = store.prestige(user)
assert result["mastery_awarded"] == 0
# --- eras --------------------------------------------------------------------------
def test_start_era_resets_visible_counters_not_real_balances(local_db):
user = _reset("unit_a", coins=12345)
_set(user, era_coins=999, era_harvests=5, prestige=3, stars=7)
if store.active_era():
store.end_era()
store.start_era("TestEra", 7)
farm = store.get_farm(user["uid"])
assert int(farm["era_coins"]) == 0
assert int(farm["era_harvests"]) == 0
assert int(farm["coins"]) == 12345
assert int(farm["prestige"]) == 3
assert int(farm["stars"]) == 7
store.end_era()
def test_start_era_twice_blocked(local_db):
if store.active_era():
store.end_era()
store.start_era("TestEra", 7)
with pytest.raises(GameError):
store.start_era("AnotherEra", 7)
store.end_era()
def test_end_era_without_active_blocked(local_db):
if store.active_era():
store.end_era()
with pytest.raises(GameError):
store.end_era()
def test_end_era_awards_stars_to_top_participant(local_db):
if store.active_era():
store.end_era()
user = _reset("unit_era_top")
store.start_era("TestEra", 7)
_set(user, era_coins=50000, era_harvests=10)
before_stars = int(store.get_farm(user["uid"])["stars"])
store.end_era()
farm = store.get_farm(user["uid"])
assert int(farm["stars"]) > before_stars
assert int(farm["era_coins"]) == 0
def test_era_gates_new_crops_and_leaderboard(local_db):
if store.active_era():
store.end_era()
_clear_game_caches()
assert store.leaderboard_for("era") == []
# --- weekly contracts ------------------------------------------------------------------
def test_weekly_contract_requires_mastery_upgrade(local_db):
user = _reset("unit_a")
with pytest.raises(GameError):
store.claim_quest(user, "harvest", "weekly")
def test_weekly_contract_claim_pays_stars_and_boost(local_db):
user = _reset("unit_a")
_set(user, mastery_points=1000)
store.upgrade_mastery(user, "contracts")
farm = store.get_farm(user["uid"])
from devplacepy.services.game.store.quests import ensure_weekly_contract
from devplacepy.services.game.store.common import _iso_week
iso_week = _iso_week(datetime.now(timezone.utc))
ensure_weekly_contract(farm, iso_week)
row = get_table("game_quests").find_one(farm_uid=farm["uid"], day=iso_week, scope="weekly")
get_table("game_quests").update({"uid": row["uid"], "progress": row["goal"]}, ["uid"])
result = store.claim_quest(user, row["kind"], "weekly")
assert result["reward_stars"] > 0
farm = store.get_farm(user["uid"])
assert int(farm["stars"]) == result["reward_stars"]
assert farm.get("contract_boost_until")