feat: real-time multiplayer race mode (free, prestige, crypto)

Adds a complete block-cadence multiplayer race mode alongside solo and tavern.
  Disabled by default; enable with MATCH_ENABLED=true.

  Highlights:
  - New shared engine: MatchRoom, MatchEngine, MatchState (transport-agnostic, unit-tested).
  - Per-economy queues: free, credits_prestige, crypto_race with atomic escrow/refund.
  - MatchScheduler drains queues on every new block and starts 2–32 player races.
  - MatchManager handles Socket.IO lifecycle, server-authoritative movement, reconnect grace,
    forfeit/AFK handling, and end-of-match persistence.
  - Crypto races use race-entry tickets (grants.race_entries) to avoid on-chain refunds; winner
    payout is queued through the existing batch payout/retry service.
  - Leaderboard integration: free → Pleb, prestige → /api/leaderboard?board=prestige,
    crypto → Hall of Champions.
  - Tavern integration: Queue for Race panel, active race list, and live spectator rendering.
  - Dedicated /match.html client with economy selector, race HUD, and post-match scoreboard.
  - Migration 022 adds matches, match_entrants, match_events, match_queue_entries,
    race_entry_transactions, users.race_entries, and payouts.match_id.

  Docs:
  - New docs/MATCH_MODE.md with full architecture, config, events, and operational notes.
  - Updated README.md, docs/TAVERN_AND_MULTIPLAYER.md, docs/TODO.md, src/.env.example,
    and src/.env.

  Tests: 40 new unit tests across 7 suites, all passing.

  Known follow-ups for real-world validation:
  - Full integration test with Postgres + Socket.IO.
  - Server boot check with MATCH_ENABLED=false (default) to ensure solo/tavern remain unaffected.
  - Browser render-kit verification in /match.html and tavern spectator.
  - Crypto-race payout end-to-end with real wallet-rpc.
