mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 06:41:27 +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.
498 lines
20 KiB
JavaScript
498 lines
20 KiB
JavaScript
const MatchQueue = require('../src/network/matchQueue');
|
|
const MatchRoom = require('../src/multiplayer/MatchRoom');
|
|
const MatchScheduler = require('../src/network/matchScheduler');
|
|
const MatchManager = require('../src/network/matchManager');
|
|
|
|
describe('match queue crash/restart invariants', () => {
|
|
const priorMatchEnabled = process.env.MATCH_ENABLED;
|
|
|
|
beforeEach(() => {
|
|
process.env.MATCH_ENABLED = 'true';
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (priorMatchEnabled === undefined) delete process.env.MATCH_ENABLED;
|
|
else process.env.MATCH_ENABLED = priorMatchEnabled;
|
|
});
|
|
|
|
test('persisting a match links every exact queue row before inserting linked entrants', async () => {
|
|
const captured = [];
|
|
const entries = [
|
|
{ userId: 7, socketId: 's7', queueEntryId: 101 },
|
|
{ userId: 8, socketId: 's8', queueEntryId: 102 }
|
|
];
|
|
const db = {
|
|
async withTransaction(fn) {
|
|
return fn({
|
|
async query(sql, params) {
|
|
captured.push({ sql, params });
|
|
if (sql.includes('UPDATE match_queue_entries') && sql.includes('SET match_id')) {
|
|
return {
|
|
rows: entries.map(entry => ({
|
|
id: entry.queueEntryId,
|
|
user_id: entry.userId
|
|
})),
|
|
rowCount: entries.length
|
|
};
|
|
}
|
|
return { rows: [{ id: 1 }], rowCount: 1 };
|
|
}
|
|
});
|
|
}
|
|
};
|
|
const room = new MatchRoom({
|
|
economy: 'crypto_race',
|
|
entrants: {
|
|
s7: { userId: 7, socketId: 's7' },
|
|
s8: { userId: 8, socketId: 's8' }
|
|
},
|
|
seed: 'a'.repeat(64)
|
|
});
|
|
const scheduler = new MatchScheduler({ matchQueue: {}, matchManager: { db } });
|
|
|
|
await scheduler._persistMatch(room, entries, 'crypto_race');
|
|
|
|
const link = captured.find(call => call.sql.includes('SET match_id'));
|
|
expect(link.params).toEqual([room.id, [101, 102], 'crypto_race']);
|
|
expect(link.sql).toContain("status = 'matched'");
|
|
expect(link.sql).toContain('match_id IS NULL');
|
|
const entrantInserts = captured.filter(call => call.sql.includes('INSERT INTO match_entrants'));
|
|
expect(entrantInserts.map(call => call.params)).toEqual([
|
|
[room.id, 7, 's7', 101],
|
|
[room.id, 8, 's8', 102]
|
|
]);
|
|
scheduler.shutdown();
|
|
});
|
|
|
|
test('a finished match with linked consumed tickets is never treated as stale escrow', async () => {
|
|
let staleSql = '';
|
|
const db = {
|
|
query: jest.fn(async (sql) => {
|
|
if (sql.includes('FROM matches')) return { rows: [], rowCount: 0 };
|
|
if (sql.includes('FROM match_queue_entries') && sql.includes("status = 'matched'")) {
|
|
staleSql = sql;
|
|
return { rows: [], rowCount: 0 };
|
|
}
|
|
return { rows: [], rowCount: 0 };
|
|
}),
|
|
withTransaction: jest.fn()
|
|
};
|
|
const queue = new MatchQueue({ db });
|
|
|
|
await queue.initialize();
|
|
await queue.shutdown();
|
|
|
|
expect(staleSql).toContain("status = 'matched' AND match_id IS NULL");
|
|
expect(staleSql).not.toContain("status = 'consumed'");
|
|
expect(db.withTransaction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('a crash after drain but before match persistence refunds the unlinked hold exactly once', async () => {
|
|
const state = {
|
|
queueStatus: 'matched',
|
|
matchId: null,
|
|
lotRemaining: 0,
|
|
userTickets: 0,
|
|
ledgerRows: 0
|
|
};
|
|
const db = {
|
|
async query(sql) {
|
|
if (sql.includes('FROM match_queue_entries')
|
|
&& state.queueStatus === 'matched'
|
|
&& state.matchId === null) {
|
|
return {
|
|
rows: [{
|
|
id: 41,
|
|
user_id: 9,
|
|
economy: 'crypto_race',
|
|
escrow_amount: '1',
|
|
race_entry_lot_id: 71
|
|
}],
|
|
rowCount: 1
|
|
};
|
|
}
|
|
return { rows: [], rowCount: 0 };
|
|
},
|
|
async withTransaction(fn) {
|
|
return fn({
|
|
async query(sql) {
|
|
if (sql.includes('UPDATE match_queue_entries')) {
|
|
expect(sql).toContain("status = 'matched' AND match_id IS NULL");
|
|
if (state.queueStatus !== 'matched' || state.matchId !== null) {
|
|
return { rows: [], rowCount: 0 };
|
|
}
|
|
state.queueStatus = 'cancelled';
|
|
return {
|
|
rows: [{ id: 41, escrow_amount: '1', race_entry_lot_id: 71 }],
|
|
rowCount: 1
|
|
};
|
|
}
|
|
if (sql.includes('UPDATE race_entry_lots')) {
|
|
expect(sql).toContain('refunded_at IS NULL');
|
|
expect(sql).toContain('remaining_entries + $1 <= original_entries');
|
|
state.lotRemaining += 1;
|
|
return { rows: [{ id: 71 }], rowCount: 1 };
|
|
}
|
|
if (sql.includes('UPDATE users')) {
|
|
state.userTickets += 1;
|
|
return { rows: [{ race_entries: state.userTickets }], rowCount: 1 };
|
|
}
|
|
if (sql.includes('INSERT INTO race_entry_transactions')) {
|
|
state.ledgerRows += 1;
|
|
return { rows: [], rowCount: 1 };
|
|
}
|
|
return { rows: [], rowCount: 0 };
|
|
}
|
|
});
|
|
}
|
|
};
|
|
const queue = new MatchQueue({ db });
|
|
|
|
await queue._recoverStaleQueueEntries();
|
|
await queue._recoverStaleQueueEntries();
|
|
|
|
expect(state).toEqual(expect.objectContaining({
|
|
queueStatus: 'cancelled',
|
|
lotRemaining: 1,
|
|
userTickets: 1,
|
|
ledgerRows: 1
|
|
}));
|
|
});
|
|
|
|
test('an abandoned starting match restores a consumed funded ticket only once', async () => {
|
|
const state = {
|
|
matchStatus: 'starting',
|
|
queueStatus: 'consumed',
|
|
lotRemaining: 0,
|
|
userTickets: 0,
|
|
entrantRefunded: false,
|
|
ledgerRows: 0
|
|
};
|
|
const db = {
|
|
async query(sql) {
|
|
if (sql.includes('FROM matches') && state.matchStatus === 'starting') {
|
|
return { rows: [{ id: 'match-9', economy: 'crypto_race' }], rowCount: 1 };
|
|
}
|
|
return { rows: [], rowCount: 0 };
|
|
},
|
|
async withTransaction(fn) {
|
|
return fn({
|
|
async query(sql) {
|
|
if (sql.includes('SELECT id FROM payouts')) return { rows: [], rowCount: 0 };
|
|
if (sql.includes('UPDATE matches SET status')) {
|
|
if (state.matchStatus !== 'starting') return { rows: [], rowCount: 0 };
|
|
state.matchStatus = 'cancelled';
|
|
return { rows: [{ id: 'match-9' }], rowCount: 1 };
|
|
}
|
|
if (sql.includes('FROM match_entrants') && sql.includes('queue_entry_id')) {
|
|
return {
|
|
rows: [{ id: 91, user_id: 9, queue_entry_id: 41 }],
|
|
rowCount: 1
|
|
};
|
|
}
|
|
if (sql.includes('UPDATE match_queue_entries') && sql.includes('match_id = $2')) {
|
|
if (state.queueStatus !== 'consumed') return { rows: [], rowCount: 0 };
|
|
state.queueStatus = 'cancelled';
|
|
return {
|
|
rows: [{ id: 41, escrow_amount: '1', race_entry_lot_id: 71 }],
|
|
rowCount: 1
|
|
};
|
|
}
|
|
if (sql.includes('UPDATE race_entry_lots')) {
|
|
state.lotRemaining += 1;
|
|
return { rows: [{ id: 71 }], rowCount: 1 };
|
|
}
|
|
if (sql.includes('UPDATE users')) {
|
|
state.userTickets += 1;
|
|
return { rows: [{ race_entries: state.userTickets }], rowCount: 1 };
|
|
}
|
|
if (sql.includes('INSERT INTO race_entry_transactions')) {
|
|
state.ledgerRows += 1;
|
|
return { rows: [], rowCount: 1 };
|
|
}
|
|
if (sql.includes('UPDATE match_entrants')) {
|
|
state.entrantRefunded = true;
|
|
return { rows: [], rowCount: 1 };
|
|
}
|
|
return { rows: [], rowCount: 0 };
|
|
}
|
|
});
|
|
}
|
|
};
|
|
const queue = new MatchQueue({ db });
|
|
|
|
await queue._recoverAbandonedMatches();
|
|
await queue._recoverAbandonedMatches();
|
|
|
|
expect(state).toEqual({
|
|
matchStatus: 'cancelled',
|
|
queueStatus: 'cancelled',
|
|
lotRemaining: 1,
|
|
userTickets: 1,
|
|
entrantRefunded: true,
|
|
ledgerRows: 1
|
|
});
|
|
});
|
|
|
|
test('queue initialization stays closed when the abandoned-match scan fails', async () => {
|
|
const db = {
|
|
query: jest.fn(async sql => {
|
|
if (sql.includes('FROM matches')) throw new Error('scan unavailable');
|
|
return { rows: [], rowCount: 0 };
|
|
}),
|
|
withTransaction: jest.fn()
|
|
};
|
|
const queue = new MatchQueue({ db });
|
|
|
|
await expect(queue.initialize()).rejects.toMatchObject({
|
|
code: 'FINANCIAL_RECOVERY_INCOMPLETE',
|
|
recovery: {
|
|
scope: 'abandoned_matches',
|
|
scanFailed: true,
|
|
scanned: 0,
|
|
resolved: 0,
|
|
unresolved: []
|
|
}
|
|
});
|
|
expect(queue.initialized).toBe(false);
|
|
expect(db.withTransaction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('queue initialization rejects after processing peers when one match remains unresolved', async () => {
|
|
let transactionCount = 0;
|
|
const db = {
|
|
query: jest.fn(async sql => {
|
|
if (sql.includes('FROM matches')) {
|
|
return {
|
|
rows: [
|
|
{ id: 'unresolved-match', economy: 'crypto_race' },
|
|
{ id: 'resolved-match', economy: 'crypto_race' }
|
|
],
|
|
rowCount: 2
|
|
};
|
|
}
|
|
return { rows: [], rowCount: 0 };
|
|
}),
|
|
withTransaction: jest.fn(async fn => {
|
|
transactionCount += 1;
|
|
if (transactionCount === 1) throw new Error('refund transaction failed');
|
|
return fn({
|
|
query: jest.fn(async sql => {
|
|
if (sql.includes('SELECT id FROM payouts')) {
|
|
return { rows: [{ id: 91 }], rowCount: 1 };
|
|
}
|
|
return { rows: [], rowCount: 1 };
|
|
})
|
|
});
|
|
})
|
|
};
|
|
const queue = new MatchQueue({ db });
|
|
|
|
await expect(queue.initialize()).rejects.toMatchObject({
|
|
code: 'FINANCIAL_RECOVERY_INCOMPLETE',
|
|
recovery: {
|
|
scope: 'abandoned_matches',
|
|
scanFailed: false,
|
|
scanned: 2,
|
|
resolved: 1,
|
|
unresolved: [{ type: 'match', id: 'unresolved-match' }]
|
|
}
|
|
});
|
|
expect(queue.initialized).toBe(false);
|
|
expect(db.withTransaction).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
test('paid queue admission is closed before durable recovery completes', async () => {
|
|
const queue = new MatchQueue({ db: {} });
|
|
|
|
expect(await queue.join({ userId: 7, economy: 'credits_prestige' })).toEqual({
|
|
success: false,
|
|
reason: 'financial_recovery_pending'
|
|
});
|
|
});
|
|
|
|
test('a later liability-recovery failure closes paid joins, drains, and freezes dynamically', async () => {
|
|
const queue = new MatchQueue({
|
|
db: {},
|
|
isFinancialRecoveryReady: () => false
|
|
});
|
|
queue.initialized = true;
|
|
queue._queues.credits_prestige.push(
|
|
{ queueEntryId: 1, userId: 1, socketId: 's1' },
|
|
{ queueEntryId: 2, userId: 2, socketId: 's2' }
|
|
);
|
|
|
|
expect(await queue.join({ userId: 7, economy: 'credits_prestige' })).toEqual({
|
|
success: false,
|
|
reason: 'financial_recovery_pending'
|
|
});
|
|
await expect(queue.drain('credits_prestige', 2, 2)).resolves.toBeNull();
|
|
await expect(queue.freezePaidMatch({
|
|
economy: 'credits_prestige',
|
|
maxPlayers: 2,
|
|
minPlayers: 2,
|
|
freezeBlockHeight: 10,
|
|
targetBlockHeight: 11,
|
|
rulesetId: 'race'
|
|
})).resolves.toBeNull();
|
|
});
|
|
|
|
test('MATCH_ENABLED=false still refunds durable queued escrow during boot', async () => {
|
|
process.env.MATCH_ENABLED = 'false';
|
|
let userTickets = 0;
|
|
let lotTickets = 0;
|
|
let ledgerRows = 0;
|
|
const db = {
|
|
async query(sql) {
|
|
if (sql.includes('FROM matches')) return { rows: [], rowCount: 0 };
|
|
if (sql.includes("status = 'matched'") && sql.includes('INTERVAL')) {
|
|
return { rows: [], rowCount: 0 };
|
|
}
|
|
if (sql.includes("WHERE status = 'queued'")) {
|
|
return {
|
|
rows: [{
|
|
id: 55,
|
|
user_id: 12,
|
|
economy: 'crypto_race',
|
|
socket_id: 'old-socket',
|
|
session_token: 'session',
|
|
created_at: new Date().toISOString()
|
|
}],
|
|
rowCount: 1
|
|
};
|
|
}
|
|
return { rows: [], rowCount: 0 };
|
|
},
|
|
async withTransaction(fn) {
|
|
return fn({
|
|
async query(sql) {
|
|
if (sql.includes('DELETE FROM match_queue_entries')) {
|
|
return {
|
|
rows: [{ id: 55, escrow_amount: '1', race_entry_lot_id: 77 }],
|
|
rowCount: 1
|
|
};
|
|
}
|
|
if (sql.includes('UPDATE users')) {
|
|
userTickets += 1;
|
|
return { rows: [{ race_entries: userTickets }], rowCount: 1 };
|
|
}
|
|
if (sql.includes('UPDATE race_entry_lots')) {
|
|
lotTickets += 1;
|
|
return { rows: [{ id: 77 }], rowCount: 1 };
|
|
}
|
|
if (sql.includes('INSERT INTO race_entry_transactions')) {
|
|
ledgerRows += 1;
|
|
return { rows: [], rowCount: 1 };
|
|
}
|
|
return { rows: [], rowCount: 0 };
|
|
}
|
|
});
|
|
}
|
|
};
|
|
const queue = new MatchQueue({ db });
|
|
|
|
await queue.initialize();
|
|
await queue.shutdown();
|
|
|
|
expect(queue.isEnabled()).toBe(false);
|
|
expect(queue.length('crypto_race')).toBe(0);
|
|
expect({ userTickets, lotTickets, ledgerRows }).toEqual({
|
|
userTickets: 1,
|
|
lotTickets: 1,
|
|
ledgerRows: 1
|
|
});
|
|
});
|
|
|
|
test('MATCH_ENABLED=false still reconciles an already-finished accepted liability', async () => {
|
|
process.env.MATCH_ENABLED = 'false';
|
|
const manager = new MatchManager({ db: {}, io: null });
|
|
manager.matchPayoutService.reconcileFinishedLiabilities = jest.fn().mockResolvedValue({
|
|
scanned: 1,
|
|
created: 1,
|
|
failed: 0
|
|
});
|
|
|
|
await manager.initialize();
|
|
|
|
expect(manager.enabled).toBe(false);
|
|
expect(manager.matchPayoutService.reconcileFinishedLiabilities).toHaveBeenCalledTimes(1);
|
|
manager.shutdown();
|
|
});
|
|
|
|
test('finished accepted-liability failures keep match recovery and startup closed', async () => {
|
|
const manager = new MatchManager({ db: {}, io: null });
|
|
manager.matchPayoutService.reconcileFinishedLiabilities = jest.fn().mockResolvedValue({
|
|
ok: false,
|
|
scanned: 1,
|
|
created: 0,
|
|
failed: 1,
|
|
unresolved: [{ type: 'match_liability', id: 'match-owed' }]
|
|
});
|
|
|
|
await expect(manager.initialize()).rejects.toMatchObject({
|
|
code: 'FINANCIAL_RECOVERY_INCOMPLETE',
|
|
recovery: {
|
|
scope: 'finished_match_liabilities',
|
|
scanFailed: false,
|
|
scanned: 1,
|
|
resolved: 0,
|
|
unresolved: [{ type: 'match_liability', id: 'match-owed' }]
|
|
}
|
|
});
|
|
expect(manager.financialRecoveryReady).toBe(false);
|
|
expect(manager._liabilityReconcileTimer).toBeNull();
|
|
manager.shutdown();
|
|
});
|
|
|
|
test('finished-liability scan errors have explicit fail-closed startup semantics', async () => {
|
|
const manager = new MatchManager({ db: {}, io: null });
|
|
manager.matchPayoutService.reconcileFinishedLiabilities = jest.fn()
|
|
.mockRejectedValue(new Error('liability scan unavailable'));
|
|
|
|
await expect(manager.initialize()).rejects.toMatchObject({
|
|
code: 'FINANCIAL_RECOVERY_INCOMPLETE',
|
|
recovery: {
|
|
scope: 'finished_match_liabilities',
|
|
scanFailed: true,
|
|
scanned: 0,
|
|
resolved: 0,
|
|
unresolved: []
|
|
}
|
|
});
|
|
expect(manager.financialRecoveryReady).toBe(false);
|
|
manager.shutdown();
|
|
});
|
|
|
|
test('bounded reconciliation retries a later transient failure and reopens the dynamic gate', async () => {
|
|
const manager = new MatchManager({ db: {}, io: null });
|
|
manager.matchPayoutService.reconcileFinishedLiabilities = jest.fn().mockResolvedValue({
|
|
ok: true, scanned: 0, created: 0, failed: 0, unresolved: []
|
|
});
|
|
await manager.initialize();
|
|
manager.matchPayoutService.reconcileFinishedLiabilities
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
scanned: 1,
|
|
created: 0,
|
|
failed: 1,
|
|
unresolved: [{ type: 'match_liability', id: 'match-retry' }]
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
scanned: 1,
|
|
created: 1,
|
|
failed: 0,
|
|
unresolved: []
|
|
});
|
|
|
|
const failed = await manager._reconcileFinishedLiabilities({ failClosed: false });
|
|
expect(failed.ok).toBe(false);
|
|
expect(manager.financialRecoveryReady).toBe(false);
|
|
|
|
const retried = await manager._reconcileFinishedLiabilities({ failClosed: false });
|
|
expect(retried).toMatchObject({ ok: true, scanned: 1, created: 1, failed: 0 });
|
|
expect(manager.financialRecoveryReady).toBe(true);
|
|
manager.shutdown();
|
|
});
|
|
});
|