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.
298 lines
12 KiB
JavaScript
298 lines
12 KiB
JavaScript
const MatchManager = require('../src/network/matchManager');
|
|
const MatchPayoutService = require('../src/network/matchPayoutService');
|
|
const MatchQueue = require('../src/network/matchQueue');
|
|
const MatchRoom = require('../src/multiplayer/MatchRoom');
|
|
const MatchScheduler = require('../src/network/matchScheduler');
|
|
const {
|
|
buildPaidEntrantFreeze,
|
|
deriveFutureBlockMatchSeed
|
|
} = require('../src/network/matchFairness');
|
|
|
|
const MATCH_ENV_KEYS = [
|
|
'MATCH_ENABLED', 'MATCH_CRYPTO_RACE_ENABLED', 'MATCH_PAYOUTS_ENABLED',
|
|
'MATCH_PAYOUT_MAX', 'MATCH_ENTRY_FEE_ATOMIC', 'MATCH_HOUSE_FEE_PERCENT',
|
|
'MATCH_RULESET_ID', 'MATCH_MAX_PLAYERS', 'PAYOUTS_ENABLED', 'PAYMENTS_ENABLED'
|
|
];
|
|
|
|
describe('match payout liability production invariants', () => {
|
|
let priorEnv;
|
|
|
|
beforeEach(() => {
|
|
priorEnv = Object.fromEntries(MATCH_ENV_KEYS.map(key => [key, process.env[key]]));
|
|
Object.assign(process.env, {
|
|
MATCH_ENABLED: 'true',
|
|
MATCH_CRYPTO_RACE_ENABLED: 'true',
|
|
MATCH_PAYOUTS_ENABLED: 'true',
|
|
MATCH_PAYOUT_MAX: '1000000',
|
|
MATCH_ENTRY_FEE_ATOMIC: '10000',
|
|
MATCH_HOUSE_FEE_PERCENT: '5',
|
|
MATCH_RULESET_ID: 'race',
|
|
MATCH_MAX_PLAYERS: '4',
|
|
PAYOUTS_ENABLED: 'true',
|
|
PAYMENTS_ENABLED: 'true'
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const [key, value] of Object.entries(priorEnv)) {
|
|
if (value === undefined) delete process.env[key];
|
|
else process.env[key] = value;
|
|
}
|
|
});
|
|
|
|
const gameModeManager = (overrides = {}) => ({
|
|
payoutsEnabled: true,
|
|
directModeEnabled: true,
|
|
creditsModeEnabled: false,
|
|
_scheduleBatchPayout: jest.fn(),
|
|
...overrides
|
|
});
|
|
|
|
function cryptoRoom(rulesetId = 'race') {
|
|
return new MatchRoom({
|
|
economy: 'crypto_race',
|
|
rulesetId,
|
|
entrants: { a: { userId: 1 }, b: { userId: 2 } },
|
|
seed: '0'.repeat(64)
|
|
});
|
|
}
|
|
|
|
test('MATCH_PAYOUT_MAX is checked before any ticket commitment transaction', async () => {
|
|
process.env.MATCH_PAYOUT_MAX = '18000'; // two entries owe 19,000 after the 5% fee
|
|
const db = { withTransaction: jest.fn() };
|
|
const service = new MatchPayoutService({ db, gameModeManager: gameModeManager() });
|
|
|
|
await expect(service.collectEntryTickets(cryptoRoom(), [
|
|
{ userId: 1, queueEntryId: 11 }, { userId: 2, queueEntryId: 12 }
|
|
]))
|
|
.rejects.toMatchObject({ code: 'PAYOUT_CAP_EXCEEDED' });
|
|
expect(db.withTransaction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('crypto co-op is rejected before commitment because split semantics do not exist', async () => {
|
|
process.env.MATCH_RULESET_ID = 'coop-escape';
|
|
const db = { withTransaction: jest.fn() };
|
|
const service = new MatchPayoutService({ db, gameModeManager: gameModeManager() });
|
|
|
|
await expect(service.collectEntryTickets(cryptoRoom('coop-escape'), [
|
|
{ userId: 1, queueEntryId: 11 }, { userId: 2, queueEntryId: 12 }
|
|
]))
|
|
.rejects.toMatchObject({ code: 'UNSUPPORTED_CRYPTO_RULESET' });
|
|
expect(db.withTransaction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('scheduler clamps entrants to the selected ruleset player contract', () => {
|
|
const scheduler = new MatchScheduler({
|
|
matchQueue: {},
|
|
matchManager: {},
|
|
rulesetId: 'race',
|
|
maxPlayers: 32
|
|
});
|
|
|
|
expect(scheduler.minPlayers).toBe(2);
|
|
expect(scheduler.maxPlayers).toBe(8);
|
|
scheduler.shutdown();
|
|
});
|
|
|
|
test('hot-disable refunds queued crypto escrow and removes memory only after commit', async () => {
|
|
let available = true;
|
|
const captured = [];
|
|
const db = {
|
|
async withTransaction(fn) {
|
|
return fn({
|
|
async query(sql, params) {
|
|
captured.push({ sql, params });
|
|
if (sql.includes('UPDATE match_queue_entries')) return { rows: [{ id: 1 }], rowCount: 1 };
|
|
if (sql.includes('UPDATE users')) return { rows: [{ race_entries: 2 }], rowCount: 1 };
|
|
return { rows: [{ id: 1 }], rowCount: 1 };
|
|
}
|
|
});
|
|
}
|
|
};
|
|
const manager = gameModeManager({
|
|
_getMatchEconomies: () => available ? { free: true, crypto_race: true } : { free: true }
|
|
});
|
|
const queue = new MatchQueue({ db, gameModeManager: manager });
|
|
queue._queues.crypto_race.push({ userId: 1, socketId: 'a', createdAt: 1 });
|
|
|
|
available = false;
|
|
await queue.refundUnavailableQueues();
|
|
|
|
expect(queue.length('crypto_race')).toBe(0);
|
|
expect(captured.some(q => q.sql.includes('race_entries = race_entries + $1'))).toBe(true);
|
|
expect(captured.some(q => q.sql.includes('INSERT INTO race_entry_transactions'))).toBe(true);
|
|
});
|
|
|
|
test('failed hot-disable refund remains queued for the next reconciliation attempt', async () => {
|
|
const db = { async withTransaction() { throw new Error('db down'); } };
|
|
const manager = gameModeManager({ _getMatchEconomies: () => ({ free: true }) });
|
|
const queue = new MatchQueue({ db, gameModeManager: manager });
|
|
queue._queues.crypto_race.push({ userId: 1, socketId: 'a', createdAt: 1 });
|
|
|
|
await queue.refundUnavailableQueues();
|
|
|
|
expect(queue.length('crypto_race')).toBe(1);
|
|
});
|
|
|
|
test('finished liability is reconciled after a transient payout insertion failure', async () => {
|
|
let insertFails = true;
|
|
let payoutInserted = false;
|
|
let successfulInserts = 0;
|
|
const captured = [];
|
|
const db = {
|
|
async query(sql) {
|
|
if (sql.includes('SELECT m.id')) {
|
|
return payoutInserted
|
|
? { rows: [], rowCount: 0 }
|
|
: { rows: [{ id: 'match-1' }], rowCount: 1 };
|
|
}
|
|
return { rows: [], rowCount: 0 };
|
|
},
|
|
async withTransaction(fn) {
|
|
return fn({
|
|
async query(sql, params) {
|
|
captured.push({ sql, params });
|
|
if (sql.includes('FROM matches') && sql.includes('FOR UPDATE')) {
|
|
return { rows: [{
|
|
id: 'match-1', economy: 'crypto_race', status: 'finished', winner_user_id: 7,
|
|
payout_liability_amount_atomic: '19000',
|
|
payout_liability_cap_atomic: '1000000',
|
|
payout_liability_accepted_at: new Date().toISOString()
|
|
}], rowCount: 1 };
|
|
}
|
|
if (sql.includes('FROM payouts')) {
|
|
return payoutInserted
|
|
? { rows: [{ id: 88 }], rowCount: 1 }
|
|
: { rows: [], rowCount: 0 };
|
|
}
|
|
if (sql.includes('FROM users')) return { rows: [{ id: 7, payout_address: 'wallet' }], rowCount: 1 };
|
|
if (sql.includes('INSERT INTO payouts') && insertFails) throw new Error('insert unavailable');
|
|
if (sql.includes('INSERT INTO payouts')) {
|
|
payoutInserted = true;
|
|
successfulInserts += 1;
|
|
}
|
|
return { rows: [{ id: 88 }], rowCount: 1 };
|
|
}
|
|
});
|
|
}
|
|
};
|
|
const manager = gameModeManager();
|
|
const service = new MatchPayoutService({ db, gameModeManager: manager });
|
|
|
|
expect(await service.reconcileFinishedLiabilities()).toEqual({
|
|
ok: false,
|
|
scanned: 1,
|
|
created: 0,
|
|
failed: 1,
|
|
unresolved: [{ type: 'match_liability', id: 'match-1' }]
|
|
});
|
|
insertFails = false;
|
|
expect(await service.reconcileFinishedLiabilities()).toEqual({
|
|
ok: true,
|
|
scanned: 1,
|
|
created: 1,
|
|
failed: 0,
|
|
unresolved: []
|
|
});
|
|
expect(await service.reconcileFinishedLiabilities()).toEqual({
|
|
ok: true,
|
|
scanned: 0,
|
|
created: 0,
|
|
failed: 0,
|
|
unresolved: []
|
|
});
|
|
expect(successfulInserts).toBe(1);
|
|
expect(manager._scheduleBatchPayout).toHaveBeenCalledTimes(1);
|
|
expect(captured.filter(q => q.sql.includes('INSERT INTO payouts'))).toHaveLength(2);
|
|
});
|
|
|
|
test('manager durably persists completion before attempting liability insertion', async () => {
|
|
const manager = new MatchManager({ io: null, db: {}, debugManager: null });
|
|
manager.enabled = true;
|
|
const order = [];
|
|
manager._persistFinish = async () => { order.push('persist'); };
|
|
manager.matchPayoutService.payoutWinner = async () => { order.push('payout'); };
|
|
manager.matchLeaderboard.postMatch = async () => { order.push('leaderboard'); };
|
|
const room = cryptoRoom();
|
|
room.winnerId = 'a';
|
|
room.status = 'finished';
|
|
|
|
await manager._finalize(room);
|
|
|
|
expect(order.slice(0, 2)).toEqual(['persist', 'payout']);
|
|
manager.shutdown();
|
|
});
|
|
|
|
test('a future-block-frozen match with an ambiguous collect acknowledgement continues only when the accepted snapshot exists', async () => {
|
|
const entries = [
|
|
{ userId: 1, socketId: 'a', sessionToken: 'ta', queueEntryId: 11 },
|
|
{ userId: 2, socketId: 'b', sessionToken: 'tb', queueEntryId: 12 }
|
|
];
|
|
const matchQueue = {
|
|
refundEntries: jest.fn()
|
|
};
|
|
const payoutService = {
|
|
collectEntryTickets: jest.fn().mockRejectedValue(new Error('COMMIT acknowledgement lost')),
|
|
getAcceptedLiability: jest.fn().mockResolvedValue({
|
|
entry_fee_atomic: '10000',
|
|
pot_atomic: '20000',
|
|
house_fee_atomic: '1000',
|
|
house_fee_percent: '5',
|
|
payout_liability_amount_atomic: '19000',
|
|
payout_liability_cap_atomic: '1000000',
|
|
payout_liability_terms: { version: 2 }
|
|
})
|
|
};
|
|
const matchManager = {
|
|
db: null,
|
|
gameModeManager: gameModeManager(),
|
|
matchPayoutService: payoutService,
|
|
setEngine: jest.fn(),
|
|
attach: jest.fn(),
|
|
onTick: jest.fn(),
|
|
onFinish: jest.fn()
|
|
};
|
|
const scheduler = new MatchScheduler({
|
|
matchQueue,
|
|
matchManager,
|
|
blockCountProvider: async () => 902,
|
|
blockEntropyProvider: async height => ({
|
|
block_header: { height, hash: 'ab'.repeat(32) }
|
|
})
|
|
});
|
|
const freeze = buildPaidEntrantFreeze({
|
|
freezeBlockHeight: 899,
|
|
targetBlockHeight: 900,
|
|
economy: 'crypto_race',
|
|
rulesetId: 'race',
|
|
queueEntryIds: entries.map(entry => entry.queueEntryId)
|
|
});
|
|
const seedProof = deriveFutureBlockMatchSeed({
|
|
blockHash: 'ab'.repeat(32),
|
|
blockHeight: 900,
|
|
freeze
|
|
});
|
|
|
|
await scheduler._startEntries({
|
|
entries,
|
|
economy: 'crypto_race',
|
|
blockHeight: 900,
|
|
seedProof,
|
|
frozen: {
|
|
id: '11111111-1111-4111-8111-111111111111',
|
|
freeze,
|
|
freezeCommitment: freeze.freezeCommitment,
|
|
entries
|
|
}
|
|
});
|
|
|
|
expect(payoutService.collectEntryTickets).toHaveBeenCalledTimes(1);
|
|
expect(payoutService.getAcceptedLiability).toHaveBeenCalledTimes(1);
|
|
expect(matchQueue.refundEntries).not.toHaveBeenCalled();
|
|
expect(matchManager.attach).toHaveBeenCalledTimes(1);
|
|
const room = matchManager.attach.mock.calls[0][0];
|
|
expect(room.payoutLiabilityAmountAtomic).toBe('19000');
|
|
scheduler.shutdown();
|
|
});
|
|
});
|