first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,71 @@
# anti-cheat-validator.internal
## Purpose
Internal pure helpers backing `AntiCheatValidator`. Holds default
configuration constants (movement, rate limits, punishment thresholds),
the Euclidean `distance` calculator, the `recommend` punishment-escalation
ladder, and the `emptyHistory` factory.
Splitting these out keeps `anti-cheat-validator.ts` below the 300-line code
file limit while ensuring the helpers remain side-effect free, individually
testable, and unit-coverable.
## Features
- Default `MovementConfig` (`maxSpeed`, `maxTeleportDistance`,
`toleranceFactor`).
- Default `PunishmentThresholds` (escalation ladder for warnings → minor →
major → kick → temp/permanent ban).
- Default `RateLimitConfig` array (60 actions / 1 s / all).
- `distance(a, b)` Euclidean 3D distance (z defaults to 0).
- `recommend(h, p)` punishment ladder driven by per-severity counts and
current ban status.
- `emptyHistory(playerId)` factory for new tracked players.
## Usage: Basic
```ts
import {
DEFAULT_MOVEMENT,
distance,
} from "./anti-cheat-validator.internal.js";
distance({ x: 0, y: 0 }, { x: 3, y: 4 }); // 5
DEFAULT_MOVEMENT.maxSpeed; // 10
```
## Usage: Advanced
```ts
import {
emptyHistory,
recommend,
DEFAULT_PUNISHMENTS,
} from "./anti-cheat-validator.internal.js";
const h = emptyHistory("p1");
h.violationsBySeverity.major = 3;
recommend(h, DEFAULT_PUNISHMENTS); // "temp_ban"
```
## API Reference
- `DEFAULT_MOVEMENT: MovementConfig`
- `DEFAULT_PUNISHMENTS: PunishmentThresholds`
- `DEFAULT_RATE_LIMITS: RateLimitConfig[]`
- `distance(a: Position, b: Position): number`
- `recommend(h: PlayerViolationHistory, p: PunishmentThresholds):
PunishmentRecommendation`
- `emptyHistory(playerId: string): PlayerViolationHistory`
## Implementation Notes
`distance` treats missing `z` as 0 so 2D positions interoperate with 3D
checks. `recommend` short-circuits on the strictest applicable threshold
(critical violation or active temp ban → permanent), then walks the ladder
top-down. `emptyHistory` reserves an empty object for the per-type and
per-severity tally maps so callers can use the safe `??` increment pattern
without hitting `undefined` keys. Defaults match the migrated upstream
configuration so the public observable behaviour of `AntiCheatValidator`
is unchanged from the universal source.

View File

@@ -0,0 +1,131 @@
# anti-cheat-validator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`AntiCheatValidator` performs server-authoritative validation for player
movement and action streams in real-time multiplayer games. It tracks per-
player position history, applies movement-speed and teleport thresholds,
enforces rolling-window rate limits, runs caller-registered action
validators, accumulates violation histories, and escalates punishments
through a configurable warn → minor → major → kick → temp-ban → permanent-
ban ladder.
The validator is dependency-injectable: a `Clock` provides wall-clock
time, an `IdGenerator` mints violation ids, and an OpenTelemetry `Meter`
provides metrics emission. Configuration is validated with Zod at the
constructor boundary; invalid input raises
`GAME_SERVER.INVALID_ANTI_CHEAT_CONFIG`.
## Features
- Movement validation: teleport (distance), horizontal speed-hack,
optional vertical speed-hack, timing-anomaly detection.
- Rolling-window rate limiting per action type plus a global `"all"`
bucket.
- Pluggable action validators with full validation context
(`getPlayerPosition`, `getPlayerHistory`, `getCurrentTick`).
- Auto-flagging at a configurable violations-per-minute threshold.
- Automatic punishment ladder driven by per-severity tallies and current
ban status.
- Per-player history, statistics, OTel counters
(`game_server.anticheat.validation_total`,
`game_server.anticheat.violation_total`).
- Typed listener registry (`on`/`off`) for `violation:detected`,
`player:flagged`/`:unflagged`/`:kicked`/`:temp_banned`/`:permanent_banned`,
`rate_limit:exceeded`.
## Usage: Basic
```ts
import { AntiCheatValidator } from "@suaveplan/game-server/enhanced/anti-cheat/anti-cheat-validator";
const validator = new AntiCheatValidator();
const r = validator.validateMovement({
playerId: "p1",
position: { x: 0, y: 0 },
timestamp: Date.now(),
});
if (!r.valid) console.warn(r.violation);
```
## Usage: Advanced
```ts
import { AntiCheatValidator } from "@suaveplan/game-server/enhanced/anti-cheat/anti-cheat-validator";
let now = 0;
const validator = new AntiCheatValidator<
{ x: number; y: number },
{ damage: number }
>(
{
rateLimits: [{ maxActions: 30, windowMs: 1_000, actionType: "shoot" }],
movement: {
maxSpeed: 12,
maxTeleportDistance: 60,
allowVertical: true,
maxVerticalSpeed: 18,
toleranceFactor: 1.25,
},
autoFlagThreshold: 6,
enableAutoPunishment: true,
},
{
clock: () => now,
idGenerator: () => `v-${++now}`,
}
);
validator.registerValidator("shoot", async (action) => {
return action.payload.damage <= 100
? { valid: true, shouldWarn: false, recommendation: "none" }
: {
valid: false,
shouldWarn: false,
recommendation: "warn",
reason: "damage out of range",
};
});
const unsub = validator.on("violation:detected", (v) => audit.write(v));
// ... later
unsub();
```
## API Reference
- `new AntiCheatValidator(config?, deps?)` — validates `config` with
`AntiCheatConfigSchema`; throws `GAME_SERVER.INVALID_ANTI_CHEAT_CONFIG`
on failure. `deps`: `{ clock?, idGenerator?, meter? }`.
- `validateMovement(data)`: `ValidationResult` — first call seeds the
player's reference position; subsequent calls compare against it.
- `validateAction(action)`: `Promise<ValidationResult>` — rate-checks then
invokes any registered action validator.
- `checkRateLimit(playerId, actionType, timestamp)`: `ValidationResult`.
- `registerValidator(actionType, validator)` — throws
`GAME_SERVER.INVALID_VALIDATION_RULE` on empty `actionType`.
- `getPlayerHistory(playerId)`, `flagPlayer`, `unflagPlayer`, `banPlayer`,
`isPlayerBanned`, `setTick`, `getStats`, `resetStats`, `clearPlayer`,
`dispose`.
- `on(event, listener)` returns an unsubscribe function; `off(event,
listener)` removes a specific listener.
## Implementation Notes
Heavy data-shape work (`processFlag`, `evaluateRateLimit`,
`classifyMovement`, `recommend`) lives in the sibling
`anti-cheat-validator.internal.ts` so the main class stays focused on
lifecycle, dependency injection, and event dispatch. The Euclidean
distance helper treats missing `z` as 0 so 2D and 3D positions
interoperate.
Auto-punishment dispatches into `flagPlayer`/`banPlayer` so the listener
registry sees the corresponding `player:*` events even when escalation is
driven internally. Temp bans expire lazily on the next `isPlayerBanned`
call rather than via a timer to avoid scheduling drift. Statistics include
a live `uptimeMs` computed from the injected `Clock` so deterministic
tests can advance time without observing real wall-clock skew.

View File

