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.
194 lines
7.9 KiB
JavaScript
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);
|
|
});
|
|
});
|