mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-20 07:38:34 +02:00
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:
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user