@@ -0,0 +1,72 @@
# interest-manager.internal
## Purpose
Pure geometry helpers backing `InterestManager`: Euclidean
`vectorDistance` (z defaults to 0 for 2D interop), `zoneContains`
discriminating on `ZoneShape` (`circular` uses radius distance,
`rectangular` uses half-extent checks honouring optional `depth`, `custom`
delegates to the zone-supplied `containsPoint` predicate), and
`calcPriority` clamping a `maxPriority - distance × decay + zoneBonus`
score into `[0, maxPriority]`.
Extracting these keeps `interest-manager.ts` under the per-file line
budget and isolates the geometric primitives so they can be unit-tested
without instantiating the manager.
## Features
- `vectorDistance(a, b)` — 3D Euclidean distance with 2D fallback.
- `zoneContains(zone, point)` — discriminated containment test across
circular, rectangular (optionally 3D), and custom zones.
- `calcPriority(d, n, max, decay)` — clamped priority score with a small
bonus per shared subscribed zone.
## Usage: Basic
```ts
import {
vectorDistance,
zoneContains,
} from "./interest-manager.internal.js";
vectorDistance({ x: 0, y: 0 }, { x: 3, y: 4 }); // 5
zoneContains(
{
id: "z",
shape: "circular",
center: { x: 0, y: 0 },
radius: 5,
priority: 1,
},
{ x: 4, y: 0 }
); // true
```
## Usage: Advanced
```ts
import { calcPriority } from "./interest-manager.internal.js";
// At max distance the bonus still keeps shared-zone entities visible.
calcPriority(80, 3, 10, 0.1); // 3.5
```
## API Reference
- `vectorDistance(a: Vector3, b: Vector3): number`
- `zoneContains(zone: InterestZone, point: Vector3): boolean`
- `calcPriority(distance: number, sharedZoneCount: number, maxPriority:
number, decayRate: number): number`
## Implementation Notes
`zoneContains` treats a missing `depth` on rectangular zones as 2D
containment (no z-check), which matches the migrated upstream behaviour
and lets callers seamlessly mix 2D and 3D entities. The custom-zone path
intentionally invokes the user predicate without exception trapping —
callers are responsible for ensuring `containsPoint` is total, and any
thrown error propagates up through `InterestManager.isInZone`. The
priority formula gives shared-zone entities a half-unit lift per
subscribed zone so that interesting entities don't fall out of the top-N
slice at the edge of `viewDistance`.

View File

@@ -0,0 +1,117 @@
# interest-manager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`InterestManager` implements area-of-interest (AOI) culling for open-world
multiplayer games: it tracks zones, entities, and players in memory and
returns the bounded slice of entities each player can see, ranked by
priority. The class is the standard mechanism for shrinking broadcast
scope from O(world) to O(perceived) updates.
It is dependency-injectable: a `Clock` source provides timestamps and an
OpenTelemetry `Meter` produces visibility-calculation counters and
duration histograms. Configuration is validated with Zod; invalid input
raises `GAME_SERVER.INVALID_INTEREST_CONFIG`. Unknown players passed to
`updatePlayerPosition` raise
`GAME_SERVER.INTEREST_PLAYER_NOT_REGISTERED`.
## Features
- Three zone shapes: circular, rectangular (optionally 3D), custom
(predicate-based).
- Per-player `viewDistance` override (defaults to
`config.defaultViewDistance`).
- Priority-weighted visibility ranking via `maxPriority - distance ×
decay + sharedZones × 0.5` with output clamped into `[0, maxPriority]`.
- `getVisibleEntities` slices to `maxEntitiesPerPlayer`.
- `calculateVisibilityChanges` emits enter/exit deltas only when the set
actually changes (returns `null` on no-op).
- Typed event registry: `entity:added/removed`,
`player:subscribed/unsubscribed`, `visibility:changed`.
- OTel: `game_server.interest.visibility_calc_total` (counter),
`game_server.interest.visibility_calc_duration_ms` (histogram).
## Usage: Basic
```ts
import { InterestManager } from "@suaveplan/game-server/enhanced/interest-management/interest-manager";
const aoi = new InterestManager({ defaultViewDistance: 50 });
aoi.registerPlayer("p1", { x: 0, y: 0 });
aoi.updateEntity("npc", { x: 10, y: 0 }, { kind: "merchant" });
const visible = aoi.getVisibleEntities("p1");
```
## Usage: Advanced
```ts
import { InterestManager } from "@suaveplan/game-server/enhanced/interest-management/interest-manager";
const aoi = new InterestManager<{ name: string }>(
{
defaultViewDistance: 80,
maxEntitiesPerPlayer: 100,
priorityDecayRate: 0.05,
maxPriority: 20,
},
{ clock: () => testNow }
);
aoi.registerZone({
id: "downtown",
shape: "rectangular",
center: { x: 0, y: 0, z: 0 },
width: 200,
height: 200,
depth: 30,
priority: 5,
});
aoi.registerPlayer("p1", { x: 1, y: 1, z: 0 });
aoi.subscribeToZone("p1", "downtown");
const unsub = aoi.on("visibility:changed", (change) => {
syncEnterExitToClient(change.playerId, change.entered, change.exited);
});
// In the server tick:
aoi.updateEntity("merchant", { x: 5, y: 5, z: 0 }, { name: "Sera" });
aoi.calculateVisibilityChanges("p1");
unsub();
```
## API Reference
- `new InterestManager(config, deps?)` — validates `config` with
`InterestManagerConfigSchema`; throws
`GAME_SERVER.INVALID_INTEREST_CONFIG` on failure.
- `registerZone(zone)` / `unregisterZone(zoneId)`; throws
`GAME_SERVER.INTEREST_REGION_INVALID` on empty `zone.id`.
- `updateEntity(id, position, data?, type?)` / `removeEntity(id)`.
- `registerPlayer(id, position, viewDistance?)` /
`updatePlayerPosition(id, position)` / `unregisterPlayer(id)`.
- `subscribeToZone(playerId, zoneId)` /
`unsubscribeFromZone(playerId, zoneId)`.
- `getVisibleEntities(playerId)` / `calculateVisibilityChanges(playerId)`.
- `isInZone(zoneId, point)` / `getZonesAtPoint(point)` / `getZone` /
`getAllZones` / `getEntity` / `getStats` / `dispose`.
- `on(event, listener) → unsubscribe`; `off(event, listener)`.
## Implementation Notes
Geometry primitives (`vectorDistance`, `zoneContains`, `calcPriority`)
live in `interest-manager.internal.ts` so the main class is dominated by
lifecycle, dispatch, and metrics emission. `getVisibleEntities` is the
hot path; it sorts the candidate set in descending priority and slices to
`maxEntitiesPerPlayer` so the result is always bounded. The shared-zones
walk only checks zones the player explicitly subscribed to, so an entity
in an unsubscribed zone is treated as if the zone were absent. Visibility
deltas are derived against the previous result rather than a snapshot of
the world to support incremental synchronisation. `dispose` clears every
internal collection so the manager can be safely garbage-collected after
shutdown.

View File

@@ -0,0 +1,70 @@
# lag-compensator.internal
## Purpose
Pure helpers backing `LagCompensator`: `RTT_SAMPLE_SIZE`, the
`initialStats` factory, RTT-sample-window management (`pushRttSample`,
`calcLatency`), and snapshot neighbour-lookup logic (`findNeighbors`,
`pickClosest`).
Splitting these out keeps the main class focused on lifecycle, dependency
injection, OTel emission, and event dispatch, and lets the neighbour
search be tested independently against contrived snapshot sequences.
## Features
- `RTT_SAMPLE_SIZE` constant (20).
- `initialStats()` factory.
- `pushRttSample(samples, rtt, cap)` — appends and trims oldest.
- `calcLatency(playerId, rtt, samples, now)` — average, min, max, jitter.
- `findNeighbors(history, rewindTimestamp)` — earliest snapshot strictly
after, latest snapshot at-or-before.
- `pickClosest(before, after, rewind)` — closer-of-two by absolute time
delta, ties prefer `before`.
## Usage: Basic
```ts
import {
initialStats,
pushRttSample,
} from "./lag-compensator.internal.js";
const samples = pushRttSample([], 80, 20);
initialStats().totalLookups; // 0
```
## Usage: Advanced
```ts
import { findNeighbors, pickClosest } from "./lag-compensator.internal.js";
const history = [
{ tick: 1, timestamp: 100, state: { x: 0 }, isServerState: true },
{ tick: 2, timestamp: 200, state: { x: 5 }, isServerState: true },
];
const { before, after } = findNeighbors(history, 150);
const closest = before && after ? pickClosest(before, after, 150) : before;
```
## API Reference
- `RTT_SAMPLE_SIZE: 20`
- `initialStats(): LagCompensatorStats`
- `pushRttSample(samples: number[], rtt: number, cap: number): number[]`
- `calcLatency(playerId: string, rtt: number, samples: number[], now:
number): PlayerLatency`
- `findNeighbors<TState>(history, rewindTimestamp): NeighborResult<TState>`
- `pickClosest<TState>(before, after, rewind): StateSnapshot<TState>`
## Implementation Notes
`pushRttSample` returns a new array each call to keep ownership semantics
clear and allow callers to detect a size change. `calcLatency` always
divides by the actual sample count so the first update produces sensible
stats; `Math.min(...samples)`/`Math.max(...samples)` are safe at 20-sample
max. `findNeighbors` walks the history once and stops as soon as the
strictly-greater snapshot is found, so it is O(N) in worst-case but
typically terminates within a handful of iterations for a 128-frame
buffer. The tie-break in `pickClosest` picks the earlier snapshot to
match canonical rollback-network semantics.

