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
+2
View File
@@ -23,6 +23,8 @@ build/
tmp/
temp/
*.tmp
__pycache__/
*.py[cod]
# IDE files
.vscode/
+5 -4
View File
@@ -312,7 +312,8 @@ merge, publish, distribute, sublicense, and/or sell copies is subject to includi
and permission notice in all copies or substantial portions. The software is provided “AS IS”,
without warranty of any kind as stated in the License.
The code, documentation, examples, and disclosures are not legal advice, a licence, or a compliance
approval. Such Software operates only the two products and scopes listed above. Any other operator
is solely responsible for its service, and receives no endorsement or assumption of responsibility
from Such Software.
The MIT License governs rights in the source. Documentation, examples, and product disclosures are
informational only; they are not legal advice or a determination that a deployment complies with
applicable law. Such Software operates only the two products and scopes listed above. The MIT
License does not make an independent deployment a Such Software service or authorize it to claim
Such Software sponsorship or endorsement; its operator remains solely responsible for the service.
+3 -3
View File
@@ -25,13 +25,13 @@ the same `DatabaseManager.runMigrations()` used by application startup: lexical
the `schema_migrations` ledger, and one transaction per newly applied file. Finally, it requires the
ledger to equal the complete repository migration manifest, in order, with no missing or extra
entry. The manifest is discovered from the candidate release and must be contiguous from 001; the
current floor is migration 042. A successful current release reports:
current floor is migration 043. A successful current release reports:
```text
clone_migration_status=ok
database=monerogue_restore_20260721t220048z_28d513b29b36
migration_count=42
latest_migration=042_immutable_financial_event_snapshots.sql
migration_count=43
latest_migration=043_durable_solo_restart_snapshots.sql
```
## Invocation
+8 -5
View File
@@ -22,13 +22,16 @@ self-hosts leave this setting unset and configure a truthful operator identity o
MIT permission to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies is
subject to retaining the copyright and permission notice in all copies or substantial portions.
The software is provided “AS IS”, without warranty of any kind, as stated in `LICENSE`. The code,
documentation, examples, and disclosure copy are not legal advice, a licence, or a compliance
approval.
The software is provided “AS IS”, without warranty of any kind, as stated in `LICENSE`. The MIT
License governs rights in the source. Documentation, examples, and disclosure copy are
informational only; they are not legal advice or a determination that a deployment complies with
applicable law.
A third-party or self-hosted operator is solely responsible for its deployment, product design,
legal compliance, funds, players, representations, and support. Such Software does not operate,
supervise, endorse, or accept responsibility for third-party deployments.
legal compliance, funds, players, representations, and support. The MIT License does not make that
deployment a Such Software service or authorize it to claim Such Software sponsorship or
endorsement. Such Software does not operate, supervise, endorse, or accept responsibility for
third-party deployments.
## Required production settings
+1 -1
View File
@@ -77,7 +77,7 @@ activation boundary must then:
4. restore each fresh pre-deploy dump into a separately named clone database, run
`npm run db:migrate:clone` under the exact disposable-target contract in
[CLONE_MIGRATIONS.md](./CLONE_MIGRATIONS.md), require its exact manifest proof (currently through
migration 042), and run the separately reviewed and hash-pinned fleet copy of
migration 043), and run the separately reviewed and hash-pinned fleet copy of
`financial-audit.sql` there—the SQL is intentionally not inside the runtime artifact;
5. run `npm run preflight` from the candidate with each final protected environment. Preflight is
intentionally non-mutating and does not replace database, daemon, or wallet health checks.
+2 -2
View File
@@ -61,8 +61,8 @@ FROM audit
ORDER BY category;
WITH ledger AS (
SELECT COUNT(*) = 42
AND MAX(filename) = '042_immutable_financial_event_snapshots.sql' AS valid
SELECT COUNT(*) = 43
AND MAX(filename) = '043_durable_solo_restart_snapshots.sql' AS valid
FROM schema_migrations
), audit(row_count) AS (
SELECT COUNT(*) FROM payouts WHERE status IS DISTINCT FROM 'completed'
+4 -11
View File
@@ -1,14 +1,8 @@
# monerogue.app: Such Software operated Monero STAGENET test mechanics.
# NO REAL VALUE; 2x/3x solo test-coin outcomes only; never mainnet or crypto-match payouts.
# Install to /etc/systemd/system/monerogue.service
#
# Separate code checkout + user + DB + .env from the wownerogue (mainnet) instance, so the
# two are fully independent. NPM (Nginx Proxy Manager) already forwards monerogue.app -> :3001.
[Unit]
Description=Monerogue Game Server (Monero Stagenet)
After=network-online.target postgresql.service monero-wallet-rpc.service
After=network-online.target postgresql.service monerogue-wallet.service
Wants=network-online.target
Wants=monero-wallet-rpc.service
Requires=monerogue-wallet.service
[Service]
Type=simple
@@ -17,6 +11,7 @@ Group=monerogue
WorkingDirectory=/var/www/monerogue/current/src
EnvironmentFile=/etc/monerogue/app.env
EnvironmentFile=/etc/monerogue/wallet-rpc.env
UnsetEnvironment=BASH_ENV ENV IFS PATH SHELL HOME LD_PRELOAD LD_LIBRARY_PATH LD_AUDIT GLIBC_TUNABLES NODE_OPTIONS NODE_PATH NODE_EXTRA_CA_CERTS PYTHONHOME PYTHONPATH OPENSSL_CONF SSL_CERT_FILE SSL_CERT_DIR DOTENV_CONFIG_PATH DOTENV_KEY DATABASE_URL HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY http_proxy https_proxy all_proxy no_proxy PGHOST PGHOSTADDR PGPORT PGDATABASE PGUSER PGPASSWORD PGPASSFILE PGSERVICE PGSERVICEFILE PGOPTIONS PGSSLMODE PGSSLROOTCERT PGSSLCRL PGSSLCERT PGSSLKEY PGCHANNELBINDING PGTARGETSESSIONATTRS PGCONNECT_TIMEOUT NPM_CONFIG_PREFIX NPM_CONFIG_CACHE NPM_CONFIG_USERCONFIG NPM_CONFIG_GLOBALCONFIG NPM_CONFIG_REGISTRY NPM_CONFIG_NODE_OPTIONS NPM_CONFIG_SCRIPT_SHELL NPM_CONFIG_SHELL NPM_CONFIG_PROXY NPM_CONFIG_HTTPS_PROXY NPM_CONFIG_CAFILE NPM_CONFIG_CERT NPM_CONFIG_KEY npm_config_prefix npm_config_cache npm_config_userconfig npm_config_globalconfig npm_config_registry npm_config_node_options npm_config_script_shell npm_config_shell npm_config_proxy npm_config_https_proxy npm_config_cafile npm_config_cert npm_config_key
ExecStartPre=/usr/bin/node scripts/preflight.js
ExecStart=/usr/bin/node index.js
Restart=on-failure
@@ -24,9 +19,7 @@ RestartSec=10
TimeoutStopSec=30
KillSignal=SIGTERM
UMask=0077
# Process/filesystem hardening. Application state lives in PostgreSQL, so the checkout is
# intentionally read-only; PrivateTmp provides the only disposable writable filesystem.
LimitCORE=0
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
+4 -2
View File
@@ -3,9 +3,9 @@
# Install to /etc/systemd/system/wownerogue.service.
[Unit]
Description=Wownerogue Game Server (Wownero Mainnet)
After=network-online.target postgresql.service wownero-wallet-rpc.service
After=network-online.target postgresql.service wownerogue-wallet.service
Wants=network-online.target
Wants=wownero-wallet-rpc.service
Requires=wownerogue-wallet.service
[Service]
Type=simple
@@ -14,6 +14,7 @@ Group=wownerogue
WorkingDirectory=/var/www/wownerogue/current/src
EnvironmentFile=/etc/wownerogue/app.env
EnvironmentFile=/etc/wownerogue/wallet-rpc.env
UnsetEnvironment=BASH_ENV ENV IFS PATH SHELL HOME LD_PRELOAD LD_LIBRARY_PATH LD_AUDIT GLIBC_TUNABLES NODE_OPTIONS NODE_PATH NODE_EXTRA_CA_CERTS PYTHONHOME PYTHONPATH OPENSSL_CONF SSL_CERT_FILE SSL_CERT_DIR DOTENV_CONFIG_PATH DOTENV_KEY DATABASE_URL HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY http_proxy https_proxy all_proxy no_proxy PGHOST PGHOSTADDR PGPORT PGDATABASE PGUSER PGPASSWORD PGPASSFILE PGSERVICE PGSERVICEFILE PGOPTIONS PGSSLMODE PGSSLROOTCERT PGSSLCRL PGSSLCERT PGSSLKEY PGCHANNELBINDING PGTARGETSESSIONATTRS PGCONNECT_TIMEOUT NPM_CONFIG_PREFIX NPM_CONFIG_CACHE NPM_CONFIG_USERCONFIG NPM_CONFIG_GLOBALCONFIG NPM_CONFIG_REGISTRY NPM_CONFIG_NODE_OPTIONS NPM_CONFIG_SCRIPT_SHELL NPM_CONFIG_SHELL NPM_CONFIG_PROXY NPM_CONFIG_HTTPS_PROXY NPM_CONFIG_CAFILE NPM_CONFIG_CERT NPM_CONFIG_KEY npm_config_prefix npm_config_cache npm_config_userconfig npm_config_globalconfig npm_config_registry npm_config_node_options npm_config_script_shell npm_config_shell npm_config_proxy npm_config_https_proxy npm_config_cafile npm_config_cert npm_config_key
ExecStartPre=/usr/bin/node scripts/preflight.js
ExecStart=/usr/bin/node index.js
Restart=on-failure
@@ -21,6 +22,7 @@ RestartSec=10
TimeoutStopSec=30
KillSignal=SIGTERM
UMask=0077
LimitCORE=0
NoNewPrivileges=yes
ProtectSystem=strict
+1 -1
View File
@@ -1,7 +1,7 @@
[Unit]
Description=Wowngeon application and wallet RPC firewall
After=network.target
Before=wownerogue.service monerogue.service wownero-wallet-rpc.service monero-wallet-rpc.service
Before=wownerogue.service monerogue.service wownerogue-wallet.service monerogue-wallet.service wownero-wallet-rpc.service monero-wallet-rpc.service
[Service]
Type=oneshot
+2 -2
View File
@@ -20,8 +20,8 @@ const SOURCE_SOFTWARE = Object.freeze({
license: 'MIT',
rightsNotice: 'Permission to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies is granted under the MIT License only if the copyright and permission notice is included in all copies or substantial portions.',
warrantyNotice: 'The software is provided “AS IS”, without warranty of any kind, express or implied, to the fullest extent stated in the MIT License.',
legalAdviceNotice: 'The software, documentation, examples, and disclosures are not legal advice, a licence, or a compliance approval.',
thirdPartyNotice: 'A third-party or self-hosted operator is solely responsible for its deployment, product design, legal compliance, funds, players, claims, and support. Such Software does not operate, supervise, endorse, or accept responsibility for third-party deployments.',
legalAdviceNotice: 'Documentation, examples, and product disclosures are informational only; they are not legal advice or a determination that any deployment complies with applicable law.',
thirdPartyNotice: 'A third-party or self-hosted operator is solely responsible for its deployment, product design, legal compliance, funds, players, claims, and support. The MIT License does not make that deployment a Such Software service or authorize it to claim Such Software sponsorship or endorsement. Such Software does not operate, supervise, endorse, or accept responsibility for third-party deployments.',
operatedBoundaryNotice: 'Such Software operates only play.wowne.ro and monerogue.app, and only within the product scopes stated for those services. It does not operate or endorse deployments made from this source by anyone else.'
});
+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
};
+39 -2
View File
@@ -1137,6 +1137,13 @@ async function startServer() {
if (!sm || typeof sm.recoverOrphanedGames !== 'function') {
throw new Error('Orphaned solo-game financial recovery is unavailable.');
}
// A graceful predecessor may have frozen exact nonterminal solo runtime state. Rebuild
// those games into the reconnect cache before orphan recovery scans active rows; invalid
// or drifted snapshots abort startup before a listener or financial dispatcher exists.
const restartRestore = await socketHandlers.rehydrateSoloRestartSnapshots();
if (restartRestore.restored > 0) {
console.log(`🔄 Rehydrated ${restartRestore.restored} durable solo restart snapshot(s)`);
}
await sm.recoverOrphanedGames();
// Match queue recovery and payout-liability reconciliation require the migrations that
@@ -1310,6 +1317,13 @@ async function gracefulShutdown(signal, exitCode = 0) {
if (databaseHealthInterval) clearInterval(databaseHealthInterval);
financialEventExporter.stop();
// Work that crossed the synchronous shutdown gate may still be completing a game start or a
// sequential block-timeout pass. Drain every such producer before GameManager snapshots its
// terminal settlement set; otherwise a paused timeout pass could add a later result behind it.
if (socketHandlers?.drainShutdownProducers) {
await socketHandlers.drainShutdownProducers();
}
// Terminal solo games are held in a serialized settlement-pending state when their atomic
// completion transaction fails. Drain those retries before disabling GameModeManager or
// closing PostgreSQL so a normal deploy/restart cannot turn a known result into an orphan.
@@ -1317,10 +1331,27 @@ async function gracefulShutdown(signal, exitCode = 0) {
const soloDrain = await socketHandlers.gameManager.shutdown({ timeoutMs: 4000 });
if (soloDrain.pending > 0) {
console.error(` ${soloDrain.pending} solo settlement(s) remain unresolved after shutdown drain`);
const error = new Error('Refusing graceful restart with unresolved solo settlements');
error.code = 'SOLO_SETTLEMENT_DRAIN_INCOMPLETE';
throw error;
} else if (soloDrain.initial > 0) {
console.log(` Settled ${soloDrain.settled} terminal solo game(s) during shutdown drain`);
}
}
// Only controlled termination signals may serialize live runtime objects. After an uncaught
// exception/rejection process state is not trustworthy; startup's orphan recovery remains the
// explicit economic fallback for those crash paths.
if (signal === 'SIGTERM' || signal === 'SIGINT') {
const restartSnapshots = await socketHandlers.persistSoloRestartSnapshots();
if (restartSnapshots.captured > 0) {
console.log(` Persisted ${restartSnapshots.captured} active solo game(s) for restart`);
}
}
// Snapshot ownership is now durable. Close Socket.IO transports so no existing connection
// survives into DB teardown; disconnect cleanup may adjust in-memory maps but cannot erase the
// PostgreSQL restart snapshots.
await new Promise((resolve) => io.close(() => resolve()));
gameModeManager.shutdown();
if (payoutRetryService) payoutRetryService.stop();
if (alertService) alertService.stopPeriodicChecks?.();
@@ -1373,5 +1404,11 @@ process.on('unhandledRejection', (reason) => {
gracefulShutdown('unhandledRejection', 1).catch(() => process.exit(1));
});
process.on('SIGTERM', () => gracefulShutdown('SIGTERM', 0));
process.on('SIGINT', () => gracefulShutdown('SIGINT', 0));
process.on('SIGTERM', () => gracefulShutdown('SIGTERM', 0).catch((error) => {
console.error('Graceful SIGTERM failed:', error?.message || error);
process.exit(1);
}));
process.on('SIGINT', () => gracefulShutdown('SIGINT', 0).catch((error) => {
console.error('Graceful SIGINT failed:', error?.message || error);
process.exit(1);
}));
@@ -0,0 +1,43 @@
-- Durable, versioned snapshots for nonterminal solo games during a graceful process restart.
--
-- The existing suspended-game manager is intentionally an in-memory reconnect cache. A systemd
-- restart destroys that cache, so a controlled shutdown must first anchor the exact runtime state
-- to the durable game/user identity. Snapshots never authorize a payout or alter accepted economic
-- terms; they only allow the same active run to be reconstructed before public admission resumes.
CREATE TABLE IF NOT EXISTS solo_restart_snapshots (
game_id INTEGER PRIMARY KEY REFERENCES games(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
dungeon_seed VARCHAR(50) NOT NULL,
snapshot_version SMALLINT NOT NULL,
original_socket_id VARCHAR(255) NOT NULL,
payment_monitoring_active BOOLEAN NOT NULL DEFAULT FALSE,
state JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT solo_restart_snapshot_version_check CHECK (snapshot_version = 1),
CONSTRAINT solo_restart_snapshot_state_object_check CHECK (jsonb_typeof(state) = 'object'),
CONSTRAINT solo_restart_snapshot_user_once UNIQUE (user_id),
CONSTRAINT solo_restart_snapshot_game_seed_once UNIQUE (dungeon_seed)
);
CREATE INDEX IF NOT EXISTS idx_solo_restart_snapshots_created
ON solo_restart_snapshots (created_at);
-- A terminal game can never retain a resumable state. Keeping this invariant in PostgreSQL makes
-- every completion path (including future/manual paths) clean up the snapshot automatically.
CREATE OR REPLACE FUNCTION delete_terminal_solo_restart_snapshot()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.status IS DISTINCT FROM 'active' THEN
DELETE FROM solo_restart_snapshots WHERE game_id = NEW.id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_games_delete_terminal_restart_snapshot ON games;
CREATE TRIGGER trg_games_delete_terminal_restart_snapshot
AFTER UPDATE OF status ON games
FOR EACH ROW
WHEN (NEW.status IS DISTINCT FROM 'active')
EXECUTE FUNCTION delete_terminal_solo_restart_snapshot();
+60 -2
View File
@@ -47,6 +47,9 @@ class PaymentHandlers {
this._confirmedRetentionMs = 6 * 60 * 60 * 1000; // 6 hours
// Track expiry timeouts so they can be cleared / unref'd to avoid keeping process open
this._expiryTimeouts = new Map(); // socketId -> timeout
this._isShuttingDown = false;
this._statusUpdatesInFlight = new Set();
this._invoiceCreationsInFlight = new Set();
// Keep reference so tests can dispose / and unref so it doesn't keep event loop alive
this._cleanupInterval = setInterval(() => {
const now = Date.now();
@@ -208,7 +211,29 @@ class PaymentHandlers {
}
}
_runInvoiceCreation(socket, task) {
if (this._isShuttingDown) {
this.io?.to?.(socket.id)?.emit?.('payment_error', {
error: 'The server is restarting; new payment requests are temporarily closed.',
code: 'SERVER_SHUTTING_DOWN'
});
return Promise.resolve(null);
}
let work;
try {
work = Promise.resolve(task());
} catch (error) {
work = Promise.reject(error);
}
this._invoiceCreationsInFlight.add(work);
return work.finally(() => this._invoiceCreationsInFlight.delete(work));
}
async handlePaymentRequest(socket, data) {
return this._runInvoiceCreation(socket, () => this._handlePaymentRequestActive(socket, data));
}
async _handlePaymentRequestActive(socket, data) {
if (!this.gameModeManager) {
this.clearPendingCommerceAcknowledgement(socket.id);
this.io.to(socket.id).emit('payment_error', { error: 'Payment system not available' });
@@ -325,6 +350,11 @@ class PaymentHandlers {
}
async createAndShowPaymentRequest(socket, options = {}) {
return this._runInvoiceCreation(socket,
() => this._createAndShowPaymentRequestActive(socket, options));
}
async _createAndShowPaymentRequestActive(socket, options = {}) {
if (!this.gameModeManager) {
this.clearPendingCommerceAcknowledgement(socket.id);
this.pendingEntryFairness.delete(socket.id);
@@ -559,10 +589,37 @@ class PaymentHandlers {
}
}
/** Stop new monitor callbacks from creating queue/game/entitlement work. */
beginShutdown() {
this._isShuttingDown = true;
}
async drainShutdownWork() {
while (this._statusUpdatesInFlight.size > 0 || this._invoiceCreationsInFlight.size > 0) {
await Promise.allSettled([
...this._statusUpdatesInFlight,
...this._invoiceCreationsInFlight
]);
}
return { pending: 0 };
}
// Handle one payment status update (raw wallet-style shape: in_mempool/confirmed/complete/
// amount/required/confirmations). Extracted verbatim from the monitor callback so native and
// gateway providers share the exact same confirmation logic.
// amount/required/confirmations). A callback that began before the synchronous shutdown gate
// is tracked to completion; later callbacks do no admission work and startup recovery observes
// the underlying durable payment state.
async _handlePaymentStatus(socket, paymentRequest, currentUser, status) {
if (this._isShuttingDown) return;
const update = this._handlePaymentStatusActive(socket, paymentRequest, currentUser, status);
this._statusUpdatesInFlight.add(update);
try {
return await update;
} finally {
this._statusUpdatesInFlight.delete(update);
}
}
async _handlePaymentStatusActive(socket, paymentRequest, currentUser, status) {
if (status.in_mempool && !status.confirmed) {
if (this.mempoolNotified.has(paymentRequest.address)) return;
this.mempoolNotified.set(paymentRequest.address, Date.now());
@@ -897,6 +954,7 @@ class PaymentHandlers {
* Dispose resources (intervals, monitors) - useful for tests / graceful shutdown.
*/
dispose() {
this.beginShutdown();
if (this._cleanupInterval) {
clearInterval(this._cleanupInterval);
this._cleanupInterval = null;
+15 -4
View File
@@ -460,7 +460,13 @@ class SessionManager {
let orphans;
try {
orphans = await this.db.query(
`SELECT id FROM games WHERE status = 'active' ORDER BY id ASC`,
`SELECT g.id
FROM games g
WHERE g.status = 'active'
AND NOT EXISTS (
SELECT 1 FROM solo_restart_snapshots s WHERE s.game_id = g.id
)
ORDER BY g.id ASC`,
[]
);
} catch (error) {
@@ -480,9 +486,14 @@ class SessionManager {
// Lock the game row and re-check under the lock: another instance (or a prior
// run) may already have finalized it.
const lock = await client.query(
`SELECT id, user_id, game_mode, payment_id, status,
entry_consumed_at, entry_credits_spent
FROM games WHERE id = $1 FOR UPDATE`,
`SELECT g.id, g.user_id, g.game_mode, g.payment_id, g.status,
g.entry_consumed_at, g.entry_credits_spent
FROM games g
WHERE g.id = $1
AND NOT EXISTS (
SELECT 1 FROM solo_restart_snapshots s WHERE s.game_id = g.id
)
FOR UPDATE`,
[orphan.id]
);
const game = lock.rows[0];
+328 -60
View File
@@ -25,6 +25,11 @@ const IdentityService = require('./identityService');
const SuspendedGameManager = require('./suspendedGameManager');
const MemoryManager = require('../utils/memoryManager');
const FairnessOfferManager = require('../game/fairnessOfferManager');
const {
SNAPSHOT_VERSION: SOLO_RESTART_SNAPSHOT_VERSION,
captureSoloRestartSnapshot,
restoreSoloRestartSnapshot
} = require('../game/soloRestartSnapshot');
const { normalizeError } = require('../utils/errors');
const money = require('../money/atomic');
const {
@@ -62,6 +67,7 @@ class SocketHandlers {
this.MOVE_COOLDOWN = 100; // Minimum 100ms between moves
this.fairnessOffers = new FairnessOfferManager();
this._isShuttingDown = false;
this._admissionsInFlight = new Set();
// Initialize rate limiter with debug mode matching debugManager
this.rateLimiter = new RateLimiter({
@@ -320,10 +326,194 @@ class SocketHandlers {
/** Stop new solo movement before taking the graceful-shutdown settlement snapshot. */
beginShutdown() {
// This flag is the synchronous boundary: every admission wrapper and lower-level game
// creator observes it before any shutdown await can yield back to a client callback.
this._isShuttingDown = true;
this.gameManager?.beginShutdown?.();
this.gameModeManager?.beginGameAdmissionShutdown?.();
this.paymentHandlers?.beginShutdown?.();
this.movementManager?.shutdown?.();
}
_emitShutdownAdmissionRefusal(socket, kind = 'admission') {
const payload = {
code: 'SERVER_SHUTTING_DOWN',
message: 'The server is restarting; new entries are temporarily closed.'
};
try { socket?.emit?.('server_restarting', { ...payload, kind }); } catch (_) {}
try { this.broadcastManager?.sendStatusUpdate?.(socket?.id, 'warning', payload.message); } catch (_) {}
return payload;
}
_rejectAdmissionDuringShutdown(socket, kind) {
if (!this._isShuttingDown) return false;
this._emitShutdownAdmissionRefusal(socket, kind);
return true;
}
_runAdmission(socket, kind, task) {
if (this._rejectAdmissionDuringShutdown(socket, kind)) return Promise.resolve(null);
const token = {};
this._admissionsInFlight.add(token);
let work;
try {
work = Promise.resolve(task());
} catch (error) {
work = Promise.reject(error);
}
token.work = work;
return work.finally(() => this._admissionsInFlight.delete(token));
}
async drainAdmissionHandlers() {
while (this._admissionsInFlight.size > 0) {
await Promise.allSettled(Array.from(this._admissionsInFlight, token => token.work));
}
return { pending: 0 };
}
/**
* Drain every operation that could still create a game or install a terminal solo result.
* This must finish before GameManager.shutdown() snapshots/retries its settlement map: an
* already-running block timeout pass is itself an admission producer and can otherwise resume
* after an await and add a new settlement behind the drain.
*/
async drainShutdownProducers() {
await this.drainAdmissionHandlers();
await this.paymentHandlers?.drainShutdownWork?.();
await this.gameModeManager?.drainGameStartAdmissions?.();
await this.gameManager?.drainAdmissions?.();
return { pending: 0 };
}
/**
* Persist every nonterminal solo run that currently exists only as a live Game object.
* Movement is already frozen by beginShutdown(), and the caller drains terminal settlement
* intents before invoking this method. One transaction prevents a partial restart boundary:
* either every eligible run has a guarded snapshot or none of the new snapshots commit.
*/
async persistSoloRestartSnapshots() {
const db = this.gameModeManager?.db;
if (!db || typeof db.withTransaction !== 'function') {
throw new Error('Solo restart snapshots require a transactional database handle');
}
// Work that crossed the synchronous gate is allowed to reach a stable DB/in-memory state;
// nothing new can enter behind it. Only then enumerate the complete snapshot set.
await this.drainShutdownProducers();
const captures = new Map();
const addGame = (game, socketId, paymentMonitoringActive) => {
if (!game) return;
if (game.settlementPending || game.settlementCommitted || game.gameState === 'ended') {
throw new Error(`Refusing to snapshot terminal solo game ${game.id || 'unknown'}`);
}
// A snapshot rehydrated earlier in this same process remains durable until its owner
// claims it; it does not need (and must not receive) a second write on shutdown.
const captured = captureSoloRestartSnapshot(game, { paymentMonitoringActive });
const previous = captures.get(captured.gameId);
if (previous && previous.dungeonSeed !== captured.dungeonSeed) {
throw new Error(`Conflicting restart snapshot identity for game row ${captured.gameId}`);
}
captures.set(captured.gameId, captured);
};
for (const [socketId, game] of this.activeGames.entries()) {
addGame(game, socketId, this.paymentHandlers?.hasActiveMonitoring?.(socketId) === true);
}
for (const suspended of this.suspendedGameManager?.suspendedGames?.values?.() || []) {
if (suspended?.durableRestartSnapshot === true) continue;
addGame(suspended?.game, suspended?.originalSocketId,
suspended?.paymentMonitoringActive === true);
}
if (captures.size === 0) return { captured: 0 };
await db.withTransaction(async (client) => {
for (const snapshot of captures.values()) {
const result = await client.query(`
INSERT INTO solo_restart_snapshots (
game_id, user_id, dungeon_seed, snapshot_version, original_socket_id,
payment_monitoring_active, state, created_at
)
SELECT g.id, g.user_id, g.dungeon_seed, $4, $5, $6, $7::jsonb, NOW()
FROM games g
WHERE g.id = $1 AND g.user_id = $2 AND g.dungeon_seed = $3
AND g.status = 'active' AND g.completed_at IS NULL
ON CONFLICT (game_id) DO UPDATE SET
snapshot_version = EXCLUDED.snapshot_version,
original_socket_id = EXCLUDED.original_socket_id,
payment_monitoring_active = EXCLUDED.payment_monitoring_active,
state = EXCLUDED.state,
created_at = EXCLUDED.created_at
WHERE solo_restart_snapshots.user_id = EXCLUDED.user_id
AND solo_restart_snapshots.dungeon_seed = EXCLUDED.dungeon_seed
RETURNING game_id
`, [
snapshot.gameId,
snapshot.userId,
snapshot.dungeonSeed,
snapshot.snapshotVersion,
snapshot.originalSocketId,
snapshot.paymentMonitoringActive,
JSON.stringify(snapshot.state)
]);
if (result.rowCount !== 1) {
throw new Error(`Active solo game ${snapshot.gameId} changed during restart snapshot`);
}
}
});
return { captured: captures.size };
}
/** Reconstruct durable graceful-restart snapshots before orphan recovery or admission. */
async rehydrateSoloRestartSnapshots() {
const db = this.gameModeManager?.db;
if (!db || typeof db.query !== 'function') {
throw new Error('Solo restart rehydration requires a database handle');
}
const result = await db.query(`
SELECT s.game_id, s.user_id, s.dungeon_seed, s.snapshot_version,
s.original_socket_id, s.payment_monitoring_active, s.state, s.created_at,
g.id AS joined_game_id, g.user_id AS game_user_id,
g.dungeon_seed AS game_dungeon_seed,
g.status, g.completed_at, g.game_mode, g.start_block_height,
g.proof_version, g.fairness_offer_id, g.fairness_offer_issued_at,
g.proof_commitment, g.server_seed, g.client_seed, g.effective_seed,
g.layout_fingerprint, g.layout_fingerprints, g.generator_version,
g.proof_context
FROM solo_restart_snapshots s
LEFT JOIN games g
ON g.id = s.game_id
AND g.user_id = s.user_id
AND g.dungeon_seed = s.dungeon_seed
ORDER BY s.game_id ASC
`, []);
let restored = 0;
for (const row of result.rows || []) {
const state = restoreSoloRestartSnapshot(row, { db });
if (this.suspendedGameManager.hasSuspendedGame(state.userId)) {
throw new Error(`Duplicate suspended solo identity ${state.userId}`);
}
const accepted = this.suspendedGameManager.suspendGame(
state.userId,
state.originalSocketId,
state.game,
{
paymentMonitoringActive: state.paymentMonitoringActive,
durableRestartSnapshot: true,
snapshotVersion: SOLO_RESTART_SNAPSHOT_VERSION,
blockRec: state.game.blockRec
}
);
if (!accepted) throw new Error(`Failed to rehydrate solo game ${state.game.id}`);
restored += 1;
}
return { restored };
}
/** Dynamic financial-recovery gate used by readiness and paid admission. */
isFinancialRecoveryReady() {
return this.matchQueue?.initialized === true
@@ -432,8 +622,20 @@ class SocketHandlers {
* Initialize socket event handlers for a new connection
*/
async handleConnection(socket) {
if (this._isShuttingDown) {
this._emitShutdownAdmissionRefusal(socket, 'connection');
socket.disconnect(true);
return;
}
const connectionResult = await this.connectionHandler.handleConnection(socket);
if (!connectionResult) return; // Connection was rejected or failed
// A connection may have entered just before beginShutdown() and awaited its DB session.
// Recheck before restoring state or registering any mutating event handlers.
if (this._isShuttingDown) {
this._emitShutdownAdmissionRefusal(socket, 'connection');
socket.disconnect(true);
return;
}
// Publish the server commitment before the client can submit a client seed. register_client
// re-emits this same offer (never swaps it) in case the browser attached listeners late.
@@ -447,7 +649,22 @@ class SocketHandlers {
if (sessionInfo && sessionInfo.resumed && dbUserId && this.suspendedGameManager) {
if (this.suspendedGameManager.hasSuspendedGame(dbUserId)) {
const memUser = this.connectionHandler.getUserBySocket(socket.id);
const restored = this.suspendedGameManager.restoreGame(dbUserId, socket.id, memUser);
let restored;
try {
restored = await this.suspendedGameManager.restoreGame(dbUserId, socket.id, memUser);
} catch (error) {
// Never let a failed durable claim fall through to queue/new-game admission:
// the database still owns an active run for this identity. A reconnect can
// retry after the transient fault or an operator can resolve the snapshot.
console.error(`[handleConnection] Suspended game restore failed for user ${dbUserId}:`,
normalizeError(error).message);
socket.emit('session_restore_failed', {
code: 'ACTIVE_GAME_RESTORE_FAILED',
message: 'Your active game could not be restored safely. Please reconnect shortly.'
});
socket.disconnect(true);
return;
}
if (restored) {
restoredGame = restored.game;
@@ -549,11 +766,14 @@ class SocketHandlers {
socket.emit('active_games', gameList);
}
// Register event handlers
// Register event handlers. Admission wrappers remain live for the life of this socket and
// consult the dynamic shutdown flag, so existing connections cannot race the snapshot.
const runAdmission = (kind, task) => this._runAdmission(socket, kind, task);
socket.on('chat message', (msg) => this.chatHandler.handleChatMessage(socket, msg, {
// Legacy text command has no client contribution, but still consumes the already
// published one-time server offer with an empty clientSeed.
handleGameQueue: (socket) => this.handleJoinQueue(socket, {}),
handleGameQueue: (commandSocket) => runAdmission('chat_game_queue',
() => this.handleJoinQueue(commandSocket, {})),
handleCancelEntry: (socket) => this.queueHandler.handleCancelEntry(socket),
handleStatsRequest: (socket) => this.handleStatsRequest(socket)
}));
@@ -586,15 +806,20 @@ class SocketHandlers {
if (this.debugManager?.CONSOLE_LOGGING) console.warn('Failed to re-send identity snapshot:', err.message);
});
});
socket.on('auto_start', (data) => this.handleAutoStart(socket, (data && typeof data === 'object') ? data : {})); // New handler for start button
socket.on('play_free', (data) => this.handleAutoStart(socket, { ...((data && typeof data === 'object') ? data : {}), free: true })); // Explicit free-play choice
socket.on('join_queue', (data) => this.handleJoinQueue(socket, (data && typeof data === 'object') ? data : {})); // Queue instead of auto-start ({ free: true } for Pleb-board free play)
socket.on('early_entry', (data) => this.handleEarlyEntry(socket, (data && typeof data === 'object') ? data : {})); // Early entry without waiting for block
socket.on('auto_start', (data) => runAdmission('auto_start',
() => this.handleAutoStart(socket, (data && typeof data === 'object') ? data : {}))); // New handler for start button
socket.on('play_free', (data) => runAdmission('play_free',
() => this.handleAutoStart(socket, { ...((data && typeof data === 'object') ? data : {}), free: true }))); // Explicit free-play choice
socket.on('join_queue', (data) => runAdmission('join_queue',
() => this.handleJoinQueue(socket, (data && typeof data === 'object') ? data : {}))); // Queue instead of auto-start ({ free: true } for Pleb-board free play)
socket.on('early_entry', (data) => runAdmission('early_entry',
() => this.handleEarlyEntry(socket, (data && typeof data === 'object') ? data : {}))); // Early entry without waiting for block
socket.on('fairness_offer_request', () => this._emitFairnessOffer(socket));
socket.on('address:prompt', () => this.handleAddressPrompt(socket));
// Payment system handlers
socket.on('request_payment', (data) => this.handlePaymentRequest(socket, data));
socket.on('request_payment', (data) => runAdmission('request_payment',
() => this.handlePaymentRequest(socket, data)));
socket.on('check_payment_status', (data) => this.handleCheckPaymentStatus(socket, data));
socket.on('get_user_credits', () => this.handleGetUserCredits(socket));
socket.on('address:update', (data) => this.handleAddressUpdate(socket, data));
@@ -620,7 +845,8 @@ class SocketHandlers {
// never crash the connection or leak a stack to the client — it logs and emits a
// benign 'match_error' instead (C5). Identity is always resolved from the CONNECTION,
// never from the client payload.
socket.on('match_queue', (data) => this._handleMatchQueue(socket, data));
socket.on('match_queue', (data) => runAdmission('match_queue',
() => this._handleMatchQueue(socket, data)));
socket.on('match_move', (data) => {
try {
this.matchManager.move(socket, data);
@@ -648,6 +874,10 @@ class SocketHandlers {
}
async handlePaymentRequest(socket, data = {}) {
if (this._isShuttingDown === true) {
this._rejectAdmissionDuringShutdown?.(socket, 'request_payment');
return;
}
const request = (data && typeof data === 'object') ? data : {};
const rlId = stableId(socket, this.sessionManager);
const rlIp = clientIp(socket);
@@ -695,6 +925,10 @@ class SocketHandlers {
* If eligible, starts game immediately (bypassing block queue) and processes game start (credit deduction / payment linkage)
*/
async handleAutoStart(socket, opts = {}) {
if (this._isShuttingDown === true) {
this._rejectAdmissionDuringShutdown?.(socket, 'solo_start');
return;
}
try {
const wantsExplicitFree = opts.free === true && this.gameModeManager?.freePlayEnabled;
if (!wantsExplicitFree
@@ -843,6 +1077,10 @@ class SocketHandlers {
* Handle join_queue request — adds player to block queue instead of starting immediately
*/
async handleJoinQueue(socket, opts = {}) {
if (this._isShuttingDown === true) {
this._rejectAdmissionDuringShutdown?.(socket, 'solo_queue');
return;
}
try {
const rlId = stableId(socket, this.sessionManager);
const rlIp = clientIp(socket);
@@ -890,6 +1128,10 @@ class SocketHandlers {
* Only available for free mode and credits mode (not direct payment mode)
*/
async handleEarlyEntry(socket, opts = {}) {
if (this._isShuttingDown === true) {
this._rejectAdmissionDuringShutdown?.(socket, 'early_entry');
return;
}
try {
// Early entry is free only on a free-only instance. Mixed/credits entry may consume a
// credit, so treat ambiguous mixed-mode requests as paid and fail closed.
@@ -1013,6 +1255,10 @@ class SocketHandlers {
}
const action = (data && data.action === 'leave') ? 'leave' : 'join';
if (action === 'join' && this._isShuttingDown === true) {
this._rejectAdmissionDuringShutdown?.(socket, 'match_queue');
return;
}
if (action === 'join' && this.activeGames?.has(socket.id)) {
const soloGame = this.activeGames.get(socket.id);
socket.emit('match_error', {
@@ -1503,68 +1749,85 @@ class SocketHandlers {
* Start games for waiting players when a new block is found
*/
async startGamesForWaiting(blockHeight) {
// Start queued single-player games first and capture the result to return.
const result = await this.queueHandler.startGamesForWaiting(blockHeight);
// Then drain match-mode queues on the same block, fire-and-forget so a match-layer
// error can never block or fail single-player block processing (MP-C3).
if (this.matchScheduler) {
this.matchScheduler.onBlock(blockHeight).catch(err => {
if (this.debugManager.CONSOLE_LOGGING) console.error('[SocketHandlers] Match scheduler block error:', err);
});
}
return result;
if (this._isShuttingDown) return null;
return this._runAdmission(null, 'block_queue_start', async () => {
// Start queued single-player games first and capture the result to return.
const result = await this.queueHandler.startGamesForWaiting(blockHeight);
// Keep scheduler work inside the tracked admission boundary. A match-layer fault still
// cannot fail solo processing, but shutdown now knows when the pre-barrier block tick
// has reached a stable state.
if (this.matchScheduler) {
await this.matchScheduler.onBlock(blockHeight).catch(err => {
if (this.debugManager.CONSOLE_LOGGING) console.error('[SocketHandlers] Match scheduler block error:', err);
});
}
return result;
});
}
/**
* Check for game timeouts based on block height
*/
async checkGamesTimeout(currentHeight) {
// Snapshot the entries first: handleGameOver mutates activeGames (it deletes the
// entry), so iterating the live Map could skip games. Process sequentially and
// await each game-over rather than firing them all off concurrently on one tick.
// Anti-instant-death guard ONLY (default 2s, set 0 to disable). Random block timing
// is the game's core mechanic and is deliberately preserved — this is not a fairness
// floor. It only avoids the degenerate case where a block lands in the same instant
// the game starts and the player dies before the dungeon even renders / before their
// first move is possible (100ms move cooldown).
const graceMs = (() => { const v = parseInt(process.env.GAME_START_GRACE_MS, 10); return Number.isFinite(v) ? v : 2000; })();
const now = Date.now();
const snapshot = Array.from(this.activeGames.entries());
for (const [socketId, game] of snapshot) {
// G3: the snapshot can go stale while we await a previous game-over. If this game
// was concurrently won or otherwise ended (removed from activeGames on another
// async path), never re-end it here as a loss/timeout.
if (!this.activeGames.has(socketId)) continue;
// Terminal games deliberately remain mapped until their DB transaction succeeds.
// Never race that frozen result with a later block-timeout loss.
if (game?.settlementPending || game?.settlementCommitted || game?.gameState === 'ended') continue;
if (this._isShuttingDown) return;
return this._runAdmission(null, 'block_game_timeout', async () => {
// Snapshot the entries first: handleGameOver mutates activeGames (it deletes the
// entry), so iterating the live Map could skip games. Process sequentially and
// await each game-over rather than firing them all off concurrently on one tick.
// Anti-instant-death guard ONLY (default 2s, set 0 to disable). Random block timing
// is the game's core mechanic and is deliberately preserved — this is not a fairness
// floor. It only avoids the degenerate case where a block lands in the same instant
// the game starts and the player dies before the dungeon even renders / before their
// first move is possible (100ms move cooldown).
const graceMs = (() => {
const value = parseInt(process.env.GAME_START_GRACE_MS, 10);
return Number.isFinite(value) ? value : 2000;
})();
const now = Date.now();
const snapshot = Array.from(this.activeGames.entries());
for (const [socketId, game] of snapshot) {
// A pass can have crossed the admission gate before shutdown and then yielded
// while settling an earlier game. Never let it install a later terminal intent
// after the shutdown producer drain has begun.
if (this._isShuttingDown) break;
// G3: the snapshot can go stale while we await a previous game-over. If this game
// was concurrently won or otherwise ended (removed from activeGames on another
// async path), never re-end it here as a loss/timeout.
if (!this.activeGames.has(socketId)) continue;
// Terminal games deliberately remain mapped until their DB transaction succeeds.
// Never race that frozen result with a later block-timeout loss.
if (game?.settlementPending || game?.settlementCommitted || game?.gameState === 'ended') continue;
const user = this.connectionHandler.getUserBySocket(socketId);
if (!user) continue;
const user = this.connectionHandler.getUserBySocket(socketId);
if (!user) continue;
// G1/C3: only end when the entry block is DEFINED and a later block has arrived.
// If we've never recorded an entry block for this player, record the current
// height as the first observation instead of treating a missing value as either
// immortal (never times out) or instant-death (times out on the first tick).
if (user.blockRec == null) {
user.blockRec = currentHeight;
continue;
}
const elapsedMs = now - (game.startedAt || now);
if (currentHeight > user.blockRec && elapsedMs >= graceMs) {
if (this.debugManager.CONSOLE_LOGGING) {
console.log(`💀 GAME TIMEOUT for player ${socketId}: entered on block ${user.blockRec}, died on block ${currentHeight}`);
// G1/C3: only end when the entry block is DEFINED and a later block has arrived.
// If we've never recorded an entry block for this player, record the current
// height as the first observation instead of treating a missing value as either
// immortal (never times out) or instant-death (times out on the first tick).
if (user.blockRec == null) {
user.blockRec = currentHeight;
continue;
}
game.gameState = 'lost';
const fakeSocket = { id: socketId };
try {
await this.handleGameOver(fakeSocket, game, 'lost', 'timeout', 'You didn\'t escape before the block time limit!');
} catch (e) {
console.error(`checkGamesTimeout: handleGameOver failed for ${socketId}:`, e.message);
const elapsedMs = now - (game.startedAt || now);
if (currentHeight > user.blockRec && elapsedMs >= graceMs) {
// Keep this check immediately adjacent to terminal mutation. The synchronous
// beginShutdown flag is the authoritative cut between snapshot/restart work.
if (this._isShuttingDown) break;
if (this.debugManager.CONSOLE_LOGGING) {
console.log(`💀 GAME TIMEOUT for player ${socketId}: entered on block ${user.blockRec}, died on block ${currentHeight}`);
}
game.gameState = 'lost';
const fakeSocket = { id: socketId };
try {
await this.handleGameOver(fakeSocket, game, 'lost', 'timeout', 'You didn\'t escape before the block time limit!');
} catch (e) {
console.error(`checkGamesTimeout: handleGameOver failed for ${socketId}:`, e.message);
}
}
}
}
});
}
// Payment system handlers (placeholder for compatibility)
@@ -1686,6 +1949,11 @@ class SocketHandlers {
console.log('🛑 SocketHandlers shutting down...');
}
// Let work that crossed the synchronous admission boundary reach a stable state before
// freezing the settlement set. This includes a block-timeout pass paused on an earlier
// game's durable completion.
await this.drainShutdownProducers();
// Settle terminal solo runs while the DB pool is still open. This is idempotent if the
// top-level graceful-shutdown path already drained them.
if (this.gameManager?.shutdown) {
+59 -29
View File
@@ -10,8 +10,6 @@
* to seamlessly resume their session when they reconnect.
*/
const { normalizeError } = require('../utils/errors');
class SuspendedGameManager {
constructor({ debugManager, activeGames, cleanupTimeoutMs = 300000 }) {
this.debugManager = debugManager;
@@ -70,8 +68,14 @@ class SuspendedGameManager {
this.suspendedGames.set(dbUserId, suspendedState);
this.stats.gamesSuspended++;
// Set cleanup timer
this._scheduleCleanup(dbUserId);
// Ordinary network disconnects retain the existing bounded in-memory grace period.
// A durable restart snapshot is different: PostgreSQL is the authority and the process
// must keep the reconstructed object available until its owner reconnects (or an operator
// explicitly resolves the active row). Expiring only the Map entry would strand the
// durable active game and silently make it unresumable.
if (additionalState.durableRestartSnapshot !== true) {
this._scheduleCleanup(dbUserId);
}
if (this.debugManager?.CONSOLE_LOGGING) {
console.log(`🔄 [SuspendedGameManager] Suspended game ${game.id} for user ${dbUserId}`);
@@ -106,17 +110,14 @@ class SuspendedGameManager {
* @param {number} dbUserId - Database user ID
* @param {string} newSocketId - The new socket ID
* @param {Object} newUser - The new User object
* @returns {Object|null} The restored game or null if no suspended game
* @returns {Promise<Object|null>} The restored game or null if no suspended game
*/
restoreGame(dbUserId, newSocketId, newUser) {
async restoreGame(dbUserId, newSocketId, newUser) {
const suspended = this.suspendedGames.get(dbUserId);
if (!suspended) {
return null;
}
// Cancel cleanup timer
this._cancelCleanup(dbUserId);
const game = suspended.game;
// A settlement retry may have committed after disconnect but before reconnect. Never
@@ -126,6 +127,55 @@ class SuspendedGameManager {
return null;
}
// Claim a durable restart snapshot and realign the socket in one PostgreSQL statement
// before exposing the game as playable. If the row is no longer exactly active for this
// game/user/seed, no snapshot is consumed and no in-memory state is revived.
const db = game.db || suspended.db || null;
if (suspended.durableRestartSnapshot === true) {
if (!db || !game.dbId || !game.id) {
throw new Error('Durable solo restart snapshot is missing its database identity');
}
const claimed = await db.query(`
WITH valid_game AS MATERIALIZED (
SELECT id
FROM games
WHERE id = $1 AND user_id = $2 AND dungeon_seed = $3
AND status = 'active' AND completed_at IS NULL
FOR UPDATE
), consumed AS (
DELETE FROM solo_restart_snapshots s
USING valid_game g
WHERE s.game_id = g.id AND s.user_id = $2 AND s.snapshot_version = $4
RETURNING s.game_id
)
UPDATE games
SET socket_id = $5
WHERE id = $1 AND EXISTS (SELECT 1 FROM consumed)
RETURNING id
`, [game.dbId, dbUserId, game.id, suspended.snapshotVersion, newSocketId]);
if (claimed.rowCount !== 1) {
const error = new Error('Durable solo restart snapshot could not be claimed');
error.code = 'SOLO_RESTART_SNAPSHOT_CLAIM_FAILED';
throw error;
}
} else if (db && game.id) {
const aligned = await db.query(
`UPDATE games SET socket_id = $1
WHERE dungeon_seed = $2 AND status = 'active' AND completed_at IS NULL
RETURNING id`,
[newSocketId, game.id]
);
if (aligned.rowCount !== 1) {
const error = new Error('Suspended solo game is no longer active');
error.code = 'SUSPENDED_GAME_REALIGN_FAILED';
throw error;
}
}
// Only mutate the cache/object after the durable claim succeeds. A transient database
// failure therefore leaves the suspended entry intact for a later reconnect attempt.
this._cancelCleanup(dbUserId);
// Update game with new socket ID
game.socketId = newSocketId;
@@ -135,26 +185,6 @@ class SuspendedGameManager {
if (game.userId == null && game.dbUserId != null) game.userId = game.dbUserId;
if (game.dbUserId == null && game.userId != null) game.dbUserId = game.userId;
// Align the persisted game row's socket_id to the reconnecting socket so
// downstream row-matching (game-over UPDATE, completion) stays consistent.
// Fire-and-forget so restoreGame remains synchronous for its callers.
try {
const db = game.db || suspended.db || null;
if (db && game.id) {
Promise.resolve(
db.query('UPDATE games SET socket_id = $1 WHERE dungeon_seed = $2', [newSocketId, game.id])
).catch((err) => {
if (this.debugManager?.CONSOLE_LOGGING) {
console.warn(`[SuspendedGameManager] socket_id realign failed for game ${game.id}: ${err.message}`);
}
});
}
} catch (err) {
if (this.debugManager?.CONSOLE_LOGGING) {
console.warn(`[SuspendedGameManager] socket_id realign threw for game ${game.id}: ${normalizeError(err).message}`);
}
}
// Update the user reference if provided
if (newUser) {
game.user = newUser;
+1 -1
View File
@@ -15,7 +15,7 @@ const { Pool } = require('pg');
const DatabaseManager = require('../db/databaseManager');
const CONFIRM_PREFIX = 'MIGRATE_DISPOSABLE_CLONE:';
const MINIMUM_RELEASE_MIGRATION = 42;
const MINIMUM_RELEASE_MIGRATION = 43;
const MIGRATIONS_PATH = path.join(__dirname, '..', 'migrations');
const DATABASE_NAME = /^[a-z][a-z0-9_]{2,62}$/;
const DATABASE_USER = /^[a-z_][a-z0-9_]{0,62}$/;
+2 -1
View File
@@ -34,7 +34,8 @@ const REQUIRED_MIGRATIONS = Object.freeze([
'039_unresolved_solo_payout_review.sql',
'040_paid_match_entropy_precommit.sql',
'041_financial_event_outbox.sql',
'042_immutable_financial_event_snapshots.sql'
'042_immutable_financial_event_snapshots.sql',
'043_durable_solo_restart_snapshots.sql'
]);
const SCENARIOS = Object.freeze({
+5 -5
View File
@@ -161,11 +161,11 @@ describe('disposable clone migration execution', () => {
logSpy.mockRestore();
});
test('discovers the contiguous repository manifest through migration 42 dynamically', () => {
test('discovers the contiguous repository manifest through migration 43 dynamically', () => {
const manifest = discoverMigrationManifest();
expect(manifest.latestOrdinal).toBeGreaterThanOrEqual(MINIMUM_RELEASE_MIGRATION);
expect(manifest.latestFilename).toBe('042_immutable_financial_event_snapshots.sql');
expect(manifest.files).toHaveLength(42);
expect(manifest.latestFilename).toBe('043_durable_solo_restart_snapshots.sql');
expect(manifest.files).toHaveLength(43);
});
test('runs the normal ordered migration implementation and proves the exact ledger', async () => {
@@ -185,9 +185,9 @@ describe('disposable clone migration execution', () => {
});
expect(pool.transactions).toEqual(result.files);
expect(result.latestFilename).toBe('042_immutable_financial_event_snapshots.sql');
expect(result.latestFilename).toBe('043_durable_solo_restart_snapshots.sql');
expect(output).toContain('clone_migration_status=ok');
expect(output).toContain('latest_migration=042_immutable_financial_event_snapshots.sql');
expect(output).toContain('latest_migration=043_durable_solo_restart_snapshots.sql');
expect(output.join('\n')).not.toContain(secret);
expect(pool.config).toEqual(expect.objectContaining({
database: env.DB_NAME,
+1
View File
@@ -101,6 +101,7 @@ describe('commerce disclosure and paid-action policy', () => {
expect(disclosure.software.warrantyNotice).toContain('AS IS');
expect(disclosure.software.legalAdviceNotice).toContain('not legal advice');
expect(disclosure.software.thirdPartyNotice).toContain('solely responsible');
expect(disclosure.software.thirdPartyNotice).toContain('does not make that deployment a Such Software service');
expect(disclosure.software.thirdPartyNotice).toContain('does not operate, supervise, endorse, or accept responsibility');
});
+56
View File
@@ -0,0 +1,56 @@
'use strict';
const fs = require('fs');
const path = require('path');
const deployFile = name => fs.readFileSync(
path.join(__dirname, '..', 'scripts', 'deploy', name),
'utf8'
);
const directiveValues = (unit, directive) => unit
.split(/\r?\n/)
.filter(line => line.startsWith(`${directive}=`))
.flatMap(line => line.slice(directive.length + 1).trim().split(/\s+/));
describe('deployment unit wallet boundaries', () => {
test.each([
['monerogue', 'monerogue-wallet.service', 'monero-wallet-rpc.service'],
['wownerogue', 'wownerogue-wallet.service', 'wownero-wallet-rpc.service']
])('%s application starts only with its dedicated wallet unit', (instance, dedicated, legacy) => {
const unit = deployFile(`${instance}.service`);
expect(directiveValues(unit, 'After')).toContain(dedicated);
expect(directiveValues(unit, 'Requires')).toContain(dedicated);
expect(directiveValues(unit, 'Wants')).not.toContain(legacy);
expect(directiveValues(unit, 'Requires')).not.toContain(legacy);
expect(unit).toContain(`EnvironmentFile=/etc/${instance}/app.env`);
expect(unit).toContain(`EnvironmentFile=/etc/${instance}/wallet-rpc.env`);
for (const unsafeLoaderVariable of [
'BASH_ENV',
'PATH',
'LD_PRELOAD',
'LD_AUDIT',
'NODE_OPTIONS',
'OPENSSL_CONF',
'DATABASE_URL',
'PGPASSWORD',
'NPM_CONFIG_NODE_OPTIONS',
'npm_config_node_options'
]) {
expect(directiveValues(unit, 'UnsetEnvironment')).toContain(unsafeLoaderVariable);
}
});
test('firewall ordering covers dedicated wallets and retained rollback units', () => {
const unit = deployFile('wowngeon-firewall.service');
for (const service of [
'monerogue-wallet.service',
'wownerogue-wallet.service',
'monero-wallet-rpc.service',
'wownero-wallet-rpc.service'
]) {
expect(unit).toContain(service);
}
});
});
+115
View File
@@ -0,0 +1,115 @@
const crypto = require('crypto');
const DungeonGenerator = require('../src/game/dungeon');
const Game = require('../src/game/game');
const { attachDungeonVerification } = require('../src/game/fairnessVerifier');
const LEGACY_OVERLAP_SEED = '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9';
const LEGACY_OVERLAP_FINGERPRINT = '9fd69a09c5f7a233b03538142994dd932afb4f2d6cfa19baab7041d5303edb50';
function objectiveKeys(dungeon) {
return [dungeon.entrance, dungeon.exit, dungeon.treasure].map(point => point.join(','));
}
describe('versioned playable dungeon objectives', () => {
const saved = {};
beforeAll(() => {
for (const key of ['DIFFICULTY_PRESET', 'DUNGEON_LEVELS', 'GAME_MODE', 'PAYMENTS_ENABLED']) {
saved[key] = process.env[key];
}
process.env.DIFFICULTY_PRESET = 'easy';
process.env.DUNGEON_LEVELS = '1';
process.env.GAME_MODE = 'FREE';
process.env.PAYMENTS_ENABLED = 'false';
});
afterAll(() => {
for (const [key, value] of Object.entries(saved)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
test('v2 deterministically retries a known v1 entrance/exit overlap', () => {
const v1 = DungeonGenerator.regenerateFromSeed(LEGACY_OVERLAP_SEED, 'XMR', {
width: 30,
height: 15,
generatorVersion: 'dungeon-generator-v1'
});
const v2 = DungeonGenerator.regenerateFromSeed(LEGACY_OVERLAP_SEED, 'XMR', {
width: 30,
height: 15,
generatorVersion: 'dungeon-generator-v2'
});
expect(v1.entrance).toEqual(v1.exit);
expect([v1.entrance, v1.exit, v1.treasure]).toEqual([[17, 8], [17, 8], [16, 8]]);
expect(DungeonGenerator.layoutFingerprint(v1)).toBe(LEGACY_OVERLAP_FINGERPRINT);
expect(new Set(objectiveKeys(v1)).size).toBeLessThan(3);
expect(new Set(objectiveKeys(v2)).size).toBe(3);
expect(DungeonGenerator.hasDistinctReachableObjectives(v2, {
primaryFloor: "'1",
secondaryFloor: "'2"
})).toBe(true);
const repeated = DungeonGenerator.regenerateFromSeed(LEGACY_OVERLAP_SEED, 'XMR', {
width: 30,
height: 15,
generatorVersion: 'dungeon-generator-v2'
});
expect(DungeonGenerator.layoutFingerprint(repeated)).toBe(DungeonGenerator.layoutFingerprint(v2));
});
test('easy-profile v2 samples always have distinct reachable objectives', () => {
for (let i = 0; i < 100; i += 1) {
const seed = crypto.createHash('sha256').update(`objective-sample-${i}`).digest('hex');
const dungeon = DungeonGenerator.regenerateFromSeed(seed, 'XMR', {
width: 30,
height: 15,
generatorVersion: DungeonGenerator.GENERATOR_VERSION
});
expect(new Set(objectiveKeys(dungeon)).size).toBe(3);
expect(DungeonGenerator.hasDistinctReachableObjectives(dungeon, {
primaryFloor: "'1",
secondaryFloor: "'2"
})).toBe(true);
}
});
test('historical v1 fingerprints remain verifiable after v2 becomes current', () => {
const legacy = DungeonGenerator.regenerateFromSeed(LEGACY_OVERLAP_SEED, 'XMR', {
width: 30,
height: 15,
generatorVersion: 'dungeon-generator-v1'
});
expect(DungeonGenerator.layoutFingerprint(legacy)).toBe(LEGACY_OVERLAP_FINGERPRINT);
const result = attachDungeonVerification({ valid: true }, {
effectiveSeed: LEGACY_OVERLAP_SEED,
proofContext: {
cryptoType: 'XMR',
maxDepth: 1,
generatorVersion: 'dungeon-generator-v1',
generationOptions: { width: 30, height: 15 }
},
expectedFingerprint: LEGACY_OVERLAP_FINGERPRINT,
generatorVersion: 'dungeon-generator-v1'
});
expect(result.valid).toBe(true);
expect(result.generatorVersion).toBe('dungeon-generator-v1');
expect(result.layoutMatches).toBe(true);
});
test('public game options cannot select a historical generator', () => {
const game = Game.createStandardGame('public-socket', { id: 'public-socket' }, {
cryptoType: 'XMR',
width: 30,
height: 15,
generatorVersion: 'dungeon-generator-v1'
});
expect(game.generatorVersion).toBe(DungeonGenerator.GENERATOR_VERSION);
expect(game.gameConfig).not.toHaveProperty('generatorVersion');
expect(game.gameProof.generatorVersion).toBe(DungeonGenerator.GENERATOR_VERSION);
expect(game.gameProof.context.generatorVersion).toBe(DungeonGenerator.GENERATOR_VERSION);
});
});
+2 -2
View File
@@ -20,9 +20,9 @@ describe('deployment financial-audit contract', () => {
/FROM payouts\s+WHERE status IS NULL OR status NOT IN \('recorded', 'completed'\)/
);
expect(audit).toContain("games WHERE status IS NULL OR status NOT IN ('won', 'lost', 'expired')");
expect(audit).toContain("MAX(filename) = '042_immutable_financial_event_snapshots.sql'");
expect(audit).toContain("MAX(filename) = '043_durable_solo_restart_snapshots.sql'");
expect(audit.match(/status IS NULL OR status NOT IN \('recorded', 'completed'\)/g))
.toHaveLength(2);
expect(audit).toContain('COUNT(*) = 42');
expect(audit).toContain('COUNT(*) = 43');
});
});
+1
View File
@@ -45,6 +45,7 @@ describe('production disclosure pages', () => {
const html = renderTerms(disclosure());
expect(html).toContain('Independent deployment');
expect(html).toContain('solely responsible for its deployment');
expect(html).toContain('does not make that deployment a Such Software service');
expect(html).toContain('does not operate, supervise, endorse, or accept responsibility');
expect(html).toContain('copyright and permission notice');
expect(html).toContain('AS IS');
+32
View File
@@ -8,6 +8,7 @@
*/
const Game = require('../src/game/game');
const DungeonGenerator = require('../src/game/dungeon');
function openGame() {
// Default-size dungeon so the digger generates validly; we then override the map and
@@ -23,6 +24,37 @@ function openGame() {
}
describe('Monster collision with multi-step movement', () => {
test('two-room fallback spawns the monster deterministically on a distant passable tile', () => {
const FL = "'1";
const map = Array.from({ length: 7 }, (_, y) => Array.from({ length: 7 }, (_, x) =>
(x === 0 || y === 0 || x === 6 || y === 6) ? '#' : FL));
const dungeon = {
map,
rooms: [],
entrance: [1, 1],
exit: [5, 5],
treasure: [4, 4],
torches: []
};
const generatedFingerprint = DungeonGenerator.layoutFingerprint(dungeon);
const generate = jest.spyOn(DungeonGenerator, 'generate').mockReturnValue(dungeon);
const previousLevels = process.env.DUNGEON_LEVELS;
process.env.DUNGEON_LEVELS = '1';
try {
const game = new Game('fallback-spawn', { id: 1 }, { width: 7, height: 7, cryptoType: 'XMR' });
expect(game.monster.getState()).toMatchObject({ x: 5, y: 4 });
expect(game.dungeon.map[game.monster.y][game.monster.x]).toBe(FL);
expect(game.monster.isAt(game.player.x, game.player.y)).toBe(false);
expect(game.monster.isAt(game.dungeon.exit[0], game.dungeon.exit[1])).toBe(false);
expect(game.monster.isAt(game.dungeon.treasure[0], game.dungeon.treasure[1])).toBe(false);
expect(DungeonGenerator.layoutFingerprint(game.dungeon)).toBe(generatedFingerprint);
} finally {
generate.mockRestore();
if (previousLevels === undefined) delete process.env.DUNGEON_LEVELS;
else process.env.DUNGEON_LEVELS = previousLevels;
}
});
test('2-step monster adjacent to the player catches it (no phasing through)', () => {
const game = openGame();
game.difficultyConfig.monster.movesPerPlayerMove = 2;
+2 -2
View File
@@ -6,14 +6,14 @@ function makeDb(initialGames, initialCredits = 0) {
const refundReasons = new Set();
const query = jest.fn(async (sql, params = []) => {
if (/SELECT id FROM games WHERE status = 'active'/i.test(sql)) {
if (/SELECT g\.id[\s\S]*FROM games g[\s\S]*g\.status = 'active'/i.test(sql)) {
return {
rows: Array.from(games.values())
.filter(game => game.status === 'active')
.map(game => ({ id: game.id }))
};
}
if (/FROM games WHERE id = \$1 FOR UPDATE/i.test(sql)) {
if (/FROM games g[\s\S]*g\.id = \$1[\s\S]*FOR UPDATE/i.test(sql)) {
const game = games.get(params[0]);
return { rows: game ? [{ ...game }] : [] };
}
+2 -1
View File
@@ -619,6 +619,7 @@ describe('MatchQueue durable freeze/refund transactions', () => {
const order = [];
const context = {
beginShutdown: jest.fn(),
drainShutdownProducers: jest.fn(async () => { order.push('producers'); }),
debugManager: { CONSOLE_LOGGING: false },
tavernManager: null,
matchScheduler: { shutdown: jest.fn(async () => { order.push('scheduler'); }) },
@@ -637,6 +638,6 @@ describe('MatchQueue durable freeze/refund transactions', () => {
await SocketHandlers.prototype.shutdown.call(context);
expect(order).toEqual(['scheduler', 'queue', 'manager']);
expect(order).toEqual(['producers', 'scheduler', 'queue', 'manager']);
});
});
+494
View File
@@ -0,0 +1,494 @@
const fs = require('fs');
const path = require('path');
const Game = require('../src/game/game');
const GameManager = require('../src/game/gameManager');
const SuspendedGameManager = require('../src/network/suspendedGameManager');
const SocketHandlers = require('../src/network/socketHandlers');
const PaymentHandlers = require('../src/network/paymentHandlers');
const { hashSeed } = require('../src/game/provablyFair');
const {
SNAPSHOT_VERSION,
captureSoloRestartSnapshot,
restoreSoloRestartSnapshot
} = require('../src/game/soloRestartSnapshot');
function makeGame(generatorVersion = null) {
const user = { id: 'socket-old', joinGame: jest.fn(), endGame: jest.fn() };
const options = {
cryptoType: 'XMR',
width: 30,
height: 15,
...(generatorVersion ? {
fairnessProof: {
serverSeed: '0'.repeat(64),
clientSeed: '',
commitment: hashSeed('0'.repeat(64))
}
} : {})
};
const game = generatorVersion
? Game.createRestoredStandardGame('socket-old', user, options, generatorVersion)
: Game.createStandardGame('socket-old', user, options);
game.dbId = 91;
game.userId = 44;
game.dbUserId = 44;
game.db = { query: jest.fn() };
game.blockRec = 12345;
game.startBlock = 12345;
game.moveCount = 7;
game.startedAt = Date.now() - 5000;
game.player.hasTreasure = true;
game.dungeon.treasure = null;
const passable = new Set([game.gameConfig.primaryFloor, game.gameConfig.secondaryFloor, 0, '>', '$M']);
if (!passable.has(game.dungeon.map?.[game.monster.y]?.[game.monster.x])
|| (game.monster.x === game.player.x && game.monster.y === game.player.y)) {
outer: for (let y = 0; y < game.dungeon.map.length; y += 1) {
for (let x = 0; x < game.dungeon.map[y].length; x += 1) {
if (passable.has(game.dungeon.map[y][x])
&& (x !== game.player.x || y !== game.player.y)
&& (!game.dungeon.exit || x !== game.dungeon.exit[0] || y !== game.dungeon.exit[1])) {
game.monster.moveTo(x, y);
break outer;
}
}
}
}
game.monster.lastKnownPlayerX = game.player.x;
game.monster.lastKnownPlayerY = game.player.y;
game.seededRNG();
game.seededRNG();
return game;
}
function rowFor(game, snapshot, overrides = {}) {
return {
game_id: snapshot.gameId,
user_id: snapshot.userId,
dungeon_seed: snapshot.dungeonSeed,
joined_game_id: snapshot.gameId,
game_user_id: snapshot.userId,
game_dungeon_seed: snapshot.dungeonSeed,
snapshot_version: snapshot.snapshotVersion,
original_socket_id: snapshot.originalSocketId,
payment_monitoring_active: snapshot.paymentMonitoringActive,
state: snapshot.state,
created_at: new Date(),
status: 'active',
completed_at: null,
game_mode: 'PAID_CREDITS',
start_block_height: game.startBlock,
proof_version: game.gameProof.proofVersion,
fairness_offer_id: game.gameProof.offerId,
fairness_offer_issued_at: game.gameProof.offerIssuedAt,
proof_commitment: game.gameProof.commitment,
server_seed: game.gameProof.serverSeed,
client_seed: game.gameProof.clientSeed,
effective_seed: game.gameProof.seed,
layout_fingerprint: game.gameProof.layoutFingerprint,
layout_fingerprints: game.gameProof.layoutFingerprints,
generator_version: game.gameProof.generatorVersion,
proof_context: game.gameProof.context,
...overrides
};
}
describe('durable solo restart snapshots', () => {
const savedEnv = {};
beforeAll(() => {
for (const key of ['DIFFICULTY_PRESET', 'DUNGEON_LEVELS', 'GAME_MODE', 'PAYMENTS_ENABLED']) {
savedEnv[key] = process.env[key];
}
process.env.DIFFICULTY_PRESET = 'easy';
process.env.DUNGEON_LEVELS = '1';
process.env.GAME_MODE = 'FREE';
process.env.PAYMENTS_ENABLED = 'false';
});
afterAll(() => {
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
test('round-trips exact runtime progress and the next deterministic RNG draw', () => {
const original = makeGame();
const snapshot = captureSoloRestartSnapshot(original, { paymentMonitoringActive: true });
const db = { query: jest.fn() };
const restored = restoreSoloRestartSnapshot(rowFor(original, snapshot), { db });
expect(snapshot.snapshotVersion).toBe(SNAPSHOT_VERSION);
const serializedState = JSON.stringify(snapshot.state);
expect(serializedState).not.toContain('serverSeed');
expect(serializedState).not.toContain('fairnessProof');
expect(serializedState).not.toContain(original.gameProof.serverSeed);
expect(serializedState).not.toContain(original.gameProof.seed);
expect(restored).toMatchObject({
userId: 44,
originalSocketId: 'socket-old',
paymentMonitoringActive: true,
snapshotVersion: SNAPSHOT_VERSION
});
expect(restored.game).toMatchObject({
id: original.id,
dbId: 91,
userId: 44,
dbUserId: 44,
moveCount: 7,
blockRec: 12345,
gameState: 'active'
});
expect(restored.game.player.getState()).toEqual(original.player.getState());
expect(restored.game.monster.getState()).toEqual(original.monster.getState());
expect(restored.game.monster.lastKnownPlayerX).toBe(original.monster.lastKnownPlayerX);
expect(restored.game.monster.lastKnownPlayerY).toBe(original.monster.lastKnownPlayerY);
expect(restored.game.seededRNG()).toBe(original.seededRNG());
expect(restored.game.gameProof).toMatchObject({
gameId: original.id,
commitment: original.gameProof.commitment,
seed: original.gameProof.seed
});
});
test.each(['dungeon-generator-v1', 'dungeon-generator-v2'])(
'restores a durable %s run with its persisted fingerprints and next RNG draw',
(generatorVersion) => {
const original = makeGame(generatorVersion);
const snapshot = captureSoloRestartSnapshot(original);
const expectedManifest = original.gameProof.layoutFingerprints;
const expectedFingerprint = original.gameProof.layoutFingerprint;
const restored = restoreSoloRestartSnapshot(rowFor(original, snapshot), {
db: { query: jest.fn() }
});
expect(restored.game.generatorVersion).toBe(generatorVersion);
expect(restored.game.gameProof.generatorVersion).toBe(generatorVersion);
expect(restored.game.gameProof.layoutFingerprints).toEqual(expectedManifest);
expect(restored.game.gameProof.layoutFingerprint).toBe(expectedFingerprint);
expect(restored.game.seededRNG()).toBe(original.seededRNG());
}
);
test('never captures or restores terminal/settlement-drifted state', () => {
const game = makeGame();
game.settlementPending = true;
expect(() => captureSoloRestartSnapshot(game)).toThrow(/terminal or settlement-pending/);
game.settlementPending = false;
const snapshot = captureSoloRestartSnapshot(game);
expect(() => restoreSoloRestartSnapshot(rowFor(game, snapshot, { status: 'won' }), {
db: { query: jest.fn() }
})).toThrow(/active and nonterminal/);
expect(() => restoreSoloRestartSnapshot(rowFor(game, snapshot, {
proof_commitment: '0'.repeat(64)
}), { db: { query: jest.fn() } })).toThrow(/commitment/);
expect(() => restoreSoloRestartSnapshot(rowFor(game, snapshot, {
game_user_id: 45
}), { db: { query: jest.fn() } })).toThrow(/exactly join/);
});
test('normalizes PostgreSQL fairness timestamps back to the numeric proof contract', () => {
const game = makeGame();
const snapshot = captureSoloRestartSnapshot(game);
const issuedAt = new Date('2026-07-22T12:34:56.789Z');
const restored = restoreSoloRestartSnapshot(rowFor(game, snapshot, {
fairness_offer_issued_at: issuedAt
}), { db: { query: jest.fn() } });
expect(restored.game.gameProof.offerIssuedAt).toBe(issuedAt.getTime());
});
test('rejects impossible active geometry and future runtime timestamps', () => {
const game = makeGame();
const snapshot = captureSoloRestartSnapshot(game);
const overlapping = JSON.parse(JSON.stringify(snapshot.state));
overlapping.monster.x = overlapping.player.x;
overlapping.monster.y = overlapping.player.y;
expect(() => restoreSoloRestartSnapshot(rowFor(game, snapshot, { state: overlapping }), {
db: { query: jest.fn() }
})).toThrow(/overlap/);
const future = JSON.parse(JSON.stringify(snapshot.state));
future.startedAt = Date.now() + 120000;
expect(() => restoreSoloRestartSnapshot(rowFor(game, snapshot, { state: future }), {
db: { query: jest.fn() }
})).toThrow(/startedAt/);
});
test('durable claim is atomic, timerless, and only exposes the game after PostgreSQL accepts it', async () => {
jest.useFakeTimers();
const activeGames = new Map();
const db = { query: jest.fn().mockResolvedValue({ rows: [{ id: 91 }], rowCount: 1 }) };
const game = makeGame();
game.db = db;
const suspended = new SuspendedGameManager({
debugManager: { CONSOLE_LOGGING: false },
activeGames,
cleanupTimeoutMs: 1
});
suspended.suspendGame(44, 'socket-old', game, {
durableRestartSnapshot: true,
snapshotVersion: SNAPSHOT_VERSION,
paymentMonitoringActive: true
});
expect(jest.getTimerCount()).toBe(0);
const newUser = { joinGame: jest.fn() };
const restored = await suspended.restoreGame(44, 'socket-new', newUser);
expect(db.query).toHaveBeenCalledWith(expect.stringMatching(/DELETE FROM solo_restart_snapshots/),
[91, 44, game.id, SNAPSHOT_VERSION, 'socket-new']);
expect(restored.game).toBe(game);
expect(activeGames.get('socket-new')).toBe(game);
expect(suspended.hasSuspendedGame(44)).toBe(false);
expect(newUser.joinGame).toHaveBeenCalledWith(game);
suspended.cleanup();
jest.useRealTimers();
});
test('failed durable claim leaves the game suspended and never makes it active', async () => {
const activeGames = new Map();
const game = makeGame();
game.db = { query: jest.fn().mockResolvedValue({ rows: [], rowCount: 0 }) };
const suspended = new SuspendedGameManager({
debugManager: { CONSOLE_LOGGING: false },
activeGames
});
suspended.suspendGame(44, 'socket-old', game, {
durableRestartSnapshot: true,
snapshotVersion: SNAPSHOT_VERSION
});
await expect(suspended.restoreGame(44, 'socket-new', { joinGame: jest.fn() }))
.rejects.toMatchObject({ code: 'SOLO_RESTART_SNAPSHOT_CLAIM_FAILED' });
expect(suspended.hasSuspendedGame(44)).toBe(true);
expect(activeGames.size).toBe(0);
expect(game.socketId).toBe('socket-old');
suspended.cleanup();
});
test('shutdown persistence uses one transaction and exact active game/user/seed guards', async () => {
const game = makeGame();
const client = { query: jest.fn().mockResolvedValue({ rows: [{ game_id: 91 }], rowCount: 1 }) };
const db = { withTransaction: jest.fn(async callback => callback(client)) };
const context = Object.assign(Object.create(SocketHandlers.prototype), {
_admissionsInFlight: new Set(),
gameModeManager: { db },
activeGames: new Map([['socket-old', game]]),
paymentHandlers: { hasActiveMonitoring: jest.fn().mockReturnValue(true) },
suspendedGameManager: { suspendedGames: new Map() }
});
const result = await SocketHandlers.prototype.persistSoloRestartSnapshots.call(context);
expect(result).toEqual({ captured: 1 });
expect(db.withTransaction).toHaveBeenCalledTimes(1);
const [sql, params] = client.query.mock.calls[0];
expect(sql).toMatch(/g\.id = \$1 AND g\.user_id = \$2 AND g\.dungeon_seed = \$3/);
expect(sql).toMatch(/g\.status = 'active' AND g\.completed_at IS NULL/);
expect(params.slice(0, 6)).toEqual([91, 44, game.id, SNAPSHOT_VERSION, 'socket-old', true]);
expect(JSON.parse(params[6])).toMatchObject({ version: SNAPSHOT_VERSION, dbId: 91, userId: 44 });
});
test('startup rehydration validates every row before placing it in the timerless cache', async () => {
jest.useFakeTimers();
const game = makeGame();
const snapshot = captureSoloRestartSnapshot(game);
const row = rowFor(game, snapshot);
const db = { query: jest.fn().mockResolvedValue({ rows: [row], rowCount: 1 }) };
const suspendedGameManager = new SuspendedGameManager({
debugManager: { CONSOLE_LOGGING: false },
activeGames: new Map(),
cleanupTimeoutMs: 1
});
const context = Object.assign(Object.create(SocketHandlers.prototype), {
gameModeManager: { db },
suspendedGameManager
});
const result = await SocketHandlers.prototype.rehydrateSoloRestartSnapshots.call(context);
expect(result).toEqual({ restored: 1 });
expect(suspendedGameManager.hasSuspendedGame(44)).toBe(true);
expect(suspendedGameManager.getSuspendedState(44)).toMatchObject({
durableRestartSnapshot: true,
snapshotVersion: SNAPSHOT_VERSION
});
expect(jest.getTimerCount()).toBe(0);
suspendedGameManager.cleanup();
jest.useRealTimers();
});
test('shutdown closes every admission layer synchronously and drains work already inside', async () => {
let release;
const inFlight = new Promise(resolve => { release = resolve; });
const context = Object.assign(Object.create(SocketHandlers.prototype), {
_isShuttingDown: false,
_admissionsInFlight: new Set(),
gameManager: { beginShutdown: jest.fn() },
gameModeManager: { beginGameAdmissionShutdown: jest.fn() },
paymentHandlers: { beginShutdown: jest.fn() },
movementManager: { shutdown: jest.fn() },
broadcastManager: { sendStatusUpdate: jest.fn() }
});
const socket = { id: 'socket-old', emit: jest.fn() };
const existing = context._runAdmission(socket, 'solo_start', () => inFlight);
context.beginShutdown();
const rejectedTask = jest.fn();
await context._runAdmission(socket, 'solo_start', rejectedTask);
let drained = false;
const drain = context.drainAdmissionHandlers().then(() => { drained = true; });
await Promise.resolve();
expect(context._isShuttingDown).toBe(true);
expect(context.gameManager.beginShutdown).toHaveBeenCalledTimes(1);
expect(context.gameModeManager.beginGameAdmissionShutdown).toHaveBeenCalledTimes(1);
expect(context.paymentHandlers.beginShutdown).toHaveBeenCalledTimes(1);
expect(context.movementManager.shutdown).toHaveBeenCalledTimes(1);
expect(rejectedTask).not.toHaveBeenCalled();
expect(socket.emit).toHaveBeenCalledWith('server_restarting', expect.objectContaining({
code: 'SERVER_SHUTTING_DOWN'
}));
expect(drained).toBe(false);
release();
await existing;
await drain;
expect(drained).toBe(true);
});
test('GameManager is a non-bypassable last-line creation gate', async () => {
const manager = new GameManager({
activeGames: new Map(),
io: { to: () => ({ emit: jest.fn() }) },
broadcastManager: {},
debugManager: { CONSOLE_LOGGING: false },
gameModeManager: null
});
manager.beginShutdown();
await expect(manager.createGameForUser({ id: 'socket', joinGame: jest.fn() }))
.rejects.toMatchObject({ code: 'SERVER_SHUTTING_DOWN' });
});
test('PaymentHandlers internally refuses indirect invoice creation after the barrier', async () => {
const emitted = [];
const paymentHandlers = new PaymentHandlers({
io: { to: id => ({ emit: (event, payload) => emitted.push({ id, event, payload }) }) },
gameModeManager: { createPaymentRequest: jest.fn() },
walletService: {},
debugManager: { CONSOLE_LOGGING: false },
queueManager: {},
broadcastManager: {},
sessionManager: null
});
const socket = { id: 'socket-old' };
paymentHandlers.beginShutdown();
await paymentHandlers.createAndShowPaymentRequest(socket, {});
await paymentHandlers.handlePaymentRequest(socket, {});
expect(paymentHandlers.gameModeManager.createPaymentRequest).not.toHaveBeenCalled();
expect(emitted).toEqual([
expect.objectContaining({ event: 'payment_error', payload: expect.objectContaining({ code: 'SERVER_SHUTTING_DOWN' }) }),
expect.objectContaining({ event: 'payment_error', payload: expect.objectContaining({ code: 'SERVER_SHUTTING_DOWN' }) })
]);
paymentHandlers.dispose();
});
test('block-driven queue starts and timeouts are frozen after beginShutdown', async () => {
const context = Object.assign(Object.create(SocketHandlers.prototype), {
_isShuttingDown: true,
queueHandler: { startGamesForWaiting: jest.fn() },
activeGames: new Map([['socket', { startedAt: 0 }]])
});
await expect(context.startGamesForWaiting(123)).resolves.toBeNull();
await expect(context.checkGamesTimeout(123)).resolves.toBeUndefined();
expect(context.queueHandler.startGamesForWaiting).not.toHaveBeenCalled();
});
test('shutdown drains a paused timeout pass before settlement drain and snapshots later games', async () => {
const first = makeGame();
first.socketId = 'socket-first';
const second = makeGame();
second.socketId = 'socket-second';
second.dbId = 92;
second.userId = 45;
second.dbUserId = 45;
let releaseFirst;
const firstSettlement = new Promise(resolve => { releaseFirst = resolve; });
const activeGames = new Map([
['socket-first', first],
['socket-second', second]
]);
const client = { query: jest.fn().mockResolvedValue({ rows: [{ game_id: 92 }], rowCount: 1 }) };
const db = { withTransaction: jest.fn(async callback => callback(client)) };
const handleGameOver = jest.fn(async (socket, game) => {
game.settlementPending = true;
await firstSettlement;
game.settlementPending = false;
game.settlementCommitted = true;
activeGames.delete(socket.id);
return { success: true };
});
const context = Object.assign(Object.create(SocketHandlers.prototype), {
_isShuttingDown: false,
_admissionsInFlight: new Set(),
activeGames,
handleGameOver,
connectionHandler: {
getUserBySocket: jest.fn(socketId => ({
id: socketId,
blockRec: 10
}))
},
debugManager: { CONSOLE_LOGGING: false },
gameManager: {
beginShutdown: jest.fn(),
drainAdmissions: jest.fn().mockResolvedValue({ pending: 0 })
},
gameModeManager: {
db,
beginGameAdmissionShutdown: jest.fn(),
drainGameStartAdmissions: jest.fn().mockResolvedValue({ pending: 0 })
},
paymentHandlers: {
beginShutdown: jest.fn(),
drainShutdownWork: jest.fn().mockResolvedValue({ pending: 0 }),
hasActiveMonitoring: jest.fn().mockReturnValue(false)
},
movementManager: { shutdown: jest.fn() },
suspendedGameManager: { suspendedGames: new Map() }
});
const timeoutPass = context.checkGamesTimeout(11);
expect(handleGameOver).toHaveBeenCalledTimes(1);
context.beginShutdown();
let producersDrained = false;
const drain = context.drainShutdownProducers().then(() => { producersDrained = true; });
await Promise.resolve();
expect(producersDrained).toBe(false);
expect(second.gameState).toBe('active');
releaseFirst();
await timeoutPass;
await drain;
expect(handleGameOver).toHaveBeenCalledTimes(1);
expect(second.gameState).toBe('active');
const snapshots = await context.persistSoloRestartSnapshots();
expect(snapshots).toEqual({ captured: 1 });
expect(client.query.mock.calls[0][1].slice(0, 3)).toEqual([92, 45, second.id]);
});
test('migration enforces versioned identities and removes snapshots on terminal transition', () => {
const migration = fs.readFileSync(path.join(
__dirname, '../src/migrations/043_durable_solo_restart_snapshots.sql'
), 'utf8');
expect(migration).toMatch(/snapshot_version = 1/);
expect(migration).toMatch(/UNIQUE \(user_id\)/);
expect(migration).toMatch(/AFTER UPDATE OF status ON games/);
expect(migration).toMatch(/DELETE FROM solo_restart_snapshots WHERE game_id = NEW\.id/);
});
});
+2 -2
View File
@@ -164,7 +164,7 @@ describe('solo terminal settlement retry', () => {
expect(harness.activeGames.has('socket-2')).toBe(false);
});
test('reconnect restores a pending terminal lock without re-counting it as a playable game', () => {
test('reconnect restores a pending terminal lock without re-counting it as a playable game', async () => {
jest.useFakeTimers();
const activeGames = new Map();
const suspended = new SuspendedGameManager({
@@ -181,7 +181,7 @@ describe('solo terminal settlement retry', () => {
const newUser = { joinGame: jest.fn() };
suspended.suspendGame(44, 'socket-old', game);
const restored = suspended.restoreGame(44, 'socket-new', newUser);
const restored = await suspended.restoreGame(44, 'socket-new', newUser);
expect(restored.game).toBe(game);
expect(activeGames.get('socket-new')).toBe(game);
+3 -2
View File
@@ -46,8 +46,9 @@ describe('parallel Monero stagenet wallet candidate boundary', () => {
expect(unit).not.toContain('mainnet');
});
test('runbook keeps host mutation and production deployment at NO-GO', () => {
expect(runbook).toContain('STATUS: DESIGN / NO-GO FOR HOST MUTATION OR PRODUCTION DEPLOYMENT');
test('historical design record remains non-authorizing and fail-closed', () => {
expect(runbook).toContain('STATUS: HISTORICAL NON-MUTATING DESIGN RECORD');
expect(runbook).toContain('this document and its sample unit authorize nothing');
expect(runbook).toContain('never alters the legacy artifacts');
expect(runbook).toContain('renameat2(RENAME_NOREPLACE)');
expect(runbook).toContain('write `READY` **last**');