feat: preserve solo runs across production restarts

This commit is contained in:
jwinterm
2026-07-22 15:05:49 -04:00
parent b2772ff73b
commit 80d7f35e1f
37 changed files with 1816 additions and 160 deletions
+5 -5
View File
@@ -161,11 +161,11 @@ describe('disposable clone migration execution', () => {
logSpy.mockRestore();
});
test('discovers the contiguous repository manifest through migration 42 dynamically', () => {
test('discovers the contiguous repository manifest through migration 43 dynamically', () => {
const manifest = discoverMigrationManifest();
expect(manifest.latestOrdinal).toBeGreaterThanOrEqual(MINIMUM_RELEASE_MIGRATION);
expect(manifest.latestFilename).toBe('042_immutable_financial_event_snapshots.sql');
expect(manifest.files).toHaveLength(42);
expect(manifest.latestFilename).toBe('043_durable_solo_restart_snapshots.sql');
expect(manifest.files).toHaveLength(43);
});
test('runs the normal ordered migration implementation and proves the exact ledger', async () => {
@@ -185,9 +185,9 @@ describe('disposable clone migration execution', () => {
});
expect(pool.transactions).toEqual(result.files);
expect(result.latestFilename).toBe('042_immutable_financial_event_snapshots.sql');
expect(result.latestFilename).toBe('043_durable_solo_restart_snapshots.sql');
expect(output).toContain('clone_migration_status=ok');
expect(output).toContain('latest_migration=042_immutable_financial_event_snapshots.sql');
expect(output).toContain('latest_migration=043_durable_solo_restart_snapshots.sql');
expect(output.join('\n')).not.toContain(secret);
expect(pool.config).toEqual(expect.objectContaining({
database: env.DB_NAME,
+1
View File
@@ -101,6 +101,7 @@ describe('commerce disclosure and paid-action policy', () => {
expect(disclosure.software.warrantyNotice).toContain('AS IS');
expect(disclosure.software.legalAdviceNotice).toContain('not legal advice');
expect(disclosure.software.thirdPartyNotice).toContain('solely responsible');
expect(disclosure.software.thirdPartyNotice).toContain('does not make that deployment a Such Software service');
expect(disclosure.software.thirdPartyNotice).toContain('does not operate, supervise, endorse, or accept responsibility');
});
+56
View File
@@ -0,0 +1,56 @@
'use strict';
const fs = require('fs');
const path = require('path');
const deployFile = name => fs.readFileSync(
path.join(__dirname, '..', 'scripts', 'deploy', name),
'utf8'
);
const directiveValues = (unit, directive) => unit
.split(/\r?\n/)
.filter(line => line.startsWith(`${directive}=`))
.flatMap(line => line.slice(directive.length + 1).trim().split(/\s+/));
describe('deployment unit wallet boundaries', () => {
test.each([
['monerogue', 'monerogue-wallet.service', 'monero-wallet-rpc.service'],
['wownerogue', 'wownerogue-wallet.service', 'wownero-wallet-rpc.service']
])('%s application starts only with its dedicated wallet unit', (instance, dedicated, legacy) => {
const unit = deployFile(`${instance}.service`);
expect(directiveValues(unit, 'After')).toContain(dedicated);
expect(directiveValues(unit, 'Requires')).toContain(dedicated);
expect(directiveValues(unit, 'Wants')).not.toContain(legacy);
expect(directiveValues(unit, 'Requires')).not.toContain(legacy);
expect(unit).toContain(`EnvironmentFile=/etc/${instance}/app.env`);
expect(unit).toContain(`EnvironmentFile=/etc/${instance}/wallet-rpc.env`);
for (const unsafeLoaderVariable of [
'BASH_ENV',
'PATH',
'LD_PRELOAD',
'LD_AUDIT',
'NODE_OPTIONS',
'OPENSSL_CONF',
'DATABASE_URL',
'PGPASSWORD',
'NPM_CONFIG_NODE_OPTIONS',
'npm_config_node_options'
]) {
expect(directiveValues(unit, 'UnsetEnvironment')).toContain(unsafeLoaderVariable);
}
});
test('firewall ordering covers dedicated wallets and retained rollback units', () => {
const unit = deployFile('wowngeon-firewall.service');
for (const service of [
'monerogue-wallet.service',
'wownerogue-wallet.service',
'monero-wallet-rpc.service',
'wownero-wallet-rpc.service'
]) {
expect(unit).toContain(service);
}
});
});
+115
View File
@@ -0,0 +1,115 @@
const crypto = require('crypto');
const DungeonGenerator = require('../src/game/dungeon');
const Game = require('../src/game/game');
const { attachDungeonVerification } = require('../src/game/fairnessVerifier');
const LEGACY_OVERLAP_SEED = '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9';
const LEGACY_OVERLAP_FINGERPRINT = '9fd69a09c5f7a233b03538142994dd932afb4f2d6cfa19baab7041d5303edb50';
function objectiveKeys(dungeon) {
return [dungeon.entrance, dungeon.exit, dungeon.treasure].map(point => point.join(','));
}
describe('versioned playable dungeon objectives', () => {
const saved = {};
beforeAll(() => {
for (const key of ['DIFFICULTY_PRESET', 'DUNGEON_LEVELS', 'GAME_MODE', 'PAYMENTS_ENABLED']) {
saved[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(saved)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
test('v2 deterministically retries a known v1 entrance/exit overlap', () => {
const v1 = DungeonGenerator.regenerateFromSeed(LEGACY_OVERLAP_SEED, 'XMR', {
width: 30,
height: 15,
generatorVersion: 'dungeon-generator-v1'
});
const v2 = DungeonGenerator.regenerateFromSeed(LEGACY_OVERLAP_SEED, 'XMR', {
width: 30,
height: 15,
generatorVersion: 'dungeon-generator-v2'
});
expect(v1.entrance).toEqual(v1.exit);
expect([v1.entrance, v1.exit, v1.treasure]).toEqual([[17, 8], [17, 8], [16, 8]]);
expect(DungeonGenerator.layoutFingerprint(v1)).toBe(LEGACY_OVERLAP_FINGERPRINT);
expect(new Set(objectiveKeys(v1)).size).toBeLessThan(3);
expect(new Set(objectiveKeys(v2)).size).toBe(3);
expect(DungeonGenerator.hasDistinctReachableObjectives(v2, {
primaryFloor: "'1",
secondaryFloor: "'2"
})).toBe(true);
const repeated = DungeonGenerator.regenerateFromSeed(LEGACY_OVERLAP_SEED, 'XMR', {
width: 30,
height: 15,
generatorVersion: 'dungeon-generator-v2'
});
expect(DungeonGenerator.layoutFingerprint(repeated)).toBe(DungeonGenerator.layoutFingerprint(v2));
});
test('easy-profile v2 samples always have distinct reachable objectives', () => {
for (let i = 0; i < 100; i += 1) {
const seed = crypto.createHash('sha256').update(`objective-sample-${i}`).digest('hex');
const dungeon = DungeonGenerator.regenerateFromSeed(seed, 'XMR', {
width: 30,
height: 15,
generatorVersion: DungeonGenerator.GENERATOR_VERSION
});
expect(new Set(objectiveKeys(dungeon)).size).toBe(3);
expect(DungeonGenerator.hasDistinctReachableObjectives(dungeon, {
primaryFloor: "'1",
secondaryFloor: "'2"
})).toBe(true);
}
});
test('historical v1 fingerprints remain verifiable after v2 becomes current', () => {
const legacy = DungeonGenerator.regenerateFromSeed(LEGACY_OVERLAP_SEED, 'XMR', {
width: 30,
height: 15,
generatorVersion: 'dungeon-generator-v1'
});
expect(DungeonGenerator.layoutFingerprint(legacy)).toBe(LEGACY_OVERLAP_FINGERPRINT);
const result = attachDungeonVerification({ valid: true }, {
effectiveSeed: LEGACY_OVERLAP_SEED,
proofContext: {
cryptoType: 'XMR',
maxDepth: 1,
generatorVersion: 'dungeon-generator-v1',
generationOptions: { width: 30, height: 15 }
},
expectedFingerprint: LEGACY_OVERLAP_FINGERPRINT,
generatorVersion: 'dungeon-generator-v1'
});
expect(result.valid).toBe(true);
expect(result.generatorVersion).toBe('dungeon-generator-v1');
expect(result.layoutMatches).toBe(true);
});
test('public game options cannot select a historical generator', () => {
const game = Game.createStandardGame('public-socket', { id: 'public-socket' }, {
cryptoType: 'XMR',
width: 30,
height: 15,
generatorVersion: 'dungeon-generator-v1'
});
expect(game.generatorVersion).toBe(DungeonGenerator.GENERATOR_VERSION);
expect(game.gameConfig).not.toHaveProperty('generatorVersion');
expect(game.gameProof.generatorVersion).toBe(DungeonGenerator.GENERATOR_VERSION);
expect(game.gameProof.context.generatorVersion).toBe(DungeonGenerator.GENERATOR_VERSION);
});
});
+2 -2
View File
@@ -20,9 +20,9 @@ describe('deployment financial-audit contract', () => {
/FROM payouts\s+WHERE status IS NULL OR status NOT IN \('recorded', 'completed'\)/
);
expect(audit).toContain("games WHERE status IS NULL OR status NOT IN ('won', 'lost', 'expired')");
expect(audit).toContain("MAX(filename) = '042_immutable_financial_event_snapshots.sql'");
expect(audit).toContain("MAX(filename) = '043_durable_solo_restart_snapshots.sql'");
expect(audit.match(/status IS NULL OR status NOT IN \('recorded', 'completed'\)/g))
.toHaveLength(2);
expect(audit).toContain('COUNT(*) = 42');
expect(audit).toContain('COUNT(*) = 43');
});
});
+1
View File
@@ -45,6 +45,7 @@ describe('production disclosure pages', () => {
const html = renderTerms(disclosure());
expect(html).toContain('Independent deployment');
expect(html).toContain('solely responsible for its deployment');
expect(html).toContain('does not make that deployment a Such Software service');
expect(html).toContain('does not operate, supervise, endorse, or accept responsibility');
expect(html).toContain('copyright and permission notice');
expect(html).toContain('AS IS');
+32
View File
@@ -8,6 +8,7 @@
*/
const Game = require('../src/game/game');
const DungeonGenerator = require('../src/game/dungeon');
function openGame() {
// Default-size dungeon so the digger generates validly; we then override the map and
@@ -23,6 +24,37 @@ function openGame() {
}
describe('Monster collision with multi-step movement', () => {
test('two-room fallback spawns the monster deterministically on a distant passable tile', () => {
const FL = "'1";
const map = Array.from({ length: 7 }, (_, y) => Array.from({ length: 7 }, (_, x) =>
(x === 0 || y === 0 || x === 6 || y === 6) ? '#' : FL));
const dungeon = {
map,
rooms: [],
entrance: [1, 1],
exit: [5, 5],
treasure: [4, 4],
torches: []
};
const generatedFingerprint = DungeonGenerator.layoutFingerprint(dungeon);
const generate = jest.spyOn(DungeonGenerator, 'generate').mockReturnValue(dungeon);
const previousLevels = process.env.DUNGEON_LEVELS;
process.env.DUNGEON_LEVELS = '1';
try {
const game = new Game('fallback-spawn', { id: 1 }, { width: 7, height: 7, cryptoType: 'XMR' });
expect(game.monster.getState()).toMatchObject({ x: 5, y: 4 });
expect(game.dungeon.map[game.monster.y][game.monster.x]).toBe(FL);
expect(game.monster.isAt(game.player.x, game.player.y)).toBe(false);
expect(game.monster.isAt(game.dungeon.exit[0], game.dungeon.exit[1])).toBe(false);
expect(game.monster.isAt(game.dungeon.treasure[0], game.dungeon.treasure[1])).toBe(false);
expect(DungeonGenerator.layoutFingerprint(game.dungeon)).toBe(generatedFingerprint);
} finally {
generate.mockRestore();
if (previousLevels === undefined) delete process.env.DUNGEON_LEVELS;
else process.env.DUNGEON_LEVELS = previousLevels;
}
});
test('2-step monster adjacent to the player catches it (no phasing through)', () => {
const game = openGame();
game.difficultyConfig.monster.movesPerPlayerMove = 2;
+2 -2
View File
@@ -6,14 +6,14 @@ function makeDb(initialGames, initialCredits = 0) {
const refundReasons = new Set();
const query = jest.fn(async (sql, params = []) => {
if (/SELECT id FROM games WHERE status = 'active'/i.test(sql)) {
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 WHERE id = \$1 FOR UPDATE/i.test(sql)) {
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 }] : [] };
}
+2 -1
View File
@@ -619,6 +619,7 @@ describe('MatchQueue durable freeze/refund transactions', () => {
const order = [];
const context = {
beginShutdown: jest.fn(),
drainShutdownProducers: jest.fn(async () => { order.push('producers'); }),
debugManager: { CONSOLE_LOGGING: false },
tavernManager: null,
matchScheduler: { shutdown: jest.fn(async () => { order.push('scheduler'); }) },
@@ -637,6 +638,6 @@ describe('MatchQueue durable freeze/refund transactions', () => {
await SocketHandlers.prototype.shutdown.call(context);
expect(order).toEqual(['scheduler', 'queue', 'manager']);
expect(order).toEqual(['producers', 'scheduler', 'queue', 'manager']);
});
});
+494
View File
@@ -0,0 +1,494 @@
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/);
});
});
+2 -2
View File
@@ -164,7 +164,7 @@ describe('solo terminal settlement retry', () => {
expect(harness.activeGames.has('socket-2')).toBe(false);
});
test('reconnect restores a pending terminal lock without re-counting it as a playable game', () => {
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({
@@ -181,7 +181,7 @@ describe('solo terminal settlement retry', () => {
const newUser = { joinGame: jest.fn() };
suspended.suspendGame(44, 'socket-old', game);
const restored = suspended.restoreGame(44, 'socket-new', newUser);
const restored = await suspended.restoreGame(44, 'socket-new', newUser);
expect(restored.game).toBe(game);
expect(activeGames.get('socket-new')).toBe(game);
+3 -2
View File
@@ -46,8 +46,9 @@ describe('parallel Monero stagenet wallet candidate boundary', () => {
expect(unit).not.toContain('mainnet');
});
test('runbook keeps host mutation and production deployment at NO-GO', () => {
expect(runbook).toContain('STATUS: DESIGN / NO-GO FOR HOST MUTATION OR PRODUCTION DEPLOYMENT');
test('historical design record remains non-authorizing and fail-closed', () => {
expect(runbook).toContain('STATUS: HISTORICAL NON-MUTATING DESIGN RECORD');
expect(runbook).toContain('this document and its sample unit authorize nothing');
expect(runbook).toContain('never alters the legacy artifacts');
expect(runbook).toContain('renameat2(RENAME_NOREPLACE)');
expect(runbook).toContain('write `READY` **last**');