View File

@@ -0,0 +1,123 @@
# lag-compensator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`LagCompensator` performs server-side state rewinding for accurate hit
registration in networked multiplayer games. It maintains a bounded
per-entity ring buffer of `StateSnapshot` records, tracks per-player RTT
statistics (average, min, max, jitter), and exposes a `validateHit`
method that combines RTT compensation with snapshot interpolation to
adjudicate hit candidates against the world state the shooter actually
perceived at the moment of input.
The compensator is dependency-injectable: a `Clock` supplies timestamps
for `recordState` defaults and latency timestamps; an OpenTelemetry
`Meter` produces a lookup counter and a compensation-amount histogram.
Configuration is validated with Zod at the constructor boundary; invalid
input raises `GAME_SERVER.INVALID_LAG_COMPENSATION_CONFIG`.
## Features
- Bounded per-entity history with `history:pruned` events on overflow.
- Per-player RTT tracking with a 20-sample rolling window
(`RTT_SAMPLE_SIZE`).
- `getStateAtTime` supports exact-match, before-only, after-only,
closest-neighbour, and lerp-interpolated lookups.
- `validateHit` applies optional RTT compensation
(`enableRttCompensation`), perceives the shooter and target at the
same rewind point, runs a caller-supplied predicate, and emits
`hit:validated` with both compensated states.
- Compensation clamping at `maxCompensationMs` flagged on the result via
`clamped: true`.
- OTel: `game_server.lagcomp.lookup_total` (counter),
`game_server.lagcomp.compensation_ms` (histogram).
## Usage: Basic
```ts
import { LagCompensator } from "@suaveplan/game-server/enhanced/lag-compensation/lag-compensator";
const lc = new LagCompensator<{ x: number; y: number }>();
lc.recordState("p1", { x: 0, y: 0 }, 1);
lc.recordState("p2", { x: 5, y: 0 }, 1);
const result = lc.validateHit({
shooterId: "p1",
targetId: "p2",
actionTimestamp: Date.now(),
serverTimestamp: Date.now(),
validator: (s, t) => Math.hypot(s.x - t.x, s.y - t.y) <= 6,
});
```
## Usage: Advanced
```ts
import { LagCompensator } from "@suaveplan/game-server/enhanced/lag-compensation/lag-compensator";
let now = 0;
const lc = new LagCompensator<{ x: number; y: number }>(
{
maxHistorySize: 256,
maxCompensationMs: 200,
enableRttCompensation: true,
interpolationFactor: 1,
},
{ clock: () => now }
);
lc.updateRtt("shooter", 80);
for (let t = 0; t < 10; t++) {
now = t * 16;
lc.recordState("shooter", { x: t, y: 0 }, t);
lc.recordState("target", { x: t * 2, y: 0 }, t);
}
const unsub = lc.on("hit:validated", (r) => audit.push(r));
const result = lc.validateHit(
{
shooterId: "shooter",
targetId: "target",
actionTimestamp: now - 50,
serverTimestamp: now,
validator: (s, t) => Math.hypot(s.x - t.x, s.y - t.y) <= 10,
},
(a, b, t) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t })
);
unsub();
```
## API Reference
- `new LagCompensator(config?, deps?)` — validates `config` with
`LagCompensatorConfigSchema`; throws
`GAME_SERVER.INVALID_LAG_COMPENSATION_CONFIG` on failure.
- `recordState(entityId, state, tick, timestamp = clock())`.
- `updateRtt(playerId, rtt)` / `getPlayerLatency(playerId)`.
- `getStateAtTime(entityId, targetTimestamp, currentTimestamp = clock(),
lerp?)`.
- `validateHit(request, lerp?)`.
- `getTrackedEntities()`, `clearEntityHistory(entityId)`,
`removePlayer(playerId)`, `getStats()`, `resetStats()`, `dispose()`.
- `on(event, listener) → unsubscribe`; `off(event, listener)`.
## Implementation Notes
The interpolation factor is applied symmetrically as
`f × raw + (1 − f) × raw`, identical to the upstream behaviour even
though it collapses algebraically — kept verbatim so engines tuning
`interpolationFactor` for visual smoothing match validation results.
Tied neighbour timestamps prefer the earlier snapshot in `pickClosest`
to match canonical rollback-network semantics. `validateHit` halves the
shooter's RTT for compensation under the standard one-way-latency
assumption; disabling `enableRttCompensation` lets callers run their own
network model. The lookup counter tags `{clamped: "true" | "false"}` so
observability dashboards can separate compensated-but-bounded queries
from edge cases that hit the cap. `dispose` clears every collection and
resets statistics so the compensator can be safely garbage-collected.

View File

@@ -0,0 +1,113 @@
# LeaderboardManager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`LeaderboardManager` provides multi-leaderboard score tracking for game
servers with three independent ranking modes — raw score, win-count, and
Elo. Each leaderboard is configured separately, sorted independently, and
capped at a configurable max-entry size that evicts the lowest-ranked
entries to keep memory bounded under unbounded submission volume.
The manager injects its `Clock` so deterministic tests and seeded replays
can advance time without resorting to fake timers, and emits OTel counters
(`game_server.leaderboard.submit_total`, `game_server.leaderboard.eviction_total`)
so production deployments can monitor submission throughput and eviction
pressure without bespoke instrumentation.
## Features
- Three ranking modes: `score`, `wins`, `elo`.
- Ascending (golf) or descending (default) sort.
- Tie-break by secondary score.
- Per-board `maxEntries` cap with rank-based eviction.
- Elo update with configurable K-factor.
- Injectable `Clock` for deterministic time.
- Stable `GAME_SERVER.*` error codes (`LEADERBOARD_NOT_FOUND`,
`INVALID_LEADERBOARD_CONFIG`, `INVALID_SCORE_SUBMISSION`,
`LEADERBOARD_ENTRY_INVALID`).
- Zod-validated configs and submissions.
- OTel `Counter` instrumentation.
## Usage: Basic
```ts
import { LeaderboardManager } from "@suaveplan/game-server/enhanced/leaderboard/leaderboard-manager";
const lm = new LeaderboardManager();
lm.createLeaderboard({ id: "global", rankingMode: "score" });
lm.submitScore("global", {
playerId: "alice",
displayName: "Alice",
score: 4200,
});
const top = lm.getTopN("global", 10);
```
## Usage: Advanced
```ts
import { LeaderboardManager } from "@suaveplan/game-server/enhanced/leaderboard/leaderboard-manager";
let now = 1_700_000_000_000;
const lm = new LeaderboardManager<{ region: string }>(
{ defaultMaxEntries: 500, defaultEloKFactor: 24, defaultInitialElo: 1500 },
{ clock: () => now }
);
lm.createLeaderboard({
id: "ranked-1v1",
rankingMode: "elo",
maxEntries: 1000,
eloKFactor: 32,
});
lm.submitScore("ranked-1v1", {
playerId: "alice",
displayName: "Alice",
score: 0,
isWin: true,
opponentElo: 1450,
metadata: { region: "eu-west" },
});
now += 60_000;
const entry = lm.getEntry("ranked-1v1", "alice");
const stats = lm.getStats();
```
## API Reference
- `new LeaderboardManager(config?, deps?)` — `deps.clock?: Clock`, `deps.meter?: Meter`.
- `createLeaderboard(config: LeaderboardConfig): LeaderboardConfig`
- `deleteLeaderboard(id: string): boolean`
- `submitScore(id, submission): LeaderboardEntry`
- `getTopN(id, n): readonly LeaderboardEntry[]`
- `getEntry(id, playerId): LeaderboardEntry | undefined`
- `removeEntry(id, playerId): boolean`
- `resetLeaderboard(id): boolean`
- `listLeaderboards(): readonly LeaderboardConfig[]`
- `getStats(): LeaderboardStats`
## Implementation Notes
Ranks are recomputed in full on every submission. For the configured
ceiling of 1000 entries this is `O(N log N)` per write and stays well
under a millisecond on commodity hardware; if profiles show this becoming
a bottleneck, the sort can be swapped for an order-statistic tree without
changing the public surface.
The Elo update follows the standard logistic formulation with a 400-point
spread and rounding to the nearest integer rating. K-factor is per-board
so high-volatility competitive ladders can run alongside calibration
boards with low K. Submissions without an `opponentElo` are treated as
unranked sessions and do not alter the Elo rating, which avoids polluting
ladders with practice games or AI matches.
Eviction operates on rank rather than insertion order so the cap reflects
the leaderboard's intent ("top 1000 players") rather than churn. The
`eviction_total` counter is tagged by `leaderboardId` so per-board churn
is observable in production dashboards without code changes.

