Files
wownerogue/test/rulesets_ultra.test.js
T
jwinterm 5753e919e5 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.
2026-07-10 19:20:23 -04:00

119 lines
5.0 KiB
JavaScript

/**
* 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');
});
});