@its-not-rocket-science/ananke 0.1.6 → 0.1.7

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
@@ -10,6 +10,26 @@ Versioning follows [Semantic Versioning](https://semver.org/).
10
10
 
11
11
  ---
12
12
 
13
+ ## [0.1.7] — 2026-03-23
14
+
15
+ ### Added
16
+
17
+ - **CE-9 · World-state Diffing and Incremental Snapshots** (`src/sim/cover.ts`)
18
+ - diffWorldState(prev, next): top-level-field diff per entity; world
19
+ scalar/subsystem diffs; added/removed entity tracking
20
+ - applyDiff(base, diff): reconstruct next state (non-mutating, copy-on-write)
21
+ - packDiff(diff): custom binary encoding — magic "ANKD", tagged-value
22
+ format (null/bool/uint8/int32/float64/string/array/object); zero
23
+ external dependencies, implemented with DataView/Uint8Array
24
+ - unpackDiff(bytes): full round-trip with magic and version validation
25
+ - isDiffEmpty(), diffStats() — helpers for logging and network budgeting
26
+ - 30 tests; verified binary size < full JSON for single-entity changes
27
+ - Export via src/index.ts
28
+
29
+ ---
30
+
31
+ ---
32
+
13
33
  ## [0.1.6] — 2026-03-23
14
34
 
15
35
  ### Added
@@ -20,3 +20,5 @@ export * from "./scenario.js";
20
20
  export * from "./catalog.js";
21
21
  export * from "./sim/formation-combat.js";
22
22
  export * from "./sim/cover.js";
23
+ export * from "./sim/ai/behavior-trees.js";
24
+ export * from "./snapshot.js";
package/dist/src/index.js CHANGED
@@ -29,3 +29,5 @@ export * from "./scenario.js"; // loadScenario(), validateScenario(), AnankeScen
29
29
  export * from "./catalog.js"; // CE-12: registerArchetype(), registerWeapon(), registerArmour(), getCatalogEntry()
30
30
  export * from "./sim/formation-combat.js"; // Phase 69: FormationUnit, TacticalEngagement, resolveTacticalEngagement()
31
31
  export * from "./sim/cover.js"; // CE-15: CoverSegment, computeCoverProtection(), isLineOfSightBlocked(), applyExplosionToTerrain()
32
+ export * from "./sim/ai/behavior-trees.js"; // CE-10: BehaviorNode, FlankTarget, RetreatTo, ProtectAlly, GuardPosition, HealTarget, Sequence, Fallback
33
+ export * from "./snapshot.js"; // CE-9: diffWorldState(), applyDiff(), packDiff(), unpackDiff(), WorldStateDiff
@@ -0,0 +1,166 @@
1
+ /**
2
+ * CE-10 — Pre-built AI Behavior Tree Library
3
+ *
4
+ * A thin, composable layer over the existing AI decision system. Each
5
+ * `BehaviorNode` receives the ticking entity, the current world state, and
6
+ * kernel context, and either returns a `Command` (success) or `null` (failure /
7
+ * not applicable).
8
+ *
9
+ * ## Semantics
10
+ * - `null` — node failed / condition not met
11
+ * - Command — node succeeded; caller should use this command
12
+ *
13
+ * ## Composite nodes
14
+ * - `Sequence` — run children left-to-right; return first **non-null** result
15
+ * (priority / fallback selector pattern)
16
+ * - `Fallback` — identical semantics to Sequence for this usage:
17
+ * try children in order, first non-null wins
18
+ *
19
+ * Note: The ROADMAP spec uses "first success wins" for both Sequence and
20
+ * Fallback (i.e. both are priority-selectors). A traditional BT Sequence
21
+ * would return the *last* child result; here all composites return the first
22
+ * non-null command for practical game-AI use.
23
+ *
24
+ * ## Determinism constraint
25
+ * All nodes are deterministic. Any tie-breaking or random sampling uses
26
+ * `eventSeed(world.seed, world.tick, entity.id, salt)` — never `Math.random()`.
27
+ */
28
+ import type { Entity } from "../entity.js";
29
+ import type { WorldState } from "../world.js";
30
+ import type { KernelContext } from "../context.js";
31
+ import type { Command } from "../commands.js";
32
+ import { type Q } from "../../units.js";
33
+ /**
34
+ * A single node in a behavior tree.
35
+ *
36
+ * `tick` is called once per AI frame (typically once per `stepWorld` tick).
37
+ * Returns a `Command` if this node produces an action, or `null` if the node's
38
+ * condition is not satisfied (pass control to the next node in a composite).
39
+ */
40
+ export interface BehaviorNode {
41
+ tick(entity: Entity, world: WorldState, ctx: KernelContext): Command | null;
42
+ }
43
+ /**
44
+ * Move toward a target entity and attack when in range.
45
+ *
46
+ * If the target is dead or missing, returns null.
47
+ * Flanks by approaching from the target's perpendicular when `flankOffset > 0`.
48
+ *
49
+ * @param targetId Id of the entity to flank/attack.
50
+ * @param flankOffset Lateral offset in SCALE.m units (0 = direct approach).
51
+ */
52
+ export declare function FlankTarget(targetId: number, flankOffset?: number): BehaviorNode;
53
+ /**
54
+ * Move to a fixed world position and stop when within `arrivalRadius_m` metres.
55
+ *
56
+ * Returns null when already at the destination (no command needed).
57
+ *
58
+ * @param x_m Destination x [SCALE.m].
59
+ * @param y_m Destination y [SCALE.m].
60
+ * @param arrivalRadius_m Stop radius [SCALE.m]. Default 5 000 (0.5 m).
61
+ */
62
+ export declare function RetreatTo(x_m: number, y_m: number, arrivalRadius_m?: number): BehaviorNode;
63
+ /**
64
+ * Move toward an allied entity and interpose between it and the nearest threat.
65
+ *
66
+ * - If ally is missing or dead, returns null.
67
+ * - If no threats (other entities) are present, returns null.
68
+ * - Otherwise moves toward the ally.
69
+ *
70
+ * @param allyId Id of the entity to protect.
71
+ */
72
+ export declare function ProtectAlly(allyId: number): BehaviorNode;
73
+ /**
74
+ * Hold a position: move toward the guard point if outside `radius_m`, defend
75
+ * if inside. Returns null when the entity is already inside the radius and
76
+ * there are no threats to defend against.
77
+ *
78
+ * @param x_m Guard point x [SCALE.m].
79
+ * @param y_m Guard point y [SCALE.m].
80
+ * @param radius_m Patrol radius [SCALE.m].
81
+ */
82
+ export declare function GuardPosition(x_m: number, y_m: number, radius_m: number): BehaviorNode;
83
+ /**
84
+ * Move toward a target entity and issue a Treat command when in range.
85
+ *
86
+ * - Returns null if target is missing, already dead, or has no injuries.
87
+ * - Healing uses the first injured region found (deterministic iteration order).
88
+ *
89
+ * @param targetId Id of the entity to heal.
90
+ */
91
+ export declare function HealTarget(targetId: number): BehaviorNode;
92
+ /**
93
+ * Priority selector: tries each child node in order and returns the first
94
+ * non-null command. If all children return null, returns null.
95
+ *
96
+ * This is the most commonly used composite. Place higher-priority behaviours
97
+ * earlier in the list.
98
+ *
99
+ * @param nodes Child nodes to evaluate in order.
100
+ */
101
+ export declare function Sequence(...nodes: BehaviorNode[]): BehaviorNode;
102
+ /**
103
+ * Fallback: identical to `Sequence` — tries children in order, returns first
104
+ * non-null result. Provided as a semantic alias for callers who prefer the
105
+ * standard BT naming convention (Sequence = AND-chain; Fallback = OR-chain).
106
+ *
107
+ * @param nodes Child nodes to evaluate in order.
108
+ */
109
+ export declare function Fallback(...nodes: BehaviorNode[]): BehaviorNode;
110
+ /**
111
+ * Gate: return `inner.tick()` only if the entity's shock is below `maxShockQ`.
112
+ * When the entity is in severe shock it cannot act — returns null instead.
113
+ *
114
+ * Useful for wrapping aggressive nodes: "attack only if not badly shocked."
115
+ *
116
+ * @param maxShockQ Q threshold; entity.injury.shock must be below this.
117
+ * @param inner Wrapped node.
118
+ */
119
+ export declare function IfNotShocked(maxShockQ: Q, inner: BehaviorNode): BehaviorNode;
120
+ /**
121
+ * Gate: return `inner.tick()` only when `entity.energy.fatigue` is below
122
+ * `maxFatigueQ`. Prevents exhausted entities from taking high-intensity actions.
123
+ *
124
+ * @param maxFatigueQ Q threshold; fatigue must be below this.
125
+ * @param inner Wrapped node.
126
+ */
127
+ export declare function IfNotExhausted(maxFatigueQ: Q, inner: BehaviorNode): BehaviorNode;
128
+ /**
129
+ * Probabilistic gate: run `inner` only when
130
+ * `eventSeed(world.seed, world.tick, entity.id, salt) % SCALE.Q < probability_Q`.
131
+ *
132
+ * Allows stochastic variation without `Math.random()`.
133
+ *
134
+ * @param probability_Q Q probability [0..SCALE.Q]; q(1.0) = always, q(0) = never.
135
+ * @param salt Arbitrary integer to distinguish independent rolls on the
136
+ * same entity+tick (default 0).
137
+ * @param inner Wrapped node.
138
+ */
139
+ export declare function WithProbability(probability_Q: Q, inner: BehaviorNode, salt?: number): BehaviorNode;
140
+ /**
141
+ * Standard aggressive attacker: attack `targetId` at full intensity.
142
+ * Falls back to retreat if badly shocked (shock ≥ q(0.70)).
143
+ *
144
+ * @param targetId Primary attack target.
145
+ * @param retreatX Fallback retreat x [SCALE.m].
146
+ * @param retreatY Fallback retreat y [SCALE.m].
147
+ */
148
+ export declare function aggressorTree(targetId: number, retreatX: number, retreatY: number): BehaviorNode;
149
+ /**
150
+ * Standard defender: hold position, heal allies when in range, defend otherwise.
151
+ *
152
+ * @param guardX Guard point x [SCALE.m].
153
+ * @param guardY Guard point y [SCALE.m].
154
+ * @param radius_m Guard radius [SCALE.m].
155
+ * @param allyIds Ally ids to heal (checked in order; first injured ally wins).
156
+ */
157
+ export declare function defenderTree(guardX: number, guardY: number, radius_m: number, allyIds: number[]): BehaviorNode;
158
+ /**
159
+ * Medic tree: prioritise healing each ally in the given list (first injured wins),
160
+ * then retreat to a safe point if badly shocked.
161
+ *
162
+ * @param allyIds Allies to heal in priority order.
163
+ * @param safeX Retreat x [SCALE.m].
164
+ * @param safeY Retreat y [SCALE.m].
165
+ */
166
+ export declare function medicTree(allyIds: number[], safeX: number, safeY: number): BehaviorNode;
@@ -0,0 +1,340 @@
1
+ /**
2
+ * CE-10 — Pre-built AI Behavior Tree Library
3
+ *
4
+ * A thin, composable layer over the existing AI decision system. Each
5
+ * `BehaviorNode` receives the ticking entity, the current world state, and
6
+ * kernel context, and either returns a `Command` (success) or `null` (failure /
7
+ * not applicable).
8
+ *
9
+ * ## Semantics
10
+ * - `null` — node failed / condition not met
11
+ * - Command — node succeeded; caller should use this command
12
+ *
13
+ * ## Composite nodes
14
+ * - `Sequence` — run children left-to-right; return first **non-null** result
15
+ * (priority / fallback selector pattern)
16
+ * - `Fallback` — identical semantics to Sequence for this usage:
17
+ * try children in order, first non-null wins
18
+ *
19
+ * Note: The ROADMAP spec uses "first success wins" for both Sequence and
20
+ * Fallback (i.e. both are priority-selectors). A traditional BT Sequence
21
+ * would return the *last* child result; here all composites return the first
22
+ * non-null command for practical game-AI use.
23
+ *
24
+ * ## Determinism constraint
25
+ * All nodes are deterministic. Any tie-breaking or random sampling uses
26
+ * `eventSeed(world.seed, world.tick, entity.id, salt)` — never `Math.random()`.
27
+ */
28
+ import { CommandKinds, MoveModes, DefenceModes } from "../kinds.js";
29
+ import { q, SCALE } from "../../units.js";
30
+ import { eventSeed } from "../seeds.js";
31
+ // ── Internal geometry helpers ─────────────────────────────────────────────────
32
+ /** Squared distance between two entities (position_m, 2-D x/y). */
33
+ function distSq2D(a, b) {
34
+ const dx = b.position_m.x - a.position_m.x;
35
+ const dy = b.position_m.y - a.position_m.y;
36
+ return dx * dx + dy * dy;
37
+ }
38
+ /** Integer square-root approximation via Newton's method (no float dependency). */
39
+ function isqrt(n) {
40
+ if (n <= 0)
41
+ return 0;
42
+ let x = Math.round(Math.sqrt(n));
43
+ // One Newton step for accuracy at fixed-point scales
44
+ x = Math.trunc((x + Math.trunc(n / Math.max(1, x))) / 2);
45
+ return Math.max(0, x);
46
+ }
47
+ /** Signed direction from `from` to `to`, normalised to unit Q vector. */
48
+ function dirTo(fromX, fromY, toX, toY) {
49
+ const dx = toX - fromX;
50
+ const dy = toY - fromY;
51
+ const len = isqrt(dx * dx + dy * dy);
52
+ if (len === 0)
53
+ return { x: 0, y: 0, z: 0 };
54
+ return {
55
+ x: Math.round((dx * SCALE.Q) / len),
56
+ y: Math.round((dy * SCALE.Q) / len),
57
+ z: 0,
58
+ };
59
+ }
60
+ /** Find an entity by id in the world, or undefined. */
61
+ function findEntity(world, id) {
62
+ return world.entities.find(e => e.id === id);
63
+ }
64
+ /** True if entity is dead or unconscious. */
65
+ function isIncapacitated(e) {
66
+ return (e.injury?.dead ?? false) || (e.injury?.consciousness ?? SCALE.Q) <= 0;
67
+ }
68
+ // ── Leaf nodes ────────────────────────────────────────────────────────────────
69
+ /**
70
+ * Move toward a target entity and attack when in range.
71
+ *
72
+ * If the target is dead or missing, returns null.
73
+ * Flanks by approaching from the target's perpendicular when `flankOffset > 0`.
74
+ *
75
+ * @param targetId Id of the entity to flank/attack.
76
+ * @param flankOffset Lateral offset in SCALE.m units (0 = direct approach).
77
+ */
78
+ export function FlankTarget(targetId, flankOffset = 0) {
79
+ return {
80
+ tick(entity, world) {
81
+ const target = findEntity(world, targetId);
82
+ if (!target || isIncapacitated(target))
83
+ return null;
84
+ const tx = target.position_m.x + flankOffset;
85
+ const ty = target.position_m.y;
86
+ const threshold = 15_000 * 15_000; // ~1.5 m in SCALE.m
87
+ if (distSq2D(entity, target) <= threshold) {
88
+ // In melee range — attack
89
+ return { kind: CommandKinds.Attack, targetId, intensity: q(1.0) };
90
+ }
91
+ const dir = dirTo(entity.position_m.x, entity.position_m.y, tx, ty);
92
+ return { kind: CommandKinds.Move, dir, intensity: q(1.0), mode: MoveModes.Run };
93
+ },
94
+ };
95
+ }
96
+ /**
97
+ * Move to a fixed world position and stop when within `arrivalRadius_m` metres.
98
+ *
99
+ * Returns null when already at the destination (no command needed).
100
+ *
101
+ * @param x_m Destination x [SCALE.m].
102
+ * @param y_m Destination y [SCALE.m].
103
+ * @param arrivalRadius_m Stop radius [SCALE.m]. Default 5 000 (0.5 m).
104
+ */
105
+ export function RetreatTo(x_m, y_m, arrivalRadius_m = 5_000) {
106
+ return {
107
+ tick(entity) {
108
+ const dx = x_m - entity.position_m.x;
109
+ const dy = y_m - entity.position_m.y;
110
+ const r = arrivalRadius_m;
111
+ if (dx * dx + dy * dy <= r * r)
112
+ return null; // already there
113
+ const dir = dirTo(entity.position_m.x, entity.position_m.y, x_m, y_m);
114
+ return { kind: CommandKinds.Move, dir, intensity: q(1.0), mode: MoveModes.Run };
115
+ },
116
+ };
117
+ }
118
+ /**
119
+ * Move toward an allied entity and interpose between it and the nearest threat.
120
+ *
121
+ * - If ally is missing or dead, returns null.
122
+ * - If no threats (other entities) are present, returns null.
123
+ * - Otherwise moves toward the ally.
124
+ *
125
+ * @param allyId Id of the entity to protect.
126
+ */
127
+ export function ProtectAlly(allyId) {
128
+ return {
129
+ tick(entity, world) {
130
+ const ally = findEntity(world, allyId);
131
+ if (!ally || isIncapacitated(ally))
132
+ return null;
133
+ // Require at least one other living entity to protect against
134
+ const threats = world.entities.filter(e => e.id !== entity.id && e.id !== allyId && !isIncapacitated(e));
135
+ if (threats.length === 0)
136
+ return null;
137
+ const threshold = 20_000 * 20_000; // 2 m
138
+ if (distSq2D(entity, ally) <= threshold) {
139
+ // Close enough — defend
140
+ return { kind: CommandKinds.Defend, mode: DefenceModes.Block, intensity: q(1.0) };
141
+ }
142
+ const dir = dirTo(entity.position_m.x, entity.position_m.y, ally.position_m.x, ally.position_m.y);
143
+ return { kind: CommandKinds.Move, dir, intensity: q(1.0), mode: MoveModes.Run };
144
+ },
145
+ };
146
+ }
147
+ /**
148
+ * Hold a position: move toward the guard point if outside `radius_m`, defend
149
+ * if inside. Returns null when the entity is already inside the radius and
150
+ * there are no threats to defend against.
151
+ *
152
+ * @param x_m Guard point x [SCALE.m].
153
+ * @param y_m Guard point y [SCALE.m].
154
+ * @param radius_m Patrol radius [SCALE.m].
155
+ */
156
+ export function GuardPosition(x_m, y_m, radius_m) {
157
+ return {
158
+ tick(entity, world) {
159
+ const dx = x_m - entity.position_m.x;
160
+ const dy = y_m - entity.position_m.y;
161
+ const rSq = radius_m * radius_m;
162
+ const insideRadius = dx * dx + dy * dy <= rSq;
163
+ if (!insideRadius) {
164
+ const dir = dirTo(entity.position_m.x, entity.position_m.y, x_m, y_m);
165
+ return { kind: CommandKinds.Move, dir, intensity: q(1.0), mode: MoveModes.Walk };
166
+ }
167
+ // Inside radius — defend if threats present
168
+ const threats = world.entities.filter(e => e.id !== entity.id && !isIncapacitated(e));
169
+ if (threats.length === 0)
170
+ return null;
171
+ return { kind: CommandKinds.Defend, mode: DefenceModes.Block, intensity: q(0.5) };
172
+ },
173
+ };
174
+ }
175
+ /**
176
+ * Move toward a target entity and issue a Treat command when in range.
177
+ *
178
+ * - Returns null if target is missing, already dead, or has no injuries.
179
+ * - Healing uses the first injured region found (deterministic iteration order).
180
+ *
181
+ * @param targetId Id of the entity to heal.
182
+ */
183
+ export function HealTarget(targetId) {
184
+ return {
185
+ tick(entity, world) {
186
+ const target = findEntity(world, targetId);
187
+ if (!target || target.injury?.dead)
188
+ return null;
189
+ // Find an injured region with surface or internal damage
190
+ const byRegion = target.injury?.byRegion;
191
+ if (!byRegion)
192
+ return null;
193
+ let injuredRegionId;
194
+ for (const [regionId, region] of Object.entries(byRegion)) {
195
+ if ((region.surfaceDamage ?? 0) > 0 || (region.internalDamage ?? 0) > 0) {
196
+ injuredRegionId = regionId;
197
+ break;
198
+ }
199
+ }
200
+ if (!injuredRegionId)
201
+ return null;
202
+ const threshold = 10_000 * 10_000; // 1 m
203
+ if (distSq2D(entity, target) > threshold) {
204
+ const dir = dirTo(entity.position_m.x, entity.position_m.y, target.position_m.x, target.position_m.y);
205
+ return { kind: CommandKinds.Move, dir, intensity: q(0.5), mode: MoveModes.Walk };
206
+ }
207
+ return {
208
+ kind: CommandKinds.Treat,
209
+ targetId,
210
+ action: "bandage",
211
+ tier: "bandage",
212
+ regionId: injuredRegionId,
213
+ };
214
+ },
215
+ };
216
+ }
217
+ // ── Composite nodes ───────────────────────────────────────────────────────────
218
+ /**
219
+ * Priority selector: tries each child node in order and returns the first
220
+ * non-null command. If all children return null, returns null.
221
+ *
222
+ * This is the most commonly used composite. Place higher-priority behaviours
223
+ * earlier in the list.
224
+ *
225
+ * @param nodes Child nodes to evaluate in order.
226
+ */
227
+ export function Sequence(...nodes) {
228
+ return {
229
+ tick(entity, world, ctx) {
230
+ for (const node of nodes) {
231
+ const result = node.tick(entity, world, ctx);
232
+ if (result !== null)
233
+ return result;
234
+ }
235
+ return null;
236
+ },
237
+ };
238
+ }
239
+ /**
240
+ * Fallback: identical to `Sequence` — tries children in order, returns first
241
+ * non-null result. Provided as a semantic alias for callers who prefer the
242
+ * standard BT naming convention (Sequence = AND-chain; Fallback = OR-chain).
243
+ *
244
+ * @param nodes Child nodes to evaluate in order.
245
+ */
246
+ export function Fallback(...nodes) {
247
+ return Sequence(...nodes);
248
+ }
249
+ // ── Condition-gate nodes ──────────────────────────────────────────────────────
250
+ /**
251
+ * Gate: return `inner.tick()` only if the entity's shock is below `maxShockQ`.
252
+ * When the entity is in severe shock it cannot act — returns null instead.
253
+ *
254
+ * Useful for wrapping aggressive nodes: "attack only if not badly shocked."
255
+ *
256
+ * @param maxShockQ Q threshold; entity.injury.shock must be below this.
257
+ * @param inner Wrapped node.
258
+ */
259
+ export function IfNotShocked(maxShockQ, inner) {
260
+ return {
261
+ tick(entity, world, ctx) {
262
+ if ((entity.injury?.shock ?? 0) >= maxShockQ)
263
+ return null;
264
+ return inner.tick(entity, world, ctx);
265
+ },
266
+ };
267
+ }
268
+ /**
269
+ * Gate: return `inner.tick()` only when `entity.energy.fatigue` is below
270
+ * `maxFatigueQ`. Prevents exhausted entities from taking high-intensity actions.
271
+ *
272
+ * @param maxFatigueQ Q threshold; fatigue must be below this.
273
+ * @param inner Wrapped node.
274
+ */
275
+ export function IfNotExhausted(maxFatigueQ, inner) {
276
+ return {
277
+ tick(entity, world, ctx) {
278
+ if ((entity.energy?.fatigue ?? 0) >= maxFatigueQ)
279
+ return null;
280
+ return inner.tick(entity, world, ctx);
281
+ },
282
+ };
283
+ }
284
+ /**
285
+ * Probabilistic gate: run `inner` only when
286
+ * `eventSeed(world.seed, world.tick, entity.id, salt) % SCALE.Q < probability_Q`.
287
+ *
288
+ * Allows stochastic variation without `Math.random()`.
289
+ *
290
+ * @param probability_Q Q probability [0..SCALE.Q]; q(1.0) = always, q(0) = never.
291
+ * @param salt Arbitrary integer to distinguish independent rolls on the
292
+ * same entity+tick (default 0).
293
+ * @param inner Wrapped node.
294
+ */
295
+ export function WithProbability(probability_Q, inner, salt = 0) {
296
+ return {
297
+ tick(entity, world, ctx) {
298
+ const roll = eventSeed(world.seed, world.tick, entity.id, 0, salt) % SCALE.Q;
299
+ if (roll >= probability_Q)
300
+ return null;
301
+ return inner.tick(entity, world, ctx);
302
+ },
303
+ };
304
+ }
305
+ // ── Pre-built behavior tree presets ──────────────────────────────────────────
306
+ /**
307
+ * Standard aggressive attacker: attack `targetId` at full intensity.
308
+ * Falls back to retreat if badly shocked (shock ≥ q(0.70)).
309
+ *
310
+ * @param targetId Primary attack target.
311
+ * @param retreatX Fallback retreat x [SCALE.m].
312
+ * @param retreatY Fallback retreat y [SCALE.m].
313
+ */
314
+ export function aggressorTree(targetId, retreatX, retreatY) {
315
+ return Sequence(IfNotShocked(q(0.70), FlankTarget(targetId)), RetreatTo(retreatX, retreatY));
316
+ }
317
+ /**
318
+ * Standard defender: hold position, heal allies when in range, defend otherwise.
319
+ *
320
+ * @param guardX Guard point x [SCALE.m].
321
+ * @param guardY Guard point y [SCALE.m].
322
+ * @param radius_m Guard radius [SCALE.m].
323
+ * @param allyIds Ally ids to heal (checked in order; first injured ally wins).
324
+ */
325
+ export function defenderTree(guardX, guardY, radius_m, allyIds) {
326
+ const healNodes = allyIds.map(id => HealTarget(id));
327
+ return Sequence(...healNodes, GuardPosition(guardX, guardY, radius_m));
328
+ }
329
+ /**
330
+ * Medic tree: prioritise healing each ally in the given list (first injured wins),
331
+ * then retreat to a safe point if badly shocked.
332
+ *
333
+ * @param allyIds Allies to heal in priority order.
334
+ * @param safeX Retreat x [SCALE.m].
335
+ * @param safeY Retreat y [SCALE.m].
336
+ */
337
+ export function medicTree(allyIds, safeX, safeY) {
338
+ const healNodes = allyIds.map(id => HealTarget(id));
339
+ return Sequence(IfNotShocked(q(0.80), Sequence(...healNodes)), RetreatTo(safeX, safeY));
340
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * CE-9 — World-State Diffing + Incremental Snapshots
3
+ *
4
+ * Reduces long-run storage from O(ticks × fullState) to O(initialState + Σ deltas).
5
+ *
6
+ * ## Diff model
7
+ * `WorldStateDiff` captures:
8
+ * - World-level scalar changes (`tick`, `seed`, optional subsystem fields).
9
+ * - Entity-level changes at **top-level-field granularity**: each field that
10
+ * differs from the previous snapshot is stored in full; unchanged fields are
11
+ * omitted. This avoids deep-diffing complex nested types.
12
+ * - Newly added entities (stored in full).
13
+ * - Removed entity ids.
14
+ *
15
+ * ## Binary wire format (`packDiff` / `unpackDiff`)
16
+ * A compact, dependency-free binary encoding using a simple tag-value scheme.
17
+ * Zero external dependencies — implemented entirely with `DataView` / `Uint8Array`.
18
+ *
19
+ * Layout:
20
+ * [4 bytes magic "ANKD"] [1 byte version=1] [tag-value payload...]
21
+ *
22
+ * Tag bytes:
23
+ * 0x01 null | 0x02 true | 0x03 false
24
+ * 0x04 uint8 (1 byte) | 0x05 int32 LE (4 bytes) | 0x06 float64 LE (8 bytes)
25
+ * 0x07 string (uint16 LE length + UTF-8 bytes)
26
+ * 0x08 array (uint32 LE count + items)
27
+ * 0x09 object (uint32 LE count + key-value pairs, keys as 0x07 strings)
28
+ * 0x0A undefined/absent (skipped in round-trip)
29
+ */
30
+ import type { WorldState } from "./sim/world.js";
31
+ import type { Entity } from "./sim/entity.js";
32
+ /** A patch for a single entity — only changed top-level fields are included. */
33
+ export interface EntityPatch {
34
+ /** Entity id. */
35
+ id: number;
36
+ /** Changed top-level fields (field name → new value). */
37
+ changes: Record<string, unknown>;
38
+ }
39
+ /**
40
+ * Delta between two consecutive `WorldState` snapshots.
41
+ *
42
+ * Applying a `WorldStateDiff` to the `prev` snapshot must yield a state
43
+ * indistinguishable (JSON-round-trip-equal) from `next`.
44
+ */
45
+ export interface WorldStateDiff {
46
+ /** `next.tick` — always present for sequencing. */
47
+ tick: number;
48
+ /** Changed world-level scalar/subsystem fields (excluding `entities`). */
49
+ worldChanges: Record<string, unknown>;
50
+ /** Entities present in `next` but not in `prev` — stored in full. */
51
+ added: Entity[];
52
+ /** Entity ids present in `prev` but not in `next`. */
53
+ removed: number[];
54
+ /** Top-level-field patches for entities present in both states. */
55
+ modified: EntityPatch[];
56
+ }
57
+ /**
58
+ * Compute the diff between two `WorldState` snapshots.
59
+ *
60
+ * The diff is guaranteed to be **idempotent**: `applyDiff(prev, diffWorldState(prev, next))`
61
+ * produces a state that is JSON-round-trip-equal to `next`.
62
+ *
63
+ * Complexity: O(E × F) where E = entity count and F = top-level field count per entity.
64
+ *
65
+ * @param prev Base snapshot.
66
+ * @param next New snapshot (must have the same `seed`; `tick` may differ).
67
+ * @returns `WorldStateDiff` — empty diff if states are identical.
68
+ */
69
+ export declare function diffWorldState(prev: WorldState, next: WorldState): WorldStateDiff;
70
+ /**
71
+ * Apply a `WorldStateDiff` to a base `WorldState`, producing the `next` state.
72
+ *
73
+ * **Does not mutate `base`** — returns a new `WorldState` object.
74
+ * The returned state may share sub-object references with `base` for unchanged
75
+ * entities (copy-on-write semantics).
76
+ *
77
+ * @param base The `prev` snapshot that was passed to `diffWorldState`.
78
+ * @param diff The diff produced by `diffWorldState`.
79
+ * @returns Reconstructed `next` state.
80
+ */
81
+ export declare function applyDiff(base: WorldState, diff: WorldStateDiff): WorldState;
82
+ /**
83
+ * Returns `true` when the diff contains no changes — states were identical.
84
+ */
85
+ export declare function isDiffEmpty(diff: WorldStateDiff): boolean;
86
+ /**
87
+ * Summary statistics for a diff (useful for logging / network budget monitoring).
88
+ */
89
+ export interface DiffStats {
90
+ /** Number of world-level changed fields. */
91
+ worldChangedFields: number;
92
+ addedEntities: number;
93
+ removedEntities: number;
94
+ modifiedEntities: number;
95
+ /** Total changed fields across all modified entities. */
96
+ totalEntityChanges: number;
97
+ }
98
+ export declare function diffStats(diff: WorldStateDiff): DiffStats;
99
+ /**
100
+ * Encode a `WorldStateDiff` as a compact binary `Uint8Array`.
101
+ *
102
+ * The binary format is self-describing (no schema required for decoding).
103
+ * `unpackDiff(packDiff(diff))` is guaranteed to produce a diff that when
104
+ * applied gives the same result as the original.
105
+ *
106
+ * @param diff Diff to encode.
107
+ * @returns Binary representation.
108
+ */
109
+ export declare function packDiff(diff: WorldStateDiff): Uint8Array;
110
+ /**
111
+ * Decode a `WorldStateDiff` previously encoded by `packDiff`.
112
+ *
113
+ * @param bytes Binary data produced by `packDiff`.
114
+ * @returns Decoded `WorldStateDiff`.
115
+ * @throws If the magic bytes or version do not match.
116
+ */
117
+ export declare function unpackDiff(bytes: Uint8Array): WorldStateDiff;
@@ -0,0 +1,379 @@
1
+ /**
2
+ * CE-9 — World-State Diffing + Incremental Snapshots
3
+ *
4
+ * Reduces long-run storage from O(ticks × fullState) to O(initialState + Σ deltas).
5
+ *
6
+ * ## Diff model
7
+ * `WorldStateDiff` captures:
8
+ * - World-level scalar changes (`tick`, `seed`, optional subsystem fields).
9
+ * - Entity-level changes at **top-level-field granularity**: each field that
10
+ * differs from the previous snapshot is stored in full; unchanged fields are
11
+ * omitted. This avoids deep-diffing complex nested types.
12
+ * - Newly added entities (stored in full).
13
+ * - Removed entity ids.
14
+ *
15
+ * ## Binary wire format (`packDiff` / `unpackDiff`)
16
+ * A compact, dependency-free binary encoding using a simple tag-value scheme.
17
+ * Zero external dependencies — implemented entirely with `DataView` / `Uint8Array`.
18
+ *
19
+ * Layout:
20
+ * [4 bytes magic "ANKD"] [1 byte version=1] [tag-value payload...]
21
+ *
22
+ * Tag bytes:
23
+ * 0x01 null | 0x02 true | 0x03 false
24
+ * 0x04 uint8 (1 byte) | 0x05 int32 LE (4 bytes) | 0x06 float64 LE (8 bytes)
25
+ * 0x07 string (uint16 LE length + UTF-8 bytes)
26
+ * 0x08 array (uint32 LE count + items)
27
+ * 0x09 object (uint32 LE count + key-value pairs, keys as 0x07 strings)
28
+ * 0x0A undefined/absent (skipped in round-trip)
29
+ */
30
+ // ── Diff ──────────────────────────────────────────────────────────────────────
31
+ /**
32
+ * Compute the diff between two `WorldState` snapshots.
33
+ *
34
+ * The diff is guaranteed to be **idempotent**: `applyDiff(prev, diffWorldState(prev, next))`
35
+ * produces a state that is JSON-round-trip-equal to `next`.
36
+ *
37
+ * Complexity: O(E × F) where E = entity count and F = top-level field count per entity.
38
+ *
39
+ * @param prev Base snapshot.
40
+ * @param next New snapshot (must have the same `seed`; `tick` may differ).
41
+ * @returns `WorldStateDiff` — empty diff if states are identical.
42
+ */
43
+ export function diffWorldState(prev, next) {
44
+ // ── World-level scalar/subsystem changes ──────────────────────────────────
45
+ const worldChanges = {};
46
+ const worldKeys = [
47
+ "tick", "seed",
48
+ "activeFieldEffects",
49
+ "__sensoryEnv",
50
+ "__factionRegistry",
51
+ "__partyRegistry",
52
+ "__relationshipGraph",
53
+ "__nutritionAccum",
54
+ ];
55
+ for (const key of worldKeys) {
56
+ if (key === "entities")
57
+ continue;
58
+ const pv = prev[key];
59
+ const nv = next[key];
60
+ if (!jsonEqual(pv, nv)) {
61
+ worldChanges[key] = nv;
62
+ }
63
+ }
64
+ // ── Entity diff ───────────────────────────────────────────────────────────
65
+ const prevMap = new Map(prev.entities.map(e => [e.id, e]));
66
+ const nextMap = new Map(next.entities.map(e => [e.id, e]));
67
+ const added = [];
68
+ const removed = [];
69
+ const modified = [];
70
+ // Removed or modified
71
+ for (const [id, pe] of prevMap) {
72
+ const ne = nextMap.get(id);
73
+ if (!ne) {
74
+ removed.push(id);
75
+ }
76
+ else {
77
+ const changes = entityChanges(pe, ne);
78
+ if (Object.keys(changes).length > 0) {
79
+ modified.push({ id, changes });
80
+ }
81
+ }
82
+ }
83
+ // Added
84
+ for (const [id, ne] of nextMap) {
85
+ if (!prevMap.has(id)) {
86
+ added.push(ne);
87
+ }
88
+ }
89
+ return { tick: next.tick, worldChanges, added, removed, modified };
90
+ }
91
+ /**
92
+ * Apply a `WorldStateDiff` to a base `WorldState`, producing the `next` state.
93
+ *
94
+ * **Does not mutate `base`** — returns a new `WorldState` object.
95
+ * The returned state may share sub-object references with `base` for unchanged
96
+ * entities (copy-on-write semantics).
97
+ *
98
+ * @param base The `prev` snapshot that was passed to `diffWorldState`.
99
+ * @param diff The diff produced by `diffWorldState`.
100
+ * @returns Reconstructed `next` state.
101
+ */
102
+ export function applyDiff(base, diff) {
103
+ // Reconstruct world-level fields
104
+ const next = {
105
+ ...base,
106
+ ...diff.worldChanges,
107
+ tick: diff.tick,
108
+ };
109
+ // Remove entities
110
+ const removedSet = new Set(diff.removed);
111
+ let entities = base.entities.filter(e => !removedSet.has(e.id));
112
+ // Modify entities (patch changed fields)
113
+ entities = entities.map(e => {
114
+ const patch = diff.modified.find(p => p.id === e.id);
115
+ if (!patch)
116
+ return e;
117
+ return { ...e, ...patch.changes };
118
+ });
119
+ // Add new entities
120
+ entities = [...entities, ...diff.added];
121
+ // Restore canonical sort order (ascending id)
122
+ entities.sort((a, b) => a.id - b.id);
123
+ return { ...next, entities };
124
+ }
125
+ // ── isEmpty / stats ───────────────────────────────────────────────────────────
126
+ /**
127
+ * Returns `true` when the diff contains no changes — states were identical.
128
+ */
129
+ export function isDiffEmpty(diff) {
130
+ return (Object.keys(diff.worldChanges).length === 0 &&
131
+ diff.added.length === 0 &&
132
+ diff.removed.length === 0 &&
133
+ diff.modified.length === 0);
134
+ }
135
+ export function diffStats(diff) {
136
+ return {
137
+ worldChangedFields: Object.keys(diff.worldChanges).length,
138
+ addedEntities: diff.added.length,
139
+ removedEntities: diff.removed.length,
140
+ modifiedEntities: diff.modified.length,
141
+ totalEntityChanges: diff.modified.reduce((s, p) => s + Object.keys(p.changes).length, 0),
142
+ };
143
+ }
144
+ // ── Binary pack / unpack ──────────────────────────────────────────────────────
145
+ const MAGIC = 0x414E4B44; // "ANKD"
146
+ const VERSION = 1;
147
+ const TAG = {
148
+ NULL: 0x01,
149
+ TRUE: 0x02,
150
+ FALSE: 0x03,
151
+ UINT8: 0x04,
152
+ INT32: 0x05,
153
+ FLOAT64: 0x06,
154
+ STRING: 0x07,
155
+ ARRAY: 0x08,
156
+ OBJECT: 0x09,
157
+ };
158
+ /**
159
+ * Encode a `WorldStateDiff` as a compact binary `Uint8Array`.
160
+ *
161
+ * The binary format is self-describing (no schema required for decoding).
162
+ * `unpackDiff(packDiff(diff))` is guaranteed to produce a diff that when
163
+ * applied gives the same result as the original.
164
+ *
165
+ * @param diff Diff to encode.
166
+ * @returns Binary representation.
167
+ */
168
+ export function packDiff(diff) {
169
+ const buf = new Writer();
170
+ buf.writeUint32(MAGIC);
171
+ buf.writeUint8(VERSION);
172
+ buf.writeValue(diff);
173
+ return buf.toUint8Array();
174
+ }
175
+ /**
176
+ * Decode a `WorldStateDiff` previously encoded by `packDiff`.
177
+ *
178
+ * @param bytes Binary data produced by `packDiff`.
179
+ * @returns Decoded `WorldStateDiff`.
180
+ * @throws If the magic bytes or version do not match.
181
+ */
182
+ export function unpackDiff(bytes) {
183
+ const r = new Reader(bytes);
184
+ const magic = r.readUint32();
185
+ if (magic !== MAGIC)
186
+ throw new Error(`snapshot: invalid magic 0x${magic.toString(16)}`);
187
+ const version = r.readUint8();
188
+ if (version !== VERSION)
189
+ throw new Error(`snapshot: unsupported version ${version}`);
190
+ return r.readValue();
191
+ }
192
+ // ── Internal: JSON equality ───────────────────────────────────────────────────
193
+ function jsonEqual(a, b) {
194
+ if (a === b)
195
+ return true;
196
+ if (a === null || b === null)
197
+ return false;
198
+ if (typeof a !== "object" || typeof b !== "object")
199
+ return false;
200
+ return JSON.stringify(a) === JSON.stringify(b);
201
+ }
202
+ /** Compute changed top-level fields between two entity versions. */
203
+ function entityChanges(prev, next) {
204
+ const changes = {};
205
+ const allKeys = new Set([...Object.keys(prev), ...Object.keys(next)]);
206
+ for (const key of allKeys) {
207
+ if (!jsonEqual(prev[key], next[key])) {
208
+ changes[key] = next[key];
209
+ }
210
+ }
211
+ return changes;
212
+ }
213
+ class Writer {
214
+ chunks = [];
215
+ size = 0;
216
+ writeUint8(v) {
217
+ const b = new Uint8Array(1);
218
+ b[0] = v & 0xFF;
219
+ this.push(b);
220
+ }
221
+ writeUint16(v) {
222
+ const b = new Uint8Array(2);
223
+ new DataView(b.buffer).setUint16(0, v, true);
224
+ this.push(b);
225
+ }
226
+ writeUint32(v) {
227
+ const b = new Uint8Array(4);
228
+ new DataView(b.buffer).setUint32(0, v >>> 0, true);
229
+ this.push(b);
230
+ }
231
+ writeInt32(v) {
232
+ const b = new Uint8Array(4);
233
+ new DataView(b.buffer).setInt32(0, v, true);
234
+ this.push(b);
235
+ }
236
+ writeFloat64(v) {
237
+ const b = new Uint8Array(8);
238
+ new DataView(b.buffer).setFloat64(0, v, true);
239
+ this.push(b);
240
+ }
241
+ writeString(s) {
242
+ const enc = new TextEncoder().encode(s);
243
+ this.writeUint8(TAG.STRING);
244
+ this.writeUint16(enc.length);
245
+ this.push(enc);
246
+ }
247
+ writeValue(v) {
248
+ if (v === null || v === undefined) {
249
+ this.writeUint8(TAG.NULL);
250
+ }
251
+ else if (typeof v === "boolean") {
252
+ this.writeUint8(v ? TAG.TRUE : TAG.FALSE);
253
+ }
254
+ else if (typeof v === "number") {
255
+ if (Number.isInteger(v) && v >= 0 && v <= 255) {
256
+ this.writeUint8(TAG.UINT8);
257
+ this.writeUint8(v);
258
+ }
259
+ else if (Number.isInteger(v) && v >= -2147483648 && v <= 2147483647) {
260
+ this.writeUint8(TAG.INT32);
261
+ this.writeInt32(v);
262
+ }
263
+ else {
264
+ this.writeUint8(TAG.FLOAT64);
265
+ this.writeFloat64(v);
266
+ }
267
+ }
268
+ else if (typeof v === "string") {
269
+ this.writeString(v);
270
+ }
271
+ else if (Array.isArray(v)) {
272
+ this.writeUint8(TAG.ARRAY);
273
+ this.writeUint32(v.length);
274
+ for (const item of v)
275
+ this.writeValue(item);
276
+ }
277
+ else {
278
+ const entries = Object.entries(v).filter(([, val]) => val !== undefined);
279
+ this.writeUint8(TAG.OBJECT);
280
+ this.writeUint32(entries.length);
281
+ for (const [key, val] of entries) {
282
+ this.writeString(key);
283
+ this.writeValue(val);
284
+ }
285
+ }
286
+ }
287
+ push(b) {
288
+ this.chunks.push(b);
289
+ this.size += b.length;
290
+ }
291
+ toUint8Array() {
292
+ const out = new Uint8Array(this.size);
293
+ let off = 0;
294
+ for (const c of this.chunks) {
295
+ out.set(c, off);
296
+ off += c.length;
297
+ }
298
+ return out;
299
+ }
300
+ }
301
+ // ── Binary Reader ─────────────────────────────────────────────────────────────
302
+ class Reader {
303
+ view;
304
+ pos = 0;
305
+ constructor(bytes) {
306
+ this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
307
+ }
308
+ readUint8() {
309
+ return this.view.getUint8(this.pos++);
310
+ }
311
+ readUint16() {
312
+ const v = this.view.getUint16(this.pos, true);
313
+ this.pos += 2;
314
+ return v;
315
+ }
316
+ readUint32() {
317
+ const v = this.view.getUint32(this.pos, true);
318
+ this.pos += 4;
319
+ return v;
320
+ }
321
+ readInt32() {
322
+ const v = this.view.getInt32(this.pos, true);
323
+ this.pos += 4;
324
+ return v;
325
+ }
326
+ readFloat64() {
327
+ const v = this.view.getFloat64(this.pos, true);
328
+ this.pos += 8;
329
+ return v;
330
+ }
331
+ readString() {
332
+ const len = this.readUint16();
333
+ const bytes = new Uint8Array(this.view.buffer, this.view.byteOffset + this.pos, len);
334
+ this.pos += len;
335
+ return new TextDecoder().decode(bytes);
336
+ }
337
+ readValue() {
338
+ const tag = this.readUint8();
339
+ switch (tag) {
340
+ case TAG.NULL: return null;
341
+ case TAG.TRUE: return true;
342
+ case TAG.FALSE: return false;
343
+ case TAG.UINT8: return this.readUint8();
344
+ case TAG.INT32: return this.readInt32();
345
+ case TAG.FLOAT64: return this.readFloat64();
346
+ case TAG.STRING: {
347
+ const len = this.readUint16();
348
+ const bytes = new Uint8Array(this.view.buffer, this.view.byteOffset + this.pos, len);
349
+ this.pos += len;
350
+ return new TextDecoder().decode(bytes);
351
+ }
352
+ case TAG.ARRAY: {
353
+ const count = this.readUint32();
354
+ const arr = [];
355
+ for (let i = 0; i < count; i++)
356
+ arr.push(this.readValue());
357
+ return arr;
358
+ }
359
+ case TAG.OBJECT: {
360
+ const count = this.readUint32();
361
+ const obj = {};
362
+ for (let i = 0; i < count; i++) {
363
+ // key is always a STRING tag
364
+ const keyTag = this.readUint8();
365
+ if (keyTag !== TAG.STRING)
366
+ throw new Error(`snapshot: expected string key tag, got 0x${keyTag.toString(16)}`);
367
+ const keyLen = this.readUint16();
368
+ const keyBytes = new Uint8Array(this.view.buffer, this.view.byteOffset + this.pos, keyLen);
369
+ this.pos += keyLen;
370
+ const key = new TextDecoder().decode(keyBytes);
371
+ obj[key] = this.readValue();
372
+ }
373
+ return obj;
374
+ }
375
+ default:
376
+ throw new Error(`snapshot: unknown tag 0x${tag.toString(16)}`);
377
+ }
378
+ }
379
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@its-not-rocket-science/ananke",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "description": "Deterministic lockstep-friendly SI-units RPG/physics core (fixed-point TS)",
6
6
  "license": "MIT",