View File

@@ -0,0 +1,100 @@
# GamePersistence
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`GamePersistence` persists game state to a pluggable key-value backend via
the `StorageLike` structural contract from `@suaveplan/types`. State, named
snapshots, and a bounded audit-trail history are serialised together as a
UTF-8 JSON document under a deterministic key (`game-server:save:<gameId>:<id>`),
allowing any conformant `AbstractStorage` adapter — in-memory, SurrealDB,
SQLite, S3 — to back the engine without code changes.
The manager exposes async `save`, `load`, `delete`, `createSnapshot`, and
`restoreSnapshot` operations that throw stable `GAME_SERVER.*` errors,
plus an OTel save-duration histogram and counter pair so production
throughput is observable without bespoke instrumentation.
## Features
- Pluggable `StorageLike` backend (in-memory, durable, network).
- Named snapshots with rotating `maxSnapshots` cap.
- Bounded audit-trail history with `maxHistoryEntries` cap.
- Injectable `Clock` and `IdGenerator` for deterministic tests.
- Stable error codes: `PERSISTENCE_SAVE_FAILED`, `PERSISTENCE_LOAD_FAILED`,
`PERSISTENCE_NOT_FOUND`, `INVALID_SAVE_RECORD`.
- OTel counters + duration histogram.
- Tracks `currentSaveId` for snapshot context.
## Usage: Basic
```ts
import { GamePersistence } from "@suaveplan/game-server/enhanced/persistence/game-persistence";
const gp = new GamePersistence<MyState>(
{ gameId: "chess" },
{ storage: createSurrealStorage() }
);
const result = await gp.save(state, { name: "quicksave" });
const loaded = await gp.load(result.id!);
```
## Usage: Advanced
```ts
import { GamePersistence } from "@suaveplan/game-server/enhanced/persistence/game-persistence";
const gp = new GamePersistence<MyState>(
{ gameId: "chess", maxSnapshots: 50, maxHistoryEntries: 200 },
{
storage,
clock: () => now,
idGenerator: () => `save-${++counter}`,
meter,
}
);
const save = await gp.save(state, { name: "checkpoint", tags: ["wave-4"] });
const snap = await gp.createSnapshot(state, "before-boss", "auto");
const restored = await gp.restoreSnapshot(save.id!, snap.id);
const history = await gp.getHistory(save.id!);
const stats = gp.getStats();
```
## API Reference
- `new GamePersistence(config, deps)` — `deps.storage: StorageLike`.
- `save(state, input): Promise<SaveResult>`
- `load(id): Promise<LoadResult>`
- `delete(id): Promise<boolean>`
- `exists(id): Promise<boolean>`
- `createSnapshot(state, name, reason?): Promise<Snapshot>`
- `restoreSnapshot(saveId, snapshotId): Promise<LoadResult>`
- `getHistory(saveId): Promise<readonly HistoryEntry[]>`
- `getStats(): PersistenceStats`
- `getCurrentSaveId(): string | null`
## Implementation Notes
State is encoded as UTF-8 JSON via `TextEncoder`/`TextDecoder` so the
on-disk shape is debuggable with any text viewer; binary-only stores will
treat the payload as opaque bytes. For game states with large embedded
binaries (images, audio), callers should chunk those out to a separate
asset store and reference them by id in the persisted state to avoid
inflating the JSON payload.
Storage keys are namespaced by both `gameId` and the per-save id so a
single shared backend can host saves for many concurrent game instances
without prefix collisions. The `currentSaveId` cursor is in-memory only —
restarting the process requires an explicit `load()` before
`createSnapshot()` will succeed.
JSON decode failures are mapped to `PERSISTENCE_LOAD_FAILED` rather than
silently returning `null`, so corrupted storage surfaces as an
inspectable error chain instead of an empty save list. Save failures
preserve their underlying cause through `GameServerError.cause`, making
storage-side diagnostics directly available to the caller.

View File

@@ -0,0 +1,104 @@
# ReplayPlayer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ReplayPlayer` plays back a `ReplayData<TState, TInput>` document produced
by `ReplayRecorder`. It exposes the controls expected of a media player —
play, pause, stop, step forward/backward, seek, speed — but is driven by
explicit `tick()` calls rather than a timer, so playback is fully
deterministic and trivially exercised in tests or headless replay rigs.
The player delivers replayed inputs through observer subscriptions
(`onInput`, `onSnapshotRestore`) so consumer engines stay in control of
their own dispatch pipeline. OTel counters track `playback_advance_total`
and `playback_seek_total` so dashboards can monitor replay throughput in
production replay-analysis workloads.
## Features
- Tick-driven, deterministic playback.
- Speeds: `0.25 / 0.5 / 1 / 2 / 4` with Zod-enforced enum.
- Snapshot-aware seek and stepBackward.
- Loop mode for highlight loops or attract screens.
- Observer-style input + snapshot listeners (no global emitter).
- Stable error codes: `REPLAY_NOT_LOADED`, `REPLAY_PARSE_FAILED`,
`REPLAY_INVALID_SPEED`.
- OTel counters tagged per advance / seek.
## Usage: Basic
```ts
import { ReplayPlayer } from "@suaveplan/game-server/enhanced/replay/replay-player";
const player = new ReplayPlayer<GameState, GameInput>();
player.load(replay);
player.onInput((input) => engine.apply(input.data, input.playerId));
player.play();
setInterval(() => player.tick(), 1000 / 60);
```
## Usage: Advanced
```ts
import { ReplayPlayer } from "@suaveplan/game-server/enhanced/replay/replay-player";
const player = new ReplayPlayer<GameState, GameInput>(
{ loop: true, initialSpeed: 2 }
);
player.load(replay);
const offInput = player.onInput((i) => engine.apply(i.data, i.playerId));
const offSnap = player.onSnapshotRestore((state) => engine.replace(state));
player.play();
for (let i = 0; i < replay.metadata.totalTicks * 4; i += 1) player.tick();
player.seekToTick(replay.metadata.totalTicks / 2);
player.setSpeed(0.5);
player.stepBackward();
offInput();
offSnap();
```
## API Reference
- `new ReplayPlayer(config?, deps?)`
- `load(replay): void`
- `play(): void` / `pause(): void` / `stop(): void`
- `tick(): void`
- `stepForward(): void` / `stepBackward(): void`
- `seekToTick(target): void`
- `setSpeed(speed: PlaybackSpeed): void`
- `onInput(listener): () => void`
- `onSnapshotRestore(listener): () => void`
- `getPosition(): PlaybackPosition`
- `getMetadata(): ReplayMetadata | null`
- `getStatus(): PlaybackStatus` / `getSpeed(): PlaybackSpeed`
- `getCurrentState(): TState | null` / `isLoaded(): boolean`
## Implementation Notes
Playback advancement multiplies `speed` into the number of ticks consumed
per `tick()` call, which yields exact rather than approximate replay speed
on every callable cadence (60Hz, 30Hz, single-step debugging). Speeds
outside the canonical set are rejected via Zod-validated config and an
explicit `REPLAY_INVALID_SPEED` error at `setSpeed` time.
Seek walks the recorded snapshots backwards to find the most-recent
snapshot at or before the target tick, restores from it, then advances
input-by-input to the requested tick. With no snapshots, the player falls
back to the initial state — slower but always correct. Engines that need
snappy scrubbing should record interval snapshots at a reasonable rate
(60–300 ticks).
End-of-replay handling is split between `loop=true` (rewinds and keeps
playing) and `loop=false` (transitions to `ended`). The transition to
`ended` is sticky — further `tick()` calls remain a no-op until `play()`
or `seekToTick()` rewinds the cursor, which prevents accidental input
re-emission from off-by-one tick loops.

