Files
wownerogue/test/payoutSnapshot.test.js
T
jwinterm a62eefb47b 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.
2026-07-09 17:46:58 -04:00

110 lines
4.4 KiB
JavaScript

/**
* Payout snapshot tests (Phase 1.2).
*
* The payout terms are snapshotted onto the game at start. completeGame must pay from
* that snapshot, so a mid-game config change can't alter an in-flight game's payout.
*/
const GameModeManager = require('../src/game/gameModeManager');
const createMockDb = () => {
const mockClient = { query: jest.fn().mockResolvedValue({ rows: [] }) };
return {
query: jest.fn().mockResolvedValue({ rows: [] }),
withTransaction: jest.fn().mockImplementation(async (cb) => cb(mockClient)),
_mockClient: mockClient
};
};
const createMockPaymentConfig = () => ({
getConfig: () => ({
paymentsEnabled: true,
currency: { symbol: 'WOW', decimals: 11 },
modes: {
direct: { enabled: true, price: 100000000000n },
credits: { enabled: true, creditsPerGame: 1, packages: [{ id: 'small', credits: 10, price: '500000000000', bonus: 0 }] }
},
payouts: {
rules: {
direct: { enabled: true, multipliers: { escape: 2, escapeWithTreasure: 3 } },
credits: { enabled: false, multipliers: { escape: 1.5, escapeWithTreasure: 2 }, baseValue: 50000000000n }
}
},
preferences: { preferCreditsFirst: true },
earlyEntry: { enabled: false }
}),
getLegacyGameMode: () => 'PAID_SINGLE',
eventBus: { on: () => {} }
});
function buildGmm() {
const db = createMockDb();
const gmm = new GameModeManager(
db,
{ processPayout: jest.fn(), processBatchPayout: jest.fn() },
{ CONSOLE_LOGGING: false, getCurrentBlockHeight: () => 1 },
createMockPaymentConfig()
);
gmm._scheduleBatchPayout = jest.fn(); // don't actually schedule
return { gmm, db };
}
describe('Payout uses the snapshot recorded at game start', () => {
test('pays the snapshot amount, not the live-config amount', async () => {
const { gmm, db } = buildGmm();
// Snapshot recorded at start: escape pays 123456 atomic (deliberately unlike the
// live 2x = 200000000000), treasure pays 999999.
db.query
.mockResolvedValueOnce({ rows: [{
id: 1, user_id: 1, game_mode: 'PAID_SINGLE', payout_address: 'wow1addr',
payout_escape_amount: '123456', payout_treasure_amount: '999999',
payout_escape_mult: '2.000', payout_treasure_mult: '3.000'
}] }) // game record lookup
.mockResolvedValueOnce({ rows: [] }) // game completion UPDATE
.mockResolvedValueOnce({ rows: [] }) // existing-payout check
.mockResolvedValueOnce({ rows: [{ id: 1, payout_address: 'wow1addr' }] }) // user lookup
.mockResolvedValueOnce({ rows: [{ id: 999 }] }); // INSERT payout
// Even if live config is mutated after start, the snapshot must win.
gmm.directPayoutMultipliers = { escape: 9, escapeWithTreasure: 9 };
const result = await gmm.completeGame('socket1', 'game123', true, false, { moves: 10 });
expect(result.payout).toBeDefined();
expect(result.payout.amount).toBe('123456');
});
test('falls back to live calculation for legacy games without a snapshot', async () => {
const { gmm, db } = buildGmm();
db.query
.mockResolvedValueOnce({ rows: [{ id: 1, user_id: 1, game_mode: 'PAID_SINGLE', payout_address: 'wow1addr' }] }) // no snapshot cols
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [{ id: 1, payout_address: 'wow1addr' }] })
.mockResolvedValueOnce({ rows: [{ id: 999 }] });
const result = await gmm.completeGame('socket1', 'game123', true, false, { moves: 10 });
expect(result.payout.amount).toBe(200000000000); // live 2x
});
test('no payout for a PAID_SINGLE win when direct payouts are disabled (no-payout instance)', async () => {
const { gmm, db } = buildGmm();
gmm.directPayoutEnabled = false; // e.g. mainnet legitimacy: sell entry, never pay out
db.query
.mockResolvedValueOnce({ rows: [{ id: 1, game_mode: 'PAID_SINGLE', payout_address: 'wow1addr' }] }) // game record
.mockResolvedValue({ rows: [] }); // completion UPDATE + anything else
const result = await gmm.completeGame('socket1', 'game123', true, true, { moves: 10 });
expect(result.success).toBe(true);
expect(result.payout == null || result.payout === undefined).toBe(true); // no payout queued
// No payout INSERT happened.
const insertedPayout = db.query.mock.calls.find(c => /INSERT INTO payouts/i.test(c[0]));
expect(insertedPayout).toBeUndefined();
});
});