From a9fad6903875462725ee3c63a42184031b20a986 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Thu, 9 Jul 2026 19:18:34 -0400 Subject: [PATCH] fix(match): reconcile no-address winnings on address set; age-guard boot recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up 1: when a user sets a payout address, gameModeManager.setUserPayoutAddress now converts any claimable match winnings recorded with no address (reason 'match_winner_no_address', status 'needs_review', sentinel 'PENDING_NO_ADDRESS') into sendable 'pending' payouts and kicks the batcher. The reason filter is narrow so it never touches the ambiguous-broadcast needs_review rows (which would double-pay). Follow-up 2: match rows are now marked status='active' with started_at when the countdown completes, and _recoverAbandonedMatches gains a lock-free age guard — single-instance (default) reclaims all in-flight rows immediately, multi-instance (MATCH_SINGLE_INSTANCE=false) only reclaims rows older than the hard ceiling + buffer, so a race running on a sibling instance is never cross-cancelled. Adds test/followups_ultra.test.js (5 tests). Full suite: 46 suites / 317 tests pass. --- src/game/gameModeManager.js | 37 ++++++++++-- src/network/matchManager.js | 10 ++++ src/network/matchQueue.js | 17 +++++- test/followups_ultra.test.js | 112 +++++++++++++++++++++++++++++++++++ 4 files changed, 171 insertions(+), 5 deletions(-) create mode 100644 test/followups_ultra.test.js diff --git a/src/game/gameModeManager.js b/src/game/gameModeManager.js index 02b1b7b..fcb3c05 100644 --- a/src/game/gameModeManager.js +++ b/src/game/gameModeManager.js @@ -1285,17 +1285,46 @@ class GameModeManager { async setUserPayoutAddress(socketId, payoutAddress) { try { const user = await this.getOrCreateUser(socketId); - + await this.db.query(` - UPDATE users + UPDATE users SET payout_address = $1, updated_at = NOW() WHERE id = $2 `, [payoutAddress, user.id]); - + console.log(`💰 Set payout address for user ${user.id}: ${payoutAddress}`); + + // Reconcile any claimable match winnings this user earned with no address on file. + // A match winner without a payout address is recorded as a durable 'needs_review' + // liability (reason 'match_winner_no_address', sentinel address 'PENDING_NO_ADDRESS'); + // now that a real address exists, convert those to sendable 'pending' payouts and kick + // the batcher. The reason filter is deliberately narrow so this NEVER touches the + // ambiguous-broadcast 'needs_review' rows from the single-payout path (which have a real + // address and a different reason) — converting those could double-pay. + if (typeof payoutAddress === 'string' && payoutAddress.trim().length > 0) { + try { + const reconciled = await this.db.query(` + UPDATE payouts + SET payout_address = $1, status = 'pending', last_error = NULL + WHERE user_id = $2 + AND status = 'needs_review' + AND reason = 'match_winner_no_address' + AND payout_address = 'PENDING_NO_ADDRESS' + RETURNING id + `, [payoutAddress.trim(), user.id]); + if (reconciled.rowCount > 0) { + console.log(`💰 Reconciled ${reconciled.rowCount} claimable match payout(s) for user ${user.id} now that an address is set`); + if (typeof this._scheduleBatchPayout === 'function') this._scheduleBatchPayout(); + } + } catch (reconErr) { + // Never let reconciliation failure break address-setting. + const n = normalizeError(reconErr, 'Failed to reconcile claimable match payouts'); + console.error('⚠️ Payout reconciliation error (address still saved):', n.message); + } + } return true; - + } catch (error) { const normalized = normalizeError(error, 'Failed to update payout address'); console.error('❌ Error setting payout address:', normalized.message); diff --git a/src/network/matchManager.js b/src/network/matchManager.js index 9723ce1..19c60d5 100644 --- a/src/network/matchManager.js +++ b/src/network/matchManager.js @@ -114,6 +114,16 @@ class MatchManager { const engine = this.engines.get(room.id); if (engine && typeof engine.start === 'function') engine.start(); // activates room + ticks else r.start(); + // Reflect the now-live race in the DB (was left 'starting' during the countdown). This + // lets boot recovery distinguish a genuinely-running race from an abandoned one, and it + // stamps started_at so the age-guarded (multi-instance) recovery path won't reclaim a + // race that is legitimately in flight. + if (this.db) { + this.db.query( + `UPDATE matches SET status = 'active', started_at = COALESCE(started_at, NOW()) WHERE id = $1 AND status = 'starting'`, + [room.id] + ).catch(err => this._log('[MatchManager] mark-active error', err.message)); + } this._broadcast(room.id, 'match_start', { matchId: room.id, tickMs, seedHash: r.seedHash }); }, countdownMs); diff --git a/src/network/matchQueue.js b/src/network/matchQueue.js index 2bfbcc2..2c6f6e1 100644 --- a/src/network/matchQueue.js +++ b/src/network/matchQueue.js @@ -111,9 +111,24 @@ class MatchQueue { * cancel the match and refund every entrant so escrow is never stranded. */ async _recoverAbandonedMatches() { + // Single-instance (default, this deployment's model): a boot means all in-memory match + // state is gone, so every in-flight row is abandoned — reclaim them all immediately. + // Multi-instance (MATCH_SINGLE_INSTANCE=false): a sibling instance may be actively running + // a recent race, so only reclaim rows older than the maximum possible match duration + // (hard ceiling + buffer). Anything younger is left for its owner (its own ceiling watchdog + // finalizes it) or a later boot. This is a lock-free age guard; true cross-instance + // ownership would use a heartbeat/lease column. + const singleInstance = String(process.env.MATCH_SINGLE_INSTANCE || 'true').toLowerCase() !== 'false'; + const ceilingMs = parseInt(process.env.MATCH_HARD_CEILING_MS, 10) || 240000; + const minAgeSec = singleInstance ? 0 : Math.ceil((ceilingMs + 60000) / 1000); let rows = []; try { - const res = await this.db.query(`SELECT id, economy FROM matches WHERE status IN ('starting', 'active')`); + const res = await this.db.query( + `SELECT id, economy FROM matches + WHERE status IN ('starting', 'active') + AND COALESCE(started_at, created_at) <= NOW() - ($1::int * INTERVAL '1 second')`, + [minAgeSec] + ); rows = res.rows || []; } catch (err) { // matches table may not exist in a minimal test DB — non-fatal. diff --git a/test/followups_ultra.test.js b/test/followups_ultra.test.js new file mode 100644 index 0000000..d647268 --- /dev/null +++ b/test/followups_ultra.test.js @@ -0,0 +1,112 @@ +/** + * Post-review follow-ups. + * + * 1. No-address match winner reconciliation: setUserPayoutAddress converts a claimable + * 'needs_review' match payout (reason 'match_winner_no_address') into a sendable 'pending' + * payout once a real address is set — and NEVER touches other 'needs_review' rows (e.g. the + * ambiguous single-payout rows, which would double-pay if reset). + * 2. Match boot-recovery age guard: _recoverAbandonedMatches reclaims all in-flight matches + * immediately in single-instance mode, but only aged-out ones when MATCH_SINGLE_INSTANCE=false. + */ + +const GameModeManager = require('../src/game/gameModeManager'); +const MatchQueue = require('../src/network/matchQueue'); + +const createMockPaymentConfig = () => ({ + getConfig: () => ({ + paymentsEnabled: true, + currency: { symbol: 'WOW', decimals: 11 }, + modes: { direct: { enabled: true, price: 100000000000n }, credits: { enabled: false, creditsPerGame: 1, packages: [] } }, + payouts: { rules: { direct: { enabled: true, multipliers: { escape: 2, escapeWithTreasure: 3 }, minPayout: 1000000000n, maxPayout: 10000000000000n } } }, + preferences: { preferCreditsFirst: true }, + earlyEntry: { enabled: false } + }), + getLegacyGameMode: () => 'PAID_SINGLE', + eventBus: { on: () => {} } +}); + +function buildGmm() { + const calls = []; + const db = { + query: jest.fn().mockImplementation(async (sql) => { + calls.push(sql); + if (/UPDATE\s+payouts/i.test(sql)) return { rowCount: 1, rows: [{ id: 99 }] }; + return { rowCount: 0, rows: [] }; + }), + withTransaction: jest.fn().mockImplementation(async (cb) => cb({ query: jest.fn().mockResolvedValue({ rows: [] }) })) + }; + const gmm = new GameModeManager(db, { processPayout: jest.fn(), processBatchPayout: jest.fn() }, + { CONSOLE_LOGGING: false, getCurrentBlockHeight: () => 1 }, createMockPaymentConfig()); + gmm._scheduleBatchPayout = jest.fn(); + gmm.alertService = { sendAlert: jest.fn().mockResolvedValue() }; + gmm.getOrCreateUser = jest.fn().mockResolvedValue({ id: 7 }); + return { gmm, db, calls }; +} + +describe('Follow-up 1 — no-address match winner reconciliation', () => { + test('converts claimable rows and kicks the batcher when an address is set', async () => { + const { gmm, db } = buildGmm(); + const ok = await gmm.setUserPayoutAddress('sock', 'Wo3pRealAddress'); + expect(ok).toBe(true); + const payoutUpdate = db.query.mock.calls.find(c => /UPDATE\s+payouts/i.test(c[0])); + expect(payoutUpdate).toBeTruthy(); + // Narrow, safe filter — only the no-address match liability, never generic needs_review. + expect(payoutUpdate[0]).toMatch(/reason\s*=\s*'match_winner_no_address'/); + expect(payoutUpdate[0]).toMatch(/status\s*=\s*'needs_review'/); + expect(payoutUpdate[0]).toMatch(/PENDING_NO_ADDRESS/); + expect(payoutUpdate[0]).toMatch(/SET payout_address = \$1, status = 'pending'/); + expect(payoutUpdate[1]).toEqual(['Wo3pRealAddress', 7]); + expect(gmm._scheduleBatchPayout).toHaveBeenCalledTimes(1); // rowCount > 0 + }); + + test('does not run reconciliation for an empty/cleared address', async () => { + const { gmm, db } = buildGmm(); + await gmm.setUserPayoutAddress('sock', ' '); + expect(db.query.mock.calls.some(c => /UPDATE\s+payouts/i.test(c[0]))).toBe(false); + expect(gmm._scheduleBatchPayout).not.toHaveBeenCalled(); + }); + + test('does not schedule a batch when there is nothing to reconcile', async () => { + const { gmm, db } = buildGmm(); + db.query.mockImplementation(async () => ({ rowCount: 0, rows: [] })); // no claimable rows + await gmm.setUserPayoutAddress('sock', 'Wo3pRealAddress'); + expect(gmm._scheduleBatchPayout).not.toHaveBeenCalled(); + }); +}); + +describe('Follow-up 2 — match boot-recovery age guard', () => { + function queueWithCapturingDb() { + const params = []; + const db = { + query: jest.fn().mockImplementation(async (sql, p) => { params.push({ sql, p }); return { rows: [] }; }), + withTransaction: jest.fn().mockImplementation(async (cb) => cb({ query: jest.fn().mockResolvedValue({ rows: [] }) })) + }; + return { q: new MatchQueue({ db }), db, params }; + } + + const ENV = process.env.MATCH_SINGLE_INSTANCE; + const CEIL = process.env.MATCH_HARD_CEILING_MS; + afterEach(() => { + if (ENV === undefined) delete process.env.MATCH_SINGLE_INSTANCE; else process.env.MATCH_SINGLE_INSTANCE = ENV; + if (CEIL === undefined) delete process.env.MATCH_HARD_CEILING_MS; else process.env.MATCH_HARD_CEILING_MS = CEIL; + }); + + test('single-instance (default): reclaims all in-flight matches (age threshold 0)', async () => { + delete process.env.MATCH_SINGLE_INSTANCE; + const { q, params } = queueWithCapturingDb(); + await q._recoverAbandonedMatches(); + const scan = params.find(x => /FROM matches/i.test(x.sql)); + expect(scan).toBeTruthy(); + expect(scan.sql).toMatch(/COALESCE\(started_at, created_at\)/); + expect(scan.p).toEqual([0]); + }); + + test('multi-instance: only reclaims matches older than hard ceiling + buffer', async () => { + process.env.MATCH_SINGLE_INSTANCE = 'false'; + process.env.MATCH_HARD_CEILING_MS = '240000'; + const { q, params } = queueWithCapturingDb(); + await q._recoverAbandonedMatches(); + const scan = params.find(x => /FROM matches/i.test(x.sql)); + expect(scan.p).toEqual([300]); // (240000 + 60000) / 1000 + }); +});