View File

@@ -0,0 +1,105 @@
# ReplayRecorder
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ReplayRecorder` captures gameplay into a deterministic, replay-ready
`ReplayData<TState, TInput>` document. It records inputs with monotonic
sequence numbers, takes periodic state snapshots at a configurable tick
interval, and tracks paused intervals so the resulting timeline reflects
actual elapsed gameplay rather than wall-clock duration.
The recorder injects its `Clock` and `IdGenerator` so deterministic tests
can produce byte-identical replay files run after run, and emits OTel
counters for input and snapshot volume so production replay-capture rates
are observable. The output document is the input contract for
`ReplayPlayer` — record on one machine, ship the JSON, play back anywhere.
## Features
- Status FSM: `idle → recording → paused → stopped`.
- Per-input monotonic `sequence` counters.
- Auto-snapshot every N ticks plus manual snapshots.
- Pause/resume that excludes paused intervals from duration.
- Tags and free-form metadata attached at stop time.
- Stable error codes: `REPLAY_RECORDING_STATE_INVALID`,
`REPLAY_PARSE_FAILED`.
- OTel counters tagged by snapshot reason.
- Injectable `Clock` + `IdGenerator`.
## Usage: Basic
```ts
import { ReplayRecorder } from "@suaveplan/game-server/enhanced/replay/replay-recorder";
const rec = new ReplayRecorder<GameState, GameInput>();
rec.start(initial);
rec.recordInput({ dx: 1 }, "alice");
rec.tick(current);
const replay = rec.stop();
```
## Usage: Advanced
```ts
import { ReplayRecorder } from "@suaveplan/game-server/enhanced/replay/replay-recorder";
let now = 1_700_000_000_000;
let counter = 0;
const rec = new ReplayRecorder<GameState, GameInput>(
{ snapshotInterval: 60, maxInputBuffer: 50_000 },
{
clock: () => now,
idGenerator: () => `r-${++counter}`,
}
);
rec.addTag("ranked");
rec.setMetadata({ matchId: "match-42", seed: 0xdeadbeef });
rec.start(initial);
for (const event of stream) {
rec.recordInput(event, event.playerId);
rec.tick(currentState);
}
const replay = rec.stop();
// replay.version === 1, deterministic if (clock, idGenerator, inputs) match
```
## API Reference
- `new ReplayRecorder(config?, deps?)`
- `start(initialState): void`
- `pause(): void` / `resume(): void`
- `recordInput(input, playerId?): void`
- `recordSnapshot(state, reason?): RecordedSnapshot`
- `tick(currentState?): void`
- `addTag(tag): void` / `setMetadata(extra): void`
- `stop(): ReplayData`
- `reset(): void`
- `getStats(): RecordingStats` / `getStatus(): RecordingStatus`
## Implementation Notes
Pause handling is deliberately exclusive: the elapsed-time formula uses
`pauseTime` while paused and `clock()` otherwise, so the duration reported
during pause never advances. Resumption tallies the paused interval into
`totalPausedTime` and is subtracted from all subsequent elapsed lookups,
yielding a timeline that matches "active gameplay" not wall clock.
Snapshot intervals operate on tick count rather than wall-clock time so a
60Hz game running at 30Hz under load still produces snapshots at
predictable gameplay milestones. The auto-snapshot path skips when no
state is provided to `tick()`, allowing pure-input replay capture for
deterministic engines where the player can reconstruct state from inputs
alone.
Input buffer overflow surfaces as a `GameServerError` rather than silent
truncation so a runaway client can be detected at capture time. Callers
that prefer back-pressure can wrap `recordInput` in their own throttle;
the default `maxInputBuffer` of 10_000 is enough for ~15 minutes of dense
60Hz inputs from a single player.

View File

@@ -0,0 +1,69 @@
# Registry — internal helpers
## Purpose
Pure helpers extracted from `GameInstanceRegistry` so the orchestrator
class stays inside the per-file budget. The helpers cover the two big
chunks of stateless logic: applying every filter dimension to a
candidate list, and dispatching one of the four load-balancing
strategies.
## Features
- `applyFilter` — ten-dimension filter pipeline (health, type, region,
mode, map, slot count, max count, privacy, tags, version).
- `applyStrategy` — switch over `least-players`, `random`,
`region-based`, `round-robin`. The class supplies its `rng`,
`regionLatencies`, and `roundRobinIndex` references.
## Usage: Basic
```ts
import { applyFilter } from "./game-instance-registry.internal.js";
const healthy = applyFilter(instances, { healthyOnly: true });
```
## Usage: Advanced
```ts
import { applyStrategy } from "./game-instance-registry.internal.js";
const result = applyStrategy(
candidates,
"region-based",
rng,
regionLatencyMap,
roundRobinIndex,
/* preferredRegion */ "us-east"
);
```
## API Reference
- `applyFilter<TCustom>(instances, filter): GameInstance<TCustom>[]`.
- `applyStrategy<TCustom>(instances, strategy, rng, regionLatencies, roundRobinIndex, preferredRegion?): { instance, score }`.
## Implementation Notes
- The healthy-instance filter is applied first because in a real
deployment most candidates are unhealthy at the moment of an outage,
and short-circuiting the rest of the chain saves work on the long
tail of subsequent dimension filters.
- `random` prefers `Rng.nextIntInRange` when present and falls back to
`Math.floor(rng.nextFloat() * len)`. This mirrors the contract spelled
out by `@suaveplan/types`'s `Rng`, which marks `nextIntInRange` as
optional but encourages CSPRNG callers to provide it for unbiased
rejection sampling.
- `region-based` falls back to a 100 ms default latency for unknown
regions — same as the upstream baseline. The fallback keeps the
weighting deterministic when a deployment grows into a new region
before the operator updates the latency map.
- `round-robin` keys the cursor on a sorted join of instance IDs, so
changing the candidate set does not silently reset the rotation.
The cursor lives on the orchestrator class instance (not in
module-level state) so two registries can run round-robin in
parallel without aliasing each other.
- Every function is pure: callers thread their own `rng`,
`regionLatencies`, and `roundRobinIndex` references in. That is
what lets the orchestrator class stay under the file-size budget
while keeping the helpers test-friendly in isolation.

View File

@@ -0,0 +1,119 @@
# `GameInstanceRegistry`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`GameInstanceRegistry` is the in-memory primitive that tracks every live
game-server instance in a multi-host deployment. It owns the canonical
`{id → instance}` map, evicts stale entries whose last heartbeat is older
than `instanceTTL`, and selects one healthy instance per discovery
request using one of four load-balancing strategies. Transport façades
(HTTP/gRPC) that surface the registry over the network live in sibling
packages — this module is the pure data structure plus its lifecycle.
## Features
- TTL-based eviction driven by an injected `Clock` (no implicit `Date.now`).
- Four load-balancing strategies: `least-players`, `random`, `region-based`,
`round-robin`. The `random` strategy consults an injected `Rng`.
- Filter-based `discover()`: by game type, region, mode, map, slot count,
privacy, tags, and version.
- Lifecycle events streamed through a single fan-in listener:
`registered`, `deregistered`, `health-changed`, `capacity-changed`,
`full`, `available`.
- Zod-validated `register()` boundary; every public mutation routes to a
typed `GameServerError` on lookup miss.
- One OTel counter (`game_server.registry.instance_lifecycle_total`)
tagged with `op ∈ {register, deregister, expire, health, capacity}`.
## Usage: Basic
```ts
import { GameInstanceRegistry } from "@suaveplan/game-server/enhanced/service-discovery/game-instance-registry";
const registry = new GameInstanceRegistry({
clock: Date.now,
rng: { nextUint32: () => 0, nextFloat: Math.random },
});
registry.register({
id: "i-001",
name: "us-east-shard-1",
host: "10.0.0.1",
port: 4000,
metadata: {
gameType: "duel",
region: "us-east",
playerCount: 0,
maxPlayers: 8,
},
});
const selected = registry.selectInstance(
{ gameType: "duel", minAvailableSlots: 2 },
"least-players"
);
```
## Usage: Advanced
```ts
const off = registry.on((event) => {
if (event.kind === "full") notifyMatchmaker(event.instance.id);
if (event.kind === "health-changed" && event.newStatus === "unhealthy") {
drainSessionsFrom(event.instance.id);
}
});
// Region-weighted selection — pass a latency map for cold start.
const r = new GameInstanceRegistry(
{ clock, rng },
{
instanceTTL: 30_000,
defaultStrategy: "region-based",
regionLatencies: new Map([
["us-east", 25],
["eu-west", 95],
["ap-southeast", 180],
]),
autoDeregisterEmpty: true,
}
);
setInterval(() => r.sweep(), 5_000);
off();
```
## API Reference
- `new GameInstanceRegistry(deps, config?)` — `deps: { clock, rng }`,
`config: { instanceTTL?, defaultStrategy?, autoDeregisterEmpty?, regionLatencies? }`.
- `register(registration)` — validate + insert; emits `registered`.
- `deregister(id)` — remove; emits `deregistered`. Throws on unknown id.
- `heartbeat(id)` — refresh `lastHeartbeat`.
- `updateHealth(id, status)` — transition health; emits `health-changed`.
- `updateCapacity({ instanceId, playerCount, maxPlayers? })` — emits
`capacity-changed`, plus `full` or `available` at the boundary.
- `discover(filter?)` — filter healthy instances.
- `selectInstance(filter?, strategy?)` — pick one or `null`.
- `getInstance(id)` — direct lookup, or `null`.
- `getStats()` — aggregate snapshot.
- `sweep()` — force the TTL pass (also invoked by `discover`).
- `on(listener)` — subscribe to lifecycle events, returns an unsubscribe.
- `dispose()` — clear all internal state.
## Implementation Notes
- Eviction runs in two phases: at `age > instanceTTL` the instance is
marked `unhealthy`; at `age > instanceTTL * 2` it is removed. This
preserves the upstream semantics where downstream consumers had one
full TTL window to fail their own probes.
- `random` prefers the optional `Rng.nextIntInRange` and falls back to
`Math.floor(rng.nextFloat() * len)` when only `nextFloat` is provided.
- `round-robin` keys on a sorted join of instance IDs so adding /
removing instances does not silently reset the rotation.
- The lifecycle counter is registered through the global OTel meter so
no SDK wiring is needed in tests.

View File

@@ -0,0 +1,104 @@
# `BitPackerStateSync`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`BitPackerStateSync` is the byte-packed state-synchronisation primitive
for low-bandwidth, high-frequency multiplayer updates. It packs game
state to `Uint8Array` via a caller-supplied `@suaveplan/bitpacker`
`BitPacker`, maintains a bounded client-side prediction history, supports
server-authoritative reconciliation with input replay above the
server-acknowledged tick, and linearly interpolates between two history
frames for smooth rendering. It coexists with the JSON-delta
`state/state-sync` from wave B-3 — the JSON path is for human-readable
debugging, this path is for the wire.
## Features
- Pack / unpack via the caller's pre-configured `BitPacker` (strategies
registered by the caller — gives full control over schema dialect).
- Client-side prediction with bounded history (`maxPredictionFrames`).
- Server reconciliation: replays every pending input with `tick > serverTick`
through the caller's `simulate` function, then compares to the server
state via a caller-supplied `compare` function. Below
`reconciliationThreshold` the result is a no-op.
- Linear interpolation between two history frames bracketing the
delayed render time. Falls back to the latest frame when no bracket
exists.
- All pack / unpack failures route through `GameServerError` with
`BITPACK_ENCODE_FAILED` / `BITPACK_DECODE_FAILED`.
- One counter (`game_server.state.bitpack_delta_total`) and one
histogram (`game_server.state.bitpack_bytes`) report wire activity.
## Usage: Basic
```ts
import { BitPacker } from "@suaveplan/bitpacker";
import { registerDefaultStrategies } from "@suaveplan/bitpacker-strategies";
import { BitPackerStateSync } from "@suaveplan/game-server/enhanced/state-sync/bitpacker-state-sync";
const packer = new BitPacker();
registerDefaultStrategies(packer.strategyManager);
const sync = new BitPackerStateSync<{ x: number; y: number }>(
{ clock: Date.now },
{ packer, schema: { x: 255, y: 255 } }
);
const msg = sync.packState({ x: 100, y: 50 });
// transmit msg.payload over WebSocket / WebRTC
const restored = sync.unpackState(msg);
```
## Usage: Advanced
```ts
// Client-side prediction + reconciliation loop
const simulate = (s: World, input: Input) => applyInput(s, input);
const compare = (a: World, b: World) => squaredDistance(a, b);
sync.tick();
const predicted = sync.predict(currentWorld, localInput, simulate);
renderWorld(predicted);
onServerSnapshot((snapshot, serverTick) => {
const result = sync.reconcile(snapshot.state, serverTick, simulate, compare);
if (result.needed) snapToServerCorrected(result.state, result.framesResimulated);
});
// Interpolation for remote entities (lerp between two server frames)
const visible = sync.interpolate(serverHistory, performance.now(), lerpWorld);
```
## API Reference
- `new BitPackerStateSync({ clock }, config)` —
`config: { packer, schema?, enablePrediction?, enableInterpolation?, enableReconciliation?, interpolationDelayTicks?, tickDurationMs?, maxPredictionFrames?, reconciliationThreshold? }`.
- `tick() / getTick() / setTick(value)` — logical tick management.
- `packState(state) → StateSyncMessage` — kind `"state:delta"`.
- `packFullSnapshot(state) → StateSyncMessage` — kind `"state:full"`.
- `unpackState(message) → TState`.
- `predict(state, input, simulate) → TState`.
- `reconcile(serverState, serverTick, simulate, compare) → ReconciliationResult`.
- `interpolate(states, renderTime, lerp) → TState | null`.
- `getStats() / resetStats()`.
- `getHistory() / getPendingInputs() / getLastServerState()`.
- `dispose()` — release internal buffers.
## Implementation Notes
- The `BitPacker` instance is supplied by the caller because strategy
registration is application-specific — different games pack
different field types, and the strategies package owns the registry.
- History is trimmed to `maxPredictionFrames` (not 2×) once it exceeds
the `2 × maxPredictionFrames` threshold; this is the upstream rule
(small grace before trim).
- `interpolate` derives its delay as `interpolationDelayTicks * tickDurationMs`,
which lets callers configure the visual smoothness independent of
the network sync interval.
- Counters and histograms are registered against the global OTel meter
named `@suaveplan/game-server` at version `0.1.0` — consistent with
every other game-server instrument.

View File

@@ -0,0 +1,66 @@
# Vector-clock sync — internal helpers
## Purpose
This module factors out the pure helpers that drive
`VectorClockSync` — happens-before / concurrent predicates, conflict
resolution, causality sort, concurrent-group detection, and
missing-action computation. Pulling them out of the class keeps
`vector-clock-sync.ts` under the per-file budget and lets the helpers be
exercised directly by tests without standing the full class up.
## Features
- `happenedBefore` / `areConcurrent` — vector-clock predicates that
delegate to `vectorClockCompare` from `@suaveplan/timing`.
- `resolveConflict` — strategy selection (`custom` → `merge` →
`last-write-wins`) with the same precedence as the upstream sync.
- `sortByCausality` — stable happens-before sort with timestamp tie
breaker.
- `findConcurrentGroups` — dedup by group identity (set equality).
- `findMissingActions` — pool filter against a remote clock state.
## Usage: Basic
```ts
import { happenedBefore } from "./vector-clock-sync.internal.js";
const earlier = happenedBefore(a, b);
```
## Usage: Advanced
```ts
import {
findMissingActions,
resolveConflict,
} from "./vector-clock-sync.internal.js";
const conflict = resolveConflict(
incoming,
[conflictingLocal],
/* conflictResolver */ undefined,
/* eventMerger */ (a, b) => ({ ...a, ...b })
);
const missing = findMissingActions(pool, theirClock);
```
## API Reference
- `happenedBefore<T>(a, b): boolean`
- `areConcurrent<T>(a, b): boolean`
- `resolveConflict<T>(action, conflicts, conflictResolver?, eventMerger?): ConflictResolution<T>`
- `sortByCausality<T>(actions): GameAction<T>[]`
- `findConcurrentGroups<T>(actions): GameAction<T>[][]`
- `findMissingActions<T>(pool, theirClock): GameAction<T>[]`
## Implementation Notes
- `resolveConflict` returns `conflictDetected: true` whenever the
`conflicts` array is non-empty, even if the chosen winner is the
original action (this matches upstream semantics and is what the
outer class uses to decide whether to emit metrics).
- `sortByCausality` is stable for concurrent actions because the
comparator falls through to the wall-clock timestamp difference.
- `findConcurrentGroups` deduplicates by membership equality, not
reference equality, so two iterations that reach the same group
through different seed actions collapse to one entry.

View File

@@ -0,0 +1,101 @@
# `VectorClockSync`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`VectorClockSync` is the distributed event-ordering primitive used by
multiplayer game sessions to decide whether two actions are causally
ordered or concurrent — the basis for deterministic replay, conflict
resolution and resync after partition. It builds on
`@suaveplan/timing`'s `createVectorClock` so all clock arithmetic lives
in one place and the game-server module just records actions, tags
them with the current snapshot, and decides what to do when two clocks
are incomparable.
## Features
- Per-session bookkeeping (`pendingActions`, `confirmedActions`,
participants, session vector clock).
- Three conflict-resolution strategies, dispatched in this order:
`custom` (resolver supplied) → `merge` (merger supplied AND a single
concurrent conflict) → `last-write-wins`.
- Happens-before and concurrent predicates exposed both as instance
methods and as standalone helpers in `vector-clock-sync.internal.ts`.
- `requestSync` / `handleSyncRequest` / `applySyncResponse` cover the
three legs of a pull-style sync round-trip.
- Action history bounded by `maxActionHistory`, with FIFO eviction.
- Injectable `Clock` and `IdGenerator` everywhere a timestamp / id is
minted (no implicit `Date.now` / `Math.random`).
- Two OTel counters: `game_server.vector_clock.actions_total` and
`game_server.vector_clock.conflicts_total`.
## Usage: Basic
```ts
import { VectorClockSync } from "@suaveplan/game-server/enhanced/timing/vector-clock-sync";
const sync = new VectorClockSync(
{ clock: Date.now, idGenerator: () => crypto.randomUUID() },
{ nodeId: "player-1" }
);
const action = sync.recordAction("session-1", "move", { x: 10 }, /* tick */ 4);
broadcast(action);
```
## Usage: Advanced
```ts
// On the receiving side
const result = sync.receiveAction(remoteAction);
if (result?.conflictDetected) {
metrics.recordConflict(result.strategy);
applyChosen(result.resolvedAction);
}
// Pull-based sync round-trip after reconnect
const request = sync.requestSync("player-2", "session-1");
sendOverWire(request);
// On the peer
const response = peerSync.handleSyncRequest(request);
// Back on the requester
const newCount = sync.applySyncResponse(response);
```
## API Reference
- `new VectorClockSync({ clock, idGenerator }, config)` —
`config: { nodeId, maxActionHistory?, autoResolveConflicts?, conflictResolver?, eventMerger? }`.
- `recordAction(sessionId, actionType, data, tick) → GameAction`.
- `receiveAction(action) → ConflictResolution | null`.
- `acknowledgeAction(id) → boolean`.
- `getCausalityChain(sessionId) → CausalityChain`.
- `happenedBefore(a, b) / areConcurrent(a, b)` — predicates.
- `requestSync(toNode, sessionId) → SyncRequest`.
- `handleSyncRequest(request) → SyncResponse`.
- `applySyncResponse(response) → number` (new actions count).
- `addParticipant / removeParticipant`.
- `getSessionState / getStats / dispose`.
- `createVectorClockSync(deps, config)` — convenience factory.
## Implementation Notes
- `receiveAction` always advances this node's clock by `update(merge)`
before resolution; this is what guarantees the resulting causality
chain stays consistent even if the application discards the
conflict winner.
- Action IDs are generated by the injected `IdGenerator` rather than
derived from `${nodeId}-${seq}-${timestamp}` so callers can plug in
UUID v7 or any other source — the original derivation was lossy
when two actions raced inside the same millisecond.
- The action-history map is capped at `maxActionHistory` and evicts in
insertion order. Beyond the cap, replays only work for actions
inside the window.
- `findConcurrentGroups` dedupes by membership equality (set equality)
so two iterations that reach the same group through different seed
actions collapse to one entry.

View File

@@ -0,0 +1,81 @@
# tournament-bracket.internal
## Purpose
Pure bracket-generation helpers used by `TournamentManager`. Extracted to
a sibling internal module to keep `tournament-manager.ts` under the
project's 300-line hard limit while preserving full coverage of the
generation paths through the manager's public surface.
The helpers are deliberately framework-free — they accept an injectable
`createMatch` factory so the manager retains exclusive ownership of match
ids, OTel counters, and other side-effecting machinery, leaving these
helpers as deterministic pure functions over the participant + match
type domain.
## Features
- `roundName(round, total)` — human-readable round labels.
- `shuffleWithRng(arr, rng)` — in-place Fisher-Yates via injectable `Rng`.
- `padToPowerOfTwo(participants)` — null-padding for single-elim.
- `buildSingleElim(...)` — bracket with first-round byes.
- `buildRoundRobin(...)` — circle-method scheduling.
## Usage: Basic
```ts
import { buildSingleElim } from "./tournament-bracket.internal.js";
const rounds = buildSingleElim(participants, (round, position) =>
createMatch(round, position)
);
```
## Usage: Advanced
```ts
import {
buildRoundRobin,
shuffleWithRng,
} from "./tournament-bracket.internal.js";
shuffleWithRng(participants, rng);
const rounds = buildRoundRobin(participants, (round, position) => ({
id: nextId(),
round,
position,
participant1: null,
participant2: null,
status: "pending",
result: null,
nextMatchId: null,
}));
```
## API Reference
- `roundName(round: number, total: number): string`
- `shuffleWithRng<T>(arr: T[], rng: Rng | undefined): void`
- `padToPowerOfTwo<T>(participants: Participant<T>[]): (Participant<T> | null)[]`
- `buildSingleElim<TP, TR>(participants, createMatch): Round<TP, TR>[]`
- `buildRoundRobin<TP, TR>(participants, createMatch): Round<TP, TR>[]`
## Implementation Notes
`buildSingleElim` throws `INVALID_BRACKET_SIZE` if the padded size is not
a power of two — this should be unreachable in practice since
`padToPowerOfTwo` always pads to a power of two, but the guard catches
caller misuse (e.g. handing in a pre-padded list) without silent
corruption of the bracket shape.
`buildRoundRobin` uses the standard circle method: one participant is
fixed and the others rotate around it. For an odd participant count a
`null` slot is injected so each round contains exactly `floor(n/2)`
matches; pairs with `null` are filtered out before emitting matches so
they don't appear in the bracket. This yields `n - 1` rounds for `n`
even and `n` rounds for `n` odd, matching the conventional schedule.
Shuffle is unbiased Fisher-Yates and uses `Rng.nextIntInRange` —
implementations of `Rng` that do not provide it (it is optional in the
core contract) leave the order untouched, which is the conservative
default for any tournament harness that prefers explicit error over silent
non-determinism.

View File

@@ -0,0 +1,129 @@
# TournamentManager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TournamentManager` orchestrates single-elimination and round-robin
tournaments. Participants register during the `pending` phase, then a
single `start()` locks the field, applies seeding (or RNG-driven
shuffling), and constructs the bracket. From there the manager tracks
match results, propagates winners through `nextMatchId` links, updates
standings, and emits stable error codes whenever the caller would
otherwise corrupt the bracket.
The manager injects its `Clock`, `IdGenerator`, and `Rng` so deterministic
test rigs and seeded replays produce byte-identical brackets each run.
OTel counters record per-match creation and completion so production
operators can monitor tournament throughput without bespoke
instrumentation.
## Features
- Two bracket types: single-elimination, round-robin.
- Automatic first-round bye propagation for non-power-of-two fields.
- Deterministic seeded shuffling via injectable `Rng`.
- Standings table with stable points/wins/losses tie-break.
- Stable `GAME_SERVER.*` error codes (`INVALID_BRACKET_SIZE`,
`TOURNAMENT_INVALID_STATE`, `TOURNAMENT_FULL`,
`TOURNAMENT_DUPLICATE_PARTICIPANT`,
`TOURNAMENT_INSUFFICIENT_PARTICIPANTS`, `TOURNAMENT_MATCH_NOT_FOUND`,
`TOURNAMENT_INVALID_RESULT`).
- Zod-validated config.
- OTel match counter tagged by `op` (`created`, `completed`).
## Usage: Basic
```ts
import { TournamentManager } from "@suaveplan/game-server/enhanced/tournament/tournament-manager";
const tm = new TournamentManager({
id: "weekly-cup",
name: "Weekly Cup",
bracketType: "single-elimination",
maxParticipants: 16,
});
tm.addParticipant("alice");
tm.addParticipant("bob");
tm.start();
const firstMatch = tm.getTournament().rounds[0]!.matches[0]!;
tm.recordResult(firstMatch.id, "alice");
```
## Usage: Advanced
```ts
import { TournamentManager } from "@suaveplan/game-server/enhanced/tournament/tournament-manager";
const tm = new TournamentManager<PlayerProfile, MatchTelemetry>(
{
id: "monthly",
name: "Monthly Championship",
bracketType: "round-robin",
maxParticipants: 8,
shuffleSeeds: true,
winPoints: 3,
drawPoints: 1,
lossPoints: 0,
},
{
clock: () => now,
rng: seededRng,
idGenerator: () => `m-${++counter}`,
}
);
for (const player of players) {
tm.addParticipant(player.id, player.profile);
}
tm.start();
for (const round of tm.getTournament().rounds) {
for (const match of round.matches) {
const winnerId = await playMatch(match);
tm.recordResult(match.id, winnerId, { latencyMs: 27 });
}
}
const champion = tm.getWinner();
const standings = tm.getStandings();
const stats = tm.getStats();
```
## API Reference
- `new TournamentManager(config, deps?)`
- `addParticipant(id, data?, seed?): Participant`
- `start(): void`
- `getMatch(id): Match`
- `recordResult(matchId, winnerId, data?): Match`
- `cancel(): void`
- `getTournament(): Tournament`
- `getWinner(): Participant | null`
- `getStandings(): readonly Standing[]`
- `getStats(): TournamentStats`
## Implementation Notes
Bracket generation is split into `tournament-bracket.internal.ts` and
standings/match utilities into `tournament-standings.internal.ts` to keep
this file under the project's 300-line hard limit while preserving full
behaviour and 100% coverage through the public surface. Internal modules
are not exported through the package barrel — their tests run via the
manager.
Single-elimination brackets pad participants to the next power of two
with `null` slots that become first-round byes. Byes auto-advance their
opponent into the next round during `start()`, so the bracket is
immediately consistent before the first `recordResult()` call. Tournament
completion is detected when exactly one participant remains active
(single-elim) or every round has completed (round-robin).
Seeding has two paths: explicit `useSeeding=true` sorts by the
caller-supplied `seed` value, while `shuffleSeeds=true` overrides that
ordering by Fisher-Yates shuffling with the injected `Rng`. An `Rng`
implementation that does not provide `nextIntInRange` is treated as a
no-op shuffle — the conservative default for tournament harnesses that
prefer explicit ordering errors over silent non-determinism.

