mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 22:31:26 +02:00
feat: preserve solo runs across production restarts
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }] : [] };
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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**');
|
||||
|
||||
Reference in New Issue
Block a user