@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 +37 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.js +4 -0
- package/dist/src/sim/ai/behavior-trees.d.ts +166 -0
- package/dist/src/sim/ai/behavior-trees.js +340 -0
- package/dist/src/sim/cover.d.ts +186 -0
- package/dist/src/sim/cover.js +290 -0
- package/dist/src/sim/formation-combat.d.ts +170 -0
- package/dist/src/sim/formation-combat.js +255 -0
- package/dist/src/snapshot.d.ts +117 -0
- package/dist/src/snapshot.js +379 -0
- package/package.json +1 -1
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
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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
|
+
}
|