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:
2026-07-22 23:55:46 +02:00
co-authored by Claude Sonnet 5
parent 024edb5291
commit 34f76aad65
54 changed files with 3886 additions and 164 deletions
+50
View File
@@ -55,3 +55,53 @@ The infinite progression for maxed farms. Six new default-0 `game_farms` columns
**Auto-harvest** is lazy and tick-free: `store._auto_harvest(farm, owner_uid, now)` runs at the top of `serialize_farm` ONLY when the viewer is the owner AND `legacy_autoharvest > 0`; it **clears each ready plot first then credits** the pre-clear crop's coins/xp/`total_harvests` in one `_update_farm`, advances quests, and re-reads the farm - clear-then-credit is idempotent (a second read sees empty plots), and it never fires on a visitor's `GET /game/farm/{username}` view or the leaderboard, honouring the no-background-service invariant (both `GET /game` and `GET /game/state` go through `state_payload` with viewer=owner, so both auto-collect).
**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`).
+321 -16
View File
@@ -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,
+246 -87
View File
@@ -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:
+48
View File
@@ -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}
+86
View File
@@ -0,0 +1,86 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from datetime import datetime, timedelta
from .. import economy
from .common import GameError, _iso, _lvl, _parse, conditional_update_farm
from .farm import ensure_farm, get_farm
def upgrade_defense(user: dict) -> dict:
farm = ensure_farm(user["uid"])
level = _lvl(farm, "defense_level")
next_tier = economy.next_defense_tier(level)
if not next_tier:
raise GameError("Defense is already at the top tier.")
rows = conditional_update_farm(
farm["uid"],
set_clause="coins = coins - :cost, defense_level = :new_level",
where_clause="COALESCE(defense_level, 0) = :current_level AND coins >= :cost",
params={"cost": next_tier.upgrade_cost, "new_level": next_tier.level, "current_level": level},
)
if rows == 0:
farm = get_farm(user["uid"])
current_level = _lvl(farm, "defense_level")
if current_level != level:
raise GameError("Defense already changed - refresh and try again.")
raise GameError("Not enough coins to upgrade defense.")
return {"defense_level": next_tier.level, "spent": next_tier.upgrade_cost}
def charge_upkeep(farm: dict, now: datetime) -> dict:
level = _lvl(farm, "defense_level")
if level <= 0:
return farm
last_raw = farm.get("defense_last_upkeep_at") or ""
last = _parse(last_raw)
if not last:
rows = conditional_update_farm(
farm["uid"],
set_clause="defense_last_upkeep_at = :ts",
where_clause="(defense_last_upkeep_at IS NULL OR defense_last_upkeep_at = '') AND COALESCE(defense_level, 0) = :level",
params={"ts": _iso(now), "level": level},
)
if rows:
return {**farm, "defense_last_upkeep_at": _iso(now)}
return get_farm(farm["user_uid"]) or farm
elapsed_days = int((now - last).total_seconds() // 86400)
if elapsed_days < 1:
return farm
days_due = min(elapsed_days, economy.UPKEEP_GRACE_DAYS)
tier = economy.defense_tier(level)
coins = int(farm.get("coins", 0))
total_due = economy.daily_upkeep(tier, coins) * days_due
new_timestamp = _iso(last + timedelta(days=elapsed_days))
if coins >= total_due:
rows = conditional_update_farm(
farm["uid"],
set_clause="coins = coins - :total_due, defense_last_upkeep_at = :new_ts",
where_clause="defense_last_upkeep_at = :expected_last AND COALESCE(defense_level, 0) = :expected_level AND coins >= :total_due",
params={
"total_due": total_due,
"new_ts": new_timestamp,
"expected_last": last_raw,
"expected_level": level,
},
)
if rows == 0:
return get_farm(farm["user_uid"]) or farm
return {**farm, "coins": coins - total_due, "defense_last_upkeep_at": new_timestamp}
new_level = max(0, level - 1)
rows = conditional_update_farm(
farm["uid"],
set_clause="coins = 0, defense_level = :new_level, defense_last_upkeep_at = :new_ts",
where_clause="defense_last_upkeep_at = :expected_last AND COALESCE(defense_level, 0) = :expected_level",
params={"new_level": new_level, "new_ts": new_timestamp, "expected_last": last_raw, "expected_level": level},
)
if rows == 0:
return get_farm(farm["user_uid"]) or farm
return {
**farm,
"coins": 0,
"defense_level": new_level,
"defense_last_upkeep_at": new_timestamp,
}
+148
View File
@@ -0,0 +1,148 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from devplacepy.database import get_table, get_users_by_uids
from devplacepy.utils import generate_uid
from .. import economy
from .common import GameError, _farms, _iso, _lvl, _now, _update_farm
def _eras():
return get_table("game_eras")
def _era_results():
return get_table("game_era_results")
def active_era() -> dict | None:
return _eras().find_one(active=1)
def active_era_name() -> str | None:
era = active_era()
return era["name"] if era else None
def start_era(name: str, duration_days: int) -> dict:
if active_era():
raise GameError("An Era is already running.")
now = _now()
ends_at = now.replace(microsecond=0)
from datetime import timedelta
ends_at = ends_at + timedelta(days=max(1, duration_days))
last = list(_eras().find(order_by=["-era_number"]))
era_number = (int(last[0]["era_number"]) + 1) if last else 1
row = {
"uid": generate_uid(),
"era_number": era_number,
"name": name,
"started_at": _iso(now),
"ends_at": _iso(ends_at),
"active": 1,
"created_at": _iso(now),
}
_eras().insert(row)
now_iso = _iso(now)
for farm in _farms().find():
_update_farm(
farm["uid"],
{"era_coins": 0, "era_harvests": 0, "era_joined_at": now_iso},
)
return row
def end_era() -> dict:
era = active_era()
if not era:
raise GameError("No Era is currently running.")
farms = [f for f in _farms().find() if _lvl(f, "era_coins") or _lvl(f, "era_harvests")]
ranked = sorted(
farms,
key=lambda f: economy.era_score(
_lvl(f, "era_coins"), _lvl(f, "era_harvests"), _lvl(f, "prestige")
),
reverse=True,
)
now = _iso(_now())
from .cosmetics import _cosmetics
era_cosmetics = [c for c in economy.COSMETICS if c.era_key == era["name"]]
for rank, farm in enumerate(ranked, start=1):
stars_award = economy.era_reward_stars(rank)
reward_cosmetic_key = ""
if stars_award and era_cosmetics:
reward_cosmetic_key = era_cosmetics[(rank - 1) % len(era_cosmetics)].key
if not _cosmetics().find_one(
user_uid=farm["user_uid"], cosmetic_key=reward_cosmetic_key
):
_cosmetics().insert(
{
"uid": generate_uid(),
"user_uid": farm["user_uid"],
"cosmetic_key": reward_cosmetic_key,
"purchased_at": now,
"created_at": now,
}
)
reset_fields = {"era_coins": 0, "era_harvests": 0}
if stars_award:
reset_fields["stars"] = _lvl(farm, "stars") + stars_award
_update_farm(farm["uid"], reset_fields)
_era_results().insert(
{
"uid": generate_uid(),
"era_number": era["era_number"],
"user_uid": farm["user_uid"],
"rank": rank,
"era_score": economy.era_score(
_lvl(farm, "era_coins"), _lvl(farm, "era_harvests"), _lvl(farm, "prestige")
),
"era_coins_final": _lvl(farm, "era_coins"),
"reward_stars": stars_award,
"reward_cosmetic_key": reward_cosmetic_key,
"created_at": now,
}
)
_eras().update({"uid": era["uid"], "active": 0}, ["uid"])
return {"era_number": era["era_number"], "participants": len(ranked)}
def leaderboard_era(limit: int = 25) -> list[dict]:
era = active_era()
if not era:
return []
farms = sorted(
_farms().find(),
key=lambda f: economy.era_score(
_lvl(f, "era_coins"), _lvl(f, "era_harvests"), _lvl(f, "prestige")
),
reverse=True,
)[:limit]
if not farms:
return []
users = get_users_by_uids([f["user_uid"] for f in farms])
entries = []
for rank, farm in enumerate(farms, start=1):
owner = users.get(farm["user_uid"])
if not owner:
continue
entries.append(
{
"rank": rank,
"username": owner.get("username", ""),
"level": int(farm.get("level", 1)),
"xp": int(farm.get("xp", 0)),
"coins": int(farm.get("coins", 0)),
"total_harvests": int(farm.get("total_harvests", 0)),
"prestige": int(farm.get("prestige") or 0),
"score": economy.era_score(
_lvl(farm, "era_coins"), _lvl(farm, "era_harvests"), _lvl(farm, "prestige")
),
"title": economy.cosmetic_title_name(farm.get("active_title") or ""),
}
)
return entries
+164 -9
View File
@@ -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
+77
View File
@@ -0,0 +1,77 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from datetime import datetime, timedelta
from devplacepy.cache import TTLCache
from devplacepy.database import db, get_table
from devplacepy.utils import generate_uid
from .. import economy
from .common import _iso
_saturation_cache = TTLCache(ttl=30, max_size=32)
def _ticks():
return get_table("game_market_ticks")
def _hour_bucket(now: datetime) -> str:
return now.strftime("%Y-%m-%dT%H")
def record_harvest_tick(crop_key: str, now: datetime) -> None:
with db:
db.query(
"INSERT INTO game_market_ticks (uid, crop_key, hour_bucket, harvests, updated_at) "
"VALUES (:uid, :crop_key, :hour_bucket, 1, :now) "
"ON CONFLICT(crop_key, hour_bucket) DO UPDATE SET "
"harvests = harvests + 1, updated_at = :now",
uid=generate_uid(),
crop_key=crop_key,
hour_bucket=_hour_bucket(now),
now=_iso(now),
)
_saturation_cache.clear()
def recent_harvests(crop_key: str, window_hours: int) -> int:
cache_key = f"{crop_key}:{window_hours}"
cached = _saturation_cache.get(cache_key)
if cached is not None:
return cached
from .common import _now
cutoff = _hour_bucket(_now() - timedelta(hours=window_hours))
row = db.query(
"SELECT COALESCE(SUM(harvests), 0) AS total FROM game_market_ticks "
"WHERE crop_key = :crop_key AND hour_bucket >= :cutoff",
crop_key=crop_key,
cutoff=cutoff,
)
total = 0
for result in row:
total = int(result["total"] or 0)
break
_saturation_cache.set(cache_key, total)
return total
def market_factor_for(crop_key: str) -> float:
recent = recent_harvests(crop_key, economy.MARKET_WINDOW_HOURS)
saturation = economy.market_saturation_factor(recent)
return saturation * economy.market_buff_factor(crop_key, saturation)
def prune_ticks(older_than_hours: int = 96) -> int:
from .common import _now
cutoff = _hour_bucket(_now() - timedelta(hours=older_than_hours))
table = _ticks()
stale = [row["uid"] for row in table.find() if row.get("hour_bucket", "") < cutoff]
for uid in stale:
table.delete(uid=uid)
return len(stale)
+34
View File
@@ -0,0 +1,34 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from .. import economy
from .common import GameError, _lvl, conditional_update_farm
from .farm import ensure_farm, get_farm
MASTERY_COLUMN = {m.key: f"mastery_{m.key}" for m in economy.MASTERY_UPGRADES}
def upgrade_mastery(user: dict, key: str) -> dict:
upgrade = economy.mastery_for(key)
if not upgrade:
raise GameError("Unknown Mastery upgrade.")
farm = ensure_farm(user["uid"])
column = MASTERY_COLUMN[upgrade.key]
level = _lvl(farm, column)
if level >= upgrade.max_level:
raise GameError("That Mastery upgrade is maxed out.")
cost = economy.mastery_cost(upgrade, level)
rows = conditional_update_farm(
farm["uid"],
set_clause=f"mastery_points = COALESCE(mastery_points, 0) - :cost, {column} = :new_level",
where_clause=f"COALESCE({column}, 0) = :current_level AND COALESCE(mastery_points, 0) >= :cost",
params={"cost": cost, "new_level": level + 1, "current_level": level},
)
if rows == 0:
farm = get_farm(user["uid"])
if _lvl(farm, column) != level:
raise GameError("That Mastery upgrade already changed - refresh and try again.")
raise GameError("Not enough Mastery points for that upgrade.")
return {"key": upgrade.key, "level": level + 1, "spent": cost}
+45 -4
View File
@@ -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:
+133 -4
View File
@@ -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