Removes operator-private material from the shipped tree, corrects documentation
that had drifted from the code, deletes dead code, and applies a consistent
prose style.
Private material moved out of the public tree (docs/private/, git-ignored):
- the wallet keyfile recovery runbook and its helper scripts, which together
published the mainnet wallet file path, the service account, backup snapshot
filenames, and the wallet RPC listener state. The helper duplicated the same
constants, so relocating the document alone would not have removed them.
- the stagenet parallel-candidate runbook and its fixtures.
- the dated production-state record, the internal remediation plan, and the
TODO list. None are reference documentation for an outside reader.
Redacted or genericised:
- operator home-directory paths in shipped scripts and units. The asset build
script now takes KENNEY_ASSETS_DIR instead of a hard-coded path.
- private LAN addresses in tests, env examples and deploy docs, replaced with
RFC 5737 documentation addresses.
- the default alert sender and the notification hub example.
- the Monero wallet unit now matches the Wownero one: a dedicated service
account and a config file, instead of the operator's login account with the
wallet password in argv where any local user can read it from /proc.
Ignore rules now cover every .env variant rather than the bare name, plus local
agent state. The deployment docs instruct operators to create .env.mainnet and
.env.stagenet, which the exact-name rule would have allowed to be committed.
Documentation corrected against the code: per-chain dungeon level counts, the
premium unlock rule, Tavern chat scope, backup retention, the chain-profile
fallback, the environment template location, and several sections describing
shipped work as pending.
Dead code removed: configPersistence, rpccalls, a duplicate vendored ROT.js
bundle, the unused tile-picker overlay, the unreachable legacy game-size path
and its vestigial parameter, spectator stubs that shadowed the real
SpectatorManager, and commented-out debugging. nip98 now imports the shared
nostr loader rather than duplicating it.
Prose style: em-dashes removed throughout in favour of colons, semicolons and
commas; comments restated as present-tense descriptions of the code rather than
narration of past changes.
127 suites / 1015 tests pass.
Closes the two findings left open from the pre-launch review.
Node redundancy (it was node reachability, not chain health)
RPC_ENDPOINTS accepts any number of daemons in preference order; PRIMARY /
FALLBACK still work and are appended. The list is deduplicated, which exposes
that the stock config pointed both at the SAME host — what looked like
redundancy was one node.
One logical call now walks every endpoint before failing, instead of needing
three consecutive failures to try exactly one alternate. Each node still has
to pass the chain-identity check before it may answer, so failover cannot
silently serve another chain's data. `failoverActive` is no longer a one-way
latch: the preferred node is re-tested every RPC_PREFERRED_RETRY_MS, so
recovery no longer requires a restart. healthCheck probes every node.
Graceful failure
Entries are refused while no node is reachable. A run is measured in blocks
and its entry block comes from the last successful poll, so a game admitted
during an outage was anchored to an already-past block and killed as a timeout
on the first recovered poll — the player lost a run, and in paid mode an
entry, to our infrastructure. The gate sits in _runAdmission, ahead of any
credit spend, and says so to the player.
Wallet: refused, not failed over
Two wallet-rpc processes over one wallet file can build transactions from the
same outputs and corrupt the cache. A standby wallet is a custody decision for
the operator, not something this code may take on its own — daemons are
read-only and interchangeable, wallets are neither. So a wallet outage
declines paid intake cleanly and leaves credits and free play working.
Auth limiter evicts instead of refusing
The bounded key map 429'd any NEW ip once full, turning a memory cap into a
DoS primitive: a distributed source could hold it at capacity and lock every
first-time caller out of Smirk login. It now reclaims expired entries and, if
still full, evicts the oldest live windows. Same memory ceiling, still a
working limiter.
129 suites / 1026 tests pass.
Adversarial multi-agent review of the launch candidate produced 22 confirmed
findings (12 more were refuted). This closes the critical, high, and the
mediums/lows that are cheap and real, then does the iso/3D/Tavern art pass.
CRITICAL — unauthenticated remote kill switch
Socket.IO does not wrap listener invocation, and this process treats
uncaughtException/unhandledRejection as fatal, so ONE unguarded throw in ANY
listener exits the server. `socket.emit('debug_ping')` with no argument did
exactly that, and systemd restarts straight back into it. Every registration
in handleConnection now goes through a safe-dispatch wrapper that contains
sync throws and rejected promises to the offending socket; handleDebugPing
also stops trusting its payload.
HIGH
- NIP-98 login now binds the signed event to this deployment's host. The path
check alone let an event signed for any other origin authenticate here, and
linking ADOPTS the account owning that pubkey — full account takeover.
- Fog of war is enforced server-side. getState() shipped absolute
entrance/exit/treasure coordinates and the monster's exact position in every
frame, with concealment implemented only in the browser. Discovery is sticky;
the monster is reported only while in view.
- NODE_ENV must now be explicit and recognized. Unset, every production gate
read false while DebugManager reported production and silenced logging.
- Tavern chat applies the lobby's ban list, rate limiter and user_id
attribution before entering global chat.
- userRegistry is deleted by socket.id (it was keyed by socket.id and deleted
by socket.client.id, so nothing was ever freed).
MEDIUM / LOW
- Payouts: a legitimately split transfer_split kept its transaction hashes
instead of quarantining already-broadcast funds with tx_hash NULL, and
provably pre-broadcast failures return to 'pending' rather than needs_review.
- RateLimiter key parsing fixed — cleanup() freed nothing and expired windows
never reset, because ids contain colons.
- Enter no longer submits a paid entry from behind a modal.
- Lobby chat no longer double-escapes server-escaped text.
- Rate limits for identity:get / address:prompt / early_entry.
- Spectators released when a game ends by disconnect, not just settlement.
- movementManager / addressManager per-socket maps cleaned on disconnect.
Renderers
Iso: opaque shading instead of translucent fog, additive torch/lantern/beacon
light pools, crevice AO, embers, cold memory wash, and it stops reallocating
the whole canvas every animation frame.
3D: instanced level geometry (3 draw calls), procedural stone/flagstone
materials, torch lights + shadows, real meshes for stairs/chest/monster
(they were humanoid capsules), dust motes, wall cutaway, and lighting
rebalanced now that fog is measured from the camera.
Tavern
Block height and hash fragments drift around the stage edge and ripple when a
block lands, fed by a cosmetic chain tip added to the blockheight broadcast.
Speech bubbles work again (they keyed off a socketId the server stopped
sending).
126 suites / 1005 tests pass.
Nails down the premium unlock system per the design pass:
- FREE `original` topdown pack (NEW): the game's bare tiles — floor/wall/door/
torch/features only, no furniture, so the free tavern reads plain (the intended
"reason to unlock"). Registered in assetPacks (RK.THEMES.original) as the free
default topdown pack; plain ASCII stays free (accessibility).
- Loyalty ladder (lifetime credits PURCHASED, cumulative, NOT deducted → buying to
play also unlocks cosmetics): Interior 1, skins 5, iso 10, +tilepack 20, +iso 40,
3D 50. Each row is data (tilepack OR skin) → operator appends more at any rung.
DEFAULT_CATALOG (entitlements.js) + migration 028 + MONETIZATION.md ladder table.
- Tests updated to the new thresholds (catalogEntitlements_ultra, directEntryCredit_ultra).
Note: this reprices roguelike-interior from free → 1cr, so once deployed the free
tavern becomes bare (by design). Deploy is bundled with the single-player render-kit
migration so the free-tier change lands with its payoff. Suite 61/432 green.
- README: rewrite Difficulty section (per-network LEVELS ∝ block time, WOW=5min,
multi-level descent, corrected casino size); document game_event + depth in the
Socket.IO table.
- NEW docs/BALANCE_SIM.md: the block-timed edge math (exponential deadline), the
headless sim over the real engine, what it proved (size self-cancels, fast monster
fails on slow chains), and the per-network NETWORK_TUNING model.
- NEW docs/MULTI_LEVEL.md: descent mechanic, vault-only treasure, per-level
provably-fair seeding, config, file map, open polish.
- NEW docs/RENDER_PACKS.md: modes-vs-packs, the RK.fx layer, iso/tiled/zoom/cat.
- docs/ARCHITECTURE.md: mark the block-time-difficulty gotcha RESOLVED; add render
kit + balance/pacing sections and cross-links.
- docs/TODO.md: 2026-07 done/pending section.
Caveat 2 (premium sub had no data source) -> RESOLVED:
- src/services/subscriptionService.js: resolves a premium tier by npub. Two
sources, both optional (absent => null => unchanged): PREMIUM_NPUBS operator
allowlist (npub1/hex[:tier]) works TODAY with zero backend dependency, and an
optional SMIRK_PREMIUM_STATUS_URL HTTP endpoint for full automation. Cached.
- identityService.entitlementsForUser populates user.subscription_tier from it
(by smirk_public_key) before the snapshot, so an active sub unlocks cosmetics.
(The Smirk backend's /premium/status is self-only, so a by-npub service lookup
is the one remaining backend addition for full automation — documented.)
Caveat 1 (client signing was bare scaffold) -> HARDENED:
- html/js/smirkChatSign.js now does the Nostr-scope-grant retry (sign first; on
NOT_AUTHORIZED, getNostrPublicKey() once + retry), same as SmirkAuth — so a
returning user signs with one approval instead of silently falling back. Still
feature-detected + graceful. A real Smirk wallet click is the last confirm.
Docs updated (MONETIZATION.md, CHAT_AND_NOSTR.md). Tests: +6 (allowlist hex/npub1,
HTTP active/inactive+cache, identityService unlock, non-premium unchanged).
Suite 57 suites / 413 tests green.
Adds a complete block-cadence multiplayer race mode alongside solo and tavern.
Disabled by default; enable with MATCH_ENABLED=true.
Highlights:
- New shared engine: MatchRoom, MatchEngine, MatchState (transport-agnostic, unit-tested).
- Per-economy queues: free, credits_prestige, crypto_race with atomic escrow/refund.
- MatchScheduler drains queues on every new block and starts 2–32 player races.
- MatchManager handles Socket.IO lifecycle, server-authoritative movement, reconnect grace,
forfeit/AFK handling, and end-of-match persistence.
- Crypto races use race-entry tickets (grants.race_entries) to avoid on-chain refunds; winner
payout is queued through the existing batch payout/retry service.
- Leaderboard integration: free → Pleb, prestige → /api/leaderboard?board=prestige,
crypto → Hall of Champions.
- Tavern integration: Queue for Race panel, active race list, and live spectator rendering.
- Dedicated /match.html client with economy selector, race HUD, and post-match scoreboard.
- Migration 022 adds matches, match_entrants, match_events, match_queue_entries,
race_entry_transactions, users.race_entries, and payouts.match_id.
Docs:
- New docs/MATCH_MODE.md with full architecture, config, events, and operational notes.
- Updated README.md, docs/TAVERN_AND_MULTIPLAYER.md, docs/TODO.md, src/.env.example,
and src/.env.
Tests: 40 new unit tests across 7 suites, all passing.
Known follow-ups for real-world validation:
- Full integration test with Postgres + Socket.IO.
- Server boot check with MATCH_ENABLED=false (default) to ensure solo/tavern remain unaffected.
- Browser render-kit verification in /match.html and tavern spectator.
- Crypto-race payout end-to-end with real wallet-rpc.
Prepared (not executed) artifacts for the two-instance deployment:
- scripts/deploy/monero-wallet-rpc.service: Monero STAGENET wallet-rpc unit
(mirrors wownero-wallet-rpc; binds 127.0.0.1:38083, daemon :38081, password
placeholder).
- scripts/deploy/monerogue.service: second game instance unit (separate
user/dir/DB, :3001).
- docs/DEPLOY_INSTANCES.md: full runbook for both instances — mainnet
reconfigured to free-to-play + NO payouts, and the new Monero stagenet
instance with payouts ON; includes the monerogue .env and verification.
No server changes and no secrets committed (placeholders only).
- Remove the duplicate _gameMode declaration in the client SocketHandlers
object literal — it was declared twice, so the later `null` silently
shadowed the earlier `'free'`. Now declared once with the effective value.
- Remove the dead SocketHandlers._appendMessage calls in smirkAuth.js; that
method never existed, so the guard always fell through to the working
.text() append. Keep the safe append.
- Gate broadcastManager's high-volume per-event logs (user count, chat message
incl. message/socketId PII, game update, game start) behind debug mode;
previously they logged on every event regardless of NODE_ENV.
Full suite: 21 suites / 155 tests.
4.2 (partial): Delete the dead handlePlayerMove socket handler and its
orphaned playerMoveTimestamps map + cleanup. It was never registered (the live
move path is MovementManager.handleMove) but, if ever wired, it double-stepped
the monster and bypassed the escape/collision event handling — a loaded
footgun. No behaviour change.
4.5: Delete 14 stale non-jest files from test/ (5 scratch .js scripts + 9
.html debug pages) — they contain no jest constructs and are referenced
nowhere. test/ is now exclusively the 21 real jest suites.
Full suite: 21 suites / 155 tests.
3.2 Reachability: a dungeon whose exit is unreachable from the entrance is
paid-but-unwinnable. generate() now BFS-checks entrance->exit and regenerates
if unreachable. Retries are deterministic (per-attempt ROT seed = seedInt +
attempt; the seeded rng stream keeps advancing), so verify-regeneration
replays the same attempts and reproduces the exact dungeon — the provably-fair
determinism from Phase 0.2 is preserved. The single-pass body is extracted to
_generateOnce and isReachable does the flood-fill.
3.3 Monster collision: with movesPerPlayerMove > 1 (the casino preset's 1.5x
speed) the monster takes multiple steps per turn, but collision was only
checked after the loop — so it could step onto the player's tile and off again
between checks, phasing through and missing the catch (player-favorable, erodes
the house edge). Collision is now checked after each sub-step.
Adds reachability tests (provablyFair.test.js) and monsterCollision.test.js.
Random block timing remains the untouched core mechanic. Full suite: 20 suites
/ 153 tests.
Random block timing is the core mechanic and is intentionally NOT softened —
early entry is a knowingly-risky bet. The only change is a small configurable
grace (GAME_START_GRACE_MS, default 2000ms, 0 to disable) so a block landing
in the same instant a game starts can't kill the player before the dungeon
even renders or before their first move is possible (100ms move cooldown).
(Replaces an earlier over-reaching "uniform time budget" idea that would have
defeated the random-timing mechanic.)
- register_client now validates the client-supplied clientId (type, length,
charset) before it enters clientSocketMap as both key and value, closing a
map-poisoning / mapping-impersonation vector.
- mempoolNotified is now a Map with real TTL eviction owned by PaymentHandlers
(the actual dedup set). The dead duplicate Set in SocketHandlers and its
random-10%-per-cycle eviction (which could drop live entries) are removed;
addresses are also evicted promptly when monitoring stops.
- The SessionManager `sessions` cache is evicted on disconnect (removeSocket,
called after the suspend logic reads it) so it no longer grows unbounded
with every socket ever seen.
- sessionManager.dispose(), suspendedGameManager.cleanup() and
paymentHandlers.dispose() are wired into SocketHandlers.shutdown() so their
timers/maps don't leak on shutdown.
- checkGamesTimeout snapshots activeGames before iterating and awaits each
game-over sequentially (was mutating the Map during forEach and firing
async game-overs concurrently, which could skip games / double-process).
Adds inputValidation.test.js. Full suite: 19 suites / 149 tests.
Socket-event rate limits keyed on socket.id (new on every reconnect) and
passed NO ip argument, so the "IP-limited" prefixes never actually applied —
reconnecting reset every limit, and chat had no IP limit at all.
- New rateLimitContext helper resolves a STABLE identity (anon_token -> id,
falling back to socket id) and the real client IP (X-Forwarded-For honoured
only when TRUST_PROXY=true, else handshake address — no spoofing on direct
exposure).
- game:start, address:set and chat:message now pass (stableId, action, ip);
connection:new uses the real IP. 'chat:' added to IP-limited prefixes.
- Express 'trust proxy' enabled when TRUST_PROXY=true; documented in
.env.example. Aligned the contradictory default limit values with the
production override.
Adds rateLimit.test.js (reconnect can't bypass, IP backstop for fresh
identities, XFF gating). Full suite: 18 suites / 143 tests.
There were two identity systems: SessionManager keyed on the stable, UNIQUE
anon_token, and getOrCreateUser keyed on the mutable, non-unique socket_id.
The latter could create a duplicate "orphan" user row (or resolve the wrong
row) when the socket_id lookup raced the session's socket_id update —
desyncing credits and payouts between the two rows for one human.
- getOrCreateUser now resolves through SessionManager's stable identity first
(anon_token -> users.id), re-selecting a fresh row by stable id, and only
falls back to the legacy socket_id lookup when no session exists. The
last-resort create now warns, since it indicates a missing session.
- gameModeManager.sessionManager is wired in SocketHandlers so the
session-first path is active in production.
- anon_token is already UNIQUE (migration 003), so the stable-identity
constraint already exists; no new migration needed.
Re-keying the in-memory activeGames map by user_id is deferred (invasive;
already mitigated by stamping dbUserId on the game in the disconnect fix).
Adds identityResolution.test.js proving no duplicate row is created when a
session exists. Full suite: 17 suites / 136 tests.
- Migration 018 widens users.credits and credit_transactions.amount/
balance_after from INTEGER to BIGINT (consistent with the atomic-money
columns; removes a latent overflow risk on admin-configurable packages).
- Migration 010 rewritten with conname-guarded DO blocks so it is idempotent
and safe to re-run / recover from a partial apply (bare ADD CONSTRAINT
previously hard-failed on re-run and could wedge startup).
- The migration runner now applies each migration and records its ledger row
in a single transaction, so a migration can never be applied-but-unrecorded
(which would re-run a non-idempotent migration on the next boot).
Verified end-to-end against a throwaway Postgres database: all 18 migrations
apply cleanly, a second run skips via the ledger, credits is bigint, and the
new constraints/indexes are present. Full suite: 16 suites / 134 tests.
The admin "send funds" refund path called walletRPCService.sendPayment(),
which does not exist — guarded by a typeof check, so it silently never sent
while still reporting the refund as processed.
Now uses the real processPayout() (validates the address and transfers,
returning { success, txHash }), and refunds the amount the user actually paid
(received_amount, recorded at confirmation) rather than the expected amount,
falling back to expected for older records.
Two bugs in payoutRetryService.retryPayout:
- The "already on-chain" path guarded the user-stats update with
NOT EXISTS (... WHERE id = this AND status = 'completed'), but it had just
marked that same row completed in the same transaction, so the guard was
always true-negative and stats were never counted. Replaced with a
conditional UPDATE ... WHERE status <> 'completed' RETURNING, so the row's
transition drives an exactly-once stat count.
- A successful retry wrote tx_hash in a standalone query and then marked the
payout completed in a separate transaction; a crash between the two left a
tx_hash on a non-completed row. Now tx_hash + completion + stats happen in
one transaction.
Adds payoutRetry.test.js (count-once, no-double-count, atomic completion).
Full suite: 16 suites / 134 tests.
A typo in a status string (e.g. 'complete' vs 'completed') silently produced
a money row no index/predicate matched, orphaning funds.
- Migration 017 adds idempotent NOT VALID CHECK constraints on payments.status,
payments.payment_type and payouts.status. NOT VALID enforces all new writes
without failing on any pre-existing rows; conname guards make it re-runnable.
- The in-code existing-payout guard in completeGame now also covers the
'processing' status (matching the unique index from migration 015), so a
duplicate win can't create a second payable row while a payout is mid-flight.
Full suite: 15 suites / 131 tests.
Payouts were computed at completion from LIVE config, so an admin changing
multipliers mid-game would change the payout of an already-in-progress game.
- Migration 016 adds payout_escape_amount/payout_treasure_amount (BIGINT) and
the corresponding multipliers to the games table.
- Both game-start paths stamp the resolved payout amounts (computed from the
config in effect at entry) onto the game row.
- completeGame pays from the snapshot, falling back to live calculation only
for legacy games that predate the snapshot columns.
Adds payoutSnapshot.test.js proving a mid-game config change cannot alter an
in-flight payout. Full suite: 15 suites / 131 tests.
Money is integer atomic units, but payout and sum math ran on float64
(`Math.round(base * multiplier)`, `sum + Number(x)`), which silently loses
precision above 2^53 — a real risk for large/credit/XMR amounts.
- Add src/money/atomic.js: the single source of truth for atomic-unit math
(toBig, toSafe, sum, add, mulByDecimal, format). mulByDecimal multiplies an
atomic amount by a decimal multiplier using exact integer arithmetic with
half-up rounding; toSafe narrows to a number only when exactly representable
and otherwise returns a decimal string.
- calculatePayout now uses money.mulByDecimal/toSafe instead of float math.
- walletRPCService batch total + fee sum use money.sum instead of float reduce.
Adds moneyAtomic.test.js with property tests on amounts above 2^53 proving
exactness where the old float path drifted. Full suite: 14 suites / 129 tests.
The client trusted the server to pre-escape chat content and rendered
user-supplied fields via .html()/innerHTML — one server regression away from
stored XSS, and the admin panel injected user-controlled payout addresses
into its authenticated DOM.
- Add a single shared escapeHtml helper (escapes all five HTML-significant
chars including both quotes); leaderboard now delegates to it.
- Escape chat username + message in onChatBroadcast/onChatHistory.
- Escape user-controlled fields in the admin panel (payout address, socket
id, player name, status) and make its local escapeHtml quote-safe so values
in title="..." can't break out.
- Add CSP plus X-Content-Type-Options, X-Frame-Options and Referrer-Policy
headers via middleware. connect-src 'self' keeps Socket.IO working;
removing 'unsafe-inline' (nonces) is tracked as a later cleanup.
Full suite: 13 suites / 119 tests.
Two disconnect-related money/UX gaps:
1. A confirmed single_game payment whose game never started (user paid, then
disconnected) was money-in-nothing-given with no recovery path. recover-
PendingPayments now detects confirmed single_game payments with no linked
games row and grants the equivalent credits — idempotently (per-payment
reason key, row-locked re-check of both the game link and prior recovery).
2. In-progress games could be dropped on disconnect when a fresh getBySocket
lookup failed. The stable dbUserId is now stamped on the game at creation,
so suspend/restore keys on it directly without a failure-prone disconnect-
time lookup. Any game (paid or free) is preserved/reconnectable whenever a
session exists; the remaining drop-branch is only the true no-session edge
(e.g. DB unavailable at connect), which is logged for audit rather than
masked.
Adds singleGameRecovery.test.js. Full suite: 13 suites / 119 tests.
The anonymous session token is a bearer credential that was sent in the
Socket.IO handshake query string, where it leaks into proxy/access/referer
logs and is easily observed. Anyone holding it becomes that user (credits,
payout address, pending payments).
- Client now sends the token in the handshake `auth` payload instead of the
query string. Server reads `auth` first and falls back to the query string
for backward compatibility during rollout.
- The token is rotated on every successful resume, so a leaked/observed
token is invalidated the next time the legitimate owner reconnects. The
client already persists the token returned in `session_resumed`.
Adds a security test asserting resume returns a new token and the old token
no longer resumes. Full suite: 12 suites / 116 tests.
transfer_split sends one on-chain tx to many destinations, so payout rows
in a batch share a tx_hash. The old UNIQUE index on payouts.tx_hash made
the 2nd+ row fail to record, leaving funds sent but rows stranded in
'processing' and re-sendable by the retry service.
- Migration 015 drops the payouts tx_hash unique index (idempotency is
enforced by idx_payouts_one_per_game) and extends that guard to cover
'processing' so no second payable row can be inserted mid-flight.
- Batch completion now updates the whole batch in a single transaction via
id = ANY(...), so rows can't be left half-completed. Per-user stats use
BIGINT arithmetic in SQL (no JS float).
- Ambiguous batch failures are marked 'needs_review' (which the retry
service does NOT pick up) instead of 'failed', preventing a double-send
when transfer_split may have already broadcast. Operator is alerted.
Adds batchPayout.test.js covering the shared-tx_hash completion and the
needs_review failure path. Full suite: 12 suites / 115 tests.
Phase 0.1 — Underpayment confirmation bug (CRITICAL):
Payment confirmation gated on `status.confirmed`, which checkPaymentStatus
sets for ANY incoming tx regardless of amount, so sending a single atomic
unit granted a full game/credits. Now gate on `status.complete` (received
>= expected). Underpayers are warned once and the payment stays pending so
a top-up can complete it. Persist `received_amount` for single-game and
credits confirmations for reconciliation. Adds an underpayment regression
test.
Phase 0.2 — Provably-fair was decorative (CRITICAL):
The committed seed had zero effect on the dungeon (created seededRNG was
unused; generation used Math.random + a globally-seeded ROT.RNG). Thread a
per-game seeded RNG derived from the committed seed through dungeon
generation (reseed the Digger via seedToInt + replace all Math.random) and
monster movement (seededShuffle). Add DungeonGenerator.regenerateFromSeed
and layoutFingerprint; /api/verify now regenerates the dungeon from the
seed and returns a fingerprint a player can independently reproduce. Adds a
determinism test suite.
Full suite: 11 suites / 113 tests passing.
Plan and progress tracked in docs/REMEDIATION_PLAN.md.