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.