first commit
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user