Files
wownerogue/test/soloRestartSnapshot.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

495 lines
22 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const Game = require('../src/game/game');
const GameManager = require('../src/game/gameManager');
const SuspendedGameManager = require('../src/network/suspendedGameManager');
const SocketHandlers = require('../src/network/socketHandlers');
const PaymentHandlers = require('../src/network/paymentHandlers');
const { hashSeed } = require('../src/game/provablyFair');
const {
SNAPSHOT_VERSION,
captureSoloRestartSnapshot,
restoreSoloRestartSnapshot
} = require('../src/game/soloRestartSnapshot');
function makeGame(generatorVersion = null) {
const user = { id: 'socket-old', joinGame: jest.fn(), endGame: jest.fn() };
const options = {
cryptoType: 'XMR',
width: 30,
height: 15,
...(generatorVersion ? {
fairnessProof: {
serverSeed: '0'.repeat(64),
clientSeed: '',
commitment: hashSeed('0'.repeat(64))
}
} : {})
};
const game = generatorVersion
? Game.createRestoredStandardGame('socket-old', user, options, generatorVersion)
: Game.createStandardGame('socket-old', user, options);
game.dbId = 91;
game.userId = 44;
game.dbUserId = 44;
game.db = { query: jest.fn() };
game.blockRec = 12345;
game.startBlock = 12345;
game.moveCount = 7;
game.startedAt = Date.now() - 5000;
game.player.hasTreasure = true;
game.dungeon.treasure = null;
const passable = new Set([game.gameConfig.primaryFloor, game.gameConfig.secondaryFloor, 0, '>', '$M']);
if (!passable.has(game.dungeon.map?.[game.monster.y]?.[game.monster.x])
|| (game.monster.x === game.player.x && game.monster.y === game.player.y)) {
outer: for (let y = 0; y < game.dungeon.map.length; y += 1) {
for (let x = 0; x < game.dungeon.map[y].length; x += 1) {
if (passable.has(game.dungeon.map[y][x])
&& (x !== game.player.x || y !== game.player.y)
&& (!game.dungeon.exit || x !== game.dungeon.exit[0] || y !== game.dungeon.exit[1])) {
game.monster.moveTo(x, y);
break outer;
}
}
}
}
game.monster.lastKnownPlayerX = game.player.x;
game.monster.lastKnownPlayerY = game.player.y;
game.seededRNG();
game.seededRNG();
return game;
}
function rowFor(game, snapshot, overrides = {}) {
return {
game_id: snapshot.gameId,
user_id: snapshot.userId,
dungeon_seed: snapshot.dungeonSeed,
joined_game_id: snapshot.gameId,
game_user_id: snapshot.userId,
game_dungeon_seed: snapshot.dungeonSeed,
snapshot_version: snapshot.snapshotVersion,
original_socket_id: snapshot.originalSocketId,
payment_monitoring_active: snapshot.paymentMonitoringActive,
state: snapshot.state,
created_at: new Date(),
status: 'active',
completed_at: null,
game_mode: 'PAID_CREDITS',
start_block_height: game.startBlock,
proof_version: game.gameProof.proofVersion,
fairness_offer_id: game.gameProof.offerId,
fairness_offer_issued_at: game.gameProof.offerIssuedAt,
proof_commitment: game.gameProof.commitment,
server_seed: game.gameProof.serverSeed,
client_seed: game.gameProof.clientSeed,
effective_seed: game.gameProof.seed,
layout_fingerprint: game.gameProof.layoutFingerprint,
layout_fingerprints: game.gameProof.layoutFingerprints,
generator_version: game.gameProof.generatorVersion,
proof_context: game.gameProof.context,
...overrides
};
}
describe('durable solo restart snapshots', () => {
const savedEnv = {};
beforeAll(() => {
for (const key of ['DIFFICULTY_PRESET', 'DUNGEON_LEVELS', 'GAME_MODE', 'PAYMENTS_ENABLED']) {
savedEnv[key] = process.env[key];
}
process.env.DIFFICULTY_PRESET = 'easy';
process.env.DUNGEON_LEVELS = '1';
process.env.GAME_MODE = 'FREE';
process.env.PAYMENTS_ENABLED = 'false';
});
afterAll(() => {
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
test('round-trips exact runtime progress and the next deterministic RNG draw', () => {
const original = makeGame();
const snapshot = captureSoloRestartSnapshot(original, { paymentMonitoringActive: true });
const db = { query: jest.fn() };
const restored = restoreSoloRestartSnapshot(rowFor(original, snapshot), { db });
expect(snapshot.snapshotVersion).toBe(SNAPSHOT_VERSION);
const serializedState = JSON.stringify(snapshot.state);
expect(serializedState).not.toContain('serverSeed');
expect(serializedState).not.toContain('fairnessProof');
expect(serializedState).not.toContain(original.gameProof.serverSeed);
expect(serializedState).not.toContain(original.gameProof.seed);
expect(restored).toMatchObject({
userId: 44,
originalSocketId: 'socket-old',
paymentMonitoringActive: true,
snapshotVersion: SNAPSHOT_VERSION
});
expect(restored.game).toMatchObject({
id: original.id,
dbId: 91,
userId: 44,
dbUserId: 44,
moveCount: 7,
blockRec: 12345,
gameState: 'active'
});
expect(restored.game.player.getState()).toEqual(original.player.getState());
expect(restored.game.monster.getState()).toEqual(original.monster.getState());
expect(restored.game.monster.lastKnownPlayerX).toBe(original.monster.lastKnownPlayerX);
expect(restored.game.monster.lastKnownPlayerY).toBe(original.monster.lastKnownPlayerY);
expect(restored.game.seededRNG()).toBe(original.seededRNG());
expect(restored.game.gameProof).toMatchObject({
gameId: original.id,
commitment: original.gameProof.commitment,
seed: original.gameProof.seed
});
});
test.each(['dungeon-generator-v1', 'dungeon-generator-v2'])(
'restores a durable %s run with its persisted fingerprints and next RNG draw',
(generatorVersion) => {
const original = makeGame(generatorVersion);
const snapshot = captureSoloRestartSnapshot(original);
const expectedManifest = original.gameProof.layoutFingerprints;
const expectedFingerprint = original.gameProof.layoutFingerprint;
const restored = restoreSoloRestartSnapshot(rowFor(original, snapshot), {
db: { query: jest.fn() }
});
expect(restored.game.generatorVersion).toBe(generatorVersion);
expect(restored.game.gameProof.generatorVersion).toBe(generatorVersion);
expect(restored.game.gameProof.layoutFingerprints).toEqual(expectedManifest);
expect(restored.game.gameProof.layoutFingerprint).toBe(expectedFingerprint);
expect(restored.game.seededRNG()).toBe(original.seededRNG());
}
);
test('never captures or restores terminal/settlement-drifted state', () => {
const game = makeGame();
game.settlementPending = true;
expect(() => captureSoloRestartSnapshot(game)).toThrow(/terminal or settlement-pending/);
game.settlementPending = false;
const snapshot = captureSoloRestartSnapshot(game);
expect(() => restoreSoloRestartSnapshot(rowFor(game, snapshot, { status: 'won' }), {
db: { query: jest.fn() }
})).toThrow(/active and nonterminal/);
expect(() => restoreSoloRestartSnapshot(rowFor(game, snapshot, {
proof_commitment: '0'.repeat(64)
}), { db: { query: jest.fn() } })).toThrow(/commitment/);
expect(() => restoreSoloRestartSnapshot(rowFor(game, snapshot, {
game_user_id: 45
}), { db: { query: jest.fn() } })).toThrow(/exactly join/);
});
test('normalizes PostgreSQL fairness timestamps back to the numeric proof contract', () => {
const game = makeGame();
const snapshot = captureSoloRestartSnapshot(game);
const issuedAt = new Date('2026-07-22T12:34:56.789Z');
const restored = restoreSoloRestartSnapshot(rowFor(game, snapshot, {
fairness_offer_issued_at: issuedAt
}), { db: { query: jest.fn() } });
expect(restored.game.gameProof.offerIssuedAt).toBe(issuedAt.getTime());
});
test('rejects impossible active geometry and future runtime timestamps', () => {
const game = makeGame();
const snapshot = captureSoloRestartSnapshot(game);
const overlapping = JSON.parse(JSON.stringify(snapshot.state));
overlapping.monster.x = overlapping.player.x;
overlapping.monster.y = overlapping.player.y;
expect(() => restoreSoloRestartSnapshot(rowFor(game, snapshot, { state: overlapping }), {
db: { query: jest.fn() }
})).toThrow(/overlap/);
const future = JSON.parse(JSON.stringify(snapshot.state));
future.startedAt = Date.now() + 120000;
expect(() => restoreSoloRestartSnapshot(rowFor(game, snapshot, { state: future }), {
db: { query: jest.fn() }
})).toThrow(/startedAt/);
});
test('durable claim is atomic, timerless, and only exposes the game after PostgreSQL accepts it', async () => {
jest.useFakeTimers();
const activeGames = new Map();
const db = { query: jest.fn().mockResolvedValue({ rows: [{ id: 91 }], rowCount: 1 }) };
const game = makeGame();
game.db = db;
const suspended = new SuspendedGameManager({
debugManager: { CONSOLE_LOGGING: false },
activeGames,
cleanupTimeoutMs: 1
});
suspended.suspendGame(44, 'socket-old', game, {
durableRestartSnapshot: true,
snapshotVersion: SNAPSHOT_VERSION,
paymentMonitoringActive: true
});
expect(jest.getTimerCount()).toBe(0);
const newUser = { joinGame: jest.fn() };
const restored = await suspended.restoreGame(44, 'socket-new', newUser);
expect(db.query).toHaveBeenCalledWith(expect.stringMatching(/DELETE FROM solo_restart_snapshots/),
[91, 44, game.id, SNAPSHOT_VERSION, 'socket-new']);
expect(restored.game).toBe(game);
expect(activeGames.get('socket-new')).toBe(game);
expect(suspended.hasSuspendedGame(44)).toBe(false);
expect(newUser.joinGame).toHaveBeenCalledWith(game);
suspended.cleanup();
jest.useRealTimers();
});
test('failed durable claim leaves the game suspended and never makes it active', async () => {
const activeGames = new Map();
const game = makeGame();
game.db = { query: jest.fn().mockResolvedValue({ rows: [], rowCount: 0 }) };
const suspended = new SuspendedGameManager({
debugManager: { CONSOLE_LOGGING: false },
activeGames
});
suspended.suspendGame(44, 'socket-old', game, {
durableRestartSnapshot: true,
snapshotVersion: SNAPSHOT_VERSION
});
await expect(suspended.restoreGame(44, 'socket-new', { joinGame: jest.fn() }))
.rejects.toMatchObject({ code: 'SOLO_RESTART_SNAPSHOT_CLAIM_FAILED' });
expect(suspended.hasSuspendedGame(44)).toBe(true);
expect(activeGames.size).toBe(0);
expect(game.socketId).toBe('socket-old');
suspended.cleanup();
});
test('shutdown persistence uses one transaction and exact active game/user/seed guards', async () => {
const game = makeGame();
const client = { query: jest.fn().mockResolvedValue({ rows: [{ game_id: 91 }], rowCount: 1 }) };
const db = { withTransaction: jest.fn(async callback => callback(client)) };
const context = Object.assign(Object.create(SocketHandlers.prototype), {
_admissionsInFlight: new Set(),
gameModeManager: { db },
activeGames: new Map([['socket-old', game]]),
paymentHandlers: { hasActiveMonitoring: jest.fn().mockReturnValue(true) },
suspendedGameManager: { suspendedGames: new Map() }
});
const result = await SocketHandlers.prototype.persistSoloRestartSnapshots.call(context);
expect(result).toEqual({ captured: 1 });
expect(db.withTransaction).toHaveBeenCalledTimes(1);
const [sql, params] = client.query.mock.calls[0];
expect(sql).toMatch(/g\.id = \$1 AND g\.user_id = \$2 AND g\.dungeon_seed = \$3/);
expect(sql).toMatch(/g\.status = 'active' AND g\.completed_at IS NULL/);
expect(params.slice(0, 6)).toEqual([91, 44, game.id, SNAPSHOT_VERSION, 'socket-old', true]);
expect(JSON.parse(params[6])).toMatchObject({ version: SNAPSHOT_VERSION, dbId: 91, userId: 44 });
});
test('startup rehydration validates every row before placing it in the timerless cache', async () => {
jest.useFakeTimers();
const game = makeGame();
const snapshot = captureSoloRestartSnapshot(game);
const row = rowFor(game, snapshot);
const db = { query: jest.fn().mockResolvedValue({ rows: [row], rowCount: 1 }) };
const suspendedGameManager = new SuspendedGameManager({
debugManager: { CONSOLE_LOGGING: false },
activeGames: new Map(),
cleanupTimeoutMs: 1
});
const context = Object.assign(Object.create(SocketHandlers.prototype), {
gameModeManager: { db },
suspendedGameManager
});
const result = await SocketHandlers.prototype.rehydrateSoloRestartSnapshots.call(context);
expect(result).toEqual({ restored: 1 });
expect(suspendedGameManager.hasSuspendedGame(44)).toBe(true);
expect(suspendedGameManager.getSuspendedState(44)).toMatchObject({
durableRestartSnapshot: true,
snapshotVersion: SNAPSHOT_VERSION
});
expect(jest.getTimerCount()).toBe(0);
suspendedGameManager.cleanup();
jest.useRealTimers();
});
test('shutdown closes every admission layer synchronously and drains work already inside', async () => {
let release;
const inFlight = new Promise(resolve => { release = resolve; });
const context = Object.assign(Object.create(SocketHandlers.prototype), {
_isShuttingDown: false,
_admissionsInFlight: new Set(),
gameManager: { beginShutdown: jest.fn() },
gameModeManager: { beginGameAdmissionShutdown: jest.fn() },
paymentHandlers: { beginShutdown: jest.fn() },
movementManager: { shutdown: jest.fn() },
broadcastManager: { sendStatusUpdate: jest.fn() }
});
const socket = { id: 'socket-old', emit: jest.fn() };
const existing = context._runAdmission(socket, 'solo_start', () => inFlight);
context.beginShutdown();
const rejectedTask = jest.fn();
await context._runAdmission(socket, 'solo_start', rejectedTask);
let drained = false;
const drain = context.drainAdmissionHandlers().then(() => { drained = true; });
await Promise.resolve();
expect(context._isShuttingDown).toBe(true);
expect(context.gameManager.beginShutdown).toHaveBeenCalledTimes(1);
expect(context.gameModeManager.beginGameAdmissionShutdown).toHaveBeenCalledTimes(1);
expect(context.paymentHandlers.beginShutdown).toHaveBeenCalledTimes(1);
expect(context.movementManager.shutdown).toHaveBeenCalledTimes(1);
expect(rejectedTask).not.toHaveBeenCalled();
expect(socket.emit).toHaveBeenCalledWith('server_restarting', expect.objectContaining({
code: 'SERVER_SHUTTING_DOWN'
}));
expect(drained).toBe(false);
release();
await existing;
await drain;
expect(drained).toBe(true);
});
test('GameManager is a non-bypassable last-line creation gate', async () => {
const manager = new GameManager({
activeGames: new Map(),
io: { to: () => ({ emit: jest.fn() }) },
broadcastManager: {},
debugManager: { CONSOLE_LOGGING: false },
gameModeManager: null
});
manager.beginShutdown();
await expect(manager.createGameForUser({ id: 'socket', joinGame: jest.fn() }))
.rejects.toMatchObject({ code: 'SERVER_SHUTTING_DOWN' });
});
test('PaymentHandlers internally refuses indirect invoice creation after the barrier', async () => {
const emitted = [];
const paymentHandlers = new PaymentHandlers({
io: { to: id => ({ emit: (event, payload) => emitted.push({ id, event, payload }) }) },
gameModeManager: { createPaymentRequest: jest.fn() },
walletService: {},
debugManager: { CONSOLE_LOGGING: false },
queueManager: {},
broadcastManager: {},
sessionManager: null
});
const socket = { id: 'socket-old' };
paymentHandlers.beginShutdown();
await paymentHandlers.createAndShowPaymentRequest(socket, {});
await paymentHandlers.handlePaymentRequest(socket, {});
expect(paymentHandlers.gameModeManager.createPaymentRequest).not.toHaveBeenCalled();
expect(emitted).toEqual([
expect.objectContaining({ event: 'payment_error', payload: expect.objectContaining({ code: 'SERVER_SHUTTING_DOWN' }) }),
expect.objectContaining({ event: 'payment_error', payload: expect.objectContaining({ code: 'SERVER_SHUTTING_DOWN' }) })
]);
paymentHandlers.dispose();
});
test('block-driven queue starts and timeouts are frozen after beginShutdown', async () => {
const context = Object.assign(Object.create(SocketHandlers.prototype), {
_isShuttingDown: true,
queueHandler: { startGamesForWaiting: jest.fn() },
activeGames: new Map([['socket', { startedAt: 0 }]])
});
await expect(context.startGamesForWaiting(123)).resolves.toBeNull();
await expect(context.checkGamesTimeout(123)).resolves.toBeUndefined();
expect(context.queueHandler.startGamesForWaiting).not.toHaveBeenCalled();
});
test('shutdown drains a paused timeout pass before settlement drain and snapshots later games', async () => {
const first = makeGame();
first.socketId = 'socket-first';
const second = makeGame();
second.socketId = 'socket-second';
second.dbId = 92;
second.userId = 45;
second.dbUserId = 45;
let releaseFirst;
const firstSettlement = new Promise(resolve => { releaseFirst = resolve; });
const activeGames = new Map([
['socket-first', first],
['socket-second', second]
]);
const client = { query: jest.fn().mockResolvedValue({ rows: [{ game_id: 92 }], rowCount: 1 }) };
const db = { withTransaction: jest.fn(async callback => callback(client)) };
const handleGameOver = jest.fn(async (socket, game) => {
game.settlementPending = true;
await firstSettlement;
game.settlementPending = false;
game.settlementCommitted = true;
activeGames.delete(socket.id);
return { success: true };
});
const context = Object.assign(Object.create(SocketHandlers.prototype), {
_isShuttingDown: false,
_admissionsInFlight: new Set(),
activeGames,
handleGameOver,
connectionHandler: {
getUserBySocket: jest.fn(socketId => ({
id: socketId,
blockRec: 10
}))
},
debugManager: { CONSOLE_LOGGING: false },
gameManager: {
beginShutdown: jest.fn(),
drainAdmissions: jest.fn().mockResolvedValue({ pending: 0 })
},
gameModeManager: {
db,
beginGameAdmissionShutdown: jest.fn(),
drainGameStartAdmissions: jest.fn().mockResolvedValue({ pending: 0 })
},
paymentHandlers: {
beginShutdown: jest.fn(),
drainShutdownWork: jest.fn().mockResolvedValue({ pending: 0 }),
hasActiveMonitoring: jest.fn().mockReturnValue(false)
},
movementManager: { shutdown: jest.fn() },
suspendedGameManager: { suspendedGames: new Map() }
});
const timeoutPass = context.checkGamesTimeout(11);
expect(handleGameOver).toHaveBeenCalledTimes(1);
context.beginShutdown();
let producersDrained = false;
const drain = context.drainShutdownProducers().then(() => { producersDrained = true; });
await Promise.resolve();
expect(producersDrained).toBe(false);
expect(second.gameState).toBe('active');
releaseFirst();
await timeoutPass;
await drain;
expect(handleGameOver).toHaveBeenCalledTimes(1);
expect(second.gameState).toBe('active');
const snapshots = await context.persistSoloRestartSnapshots();
expect(snapshots).toEqual({ captured: 1 });
expect(client.query.mock.calls[0][1].slice(0, 3)).toEqual([92, 45, second.id]);
});
test('migration enforces versioned identities and removes snapshots on terminal transition', () => {
const migration = fs.readFileSync(path.join(
__dirname, '../src/migrations/043_durable_solo_restart_snapshots.sql'
), 'utf8');
expect(migration).toMatch(/snapshot_version = 1/);
expect(migration).toMatch(/UNIQUE \(user_id\)/);
expect(migration).toMatch(/AFTER UPDATE OF status ON games/);
expect(migration).toMatch(/DELETE FROM solo_restart_snapshots WHERE game_id = NEW\.id/);
});
});