diff --git a/README.md b/README.md index 02c14795..e79de0fc 100644 --- a/README.md +++ b/README.md @@ -125,8 +125,9 @@ The **Code Farm** (`/game`) is a cooperative idle game in the spirit of Farmvill - **Daily bonus.** Claim a coin bonus once per day; consecutive days build a streak that grows the reward (capped at seven days). - **Daily quests.** Three quests rotate every day (plant, harvest, water, or earn goals), tracked automatically as you play; claim each one for coins and XP when complete. - **Perks.** Spend coins on four permanent upgrades - Optimizer (+harvest coins), Build Cache (+build speed), Bulk Licenses (-planting cost), and Mentorship (+harvest XP) - each levelling up with escalating cost. -- **Refactor (prestige).** At level 10 you can refactor: the farm resets (coins, level, CI, extra plots, perks) in exchange for a permanent +25% coin bonus that stacks with every refactor. Each refactor also awards **Stars** (scaled by the level and prestige you reached) to spend in the Legacy shop. -- **Stars and Legacy (endgame).** Stars buy permanent **Legacy** upgrades that survive every refactor, unlike perks: **CI Bot** (auto-collects ready builds when you open your farm), **Tech Debt Payoff** (+coins, stacks with prestige), **Bare-Metal** (+base build speed), **Monorepo** (+starting plots after each refactor), and **Branch Protection** (longer steal grace and a smaller steal cut). This is the infinite progression for maxed farms, and CI Bot makes the game playable hands-off. +- **Refactor (prestige).** At level 10 you can refactor: the farm resets (coins, level, CI, extra plots, perks) in exchange for a permanent +25% coin bonus that stacks with every refactor. Each refactor also awards **Stars** (scaled by the level and prestige you reached) to spend in the Legacy shop. Refactoring costs a **dynamic coin fee** that grows with your prestige and current wealth, so you must farm well past level 10 to afford each one - and 10% of what remains after the fee (more with the Golden Parachute Legacy upgrade, up to 35%) carries over into the new run. +- **Community treasury and weekly grant.** Every refactor fee flows into a shared treasury. Active low-balance, low-prestige farms (at least five harvests this week, under 10,000 coins, at most prestige 5) can claim a capped grant from it once per week - a direct wealth transfer from the farms refactoring at the top to the farms building at the bottom. +- **Stars and Legacy (endgame).** Stars buy permanent **Legacy** upgrades that survive every refactor, unlike perks: **CI Bot** (auto-collects ready builds when you open your farm), **Tech Debt Payoff** (+coins, stacks with prestige), **Bare-Metal** (+base build speed), **Monorepo** (+starting plots after each refactor), **Branch Protection** (longer steal grace and a smaller steal cut), and **Golden Parachute** (a larger refactor coin carry-over). This is the infinite progression for maxed farms, and CI Bot makes the game playable hands-off. - **Golden builds.** A small share of plantings come out golden (marked with a sparkle); harvesting a golden build pays several times the coins. - **Visit and water friends.** Open another member's farm at `/game/farm/{username}` and water their growing builds to speed them up - you earn coins for helping, and the owner sees the help live. This is the social loop that makes the game cooperative. - **Steal a harvest.** A ready build on someone else's farm can be stolen once a protection window passes - the owner gets that grace period (longer if they invested in Branch Protection or a Defense building) to harvest it first. A successful steal pays the thief a fraction of the build's coin value (the owner loses the whole build) and earns the **Cat Burglar** badge; the victim gets the **Robbed** badge and a live notification that someone raided their farm (the thief is never named). You can raid any given neighbour only **once per hour**. Raiding a farm with 10x your own coins grants a 24-hour **Underdog** boost (+25% coin gain) and the **David vs Goliath** badge. Stealing pays coins only, so the harvest-based leaderboards stay earned by real farming. @@ -138,7 +139,7 @@ The **Code Farm** (`/game`) is a cooperative idle game in the spirit of Farmvill - **Leaderboards.** Several boards, selectable from the game page: **Overall score** (a composite weighing refactor/prestige count, XP, lifetime harvests, coins, CI tier, plots, perks, and streak), **Prestige**, **Harvests this week**, **Raid efficiency** (average coins per successful raid), **Fastest to Kernel** (time since your last refactor), **Fair play** (rewards recent activity over hoarding), and (when running) the current **Era** board. - **Eras (admin-managed seasons).** Administrators can start a Era at `/admin/game`: every farm's *visible* Era coins/harvests counters reset to zero, but real coin balances, prestige, Stars, Legacy, and Mastery are never touched. Ending an Era ranks farms by Era score (which gives prestige only partial weight, so veterans keep an edge without it being insurmountable), awards Stars to the top 10, and permanently records the results. -The farm refreshes live over the pub/sub bus (a watered build appears on the owner's screen at once) and every plot countdown ticks client-side. Every endpoint also answers JSON, and Devii can play the game on the member's behalf via the `game_*` tools (`game_state`, `game_plant`, `game_harvest`, `game_buy_plot`, `game_upgrade_ci`, `game_water`, `game_steal`, `game_view_farm`, `game_leaderboard`, `game_upgrade_perk`, `game_upgrade_legacy`, `game_prestige`, `game_upgrade_mastery`, `game_buy_infrastructure`, `game_upgrade_defense`, `game_buy_cosmetic`, `game_equip_cosmetic`). See the API reference group **Code Farm**. +The farm refreshes live over the pub/sub bus (a watered build appears on the owner's screen at once) and every plot countdown ticks client-side. Every endpoint also answers JSON, and Devii can play the game on the member's behalf via the `game_*` tools (`game_state`, `game_plant`, `game_harvest`, `game_buy_plot`, `game_upgrade_ci`, `game_water`, `game_steal`, `game_view_farm`, `game_leaderboard`, `game_upgrade_perk`, `game_upgrade_legacy`, `game_prestige`, `game_claim_grant`, `game_upgrade_mastery`, `game_buy_infrastructure`, `game_upgrade_defense`, `game_buy_cosmetic`, `game_equip_cosmetic`). See the API reference group **Code Farm**. ## Engagement diff --git a/devplacepy/database/schema.py b/devplacepy/database/schema.py index e674dc7a..b9b043b5 100644 --- a/devplacepy/database/schema.py +++ b/devplacepy/database/schema.py @@ -1043,6 +1043,8 @@ def init_db(): ("legacy_speed", 0), ("legacy_plots", 0), ("legacy_defense", 0), + ("legacy_carryover", 0), + ("last_grant_week", ""), ("prestiged_at", ""), ("mastery_points", 0), ("mastery_points_earned_total", 0), @@ -1170,6 +1172,17 @@ def init_db(): unique=True, ) + game_treasury = get_table("game_treasury") + for column, example in ( + ("uid", ""), + ("balance", 0), + ("collected_total", 0), + ("granted_total", 0), + ("updated_at", ""), + ): + if not game_treasury.has_column(column): + game_treasury.create_column_by_example(column, example) + game_eras = get_table("game_eras") for column, example in ( ("uid", ""), diff --git a/devplacepy/docs_api/groups/game.py b/devplacepy/docs_api/groups/game.py index 9d0b10e9..77799a8f 100644 --- a/devplacepy/docs_api/groups/game.py +++ b/devplacepy/docs_api/groups/game.py @@ -12,6 +12,9 @@ The Code Farm is a cooperative idle game. Each member owns a farm of plots, plan projects that build over real time, harvests them for coins and XP, upgrades their CI tier for faster builds, and waters other members' growing builds to speed them up and earn coins. +Refactoring (prestige) costs a dynamic coin fee that grows with prestige and current wealth; +the fees fill a community treasury from which active low-balance farms can claim a weekly grant. + All endpoints negotiate HTML or JSON. The action endpoints return the full farm state so a client can refresh without a second request. """, @@ -180,17 +183,26 @@ client can refresh without a second request. method="POST", path="/game/prestige", title="Refactor (prestige)", - summary="Reset the farm at level 10+ for a permanent +25% coin bonus and earn Stars to spend on Legacy upgrades. From prestige 50 onward, every 10 more prestige also earns a permanent Mastery point.", + summary="Reset the farm at level 10+ for a permanent +25% coin bonus and earn Stars to spend on Legacy upgrades. Refactoring costs a coin fee that scales with prestige and current wealth (the farm state's refactor_cost); the fee funds the community treasury and a fraction of the remaining coins (10% base, more with the Golden Parachute Legacy upgrade) carries over. From prestige 50 onward, every 10 more prestige also earns a permanent Mastery point.", auth="user", destructive=True, - sample_response={"ok": True, "farm": {"prestige": 1}}, + sample_response={"ok": True, "farm": {"prestige": 1, "coins": 6550}}, + ), + endpoint( + id="game-grant", + method="POST", + path="/game/grant", + title="Claim the community grant", + summary="Claim the weekly community grant, paid from the treasury filled by refactor fees. Eligible farms are active (5+ harvests this week), below 10000 coins, and at most prestige 5.", + auth="user", + sample_response={"ok": True, "farm": {"coins": 2550}}, ), endpoint( id="game-legacy", method="POST", path="/game/legacy", title="Buy a Legacy upgrade", - summary="Spend Stars on a permanent Legacy upgrade that survives every refactor: autoharvest, multiplier, speed, plots, or defense.", + summary="Spend Stars on a permanent Legacy upgrade that survives every refactor: autoharvest, multiplier, speed, plots, defense, or carryover (Golden Parachute, raises the refactor coin carry-over).", auth="user", params=[field("key", "form", "string", True, "multiplier", "Legacy upgrade key.")], sample_response={"ok": True, "farm": {"stars": 1}}, diff --git a/devplacepy/routers/CLAUDE.md b/devplacepy/routers/CLAUDE.md index 72169668..cd85fa83 100644 --- a/devplacepy/routers/CLAUDE.md +++ b/devplacepy/routers/CLAUDE.md @@ -43,7 +43,7 @@ Prefixes are wired in `main.py`: | `/xmlrpc` | xmlrpc.py - reverse-proxies XML-RPC calls to the forking XML-RPC bridge (`services/xmlrpc/`, supervised by `XmlrpcService` on loopback `config.XMLRPC_PORT`), which generates one XML-RPC method per documented REST endpoint from `docs_api.API_GROUPS` (`posts.create`, `feed.list`, ...). One struct of named params per call; auth via in-band `api_key`, `X-API-KEY`/`Bearer` header, or `http://user:pass@host/xmlrpc` Basic. Full introspection + `system.multicall`; REST errors become XML-RPC faults. Exempt from the rate limiter (enforced on the forwarded internal hop). See `devplacepy/services/xmlrpc/CLAUDE.md` | | `/api` | devrant/ package - devRant-compatible REST protocol (`auth.py`, `rants.py`, `comments.py`, `notifs.py`). Translates devRant requests onto DevPlace data: rants<->posts, comments/votes onto the native engagement layer, devRant integer ids onto each table's auto-increment `id`. Token auth via `devrant_tokens`. See `routers/devrant/CLAUDE.md` for the deep detail on this tree | | `/dbapi` | dbapi/ package - **primary-administrator-only, strictly READ-ONLY** generic database API over `dataset` (never inserts/updates/deletes/restores; the write surface was removed because it bypassed every per-route admin safeguard). `tables.py` (`GET /tables`, `GET /{table}/schema`), `crud.py` (read only: `GET /{table}` + `GET /{table}/{key}/{value}`, `?include_deleted`), `query.py` (`POST /query` hard SELECT-only validated read-only run; `POST /query/async` + `GET /query/{uid}` + `WS /query/{uid}/ws` via `DbApiJobService` kind `dbquery`), `nl.py` (`POST /nl` natural-language->SQL via the AI gateway, returns validated SELECT, `execute=true` runs it read-only). **Auth = the PRIMARY administrator only** (the earliest-created Admin, `utils.is_primary_admin` / `database.get_primary_admin_uid` - the same identity that gates backup downloads) by session/api_key (`services/dbapi/policy.py`); every other administrator is refused like a member, and there is **no internal-key path** - the gateway `internal_gateway_key()` is NOT accepted (no service-to-service access). SQL validated by `services/dbapi/validate.py` (sqlglot classify + suspicious-flag + read-only `EXPLAIN` dry-run). Devii tools `db_*` are read-only (list/get/query/nl; no write tools) and flagged `requires_primary_admin=True`, so they are added to the LLM tool list ONLY for the primary administrator (every other session never sees them and Devii is unaware they exist). `services/pubsub/policy.py` resolves its own admin/internal actor and does NOT reuse `dbapi.policy.caller_for`, so the primary-admin restriction does not leak onto the pub/sub bus. See `devplacepy/services/dbapi/CLAUDE.md` | -| `/game` | game/ package - the **Code Farm** idle game (`index.py` + `farm.py`): `GET /game` (page), `GET /game/state`, `GET /game/leaderboard?board=`, `POST /game/{plant,harvest,buy-plot,upgrade,fertilize,daily,perk,prestige,legacy,mastery,quests/claim}`, `POST /game/{defense/upgrade,infrastructure/buy,cosmetics/buy,cosmetics/equip}`, plus social `GET /game/farm/{username}` and `POST /game/farm/{username}/{water,steal}`. See `devplacepy/services/game/CLAUDE.md` | +| `/game` | game/ package - the **Code Farm** idle game (`index.py` + `farm.py`): `GET /game` (page), `GET /game/state`, `GET /game/leaderboard?board=`, `POST /game/{plant,harvest,buy-plot,upgrade,fertilize,daily,grant,perk,prestige,legacy,mastery,quests/claim}`, `POST /game/{defense/upgrade,infrastructure/buy,cosmetics/buy,cosmetics/equip}`, plus social `GET /game/farm/{username}` and `POST /game/farm/{username}/{water,steal}`. See `devplacepy/services/game/CLAUDE.md` | | (none) | push.py - web push + PWA: `GET`/`POST /push.json` (VAPID public key / subscription register), `GET /service-worker.js`, `GET /manifest.json` | | (none) | docs.py (docs/ package) - the documentation site (prose pages + API reference). See `routers/docs/CLAUDE.md` for the deep detail on this tree (`DOCS_PAGES`, audience tiers, prose rendering pipeline) | | `/pubsub` | pubsub.py - **database-free** publish/subscribe bus. `WS /pubsub/ws` (subscribe/unsubscribe/publish frames, `foo.*` wildcards), `POST /pubsub/publish` + `GET /pubsub/topics` (admin/internal). Lock-owner-gated WS (`4013` retry) so subscribers converge on one worker; topic authz in `services/pubsub/policy.py` (`user.{uid}.*` private, `public.*` shared, admin/internal anywhere, guests opt-in). In-process `services.pubsub.publish(topic, data)` for backends; frontend `app.pubsub`. See `devplacepy/services/pubsub/CLAUDE.md` | @@ -188,7 +188,7 @@ News articles have an internal detail page at `/news/{slug}` with full comment s The home route (`main.py` `landing()`) never redirects - it renders `templates/landing.html` for everyone, branching on `user`: - **Guests** get the marketing hero (`Join DevPlace Free` CTA + features grid). -- **Signed-in users** get a personalized hero (`.landing-hero-user`): avatar, "Welcome back, {username}", a `Go to your feed` CTA, a Posts/Stars/Level stat strip (`user_post_count` + the user dict's `stars`/`level`), and quick links. Styles live in `.landing-hero-user`/`.landing-welcome`/`.landing-stats`/`.landing-quicklinks` in `static/css/landing.css`. +- **Signed-in users** get a personalized dashboard hero (`.dashboard-welcome`): avatar, "Welcome back, {username}", quicklink buttons (`.dashboard-btn`, with `New Post` -> `/feed` as `.dashboard-btn-primary`, plus Code Farm/Projects/Gists), and a Posts/Stars/Level stat strip (`.dashboard-stats`, `user_post_count` + the user dict's `stars`/`level`). Styles live in the `.dashboard-*` classes in `static/css/landing.css`. - Both states share the Latest Posts + Developer News + "Build With Us" sections. The **Build With Us** section is static HTML/CSS (`.landing-help-*` in `landing.css`): four cards linking to `/docs/index.html` (Documentation), `/swagger` + `/openapi.json` (API Reference), `/issues` (Contribute & Report), and Devii. The Devii card's `Launch Devii` button is a plain `` + const prestigeBtn = farm.refactor_affordable + ? `
` : ""; - const prestigeText = farm.prestige_available - ? "Reset your farm for a permanent +25% coin bonus." - : `Reach level ${farm.prestige_min_level} to refactor.`; + let prestigeText; + if (!farm.prestige_available) { + prestigeText = `Reach level ${farm.prestige_min_level} to refactor.`; + } else if (farm.refactor_affordable) { + prestigeText = `Pay ${farm.refactor_cost}c to reset your farm for a permanent +25% coin bonus. You keep ${farm.refactor_carryover_pct}% of what remains (${farm.refactor_carryover_preview}c) plus your Stars and Legacy upgrades.`; + } else { + prestigeText = `Refactoring costs ${farm.refactor_cost}c right now - keep farming to afford it.`; + } return ( `
CI upgrade${ciText}
${ciUpgrade}
` + `
Refactor (prestige ${farm.prestige})${prestigeText}
${prestigeBtn}
` @@ -226,6 +232,13 @@ export class GameFarm { .join(""); } + _grantHtml(farm) { + const action = farm.grant_available + ? `
` + : `${farm.grant_reason}`; + return `

Treasury: ${farm.treasury_balance}c collected from refactor fees

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

Community grant

+
{% include "_game_grant.html" %}
+
+

Quests & contracts

diff --git a/tests/api/game/mutations.py b/tests/api/game/mutations.py index 2a56f018..3187a0e9 100644 --- a/tests/api/game/mutations.py +++ b/tests/api/game/mutations.py @@ -6,7 +6,7 @@ from datetime import datetime, timezone, timedelta import requests from devplacepy.database import get_table, refresh_snapshot -from devplacepy.services.game import store +from devplacepy.services.game import economy, store from tests.conftest import BASE_URL JSON = {"Accept": "application/json"} @@ -337,3 +337,91 @@ def test_steal_cooldown_blocks_second_raid(app_server, seeded_db): f"{BASE_URL}/game/farm/{owner}/steal", data={"slot": 0}, headers=JSON ) assert second.status_code == 400 + + +def test_prestige_insufficient_coins_returns_400(app_server, seeded_db): + session, name = _signup() + _reset_farm(name, coins=100) + _set_farm(name, xp=economy.xp_threshold(economy.PRESTIGE_MIN_LEVEL)) + r = session.post(f"{BASE_URL}/game/prestige", headers=JSON) + assert r.status_code == 400 + assert "costs" in r.json()["error"]["message"] + + +def test_prestige_charges_fee_and_carries_over(app_server, seeded_db): + session, name = _signup() + _reset_farm(name, coins=100000) + _set_farm(name, xp=economy.xp_threshold(economy.PRESTIGE_MIN_LEVEL)) + fee = economy.refactor_cost(0, 100000) + carried = economy.refactor_carryover(100000, fee, 0) + r = session.post(f"{BASE_URL}/game/prestige", headers=JSON) + assert r.status_code == 200 + farm = r.json()["farm"] + assert farm["prestige"] == 1 + assert farm["coins"] == economy.STARTING_COINS + carried + refresh_snapshot() + assert store.treasury_balance() >= fee + + +def test_state_exposes_refactor_and_grant_fields(app_server, seeded_db): + session, name = _signup() + _reset_farm(name) + r = session.get(f"{BASE_URL}/game/state", headers=JSON) + assert r.status_code == 200 + farm = r.json()["farm"] + for key in ( + "refactor_cost", + "refactor_affordable", + "refactor_carryover_pct", + "refactor_carryover_preview", + "grant_available", + "grant_amount", + "grant_reason", + "treasury_balance", + ): + assert key in farm + assert farm["refactor_cost"] == economy.refactor_cost(0, farm["coins"]) + + +def test_grant_requires_auth(app_server, seeded_db): + r = requests.post(f"{BASE_URL}/game/grant", headers=JSON) + assert r.status_code in (401, 303) + + +def test_grant_claim_flow(app_server, seeded_db): + session, name = _signup() + _reset_farm(name, coins=100) + refresh_snapshot() + store.ensure_treasury() + get_table("game_treasury").update( + {"uid": "treasury-main", "balance": 100000}, ["uid"] + ) + week = store._iso_week(store._now()) + _set_farm( + name, + harvests_week=economy.GRANT_MIN_WEEK_HARVESTS, + harvests_week_start=week, + ) + r = session.post(f"{BASE_URL}/game/grant", headers=JSON) + assert r.status_code == 200 + assert r.json()["farm"]["coins"] == 100 + economy.GRANT_CAP + second = session.post(f"{BASE_URL}/game/grant", headers=JSON) + assert second.status_code == 400 + + +def test_grant_ineligible_when_rich_returns_400(app_server, seeded_db): + session, name = _signup() + _reset_farm(name, coins=economy.GRANT_WEALTH_CEILING + 1) + refresh_snapshot() + store.ensure_treasury() + get_table("game_treasury").update( + {"uid": "treasury-main", "balance": 100000}, ["uid"] + ) + week = store._iso_week(store._now()) + _set_farm( + name, + harvests_week=economy.GRANT_MIN_WEEK_HARVESTS, + harvests_week_start=week, + ) + r = session.post(f"{BASE_URL}/game/grant", headers=JSON) + assert r.status_code == 400 diff --git a/tests/e2e/admin/awards.py b/tests/e2e/admin/awards.py index ec94d392..fd706c34 100644 --- a/tests/e2e/admin/awards.py +++ b/tests/e2e/admin/awards.py @@ -54,7 +54,7 @@ def test_admin_sees_revoke_button(alice, bob): admin_page, _ = alice member_page, _ = bob admin_page.goto(f"{BASE_URL}/profile/bob_test?tab=awards", wait_until="domcontentloaded") - expect(admin_page.locator(AWARD_REVOKE)).to_be_visible() + expect(admin_page.locator(AWARD_REVOKE).first).to_be_visible() member_page.goto(f"{BASE_URL}/profile/bob_test?tab=awards", wait_until="domcontentloaded") expect(member_page.locator(AWARD_REVOKE)).to_have_count(0) diff --git a/tests/e2e/ai/usage/profile.py b/tests/e2e/ai/usage/profile.py index 2a215f37..ed09d256 100644 --- a/tests/e2e/ai/usage/profile.py +++ b/tests/e2e/ai/usage/profile.py @@ -97,7 +97,7 @@ def test_member_sees_only_quota_percentage(bob): assert ( page.locator(".ai-quota-only .ai-usage-title", has_text="AI quota").count() == 1 ) - assert "%" in page.locator(".ai-quota-only .ai-quota-pct").inner_text() + assert "%" in page.locator(".ai-quota-only .ai-quota-pct").first.inner_text() assert page.locator("[data-ai-usage]").count() == 0 diff --git a/tests/e2e/auth/signup.py b/tests/e2e/auth/signup.py index cedffd0e..4f7020cf 100644 --- a/tests/e2e/auth/signup.py +++ b/tests/e2e/auth/signup.py @@ -324,7 +324,7 @@ def test_landing_authenticated_shows_dashboard(page, app_server): assert page.url.rstrip("/") == BASE_URL.rstrip("/") assert page.is_visible("text=Welcome back") assert page.is_visible("text=home_user") - feed_cta = page.locator("a.landing-cta:has-text('Go to your feed')") + feed_cta = page.locator("a.dashboard-btn-primary:has-text('New Post')") assert feed_cta.is_visible() assert feed_cta.get_attribute("href") == "/feed" assert not page.is_visible("text=Join DevPlace Free") diff --git a/tests/e2e/game/index.py b/tests/e2e/game/index.py index 0486447e..b7e436cd 100644 --- a/tests/e2e/game/index.py +++ b/tests/e2e/game/index.py @@ -232,6 +232,8 @@ def test_prestige_resets_farm(alice): reset_farm("alice_test") set_farm_xp("alice_test", economy.xp_threshold(economy.PRESTIGE_MIN_LEVEL)) + fee = economy.refactor_cost(0, 100000) + carried = economy.refactor_carryover(100000, fee, 0) open_game(page) prestige = page.locator("form[data-game-action='prestige'] button") prestige.wait_for(state="visible") @@ -240,10 +242,49 @@ def test_prestige_resets_farm(alice): confirm_btn.wait_for(state="visible") confirm_btn.click() expect(page.locator("[data-hud-prestige]")).to_have_text("1") - expect(page.locator("[data-hud-coins]")).to_have_text("50") + expect(page.locator("[data-hud-coins]")).to_have_text( + str(economy.STARTING_COINS + carried) + ) assert page.locator("form[data-game-action='prestige']").count() == 0 +def test_prestige_unaffordable_shows_cost_without_button(alice): + page, _ = alice + from devplacepy.services.game import economy + + reset_farm("alice_test", coins=100) + set_farm_xp("alice_test", economy.xp_threshold(economy.PRESTIGE_MIN_LEVEL)) + open_game(page) + assert page.locator("form[data-game-action='prestige']").count() == 0 + assert page.is_visible("text=keep farming to afford it") + + +def test_grant_section_claim(alice): + page, _ = alice + from devplacepy.database import get_table + from devplacepy.services.game import economy, store + from devplacepy.services.game.store import _iso_week, _now + + reset_farm("alice_test", coins=100) + store.ensure_treasury() + get_table("game_treasury").update( + {"uid": "treasury-main", "balance": 100000}, ["uid"] + ) + set_farm( + "alice_test", + harvests_week=economy.GRANT_MIN_WEEK_HARVESTS, + harvests_week_start=_iso_week(_now()), + ) + open_game(page) + claim = page.locator("form[data-game-action='grant'] button") + claim.wait_for(state="visible") + claim.click() + expect(page.locator("[data-hud-coins]")).to_have_text( + str(100 + economy.GRANT_CAP) + ) + assert page.locator("form[data-game-action='grant']").count() == 0 + + def test_leaderboard_panel_populates(alice): page, _ = alice reset_farm("alice_test") diff --git a/tests/unit/services/game/economy.py b/tests/unit/services/game/economy.py index cbee1a17..65b70109 100644 --- a/tests/unit/services/game/economy.py +++ b/tests/unit/services/game/economy.py @@ -399,3 +399,67 @@ def test_weekly_contract_deterministic(): assert first == second assert first["kind"] in economy.QUEST_DEFS assert first["reward_stars"] > 0 + + +# --- refactor fee, carry-over, and grants ------------------------------------------- + + +def test_refactor_cost_monotonic_in_prestige(): + previous = 0 + for prestige in range(0, 200): + cost = economy.refactor_cost(prestige, 0) + assert cost > previous + previous = cost + + +def test_refactor_cost_monotonic_in_wealth(): + previous = -1 + for coins in range(0, 2_000_000, 10_000): + cost = economy.refactor_cost(0, coins) + assert cost >= previous + previous = cost + + +def test_refactor_cost_floor_is_base(): + assert economy.refactor_cost(0, 0) == economy.REFACTOR_BASE_COST + assert economy.refactor_cost(-5, -100) == economy.REFACTOR_BASE_COST + + +def test_refactor_cost_includes_wealth_tax(): + coins = 100_000 + expected = round( + economy.REFACTOR_BASE_COST + economy.REFACTOR_WEALTH_PCT * coins + ) + assert economy.refactor_cost(0, coins) == expected + + +def test_refactor_carryover_fraction_bounds(): + for level in range(0, 50): + fraction = economy.refactor_carryover_fraction(level) + assert economy.REFACTOR_CARRYOVER_BASE <= fraction <= economy.REFACTOR_CARRYOVER_MAX + + +def test_refactor_carryover_never_exceeds_remainder(): + for coins in range(0, 500_000, 7_777): + for level in (0, 3, 5): + fee = economy.refactor_cost(0, coins) + carried = economy.refactor_carryover(coins, fee, level) + assert 0 <= carried <= max(0, coins - fee) + + +def test_refactor_carryover_zero_when_unaffordable(): + assert economy.refactor_carryover(100, 20_000, 5) == 0 + + +def test_grant_amount_capped_and_bounded(): + assert economy.grant_amount(0) == 0 + assert economy.grant_amount(-10) == 0 + assert economy.grant_amount(100) == 100 + assert economy.grant_amount(10**9) == economy.GRANT_CAP + + +def test_legacy_carryover_upgrade_registered(): + upgrade = economy.legacy_for("carryover") + assert upgrade is not None + assert upgrade.max_level == 5 + assert "carry-over" in economy.legacy_value_text(upgrade, 2) diff --git a/tests/unit/services/game/store.py b/tests/unit/services/game/store.py index ae8fd163..00388a91 100644 --- a/tests/unit/services/game/store.py +++ b/tests/unit/services/game/store.py @@ -45,6 +45,7 @@ def _reset(username, coins=100000): get_table("game_steals").delete(owner_uid=user["uid"]) get_table("game_cosmetics").delete(user_uid=user["uid"]) get_table("game_market_ticks").delete() + get_table("game_treasury").delete() _clear_game_caches() farm = store.ensure_farm(user["uid"]) get_table("game_farms").update({"uid": farm["uid"], "coins": coins}, ["uid"]) @@ -493,10 +494,15 @@ def test_prestige_resets_farm_and_increments(local_db): ci_tier=3, ) store.buy_plot(user) + coins_before = _coins(user) + fee = economy.refactor_cost(0, coins_before) + carried = economy.refactor_carryover(coins_before, fee, 0) result = store.prestige(user) assert result["prestige"] == 1 + assert result["fee"] == fee + assert result["carried"] == carried farm = store.get_farm(user["uid"]) - assert farm["coins"] == economy.STARTING_COINS + assert farm["coins"] == economy.STARTING_COINS + carried assert farm["xp"] == 0 assert farm["ci_tier"] == 1 assert farm["perk_growth"] == 0 @@ -504,6 +510,45 @@ def test_prestige_resets_farm_and_increments(local_db): assert len(store.get_plots(farm["uid"])) == economy.STARTING_PLOTS +def test_prestige_insufficient_coins_blocked(local_db): + user = _reset("unit_a", coins=100) + _set(user, xp=economy.xp_threshold(economy.PRESTIGE_MIN_LEVEL)) + with pytest.raises(GameError): + store.prestige(user) + farm = store.get_farm(user["uid"]) + assert int(farm["prestige"] or 0) == 0 + assert int(farm["coins"]) == 100 + + +def test_prestige_fee_funds_treasury(local_db): + user = _reset("unit_a") + _set(user, xp=economy.xp_threshold(economy.PRESTIGE_MIN_LEVEL)) + fee = economy.refactor_cost(0, _coins(user)) + store.prestige(user) + assert store.treasury_balance() == fee + + +def test_prestige_carryover_scales_with_legacy(local_db): + user = _reset("unit_a") + _set( + user, + xp=economy.xp_threshold(economy.PRESTIGE_MIN_LEVEL), + legacy_carryover=5, + ) + coins_before = _coins(user) + fee = economy.refactor_cost(0, coins_before) + carried = economy.refactor_carryover(coins_before, fee, 5) + assert carried > economy.refactor_carryover(coins_before, fee, 0) + store.prestige(user) + farm = store.get_farm(user["uid"]) + assert farm["coins"] == economy.STARTING_COINS + carried + + +def test_prestige_fee_rises_with_prestige_and_wealth(local_db): + assert economy.refactor_cost(1, 0) > economy.refactor_cost(0, 0) + assert economy.refactor_cost(0, 1_000_000) > economy.refactor_cost(0, 0) + + # --- legacy upgrades --------------------------------------------------------- @@ -851,7 +896,8 @@ def test_farm_analytics_tracks_lifetime_totals_once_unlocked(local_db): def test_prestige_awards_mastery_at_threshold(local_db): user = _reset("unit_a") - _set(user, xp=economy.xp_threshold(economy.PRESTIGE_MIN_LEVEL), prestige=49) + coins = economy.refactor_cost(49, 0) * 2 + _set(user, xp=economy.xp_threshold(economy.PRESTIGE_MIN_LEVEL), prestige=49, coins=coins) result = store.prestige(user) assert result["mastery_awarded"] == 1 farm = store.get_farm(user["uid"]) @@ -946,3 +992,87 @@ def test_weekly_contract_claim_pays_stars_and_boost(local_db): farm = store.get_farm(user["uid"]) assert int(farm["stars"]) == result["reward_stars"] assert farm.get("contract_boost_until") + + +# --- treasury and community grant -------------------------------------------- + + +def _seed_treasury(amount): + store.ensure_treasury() + get_table("game_treasury").update( + {"uid": "treasury-main", "balance": amount}, ["uid"] + ) + + +def _make_grant_eligible(user): + _set( + user, + coins=100, + harvests_week=economy.GRANT_MIN_WEEK_HARVESTS, + harvests_week_start=store._iso_week(store._now()), + ) + + +def test_claim_grant_pays_from_treasury(local_db): + user = _reset("unit_a") + _seed_treasury(100_000) + _make_grant_eligible(user) + result = store.claim_grant(user) + assert result["amount"] == economy.GRANT_CAP + assert _coins(user) == 100 + economy.GRANT_CAP + assert store.treasury_balance() == 100_000 - economy.GRANT_CAP + + +def test_claim_grant_once_per_week(local_db): + user = _reset("unit_a") + _seed_treasury(100_000) + _make_grant_eligible(user) + store.claim_grant(user) + _set(user, coins=100) + with pytest.raises(GameError): + store.claim_grant(user) + + +def test_claim_grant_requires_activity(local_db): + user = _reset("unit_a") + _seed_treasury(100_000) + _set(user, coins=100, harvests_week=economy.GRANT_MIN_WEEK_HARVESTS - 1) + with pytest.raises(GameError): + store.claim_grant(user) + + +def test_claim_grant_wealth_ceiling(local_db): + user = _reset("unit_a") + _seed_treasury(100_000) + _set( + user, + coins=economy.GRANT_WEALTH_CEILING, + harvests_week=economy.GRANT_MIN_WEEK_HARVESTS, + ) + with pytest.raises(GameError): + store.claim_grant(user) + + +def test_claim_grant_prestige_cap(local_db): + user = _reset("unit_a") + _seed_treasury(100_000) + _make_grant_eligible(user) + _set(user, prestige=economy.GRANT_MAX_PRESTIGE + 1) + with pytest.raises(GameError): + store.claim_grant(user) + + +def test_claim_grant_empty_treasury(local_db): + user = _reset("unit_a") + _make_grant_eligible(user) + with pytest.raises(GameError): + store.claim_grant(user) + + +def test_claim_grant_partial_treasury(local_db): + user = _reset("unit_a") + _seed_treasury(300) + _make_grant_eligible(user) + result = store.claim_grant(user) + assert result["amount"] == 300 + assert store.treasury_balance() == 0