mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-29 02:58:33 +01:00
feat: productionize paid modes and PvP capture
This commit is contained in:
+7
-1
@@ -42,6 +42,9 @@ const DUNGEON_CONFIGS = {
|
||||
* Dungeon generation utilities
|
||||
*/
|
||||
class DungeonGenerator {
|
||||
static get GENERATOR_VERSION() { return 'dungeon-generator-v1'; }
|
||||
static get FINGERPRINT_VERSION() { return 1; }
|
||||
|
||||
// Get the configuration object with difficulty settings applied
|
||||
static getConfig(cryptoType = null) {
|
||||
const difficulty = getDifficultyConfig(cryptoType || process.env.CRYPTO_TYPE || 'WOW');
|
||||
@@ -99,7 +102,10 @@ class DungeonGenerator {
|
||||
* @param {object} dungeon - A dungeon object from generate()/regenerateFromSeed()
|
||||
* @returns {string} SHA-256 hex of the layout-defining fields
|
||||
*/
|
||||
static layoutFingerprint(dungeon) {
|
||||
static layoutFingerprint(dungeon, fingerprintVersion = this.FINGERPRINT_VERSION) {
|
||||
if (Number(fingerprintVersion) !== 1) {
|
||||
throw new Error(`Unsupported dungeon fingerprint version: ${fingerprintVersion}`);
|
||||
}
|
||||
const payload = JSON.stringify({
|
||||
map: dungeon.map,
|
||||
entrance: dungeon.entrance,
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* One-time, socket-bound server-seed commitments for solo games.
|
||||
*
|
||||
* The server publishes hash(serverSeed) before a client chooses its clientSeed. A start request
|
||||
* consumes exactly one published offer; the secret serverSeed is then handed to Game internally
|
||||
* and revealed only after the run. Keeping issuance separate from consumption prevents the server
|
||||
* from choosing/grinding its seed after seeing the player's contribution.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { generateSeed, hashSeed, normalizeClientSeed } = require('./provablyFair');
|
||||
|
||||
const DEFAULT_TTL_MS = 15 * 60 * 1000;
|
||||
|
||||
class FairnessOfferManager {
|
||||
constructor({ ttlMs = DEFAULT_TTL_MS, now = () => Date.now(), seedFactory = generateSeed, idFactory = () => crypto.randomUUID() } = {}) {
|
||||
this.ttlMs = Math.max(1000, Number(ttlMs) || DEFAULT_TTL_MS);
|
||||
this.now = now;
|
||||
this.seedFactory = seedFactory;
|
||||
this.idFactory = idFactory;
|
||||
this.offers = new Map(); // offerId -> private offer
|
||||
this.currentBySocket = new Map(); // socketId -> offerId
|
||||
this.consumed = new Map(); // offerId -> expiry (explicit replay detection)
|
||||
}
|
||||
|
||||
_prune() {
|
||||
const now = this.now();
|
||||
for (const [id, offer] of this.offers.entries()) {
|
||||
if (offer.expiresAt <= now) {
|
||||
this.offers.delete(id);
|
||||
if (this.currentBySocket.get(offer.socketId) === id) this.currentBySocket.delete(offer.socketId);
|
||||
}
|
||||
}
|
||||
for (const [id, expiresAt] of this.consumed.entries()) {
|
||||
if (expiresAt <= now) this.consumed.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
_public(offer) {
|
||||
return {
|
||||
offerId: offer.offerId,
|
||||
commitment: offer.commitment,
|
||||
issuedAt: offer.issuedAt,
|
||||
expiresAt: offer.expiresAt,
|
||||
algorithm: 'HMAC-SHA256(serverSeed, clientSeed)',
|
||||
proofVersion: 2
|
||||
};
|
||||
}
|
||||
|
||||
/** Return the existing unexpired offer for a socket, or publish a fresh one. */
|
||||
ensureOffer(socketId) {
|
||||
this._prune();
|
||||
const owner = String(socketId || '');
|
||||
if (!owner) throw new Error('socketId is required for a fairness offer');
|
||||
const currentId = this.currentBySocket.get(owner);
|
||||
const current = currentId ? this.offers.get(currentId) : null;
|
||||
if (current) return this._public(current);
|
||||
|
||||
const issuedAt = this.now();
|
||||
const serverSeed = this.seedFactory();
|
||||
const offer = {
|
||||
offerId: this.idFactory(),
|
||||
socketId: owner,
|
||||
serverSeed,
|
||||
commitment: hashSeed(serverSeed),
|
||||
issuedAt,
|
||||
expiresAt: issuedAt + this.ttlMs
|
||||
};
|
||||
this.offers.set(offer.offerId, offer);
|
||||
this.currentBySocket.set(owner, offer.offerId);
|
||||
return this._public(offer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume a published offer once. Missing offerId is accepted only for legacy clients with
|
||||
* an empty client seed; modern clients must echo the offer they actually received.
|
||||
*/
|
||||
consume(socketId, { offerId = null, clientSeed = '' } = {}) {
|
||||
this._prune();
|
||||
const owner = String(socketId || '');
|
||||
const normalizedSeed = normalizeClientSeed(clientSeed);
|
||||
if (clientSeed != null && String(clientSeed).trim() !== '' && normalizedSeed === null) {
|
||||
return { success: false, code: 'INVALID_CLIENT_SEED', reason: 'Client seed must be 1-64 hexadecimal characters.' };
|
||||
}
|
||||
|
||||
if (!offerId && normalizedSeed) {
|
||||
return { success: false, code: 'OFFER_REQUIRED', reason: 'A published fairness offer is required for a client seed.' };
|
||||
}
|
||||
|
||||
const resolvedId = offerId || this.currentBySocket.get(owner);
|
||||
if (!resolvedId) {
|
||||
return { success: false, code: 'OFFER_MISSING', reason: 'No active fairness offer. Request a new offer and retry.' };
|
||||
}
|
||||
if (this.consumed.has(resolvedId)) {
|
||||
return { success: false, code: 'OFFER_REPLAYED', reason: 'That fairness offer was already used.' };
|
||||
}
|
||||
|
||||
const offer = this.offers.get(resolvedId);
|
||||
if (!offer) {
|
||||
return { success: false, code: 'OFFER_EXPIRED', reason: 'That fairness offer expired. Request a new offer and retry.' };
|
||||
}
|
||||
if (offer.socketId !== owner) {
|
||||
return { success: false, code: 'OFFER_OWNER_MISMATCH', reason: 'That fairness offer belongs to another connection.' };
|
||||
}
|
||||
|
||||
// Synchronous delete/mark makes consumption atomic in Node's event loop. No await may
|
||||
// occur between checking and claiming this offer.
|
||||
this.offers.delete(resolvedId);
|
||||
if (this.currentBySocket.get(owner) === resolvedId) this.currentBySocket.delete(owner);
|
||||
this.consumed.set(resolvedId, Math.max(offer.expiresAt, this.now() + this.ttlMs));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
proofInput: {
|
||||
proofVersion: 2,
|
||||
offerId: offer.offerId,
|
||||
offerIssuedAt: offer.issuedAt,
|
||||
serverSeed: offer.serverSeed,
|
||||
commitment: offer.commitment,
|
||||
clientSeed: normalizedSeed || ''
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
discardSocket(socketId) {
|
||||
const owner = String(socketId || '');
|
||||
const id = this.currentBySocket.get(owner);
|
||||
if (id) this.offers.delete(id);
|
||||
this.currentBySocket.delete(owner);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FairnessOfferManager;
|
||||
@@ -0,0 +1,118 @@
|
||||
const DungeonGenerator = require('./dungeon');
|
||||
const { levelSeed } = require('./provablyFair');
|
||||
|
||||
function parseJson(value) {
|
||||
if (value == null || value === '') return null;
|
||||
if (typeof value === 'object') return value;
|
||||
try { return JSON.parse(value); } catch (_) { return null; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate and compare the versioned layout manifest attached to an already-verified
|
||||
* seed relationship. Legacy rows with only `layout_fingerprint` remain level-one verifiable;
|
||||
* new rows must contain one contiguous entry for every advertised depth.
|
||||
*/
|
||||
function attachDungeonVerification(result, {
|
||||
effectiveSeed,
|
||||
proofContext = null,
|
||||
expectedFingerprint = null,
|
||||
expectedFingerprints = null,
|
||||
generatorVersion = null
|
||||
} = {}) {
|
||||
if (!result.valid || !effectiveSeed) return result;
|
||||
try {
|
||||
const context = parseJson(proofContext) || {};
|
||||
const options = context.generationOptions && typeof context.generationOptions === 'object'
|
||||
? context.generationOptions
|
||||
: {};
|
||||
const recordedGenerator = generatorVersion || context.generatorVersion || DungeonGenerator.GENERATOR_VERSION;
|
||||
if (recordedGenerator !== DungeonGenerator.GENERATOR_VERSION) {
|
||||
throw Object.assign(new Error('unsupported_generator_version'), { code: 'UNSUPPORTED_GENERATOR' });
|
||||
}
|
||||
|
||||
const parsedManifest = parseJson(expectedFingerprints);
|
||||
const hasManifest = Array.isArray(parsedManifest) && parsedManifest.length > 0;
|
||||
const maxDepth = Math.max(1, parseInt(context.maxDepth, 10) || 1);
|
||||
let manifest;
|
||||
if (hasManifest) {
|
||||
manifest = parsedManifest.slice().sort((a, b) => Number(a.depth) - Number(b.depth));
|
||||
const depths = manifest.map(item => Number(item.depth));
|
||||
const expectedDepths = Array.from({ length: maxDepth }, (_, i) => i + 1);
|
||||
if (manifest.length !== maxDepth || depths.some((depth, i) => depth !== expectedDepths[i])) {
|
||||
throw Object.assign(new Error('incomplete_layout_manifest'), { code: 'INCOMPLETE_MANIFEST' });
|
||||
}
|
||||
} else {
|
||||
manifest = [{
|
||||
depth: 1,
|
||||
fingerprintVersion: 1,
|
||||
generatorVersion: recordedGenerator,
|
||||
fingerprint: expectedFingerprint ? String(expectedFingerprint).trim() : null
|
||||
}];
|
||||
}
|
||||
|
||||
const levels = manifest.map((entry) => {
|
||||
const depth = Number(entry.depth);
|
||||
const entryGenerator = entry.generatorVersion || recordedGenerator;
|
||||
if (entryGenerator !== DungeonGenerator.GENERATOR_VERSION) {
|
||||
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
|
||||
);
|
||||
if (depth < maxDepth) dungeon.treasure = null;
|
||||
const fingerprint = DungeonGenerator.layoutFingerprint(dungeon, fingerprintVersion);
|
||||
const expected = entry.fingerprint ? String(entry.fingerprint).trim() : null;
|
||||
return {
|
||||
depth,
|
||||
fingerprintVersion,
|
||||
generatorVersion: entryGenerator,
|
||||
fingerprint,
|
||||
expectedFingerprint: expected,
|
||||
matches: expected ? fingerprint === expected : null,
|
||||
dungeonSize: { width: dungeon.map[0].length, height: dungeon.map.length },
|
||||
entrance: dungeon.entrance,
|
||||
exit: dungeon.exit,
|
||||
treasure: dungeon.treasure
|
||||
};
|
||||
});
|
||||
|
||||
const compared = levels.filter(level => level.expectedFingerprint);
|
||||
const allMatch = compared.length > 0 ? compared.every(level => level.matches) : null;
|
||||
result.generatorVersion = recordedGenerator;
|
||||
result.layoutManifestVersion = hasManifest ? 1 : 0;
|
||||
result.verificationScope = hasManifest ? 'all_depths' : 'legacy_level_1_only';
|
||||
result.levels = levels;
|
||||
result.verifiedDepths = levels.map(level => level.depth);
|
||||
result.layoutFingerprint = levels[0]?.fingerprint || null;
|
||||
result.expectedLayoutFingerprint = levels[0]?.expectedFingerprint || null;
|
||||
result.layoutMatches = allMatch;
|
||||
result.dungeonSize = levels[0]?.dungeonSize || null;
|
||||
result.entrance = levels[0]?.entrance || null;
|
||||
result.exit = levels[0]?.exit || null;
|
||||
result.treasure = levels[0]?.treasure ?? null;
|
||||
if (allMatch === false) {
|
||||
result.valid = false;
|
||||
result.message = '❌ Seed derivation verified, but one or more regenerated depth fingerprints do not match the played run.';
|
||||
} else if (hasManifest && allMatch === true) {
|
||||
result.message = `✅ Game proof and all ${levels.length} dungeon depth fingerprints verified.`;
|
||||
}
|
||||
} catch (error) {
|
||||
result.valid = false;
|
||||
if (error?.code === 'UNSUPPORTED_GENERATOR') {
|
||||
result.message = '❌ This proof uses an unsupported dungeon generator version.';
|
||||
result.regenerationError = 'unsupported_generator_version';
|
||||
} else if (error?.code === 'INCOMPLETE_MANIFEST') {
|
||||
result.message = '❌ The persisted all-depth layout manifest is incomplete.';
|
||||
result.regenerationError = 'incomplete_layout_manifest';
|
||||
} else {
|
||||
result.message = '❌ Proof fields verified, but the persisted dungeon context could not be regenerated.';
|
||||
result.regenerationError = 'regeneration_failed';
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = { attachDungeonVerification };
|
||||
+92
-13
@@ -18,6 +18,11 @@ class Game {
|
||||
* @param {object} gameOptions - Optional game configuration overrides
|
||||
*/
|
||||
constructor(socketId, user, gameOptions = {}) {
|
||||
const suppliedOptions = (gameOptions && typeof gameOptions === 'object') ? gameOptions : {};
|
||||
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;
|
||||
this.user = user;
|
||||
this.id = uuidv4();
|
||||
this.socketId = socketId;
|
||||
@@ -28,7 +33,11 @@ class Game {
|
||||
this.startedAt = Date.now();
|
||||
|
||||
// Provably fair: Generate game proof before anything else
|
||||
this.gameProof = createGameProof(this.id);
|
||||
this.gameProof = createGameProof(
|
||||
this.id,
|
||||
fairnessProof?.clientSeed ?? optionClientSeed ?? '',
|
||||
fairnessProof
|
||||
);
|
||||
// Per-game deterministic RNG derived from the committed seed. ALL game randomness
|
||||
// (dungeon generation + monster movement) flows through this so the game is
|
||||
// reproducible from the seed for provably-fair verification.
|
||||
@@ -39,22 +48,30 @@ class Game {
|
||||
const dungeonConfig = DungeonGenerator.getConfig();
|
||||
|
||||
// Determine dimensions - use provided options or defaults
|
||||
const width = gameOptions.width || dungeonConfig.DEFAULT_WIDTH;
|
||||
const height = gameOptions.height || dungeonConfig.DEFAULT_HEIGHT;
|
||||
const width = dungeonOptions.width || dungeonConfig.DEFAULT_WIDTH;
|
||||
const height = dungeonOptions.height || dungeonConfig.DEFAULT_HEIGHT;
|
||||
|
||||
// Merge game configuration
|
||||
this.gameConfig = {
|
||||
width: width,
|
||||
height: height,
|
||||
// Dungeon generation options (can be overridden by gameOptions)
|
||||
floorVariation: gameOptions.floorVariation !== undefined ? gameOptions.floorVariation : dungeonConfig.FLOOR_VARIATION,
|
||||
torchEnabled: gameOptions.torchEnabled !== undefined ? gameOptions.torchEnabled : dungeonConfig.TORCH_ENABLED,
|
||||
torchDensity: gameOptions.torchDensity !== undefined ? gameOptions.torchDensity : dungeonConfig.TORCH_DENSITY,
|
||||
primaryFloor: gameOptions.primaryFloor || dungeonConfig.PRIMARY_FLOOR,
|
||||
secondaryFloor: gameOptions.secondaryFloor || dungeonConfig.SECONDARY_FLOOR,
|
||||
torchTile: gameOptions.torchTile || dungeonConfig.TORCH_TILE,
|
||||
floorVariation: dungeonOptions.floorVariation !== undefined ? dungeonOptions.floorVariation : dungeonConfig.FLOOR_VARIATION,
|
||||
torchEnabled: dungeonOptions.torchEnabled !== undefined ? dungeonOptions.torchEnabled : dungeonConfig.TORCH_ENABLED,
|
||||
torchDensity: dungeonOptions.torchDensity !== undefined ? dungeonOptions.torchDensity : dungeonConfig.TORCH_DENSITY,
|
||||
primaryFloor: dungeonOptions.primaryFloor || dungeonConfig.PRIMARY_FLOOR,
|
||||
secondaryFloor: dungeonOptions.secondaryFloor || dungeonConfig.SECONDARY_FLOOR,
|
||||
torchTile: dungeonOptions.torchTile || dungeonConfig.TORCH_TILE,
|
||||
// Resolve every layout-affecting preset value now. Persisting these exact values lets an
|
||||
// old game regenerate after operators change difficulty environment variables later.
|
||||
roomWidthRange: dungeonOptions.roomWidthRange || dungeonConfig.ROOM_WIDTH_RANGE,
|
||||
roomHeightRange: dungeonOptions.roomHeightRange || dungeonConfig.ROOM_HEIGHT_RANGE,
|
||||
corridorLengthRange: dungeonOptions.corridorLengthRange || dungeonConfig.CORRIDOR_LENGTH_RANGE,
|
||||
dugPercentage: dungeonOptions.dugPercentage ?? dungeonConfig.DUG_PERCENTAGE,
|
||||
roomPositionRatio: dungeonOptions.roomPositionRatio ?? dungeonConfig.difficulty?.treasure?.roomPositionRatio,
|
||||
cryptoType: dungeonOptions.cryptoType || process.env.CRYPTO_TYPE || 'WOW',
|
||||
// Other game options
|
||||
...gameOptions
|
||||
...dungeonOptions
|
||||
};
|
||||
|
||||
if (CONSOLE_LOGGING) {
|
||||
@@ -64,6 +81,56 @@ class Game {
|
||||
}
|
||||
|
||||
this.initializeGame();
|
||||
|
||||
// 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.context = this.getProofContext();
|
||||
this.gameProof.layoutFingerprints = this._buildLayoutFingerprintManifest();
|
||||
// Backward-compatible level-one alias used by older verification clients/rows.
|
||||
this.gameProof.layoutFingerprint = this.gameProof.layoutFingerprints[0]?.fingerprint || null;
|
||||
}
|
||||
|
||||
_buildLayoutFingerprintManifest() {
|
||||
const manifest = [];
|
||||
for (let depth = 1; depth <= this.maxDepth; depth += 1) {
|
||||
let dungeon;
|
||||
if (depth === 1) {
|
||||
dungeon = this.dungeon;
|
||||
} else {
|
||||
dungeon = DungeonGenerator.regenerateFromSeed(
|
||||
levelSeed(this.gameProof.seed, depth),
|
||||
this.gameConfig.cryptoType,
|
||||
this.gameProof.context.generationOptions
|
||||
);
|
||||
if (depth < this.maxDepth) dungeon.treasure = null;
|
||||
}
|
||||
manifest.push({
|
||||
depth,
|
||||
fingerprintVersion: DungeonGenerator.FINGERPRINT_VERSION,
|
||||
generatorVersion: DungeonGenerator.GENERATOR_VERSION,
|
||||
fingerprint: DungeonGenerator.layoutFingerprint(dungeon)
|
||||
});
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
getProofContext() {
|
||||
const keys = [
|
||||
'width', 'height', 'floorVariation', 'torchEnabled', 'torchDensity', 'primaryFloor',
|
||||
'secondaryFloor', 'torchTile', 'roomWidthRange', 'roomHeightRange',
|
||||
'corridorLengthRange', 'dugPercentage', 'roomPositionRatio', 'cryptoType'
|
||||
];
|
||||
const generationOptions = {};
|
||||
for (const key of keys) generationOptions[key] = this.gameConfig[key];
|
||||
return {
|
||||
cryptoType: this.gameConfig.cryptoType || process.env.CRYPTO_TYPE || 'WOW',
|
||||
maxDepth: this.maxDepth,
|
||||
generatorVersion: DungeonGenerator.GENERATOR_VERSION,
|
||||
fingerprintVersion: DungeonGenerator.FINGERPRINT_VERSION,
|
||||
generationOptions
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,7 +167,7 @@ class Game {
|
||||
this.height = this.gameConfig.height;
|
||||
|
||||
// Get difficulty settings
|
||||
const difficultyConfig = getDifficultyConfig(process.env.CRYPTO_TYPE || 'WOW');
|
||||
const difficultyConfig = getDifficultyConfig(this.gameConfig.cryptoType || process.env.CRYPTO_TYPE || 'WOW');
|
||||
this.difficultyConfig = difficultyConfig;
|
||||
|
||||
// Multi-level descent: a run spans `maxDepth` levels (a pacing knob ∝ block time, from the
|
||||
@@ -151,6 +218,17 @@ class Game {
|
||||
}
|
||||
this.dungeon = dungeon;
|
||||
|
||||
// Once the constructor has committed its all-level manifest, assert every actually played
|
||||
// descent still matches it. This turns accidental generator drift within a live process into
|
||||
// a hard failure rather than silently serving an unverifiable paid layout.
|
||||
const expected = this.gameProof.layoutFingerprints?.find(item => item.depth === depth);
|
||||
if (expected) {
|
||||
const actual = DungeonGenerator.layoutFingerprint(dungeon, expected.fingerprintVersion);
|
||||
if (actual !== expected.fingerprint) {
|
||||
throw new Error(`Generated level ${depth} does not match its committed fingerprint`);
|
||||
}
|
||||
}
|
||||
|
||||
// Place player at the entrance.
|
||||
if (dungeon.entrance) {
|
||||
this.player.moveTo(dungeon.entrance[0], dungeon.entrance[1]);
|
||||
@@ -359,6 +437,7 @@ class Game {
|
||||
* @param {object} gameStats - Additional statistics
|
||||
*/
|
||||
endGame(result, gameStats = {}) {
|
||||
this.finalResult = result;
|
||||
if (this.user) {
|
||||
const score = gameStats.score || 0;
|
||||
this.user.endGame(result, score, gameStats);
|
||||
@@ -382,7 +461,7 @@ class Game {
|
||||
*/
|
||||
getProofReveal() {
|
||||
return getPostGameReveal(this.gameProof, {
|
||||
won: this.gameState === 'won',
|
||||
won: this.finalResult === 'won' || this.gameState === 'won',
|
||||
treasureFound: this.player?.hasTreasure ?? false,
|
||||
moves: this.moveCount,
|
||||
duration: Math.floor((Date.now() - this.startedAt) / 1000)
|
||||
@@ -401,4 +480,4 @@ class Game {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Game;
|
||||
module.exports = Game;
|
||||
|
||||
+343
-112
@@ -6,13 +6,30 @@
|
||||
const Game = require('../game/game');
|
||||
|
||||
class GameManager {
|
||||
constructor({ activeGames, io, broadcastManager, debugManager, gameModeManager, spectatorManager = null }) {
|
||||
constructor({
|
||||
activeGames,
|
||||
io,
|
||||
broadcastManager,
|
||||
debugManager,
|
||||
gameModeManager,
|
||||
spectatorManager = null,
|
||||
settlementRetryBaseMs = 1000,
|
||||
settlementRetryMaxMs = 30000
|
||||
}) {
|
||||
this.activeGames = activeGames;
|
||||
this.io = io;
|
||||
this.broadcastManager = broadcastManager;
|
||||
this.debugManager = debugManager;
|
||||
this.gameModeManager = gameModeManager;
|
||||
this.spectatorManager = spectatorManager;
|
||||
this.suspendedGameManager = null;
|
||||
this._pendingSettlements = new Map();
|
||||
this._settlementRetryBaseMs = Math.max(1, Number(settlementRetryBaseMs) || 1000);
|
||||
this._settlementRetryMaxMs = Math.max(
|
||||
this._settlementRetryBaseMs,
|
||||
Number(settlementRetryMaxMs) || 30000
|
||||
);
|
||||
this._isShuttingDown = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,6 +40,11 @@ class GameManager {
|
||||
this.spectatorManager = spectatorManager;
|
||||
}
|
||||
|
||||
/** Late binding avoids a constructor cycle with reconnect support. */
|
||||
setSuspendedGameManager(suspendedGameManager) {
|
||||
this.suspendedGameManager = suspendedGameManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new game for a user
|
||||
* @param {Object} user - User object
|
||||
@@ -31,6 +53,13 @@ class GameManager {
|
||||
* @returns {Object} Created game instance
|
||||
*/
|
||||
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).
|
||||
if (this.activeGames.has(user.id)) {
|
||||
throw new Error('User already has an active or settlement-pending game');
|
||||
}
|
||||
|
||||
let game;
|
||||
|
||||
if (gameType === 'legacy') {
|
||||
@@ -39,12 +68,18 @@ class GameManager {
|
||||
game = Game.createStandardGame(user.id, user, options);
|
||||
}
|
||||
|
||||
// Persist before exposing the game or charging entry. Paid-start processing updates this
|
||||
// exact row; allowing play to continue after a failed INSERT can consume a payment/credit
|
||||
// with no durable game or payout-liability anchor.
|
||||
await this._insertGameRecord(game, user);
|
||||
|
||||
if (this.gameModeManager?.paymentsEnabled && !game.dbId) {
|
||||
throw new Error('Durable game record is required before a paid game can start');
|
||||
}
|
||||
|
||||
user.joinGame(game);
|
||||
this.activeGames.set(user.id, game);
|
||||
|
||||
// Insert DB record and wait for it (needed for processGameStart to find the row)
|
||||
await this._insertGameRecord(game, user);
|
||||
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`[createGameForUser] Created ${gameType} game ${game.id} for user ${user.id} (dbId: ${game.dbId || 'none'})`);
|
||||
}
|
||||
@@ -61,130 +96,298 @@ class GameManager {
|
||||
* @param {number} score - Game score
|
||||
*/
|
||||
async handleGameOver(socket, game, status, reason, message, score = 0) {
|
||||
const socketId = socket?.id || socket;
|
||||
if (!socketId || !game?.id) return { success: false, reason: 'Invalid game completion' };
|
||||
if (game.settlementCommitted) return { success: true, reason: 'Game already completed' };
|
||||
|
||||
// Escape, monster collision, and block timeout may converge in one event-loop turn. The
|
||||
// first caller freezes the authoritative terminal facts synchronously; every later caller
|
||||
// shares that exact settlement attempt instead of overwriting it or paying twice.
|
||||
const existing = this._pendingSettlements.get(game.id);
|
||||
if (existing) return this._attemptSettlement(existing);
|
||||
|
||||
try {
|
||||
const socketId = socket.id || socket;
|
||||
|
||||
// IMMEDIATELY remove from active games to prevent double-processing.
|
||||
// If escape + block timeout fire in the same event loop tick, the second
|
||||
// call would find the game still here during the first call's await.
|
||||
this.activeGames.delete(socketId);
|
||||
|
||||
game.gameState = status;
|
||||
const moves = game.moveCount || 0;
|
||||
const durationSeconds = game.startedAt ? Math.max(0, Math.round((Date.now() - game.startedAt) / 1000)) : null;
|
||||
const durationSeconds = game.startedAt
|
||||
? Math.max(0, Math.round((Date.now() - game.startedAt) / 1000))
|
||||
: null;
|
||||
const finalScore = score > 0 ? score : this._calculateScore(game, status, reason);
|
||||
const gameStats = {
|
||||
score: finalScore,
|
||||
reason: reason,
|
||||
treasuresFound: game.player.hasTreasure ? 1 : 0,
|
||||
moves,
|
||||
durationSeconds
|
||||
};
|
||||
game.endGame(status, gameStats);
|
||||
|
||||
// Process game completion with payment system
|
||||
let payoutInfo = null;
|
||||
if (this.gameModeManager) {
|
||||
try {
|
||||
payoutInfo = await this.gameModeManager.completeGame(
|
||||
socketId,
|
||||
game.id,
|
||||
status === 'won',
|
||||
game.player.hasTreasure || false,
|
||||
{ moves, durationSeconds, score: finalScore }
|
||||
);
|
||||
|
||||
if (this.debugManager.CONSOLE_LOGGING && payoutInfo) {
|
||||
console.log(`💰 Payout processed for ${socketId}:`, payoutInfo);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing game completion:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit game over event with provably fair reveal
|
||||
const proofReveal = game.getProofReveal ? game.getProofReveal() : null;
|
||||
|
||||
this.io.to(socketId).emit('game_over', {
|
||||
status: status,
|
||||
reason: reason,
|
||||
message: message,
|
||||
const treasure = !!game.player?.hasTreasure;
|
||||
const terminal = Object.freeze({
|
||||
status,
|
||||
reason,
|
||||
message,
|
||||
score: finalScore,
|
||||
moves,
|
||||
durationSeconds,
|
||||
payout: payoutInfo,
|
||||
treasure: game.player.hasTreasure || false,
|
||||
proof: proofReveal
|
||||
treasure,
|
||||
won: status === 'won',
|
||||
outcome: reason === 'escaped' ? 'escaped' : reason
|
||||
});
|
||||
|
||||
// Persist completion details if DB available
|
||||
await this._updateGameRecord(game, socketId, status, reason, moves, durationSeconds);
|
||||
|
||||
// Notify spectators that the game has ended and broadcast updated list
|
||||
if (this.spectatorManager) {
|
||||
this.spectatorManager.notifyGameEnded(game.id, {
|
||||
status,
|
||||
reason,
|
||||
message,
|
||||
// Mark terminal before any await. Keep ownership in activeGames until the transaction
|
||||
// commits: movement and all existing start paths can then fail closed on this entry.
|
||||
game.gameState = status;
|
||||
game.settlementPending = true;
|
||||
const settlement = {
|
||||
game,
|
||||
socketId,
|
||||
terminal,
|
||||
proof: null,
|
||||
attempts: 0,
|
||||
retryTimer: null,
|
||||
inFlight: null,
|
||||
pendingNoticeSent: false,
|
||||
committed: false
|
||||
};
|
||||
this._pendingSettlements.set(game.id, settlement);
|
||||
try {
|
||||
game.endGame(status, {
|
||||
score: finalScore,
|
||||
reason,
|
||||
treasuresFound: treasure ? 1 : 0,
|
||||
moves,
|
||||
durationSeconds,
|
||||
treasure: game.player.hasTreasure || false
|
||||
durationSeconds
|
||||
});
|
||||
} catch (error) {
|
||||
// In-memory user stats and rendering state are secondary to the durable result.
|
||||
// A local callback failure must not suppress the settlement retry.
|
||||
console.error('In-memory game finalization failed:', error.message || error);
|
||||
}
|
||||
try {
|
||||
settlement.proof = game.getProofReveal ? game.getProofReveal() : null;
|
||||
} catch (error) {
|
||||
console.error('Game proof reveal failed:', error.message || error);
|
||||
}
|
||||
return await this._attemptSettlement(settlement);
|
||||
} catch (error) {
|
||||
// Never release a terminal game merely because orchestration failed. If an intent was
|
||||
// installed it remains retryable; otherwise retain the game and flag it for operators.
|
||||
game.settlementPending = true;
|
||||
console.error('GameManager.handleGameOver error:', error.message || error);
|
||||
return { success: false, reason: 'Game settlement pending' };
|
||||
}
|
||||
}
|
||||
|
||||
async _attemptSettlement(settlement) {
|
||||
if (!settlement || settlement.committed) return { success: true, reason: 'Game already completed' };
|
||||
if (settlement.inFlight) return settlement.inFlight;
|
||||
|
||||
const attempt = (async () => {
|
||||
settlement.attempts += 1;
|
||||
const socketId = this._socketIdForGame(settlement.game, settlement.socketId);
|
||||
settlement.socketId = socketId;
|
||||
let payoutInfo;
|
||||
|
||||
try {
|
||||
if (this.gameModeManager) {
|
||||
const terminal = settlement.terminal;
|
||||
payoutInfo = await this.gameModeManager.completeGame(
|
||||
socketId,
|
||||
settlement.game.id,
|
||||
terminal.won,
|
||||
terminal.treasure,
|
||||
{
|
||||
moves: terminal.moves,
|
||||
durationSeconds: terminal.durationSeconds,
|
||||
score: terminal.score,
|
||||
reason: terminal.reason,
|
||||
outcome: terminal.outcome
|
||||
}
|
||||
);
|
||||
} else {
|
||||
const terminal = settlement.terminal;
|
||||
await this._updateGameRecord(
|
||||
settlement.game,
|
||||
socketId,
|
||||
terminal.status,
|
||||
terminal.reason,
|
||||
terminal.moves,
|
||||
terminal.durationSeconds
|
||||
);
|
||||
payoutInfo = { success: true, payout: null };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing game completion:', error.message || error);
|
||||
payoutInfo = { success: false };
|
||||
}
|
||||
|
||||
// Broadcast leaderboard update if score > 0
|
||||
if (finalScore > 0) {
|
||||
try {
|
||||
let displayName = null;
|
||||
if (this.gameModeManager?.db) {
|
||||
const userRow = await this.gameModeManager.db.query(
|
||||
`SELECT COALESCE(display_name,
|
||||
CASE WHEN payout_address IS NOT NULL
|
||||
THEN LEFT(payout_address, 4) || '...' || RIGHT(payout_address, 4)
|
||||
ELSE 'Anon#' || id
|
||||
END) as name
|
||||
FROM users WHERE socket_id = $1`, [socketId]);
|
||||
displayName = userRow.rows[0]?.name || 'Unknown';
|
||||
}
|
||||
this.io.emit('leaderboard_update', {
|
||||
name: displayName || 'Unknown',
|
||||
score: finalScore,
|
||||
treasure: game.player.hasTreasure || false
|
||||
});
|
||||
// `completeGame` deliberately normalizes transaction errors into success:false.
|
||||
// Treat every other shape as non-durable and retry; only explicit success releases
|
||||
// the game and publishes a result.
|
||||
if (!payoutInfo || payoutInfo.success !== true) {
|
||||
this._emitSettlementPending(settlement);
|
||||
this._scheduleSettlementRetry(settlement);
|
||||
return payoutInfo || { success: false, reason: 'Game settlement pending' };
|
||||
}
|
||||
|
||||
// Global "win feed": announce actual escapes to everyone so the room feels
|
||||
// alive (the leaderboard_update above also fires for lost-with-treasure runs;
|
||||
// the feed should only celebrate real escapes). Payout amount is intentionally
|
||||
// omitted — it's processed asynchronously and not reliably known yet here.
|
||||
if (status === 'won') {
|
||||
this.io.emit('win_feed', {
|
||||
name: displayName || 'Someone',
|
||||
treasure: game.player.hasTreasure || false,
|
||||
score: finalScore,
|
||||
paid: !!(payoutInfo && payoutInfo.amount)
|
||||
});
|
||||
}
|
||||
} catch (lbErr) {
|
||||
// Non-critical, don't block game over
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.warn('Leaderboard broadcast failed:', lbErr.message);
|
||||
}
|
||||
settlement.committed = true;
|
||||
settlement.game.settlementPending = false;
|
||||
settlement.game.settlementCommitted = true;
|
||||
if (settlement.retryTimer) {
|
||||
clearTimeout(settlement.retryTimer);
|
||||
settlement.retryTimer = null;
|
||||
}
|
||||
this._pendingSettlements.delete(settlement.game.id);
|
||||
|
||||
// Capture the reconnect-adjusted socket before releasing ownership. Remove only this
|
||||
// exact game so a theoretically newer entry can never be deleted by a late retry.
|
||||
const committedSocketId = this._socketIdForGame(settlement.game, settlement.socketId);
|
||||
for (const [activeSocketId, activeGame] of this.activeGames.entries()) {
|
||||
if (activeGame === settlement.game) this.activeGames.delete(activeSocketId);
|
||||
}
|
||||
const stableUserId = settlement.game.dbUserId ?? settlement.game.userId;
|
||||
if (stableUserId != null) {
|
||||
try {
|
||||
this.suspendedGameManager?.removeSuspendedGame?.(stableUserId, { countExpired: false });
|
||||
} catch (error) {
|
||||
console.error('Committed suspended-game cleanup failed:', error.message || error);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`🎮 Game ${game.id} ended for ${socketId}: ${status} (${reason}), score: ${finalScore}`);
|
||||
}
|
||||
await this._publishCommittedSettlement(settlement, payoutInfo, committedSocketId);
|
||||
return payoutInfo;
|
||||
})();
|
||||
|
||||
settlement.inFlight = attempt;
|
||||
try {
|
||||
return await attempt;
|
||||
} finally {
|
||||
if (settlement.inFlight === attempt) settlement.inFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
_scheduleSettlementRetry(settlement) {
|
||||
if (this._isShuttingDown || !settlement || settlement.committed || settlement.retryTimer) return;
|
||||
const exponent = Math.min(Math.max(settlement.attempts - 1, 0), 10);
|
||||
const delay = Math.min(this._settlementRetryBaseMs * (2 ** exponent), this._settlementRetryMaxMs);
|
||||
const timer = setTimeout(() => {
|
||||
if (settlement.retryTimer === timer) settlement.retryTimer = null;
|
||||
if (settlement.committed || !this._pendingSettlements.has(settlement.game.id)) return;
|
||||
this._attemptSettlement(settlement).catch(error => {
|
||||
console.error('Solo settlement retry failed:', error.message || error);
|
||||
});
|
||||
}, delay);
|
||||
timer.unref?.();
|
||||
settlement.retryTimer = timer;
|
||||
}
|
||||
|
||||
_emitSettlementPending(settlement) {
|
||||
if (settlement.pendingNoticeSent) return;
|
||||
settlement.pendingNoticeSent = true;
|
||||
const socketId = this._socketIdForGame(settlement.game, settlement.socketId);
|
||||
try {
|
||||
this.io.to(socketId).emit('game_settlement_pending', {
|
||||
gameId: settlement.game.id,
|
||||
code: 'GAME_FINISH_NOT_DURABLE',
|
||||
retrying: true
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('GameManager.handleGameOver error:', error);
|
||||
// Still try to clean up even if there was an error
|
||||
const socketId = socket?.id || socket;
|
||||
if (socketId) {
|
||||
this.activeGames.delete(socketId);
|
||||
// Socket delivery is best-effort. It must never prevent the authoritative DB retry.
|
||||
console.error('Solo settlement-pending broadcast failed:', error.message || error);
|
||||
}
|
||||
}
|
||||
|
||||
_socketIdForGame(game, fallback) {
|
||||
for (const [socketId, activeGame] of this.activeGames.entries()) {
|
||||
if (activeGame === game) return socketId;
|
||||
}
|
||||
return game?.socketId || fallback;
|
||||
}
|
||||
|
||||
async _publishCommittedSettlement(settlement, payoutInfo, socketId) {
|
||||
const { game, terminal, proof } = settlement;
|
||||
|
||||
try {
|
||||
this.io.to(socketId).emit('game_over', {
|
||||
status: terminal.status,
|
||||
reason: terminal.reason,
|
||||
message: terminal.message,
|
||||
score: terminal.score,
|
||||
moves: terminal.moves,
|
||||
durationSeconds: terminal.durationSeconds,
|
||||
payout: payoutInfo,
|
||||
treasure: terminal.treasure,
|
||||
proof
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Game-over broadcast failed:', error.message || error);
|
||||
}
|
||||
|
||||
try {
|
||||
this.spectatorManager?.notifyGameEnded(game.id, {
|
||||
status: terminal.status,
|
||||
reason: terminal.reason,
|
||||
message: terminal.message,
|
||||
score: terminal.score,
|
||||
moves: terminal.moves,
|
||||
durationSeconds: terminal.durationSeconds,
|
||||
treasure: terminal.treasure
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Spectator game-over broadcast failed:', error.message || error);
|
||||
}
|
||||
|
||||
if (terminal.score > 0) {
|
||||
try {
|
||||
let displayName = null;
|
||||
if (this.gameModeManager?.db) {
|
||||
const userRow = await this.gameModeManager.db.query(
|
||||
`SELECT COALESCE(display_name,
|
||||
CASE WHEN payout_address IS NOT NULL
|
||||
THEN LEFT(payout_address, 4) || '...' || RIGHT(payout_address, 4)
|
||||
ELSE 'Anon#' || id
|
||||
END) as name
|
||||
FROM users WHERE socket_id = $1`, [socketId]);
|
||||
displayName = userRow.rows[0]?.name || 'Unknown';
|
||||
}
|
||||
this.io.emit('leaderboard_update', {
|
||||
name: displayName || 'Unknown',
|
||||
score: terminal.score,
|
||||
treasure: terminal.treasure
|
||||
});
|
||||
if (terminal.status === 'won') {
|
||||
this.io.emit('win_feed', {
|
||||
name: displayName || 'Someone',
|
||||
treasure: terminal.treasure,
|
||||
score: terminal.score,
|
||||
paid: !!payoutInfo?.payout?.amount
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.warn('Leaderboard broadcast failed:', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`🎮 Game ${game.id} ended for ${socketId}: ${terminal.status} (${terminal.reason}), score: ${terminal.score}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop timers and make bounded, serialized final attempts before the DB pool closes.
|
||||
* Returns unresolved count so the process shutdown path can log/escalate it truthfully.
|
||||
*/
|
||||
async shutdown({ timeoutMs = 4000, retryIntervalMs = 50 } = {}) {
|
||||
this._isShuttingDown = true;
|
||||
for (const settlement of this._pendingSettlements.values()) {
|
||||
if (settlement.retryTimer) clearTimeout(settlement.retryTimer);
|
||||
settlement.retryTimer = null;
|
||||
}
|
||||
|
||||
const initial = this._pendingSettlements.size;
|
||||
const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0);
|
||||
do {
|
||||
const pending = Array.from(this._pendingSettlements.values());
|
||||
if (pending.length === 0) break;
|
||||
await Promise.allSettled(pending.map(settlement => this._attemptSettlement(settlement)));
|
||||
if (this._pendingSettlements.size === 0 || Date.now() >= deadline) break;
|
||||
await new Promise(resolve => setTimeout(resolve, Math.max(1, retryIntervalMs)));
|
||||
} while (Date.now() < deadline);
|
||||
|
||||
return { initial, settled: initial - this._pendingSettlements.size, pending: this._pendingSettlements.size };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -317,10 +520,36 @@ class GameManager {
|
||||
|
||||
try {
|
||||
const result = await db.query(`
|
||||
INSERT INTO games (user_id, socket_id, game_mode, status, start_block_height, dungeon_seed, created_at)
|
||||
VALUES ((SELECT id FROM users WHERE socket_id = $1), $2, $3, 'active', $4, $5, NOW())
|
||||
INSERT INTO games (
|
||||
user_id, socket_id, game_mode, status, start_block_height, dungeon_seed,
|
||||
proof_version, fairness_offer_id, fairness_offer_issued_at,
|
||||
proof_commitment, server_seed, client_seed, effective_seed,
|
||||
layout_fingerprint, layout_fingerprints, generator_version,
|
||||
proof_context, created_at
|
||||
)
|
||||
VALUES (
|
||||
(SELECT id FROM users WHERE socket_id = $1), $2, $3, 'active', $4, $5,
|
||||
$6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, NOW()
|
||||
)
|
||||
RETURNING id, user_id
|
||||
`, [socketId, socketId, gameMode, blockHeight, game.id]);
|
||||
`, [
|
||||
socketId,
|
||||
socketId,
|
||||
gameMode,
|
||||
blockHeight,
|
||||
game.id,
|
||||
game.gameProof?.proofVersion || 1,
|
||||
game.gameProof?.offerId || null,
|
||||
game.gameProof?.offerIssuedAt ? new Date(game.gameProof.offerIssuedAt) : null,
|
||||
game.gameProof?.commitment || null,
|
||||
game.gameProof?.serverSeed || null,
|
||||
game.gameProof?.clientSeed || '',
|
||||
game.gameProof?.seed || null,
|
||||
game.gameProof?.layoutFingerprint || null,
|
||||
game.gameProof?.layoutFingerprints ? JSON.stringify(game.gameProof.layoutFingerprints) : null,
|
||||
game.gameProof?.generatorVersion || null,
|
||||
game.gameProof?.context ? JSON.stringify(game.gameProof.context) : null
|
||||
]);
|
||||
game.dbId = result.rows[0]?.id || null;
|
||||
|
||||
// Stamp the stable DB users.id onto the in-memory game object so
|
||||
@@ -344,6 +573,7 @@ class GameManager {
|
||||
} catch (err) {
|
||||
console.error('Game insert failed:', err.message);
|
||||
game.dbId = null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -364,7 +594,8 @@ class GameManager {
|
||||
try {
|
||||
await db.query(`
|
||||
UPDATE games SET status = $1, outcome = $2, treasure_found = $3, moves_made = $4,
|
||||
duration_seconds = $5, score = $8, completed_at = NOW()
|
||||
duration_seconds = $5, score = $8, completed_at = NOW(),
|
||||
proof_revealed_at = NOW()
|
||||
WHERE dungeon_seed = $6 AND socket_id = $7
|
||||
`, [status, outcome, game.player.hasTreasure, moves, durationSeconds, game.id, socketId, score]);
|
||||
|
||||
@@ -386,4 +617,4 @@ class GameManager {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GameManager;
|
||||
module.exports = GameManager;
|
||||
|
||||
+1339
-291
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,17 @@
|
||||
const ChainProfile = require('../../chain/chainProfile');
|
||||
const money = require('../../money/atomic');
|
||||
|
||||
const DEFAULT_DECIMALS = 12;
|
||||
|
||||
const parseAtomicEnvValue = (val, fallback) => {
|
||||
if (val === undefined || val === null || val === '') return fallback;
|
||||
if (typeof val !== 'string') {
|
||||
const numVal = Number(val);
|
||||
return Number.isFinite(numVal) ? Math.trunc(numVal) : fallback;
|
||||
}
|
||||
const cleaned = val.replace(/_/g, '').trim();
|
||||
if (!cleaned) {
|
||||
const cleaned = typeof val === 'string' ? val.replace(/_/g, '').trim() : val;
|
||||
try {
|
||||
const parsed = money.toBig(cleaned);
|
||||
return parsed >= 0n ? money.toSafe(parsed) : fallback;
|
||||
} catch (_) {
|
||||
return fallback;
|
||||
}
|
||||
const num = Number(cleaned);
|
||||
if (!Number.isFinite(num) || num < 0) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.trunc(num);
|
||||
};
|
||||
|
||||
// Delegates to the ChainProfile registry — one source for all chains (WOW 11, XMR 12, BTC/LTC 8,
|
||||
@@ -53,24 +48,36 @@ const formatAtomic = ({ value, decimals, digits = 6 }) => {
|
||||
if (value === undefined || value === null) {
|
||||
return '0';
|
||||
}
|
||||
const divisor = getDecimalDivisor(decimals);
|
||||
const quotient = Number(value) / divisor;
|
||||
if (!Number.isFinite(quotient)) {
|
||||
return value.toString();
|
||||
try {
|
||||
const atomic = money.toBig(value);
|
||||
const negative = atomic < 0n;
|
||||
const abs = negative ? -atomic : atomic;
|
||||
const scale = 10n ** BigInt(decimals);
|
||||
const whole = abs / scale;
|
||||
const fraction = (abs % scale).toString().padStart(decimals, '0')
|
||||
.slice(0, Math.max(0, digits)).replace(/0+$/, '');
|
||||
return `${negative ? '-' : ''}${whole}${fraction ? `.${fraction}` : ''}`;
|
||||
} catch (_) {
|
||||
return String(value);
|
||||
}
|
||||
return quotient.toFixed(digits).replace(/0+$/, '').replace(/\.$/, '');
|
||||
};
|
||||
|
||||
const formatAtomicHuman = ({ value, decimals, digits = 3 }) => {
|
||||
if (value === undefined || value === null) {
|
||||
return '0';
|
||||
}
|
||||
const divisor = getDecimalDivisor(decimals);
|
||||
const quotient = Number(value) / divisor;
|
||||
if (!Number.isFinite(quotient)) {
|
||||
return value.toString();
|
||||
try {
|
||||
const atomic = money.toBig(value);
|
||||
const negative = atomic < 0n;
|
||||
const abs = negative ? -atomic : atomic;
|
||||
const scale = 10n ** BigInt(decimals);
|
||||
const whole = abs / scale;
|
||||
const fraction = (abs % scale).toString().padStart(decimals, '0')
|
||||
.slice(0, Math.max(0, digits)).padEnd(Math.max(0, digits), '0');
|
||||
return `${negative ? '-' : ''}${whole}${digits > 0 ? `.${fraction}` : ''}`;
|
||||
} catch (_) {
|
||||
return String(value);
|
||||
}
|
||||
return quotient.toFixed(digits);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -11,9 +11,11 @@ class MovementManager {
|
||||
this._lastMove = new Map(); // socketId -> timestamp
|
||||
this.postMoveHook = typeof postMoveHook === 'function' ? postMoveHook : null;
|
||||
this.spectatorManager = spectatorManager; // For broadcasting to spectators
|
||||
this._acceptingMoves = true;
|
||||
}
|
||||
|
||||
handleMove(socketId, moveData) {
|
||||
if (!this._acceptingMoves) return;
|
||||
if (!moveData || typeof moveData.direction !== 'string') return;
|
||||
const now = Date.now();
|
||||
const last = this._lastMove.get(socketId) || 0;
|
||||
@@ -23,6 +25,11 @@ class MovementManager {
|
||||
const game = this.activeGames.get(socketId);
|
||||
if (!game) return; // not in a game
|
||||
|
||||
// A terminal result stays in activeGames while its atomic DB completion is retried. Keeping
|
||||
// the entry blocks replacement games; this explicit guard prevents the retained Game object
|
||||
// from accepting moves (Game.movePlayer historically does not inspect gameState itself).
|
||||
if (game.settlementPending || game.settlementCommitted || game.gameState === 'ended') return;
|
||||
|
||||
const dir = moveData.direction;
|
||||
let dx = 0, dy = 0;
|
||||
switch (dir) {
|
||||
@@ -95,6 +102,12 @@ class MovementManager {
|
||||
this.spectatorManager.broadcastToSpectators(game.id, state);
|
||||
}
|
||||
}
|
||||
|
||||
/** Freeze gameplay before the graceful-shutdown settlement drain takes its snapshot. */
|
||||
shutdown() {
|
||||
this._acceptingMoves = false;
|
||||
this._lastMove.clear();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MovementManager;
|
||||
|
||||
+83
-14
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Provably Fair Gaming System
|
||||
*
|
||||
* This module implements cryptographic verification for fair game generation.
|
||||
* This module implements cryptographic verification for committed dungeon generation.
|
||||
*
|
||||
* How it works:
|
||||
* 1. Server generates a random seed before game starts
|
||||
@@ -17,10 +17,13 @@
|
||||
* 6. Player can verify: hash(seed) === pre-game commitment
|
||||
* 7. Player can regenerate the dungeon using the seed to verify fairness
|
||||
*
|
||||
* This prevents the server from:
|
||||
* This lets a player detect the server:
|
||||
* - Generating impossible dungeons after seeing player's payment
|
||||
* - Changing dungeon mid-game
|
||||
* - Claiming different game outcomes
|
||||
* - Substituting a different recorded dungeon layout after the run
|
||||
*
|
||||
* Outcome execution is a separate server-authoritative concern. Without a signed input/event
|
||||
* transcript, this proof does not independently replay block timing, player moves, monster turns,
|
||||
* the declared result, or payout delivery.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
@@ -42,6 +45,16 @@ function hashSeed(seed) {
|
||||
return crypto.createHash('sha256').update(seed).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a client contribution. Empty is a supported legacy value; non-empty values must be
|
||||
* short hex so they are unambiguous across browser/server implementations. `null` means invalid.
|
||||
*/
|
||||
function normalizeClientSeed(value) {
|
||||
if (value === undefined || value === null || String(value).trim() === '') return '';
|
||||
const seed = String(value).trim();
|
||||
return /^[0-9a-fA-F]{1,64}$/.test(seed) ? seed.toLowerCase() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that a seed matches its commitment hash
|
||||
* @param {string} seed - The revealed seed
|
||||
@@ -156,17 +169,26 @@ function seededShuffle(rng, array) {
|
||||
* @param {string} [clientSeed=''] - Optional client-supplied seed (non-breaking)
|
||||
* @returns {object} Proof object with seeds, commitment, and creation time
|
||||
*/
|
||||
function createGameProof(gameId, clientSeed = '') {
|
||||
const serverSeed = generateSeed();
|
||||
function createGameProof(gameId, clientSeed = '', precommit = null) {
|
||||
const normalizedClientSeed = normalizeClientSeed(clientSeed);
|
||||
if (normalizedClientSeed === null) throw new Error('Invalid client seed');
|
||||
|
||||
const serverSeed = precommit?.serverSeed || generateSeed();
|
||||
const commitment = hashSeed(serverSeed); // commits to serverSeed only
|
||||
if (precommit?.commitment && precommit.commitment !== commitment) {
|
||||
throw new Error('Fairness offer commitment does not match its server seed');
|
||||
}
|
||||
// Effective RNG seed folds in the client seed so neither party controls it.
|
||||
const seed = deriveSeed(serverSeed, clientSeed);
|
||||
const seed = deriveSeed(serverSeed, normalizedClientSeed);
|
||||
const timestamp = Date.now();
|
||||
|
||||
return {
|
||||
gameId,
|
||||
proofVersion: precommit?.proofVersion || 1,
|
||||
offerId: precommit?.offerId || null,
|
||||
offerIssuedAt: precommit?.offerIssuedAt || null,
|
||||
serverSeed, // Keep secret until game ends
|
||||
clientSeed, // Client contribution (may be '')
|
||||
clientSeed: normalizedClientSeed, // Client contribution (may be '')
|
||||
seed, // Derived effective seed used to generate the dungeon
|
||||
commitment, // Show to player before game starts
|
||||
timestamp,
|
||||
@@ -183,6 +205,10 @@ function createGameProof(gameId, clientSeed = '') {
|
||||
function getPreGameCommitment(proof) {
|
||||
return {
|
||||
gameId: proof.gameId,
|
||||
proofVersion: proof.proofVersion || 1,
|
||||
offerId: proof.offerId || null,
|
||||
offerIssuedAt: proof.offerIssuedAt || null,
|
||||
clientSeed: proof.clientSeed ?? '',
|
||||
commitment: proof.commitment,
|
||||
timestamp: proof.timestamp,
|
||||
message: "This hash commits to your game's layout. After the game, you'll receive the seed to verify fairness."
|
||||
@@ -203,10 +229,18 @@ function getPostGameReveal(proof, gameResult = {}) {
|
||||
const effectiveSeed = proof.seed ?? deriveSeed(serverSeed, clientSeed);
|
||||
return {
|
||||
gameId: proof.gameId,
|
||||
proofVersion: proof.proofVersion || 1,
|
||||
offerId: proof.offerId || null,
|
||||
offerIssuedAt: proof.offerIssuedAt || null,
|
||||
serverSeed,
|
||||
clientSeed,
|
||||
seed: effectiveSeed,
|
||||
seed: effectiveSeed, // backward-compatible alias
|
||||
effectiveSeed,
|
||||
commitment: proof.commitment,
|
||||
layoutFingerprint: proof.layoutFingerprint || null,
|
||||
layoutFingerprints: proof.layoutFingerprints || null,
|
||||
generatorVersion: proof.generatorVersion || proof.context?.generatorVersion || null,
|
||||
context: proof.context || null,
|
||||
timestamp: proof.timestamp,
|
||||
gameResult: {
|
||||
won: gameResult.won ?? false,
|
||||
@@ -214,17 +248,50 @@ function getPostGameReveal(proof, gameResult = {}) {
|
||||
moves: gameResult.moves ?? 0,
|
||||
duration: gameResult.duration ?? 0
|
||||
},
|
||||
verificationUrl: `/verify/${effectiveSeed}`,
|
||||
verificationUrl: `/verify/${proof.gameId}`,
|
||||
instructions: [
|
||||
"To verify this game was fair:",
|
||||
"To verify this game's committed dungeon generation:",
|
||||
`1. Compute SHA-256 of the serverSeed and confirm it equals the commitment: ${proof.commitment}`,
|
||||
"2. Compute HMAC-SHA256(key=serverSeed, msg=clientSeed) to derive the effective seed",
|
||||
"3. The effective seed deterministically generates the dungeon layout, treasure, and monster behaviour",
|
||||
`4. Open ${`/verify/${effectiveSeed}`} to regenerate the dungeon from the effective seed and compare its layout fingerprint`
|
||||
"3. The effective seed deterministically generates every dungeon depth and its recorded placements",
|
||||
`4. Open ${`/verify/${proof.gameId}`} to regenerate every depth and compare the versioned layout fingerprints`,
|
||||
"5. This layout proof does not replay player input, block timing, monster turns, the result, or payout delivery"
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
/** Verify both halves of a v2 proof: the published server commitment and derived RNG seed. */
|
||||
function verifyGameProof({ serverSeed, clientSeed = '', effectiveSeed, seed, commitment } = {}) {
|
||||
const normalizedClientSeed = normalizeClientSeed(clientSeed);
|
||||
const revealedServerSeed = String(serverSeed || '');
|
||||
const claimedEffectiveSeed = String(effectiveSeed || seed || '');
|
||||
const expectedCommitment = String(commitment || '');
|
||||
const shapeValid = /^[0-9a-f]{64}$/i.test(revealedServerSeed)
|
||||
&& /^[0-9a-f]{64}$/i.test(claimedEffectiveSeed)
|
||||
&& /^[0-9a-f]{64}$/i.test(expectedCommitment)
|
||||
&& normalizedClientSeed !== null;
|
||||
const computedCommitment = shapeValid ? hashSeed(revealedServerSeed) : '';
|
||||
const computedEffectiveSeed = shapeValid ? deriveSeed(revealedServerSeed, normalizedClientSeed) : '';
|
||||
const commitmentValid = shapeValid && computedCommitment === expectedCommitment;
|
||||
const effectiveSeedValid = shapeValid && computedEffectiveSeed === claimedEffectiveSeed;
|
||||
const valid = commitmentValid && effectiveSeedValid;
|
||||
|
||||
return {
|
||||
valid,
|
||||
commitmentValid,
|
||||
effectiveSeedValid,
|
||||
serverSeed: revealedServerSeed,
|
||||
clientSeed: normalizedClientSeed === null ? String(clientSeed || '') : normalizedClientSeed,
|
||||
effectiveSeed: claimedEffectiveSeed,
|
||||
expectedCommitment,
|
||||
computedCommitment,
|
||||
computedEffectiveSeed,
|
||||
message: valid
|
||||
? '✅ Game proof verified: the server commitment and client-derived dungeon seed both match.'
|
||||
: '❌ Game proof verification failed.'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verification result for a game
|
||||
* @param {string} seed - The revealed seed
|
||||
@@ -250,6 +317,7 @@ function verifyGame(seed, expectedCommitment) {
|
||||
module.exports = {
|
||||
generateSeed,
|
||||
hashSeed,
|
||||
normalizeClientSeed,
|
||||
verifySeed,
|
||||
deriveSeed,
|
||||
createSeededRNG,
|
||||
@@ -260,5 +328,6 @@ module.exports = {
|
||||
createGameProof,
|
||||
getPreGameCommitment,
|
||||
getPostGameReveal,
|
||||
verifyGame
|
||||
verifyGame,
|
||||
verifyGameProof
|
||||
};
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
const { defineRuleset, WIN, ECONOMY } = require('./Ruleset');
|
||||
const { resolveWinCondition } = require('./winConditions');
|
||||
const { BUILTINS, getRuleset, listRulesets, rulesetFromMatchOpts } = require('./registry');
|
||||
const {
|
||||
BUILTINS,
|
||||
getRuleset,
|
||||
listRulesets,
|
||||
listMatchRulesets,
|
||||
resolveMatchRuleset,
|
||||
rulesetFromMatchOpts
|
||||
} = require('./registry');
|
||||
|
||||
module.exports = {
|
||||
defineRuleset,
|
||||
@@ -10,5 +17,7 @@ module.exports = {
|
||||
BUILTINS,
|
||||
getRuleset,
|
||||
listRulesets,
|
||||
listMatchRulesets,
|
||||
resolveMatchRuleset,
|
||||
rulesetFromMatchOpts
|
||||
};
|
||||
|
||||
@@ -11,32 +11,37 @@ const BUILTINS = {
|
||||
id: 'solo-classic', label: 'Classic Escape', mode: 'solo',
|
||||
players: { min: 1, max: 1 },
|
||||
winCondition: { type: WIN.FIRST_TO_EXIT },
|
||||
economy: { model: ECONOMY.FREE }
|
||||
economy: { model: ECONOMY.FREE },
|
||||
metadata: { description: 'The original single-player escape dungeon.' }
|
||||
}),
|
||||
'race': defineRuleset({
|
||||
id: 'race', label: 'Escape Race', mode: 'race',
|
||||
world: { difficultyPreset: 'race' }, // matches DEFAULT_MATCH_PRESET (falls through difficultyConfig, preserved)
|
||||
players: { min: 2, max: 8 },
|
||||
winCondition: { type: WIN.FIRST_TO_EXIT },
|
||||
economy: { model: ECONOMY.CRYPTO_RACE }
|
||||
economy: { model: ECONOMY.CRYPTO_RACE },
|
||||
metadata: { description: 'First player to reach the exit wins.' }
|
||||
}),
|
||||
'last-alive': defineRuleset({
|
||||
id: 'last-alive', label: 'Last Alive', mode: 'pvp',
|
||||
world: { difficultyPreset: 'normal' },
|
||||
entities: { monster: true, pvpCombat: true }, // step onto a rival to strike them down
|
||||
players: { min: 2, max: 8 },
|
||||
winCondition: { type: WIN.LAST_ALIVE }
|
||||
winCondition: { type: WIN.LAST_ALIVE },
|
||||
metadata: { description: 'Outlast rivals or strike them down by moving into their tile.' }
|
||||
}),
|
||||
'score-attack': defineRuleset({
|
||||
id: 'score-attack', label: 'Score Attack', mode: 'race',
|
||||
players: { min: 1, max: 8 },
|
||||
winCondition: { type: WIN.HIGH_SCORE }
|
||||
winCondition: { type: WIN.HIGH_SCORE },
|
||||
metadata: { description: 'Highest score at the deadline wins; escaping does not end the match.' }
|
||||
}),
|
||||
'coop-escape': defineRuleset({
|
||||
id: 'coop-escape', label: 'Co-op Escape', mode: 'coop',
|
||||
entities: { pvpCombat: false },
|
||||
players: { min: 2, max: 8 },
|
||||
winCondition: { type: WIN.ALL_ESCAPE }
|
||||
winCondition: { type: WIN.ALL_ESCAPE },
|
||||
metadata: { description: 'Work together until every surviving player escapes.' }
|
||||
})
|
||||
};
|
||||
|
||||
@@ -49,10 +54,30 @@ function getRuleset(id, overrides = null) {
|
||||
function listRulesets() {
|
||||
return Object.values(BUILTINS).map(r => ({
|
||||
id: r.id, label: r.label, mode: r.mode,
|
||||
description: r.metadata.description || '',
|
||||
players: r.players, winCondition: r.winCondition.type, pvpCombat: r.entities.pvpCombat
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Match-mode catalog. `solo-classic` describes the separate solo engine and must never be
|
||||
* selected for a multiplayer queue. Keeping this filter beside the registry gives the server,
|
||||
* scheduler, and clients one stable allowlist.
|
||||
*/
|
||||
function listMatchRulesets() {
|
||||
return listRulesets().filter(r => r.mode !== 'solo');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an operator-selected multiplayer ruleset. Unknown/solo ids fail closed to the classic
|
||||
* race, preserving the historical behavior and preventing arbitrary client-authored specs.
|
||||
*/
|
||||
function resolveMatchRuleset(id) {
|
||||
const key = typeof id === 'string' ? id.trim() : '';
|
||||
const candidate = BUILTINS[key];
|
||||
return candidate && candidate.mode !== 'solo' ? candidate : BUILTINS.race;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the legacy MatchRoom option bag ({economy, variant, difficultyPreset, maxPlayers}) to a
|
||||
* ruleset that preserves the current race behavior — win condition stays first-to-exit regardless
|
||||
@@ -71,4 +96,11 @@ function rulesetFromMatchOpts(opts = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { BUILTINS, getRuleset, listRulesets, rulesetFromMatchOpts };
|
||||
module.exports = {
|
||||
BUILTINS,
|
||||
getRuleset,
|
||||
listRulesets,
|
||||
listMatchRulesets,
|
||||
resolveMatchRuleset,
|
||||
rulesetFromMatchOpts
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* onExit(room, id) — a player reached the exit. Decide whether that ends the match / sets a winner.
|
||||
* onDeath(room) — a player just died. Decide whether the match is now over.
|
||||
* rank(room) — final ordering (best-first) used by finalize() for placement + winnerId.
|
||||
* rank(room) — final ordering used for placement and competitive winnerId.
|
||||
*
|
||||
* FIRST_TO_EXIT reproduces the classic race behavior byte-for-byte (so existing matches are
|
||||
* unchanged); LAST_ALIVE and HIGH_SCORE are the new PvP / score modes.
|
||||
|
||||
Reference in New Issue
Block a user