mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-11-12 18:14:15 +01:00
A browser-based roguelike synchronized with crypto block timing. A run is a race against the next block: reach the exit before the block lands, or the house wins. The same codebase runs the solo dungeon, the social Tavern, and operator-selected PvP and race rulesets, with separate free and paid leaderboards. Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md for the design, and docs/DEPLOY.md to operate an instance.
196 lines
12 KiB
Markdown
196 lines
12 KiB
Markdown
# Rulesets
|
|
|
|
Gameplay is a data object. The match engine reads a `Ruleset` instead of hardcoding "first to the
|
|
exit wins," so additional modes (PvP last-alive, score attack, co-op escape) are configuration rather
|
|
than new subsystems.
|
|
|
|
## The Ruleset object
|
|
|
|
`src/game/rulesets/Ruleset.js` exposes `defineRuleset(spec)`, which normalizes a partial spec into a
|
|
complete, frozen, range-clamped ruleset. An empty spec yields the classic single-dungeon escape.
|
|
|
|
| Field | Meaning | Default | Clamp |
|
|
| --- | --- | --- | --- |
|
|
| `id`, `label` | Identity and display name | `custom` / `Custom` | |
|
|
| `mode` | Coarse family: `solo`, `race`, `pvp`, `coop` | `race` | |
|
|
| `world.difficultyPreset` | Key passed to `getDifficultyConfig` | `normal` | |
|
|
| `world.maps` | Reserved for multi-level matches; the match engine generates one map | `1` | 1..10 |
|
|
| `entities.monster` | Spawn the shared monster | `true` | |
|
|
| `entities.monsterCount` | Reserved count field | `1` (`0` when `monster` is false) | 0..8 |
|
|
| `entities.pvpCombat` | Stepping onto a living rival strikes them down | `false` | |
|
|
| `players.min` / `players.max` | Entrant bounds; `min` is lowered to `max` if it exceeds it | `1` / `4` | 1..32 |
|
|
| `winCondition.type` | `first-to-exit`, `last-alive`, `high-score`, `all-escape` | `first-to-exit` | |
|
|
| `timing.tickMs` | Engine tick interval | `250` | 50..5000 |
|
|
| `timing.minDurationMs` | Active-play floor before a block deadline may fire | `20000` | 0..600000 |
|
|
| `timing.hardCeilingMs` | Absolute match length ceiling | `240000` | 1000..3600000 |
|
|
| `timing.blockDeadline` | End on the first advancing canonical header | `true` | |
|
|
| `economy.model` | `free`, `credits_prestige`, `crypto_race` | `free` | |
|
|
| `economy.houseFeePercent` | House rake for pot modes | `0` | |
|
|
| `economy.payoutMultipliers` | `escape`, `escapeWithTreasure` | `2`, `3` | |
|
|
| `metadata` | Free-form descriptor bag (`description` is surfaced to clients) | `{}` | |
|
|
|
|
Unrecognized `winCondition.type` and `economy.model` values fall back to the defaults rather than
|
|
propagating, and every nested section is frozen.
|
|
|
|
## Built-in rulesets
|
|
|
|
`src/game/rulesets/registry.js` ships the catalog and the helpers `getRuleset(id, overrides)`,
|
|
`listRulesets()`, `listMatchRulesets()`, `resolveMatchRuleset(id)`, and `rulesetFromMatchOpts(opts)`.
|
|
|
|
| id | mode | players | win condition | notes |
|
|
| --- | --- | --- | --- | --- |
|
|
| `solo-classic` | `solo` | 1 | `first-to-exit` | Catalog descriptor for the single-player escape. Never selectable for a multiplayer queue. |
|
|
| `race` | `race` | 2..8 | `first-to-exit` | Classic escape race; `race` difficulty preset; `crypto_race` economy. |
|
|
| `last-alive` | `pvp` | 2..8 | `last-alive` | `pvpCombat` enabled, monster present, `normal` preset. |
|
|
| `score-attack` | `race` | 1..8 | `high-score` | Escaping does not end the match; score decides. |
|
|
| `coop-escape` | `coop` | 2..8 | `all-escape` | `pvpCombat` disabled; ends when every survivor escapes. |
|
|
|
|
`listMatchRulesets()` filters out `mode === 'solo'`, giving the server, scheduler, and clients one
|
|
stable multiplayer allowlist. `resolveMatchRuleset(id)` fails closed to `race` for unknown or
|
|
solo-only ids. `rulesetFromMatchOpts()` maps the legacy `MatchRoom` option bag
|
|
(`economy`, `difficultyPreset`, `maxPlayers`) onto a `race` ruleset; the legacy `variant` label does
|
|
not change the win condition.
|
|
|
|
## Win conditions as strategies
|
|
|
|
`src/game/rulesets/winConditions.js` implements each condition as a small strategy object that
|
|
`MatchRoom` consults at three decision points:
|
|
|
|
```
|
|
onExit(room, id) a player reached the exit: end the match? set a winner?
|
|
onDeath(room) a player died: is the match over?
|
|
rank(room) final ordering (best-first) for placement; competitive modes also set winnerId
|
|
```
|
|
|
|
- **`FIRST_TO_EXIT`**: the first player out wins and the match ends immediately. Ranking orders
|
|
winner, then finished, alive, Manhattan distance to the exit, treasure, fewer moves, id.
|
|
- **`LAST_ALIVE`** (PvP): reaching the exit means you survived, not that you won; the match ends when
|
|
at most one contender remains in play and at least two started. Ranking orders last-standing,
|
|
escaped survivor, then later deaths above earlier ones (`deathOrder`), then treasure, distance, id.
|
|
With `entities.pvpCombat`, moving onto a living rival's tile strikes them down and the attacker
|
|
holds position.
|
|
- **`HIGH_SCORE`**: no instant win. Score rewards escaping, carrying treasure, move efficiency, and
|
|
proximity to the exit for players still inside when the match resolves.
|
|
- **`ALL_ESCAPE`** (co-op): ends when every player still alive has escaped (or all have died) and
|
|
reuses the race ordering for placements.
|
|
|
|
`resolveWinCondition(type)` returns `FIRST_TO_EXIT` for any unknown type.
|
|
|
|
## Engine integration
|
|
|
|
`MatchRoom` (`src/multiplayer/MatchRoom.js`) resolves its ruleset with the precedence
|
|
`opts.ruleset` (an explicit spec), then `opts.rulesetId` plus optional `opts.rulesetOverrides`, then
|
|
`rulesetFromMatchOpts(opts)`. It then:
|
|
|
|
- spawns the shared monster only when `entities.monster` is set;
|
|
- takes `minDurationMs` and `hardCeilingMs` from `timing` (the manager and scheduler may override);
|
|
- clamps `maxPlayers` into 2..32 from `opts.maxPlayers` or `players.max`;
|
|
- delegates `_checkResolution` to `winCondition.onExit`, `_killPlayer` to `winCondition.onDeath`, and
|
|
`finalize` to `winCondition.rank`;
|
|
- treats co-op modes (`mode === 'coop'` or `all-escape`) as collective, recording placements as
|
|
progress metadata and leaving the individual `winnerId` null;
|
|
- exposes a client-safe summary (`id`, `label`, `mode`, `description`, `winCondition`, `pvpCombat`).
|
|
|
|
Terminal players stay in `occupants` so spectators and final results can render them, but corpses and
|
|
escaped heroes are pass-through: co-op and score attack require several players to cross the single
|
|
exit tile on different ticks. Living, in-play players remain solid, and an occupant with no match
|
|
state stays solid rather than failing open into an overlap.
|
|
|
|
Same-tick intents resolve in a committed order derived from `sha256(seed:tick:entrantSlot)`, never
|
|
packet arrival order. An entrant eliminated earlier in that order cannot move or attack afterwards;
|
|
the attempt emits `move_failed` with reason `eliminated_before_action`.
|
|
|
|
`MatchEngine` (`src/multiplayer/MatchEngine.js`) is a thin, transport-agnostic tick driver that calls
|
|
`room.resolveTick()` on an interval and reports results to its owner.
|
|
|
|
The single-player engine (`src/game/game.js`) does not consume rulesets; `solo-classic` is a catalog
|
|
descriptor for parity and display only.
|
|
|
|
## Selection and safety
|
|
|
|
Multiplayer rulesets are trusted server configuration, selected with `MATCH_RULESET_ID` (default
|
|
`race`). Clients receive the active ruleset and the safe catalog metadata for display, but cannot
|
|
select or author executable rules. Match mode as a whole is gated by `MATCH_ENABLED`, which must be
|
|
the literal string `true` to be active.
|
|
|
|
Crypto payout admission requires single-winner semantics. `matchEconomyPolicy` accepts a ruleset only
|
|
when its mode is neither `solo` nor `coop`, its win condition is one of `first-to-exit`,
|
|
`last-alive`, or `high-score`, and `players.max` is at least 2; otherwise admission is denied with
|
|
`unsupported_crypto_ruleset`. `environmentValidator` applies the same check at boot and additionally
|
|
requires `MATCH_MAX_PLAYERS` to fall inside the ruleset's player contract. Match persistence and
|
|
transport require at least two entrants, so the effective minimum is raised to 2 even for rulesets
|
|
that are also valid as a one-player challenge.
|
|
|
|
`src/migrations/029_match_ruleset_id.sql` persists the selected ruleset on each match
|
|
(`matches.ruleset_id`, default `race`) with a partial index on finished matches, keeping newer
|
|
built-ins auditable without widening the money and economy keys.
|
|
|
|
## Timing and block deadlines
|
|
|
|
`matchScheduler` resolves timing with the precedence: explicit constructor argument, then
|
|
`MATCH_TICK_MS` / `MATCH_MIN_DURATION_MS` / `MATCH_HARD_CEILING_MS`, then the ruleset's `timing`,
|
|
then the built-in defaults.
|
|
|
|
When `timing.blockDeadline` is true, each advancing canonical header is offered to `MatchManager`.
|
|
An active room expires with reason `block_deadline` only when the header is strictly later than the
|
|
room's start header and its active-play `minDurationMs` floor has elapsed. Strict height comparison
|
|
protects rooms created by the same header, and duplicate polls are idempotent. With
|
|
`blockDeadline: false`, the hard-ceiling watchdog owned by `MatchManager` is the only clock-based end.
|
|
|
|
For paid multiplayer, the ruleset id is part of the durable entrant freeze at the freeze header:
|
|
`buildPaidEntrantFreeze` commits version, freeze height, target height, delay, economy, ruleset id,
|
|
and the canonical queue entry ids into a single hash. `deriveFutureBlockMatchSeed` then accepts only
|
|
the exact committed future header, after its required confirmation wait, and mixes the freeze
|
|
commitment into the seed. Changing the active ruleset cannot reinterpret a pending entrant set or
|
|
trigger a server-random fallback.
|
|
|
|
## Economy per mode
|
|
|
|
A ruleset's `economy` block (`model`, `houseFeePercent`, `payoutMultipliers`) maps onto the existing
|
|
payout paths: `gameModeManager` for single player (multiplier on entry fee) and `matchPayoutService`
|
|
for pot modes (winner takes the pot minus the house fee). See [MONETIZATION.md](MONETIZATION.md) and
|
|
[MATCH_MODE.md](MATCH_MODE.md).
|
|
|
|
## World presets and per-chain tuning
|
|
|
|
`src/game/difficultyConfig.js` holds the world half of a ruleset. `getDifficultyPreset` selects a
|
|
preset from `DIFFICULTY_PRESET` or an explicit override; with neither set it returns `normal` for
|
|
free play (`GAME_MODE=FREE` and `PAYMENTS_ENABLED` not true) and `casino` otherwise. Presets tune
|
|
dungeon size and openness, monster start distance, speed, aggressiveness and vision, treasure
|
|
placement, and a target house win rate.
|
|
|
|
Block time enters through `NETWORK_TUNING`, which folds a per-chain level count onto the resolved
|
|
preset so a run's cumulative length scales with the chain's mean block time. Each level stays
|
|
base-sized with a fair monster; the house edge comes from racing the block across the whole descent
|
|
rather than from one oversized map or a faster-than-fair monster.
|
|
|
|
| Chain | Mean block time | Levels |
|
|
| --- | --- | --- |
|
|
| GRIN | ~1 min | 1 |
|
|
| XMR | ~2 min | 1 |
|
|
| LTC | ~2.5 min | 2 |
|
|
| WOW | ~5 min | 2 |
|
|
| BTC | ~10 min | 3 |
|
|
|
|
Precedence in `getDifficultyConfig` runs low to high: preset, per-chain tuning, environment variables
|
|
(`DUNGEON_LEVELS`, `DUNGEON_*`, `MONSTER_*`), then explicit overrides passed by the caller. Set
|
|
`NETWORK_TUNING_DISABLED=true` to skip the per-chain step. The level count drives multi-level solo
|
|
runs; `MatchRoom` consumes only the dungeon geometry from the resolved config and generates a single
|
|
shared map.
|
|
|
|
## Files
|
|
|
|
```
|
|
src/game/rulesets/Ruleset.js defineRuleset + WIN / ECONOMY enums
|
|
src/game/rulesets/winConditions.js FIRST_TO_EXIT / LAST_ALIVE / HIGH_SCORE / ALL_ESCAPE
|
|
src/game/rulesets/registry.js built-ins + match resolver and catalog helpers
|
|
src/multiplayer/MatchRoom.js consumes the ruleset; PvP combat; delegates win/lose/rank
|
|
src/multiplayer/MatchEngine.js tick driver
|
|
src/network/matchScheduler.js advancing-header deadline dispatch
|
|
src/network/matchManager.js active-play floor + idempotent expiry
|
|
src/network/matchEconomyPolicy.js crypto admission contract (player bounds, win semantics)
|
|
src/network/matchFairness.js paid entrant freeze + future-block seed derivation
|
|
src/game/difficultyConfig.js world presets and per-chain level tuning
|
|
src/migrations/029_match_ruleset_id.sql persists the selected ruleset on each match
|
|
```
|