feat(game): Ruleset engine + PvP modes on the match engine (Pillar 4)

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.
This commit is contained in:
jwinterm
2026-07-10 19:20:23 -04:00
parent 12b887f8f1
commit 5753e919e5
6 changed files with 504 additions and 57 deletions
+81
View File
@@ -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 };
+14
View File
@@ -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
};
+74
View File
@@ -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 };
+158
View File
@@ -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 };
+59 -57
View File
@@ -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;
+118
View File
@@ -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');
});
});