How the client and the backend talk, what each side is allowed to decide, and what every call costs.
Read this before changing anything in src/worker.js or any code path that makes a network call.
The stack is Cloudflare Workers plus SQLite-backed Durable Objects. The older
Save, Economy & Anti-Cheat page describes the retired Unity/UGS design and is kept for its
doctrine only — its named services no longer apply.
| Rule | Meaning |
|---|---|
| Clients send events and requests, never state | The save is a prediction cache. A server reconciliation overwrites it. Editing it should be inert. |
| Nothing happens offline | Offline play is banned outright. No queue, no outbox, no banking, no replay of work done while disconnected. On loss of connection autoplay pauses, the battle pauses, and a reconnecting window shows. Play resumes only when the connection returns. |
| Ownership is fenced by the server, not asserted by the client | Adopting the save mints an epoch; every later request carries it and the object refuses any instance holding a lower one. A check a client performs on itself is worth nothing once the client is the thing that is broken. |
| A dead writer is a dead channel | “Offline” means the writer, not the socket. A window that can no longer save takes the same path as a lost connection — play pauses — because /api/battle is a separate writer and keeps crediting a window whose save has stopped moving. The one difference: a write halt is terminal, cleared only by a reload that re-adopts the server copy. Never by a client-side merge. |
| The server derives rewards | The client reports what it did, never what it earned. XP, food and drops come from server tables, and the client applies what it is told. |
| Cost scales with battles, not with time connected | An idle account must write nothing. If a player is doing nothing there is nothing to save. |
Duration is billed in wall-clock time at the full 128 MB allocation whenever an object is active or
idle but unable to hibernate. An idle object that qualifies for hibernation is not billed at all.
Hibernation requires all of: no setTimeout/setInterval, no in-progress awaited
fetch(), no standard WebSocket API, and no request still being processed. It then hibernates after 10s idle.
Nothing inside a Durable Object class may ever use setTimeout, setInterval, a plain
WebSocket accept(), or an awaited fetch(). Any one of them makes the object permanently
non-hibernateable. With a request arriving every few seconds the object then never leaves the billed idle state.
| At 300k players × 24h | Duration cost |
|---|---|
| Hibernateable (current code) | ~$250 / month |
| One blocker introduced | ~$101,000 / month |
| Limit | Value | Consequence |
|---|---|---|
| Requests per single object | ~1,000/s soft limit | Past it the object queues, then returns an overloaded error to the caller |
blockConcurrencyWhile() | ~200 req/s at 5ms per call | Blocks all concurrency unconditionally. Reserve for initialisation and migrations, never regular request handling |
| SQLite per object | 10 GB | Writes fail with SQLITE_FULL |
Cloudflare's own guidance is “do not create a single global Durable Object that handles all requests” —
which is exactly what idFromName('main') is today. Their Queues team hit the same wall at ~400 msg/s and
only got a 10× speedup by sharding into consumer-shard objects. Shared objects must therefore be sharded too: a single
leaderboard object is the same mistake at a smaller scale.
Objects never call each other. The Worker orchestrates. A PlayerDO that awaits a
fetch() to another object blocks hibernation for the whole round trip and bills for the wait — it is the
same mistake as an open socket, wearing an internal address. When a battle needs both a player write and a leaderboard
submit, the Worker calls each in turn.
Scheduled work is a Worker scheduled() cron that calls into the object.
Never a DO alarm: setAlarm() is itself billed as a row written and keeps the object awake.
Also standing: no unqualified DELETE or SELECT in a constructor, and one-off migrations claim
their completion flag before doing the work.
Every route below is real and reachable today. The Caller column is the discipline that matters:
a route nothing calls is either a door for tooling or a door that is built and not yet wired, and the two are
not the same thing. Shell = play/index.html, Game = play/app.js.
Everything under /api/_internal/* is Worker→object plumbing and answers 403 from outside —
roughly thirty routes, deliberately not enumerated here because nothing outside src/worker.js may call one.
| Endpoint | Caller | Trigger | Writes |
|---|---|---|---|
/api/me | Shell | Connection check: 60 s heartbeat (skipped when another /api/ call answered ok inside the minute, never while paused), on online, on tab return, and instantly whenever any API call fails | none |
/api/save GET | Shell | Boot, once. Mints the ownership epoch — see the fence. The server copy wins unconditionally, not by timestamp | 1 row (the epoch) |
/api/save POST | Game | Debounced action flush, and forced boundaries | 1 coalesced row |
/api/battle | Game | Once per battle. Returns rewards and the replacement spawn | 1 coalesced row |
/api/floor/enter | Game | Floor entry. Returns the derived pool and accrued refill | 1 row (cursor) |
/api/grants/pending GET | Game | The drain. Asks what the server says it owes and takes delivery; the gid is acked inside the save, so a rollback re-owes the grant rather than losing it | none |
/api/name/claim | Game | Immediate, never debounced — cross-player uniqueness | 1 row |
/api/feedback POST | Game | In-game bug report. Logs capped at 30,000 — rejected, never truncated. Screenshots were removed 2026-08-05 (D order): a shot field is ignored, never stored | 1 row |
/api/logout | Shell | Sign-out. Exempt from the fence | 1 row |
| Endpoint | Caller | Trigger | Writes |
|---|---|---|---|
/api/shop/catalogue GET | Game | Shop open. Resolved from premium_shop.csv in the Worker; 503 shop_data_unavailable rather than an invented price if the pinned codex has no such file | none |
/api/shop/buy POST | Game | Purchase, keyed on a client idem. Returns the new ledger balance | ledger + entitlement |
/api/wallet/spend POST | Game | Inventory and soul slot blocks. The client names a kind, never a cost — the server resolves cap and STEP from inventory_slots.csv / soul_slots.csv, checks the ledger, debits and raises the cap in one transaction | ledger + slot_caps |
/api/entitlements/pending | — (nothing calls it) | Built for a player disputing a missing purchase. Live and correct; simply unwired | none |
| Endpoint | Caller | Trigger | State |
|---|---|---|---|
/api/roll POST | — | Upgrade attempts, soul grading, breeding economics | Built, probe-verified, not wired — the client still rolls these locally |
/api/hatch POST | — | Egg hatching outcome | Built, probe-verified, not wired |
/api/breed/claim POST | — | Breeding result | Built, probe-verified, not wired |
/api/transfer/pubkey GET · /api/transfer/dungeon POST · /api/transfer/sent GET | Shell + Game (lane 2 of T48; the Dungeon pins the pubkey) | Parked for a future expansion (D 2026-11-02): every door answers 404 transfer_parked unless the Worker var TRANSFER_OPEN is 1 (absent on both live workers; spec § 0). As built: creature transfer to Herumon Dungeon (T48, docs/creature-transfer-2026-11.md). pubkey is public and answers the Ed25519 public half of the Worker secret TRANSFER_SIGN_KEY (503 transfer_unconfigured without it). dungeon {uid}: the object judges its own items row (cat Monster, not gone - the uid is a hint), marks it sent/sent_src without spending it (a COPY), signs the v1 record (uid, species, line_id, stage, level, grade, mutant, sex, origin tower, acct hash, ts) over its canonical JSON and keeps it in transfers; a repeat answers 409 already_sent with the same record and signature, a foreign uid 404 transfer_unknown. sent lists the account's sent uids for the badge and the sheet line | Built, probe-verified (tools/probe_t48_transfer_srv.py 9/9, 2026-11-02); shell write and Dungeon import are the later lanes |
/api/evolve POST | evolveDoor (the advancement arrow) | A creature advances a stage: the uid, the form it leaves, the form it reaches. The worker stamps the step off creatures.csv; the object judges the stored record's form and level, writes adv_ledger (src door) and renames the items row. 403 evolve_short, 409 evolve_form, 404 evolve_unknown; idempotent on (uid, to) | Built, wired, probe-verified (T19 part 2, 2026-10-22) |
/api/battle returns a resolved drops array and the client grants exactly that,
having had its own gear, enhancement and egg rolls deleted rather than mirrored. Until the three
doors above are wired they are backend that protects nothing in practice — the exploit stays open on the client.The ladder is codex/unlocks.csv (see index.html, "System unlocks"), read through the pinned codex like every
authored table and judged on the account's best floor - the server's progress ratchet (PlayerDO.progressOf), never the
current floor. The Worker stamps the forwarded request (x-lock: system:floor, always reset first so a client-sent header never reaches
the object) and the player's object answers 403 {error:'locked', system, floor, maxf}; the world doors and the trader refuse in the
Worker, whose hop already carries the ratchet. A missing table fails open with one UNLOCKS_OPEN ops line.
| Endpoint | Gated form | Rung | Stays open |
|---|---|---|---|
/api/hatch POST | the batch form (eggs[], Hatch All) | hatch_all 8 | the single-egg form |
/api/dismantle POST | two or more rows (Dismantle All, multi-select) | dismantle_all 8 | one row |
/api/roll POST | soul_grade / enchant / grade on a non-Gear category / any grade with all (the ALL-IN ticket) | souls 12 / scrolls 30 / grading 40 / upgrade_all 40 | the Gear grading step (taught in the scripted opening), egg, sex, breed_rate |
/api/devour POST | every call | devour 12 | — |
/api/apvp/queue, /api/apvp/finish POST | the mint and the settle (Worker-side, the ratchet rides the myteam hop) | auto_pvp 15 | tickets, the defense log |
/api/unlock POST | kind pen / kind class | breeding 25 / classes 50 | — |
/api/shop/trader GET, /api/shop/trade POST | every call (Worker-side, the T5 floor read) | trader 40 | — |
/api/pvp/mm/queue POST | the queue (Worker-side, the ratchet rides the abyss/busy hop) | competitive_pvp 60 | poll / ready, so a queued ticket can finish |
/api/save POST | a soul equipped below 12, a pen in use below 25: flagged unlock_claim_bad (class system), the save is kept | souls 12 / breeding 25 | the save itself - detection only |
maxf on the boot GET /api/save (index.html sets window.__HT_MAXF
before app.js reads it), on /api/me, on /api/floor/enter and on the battle credit, so the client's padlock and the door's
refusal agree and an unlock shows the moment the floor is credited. Gate: tools/probe_t6b_unlock_doors.py.B1 (D 2026-09-09): "the guaranteed items exist so the lesson never waits on RNG". Drops are server-rolled (D 2026-07-31: hardcoded never, client
authority never), so a guaranteed drop is a server rule from a codex row. codex/opening.csv (see index.html, "The
opening") is read like unlocks.csv - the pinned ref, an hourly cache, fails open with one OPENING_OPEN ops line
per ten minutes (no table = no guaranteed drop, never a failed battle) - and rides the battle event into the object as _opening, because the
object never fetches. A row with a Selection is a guaranteed drop for the clear of its Floor: the object's only floor-clear signal is the progress ratchet,
so the drop is minted on the account's first credited non-boss victory with the ratchet at Floor+1 or past it, and only while the ratchet is still inside
the opening (the table's last Floor + 1) - a veteran never collects the kit. Each beat is claimed first in kv (opening:<realm>:<beat>),
once per account, then minted through the first-win row shapes (fixed / random_family / lowest_rarity_tie_random) plus class_weapon (the stored
save's charSoul names the class line; the Worker's resolveDrop picks a uniform weapon of that family, tier and slot), with a server
uid in items (origin drop), into the same drops array as every other drop tagged op:<beat>, recorded in
drops_granted with the event so a replay keeps it. Selection currency (the floor-2 materials beat, T42) is a wallet credit, not an item: owed on the account's first clear of
its Floor (the server's own clear record, kv clear:<realm>) or once the ratchet is past it, for exactly one first upgrade attempt of the
weapon_drop weapon - grading.csv's Gear row at that beat's Tier from its Grade, the price the roll door charges (today 1 Scrap) - booked
as wallet_ledger (source opening, ref opening:<realm>:<beat>:<cur>) and answered as opCredit for the client to adopt.
Any other unbuilt Selection is flagged opening_sel_unbuilt once and left unclaimed. Gate: tools/probe_t42_materials.py. Gate: tools/probe_t6d_opening.py.
| Endpoint | Caller | Trigger | Notes |
|---|---|---|---|
/api/auth/steam, /api/auth/steam/callback | Shell | Sign-in | Steam OpenID check_authentication round trip, then a signed session cookie. Rate-capped per IP before a session exists. Planned (T22, dfmc-client steam/INTEGRATIONS.md): POST /api/auth/steam/ticket, a Steam session ticket checked with AuthenticateUserTicket, minting the same session for the same steamid - the shell’s door, the web keeps this one |
/api/auth/discord, /google, /apple + /callback | Shell | Sign-in, or Link from Settings (?link=1) | Discord: OAuth code + PKCE S256, scope identify, identity = /users/@me read with our own token. Google: OIDC code + PKCE + nonce, id_token RS256 checked against Google’s JWKS (iss, aud, exp, nonce). Apple: form_post, ES256 client-secret JWT, id_token against Apple’s JWKS, no scope (only the stable sub). State, nonce and verifier are held server-side and taken once. A first sign-in makes a new account (19 digits led by 9 — never a Steam64); a linked identity opens the account it is linked to. No credentials → the button is hidden and the door answers 503 provider_unconfigured |
/api/auth/providers | Shell | Sign-in screen | Which provider doors this deployment has credentials for; Steam always |
/api/auth/dev, /api/auth/dev_identity | Harness | Local wrangler only | Dead code unless DEV_LOGIN=1, which deployed vars never set. dev_identity mints a verified-identity ticket without a provider, for the link/merge probes; OAUTH_MOCK_BASE (the probes’ stub provider) is honoured under the same lock only |
/api/account/resolve GET | Sign-in flow | provider+uid → account id, before a session exists | Ops-key gated. It was briefly unauthenticated and was an enumeration oracle — it told any caller whether a given Google/Apple id had an account. Resolution belongs inside a signed platform token exchange; do not re-open a public one |
/api/account/link, /api/account/identities | Game | Attach another platform login | Takes a ticket, never an identity. A provider callback in link mode leaves a server-side ticket (15 min, bound to the session that started the link); a client-sent provider_uid answers 400 verified_identity_required, a foreign ticket 403. Link answers 409 account_conflict carrying a summary of BOTH sides when the other login already has progress — including a legacy Steam account, which holds no index row |
/api/account/merge POST | Game | Choose which progress survives | Never auto-merges, and only between two verified identities — this session’s and the ticket’s, which the merge spends. Progress is deleted, currency is not — the loser's Wraithgold transfers as an audited merge_out/merge_in pair. A losing account holding paid-provenance units refuses outright |
/api/account/reset POST | Game | Player-initiated realm wipe | Advances the realm generation; since T61 (2026-11-02) it clears everything the account holds — the wallet ledger (all but a Wraithgold row with paid provenance), slot caps, entitlements, trades, saved builds, and on the World the name, badges, board rows, ratings, matches and season rows. Only the login (accounts / identity / sessions) and the account history (T39) stay. |
Provider setup (D; the secrets are not in the repo and never in wrangler.jsonc). Redirect URIs are
https://<host>/api/auth/<p>/callback for release and https://<host>/dev/api/auth/<p>/callback for dev
(<p> = discord, google, apple). Discord: Developer Portal → New Application → OAuth2 → add both redirects;
wrangler secret put DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET. Google: Cloud Console → APIs & Services →
OAuth consent screen (External, scope openid) → Credentials → OAuth client ID, type Web application, both redirects;
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET. Apple (paid Developer account): an App ID with Sign in with Apple, a
Services ID (its identifier is the client id) with the host as domain and both redirects as return URLs, and a Sign in
with Apple key (.p8); APPLE_CLIENT_ID (the Services ID), APPLE_TEAM_ID, APPLE_KEY_ID,
APPLE_PRIVATE_KEY (the .p8 contents). A provider with any of its secrets missing stays hidden. The dev allowlist
(DEV_ALLOWED_STEAMIDS) also bounds these logins on /dev: a linked identity of a listed account gets in, a new account is never made there.
| Endpoint | Caller | Trigger | Writes |
|---|---|---|---|
/api/pvp/finish | Game | Immediate — another player is waiting | shared object |
/api/db/* | Game | Identity-bound PvP key/value transport | shared object |
/api/lb/board/* GET | Game | Board view. Serves the daily lb_snap; see the caching note below — there is no edge cache | none (snapshot day roll excepted) |
/api/lb/me | Game | Own rank. /api/lb/submit is retired: it answers 410 (scores are server-derived since ANTICHEAT 2) and the client calls it nowhere | shared object |
| Endpoint | Lock | What it is for |
|---|---|---|
/api/backup/export | FEEDBACK_KEY | Default reads the R2 mirror (source:"r2-mirror", sweep-time copy, env-scoped); live=1 reads the player object's actual SQL (source:"live") — the only truth read. One diagnosis session was built on mirrors mistaken for live saves; every export now names its source |
/api/backup/history, /api/backup/facts | FEEDBACK_KEY | The version ring and the server-built ratchet/ledger. These are the diagnosis doors — the admin console's per-player routes read the WorldDO and answer empty |
/api/backup/drops | FEEDBACK_KEY | Read-only view of drops_granted, the server's own record of what it handed out per battle event. Never pruned since 2026-10-23 (T39: account history is kept forever); reports oldest_retained. There is deliberately no restore side |
/api/backup/restore, /api/backup/recover, /api/backup/grant | FEEDBACK_KEY | The one legitimate door for a rich save. Restore refuses an older payload over a newer live save with 409 unless force=1; since 2026-11-02 (T68) it also refuses a payload holding more souls or items than the server slot cap (409 over_cap, both counts listed) unless force=1, which stores as given and flags ops_restore_over_cap — see admin-backend |
/api/backup/reverse | FEEDBACK_KEY | SHUT — 501 refunds_not_wired. The machinery beneath it is correct and is what a store webhook will call, keyed on that store's transaction id. It previously trusted a caller-supplied amount, which is a delayed-fuse bug |
/api/backup/sweep, /api/lb/rank/sweep | FEEDBACK_KEY | Manual triggers for the two cron passes. Both return their result |
/api/feedback/export | FEEDBACK_KEY | Bug reports. The param is k, not key. POST with {ids:[..],status:"processed"} closes them |
/api/usage, /api/ledger/reconcile, /api/lb/purge, /api/lb/backfill | FEEDBACK_KEY | Spend dashboard, ledger reconciliation, destructive board maintenance |
/api/admin/killswitch | FEEDBACK_KEY (header) only — an admin session without the key is refused | The tiered kill-switch (see Kill-switch below). GET is the status: tier, auto tier, any forced tier, the signals and the thresholds in force. POST {op:"force", tier:0-3, minutes?, why?}, {op:"clear"}, {op:"config", cfg:{...}}, {op:"config_reset"}; every action is an audit row |
/api/admin/* | ADMIN_STEAMIDS + session | Traffic queue, budgets, flags, feedback triage, wipe. Known broken: the per-player routes (flags, save-history, rollback) are routed to the WorldDO while player data lives in the PlayerDO, so they are blind. Use the /api/backup/ doors instead |
/api/health, /__build | open | Uptime probe and this deployment's client-file fingerprint. /__build is per environment; the /api paths are shared |
Board caching, corrected 2026-07-31 — there is no edge cache and there never was.
A Worker on the route preempts the CDN cache, so the s-maxage=1800 this page used to claim was
decorative and the max-age beside it governed browsers, which serve fetch()
from cache without revalidating until expiry. A 30-minute browser hold is how D stared at a one-row board while the
origin was serving six. The header is now private, max-age=120. What actually protects the object is the
daily lb_snap snapshot, which was doing that job the whole time — the edge was never
part of it. Revisit the two minutes at player scale.
There is no periodic autosave. A player sitting in a menu costs nothing.
| Writer | Cadence | Notes |
|---|---|---|
/api/battle | One per battle | Irreducible — it is the game. Capped by the spawner at 60/hour/floor. |
| Action flush | 10 s debounce, reset per action; as built, autoplay's battle-triggered flush is lazy (5 min) and any manual action tightens it back to 10 s | A whole loadout rework becomes one call; an idle autoplay hour becomes ~12. |
| System | Why |
|---|---|
| Equip, unequip, swap | Rearranging owned items; only the resting state matters |
| Socket and unsocket gems, runes, souls | Intermediate states are noise |
| Inventory sort, party composition, pen assignment | Pure arrangement |
| Settings, autoplay config | No server arbitration |
| Dismantle | Own items, own scrap. A lost call leaves the item; the next flush heals it |
| System | Why |
|---|---|
| Purchases and entitlement acks | Real money gets zero-loss durability regardless of cadence cost. The ack lives in the save, never the ledger — a ledger-side ack survives a restore and silently loses the purchase |
| Name claim | Cross-player uniqueness needs arbitration |
| PvP actions | Another player is blocked on it |
| Boss key consumption | Gates zone progression; a lost call duplicates the key |
A debounce may only ever be pending while the player is fiddling in menus — never while the server is being asked to
judge something. Flush immediately on: battle entry, floor entry, boss entry, any purchase, pagehide,
and on reconnect. Otherwise the server validates a battle against a stale loadout.
hidden coalesce, the non-OK re-arm, and (in the next
section) the shell writing both run keys. The backend half is identical on both branches —
src/worker.js is byte-for-byte the same file on main and dev.There is no queue in the durable sense and there must never be one: the doctrine bans banking work done offline. What follows is a deadline and a dirty flag, both of which live in the page and die with it. Nothing is replayed from a previous session, and a re-arm always resends current state, never a recorded intent.
| Mechanism | As built |
|---|---|
| The deadline takes the minimum | queueFlush(ms) sets _srvFlushAt
to min(existing, now + ms) and marks _srvDirty. So a lazy 5-minute autoplay deadline is
tightened to 10 s by the next manual action rather than replaced by whichever call happened last. |
| The heartbeat fires it | maybeFlush() runs on the rAF ticker and compares
against Date.now() explicitly — the ticker passes performance.now(), a
different clock domain, and comparing the two never fires at all. |
| One write in flight at a time | A flush issued while another is unsettled is
chained behind it and re-runs once the first has landed and refreshed _srvVer. Without
this, two overlapping writes from the same session carry the same base_ver; before 2026-08-20 the second
was refused 409, and the client — correctly following its own rule — concluded another device owned the run
and rebooted onto a pre-ascend copy (reported live as “kicked down a floor when ascending”). Today an
epoch-bearing session's mismatch is accepted and flagged save_conflict; only an epoch-less session is
still refused. |
| Unload writes cannot chain | A pagehide or keepalive write cannot
wait on a promise the page will not live to settle, so it re-arms the dirty flag instead of queueing behind the
in-flight one. |
| Nothing to save is nothing to send | debounce, hidden and
pagehide all return early when _srvDirty is false. Before this, a session the player never
touched uploaded its whole state the moment a tab was backgrounded. |
hidden is coalesced, pagehide never is DEV |
visibilitychange→hidden fires on every screen-off, notification pull and app switch, which on a
phone is constant, and each one used to spend a token from the save bucket. Throttled to one flush per
HIDE_COALESCE_MS (15 s, matching the 429 retry_ms), with _srvDirty
deliberately left set when a flush is skipped so nothing is dropped and the heartbeat carries it.
Backgrounding is recoverable because the page survives; a close is the last chance there will ever be. |
| Re-arm rules, by answer DEV | 2xx — clean, adopt srv{}.
409 carrying data (a version conflict) — never re-arm; this window is stale and
resending is precisely the wrong move, so it reboots onto server truth through onSaveConflict.
409 {error:'stale'} with server_ts (the wall-clock ordering gate, #150/#151)
— heal, not reboot: window.__HT_TSFLOOR is raised to server_ts + 1, and the write
re-arms at 250 ms, up to 4 times; past four heals it falls into the fail ladder below.
Any other non-OK (429, data-less 409, 5xx) — re-arm and honour retry_ms when the
server gives one, else 10 s; the sixth failure in a row (SRV_FAIL_HALT) halts writes.
413 and 403 halt writes at once. Network throw — re-arm at
10 s and fire the connection check immediately. |
Why not sendBeacon | It cannot set custom headers, so it cannot carry
x-ht-epoch — and the fence treats a missing epoch against a non-null session epoch as a mismatch.
Exempting beacons would reopen exactly the bypass the fence exists to close. fetch(keepalive:true) can
set headers, which is why it is used. |
keepalive request
body is capped at 64 KiB (measured in Chromium 2026-08-03) while a save is allowed up to 409,600 bytes. The
client therefore gzips any body over 4 KB with CompressionStream (buffered, never streamed — a
keepalive stream body is refused) and sends it with x-ht-enc: gzip; a real ~131 KB save goes out at
~24 KB. keepalive is set only when the bytes on the wire are at most KEEPALIVE_MAX
(60,000); a larger body is sent as an ordinary fetch. Where CompressionStream is missing the body goes as
plain JSON.| Fact | Who decides |
|---|---|
| What spawned on a floor | Server — derived, see below |
| Whether a battle could be entered | Server — it holds the spawn cursor |
| XP, food, drops, class XP, creature XP | Server — derived from its own tables and returned to the client |
| Player decisions during a fight | Client — the only thing it genuinely owns |
| Fight simulation | Client, everywhere. Guarded by a power-plausibility gate — an unwinnable matchup is refused at the request, not flagged for later review — plus cross-player statistical anomaly detection |
Deterministic replay does account for player choices — the choices are the inputs, and for autoplay there are none to send because the heuristic is deterministic. It was rejected anyway, because it requires the battle engine to run server-side, which is the engine-extraction job already ruled out. Do not re-propose it as a cheap addition; it is that job wearing a different name.
What the power gate does not catch, accepted knowingly: a forged win in a fight that was plausibly winnable, and an
understated turns count (which feeds the rate cap). The exposure is bounded because the server already owns what
spawned, whether the battle could be entered, and what the rewards are — and the spawner caps a player at 60 battles an
hour. The most a forger converts is some losses into wins, at a rate they were already limited to, visibly out of line with
their power in the statistics.
| Element | Rule |
|---|---|
| Identity | HMAC(serverSecret, account + floor + DATA_PIN + spawnIndex). The secret never leaves the server, so there is no lookahead: a player cannot compute future spawns |
| Refill | Lazy on access: min(elapsed_minutes, 10 − current), overflow discarded, frozen at cap. Only spawns whose 60s has already elapsed are ever sent |
| Consistency | Index N always resolves to the same creature, so a spawn keeps its identity and roll until killed, and re-entry never rerolls |
| Stored | {lastAccessTs, liveIndices[], cursor, pin} per floor, ~50 bytes. The save stores nothing about pools |
| Delivery | Floor entry, and on the /api/battle response. Respawns cost no extra request |
| Data changes — adding creatures (D ruling) | Version per floor, not by DATA_PIN: a spawnTableVersion advances only when that floor's pool actually changes, so adding creatures to floor 40 never re-rolls floor 3. Nothing on a floor is ownable (creatures come from eggs and breeding; floor spawns cannot be captured), so re-deriving a changed floor costs a player nothing but a different opponent. A battle in flight is the only hazard: /api/battle carries the version it started under, and the server resolves under the old table |
| Structural changes | Anything changing the derivation function, the secret, or the table format takes a maintenance window — the flag exists in the worker (MAINTENANCE=1, bring-up is two edits per OPS). A window is required whenever the same data would be valid under two rulesets at once; routine content patches do not create that, data-shape changes do |
DATA_PIN: set once, never rotated casually.The server save is authoritative. If the server holds a save for a realm, that save wins on login, unconditionally. The local copy is used only when the server has nothing for that realm, which is a first login on a browser that already carries progress; that one copy is pushed up and becomes the server's.
| Server has… | Local has… | Result |
|---|---|---|
| a save | anything, any age | Server save adopted into localStorage. If the local copy carried later work, that work is discarded and the in-progress battle snapshot is dropped with it — a battle snapshot belongs to the save it was taken from. |
| nothing | a save at the current SAVE_EPOCH | Local kept and posted to the server. |
| nothing | nothing, or a pre-epoch save | Character creation. |
Why not the newest copy. Adoption used to pick whichever save carried the later
ts. That stamp records when a browser last wrote, not how far the save got, so a
browser left on old progress won every conflict simply by being opened last — and then posted its
old state back up. It also put the decision in the hands of the client clock, which is arbitrary: a
machine with a skewed clock would win every conflict forever, or lose every one. No timestamp comparison
between two clients can be trusted, so none is made.
Two server-side gates remain behind this and are unchanged: a write whose ts precedes the
stored one is rejected 409 stale, and a write whose playerXp is under half the
stored value is no longer refused (2026-10-01, xp409): it lands, the stored XP is kept by the grow-only
rule and handed back in srv, and xp_regress_halved is flagged. maxFloor is grow-only at the save door
(maxfloor_regress, below), and a floorNum that moves down in the same write as a regressed
maxFloor is held at the stored position (floornum_stale_writer, #114); the login rule above
keeps a stale client from proposing either in the first place.
A save is a proposal, not a fact. The client posts its state; the server decides what
the account becomes. Every rule below runs on every write, and none of them can move a
player backwards — that was the defect they exist to kill. The door order is: the size ceiling
(413), the floor and XP plausibility checks (400 implausible floor /
implausible xp), the version handshake, then the generation check, then the content rules.
Scrap, Ink, Dust and Essence are the server’s from the wipe (T19a, 2026-10-22). Every earn and every spend of the four materials is a wallet-ledger row before the client can show it, so the door compares the save’s wallet with the ledger: a claim above it (beyond a small grace) is clamped to the ledger and handed back in srv.wallet, which the client adopts; a claim below it is kept and catches up on the next save. A save built a moment before an upgrade, a class purchase or a trade landed is clamped silently; anything else is flagged wallet_over_ledger. Buying a class, trading with the Shadow Trader and every upgrade attempt are refused insufficient on the ledger, never on the number on screen. Ichor keeps its own ceiling and Wraithgold its entitlement ledger.
| Rule | What it does | Flag |
|---|---|---|
| Generation | A save from a generation older than the account's is answered
{wiped:true} and the client reboots fresh. Runs after the size ceiling, the plausibility checks and the
version handshake, and before any content rule — so a stale-generation save over 409,600 B is answered
413, not {wiped:true}. | — |
| Version handshake | If the client sends base_ver and it is not the
version the server holds: a session with an epoch has already proven ownership at the epoch
fence, so the write is accepted (every rule below still runs, the version comes from the server's
own counter) and flagged — save_retry when the client declares a retry and is exactly one step
behind, else save_conflict (same owner — accepted) (since 2026-08-20). The gap is not
bounded: an epoch session's write is stored whatever version it echoes; the flag is the only record of the shape. A session
without an epoch is refused 409 with the authoritative {ver, ts, data}; it
adopts that copy and reboots, and nothing of its own is stored. No longer optional (2026-08-02): optionality was the migration ramp, and an omitted
base_ver is an omitted check. Once a session has an epoch it is by definition running a build
that sends both, so from that point an absent base_ver against a save the server holds is answered with
the ordinary conflict payload. Pre-epoch sessions keep the old behaviour until they reload, and never
after. | save_conflict, save_retry, save_no_base_ver |
| Two size ceilings | The device-only fields are stripped, then what remains is measured:
over 409,600 B is refused 413 (and over 262,144 B is
flagged save_large). A second, looser guard refuses a serialised save
over 900,000 B after every other rule has run. Accepted writes feed a
time-spaced save_hist ring per realm, which is what
/api/backup/history reads: a save enters it only if it lands at least WR_HIST_MS
(60 s) after the last history row, and the ring keeps the 20 most recent rows — not the last 20
versions (write-cost step 9, 2026-08-06). | save_oversize |
| Genesis | An account with no server save may only create a newborn one. Rich state enters through the key-gated ops restore, never this door. | save_genesis |
| Floor ceiling | maxFloor and floorNum are capped at
max(ratchet + 1, what the account already held). The second term matters: when the ratchet
stalls, the cap can no longer confiscate floors the server itself previously stored. | maxfloor_over, floornum_over |
| Grow-only progress | maxFloor, playerXp and
classXp never decrease. A stale device, an old tab or a re-adopted local slot simply loses
the merge. Both level readouts derive from those two xp fields, which is why a stale write used to read
as "I lost twenty levels". | maxfloor_regress, xp_regress_merged |
| Grow-only earned state | Earned scalars (wildWins,
invCap, soulCap, pensUnlocked) keep the higher value; earned maps
(zoneWins, zoneKills, crXp, bossDown,
hatchLv) merge per key; cleared, codex and clsOwned
merge by union — a floor once cleared and a creature once seen are never un-seen. | grow_only, grow_only_map |
| Credible counters | Kill and win counters are capped by what the server actually
credited. Every accepted save stamps _ledK/_ledB — the ledger's kill and
battle counts at that moment — so the next save may only claim the delta the server
credited since, plus a small burst for battles in flight. A blob claiming two thousand kills against
sixty credited ones is contradicting the server's own record. | kills_over_ledger, wins_over_ledger |
| Ledger ceilings | playerXp and wallet.Ichor are measured
against a delta ceiling: the credited part of the previous stored value (the stored value
minus the server-written excess _ovX / _ovF, what the ledger never credited) plus what the ledger credited since the last
accepted save's stamp (_ledX / _ledF) plus grace (ledger_grace: 2,500 XP,
300 Ichor). A save with no stamp gets the flat form max(previous stored value, ledger + grace) once.
The Ichor ceiling also adds the verified dismantle allowance (and any unconsumed trade allowance). XP has no kept band (2026-10-01, xp409): any
playerXp claim over the ceiling is clamped to it (never below the value already stored) and reported in
srv; a claim under the credited total is lifted to it. An Ichor claim over
the ceiling but within the clamp band (food_clamp 5,000 Ichor) is
kept and flagged, and its excess is stamped, so the next save is not measured from it: the band
is one band in total, never one per save. Beyond the band it is forged-scale, clamped to the ceiling (never below
the value already stored) and reported back in srv. | xp_over_ledger (clamped), ichor_over_ledger (kept), xp_forged,
ichor_forged (clamped) |
| Class XP ceiling | classXp is hard-capped at class_max_xp
(18,464,128, the level-100 cumulative total; classxp_over_cap), then held to the same delta ceiling
against the ledger's class credit (_ledC, grace 2,500; excess _ovC, as above). Any claim over the
ceiling is clamped and reported back in srv, and a claim under the credited total is lifted to it
(shipped 2026-08-01; the cls_clamp kept band removed 2026-10-01, xp409). | classxp_over_cap, classxp_over_ledger (clamped),
classxp_forged (clamped) |
| Wraithgold is overwritten, not clamped | Changed 2026-08-02 — the clamp is
deleted. wgCeil and the wraithgold_over_grant flag no longer exist. A ceiling on a
client-written number stopped minting but had no idea what was spent, which is exactly why a displayed
balance could disagree with the ledger. The door now overwrites wallet.Wraithgold from
wgBalance() — SUM(delta) over the wallet ledger and nothing else — and reports
the correction as srv.wg. A client asserting 999,999 stores as its real balance. This was only safe once
all three sinks (item block, soul block, shop) were on the ledger; deleting it earlier would have silently
refunded every block anyone had bought. The account seed (WG_SEED 100, WG_ALPHA_GRANT is the
off switch) is a flat ledger entry posted once per account per realm, idempotent on
(source,ref) = (migration, seed:<realm>) — never read from the save. | wallet_drift |
| Slot caps are overwritten too | invCap and soulCap come from the
server-owned slot_caps row, seeded from the save exactly once (the save is the only
record of blocks already bought, so nobody loses any) and owned by the server after that. The client earns a cap only
through /api/wallet/spend, so whatever the blob claims is advisory. Note for live accounts: from
first touch the server's number wins, so a blob holding a cap the server never recorded is corrected
downward. The soul bag is counted against that cap (2026-10-01, soulcap): soul-category rows
in bag+bagAdd, minus hatched-hidden eggs, plus one class soul per owned class. A save may not
hold more souls than the server cap, or, on an account already over it, more than it already held; new souls past
that are stripped newest first. The breed-egg roll on a full bag mints nothing (409 soul_bag_full). | reported in srv{}
(soulStripped); soul_over_cap |
| One line per build | A repeated imgId across equipOv is flagged.
Visibility only, deliberately not a refusal — live saves already hold the violation and
rejecting would lock those accounts out of their own progress to punish them for a hole the client left open. The
client now refuses to seat a duplicate; this measures the back-population. Species-level only: a Stage 1 beside
its own Stage 3 needs Line_ID server-side, which the creatures loader does not keep
today. | party_line_dupe |
| Wallet rates | Two tiers. Crossing the soft line only reports; the hard line
clamps, and it sits above the worst legitimate income in the data. The hard burst is a token bucket:
the part of a balance no allowance explained is stamped (_ovW) and taken off the next save's base, so
the burst refills at wallet_caps_per_min instead of being re-earned by every save. Scrap, Dust, Essence
and Ink have no balance ceiling (no refusal on balance during BOOK); a claim beyond what the server booked plus the
soft burst is watch-only: wallet_over_ledger. The save's ts that clocks
this guard is bounded to the server's clock plus 2 minutes. | wallet_rate, wallet_rate_hard,
wallet_over_ledger (watch) |
Why the seed moved server-side (2026-08-02). It used to be minted by the
CLIENT: on load, an account with no wgSeed flag set its own wallet to WG_SEED and claimed the
flag in the same write. That is correct shape for a client-side migration and wrong for money. The ceiling above is
held = previous stored Wraithgold, so for any account that ALREADY had a save when the seed shipped
(2026-07-27) held was 0, the claimed 100 was clamped straight back to 0 and flagged
wraithgold_over_grant — and the client never retried, because it had already written
wgSeed:1. Permanent zero, reachable only by the OLDEST accounts, which is why it surfaced as a single
developer report against three healthy player accounts. The rule to take from it: a client-side one-off
cannot mint anything the server ratchets. If the server owns the ceiling, the server owns the grant.
An entitlement row was the other candidate shape and was rejected: nothing on the client applies pending entitlements yet, so the credit would have lifted the ceiling and never reached the wallet.
ONE WRITE IN FLIGHT AT A TIME. base_ver is optimistic concurrency:
a write lands only if the version the client echoes is still the one the server holds. It follows that two
overlapping writes from the SAME session are indistinguishable from a second device — both carry the same
base_ver, the first bumps the version, and the second is refused 409. The client then does exactly
what it is told to do with a 409: concludes it is the loser, stops saving, adopts the server copy and reloads.
That copy predates whatever the losing write was carrying, so the player is thrown backwards.
Reported live 2026-08-02 on a single device: “frequently getting this error and getting kicked down a floor
when trying to ascend a floor.” The ascend is precisely where it bites — preloadFloor() forces a
floor-entry flush, and the rAF heartbeat can fire the 10s debounce flush in the same breath.
Writes are now serialized: a flush issued while another is in flight is CHAINED behind it and re-runs once the first has
landed and refreshed the version, so it never sends a stale base_ver. Unload writes
(pagehide, keepalive) cannot wait for a promise the page will not live to settle, so they re-arm
the dirty flag instead of chaining.
Since 2026-08-20 the server no longer refuses the self-race. A base_ver mismatch from a
session that holds an epoch is accepted and flagged (save_conflict same owner — accepted, or
save_retry for a declared one-step retry): the epoch fence already proved no other window claimed the run.
Only an epoch-less session still gets the 409 and the reboot. Serializing writes stays, because it keeps the
flag quiet. An ops write to the save (restore, rollback, a migration, a repair) is therefore not left to
base_ver: since 2026-09-30 (F380) it rotates the session epoch like a new tab’s claim, and the open
window is fenced and reloads onto it.
The general point: a concurrency primitive that cannot tell your own second write from someone else’s turns every self-race into a false conflict — and a false conflict here is indistinguishable, to the player, from data loss.
The server says what it changed. An accepted write answers with
srv:{} naming every field the server overrode, and the client adopts those values. A clamp
that returns 200 OK and quietly eats progress is data loss with a success message on top;
that is exactly how a stalled ratchet cost two players sixteen and ten floors and hours of xp.
If-Match → 412), Unity Cloud Save (write locks), Nakama
(object versions), DynamoDB (conditional writes), Google Play Games (both conflicting snapshots returned,
resolved by highest progress). None of them resolve conflicts by client clock, and none silently reduce a
submitted value. Earned progress is a grow-only counter — it merges by max, so a late
arrival from a stale device can never subtract.Chance belongs to the server. Deterministic work belongs to the client. That split is the ruling (2026-07-31): drops, upgrade attempts, egg hatching and breeding results are told to the client and never reported by it; equipping, unequipping and dismantling are deterministic, so the client performs them and they are validated in the next exchange by the save-door rules above.
POST /api/roll — session-gated. The Worker resolves the authored row
from the codex CSVs (grading.csv, soul_grading.csv, breeding.csv;
pinned to DATA_PIN on release and to main on dev, module-cached) and hands it to
the player's object, exactly the way /api/floor/enter hands over its floor table — a
Durable Object may never await a fetch. The object draws with
HMAC-SHA256(SPAWN_SECRET, "roll|sid|realm|kind|idem"), the same keyed generator the spawn
table uses, records the outcome and answers it.
| Property | Mechanic | |
|---|---|---|
| Idempotent | The outcome is keyed on the client's idem string. A
retry, a reconnect or a double-tap replays the stored result: it can never re-roll and never
double-charge. This is what makes it safe to ask again after losing the answer. | |
| Unpredictable | The draw is keyed on the server secret. A client cannot compute it in advance and cannot reproduce it afterwards. | |
| Authored only | A from/to/tier step the
CSV does not contain is refused 400. No inventing a cheaper ladder. | |
| Rate ceiling | Counted in SQL, not memory — an in-memory throttle dies with the isolate. | |
| Attempt ordinal (ON DEV, not on release) |
The row above is true of a RETRY and was never true of a SECOND ATTEMPT. Because
idem is minted by the client fresh on every click, a reload before the spend reached the
server bought a different key and therefore a different draw. For the kinds where the server answering
is the cost — grade, soul_grade and, since 2026-08-14,
enchant — the draw key is server-derived instead:
srv|<kind>|<realm>|<iid>|<n>, where iid is the target
instance the client names and n is an ordinal the object holds. The
ordinal advances only once a save lands on a version higher than the one current when the roll was
answered — that is what "the spend settled" means, and it is the cheapest honest
evidence available because the client debits in the same tick it applies. Until then the same attempt
is replayed, flagged replay + unsettled. A client must never sell an
unsettled answer: it decides nothing and costs nothing, or it charges twice for one draw
(#135). egg and sex are deliberately excluded — both already derive
from stable server-side identities, so a fresh key cannot buy them a new draw. Additive:
a client sending no iid falls through to the old client-keyed behaviour, so one backend
serves release and dev at once. |
Answered today: grade and soul_grade (success, authored odds, cost, the
on-failure rule) and breed_rate (Ichor per minute, minutes per egg, Ichor per egg, dismantle
value). Now server-owned and WIRED: wild battle drops — /api/battle
returns a resolved drops array and the client grants exactly that, having had its own gear,
enhancement and egg rolls deleted. Server-owned but not yet wired: egg hatching results
and upgrade attempts — the doors exist and are probe-verified, but the client still rolls those
locally until their batches land. Since moved to the server as well: boss guaranteed loot and zone keys
(2026-08-08) and the first-win ladder (2026-09-28), which now ride the same drops array.
How a wired outcome reaches the screen. Deterministic rewards (XP, Ichor) are computed and shown the moment the battle ends, because nothing about them is chance. The chance-bearing part waits for the server: the client posts the battle, and the resolved drops are granted when the response lands, inside the victory bar’s own fill animation. Space is checked through the same bag guard as any other grant, so a full bag loses the drop rather than overflowing. If the player has already left the victory screen the items still land in the bag. Nothing is banked offline: a failed post grants nothing and forces the connection check, which pauses play.
The drain is off. Everything below describes how the lane worked until v0.34 and is kept as the record of why it was switched off, not as current behaviour. Drops arrive inline at the end of each battle; there is no catch-up on load. The lane itself disappears in the server-authority rework, when the server holds the bag and there is nothing to catch up on.
Two faults made it do more harm than good. The owed list was computed from the stored save’s grantAck, and that field was written to the save but never read back on load — so every page load began with an empty acknowledgement, the first save afterwards overwrote the stored history, and the server re-owed everything it had already handed over. Players came back to bags past their cap. And the drain filtered the offered items on having a name, which eggs do not carry: it found nothing to place, and acknowledged the grant anyway. Ninety-three eggs were destroyed that way and 1,357 more were queued to follow; 785 of those were formally revoked on 2026-08-05 and the remainder sit dormant.
The rule. pendingGrants(steamid, realm) = every row in
item_grants that is minted, not revoked, and whose gid is not listed in the
STORED save’s grantAck. That is the whole definition. There is no server-side
“delivered” column, deliberately: the ack lives in the save so that a restore to an earlier
point rolls the ack back with the items, and the grant becomes owed again rather than the item
vanishing. A server-side ack loses the item silently in exactly that case.
The order of a delivery. (1) The server mints a grant, one row per battle event,
keyed drop:<eventId>, holding raw descriptors — lane,
grade, tier and the draw seeds. (2) The Worker resolves those descriptors
against items.csv on the way out, because the Durable Object may never hold the item table.
(3) The client applies the items through invRoomFor, the same guard every other path that
hands the player an item uses, and appends the gid to grantAck. (4) The next
save carries that ack, and the grant stops being pending. A full bag pauses the drain and
reports how many items are still waiting; it never eats them.
grantAck must survive a page load, and until 2026-08-05 it did not.
loadRun restores a fixed list of keys from the stored save. grantAck was written
to the save and concatenated in two places but was not in that list, so every page load began
with an empty ack. The first save afterwards then overwrote the stored history with only what that
session had drained, the server was owed every forgotten grant again, and the drain handed the player
items they already had. Measured on a seeded account: 40 acks stored, client boots with 0, inventory
28 → 68 within thirty seconds. Live, on real accounts: 134 → 105 → 0
acks across one session, with the save shrinking 64,386 → 62,469 bytes and nothing else
changed; another account holding 1,048 grants against 0 acks with a bag at 258 of 160.
The ack has no floor, and that is the sharp edge. A save carrying fewer acks is
accepted over a stored save carrying more, and every gid dropped in that write is owed again on the next
drain. This is the same class of hole the rollback work closed for maxFloor,
playerXp and classXp by making them grow-only — and grantAck
is the one field where walking backwards mints items. Re-acking is idempotent and never produces
a duplicate grant, which is what makes recovery from a wipe safe.
Failure modes to keep in mind. A grant whose items all fail to resolve is still offered, with an empty item list. The ack list grows with every delivery and is not pruned against the grant lane’s own retention, so acks for long-expired grants stay in the save as dead weight — on a heavy account that is roughly 19 KB against a 184 KB save, under the 256 KiB ceiling but above the 128 KiB warn line. Nothing here accrues offline: a grant is only ever minted by a battle the player actually fought while connected. The grant rows themselves are also never pruned, so the cost of reading them used to grow with the account’s whole history; since 2026-09-17 the save door reads only the outstanding ones, which bounds that read by what is actually owed rather than by everything ever given.
| Out of the save | Where it lives instead |
|---|---|
| Battle turn logs | Client-local, never uploaded. The device adventure log they fed was retired 2026-10-22 (ADVLOG-OUT, D T31-2); the server keeps only the ~185-byte battle metadata row |
| Adventure log, chapters and events | Retired 2026-10-22 (ADVLOG-OUT, D T31-2): no player-side adventure log, no extraction doors. The player action record is server-side only (battle rows, ledgers) |
| Floor spawn pools | Derived, see above |
| Item and equipment display fields | Looked up from items.csv by id. The save keeps id, quantity and instance data only |
Measured against a real save: 655 KB → ~12 KB. Turn logs alone were 68% of it.
Local storage is a cache of server truth plus three things the server deliberately never sees. It is
not a queue, not an outbox and not a second source of progress. Every key below is namespaced by
environment and account, because /dev/ shares the origin playdfmc.com with
release — an unnamespaced key means one blob shared between two environments and between every account that
has signed in on that browser.
| Key | Holds | Notes |
|---|---|---|
herumon_run_v2:<env>:<sid>…:<sid>_hc |
The run save, per realm | This is the key the game reads. env is dev or rel;
sid is the steamid, or anon before /api/me answers |
herumon_run_rel_v1herumon_run_rel_v1_hc |
The same adopted save, written in parallel by the shell | LIVE on release since 2026-08-03 (0.26).
Legacy, and it must keep being written. It feeds the first-boot migration for a device that has
never played. The two formulas are duplicated in play/index.html and play/app.js and must
stay identical — tools/probe_runkey.py is the gate. See the root-cause section below for what
happens when they drift |
herumon_battle_v2:<env>:<sid> |
In-progress battle snapshot | Stamped {ep, gen} at persist. Resume drops any snapshot that is unstamped, from another
epoch or generation, parked before the save the session adopted (its wts older than
__HT_SAVETS, #122), or whose floor exceeds the loaded save's maxFloor (the
maxFloor+1 check only decides while the save has no maxFloor yet; the unit guard
ahead of it refuses anything above maxFloor). The unnamespaced
herumon_battle_v1 is deleted on sight — it once teleported a reborn account to floor 9 |
herumon_chain_standardherumon_chain_removed-mode |
Battle-event hash-chain head | Cleared on wipe-kick and sign-out. Server-side chains are steamid-keyed, so a foreign head is benign |
herumon_mode, herumon_pid, herumon_pvp_net,
herumon_loop_db |
Realm selection, PvP peer id, PvP transport setting, loopback PvP bus | Device preferences and the two-tab PvP harness. None of it is progress |
<key>_bak, <key>_preserved_<ts> |
Rescue copies | _bak is the previous local write. _preserved_ is a local save the shell refused to
upload into an empty account — preserved aside, never destroyed, installable through the key-locked ops
restore if it is genuinely that player's |
sessionStorage.ht_conflict |
Why the last reboot happened | Survives the reload, so the reason is still readable afterwards by a probe or by anyone reading the console |
advBattles, advChapters and floors are stripped by the client
and by the save door — turn logs and floor pools are device data, pools are derived and logs are prose.
advArc, the adventure-log archive, joined them on 2026-08-01 after it walked live accounts into the save
ceiling. On login the shell splices all four back onto the adopted server copy, gated on the local slot carrying the
same steamid and the same generation — an unstamped or foreign local loses its device log once and
nothing else. For the first three the server never holds a value, so the splice is uncontested; for
advArc the local copy wins outright, because a stored save written before the strip still carries an
archive and that copy is by definition the stale one.
POST /api/save strips the device-only fields, then measures what remains. Anything over
409,600 B (400 KiB) is refused 413 save too large and flagged
save_oversize; over 262,144 B is accepted but flagged save_large.
origin birth record) and an item ~177 B.
At the authored caps of 600 souls + 160 items a fully legitimate save projects to
~321,700 B. Any ceiling below that refuses saves the game's own caps permit, so every player who
fills their collection is locked out — which is exactly what happened twice. Do not lower this below the
projection without lowering the caps first.serverFlush returns false on a 413, and enterBattle awaits that flush
and refuses to proceed when it fails (the Stage 5 boundary, so the server never judges a fight against a stale
loadout). Floor entry (preloadFloor) and boss entry (enterBossRoom) fire the same flush without
awaiting it and refuse only while play is paused (see Stage 5 is a gate below). An oversize save therefore
presents to the player as "the game will not start fights", not as a save error.
FIXED 2026-08-08 — LIVE on release since 2026-08-08 (0.42). A refused save now PAUSES play and files an
automatic bug report. Two classes, because they are not the same failure:
terminal (413 too large, 403 refused) halts on the FIRST refusal — a
retry resends the same body, so it can never succeed; persistent (429, 5xx) is allowed
its retries and halts once the writer has failed SRV_FAIL_HALT = 6 times running, roughly a minute of a
dead write path. Counting attempts rather than trusting the status is what catches the case nobody predicted.
It calls haltWrites() and not __setPaused() deliberately: haltWrites latches
__HT_HALTED, which is what stops __connCheck un-pausing the window the instant
/api/me answers 200 — and during a write halt it always will, because the connection was never the
problem. The report fires BEFORE the halt, or the re-render costs it its log, and it uses its own
save bucket so it cannot eat the boot budget or file one per battle during an outage.| Field | Travels? | Why |
|---|---|---|
advBattles, advChapters, floors | No — stripped by serverSnap() on the client and by the save door | Turn logs and floor pools are device data. Pools are derived; logs are prose |
advArc (adventure-log archive) | No, since 2026-08-01 — same two strips | It grows forever by design (compress-not-discard) and is the field that walked live accounts into the ceiling |
| Everything else | Yes | Game state the server validates and stores |
Why this matters, measured on a live account. advArc was added on 2026-07-28 and was
never added to the strip list, so it rode every upload. On one player's save it was 68,560 B of
127,115 B — 54%, leaving 3,957 B of headroom. Once an account reaches the cap every
save is refused, and the client's serverFlush re-arms only on a network throw: a 413 returns
false silently, with no retry and no warning. The player keeps playing, /api/battle keeps
crediting the ledger, and the next login adopts the stale stored copy — because the server save is
authoritative on login. That is the rollback. One account went thirteen hours and 1,908 credited
kills between two accepted saves; three of five active accounts were at or against the cap, and every healthy
account was far below it.
The archive lives on the device only. It is spliced back into the adopted save by
syncSaves() on login, and for this one field local wins — unlike the three fields above it
still exists inside every save written before the strip, and that stored copy is by definition the stale one. A device
with no archive of its own keeps the server's last upload rather than nothing. Without the splice, every login would
blank the player's log. No migration is needed: the first post-fix save is under the cap, is accepted, and the stored
blob sheds its archive on that write.
Diagnosing a stalled account. The admin console's per-player routes read the WorldDO and answer
empty, so use the key-gated doors: /api/backup/history?sid=&realm= for the version ring (a version
number that has not advanced while play continued is the symptom) and /api/backup/facts?sid= for
the ratchet and ledger the server built from credited battles — the only honest record of what a rolled-back
player actually earned.
Known and not yet fixed: a refused save is still silent to the player — any non-OK, non-409 answer should warn and pause, since a save being refused is functionally offline. The archive also packs one event per block, so gzip+base64 inflates it (40,912 B of text stored as 68,560 B, against 7,856 B if packed as designed); now that it stays on the device this is a localStorage-quota problem rather than a save-ceiling one. And 275 of 405 archived battle rows carry a blank timestamp.
Retired 2026-10-22: the adventure log (device archive and the planned server rows alike) is gone (ADVLOG-OUT, D T31-2). The archive once rode the save; it then lived on the device only; now neither exists, and the player action record is what the server already holds from the battle path (power, result, loot).
The bag stays inside the save. On every sync the Worker checks each row against
items.csv and creatures.csv and removes anything that could never have existed, then
forwards a clean body to the object. It runs in the Worker and not the object for the same reason the drop tables
do: items.csv is 208 KB, a save fires every 10–60 s, and handing that table to a
Durable Object per request is the cost trap the whole drop design was shaped to avoid.
| Rejected | Test |
|---|---|
| Unauthored base | the name is not in items.csv |
| Wrong category | the row's category contradicts the authored one |
| Off-ladder grade | not one of the ten authored grades |
| Wrong tier | only for bases that HAVE a tier — see below |
| Invented species | a monster naming nothing in creatures.csv |
Quantity is clamped, not rejected (nothing stacks, D 2026-08-01). Any row whose
qty is not 1 — above or below — is kept with qty set to 1, and the change is recorded
as qty:N (nothing stacks).
Eggs have grades, not tiers. items.csv authors them with a blank Tier
and the client writes tier: 0. A validator that compared the two would call every egg in the game a
mismatch — 60 across the live accounts, 54 on one of them. Untiered bases skip the tier test entirely.
Legality is not provenance. The checks above ask whether an item could exist, which needs no history. Whether a legal item was ever actually granted is a different question, and the server's grant record only begins the day it shipped — so every item every live account holds predates it. Unrecognised items are therefore FLAGGED, never removed, until granted items are born carrying a server-issued id. The server keeps one row per item so it can answer that question later; the first sync adopts what an account already holds, once.
Nothing is stripped silently: every removal is flagged and counted back to the client in srv{}, and
the validator fails open — if the codex cannot be read the save still lands untouched.
| Object | Holds |
|---|---|
PlayerDO — idFromName(steamid) | saves, save_hist, ledger, chains, floor cursors, adventure-log rows, entitlements |
WorldDO — shared | lb, lb_snap, names (uniqueness), anomalies and the traffic-review queue, game-wide rate counters, matches, pidowner, ratings |
Two separate classes, not two names on one class. It keeps the per-player object small and lets the build
guard check each explicitly. Shard WorldDO in turn if leaderboards get hot — a single global leaderboard object
is the same mistake at a smaller scale, and it is the one Cloudflare's Queues team hit at ~400 msg/s.
A single global object caps at ~1,200 players on save size alone, against a 10 GB per-object SQLite limit. Per-player never needs rebalancing; a fixed shard pool does, every time it grows.
Superseded by measurement (2026-07-29): the numbers below were the design estimate; the implemented handlers were then counted statement-by-statement. As shipped (with the battle response carrying the pool, the 60 s heartbeat, and the lazy autoplay flush): ~142 requests and ~336 rows written per player-hour of sustained autoplay. At 300k players × literal 24/7 that is ≈ $88k/month (rows written carry 82% of it); at a realistic 8 h/day, ≈ $29k/month. At alpha scale everything sits inside included tiers: $5/month flat to roughly 2–3k sustained players. The two backlog levers above are worth a further ~$27k/month at full scale. Nothing offline means autoplay needs a live connected tab — literal 24/7 for all players is a planning ceiling, not a forecast.
Rates, verified against Cloudflare's pricing docs 2026-07-29: DO requests $0.15/M after 1M; duration $12.50/M GB-s after
400k; rows written $1.00/M after 50M; rows read $0.001/M after 25B; SQLite storage 5 GB included then $0.20/GB-month.
R2 $0.015/GB-month, $4.50/M Class A. Fine print that bites: setAlarm() is billed as a row written, deletes
count as rows written, the KV-style get/put/delete/list methods are billed as row operations, and every write
stores nominal metadata that counts toward billable storage.
| Line | 300k × 24/7 | 300k, 30% × 8h |
|---|---|---|
| Rows written | $13,990 | $1,354 |
| Worker requests | $4,860 | $483 |
| DO requests | $2,430 | $243 |
| Worker CPU | $324 | $32 |
| DO duration | $250 | $20 |
| Storage (3.6 GB) | $0 | $0 |
| R2 backups | $45 | $45 |
| Total | ~$21,900 | ~$2,180 |
Rows written dominates at every scale, and it is bought down by cadence, not by save size — a row is a row whether it holds 12 KB or 655 KB. Storage size matters for the 10 GB ceiling and for bandwidth, not for this line.
Each step depends on the one before it. The whole set lands in the wipe window, because that is the only time step 2 is free — afterwards it means moving live saves between objects while players are connected.
| # | Step | Why here |
|---|---|---|
| 0 | Build guard + budget alert | Ships first, before the object is touched. It is the only thing standing between a slip and the six-figure duration bill, and it must exist while the rewrite is happening, not after |
| 1 | Validate the session in the Worker | Today nothing knows who is calling until the request is already inside the object. Routing by account is impossible without it, and it is also the abuse fix — unauthenticated traffic gets a 401 from the Worker instead of costing a DO request |
| 2 | Route by account — idFromName(steamid) | Objects are created lazily on first request, so there is nothing to provision. This is the ceiling being lifted; everything else is optimisation |
| 3 | Split WorldDO out | Cross-player state has nowhere to live once players are islands |
| 4 | Save format — logs to rows, pools derived, items by id | Changes what lives in the object, so it follows the object move. 655 KB → ~12 KB |
| 5 | Write path — delete the periodic save, add the debounced flush | Depends on the format. This is the cost fix: rows written is ~95% of the bill |
| 6 | Backup sweep — Worker cron, fan out across accounts | BUILT 2026-07-29 — see “As built”: world account registry, cursored pages, five-slot daily ring, day gate, budget-asked, manual trigger |
| 7 | Rate caps, traffic-review queue, then leaderboards back on | Everything above must be stable first |
If time runs short, cut 7 and the mirror's version retention. Steps 0–5 move together; half of them shipped is worse than none, because the format and the routing each invalidate the old backup path.
All seven stages are implemented, probed and deployed on the dev environment. Branch dev;
every stage carries a full-stack probe under tools/ (real Worker, real objects, real client, real battles).
What follows is where the implementation refined the contract above — read the contract for the why, this for the how.
| Area | As implemented |
|---|---|
| Sessions | v2.<steamid>.<exp>.<nonce>.<HMAC-SHA256>, minted only by the Steam
callback after a server-side check_authentication round-trip; verified in the Worker before any object wakes.
One active session per account. Revocation stays a row in the owner's PlayerDO. |
| Routing | PLAYER.idFromName(steamid) per player; WorldDO at idFromName('world')
for boards, names, PvP KV, feedback, admin, usage, and the cross-player account registry. World trusts x-sid set only by the
Worker after verification. The old 'main' object is bound but fully unrouted. |
| Spawns in play | As specified — plus one refinement: the battle response carries the floor's
refreshed pool (derived from state already loaded, zero extra reads), so the client never makes a separate post-battle
/api/floor/enter. That endpoint serves boot, travel and refill-due only. The event carries its pool slot; the primary
kill's identity is dictated by the derivation, extras are clamped, dead slots 409. |
| Write path | Two writers, as ruled — with autoplay refined: a battle response arms a lazy 5-minute flush (the battle event is the authoritative reward record; the save copy may lag). Any manual action tightens the deadline back to 10 s; every forced boundary (battle entry, floor travel, boss entry, hidden/pagehide with keepalive, reconnect, signout) still flushes instantly. The connection poll is a 60 s heartbeat; any failed request checks instantly, so pause latency is one round-trip. |
| Save diet | Measured on a real save: 670,388 B local → 20,875 B stored (32×). Battle logs and adventure history never leave the device; the shell splices local-only fields back on adoption so logs survive login. 400 KiB post-strip ceiling → 413 + flag (raised from 128 KB via 256 KiB; see changelog). |
| Rate caps | Token buckets in Worker isolate memory, keyed on the verified steamid, checked before any
object wakes. Doubled 2026-08-06 after ordinary play locked a live account out (see the changelog entry):
battle 24 burst / 4 per min, floor 30/8, save 20/12,
save_read 60/60, me 60/60, db 80/40, other 40/24, wallet 24/40;
battle is set from the spawn budget, not from “parity”. tables.json
spawn_budget accrues one credit per 60,000 ms, cap 10, and incense halves
per_ms — so an incense player legitimately accrues 2/min and the old 1/min cap could not
be reached without breaching it. 4/min is 2× the fastest legitimate accrual. The bucket is a COST limit,
not an economy one: the spawn budget is server-side and authoritative and binds earnings whatever this number is.
Consequence to hold in mind — a wider /api/battle surface makes the Phase 2b kill-claim
cross-check more important, not less;
name 3/hr and auth 10/10 are UNCHANGED — they are per-IP abuse limits, not play limits,
and no real player has breached either;
auth caps per IP before a session exists; a blanket ip_all 600 burst / 360 per min ceilings one address across
everything (raised from 240/120 after the 2026-08-08 CGNAT incident: one carrier-grade NAT pools a whole evening
crowd into one counter). It runs after the session gate — verifySession, the soft-block
check and the per-account bucket all run first, so a request later refused by ip_all has already
spent its account token. Three more classes route out of other:
roll 300/240 (/api/roll, /api/hatch, /api/breed/claim — the
chance doors, split out 2026-08-08), mm 60/45 (/api/pvp/mm/, the matchmaking poller) and
pvpm 120/90 (/api/db/m/). Over cap = 429 plus a breach report to the world's traffic table
(throttled 60 s per sid|class) — never a silent drop.
Reads are a separate class, and they are exempt (2026-08-02). rateClass takes the
HTTP method, so GET /api/save spends save_read tokens rather than write tokens. Membership of
RATE_READ_ONLY means two things at the gate: never soft-blocked, and never
escalated to the traffic queue.
/api/me joined the set on 2026-08-06, and the reason is the class, not the block.
other is a junk drawer: it held /api/me — the one endpoint the shell uses to learn
whether it is signed in — beside /api/hatch, /api/roll, /api/breed/claim
and /api/account/reset, so tripping the bucket on a hatching spree took the login door with it and a
player holding a valid 90-day cookie was shown a Steam gate. /api/me measures 4 rows read and ZERO
written, mints nothing and mutates nothing, so it satisfies the rule this set is governed by on its own terms. It
still spends a token and still answers 429 when the bucket is dry: what was removed is the LOCKOUT, not the limit,
and the soft block is unchanged in force on every endpoint that mints. Reading your own save grants nothing, so it cannot be an abuse vector —
and because the shell retries that read on failure, the escalation was being fed by the retry loop itself.
Nothing may be added to this set that can mutate state or mint a value. |
| Traffic review | TRAFFIC_BLOCK_N breaches in 10 min (50 in the deployed wrangler.jsonc vars since the
roll-bucket change; the code default of 3 applies only where the var is unset) soft-block that sid for that endpoint class only —
play elsewhere continues, nothing automatic ever bans. The Worker checks a soft-block cache (SB_TTL_MS, 60 s) before any object wakes.
Admin queue (ADMIN_STEAMIDS, session-checked): GET /api/admin/traffic, POST /api/admin/traffic/clear
(audited with the clearing admin's sid), POST /api/admin/traffic/block.
Blocks now expire (2026-08-02), and this is the incident that forced it. The three numbers were a trap: save is cap 10 / 6 per min, reportBreach fires at most once a minute, and
three breaches inside ten minutes blocks the class — so roughly three minutes of sustained 429s permanently
disabled an account's save endpoint, which ordinary autoplay reaches. Nothing in the codebase ever cleared a
block, and the flag's “queued for review” described a queue that did not exist. Worse, a soft block is per
(account, class): with save blocked, /api/me kept answering 200 (class other)
while GET /api/save answered 403 forever, so the shell fell back to the sign-in gate and told the player to do
the one thing that could not help. It was reported as “player can't log in” and cost an alpha account seven
hours; two more accounts had been blocked for three days and nobody knew. Blocks now age out after
SB_EXPIRE_MS (code default 1 h; 0 restores permanent). Set to 5 min in
wrangler.jsonc on 2026-08-06, because an hour of total lockout was never proportionate to three
429s that ordinary play produces; a genuine abuser is simply re-blocked by the next ten-minute window. The row survives with
blocked=0, so /api/admin/traffic history and the audit table are untouched and a real abuser is
re-blocked by the next window. |
| Budgets | Ceilings on everything the code itself can spend: BUDGET kinds (r2_write 800k/mo,
rank_write 2M/mo), counted in the world's usage ledger. A wmeta override per kind beats the constant —
adjustable from the console: GET /api/admin/budget, POST /api/admin/budget/set (0 pauses the kind; every change
audited by direct insert, bypassing the flag throttle). Refusal pauses the caller for the month with a budget_exhausted flag.
What budgets cannot cap: raw Worker-request billing under a flood — that is the WAF rate rule at the edge, plus
MAINTENANCE=1 as the universal breaker. Durable Object row writes are watched (not capped) by the
write breaker — see Write accounting. |
| Kill-switch | Steps the game down as spend climbs, from a signal or an operator, never from a code edit or a deploy. The World object holds a
tier 0-3. It moves on its own from signals the World already measures: the highest BUDGET used/cap and the number of
soft-blocked accounts. The thresholds sit in src/killswitch.json, and POST /api/admin/killswitch {op:"config"} overrides any of them at run time.
A tier is reached when any of its signals meets its threshold. It steps back down only once every signal is under threshold × recover_ratio (0.9).
Default thresholds: tier 1 at 75% of a budget or 10 blocked accounts, tier 2 at 90% or 25, tier 3 never on its own. An operator can force any tier, with an optional expiry, and clear it.
Tier 1: an audit row, and the non-essential doors (/api/lb/*, /api/me/history, /api/feedback, /api/name/*) are slowed: 6 a minute per account, then 429 ks_slow.
Tier 2: the non-play doors are also read-only (feedback, name claims, account doors, the board maintenance passes: a write answers 503 read_only), and diagnostic flag rows are shed.
Saves, battles, floors, rolls, grants, the wallet and PvP are never touched at tiers 1-2.
Tier 3: the maintenance notice, the same page MAINTENANCE=1 shows, for everything except health, the ops doors, the budget console and this door.
Each isolate re-reads the tier every 30 s, which costs one World read and no writes unless the tier moves. /api/usage carries the tier.
What the player sees (2026-10-23): a 429 ks_slow or 503 read_only answer shows one small line at the top of the screen (“Some features are slowed / read-only for a moment”) that never blocks a tap; the next normal answer from the same door, or any normal answer once the answer’s retry_ms (30 s at least) has passed, clears it. Nothing is retried. A 503 maintenance reloads the page once, so the maintenance notice above shows; a second one within 5 minutes keeps a line on screen instead of reloading again. |
| Backup sweep | Worker cron, every minute, never a DO alarm. waccounts in the world (written on every
login) is the only cross-player census; wmeta holds the cursor; 250 accounts a page (~750 subrequests; 300k ≈ 20 h a cycle).
Mirrors to R2 as {env}saves/{sid}/{realm}/slotN.json — env-prefixed since 2026-07-30: the dev deployment writes under dev/, release stays unprefixed (existing-data compat), because both deployments share ONE bucket and unprefixed dev sweeps were rotating over release backups. The deployment learns which environment it is from ENV_NAME (deploy-time var, authoritative) or from its own live traffic (per-deployment stable), and the sweep skips the minute rather than guess when cold. N = utc_day % 5 — five generations always exist, the
oldest is overwritten, zero list or delete calls. Day gate: one full cycle per UTC day — without it a small account list
wraps instantly and the cron re-mirrors unchanged saves forever (measured ~$390/mo of waste at 1,000 accounts). The sweep asks the
r2_write budget before any put. Tooling, each route carrying its own key lock: /api/backup/export (newest of
the five slots via heads), /api/backup/list, POST /api/backup/sweep (manual, returns its result). |
| Exact ranks | Built — see the Open table below for the full design (keep-all, daily keyset rank pass,
/api/lb/me, pending_cut). Manual trigger POST /api/lb/rank/sweep, double-locked. |
| Environments | The request's own path decides the environment: /dev/* is dev (the zone route's
contract), everything else is production. Dev reads MAINTENANCE_DEV, production reads MAINTENANCE — a
dev→main fast-forward can never open production by accident. The shell installs a fetch/sendBeacon shim before any script runs:
under /dev/, every same-origin /api/ call rewrites to /dev/api/ (nothing else ever prefixed API calls
— before the environments diverged, all /dev/ API testing silently ran against production). The Steam redirect's
return_to carries /dev home. Dev login allowlist: DEV_ALLOWED_STEAMIDS admits only listed
accounts, checked after Steam proves the identity (403 CLOSED ALPHA page); empty = open; production never consults it.
When any environment goes down, restore "run_worker_first": true so the notice page beats the static asset layer —
and remove it again on bring-up. |
| Test-only vars | DEV_LOGIN=1 (mints sessions without Steam, local wrangler only) and
RATE_OFF=1 (bypasses buckets for harnesses — born of the harness being 429'd at login #11, the blanket doing its job).
Neither is ever set in deployed vars. |
| Ops lock rule | Recorded after a live near-miss: every spend-capable or data-bearing tooling route carries its own lock, never a lock inherited from a gate another change can route around. (The maintenance exception once routed backup paths around the session gate that held the key check — a wrong key ran the sweep until sealed.) |
| Item | Assessment |
|---|---|
| 4a — merge the chain head into the event row | Low risk, ~$13k/mo at 300k×24/7. Swaps the
chains lookup for a latest-event-by-chain read (needs an index) and changes the events prune to
“expired, except each chain's newest row” or heads vanish and chains falsely reset. Operates inside the
anti-forgery core — wants a settled playtest before and after. Backlogged 2026-07-29 (D). |
| 4b — defer the ledger write | Refused for now. Holds earned rewards in DO memory, which dies with the isolate; grace is xp 500 / food 300, and a batch window of a few battles can exceed it at higher floors — an eviction would clamp a legitimate player's save: silent progress loss, the exact failure the durability rule forbids. The escape hatches cost what they save (a safety alarm bills a row per battle; wider grace widens cheat headroom identically). Worth ~$14k/mo at 300k×24/7 and $0 at alpha. Revisit at 50k+ real players with telemetry and a grace audit. Backlogged 2026-07-29 (D). |
Where each decision on this page has been done before, so it is not re-litigated from scratch.
| Decision | Precedent |
|---|---|
| Rewards server-derived, combat client-side | Firestone (Holyday Studios): 99% server-side, main battle client-side because the calculation volume does not justify moving it. Residual handled by statistical flagging |
| Replays as inputs, not recordings — noted, not adopted | Clash of Clans recreates a match from recorded taps rather than storing video. DFMC keeps turn logs client-side instead, because it does not run the engine server-side and so cannot recreate anything |
| Server-verified leaderboard runs — considered, rejected | AntGame.io controls the seed, takes the client's run, and re-simulates it, so no submission can be forged. Rejected here on cost: it needs the engine server-side |
| HMAC commit-reveal so the client cannot look ahead | The provably-fair construction: publish the hash of a secret server seed first, compute outcomes as HMAC(server_seed, client_seed:nonce), reveal the seed only afterwards. Works only while the seed stays secret until after resolution |
| Never expose the seed to the client | Diablo II: Resurrected — a deterministic PRNG with client-visible state was fully reverse-engineered into known-seed farming routes with guaranteed drops |
| No periodic autosave; event-driven writes | Nakama's own guidance: avoid background jobs in favour of an event-driven route so work is only performed for players actually present |
| Offline progression banned outright | Offline income is normally a delta-time calculation against the device clock, and is therefore trivially exploited — Melvor Idle and Territory Idle both have documented clock-skip exploits. Games that keep it either make time server-authoritative or convert offline time into a capped resource spent while online |
| Economic caps rather than anti-automation | Idle players broadly treat auto-clickers as legitimate optimisation, so the defence is the value ceiling per real second — here, the 60 spawns/hour spawner — not client-side automation detection |
| Staying custom rather than moving to a BaaS | Platforms solve auth, IAP receipt validation, leaderboards and sharded storage; they never solve game-rule validation or anti-cheat. PlayFab cut its free tier in March 2026 from 100,000 to 1,000 lifetime players per title, with the replacement tier limited to studios shipping on Xbox — the lock-in risk in practice |
| Per-player actor isolation | Microsoft Orleans has run Halo backend services since 2011. The model is proven; a published cost postmortem for a live-service game specifically on Durable Objects does not yet exist, so expect undocumented operational edges |
| Item | State |
|---|---|
| Backup sweep | RESOLVED — it has been scheduled since 2026-07-29, and this row was stale. wrangler.jsonc carries crons: ["* * * * *"], the Worker's scheduled() runs backupSweep then rankSweep, and mirrors exist in R2 for every live account. The real defect, found by checking rather than by repeating the claim, was narrower and is also fixed (2026-08-02): envPrefix() read a module global set inside fetch(), and a scheduled() invocation can run in an isolate that has never served a request — it returned null and the sweep bailed with paused:'env-unknown'. That was the right call (writing into the other environment's keys would be worse) but it meant the sweep could sit out indefinitely on a quiet worker. Measured on live: the newest mirror for an active account was two days old while every forced sweep, which runs on a request-served isolate, worked perfectly. The environment is now stamped durably in the world object the first time each isolate serves a request; ENV_NAME still wins if set, and a world that has genuinely never seen traffic still pauses rather than guessing. Original finding, kept for the record: the bucket existed and the binding was live with nothing scheduling the sweep |
| Mirror version retention | SOLVED 2026-07-29 by construction: the five-slot daily ring (slot = utc_day % 5) always holds five generations; the oldest is overwritten, nothing is ever listed or deleted |
| Save trust | CLOSED 2026-07-29 (D ruling: state never creates progress). An account with no server save may only create a newborn one (genesis caps on maxFloor, floorNum, classXp, wallet, bagAdd, invCap, crXp; violation = 403 + flag). maxFloor now lives in a server-owned ratchet advanced solely by credited battle events: floor entry gates on the ratchet and every save's claim is clamped to ratchet+1 — the old circle (the gate reading a number the client wrote) is broken. Accounts holding a server save before the rule grandfather once. The shell never uploads a local into an empty account (it preserves it aside); the one legitimate door for a rich save is the key-locked POST /api/backup/restore, which installs verbatim, seeds the ratchet, and audits. Original finding, kept for the record: /api/save clamps only playerXp and food. classXp, creature XP, items, equipment, wallet, maxFloor and invCap are accepted verbatim; floorNum was range-checked 1–100 only until 2026-07-30 — it now clamps to ratchet+1 like maxFloor (flag floornum_over) |
| Exact ranks | BUILT 2026-07-29. Keep-all: submit stores one row per (realm, board, account) forever — the top-200 prune is gone, the table is bounded by the player count (~60 B a row). A daily rank pass rides the Worker cron beside the backup sweep: one keyset page a minute (score DESC, at ASC, acct ASC, never OFFSET), cursor in wmeta, one full cycle per UTC day, the rank_write budget (default 2M rows/mo, admin-adjustable) asked before every page’s writes, and a poison page skipped after ten failures with a flag — never a re-arming loop. Ranks land in lb_ranks; board totals publish to wmeta at completion so /api/lb/me answers rank, score, total and pending_cut from three point reads, privately cached 15 minutes. Board reads are untouched: the daily top-100 snapshot, served under a two-minute BROWSER cache — there is no edge cache on this route, see the caching note above. A run submitted after the cut shows pending_cut until the next pass — rank freshness equals board freshness by design. Cost at alpha: $0 (deep inside included tiers); at 300k players the pass is ~27M rows/mo, and the default budget refuses long before that — raising it is a deliberate admin act. Residual: rows of departed players linger (storage pennies; archival someday) |
| Build guard | BUILT: tools/do_guard.py scans every *DO class for the banned constructs and is mandatory before every deploy. Budget alert set in the dashboard (alerts notify; the breakers are code + MAINTENANCE=1) |
| Single global object | RESOLVED 2026-07-29 (per-player objects + WorldDO; the old 'main' is bound but unrouted, its data untouched until the wipe). Original risk, kept for the record: idFromName('main') routes every player through one object — the thing Cloudflare's own docs say not to build. It is the ceiling, not an optimisation: ~1,200 players on save size and ~1,000 req/s on throughput |
| Unauthenticated traffic | RESOLVED 2026-07-29 (signed sessions verified pre-object; blanket per-IP bucket first; auth per-IP before a session exists; WAF rate rule at the edge remains the dashboard step). Original finding: Reaches the object before any session check, so it costs DO requests and duration and can starve one global object. Step 1. MEASURED AGAINST PRODUCTION 2026-07-28: /api/me, /api/db/queue.json and a nonsense path all answered 401 from the object. A tested implementation is parked at tools/reference/edge_gate.js in dfmc-client and tools/probe_edgegate.mjs is its acceptance test — 11 of 18 rows fail today, each one a request a stranger can make that wakes the object. Note the parked version only checks that a cookie is present; step 2 needs the Worker to resolve it to a steamid, so the sessions table must become readable from the Worker (cache/KV, or a sessions WorldDO) |
| Board read is not actually cached | RESOLVED 2026-07-31, and the “fix” recorded here in July was itself wrong. There is no edge cache and there cannot be one on this route: a Worker preempts the CDN. Reads serve the daily lb_snap under private, max-age=120 — a browser cache. The previous 30-minute browser hold was mistaken for an edge hold and is how D stared at a one-row board while the origin served six. Original finding: Original finding: /api/lb/board/* takes no auth by design and its response carries s-maxage=1800 with a comment saying the edge serves it. Measured against production 2026-07-28: cf-cache-status is absent entirely — every read hits the object. A Worker response is not zone-cached unless the Worker puts it there, and even then a unique ?bust= mints a fresh entry per request. Cache in the Worker on a query-stripped key, and shape-check the realm/board segments at the edge so a scanner never reaches the object |
| Request body is paid for before it is judged | Largely closed by the Stage 1 session gate: the per-account bucket and the soft-block check now run in the Worker, before any object wakes, so an unauthenticated or over-cap post is refused without the body ever reaching the object. What remains is narrower — an authenticated, within-cap post is still parsed in full inside the object before its size is judged, and there is still no content-length gate. Original finding: /api/feedback await req.json()s before its rate limit, so an oversized post is parsed in full and then counted. A content-length gate belongs in the Worker with step 1. Sizing: a report is 220,000 shot + 24,000 logs + 4,000 body; the largest save alive today is 872 KB and the median 433 KB across all 10 accounts, so a 4 MB general ceiling is ~4.5× headroom — a tripwire, not a working limit |
| Bug-report screenshots live in the object | CLOSED 2026-08-05 — screenshots were removed (D order). The client sends an empty shot, and any shot that does arrive is ignored, never stored (the column stays, always empty), and /api/feedback/export no longer selects it. Original finding: base64 JPEGs in the same SQLite as every save, capped at 240,000 chars and rate-limited 20/hour/account plus 300/hour globally. |
Where feedback lives after the shard | Unassigned. Rows are per-account but the export and the global hourly ceiling are cross-player, so it is either PlayerDO rows with a WorldDO counter, or a WorldDO table outright. Needs a decision with step 3 |
/api/entitlements/pending answered 500 to everyone | CLOSED. Fixed 2026-07-28. It sat above const ses = this.session(req) and referenced it — temporal dead zone. Confirmed live before the fix. Nothing in the client calls it, so it was dormant, but it is the endpoint for a player disputing a missing purchase. Carry the hoist through the class split |
FEEDBACK_KEY is a var, not a secret | STILL OPEN — verified 2026-08-03, and the blast radius has grown. Plaintext in wrangler.jsonc and therefore in the repo. It now gates every /api/backup/* door including restore, recover, grant and reverse, plus /api/account/resolve, /api/ledger/reconcile (writes), /api/lb/purge (destructive) and both manual sweeps. Original finding: it gates /api/backup/export (every save of every player), /api/ledger/reconcile and /api/lb/purge. Should be a Wrangler secret and should be rotated. Security, not cost — but the cutover is the natural moment |
| The chance doors are built and not wired | /api/roll, /api/hatch and /api/breed/claim all exist, are session-gated, are idempotent on the client's idem and pass their probes — and nothing calls them. The client still rolls upgrades, hatches and breeding results locally, so the exploit those doors close is open in practice. Drops are the one wired case. Until a batch lands, this page describes protection the build does not yet have; treat every unwired row above as backend, not as defence |
| The admin console cannot see player data | /api/admin/* is routed to the WorldDO while saves, flags and history live in the PlayerDO, so flags, save-history and rollback answer empty. Not a data loss — a blind console. The key-gated /api/backup/history and /api/backup/facts are the working substitutes and are what any diagnosis should use |
| The WORLD object is not fenced | It authenticates from the x-sid header set by the Worker and holds no session table, so it has no epoch to compare: /api/db, /api/pvp/*, /api/lb/* and /api/admin/* are unfenced. Safe by construction today — _trustHeaderSid returns no epoch and the gate skips — but that is an accident of shape, not a decision. Lower stakes than the save path; recorded rather than fixed |
| Vestigial second seed path | Cosmetic, no player impact, but it should not survive a reading. After the ledger overwrite the save door still runs the old __wg_seed kv block, which raises the blob to max(before, WG_SEED). It is a no-op because wgEnsureSeed has already posted the same 100 to the ledger immediately above it — but it is a second seeding path pointed at the blob rather than the ledger, and the whole point of 2026-08-02 was that there is exactly one balance. Delete on the next pass through that handler |
keepalive body cap vs the save ceiling | CLOSED 2026-08-03. Measured at 64 KiB in Chromium; the client gzips the body (x-ht-enc: gzip) and sets keepalive only when the wire size is at most KEEPALIVE_MAX (60,000). See the queueing section |
There are two writers and only one of them can be latched off. _staleInst (another window
took the localStorage slot, or the slot was cleared underneath this one) and _loadBroken (the
save would not parse) both stop saveLocal and serverFlush. Neither stops
/api/battle. A window in that state keeps being credited — the ledger and the floor
ratchet advance normally — while the save blob stands still. On the next login the server copy wins
unconditionally, which is correct, and everything that lived only in the blob is gone with it: wallet,
inventory, creature levels, eggs, pens.
This cost a player ten hours on 2026-08-01. His last stored save was ver 874 at 10:43 UTC;
the next was ver 876 at 21:11 carrying playerXp 478,119 and
maxFloor 69 from the server's own ledger and ratchet, beside the Ichor and the 66-item
inventory of 10:43. The only warning had been a toast, and a toast does not stop autoplay.
| Rule | As built 2026-08-02 |
|---|---|
| A latch pauses play | haltWrites() calls the shell's
window.__HT_HALT, which enters the same paused state as a lost connection: overlay up,
__HT_PAUSED set, so enterBattle, lockIn and
challengeBoss all refuse. Autoplay stops at the next battle boundary. |
| A halt outranks the poller | __connCheck clears the pause the
moment /api/me answers 200 — and during a write halt it always will, because the
connection was never the problem. __setPaused refuses to un-pause while
__HT_HALTED is set. The overlay reads NOT SAVING and offers only a reload. |
| Stage 5 is a gate, not a hope | Battle entry awaits
serverFlush('battle-entry') and refuses the fight if it did not land, re-taking the phase
guards after the round trip. Floor and boss entry refuse while paused. Previously the flush was
fire-and-forget, so a dead writer returned false instantly and the fight was entered
anyway — the server then judged it against a loadout it had never been sent, which is the exact
thing the boundary exists to prevent. |
| The server derives the paralysis | A paralysed client cannot report itself, so
/api/battle compares the ledger's battle count against _ledB, the count the
server itself stamped into the last accepted save. Past STALE_WRITER_N (25) it returns
_stale and flags stale_writer. One indexed row read via
json_extract, no JS parse of a 128 KB blob, inside a call that already exists —
no extra write, so cost still scales with battles, not with time connected. Fail-open: a diagnostic may
never refuse a battle. STALE_WRITER_N=0 disables it. |
| The client tries the writer before it dies | On _stale the client
flushes first. A healthy client simply closes the gap and plays on; a latched one gets
false back immediately and halts. A false positive costs one save, not a session. |
What is deliberately absent. No outbox, no replay, no client-side merge of the two copies. The ten hours are gone and stay gone. What survived — the XP, the floor, the kill count — survived because the server derived it from its own tables, which is the doctrine working exactly as intended.
LIVE on release since 2026-09-02 (0.68). The floor-transit veil (ASCENDING / DESCENDING) is an input-blocking overlay held up until the next floor is real: the server's answer for that floor, then its plate and every spawn image decoded. Reported 2026-08-04 from a metro tunnel: the veil read ASCENDING · FLOOR 23 and never lifted, and it stayed up after the connection came back. Only a reload cleared it. Reproduced headless before anything was changed.
| Rule | As built 2026-08-04 (dev) |
|---|---|
| The refusal comes before the veil | travel() and
travelTo() refuse outright while __HT_PAUSED is set, the way
enterBossRoom() always has. Previously the veil went up first and the refusal happened
one call later, inside preloadFloor. The page above has claimed since 2026-08-02 that
“floor and boss entry refuse while paused”; that was true of the intent and of boss
entry, and one call too late for floors. |
| A refusal never leaves a veil behind | preloadFloor still
refuses while paused, and now clears any transit it is declining to drive — the case where the
pause lands between raising the veil and the call, and the re-entry from
syncFloorSrv's callback. |
| The watchdog belongs to whoever raises the veil | Every anti-softlock timer
used to be armed inside preloadFloor (two of them, both 30 s), so a veil
raised by a path that never reached it was watched by nothing. Each trip now carries a token and
veilWatch(tok, target) is armed by travel/travelTo at 35 s
— longer than both inner timers, so it only ever fires when they were never armed. It cancels
the trip and shows FLOOR n UNREACHABLE. Keyed on the token, not on floor numbers, because
travelTo may legally target the current floor. |
| Resume finishes the trip | onConnectionChange(false) re-drives a
pending transit through preloadFloor(target). It previously only reset the pacing
counters, so a trip caught in flight had nothing to restart it — and
travel/travelTo/enterBossRoom all refuse while a transit is
set, so the player could not restart it either. |
| Nothing is credited for the attempt | The watchdog cancels; it does not
retry and does not advance the floor. A refused or abandoned trip leaves floorNum,
the streak and the server untouched — the doctrine is unchanged, only the exit is. |
Why it was near-certain rather than rare. Autoplay's auto-advance branch calls
travel('up') on a 2.6 s cadence and had no pause check of its own, so any autoplaying
account that lost its line on an open floor raised a veil nothing could clear. The refusal in
travel() covers that branch and the manual button and the floor map in one place.
Measured: tools/probe_veil.py — normal travel lands; a paused
travel raises no veil; an orphaned veil is cleared; a trip caught by a pause completes on resume; a veil
nothing drives clears itself within 35 s and hands control back. BEFORE 1/6, AFTER 6/6 against the
real client.
A login already fences other devices: /api/_internal/session deletes every
previous token for the steamid, so the old device's next request is simply unauthenticated. It does not
fence other tabs, because two tabs share one cookie, one token and one session row — and
that is the case that cost ten hours on 2026-08-01.
So the token is per instance, not per login. GET /api/save is the moment a tab adopts the
save, so it mints a fresh epoch on the session row and returns it. Every later request from
that tab carries x-ht-epoch. The object compares and refuses 409 stale_session
on any mismatch, flagging stale_instance. This is Kleppmann's fencing token: the guarantee
only exists because the resource rejects the stale number — a client that checks itself is
worth nothing once the client is what has failed.
| Detail | Why |
|---|---|
| A missing header is refused too | Once a session has an epoch, omitting the
header is a mismatch. Otherwise the bypass is simply not to send it. The same reasoning closed
base_ver's optionality on the save door: once a session has an epoch it is by
definition running a build that sends both, so an absent base_ver against a save the
server holds is answered with the ordinary conflict payload and the client reboots onto server
truth. Pre-epoch sessions keep the old behaviour until they reload, and never after. |
| Enforced only once the session HAS an epoch | A tab open across the deploy keeps working until it reloads. After that every session has one and the gate is absolute. This is a migration window, not a permanent exemption. |
Exempt: /api/me, /api/logout, GET /api/save |
The first two bootstrap and tear down; the third is the claim. |
| The claim rides an existing request | No extra round trip. One row written per boot, against the seven this account already writes per battle. |
| One client wrapper, not N call sites | The shell wraps fetch once
to attach the header and to recognise stale_session. app.js issues fetches
from dozens of places and global-regexing a call site there is forbidden. |
| A second tab is stopped before it can claim | The epoch catches a second tab
after it has taken the save. The Web Locks API stops it taking one at all, which matters
because GET /api/save mints the epoch — a loser tab that booted normally would fence
out the legitimate owner. The lock is claimed with ifAvailable before
/api/me, held for the tab's lifetime, and handed on by the browser when the holder
closes: no heartbeat, no timer, no server cost. The loser gets ALREADY OPEN and a
play here instead button that steals the lock; the steal rejects the holder's
request, which is how the retired tab learns to halt. Scoped per environment, or opening
/dev/ would evict a production tab on the same origin. Where the API is missing the tab
proceeds and the epoch remains the guarantee — this layer is prevention, not the fence.
A signed-out gate lets go (#992). A tab that answers 401 at /api/me owns no
climb and can mint no epoch, so it releases the lock the moment it shows the Steam button; the window
the Steam return lands in claims cleanly, announces itself on a BroadcastChannel, and the
gate it left behind stands down with a quiet signed in in another window notice. |
| Known gap: the WORLD object is not fenced | It authenticates from the
x-sid header and holds no session table, so it has no epoch to compare. PvP, leaderboard
and admin paths are therefore unfenced. Lower stakes, recorded rather than fixed. |
The drop grant log is the restoration source, and it expires.
drops_granted(ev, realm, ts, items) is the server's own record of what it handed out per
battle event, and since 2026-10-23 (T39) it is never pruned — account history is kept
forever (events_seen, the battle replay cache, still prunes at 7 days). A save-loss restoration is a
REPLAY of fact for every drop the table holds; before its first row, it is an estimate. Read-only, ops-key
gated: GET /api/backup/drops?key=&sid=&realm=&from=&to=, which also reports
oldest_retained so you can see how much of the window is left. There is deliberately no
restore side — granting is a separate decision from knowing.
The client pushes on visibilitychange → hidden and on pagehide, both with fetch(…, {keepalive:true}), guarded by _srvDirty so an untouched session sends nothing. Two defects sat on that path, both fixed 2026-08-03.
1. A rate-limited save was dropped silently. serverFlush clears _srvDirty before the fetch, and the .catch() that re-arms it only runs on a network throw. A 429 resolves — it fell through to return !!(r&&r.ok) and nothing re-armed, so the client went on believing it was clean while the write never landed. The save class is cap 10 / 6-per-minute, and this is the one path where the page may not survive to retry. Any non-OK response now re-arms and honours the server's retry_ms.
A 409 carrying data is the deliberate exception and must never re-arm. A conflict means this window is stale; resending is precisely the wrong move. It returns through onSaveConflict above, which reboots onto server truth. A 409 {error:'stale'} with server_ts (the ts-ordering gate) is healed instead: __HT_TSFLOOR rises to server_ts + 1 and the write re-arms at 250 ms, up to 4 times, before it joins the fail ladder; any other data-less 409 is treated like a 429.
2. Backgrounding burned the rate bucket. hidden and pagehide bypassed the debounce, and on a phone hidden fires on every screen-off, notification pull and app switch. Each one spent a token; three breaches inside the window soft-block the account, which is the trap that once made a signed-in account look logged-out for seven hours. hidden is now throttled to one flush per HIDE_COALESCE_MS (15 s, matching the 429 retry_ms); _srvDirty is deliberately left set when a flush is skipped, so nothing is dropped and the debounce heartbeat sends it.
pagehide is never coalesced. Backgrounding is recoverable because the page survives; a close is the last chance there will ever be.
Why not sendBeacon: it cannot set custom headers, so it cannot carry x-ht-epoch. The fence treats a missing epoch against a non-null session epoch as a mismatch, so a beacon write would be refused — and exempting it would reopen the exact bypass the fence exists to close. fetch(keepalive:true) can set headers, which is why it is used.
CLOSED 2026-08-03, the keepalive cap vs the save ceiling: a keepalive request body is capped at 64 KiB (measured in Chromium), while a save is allowed up to 409,600 bytes. The client gzips any body over 4 KB (x-ht-enc: gzip; ~131 KB goes out as ~24 KB) and sets keepalive only when the wire size is at most KEEPALIVE_MAX (60,000).
Client: serverFlush(), HIDE_COALESCE_MS in play/app.js. Probe: tools/probe_saveflush.py (8/8).
This is the root cause of the multi-device rollback, and it is two constants that drifted apart.
index.html — REALM_KEYS = { standard: 'herumon_run_rel_v1', … }. syncSaves() writes the authoritative server save here on every boot.app.js — runKeyBase() returns herumon_run_v2:<env>:<sid>. loadRunInner() reads this.The only bridge between them was the legacy migration in app.js, and it is gated on have == null — by design, "a player who already has a namespaced run keeps it and the shared blob is simply dropped." That is correct for a one-time migration and fatal as a permanent bridge: on any device that had played before, the freshly-adopted server save was written to the legacy key, skipped by the migration, and then deleted unread.
Why every other guard was silent. The app then booted on its own stale state holding a legitimately minted epoch and a current base_ver — so by every check the server makes, the write that followed was valid. Proven separately: tools/probe_rollback.py drives a real worker and the server refuses every stale combination there is (old epoch, old base_ver, borrowed epoch, omitted headers). The fence was never the problem. "Server wins on login" was never the problem either — it won, into a slot nothing loads from.
It also explains the damage pattern. playerXp, classXp and maxFloor survived because they are ledger-backed and grow-only server-side, so a stale blob could not lower them. Everything held only in the save — wallet, bag, equipOv (including creature XP), soulOv, sockOv — rolled straight back. And a refresh never healed it, because the migration is skipped on every boot once the namespaced key exists.
Fix: the shell now writes the adopted save to both keys. The legacy slot still feeds the first-boot migration for a device that has never played; the namespaced slot is what loadRunInner opens. The key formula is duplicated in the shell and must stay identical to runKeyBase() — tools/probe_runkey.py is the gate, and its control (empty namespaced key) matters as much as its failure case, because a probe that only shows the bug cannot tell a real defect from a broken harness.
Shell: runKeyFor(), syncSaves() in play/index.html. Client: runKeyBase(), loadRunInner() in play/app.js. Probes: probe_runkey.py (3/3), probe_rollback.py (server-side matrix).
queueFlush() is a trailing debounce armed by an action, not a periodic tick: 25 saveRun() call sites arm it, _srvFlushAt keeps the earliest pending deadline, and the rAF heartbeat only checks whether that deadline has passed. An idle, clean session sends nothing. Its own comment states the intent — "10s debounce that resets per action, so a whole loadout rework becomes ONE server row."
Inside a battle that guarantee inverted. State changes every turn, so the save re-dirtied immediately and the coalescer degenerated into a true ten-second heartbeat for the fight's entire duration. Those are the least valuable saves in the game: /api/battle has already credited the ledger server-side, and enterBattle is a hard gate that awaits a flush and refuses the fight if it does not land — so the previous battle is already stored. The client was re-uploading, at full save size, a battle the server already knew about.
And that traffic alone consumed the entire rate budget. A save every ten seconds is 6/minute; the save bucket is cap 10 refilling at exactly 6/minute. Zero headroom — so every one of the seven event-driven flushes (battle-entry, floor-entry, boss-entry, hidden, pagehide, signout, stale-writer) was an overdraft against a 10-token buffer, and three breaches inside ten minutes soft-block the account. That is the mechanism behind an account that looks logged-out while it is signed in.
The fix is to stop ticking while a round is in progress. FIGHT_PHASES is bintro, intro, select, target, confirm, resolving — deliberately narrower than gearBonus's inBattle list, which also contains victory, powerup, evolution and defeat because it answers a different question ("are stats locked"). _srvDirty is left set while suppressed, so the pending flush fires on the first tick after the fight ends. Leaving the fight is therefore the victory flush, for free — no second trigger to keep in step, and victory previously had no flush of its own at all. A close mid-fight is still safe, because pagehide flushes unconditionally.
Measured, not reasoned (tools/probe_flushrate.py, 4/4, counting real POSTs while re-dirtying every 400 ms as a fight does): 0 saves across a 25-second fight, against 2 in the same window on the floor, which is the control proving coalescing still works. The held flush lands on the transition to victory.
Why this rather than lengthening the interval. Tuning 10s to 20s would have halved a number that should be zero. The interval was only long enough to matter because we were saving something the server already had.
Client: queueFlush(), maybeFlush(), FIGHT_PHASES in play/app.js. Probe: tools/probe_flushrate.py.
The game's tables (creatures.csv, floors.csv, floor_creature_rarity.csv,
moves.csv, items.csv, classes.csv and a dozen more) are fetched from the codex
at boot — live main on dev, the immutable DATA_PIN snapshot on release. On release they come from the game’s own worker first (/api/codex/<DATA_PIN>/, the bytes baked into its bundle, 2026-10-23), with jsDelivr and raw as the fallback.
The server treats a missing table as fatal: /api/floor/enter answers
503 spawn_data_unavailable rather than build a pool it cannot vouch for — no pool without
truth. Today the client halts too: if any core table (floors, creatures,
moves, items, classes) is missing, the loader calls haltData and play does
not start (the ruling below). Until 2026-08-04 the client did the opposite. A failed fetch resolves to null, the loader
emitted Core CSVs missing — game data incomplete, and play continued. Worse, the
loader then aborted at its first unguarded dereference of that null (crs.forEach), so every structure it
would have built after that point was missing too — the client was left partially initialised, with no
single flag saying so.
This is the data side of report #20 (“sumiu td” — the screen vanished). The CSVs
were never wrong; they were absent. With no creature table, every spawn descriptor the server sent resolved to
nothing and warned Spawn "?" has no Line_ID in creatures.csv. That nameless unit is what threw inside
renderVals, and because renderVals is all-or-nothing, one exception blanked every binding on
screen at once. Reproduced by blocking exactly one cross-origin CSV: the client boots, is playable, and holds no
creature data at all.
Mitigated (2026-08-04): core tables now retry a transient failure — three attempts with
backoff, network errors and 5xx only. A 4xx is not retried, because a file legitimately absent
from an older DATA_PIN must fail fast rather than stall the boot three times over. Measured: two 503s
followed by a success now loads all 300 creature rows with no warning at all, where before a single 503 lost the table
for the whole session.
Ruled 2026-08-04 (owner): if it cannot load, it cannot play. When the retries are exhausted the client now fails closed and matches the server. It stays on the loading screen, names the file that did not arrive, and offers RELOAD; save writes are stopped so a half-initialised session can never overwrite real progress. It also files its own bug report with the log attached, because the player cannot describe this failure and the log is the only thing that identifies which file died. One report per boot, and a failure to report never becomes a second error.
Implementation note worth keeping: the halt screen (#__pause, z-index 99991) sits below the loading screen (#__load, z-index 99998). If the boot never finished, the loader is still up and the halt message renders completely invisible underneath it — every computed style reads visible, the text is in the DOM, and the player sees a logo and nothing else. A DOM text assertion passes. This was caught by screenshotting the result, not by querying it. The message is therefore drawn on the loader, which is also the honest behaviour: the game never started, so it never leaves the loading screen.
A battle in progress is held on the device, never on the server: persistBattle() writes
{units, round, turns, floorNum, engagedIdx, inBossFight, streak, pvp} plus the seed and the move log
into localStorage, and tryResumeBattle() reads it back. The server holds nothing mid-fight
— a spawn slot is marked dead only when a kill is credited — so a paused enemy is still standing
on the floor when the player comes back.
There are now two slots, named by kind (LIVE on release since 2026-09-02 (0.68)). There was one:
herumon_battle_v2:<env>:<sid>, with no kind in it, and persistBattle writes
s.pvp into that same blob. A PvP match joined out of a PvE battle therefore overwrote the paused
PvE fight. PvE keeps the historic key so a snapshot written by an older build still resumes across the update;
PvP is …:<sid>:pvp. The kind is carried by the key and never inferred from the blob
— a snapshot is only ever read out of the slot that owns it.
clearBattleSave(kind) names one slot, defaulting to the kind of
the fight on screen. A PvP defeat must not take the parked PvE fight with it.rmBattleSaves() and clear every slot. A wipe that leaves one
snapshot standing is the foreign-slot bug again, one key over.And the resume path had never once run. The reconcile guard that drops a foreign or poisoned
snapshot called .filter on snap.units — which is an object keyed
a0/a1/a2/e0/e1/e2, not an array. It threw on every boot, the surrounding catch called
clearBattleSave(), and the snapshot was deleted instead of restored. Measured on the
pristine build rather than read: persist a real fight, reload, and the client lands on the floor screen with no resume
note and an empty slot. Fixed by iterating the object's values; the same guard's floor test read d.floor
and d.floorNum, neither of which exists at the top level of a snapshot, so it evaluated to 0 and never
fired either. After the fix a reload returns to select with all six units and the resume note. The fix (commit
26367ed, 2026-08-04) is on release too.
LIVE on release since 2026-09-02 (0.68). The save is being split into seven keys by write frequency
(run, party, pens, inv, sock, codex,
pvp) held in save_parts beside the authoritative saves blob. An audit on
2026-08-04 found that six code paths write saves and exactly one of them wrote the
parts — the save door. The five that did not: the ops restore door, the R2 backup restore, bulk
dismantle, the ledger-reconcile sweep, and admin rollback. They were found by measuring, not by reading, which is
the reason to assume there will one day be a seventh.
Every writer now goes through writeSave(). It writes saves, splits and
writes the changed parts, and stamps a watermark, all in one transaction. This is not the dual-write
anti-pattern: that anti-pattern is about crossing a transaction boundary between two independent systems, and
saves and save_parts are two tables in ONE SQLite database inside ONE single-threaded
object. What three separate save platforms advertise as a hard-won feature — atomic writes across records
— is free here. (research.html entry 4.)
| Piece | What it guarantees |
|---|---|
writeSave(sid, realm, d, ver, ts, raw) | The only place saves is written.
Splits and stamps in the same transaction. d may be null when a caller holds only a string it
could not parse — the save still lands and the watermark is cleared, so the parts are
known-stale rather than silently wrong. |
save_parts_mark | One row per (steamid, realm) recording the saves.ver the
parts were last reconciled against, and the wipe generation they belong to. |
partsFresh(sid, realm) | The stage-3 primitive. Parts are trusted only while the
watermark equals saves.ver and the generation still matches. Reported by the parts door
now, so the rate is known before anything trusts it. |
Monotonic ver | An invariant, not a convention. A writer asking for a version at or
below the stored one is bumped forward and flagged save_ver_nonmono — otherwise a restore
that lowered the version would make stale parts read fresh. |
Why both a facade and a watermark, when either alone looks sufficient. The facade is the fix:
it makes the five bypassing writers correct. The watermark is what survives the writer nobody remembered — a
raw write anywhere leaves the mark behind saves.ver, and the read path rebuilds instead of serving the
old projection. A guarantee that depends on remembering to call a function is exactly what just failed.
The watermark is stored, never inferred. writeSaveParts skips any key whose content
hash did not move, so max(save_parts.ver) lags saves.ver whenever an accepted save changed
nothing in a key. Measured on a live account: saves.ver 3430 against a highest part version of 3429.
Inferring the watermark would have read that account as fresh.
A realm reset now clears the parts. resetRealm deleted saves,
save_hist, chains, ledger, progress and more — and there
was no DELETE FROM save_parts anywhere in the Worker. Inert while saves is authoritative;
at read cutover the wiped run would have loaded straight back out of the projection, so the reset would silently
not have happened. Both defences are in: the generation stamped on the watermark makes stale parts unreadable, and
the rows are deleted as well, because ignoring data is not reclaiming it.
LIVE on release since 2026-09-02 (0.68). The server's figure is the only number a battle pays (see system-xp.html), so the question of what happens when that answer does not arrive stopped being cosmetic. Owner ruling: hold the player for a couple more tries; local authority is never an option; if it is an outage the player gets the reconnecting window, not a fabricated battle end; and a failure files an automatic report.
| Answer | What happens |
|---|---|
| Network throw, or 5xx | Retried. The request may never have arrived. Three attempts total, backing off 600 ms then 1800 ms with jitter. |
| 429 | Retried, but never sooner than the server's own retry_ms.
Retrying inside the window is what turns a rate limit into a soft block, and a soft block on an
honest player is how the seven-hour login incident happened. |
| Any other 4xx | Not retried. A 400/403/409 is a decision, not an outage; hammering it spends the bucket and files an honest player into the traffic table. The body still heals the floor pool exactly as before. |
| All attempts exhausted | That is the outage. Play pauses into the reconnecting window, nothing is credited, nothing is banked, and one automatic report is filed. |
Retrying is safe by construction, and it was checked rather than assumed.
/api/battle is deduplicated on the battle event id, so a repeat credits once.
But the dedup branch used to answer nothing, and that was a hole the moment the client stopped
crediting itself. It returned {ok, dedup} with no figures, so a retry after a
lost response — the request landed, the answer did not — paid the player zero for a
battle the server really had credited. The event row now stores the answer it gave and the dedup branch
replays it. It costs nothing extra: the credited figures are all known where the row is already written,
and rows written is most of the bill. Drops are deliberately not replayed inline — they
were minted into the pending lane, and draining that lane exactly once is the whole point of it. Events
recorded before this shipped have no stored answer and replay as a bare dedup, which is the old
behaviour rather than a new hole.
An exhausted credit pauses immediately, and does not wait for the poll. The connection check normally requires two consecutive misses — one blip is not an outage. A caller that has already failed three attempts with backoff has proved the point far better than one poll miss, so it asks to pause at once. Waiting for a second miss let the player fight another battle that could not be credited either, which is precisely what “nothing happens offline” forbids. Incidental failures and the 60 s poll keep the two-miss rule.
The report is budgeted and bounded. /api/feedback shares the
other rate class with the shop catalogue and grants (hatching and breeding moved to roll on
2026-08-08), so during an outage an
autoplaying account would otherwise file one report per battle and rate-limit itself — reporting a
failure must never become the second failure. Battle-credit failures get two reports per session with a
sixty-second cooldown; the boot bucket keeps its separate one-per-boot budget. The log payload is capped
per line, truncated to the last 16 KB (the failure is at the end, not the start) and stripped of C0
control bytes. That is a size and hygiene bound, not a security boundary: a report is text a browser
wrote, and whatever reads it must still treat it as untrusted and never interpolate it as markup.
LIVE on release since 2026-09-02 (0.68). Every battle event carries the chain it belongs to and the
head it follows — a hash of the previous event. The server compares the submitted head against
the one it holds and flags chain_break on a mismatch. The flag fired 19 times on the owner's own
account, which is exactly why it was never enforced.
The cause was an ordering difference, not an attack. The client advanced its head when it built the event, before the request was sent. The server advances its head only after the event passes validation. Every path that returns before that write therefore left the client one ahead:
bad event and bad slot — malformed submissions.dead_slot — the client claims a spawn the server already marked dead, which a
legitimately resumed battle can do.spawn_data_unavailable (503) — fail-closed, and what a cold start or a deploy produces.It self-corrects after exactly one flag, which is why the pattern looked odd: the mismatching battle still advances the server's head, so the battle after it lines up again. Ten flags on one chain id over a day and a half is ten rejected or lost battles, not a sustained break.
The head now advances on confirmation. An answer without an error — credited,
accepted-but-uncredited, or a dedup replay of an event the server already holds — means the server's head is
now this event's. An error means it rejected the event and its head did not move, so the client's must not either;
the retry and the pause cover the no-answer case, where it also did not move. The two heads now agree by
construction.
It is still not enforced, and that is deliberate. Making the flag trustworthy is a separate
decision from acting on it. A correction of the record belongs here too: the first diagnosis of this said the
server does not advance on an uncredited battle. It does — the chains write is unconditional, and
a rate-capped battle advances it. That conclusion came from a probe stub rather than the worker, and the real cause
is the list above.
Rows written is the dominant Cloudflare cost line at every population size — Durable Objects bill
$1.00 per million rows written against $0.001 per million read, a thousand to one — and
until 2026-08-06 nothing in the build recorded which system produced them. The billing telemetry cannot answer
it either: durableObjectsPeriodicGroups resolves to object and day, with no route dimension anywhere in
the dataset. Research: research.html entries 6 and 7.
| Where the wrap lives | ONE wrap of sql.exec in the MainDO
constructor, not at the 313 call sites. Most SQL in this build is issued from helpers on a caller's behalf, so a
per-call-site tag would miss the majority of statements and could drift; a constructor wrap catches all of them by
construction. |
| What identifies a system | A request-scoped tag set at the top of MainDO.fetch
from the path: battle, save, floor, breeding,
hatching, grants, wallet, leaderboard, pvp,
session, feedback, roll, ops. Everything beneath the tag
inherits it. A Durable Object serves one request at a time, so the tag cannot interleave. |
| What it costs | Zero database writes. One console.log per
request, and observability is already on. A per-write audit row would be paid for in the exact
currency being measured — it would double the dimension under investigation — and the existing
spend() helper is worse than a bare insert at two reads and a write per call. |
| Why cursors are summed at the END of the request | rowsRead is only final once
the caller has consumed the cursor, and callers spread it after exec() returns. Reading the
counter inside the wrapper would undercount every SELECT in the build. The cursors are held for the
life of one request (capped at 4,000) and summed in the finally. |
| Hibernation | No timer, no fetch, no socket. do_guard green. |
| The write breaker | wrBreaker reads the same per-request row count and
alerts; it never refuses an authoritative write. Layer 1: a request over 2,000 rows
(WR_REQ_MAX) files wr_request_runaway. Layer 2: rows per object per hour against
20,000 (WR_HOUR_MAX), flagged once each at 70/90/100% as wr_budget, wr_budget_90 and
wr_budget_shed (one kind per step since 2026-09-30, so the one-a-minute flag throttle can no longer swallow the 100% row). At 100%
the object sheds diagnostic writes only: flag() stops inserting anomalies rows
until the hour turns; saves, battle credits, grants and wallet moves are untouched. Layer 3: the hour total is held
in memory and flushed to wr_hours (168 hours kept) on the next hour's first request, where the closed
hour is compared to 10× the p90 of the retained active hours (at least 12 needed) —
wr_rate_anomaly. An evicted object under-counts its hour. Full account: admin-backend,
“The write breaker”. |
First measurements (tools/probe_wrcost.py 13/13, real workerd, 2026-08-06 —
LIVE on release since 2026-08-08 (0.42).: /api/me on a warm object is 2 statements, 2 rows read and 0
written; the same call on a COLD object writes 2 rows to kv, once, on the first
request after WIPE_EPOCH moves. GET /api/save writes 1 row despite being a
read path. And a rejected save (409 stale_session) still costs 2–9 rows with
flag_rept as its top table — rejections are not free, and the flag path is what
makes them expensive. None of these are yet a ranking: research entry 7 is explicit that lever value is unmeasured on
this build until a full collection day has run.
Two things go wrong at once when the tab is hidden, and only together do they produce COULD NOT REACH THE SERVER — THIS BATTLE WAS NOT COUNTED:
visibilitychange→hidden fires serverFlush('hidden',{keepalive:true}). A real save is ~131 KB and gzips to ~24 KB, and keepalive bodies share a 64 KiB browser-wide quota. /api/battle is also keepalive, so a credit in flight competes with the flush for that quota and can be refused by the browser before it ever reaches the network.An attempt made while the document is hidden no longer advances the attempt counter. A hidden tab is not evidence of an outage. A wall-clock bound still applies, so a player who never comes back cannot retry forever, and the three-attempt rule is untouched once the tab is visible — which is the case that rule was written for.
And the two keepalive bodies stop competing at all: the hide flush yields while a credit is in flight. The save is the cheap one to defer — the battle event is the authoritative reward record, and the credit's own success already queues a flush — so the save still lands at the next boundary. Nothing is skipped, only ordered.
Nothing about this banks a result offline. A credit that genuinely cannot be delivered still credits nothing and still pauses play; what changed is that a tab switch is no longer mistaken for that.
Client: the _battlePost retry and the visibilitychange handler in play/app.js. Probe: tools/probe_hidecredit.py — BEFORE 6/6 (asserting the retry has no notion of a hidden tab), AFTER 7/7.
Section rewritten 2026-08-09 (0.45). The heading this replaced — “the spawn budget is one pool” — is the sentence that let a real bug survive a fix. It described the layer on top and denied the existence of the layer underneath, and a probe written to it spent credits without ever marking a slot dead: it proved the pool was shared across floors while never touching the counter that was actually binding.
Two counters decide how many spawns a floor shows, and the player sees the minimum of them.
| Limiter | Keyed by | Rate and cap |
|---|---|---|
The account budget — table spawn_budget | Realm only. One pool, spent wherever you fight | TABLES.spawn_budget: per_ms 60,000, cap 10. Incense halves per_ms — 2/min, cap untouched |
The floor's own memory — floors_srv.dead[] | Realm and floor. One row per floor, each running its own clock from its own last drain | refillSrv(), cap 10, the same rate off the same boon expiry |
/api/floor/enter builds the pool out of that floor's own dead[] and then caps it to the
account budget. What the player is shown is therefore min(this floor's live slots, your budget),
and the countdown quotes whichever of the two is actually binding.
/api/battle takes a credit from the budget and marks that slot dead on the floor. Equal drains against equal
rates keep the two equal, which is why a fault in one of them can hide indefinitely.TABLES in both places rather than written as a
literal in either, so a future rate lever moves them together by construction.refillSrv hardcoded one slot per 60,000 ms and took only the floor state, so it had no steamid, no realm
and no way to know a boon existed. Under Incense the account accrued 2/min while every floor still refilled 1/min.
The floor just farmed fell behind the budget and looked empty; an untouched floor, whose slots had never been drained, showed
the full amount. D's own report is the shape exactly: floor 49 empty, floor 48 full.
The repair is arithmetic, not a rate swap. refillSrv(st, boonUntil) now runs the budget's own
accrue(), and advances its clock with accrueAt() — the exact inverse, such that
accrue(from, accrueAt(from, n, bu), bu) === n by construction. A plain 60000→30000 swap would still have been
wrong across the instant a boon expires, because that window has to split at the boundary. A third hardcoded
60,000 was found in the same expression while fixing it: the floor-bound branch of nextInMs told a player under
Incense to wait twice as long as the floor actually needed.
refillPool() still re-asks the server on a hardcoded
60,000 ms, so under Incense the on-screen countdown can lag by up to 30 s. It is cosmetic — the client never
invents a spawn and the number shown comes from the server's nextInMs — and it was left out so the fix
stayed worker-only.Why over-showing is worse than it looks. An over-budget kill is not merely unpaid: the server sets credited = false and raises a spawn_budget flag against the account. A player shown a full map they cannot be paid for therefore accrues anti-cheat signal for doing exactly what the screen invited. Under-showing is self-correcting; over-showing is not.
Travel requires the server answer for every floor, cached or not. That follows the offline ruling — a dead connection must pause rather than serve stale content — and the existing 30 s FLOOR N UNREACHABLE watchdog covers the failure.
Reviving a slot from the budget alone was considered and refused. st.seq[i] is bumped in exactly two places — refillSrv and a floor re-roll — and deriveSlot derives creature identity from it, so a slot revived without a serial bump comes back byte-identical and the same creature can be refought for its drops. The dead-slot 409 in /api/battle is also the only thing stopping a client refighting one slot, and it calls flag(): honest players would have tripped it. That is a change to creature identity and to the anti-cheat surface, not a bug fix.
Server: spawn_budget, accrue(), accrueAt(), refillSrv(), floorStateOf(), capPoolToBudget(), peekSpawnCredits() in src/worker.js. Client: preloadFloor(), capPoolLocal(), refillPool() in play/app.js. Probes: tools/probe_floorclock_boon.mjs (9/9 after, 3/9 on the pre-fix tree, with T6 and T8 passing on both as controls), tools/probe_spawnshare.mjs (9/9, real SQLite, methods lifted from the shipped worker) and tools/probe_spawncap.py (BEFORE 3/3, AFTER 6/6).
LIVE on release since 2026-08-08 (0.42).
D's order, 2026-08-08, verbatim: “server side for all chance outcomes”. An
audit of play/app.js found 61 Math.random() calls; most are cosmetic (spawn
positions, animation jitter, AI targeting, shuffles, uids, commit salt). Seven decide something a
player owns and are the migration list, in D's order:
| # | Roll | Status |
|---|---|---|
| 1 | item + class-soul grading success | DONE, on dev |
| 4 | enchant roll | next |
| 2 | bred egg grade | pending |
| 3 | breeding parent line 50/50 | pending |
| 5 | drop grade band | pending |
| 6 | boss key drop chance | pending |
| 7 | creature sex | pending — already server-side on the HATCH path (/api/hatch returns g); breeding and the ensureGenders backfill remain |
| 8 | wild spawn tier / quality / mutant | HELD — needs D's ruling |
POST /api/roll was built on 2026-07-31 under the rollback/authority batch and accepts
grade, soul_grade and breed_rate. No client ever called
it. Wiring the forge to it exposed a defect that had been shipped and unreachable for eight
days: the worker matched a row with
String(r.Category) === cat, but soul_grading.csv authors
neither a Category nor a Tier column, so that comparison tested the
literal string "undefined" against a real category and no
soul_grade request could ever resolve a row. Both keys are now column-tolerant:
a column the CSV does not author is an unconstrained key, not a mismatch. The lesson is the standing
one — an endpoint nothing calls is not tested by its own existence.
The worker resolves the authored row (a Durable Object may never await fetch() —
the hibernation rule) and hands it to the player's own object. The object draws
HMAC-SHA256(SPAWN_SECRET, "roll|steamid|realm|kind|idem"), the same keyed generator the
spawn table uses: the client cannot predict it and cannot reproduce it. The result is stored under the
client's idem key, so a retry, a reconnect or a double-tap always answers the same
outcome — that idempotency is what makes it safe for the client to ask again when it
loses the answer.
The forge used to debit the bundle synchronously and then roll. It now creates the ticket, shows the attempt animation, and debits in the same tick that applies the outcome. Three consequences, all deliberate:
The 620 ms wait is now an animation floor, not the roll. If the answer beats it, the
result waits for the animation; if the animation beats the answer, the result waits for the server, up
to UPG_ROLL_MS (12 s).
Write cost. A roll writes one row to a rolls table and
sweeps rows older than seven days. Against the measured 27.4 rows per save this is noise, and the
record only has to outlive a session. The abuse ceiling is counted in the same table (120/min) because
an in-memory throttle dies with the isolate.
Probe. tools/probe_upghang.py — BEFORE 6/14, AFTER
14/14 across fourteen consecutive runs. It stubs /api/roll on a narrow single-path
route (never a catch-all, which serves every request 200 and leaves dc-runtime.js
unexecuted while looking like a broken build). Test 9 aborts the route outright to prove the offline
rule. Three harness races were found and fixed rather than tolerated, and each is a general trap: the
wallet is not a stable observable (the breeding pens both accrue and consume Ichor
every frame, so a “wallet unchanged” assertion drifts in both directions); a bench reset's
setState had not committed after a fixed sleep, so a cost was read off the previous item
and a single correct charge was reported as bundle 10, charged 30; and settling was raced with
a sleep instead of waited for as a condition.
LIVE on release since 2026-08-08 (0.42). D restated the rule on 2026-08-08 because the build was not honouring it: “Only 10 creatures can be spawned at most in any floor. If I kill one on floor 27 and now have 9 creatures spawned, all floors that I go to now have 9 until it refills. Refills are server side and the rate is increased by having incense applied.” That is the same rule this page and Premium Shop already author — one pool, cap ten, one spawn per 60 s while below cap, 30 s under Incense, the cap itself unchanged. It is not a pool per floor.
dead[], refilled on its own clock, and the player sees the smaller of the two. A rate change that moves only the budget leaves the floors behind — reports #85 and #87.The server was already right, and this was measured rather than assumed. Against
a real Worker and real Durable Object SQLite: with the pool drained on one floor, an untouched floor
whose own ten slots were all occupied answered sb=0 and ten nulls. One kill anywhere
takes every floor to nine. /api/battle carries the refreshed pool and the new budget
back in slot{pool, sb, sbNextMs}. Spawn identity is
HMAC(SPAWN_SECRET, account+floor+ver+index) — seed-based, unchanged.
syncFloorSrv ended with if(!j||!j.ok) return;. A 429 from the
floor rate cap (30 burst / 8 per min) landed on that line and returned in
silence — no retry, no pause, no connection check — and
spawnSb kept whatever it last held. capPoolLocal then capped the floor
against a stale budget. Measured: the client painted ten spawns while the
server would have shown two.
This is not cosmetic. An over-budget kill is not merely uncredited — it
sets credited=false and raises a spawn_budget flag against the
account. An honest player accrues anti-cheat signal for fighting exactly what the screen invited them
to fight. It is the same failure class as the battle-credit reports (#71/#72/#73): a refused or
dropped request that nothing surfaces.
retry_ms where it gives one, up to SPAWN_SYNC_TRIES (4); past that the
connection check fires and play pauses._spawnT, deliberately outside _timers
— every battle path calls clearTimers(). Same lesson as the forge ticket.spawnStale is set and the floor shows nothing. Under-showing is
self-correcting; over-showing costs the player.fpool() is now the single gate — every surface that paints the floor pool
goes through capPoolLocal, so a stale budget cannot reach the screen by a side route
(bossExit and the resume path both painted from cache).The full-stack harness exists now, and OPS had been recording it as
missing. npx wrangler dev --local --var DEV_LOGIN:1 serves the real Worker and
play/ as assets, so localhost:8787 is the real client on real object
storage. Two traps cost a run each: ALLOWED_ORIGINS whitelists the hostname
localhost only, so a browser POST from 127.0.0.1 is 403 bad origin;
and the Genesis rule refuses a fresh account claiming progress, so a seed save must be clamped to
maxFloor≤2, classXp≤300, 15 bagAdd,
invCap≤80 and a capped wallet. tools/probe_spawnbudget.py: BEFORE 5/7,
AFTER 7/7. tools/repro_spawnbudget.py holds the diagnosis.
LIVE on release since 2026-08-08 (0.42). D, 2026-08-08: “Enchant roll needs to be server side as well because it changes the item in the database (scrolls are only overwritable not removable).” That last clause is the whole design. A scroll applied over another destroys the one underneath and there is no way back, so the outcome has to exist before anything is consumed. The client asks and is told; if no answer comes, the scroll is still in the bag and the socket is untouched. There is no refund path because nothing is ever spent speculatively.
Gems and runes carry no chance at all — their value is read straight from
items.csv — so they are untouched and still apply in the same tick. Only the
scroll waits.
codex/enchant_base.csv authors the magnitude table: Const,
Per_Grade, Per_Tier, GF_Base, GF_Step and a
Mode of multiply or add. Nothing had ever read it. The client carried
3.6, 5.6, 1.70, 0.56, 12.0, 0.90 and 0.50 as literals in enchBase(), so the CSV and the
game could drift apart in silence and the balance file was decoration. enchBase() is
deleted and the server reads the CSV — D, 2026-07-31: “The csvs need to be the source
of all truth so we can balance. Hardcoded NEVER. Client authority NEVER.”
The draw takes two HMAC lanes from the same keyed generator as the grade roll:
lane 0 picks the enchantment against the authored Weight_Pct, lane 1 picks the magnitude
inside the row's Roll_Min_Mult–Roll_Max_Mult band. Lane 0 is
byte-identical to the previous single-lane call, so every grade roll already recorded is unaffected.
The roll can only take the stat the host item carries — a Defense item never rolls
Attack — and the eligible pool is filtered server-side, not trusted from the request.
Probe. tools/probe_enchant.py — BEFORE 2/9, AFTER
9/9. Test 7 aborts /api/roll and proves the scroll survives. Two of its own assertions
were wrong first and were fixed rather than relaxed. Test 8 searched the served bundle for
enchBase( and matched the comment recording the deletion — the probe failed on
its own tombstone; it matches the definitions now. And test 5 judged the class mix on eighteen
draws, because /api/roll has its own rate class and answers 429 with
retry_ms 15,000: 200 requests yield about 64 draws however they are paced, and waiting
out every refusal would take half an hour for a distribution check. A flat ±0.10 was both too
tight for n=64 and worthless as evidence, so the bound is now three standard deviations of a
binomial at the observed n, with a sample floor of 50 so it cannot pass on a handful.
LIVE on release since 2026-08-08 (0.42). D, 2026-08-08: “All three mons in battle need to come from the server together with the battle xp and loot. Rethink this system as it's being done very stupid.”
A wild battle has three enemies. Only e2 — the spawn you clicked —
was ever derived. e0 and e1 were conjured by the client with
Math.random(): tier, species, level, quality and mutant, all client-side, then sent back
in the kills array for the server to price.
The defence was a clamp — an extra's tier pulled down to the primary's, its level pinned to floor ±3, its species accepted only if the floor's own table authored that creature. That bounds the damage and it is still the client deciding what two thirds of every fight are, and account XP, class XP, food and the egg lane all read off those claims.
An extra is a property of the slot. It comes off the same keyed stream on its own
lane suffix — deriveExtras() calls deriveSlot() with
lane = 'x0' | 'x1' — and it ships with the pool the client already asks
for.
/api/floor/enter and the
post-battle pool, both of which the client receives anyway. Deriving is pure CPU: no extra reads,
no extra writes. That is what makes this affordable where a per-battle start call was not —
a full extra request pair per battle was measured at roughly $18k/month at 300k players and
deliberately removed in July.(floor, ver, slot, seq), so XP, class XP, food and the egg lane are its own
facts end to end. claimedSpecies() and the tier clamp survive only to tolerate an
older client, and a kills array of the wrong length is flagged rather than believed.seq moves only when the slot
respawns. That is the same hole report #81 found in breeding — closed here before anyone
went looking for it.px/py: they are battle-only and never stand on the
floor.rollSpawns, rollWeightedRow, buildSpawn,
spawnOne, conjureExtra, ensureFloorPool. Between them they
rolled encounter tier, species, level offset, wild quality and mutant. Only
conjureExtra was still reachable — the rest sat one call away from being
wired back up, which is exactly how the boss-loot roller survived four months after the wild lanes
moved. The cutout fallback no longer re-rolls either: a bad crop is an art problem and must
never silently become a different creature. wildTags() stays — deterministic in
floor, quality and grades, and it reads no RNG.
Verified. Slot 0 on floor 2 issues primary DFMC-076 (UC)
with extras DFMC-010 (C) and DFMC-256 (R); re-entering the floor returns the
identical encounter; the battle credits 21 XP / 11 Ichor / 21 class from three server-derived kills
with an empty client kills array. Full regression, all green:
probe_spawnbudget 7/7, probe_bossloot 9/9, probe_bosskey 5/5,
probe_enchant 9/9, probe_eggroll 8/8, probe_sexroll 8/8,
probe_upghang 14/14.
Build 0.47. bossEdge gated two unrelated rules at once, and the two rules do not want the same
test.
| Rule | What it needs to know | Why |
|---|---|---|
| The spawn-credit skip | “Is this a boss floor?” Nothing else. | A boss floor has no wild slots, so there is nothing for a credit to buy. Charging one is charging for a spawn that does not exist. |
| The rate-cap overdraft | “Is this the first clear of this boss?” — floor === _mfB + 1. | The _mfB ratchet is monotonic, so that equality can be true at most once per boss per account. That is what bounds the overdraft to one; without it the allowance would be renewable by re-fighting. |
bossEdge carried the ratchet clause, and the credit skip was reading it. Every re-fight of an
already-cleared boss therefore spent one of the account's ten spawn credits — the ratchet had already
advanced past that floor, so the equality was false, so the skip did not fire, so a floor with no wild slots on it
debited the wild-spawn budget. The first clear of each boss was free and every clear after it was charged.
The flag is now split, and the ratchet clause lives on exactly one of them:
bossFloor — no ratchet clause. Gates the spawn-credit skip.bossEdge = bossFloor && floor === _mfB + 1 — gates the rate-cap overdraft, still bounded to one per boss per account by the same monotonic ratchet.Both anticheat tests remain on the skip. Neither was relaxed to make the split work: the floor must be
authored as a boss floor in floors.csv, and the submitted event must satisfy the
UNCLAMPED +e.floor === floor equality. The equality is deliberately taken before any clamp,
so a client cannot claim a boss floor by submitting a number that the server would have pulled into range.
Build 0.47. Read this beside “There are two spawn limiters, and the player sees the smaller one” above — the countdown is where that section's arithmetic becomes a number on screen, and it was quoting the wrong limiter.
nextInMs chose which clock to quote with credits < occ. That test cannot fire in the case
that matters:
occ is the count of the floor's occupied slots. When a floor's own slots are all dead,
occ is 0, and no non-negative credit count is less than 0.The countdown is now max(floorClock, credits > 0 ? 0 : budgetClock). The reasoning is the spawn rule
itself: a spawn needs BOTH a refilled floor slot and a credit to spend, so the honest answer is the
later of the two clocks, and a credit already in hand imposes no wait at all — which is
why the budget term collapses to 0 rather than to the accrual clock whenever credits > 0.
There are two limiters and the screen shows the binding one. The account budget accrues one credit per 60,000 ms with cap 10; the floor's own refill runs its own clock off its own last drain. The countdown now names whichever of the two the player is actually waiting on, which is the same rule the pool size already obeys.
Build 0.47. lbUpsert is the only writer of lb.name, and its two
early returns fire before the name-resolution block:
| Mode | Early return when |
|---|---|
max | the incoming score is lower than the stored one |
set | the incoming score is equal to the stored one |
Both are correct as score logic and both skip the name. The consequence is exact: a displayed name is frozen
at whatever it was the last time that player's score moved. A renamed player whose score is stable never
appears under the new name, however many writes go past. /api/name/claim wrote only the names
registry, so nothing else was going to correct it either.
lbUpsert — this is a cost decisionHoisting name resolution above the early returns would put
SELECT name FROM names WHERE steamid = ? on every quiet fold write — the writes that
currently return early precisely because they have nothing to do. And names is
PRIMARY KEY(name_lc): steamid is not indexed. That query is an un-indexed full
table scan, placed on the hot path, which is the same pattern already identified on this page as the cause of rising
read costs. It is the wrong repair even though it is the obvious one.
The rename does the work instead, once, at the moment the name actually changes:
/api/name/claim runs UPDATE lb SET name = ? WHERE acct = ? AND name <> ? across the account's board rows.lb_snap rows for the affected boards, so the next read recuts them.name <> ? clause — so a
no-op rename writes nothing. Writes cost roughly 1000× reads
($1.00 per million written against $0.001 per million read), so a free no-op is not a
nicety here.The snapshot bust is throttled to once per account per 60 s, held in memory on the Durable
Object. Without it, a player alternating between two names would force a global board recut per
request — the bust is cheap to issue and expensive to serve, which is the shape that has to be rate-bound
rather than merely correct. In-memory is sufficient because the object serves one request at a time and losing the
throttle on eviction only costs one extra recut. /api/name/claim sits behind the existing rate limiter as
well; measured, it answers 429 with retry_ms 15000.
Why a bust is needed at all. lb_snap is recut only when the snapshot passes
24 h. Without an explicit delete, a rename would be invisible on the board for up to a day even
after lb itself carried the new name.
Hotfix on top of build 0.47, release commit 208bb79. Already live.
Symptom. The player was on floor 93 on his PC. He logged into his phone briefly, returned to the PC, and landed on floor 57.
/api/save already merges two classes of stale-device damage back up, and both fired correctly on
this write:
| Field | Rule | Flag |
|---|---|---|
maxFloor | Grow-only — the stored value is kept when the claim is lower. | maxfloor_regress |
playerXp, classXp | Grow-only. The rule's own comment names this exact report class: “logged in on my phone and lost 20 levels is exactly this write”. | xp_regress_merged |
floorNum | Deliberately excluded — stored verbatim in the downward direction. | — |
The exclusion is not an oversight and it is documented in the maxFloor rule's own comment:
“floorNum is a POSITION, not progress — the player walks down floors legitimately — so it
is ceilinged but never floored.”
That reasoning is correct about a player and wrong about a stale device. The chain, in order:
ts, so the
ts < cur[0].ts staleness guard passes — the write does not look stale, because its clock is not
old.maxFloor and both XP fields are merged back up by their own grow-only rules, so nothing
progress-shaped is lost. The account still knows the player reached 93.floorNum alone is stored verbatim, at whatever the old blob said.maxFloor of 93.When maxFloor had to be merged back UP — the regression case, specifically —
and the claimed floorNum is below the stored one, the stored position is
kept. Like every other server correction it is reported back in srv, so the client adopts the server's
answer rather than being quietly overruled. New flag: floornum_stale_writer.
maxFloor REGRESSING and not the XP mergeThis is the part a future session would get wrong, so it is recorded as reasoning and not just as a rule.
The XP merge is not a staleness signal. The server credits XP from its own ledger, so a perfectly
current client routinely saves a slightly lower XP number simply because it has not yet seen the last credit.
Keying this rule on xp_regress_merged would hold the position of ordinary players during ordinary
play — a worse bug than the one being fixed, and one that would look like the game refusing to let people
walk.
maxFloor is unambiguous. It is grow-only and the server hands its value back in
srv, so a current device always carries at least what the account holds. A claim below it can only come from
a blob that predates the account's own progress. That is the definition of a stale writer, established
without a clock.
The flag is set on the REGRESSION specifically. The over-claim ceiling clamp writes the same
_srvFix.maxFloor key and is not a staleness signal — it is an honest client being
capped. Testing for the presence of the key rather than for the regression would collapse the two cases.
ceil = max(ratchet + 1, storedMaxFloor). This rule refuses to teleport a player
backwards and does nothing else — it is not a second opinion about the upward direction.maxFloor triggers nothing at all, and its position is taken verbatim. The rule only reaches a
write that has already proved itself stale on a different field.Gate: tools/probe_floorpos.mjs — 14/14. It covers the reported case,
the legitimate walk-down, a current device climbing, the pre-existing ceiling rule, the XP-lag case in
both directions, and a sweep over all 120 claimable floors proving a stale writer can never
lower a stored position.
Status: on release. D: “Logged into my phone and was at level 56. When I logged out I was at level 93 on my pc.”
Two attempts at this were aimed at the save door and both missed. His own account's fault log says why: across two hundred recorded anomalies there is no deepest-floor regression, no stale writer and no missing version check. The server never did anything wrong. The phone adopted floor 93 correctly — and then the resume of a PAUSED FIGHT put him back on 56, because a paused fight lives in the device's own storage, survives adoption, and restores the floor number from its own snapshot.
Every guard on that path looks upward. A paused fight from a floor the account has never reached is thrown away — the anti-teleport rule. Nothing looks downward, because a lower floor is perfectly legitimate for a player who walked down. A floor-56 snapshot on a deepest-floor-95 account passes all of them. And the save that follows is entirely legal: current version, no regression, nothing to flag — which is exactly the shape of the evidence, and why no server-side change could ever have fixed it.
D's ruling, 2026-08-12. Resuming stays LOCAL — a server-held fight was considered and rejected on write cost. “If you change devices then the fight is dropped; if you come back to an old fight it just doesn't start, it goes directly to the current floor that you were on on the last device.” That is what ships: the snapshot now records the moment it was parked, and a boot discards it if the account has been played anywhere else since. The player lands on the floor the last device left them on, which is the floor the adopted save already carries. Zero server writes — the whole rule is a comparison of two timestamps the client already holds.
It costs the player nothing. A spawn credit is spent, and a slot marked dead, only on a CREDITED KILL — parking a fight does neither, so the creature is still standing on the floor when the player returns. The one recorded refusal on D's account is the opposite order: a fight parked on a slot, that slot later killed for real, and the stale fight then resolving against a corpse — refused with no reward, which is the correct outcome for a fight that should not have resumed at all.
Snapshots written before this stamp existed carry no parked-at time and are judged by the existing rules exactly as before, so nothing breaks mid-rollout. Reproduced on D's own save before fixing: a floor-56 parked fight moved a deepest-floor-95 account to floor 56; after the fix it stays put, and an honest paused fight on the same device still resumes.
A dismantle pays Ichor, and the claim rides the next save as a queue of dismantled rows. The worker prices each row; the player's object then verifies each one against its own previous save: the uid must have been held (bag, added items, the two party slots, or a socketed soul's source), must be gone now, and its category, grade, tier and quantity must match what was held. A row that fails any of these earns nothing — the dismantle itself is never refused, only its Ichor allowance. This guard is watch-only and one flag per account per kind per day.
2026-09-02 (report #703, LIVE on release since 2026-09-02 (0.68)): an unpriced claim is PENDING, never zero.
When the Worker cannot read the codex tables (the dismantle and breeding CSVs) it hands the claim to the
object unpriced. The object used to verify and spend every row anyway and pay zero — the
reply read ok 60, bad 0, allow 0, the client took the 200 as a receipt and withdrew the rows,
and the income was unbooked forever: the honest wallet then tripped the Ichor ceiling on its next save
(D's own account, five clamps in two minutes; reproduced on his seed: 60 creatures, 8,505 Ichor, one save).
Now an unpriced claim is left exactly where it is — nothing verified, nothing spent, nothing booked
— the reply carries dism.pending so the client keeps carrying the rows to the next flush,
one flag (dism_unpriced) names the outage, and the Ichor ceiling is not measured on that save
(the claim is pending, not forged; the next priced save settles it). Gate tools/probe_dismoutage.mjs.
2026-08-20 (the Ichor-clamp batch, RELEASED IN 0.56): the grant-record fallback now accepts every origin the server itself stamps — seed, craft, drop, breed, bred, boss, hatch and grant — not just the four that existed when #153 shipped; unsourced stays refused because it honestly means nobody knows. The two refusal reasons are distinct (absent-no-grant for no record at all, origin-<stamp> for a record whose stamp is not accepted), so a missing record can never again be conflated with a rejected one — the conflation is what let honest hatch-and-dismantle and boss-egg dismantles clamp four accounts at the Ichor ceiling. The declaration queue caps at 800 rows on both ends: the authored caps are 600 souls + 160 items, so the largest legal single-window purge fits whole, where the old 400 clipped honest Adam-and-Eve runs and the dropped rows had paid the client with nothing declared behind them (the 08-15 clamp shape: 40,132 cut beside a working 71,955 allowance). A list arriving at the cap raises the watch-only dism_queue_full flag. food_clamp resized 2,500 → 5,000 in gen_tables.py — honest kept-overs had reached 2,450 of the old band; 5,000 is six best-case fights. Admin grants are sourced from birth under the same batch (see admin-backend). Gates: probe_dismorigin 9/9 (red 4/8), probe_dismqcap 8/8 (red on 400), probe_grantbook 9/9; dismwindow 10/10, ledgerrace 13/13, rollbook 5/5, breedbook 5/5 re-run.
2026-08-20, later the same day (RELEASED IN 0.56): the dismantle CALL. POST /api/dismantle is the "send immediately" tier made real: the Worker prices the claimed rows exactly as it prices the declaration queue (client price stamps overwritten, never merged; fails closed, unlike the save door — an unpriceable dismantle has simply not happened yet and the client keeps the item), and the player’s object verifies each uid by the queue’s own two-path rule: held in the stored save with identity matched, or absent but backed by an unspent grant with an accepted origin. Acceptance spends the record (gone, with gone_src=call as the channel marker) on both paths, books every currency on the queue’s exact keys (source dism, ref uid:cur) so the two doors are structurally unable to pay one uid twice, and the Ichor slice lands in ledger.food_total the way battle income does — the ceiling covers the wallet with no envelope at all. Replay by idem answers the stored result verbatim (the hatch pattern). The client fires the call after its local flow, fire-and-forget, keeps the queue append as the fallback carrier ("a lost call leaves the item; the next flush heals it"), and withdraws accepted uids from the queue on the answer; a stray re-declaration of a call-paid uid settles quietly at the save door — ok, unbooked, unflagged — while a queue-spent grant replayed at the queue still flags grant-spent as shipped. Gate: probe_dismcall 11/11 end-to-end; dismwindow 10/10 twice over, ledgerrace 13/13, rollbook 5/5, breedbook 5/5, dismorigin 9/9 re-run.
2026-08-13 (report #133, ON DEV): the flag used to say only
"4 of 32 unverifiable" — a count with no WHICH and no WHY, and the count alone ended two
diagnosis theories in retraction. It now names the first bad row's uid prefix and its reason
(no-uid / absent-from-prev / still-held / a field mismatch with both
sides' values), which is enough to pull the save pair and settle the cause. Nothing else changed: same
single flag write, still gates nothing.
dfmc-codex, on https://codex.playdfmc.com/ — a custom domain on the playdfmc.com zone beside the game and art.playdfmc.com — configured by wrangler.jsonc in this repo and published by tools/deploy_codex.sh: the checkout is sparse and blob-less, so the script exports an archive of HEAD into _site/ minus what nobody serves (codex/images/originals, the ground-plate png masters, _parking, archive, tools, scripts — the same filter the Pages workflow applies; .assetsignore is the second copy), 4,850 files / 826 MiB against the platform’s 20,000 files and 25 MiB per file, and an unknown path answers 404.html. GitHub Pages (eccestanczyk.github.io/dfmc) keeps deploying the same tree and keeps serving it — the dev client’s live csv and Image_Path reads (PAGED, loadFloorData) still go there, and old links keep working — but a page opened there forwards to the same path on the new host, search and hash intact (nav.js, first thing in its IIFE; pages-redirect.js for the eight pages that do not load nav.js). In the game, the codex the player opens — Settings > Help / Privacy / Terms / Credits (SETDOC_URL) and the info panels’ “more” anchor (infoHref) — builds on one const, CODEX_SITE, release and dev alike. The release client’s data and art host is unchanged (R2 at the DATA_PIN, entry above). The deploy is a release-ritual step (dfmc-client docs/release-ritual.md step 12) and documented in this repo’s README. Probe tools/probe_codexhost.py 3/6 → 6/6; probe_settings re-aimed to the new host, 9/10 → 10/10./api/auth/providers answered google:false until the Google credentials went onto both workers that evening (T51) — the gate already hid every door the deployment had no credentials for, and the live gate shows Google and Discord today. What changed: the doors stand Steam, Google, Discord, Apple (Apple appears the day /api/auth/providers says apple:true), all in the Steam button’s style, and the gate’s six strings (tagline, four doors, footnote) are gate_* rows of codex/strings_en.csv and strings_pt-BR.csv. The gate paints before app.js and before any codex csv is read, so tools/l10n_gate.py --bake bakes those rows for every locale into play/index.html (window.__GATE_L10N), the way it bakes English into app.js, and the gate picks the locale from the device language exactly as app.js’s l10nDetect does (there is no save, so no Settings pick, before the sign-in); the l10n gate fails when the baked block drifts from the csv. The Steam shell is untouched: there the sign-in is the session ticket and the gate is not meant to show (steam/INTEGRATIONS.md section 1). Gate tools/probe_googlebtn.py 5/11 → 11/11.codex/*.csv, the slash sprite, the Image_Path art, ~1,900 files — is served from https://art.playdfmc.com/<DATA_PIN>/<path> (R2 bucket dfmc-art, a custom domain on the playdfmc.com zone, immutable for a year per pin), put there by tools/push_art_r2.py at each pin move (keys are <pin>/<path>; old pins are removed by hand; it replaces tools/warm_art.py). The order of reads on release: the codex door (/api/codex/<pin>/, 2026-10-23 below) for csv, then R2; jsDelivr at the same pin is the fallback only — an image the bucket lacks hops to jsDelivr@pin and then @main as before, the csv second chance is jsDelivr@pin. A clean release boot makes zero requests to github.com, raw.githubusercontent.com or jsDelivr; the 2026-09-14 refusal of cold pins (#913/#914, “Package size exceeded”) is out of the release path. Dev (/dev/, live main) and the Steam shell (the bundled tree) are unchanged. Probe tools/probe_artr2.py (1/6 before, 6/6 after).starterIdsOf): classes.csv Starter_A / Starter_B, Common stage-1 ids (index.html “Starter classes” names them), with the old Starter_SR / Starter_SSR names read as the fallback only while the bake at DATA_PIN still carries them (a row naming no pair is the existing 503 class_data_unavailable). Nothing else at the door moves: one mint per account, server uids and genders, the Worn grade, the four-row set. Probe tools/probe_starterdown.py (7/11 before, 11/11 after, on a rig whose bake carries the checkout csv).__starter_<realm> key is absent, and that key, like every kv row, survived /api/account/reset. The creation that followed a reset therefore skipped the mint — no srv.starter, no ledger rows — and the surviving __inv_seeded_<realm> key told the worn-loadout check the account was seeded, so the client’s four proposal rows, whose uids the emptied ledger never held, were taken off (party_unminted): the hero entered floor 1 with an empty loadout and two empty party slots (“— Lv0”, DOWN). The reset now deletes both keys with the items they describe, so the post-reset creation mints like a first one. Probe tools/probe_ftuemons.py (15/20 before, 20/20 after).save_genesis and 1139 floor_oob the same minute). On dev; the server change ships to both branches. Two mechanisms were open, both on the login path above. The dead-generation slot: the shell’s generation sweep read only the legacy key (which the app deletes on every boot) and the app never compared its own slot’s gen with the boot’s, so a slot written before the reset — the reset removes it, but the reloading page’s own pagehide flush could write it back — booted as the run, the snapshot re-stamped the current generation (the server’s gen fence cannot see it) and the client entered floor 119 and offered the floor-119 save as a birth. Now the app checks the slot’s generation on boot: below the boot’s it is kept aside (_preserved_, ops can restore it), dropped, and the player starts fresh; the reset stops the dying page writing anything before the reload. The unborn shell: a flush while character creation was open (a reload on class selection) sent the heroless default state as a legacy body; the account held no save, so the door stored it as the birth (ver 1, no heroName). From then on the client was on v2 (a stored ver) and no intent carries heroName / charSoul / clsOwned / equipOv: the creation could never reach the server, every reload re-adopted the shell and opened class selection, the class switch was refused not_held against it, and the shell’s empty equipOv is the empty party. Rules now: nothing goes up before the birth (the client sends no save while its state has no hero); the door stores no heroless legacy body on an empty account (answers {ok, unborn:true}, writes nothing); a legacy body with a hero arriving on a stored save without one IS the birth — the genesis ceilings judge it and the stored ver advances — instead of 410 save_upload_retired; the client stays off v2 while the adopted save has no hero, so the creation goes up as the birth body and v2 resumes on its ver. An account already holding a heroless shell (D’s) is repaired by its next character creation; no restore is needed. Also: the wor_migrate guard has its description (auto 1140), and a schema sweep with nothing to rebuild (zero tables planned) logs and no longer flags — it is the normal boot of a fresh object, not a detection. Gate tools/probe_bootreload.py 12/12 (3/12 on the pre-fix tree)./api/transfer/* door, pubkey included, answers 404 {error:"transfer_parked"} from the generic /api/ gate — before the key check, before the session — unless the Worker var TRANSFER_OPEN is 1; wrangler.jsonc carries no such var, so both live workers are parked, and .dev.vars sets it for local rigs. The doors below stay as the expansion’s base. D’s rules for the expansion, same answer, verbatim: “hold off on the creatures for a future expansion. Creatures transfered to a different game retain mutant status, grade. But they start at lvl 1 on the new game. They have a World traveler mark on their sheet to show which other games it has visited” — against what is built: level 1 on arrival (today the Dungeon maps the Tower level 1:1), mutant and grade retained (mutant is mon.tower.mutant metadata only today), a World traveler mark on the sheet listing the games visited (replaces the “Dimensional traveler levels” line). The badge at 50 is not in his new words — an open point for the expansion. Spec § 0 of docs/creature-transfer-2026-11.md. Gates: tools/probe_t48_transfer_srv.py 9/12 → 12/12 (the three parked checks), tools/probe_t48_transfer_ui.py 12/12 under the open switch (W1, no bridge: no SEND, no line, no call, is what 0.92 shows), the Dungeon’s tools/tests/transfer_import.mjs 37/39 → 39/39./api/account/reset) was a partial reset: the server kept every Wraithgold row, the bought slot caps, the entitlements and the weekly trades (“the ledger survives it”), and on the World only the hero name was freed (T30), so the badges and their holder counters, the keep-best board rows (climb, power), the Competitive and Auto PvP ratings, the defense snapshot, the season rows, the won-with lineups and the day’s tickets all outlived the reset. Now the player’s own reset has the same scope as the epoch wipe and the GM wipe (full), with two things that stay: a Wraithgold row with paid provenance (T3: money paid is never deleted by a reset — the spend rows go with what they bought, so the paid credit reads back as balance) and the login itself (accounts, identity, sessions). The Shadow Trader’s standing answer and the Codex’s Defeated stamps now go on every reset path. The World half rides the same relay the name used: the account’s badge rows go and each badge’s holder counter moves down by exactly the rows removed (the holder % stays a maintained counter), the board rows and their ranks go and the touched boards’ snapshots are recut on the next read, and the ratings, defense, match, season, won-with, ticket and challenge-code rows naming the account go. Kept: account history (anomalies, flag_rept, acct_log — T39), the idempotency tables (a replayed reset still answers the first generation), the Steam avatar, locks and bans. Gate tools/probe_t61_resetall.py 4/11 -> 11/11.docs/creature-transfer-2026-11.md). On dev; the server change ships to both branches. Three doors: GET /api/transfer/pubkey (public) answers the Ed25519 public half of the new Worker secret TRANSFER_SIGN_KEY; POST /api/transfer/dungeon {uid} judges the player object's OWN items row (cat Monster, not gone), marks it sent/sent_src=dungeon without spending it (Tower → Dungeon is a copy), signs a v1 record (uid, species, line_id, stage, level, grade, mutant, sex, origin tower, an opaque account hash, ts) over its canonical JSON (keys sorted) and keeps it in transfers; a repeat answers 409 already_sent with the same record and signature (idempotent, nothing minted twice), a uid the player does not own 404 transfer_unknown; GET /api/transfer/sent lists the sent uids. The secret missing answers 503 transfer_unconfigured at the doors only, never the boot refusal. The shell's shared-folder write, the creature sheet's Dimensional traveler line, the badge award and the Dungeon import are the later lanes. Gate tools/probe_t48_transfer_srv.py (2/9 -> 9/9 on a local wrangler: a tampered record fails verification, the creature stays in the bag)./api/save still stored a client-written full save onto an account that already held one, and the Worker ops switch SAVE_UPLOAD=0 refused every such body including the birth save, so a new account could never be born. Now, after the generation check and before the genesis rule, a legacy full-save body onto a stored save is answered 410 save_upload_retired (carrying the stored ver) and nothing stored is touched; every later write is a v2 intent batch. A brand-new account’s birth save is still judged by the genesis rule, and a stale tab after a wipe is still answered {wiped:true}. The SAVE_UPLOAD switch is removed, and so is the client’s ht_save_v2=0 OFF switch. Gate tools/probe_savedoor.py (9/14 -> 14/14).codex[].defeated (the Codex's “seen” state) was the client's alone - it is now the server's too: the battle credit stamps the species of every kill the server derived (a grow-only cx_def row; a boss trio carries no species), and the save door writes defeated for every such species and every species the server stamped acquired (the client's own hatch / evolve pairing). A save's existing defeated marks are never taken off. Gates tools/probe_saderive.py (3/7 -> 7/7) and tools/probe_sa_legal.py, whose GAP lines now fail.not a fresh start on every save — writes halted and auto-reported on every reload for two days. The client now takes that refusal, while the server holds no save for the account, as the server’s word that the account is empty: the dead device save is kept aside (<slot>_preserved_<ts>, restorable through the ops door), dropped with its parked battles, and the page reloads into character creation. The server is unchanged. Gate tools/probe_savewipe.py.economy_params.csv (Key, Value): WG_Seed 100, the one-time alpha grant the server mints. The bag floors 80 / 30, blocks 10 / 30 and ceilings 400 / 600 are derived from inventory_slots.csv / soul_slots.csv (first row less one block, the block, the last row) by the worker (from the bake: capsOf runs in the DO) and the client. No number changed. A pin that predates the row keeps the old value and names it (never a refusal); it reaches players when DATA_PIN advances and the bake runs. Gate tools/probe_hardcode3.mjs 33/72 → 72/72.clamped; the client only understood the word, so it kept its own map and sent it again on the next save, raising the same flag every save. It now takes the server’s map whole. No rule changed.discord:… / google:… / apple:… / steam:… picks one; an id nobody owns answers “no account”. Gate: tools/probe_ops_uid.py 3/10 -> 10/10.tools/probe_ban_gaps.py 15/16 → 16/16.tools/probe_ban_gaps.py 3/13 → 13/13.STEAM_WEBAPI_KEY while real-money purchases read STEAM_PUBLISHER_KEY, but it is the same key. Everything now reads STEAM_PUBLISHER_KEY. The old name still works for one release, and the worker logs a line asking for it to be renamed. Neither name may be stored as a plain config value (tools/check_secret_vars.py). Gate: tools/probe_steam_key_name.py 2/6 → 6/6.tools/probe_steam_bans_doors.py 6/16 → 16/16.tools/probe_a8_split.py 20/28 → 28/28 on D’s save and a fresh account (a save leaves and keeps the header, the boot answer byte-identical; then a second worker build with the switch off still boots a header, rehydrates it, and writes a full row again).bequests, hcSeasonActive and the item-scan stamp _itRid) were missing from it. A catch-all rest part now holds every other field, the stamps moved into run, and every one of the ~40 places that read the stored save goes through one reader that answers from the parts when they are current (one database statement, no extra parse). Stage 5 is built but switched OFF: when on, a save writes its parts and a header row with no copy of the whole save, one row fewer per save (measured: the save doors 184→183, 61→60, 5→4). An ops door retires one account to the new shape or rehydrates it back (the rollback). Gates: tools/probe_a8_split.py 16/22 → 22/22 on D’s save and a fresh account (old shape → new → old, the boot answer byte-identical each step); writecost green with the switch off and on.codex/achievements.csv (Ach_ID, Steam_API_Name, Name, Kind, Arg, Hidden, Description) names nine — floors 10/30/60/100/120 and Void Completionist read the player’s profile badges (the same award events), the Codex one counts 300 Acquired species, the mutant one needs a mutant hatch, the first-Void one a Void-grade creature minted by the server. GET /api/steam/achievements answers the earned list and the Steam shell sets each one Steam does not have yet; the page cannot add one. Rich presence shows “Floor 87 · Void Apex” to Steam friends; Timelines markers are dropped at a boss kill, a Void hatch and floor 100. A gamepad / Steam Deck default maps the sticks and trackpad to the pointer and the buttons to the game’s default hotkeys (A autoplay, E equipment, I inventory, L lock, ESC). Game Bans (ICheatReportingService) are filed by ops only on the server’s own suspect-class anticheat flags of that account, logged, and liftable on appeal. The web build is unchanged. Gates: tools/probe_t22_ach.py 4/20 → 20/20, tools/probe_t22_shell.py 2/12 → 12/12.429 ks_slow (tier 1) or 503 read_only (tier 2), so a refused feedback write or a slowed board just failed silently. The client’s one fetch wrapper now shows a small non-blocking line (“Some features are slowed / read-only for a moment”) and clears it on a later normal answer; it never retries. A 503 maintenance (tier 3) reloads the page once so the server’s maintenance notice shows, and a second one within 5 minutes keeps a line instead of reloading again. Gate: tools/probe_ks_banner.py 3/8 → 8/8, tiers forced on a local worker./api/admin/killswitch. The thresholds live in config and can be changed at run time.
Tier 1 slows the non-essential doors. Tier 2 also makes the non-play doors read-only and sheds diagnostic flags. Tier 3 shows the maintenance page to everyone except the ops doors. The game itself keeps working at tiers 1 and 2. Probe tools/probe_a12_killswitch.py: 19 green / 22 red before the change, 41 / 0 after. With tier 1 forced, the legal-play gate (probe_sa_legal) still passes. The route gate has rows for the new door.account_conflict choice); sign-in, /api/account/link and /api/account/merge all go through it, and a merge moves every login of the losing account in one step. Link and merge tickets are unchanged (one verified login, spent once). Existing accounts keep working: an old index row is copied into the ledger the first time it is read, and a one-time backfill (one page per cron tick; ops POST /api/backup/wledger) copies every account’s logins, never overwriting a newer claim. Sign-in reads one ledger row in place of the old index object. Probe tools/probe_a13_world_ledger.py 0/7 (pre-ledger worker) → 7/7.POST /api/battle now answers ichBal (post-wipe: the ledger’s Ichor after the battle’s own row) beside ich, and a v2 client shows that balance outright instead of reconciling a delta against its estimate. POST /api/unlock sells the Ichor pens too (pen_unlocks.csv price, ledger debit, the pen written into the stored save with a version bump); the breed screen’s egg dismantle calls POST /api/dismantle at the click; the breed meter no longer moves the v2 wallet (the egg’s cost is booked at the egg ask). Every new write goes through wgPost, which drops the derive’s cached sums. See system-breeding. Probe tools/probe_ichor_doors.py 5/11 → 11/11.docs/security-review-2026-10.md, SEC-06/07/09/10/11). The login-link ticket a provider callback leaves is now used once: a link that lands spends it, and a conflict hands it back because the merge choice spends the same ticket. A merge takes its ticket before it does anything, so two merges sent at once cannot both run; a merge refused before it writes keeps the ticket. Reset Progress sends a key; a double click or a retried send resets once. Only the boot read claims the save: it sends a header (x-ht-boot) that a link or a form on another site cannot add, so opening a stranger’s link to /api/save no longer knocks the open game off with “this window no longer owns the save”. The ops doors that write answer POST only.maybeMaintain) deleted drops_granted (a credited battle's drops) at 30 days and matches (a PvP match and its verified winner) at 14, and the dismantle and devour doors deleted their own 30-day result rows (dism_calls, devours) on every call, so the A19 Download My History only reached back a month for drops. All four deletions are gone. wallet_ledger, item_grants and trades never had a retention prune; only the wipe clears them. Still pruned, because they are not history: events_seen (the battle replay key and its cached answer, 7 days; the credit itself is in the ledger, wallet_ledger and drops_granted), anomalies (ops evidence, 30 days; audit kinds are kept forever in the World's acct_log), expired sessions, the items tombstones (reconciler state, 30 days, that every live-items read walks past), rolls/roll_att (roll replay, 7 days), hatches (released only when the egg's item row is gone) and save_hist (the last 20 save snapshots). trades gains its (steamid, realm, ts) index in the once-per-object schema block. /api/me/history now answers kept: 'forever' for every source and the download confirmation says nothing in the history expires. Gate tools/probe_t39_retention.py red 7/14 → 14/14.docs/security-review-2026-10.md). The edge now drops every header that only the Worker may set before any object sees a request. Writes from a browser must come from the game’s own origin: playdfmc.com, which is also what the Steam shell loads. The Worker checks Origin first and falls back to Referer. Logout and the PvP matchmaking doors answer POST only. The ops key is read from a header; for one release the URL form still works and is marked deprecated. Sign-in and the save door gain a Cloudflare-enforced rate limit on top of the per-isolate buckets. The admin feedback-status door matches a report by (kind, id). Nothing a player sees changes.DATA_PIN (raw.githubusercontent as the second chance), so a boot depended on a third party being warm: #913/#914 (a cold pin, floors.csv refused, boot halted), the 2026-08-17 GitHub incident, every pin move since. The worker already carried the pinned tables in its bundle. Now GET /api/codex/<DATA_PIN>/<file>.csv answers those baked bytes: read-only, the bundle’s own pin and a baked file only (anything else 404, any other method 405), cache-control: public, max-age=31536000, immutable (the pin is in the URL), no session (the tables load before sign-in and are public at the pin), and no Durable Object wakes (it answers before the session gate; never soft-blocked or escalated, it pays only the per-IP blanket token). The client asks the door first and falls back to jsDelivr / raw on any failure; dev (live main) and the Steam shell (its bundled tree) keep their own reads. The bake now covers every table the client names too: 41 files, +8 client-only (bosses, move_vfx, eggs, move_icons, fx_bank, afx_bank, afx_events, bgm). Art is untouched and still comes from jsDelivr at the pin, so the warm-the-pin step of the release stays. Gate: tools/probe_codexdoor.py 2/10 → 10/10./api/_internal/cursor read) answered 500 with SQLITE_LOCKED, and its WITHOUT ROWID sweep failed on items, drops_granted and wdel on every wake. Cause: the constructor's guarded-ALTER probes (SELECT .. LIMIT 1 on apvp_tickets / apvp_match) were never drained; on a table with rows the cursor stays an active statement, and SQLite refuses DROP TABLE while one is open. Player objects hold no tickets or matches, so only the World was hit. Rule: every probe cursor is drained (.toArray()) before any DDL, and a retired-table drop runs in the constructor's blockConcurrencyWhile beside the schema sweep, gated on sqlite_master, never inside a request. Gate: tools/probe_world_locked.py 2/9 → 9/9; prod tail 180 s: 0 × 5xx, cursor reads 200.floor intent is now the whole floor change, as on the client: the new floor's streak starts at 0 (the server's clear count lifts it when that count is on this floor) and maxFloor rises to the floor stood on. It used to keep the last floor's streak, so a fresh account on floor 4 read as cleared, auto-advance walked to floor 5 with no floor-4 win and the floor door refused it (403 floor locked). A piece the loadout moves to the bag takes its category from the server's item record: equipment slots carry none, so an unequipped starter could not be dismantled. The derive that builds the stored save from the server's records no longer fails on every v2 batch (it read row ids the item tables no longer have); new drops join the bag and a dismantled piece leaves it. Its read of "items minted since the last save" is now an indexed range (new indexes on the item and drop records’ mint times), not a scan of every item on every save, so that read no longer grows with the bag. Gates: probe_sa_v2_on 19/19, probe_sa_legal now also plays floors 1-6, an unequip and a dismantle on the v2 body.ht_save_v2='0' is the ops OFF switch (the legacy full-save body, unchanged). v2 starts once the server holds a save: the birth save of a new account still goes as the legacy body the starter door judges. Boot queues nothing (a uid the client stamps on uid-less gear is not a move). The prefs blob is restored from the boot read even with no local slot, and from an answer when nothing changed in flight. eggHide is prefs: it names pen-preview eggs the bag never held, and no count on either side subtracts it. A row dismantled in a v2 batch no longer rejoins the bag through the derive.codex/opening.csv row 2 (materials_drop, Selection currency) was flagged opening_sel_unbuilt and granted nothing - the last flag the legal-play gate raised on a fresh account. Now, on the account's first floor-2 clear (the server's own clear record) the server books wallet_ledger rows for exactly one first upgrade attempt of the opening weapon, priced from grading.csv (Gear T1 Common→Uncommon: 1 Scrap), once per account (kv opening:<realm>:materials_drop, ref opening:<realm>:materials_drop:Scrap), and the battle answer carries it as opCredit; the client adopts the balance and prices nothing. The row's Family said Ichor, which the first attempt does not cost: it now says Scrap (ITM-0391). A save that claims more Scrap than the ledger holds is corrected to the ledger (post-wipe). Gate: tools/probe_t42_materials.py.floor (the floor you stand on - any walk down applies, a floor past the server’s reach, one past its ratchet or the stored best floor, is refused); classAdvance (the class stage you acknowledged - one stage at a time, and only once the class’s level on the server’s class-XP ledger has reached it, 33 and 66); pen / unpen (a parent placed in or taken out of a breeding pen, or a pen paused or resumed - only into a pen the account owns, only a creature, and a pen runs only on a pair the server holds). A piece taken off a stack (it had no id of its own) now names the stack it came from: the server checks the stack has one to spare, gives the piece its own id, opens its ledger row and the answer hands that id back. A seated soul is now found by every intent that names it (an advance on a soul-slot creature used to be refused as not held). The tutorial arrows’ seen-flags ride the stored preferences instead of the save. Every intent still waiting for the server’s answer is kept on the device, so a change made just before a reload or a lost connection is sent after it rather than undone. Not built here: eggHide (it hides an egg the server cannot see, so there is nothing to prove it against - a design question), and pvpTeam / hatchLv, which no client action writes.equip, unequip, sock, unsock, soul or classSwitch - listing every piece that moved into or out of the loadout and where it now sits (a gear or companion slot, a soul slot, a socket, the bag, or a socket folded into a bag piece). The server moves its OWN records in the stored save: every moved piece must be one the account holds, once, whole and not in a pen, or that row is refused and the rest of the batch still applies; the placed record is the server’s, only its display text comes from the client; a piece the row displaces goes to the bag. A class switch needs a class the account owns and banks class XP and advancement per class. Switching class now returns every stripped piece and loose gem or rune to the bag as itself (its id), not as a new copy. The full key-by-key list of what v2 still needs is docs/server-authority-intents-2026-10.md in dfmc-client.bossKeys is no longer client state: it left the run keys, the state defaults and the save body; the boss plate’s key count is the bag’s Key rows. adoptSrvFields now takes srv.streak; srv.party empties the slots the door emptied (only where the slot still holds the piece that save sent, so an equip made in flight stands) and puts a weapon of another class line back in the bag; srv.floorMap drops cleared/bossDown floors at or above maxFloor; srv.crXp (answered only as “clamped”) stops crXp riding the save for the session so the door keeps its stored map. A corrected value is not re-sent, so it is not re-flagged. Class switch already strips all eight gear slots to the bag (no foreign weapon stays worn). Shell: the boot splice copies only the floor pools now (the adventure-log and battle-log keys are gone). Gate: tools/probe_sa_adopt.py 10/15 → 15/15.srv names what moved. (1) Party: a worn uid (equipOv/soulOv) new to its slot must be a live items row of the account, or it is taken off (party_unminted); post-wipe a worn piece with no uid is taken off too. A weapon whose items.csv Family is not the class line of the server-owned charSoul is moved to the bag, not worn (party_unwearable). One statement per save, and only for pieces that changed slot. (2) Boss keys: the bossKeys counter is no longer stored; the bag’s Key rows are the count, and a non-zero claim is answered with them (bosskeys_counter). (3) Codex Acquired: confirmed server-owned — the mutant-odds bonus reads cx_acq, never the save. (4) Ceilings: crXp may not rise above the stored map (there is no per-creature XP ledger yet; crxp_over_ledger); streak may grow only by the battles the server credited since the last save (streak_over_ledger); a cleared/bossDown floor above the progress ratchet is removed (floor_map_over_ratchet). Confirmed unchanged: post-wipe sockOv with a uid-less stone is still refused (sock_unledgered), grantAck is echoed as stored, and a save never rewrites an items row’s grade, name or qty. Proof: tools/probe_sa_save.py 22/37 → 37/37; write-cost gate save tick unchanged./api/advlog/* answers 410 advlog_removed and writes nothing (the CRAFT-OUT way); /api/admin/advlog/start|status and the GM console panel are deleted; /api/backup/advlog/list|get answer 404 advlog_removed; the world drops its advlog_up/advlog_dev tables; account deletion no longer walks the advlog/ R2 prefix. Existing R2 advlog/ objects are never read again and expire under the 180-day retention sweep. The server-side record that stays is the account action log (acct_log) and the battle telemetry. A save still carrying advChapters saves; the save door strips it. Removed code: archive/adventure-log/worker-advlog.js. Gate tools/probe_advlog_server.py 5/12 → 12/12.BATTLE_PW_MARGIN), and the event id is unseen. A result that passes is credited whether or not the /api/floor/enter message ever arrived; the missing enter is recorded as a battle_noenter flag and nothing else (before this it was already flag-and-credit; that stays). A result that fails any of the four is not a lost message, it is impossible, and is REFUSED - credited:false, refused:'battle_impossible', why - before anything is spent: a floor past the reach used to be clamped to ratchet+1 and paid (and the climb board took the clamped floor), a species the table does not list used to be clamped to Common and paid, a near-zero loadout used to be flagged battle_pw_bad and paid. The refusal is stored on the event id like a credit, so a retry answers the same thing and the chain head still moves. T42: the Ichor a credited battle pays is now a wallet_ledger row (source battle, ref <event id>:Ichor, the amount from the kills the server derived, never the body's), one row per event by the idempotency index; food_total keeps its running sum for the save door's ceiling. A battle pays no Scrap/Dust/Essence/Ink and the client credits itself none on victory. F097: the streak toward a floor clear is the server's count (one kv row on the player's object: credited wins on the floor vs floors.csv Wins_To_Clear; a credited win elsewhere restarts it, as the client's counter restarts on a floor change; losses never reach this door, so they do not reset it), the client's fc:1 is ignored, the first-clear key is granted only on the server's own justCleared, and the answer carries streak / cleared / justCleared / need, which the client adopts over its own prediction. A16: headOk is gone from the answer and the stored replay (no client ever read it; the chain check stays a server-side flag). Gate tools/probe_sa_battle.py 8/18 → 18/18 on the local rig; write-cost battle_f1 re-pinned (writes 18 → 23 on the first-touch sample: the Ichor row and the one-time clear-row seed; reads 43 → 47).SA_V2() on (localStorage ht_save_v2=1, window.__HT_SAVE_V2, or the client itself after a 410 save_upload_retired) serverFlush POSTs {v:2, seq, realm, intents, prefs} and no game state: the three queues (_dismQ/_advQ/_bufQ) go as dismantle/advance/equip|sock|unsock intents in the order they were taken (every queue row now carries ts), each with one client-minted uuid kept on the row so a resend carries the same id; seq is per account, monotone, persisted locally and continues from the last answered seq across a reload (the boot read carries none; a device behind the account learns the server’s seq from a replay answer and re-sends under the next, the ids making that a no-op). The client adopts the answered state on every reply (saAdopt: the save’s state keys minus device data and prefs, the bag replaced, a still-queued dismantle keeping its prediction), drops every applied/dup/refused id from its queues, shows each refusal once in plain words (“The server refused dismantling X - you no longer hold it. The change was undone.”, no retry), and treats dup as applied. prefs = settings, invBag, pvpTab, rankTab (≤ 16 KB, settings-only past that), adopted from the boot read’s prefs on load. A legacy save answered 410 turns v2 on and re-sends the queues as intents at once. Flip rows: F043 (the load no longer widens soulCap; the stored cap is adopted), F113/F130 (drainGrants deleted), F121 (client side: nothing writes entAck; the server’s read is the save-door lane), F037 left open (the lazy autoplay lever and the battle-entry flush stay; the v2 tick keeps one batch per tick). The switch is off by default: the worker’s v2 path takes no state, so until the door lanes make every other mutation an intent a v2 tick would lose floor progress, pens, XP and every spend to the next adoption. Boot still reads GET /api/save exactly once. Gate tools/probe_sa_flip_client.py 8/19 → 19/19 (real client on the devserver passthrough against the local worker)./api/save POST (same URL, same rate class) now also accepts a v2 body {v:2, seq, intents:[{id,t,...}], prefs}. The server clones its own stored save, applies each intent in order against it — dismantle, advance and the equip/socket family, the same three queues the legacy save carried, through the same verify-and-book code — refuses only the impossible with {id, why} and lets the rest apply, and answers {seq, applied, refused, state}: the state as the server now holds it, which the client adopts. A batch at or below the last applied seq gets the stored answer and applies nothing; an intent id already applied is a no-op, never a second credit (the last 2,000 ids per account). prefs is the one client write that survives: a 16 KB-capped blob the server stores unread and hands back on the answer and the boot read. The legacy full save stays accepted until SAVE_UPLOAD=0, when it answers 410 save_upload_retired and the stored state is untouched; the flag flips at the release after the client lane. Gate tools/probe_sa_flip.py 6/19 → 19/19 on the local rig.tools/writecost_gate.py boots the real worker on the local rig, restores D's save and drives one honest request per door (boot GET /api/save once, the save tick, floor entry and a floor-1 fight sampled five times, hatch, dismantle, grade roll, breed start, class buy, Auto PvP queue, the climb board, /api/me, a fresh account's starter boot and first save), sums the WRCOST lines each request fans out to and compares them to tools/writecost_baseline.json: a door may not write more rows than the baseline (the sampled fight may grow by its own drop-roll spread), may not read past the baseline plus one batched SELECT (one row per item the save carries), may not add more than one statement, and may not newly read every row of its top table; RATE, RATE_READ_ONLY, BUDGET, TRAFFIC_BLOCK_N and cpu_ms are pinned too (--accept re-pins them, the report justifies it). Runs inside tools/gates.sh on every commit; --baseline re-measures after the flip. The baseline itself is the cost table before the flip: the save tick 61 rows written / 257 read for a 47-item save, a first save after a restore 181 / 1175, a newborn's first save 70 / 1208, the fight 7-19 / 22-44, the climb board 1 / 3, /api/me 0 / 3.codex/opening.csv is read like unlocks.csv (openingTable: pinned ref, hourly cache, fails open with one OPENING_OPEN line) and rides the battle event as _opening; openingDrops mints a row's item once per account on the first credited non-boss victory with the ratchet past the row's Floor, only while the ratchet is inside the opening, claimed first in kv (opening:<realm>:<beat>), through the first-win row shapes plus class_weapon (charSoul → the class line's main-hand weapon), a server uid in items, into the same drops array tagged op:<beat>. The first-win fw tag now rides only the rows that carry it, so an opening row never reads as a first-win bonus. The materials beat (Selection currency) is not built: flagged opening_sel_unbuilt once, nothing minted; the section above. Gate: tools/probe_t6d_opening.py.resetRealm kept every Wraithgold ledger row (the cur<>'Wraithgold' filter) because Wraithgold is the only currency with paid provenance, so the WIPE_EPOCH bump would have carried every alpha seed, GM grant, bought pen unlock (penLedMax reads the pen: spend rows) and bought slot cap (slot_caps) into the new economy. resetRealm(sid, realm, full): the WIPE_EPOCH catch-up (wipeCatchUp) and the GM /api/_internal/wipe door pass full, and a full reset deletes every wallet_ledger row that is not a Wraithgold row with prov LIKE 'paid_%' (today: none, so every balance goes) plus the slot_caps row; the seed row goes too, so wgEnsureSeed re-seeds WG_SEED on the first post-wipe touch while WG_ALPHA_GRANT is on. A real purchase can never be wiped by a future bump. The player’s own /api/account/reset and a merge are not full: the ledger survives those, as the table above says. What survives a full reset: names, entitlements (boons), the account row (acct_gen bumped), audit and idempotency tables, and the Auto PvP season history on the world object. The record of the wipe is docs/wipe-runbook-2026-10.md in dfmc-client. Gate tools/probe_wipe.py (real workerd, WIPE_EPOCH 1 then 2; W5 re-aimed from “every Wraithgold row survives”): 32/37 -> 37/37.403 {error:'locked', system, floor, maxf}, judged on the progress ratchet (the account's best floor), reading codex/unlocks.csv through the pinned codex; the table under "Chance - the roll doors" names the door, the gated form and what stays open. The Worker stamps x-lock on the forwarded request and the player's object judges it (lockGate); the world doors (Auto PvP mint/settle, the competitive queue) and the trader refuse in the Worker. maxf rides the boot GET /api/save, /api/me, /api/floor/enter and the battle credit. Save-only systems are flagged unlock_claim_bad, never refused. A missing table fails open (UNLOCKS_OPEN ops line) until the release pin carries the CSV. Gate: tools/probe_t6b_unlock_doors.py.POST /api/save: the per-save socket walk now refuses 409 {error:'sock_unledgered', n, why[]} when the account is post-wipe (__wipe_epoch >= 2) and a sockOv entry has no uid (:no-uid), or a uid with no items row (:no-row) or a spent one (:spent-<lane>); below the wipe the walk is unchanged (uid-less skipped, row-less flagged sock_claim_bad). New flag kind sock_unledgered records each refusal. POST /api/roll (grade): the attempt key AND the anchor key (roll_prog) are uid:<suid> whenever the ledger holds the row, the positional iid only otherwise; post-wipe a grade ask with no ledger row answers 400 {error:'no_anchor'} (the client prints it as a named refusal, nothing spent). soul_grade and enchant are untouched. Gate tools/probe_t19c.py (two-phase local worker, WIPE_EPOCH 1 then 2): 9/15 → 15/15.codex[].mutant is now read out of cx_acq.mut (stamped by the hatch door's roll, and for an evolved form of a server-hatched mutant): a missing mark is written in at either epoch and the corrected map rides back as cdx; a mark the server never stamped is flagged codex_claim_bad (system class) and, post-wipe (gradeFrozen), taken off the stored save - the T19b evolve_claim_bad pattern. Probe tools/probe_t19d.py 5/13 → 13/13 at both epochs.POST /api/evolve on the player object, beside /api/devour and /api/hatch: the worker verifies the session, reads the line step off creatures.csv (spawnData's step table, the same rows the client gates the arrow on) and forwards it with the uid; the object reads the creature out of the STORED save, refuses a form that is not the step's departure (409 evolve_form, unless the ledger already holds the earlier leg of a chain), a level under Evolve_Level (403 evolve_short; the best of the record's xp, the species map and the items ledger's xp) or a creature the stored save does not hold (404 evolve_unknown, the birth window: the client saves first and asks again), writes the step to adv_ledger with src door and renames the items row; idempotent on (uid, to). (2) The save door judges every held creature whose form moved against the stored save: without a ledger row for the step (door or verified _advQ claim) it flags evolve_claim_bad (system class); post-wipe (gradeFrozen, __wipe_epoch >= 2) it also puts the form back and answers evo.back — the save lands, the claim does not. Pre-wipe: flag only, nothing refused. (3) The client's playAdvance asks the door before the reveal and plays only on its ok. Gate tools/probe_t19b.py: a local worker at epoch 1 and 2, 4/13 → 13/13.srv.wallet, which the client adopts; a save built a moment before an upgrade, a class purchase or a trade landed is clamped silently, anything else is flagged wallet_over_ledger. The class, trade and roll doors refuse insufficient on the ledger, never on the number on screen, and the roll answer carries wallet for the client to adopt. Pre-wipe nothing changes. LIVE on release since 2026-10-23 (0.89). The ledger side is on admin-backend. Gate: dfmc-client tools/probe_t19a.py 8/22 -> 22/22.steam/ in dfmc-client is a thin Electron shell: one base URL (release https://playdfmc.com/, --dev / --local switch it), backgroundThrottling off and a powerSaveBlocker so the game keeps running behind other windows, the overlay flags, a guarded steamworks.js init (no Steam, no app id, no module: a plain window), a 16:9 window. No installer, no signing, no store page. (2) The download carries the art. Today the client reads the data at DATA_REF from raw.githubusercontent (rate-limited per IP) with a jsDelivr fallback, the art at ART_REF from jsDelivr, and floor data from GitHub Pages on dev - three third parties between the player and the first floor. The client now has ONE base: DFMC_BASE reads window.DFMC_SHELL.dataBase, which the shell’s preload exposes, and DFMC_TREE(ref, host) answers that base when it is set, else the exact CDN URL as before; RAWD / CDND go through it and the Pages detour is skipped under a bundled tree. The web build is unchanged (the shell object does not exist there). The bundle itself (about 123 MB of the 3.3 GB dfmc tree: the eight boot CSVs, pens, AFX/VFX tables, UI icons, the creature / mutant / class / egg art, 120 ground plates, faces) is listed in steam/ASSETS.md with the steps left: the ART_REF image families through the same constant, the copier at DATA_PIN, a loopback server so the https page can read it, then API_BASE so the whole client loads from the app directory. (3) steam/INTEGRATIONS.md plans the rest: sign-in by Steam session ticket (POST /api/auth/steam/ticket, AuthenticateUserTicket on the worker, then the same mintSession(steamid) as the OpenID door - a second door to the same identity, which also makes Deck sign-in keyboard-free), Inventory Service / Community Market for mons and items as a mirror of the server-owned economy (export deletes ours and creates Steam’s, atomically with the ledger; provenance tags ride as Steam tags; Wraithgold is never a Steam item), achievements decided by the worker and activated by the shell (15 drafted, Void Completionist among them), trading cards (after release and Valve’s metric), rich presence, wishlists, Family Sharing, the Deck checklist; cloud saves, Steam leaderboards and Workshop: no. Everything waits on the Steamworks account and app id, and on deleting the previous Steam project (D7). (4) Housekeeping D asked for: ringpulse, evoreveal and the #rough SVG filter were v0.5 Designer leftovers referenced by nothing - not from the VFX bank - and are out of the build. Gate tools/probe_t22.py 2/8 on the old tree, 8/8 after.grantAck and only ever appended to it (one dropGid per battle). The door trimmed the STORED copy to its 64 keepers without telling the client, so for the length of a session the client re-sent every gid it had ever acked on every save, and the door re-read all of them (one SELECT per 90 gids, per save). And when the prune threw, for any reason, the catch swallowed it and the save was stored unpruned, which the client adopted at its next boot: that is how two weeks passed unseen. (2) The fix, in two halves of one mechanism. The save answer now carries gack, the grantAck exactly as the door stored it, whenever the door trimmed or capped it; the client replaces its own list with it and keeps only the gids it appended while that save was in flight (a battle credited mid-save), which are exactly the ones absent from the snapshot it sent. The wire list is now the stored list plus a handful, bounded by the same 64 the stored one is. A prune failure raises the gack_prune_fail anomaly (auto-reported like every kind) and the stored list is hard-capped at 256 gids, so no failure mode of the prune can store an unbounded list; a healthy prune never reaches the cap. Dropping an ack can never hand an item twice: grant uids are minted server-side and the save door’s dupe guard strips a second copy of any uid, so the worst case of a dropped ack is a pending row. (3) Gates: tools/probe_t18.py 3/9 → 9/9 (the real prune block under node:sqlite, healthy and with the ack statement throwing; then the real client on the probe devserver adopting the answer, with the mid-flight gid kept); tools/probe_1071.py 5/5 unchanged.grantAck. The client appends one gid per battle (dropGid) and per drained grant and never trims its own copy; the save door’s grant-ack prune (#429) is what bounds the stored list. (2) Why it stopped: on 2026-09-17 (#946) the prune was batched at 450 gids per statement, but a SQLite-backed Durable Object binds at most 100 parameters per query. Every save whose list held more than ~98 gids threw inside the prune, the error was swallowed, and the save was stored unpruned; the client adopted that list at its next boot, so the stored list only grew. On one account that was ~147 KB in eight days, reaching 409,662 B against the 409,600 B ceiling, and every save was refused from then on. (3) Fix: the prune now binds at most 90 gids per statement, which is how every other IN-list in the worker is chunked. The rules are unchanged: acks are stamped on the grant row, gids with no grant row are kept (at most 64), and the walk runs backward. The first save after deploy drains the whole backlog, using reads only, so a 0.88 client is unlocked with no client change. The ceiling is not raised. Gate tools/probe_1071.py 1/5 → 5/5 (the real prune block under the platform’s 100-bind limit, on a save shaped like the reporter’s: 400 items, 600 souls and 6 pens, with grantAck about half of its 409 KB).linkmerge; D: “Implement. Also implement discord as a log-in method”). LIVE on release since 2026-10-23 (0.89); the provider secrets (DISCORD_*, GOOGLE_*, APPLE_*) are not set on the release worker yet, so the buttons have no provider behind them. (1) The hole: /api/account/link and /merge took a client-sent provider_uid on trust — any signed-in player could attach any login, or provoke a conflict with an account it did not own and merge it in. Both now take a server-side ticket that only a verified provider callback leaves, bound to the session; a sent uid is refused. (2) Discord, Google and Apple sign-in doors (table above), state/nonce/PKCE held server-side; a first sign-in makes an account, a linked login opens its account, Steam signs in the same way (an index claim first, else the legacy steamid account). (3) The sign-in screen shows a button per configured provider; Settings › Link Account picks one, and a login that already has its own climb offers the existing merge choice. Gate tools/probe_login.py 3/29 → 29/29 against a strict local stub provider.xp409 and soulcap; D: “ANTICHEAT AND SERVER AUTHORITATIVE. Client can’t rule EVER”). LIVE on release since 2026-10-23 (0.89). (1) XP: a save under half the stored playerXp is no longer refused 409 (an honest stale phone was halted); the grow-only merge keeps the server value and srv tells the device. The XP kept bands (xp_clamp, cls_clamp) are gone: playerXp and classXp over credited + grace are clamped and told; a claim under the credited total is lifted to it, so a clamped racer is made whole by the battle door’s own credit. Ichor’s band is unchanged. (2) Soul bag: no server door counted souls, and the client judged room against its own blob cap, so accounts whose blob cap ran ahead of slot_caps filled past the paid cap and, once the client adopted the server cap (2026-09-30), read “bag full” every session. The save door now counts souls against the server cap and strips new souls past it (soul_over_cap, srv.soulStripped); an account already over keeps what it holds but cannot grow; the breed-egg roll refuses on a full bag (409 soul_bag_full). Gate tools/probe_authdoors.mjs 5/15 → 15/15./api/me heartbeat now skips a minute in which any same-origin /api/ response already came back ok — battle and save traffic prove the connection as well as it did. It still fires when the window has been API-silent for a minute, always fires while paused or after a miss (the recovery path), and __HT_CONNCHECK, online and tab-return are never gated, so a failed request still pauses within one round-trip. (2) The update watcher no longer polls /__build while document.hidden; the tab-return re-check reads it, and a new build still reloads or prompts on return. (3) The Google Fonts preconnect lines left in the markup head are gone — the fonts are self-hosted, so they opened an unused connection to Google on every boot. Pinned by tools/probe_net_bg.py (6/10 before, 10/10 after).setState on a key nothing read, so the whole template re-rendered every second for one number. The seconds are a ref’d span the tick writes directly; the pill still shows and hides on the spawn-budget state, and the resync cadence (nextInMs, the 1.5 s → spawnIv backoff) is unchanged. Gate: tools/probe_perf_2.py, template commits over 5 s idle 5 → 0, the pill still counting./api/account/resolve’s legacy fallback answered a bare steamid with itself when it had no idx row, and a pre-link Steam account never has one, so after a merge the loser’s own Steam ID still resolved to the wiped loser (F224 re-points only the claims that exist). The fallback now asks that account’s object for the outcome merge_lose stores (F228, kv merge_out:<winner>:standard, new internal /api/_internal/merged_to) and answers the winner, following a winner that was itself merged later (at most four hops, a cycle stops). An unmerged steamid still resolves to itself. Gate: tools/probe_lanew_relay.py 8/8 (5/8 on the tree before).PUT /api/db/lb/<realm>/self/<field> still validated and stored a kv row, but the boards read the server-written SQL lb table and the client’s only writer was gone: a billed write with no reader. The lb root now refuses every client write (403 lb retired), like the queue root before it; /api/db/* is the PvP match transport (m/) only. Rows already stored stay readable (the matchmaking queue’s name fallback) and are never added to. Gates re-aimed: tools/probe_worlddoors.py’s kv-door control is the caller’s own round-commit node, and tools/probe_f196_mm_commit.mjs expects the ladder claim refused.x-ht-enc: gzip is inflated in chunks and refused 413 past 4 MiB (above every downstream ceiling: advlog 1.25 MiB, save 400 KiB); it used to inflate in full before any size check. F224: /api/account/merge re-points the global index of every login the losing account held, not only the conflicted one, so they resolve to the kept account. F228: the losing half of a merge keeps its outcome (amount moved, identities) on the losing object, so a merge cut off between its two halves can be re-run and completes; the credit is idempotency-keyed and cannot double. F237: mon_uid_bad (watch-only) no longer counts a birth record the client itself supplied on a row the save door self-recorded (origin unsourced) as proof of a server-minted creature. Probe tools/probe_laner_ops.py.base_ver in parallel: a duplicate billed write and a same-owner save_conflict flag. The guard is now taken before the first await and released when the write settles (or throws), so the second flush waits and re-runs on the refreshed version. Probe tools/probe_review_lanes.mjs.backupSweep wrote its cursor once, after the whole 250-account page; under the 500 ms cpu_ms cap a page runs out after ~70 accounts, the invocation was killed before that write and every tick replayed the same page (and rankSweep, awaited after it, never ran). The page is now walked 25 accounts at a time, and each chunk ends with its board/snapshot flush and a cursor + GM-accumulator checkpoint, so a killed run keeps every finished chunk; each save is parsed once instead of three times. F6-049: an error answer from the world’s accounts door is retry-later: it used to read as an empty page, close the day, reset the cursor and publish a partial GM snapshot. F243: a forced sweep after the day’s cycle already completed replays page 1 without moving the cursor, so tomorrow’s cycle starts from the top. F6-045: the sweep reaches each player object with no session, so the lazy WIPE_EPOCH catch-up never ran there and a pre-wipe save was folded onto the boards daily; the backup door now runs the same catch-up (wipeCatchUp) before it reads. F6-046: the save door’s pen entitlement awaited penUnlocks, a raw.githubusercontent.com fetch on dev, inside PlayerDO; the object now reads the isolate cache or the baked pinned csv and never the network, and do_guard fails any DO body that calls a module helper reaching the global fetch. F6-047 / F6-052: the item legality map and the shop catalogue are null-prototype maps and every lookup is own-key: an item named constructor / __proto__ no longer passes the not-authored gate, and sku __proto__ no longer books a 0-cost spend row (the object also refuses a forwarded row that names no SKU). F245 / F246 / F249: the object’s copies of /api/ledger/reconcile, /api/backup/sweep|export|recover|restore and /api/lb/purge were unreachable (the Worker answers first, or its session gate ate the sessionless call) and are removed with mirror(), backupPage() and restoreRows(); /api/ledger/reconcile answers 501 at the Worker, as it has since the admin-console ruling, and is not a working door. F247 + account/resolve: /api/entitlements (exact path) and /api/account/resolve are ops-key doors in the Worker gate; entitlements is routed to the NAMED account’s object. /api/entitlements/pending stays the player’s session door. Gates: tools/probe_lane_n_sweep.mjs 4/24 → 24/24, tools/probe_lane_n_ops.py 5/11 → 11/11.peekSpawnCredits priced the rest of the next credit at the rate in force now. When Incense or Bait ended before that credit, the answer was short: 15 s for a 25 s wait, and the pill hit zero and re-asked. The remainder now goes through accrueAt, the inverse of the accrual, which the floor clock already uses. Same arithmetic everywhere. Gate tools/probe_laneq.mjs 0/15 → 15/15./api/battle took the account rate bank and one spawn credit, then could still answer 400 bad slot, 409 no such spawn (a stale floor view fighting a slot the server already marked dead) or 503 spawn_data_unavailable - nothing paid, no events_seen row, and the credit and bank time gone. Those three answers now refund both first. The bank write itself stays in the same synchronous stretch as the decision (the F081 race guard); each refund is its own read-modify-write, so a battle that landed in between keeps its own spend./api/name/claim refuses a name containing control, format, private-use or line/paragraph-separator characters (zero-width space and joiner, the right-to-left override, ...), which rendered identically to a name already held; character creation applies the same filter. Look-alike letters from other scripts are NOT refused - that is a charset decision still open. /api/account/reset: the client wipes its local slot and reloads only when the server confirms the reset; a refused or unreachable reset (locked account, rate limit, server error, offline) leaves the save untouched and says so, where it used to reload into the untouched server save as if nothing had been asked. The feedback form stops the title at 200 and the body at 4,000 characters, the same limits the server keeps, so no part of a report is cut without the player seeing it. Gates tools/probe_nameclaim_merge.py, tools/probe_advlogstore.py.__wg_seed block that ran after the ledger overwrite, cost a kv read per save and could lift a first save's Wraithgold above the ledger (F054; wgEnsureSeed owns the seed), and the Shadow Trader allowance retired on 2026-08-25 (F055). When the codex tables cannot be read at the save pre-pass, a dismantle claim is now stamped unpriced and left pending (#703) instead of being settled at zero (F080). pwlast is written only when the power claim changes; its two readers read pw alone, and the re-stamp cost a billed row on every save (F6-024). The WITHOUT ROWID sweep records the table count it saw and re-runs on the next boot when a stateful table was created lazily after it, so objects born after the migration get the saving too (F382). Write accounting no longer stops counting after 4,000 statements in one request; older cursors fold into running sums (F383). The write budget's three steps have their own flag kinds (F384). The version-handshake row now says an epoch session's write is accepted at any version gap (F381). Gate tools/probe_lanej.mjs 4/19 → 19/19; probe_saveratchet 15/15 and probe_ledgerrace 13/13 on the tree._led* stamps (_ovX/_ovC/_ovF/_ovW, server-written, read only from the stored row) and measures the next save from the credited part: the honest in-flight racer is still kept, but the band is one band in total. A clamp never lowers a stored value. Scrap/Dust/Essence/Ink keep no balance ceiling (D's ruling #3, no balance refusal during BOOK); the new wallet_over_ledger flag only watches. The client's save ts is bounded to server time + 2 minutes, so a save dated tomorrow no longer buys a day of rate allowance or locks other devices out as ‘stale’. Probe tools/probe_saveratchet.mjs 7/15 → 15/15.localStorage outside any try, so a full or blocked store turned a healthy GET /api/save into an endless ‘could not load your save’ retry; the slot the app reads is now written first, the redundant legacy copy is dropped if it will not fit, and if the device still refuses the save is handed to the app in memory and the game boots on it. serverFlush answered false while a battle credit was in flight, which sign-out, battle entry and the Shadow Trader read as a dead write path right after a victory; it now waits and settles with the answer of the flush the credit fires when it lands (creditSettled, 120 s ceiling). The same wait fixes the battle answer’s stale-writer branch, which ran inside the credit and could only halt. The login splice now reads the device half from the namespaced slot, not the legacy key the app deletes. Gate tools/probe_blpersist.py.detail (SQLite messages naming tables and columns, parse errors, internal function names); the Auto PVP finish door did the same on a re-simulation error. Both now log the text server-side and answer the bare error word; an ops-key caller still gets the detail. Four worker helpers with no caller left (mmRelease, pidsOf, spawnPerMs, wrSystem) were removed. Probe tools/probe_worlddoors.py.maxFloor is refused by the unit guard (the maxFloor+1 check only decides for a save with no maxFloor), and one parked before the adopted save (wts < __HT_SAVETS, #122) is dropped. Write accounting gains the write breaker (2,000-row request ceiling, 20,000 rows/hour with 70/90/100 wr_budget flags, the diagnostic anomalies shed at the ceiling, wr_hours and the closed-hour p90 check); the Budgets row points to it./api/backup/restore, /api/backup/rollback, the admin rollback, the R2 recover, sockmig, monmig, birthmig, bulk dismantle, breedfix apply/revert, the heroname tool) bumped the save version but left the session epoch alone; only GET /api/save rotated it. Since 2026-08-20 the save door accepts an epoch-bearing save whose base_ver is behind (same owner — accepted), so the open tab’s next flush silently overwrote the ops write. Each of them now applies the same +1 epoch rotation to the account’s session in the same write, so that tab’s next request is refused 409 stale_session (“this window no longer owns your save”) and it reloads onto the ops result. The same-owner acceptance itself is unchanged. Restore, rollback, birthmig and breedfix also keep the save they replace in save_hist at its own version first, as sockmig/monmig/heroname already did, so an ops write can itself be rolled back. Gate: tools/probe_opsepoch.py 10/16 -> 16/16.adoptSrvFields took only maxFloor/floorNum/playerXp/classXp/wallet from the accepted save’s srv{}, so srv.wg (the Wraithgold ledger balance), the server-owned slot caps srv.invCap/srv.soulCap and the grow-only counters the door held back (wildWins, pensUnlocked, hatchLv) were ignored: the HUD kept the refused value and every save re-sent it. They are adopted now, server value wins; srv.wg lands as wallet.Wraithgold. Word-valued map fixes (restored/merged/held) and a field the save holds as a map are never flattened. The load-time soul-bag widening (F043, open question) is unchanged, so after the first save of a session the soul cap is the server’s. Gate: tools/probe_f036_srvadopt.py 6/11 → 11/11.{error:'stale'} heals through __HT_TSFLOOR (250 ms, up to 4 times) and 413/403 halt at once; the keepalive cap is closed (gzip, x-ht-enc, KEEPALIVE_MAX 60,000); the battle-resume fix is on release; an epoch session's base_ver mismatch is accepted and flagged; the ledger ceilings are delta ceilings with a kept/clamped band, and the class XP ceiling has its own row; maxFloor/floorNum do have regression gates; the save door's real order (size, plausibility, handshake, then generation); save_hist is time-spaced; the client fails closed on core data; /api/lb/submit is retired (410); ip_all is 600/360 and runs after the session gate, with the roll, mm and pvpm classes listed; TRAFFIC_BLOCK_N is 50 as deployed; bag qty is clamped to 1, not rejected; screenshots are gone and feedback shares other with the shop and grants only.bosses.csv, floor_creature_rarity.csv and encounter_weights.csv without a guard. When one of those was lost at every origin, the data wiring threw, the error only raised a warning banner, and the loader revealed a game with no data after 25 seconds — no RELOAD, and save writes still running. Those three now halt exactly like the core five: the loading screen names the file, offers RELOAD, stops save writes and files the report. move_vfx.csv is the one optional table: every cast already has an archetype fallback, so a lost effect table warns and the game plays with fallback effects. Any other throw in the data wiring now halts too, instead of opening a half-loaded session. Gate: tools/probe_f103_failclosed.py (client repo), 4/8 before, 8/8 after./api/battle, all closed server-side. (1) BOSS KILLS: the conclusion that reads no claimed kills was true only off a boss floor - on a boss floor, and during a spawn-table outage, the client’s kills were priced as sent. A boss battle now pays the fight the server knows it was: the boss (SSR, its authored level) and its two Super Rare actives at the floor’s level, capped at 100. Any other battle on a boss floor pays nothing. An outage still credits the battle but prices every kill at the Common tier within three levels of the floor. (2) THE BOSS KEY: a boss battle is credited only when the server spends a key it minted for that den (see system-void-apex); without one it is refused. (3) THE RATE CAP: the token bucket belonged to the battle chain, and a chain the server had never seen started with a full bank, so the cap could be sidestepped. The bank now belongs to the account, one per realm, whatever chain the event rides. (4) REFUSALS: an uncredited battle answered its full XP, class XP and Ichor, and the client added them while the ledger booked nothing, so the next save tripped the ledger ceiling on an honest account. A refusal now answers zero, and the client adopts zero from any answer marked uncredited. Also: the Worker’s own stamps on these two doors (spawn table, drop tables, egg lines, power tables) can no longer be supplied by the client; every top-level underscore key is dropped before the Worker stamps, the rule the save door got on 2026-09-29./api/battle counts the win server-side and draws first_win_drops.csv. The Worker resolves the draw against items.csv in resolveDrop, and the object never holds the item table. The bonus comes back in the battle’s drops with fw:1 and server uids, minted under that battle’s drop:<event> grant. If the save carrying the ack is lost, /api/grants/pending hands it out again on the next boot, exactly once. A replayed event is deduplicated before the draw, so it grants nothing and does not advance the count. Nothing new reads /api/save. Gate tools/probe_firstwin_srv.py 13/13./api/me so a loser tab never mints an epoch, and it was held for the tab’s whole life — including by a tab that then answered 401 and sat on the sign-in gate. The gate the player had just left held the account’s window, and the signed-in window arriving from Steam was the loser. What changed. A tab that shows the gate releases the lock at once: it holds no climb and cannot claim a save. The returning window claims it, boots straight into the game and announces the sign-in on a same-origin BroadcastChannel; the old gate tab hides its Steam button and says signed in in another window — this window can be closed, with no steal button, and a gate a mobile browser froze in the background checks the lock itself when it is shown again. Unchanged: a genuine second signed-in window still meets ALREADY OPEN / PLAY HERE INSTEAD, the owner still holds the lock for its lifetime, the epoch fence and the OpenID callback are untouched, and nothing new reads /api/save. Gate tools/probe_steamreturn.py 1/5 → 5/5 red-first on the real shell, the two #16 checks green on both trees.item_grants row the account had ever been given, acknowledged ones included, and then issued one UPDATE statement per grant. Neither had a bound, and the grant table is in no retention lane, so both curves could only grow with the account’s lifetime. The read now asks only for rows that are still outstanding, which is all the acknowledgement needs and which self-drains — once a backlog is acknowledged it never costs a row again; on an account with 9,383 settled grants and three new ones the read falls from 9,386 rows to 3, on every save, forever. The write is batched at 450 gids a statement, the same per-statement cap the retention pass already uses, so 9,383 acknowledgements cost 21 statements instead of 9,383. Nothing about delivery changed: the acknowledgement still lands durably on the grant row carrying the save’s own timestamp, so a restore to an earlier point still rolls the acknowledgement back with the items; a gid with no grant row is still kept in the save, because the row is the only other place it could be recorded; and the kept list is still capped at 64. The ceiling was not raised — it is the only reason this was found, and 9,383 is 188× the heaviest request ever measured as legitimate. Reviewed the same day, and the review found a second thing: the first version of this fix answered “does this gid have a grant row?” by looking up every gid the save sent, and the acknowledgement list has no length cap — so the read cost could be driven by how long a list the client chose to send, which the whole-table read it replaced could not. The list kept in the save is only ever the last 64 gids with no grant row, so only the tail of it can reach that list at all; the lookup now walks backward from the end and stops as soon as it has 64, which makes a flood of invented gids the cheapest input rather than the most expensive. The answer is the same 64 either way, and a gid the walk never asks about is treated as having no row and is therefore kept — the safe direction, since a gid with no row that was wrongly dropped would be offered again and the player handed the item twice. Still open: a one-time drain of a real backlog still writes one row per grant, because those rows really are being written; the only lever that lowers it is a retention window on item_grants, and that is not a free choice — a grant row is the record that an item was legitimately granted, and deleting grant history interacts with the unsourced-item check. No grant row is deleted and no retention rule is added here. Gate tools/probe_946.mjs, the ack block and the breaker sliced out of the shipped worker and run against a real SQLite database from the shipped schema: 7/13 → 13/13 red-first, the review’s three checks red against the morning’s own fix.tools/probe_soulfilter.py 0/4 → 6/6 red-first.toFloor marked the fought slot dead by index, on top of that answer: the successor died locally, the floor showed nothing, the countdown read 0s because the server had honestly said “now” (a slot alive and a credit in hand), and no timer was left to ask again. The server was right the whole time. Now the kill lands only on the creature that was fought — the enemy unit carries the spawn’s server sequence and the slot must still hold that sequence — and a successor stands; the pump is re-run after the local kill so its judgement is of the floor as it is. Gate tools/probe_droughtkill.py 2/4 → 4/4 on the real client, red-first; probe_drought0 5/5 and probe_droughtrate 3/3 unchanged.tools/probe_scopeicon.py 3/5 → 5/5 and tools/probe_selficon.py 2/4 → 4/4, both red-first and both measured on the painted screen.gone_src says which door retired it. sync means only “this stopped appearing in the save” — reconciler bookkeeping, not a spend — and both dismantle doors have treated it as unspent and re-verified since 2026-08-20. The socket opening balance and the save door’s socket check both asked only whether the row was marked, not by which door, so a stone the reconciler had merely noticed leaving the bag was refused an opening balance permanently and reported as suspect on every save afterwards. Both read the lane now: a reconciler mark is re-opened where the save says the stone is, a genuine dismantle is still refused, and the report names which door spent it instead of saying only “no row”. Nothing is refused to a player — the socket check is still watch-only. Gate tools/probe_socktomb.mjs 5/7 → 7/7 red-first on the real worker; the migration gate still 11/11.per_ms, incense_sub_ms, bait_mult are in tables.json and in no codex CSV the client fetches, and a second copy of a balance number in the client is the shape this codex forbids). It is the same arithmetic the accrual, the floor clock and the budget countdown already use, on values the boon read had already loaded, so it costs no extra rows and no extra reads. The re-ask can only ever get shorter, and it stays inside the floor rate bucket: measured at 6.6 asks a minute with both boons, against a bucket that refills eight. Gate tools/probe_droughtboon.py 3/4 → 4/4 red-first, with free-to-play pinned at sixty seconds as its own check; the #700 and #595 gates still 3/3 and 5/5. Still open: why the answer says “now” at all while the floor is drawing nothing. The server sends its own count of what it believes is alive and the client stores it and never reads it.probe_sockunpin 10/10 (5/8 before), the socket-identity gates 14/14 + 9/9 + 13/13, the dismantle family green.retry:1 on the save; lost:1 on the next battle event after an unanswered credit), and the server believes it only when the shape agrees — the same window and exactly one version behind; a chain head that is exactly the previous one (a new prev_head on the chain row) — recording it as an audit row on the account (save_retry, chain_resync) rather than a conflict. Any other shape still flags. Separately, only the hidden-tab path used to wait for an in-flight battle credit; the 10s debounce and the floor-entry flush now wait too, so a victory followed by a floor move no longer sends XP and Ichor the ledger has not credited yet (the “kept” over-ledger family). Gates: probe_lostreply 7/7 (4/7 before), probe_flushhold 6/6 (3/6 before).probe_ichorrace 10/10 (no rate flag on ledger-explained income, red before), probe_eggdism 14/14 (case C re-ruled), probe_dupeguard 7/7 (the evidence line, red before).srv.wallet and the client adopts it beside maxFloor/floorNum/playerXp/classXp — the client used to keep its higher number and re-claim it on every save, which is why one clamp became five. (3) The 0.67 stopgap (Ichor band 5,000 → 12,000) is reverted to 5,000. Chain breaks, the rate cap and the Apex kill formula were all cleared as causes: the battle door credits on a broken chain, and battle income is reconciled to the server's figure on every victory. Gates: probe_dismoutage 6/6 (red 2/5 before), probe_ichorrace 8/8, egg-dismantle case A green again at the 5,000 band, dismantle-call 11/11, ledger-race 13/13, dismantle-window 10/10.tools/probe_bootcodex.py 3/4 → 4/4.tools/probe_droughtrate.py 0/3 → 3/3; the #595 gate still 5/5.standard and anything else is refused; hcSeasonActive and bequests left the accepted-field list (verified empty on every live account before removal — nothing stored was lost, stale fields are simply no longer read); gens answers the one realm. Build record on Realms.2026-08-26 — the birth record moves off the save and onto the items row (D, option B). On release.
Mechanics: every creature and egg record used to carry origin — {src, ts, egg{name, grade, uid}, parents, pen, floor, mut}, written at hatch/breed, rendered by no screen, read by the server nowhere — 195 of a soul's 370 bytes and 37% of the largest live save. Now: (1) the save door, before it measures the save's size, moves each record's origin into items.birth once (first record wins, never overwritten) and strips it off the save; (2) a record the items ledger has no row for keeps its origin on the save, so provenance is never lost to a coverage gap; (3) the hatch grant writes a hatched creature's birth from the egg's own row (name, grade, and the egg's parents/pen when it was bred) and the bred-egg mint writes the egg's from the pen ledger — a new creature's birth is the server's fact, not the client's claim; (4) the client's snapshot drops origin from every record it uploads; the live in-memory records are untouched. Measured on the full stack: one live save 178,574 → 115,513 bytes; the largest account projects 266 KB → 162 KB on its next save. Gates: tools/probe_nobirth.py 7/7 (red-first), tools/probe_birth_e2e.py 17/17.
2026-08-26 — the save split's shadow verify was red on pvpSnap and is green again; the save re-measured. On release.
The Auto PvP team slice (pvpSnap, ~08-14) was assigned to no split key, so every active
account's parts failed to rejoin (mismatched: ['pvpSnap']) and stage 2 of the split had been
silently red for twelve days; one entry in the pvp part, 18/18 live saves round-trip. Measured
on the stored copies (compact bytes, the ceiling's own measure): the largest account is 266 KB, 79% of it
inventory, and 37% of it is the creature birth record (origin: egg, parents,
pen, floor, stamp) that no screen renders and the server never reads. Options and their measured savings
are in the client repo's PREP_SAVE_SPLIT.md §1b; nothing about the stored shape changed
today.
2026-08-26 — the tier-1 intent queue drains after its save is acknowledged. On release.
The mechanics, since this page did not carry them: every equip and socket change on the client appends one
intent row {kind, uid, field, prevValue, newValue, ts} to _bufQ (cap 800 rows, oldest
dropped) and settles a debounced equip-change flush; the queue rides the next /api/save, where the
server verifies each row's newValue against the items it holds in its own previous copy, records a
verified move on its items row (held state), flags an unverifiable one as tier1_claim_bad (never
refuses), and deletes the queue from the stored copy. The queue survives a refused (409) save exactly like
_dismQ/_advQ. What it lacked until today was their drain: on a 200 the client now drops the
rows that rode that save (keyed on every field plus the row's own stamp, so a row noted while the save was in
flight stays). Before: every row was resent on every save for the whole session — 1,282 flag rows in four
days on seven accounts, the same item 452 times on one, the server re-running its held-state writes per replayed
row per save, and up to ~95 KB of dead queue inside the payload the 400 KiB size ceiling measures (one account's
saves measured 287 KB in flight against a 224 KB stored copy). Gate tools/probe_bufq_drain.py 8/8,
red-first (4/8 on the unfixed tree).
2026-08-14 — the roll door's attempt ordinal now covers scrolls, and the boon clock got the probe it never had. On release.
Two things, both in the "a fix is not finished until something would catch it breaking" family.
The scroll door was the last roll kind still keyed on a client-minted key, and the highest
value one — a scroll overwrites a socket permanently. enchant joins grade
and soul_grade in the server-derived key set; the roll-door table above now records the whole
rule, including what a client owes an unsettled answer. Details in upgrading.html and
system-sockets.html. The boon clock, fixed the day before, shipped with no probe
of its own, and the two probes that already covered the subsystem had been silently dead since
that fix: both lift worker methods by name into a sandbox, and the change moved the clock onto two new
helpers (_boonEdges, spawnIvAt) that neither lift list named, so every test after
the first threw and nobody noticed — probe_floorclock_boon was scoring 2/9 and
probe_spawnshare crashed outright. A lift list is a dependency list. Both revived (9/9 and
9/9), and tools/probe_spawnpeek.mjs added to pin the displayed budget to the spent one across
all four boon combinations: 8/8, and 4/8 against the build the bug was live on, where the
displayed wait reads 40 s for what is really a 10 s one.
2026-08-13 — Bait and Incense actually change the clock now (D's live report). On release.
Three defects wore one symptom. The two authored constants behind #105's arithmetic —
interval = (60s − incense 30s) × bait 0.5 — were never written into the generated tables, so
the fallbacks silently ran every boon combination at the base 60 seconds. The budget's read-side
was a legacy fork besides: it never read bait at all and priced the remaining wait with the old
"incense halves the rate" rule while the accrual ran the new arithmetic — two clocks, so the
answered wait shrank at half wall speed, the on-screen pill hit zero early, resynced, and was
handed a few seconds back, forever. And the floor-refill clock in the enter answer knew about
incense but not bait. One arithmetic everywhere now, constants authored in the generator, bait on
every call. Gate: tools/probe_boonclock.mjs 7/7 through the real shop door — a fresh
wait measures 14.4s against the authored 15, and three seconds of wall time shorten it by three
seconds.
2026-08-13 — clock skew between devices no longer halts play (reports #150/#151). On release.
The save door refuses a body whose timestamp is older than the stored save's — a wall-clock
ordering gate that clock skew between two devices trips on every save from the slower one. A live
player hit it six times running, was halted, reloaded, and was halted again inside four minutes,
because the reload didn't move his clock. The refusal has always answered server_ts for
recovery; the client now uses it: save timestamps are account-monotone — floored at the
adopted server save's own timestamp on boot, and raised past server_ts whenever a stale
answer arrives, with an immediate retry that never touches the failure ladder. Bounded at four
heals so a genuinely broken loop still surfaces. Gate: tools/probe_tsheal.py 5/5,
including one genuine stale answer healed live.
2026-08-13 — the spawn countdown no longer restarts on floor changes (report #141). On release.
The floor-enter answer combined two clocks — the floor's own refill and the account budget —
but the floor term ran unconditionally, and a full floor's refill anchor snaps to the moment it
fills. Every floor change onto a refilled floor therefore read "next refill in a full window"
(30 seconds under incense) and drowned out the true budget remainder. Measured before the fix:
nine spawns alive, nine credits in hand, and the answer said wait a full minute. The floor clock
now speaks only when the floor is empty — the one case where it is genuinely the binding
limit, which is exactly the case the two-clock pairing was built for (#103) and which is
untouched. Gate: tools/probe_spawnclock.mjs 6/6.
2026-08-13 — the save door's worker stamps actually reach the player object now (report #139, and the "(dism 0)" mystery with it). On release.
The worker annotates every save with resolved facts the object cannot fetch itself — the
advancement requirements from the codex, the priced dismantle allowance — but the rebuilt body
carrying those stamps only shipped when the inventory-strip list was non-empty. On every ordinary
save the original request forwarded instead and every stamp vanished: the advancement ledger
never verified a single claim (every honest advancement flagged "N of N unverifiable"), and the
dismantle allowance never reached the Ichor ceiling. The stamped body now ships on every parsed
save. The verifier also learned two honest shapes it used to reject: a creature hatched and
advanced inside one flush window (verified against the arriving form's banked XP, recorded as a
birth), and a chained double-advancement (each verified step becomes the departing form for the
next). Forged steps, wrong forms and short levels still flag — now with reasons in the note.
Gate: tools/probe_advclaim.mjs 9/9.
2026-08-13 — the ledger ceilings separate watching from taking (reports #140/#145, the four-day canary account). On release.
The client credits battle income the instant a fight ends; the server credits it through the
battle-event pipeline; a save landing in the gap read claim > ceiling and was clamped —
an honest fast player was shaved of roughly one flush-window of XP and Ichor per landing, a hundred
times in four days on one account. Now: inside the authored in-flight envelope
(ledger_grace.*_clamp: xp 4000 / food 2500 / cls 6000, sized from that account's measured
honest overs) the flag still fires but the claim is kept — the ratchet self-heals when
the pipeline credits. Beyond the envelope a distinct *_forged flag fires and the claim clamps,
so forgery gets nothing and its signal no longer drowns in honest-racer noise. The class level-100
hard cap is untouched. Gate: tools/probe_ledgerrace.mjs 13/13 (both honest-keep cases and
both forged-flag kinds were red before the change).
2026-08-13 — the dismantle-claim flag names its first bad row (report #133). On release. New section above; the guard's mechanics were previously undocumented and are now written down as built (live since 2026-08-03).
2026-08-08 — the whole encounter is derived server-side, not just the spawn you clicked. LIVE on release since 2026-08-08 (0.42). The two battle extras were rolled by the client and priced against a clamp; they are now a property of the slot, derived on their own lane and shipped with the pool at no extra request. The battle conclusion reads no claimed kills. Six client spawn rollers deleted.
2026-08-08 — server-side rolls, item 4: the scroll enchantment.
LIVE on release since 2026-08-08 (0.42). The client no longer rolls it and no longer holds the magnitude
table. Nothing is consumed until the server answers, which matters here more than anywhere: a scroll
overwrites and can never be removed. Fixed in passing: codex/enchant_base.csv had never
been read by anything, and the coefficients lived as literals in the client. Probe
tools/probe_enchant.py: BEFORE 2/9, AFTER 9/9.
2026-08-08 — a refused floor sync no longer leaves the client showing a full floor.
LIVE on release since 2026-08-08 (0.42). One pool, cap ten, shared across every floor — the server
always did this and was measured doing it. syncFloorSrv swallowed a 429 and kept the old
spawnSb, so the client drew ten where the server would draw two, and every over-budget
kill raised a spawn_budget flag against an honest player. Refusals now retry with
backoff on a timer outside _timers, and an unknown budget shows nothing rather than a
stale number. Probe tools/probe_spawnbudget.py: BEFORE 5/7, AFTER 7/7.
2026-08-08 — server-side rolls, item 1: item and class-soul grading.
LIVE on release since 2026-08-08 (0.42). The forge decides nothing and is told. Nothing is spent until the
server answers, so an unanswered attempt costs nothing. Fixed in passing: POST /api/roll
could never resolve a soul_grade row, because it constrained on two columns
soul_grading.csv does not author — shipped 2026-07-31 and unreachable until a client
finally called it. Probe tools/probe_upghang.py: BEFORE 6/14, AFTER 14/14.
2026-08-08 — a refused save is LOUD: play pauses and a report files itself.
LIVE on release since 2026-08-08 (0.42).
D's order, and the item had been open since 2026-08-01 without being built. The !r.ok branch of
serverFlush did exactly one thing — re-arm a retry — so a refusal was invisible: no toast, no
pause, dataWarns:0. Meanwhile /api/battle kept crediting a ledger the next login would discard,
which is how report #68's account played into a void for hours and how the 2026-08-01 rollback cost ten. Both halves
of the machinery already existed and neither was wired to this path: haltWrites() (pause overlay, latched
against the connection poller) and autoReport() (budgeted, bounded logs).
Terminal codes halt immediately; everything else halts after 6 consecutive failures.
tools/probe_saveloud.py 14/14 against the real client, including a BEFORE control on the unpatched build
proving the defect (paused=false, reports=0) — and the probe caught two of its own faults on the way: it was
refusing the boot's GET /api/save as well as the write, which correctly triggered the fail-closed
load screen and looked exactly like a broken build.
2026-08-08 — save ceiling raised to 400 KiB (256 KiB → 409,600 B), and this page
corrected from two raises of drift. ON RELEASE — dfmc-client main
c086232326f8, dev c3366b88ed5a.
Report #68: a player was locked out of play at 263,087 B, with 20 consecutive save_oversize flags
— not merely unable to save, but unable to enter any battle, because the battle-entry flush gate refuses the fight
when the save is refused. Root cause was a collision, not a leak: the authored caps permit a ~321,700 B save and the
ceiling refused at 262,144 B. Not a write-cost risk: DO SQLite bills rows at D1 rates and row size
does not affect the count, so a larger save costs the same per write; the hard platform limit is 2 MB per SQL row.
Only SQL Stored data scales with bytes ($0.20/GB-mo over 5 GB), ~8.4 MB per player-realm worst case across the
21 kept copies. SAVE_WARN moved 128 → 256 KiB so the flag still precedes the refusal.
This page was stale: it documented 131,072 B at four sites, so the 2026-08-03 raise to
256 KiB shipped without its codex update — an ORDER #1 miss, corrected here alongside the new value.
Still triage: the real fix is the server-side inventory (~30 KB saves), and the silent-413 hole is
still open.
floorNum joins the merge doctrine in the downward direction (hotfix on top of 0.47, release commit 208bb79, live).
Report #114: floor 93 on the PC, a brief login on the phone, back to the PC on floor 57.
/api/save already merged two classes of stale-device damage back up — maxFloor
grow-only (maxfloor_regress) and playerXp/classXp grow-only
(xp_regress_merged, whose comment already named this report class) — but
floorNum was deliberately excluded, on the reasoning recorded in the
maxFloor rule's own comment: “floorNum is a POSITION, not progress — the player walks
down floors legitimately — so it is ceilinged but never floored.” Correct about a player, wrong
about a stale device: a phone replaying an old local slot writes a fresh ts, so the
ts < cur[0].ts guard passes, maxFloor and XP merge back up, and floorNum
alone is stored verbatim for the next login to adopt. Now, when maxFloor had to be merged back UP and
the claimed floorNum is below the stored one, the stored position is kept and reported in
srv; new flag floornum_stale_writer. The signal is deliberately the
maxFloor regression and not the XP merge — the server credits XP from its own ledger,
so a current client routinely saves a slightly lower XP number it has not yet seen the credit for, and keying on
that would hold ordinary players' positions during ordinary play. The flag is set on the regression specifically:
the over-claim ceiling clamp writes the same _srvFix.maxFloor key and is not a staleness signal.
Downward only — a higher claim is already handled by ceil = max(ratchet + 1, storedMaxFloor).
Worst case is one save not moving a position, with the next current save moving it; it lowers nothing, locks nobody
out, and reads and writes no extra rows. New section above. Gate
tools/probe_floorpos.mjs 14/14, including a sweep over all 120 claimable floors.bossEdge was one flag doing two jobs. It carried floor === _mfB + 1 — “the
first clear of this boss” — which is load-bearing for the rate-cap overdraft, because
the _mfB ratchet is monotonic and that is what bounds the overdraft to one per boss per account. It
does not belong to the spawn-credit skip, whose rule is simply “boss floors have no wild
slots”. So every re-fight of an already-cleared boss debited one of the account's ten credits for a floor
with nothing to spawn. Now split into bossFloor (no ratchet clause, gates the credit skip) and
bossEdge = bossFloor && floor === _mfB + 1 (gates the overdraft). Both anticheat tests remain
on the skip: the authored boss-floor test from floors.csv, and the UNCLAMPED
+e.floor === floor equality. New section above.nextInMs chose its clock with credits < occ. When a floor's own slots are all dead
occ is 0, so that test can never fire; the expression fell through to the FLOOR clock, which on an
idle floor has already elapsed and clamps to 0. It is now
max(floorClock, credits > 0 ? 0 : budgetClock): a spawn needs BOTH a refilled floor slot and a
credit to spend, so the honest answer is the later of the two clocks, and a credit already in hand imposes no
wait. There are two limiters — the account budget (1 per 60 s, cap 10) and the floor's own refill
— and the screen now shows the binding one. New section above.lbUpsert is the only writer of lb.name and its two early returns (mode max
with a lower score, mode set with an equal score) fire before the name-resolution block, so a
displayed name was stuck at whatever it was the last time that account's score changed;
/api/name/claim wrote only the names registry. The fix is on the rename, not in
lbUpsert, and deliberately so: hoisting the resolution would add
SELECT name FROM names WHERE steamid=? to every quiet fold write, and names is
PRIMARY KEY(name_lc) with steamid unindexed — an un-indexed full table scan on the
hot path, the same pattern already identified as the cause of rising read costs. /api/name/claim now
runs UPDATE lb SET name=? WHERE acct=? AND name<>? and deletes the lb_snap rows for
the affected boards, so a no-op rename writes nothing (writes cost ~1000× reads). The snapshot bust is
throttled to once per account per 60 s, in memory on the Durable Object, because alternating between two
names would otherwise force a global board recut per request; the endpoint is also behind the existing rate
limiter (measured: 429 with retry_ms 15000). lb_snap is otherwise recut only when the
snapshot passes 24 h, which is why an explicit bust is needed. New section above.dead[], and the player sees the smaller. refillSrv hardcoded 1 per 60,000 ms and could not see the boon, so under Incense the account refilled at 2/min while every floor stayed at 1/min. It now runs the budget’s own accrue()/accrueAt() off the same expiry, and a third hardcoded 60,000 in nextInMs went with it. This page said “the spawn budget is one pool” and that sentence is why an earlier fix stopped half way — the section above is rewritten. Verified 9/9, 3/9 on the pre-fix tree.battle-entry, floor-entry and
boss-entry were excluded from the not-dirty skip that debounce/hidden/pagehide
already had, so they uploaded unconditionally. The damaging case: the in-fight debounce is held while phase is in
FIGHT_PHASES and released at the victory boundary, then autoplay calls enterBattle ~0.4 s
later and the entry gate re-sent byte-identical state. Measured on the locked-out account: gaps of 16.8 s, 0.4 s,
13.3 s, 0.5 s at ~165 KB each. The entry flush is not merely a save — it is the report #16
liveness gate, and enterBattle refuses the fight when it fails, so it could not simply be deleted. The skip is
therefore bounded by PROOF: it may only skip when a write succeeded within ENTRY_PROOF_MS (15 s,
the same clock as HIDE_COALESCE_MS and the 429 retry_ms), because that success IS the evidence the
gate exists to obtain. _srvOkAt is stamped only on a real 2xx — a 409 returns earlier and a 429 is
r.ok false, so neither can forge liveness. Probe tools/probe_entryskip.py 6/6: 2.0 saves per
battle before, 1.0 after, every gate still passing, stale proof still uploads, dirty state always uploads, 0 page errors.
/api/me is its own read-only class (ON RELEASE). See the rate-caps row above.
battle 2 → 4 per minute (ON RELEASE). Sized from
spawn_budget including the incense halving, rather than from the phrase “spawner parity”.save bucket refilling at exactly 6/min. The bucket drained; enterBattle
awaits its battle-entry flush and refuses the fight if it does not land, so fights
stopped starting — the player’s words were “not starting fights”. Three 429s inside ten minutes then
soft-blocked save at 04:05:53 and other at 04:09:58, and /api/me is class
other, so the shell fell back to the Steam gate and a signed-in player saw a login screen. Colodetti took
11 battle breaches at 01:58 the same night against a bucket refilling at 1/min while fighting
roughly 3/min. Caps doubled for battle, floor, save, save_read,
db, other and wallet; auth, name and ip_all
deliberately untouched. SB_EXPIRE_MS dropped to 5 min so an account already blocked recovers on the next
poll instead of sitting out the hour.
battle-entry
re-uploads the identical state immediately after, because battle-entry, floor-entry and
boss-entry are excluded from the not-dirty skip that debounce/hidden/pagehide
enjoy. Measured on Plank’s own save history: gaps run 16.8 s, 0.4 s, 13.3 s, 0.5 s. Halving that
is open work.loadRun’s restore list carried 44 keys and grantAck was not one of them,
so every page load started the ack empty and the next save destroyed the stored history. The server
then owed every forgotten grant again and the drain handed back items the player already had —
which is why bags climbed past their cap and why a player who left a tab idle came back to a full
inventory. Reproduced against a real client, real worker and real DO SQLite before it was diagnosed
(probe_ackrestore.py 4/4, probe_ackregress.py 6/6). The fix is the one
missing key. It does not undo deliveries already made: the duplicates already sitting in bags are a
separate reconciliation.chain_break. The
heads now agree by construction. Still not enforced.travel/travelTo now refuse while paused instead of raising a veil
preloadFloor will not drive; preloadFloor clears a veil it refuses; the
35 s watchdog is armed by whoever raises the veil rather than living inside
preloadFloor; and a resumed connection re-drives a trip caught in flight. Autoplay's
auto-advance was the branch that made it near-certain. Nothing is credited for a cancelled trip.
BEFORE 1/6, AFTER 6/6 on tools/probe_veil.py./api/battle now REPLAYS the answer it gave, closing the case where
a lost response paid the player nothing for a battle the server had credited.saves and one wrote the parts; all six now go through
writeSave(), which splits and stamps a watermark in the same transaction. The read cutover will
trust the parts only while that watermark matches saves.ver and the wipe generation, and rebuild
from the monolith otherwise — so a writer that bypasses the facade is caught rather than served. A realm
reset now deletes the parts, which it never did: at cutover that would have resurrected the wiped run.
Monotonic ver became an enforced invariant, because a restore that lowered it would have made
stale parts read fresh.snap.units as an array when it is an object, threw on every boot and dropped the snapshot,
so no battle had ever resumed — measured against the pristine build, not inferred. Its floor test read
fields that do not exist at the top level of a snapshot and never fired either.Line_ID because the creature table was absent, not wrong. Three attempts, 5xx and network errors only. The remaining fail-open behaviour after the retries are exhausted is unchanged and needs an owner ruling.src/worker.js (byte-identical on main and dev, so
all backend statements are true on RELEASE), play/app.js and play/index.html. Corrections:
the endpoint table listed nine routes against a real surface of about thirty-five, and is now a full inventory that
distinguishes wired doors from built-and-unwired ones; the board read was never behind an edge cache
and this page said twice that it was; the Wraithgold ceiling and its wraithgold_over_grant flag were
deleted on 2026-08-02 and replaced by a ledger overwrite, as were the slot caps; base_ver is no longer
optional; the save_read class, the read-only exemption and the one-hour soft-block TTL were missing
entirely; the “nothing schedules the sweep” row had been stale since 2026-07-29; and /api/me
polls at 60 s, not 15. New sections: How a sync is queued (deadline-takes-the-minimum, write chaining,
the hidden/pagehide split, and the re-arm rule per answer class) and What lives on the
device (every localStorage key, its namespacing, and the four fields that never travel). Five items added to Open,
the largest being that the three chance doors are built and nothing calls them.herumon_run_rel_v1 while the app read herumon_run_v2:<env>:<sid>, and the migration bridging them only copies when the namespaced key is empty — so any device that had played before discarded the server save unread and booted on its own stale state, with a valid epoch and base_ver. The shell now writes both keys. The fence was never at fault.retry_ms, while a 409 still does not. hidden is throttled to one flush per 15 s with the dirty flag kept, and pagehide stays unthrottled. Flags an open tension: keepalive bodies cap at 64 KB against a 131,072-byte save ceiling.base_ver is no longer optional (report #16). Web Locks holds ownership for the tab's lifetime and the loser is offered a steal-and-reload instead of silently racing the owner; a fenced session must now send base_ver on any realm the server already holds.GET /api/save mints a per-instance epoch; every later request carries it and the object refuses a lower one with 409 stale_session. A login already fenced other devices; this fences other tabs, which is the case that caused the loss. Adds the ops-key /api/backup/drops read door over the 7-day drop grant log._staleInst/_loadBroken stopped both save writers but not /api/battle, so a window kept being credited for ten hours while nothing it did was stored, and the login rule then correctly discarded all of it. The latch now enters the connection-lost pause, battle entry awaits its flush and refuses the fight if it did not land, and /api/battle derives the divergence from _ledB and answers _stale.envPrefix() reads a module global set
inside fetch(), and a scheduled() invocation can run in an isolate that has never served a
request. It then returned null and the sweep bailed with paused:'env-unknown' — the right call
(writing into the other environment’s keys would be worse) but it meant the sweep could sit out indefinitely on a
quiet worker. Measured on live: the newest mirror for an active account was a save from two days earlier while every
forced sweep, which runs on a request-served isolate, worked perfectly. The environment is now STAMPED durably in the
world object the first time each isolate serves a request, and the sweep reads it back when its own isolate does not
know; ENV_NAME still wins if it is ever set, and a world that has genuinely never seen traffic still
pauses rather than guessing. A full forced cycle was run at the same time, so every live account now holds a current
mirror.base_ver; the second 409’d and the client
rebooted onto a pre-ascend save, reporting it as another device. Proven both ways against the shipped write path: on the
previous build the second POST carries base_ver 7 against server version 8, 409s, and arms the conflict
reboot; after the change it carries 8, is accepted, and no reboot is armed
(tools/verify_flushrace.py).__wg_seed flag claimed before
the credit. Verified tools/probe_wgseed.py 7/7 against a real worker: a pre-seed account is
seeded, a second save does not re-mint, spending down to 40 sticks at 40 (the seed is not a standing floor),
and a forged 999,999 claim is still clamped.advArc had never joined the strip list and grew
without bound, reaching 54% of a live 127,115 B save. At the cap every save is refused, the client drops a
non-OK answer silently, and each login adopts the stale stored copy — one account lost thirteen hours and
1,908 credited kills. The archive is now stripped by both the client and the save door, and spliced back from the
device on login with the local copy winning. Wire measured 127,105 B → 58,531 B on the affected
player's real save.200 OK, so every reload re-adopted the reduced copy. Now: earned progress is grow-only, the floor ceiling can never sit below what the account already held, kill and win counters are capped by what the server credited (new ledger columns, delta-stamped per save), an optional base_ver handshake refuses a stale session 409 with the authoritative save, and every override is reported in srv:{}. Separately, POST /api/roll gives the server the chance outcomes it never had — the worker held no item logic at all, so every roll in the game was made on the client and merely reported.dev/ for the dev deployment; skip-when-unknown), live=1 export reading the player object's SQL with every export response naming its source, and the ops restore gaining the newer-save guard it lacked (409 unless force=1). The bare / path also gained Cache-Control: no-cache — only /index.html had it, so the shell served for / could go stale across deploys, which is how /dev pages spent whole sessions talking to the release API.floorNum still read, so advanceProgress — the only writer of the server
ratchet — could never pass a boss (the first two players to fell one both capped at
maxFloor 10 with floor 11 refusing), and the x9 floor defeated the spawn-table machinery's own
boss handling (T4.boss skips the wild-slot clamp). The victory event now carries the boss floor
itself plus boss:1; the plain ratchet credit passes the boss with no special path, boss events
off boss floors are flagged (boss_floor_anomaly) and credit nothing extra, and the
BOSS_DEFEATED adventure-log record names the boss floor. The ops-restore ratchet seed dropped its dev-era
floor of 9 as well.{sid, gen}; the shell captures the session identity from /api/me
and refuses to adopt, splice or resume anything stamped for another account or another generation — the
device adventure-log/floors splice, the run slot at load, and battle resume are all gated. And the shell no
longer concludes anything from a FAILED save-sync: a 500 or malformed /api/save response used to
read as “the server has nothing”, preserving the real local aside and offering character creation
— one confirm from flushing a newborn over a real save. The game now refuses to mount until the sync
succeeds (gate + retry). Battle-chain heads are cleared on wipe-kick and sign-out; server-side chains were
already steamid-keyed, so foreign chains were benign.herumon_battle_v1) carried no epoch/generation stamp, survived
every wipe path, and resume validated only the realm before writing floorNum: snap.floorNum||9
— a pre-wipe snapshot resumed on a reborn account and teleported it to floor 9. The snapshot is now
stamped {ep, gen} at persist; resume DROPS any snapshot that is unstamped, from another
epoch/generation, or whose floor exceeds the loaded save's maxFloor+1; and every path that
removes a run save removes the snapshot with it. (2) progressOf seeded a missing ratchet row
at 9 (dev-seed era), so a fresh account could enter and be credited on floors ≤10 — the seed is now
1, or the save's own maxFloor. (3) Stored floorNum was verbatim while
maxFloor was clamped, so a teleported position persisted server-side and every login adoption
re-applied it; floorNum now clamps to ratchet+1 (flag floornum_over). Genesis
floor ceilings dropped 9 → 2 (2, not 1, so a first flush delayed past a single ascend cannot brick an
account). Also fixed: the floor-travel veil had no timeout while awaiting the server pool, so a refused or
dropped /api/floor/enter froze it forever — it now clears after 30 s with a warning.maxFloor ratchet, the retired shell upload, and the key-locked ops restore — see the
Save trust row. State never creates progress; only credited events do.ts wins", which let a browser last used days earlier resume at floor 6 against a
server save at floor 39 and then attempt to overwrite it. The server copy is now authoritative on
login; local is used only when the server has nothing for that realm. Reproduced and verified in all
three cases before and after.