fix(match): reconcile no-address winnings on address set; age-guard boot recovery

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.
This commit is contained in:
jwinterm
2026-07-09 19:18:34 -04:00
parent a62eefb47b
commit a9fad69038
4 changed files with 171 additions and 5 deletions
+33 -4
View File
@@ -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);
+10
View File
@@ -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);
+16 -1
View File
@@ -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.
+112
View File
@@ -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
});
});