feat: productionize paid modes and PvP capture

This commit is contained in:
jwinterm
2026-07-21 20:41:27 -04:00
parent 8fb284f8ed
commit 89398335c7
230 changed files with 31755 additions and 2777 deletions
+7 -1
View File
@@ -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,
+133
View File
@@ -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;
+118
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+28 -21
View File
@@ -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 = {
+13
View File
@@ -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
View File
@@ -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
};
+10 -1
View File
@@ -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
};
+38 -6
View File
@@ -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
};
+1 -1
View File
@@ -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.