Initial Commit
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
# StateDiffer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`StateDiffer` is a pure structural diff and patch utility for plain-object
|
||||
game state. It produces a compact, deterministic `StateDelta` between two
|
||||
snapshots and applies that delta back onto a snapshot to reconstruct the
|
||||
target — the symmetric pair `patch(a, diff(a, b)) ≡ b` is the round-trip
|
||||
invariant on which `DeltaCompressor` and `StateSync` depend. Wave B-3
|
||||
ships this as a leaf utility under `@suaveplan/game-server/state/state-differ`.
|
||||
|
||||
The migration replaces the upstream's `"unchanged"` operation type — which
|
||||
inflated wire size for no benefit — with a delta that records only the
|
||||
operations that actually change something. Arrays use index-aligned diffing
|
||||
(positional), objects use key-set diffing, and primitives fall through to a
|
||||
single `replace`. Every traversal failure is wrapped in
|
||||
`GameServerError` with code `GAME_SERVER.STATE_DIFF_FAILED` or
|
||||
`STATE_PATCH_FAILED`, so callers never see a bare runtime exception.
|
||||
|
||||
## Features
|
||||
|
||||
- Three-op grammar: `add`, `remove`, `replace` (no `unchanged` noise).
|
||||
- Object diff via key-set comparison; array diff via positional alignment.
|
||||
- Injectable `Clock` from `@suaveplan/types` for deterministic timestamps.
|
||||
- `mergeDeltas` collapses a sequence of deltas into a last-writer-wins set.
|
||||
- `isEmpty` predicate for skipping zero-change sync ticks.
|
||||
- `parsePath` handles nested arrays (`outer[0][1].inner`).
|
||||
- Plain-object guard rejects class instances (preserves identity semantics).
|
||||
- Typed errors via `GameServerError` — `STATE_DIFF_FAILED`, `STATE_PATCH_FAILED`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```typescript
|
||||
import { StateDiffer } from "@suaveplan/game-server/state/state-differ";
|
||||
|
||||
const differ = new StateDiffer();
|
||||
const delta = differ.diff(
|
||||
{ score: 0, players: ["a"] },
|
||||
{ score: 1, players: ["a", "b"] }
|
||||
);
|
||||
|
||||
console.log(delta.operations);
|
||||
// [{ type: "replace", path: "score", value: 1, oldValue: 0 },
|
||||
// { type: "add", path: "players[1]", value: "b" }]
|
||||
|
||||
const restored = differ.patch({ score: 0, players: ["a"] }, delta);
|
||||
// { score: 1, players: ["a", "b"] }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```typescript
|
||||
import { StateDiffer } from "@suaveplan/game-server/state/state-differ";
|
||||
|
||||
const clock = () => 1_700_000_000_000;
|
||||
const differ = new StateDiffer({ clock });
|
||||
|
||||
// Compose multiple ticks into one wire packet.
|
||||
const a = differ.diff({ x: 0 }, { x: 1 });
|
||||
const b = differ.diff({ x: 1 }, { x: 2 });
|
||||
const c = differ.diff({ x: 2 }, { x: 3 });
|
||||
const merged = differ.mergeDeltas([a, b, c]);
|
||||
// merged.operations === [{ type: "replace", path: "x", value: 3, oldValue: 2 }]
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class StateDiffer`
|
||||
|
||||
#### `constructor(options?: StateDifferOptions)`
|
||||
|
||||
`options.clock` — `Clock` from `@suaveplan/types`. Defaults to `Date.now`.
|
||||
|
||||
#### `diff<T>(oldState: T, newState: T): StateDelta`
|
||||
|
||||
Throws `GameServerError(GAME_SERVER.STATE_DIFF_FAILED)` on traversal failure.
|
||||
|
||||
#### `patch<T>(state: T, delta: StateDelta): T`
|
||||
|
||||
Returns a deeply-cloned, patched copy. Throws
|
||||
`GameServerError(GAME_SERVER.STATE_PATCH_FAILED)` on apply failure.
|
||||
|
||||
#### `isEmpty(delta: StateDelta): boolean`
|
||||
|
||||
#### `mergeDeltas(deltas: StateDelta[]): StateDelta`
|
||||
|
||||
### Types
|
||||
|
||||
- `DiffOperation` — discriminated union of `add | remove | replace`.
|
||||
- `StateDelta` — `{ timestamp, operations, size }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The omission of an `unchanged` operation is deliberate: the upstream emitted
|
||||
one for every untouched leaf, ballooning a single-field update into a delta
|
||||
proportional to the snapshot size. Removing it shrinks typical 1-KB
|
||||
snapshots to 30-byte deltas without losing round-trip fidelity, because
|
||||
`patch` only walks the operations that exist.
|
||||
|
||||
`Object.is` (rather than `===`) is used for equality so `NaN` and `-0`
|
||||
diff correctly. The plain-object guard rejects class instances — game
|
||||
states routinely carry `Date`, `Map`, `Set`, or domain classes whose
|
||||
internal slots would not survive a structural diff; treating them as
|
||||
opaque `replace` values is safer than producing a half-mutated clone.
|
||||
|
||||
`deepClone` is a hand-rolled recursive walker rather than
|
||||
`structuredClone` because the diffed values are constrained to plain JSON
|
||||
shapes and the recursion limit on game state (depth ≤ 16 in practice) is
|
||||
far below the V8 stack budget.
|
||||
Reference in New Issue
Block a user