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.
495 lines
22 KiB
JavaScript
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/);
|
|
});
|
|
});
|