mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 14:21:25 +02:00
A browser-based roguelike synchronized with crypto block timing. A run is a race against the next block: reach the exit before the block lands, or the house wins. The same codebase runs the solo dungeon, the social Tavern, and operator-selected PvP and race rulesets, with separate free and paid leaderboards. Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md for the design, and docs/DEPLOY.md to operate an instance.
216 lines
10 KiB
JavaScript
216 lines
10 KiB
JavaScript
/**
|
|
* Payment-safety fixes (M1, M3, M4, M5, C1).
|
|
*
|
|
* Covers the gameModeManager hardening:
|
|
* - M1: an ambiguous SINGLE payout failure is marked 'needs_review' (not 'failed') + alerts.
|
|
* - M3: a concurrent single_game payment claim aborts cleanly (no duplicate game / throw).
|
|
* - M4: completeGame refuses to insert a payout it can't attach to a resolved game + user.
|
|
* - M5: the payout amount is clamped to the configured max (cap+alert) / skipped below min.
|
|
* - C1: getOrCreateUser(socketId, { create: false }) returns null without inserting a user.
|
|
*/
|
|
|
|
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: false, creditsPerGame: 1, packages: [{ id: 'small', credits: 10, price: '500000000000', bonus: 0 }] }
|
|
},
|
|
payouts: {
|
|
rules: {
|
|
direct: { enabled: true, multipliers: { escape: 2, escapeWithTreasure: 3 }, minPayout: 1000000000n, maxPayout: 10000000000000n },
|
|
credits: { enabled: false, multipliers: { escape: 1.5, escapeWithTreasure: 2 }, baseValue: 50000000000n, minPayout: 1000000000n, maxPayout: 5000000000000n }
|
|
}
|
|
},
|
|
preferences: { preferCreditsFirst: true },
|
|
earlyEntry: { enabled: false }
|
|
}),
|
|
getLegacyGameMode: () => 'PAID_SINGLE',
|
|
eventBus: { on: () => {} }
|
|
});
|
|
|
|
function buildGmm(walletService = { processPayout: jest.fn(), processBatchPayout: jest.fn() }) {
|
|
const db = createMockDb();
|
|
const gmm = new GameModeManager(db, walletService, { CONSOLE_LOGGING: false, getCurrentBlockHeight: () => 1 }, createMockPaymentConfig());
|
|
gmm._scheduleBatchPayout = jest.fn(); // never actually schedule
|
|
gmm.alertService = { sendAlert: jest.fn().mockResolvedValue() };
|
|
return { gmm, db };
|
|
}
|
|
|
|
const ONE_PENDING = [
|
|
{ id: 42, user_id: 1, game_id: 7, payout_address: 'addrA', amount: '100000000000', multiplier: 2, reason: 'escape' }
|
|
];
|
|
|
|
describe('M1: single payout ambiguous failure', () => {
|
|
test('is marked needs_review (never failed) and alerts', async () => {
|
|
const walletService = {
|
|
getBalance: jest.fn().mockResolvedValue({ unlocked_balance: '10000000000000' }),
|
|
processPayout: jest.fn().mockRejectedValue(new Error('RPC timeout after broadcast?')),
|
|
processBatchPayout: jest.fn()
|
|
};
|
|
const { gmm, db } = buildGmm(walletService);
|
|
db._mockClient.query
|
|
.mockResolvedValueOnce({ rows: ONE_PENDING }) // SELECT ... FOR UPDATE SKIP LOCKED
|
|
.mockResolvedValue({ rows: [] });
|
|
|
|
await gmm._processPendingPayouts();
|
|
|
|
const reviewCall = db.query.mock.calls.find(c => Array.isArray(c[1]) && c[1][0] === 'needs_review');
|
|
expect(reviewCall).toBeDefined();
|
|
expect(reviewCall[1][2]).toBeNull(); // no valid transaction hash was observed
|
|
expect(reviewCall[1][3]).toBe(42); // payout id
|
|
const failedCall = db.query.mock.calls.find(c => Array.isArray(c[1]) && c[1][0] === 'failed');
|
|
expect(failedCall).toBeUndefined();
|
|
expect(gmm.alertService.sendAlert).toHaveBeenCalledWith('single_payout_failed', expect.any(Object));
|
|
});
|
|
|
|
test('a pre-broadcast insufficient-funds error stays pending (safe retry), no alert', async () => {
|
|
const walletService = {
|
|
getBalance: jest.fn().mockResolvedValue({ unlocked_balance: '10000000000000' }),
|
|
processPayout: jest.fn().mockRejectedValue(new Error('not enough unlocked money to transfer')),
|
|
processBatchPayout: jest.fn()
|
|
};
|
|
const { gmm, db } = buildGmm(walletService);
|
|
db._mockClient.query
|
|
.mockResolvedValueOnce({ rows: ONE_PENDING })
|
|
.mockResolvedValue({ rows: [] });
|
|
|
|
await gmm._processPendingPayouts();
|
|
|
|
const pendingCall = db.query.mock.calls.find(c => Array.isArray(c[1]) && c[1][0] === 'pending' && /id = \$4/i.test(c[0]));
|
|
expect(pendingCall).toBeDefined();
|
|
expect(gmm.alertService.sendAlert).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('M3: atomic single_game payment claim', () => {
|
|
test('aborts cleanly when the payment was already consumed (claim returns no row)', async () => {
|
|
const { gmm } = buildGmm();
|
|
// withTransaction runs cb with a client whose claim SELECT returns no rows.
|
|
gmm.db.withTransaction = jest.fn(async (cb) => cb({ query: jest.fn().mockResolvedValue({ rows: [] }) }));
|
|
|
|
const res = await gmm._processGameStartWithPayment({ id: 1, payout_address: 'addr' }, { id: 55 }, 'seed-1');
|
|
|
|
expect(res.success).toBe(false);
|
|
expect(res.alreadyConsumed).toBe(true);
|
|
});
|
|
|
|
test('treats a games.payment_id unique violation (23505) as already consumed, not a throw', async () => {
|
|
const { gmm } = buildGmm();
|
|
gmm.db.withTransaction = jest.fn(async (cb) => {
|
|
const client = {
|
|
query: jest.fn()
|
|
.mockResolvedValueOnce({ rows: [{ id: 55 }] }) // claim SELECT ... FOR UPDATE finds the payment
|
|
.mockImplementationOnce(() => { const e = new Error('duplicate key'); e.code = '23505'; throw e; })
|
|
};
|
|
return cb(client); // the throw propagates like the real withTransaction (rollback + rethrow)
|
|
});
|
|
|
|
const res = await gmm._processGameStartWithPayment({ id: 1, payout_address: 'addr' }, { id: 55 }, 'seed-1');
|
|
|
|
expect(res.success).toBe(false);
|
|
expect(res.alreadyConsumed).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('M4: completeGame durably records every committed liability', () => {
|
|
test('quarantines a liability when the paying identity cannot be resolved', async () => {
|
|
const { gmm, db } = buildGmm();
|
|
db.withTransaction.mockImplementation(async (cb) => cb(db));
|
|
db.query
|
|
.mockResolvedValueOnce({ rows: [{ id: 1, /* no user_id */ game_mode: 'PAID_SINGLE', payout_address: 'addr' }] }) // game record
|
|
.mockResolvedValueOnce({ rows: [] }) // completion UPDATE
|
|
.mockResolvedValueOnce({ rows: [] }) // existing-payout check
|
|
.mockResolvedValueOnce({ rows: [{ id: 88 }], rowCount: 1 }); // needs_review liability
|
|
|
|
const result = await gmm.completeGame('sockX', 'game-xyz', true, false, {});
|
|
|
|
expect(result.payout).toEqual(expect.objectContaining({ payoutId: 88, status: 'needs_review' }));
|
|
const inserted = db.query.mock.calls.find(c => /INSERT INTO payouts/i.test(c[0]));
|
|
expect(inserted).toBeDefined();
|
|
expect(inserted[1]).toContain('solo_winner_identity_review');
|
|
expect(gmm.alertService.sendAlert).toHaveBeenCalledWith('payout_liability_needs_review', expect.any(Object));
|
|
});
|
|
});
|
|
|
|
describe('M5: payout amount is clamped to configured bounds', () => {
|
|
test('caps an over-max payout and alerts', async () => {
|
|
const { gmm, db } = buildGmm();
|
|
db.withTransaction.mockImplementation(async (cb) => cb(db));
|
|
db.query
|
|
.mockResolvedValueOnce({ rows: [{
|
|
id: 1, user_id: 1, game_mode: 'PAID_SINGLE', payout_address: 'addr',
|
|
payout_escape_amount: '99999999999999', payout_treasure_amount: '99999999999999',
|
|
payout_escape_mult: '2', payout_treasure_mult: '3'
|
|
}] }) // game record
|
|
.mockResolvedValueOnce({ rows: [] }) // completion UPDATE
|
|
.mockResolvedValueOnce({ rows: [] }) // existing-payout check
|
|
.mockResolvedValueOnce({ rows: [{ id: 1, payout_address: 'addr' }] }) // user lookup by id
|
|
.mockResolvedValueOnce({ rows: [{ id: 999 }] }); // INSERT payout
|
|
|
|
const result = await gmm.completeGame('sockX', 'game-xyz', true, false, {});
|
|
|
|
expect(result.payout.amount).toBe('10000000000000'); // capped to maxPayout
|
|
expect(gmm.alertService.sendAlert).toHaveBeenCalledWith('payout_over_max', expect.any(Object));
|
|
const inserted = db.query.mock.calls.find(c => /INSERT INTO payouts/i.test(c[0]));
|
|
expect(inserted[1][3]).toBe('10000000000000'); // amount param is the capped value
|
|
});
|
|
|
|
test('skips a below-min payout entirely (no insert)', async () => {
|
|
const { gmm, db } = buildGmm();
|
|
db.withTransaction.mockImplementation(async (cb) => cb(db));
|
|
db.query
|
|
.mockResolvedValueOnce({ rows: [{
|
|
id: 1, user_id: 1, game_mode: 'PAID_SINGLE', payout_address: 'addr',
|
|
payout_escape_amount: '1', payout_treasure_amount: '1',
|
|
payout_escape_mult: '2', payout_treasure_mult: '3'
|
|
}] })
|
|
.mockResolvedValue({ rows: [] });
|
|
|
|
const result = await gmm.completeGame('sockX', 'game-xyz', true, false, {});
|
|
|
|
expect(result.reason).toBe('Below minimum payout');
|
|
expect(result.payout).toBeNull();
|
|
const inserted = db.query.mock.calls.find(c => /INSERT INTO payouts/i.test(c[0]));
|
|
expect(inserted).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('C1: getOrCreateUser create:false', () => {
|
|
test('returns null and never inserts when no user row exists', async () => {
|
|
const { gmm, db } = buildGmm();
|
|
db.query.mockResolvedValue({ rows: [] }); // socket lookup finds nothing
|
|
|
|
const user = await gmm.getOrCreateUser('ghost-socket', { create: false });
|
|
|
|
expect(user).toBeNull();
|
|
const insert = db.query.mock.calls.find(c => /INSERT INTO users/i.test(c[0]));
|
|
expect(insert).toBeUndefined();
|
|
});
|
|
|
|
test('still creates by default for existing callers', async () => {
|
|
const { gmm, db } = buildGmm();
|
|
db.query
|
|
.mockResolvedValueOnce({ rows: [] }) // socket lookup: none
|
|
.mockResolvedValueOnce({ rows: [{ id: 7, socket_id: 's' }] }); // INSERT ... RETURNING *
|
|
|
|
const user = await gmm.getOrCreateUser('s');
|
|
|
|
expect(user).toEqual({ id: 7, socket_id: 's' });
|
|
const insert = db.query.mock.calls.find(c => /INSERT INTO users/i.test(c[0]));
|
|
expect(insert).toBeDefined();
|
|
});
|
|
});
|