forked from retoor/devplacepy
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:
@@ -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")
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user