mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-23 00:58:33 +02:00
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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user