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
+30 -5
View File
@@ -42,7 +42,10 @@ const DUNGEON_CONFIGS = {
* Dungeon generation utilities
*/
class DungeonGenerator {
static get GENERATOR_VERSION() { return 'dungeon-generator-v1'; }
static get GENERATOR_VERSION() { return 'dungeon-generator-v2'; }
static get SUPPORTED_GENERATOR_VERSIONS() {
return Object.freeze(['dungeon-generator-v1', 'dungeon-generator-v2']);
}
static get FINGERPRINT_VERSION() { return 1; }
// Get the configuration object with difficulty settings applied
@@ -137,6 +140,10 @@ class DungeonGenerator {
};
const config = { ...defaultOptions, ...options };
const generatorVersion = config.generatorVersion || this.GENERATOR_VERSION;
if (!this.SUPPORTED_GENERATOR_VERSIONS.includes(generatorVersion)) {
throw new Error(`Unsupported dungeon generator version: ${generatorVersion}`);
}
if (CONSOLE_LOGGING) {
console.log(`[DungeonGenerator] Generating dungeon with effective torchDensity: ${config.torchDensity}`);
}
@@ -157,15 +164,22 @@ class DungeonGenerator {
const attemptSeed = (baseSeedInt != null) ? baseSeedInt + attempt : null;
const dungeon = this._generateOnce(width, height, config, rng, attemptSeed);
last = dungeon;
if (this.isReachable(dungeon.map, dungeon.entrance, dungeon.exit, config)) {
const reachable = this.isReachable(dungeon.map, dungeon.entrance, dungeon.exit, config);
const objectivesValid = generatorVersion === 'dungeon-generator-v1'
|| this.hasDistinctReachableObjectives(dungeon, config);
if (reachable && objectivesValid) {
return dungeon;
}
if (CONSOLE_LOGGING) {
console.warn(`[DungeonGenerator] attempt ${attempt}: exit unreachable from entrance, regenerating`);
console.warn(`[DungeonGenerator] attempt ${attempt}: invalid reachability/objectives, regenerating`);
}
}
console.error(`[DungeonGenerator] Could not generate a reachable dungeon after ${MAX_ATTEMPTS} attempts; returning last candidate.`);
return last;
if (generatorVersion === 'dungeon-generator-v1') {
// Exact historical behavior for already-recorded v1 proofs.
console.error(`[DungeonGenerator] Could not generate a reachable dungeon after ${MAX_ATTEMPTS} attempts; returning last candidate.`);
return last;
}
throw new Error(`Could not generate a playable dungeon after ${MAX_ATTEMPTS} deterministic attempts`);
}
/**
@@ -278,6 +292,17 @@ class DungeonGenerator {
}
return false;
}
/** V2 requires three distinct, reachable objectives so a run cannot start already won/looted. */
static hasDistinctReachableObjectives(dungeon, config) {
const points = [dungeon?.entrance, dungeon?.exit, dungeon?.treasure];
if (points.some(point => !Array.isArray(point) || point.length !== 2
|| !Number.isInteger(point[0]) || !Number.isInteger(point[1]))) return false;
const keys = points.map(([x, y]) => `${x},${y}`);
if (new Set(keys).size !== points.length) return false;
return this.isReachable(dungeon.map, dungeon.entrance, dungeon.exit, config)
&& this.isReachable(dungeon.map, dungeon.entrance, dungeon.treasure, config);
}
// Enhanced map creation with floor variations and torch placement
static enhanceMapWithVariations(basicMap, config) {
+3 -3
View File
@@ -26,7 +26,7 @@ function attachDungeonVerification(result, {
? context.generationOptions
: {};
const recordedGenerator = generatorVersion || context.generatorVersion || DungeonGenerator.GENERATOR_VERSION;
if (recordedGenerator !== DungeonGenerator.GENERATOR_VERSION) {
if (!DungeonGenerator.SUPPORTED_GENERATOR_VERSIONS.includes(recordedGenerator)) {
throw Object.assign(new Error('unsupported_generator_version'), { code: 'UNSUPPORTED_GENERATOR' });
}
@@ -53,14 +53,14 @@ function attachDungeonVerification(result, {
const levels = manifest.map((entry) => {
const depth = Number(entry.depth);
const entryGenerator = entry.generatorVersion || recordedGenerator;
if (entryGenerator !== DungeonGenerator.GENERATOR_VERSION) {
if (!DungeonGenerator.SUPPORTED_GENERATOR_VERSIONS.includes(entryGenerator)) {
throw Object.assign(new Error('unsupported_generator_version'), { code: 'UNSUPPORTED_GENERATOR' });
}
const fingerprintVersion = Number(entry.fingerprintVersion || context.fingerprintVersion || 1);
const dungeon = DungeonGenerator.regenerateFromSeed(
levelSeed(effectiveSeed, depth),
context.cryptoType || process.env.CRYPTO_TYPE || 'WOW',
options
{ ...options, generatorVersion: entryGenerator }
);
if (depth < maxDepth) dungeon.treasure = null;
const fingerprint = DungeonGenerator.layoutFingerprint(dungeon, fingerprintVersion);
+66 -5
View File
@@ -9,6 +9,10 @@ const { createGameProof, getPreGameCommitment, getPostGameReveal, createSeededRN
// Environment-based console logging control
const CONSOLE_LOGGING = process.env.NODE_ENV === 'debug' || process.env.NODE_ENV === 'development';
// Only this module can mint the option that selects a historical generator. Public/client game
// options may contain a same-named string, but the constructor deliberately discards it. Durable
// restart restoration supplies the database-validated version through the symbol-backed factory.
const TRUSTED_GENERATOR_VERSION = Symbol('trustedGeneratorVersion');
class Game {
/**
@@ -22,7 +26,17 @@ class Game {
const fairnessProof = suppliedOptions.fairnessProof || null;
// Internal proof material (including the secret server seed) must never ride into dungeon
// options, snapshots, logs, or the persisted public generation context.
const { fairnessProof: _privateFairnessProof, clientSeed: optionClientSeed, ...dungeonOptions } = suppliedOptions;
const {
fairnessProof: _privateFairnessProof,
clientSeed: optionClientSeed,
generatorVersion: _untrustedGeneratorVersion,
[TRUSTED_GENERATOR_VERSION]: trustedGeneratorVersion,
...dungeonOptions
} = suppliedOptions;
this.generatorVersion = trustedGeneratorVersion || DungeonGenerator.GENERATOR_VERSION;
if (!DungeonGenerator.SUPPORTED_GENERATOR_VERSIONS.includes(this.generatorVersion)) {
throw new Error(`Unsupported trusted dungeon generator version: ${this.generatorVersion}`);
}
this.user = user;
this.id = uuidv4();
this.socketId = socketId;
@@ -85,7 +99,7 @@ class Game {
// Bind the proof to every level in the committed run. Deeper layouts are independently
// regenerated from their per-depth seeds now, before play, so a crash or early death still
// leaves a complete, immutable verification manifest for the advertised descent.
this.gameProof.generatorVersion = DungeonGenerator.GENERATOR_VERSION;
this.gameProof.generatorVersion = this.generatorVersion;
this.gameProof.context = this.getProofContext();
this.gameProof.layoutFingerprints = this._buildLayoutFingerprintManifest();
// Backward-compatible level-one alias used by older verification clients/rows.
@@ -102,14 +116,17 @@ class Game {
dungeon = DungeonGenerator.regenerateFromSeed(
levelSeed(this.gameProof.seed, depth),
this.gameConfig.cryptoType,
this.gameProof.context.generationOptions
{
...this.gameProof.context.generationOptions,
generatorVersion: this.generatorVersion
}
);
if (depth < this.maxDepth) dungeon.treasure = null;
}
manifest.push({
depth,
fingerprintVersion: DungeonGenerator.FINGERPRINT_VERSION,
generatorVersion: DungeonGenerator.GENERATOR_VERSION,
generatorVersion: this.generatorVersion,
fingerprint: DungeonGenerator.layoutFingerprint(dungeon)
});
}
@@ -127,7 +144,7 @@ class Game {
return {
cryptoType: this.gameConfig.cryptoType || process.env.CRYPTO_TYPE || 'WOW',
maxDepth: this.maxDepth,
generatorVersion: DungeonGenerator.GENERATOR_VERSION,
generatorVersion: this.generatorVersion,
fingerprintVersion: DungeonGenerator.FINGERPRINT_VERSION,
generationOptions
};
@@ -140,6 +157,14 @@ class Game {
return new Game(socketId, user, options);
}
/** @internal Rebuild a run using the generator version already trusted from PostgreSQL. */
static createRestoredStandardGame(socketId, user, options = {}, generatorVersion) {
return new Game(socketId, user, {
...options,
[TRUSTED_GENERATOR_VERSION]: generatorVersion
});
}
/**
* Static factory method to create a legacy-sized game
*/
@@ -208,6 +233,7 @@ class Game {
const seed = levelSeed(this.gameProof.seed, depth);
const dungeon = DungeonGenerator.generate(this.gameConfig.width, this.gameConfig.height, {
...this.gameConfig,
generatorVersion: this.generatorVersion,
rng: isFirst ? this.seededRNG : createSeededRNG(seed),
seedInt: isFirst ? this.seedInt : seedToInt(seed)
});
@@ -242,6 +268,41 @@ class Game {
const center = dungeon.rooms[idx].getCenter();
this.monster.moveTo(center[0], center[1]);
}
// ROT.Digger can legitimately produce <=2 rooms on the 30x15 easy profile. The old branch
// then left the monster at its constructor default (0,0), normally a wall, producing an
// effectively monsterless run. Also defend against a malformed room center. Choose the
// farthest passable non-objective tile deterministically; this consumes no RNG and dungeon
// generation/layout fingerprints remain byte-identical (monster execution is explicitly
// outside the published layout proof).
const primaryFloor = this.gameConfig.primaryFloor || "'1";
const secondaryFloor = this.gameConfig.secondaryFloor || "'2";
const monsterTilePassable = (x, y) => {
const tile = dungeon.map?.[y]?.[x];
return tile === primaryFloor || tile === secondaryFloor || tile === 0 || tile === '>' || tile === '$M';
};
const monsterSpawnValid = monsterTilePassable(this.monster.x, this.monster.y)
&& (this.monster.x !== this.player.x || this.monster.y !== this.player.y)
&& (!dungeon.exit || this.monster.x !== dungeon.exit[0] || this.monster.y !== dungeon.exit[1])
&& (!dungeon.treasure || this.monster.x !== dungeon.treasure[0] || this.monster.y !== dungeon.treasure[1]);
if (!monsterSpawnValid) {
let fallback = null;
let fallbackDistance = -1;
for (let y = 0; y < dungeon.map.length; y += 1) {
for (let x = 0; x < dungeon.map[y].length; x += 1) {
if (!monsterTilePassable(x, y)) continue;
if (x === this.player.x && y === this.player.y) continue;
if (dungeon.exit && x === dungeon.exit[0] && y === dungeon.exit[1]) continue;
if (dungeon.treasure && x === dungeon.treasure[0] && y === dungeon.treasure[1]) continue;
const distance = Math.abs(x - this.player.x) + Math.abs(y - this.player.y);
if (distance > fallbackDistance) {
fallback = [x, y];
fallbackDistance = distance;
}
}
}
if (!fallback) throw new Error('Dungeon has no valid monster spawn tile');
this.monster.moveTo(fallback[0], fallback[1]);
}
// Reset lighting + FOV for the new level (a fresh descent is unexplored).
this.lightingAndFov = new LightingAndFov(dungeon.map, dungeon.torches);
+32 -1
View File
@@ -30,6 +30,8 @@ class GameManager {
Number(settlementRetryMaxMs) || 30000
);
this._isShuttingDown = false;
this._admissionClosed = false;
this._creationsInFlight = new Set();
}
/**
@@ -53,6 +55,21 @@ class GameManager {
* @returns {Object} Created game instance
*/
async createGameForUser(user, gameType = 'standard', options = {}) {
if (this._admissionClosed) {
const error = new Error('Game admission is closed for server shutdown');
error.code = 'SERVER_SHUTTING_DOWN';
throw error;
}
const creation = this._createGameForUser(user, gameType, options);
this._creationsInFlight.add(creation);
try {
return await creation;
} finally {
this._creationsInFlight.delete(creation);
}
}
async _createGameForUser(user, gameType = 'standard', options = {}) {
// Settlement-pending games intentionally remain in activeGames. This is the last-line
// guard against starting/charging a replacement run while the previous terminal result
// is not yet durable (and also closes ordinary double-start races between handlers).
@@ -86,6 +103,20 @@ class GameManager {
return game;
}
/** Synchronously close the lowest-level solo creation gate. */
beginShutdown() {
this._admissionClosed = true;
this._isShuttingDown = true;
}
/** Wait for creations that crossed the gate before beginShutdown(). */
async drainAdmissions() {
while (this._creationsInFlight.size > 0) {
await Promise.allSettled(Array.from(this._creationsInFlight));
}
return { pending: 0 };
}
/**
* Handle game over scenarios with comprehensive cleanup and payouts
* @param {Object} socket - Socket instance (or fake socket with just id)
@@ -371,7 +402,7 @@ class GameManager {
* Returns unresolved count so the process shutdown path can log/escalate it truthfully.
*/
async shutdown({ timeoutMs = 4000, retryIntervalMs = 50 } = {}) {
this._isShuttingDown = true;
this.beginShutdown();
for (const settlement of this._pendingSettlements.values()) {
if (settlement.retryTimer) clearTimeout(settlement.retryTimer);
settlement.retryTimer = null;
+29 -1
View File
@@ -94,6 +94,8 @@ class GameModeManager {
this._batchPayoutTimer = null;
this._isBatchProcessing = false;
this._isShuttingDown = false;
this._gameAdmissionClosed = false;
this._gameStartAdmissions = new Set();
// The composition root replaces this with the dynamic startup/runtime financial
// reconciliation gate. Default-open keeps isolated free/test instances compatible.
this.financialAdmissionAllowed = () => true;
@@ -1502,7 +1504,32 @@ class GameModeManager {
* SocketHandlers and passed into Game before its dungeon is generated.
*/
async processGameStart(socketId, gameId, options = {}) {
return this._dispatchGameStart(socketId, gameId, options);
if (this._gameAdmissionClosed) {
return {
success: false,
code: 'SERVER_SHUTTING_DOWN',
reason: 'Game admission is temporarily closed while the server restarts.'
};
}
const admission = this._dispatchGameStart(socketId, gameId, options);
this._gameStartAdmissions.add(admission);
try {
return await admission;
} finally {
this._gameStartAdmissions.delete(admission);
}
}
/** Close new solo entry consumption synchronously at the restart boundary. */
beginGameAdmissionShutdown() {
this._gameAdmissionClosed = true;
}
async drainGameStartAdmissions() {
while (this._gameStartAdmissions.size > 0) {
await Promise.allSettled(Array.from(this._gameStartAdmissions));
}
return { pending: 0 };
}
async _dispatchGameStart(socketId, gameId, options = {}) {
@@ -2768,6 +2795,7 @@ class GameModeManager {
/** Stop accepting scheduled payout work and cancel any debounce that has not fired yet. */
shutdown() {
this._isShuttingDown = true;
this.beginGameAdmissionShutdown();
if (this._batchPayoutTimer) {
clearTimeout(this._batchPayoutTimer);
this._batchPayoutTimer = null;
+23 -1
View File
@@ -97,7 +97,7 @@ function createSeededRNG(seed) {
let state = seed;
let counter = 0;
return function() {
const rng = function() {
// Generate next state by hashing current state + counter
const hash = crypto.createHash('sha256')
.update(state + counter.toString())
@@ -109,6 +109,28 @@ function createSeededRNG(seed) {
const num = hash.readUInt32BE(0);
return num / 0x100000000;
};
// A graceful restart must resume at the exact next draw; resetting the generator would make
// post-restart monster behaviour diverge from the committed run. These deliberately narrow
// accessors keep the normal callable-RNG API intact while allowing a validated runtime
// snapshot to preserve/restore only the monotonically increasing counter.
Object.defineProperties(rng, {
getCounter: {
value: () => counter,
enumerable: false
},
setCounter: {
value: (nextCounter) => {
if (!Number.isSafeInteger(nextCounter) || nextCounter < 0) {
throw new Error('Invalid seeded RNG counter');
}
counter = nextCounter;
},
enumerable: false
}
});
return rng;
}
/**
+339
View File
@@ -0,0 +1,339 @@
const { isDeepStrictEqual } = require('node:util');
const Game = require('./game');
const DungeonGenerator = require('./dungeon');
const SNAPSHOT_VERSION = 1;
const MAX_RNG_COUNTER = 100000000;
function invariant(condition, message) {
if (!condition) {
const error = new Error(`Invalid solo restart snapshot: ${message}`);
error.code = 'SOLO_RESTART_SNAPSHOT_INVALID';
throw error;
}
}
function integer(value, label, { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = {}) {
invariant(Number.isSafeInteger(value), `${label} must be a safe integer`);
invariant(value >= min && value <= max, `${label} is outside the accepted range`);
return value;
}
function nullableInteger(value, label, bounds = {}) {
return value == null ? null : integer(value, label, bounds);
}
function finiteNumber(value, label, { min = -Number.MAX_VALUE, max = Number.MAX_VALUE } = {}) {
invariant(Number.isFinite(value), `${label} must be finite`);
invariant(value >= min && value <= max, `${label} is outside the accepted range`);
return value;
}
function nonemptyString(value, label, maxLength = 255) {
invariant(typeof value === 'string' && value.length > 0 && value.length <= maxLength,
`${label} must be a nonempty bounded string`);
return value;
}
function jsonClone(value, label) {
try {
const encoded = JSON.stringify(value);
invariant(typeof encoded === 'string', `${label} is not JSON serializable`);
return JSON.parse(encoded);
} catch (error) {
if (error?.code === 'SOLO_RESTART_SNAPSHOT_INVALID') throw error;
invariant(false, `${label} is not JSON serializable`);
}
}
function jsonValue(value, label) {
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch (_) {
invariant(false, `${label} is not valid JSON`);
}
}
return value;
}
function normalizedHex(value, label) {
const normalized = String(value || '').trim().toLowerCase();
invariant(/^[0-9a-f]{64}$/.test(normalized), `${label} must be 64 lowercase hex characters`);
return normalized;
}
function captureSoloRestartSnapshot(game, { paymentMonitoringActive = false } = {}) {
invariant(game && typeof game === 'object', 'game is required');
invariant(!game.settlementPending && !game.settlementCommitted,
'terminal or settlement-pending games cannot be captured');
invariant(game.gameState === 'active', 'only active games can be captured');
const dbId = integer(Number(game.dbId), 'game.dbId', { min: 1 });
const userId = integer(Number(game.dbUserId ?? game.userId), 'stable user id', { min: 1 });
const gameId = nonemptyString(game.id, 'game.id', 50);
const originalSocketId = nonemptyString(game.socketId, 'game.socketId');
invariant(typeof game.seededRNG?.getCounter === 'function', 'seeded RNG is not snapshot-capable');
const player = game.player?.getState?.();
const monster = game.monster;
invariant(player && monster, 'player and monster state are required');
const state = {
version: SNAPSHOT_VERSION,
gameId,
dbId,
userId,
proofTimestamp: integer(Number(game.gameProof?.timestamp), 'proof timestamp', { min: 0 }),
gameConfig: jsonClone(game.gameConfig, 'game config'),
difficultyConfig: jsonClone(game.difficultyConfig, 'difficulty config'),
depth: integer(Number(game.depth), 'depth', { min: 1 }),
maxDepth: integer(Number(game.maxDepth), 'max depth', { min: 1 }),
player: {
x: integer(Number(player.x), 'player.x'),
y: integer(Number(player.y), 'player.y'),
hasKey: player.hasKey === true,
hasTreasure: player.hasTreasure === true
},
monster: {
x: integer(Number(monster.x), 'monster.x'),
y: integer(Number(monster.y), 'monster.y'),
lastKnownPlayerX: nullableInteger(monster.lastKnownPlayerX, 'monster.lastKnownPlayerX'),
lastKnownPlayerY: nullableInteger(monster.lastKnownPlayerY, 'monster.lastKnownPlayerY'),
visionRange: finiteNumber(Number(monster.visionRange), 'monster.visionRange', { min: 1, max: 1000 })
},
moveCount: integer(Number(game.moveCount || 0), 'move count', { min: 0 }),
startedAt: integer(Number(game.startedAt), 'startedAt', { min: 0 }),
monsterMoveAccumulator: finiteNumber(Number(game.monsterMoveAccumulator || 0),
'monster move accumulator', { min: 0, max: 1000 }),
rngCounter: integer(Number(game.seededRNG.getCounter()), 'seeded RNG counter', {
min: 0,
max: MAX_RNG_COUNTER
}),
blockRec: nullableInteger(game.blockRec ?? game.blockHeight ?? null, 'entry block', { min: 0 }),
startBlock: nullableInteger(game.startBlock ?? null, 'start block', { min: 0 }),
fee: finiteNumber(Number(game.fee || 0), 'fee', { min: 0 })
};
invariant(state.depth <= state.maxDepth, 'depth exceeds maxDepth');
return {
gameId: dbId,
userId,
dungeonSeed: gameId,
snapshotVersion: SNAPSHOT_VERSION,
originalSocketId,
paymentMonitoringActive: paymentMonitoringActive === true,
state
};
}
function restoreSoloRestartSnapshot(row, { db } = {}) {
invariant(row && typeof row === 'object', 'database row is required');
invariant(db && typeof db.query === 'function', 'database handle is required');
invariant(row.status === 'active' && row.completed_at == null,
'database game must still be active and nonterminal');
const state = jsonValue(row.state, 'state');
invariant(state && typeof state === 'object' && !Array.isArray(state), 'state must be an object');
invariant(Number(row.snapshot_version) === SNAPSHOT_VERSION && state.version === SNAPSHOT_VERSION,
'unsupported snapshot version');
const dbId = integer(Number(row.game_id), 'game id', { min: 1 });
const userId = integer(Number(row.user_id), 'user id', { min: 1 });
const dungeonSeed = nonemptyString(String(row.dungeon_seed || ''), 'dungeon seed', 50);
const originalSocketId = nonemptyString(String(row.original_socket_id || ''), 'original socket id');
invariant(Number(row.joined_game_id ?? row.game_id) === dbId
&& Number(row.game_user_id ?? row.user_id) === userId
&& String(row.game_dungeon_seed ?? row.dungeon_seed) === dungeonSeed,
'snapshot identity does not exactly join its game row');
invariant(state.dbId === dbId && state.userId === userId && state.gameId === dungeonSeed,
'snapshot identity does not match its database anchors');
const snapshotCreatedAt = row.created_at instanceof Date
? row.created_at.getTime()
: Date.parse(row.created_at);
invariant(Number.isFinite(snapshotCreatedAt), 'snapshot created_at is invalid');
const now = Date.now();
invariant(snapshotCreatedAt <= now + 60000, 'snapshot created_at is in the future');
const serverSeed = normalizedHex(row.server_seed, 'server seed');
const commitment = normalizedHex(row.proof_commitment, 'proof commitment');
const effectiveSeed = normalizedHex(row.effective_seed, 'effective seed');
const clientSeed = String(row.client_seed ?? '').trim().toLowerCase();
invariant(clientSeed === '' || /^[0-9a-f]{1,64}$/.test(clientSeed), 'client seed is invalid');
let offerIssuedAt = null;
if (row.fairness_offer_issued_at != null) {
offerIssuedAt = row.fairness_offer_issued_at instanceof Date
? row.fairness_offer_issued_at.getTime()
: Number(row.fairness_offer_issued_at);
if (!Number.isFinite(offerIssuedAt)) offerIssuedAt = Date.parse(row.fairness_offer_issued_at);
invariant(Number.isSafeInteger(offerIssuedAt) && offerIssuedAt >= 0,
'fairness offer issued timestamp is invalid');
}
const gameConfig = jsonValue(state.gameConfig, 'game config');
const difficultyConfig = jsonValue(state.difficultyConfig, 'difficulty config');
invariant(gameConfig && typeof gameConfig === 'object' && !Array.isArray(gameConfig),
'game config must be an object');
invariant(difficultyConfig && typeof difficultyConfig === 'object' && !Array.isArray(difficultyConfig),
'difficulty config must be an object');
const persistedGeneratorVersion = String(row.generator_version || '').trim();
invariant(DungeonGenerator.SUPPORTED_GENERATOR_VERSIONS.includes(persistedGeneratorVersion),
'dungeon generator version is unsupported');
const persistedContext = jsonValue(row.proof_context, 'proof context');
const persistedFingerprints = jsonValue(row.layout_fingerprints, 'layout fingerprints');
invariant(persistedContext?.generatorVersion === persistedGeneratorVersion,
'proof context generator version does not match the game row');
const placeholderUser = {
id: originalSocketId,
joinGame() {},
endGame() {}
};
const game = Game.createRestoredStandardGame(originalSocketId, placeholderUser, {
...gameConfig,
fairnessProof: {
proofVersion: Number(row.proof_version) || 1,
offerId: row.fairness_offer_id || null,
offerIssuedAt,
serverSeed,
clientSeed,
commitment
}
}, persistedGeneratorVersion);
invariant(game.gameProof.seed === effectiveSeed, 'effective seed does not match the proof inputs');
invariant(game.gameProof.commitment === commitment, 'commitment does not match the server seed');
invariant(game.maxDepth === state.maxDepth, 'runtime maxDepth has drifted');
invariant(isDeepStrictEqual(jsonClone(game.gameConfig, 'restored game config'), gameConfig),
'runtime game configuration has drifted');
invariant(isDeepStrictEqual(jsonClone(game.difficultyConfig, 'restored difficulty config'), difficultyConfig),
'runtime difficulty configuration has drifted');
invariant(isDeepStrictEqual(game.gameProof.context, persistedContext), 'proof context has drifted');
invariant(isDeepStrictEqual(game.gameProof.layoutFingerprints, persistedFingerprints),
'layout fingerprint manifest has drifted');
invariant(game.gameProof.generatorVersion === persistedGeneratorVersion,
'dungeon generator version has drifted');
invariant(game.gameProof.layoutFingerprint === String(row.layout_fingerprint || '').trim(),
'level-one layout fingerprint has drifted');
const depth = integer(Number(state.depth), 'depth', { min: 1, max: state.maxDepth });
if (depth !== 1) game._generateLevel(depth);
game.depth = depth;
const width = integer(Number(game.width), 'game width', { min: 1 });
const height = integer(Number(game.height), 'game height', { min: 1 });
const player = state.player || {};
const monster = state.monster || {};
game.player.setState({
x: integer(Number(player.x), 'player.x', { min: 0, max: width - 1 }),
y: integer(Number(player.y), 'player.y', { min: 0, max: height - 1 }),
hasKey: player.hasKey === true,
hasTreasure: player.hasTreasure === true
});
game.monster.moveTo(
integer(Number(monster.x), 'monster.x', { min: 0, max: width - 1 }),
integer(Number(monster.y), 'monster.y', { min: 0, max: height - 1 })
);
game.monster.lastKnownPlayerX = nullableInteger(monster.lastKnownPlayerX,
'monster.lastKnownPlayerX', { min: 0, max: width - 1 });
game.monster.lastKnownPlayerY = nullableInteger(monster.lastKnownPlayerY,
'monster.lastKnownPlayerY', { min: 0, max: height - 1 });
game.monster.visionRange = finiteNumber(Number(monster.visionRange),
'monster.visionRange', { min: 1, max: 1000 });
const primaryFloor = game.gameConfig.primaryFloor || "'1";
const secondaryFloor = game.gameConfig.secondaryFloor || "'2";
const passable = (x, y) => {
const tile = game.dungeon?.map?.[y]?.[x];
return tile === primaryFloor || tile === secondaryFloor
|| tile === 0 || tile === '>' || tile === '$M';
};
invariant(passable(game.player.x, game.player.y), 'player is not on a passable tile');
invariant(passable(game.monster.x, game.monster.y), 'monster is not on a passable tile');
invariant(game.player.x !== game.monster.x || game.player.y !== game.monster.y,
'active player and monster overlap');
const onExit = Array.isArray(game.dungeon?.exit)
&& game.player.x === game.dungeon.exit[0]
&& game.player.y === game.dungeon.exit[1];
invariant(!onExit, 'active player is already on the exit');
const generatedTreasure = Array.isArray(game.dungeon?.treasure)
? [...game.dungeon.treasure]
: null;
if (game.player.hasTreasure) {
invariant(depth === state.maxDepth, 'treasure cannot be held before the final depth');
invariant(generatedTreasure !== null, 'final-depth treasure cannot be reconstructed');
// Picking treasure removes the live dungeon marker immediately. The player may still
// stand on the former pickup coordinate; it is no longer an active treasure tile.
game.dungeon.treasure = null;
} else if (depth === state.maxDepth) {
invariant(generatedTreasure !== null, 'uncollected final-depth treasure is missing');
const onUncollectedTreasure = game.player.x === generatedTreasure[0]
&& game.player.y === generatedTreasure[1];
invariant(!onUncollectedTreasure, 'active player is already on uncollected treasure');
} else {
invariant(generatedTreasure === null, 'treasure exists before the final depth');
}
game.moveCount = integer(Number(state.moveCount), 'move count', { min: 0 });
game.startedAt = integer(Number(state.startedAt), 'startedAt', { min: 0 });
invariant(game.startedAt <= snapshotCreatedAt + 60000 && game.startedAt <= now + 60000,
'startedAt is later than the durable snapshot');
game.monsterMoveAccumulator = finiteNumber(Number(state.monsterMoveAccumulator),
'monster move accumulator', { min: 0, max: 1000 });
const generatedRngCounter = game.seededRNG.getCounter();
const restoredRngCounter = integer(Number(state.rngCounter), 'seeded RNG counter', {
min: 0,
max: MAX_RNG_COUNTER
});
invariant(restoredRngCounter >= generatedRngCounter,
'seeded RNG counter predates deterministic dungeon generation');
game.seededRNG.setCounter(restoredRngCounter);
game.blockRec = nullableInteger(state.blockRec, 'entry block', { min: 0 });
const durableEntryBlock = nullableInteger(
row.start_block_height == null ? null : Number(row.start_block_height),
'durable entry block',
{ min: 0 }
);
invariant(game.blockRec === durableEntryBlock,
'entry block does not match the durable game row');
game.blockHeight = game.blockRec;
game.startBlock = nullableInteger(state.startBlock, 'start block', { min: 0 });
game.fee = finiteNumber(Number(state.fee), 'fee', { min: 0 });
game.gameState = 'active';
game.updateFOV();
game.id = dungeonSeed;
game.socketId = originalSocketId;
game.dbId = dbId;
game.userId = userId;
game.dbUserId = userId;
game.db = db;
game.gameMode = String(row.game_mode || 'FREE').toUpperCase();
game.gameProof.gameId = dungeonSeed;
game.gameProof.timestamp = integer(Number(state.proofTimestamp), 'proof timestamp', { min: 0 });
invariant(game.gameProof.timestamp <= snapshotCreatedAt + 60000
&& game.gameProof.timestamp >= game.startedAt - 60000,
'proof timestamp is outside the game/snapshot lifetime');
game.gameProof.proofVersion = Number(row.proof_version) || 1;
game.gameProof.offerId = row.fairness_offer_id || null;
game.gameProof.offerIssuedAt = offerIssuedAt;
game.gameProof.context = persistedContext;
game.gameProof.layoutFingerprints = persistedFingerprints;
game.gameProof.layoutFingerprint = String(row.layout_fingerprint || '').trim();
return {
game,
userId,
originalSocketId,
paymentMonitoringActive: row.payment_monitoring_active === true,
snapshotVersion: SNAPSHOT_VERSION
};
}
module.exports = {
SNAPSHOT_VERSION,
captureSoloRestartSnapshot,
restoreSoloRestartSnapshot
};