From 5753e919e530d81c508caf55b79cdf8ad168d4eb Mon Sep 17 00:00:00 2001 From: jwinterm Date: Fri, 10 Jul 2026 19:20:23 -0400 Subject: [PATCH] feat(game): Ruleset engine + PvP modes on the match engine (Pillar 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gameplay becomes a data object. A Ruleset captures world/entities/players/ win-condition/timing/economy, and MatchRoom now READS it instead of hardcoding 'first to the exit wins' — so new modes are data, not new subsystems. - src/game/rulesets/Ruleset.js: defineRuleset normalizer (frozen, clamped, safe defaults) + WIN/ECONOMY enums. - src/game/rulesets/winConditions.js: strategies with onExit/onDeath/rank hooks. FIRST_TO_EXIT is a verbatim port of the old race logic (so existing matches are byte-identical); LAST_ALIVE (PvP battle-royale), HIGH_SCORE (score attack) and ALL_ESCAPE (co-op) are new. - src/game/rulesets/registry.js: built-ins (solo-classic descriptor, race, last-alive, score-attack, coop-escape) + getRuleset/listRulesets + rulesetFromMatchOpts (legacy opts -> race, preserved). - MatchRoom: resolves a ruleset (explicit object / rulesetId / legacy opts), delegates win/lose/rank to the strategy, honors entities.monster (optional monster) and entities.pvpCombat (stepping onto a living rival strikes them down; attacker holds position), and takes timing from the ruleset. deathOrder tracked for the last-alive tiebreak. The live single-player engine is intentionally NOT refactored (represented as the 'solo-classic' descriptor for catalog parity only — too risky to touch the deployed path). PvP modes are opt-in via rulesetId; the scheduler still builds 'race' so nothing changes until wired. Behavior-preserving: all 5 existing match suites (42 tests) green unchanged. Tests: +9 (ruleset normalize/registry + real-MatchRoom last-alive kill->winner, high-score no-instant-win, race-unchanged). Suite 55 suites / 391 tests green. --- src/game/rulesets/Ruleset.js | 81 +++++++++++++++ src/game/rulesets/index.js | 14 +++ src/game/rulesets/registry.js | 74 ++++++++++++++ src/game/rulesets/winConditions.js | 158 +++++++++++++++++++++++++++++ src/multiplayer/MatchRoom.js | 116 ++++++++++----------- test/rulesets_ultra.test.js | 118 +++++++++++++++++++++ 6 files changed, 504 insertions(+), 57 deletions(-) create mode 100644 src/game/rulesets/Ruleset.js create mode 100644 src/game/rulesets/index.js create mode 100644 src/game/rulesets/registry.js create mode 100644 src/game/rulesets/winConditions.js create mode 100644 test/rulesets_ultra.test.js diff --git a/src/game/rulesets/Ruleset.js b/src/game/rulesets/Ruleset.js new file mode 100644 index 0000000..4c8bf02 --- /dev/null +++ b/src/game/rulesets/Ruleset.js @@ -0,0 +1,81 @@ +/** + * Ruleset — gameplay as a data object (Pillar 4). + * + * A Ruleset captures everything that makes one game mode different from another: the world it + * generates, the entities in it, how many players, how someone wins, the clock, and the economy. + * The match engine READS a ruleset instead of hardcoding rules, so new modes (PvP Last-Alive, + * Score Attack, Co-op) are data — not new subsystems — and operators/players can later author their + * own lobbies by supplying a ruleset object. + * + * defineRuleset normalizes a partial spec to a complete, frozen ruleset with safe defaults. The + * defaults reproduce the classic single-dungeon escape so an empty spec is a sane baseline. + */ + +const WIN = { + FIRST_TO_EXIT: 'first-to-exit', // first player out the exit wins, ends immediately (classic race) + LAST_ALIVE: 'last-alive', // last player still alive-and-in-play wins (PvP / battle royale) + HIGH_SCORE: 'high-score', // highest score when the match ends (score attack) + ALL_ESCAPE: 'all-escape' // co-op: everyone must reach the exit +}; + +const ECONOMY = { FREE: 'free', CREDITS_PRESTIGE: 'credits_prestige', CRYPTO_RACE: 'crypto_race' }; + +const num = (v, d) => (Number.isFinite(v) ? v : d); +const clampInt = (v, lo, hi, d) => { + const n = parseInt(v, 10); + return Number.isFinite(n) ? Math.max(lo, Math.min(hi, n)) : d; +}; + +function defineRuleset(spec = {}) { + const s = spec || {}; + const world = s.world || {}; + const entities = s.entities || {}; + const players = s.players || {}; + const winCondition = s.winCondition || {}; + const timing = s.timing || {}; + const economy = s.economy || {}; + + const rs = { + id: String(s.id || 'custom'), + label: String(s.label || s.id || 'Custom'), + mode: s.mode || 'race', // 'solo' | 'race' | 'pvp' | 'coop' — a coarse family label + world: Object.freeze({ + difficultyPreset: world.difficultyPreset || 'normal', + maps: clampInt(world.maps, 1, 10, 1) // reserved for multi-level; engine uses 1 today + }), + entities: Object.freeze({ + monster: entities.monster !== false, // default true + monsterCount: clampInt(entities.monsterCount, 0, 8, entities.monster === false ? 0 : 1), + pvpCombat: !!entities.pvpCombat // stepping onto a rival kills them instead of being blocked + }), + players: Object.freeze({ + min: clampInt(players.min, 1, 32, 1), + max: clampInt(players.max, 1, 32, 4) + }), + winCondition: Object.freeze({ + type: Object.values(WIN).includes(winCondition.type) ? winCondition.type : WIN.FIRST_TO_EXIT + }), + timing: Object.freeze({ + tickMs: clampInt(timing.tickMs, 50, 5000, 250), + minDurationMs: clampInt(timing.minDurationMs, 0, 600000, 20000), + hardCeilingMs: clampInt(timing.hardCeilingMs, 1000, 3600000, 240000), + blockDeadline: timing.blockDeadline !== false + }), + economy: Object.freeze({ + model: Object.values(ECONOMY).includes(economy.model) ? economy.model : ECONOMY.FREE, + houseFeePercent: num(economy.houseFeePercent, 0), + payoutMultipliers: Object.freeze({ + escape: num(economy.payoutMultipliers && economy.payoutMultipliers.escape, 2), + escapeWithTreasure: num(economy.payoutMultipliers && economy.payoutMultipliers.escapeWithTreasure, 3) + }) + }), + metadata: Object.freeze({ ...(s.metadata || {}) }) + }; + // normalize min<=max + if (rs.players.min > rs.players.max) { + rs.players = Object.freeze({ min: rs.players.max, max: rs.players.max }); + } + return Object.freeze(rs); +} + +module.exports = { defineRuleset, WIN, ECONOMY }; diff --git a/src/game/rulesets/index.js b/src/game/rulesets/index.js new file mode 100644 index 0000000..013816f --- /dev/null +++ b/src/game/rulesets/index.js @@ -0,0 +1,14 @@ +const { defineRuleset, WIN, ECONOMY } = require('./Ruleset'); +const { resolveWinCondition } = require('./winConditions'); +const { BUILTINS, getRuleset, listRulesets, rulesetFromMatchOpts } = require('./registry'); + +module.exports = { + defineRuleset, + WIN, + ECONOMY, + resolveWinCondition, + BUILTINS, + getRuleset, + listRulesets, + rulesetFromMatchOpts +}; diff --git a/src/game/rulesets/registry.js b/src/game/rulesets/registry.js new file mode 100644 index 0000000..7604453 --- /dev/null +++ b/src/game/rulesets/registry.js @@ -0,0 +1,74 @@ +const { defineRuleset, WIN, ECONOMY } = require('./Ruleset'); + +/** + * Built-in rulesets. `race` reproduces the current match behavior exactly (so nothing changes for + * existing matches). `last-alive` and `score-attack` are the new PvP / score modes the match engine + * now supports as data. `solo-classic` describes the live single-player escape for catalog/parity — + * the single-player engine isn't refactored to consume it yet, so it's a descriptor, not wired. + */ +const BUILTINS = { + 'solo-classic': defineRuleset({ + id: 'solo-classic', label: 'Classic Escape', mode: 'solo', + players: { min: 1, max: 1 }, + winCondition: { type: WIN.FIRST_TO_EXIT }, + economy: { model: ECONOMY.FREE } + }), + '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 } + }), + '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 } + }), + 'score-attack': defineRuleset({ + id: 'score-attack', label: 'Score Attack', mode: 'race', + players: { min: 1, max: 8 }, + winCondition: { type: WIN.HIGH_SCORE } + }), + '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 } + }) +}; + +function getRuleset(id, overrides = null) { + const base = BUILTINS[id] || BUILTINS.race; + if (!overrides) return base; + return defineRuleset({ ...base, ...overrides, id: base.id, label: base.label }); +} + +function listRulesets() { + return Object.values(BUILTINS).map(r => ({ + id: r.id, label: r.label, mode: r.mode, + players: r.players, winCondition: r.winCondition.type, pvpCombat: r.entities.pvpCombat + })); +} + +/** + * 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 + * of `variant` (which was a stored-but-unused label), so wiring the engine to rulesets changes + * nothing until a caller explicitly asks for a different ruleset by id. + */ +function rulesetFromMatchOpts(opts = {}) { + return defineRuleset({ + id: 'race', + label: 'Escape Race', + mode: 'race', + world: { difficultyPreset: opts.difficultyPreset || 'race' }, + players: { min: 2, max: opts.maxPlayers || 8 }, + winCondition: { type: WIN.FIRST_TO_EXIT }, + economy: { model: opts.economy || ECONOMY.FREE } + }); +} + +module.exports = { BUILTINS, getRuleset, listRulesets, rulesetFromMatchOpts }; diff --git a/src/game/rulesets/winConditions.js b/src/game/rulesets/winConditions.js new file mode 100644 index 0000000..773edc7 --- /dev/null +++ b/src/game/rulesets/winConditions.js @@ -0,0 +1,158 @@ +/** + * Win-condition strategies. Each is a small object the MatchRoom consults at three decision points, + * so the room no longer hardcodes "first to the exit wins": + * + * 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. + * + * 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. + */ +const { WIN } = require('./Ruleset'); + +function manhattanToExit(room, occ, onlyIfAlive, state) { + const exit = room.dungeon.exit; + if (!exit || !occ) return Infinity; + if (onlyIfAlive && (!state || !state.alive)) return Infinity; + return Math.abs(occ.x - exit[0]) + Math.abs(occ.y - exit[1]); +} + +function buildRanked(room, onlyIfAlive) { + return Array.from(room.playerStates.entries()).map(([id, state]) => { + const occ = room.occupants.get(id); + return { id, state, occ, dist: manhattanToExit(room, occ, onlyIfAlive, state) }; + }); +} + +const FIRST_TO_EXIT = { + onExit(room, id) { + if (!room.winnerId) { + room.winnerId = id; + room.endReason = 'escaped'; + room.status = 'finished'; + } + }, + onDeath(room) { + if (room.activePlayerCount === 0 && room.finishCount === 0 && room.status === 'active') { + room.endReason = 'all_dead'; + room.status = 'finished'; + } + }, + // Verbatim port of the original finalize() sort. + rank(room) { + const preWinner = room.winnerId; + const ranked = buildRanked(room, true); + ranked.sort((a, b) => { + if (preWinner) { + const aw = a.id === preWinner ? 1 : 0; + const bw = b.id === preWinner ? 1 : 0; + if (aw !== bw) return bw - aw; + } + 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); + }); + return ranked; + } +}; + +// Last player still alive-and-in-play wins. Reaching the exit means you SURVIVED (safe) but does +// not instantly win; the match ends when at most one contender remains. +const LAST_ALIVE = { + _maybeEnd(room) { + const started = room.playerStates.size; + if (started >= 2 && room.activePlayerCount <= 1 && room.status === 'active') { + room.status = 'finished'; + room.endReason = room.activePlayerCount === 1 ? 'last_alive' : 'all_dead'; + } + }, + onExit(room /*, id */) { LAST_ALIVE._maybeEnd(room); }, + onDeath(room) { LAST_ALIVE._maybeEnd(room); }, + rank(room) { + // in-play (last standing) > escaped survivor > dead-later > dead-earlier; then treasure, id. + const tier = (s) => (s.alive && !s.finished ? 3 : (s.escaped ? 2 : (s.alive ? 1 : 0))); + const ranked = buildRanked(room, false); + ranked.sort((a, b) => { + const ta = tier(a.state), tb = tier(b.state); + if (ta !== tb) return tb - ta; + // among the dead, whoever died later ranks higher (survived longer) + const da = a.state.deathOrder || 0, db = b.state.deathOrder || 0; + if (da !== db) return db - da; + const tA = a.state.hasTreasure ? 1 : 0, tB = b.state.hasTreasure ? 1 : 0; + if (tA !== tB) return tB - tA; + if (a.dist !== b.dist) return a.dist - b.dist; + return a.id.localeCompare(b.id); + }); + return ranked; + } +}; + +// Highest score when everyone is resolved (or the clock expires). No instant win on exit. +const HIGH_SCORE = { + onExit(/* room, id */) { /* escaping just finishes the player; scoring decides the winner */ }, + onDeath(room) { + if (room.activePlayerCount === 0 && room.status === 'active') { + room.endReason = 'resolved'; + room.status = 'finished'; + } + }, + rank(room) { + const pre = (s, dist) => { + let v = 0; + if (s.escaped) v += 200; + if (s.hasTreasure) v += 200; + v += Math.max(0, 300 - Math.max(s.moves - 30, 0) * 3); + if (!s.escaped && Number.isFinite(dist)) v += Math.max(0, 100 - dist * 2); + return v; + }; + const ranked = buildRanked(room, false); + ranked.sort((a, b) => { + const sa = pre(a.state, a.dist), sb = pre(b.state, b.dist); + if (sa !== sb) return sb - sa; + if (a.state.moves !== b.state.moves) return a.state.moves - b.state.moves; + return a.id.localeCompare(b.id); + }); + return ranked; + } +}; + +// ALL_ESCAPE (co-op) — a lightweight variant of race: the match ends when everyone still alive has +// escaped (or all died). Ranking falls back to the race ordering. +const ALL_ESCAPE = { + onExit(room) { + if (room.activePlayerCount === 0 && room.finishCount > 0 && room.status === 'active') { + room.endReason = 'all_escaped'; + room.status = 'finished'; + } + }, + onDeath(room) { + if (room.activePlayerCount === 0 && room.status === 'active') { + room.endReason = room.finishCount > 0 ? 'all_escaped' : 'all_dead'; + room.status = 'finished'; + } + }, + rank(room) { return FIRST_TO_EXIT.rank(room); } +}; + +const STRATEGIES = { + [WIN.FIRST_TO_EXIT]: FIRST_TO_EXIT, + [WIN.LAST_ALIVE]: LAST_ALIVE, + [WIN.HIGH_SCORE]: HIGH_SCORE, + [WIN.ALL_ESCAPE]: ALL_ESCAPE +}; + +function resolveWinCondition(type) { + return STRATEGIES[type] || FIRST_TO_EXIT; +} + +module.exports = { resolveWinCondition, FIRST_TO_EXIT, LAST_ALIVE, HIGH_SCORE, ALL_ESCAPE }; diff --git a/src/multiplayer/MatchRoom.js b/src/multiplayer/MatchRoom.js index bacdb46..00f2732 100644 --- a/src/multiplayer/MatchRoom.js +++ b/src/multiplayer/MatchRoom.js @@ -22,6 +22,7 @@ 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 { defineRuleset, getRuleset, rulesetFromMatchOpts, resolveWinCondition } = require('../game/rulesets'); const CONSOLE_LOGGING = process.env.NODE_ENV === 'debug' || process.env.NODE_ENV === 'development'; @@ -46,10 +47,19 @@ class MatchRoom extends Room { // 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)); + // Resolve the ruleset (Pillar 4). Precedence: an explicit ruleset object, then a rulesetId + // from the registry, else one synthesized from the legacy opts — which preserves the classic + // race behavior exactly, so nothing changes until a caller asks for a different ruleset. + this.ruleset = opts.ruleset ? defineRuleset(opts.ruleset) + : (opts.rulesetId ? getRuleset(opts.rulesetId, opts.rulesetOverrides) : rulesetFromMatchOpts(opts)); + this.winCondition = resolveWinCondition(this.ruleset.winCondition.type); + this.pvpCombat = this.ruleset.entities.pvpCombat; + this._deathCounter = 0; // increments per death → last-alive tiebreak (died later ranks higher) + + this.economy = opts.economy || this.ruleset.economy.model; + this.variant = opts.variant || (this.pvpCombat ? 'pvp' : 'race'); + this.difficultyPreset = opts.difficultyPreset || this.ruleset.world.difficultyPreset || DEFAULT_MATCH_PRESET; + this.maxPlayers = Math.max(2, Math.min(32, opts.maxPlayers || this.ruleset.players.max || 4)); this.cryptoType = opts.cryptoType || process.env.CRYPTO_TYPE || 'WOW'; this.startBlockHeight = opts.startBlockHeight || null; this.createdAt = Date.now(); @@ -69,8 +79,9 @@ class MatchRoom extends Room { // Generate the shared dungeon this._generateDungeon(); - // Spawn players at entrance, monster in configured room - this._spawnMonster(); + // Spawn players at entrance, and the shared monster when the ruleset includes one. + this.monster = null; + if (this.ruleset.entities.monster) this._spawnMonster(); this._resetEntrants(opts.entrants || {}); // Shared treasure state @@ -79,9 +90,9 @@ class MatchRoom extends Room { // 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 + // Timer / deadline bookkeeping (from the ruleset; defaults reproduce the classic values). + this.minDurationMs = this.ruleset.timing.minDurationMs; // floor before next-block expiry can fire (20s) + this.hardCeilingMs = this.ruleset.timing.hardCeilingMs; // absolute max (4m) this.startedAt = null; this.endedAt = null; this.endReason = null; @@ -309,7 +320,19 @@ class MatchRoom extends Room { } 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'; + // PvP: stepping onto a living rival strikes them down; the attacker holds position. + if (this.pvpCombat) { + const victimId = this._livingOccupantIdAt(p.to.x, p.to.y, p.id); + if (victimId) { + this._killPlayer(victimId, p.id); + events.push({ type: 'player_death', id: victimId, killedBy: p.id, tick: this.tickCount }); + reason = 'attack'; + } else { + reason = 'occupied'; // only a corpse / non-target is here + } + } else { + reason = 'occupied'; + } } else if (destCount.get(`${p.to.x},${p.to.y}`) > 1) { reason = 'collision_pileup'; } @@ -399,6 +422,19 @@ class MatchRoom extends Room { return tile === "'1" || tile === "'2" || tile === 0 || tile === '>' || tile === '$M'; } + // The id of a living, in-play occupant standing on (x,y), excluding `excludeId`. Used for PvP + // strikes (a corpse or a finished/escaped player is not a valid target). + _livingOccupantIdAt(x, y, excludeId) { + for (const occ of this.occupants.values()) { + if (occ.id === excludeId) continue; + if (occ.x === x && occ.y === y) { + const s = this.playerStates.get(occ.id); + if (s && s.alive && !s.finished) return occ.id; + } + } + return null; + } + _checkResolution() { const events = []; const exit = this.dungeon.exit; @@ -422,15 +458,12 @@ class MatchRoom extends Room { events.push({ type: 'treasure_pickup', id: occ.id, x: occ.x, y: occ.y, tick: this.tickCount }); } - // Exit reached. + // Exit reached. The ruleset's win condition decides what that means (race: first out + // wins & ends; last-alive: you survived but the match continues; score: just finished). 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'; - } + this.winCondition.onExit(this, occ.id); } } @@ -443,6 +476,7 @@ class MatchRoom extends Room { if (!state || !state.alive) return; state.alive = false; state.killedBy = killedBy; + state.deathOrder = ++this._deathCounter; // later deaths rank higher in last-alive // Drop treasure at corpse if carrying. if (state.hasTreasure && this.treasure) { @@ -452,11 +486,8 @@ class MatchRoom extends Room { 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'; - } + // The ruleset decides whether this death ends the match (race: all-dead; last-alive: one left). + this.winCondition.onDeath(this); } _finishPlayer(id, escaped) { @@ -529,38 +560,10 @@ class MatchRoom extends Room { finalize() { if (this.status !== 'finished') return this.playerStates; - const exit = this.dungeon.exit; - // A winner already designated by first-to-escape (_checkResolution) must remain the - // winner: the first player out wins the race regardless of treasure/speed tiebreakers - // among later finishers. When no one escaped (timeout / all-dead) preWinner is null and - // ranking alone decides. - const preWinner = this.winnerId; - 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) => { - // Designated escape-winner always ranks first (placement #1 == crypto payout target). - if (preWinner) { - const aw = a.id === preWinner ? 1 : 0; - const bw = b.id === preWinner ? 1 : 0; - if (aw !== bw) return bw - aw; - } - 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); - }); + // The ruleset's win condition provides the final ordering (best-first). FIRST_TO_EXIT + // reproduces the classic ranking (escape-winner first, then finished > alive > closer-to- + // exit > treasure > fewer-moves); last-alive/high-score order by their own rules. + const ranked = this.winCondition.rank(this); for (let i = 0; i < ranked.length; i++) { const r = ranked[i]; @@ -568,10 +571,9 @@ class MatchRoom extends Room { r.state.score = this._calculateScore(r.state, i + 1, r.dist); } - // Winner consistency across ALL end paths (escape / timeout / all-dead): the crypto - // payout target (winnerId) and the leaderboard's placement #1 always name the SAME - // player. finalize() is the single source of truth — it runs after expire() in every - // path, so it reconciles any provisional winner expire() may have set. + // Winner consistency across ALL end paths: the payout target (winnerId) and the + // leaderboard's placement #1 always name the SAME player. finalize() is the single source + // of truth — it runs after expire() in every path, reconciling any provisional winner. if (ranked.length > 0) this.winnerId = ranked[0].id; return this.playerStates; diff --git a/test/rulesets_ultra.test.js b/test/rulesets_ultra.test.js new file mode 100644 index 0000000..4ccb4b6 --- /dev/null +++ b/test/rulesets_ultra.test.js @@ -0,0 +1,118 @@ +/** + * Pillar 4 — Ruleset engine + PvP modes on the match engine. + */ +const { defineRuleset, WIN, getRuleset, listRulesets, rulesetFromMatchOpts } = require('../src/game/rulesets'); +const MatchRoom = require('../src/multiplayer/MatchRoom'); + +const SEED = 'ruleset-test-seed'; + +describe('defineRuleset normalization', () => { + test('empty spec yields safe classic defaults', () => { + const r = defineRuleset({}); + expect(r.winCondition.type).toBe(WIN.FIRST_TO_EXIT); + expect(r.entities.monster).toBe(true); + expect(r.entities.pvpCombat).toBe(false); + expect(r.timing.tickMs).toBe(250); + expect(r.economy.model).toBe('free'); + expect(Object.isFrozen(r)).toBe(true); + }); + + test('clamps out-of-range values and unknown win type', () => { + const r = defineRuleset({ players: { min: 99, max: 2 }, timing: { tickMs: 1 }, winCondition: { type: 'nonsense' } }); + expect(r.players.max).toBe(2); + expect(r.players.min).toBeLessThanOrEqual(r.players.max); + expect(r.timing.tickMs).toBe(50); // clamped up to the floor + expect(r.winCondition.type).toBe(WIN.FIRST_TO_EXIT); // invalid -> default + }); + + test('monster:false forces monsterCount 0', () => { + expect(defineRuleset({ entities: { monster: false } }).entities.monsterCount).toBe(0); + }); +}); + +describe('registry', () => { + test('built-ins include the new PvP + score modes', () => { + const ids = listRulesets().map(r => r.id); + expect(ids).toEqual(expect.arrayContaining(['solo-classic', 'race', 'last-alive', 'score-attack'])); + expect(getRuleset('last-alive').entities.pvpCombat).toBe(true); + expect(getRuleset('score-attack').winCondition.type).toBe(WIN.HIGH_SCORE); + }); + + test('unknown id falls back to race', () => { + expect(getRuleset('does-not-exist').id).toBe('race'); + }); + + test('rulesetFromMatchOpts preserves race semantics (first-to-exit)', () => { + const r = rulesetFromMatchOpts({ economy: 'crypto_race', variant: 'pvp', difficultyPreset: 'race', maxPlayers: 6 }); + expect(r.winCondition.type).toBe(WIN.FIRST_TO_EXIT); // variant never silently changes the win rule + expect(r.economy.model).toBe('crypto_race'); + expect(r.players.max).toBe(6); + }); +}); + +function findAdjacentWalkable(room) { + for (let y = 0; y < room.rows; y++) { + for (let x = 0; x < room.cols - 1; x++) { + if (room.isWalkable(x, y) && room.isWalkable(x + 1, y)) return { x, y }; + } + } + return null; +} + +describe('MatchRoom drives new rulesets', () => { + test('last-alive PvP: stepping onto a rival kills them and ends the match with the survivor as winner', () => { + const room = new MatchRoom({ + ruleset: { id: 'test-pvp', winCondition: { type: WIN.LAST_ALIVE }, entities: { monster: false, pvpCombat: true }, players: { min: 2, max: 4 } }, + entrants: { a: { userId: 1 }, b: { userId: 2 } }, + seed: SEED + }); + expect(room.monster).toBeNull(); // monster:false honored + room.start(); + + const cell = findAdjacentWalkable(room); + room.occupants.get('a').moveTo(cell.x, cell.y); + room.occupants.get('b').moveTo(cell.x + 1, cell.y); + + room.queueMove('a', 1, 0); // a strikes east into b + const res = room.resolveTick(); + + expect(res.events.some(e => e.type === 'player_death' && e.id === 'b' && e.killedBy === 'a')).toBe(true); + expect(room.playerStates.get('b').alive).toBe(false); + expect(room.status).toBe('finished'); + expect(room.endReason).toBe('last_alive'); + + room.finalize(); + expect(room.winnerId).toBe('a'); + expect(room.playerStates.get('a').placement).toBe(1); + expect(room.playerStates.get('b').placement).toBe(2); + }); + + test('high-score: reaching the exit does NOT instantly win — the match keeps going', () => { + const room = new MatchRoom({ + ruleset: { id: 'test-score', winCondition: { type: WIN.HIGH_SCORE }, entities: { monster: false }, players: { min: 1, max: 4 } }, + entrants: { a: { userId: 1 }, b: { userId: 2 } }, + seed: SEED + }); + room.start(); + const exit = room.dungeon.exit; + room.occupants.get('a').moveTo(exit[0], exit[1]); + const res = room.resolveTick(); + + expect(room.playerStates.get('a').escaped).toBe(true); + expect(room.status).toBe('active'); // b is still in play — no instant win + expect(room.winnerId).toBeNull(); + expect(res.finished).toBe(false); + }); + + test('race (default) still ends the moment someone escapes', () => { + const room = new MatchRoom({ economy: 'crypto_race', entrants: { a: { userId: 1 }, b: { userId: 2 } }, seed: SEED }); + room.start(); + const exit = room.dungeon.exit; + room.occupants.get('a').moveTo(exit[0], exit[1]); + room.resolveTick(); + expect(room.status).toBe('finished'); + expect(room.endReason).toBe('escaped'); + room.finalize(); + expect(room.winnerId).toBe('a'); + }); +});