fix: resolve security/correctness review + AAA graphics pass

Backend (Tier 0/1/3): single-payout double-pay -> needs_review; admin-retry tx_hash guard; UNIQUE index on games.payment_id (migration 023) + atomic claim; reconnect no longer resets block-timeout; completeGame keys on user_id+dungeon_seed (not socket_id); token-gated /api/user/:socketId/* endpoints; payout min/max enforcement; orphaned-active-game boot recovery; sessionManager cleanup FK fix; XFF rightmost-hop; global uncaughtException/unhandledRejection guard; server binds 127.0.0.1; provably-fair client-seed (HMAC) + RNG [0,1) bound; difficulty preset ranges reach the digger; chat no longer leaks full socket.id; removed dangerous-keyword SQL theater.

Match mode (Tier 2, still MATCH_ENABLED-gated): fixed match_queue/match_move crash paths + scheduler dead-code + leaked ceiling timer; pot collected before start with abort+refund; BigInt/basis-point fee math, capped payout; no-address winner claimable; winnerId==placement[0]; finalize schema + payout-first isolation; reconnect re-key with ownership; refunds on all cancel paths; real countdown; map/timer cleanup. +16 tests.

Graphics: new overlay FX layer (torch light, vignette, embers, sparkle/burst/flash/shake, self-suspending rAF); gameplay juice; atmospheric screens; CSS design-token chrome with framed modals; richer Kenney tile renderer; imported Kenney dungeon/interior sheets + fx particles.

Verification: 45 suites / 312 tests pass; 40 changed JS files parse; 20 server modules load clean; no regressions.
This commit is contained in:
jwinterm
2026-07-09 17:46:58 -04:00
parent 0f68eec26b
commit a62eefb47b
48 changed files with 3964 additions and 623 deletions
+17
View File
@@ -530,6 +530,11 @@ class MatchRoom extends Room {
if (this.status !== 'finished') return this.playerStates;
const exit = this.dungeon.exit;
// A winner already designated by first-to-escape (_checkResolution) must remain the
// winner: the first player out wins the race regardless of treasure/speed tiebreakers
// among later finishers. When no one escaped (timeout / all-dead) preWinner is null and
// ranking alone decides.
const preWinner = this.winnerId;
const ranked = Array.from(this.playerStates.entries()).map(([id, state]) => {
const occ = this.occupants.get(id);
const dist = exit && occ && state.alive ? Math.abs(occ.x - exit[0]) + Math.abs(occ.y - exit[1]) : Infinity;
@@ -537,6 +542,12 @@ class MatchRoom extends Room {
});
ranked.sort((a, b) => {
// Designated escape-winner always ranks first (placement #1 == crypto payout target).
if (preWinner) {
const aw = a.id === preWinner ? 1 : 0;
const bw = b.id === preWinner ? 1 : 0;
if (aw !== bw) return bw - aw;
}
const finishedA = a.state.finished ? 1 : 0;
const finishedB = b.state.finished ? 1 : 0;
if (finishedA !== finishedB) return finishedB - finishedA;
@@ -557,6 +568,12 @@ class MatchRoom extends Room {
r.state.score = this._calculateScore(r.state, i + 1, r.dist);
}
// Winner consistency across ALL end paths (escape / timeout / all-dead): the crypto
// payout target (winnerId) and the leaderboard's placement #1 always name the SAME
// player. finalize() is the single source of truth — it runs after expire() in every
// path, so it reconciles any provisional winner expire() may have set.
if (ranked.length > 0) this.winnerId = ranked[0].id;
return this.playerStates;
}