@its-not-rocket-science/ananke 0.1.5 → 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,43 @@ 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
+
33
+ ## [0.1.6] — 2026-03-23
34
+
35
+ ### Added
36
+
37
+ - **CE-15 · Dynamic Terrain Cover System** (`src/sim/cover.ts`)
38
+ - CoverSegment type: axis-aligned obstacle with material, height, burn state
39
+ - isLineOfSightBlocked(): pure integer segment-intersection test (no sqrt)
40
+ - computeCoverProtection(): multiplicative absorption across stacked cover
41
+ - arcClearsCover(): indirect/lob fire height check
42
+ - applyExplosionToTerrain(): proximity-scaled crater + wood ignition
43
+ - stepCoverDecay(): wood burn-out and crater erosion over real time
44
+ - 4 sample presets: stone wall, sandbag barricade, wooden palisade, dirt berm
45
+ - 60 tests
46
+ - Export via src/index.ts
47
+
48
+ ---
49
+
13
50
  ## [0.1.5] — 2026-03-21
14
51
 
15
52
  ### Added
@@ -18,3 +18,7 @@ export * from "./bridge/index.js";
18
18
  export * from "./world-factory.js";
19
19
  export * from "./scenario.js";
20
20
  export * from "./catalog.js";
21
+ export * from "./sim/formation-combat.js";
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
@@ -27,3 +27,7 @@ export * from "./bridge/index.js"; // BridgeEngine, InterpolatedState, BridgeCon
27
27
  export * from "./world-factory.js"; // createWorld(), EntitySpec, ARCHETYPE_MAP, ITEM_MAP
28
28
  export * from "./scenario.js"; // loadScenario(), validateScenario(), AnankeScenario
29
29
  export * from "./catalog.js"; // CE-12: registerArchetype(), registerWeapon(), registerArmour(), getCatalogEntry()
30
+ export * from "./sim/formation-combat.js"; // Phase 69: FormationUnit, TacticalEngagement, resolveTacticalEngagement()
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
+ }