View File

@@ -0,0 +1,71 @@
# tournament-standings.internal
## Purpose
Pure helper utilities for the standings table inside a tournament:
applying a win/loss outcome, marking participants eliminated, locating a
match by id, and flipping a round to completed. Extracted from
`tournament-manager.ts` to keep that file under the project's 300-line
hard limit while preserving full behaviour through the public API.
The helpers are stateless and take their targets by reference so the
manager retains exclusive ownership of mutability. They are covered
indirectly via the manager's public surface tests rather than directly,
which is the standard pattern for `.internal.ts` modules in this repo.
## Features
- `applyStanding` — write outcome + resort + re-rank in one call.
- `markEliminated` — set `eliminated = true` by participant id.
- `findMatch` — linear scan across rounds (small N).
- `markRoundCompleteIfDone` — flip round completion idempotently.
## Usage: Basic
```ts
import { applyStanding } from "./tournament-standings.internal.js";
applyStanding(standings, winner, loser, 3, 0);
```
## Usage: Advanced
```ts
import {
applyStanding,
findMatch,
markEliminated,
markRoundCompleteIfDone,
} from "./tournament-standings.internal.js";
applyStanding(tournament.standings, winner, loser, winPoints, lossPoints);
markEliminated(tournament.standings, loser.id);
const next = findMatch(tournament.rounds, currentMatch.nextMatchId!);
markRoundCompleteIfDone(tournament.rounds, currentMatch);
```
## API Reference
- `applyStanding(standings, winner, loser, winPoints, lossPoints): void`
- `markEliminated(standings, id): void`
- `findMatch(rounds, matchId): Match | null`
- `markRoundCompleteIfDone(rounds, match): void`
## Implementation Notes
The standings sort is stable on points first, then wins, then losses
ascending — the conventional Swiss-style ordering. Re-sorting after every
outcome is `O(N log N)` per result; for typical bracket sizes (≤ 256
participants) this is well under a millisecond and avoids the
bookkeeping complexity of an incremental order-statistic structure.
`findMatch` is a linear scan across all rounds. Bracket-size rounds grow
logarithmically with participant count in single-elimination and
linearly in round-robin, so the total match count stays small relative to
typical scan budgets. A hot-path id-to-match index would be premature
optimisation here and would force the manager to keep two structures in
sync.
`markRoundCompleteIfDone` treats `bye` and `completed` as equivalent
terminal states so a round consisting entirely of byes is reported as
done immediately after `start()`, which keeps `getStats().currentRound`
advancing through automatic walkovers without manual intervention.