@its-not-rocket-science/ananke 0.1.56 → 0.1.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,69 @@ Versioning follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ---
8
8
 
9
+ ## [0.1.59] — 2026-03-30
10
+
11
+ ### Added
12
+
13
+ - **PA-10 — Deterministic Networking Kit (complete):**
14
+ - `src/netcode.ts` (new): determinism utilities for authoritative lockstep and desync diagnosis.
15
+ - **`hashWorldState(world): bigint`**: FNV-64 hash over `tick`, `seed`, and all entity state sorted by `id` (Map fields serialised as sorted entry arrays for canonical form). Use as a per-tick desync checksum in multiplayer loops.
16
+ - **`diffReplays(replayA, replayB, ctx): ReplayDiff`**: steps two replays in lock-step and returns the first tick where their hashes diverge. O(N) in replay length.
17
+ - **`diffReplayJson(jsonA, jsonB, ctx): ReplayDiff`**: convenience wrapper for CLI use.
18
+ - `ReplayDiff` interface: `{ divergeAtTick, hashA, hashB, ticksCompared }`.
19
+ - `"./netcode"` subpath export added to `package.json`.
20
+ - **`ananke replay diff` CLI subcommand**: extends the `npx ananke` CLI — reads two replay JSON files and prints the first divergence tick and hex hashes, or confirms they are identical. Exit code 0 = identical; exit code 1 = divergence.
21
+ - **`docs/netcode-host-checklist.md`** (new): 8-section guide covering fixed tick rate, no wall-clock reads in simulation path, input serialisation format, desync detection, state resync (full snapshot), replay recording and diff, rollback implementation outline, and KernelContext consistency requirements.
22
+ - **`examples/lockstep-server.ts`** (new): self-contained authoritative lockstep demo — one server steps the world, two virtual clients verify hash checksums every tick. Demonstrates replay recording and `serializeBridgeFrame` integration.
23
+ - **`examples/rollback-client.ts`** (new): rollback demo — client predicts speculatively, reconciles against server hash, and re-simulates from the last confirmed snapshot when a mismatch is detected.
24
+ - npm scripts: `example:lockstep`, `example:rollback`.
25
+ - 16 new tests (189 test files, 5,569 tests total). Coverage: 97.11% stmt, 88.07% branch, 95.82% func. `netcode.ts`: 100%/100%/100%. Build: clean.
26
+
27
+ ---
28
+
29
+ ## [0.1.58] — 2026-03-30
30
+
31
+ ### Added
32
+
33
+ - **PA-9 — Simulation Cookbook (complete):**
34
+ - `docs/cookbook.md` (new): 12 task-oriented recipes designed to take a developer from zero to running simulation in under 30 minutes.
35
+ - **Recipe 1 — Simulate a duel**: `mkWorld` + `stepWorld` + command loop; expected output showing injury accumulation and fight end.
36
+ - **Recipe 2 — Run a 500-agent battle**: entity loop with `buildAICommands`; timing guidance (≤6 ms/tick on modern hardware).
37
+ - **Recipe 3 — Author a new species**: custom `Archetype` → `generateIndividual`; species-specific attribute overrides.
38
+ - **Recipe 4 — Add a custom weapon**: `Item` definition with mass, blade length, and damage profile; `createWorld` with `customItems`.
39
+ - **Recipe 5 — Drive a renderer**: `serializeBridgeFrame` + WebSocket sidecar pattern; references `docs/quickstart-unity.md`, `docs/quickstart-godot.md`, `docs/quickstart-web.md`.
40
+ - **Recipe 6 — Create a campaign loop**: `createPolity` + `stepPolityDay`; campaign-to-tactical transition example.
41
+ - **Recipe 7 — Build a validation scenario**: empirical range-check pattern; tolerance bands and `±%` reporting.
42
+ - **Recipe 8 — Use the what-if engine**: `npm run run:what-if`; scenario customization via parameter override.
43
+ - **Recipe 9 — Stream events to an agent**: delta detection + `serializeBridgeFrame` push over Server-Sent Events.
44
+ - **Recipe 10 — Save and reload a world**: `JSON.stringify` / `JSON.parse` round-trip with tick continuity check.
45
+ - **Recipe 11 — Record and replay a fight**: `ReplayRecorder` + `replayTo` + `serializeReplay` / `deserializeReplay`.
46
+ - **Recipe 12 — Load a content pack**: `loadPack` + `validatePack` + pack JSON schema reference.
47
+ - `README.md`: cookbook cross-link added in intro and "Further reading" table.
48
+
49
+ ---
50
+
51
+ ## [0.1.57] — 2026-03-30
52
+
53
+ ### Added
54
+
55
+ - **PA-8 — Host Integration SDKs (complete):**
56
+ - `src/host-loop.ts` (new): stable, versioned wire-format protocol for the Ananke sidecar ↔ renderer bridge. All values on the wire are real SI units (floats, not fixed-point).
57
+ - **Wire types**: `BridgeVec3`, `BridgeCondition`, `BridgeAnimation`, `BridgePoseModifier`, `BridgeGrappleConstraint`, `BridgeEntitySnapshot`, `BridgeFrame`, `HostLoopConfig`.
58
+ - **`serializeBridgeFrame(world, config)`**: canonical serializer — converts `WorldState` to `BridgeFrame`. Replaces per-sidecar serializer duplications in Unity and Godot reference implementations.
59
+ - **`derivePrimaryState(animation)`**: maps `AnimationHints` to a single state string (`"idle"` | `"attack"` | `"flee"` | `"prone"` | `"unconscious"` | `"dead"`). Suitable for top-level renderer state machines.
60
+ - **`derivePoseOffset(segmentId, impairmentQ)`**: anatomical local-space bone offset at a given impairment level (real metres), for injury deformation blend shapes.
61
+ - Constants: `BRIDGE_SCHEMA_VERSION = "ananke.bridge.frame.v1"`, `DEFAULT_TICK_HZ = 20`, `DEFAULT_BRIDGE_PORT = 3001`, `DEFAULT_BRIDGE_HOST`, `DEFAULT_STREAM_PATH`.
62
+ - `"./host-loop"` subpath export added to `package.json`.
63
+ - **Reference sidecar updates**: both `ananke-unity-reference` and `ananke-godot-reference` sidecars updated to v0.1.57 dependency and refactored to import `serializeBridgeFrame` from `@its-not-rocket-science/ananke/host-loop` — local serialization code removed.
64
+ - **Quickstart guides** (new):
65
+ - `docs/quickstart-unity.md`: 15-minute Unity integration guide (sidecar → WebSocket → `AnankeReceiver` → `AnimationDriver` → your mesh).
66
+ - `docs/quickstart-godot.md`: 15-minute Godot 4 integration guide (GDScript and C# addon variants).
67
+ - `docs/quickstart-web.md`: Three.js browser integration guide (zero-build-step HTML example + `serializeBridgeFrame` sidecar recipe).
68
+ - 41 new tests (188 test files, 5,553 tests total). Coverage: 97.10% stmt, 88.05% branch, 95.81% func. Build: clean.
69
+
70
+ ---
71
+
9
72
  ## [0.1.56] — 2026-03-30
10
73
 
11
74
  ### Added
package/README.md CHANGED
@@ -96,6 +96,9 @@ for (let tick = 0; tick < 2000; tick++) {
96
96
  `stepWorld` is the only function that mutates state. Everything else is pure computation.
97
97
  Call it at 20 Hz for real-time simulation; 1 Hz or lower for campaign-scale time.
98
98
 
99
+ For task-oriented walkthroughs, see the **[Simulation Cookbook](docs/cookbook.md)** — 12 recipes
100
+ from "Simulate a duel" to "Load a content pack", each with step-by-step code and expected output.
101
+
99
102
  ---
100
103
 
101
104
  ## Quick start A — Melee combat
@@ -411,6 +414,7 @@ Ananke's outputs are validated against historical and experimental sources:
411
414
 
412
415
  | Document | What's in it |
413
416
  |---|---|
417
+ | [`docs/cookbook.md`](docs/cookbook.md) | Task-oriented recipes — duel, 500-agent battle, species, renderer, campaign, replay, and more |
414
418
  | [`docs/module-index.md`](docs/module-index.md) | All 41 entry points — stability tier, use case, key exports, doc links |
415
419
  | [`docs/host-contract.md`](docs/host-contract.md) | Stable integration surface — everything needed to embed Ananke without reading `src/` |
416
420
  | [`docs/integration-primer.md`](docs/integration-primer.md) | Data-flow diagrams, type glossary, gotchas |
@@ -0,0 +1,188 @@
1
+ import type { WorldState } from "./sim/world.js";
2
+ import { type AnimationHints } from "./model3d.js";
3
+ /** Wire schema identifier — included in every BridgeFrame. */
4
+ export declare const BRIDGE_SCHEMA_VERSION: "ananke.bridge.frame.v1";
5
+ /** Default sidecar tick rate (Hz). */
6
+ export declare const DEFAULT_TICK_HZ = 20;
7
+ /** Default sidecar WebSocket/HTTP port. */
8
+ export declare const DEFAULT_BRIDGE_PORT = 3001;
9
+ /** Default sidecar host. */
10
+ export declare const DEFAULT_BRIDGE_HOST = "127.0.0.1";
11
+ /** Default WebSocket stream path. */
12
+ export declare const DEFAULT_STREAM_PATH = "/stream";
13
+ /**
14
+ * 3D vector in real metres (float).
15
+ * Converts from fixed-point SCALE.m: `x_m = x_Sm / SCALE.m`.
16
+ */
17
+ export interface BridgeVec3 {
18
+ x: number;
19
+ y: number;
20
+ z: number;
21
+ }
22
+ /**
23
+ * Entity physiological condition (Q-values as [0, 1] floats).
24
+ * Divide the underlying Q value by SCALE.Q (10 000) to get floats.
25
+ */
26
+ export interface BridgeCondition {
27
+ /** Shock intensity. q(0) = no shock; q(1.0) = incapacitating. */
28
+ shockQ: number;
29
+ /** Fear intensity. q(0) = calm; q(1.0) = panic. */
30
+ fearQ: number;
31
+ /** Consciousness level. q(0) = unconscious; q(1.0) = fully alert. */
32
+ consciousnessQ: number;
33
+ /** Cumulative fluid loss. q(0) = none; q(1.0) = lethal. */
34
+ fluidLossQ: number;
35
+ /** True if the entity is clinically dead. */
36
+ dead: boolean;
37
+ }
38
+ /**
39
+ * Animation blend weights and state flags for a renderer character controller.
40
+ * All Q-values are [0, 1] floats.
41
+ *
42
+ * Locomotive blends are mutually exclusive; typically only one is nonzero.
43
+ */
44
+ export interface BridgeAnimation {
45
+ idle: number;
46
+ walk: number;
47
+ run: number;
48
+ sprint: number;
49
+ crawl: number;
50
+ guardingQ: number;
51
+ attackingQ: number;
52
+ shockQ: number;
53
+ fearQ: number;
54
+ prone: boolean;
55
+ unconscious: boolean;
56
+ dead: boolean;
57
+ /** Dominant animation state as a single string (see `derivePrimaryState`). */
58
+ primaryState: string;
59
+ /** Max locomotion blend weight — useful for speed-parameterised blend trees. */
60
+ locomotionBlend: number;
61
+ /** Worst-case injury deformation weight across all body segments. */
62
+ injuryWeight: number;
63
+ }
64
+ /**
65
+ * Per-body-segment pose modifier — drives deformation or damage blend shapes.
66
+ * Q-values are [0, 1] floats.
67
+ */
68
+ export interface BridgePoseModifier {
69
+ segmentId: string;
70
+ /** Overall deformation blend: max(structuralQ, surfaceQ). */
71
+ impairmentQ: number;
72
+ structuralQ: number;
73
+ surfaceQ: number;
74
+ /**
75
+ * Anatomical offset for this segment at full impairment, in real metres.
76
+ * Apply to the bone's local position to show slumping/collapse.
77
+ */
78
+ localOffset_m: BridgeVec3;
79
+ }
80
+ /**
81
+ * Grapple constraint describing hold/held relationships between entities.
82
+ */
83
+ export interface BridgeGrappleConstraint {
84
+ isHolder: boolean;
85
+ holdingEntityId: number;
86
+ isHeld: boolean;
87
+ heldByIds: number[];
88
+ /** Grapple positional state. */
89
+ position: "standing" | "prone" | "pinned" | "mounted";
90
+ /** Grip strength [0, 1]. */
91
+ gripQ: number;
92
+ }
93
+ /**
94
+ * Complete per-entity snapshot for one simulation tick.
95
+ */
96
+ export interface BridgeEntitySnapshot {
97
+ entityId: number;
98
+ teamId: number;
99
+ tick: number;
100
+ /** World position in real metres. */
101
+ position_m: BridgeVec3;
102
+ /** Velocity in real m/s. */
103
+ velocity_mps: BridgeVec3;
104
+ /** Normalised facing direction (unit vector). */
105
+ facing: BridgeVec3;
106
+ /** Mass in real kg. */
107
+ massKg: number;
108
+ /** Centre-of-gravity offset from foot position (real metres). */
109
+ cogOffset_m: {
110
+ x: number;
111
+ y: number;
112
+ };
113
+ animation: BridgeAnimation;
114
+ pose: BridgePoseModifier[];
115
+ grapple: BridgeGrappleConstraint;
116
+ condition: BridgeCondition;
117
+ }
118
+ /**
119
+ * Complete serialized frame for one simulation tick.
120
+ * JSON-encoded and sent over WebSocket / HTTP.
121
+ */
122
+ export interface BridgeFrame {
123
+ /** Fixed schema identifier — check this before deserializing. */
124
+ schema: typeof BRIDGE_SCHEMA_VERSION;
125
+ scenarioId: string;
126
+ tick: number;
127
+ tickHz: number;
128
+ /** ISO 8601 generation timestamp — for latency diagnostics only. */
129
+ generatedAt: string;
130
+ entities: BridgeEntitySnapshot[];
131
+ }
132
+ /**
133
+ * Sidecar configuration — passed to `serializeBridgeFrame` and used by
134
+ * host loop implementations.
135
+ */
136
+ export interface HostLoopConfig {
137
+ /** Stable identifier for this scenario (e.g. `"knight-vs-brawler"`). */
138
+ scenarioId: string;
139
+ /** Simulation tick rate in Hz. Default: `DEFAULT_TICK_HZ` (20). */
140
+ tickHz?: number;
141
+ /** Listening port. Default: `DEFAULT_BRIDGE_PORT` (3001). */
142
+ port?: number;
143
+ /** Listening host. Default: `DEFAULT_BRIDGE_HOST`. */
144
+ host?: string;
145
+ }
146
+ /**
147
+ * Derive a single animation state string from `AnimationHints`.
148
+ *
149
+ * Priority: dead > unconscious > prone/crawl > attack > flee (run/sprint) > idle.
150
+ * Renderer character controllers use this to drive top-level state machines
151
+ * when a detailed blend tree is not available.
152
+ *
153
+ * @returns One of: `"dead"` | `"unconscious"` | `"prone"` | `"attack"` | `"flee"` | `"idle"`
154
+ */
155
+ export declare function derivePrimaryState(animation: AnimationHints): string;
156
+ /**
157
+ * Anatomical local-space offset for a body segment at maximum impairment.
158
+ *
159
+ * Applied as: `bone.localPosition += poseOffset * impairmentQ`.
160
+ * Values are in real metres (float).
161
+ *
162
+ * @param segmentId Canonical segment identifier (e.g. `"head"`, `"leftArm"`).
163
+ * @param impairmentQ Impairment blend weight [0, 1] float.
164
+ * @returns Local-space offset in real metres.
165
+ */
166
+ export declare function derivePoseOffset(segmentId: string, impairmentQ: number): BridgeVec3;
167
+ /**
168
+ * Serialize a complete simulation tick into the stable bridge wire format.
169
+ *
170
+ * This is the canonical sidecar serializer. Replaces per-project
171
+ * `serialiseFrame` implementations in Unity and Godot sidecars.
172
+ *
173
+ * @param world Current world state after `stepWorld()`.
174
+ * @param config Sidecar configuration.
175
+ * @returns A `BridgeFrame` safe to `JSON.stringify` and send over WebSocket.
176
+ *
177
+ * @example
178
+ * ```ts
179
+ * import { serializeBridgeFrame } from "@its-not-rocket-science/ananke/host-loop";
180
+ *
181
+ * function tick() {
182
+ * stepWorld(world, commands, ctx);
183
+ * const frame = serializeBridgeFrame(world, { scenarioId: "my-duel", tickHz: 20 });
184
+ * broadcast(JSON.stringify(frame));
185
+ * }
186
+ * ```
187
+ */
188
+ export declare function serializeBridgeFrame(world: WorldState, config: HostLoopConfig): BridgeFrame;
@@ -0,0 +1,185 @@
1
+ // src/host-loop.ts — PA-8: Host Integration Bridge Protocol
2
+ //
3
+ // Stable, versioned wire format for the Ananke sidecar ↔ renderer bridge.
4
+ // All values on the wire are in real SI units (floats).
5
+ //
6
+ // Usage in a sidecar:
7
+ // import { serializeBridgeFrame, HostLoopConfig } from "@ananke/host-loop";
8
+ //
9
+ // Usage in a renderer client (Unity, Godot, Web):
10
+ // const frame: BridgeFrame = JSON.parse(rawWebSocketMessage);
11
+ // // field names and types are stable across minor versions
12
+ //
13
+ // Schema version string: BRIDGE_SCHEMA_VERSION.
14
+ // Increment the version suffix (v1 → v2) only on breaking wire changes.
15
+ import { SCALE, q, clampQ, qMul } from "./units.js";
16
+ import { deriveAnimationHints, derivePoseModifiers, deriveGrappleConstraint, deriveMassDistribution, } from "./model3d.js";
17
+ // ── Protocol constants ─────────────────────────────────────────────────────────
18
+ /** Wire schema identifier — included in every BridgeFrame. */
19
+ export const BRIDGE_SCHEMA_VERSION = "ananke.bridge.frame.v1";
20
+ /** Default sidecar tick rate (Hz). */
21
+ export const DEFAULT_TICK_HZ = 20;
22
+ /** Default sidecar WebSocket/HTTP port. */
23
+ export const DEFAULT_BRIDGE_PORT = 3001;
24
+ /** Default sidecar host. */
25
+ export const DEFAULT_BRIDGE_HOST = "127.0.0.1";
26
+ /** Default WebSocket stream path. */
27
+ export const DEFAULT_STREAM_PATH = "/stream";
28
+ // ── Primary state derivation ──────────────────────────────────────────────────
29
+ /**
30
+ * Derive a single animation state string from `AnimationHints`.
31
+ *
32
+ * Priority: dead > unconscious > prone/crawl > attack > flee (run/sprint) > idle.
33
+ * Renderer character controllers use this to drive top-level state machines
34
+ * when a detailed blend tree is not available.
35
+ *
36
+ * @returns One of: `"dead"` | `"unconscious"` | `"prone"` | `"attack"` | `"flee"` | `"idle"`
37
+ */
38
+ export function derivePrimaryState(animation) {
39
+ if (animation.dead)
40
+ return "dead";
41
+ if (animation.unconscious)
42
+ return "unconscious";
43
+ if (animation.prone || animation.crawl > 0)
44
+ return "prone";
45
+ if (animation.attackingQ > 0)
46
+ return "attack";
47
+ if (animation.sprint > 0 || animation.run > 0)
48
+ return "flee";
49
+ return "idle";
50
+ }
51
+ // ── Pose offset per segment ───────────────────────────────────────────────────
52
+ /**
53
+ * Anatomical local-space offset for a body segment at maximum impairment.
54
+ *
55
+ * Applied as: `bone.localPosition += poseOffset * impairmentQ`.
56
+ * Values are in real metres (float).
57
+ *
58
+ * @param segmentId Canonical segment identifier (e.g. `"head"`, `"leftArm"`).
59
+ * @param impairmentQ Impairment blend weight [0, 1] float.
60
+ * @returns Local-space offset in real metres.
61
+ */
62
+ export function derivePoseOffset(segmentId, impairmentQ) {
63
+ // 6% of stature at full impairment (clamp to [0, SCALE.Q])
64
+ const weightQ = clampQ(Math.round(impairmentQ * SCALE.Q), q(0), SCALE.Q);
65
+ const offsetQ = qMul(weightQ, q(0.06));
66
+ const offset = offsetQ / SCALE.Q;
67
+ // `+ 0` normalises IEEE-754 negative-zero (−0) to plain 0.
68
+ switch (segmentId) {
69
+ case "head": return { x: 0, y: (-offset * 0.35) + 0, z: 0 };
70
+ case "torso":
71
+ case "thorax":
72
+ case "abdomen": return { x: 0, y: (-offset * 0.50) + 0, z: 0 };
73
+ case "leftArm": return { x: (-offset) + 0, y: 0, z: 0 };
74
+ case "rightArm": return { x: offset, y: 0, z: 0 };
75
+ case "leftLeg": return { x: (-offset * 0.45) + 0, y: (-offset) + 0, z: 0 };
76
+ case "rightLeg": return { x: offset * 0.45, y: (-offset) + 0, z: 0 };
77
+ default: return { x: 0, y: 0, z: 0 };
78
+ }
79
+ }
80
+ // ── Internal helpers ──────────────────────────────────────────────────────────
81
+ function scaleVec3(v, divisor) {
82
+ return { x: v.x / divisor, y: v.y / divisor, z: v.z / divisor };
83
+ }
84
+ function normalizeFacing(v) {
85
+ const mag = Math.hypot(v.x, v.y, v.z) || 1;
86
+ return { x: v.x / mag, y: v.y / mag, z: v.z / mag };
87
+ }
88
+ function buildBridgeAnimation(animation, pose) {
89
+ const locomotionBlend = Math.max(animation.idle, animation.walk, animation.run, animation.sprint, animation.crawl) / SCALE.Q;
90
+ const injuryWeight = pose.reduce((worst, m) => Math.max(worst, m.impairmentQ), 0) / SCALE.Q;
91
+ return {
92
+ idle: animation.idle / SCALE.Q,
93
+ walk: animation.walk / SCALE.Q,
94
+ run: animation.run / SCALE.Q,
95
+ sprint: animation.sprint / SCALE.Q,
96
+ crawl: animation.crawl / SCALE.Q,
97
+ guardingQ: animation.guardingQ / SCALE.Q,
98
+ attackingQ: animation.attackingQ / SCALE.Q,
99
+ shockQ: animation.shockQ / SCALE.Q,
100
+ fearQ: animation.fearQ / SCALE.Q,
101
+ prone: animation.prone,
102
+ unconscious: animation.unconscious,
103
+ dead: animation.dead,
104
+ primaryState: derivePrimaryState(animation),
105
+ locomotionBlend,
106
+ injuryWeight,
107
+ };
108
+ }
109
+ function buildBridgeGrapple(grapple) {
110
+ return {
111
+ isHolder: grapple.isHolder,
112
+ holdingEntityId: grapple.holdingEntityId ?? 0,
113
+ isHeld: grapple.isHeld,
114
+ heldByIds: grapple.heldByIds,
115
+ position: grapple.position,
116
+ gripQ: grapple.gripQ / SCALE.Q,
117
+ };
118
+ }
119
+ function buildBridgeCondition(entity) {
120
+ return {
121
+ shockQ: entity.injury.shock / SCALE.Q,
122
+ fearQ: (entity.condition.fearQ ?? 0) / SCALE.Q,
123
+ consciousnessQ: entity.injury.consciousness / SCALE.Q,
124
+ fluidLossQ: entity.injury.fluidLoss / SCALE.Q,
125
+ dead: entity.injury.dead,
126
+ };
127
+ }
128
+ function buildEntitySnapshot(entity, tick) {
129
+ const animation = deriveAnimationHints(entity);
130
+ const pose = derivePoseModifiers(entity);
131
+ const grapple = deriveGrappleConstraint(entity);
132
+ const mass = deriveMassDistribution(entity);
133
+ return {
134
+ entityId: entity.id,
135
+ teamId: entity.teamId,
136
+ tick,
137
+ position_m: scaleVec3(entity.position_m, SCALE.m),
138
+ velocity_mps: scaleVec3(entity.velocity_mps, SCALE.m),
139
+ facing: normalizeFacing(entity.action.facingDirQ),
140
+ massKg: mass.totalMass_kg / SCALE.kg,
141
+ cogOffset_m: mass.cogOffset_m,
142
+ animation: buildBridgeAnimation(animation, pose),
143
+ pose: pose.map(pm => ({
144
+ segmentId: pm.segmentId,
145
+ impairmentQ: pm.impairmentQ / SCALE.Q,
146
+ structuralQ: pm.structuralQ / SCALE.Q,
147
+ surfaceQ: pm.surfaceQ / SCALE.Q,
148
+ localOffset_m: derivePoseOffset(pm.segmentId, pm.impairmentQ / SCALE.Q),
149
+ })),
150
+ grapple: buildBridgeGrapple(grapple),
151
+ condition: buildBridgeCondition(entity),
152
+ };
153
+ }
154
+ // ── Public API ────────────────────────────────────────────────────────────────
155
+ /**
156
+ * Serialize a complete simulation tick into the stable bridge wire format.
157
+ *
158
+ * This is the canonical sidecar serializer. Replaces per-project
159
+ * `serialiseFrame` implementations in Unity and Godot sidecars.
160
+ *
161
+ * @param world Current world state after `stepWorld()`.
162
+ * @param config Sidecar configuration.
163
+ * @returns A `BridgeFrame` safe to `JSON.stringify` and send over WebSocket.
164
+ *
165
+ * @example
166
+ * ```ts
167
+ * import { serializeBridgeFrame } from "@its-not-rocket-science/ananke/host-loop";
168
+ *
169
+ * function tick() {
170
+ * stepWorld(world, commands, ctx);
171
+ * const frame = serializeBridgeFrame(world, { scenarioId: "my-duel", tickHz: 20 });
172
+ * broadcast(JSON.stringify(frame));
173
+ * }
174
+ * ```
175
+ */
176
+ export function serializeBridgeFrame(world, config) {
177
+ return {
178
+ schema: BRIDGE_SCHEMA_VERSION,
179
+ scenarioId: config.scenarioId,
180
+ tick: world.tick,
181
+ tickHz: config.tickHz ?? DEFAULT_TICK_HZ,
182
+ generatedAt: new Date().toISOString(),
183
+ entities: world.entities.map(e => buildEntitySnapshot(e, world.tick)),
184
+ };
185
+ }
@@ -0,0 +1,50 @@
1
+ import type { WorldState } from "./sim/world.js";
2
+ import type { KernelContext } from "./sim/context.js";
3
+ import { type Replay } from "./replay.js";
4
+ /**
5
+ * Compute a deterministic 64-bit hash of the simulation's core state.
6
+ *
7
+ * Covers `tick`, `seed`, and all entity data sorted by `id`. Optional
8
+ * subsystem fields (`__sensoryEnv`, `__factionRegistry`, etc.) are excluded —
9
+ * they are host concerns and do not affect simulation determinism.
10
+ *
11
+ * Use this as a desync checksum in multiplayer loops:
12
+ *
13
+ * ```ts
14
+ * const hash = hashWorldState(world);
15
+ * socket.emit("tick-ack", { tick: world.tick, hash: hash.toString() });
16
+ * ```
17
+ *
18
+ * @returns An unsigned 64-bit bigint.
19
+ */
20
+ export declare function hashWorldState(world: WorldState): bigint;
21
+ /** Result of comparing two replay traces. */
22
+ export interface ReplayDiff {
23
+ /** Tick at which the two replays first diverge. `-1` means the initial
24
+ * states differ before any step. `undefined` means the replays are
25
+ * identical up to the last compared tick. */
26
+ divergeAtTick: number | undefined;
27
+ /** Hash from replay A at the divergence tick (`undefined` when identical). */
28
+ hashA: bigint | undefined;
29
+ /** Hash from replay B at the divergence tick (`undefined` when identical). */
30
+ hashB: bigint | undefined;
31
+ /** Total ticks compared (including the initial-state check). */
32
+ ticksCompared: number;
33
+ }
34
+ /**
35
+ * Compare two replay traces tick-by-tick and find the first divergence.
36
+ *
37
+ * Steps both replays from their initial states in lock-step, computing
38
+ * `hashWorldState` after each tick. O(N) in replay length.
39
+ *
40
+ * @param replayA First replay (e.g. client A's recording).
41
+ * @param replayB Second replay (e.g. client B's recording).
42
+ * @param ctx KernelContext forwarded to `stepWorld`.
43
+ */
44
+ export declare function diffReplays(replayA: Replay, replayB: Replay, ctx: KernelContext): ReplayDiff;
45
+ /**
46
+ * Parse two replay JSON strings and diff them.
47
+ *
48
+ * Convenience wrapper over `diffReplays` for CLI use.
49
+ */
50
+ export declare function diffReplayJson(jsonA: string, jsonB: string, ctx: KernelContext): ReplayDiff;
@@ -0,0 +1,115 @@
1
+ // src/netcode.ts — PA-10: Deterministic Networking Kit
2
+ //
3
+ // Utilities for authoritative lockstep and desync diagnosis.
4
+ //
5
+ // Core guarantee: two clients running identical commands from identical seeds
6
+ // must produce identical hashWorldState() outputs at every tick. A mismatch
7
+ // pinpoints the first tick where state diverged.
8
+ import { stepWorld } from "./sim/kernel.js";
9
+ import { deserializeReplay } from "./replay.js";
10
+ // ── FNV-64 hash ───────────────────────────────────────────────────────────────
11
+ // 64-bit Fowler–Noll–Vo (FNV-1a) over UTF-16 code units. Pure arithmetic,
12
+ // no external dependencies, portable across Node and browsers.
13
+ const FNV64_OFFSET = 14695981039346656037n;
14
+ const FNV64_PRIME = 1099511628211n;
15
+ const UINT64_MASK = 0xffffffffffffffffn;
16
+ function fnv64(data) {
17
+ let hash = FNV64_OFFSET;
18
+ for (let i = 0; i < data.length; i++) {
19
+ hash ^= BigInt(data.charCodeAt(i));
20
+ hash = (hash * FNV64_PRIME) & UINT64_MASK;
21
+ }
22
+ return hash;
23
+ }
24
+ // ── Stable JSON serialiser ────────────────────────────────────────────────────
25
+ // JSON.stringify with sorted object keys so property insertion order does not
26
+ // affect the hash. Maps (armourState, foodInventory, reputations) are
27
+ // serialised as sorted entry arrays to guarantee a canonical form.
28
+ function stableReplacer(_key, value) {
29
+ if (value instanceof Map) {
30
+ const entries = [...value.entries()]
31
+ .sort(([a], [b]) => String(a).localeCompare(String(b)));
32
+ return { __map__: entries };
33
+ }
34
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
35
+ const sorted = {};
36
+ for (const k of Object.keys(value).sort()) {
37
+ sorted[k] = value[k];
38
+ }
39
+ return sorted;
40
+ }
41
+ return value;
42
+ }
43
+ /**
44
+ * Compute a deterministic 64-bit hash of the simulation's core state.
45
+ *
46
+ * Covers `tick`, `seed`, and all entity data sorted by `id`. Optional
47
+ * subsystem fields (`__sensoryEnv`, `__factionRegistry`, etc.) are excluded —
48
+ * they are host concerns and do not affect simulation determinism.
49
+ *
50
+ * Use this as a desync checksum in multiplayer loops:
51
+ *
52
+ * ```ts
53
+ * const hash = hashWorldState(world);
54
+ * socket.emit("tick-ack", { tick: world.tick, hash: hash.toString() });
55
+ * ```
56
+ *
57
+ * @returns An unsigned 64-bit bigint.
58
+ */
59
+ export function hashWorldState(world) {
60
+ const sorted = [...world.entities].sort((a, b) => a.id - b.id);
61
+ const canonical = JSON.stringify({ tick: world.tick, seed: world.seed, entities: sorted }, stableReplacer);
62
+ return fnv64(canonical);
63
+ }
64
+ /**
65
+ * Compare two replay traces tick-by-tick and find the first divergence.
66
+ *
67
+ * Steps both replays from their initial states in lock-step, computing
68
+ * `hashWorldState` after each tick. O(N) in replay length.
69
+ *
70
+ * @param replayA First replay (e.g. client A's recording).
71
+ * @param replayB Second replay (e.g. client B's recording).
72
+ * @param ctx KernelContext forwarded to `stepWorld`.
73
+ */
74
+ export function diffReplays(replayA, replayB, ctx) {
75
+ const worldA = structuredClone(replayA.initialState);
76
+ const worldB = structuredClone(replayB.initialState);
77
+ // Check initial state before any steps.
78
+ const initA = hashWorldState(worldA);
79
+ const initB = hashWorldState(worldB);
80
+ if (initA !== initB) {
81
+ return { divergeAtTick: -1, hashA: initA, hashB: initB, ticksCompared: 0 };
82
+ }
83
+ const maxFrames = Math.min(replayA.frames.length, replayB.frames.length);
84
+ for (let i = 0; i < maxFrames; i++) {
85
+ const frameA = replayA.frames[i];
86
+ const frameB = replayB.frames[i];
87
+ const cmdsA = new Map(frameA.commands.map(([id, cmds]) => [id, cmds]));
88
+ const cmdsB = new Map(frameB.commands.map(([id, cmds]) => [id, cmds]));
89
+ stepWorld(worldA, cmdsA, ctx);
90
+ stepWorld(worldB, cmdsB, ctx);
91
+ const hA = hashWorldState(worldA);
92
+ const hB = hashWorldState(worldB);
93
+ if (hA !== hB) {
94
+ return {
95
+ divergeAtTick: worldA.tick,
96
+ hashA: hA,
97
+ hashB: hB,
98
+ ticksCompared: i + 1,
99
+ };
100
+ }
101
+ }
102
+ // If one replay has more frames, that's not a divergence — just a shorter
103
+ // recording on one side.
104
+ return { divergeAtTick: undefined, hashA: undefined, hashB: undefined, ticksCompared: maxFrames };
105
+ }
106
+ /**
107
+ * Parse two replay JSON strings and diff them.
108
+ *
109
+ * Convenience wrapper over `diffReplays` for CLI use.
110
+ */
111
+ export function diffReplayJson(jsonA, jsonB, ctx) {
112
+ const replayA = deserializeReplay(jsonA);
113
+ const replayB = deserializeReplay(jsonB);
114
+ return diffReplays(replayA, replayB, ctx);
115
+ }
@@ -1,16 +1,20 @@
1
1
  #!/usr/bin/env node
2
- // tools/pack-cli.ts — PA-4: Ananke content pack CLI
2
+ // tools/pack-cli.ts — PA-4 / PA-10: Ananke CLI
3
3
  //
4
4
  // Usage (after npm run build):
5
5
  // node dist/tools/pack-cli.js pack validate <file.json>
6
6
  // node dist/tools/pack-cli.js pack bundle <directory>
7
+ // node dist/tools/pack-cli.js replay diff <a.json> <b.json>
7
8
  //
8
9
  // Or via the installed binary:
9
10
  // npx ananke pack validate <file.json>
10
11
  // npx ananke pack bundle <directory>
12
+ // npx ananke replay diff <a.json> <b.json>
11
13
  import { readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
12
14
  import { join, resolve, extname, basename } from "node:path";
13
15
  import { validatePack, loadPack } from "../src/content-pack.js";
16
+ import { diffReplayJson } from "../src/netcode.js";
17
+ import { q } from "../src/units.js";
14
18
  // ── Helpers ───────────────────────────────────────────────────────────────────
15
19
  function readJson(filePath) {
16
20
  try {
@@ -122,34 +126,99 @@ function cmdLoad(args) {
122
126
  console.log(` scenarios: ${result.scenarioIds.join(", ") || "none"}`);
123
127
  console.log(` fingerprint: ${result.fingerprint}`);
124
128
  }
129
+ function cmdReplayDiff(args) {
130
+ const fileA = args[0];
131
+ const fileB = args[1];
132
+ if (!fileA || !fileB) {
133
+ console.error("Usage: ananke replay diff <replay-a.json> <replay-b.json>");
134
+ process.exit(1);
135
+ }
136
+ let jsonA;
137
+ let jsonB;
138
+ try {
139
+ jsonA = readFileSync(resolve(fileA), "utf8");
140
+ }
141
+ catch (e) {
142
+ console.error(`Cannot read ${fileA}: ${String(e)}`);
143
+ process.exit(1);
144
+ }
145
+ try {
146
+ jsonB = readFileSync(resolve(fileB), "utf8");
147
+ }
148
+ catch (e) {
149
+ console.error(`Cannot read ${fileB}: ${String(e)}`);
150
+ process.exit(1);
151
+ }
152
+ const ctx = { tractionCoeff: q(1.0) };
153
+ const result = diffReplayJson(jsonA, jsonB, ctx);
154
+ console.log(`Ticks compared: ${result.ticksCompared}`);
155
+ if (result.divergeAtTick === undefined) {
156
+ console.log("✓ Replays are identical — no divergence detected.");
157
+ process.exit(0);
158
+ }
159
+ if (result.divergeAtTick === -1) {
160
+ console.error("✗ Initial states differ (before tick 0).");
161
+ console.error(` hash A: ${result.hashA?.toString(16)}`);
162
+ console.error(` hash B: ${result.hashB?.toString(16)}`);
163
+ process.exit(1);
164
+ }
165
+ console.error(`✗ Divergence at tick ${result.divergeAtTick}.`);
166
+ console.error(` hash A: ${result.hashA?.toString(16)}`);
167
+ console.error(` hash B: ${result.hashB?.toString(16)}`);
168
+ process.exit(1);
169
+ }
125
170
  // ── Entry point ───────────────────────────────────────────────────────────────
126
171
  function main() {
127
172
  const argv = process.argv.slice(2);
128
- if (argv[0] !== "pack") {
129
- console.log("Ananke CLI");
130
- console.log("");
131
- console.log("Commands:");
132
- console.log(" pack validate <file.json> — validate a pack manifest");
133
- console.log(" pack bundle <directory> [out.json] — merge JSON files into one pack");
134
- console.log(" pack load <file.json> — load a pack and report registered ids");
173
+ const cmd = argv[0];
174
+ if (!cmd) {
175
+ printHelp();
135
176
  process.exit(0);
136
177
  }
137
178
  const sub = argv[1];
138
179
  const rest = argv.slice(2);
139
- switch (sub) {
140
- case "validate":
141
- cmdValidate(rest);
180
+ switch (cmd) {
181
+ case "pack":
182
+ switch (sub) {
183
+ case "validate":
184
+ cmdValidate(rest);
185
+ break;
186
+ case "bundle":
187
+ cmdBundle(rest);
188
+ break;
189
+ case "load":
190
+ cmdLoad(rest);
191
+ break;
192
+ default:
193
+ console.error(`Unknown subcommand: pack ${sub ?? ""}`);
194
+ console.error("Available: validate, bundle, load");
195
+ process.exit(1);
196
+ }
142
197
  break;
143
- case "bundle":
144
- cmdBundle(rest);
145
- break;
146
- case "load":
147
- cmdLoad(rest);
198
+ case "replay":
199
+ switch (sub) {
200
+ case "diff":
201
+ cmdReplayDiff(rest);
202
+ break;
203
+ default:
204
+ console.error(`Unknown subcommand: replay ${sub ?? ""}`);
205
+ console.error("Available: diff");
206
+ process.exit(1);
207
+ }
148
208
  break;
149
209
  default:
150
- console.error(`Unknown subcommand: pack ${sub ?? ""}`);
151
- console.error("Available: validate, bundle, load");
210
+ console.error(`Unknown command: ${cmd}`);
211
+ printHelp();
152
212
  process.exit(1);
153
213
  }
154
214
  }
215
+ function printHelp() {
216
+ console.log("Ananke CLI");
217
+ console.log("");
218
+ console.log("Commands:");
219
+ console.log(" pack validate <file.json> — validate a pack manifest");
220
+ console.log(" pack bundle <directory> [out.json] — merge JSON files into one pack");
221
+ console.log(" pack load <file.json> — load a pack and report registered ids");
222
+ console.log(" replay diff <replay-a.json> <replay-b.json> — find the first tick divergence between two replays");
223
+ }
155
224
  main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@its-not-rocket-science/ananke",
3
- "version": "0.1.56",
3
+ "version": "0.1.59",
4
4
  "type": "module",
5
5
  "description": "Deterministic lockstep-friendly SI-units RPG/physics core (fixed-point TS)",
6
6
  "license": "MIT",
@@ -193,6 +193,14 @@
193
193
  "./extended-senses": {
194
194
  "import": "./dist/src/extended-senses.js",
195
195
  "types": "./dist/src/extended-senses.d.ts"
196
+ },
197
+ "./host-loop": {
198
+ "import": "./dist/src/host-loop.js",
199
+ "types": "./dist/src/host-loop.d.ts"
200
+ },
201
+ "./netcode": {
202
+ "import": "./dist/src/netcode.js",
203
+ "types": "./dist/src/netcode.d.ts"
196
204
  }
197
205
  },
198
206
  "workspaces": [
@@ -258,6 +266,8 @@
258
266
  "example:combat": "node dist/examples/quickstart-combat.js",
259
267
  "example:campaign": "node dist/examples/quickstart-campaign.js",
260
268
  "example:species": "node dist/examples/quickstart-species.js",
269
+ "example:lockstep": "node dist/examples/lockstep-server.js",
270
+ "example:rollback": "node dist/examples/rollback-client.js",
261
271
  "generate-module-index": "node dist/tools/generate-module-index.js",
262
272
  "pack": "node dist/tools/pack-cli.js pack",
263
273
  "generate-fixtures": "node dist/tools/generate-fixtures.js",