fix: snapshot payout terms at game start (what-you-saw-is-what-you-get)

Payouts were computed at completion from LIVE config, so an admin changing
multipliers mid-game would change the payout of an already-in-progress game.

- Migration 016 adds payout_escape_amount/payout_treasure_amount (BIGINT) and
  the corresponding multipliers to the games table.
- Both game-start paths stamp the resolved payout amounts (computed from the
  config in effect at entry) onto the game row.
- completeGame pays from the snapshot, falling back to live calculation only
  for legacy games that predate the snapshot columns.

Adds payoutSnapshot.test.js proving a mid-game config change cannot alter an
in-flight payout. Full suite: 15 suites / 131 tests.
This commit is contained in:
jwinterm
2026-05-31 09:55:45 -04:00
parent 24aed355c6
commit 473a4091d4
4 changed files with 159 additions and 7 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ Key: `S` ≤ half day · `M` ~1–3 days · `L` ~1 week+ · 🔴 launch-blocker
## Phase 1 — Money correctness hardening (🟠)
- [x] **1.1 M — `src/money/atomic.js` BigInt module.** ✅ Exact integer money math (`toBig`/`toSafe`/`sum`/`add`/`mulByDecimal`/`format`). `calculatePayout` now computes `base * multiplier` via BigInt (half-up rounding, narrows to number only when exact). `walletRPCService` batch total + fee sum use `money.sum` instead of float `reduce`. Property tests (`moneyAtomic.test.js`) prove exactness on amounts above 2^53 where the old float path drifted.
- [ ] **1.2 S — Snapshot payout config at game start** (`gameModeManager.js:237`); pay from recorded multiplier/base.
- [x] **1.2 S — Snapshot payout config at game start.** ✅ Migration 016 adds `payout_escape_amount`/`payout_treasure_amount` (BIGINT) + multipliers to `games`. Both start paths stamp the resolved payout amounts (via `_computePayoutSnapshot`); `completeGame` pays from the snapshot (falls back to live calc only for pre-snapshot games). New `payoutSnapshot.test.js` proves a mid-game config change can't alter an in-flight payout.
- [ ] **1.3 S — Close payout uniqueness holes.** Cover `processing` in the unique index; guard INSERT (`gameModeManager.js:1161`); add status/outcome/mode enum CHECKs.
- [ ] **1.4 S — Fix retry stat guard + non-atomic tx_hash write** (`src/payments/payoutRetryService.js:121,174`).
- [ ] **1.5 S — Remove dead refund send-path** (`src/index.js:795`).
+53 -6
View File
@@ -731,8 +731,25 @@ class GameModeManager {
}
}
/**
* Compute the payout terms to snapshot on a game at start, so an admin changing
* config mid-game can't alter an in-flight game's payout. Returns atomic amounts as
* strings (for the BIGINT columns) plus the multipliers for the audit trail.
*/
_computePayoutSnapshot(mode) {
const escape = this.calculatePayout(mode, { treasureFound: false });
const treasure = this.calculatePayout(mode, { treasureFound: true });
return {
escapeAmount: String(escape.amount),
treasureAmount: String(treasure.amount),
escapeMult: escape.multiplier,
treasureMult: treasure.multiplier
};
}
async _processGameStartWithCredits(user, socketId, gameId) {
const creditsToSpend = this.creditsPerGameCost;
const snap = this._computePayoutSnapshot('PAID_CREDITS');
const result = await this.db.withTransaction(async (client) => {
// Conditional update prevents credits going negative
@@ -752,9 +769,11 @@ class GameModeManager {
const remainingCredits = updateRes.rows[0].credits;
await client.query(`
UPDATE games SET game_mode = 'PAID_CREDITS', payout_address = $2
UPDATE games SET game_mode = 'PAID_CREDITS', payout_address = $2,
payout_escape_amount = $3, payout_treasure_amount = $4,
payout_escape_mult = $5, payout_treasure_mult = $6
WHERE dungeon_seed = $1
`, [gameId, user.payout_address || null]);
`, [gameId, user.payout_address || null, snap.escapeAmount, snap.treasureAmount, snap.escapeMult, snap.treasureMult]);
await client.query(`
INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type)
@@ -784,12 +803,15 @@ class GameModeManager {
}
async _processGameStartWithPayment(user, payment, gameId) {
const snap = this._computePayoutSnapshot('PAID_SINGLE');
await this.db.withTransaction(async (client) => {
await client.query(`
UPDATE games
SET payment_id = $1, game_mode = 'PAID_SINGLE', payout_address = $3
SET payment_id = $1, game_mode = 'PAID_SINGLE', payout_address = $3,
payout_escape_amount = $4, payout_treasure_amount = $5,
payout_escape_mult = $6, payout_treasure_mult = $7
WHERE dungeon_seed = $2
`, [payment.id, gameId, user.payout_address || null]);
`, [payment.id, gameId, user.payout_address || null, snap.escapeAmount, snap.treasureAmount, snap.escapeMult, snap.treasureMult]);
await client.query(`
UPDATE users
@@ -1100,9 +1122,13 @@ class GameModeManager {
let recordedPaymentMode = this.gameMode; // fallback to global
let gameDbId = null;
let lockedPayoutAddress = null; // address locked at game start
let payoutSnapshot = null; // payout terms snapshotted at game start
try {
const gameRecord = await this.db.query(`
SELECT id, game_mode, payout_address FROM games
SELECT id, game_mode, payout_address,
payout_escape_amount, payout_treasure_amount,
payout_escape_mult, payout_treasure_mult
FROM games
WHERE dungeon_seed = $1 AND socket_id = $2
LIMIT 1
`, [gameId, socketId]);
@@ -1110,6 +1136,15 @@ class GameModeManager {
gameDbId = gameRecord.rows[0].id;
recordedPaymentMode = (gameRecord.rows[0].game_mode || this.gameMode).toUpperCase();
lockedPayoutAddress = gameRecord.rows[0].payout_address || null;
const r = gameRecord.rows[0];
if (r.payout_escape_amount != null || r.payout_treasure_amount != null) {
payoutSnapshot = {
escapeAmount: r.payout_escape_amount,
treasureAmount: r.payout_treasure_amount,
escapeMult: r.payout_escape_mult,
treasureMult: r.payout_treasure_mult
};
}
}
} catch (e) {
// Non-fatal, use fallback
@@ -1141,7 +1176,19 @@ class GameModeManager {
// Handle payouts based on the actual recorded payment mode, not the current global setting
const payoutEligibleStartMode = (recordedPaymentMode === 'PAID_SINGLE') || (recordedPaymentMode === 'PAID_CREDITS' && this.creditsPayoutEnabled);
if (payoutEligibleStartMode && won) {
const { amount: payoutAmount, multiplier } = this.calculatePayout(recordedPaymentMode, { treasureFound });
// Use the payout terms snapshotted at game start (so a mid-game config change
// can't alter an in-flight payout). Fall back to live calculation only for
// older games that predate the snapshot columns.
let payoutAmount;
let multiplier;
if (payoutSnapshot) {
payoutAmount = treasureFound ? payoutSnapshot.treasureAmount : payoutSnapshot.escapeAmount;
multiplier = treasureFound ? payoutSnapshot.treasureMult : payoutSnapshot.escapeMult;
} else {
const live = this.calculatePayout(recordedPaymentMode, { treasureFound });
payoutAmount = live.amount;
multiplier = live.multiplier;
}
// Check if payout was already processed for this game (prevent duplicates)
if (gameDbId) {
+13
View File
@@ -0,0 +1,13 @@
-- Migration 016: Snapshot payout terms on the game at start
--
-- Payout amounts were computed at completion time from LIVE config, so an admin changing
-- multipliers mid-game would change the payout of an already-in-progress game ("what you
-- saw at entry" was not honoured). Snapshot the resolved payout amounts (and the
-- multipliers, for audit) onto the games row at start; completion reads the snapshot.
--
-- Amounts are atomic units (BIGINT, exact). Multipliers are stored for the audit trail.
ALTER TABLE games
ADD COLUMN IF NOT EXISTS payout_escape_amount BIGINT,
ADD COLUMN IF NOT EXISTS payout_treasure_amount BIGINT,
ADD COLUMN IF NOT EXISTS payout_escape_mult NUMERIC(8,3),
ADD COLUMN IF NOT EXISTS payout_treasure_mult NUMERIC(8,3);
+92
View File
@@ -0,0 +1,92 @@
/**
* 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, 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, 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
});
});