mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-29 02:58:33 +01:00
feat: preserve solo runs across production restarts
This commit is contained in:
+30
-5
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user