Code Farm economy rebalance: market saturation, infrastructure/defense/cosmetics, mastery track, secondary leaderboards, admin eras, underdog bonus and weekly contracts
Every purchase/upgrade path (new and pre-existing) is now race-safe against concurrent requests via atomic conditional SQL updates. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
024edb5291
commit
34f76aad65
13
CLAUDE.md
13
CLAUDE.md
@ -283,6 +283,17 @@ Required patterns: every `page.goto(...)`/`page.wait_for_url(...)` MUST pass `wa
|
||||
|
||||
**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.
|
||||
|
||||
## 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
|
||||
|
||||
Every feature in DevPlace is **one data source fanning out into several consumers**. DevPlace has no isolated change - internalize this before editing. A single handler in `routers/{area}.py` is simultaneously the **four faces of one route**:
|
||||
@ -300,7 +311,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`.
|
||||
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).
|
||||
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. **Never run the persisted test suite unless the user explicitly asks.** Write new tests in the matching tier/path when asked, following the required patterns above.
|
||||
|
||||
Failures at any implementation step block the workflow - never skip a failed step.
|
||||
|
||||
|
||||
12
README.md
12
README.md
@ -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.
|
||||
- **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) 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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
|
||||
84
devplacepy/cli/game.py
Normal file
84
devplacepy/cli/game.py
Normal 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)
|
||||
@ -12,6 +12,7 @@ from devplacepy.cli.jobs import register_jobs
|
||||
from devplacepy.cli.backups import register_backups
|
||||
from devplacepy.cli.containers import register_containers
|
||||
from devplacepy.cli.migrate import register_migrate
|
||||
from devplacepy.cli.game import register_game
|
||||
|
||||
|
||||
def build_parser():
|
||||
@ -28,6 +29,7 @@ def build_parser():
|
||||
register_backups(sub)
|
||||
register_containers(sub)
|
||||
register_migrate(sub)
|
||||
register_game(sub)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
@ -1014,6 +1014,29 @@ def init_db():
|
||||
("legacy_speed", 0),
|
||||
("legacy_plots", 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", ""),
|
||||
("updated_at", ""),
|
||||
):
|
||||
@ -1045,6 +1068,7 @@ def init_db():
|
||||
("farm_uid", ""),
|
||||
("user_uid", ""),
|
||||
("day", ""),
|
||||
("scope", "daily"),
|
||||
("slot_index", 0),
|
||||
("kind", ""),
|
||||
("label", ""),
|
||||
@ -1052,13 +1076,17 @@ def init_db():
|
||||
("progress", 0),
|
||||
("reward_coins", 0),
|
||||
("reward_xp", 0),
|
||||
("reward_stars", 0),
|
||||
("claimed", 0),
|
||||
("created_at", ""),
|
||||
("updated_at", ""),
|
||||
):
|
||||
if not game_quests.has_column(column):
|
||||
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_scope", ["farm_uid", "day", "scope"])
|
||||
|
||||
game_plots = get_table("game_plots")
|
||||
for column, example in (
|
||||
@ -1077,6 +1105,72 @@ def init_db():
|
||||
game_plots.create_column_by_example(column, example)
|
||||
_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,
|
||||
|
||||
@ -830,5 +830,37 @@ four ways to sign requests.
|
||||
params=[field("uid", "path", "string", True, "", "Schedule uid.")],
|
||||
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"},
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
@ -50,9 +50,10 @@ client can refresh without a second request.
|
||||
method="GET",
|
||||
path="/game/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",
|
||||
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(
|
||||
id="game-view-farm",
|
||||
@ -166,9 +167,12 @@ client can refresh without a second request.
|
||||
method="POST",
|
||||
path="/game/quests/claim",
|
||||
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",
|
||||
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}},
|
||||
),
|
||||
endpoint(
|
||||
@ -176,7 +180,7 @@ 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.",
|
||||
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",
|
||||
destructive=True,
|
||||
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.")],
|
||||
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"}},
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
@ -591,7 +591,25 @@ class GamePerkForm(BaseModel):
|
||||
|
||||
class GameQuestForm(BaseModel):
|
||||
quest: str = Field(min_length=1, max_length=40)
|
||||
scope: str = Field(default="daily", min_length=1, max_length=10)
|
||||
|
||||
|
||||
class GameLegacyForm(BaseModel):
|
||||
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)
|
||||
|
||||
@ -25,7 +25,7 @@ Prefixes are wired in `main.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) |
|
||||
| `/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 |
|
||||
| `/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 |
|
||||
@ -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`, `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) | 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` |
|
||||
|
||||
@ -8,6 +8,7 @@ from devplacepy.routers.admin import (
|
||||
backups,
|
||||
bots,
|
||||
containers,
|
||||
game,
|
||||
gateway_configs,
|
||||
issues,
|
||||
media,
|
||||
@ -36,5 +37,6 @@ router.include_router(auditlog.router)
|
||||
router.include_router(backups.router)
|
||||
router.include_router(bots.router)
|
||||
router.include_router(gateway_configs.router)
|
||||
router.include_router(game.router)
|
||||
router.include_router(services.router, prefix="/services")
|
||||
router.include_router(containers.router, prefix="/containers")
|
||||
|
||||
97
devplacepy/routers/admin/game.py
Normal file
97
devplacepy/routers/admin/game.py
Normal 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")
|
||||
@ -88,6 +88,8 @@ async def steal_farm(
|
||||
return RedirectResponse(url=f"/game/farm/{username}", status_code=302)
|
||||
track_action(viewer["uid"], "harvest_stolen")
|
||||
track_action(owner["uid"], "got_stolen_from")
|
||||
if result.get("underdog_triggered"):
|
||||
track_action(viewer["uid"], "underdog_raid")
|
||||
create_notification(
|
||||
owner["uid"],
|
||||
"harvest_stolen",
|
||||
|
||||
@ -6,7 +6,10 @@ from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from devplacepy.models import (
|
||||
GameCosmeticForm,
|
||||
GameInfraForm,
|
||||
GameLegacyForm,
|
||||
GameMasteryForm,
|
||||
GamePerkForm,
|
||||
GamePlantForm,
|
||||
GameQuestForm,
|
||||
@ -49,9 +52,9 @@ async def game_state(request: Request):
|
||||
|
||||
|
||||
@router.get("/leaderboard")
|
||||
async def game_leaderboard(request: Request):
|
||||
async def game_leaderboard(request: Request, board: str = "score"):
|
||||
get_current_user(request)
|
||||
entries = store.leaderboard(25)
|
||||
entries = store.leaderboard_for(board, 25)
|
||||
return JSONResponse(
|
||||
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))
|
||||
|
||||
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)
|
||||
)
|
||||
|
||||
@ -99,6 +99,7 @@ from devplacepy.schemas.backups import (
|
||||
BackupStoragePathOut,
|
||||
)
|
||||
from devplacepy.schemas.admin import (
|
||||
AdminGameOut,
|
||||
AdminMediaItemOut,
|
||||
AdminMediaOut,
|
||||
AdminNewsItemOut,
|
||||
|
||||
@ -72,3 +72,12 @@ class AdminTrashOut(_Out):
|
||||
tables: list[dict] = []
|
||||
pagination: Optional[Any] = 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
|
||||
|
||||
@ -15,6 +15,7 @@ class GameCropOut(_Out):
|
||||
min_level: int = 1
|
||||
grow_seconds: int = 0
|
||||
locked: bool = False
|
||||
market_state: str = "normal"
|
||||
|
||||
|
||||
class GamePlotOut(_Out):
|
||||
@ -62,6 +63,38 @@ class GameLegacyOut(_Out):
|
||||
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):
|
||||
kind: str = ""
|
||||
label: str = ""
|
||||
@ -71,6 +104,8 @@ class GameQuestOut(_Out):
|
||||
reward_xp: int = 0
|
||||
claimed: bool = False
|
||||
can_claim: bool = False
|
||||
scope: str = "daily"
|
||||
reward_stars: int = 0
|
||||
|
||||
|
||||
class GameFarmOut(_Out):
|
||||
@ -107,6 +142,25 @@ class GameFarmOut(_Out):
|
||||
stars: int = 0
|
||||
legacy: list[GameLegacyOut] = []
|
||||
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):
|
||||
@ -130,6 +184,9 @@ class GameLeaderboardEntryOut(_Out):
|
||||
total_harvests: int = 0
|
||||
prestige: int = 0
|
||||
score: int = 0
|
||||
raid_avg: float = 0.0
|
||||
time_to_kernel_seconds: int = 0
|
||||
title: str = ""
|
||||
|
||||
|
||||
class GameLeaderboardOut(_Out):
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..spec import Action, Param
|
||||
from ._shared import body, path
|
||||
from ._shared import body, path, query
|
||||
|
||||
|
||||
GAME_ACTIONS: tuple[Action, ...] = (
|
||||
@ -24,10 +24,18 @@ GAME_ACTIONS: tuple[Action, ...] = (
|
||||
name="game_leaderboard",
|
||||
method="GET",
|
||||
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",
|
||||
requires_auth=False,
|
||||
read_only=True,
|
||||
params=(query("board", "Leaderboard board key, defaults to score."),),
|
||||
),
|
||||
Action(
|
||||
name="game_view_farm",
|
||||
@ -136,11 +144,16 @@ GAME_ACTIONS: tuple[Action, ...] = (
|
||||
name="game_claim_quest",
|
||||
method="POST",
|
||||
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",
|
||||
requires_auth=True,
|
||||
params=(
|
||||
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(
|
||||
@ -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),),
|
||||
),
|
||||
)
|
||||
|
||||
@ -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).
|
||||
|
||||
**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`).
|
||||
|
||||
@ -35,6 +35,9 @@ class Crop:
|
||||
reward_coins: int
|
||||
reward_xp: int
|
||||
min_level: int
|
||||
min_mastery: int = 0
|
||||
steal_immune: bool = False
|
||||
era_key: str | None = None
|
||||
|
||||
|
||||
CROPS: tuple[Crop, ...] = (
|
||||
@ -45,9 +48,14 @@ CROPS: tuple[Crop, ...] = (
|
||||
Crop("rust", "Rust Engine", "\U0001f980", 200, 1800, 520, 60, 4),
|
||||
Crop("haskell", "Compiler", "λ", 500, 3600, 1380, 150, 6),
|
||||
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}
|
||||
MARKET_TRACKED_CROPS = ("shell", "python", "webapp", "api", "rust", "haskell", "kernel")
|
||||
REGISTRY_BOOST_CROPS = ("rust", "haskell", "kernel")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@ -83,6 +91,9 @@ def next_ci_tier(tier: int) -> CiTier | None:
|
||||
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:
|
||||
return (
|
||||
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(
|
||||
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:
|
||||
return max(
|
||||
1, round(crop.grow_seconds / farm_speed(ci_tier, growth_level, legacy_speed_level))
|
||||
)
|
||||
speed = 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(
|
||||
@ -178,8 +194,16 @@ def farm_score(farm: dict) -> int:
|
||||
)
|
||||
|
||||
|
||||
def unlocked_crops(level: int) -> list[Crop]:
|
||||
return [crop for crop in CROPS if crop.min_level <= level]
|
||||
def unlocked_crops(
|
||||
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(
|
||||
@ -192,19 +216,32 @@ def crop_payload(
|
||||
prestige: int = 0,
|
||||
legacy_mult_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:
|
||||
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 {
|
||||
"key": crop.key,
|
||||
"name": crop.name,
|
||||
"icon": crop.icon,
|
||||
"cost": effective_plant_cost(crop, discount_level),
|
||||
"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,
|
||||
"min_level": crop.min_level,
|
||||
"grow_seconds": grow_seconds_for(crop, ci_tier, growth_level, legacy_speed_level),
|
||||
"locked": crop.min_level > level,
|
||||
"grow_seconds": grow_seconds_for(
|
||||
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)
|
||||
|
||||
|
||||
def effective_steal_grace(defense_level: int = 0) -> int:
|
||||
return STEAL_GRACE_SECONDS + LEGACY_DEFENSE_GRACE * max(0, defense_level)
|
||||
def effective_steal_grace(defense_level: int = 0, extra_seconds: int = 0) -> int:
|
||||
return STEAL_GRACE_SECONDS + LEGACY_DEFENSE_GRACE * max(0, defense_level) + max(0, extra_seconds)
|
||||
|
||||
|
||||
def effective_steal_fraction(defense_level: int = 0) -> float:
|
||||
return max(0.1, STEAL_FRACTION - LEGACY_DEFENSE_FRACTION * max(0, defense_level))
|
||||
def effective_steal_fraction(defense_level: int = 0, floor: float = 0.1) -> float:
|
||||
return max(floor, STEAL_FRACTION - LEGACY_DEFENSE_FRACTION * max(0, defense_level))
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
UNDERDOG_COIN_RATIO = 10
|
||||
UNDERDOG_DURATION_HOURS = 24
|
||||
UNDERDOG_MULTIPLIER = 1.25
|
||||
|
||||
|
||||
def effective_plant_cost(crop: Crop, discount_level: int = 0) -> int:
|
||||
factor = max(0.0, 1 - PERK_BY_KEY["discount"].step * discount_level)
|
||||
return max(1, round(crop.cost * factor))
|
||||
|
||||
|
||||
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:
|
||||
factor = (
|
||||
(1 + PERK_BY_KEY["yield"].step * yield_level)
|
||||
* prestige_multiplier(prestige)
|
||||
* legacy_multiplier(legacy_mult_level)
|
||||
* market_factor
|
||||
)
|
||||
if underdog:
|
||||
factor *= UNDERDOG_MULTIPLIER
|
||||
return round(crop.reward_coins * factor)
|
||||
|
||||
|
||||
@ -394,12 +444,14 @@ def steal_reward_coins(
|
||||
prestige: int = 0,
|
||||
legacy_mult_level: int = 0,
|
||||
defense_level: int = 0,
|
||||
market_factor: float = 1.0,
|
||||
steal_floor: float = 0.1,
|
||||
) -> int:
|
||||
fraction = effective_steal_fraction(defense_level)
|
||||
fraction = effective_steal_fraction(defense_level, steal_floor)
|
||||
return max(
|
||||
1,
|
||||
round(
|
||||
effective_reward_coins(crop, yield_level, prestige, legacy_mult_level)
|
||||
effective_reward_coins(crop, yield_level, prestige, legacy_mult_level, market_factor)
|
||||
* fraction
|
||||
),
|
||||
)
|
||||
@ -472,3 +524,256 @@ def daily_quests(user_uid: str, day: str) -> list[dict]:
|
||||
}
|
||||
)
|
||||
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]
|
||||
|
||||
@ -27,6 +27,7 @@ from .common import (
|
||||
PERK_COLUMN,
|
||||
_farms,
|
||||
_iso,
|
||||
_iso_week,
|
||||
_lvl,
|
||||
_now,
|
||||
_parse,
|
||||
@ -36,9 +37,13 @@ from .common import (
|
||||
_steals,
|
||||
_today,
|
||||
_update_farm,
|
||||
credit_farm,
|
||||
last_steal_at,
|
||||
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 (
|
||||
_create_plot,
|
||||
_plot_at,
|
||||
@ -47,8 +52,12 @@ from .farm import (
|
||||
get_farm,
|
||||
get_plots,
|
||||
leaderboard,
|
||||
leaderboard_for,
|
||||
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 .serialize import (
|
||||
_daily_available,
|
||||
|
||||
@ -11,7 +11,10 @@ from .. import economy
|
||||
from .common import (
|
||||
GameError,
|
||||
PERK_COLUMN,
|
||||
conditional_update_farm,
|
||||
credit_farm,
|
||||
_iso,
|
||||
_iso_week,
|
||||
_lvl,
|
||||
_now,
|
||||
_parse,
|
||||
@ -23,7 +26,11 @@ from .common import (
|
||||
_update_farm,
|
||||
steal_cooldown_remaining,
|
||||
)
|
||||
from .era import active_era_name
|
||||
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 .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)))
|
||||
if crop.min_level > progress["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)
|
||||
if not plot:
|
||||
raise GameError("That plot does not exist.")
|
||||
@ -48,7 +59,11 @@ def plant(user: dict, slot: int, crop_key: str) -> dict:
|
||||
now = _now()
|
||||
ci_tier = int(farm.get("ci_tier", 1))
|
||||
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)
|
||||
_plots().update(
|
||||
@ -67,6 +82,34 @@ def plant(user: dict, slot: int, crop_key: str) -> dict:
|
||||
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:
|
||||
farm = ensure_farm(user["uid"])
|
||||
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", ""))
|
||||
if not crop:
|
||||
raise GameError("Unknown crop type.")
|
||||
planted_at = plot.get("planted_at", "")
|
||||
_plots().update(
|
||||
{
|
||||
"uid": plot["uid"],
|
||||
@ -89,26 +133,26 @@ def harvest(user: dict, slot: int) -> dict:
|
||||
},
|
||||
["uid"],
|
||||
)
|
||||
prestige = _lvl(farm, "prestige")
|
||||
golden = economy.is_golden(plot.get("uid", ""), plot.get("planted_at", ""))
|
||||
coins_gain = economy.effective_reward_coins(
|
||||
crop, _lvl(farm, "perk_yield"), prestige, _lvl(farm, "legacy_multiplier")
|
||||
)
|
||||
if golden:
|
||||
coins_gain *= economy.GOLDEN_MULTIPLIER
|
||||
xp_gain = economy.effective_reward_xp(crop, _lvl(farm, "perk_xp"))
|
||||
new_xp = int(farm.get("xp", 0)) + xp_gain
|
||||
_update_farm(
|
||||
farm["uid"],
|
||||
{
|
||||
"coins": int(farm.get("coins", 0)) + coins_gain,
|
||||
"xp": new_xp,
|
||||
"level": economy.level_for_xp(new_xp),
|
||||
"total_harvests": int(farm.get("total_harvests", 0)) + 1,
|
||||
},
|
||||
result = _harvest_crop(farm, crop, plot.get("uid", ""), planted_at, now)
|
||||
coins_gain, xp_gain, golden = result["coins"], result["xp"], result["golden"]
|
||||
extra = {}
|
||||
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")
|
||||
farm = credit_farm(
|
||||
farm,
|
||||
coins=coins_gain,
|
||||
xp=xp_gain,
|
||||
harvests=1,
|
||||
era_active=bool(active_era_name()),
|
||||
extra=extra or None,
|
||||
)
|
||||
advance_quests(user["uid"], "harvest", 1)
|
||||
advance_quests(user["uid"], "earn", coins_gain)
|
||||
if _lvl(farm, MASTERY_COLUMN["autoreplant"]):
|
||||
_try_autoreplant(farm, slot, crop, now)
|
||||
return {
|
||||
"slot": slot,
|
||||
"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:
|
||||
if visitor["uid"] == owner["uid"]:
|
||||
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(
|
||||
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))
|
||||
watered.append(visitor["uid"])
|
||||
_plots().update(
|
||||
@ -181,9 +256,16 @@ def steal(thief: dict, owner: dict, slot: int) -> dict:
|
||||
crop = economy.crop_for(plot.get("crop_key", ""))
|
||||
if not crop:
|
||||
raise GameError("Unknown crop type.")
|
||||
if crop.steal_immune:
|
||||
raise GameError("That build cannot be raided.")
|
||||
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
|
||||
grace = economy.effective_steal_grace(defense_level)
|
||||
if now < ready_at + timedelta(seconds=grace):
|
||||
raise GameError("That harvest is still protected.")
|
||||
cooldown = steal_cooldown_remaining(thief["uid"], owner["uid"], now)
|
||||
@ -204,17 +286,27 @@ def steal(thief: dict, owner: dict, slot: int) -> dict:
|
||||
},
|
||||
["uid"],
|
||||
)
|
||||
market_factor = market_factor_for(crop.key)
|
||||
coins_gain = economy.steal_reward_coins(
|
||||
crop,
|
||||
_lvl(farm, "perk_yield"),
|
||||
_lvl(farm, "prestige"),
|
||||
_lvl(farm, "legacy_multiplier"),
|
||||
defense_level,
|
||||
market_factor,
|
||||
floor,
|
||||
)
|
||||
thief_farm = ensure_farm(thief["uid"])
|
||||
_update_farm(
|
||||
thief_farm["uid"],
|
||||
{"coins": int(thief_farm.get("coins", 0)) + coins_gain},
|
||||
underdog_extra = {}
|
||||
if int(farm.get("coins", 0)) > int(thief_farm.get("coins", 0)) * economy.UNDERDOG_COIN_RATIO:
|
||||
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(
|
||||
{
|
||||
@ -233,24 +325,23 @@ def steal(thief: dict, owner: dict, slot: int) -> dict:
|
||||
"crop": crop.key,
|
||||
"coins": coins_gain,
|
||||
"owner_uid": owner["uid"],
|
||||
"underdog_triggered": bool(underdog_extra),
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
xp_gain = 0
|
||||
harvested = 0
|
||||
replant_slots = []
|
||||
extra = {}
|
||||
for plot in get_plots(farm["uid"]):
|
||||
if _plot_state(plot, now) != "ready":
|
||||
continue
|
||||
crop = economy.crop_for(plot.get("crop_key", ""))
|
||||
if not crop:
|
||||
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(
|
||||
{
|
||||
"uid": plot["uid"],
|
||||
@ -262,34 +353,47 @@ def _auto_harvest(farm: dict, owner_uid: str, now: datetime) -> dict:
|
||||
},
|
||||
["uid"],
|
||||
)
|
||||
coins = economy.effective_reward_coins(crop, yield_level, prestige, mult_level)
|
||||
if golden:
|
||||
coins *= economy.GOLDEN_MULTIPLIER
|
||||
coins_gain += coins
|
||||
xp_gain += economy.effective_reward_xp(crop, xp_level)
|
||||
coins_gain += result["coins"]
|
||||
xp_gain += result["xp"]
|
||||
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:
|
||||
return farm
|
||||
new_xp = int(farm.get("xp", 0)) + xp_gain
|
||||
_update_farm(
|
||||
farm["uid"],
|
||||
{
|
||||
"coins": int(farm.get("coins", 0)) + coins_gain,
|
||||
"xp": new_xp,
|
||||
"level": economy.level_for_xp(new_xp),
|
||||
"total_harvests": int(farm.get("total_harvests", 0)) + harvested,
|
||||
},
|
||||
farm = credit_farm(
|
||||
farm,
|
||||
coins=coins_gain,
|
||||
xp=xp_gain,
|
||||
harvests=harvested,
|
||||
era_active=bool(active_era_name()),
|
||||
extra=extra or None,
|
||||
)
|
||||
advance_quests(owner_uid, "harvest", harvested)
|
||||
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
|
||||
|
||||
|
||||
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"])
|
||||
day = _today()
|
||||
ensure_quests(farm, day)
|
||||
row = _quests().find_one(farm_uid=farm["uid"], day=day, kind=kind)
|
||||
now = _now()
|
||||
if scope == "weekly":
|
||||
day = _iso_week(now)
|
||||
ensure_weekly_contract(farm, day)
|
||||
else:
|
||||
day = _today()
|
||||
ensure_quests(farm, day)
|
||||
row = _quests().find_one(farm_uid=farm["uid"], day=day, kind=kind, scope=scope)
|
||||
if not row:
|
||||
raise GameError("No such quest today.")
|
||||
if row.get("claimed"):
|
||||
@ -299,19 +403,23 @@ def claim_quest(user: dict, kind: str) -> dict:
|
||||
raise GameError("Quest not complete yet.")
|
||||
reward_coins = int(row.get("reward_coins") or 0)
|
||||
reward_xp = int(row.get("reward_xp") or 0)
|
||||
reward_stars = int(row.get("reward_stars") or 0)
|
||||
_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
|
||||
_update_farm(
|
||||
farm["uid"],
|
||||
{
|
||||
"coins": int(farm.get("coins", 0)) + reward_coins,
|
||||
"xp": new_xp,
|
||||
"level": economy.level_for_xp(new_xp),
|
||||
},
|
||||
)
|
||||
return {"kind": kind, "reward_coins": reward_coins, "reward_xp": reward_xp}
|
||||
extra = {"stars": _lvl(farm, "stars") + reward_stars} if reward_stars else None
|
||||
if scope == "weekly":
|
||||
extra = extra or {}
|
||||
extra["contract_boost_until"] = _iso(
|
||||
now + timedelta(hours=economy.WEEKLY_CONTRACT_BOOST_HOURS)
|
||||
)
|
||||
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:
|
||||
@ -322,13 +430,11 @@ def claim_daily(user: dict) -> dict:
|
||||
last = _parse_date(farm.get("last_daily_at") or "")
|
||||
streak = _lvl(farm, "streak") + 1 if last == now.date() - timedelta(days=1) else 1
|
||||
reward = economy.daily_reward(streak)
|
||||
_update_farm(
|
||||
farm["uid"],
|
||||
{
|
||||
"coins": int(farm.get("coins", 0)) + reward,
|
||||
"streak": streak,
|
||||
"last_daily_at": _iso(now),
|
||||
},
|
||||
credit_farm(
|
||||
farm,
|
||||
coins=reward,
|
||||
era_active=bool(active_era_name()),
|
||||
extra={"streak": streak, "last_daily_at": _iso(now)},
|
||||
)
|
||||
return {"reward": reward, "streak": streak}
|
||||
|
||||
@ -343,9 +449,17 @@ def upgrade_perk(user: dict, perk_key: str) -> dict:
|
||||
if level >= perk.max_level:
|
||||
raise GameError("That perk is maxed out.")
|
||||
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.")
|
||||
_update_farm(farm["uid"], {"coins": int(farm.get("coins", 0)) - cost, column: level + 1})
|
||||
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:
|
||||
raise GameError("That legacy upgrade is maxed out.")
|
||||
cost = economy.legacy_cost(upgrade, level)
|
||||
if _lvl(farm, "stars") < cost:
|
||||
raise GameError("Not enough stars for that legacy upgrade.")
|
||||
_update_farm(
|
||||
farm["uid"], {"stars": _lvl(farm, "stars") - cost, column: level + 1}
|
||||
rows = conditional_update_farm(
|
||||
farm["uid"],
|
||||
set_clause=f"stars = COALESCE(stars, 0) - :cost, {column} = :new_level",
|
||||
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}
|
||||
|
||||
|
||||
@ -374,10 +494,53 @@ def prestige(user: dict) -> dict:
|
||||
raise GameError(
|
||||
f"Reach level {economy.PRESTIGE_MIN_LEVEL} to refactor (prestige)."
|
||||
)
|
||||
new_prestige = _lvl(farm, "prestige") + 1
|
||||
stars_award = economy.stars_for_refactor(level, _lvl(farm, "prestige"))
|
||||
old_prestige = _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"))
|
||||
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()
|
||||
for plot in get_plots(farm["uid"]):
|
||||
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):
|
||||
if slot_index not in kept_slots:
|
||||
_create_plot(farm["uid"], user["uid"], slot_index, now)
|
||||
reset = {
|
||||
"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}
|
||||
return {"prestige": new_prestige, "stars_awarded": stars_award, "mastery_awarded": mastery_award}
|
||||
|
||||
|
||||
def fertilize(user: dict, slot: int) -> dict:
|
||||
@ -430,10 +581,18 @@ def fertilize(user: dict, slot: int) -> dict:
|
||||
if reduce_by < 1:
|
||||
raise GameError("That build is almost ready already.")
|
||||
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(
|
||||
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)
|
||||
if int(farm.get("coins", 0)) < cost:
|
||||
|
||||
@ -34,6 +34,12 @@ def _today() -> str:
|
||||
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):
|
||||
parsed = _parse(value)
|
||||
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:
|
||||
fields = {**fields, "uid": farm_uid, "updated_at": _iso(_now())}
|
||||
_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}
|
||||
|
||||
62
devplacepy/services/game/store/cosmetics.py
Normal file
62
devplacepy/services/game/store/cosmetics.py
Normal 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}
|
||||
86
devplacepy/services/game/store/defense.py
Normal file
86
devplacepy/services/game/store/defense.py
Normal 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,
|
||||
}
|
||||
148
devplacepy/services/game/store/era.py
Normal file
148
devplacepy/services/game/store/era.py
Normal 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
|
||||
@ -6,7 +6,7 @@ from devplacepy.cache import TTLCache
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
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)
|
||||
@ -74,11 +74,18 @@ def buy_plot(user: dict) -> dict:
|
||||
if plot_count >= economy.MAX_PLOTS:
|
||||
raise GameError("All plots are already unlocked.")
|
||||
cost = economy.plot_cost(plot_count)
|
||||
coins = int(farm.get("coins", 0))
|
||||
if coins < cost:
|
||||
rows = conditional_update_farm(
|
||||
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.")
|
||||
_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}
|
||||
|
||||
|
||||
@ -88,13 +95,17 @@ def upgrade_ci(user: dict) -> dict:
|
||||
next_tier = economy.next_ci_tier(ci_tier)
|
||||
if not next_tier:
|
||||
raise GameError("CI is already at the top tier.")
|
||||
coins = int(farm.get("coins", 0))
|
||||
if coins < next_tier.upgrade_cost:
|
||||
raise GameError("Not enough coins to upgrade CI.")
|
||||
_update_farm(
|
||||
rows = conditional_update_farm(
|
||||
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}
|
||||
|
||||
|
||||
@ -127,7 +138,151 @@ def leaderboard(limit: int = 25) -> list[dict]:
|
||||
"total_harvests": int(farm.get("total_harvests", 0)),
|
||||
"prestige": int(farm.get("prestige") or 0),
|
||||
"score": economy.farm_score(farm),
|
||||
"title": economy.cosmetic_title_name(farm.get("active_title") or ""),
|
||||
}
|
||||
)
|
||||
_leaderboard_cache.set(f"top:{limit}", 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
|
||||
|
||||
51
devplacepy/services/game/store/infrastructure.py
Normal file
51
devplacepy/services/game/store/infrastructure.py
Normal 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
|
||||
77
devplacepy/services/game/store/market.py
Normal file
77
devplacepy/services/game/store/market.py
Normal 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)
|
||||
34
devplacepy/services/game/store/mastery.py
Normal file
34
devplacepy/services/game/store/mastery.py
Normal 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}
|
||||
@ -5,12 +5,15 @@ from __future__ import annotations
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
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
|
||||
|
||||
|
||||
WEEKLY_SLOT_INDEX = 3
|
||||
|
||||
|
||||
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
|
||||
now = _iso(_now())
|
||||
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"],
|
||||
"user_uid": farm["user_uid"],
|
||||
"day": day,
|
||||
"scope": "daily",
|
||||
"slot_index": index,
|
||||
"kind": quest["kind"],
|
||||
"label": quest["label"],
|
||||
@ -27,6 +31,7 @@ def ensure_quests(farm: dict, day: str) -> None:
|
||||
"progress": 0,
|
||||
"reward_coins": quest["reward_coins"],
|
||||
"reward_xp": quest["reward_xp"],
|
||||
"reward_stars": 0,
|
||||
"claimed": 0,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
@ -34,6 +39,35 @@ def ensure_quests(farm: dict, day: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def advance_quests(user_uid: str, kind: str, amount: int) -> None:
|
||||
if amount <= 0:
|
||||
return
|
||||
@ -41,15 +75,22 @@ def advance_quests(user_uid: str, kind: str, amount: int) -> None:
|
||||
farm = get_farm(user_uid)
|
||||
if not farm:
|
||||
return
|
||||
now = _now()
|
||||
day = _today()
|
||||
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"):
|
||||
continue
|
||||
goal = int(row.get("goal") or 0)
|
||||
progress = min(goal, int(row.get("progress") or 0) + amount)
|
||||
_quests().update(
|
||||
{"uid": row["uid"], "progress": progress, "updated_at": _iso(_now())},
|
||||
{"uid": row["uid"], "progress": progress, "updated_at": _iso(now)},
|
||||
["uid"],
|
||||
)
|
||||
except Exception:
|
||||
|
||||
@ -8,6 +8,7 @@ from datetime import datetime, timedelta
|
||||
from .. import economy
|
||||
from .common import (
|
||||
PERK_COLUMN,
|
||||
_iso_week,
|
||||
_lvl,
|
||||
_now,
|
||||
_parse,
|
||||
@ -15,7 +16,12 @@ from .common import (
|
||||
_quests,
|
||||
steal_cooldown_remaining,
|
||||
)
|
||||
from .cosmetics import owned_cosmetic_keys
|
||||
from .era import active_era_name
|
||||
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
|
||||
|
||||
|
||||
@ -69,22 +75,27 @@ def serialize_plot(
|
||||
)
|
||||
grace = economy.effective_steal_grace(defense_level)
|
||||
protected = bool(ready_at) and now < ready_at + timedelta(seconds=grace)
|
||||
immune = bool(crop and crop.steal_immune)
|
||||
eligible = (
|
||||
state == "ready"
|
||||
and not is_owner
|
||||
and bool(viewer_uid)
|
||||
and bool(crop)
|
||||
and ready_at is not None
|
||||
and not immune
|
||||
)
|
||||
can_steal = eligible and not protected and steal_locked_until <= 0
|
||||
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"
|
||||
elif eligible and steal_locked_until > 0:
|
||||
steal_reason = "cooldown"
|
||||
market_factor = market_factor_for(crop.key) if crop else 1.0
|
||||
steal_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
|
||||
else 0
|
||||
@ -96,7 +107,7 @@ def serialize_plot(
|
||||
)
|
||||
reduce_by = int(remaining * economy.FERTILIZE_FRACTION)
|
||||
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)
|
||||
return {
|
||||
@ -125,6 +136,7 @@ def serialize_farm(
|
||||
farm: dict, *, viewer: dict | None, owner: dict, now: datetime | None = None
|
||||
) -> dict:
|
||||
from .actions import _auto_harvest
|
||||
from .defense import charge_upkeep
|
||||
|
||||
now = now or _now()
|
||||
viewer_uid = viewer["uid"] if viewer else ""
|
||||
@ -132,6 +144,9 @@ def serialize_farm(
|
||||
is_owner = viewer_uid == owner_uid
|
||||
if is_owner and _lvl(farm, "legacy_autoharvest") > 0:
|
||||
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")
|
||||
growth_level = _lvl(farm, "perk_growth")
|
||||
discount_level = _lvl(farm, "perk_discount")
|
||||
@ -169,6 +184,15 @@ def serialize_farm(
|
||||
ci_entry = economy.CI_BY_TIER.get(ci_tier)
|
||||
streak = _lvl(farm, "streak")
|
||||
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 {
|
||||
"owner_username": owner.get("username", ""),
|
||||
"owner_uid": owner_uid,
|
||||
@ -205,6 +229,10 @@ def serialize_farm(
|
||||
prestige,
|
||||
legacy_mult_level,
|
||||
legacy_speed_level,
|
||||
market_factor_for(crop.key),
|
||||
mastery_earned,
|
||||
era_name,
|
||||
registry_boost,
|
||||
)
|
||||
for crop in economy.CROPS
|
||||
],
|
||||
@ -220,9 +248,99 @@ def serialize_farm(
|
||||
"stars": _lvl(farm, "stars"),
|
||||
"legacy": _serialize_legacy(farm) if is_owner else [],
|
||||
"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:
|
||||
last = _parse_date(farm.get("last_daily_at") or "")
|
||||
return last != now.date()
|
||||
@ -271,15 +389,24 @@ def _serialize_legacy(farm: dict) -> list[dict]:
|
||||
|
||||
|
||||
def _serialize_quests(user_uid: str, now: datetime) -> list[dict]:
|
||||
from .quests import ensure_weekly_contract
|
||||
|
||||
farm = get_farm(user_uid)
|
||||
if not farm:
|
||||
return []
|
||||
day = now.date().isoformat()
|
||||
ensure_quests(farm, day)
|
||||
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),
|
||||
)
|
||||
quests = []
|
||||
for row in rows:
|
||||
goal = int(row.get("goal") or 0)
|
||||
@ -295,6 +422,8 @@ def _serialize_quests(user_uid: str, now: datetime) -> list[dict]:
|
||||
"reward_xp": int(row.get("reward_xp") or 0),
|
||||
"claimed": claimed,
|
||||
"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
|
||||
|
||||
@ -263,6 +263,13 @@
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.game-lb-title {
|
||||
flex-shrink: 0;
|
||||
color: var(--accent);
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.game-lb-score {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
|
||||
@ -11,7 +11,9 @@ export class GameFarm {
|
||||
this.username = this.root.dataset.username || "";
|
||||
this.stateUrl = this.mode === "view" ? `/game/farm/${this.username}` : "/game/state";
|
||||
this.farm = null;
|
||||
this.board = "score";
|
||||
this._bindActions();
|
||||
this._bindLeaderboardBoard();
|
||||
this._startTicker();
|
||||
this.load();
|
||||
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) {
|
||||
const action = form.dataset.gameAction;
|
||||
const params = {};
|
||||
@ -68,8 +79,13 @@ export class GameFarm {
|
||||
this._setHost("[data-game-grid-host]", this._gridHtml(farm));
|
||||
if (this.mode === "own") {
|
||||
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-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-quest-host]", this._questsHtml(farm));
|
||||
}
|
||||
@ -94,6 +110,9 @@ export class GameFarm {
|
||||
set("[data-hud-prestige]", farm.prestige);
|
||||
set("[data-hud-prestige-mult]", `+${Math.round(farm.prestige_multiplier * 100 - 100)}% coins`);
|
||||
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]");
|
||||
if (fill) {
|
||||
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("");
|
||||
}
|
||||
|
||||
_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) {
|
||||
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>`
|
||||
@ -156,13 +239,14 @@ export class GameFarm {
|
||||
return quests
|
||||
.map((quest) => {
|
||||
const pct = quest.goal ? Math.floor((100 * quest.progress) / quest.goal) : 0;
|
||||
const reward = quest.reward_stars ? `+${quest.reward_stars} ★` : `+${quest.reward_coins}c`;
|
||||
let foot = "";
|
||||
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) {
|
||||
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("");
|
||||
}
|
||||
@ -186,7 +270,13 @@ export class GameFarm {
|
||||
.map((crop) => {
|
||||
const disabled = crop.locked || crop.cost > farm.coins ? " disabled" : "";
|
||||
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("");
|
||||
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]");
|
||||
if (!list) return;
|
||||
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) {
|
||||
list.innerHTML = `<li class="game-lb-empty">No farmers yet.</li>`;
|
||||
return;
|
||||
}
|
||||
const board = this.board || "score";
|
||||
list.innerHTML = data.entries
|
||||
.map(
|
||||
(entry) =>
|
||||
`<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>`
|
||||
)
|
||||
.map((entry) => {
|
||||
const title = entry.title ? `<span class="game-lb-title">${entry.title}</span>` : "";
|
||||
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("");
|
||||
} catch (error) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
14
devplacepy/templates/_game_analytics.html
Normal file
14
devplacepy/templates/_game_analytics.html
Normal 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 %}
|
||||
26
devplacepy/templates/_game_cosmetics.html
Normal file
26
devplacepy/templates/_game_cosmetics.html
Normal 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 %}
|
||||
13
devplacepy/templates/_game_defense.html
Normal file
13
devplacepy/templates/_game_defense.html
Normal 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>
|
||||
@ -7,7 +7,7 @@
|
||||
<input type="hidden" name="slot" value="{{ plot.slot }}">
|
||||
<select name="crop" class="plot-crop-select" aria-label="Choose a project to build">
|
||||
{% 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 %}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-sm btn-primary">Plant</button>
|
||||
|
||||
17
devplacepy/templates/_game_infra.html
Normal file
17
devplacepy/templates/_game_infra.html
Normal 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 %}
|
||||
16
devplacepy/templates/_game_mastery.html
Normal file
16
devplacepy/templates/_game_mastery.html
Normal 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 %}
|
||||
@ -1,12 +1,13 @@
|
||||
{% for quest in farm.quests %}
|
||||
<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 }} ★{% 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-foot">
|
||||
<span class="quest-progress">{{ quest.progress }}/{{ quest.goal }}</span>
|
||||
{% if quest.can_claim %}
|
||||
<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>
|
||||
{% elif quest.claimed %}
|
||||
|
||||
@ -32,6 +32,9 @@
|
||||
<a href="/admin/bots" class="sidebar-link {% if admin_section == 'bots' %}active{% endif %}">
|
||||
<span class="sidebar-icon">🤖</span> Bot Monitor
|
||||
</a>
|
||||
<a href="/admin/game" class="sidebar-link {% if admin_section == 'game' %}active{% endif %}">
|
||||
<span class="sidebar-icon">🚜</span> Code Farm
|
||||
</a>
|
||||
<a href="/admin/ai-usage" class="sidebar-link {% if admin_section == 'ai-usage' %}active{% endif %}">
|
||||
<span class="sidebar-icon">📊</span> AI usage
|
||||
</a>
|
||||
|
||||
31
devplacepy/templates/admin_game.html
Normal file
31
devplacepy/templates/admin_game.html
Normal 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 %}
|
||||
@ -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 |
|
||||
| λ Compiler | 500 | 1h | 1380 | 150 | 6 |
|
||||
| ⚙️ 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),
|
||||
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
|
||||
than from this table.
|
||||
the API already include your perks, refactor bonus, and the current Market Saturation factor, so
|
||||
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
|
||||
|
||||
@ -129,11 +136,56 @@ visitors, so popular farms fill up. Each plot reports `can_water`, `watered_coun
|
||||
## Raiding a ready build (competition)
|
||||
|
||||
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
|
||||
receives **half** the build's coin value and the owner loses the build entirely. The owner is told
|
||||
their farm was raided but the raider's identity is never revealed. 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.
|
||||
after a protection window (60 seconds by default, longer with Branch Protection or a Defense
|
||||
building) has passed since the build became ready. The raider receives a fraction of the build's
|
||||
coin value (half by default, less with the owner's defenses, floored so a raid never pays nothing)
|
||||
and the owner loses the build entirely - except a Security Fortress build, which can never be
|
||||
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)
|
||||
|
||||
@ -162,10 +214,10 @@ five:
|
||||
The live state reports your `stars` balance and each legacy upgrade's current level and the exact
|
||||
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
|
||||
lifetime harvests, level, and everything else:
|
||||
The default leaderboard ranks the top farms by a composite score. Refactors dominate the formula,
|
||||
then lifetime harvests, level, and everything else:
|
||||
|
||||
```
|
||||
score = xp
|
||||
@ -178,7 +230,19 @@ score = xp
|
||||
+ 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
|
||||
|
||||
@ -199,7 +263,7 @@ your **API key** in an `X-API-KEY` header (or `Authorization: Bearer`). Your key
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `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. |
|
||||
| `POST` | `/game/plant` | Plant `crop` 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/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. |
|
||||
| `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/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}/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
|
||||
integer plot index), `crop`, `perk`, `quest`, and `key` (a legacy upgrade) 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.
|
||||
integer plot index), `crop`, `perk`, `quest`, `scope`, and `key` (a legacy/Mastery/Infrastructure/cosmetic
|
||||
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
|
||||
|
||||
|
||||
@ -156,6 +156,10 @@ See [Code Farm](/docs/code-farm.html) for the game itself.
|
||||
| 💧 | Good Neighbor | Water a neighbour's build (`water`, 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) |
|
||||
| 📦 | 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
|
||||
|
||||
@ -210,6 +214,10 @@ first-use badge):
|
||||
| `water` | count | 1 Good Neighbor |
|
||||
| `harvest_stolen` | count | 1 Cat Burglar |
|
||||
| `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
|
||||
|
||||
|
||||
@ -37,8 +37,22 @@
|
||||
<span class="hud-value" data-hud-stars>{{ farm.stars }}</span>
|
||||
<span class="hud-sub">spend on Legacy</span>
|
||||
</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>
|
||||
|
||||
{% 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-main">
|
||||
<h2 class="game-section-title">Your plots</h2>
|
||||
@ -49,6 +63,8 @@
|
||||
<section class="game-shop">
|
||||
<h2 class="game-section-title">Infrastructure</h2>
|
||||
<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 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>
|
||||
<div class="perk-grid" data-legacy-host>{% include "_game_legacy.html" %}</div>
|
||||
</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>
|
||||
|
||||
<aside class="game-side">
|
||||
@ -70,12 +101,21 @@
|
||||
</section>
|
||||
|
||||
<section class="game-quests">
|
||||
<h2 class="game-section-title">Daily quests</h2>
|
||||
<h2 class="game-section-title">Quests & contracts</h2>
|
||||
<ul class="quest-list" data-quest-host>{% include "_game_quests.html" %}</ul>
|
||||
</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>
|
||||
<li class="game-lb-empty">Loading.</li>
|
||||
</ol>
|
||||
|
||||
@ -75,6 +75,10 @@ BADGE_CATALOG = {
|
||||
"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"},
|
||||
"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 = [
|
||||
|
||||
@ -191,6 +191,10 @@ ACHIEVEMENTS = {
|
||||
"water": [(1, "Good Neighbor")],
|
||||
"harvest_stolen": [(1, "Cat Burglar")],
|
||||
"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"}
|
||||
|
||||
589
fplan.md
Normal file
589
fplan.md
Normal 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.
|
||||
@ -36,6 +36,7 @@ def _reset_farm(username, coins=100000):
|
||||
get_table("game_plots").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_market_ticks").delete()
|
||||
farm = store.ensure_farm(user["uid"])
|
||||
get_table("game_farms").update({"uid": farm["uid"], "coins": coins}, ["uid"])
|
||||
return user
|
||||
|
||||
@ -37,6 +37,7 @@ def _reset_farm(username, coins=100000):
|
||||
if farm:
|
||||
get_table("game_plots").delete(farm_uid=farm["uid"])
|
||||
get_table("game_farms").delete(uid=farm["uid"])
|
||||
get_table("game_market_ticks").delete()
|
||||
farm = store.ensure_farm(user["uid"])
|
||||
get_table("game_farms").update({"uid": farm["uid"], "coins": coins}, ["uid"])
|
||||
refresh_snapshot()
|
||||
@ -189,6 +190,130 @@ def _ripen_owner_plot(owner, slot=0):
|
||||
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):
|
||||
owner_session, owner = _signup()
|
||||
_reset_farm(owner, coins=100000)
|
||||
|
||||
64
tests/e2e/admin/game.py
Normal file
64
tests/e2e/admin/game.py
Normal 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()
|
||||
@ -18,6 +18,9 @@ def reset_farm(username, coins=100000):
|
||||
get_table("game_plots").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_cosmetics").delete(user_uid=uid)
|
||||
get_table("game_market_ticks").delete()
|
||||
clear_game_caches()
|
||||
farm = store.ensure_farm(uid)
|
||||
get_table("game_farms").update({"uid": farm["uid"], "coins": coins}, ["uid"])
|
||||
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"])
|
||||
|
||||
|
||||
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):
|
||||
page.goto(f"{BASE_URL}/game", wait_until="domcontentloaded")
|
||||
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')")
|
||||
|
||||
|
||||
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):
|
||||
page, _ = mobile_page
|
||||
reset_farm("alice_test")
|
||||
|
||||
@ -225,3 +225,177 @@ def test_is_golden_deterministic_and_bounded():
|
||||
def test_is_golden_requires_both_args():
|
||||
assert economy.is_golden("", "2026-06-20T00:00:00+00:00") 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
|
||||
|
||||
@ -25,6 +25,15 @@ def _user(username):
|
||||
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):
|
||||
user = _user(username)
|
||||
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_steals").delete(thief_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"])
|
||||
get_table("game_farms").update({"uid": farm["uid"], "coins": coins}, ["uid"])
|
||||
return user
|
||||
@ -644,3 +656,293 @@ def test_leaderboard_entry_exposes_score_and_prestige(local_db):
|
||||
assert entry["prestige"] == 3
|
||||
assert entry["score"] == economy.farm_score(farm)
|
||||
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")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user