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.
196 lines
6.5 KiB
JavaScript
196 lines
6.5 KiB
JavaScript
const SessionManager = require('../src/network/sessionManager');
|
|
|
|
function makeDb(initialGames, initialCredits = 0) {
|
|
const games = new Map(initialGames.map(game => [game.id, { ...game }]));
|
|
const users = new Map([[7, initialCredits]]);
|
|
const refundReasons = new Set();
|
|
|
|
const query = jest.fn(async (sql, params = []) => {
|
|
if (/SELECT g\.id[\s\S]*FROM games g[\s\S]*g\.status = 'active'/i.test(sql)) {
|
|
return {
|
|
rows: Array.from(games.values())
|
|
.filter(game => game.status === 'active')
|
|
.map(game => ({ id: game.id }))
|
|
};
|
|
}
|
|
if (/FROM games g[\s\S]*g\.id = \$1[\s\S]*FOR UPDATE/i.test(sql)) {
|
|
const game = games.get(params[0]);
|
|
return { rows: game ? [{ ...game }] : [] };
|
|
}
|
|
if (/SELECT 1 FROM credit_transactions/i.test(sql)) {
|
|
return { rows: refundReasons.has(params[1]) ? [{}] : [] };
|
|
}
|
|
if (/UPDATE users SET credits = credits \+/i.test(sql)) {
|
|
const next = (users.get(params[1]) || 0) + Number(params[0]);
|
|
users.set(params[1], next);
|
|
return { rows: [{ credits: next }], rowCount: 1 };
|
|
}
|
|
if (/INSERT INTO credit_transactions/i.test(sql)) {
|
|
refundReasons.add(params[2]);
|
|
return { rows: [], rowCount: 1 };
|
|
}
|
|
if (/UPDATE games SET status = \$1/i.test(sql)) {
|
|
const game = games.get(params[1]);
|
|
if (game) game.status = params[0];
|
|
return { rows: [], rowCount: game ? 1 : 0 };
|
|
}
|
|
return { rows: [], rowCount: 0 };
|
|
});
|
|
|
|
return {
|
|
query,
|
|
withTransaction: jest.fn(async callback => callback({ query })),
|
|
credits: userId => users.get(userId),
|
|
game: id => games.get(id),
|
|
refundReasons
|
|
};
|
|
}
|
|
|
|
function manager(db, currentConfiguredCost = 99) {
|
|
return new SessionManager({
|
|
db,
|
|
debugManager: { CONSOLE_LOGGING: false },
|
|
gameModeManager: { creditsPerGameCost: currentConfiguredCost }
|
|
});
|
|
}
|
|
|
|
describe('orphan game recovery debit evidence', () => {
|
|
test('does not mint a refund for a pre-created active row when entry charging never committed', async () => {
|
|
const db = makeDb([{
|
|
id: 1,
|
|
user_id: 7,
|
|
game_mode: 'PAID_CREDITS',
|
|
payment_id: null,
|
|
status: 'active',
|
|
entry_consumed_at: null,
|
|
entry_credits_spent: 5
|
|
}], 10);
|
|
|
|
const result = await manager(db).recoverOrphanedGames();
|
|
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
scanned: 1,
|
|
finalized: 1,
|
|
refunded: 0,
|
|
creditsRefunded: 0,
|
|
unresolved: []
|
|
});
|
|
expect(db.credits(7)).toBe(10);
|
|
expect(db.game(1).status).toBe('expired');
|
|
expect(db.refundReasons.size).toBe(0);
|
|
});
|
|
|
|
test('restores the immutable debit snapshot, not the current configured entry cost', async () => {
|
|
const db = makeDb([{
|
|
id: 2,
|
|
user_id: 7,
|
|
game_mode: 'PAID_CREDITS',
|
|
payment_id: null,
|
|
status: 'active',
|
|
entry_consumed_at: new Date('2026-01-01T00:00:00Z'),
|
|
entry_credits_spent: 3
|
|
}], 4);
|
|
const recovery = manager(db, 99);
|
|
|
|
const first = await recovery.recoverOrphanedGames();
|
|
const second = await recovery.recoverOrphanedGames();
|
|
|
|
expect(first).toEqual({
|
|
ok: true,
|
|
scanned: 1,
|
|
finalized: 1,
|
|
refunded: 1,
|
|
creditsRefunded: 3,
|
|
unresolved: []
|
|
});
|
|
expect(second).toEqual({
|
|
ok: true,
|
|
scanned: 0,
|
|
finalized: 0,
|
|
refunded: 0,
|
|
creditsRefunded: 0,
|
|
unresolved: []
|
|
});
|
|
expect(db.credits(7)).toBe(7);
|
|
expect(db.game(2).status).toBe('refunded');
|
|
expect(db.refundReasons).toEqual(new Set(['orphan_game_refunded:2']));
|
|
});
|
|
|
|
test('does not silently convert a linked direct payment into credits', async () => {
|
|
const db = makeDb([{
|
|
id: 3,
|
|
user_id: 7,
|
|
game_mode: 'PAID_SINGLE',
|
|
payment_id: 77,
|
|
status: 'active',
|
|
entry_consumed_at: new Date('2026-01-01T00:00:00Z'),
|
|
entry_credits_spent: null
|
|
}], 1);
|
|
|
|
const result = await manager(db).recoverOrphanedGames();
|
|
|
|
expect(result.refunded).toBe(0);
|
|
expect(db.credits(7)).toBe(1);
|
|
expect(db.game(3).status).toBe('expired');
|
|
});
|
|
|
|
test('fails closed with explicit scan semantics when the orphan query fails', async () => {
|
|
const db = {
|
|
query: jest.fn().mockRejectedValue(new Error('database unavailable')),
|
|
withTransaction: jest.fn()
|
|
};
|
|
|
|
await expect(manager(db).recoverOrphanedGames()).rejects.toMatchObject({
|
|
code: 'FINANCIAL_RECOVERY_INCOMPLETE',
|
|
recovery: {
|
|
ok: false,
|
|
scope: 'orphaned_solo_games',
|
|
scanFailed: true,
|
|
scanned: 0,
|
|
resolved: 0,
|
|
unresolved: []
|
|
}
|
|
});
|
|
expect(db.withTransaction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('finishes other rows but rejects startup while any orphan remains unresolved', async () => {
|
|
const db = makeDb([{
|
|
id: 4,
|
|
user_id: 7,
|
|
game_mode: 'PAID_CREDITS',
|
|
status: 'active',
|
|
entry_consumed_at: new Date('2026-01-01T00:00:00Z'),
|
|
entry_credits_spent: 2
|
|
}, {
|
|
id: 5,
|
|
user_id: 7,
|
|
game_mode: 'FREE',
|
|
status: 'active',
|
|
entry_consumed_at: null,
|
|
entry_credits_spent: null
|
|
}], 0);
|
|
const baseTransaction = db.withTransaction;
|
|
let attempts = 0;
|
|
db.withTransaction = jest.fn(async callback => {
|
|
attempts += 1;
|
|
if (attempts === 1) throw new Error('refund transaction failed');
|
|
return baseTransaction(callback);
|
|
});
|
|
|
|
await expect(manager(db).recoverOrphanedGames()).rejects.toMatchObject({
|
|
code: 'FINANCIAL_RECOVERY_INCOMPLETE',
|
|
recovery: {
|
|
scope: 'orphaned_solo_games',
|
|
scanFailed: false,
|
|
scanned: 2,
|
|
resolved: 1,
|
|
unresolved: [{ type: 'game', id: '4' }]
|
|
}
|
|
});
|
|
expect(db.game(4).status).toBe('active');
|
|
expect(db.game(5).status).toBe('expired');
|
|
});
|
|
});
|