Files
wownerogue/test/soloSettlementRetry.test.js
jwinterm fad2a695bd Wownerogue
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.
2026-07-27 08:23:01 -04:00

194 lines
7.9 KiB
JavaScript

const GameManager = require('../src/game/gameManager');
const MovementManager = require('../src/game/movementManager');
const SuspendedGameManager = require('../src/network/suspendedGameManager');
function makeHarness(completions, { retryMs = 1000 } = {}) {
const targeted = [];
const io = {
to: jest.fn(socketId => ({
emit: (event, payload) => targeted.push({ socketId, event, payload })
})),
emit: jest.fn()
};
const game = {
id: 'solo-terminal-seed',
socketId: 'socket-1',
dbUserId: 44,
player: { hasTreasure: true },
moveCount: 17,
startedAt: Date.now() - 2400,
movePlayer: jest.fn(),
endGame: jest.fn(function endGame() { this.gameState = 'ended'; }),
getProofReveal: jest.fn(() => ({ commitment: 'proof', seed: 'revealed-after-terminal' }))
};
const activeGames = new Map([['socket-1', game]]);
const gameModeManager = {
completeGame: jest.fn(),
db: { query: jest.fn().mockResolvedValue({ rows: [{ name: 'Alice' }] }) }
};
for (const result of completions) gameModeManager.completeGame.mockResolvedValueOnce(result);
const manager = new GameManager({
activeGames,
io,
broadcastManager: {},
debugManager: { CONSOLE_LOGGING: false },
gameModeManager,
settlementRetryBaseMs: retryMs,
settlementRetryMaxMs: retryMs
});
return { manager, game, activeGames, gameModeManager, io, targeted };
}
describe('solo terminal settlement retry', () => {
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
test('retains one frozen result after success:false and publishes it once after retry succeeds', async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-07-21T12:00:00Z'));
const harness = makeHarness([
{ success: false, error: 'Temporary database error' },
{ success: true, payout: { payoutId: 91, status: 'queued', amount: '300', multiplier: 3 } }
]);
const first = await harness.manager.handleGameOver(
{ id: 'socket-1' },
harness.game,
'won',
'escaped',
'You escaped!',
777
);
expect(first.success).toBe(false);
expect(harness.activeGames.get('socket-1')).toBe(harness.game);
expect(harness.game.settlementPending).toBe(true);
expect(harness.game.endGame).toHaveBeenCalledTimes(1);
expect(harness.targeted.filter(event => event.event === 'game_over')).toHaveLength(0);
expect(harness.targeted.filter(event => event.event === 'game_settlement_pending')).toHaveLength(1);
expect(harness.io.emit).not.toHaveBeenCalledWith('win_feed', expect.anything());
// Retaining the game blocks both movement and replacement-game creation while the exact
// terminal transaction is unresolved.
const movement = new MovementManager({
activeGames: harness.activeGames,
io: harness.io,
debugManager: {},
moveCooldown: 0
});
movement.handleMove('socket-1', { direction: 'right' });
expect(harness.game.movePlayer).not.toHaveBeenCalled();
await expect(harness.manager.createGameForUser({ id: 'socket-1' }))
.rejects.toThrow('active or settlement-pending game');
// Even accidental object mutation cannot change the already-frozen DB arguments.
harness.game.moveCount = 999;
harness.game.player.hasTreasure = false;
await jest.advanceTimersByTimeAsync(1000);
expect(harness.gameModeManager.completeGame).toHaveBeenCalledTimes(2);
for (const call of harness.gameModeManager.completeGame.mock.calls) {
expect(call).toEqual([
'socket-1',
'solo-terminal-seed',
true,
true,
{
moves: 17,
durationSeconds: 2,
score: 777,
reason: 'escaped',
outcome: 'escaped'
}
]);
}
expect(harness.activeGames.has('socket-1')).toBe(false);
expect(harness.game.settlementPending).toBe(false);
expect(harness.game.settlementCommitted).toBe(true);
expect(harness.targeted.filter(event => event.event === 'game_over')).toHaveLength(1);
expect(harness.io.emit.mock.calls.filter(([event]) => event === 'leaderboard_update')).toHaveLength(1);
expect(harness.io.emit.mock.calls.filter(([event]) => event === 'win_feed')).toHaveLength(1);
// A late duplicate terminal callback is a no-op after commit.
await harness.manager.handleGameOver(
{ id: 'socket-1' }, harness.game, 'lost', 'timeout', 'Too late', 0
);
expect(harness.gameModeManager.completeGame).toHaveBeenCalledTimes(2);
expect(harness.targeted.filter(event => event.event === 'game_over')).toHaveLength(1);
});
test('graceful shutdown cancels timers and drains an unresolved terminal result before DB close', async () => {
jest.useFakeTimers();
const harness = makeHarness([
{ success: false, error: 'Temporary database error' },
{ success: true, payout: null }
], { retryMs: 60000 });
await harness.manager.handleGameOver(
{ id: 'socket-1' }, harness.game, 'lost', 'monster', 'Caught', 0
);
const result = await harness.manager.shutdown({ timeoutMs: 1000, retryIntervalMs: 1 });
expect(result).toEqual({ initial: 1, settled: 1, pending: 0 });
expect(harness.gameModeManager.completeGame).toHaveBeenCalledTimes(2);
expect(harness.activeGames.size).toBe(0);
expect(jest.getTimerCount()).toBe(0);
});
test('targets the reconnect socket and removes its re-keyed ownership after settlement', async () => {
jest.useFakeTimers();
const harness = makeHarness([
{ success: false, error: 'Temporary database error' },
{ success: true, payout: null }
]);
await harness.manager.handleGameOver(
{ id: 'socket-1' }, harness.game, 'won', 'escaped', 'Escaped', 500
);
// Mirrors SuspendedGameManager.restoreGame(): the same object moves to the new socket key.
harness.activeGames.delete('socket-1');
harness.game.socketId = 'socket-2';
harness.activeGames.set('socket-2', harness.game);
await jest.advanceTimersByTimeAsync(1000);
expect(harness.gameModeManager.completeGame.mock.calls[1][0]).toBe('socket-2');
expect(harness.targeted.filter(event => event.event === 'game_over')).toEqual([
expect.objectContaining({ socketId: 'socket-2' })
]);
expect(harness.activeGames.has('socket-1')).toBe(false);
expect(harness.activeGames.has('socket-2')).toBe(false);
});
test('reconnect restores a pending terminal lock without re-counting it as a playable game', async () => {
jest.useFakeTimers();
const activeGames = new Map();
const suspended = new SuspendedGameManager({
debugManager: { CONSOLE_LOGGING: false },
activeGames,
cleanupTimeoutMs: 60000
});
const game = {
id: 'pending-reconnect',
settlementPending: true,
player: { x: 1, y: 2, hasTreasure: true },
moveCount: 9
};
const newUser = { joinGame: jest.fn() };
suspended.suspendGame(44, 'socket-old', game);
const restored = await suspended.restoreGame(44, 'socket-new', newUser);
expect(restored.game).toBe(game);
expect(activeGames.get('socket-new')).toBe(game);
expect(newUser.joinGame).not.toHaveBeenCalled();
expect(game.user).toBe(newUser);
suspended.cleanup();
expect(jest.getTimerCount()).toBe(0);
});
});