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