mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 22:31:26 +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.
166 lines
7.0 KiB
JavaScript
166 lines
7.0 KiB
JavaScript
const MatchPayoutService = require('../src/network/matchPayoutService');
|
|
const MatchRoom = require('../src/multiplayer/MatchRoom');
|
|
|
|
describe('MatchPayoutService', () => {
|
|
const enabledEnv = (overrides = {}) => ({
|
|
MATCH_ENABLED: 'true',
|
|
MATCH_CRYPTO_RACE_ENABLED: 'true',
|
|
MATCH_PAYOUTS_ENABLED: 'true',
|
|
MATCH_PAYOUT_MAX: '100000000000000000',
|
|
MATCH_ENTRY_FEE_ATOMIC: '10000',
|
|
MATCH_HOUSE_FEE_PERCENT: '5',
|
|
PAYOUTS_ENABLED: 'true',
|
|
PAYMENTS_ENABLED: 'true',
|
|
MATCH_RULESET_ID: 'race',
|
|
MATCH_MAX_PLAYERS: '4',
|
|
CRYPTO_TYPE: 'XMR',
|
|
MONERO_NETWORK: 'stagenet',
|
|
...overrides
|
|
});
|
|
|
|
function makeDb({ address = 'WALLET_ADDRESS', accepted = true, amount = '19000', cap = '1000000', existing = null } = {}) {
|
|
const captured = [];
|
|
return {
|
|
captured,
|
|
async query(sql) {
|
|
if (sql.includes('SELECT m.id')) return { rows: [] };
|
|
return { rows: [], rowCount: 0 };
|
|
},
|
|
async withTransaction(fn) {
|
|
const client = {
|
|
async query(sql, params) {
|
|
captured.push({ sql, params });
|
|
if (sql.includes('FROM match_queue_entries') && sql.includes('FOR UPDATE')) {
|
|
const ids = params[0];
|
|
return {
|
|
rows: ids.map((id, index) => ({
|
|
id,
|
|
user_id: index + 1,
|
|
race_entry_lot_id: 100 + index,
|
|
escrow_amount: '1',
|
|
escrow_value_atomic: '10000'
|
|
})),
|
|
rowCount: ids.length
|
|
};
|
|
}
|
|
if (sql.includes('FROM matches') && sql.includes('FOR UPDATE')) {
|
|
return { rows: [{
|
|
id: params[0], economy: 'crypto_race', status: 'finished', winner_user_id: 1,
|
|
payout_liability_amount_atomic: amount,
|
|
payout_liability_cap_atomic: cap,
|
|
payout_liability_accepted_at: accepted ? new Date().toISOString() : null
|
|
}], rowCount: 1 };
|
|
}
|
|
if (sql.includes('FROM payouts') && sql.includes('LIMIT 1')) {
|
|
return { rows: existing ? [existing] : [], rowCount: existing ? 1 : 0 };
|
|
}
|
|
if (sql.includes('FROM users') && sql.includes('payout_address')) {
|
|
return { rows: [{ id: 1, payout_address: address }], rowCount: 1 };
|
|
}
|
|
return { rows: [{ id: 99 }], rowCount: 1 };
|
|
}
|
|
};
|
|
return fn(client);
|
|
}
|
|
};
|
|
}
|
|
|
|
function manager(overrides = {}) {
|
|
return {
|
|
payoutsEnabled: true,
|
|
directModeEnabled: true,
|
|
creditsModeEnabled: false,
|
|
_scheduleBatchPayout: jest.fn(),
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function makeRoom() {
|
|
const room = new MatchRoom({
|
|
economy: 'crypto_race',
|
|
entrants: { a: { userId: 1 }, b: { userId: 2 } },
|
|
seed: '0'.repeat(64)
|
|
});
|
|
room.start();
|
|
room.playerStates.get('a').escaped = true;
|
|
room.winnerId = 'a';
|
|
room.status = 'finished';
|
|
room.finalize();
|
|
return room;
|
|
}
|
|
|
|
test('collectEntryTickets atomically locks exact pot and accepted payout terms', async () => {
|
|
const db = makeDb();
|
|
const service = new MatchPayoutService({ db, env: enabledEnv(), gameModeManager: manager() });
|
|
const room = makeRoom();
|
|
|
|
await service.collectEntryTickets(room, [
|
|
{ userId: 1, socketId: 'a', queueEntryId: 11 },
|
|
{ userId: 2, socketId: 'b', queueEntryId: 12 }
|
|
]);
|
|
|
|
const updateMatch = db.captured.find(q => q.sql.includes('payout_liability_accepted_at = NOW()'));
|
|
expect(updateMatch).toBeTruthy();
|
|
expect(updateMatch.params.slice(0, 6)).toEqual([
|
|
'10000', '20000', '1000', 5, '19000', '100000000000000000'
|
|
]);
|
|
expect(JSON.parse(updateMatch.params[6])).toEqual(expect.objectContaining({
|
|
playerCount: 2,
|
|
payoutAmountAtomic: '19000',
|
|
rulesetId: 'race'
|
|
}));
|
|
expect(room.potAtomic).toBe('20000');
|
|
});
|
|
|
|
test('accepted ON -> OFF liability still creates a pending payout', async () => {
|
|
const db = makeDb();
|
|
const gmm = manager({ payoutsEnabled: false });
|
|
const service = new MatchPayoutService({
|
|
db,
|
|
env: enabledEnv({ MATCH_PAYOUTS_ENABLED: 'false', PAYOUTS_ENABLED: 'false' }),
|
|
gameModeManager: gmm
|
|
});
|
|
|
|
const result = await service.payoutWinner(makeRoom());
|
|
|
|
const insert = db.captured.find(q => q.sql.includes('INSERT INTO payouts'));
|
|
expect(result).toEqual(expect.objectContaining({ created: true, amount: '19000' }));
|
|
expect(insert.params[3]).toBe('19000');
|
|
expect(insert.params[5]).toBe('match_winner');
|
|
// Global off pauses dispatch, but does not suppress recording the obligation.
|
|
expect(gmm._scheduleBatchPayout).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test('OFF -> ON never creates a retroactive match liability', async () => {
|
|
const db = makeDb({ accepted: false });
|
|
const service = new MatchPayoutService({ db, env: enabledEnv(), gameModeManager: manager() });
|
|
|
|
const result = await service.payoutWinner(makeRoom());
|
|
|
|
expect(result).toEqual({ created: false, reason: 'liability_not_accepted' });
|
|
expect(db.captured.some(q => q.sql.includes('INSERT INTO payouts'))).toBe(false);
|
|
});
|
|
|
|
test('needs_review payout identity blocks a replacement row', async () => {
|
|
const db = makeDb({ existing: { id: 77, status: 'needs_review' } });
|
|
const service = new MatchPayoutService({ db, env: enabledEnv(), gameModeManager: manager() });
|
|
|
|
const result = await service.payoutWinner(makeRoom());
|
|
|
|
expect(result).toEqual({ created: false, reason: 'payout_exists', payoutId: 77 });
|
|
expect(db.captured.some(q => q.sql.includes('INSERT INTO payouts'))).toBe(false);
|
|
});
|
|
|
|
test('winner with no address records a claimable needs_review liability', async () => {
|
|
const db = makeDb({ address: null });
|
|
const service = new MatchPayoutService({ db, env: enabledEnv(), gameModeManager: manager() });
|
|
|
|
await service.payoutWinner(makeRoom());
|
|
|
|
const insert = db.captured.find(q => q.sql.includes('INSERT INTO payouts'));
|
|
expect(insert.params[2]).toBe('PENDING_NO_ADDRESS');
|
|
expect(insert.params[5]).toBe('match_winner_no_address');
|
|
expect(insert.params[6]).toBe('needs_review');
|
|
});
|
|
});
|