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

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.
@@ -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;