This commit is contained in:
jwinterm
2026-07-09 13:26:52 -04:00
parent 8fa8b06b20
commit 0f68eec26b
31 changed files with 3963 additions and 8 deletions
+79
View File
@@ -0,0 +1,79 @@
/**
* MatchEngine — server tick driver for a MatchRoom.
*
* The engine is intentionally thin: all state/rules live in MatchRoom, and this class owns
* the timer that calls `room.resolveTick()` at a fixed interval. It also handles graceful
* shutdown and can be used by MatchManager to drive many concurrent matches.
*
* Transport-agnostic: no Socket.IO here. The manager that owns this engine consumes the
* tick result and broadcasts it.
*/
class MatchEngine {
/**
* @param {object} opts
* @param {MatchRoom} opts.room
* @param {number} [opts.tickMs] default 250ms
* @param {Function} [opts.onTick] callback(tickResult, room)
* @param {Function} [opts.onFinish] callback(room)
*/
constructor({ room, tickMs = 250, onTick = null, onFinish = null } = {}) {
if (!room) throw new Error('MatchEngine requires a MatchRoom');
this.room = room;
this.tickMs = tickMs;
this.onTick = onTick;
this.onFinish = onFinish;
this.timer = null;
this.running = false;
}
start() {
if (this.running) return false;
const started = this.room.start();
if (!started) return false;
this.running = true;
this.timer = setInterval(() => this._tick(), this.tickMs);
return true;
}
stop() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
this.running = false;
}
/**
* Run a single tick synchronously. Useful for tests and for manually stepping.
* @returns {{tick:number, events:Array, finished:boolean}}
*/
tick() {
return this._tick();
}
_tick() {
const result = this.room.resolveTick();
if (this.onTick) this.onTick(result, this.room);
if (result.finished) {
this.stop();
this.room.finalize();
if (this.onFinish) this.onFinish(this.room);
}
return result;
}
/**
* Force end (e.g. block deadline or hard ceiling). The manager should call this when
* it knows the match must end regardless of in-engine state.
* @param {string} reason
*/
expire(reason) {
this.room.expire(reason);
this.stop();
this.room.finalize();
if (this.onFinish) this.onFinish(this.room);
}
}
module.exports = MatchEngine;
+671
View File
@@ -0,0 +1,671 @@
/**
* MatchRoom — a server-authoritative multiplayer race room.
*
* Extends Room with a shared dungeon, shared monsters, treasure, and per-player
* life/death/finish state. The engine is transport-agnostic; MatchManager owns the
* Socket.IO rooms and the server tick timer.
*
* Invariants:
* • One shared deterministic dungeon per match (provably fair from match seed).
* • All player moves are queued and resolved together by MatchEngine each tick.
* • Player-on-player collision is enabled (solidOccupants = true).
* • One shared treasure exists; first player to reach it picks it up.
* • If the treasure carrier dies, the treasure drops at their corpse.
* • First player to reach the exit wins; timer expiry ranks by proximity.
*/
const { v4: uuidv4 } = require('uuid');
const crypto = require('crypto');
const Room = require('./Room');
const Occupant = require('./Occupant');
const DungeonGenerator = require('../game/dungeon');
const Monster = require('../game/monster');
const { createGameProof, createSeededRNG, seedToInt } = require('../game/provablyFair');
const { getDifficultyConfig, getMonsterSpawnRoomIndex, getTreasureRoomIndex } = require('../game/difficultyConfig');
const CONSOLE_LOGGING = process.env.NODE_ENV === 'debug' || process.env.NODE_ENV === 'development';
const DEFAULT_MATCH_PRESET = 'race';
class MatchRoom extends Room {
/**
* @param {object} opts
* @param {string} [opts.id]
* @param {string} [opts.economy] 'free' | 'credits_prestige' | 'crypto_race'
* @param {string} [opts.variant] 'race' | 'pvp' (default 'race')
* @param {string} [opts.difficultyPreset] key passed to getDifficultyConfig
* @param {number} [opts.maxPlayers] 2–32
* @param {object} [opts.entrants] id -> { userId, name, avatar, appearance }
* @param {string} [opts.seed] optional explicit seed (testing)
* @param {number} [opts.startBlockHeight] block that triggered this race
* @param {object} [opts.cryptoType] 'WOW' | 'XMR'
*/
constructor(opts = {}) {
const id = opts.id || uuidv4();
// Room constructor needs a layout or roomData. We'll build our own map afterwards,
// so pass a minimal placeholder and then overwrite the map state.
super({ id, type: 'match', layout: ['.'], solidOccupants: true, maxOccupants: opts.maxPlayers || 4 });
this.economy = opts.economy || 'free';
this.variant = opts.variant || 'race';
this.difficultyPreset = opts.difficultyPreset || DEFAULT_MATCH_PRESET;
this.maxPlayers = Math.max(2, Math.min(32, opts.maxPlayers || 4));
this.cryptoType = opts.cryptoType || process.env.CRYPTO_TYPE || 'WOW';
this.startBlockHeight = opts.startBlockHeight || null;
this.createdAt = Date.now();
this.tickCount = 0;
this.status = 'starting'; // starting -> active -> finished | cancelled
// Provably fair proof
const proof = createGameProof(id);
this.seed = opts.seed || proof.seed;
this.seedHash = crypto.createHash("sha256").update(this.seed).digest("hex");
this.seededRNG = createSeededRNG(this.seed);
this.seedInt = seedToInt(this.seed);
// Difficulty
this.difficultyConfig = getDifficultyConfig(this.cryptoType, { preset: this.difficultyPreset });
// Generate the shared dungeon
this._generateDungeon();
// Spawn players at entrance, monster in configured room
this._spawnMonster();
this._resetEntrants(opts.entrants || {});
// Shared treasure state
this.treasure = this.dungeon.treasure ? { x: this.dungeon.treasure[0], y: this.dungeon.treasure[1], carrierId: null } : null;
// Movement queue for current tick: occupantId -> { dx, dy }
this.moveQueue = new Map();
// Timer / deadline bookkeeping
this.minDurationMs = 20000; // 20s floor before next-block expiry can fire
this.hardCeilingMs = 240000; // 4m absolute max
this.startedAt = null;
this.endedAt = null;
this.endReason = null;
// Economy bookkeeping (set later by MatchManager).
this.entryFeeAtomic = opts.entryFeeAtomic || 0;
this.potAtomic = opts.potAtomic || 0;
this.houseFeeAtomic = opts.houseFeeAtomic || 0;
this.houseFeePercent = opts.houseFeePercent || 0;
this.winnerId = null;
// Event log for this match (also persisted to match_events by manager)
this.events = [];
}
_generateDungeon() {
const cfg = this.difficultyConfig.dungeon;
const gameConfig = {
width: cfg.width,
height: cfg.height,
floorVariation: 0.01,
torchEnabled: true,
torchDensity: 0.1,
primaryFloor: "'1",
secondaryFloor: "'2",
torchTile: 'torch',
rng: this.seededRNG,
seedInt: this.seedInt
};
this.dungeon = DungeonGenerator.generate(cfg.width, cfg.height, gameConfig);
// Build a walkable grid for Room's movement/collision helpers.
this.rows = cfg.height;
this.cols = cfg.width;
this.walkable = [];
for (let y = 0; y < this.rows; y++) {
const row = [];
for (let x = 0; x < this.cols; x++) {
const tile = this.dungeon.map[y] && this.dungeon.map[y][x];
row.push(tile === "'1" || tile === "'2" || tile === '>' || tile === '$M');
}
this.walkable.push(row);
}
// Spawns: entrance first, then nearby walkable tiles if many players.
const ex = this.dungeon.entrance[0];
const ey = this.dungeon.entrance[1];
const spawnCandidates = [{ x: ex, y: ey, d: 0 }];
for (let y = 0; y < this.rows; y++) {
for (let x = 0; x < this.cols; x++) {
if (this.isWalkable(x, y) && (x !== ex || y !== ey)) {
const d = Math.abs(x - ex) + Math.abs(y - ey);
spawnCandidates.push({ x, y, d });
}
}
}
spawnCandidates.sort((a, b) => a.d - b.d);
this.spawns = spawnCandidates.slice(0, this.maxPlayers);
}
_spawnMonster() {
if (this.dungeon.rooms && this.dungeon.rooms.length > 2) {
const idx = getMonsterSpawnRoomIndex(
this.dungeon.rooms,
this.difficultyConfig.monster.startDistanceFromPlayer
);
const room = this.dungeon.rooms[idx];
const center = room.getCenter();
this.monster = new Monster(center[0], center[1], {
visionRange: this.difficultyConfig.monster.visionRange || 12
});
} else {
this.monster = new Monster(0, 0, {
visionRange: this.difficultyConfig.monster.visionRange || 12
});
}
}
/**
* Replace the inherited tavern occupants with match entrants placed at the entrance.
* @param {object} entrants — id -> { userId, name, avatar, appearance }
*/
_resetEntrants(entrants) {
this.occupants.clear();
this.playerStates = new Map(); // id -> { alive, finished, escaped, hasTreasure, placement, killedBy }
const ids = Object.keys(entrants);
for (let i = 0; i < ids.length && i < this.maxPlayers; i++) {
const id = ids[i];
const e = entrants[id] || {};
const spawn = this.spawns[i] || this.spawns[0] || { x: 0, y: 0 };
const occ = new Occupant(id, {
x: spawn.x,
y: spawn.y,
name: e.name || null,
avatar: e.avatar || 'default',
appearance: e.appearance || null,
facing: 'down'
});
this.occupants.set(id, occ);
this.playerStates.set(id, {
userId: e.userId || null,
alive: true,
finished: false,
escaped: false,
hasTreasure: false,
placement: null,
killedBy: null,
score: 0,
moves: 0
});
}
}
/** Number of players still alive and not finished. */
get activePlayerCount() {
let n = 0;
for (const s of this.playerStates.values()) {
if (s.alive && !s.finished) n++;
}
return n;
}
/** Number of players who have reached the exit. */
get finishCount() {
let n = 0;
for (const s of this.playerStates.values()) {
if (s.finished) n++;
}
return n;
}
/**
* Queue a move for an occupant. Moves are not applied until resolveTick().
* @param {string} id
* @param {number} dx - -1, 0, or 1
* @param {number} dy - -1, 0, or 1
*/
queueMove(id, dx, dy) {
const occ = this.occupants.get(id);
const state = this.playerStates.get(id);
if (!occ || !state || !state.alive || state.finished) return false;
dx = Math.sign(dx || 0);
dy = Math.sign(dy || 0);
if (Math.abs(dx) + Math.abs(dy) !== 1) return false;
this.moveQueue.set(id, { dx, dy });
return true;
}
/**
* Resolve one server tick: apply all queued moves, resolve collisions, move the
* monster, check deaths/treasure/exits. Returns a tick result with events.
* @returns {{tick:number, events:Array, finished:boolean}}
*/
resolveTick() {
if (this.status !== 'active') {
return { tick: this.tickCount, events: [], finished: this.status === 'finished' };
}
this.tickCount++;
const events = [];
// 1. Resolve player moves simultaneously.
const moves = this._resolvePlayerMoves(events);
// 2. Move the shared monster toward the nearest visible active player.
const monsterEvents = this._moveMonster();
events.push(...monsterEvents);
// 3. Check win/death/treasure after all entities have moved.
const resolutionEvents = this._checkResolution();
events.push(...resolutionEvents);
this.moveQueue.clear();
if (this.status === 'finished') {
this.endedAt = Date.now();
events.push({ type: 'match_end', reason: this.endReason, winnerId: this.winnerId, tick: this.tickCount });
}
return { tick: this.tickCount, events, finished: this.status === 'finished' };
}
_resolvePlayerMoves(events) {
// Build a map of intended destinations so we can detect head-on swaps and pile-ups.
const plans = [];
for (const [id, { dx, dy }] of this.moveQueue.entries()) {
const occ = this.occupants.get(id);
const state = this.playerStates.get(id);
if (!occ || !state || !state.alive || state.finished) continue;
const nx = occ.x + dx;
const ny = occ.y + dy;
const facing = Room.facingFor(dx, dy);
plans.push({ id, from: { x: occ.x, y: occ.y }, to: { x: nx, y: ny }, facing });
}
// If A moves to B's tile and B moves to A's tile, both bounce.
const swapPairs = new Set();
for (let i = 0; i < plans.length; i++) {
for (let j = i + 1; j < plans.length; j++) {
const a = plans[i];
const b = plans[j];
if (a.to.x === b.from.x && a.to.y === b.from.y &&
b.to.x === a.from.x && b.to.y === a.from.y) {
swapPairs.add(a.id);
swapPairs.add(b.id);
}
}
}
// Destination counts for pile-up detection.
const destCount = new Map();
for (const p of plans) {
if (swapPairs.has(p.id)) continue;
const key = `${p.to.x},${p.to.y}`;
destCount.set(key, (destCount.get(key) || 0) + 1);
}
// Apply each valid move.
for (const p of plans) {
const occ = this.occupants.get(p.id);
let reason = null;
if (swapPairs.has(p.id)) {
reason = 'collision_swap';
} else if (!this.isWalkable(p.to.x, p.to.y)) {
reason = 'blocked';
} else if (this.solidOccupants && this.isOccupied(p.to.x, p.to.y, p.id)) {
reason = 'occupied';
} else if (destCount.get(`${p.to.x},${p.to.y}`) > 1) {
reason = 'collision_pileup';
}
if (reason) {
if (p.facing) occ.facing = p.facing;
events.push({ type: 'move_failed', id: p.id, reason, from: p.from, to: p.to, tick: this.tickCount });
continue;
}
occ.moveTo(p.to.x, p.to.y, p.facing);
const state = this.playerStates.get(p.id);
state.moves++;
events.push({ type: 'move', id: p.id, from: p.from, to: p.to, facing: p.facing, tick: this.tickCount });
// If the player stepped onto the monster, they die immediately.
if (this.monster && p.to.x === this.monster.x && p.to.y === this.monster.y) {
this._killPlayer(p.id, 'monster');
events.push({ type: 'player_death', id: p.id, killedBy: 'monster', tick: this.tickCount });
}
}
return events;
}
_moveMonster() {
if (!this.monster || this.activePlayerCount === 0) return [];
const events = [];
const occs = Array.from(this.occupants.values()).filter(o => {
const s = this.playerStates.get(o.id);
return s && s.alive && !s.finished;
});
if (occs.length === 0) return events;
// If the monster is already standing on a player, kill them immediately.
for (const occ of occs) {
if (occ.x === this.monster.x && occ.y === this.monster.y) {
this._killPlayer(occ.id, 'monster');
events.push({ type: 'player_death', id: occ.id, killedBy: 'monster', tick: this.tickCount });
}
}
// Remove dead players from the active set before choosing a target.
const activeOccs = occs.filter(o => {
const s = this.playerStates.get(o.id);
return s && s.alive && !s.finished;
});
if (activeOccs.length === 0) return events;
// Target nearest visible player.
let target = null;
let bestDist = Infinity;
for (const occ of activeOccs) {
const dist = this.monster.distanceTo(occ.x, occ.y);
if (dist < bestDist && this.monster.hasLineOfSight(occ.x, occ.y, this.dungeon, this._monsterPassable.bind(this))) {
bestDist = dist;
target = occ;
}
}
const movesPerTick = this.difficultyConfig.monster.movesPerPlayerMove || 1.0;
let movesRemaining = movesPerTick;
while (movesRemaining >= 1) {
movesRemaining--;
const moved = this.monster.moveTowardPlayer(
target,
this.dungeon,
this.seededRNG
);
if (moved) {
events.push({ type: 'monster_move', x: this.monster.x, y: this.monster.y, tick: this.tickCount });
// Check collisions with any player on this monster tile.
for (const occ of activeOccs) {
if (occ.x === this.monster.x && occ.y === this.monster.y) {
this._killPlayer(occ.id, 'monster');
events.push({ type: 'player_death', id: occ.id, killedBy: 'monster', tick: this.tickCount });
}
}
}
}
return events;
}
_monsterPassable(tile) {
return tile === "'1" || tile === "'2" || tile === 0 || tile === '>' || tile === '$M';
}
_checkResolution() {
const events = [];
const exit = this.dungeon.exit;
for (const occ of this.occupants.values()) {
const state = this.playerStates.get(occ.id);
if (!state || !state.alive || state.finished) continue;
// Monster collision (player moved onto monster tile).
if (this.monster && occ.x === this.monster.x && occ.y === this.monster.y) {
this._killPlayer(occ.id, 'monster');
events.push({ type: 'player_death', id: occ.id, killedBy: 'monster', tick: this.tickCount });
continue;
}
// Treasure pickup.
if (this.treasure && this.treasure.carrierId === null &&
occ.x === this.treasure.x && occ.y === this.treasure.y) {
this.treasure.carrierId = occ.id;
state.hasTreasure = true;
events.push({ type: 'treasure_pickup', id: occ.id, x: occ.x, y: occ.y, tick: this.tickCount });
}
// Exit reached.
if (exit && occ.x === exit[0] && occ.y === exit[1]) {
this._finishPlayer(occ.id, true);
events.push({ type: 'player_exit', id: occ.id, hasTreasure: state.hasTreasure, tick: this.tickCount });
if (!this.winnerId) {
this.winnerId = occ.id;
this.endReason = 'escaped';
this.status = 'finished';
}
}
}
return events;
}
_killPlayer(id, killedBy) {
const state = this.playerStates.get(id);
const occ = this.occupants.get(id);
if (!state || !state.alive) return;
state.alive = false;
state.killedBy = killedBy;
// Drop treasure at corpse if carrying.
if (state.hasTreasure && this.treasure) {
this.treasure.carrierId = null;
this.treasure.x = occ.x;
this.treasure.y = occ.y;
state.hasTreasure = false;
}
if (this.activePlayerCount === 0 && this.finishCount === 0 && this.status === 'active') {
// Everyone died without anyone escaping.
this.endReason = 'all_dead';
this.status = 'finished';
}
}
_finishPlayer(id, escaped) {
const state = this.playerStates.get(id);
if (!state || !state.alive || state.finished) return;
state.finished = true;
state.escaped = escaped;
if (this.treasure && this.treasure.carrierId === id) {
// Treasure carried out stays with the winner's record but is removed from the map.
this.treasure = null;
}
}
/**
* Transition from 'starting' to 'active', recording the start time.
*/
start() {
if (this.status !== 'starting') return false;
this.status = 'active';
this.startedAt = Date.now();
this.events.push({ type: 'match_start', tick: this.tickCount, seedHash: this.seedHash });
return true;
}
/**
* Force end because of a block deadline or hard ceiling. Determines winner by proximity
* to exit among living players, or by progress if no one is alive.
* @param {string} reason - 'block_deadline' | 'hard_ceiling' | 'abandoned'
*/
expire(reason) {
if (this.status === 'finished' || this.status === 'cancelled') return;
this.status = 'finished';
this.endReason = reason;
this.endedAt = Date.now();
const exit = this.dungeon.exit;
const ranked = Array.from(this.playerStates.entries()).map(([id, state]) => {
const occ = this.occupants.get(id);
const dist = exit && occ ? Math.abs(occ.x - exit[0]) + Math.abs(occ.y - exit[1]) : Infinity;
return { id, state, occ, dist };
});
// Living closer to exit beats dead; then treasure; then fewer moves; then deterministic.
ranked.sort((a, b) => {
const aliveA = a.state.alive && !a.state.finished ? 1 : 0;
const aliveB = b.state.alive && !b.state.finished ? 1 : 0;
if (aliveA !== aliveB) return aliveB - aliveA;
if (a.dist !== b.dist) return a.dist - b.dist;
const tA = a.state.hasTreasure ? 1 : 0;
const tB = b.state.hasTreasure ? 1 : 0;
if (tA !== tB) return tB - tA;
if (a.state.moves !== b.state.moves) return a.state.moves - b.state.moves;
return a.id.localeCompare(b.id);
});
if (ranked.length > 0 && (ranked[0].state.alive || ranked[0].state.finished)) {
this.winnerId = ranked[0].id;
}
// Assign placements deterministically.
for (let i = 0; i < ranked.length; i++) {
ranked[i].state.placement = i + 1;
}
}
/**
* Calculate final scores and placements. Called once when the match is finished.
* @returns {Map} playerStates with score and placement populated
*/
finalize() {
if (this.status !== 'finished') return this.playerStates;
const exit = this.dungeon.exit;
const ranked = Array.from(this.playerStates.entries()).map(([id, state]) => {
const occ = this.occupants.get(id);
const dist = exit && occ && state.alive ? Math.abs(occ.x - exit[0]) + Math.abs(occ.y - exit[1]) : Infinity;
return { id, state, occ, dist };
});
ranked.sort((a, b) => {
const finishedA = a.state.finished ? 1 : 0;
const finishedB = b.state.finished ? 1 : 0;
if (finishedA !== finishedB) return finishedB - finishedA;
const aliveA = a.state.alive ? 1 : 0;
const aliveB = b.state.alive ? 1 : 0;
if (aliveA !== aliveB) return aliveB - aliveA;
if (a.dist !== b.dist) return a.dist - b.dist;
const tA = a.state.hasTreasure ? 1 : 0;
const tB = b.state.hasTreasure ? 1 : 0;
if (tA !== tB) return tB - tA;
if (a.state.moves !== b.state.moves) return a.state.moves - b.state.moves;
return a.id.localeCompare(b.id);
});
for (let i = 0; i < ranked.length; i++) {
const r = ranked[i];
r.state.placement = i + 1;
r.state.score = this._calculateScore(r.state, i + 1, r.dist);
}
return this.playerStates;
}
_calculateScore(state, placement, distToExit) {
// Base solo-style score, then MP placement bonus.
let score = 100;
if (state.escaped) score += 200;
if (state.hasTreasure) score += 200;
// Speed bonus: fewer moves is better (cap at 300).
score += Math.max(0, 300 - Math.max(state.moves - 30, 0) * 3);
// Proximity bonus for non-escapers (closer to exit is better).
if (!state.escaped && Number.isFinite(distToExit)) {
score += Math.max(0, 100 - distToExit * 2);
}
// Placement bonus: winner gets 300, 2nd 200, 3rd 100, others 0.
const placementBonus = [300, 200, 100][placement - 1] || 0;
score += placementBonus;
return Math.max(0, Math.round(score));
}
/**
* Produce a renderer-agnostic game state object that the existing render-kit adapter
* `RK.sceneFromGameState()` can consume. This lets ASCII/Tiled/Fancy/Iso/3D modes all
* draw the same match with no extra renderer work.
*/
toGameState(viewerId = null) {
const visible = [];
const explored = [];
const lighting = {};
// For simplicity, the match spectator/client receives the full dungeon (no per-player
// FOV in the MVP). Later we can add per-viewer FOV by running LightingAndFov here.
for (let y = 0; y < this.rows; y++) {
const row = [];
for (let x = 0; x < this.cols; x++) {
row.push(this.dungeon.map[y][x]);
}
visible.push(row);
explored.push(row);
}
return {
matchId: this.id,
economy: this.economy,
tick: this.tickCount,
status: this.status,
visibleTiles: visible,
exploredTiles: explored,
lighting,
dungeonRows: this.rows,
dungeonCols: this.cols,
players: Array.from(this.occupants.values()).map(o => {
const s = this.playerStates.get(o.id);
return {
id: o.id,
x: o.x,
y: o.y,
name: o.name,
avatar: o.avatar,
appearance: o.appearance,
facing: o.facing,
alive: s ? s.alive : true,
finished: s ? s.finished : false,
escaped: s ? s.escaped : false,
hasTreasure: s ? s.hasTreasure : false,
placement: s ? s.placement : null,
you: o.id === viewerId
};
}),
monster: this.monster ? { x: this.monster.x, y: this.monster.y } : null,
treasure: this.treasure ? { x: this.treasure.x, y: this.treasure.y, carrierId: this.treasure.carrierId } : null,
exit: this.dungeon.exit,
entrance: this.dungeon.entrance,
winnerId: this.winnerId,
endReason: this.endReason
};
}
/** Snapshot for the shared-world layer (includes occupants only). */
snapshot() {
return {
...super.snapshot(),
economy: this.economy,
variant: this.variant,
tick: this.tickCount,
status: this.status,
treasure: this.treasure,
monster: this.monster ? { x: this.monster.x, y: this.monster.y } : null,
winnerId: this.winnerId,
endReason: this.endReason
};
}
/** Full state for a joining/reconnecting player. */
fullState(viewerId = null) {
return {
...this.snapshot(),
cols: this.cols,
rows: this.rows,
walkable: this.walkable,
dungeon: this.dungeon,
seedHash: this.seedHash,
gameState: this.toGameState(viewerId)
};
}
}
module.exports = MatchRoom;
+108
View File
@@ -0,0 +1,108 @@
/**
* MatchState — serialization helpers for MatchRoom.
*
* Produces renderer-agnostic and broadcast-safe state objects. The render kit already
* understands the `gameState` shape from `sceneModel.js`, so the primary job here is to
* wrap a MatchRoom into that shape and add match-specific metadata.
*/
class MatchState {
/**
* Build a spectator/client-friendly game-state object from a MatchRoom.
* Compatible with `RK.sceneFromGameState()` in `html/js/render/sceneModel.js`.
* @param {MatchRoom} room
* @param {string} [viewerId] — if provided, that player is marked `you`
* @returns {object}
*/
static toGameState(room, viewerId = null) {
if (!room) return null;
return room.toGameState(viewerId);
}
/**
* Build the shared-world snapshot used by Socket.IO broadcasts.
* @param {MatchRoom} room
* @returns {object}
*/
static snapshot(room) {
if (!room) return null;
return room.snapshot();
}
/**
* Full state for a joining or reconnecting player.
* @param {MatchRoom} room
* @param {string} [viewerId]
* @returns {object}
*/
static fullState(room, viewerId = null) {
if (!room) return null;
return room.fullState(viewerId);
}
/**
* Flatten a MatchRoom into the persistence shape for `match_events`.
* @param {MatchRoom} room
* @param {Array} events — events from the last tick (or full match)
* @returns {Array}
*/
static toEventRows(room, events = []) {
return events.map(ev => ({
match_id: room.id,
tick: ev.tick ?? room.tickCount,
type: ev.type,
payload: ev
}));
}
/**
* Flatten a MatchRoom into the persistence shape for `match_entrants` after finalize().
* @param {MatchRoom} room
* @returns {Array}
*/
static toEntrantRows(room) {
const rows = [];
for (const [id, state] of room.playerStates.entries()) {
rows.push({
match_id: room.id,
user_id: state.userId,
socket_id: id,
placement: state.placement,
escaped: state.escaped,
has_treasure: state.hasTreasure,
killed_by: state.killedBy,
score: state.score
});
}
return rows;
}
/**
* Build the persistence row for `matches` after the race ends.
* @param {MatchRoom} room
* @returns {object}
*/
static toMatchRow(room) {
return {
id: room.id,
status: room.status,
economy: room.economy,
variant: room.variant,
difficulty_preset: room.difficultyPreset,
max_players: room.maxPlayers,
seed_hash: room.seedHash,
seed: room.status === 'finished' ? room.seed : null,
dungeon: room.dungeon,
start_block_height: room.startBlockHeight,
started_at: room.startedAt ? new Date(room.startedAt) : null,
ended_at: room.endedAt ? new Date(room.endedAt) : null,
entry_fee_atomic: room.economy === 'crypto_race' ? room.entryFeeAtomic || 0 : 0,
pot_atomic: room.economy === 'crypto_race' ? room.potAtomic || 0 : 0,
house_fee_atomic: room.economy === 'crypto_race' ? room.houseFeeAtomic || 0 : 0,
house_fee_percent: room.houseFeePercent || 0,
winner_user_id: room.winnerId ? room.playerStates.get(room.winnerId)?.userId : null
};
}
}
module.exports = MatchState;