@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.
- package/CHANGELOG.md +31 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +3 -0
- package/dist/src/parallel.d.ts +161 -0
- package/dist/src/parallel.js +235 -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/snapshot.d.ts +117 -0
- package/dist/src/snapshot.js +379 -0
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,37 @@ Versioning follows [Semantic Versioning](https://semver.org/).
|
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
+
## [0.1.8] — 2026-03-24
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **CE-7 · Spatial Partitioning API for WebWorker Support** (`src/parallel.ts`)
|
|
18
|
+
- Add partitionWorld / mergePartitions / detectBoundaryPairs /
|
|
19
|
+
assignEntitiesToPartitions / canonicaliseBoundaryPairs. Boundary pairs
|
|
20
|
+
are sorted in canonical (min-id first) order to preserve determinism
|
|
21
|
+
across partitions.
|
|
22
|
+
- Export via src/index.ts
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## [0.1.7] — 2026-03-23
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- **CE-9 · World-state Diffing and Incremental Snapshots** (`src/sim/cover.ts`)
|
|
31
|
+
- diffWorldState(prev, next): top-level-field diff per entity; world
|
|
32
|
+
scalar/subsystem diffs; added/removed entity tracking
|
|
33
|
+
- applyDiff(base, diff): reconstruct next state (non-mutating, copy-on-write)
|
|
34
|
+
- packDiff(diff): custom binary encoding — magic "ANKD", tagged-value
|
|
35
|
+
format (null/bool/uint8/int32/float64/string/array/object); zero
|
|
36
|
+
external dependencies, implemented with DataView/Uint8Array
|
|
37
|
+
- unpackDiff(bytes): full round-trip with magic and version validation
|
|
38
|
+
- isDiffEmpty(), diffStats() — helpers for logging and network budgeting
|
|
39
|
+
- 30 tests; verified binary size < full JSON for single-entity changes
|
|
40
|
+
- Export via src/index.ts
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
13
44
|
## [0.1.6] — 2026-03-23
|
|
14
45
|
|
|
15
46
|
### Added
|
package/dist/src/index.d.ts
CHANGED
package/dist/src/index.js
CHANGED
|
@@ -29,3 +29,6 @@ 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
|
|
34
|
+
export * from "./parallel.js"; // CE-7: partitionWorld(), mergePartitions(), detectBoundaryPairs(), assignEntitiesToPartitions()
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CE-7 — Multi-threading / WebWorker Support
|
|
3
|
+
*
|
|
4
|
+
* Spatial partitioning utilities for running `stepWorld` in parallel Workers.
|
|
5
|
+
*
|
|
6
|
+
* ## Threading Model
|
|
7
|
+
*
|
|
8
|
+
* ```
|
|
9
|
+
* Host thread
|
|
10
|
+
* 1. partitionWorld(world, specs) → N WorldState slices
|
|
11
|
+
* 2. postMessage(slice_i, commandSubset_i) to Worker i
|
|
12
|
+
*
|
|
13
|
+
* Worker i
|
|
14
|
+
* 3. stepWorld(slice_i, commands_i, ctx) → stepped WorldState
|
|
15
|
+
* 4. postMessage(steppedSlice_i) back to host
|
|
16
|
+
*
|
|
17
|
+
* Host thread
|
|
18
|
+
* 5. mergePartitions([steppedSlice_0, …, steppedSlice_N-1], boundaryPairs)
|
|
19
|
+
* → merged WorldState
|
|
20
|
+
* 6. (optional) run a cross-partition boundary-pair resolution pass using
|
|
21
|
+
* the sorted boundary pairs returned by canonicaliseBoundaryPairs()
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* ## Determinism guarantee
|
|
25
|
+
*
|
|
26
|
+
* Each partition is fully deterministic in isolation: same seed + same commands
|
|
27
|
+
* always produces the same output. Cross-partition boundary pairs **must** be
|
|
28
|
+
* resolved in canonical order (lower entity id first) after merging to avoid
|
|
29
|
+
* seed divergence. Use `canonicaliseBoundaryPairs()` before any boundary
|
|
30
|
+
* resolution step.
|
|
31
|
+
*
|
|
32
|
+
* ## Partition sizing guidelines
|
|
33
|
+
*
|
|
34
|
+
* | Entity count | Suggested partitions |
|
|
35
|
+
* |-------------|---------------------|
|
|
36
|
+
* | < 200 | 1 (no benefit) |
|
|
37
|
+
* | 200–500 | 2 |
|
|
38
|
+
* | 500–2 000 | 4 |
|
|
39
|
+
* | > 2 000 | 8 |
|
|
40
|
+
*
|
|
41
|
+
* Keep each partition roughly equal in entity count for best load balance.
|
|
42
|
+
* Avoid partitions with < 25 entities (thread-overhead exceeds compute savings).
|
|
43
|
+
*/
|
|
44
|
+
import type { WorldState } from "./sim/world.js";
|
|
45
|
+
/**
|
|
46
|
+
* Specification for a single spatial partition.
|
|
47
|
+
*
|
|
48
|
+
* `regionIds` are arbitrary string labels used to identify the geographic
|
|
49
|
+
* region(s) this partition covers (e.g. `["north-west", "north-centre"]`).
|
|
50
|
+
* They are metadata only — they do not affect computation.
|
|
51
|
+
*
|
|
52
|
+
* `entities` lists the entity ids that belong to this partition.
|
|
53
|
+
* An entity id must appear in **at most one** partition; duplicate ids across
|
|
54
|
+
* partitions cause undefined behaviour in `mergePartitions`.
|
|
55
|
+
*/
|
|
56
|
+
export interface PartitionSpec {
|
|
57
|
+
/** Human-readable region labels (metadata only). */
|
|
58
|
+
regionIds: string[];
|
|
59
|
+
/** Entity ids assigned to this partition. */
|
|
60
|
+
entities: number[];
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Result summary returned by `mergePartitions`.
|
|
64
|
+
* The merged `WorldState` is the primary payload; `unresolvedBoundaryPairs` are
|
|
65
|
+
* the boundary pairs sorted in canonical order and ready for an optional
|
|
66
|
+
* post-merge resolution pass (e.g. cross-partition push/repulsion).
|
|
67
|
+
*/
|
|
68
|
+
export interface MergeResult {
|
|
69
|
+
/** Merged world state with all entities from all partitions. */
|
|
70
|
+
world: WorldState;
|
|
71
|
+
/**
|
|
72
|
+
* Boundary pairs sorted in canonical order (a < b) for post-merge
|
|
73
|
+
* cross-partition resolution. Pass these to your boundary-resolution step
|
|
74
|
+
* to preserve determinism.
|
|
75
|
+
*/
|
|
76
|
+
sortedBoundaryPairs: [number, number][];
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Split a `WorldState` into N independent partition slices.
|
|
80
|
+
*
|
|
81
|
+
* Each partition slice contains:
|
|
82
|
+
* - All world-level scalar / subsystem fields (shared, copy-on-write).
|
|
83
|
+
* - Only the entities whose ids appear in `spec.entities`.
|
|
84
|
+
*
|
|
85
|
+
* Entity ids not referenced in any spec are silently dropped from all slices.
|
|
86
|
+
* If you need unassigned entities preserved, include them in one of the specs.
|
|
87
|
+
*
|
|
88
|
+
* @param world The current world state to partition.
|
|
89
|
+
* @param specs One spec per desired partition. Must have at least one entry.
|
|
90
|
+
* @returns Array of `WorldState` slices, one per spec, in the same order.
|
|
91
|
+
*/
|
|
92
|
+
export declare function partitionWorld(world: WorldState, specs: PartitionSpec[]): WorldState[];
|
|
93
|
+
/**
|
|
94
|
+
* Merge N independently-stepped partition slices back into a single
|
|
95
|
+
* `WorldState`, and return the boundary pairs sorted in canonical order.
|
|
96
|
+
*
|
|
97
|
+
* **Entity ownership rule:** If an entity id appears in multiple partition
|
|
98
|
+
* results (which can happen if a host intentionally duplicated a boundary
|
|
99
|
+
* entity as a read-only context ghost), the entity state from the
|
|
100
|
+
* **last partition** in the array wins. To avoid ambiguity, ensure each
|
|
101
|
+
* entity id appears in at most one partition before stepping.
|
|
102
|
+
*
|
|
103
|
+
* World-level fields (`tick`, `seed`, subsystems) are taken from the first
|
|
104
|
+
* non-empty partition. All partitions should have been stepped with the same
|
|
105
|
+
* seed and world-level state; mixing partitions stepped at different ticks
|
|
106
|
+
* produces undefined behaviour.
|
|
107
|
+
*
|
|
108
|
+
* @param partitions Stepped WorldState slices, one per worker.
|
|
109
|
+
* @param boundaryPairs Pairs of entity ids that span partition boundaries.
|
|
110
|
+
* Order within each pair does not matter — they are
|
|
111
|
+
* normalised to `[min, max]` internally.
|
|
112
|
+
* @returns Merged world state + canonically sorted boundary pairs.
|
|
113
|
+
*/
|
|
114
|
+
export declare function mergePartitions(partitions: WorldState[], boundaryPairs: [number, number][]): MergeResult;
|
|
115
|
+
/**
|
|
116
|
+
* Normalise and sort an array of entity-id pairs into canonical form.
|
|
117
|
+
*
|
|
118
|
+
* Each pair is normalised to `[min(a, b), max(a, b)]` and the array is sorted
|
|
119
|
+
* lexicographically by `[a, b]`. Duplicate pairs are preserved (callers may
|
|
120
|
+
* de-duplicate if needed).
|
|
121
|
+
*
|
|
122
|
+
* Use this before any cross-partition resolution step to guarantee the same
|
|
123
|
+
* pair-processing order regardless of how the host partitioned the world.
|
|
124
|
+
*
|
|
125
|
+
* @param pairs Boundary pairs in any order.
|
|
126
|
+
* @returns New sorted array with each pair normalised.
|
|
127
|
+
*/
|
|
128
|
+
export declare function canonicaliseBoundaryPairs(pairs: [number, number][]): [number, number][];
|
|
129
|
+
/**
|
|
130
|
+
* Automatically detect cross-partition entity pairs within a given range.
|
|
131
|
+
*
|
|
132
|
+
* Scans all entity pairs `(e_i from partition_A, e_j from partition_B)` where
|
|
133
|
+
* `A ≠ B`, and returns any pair whose entities are within `range_m` of each
|
|
134
|
+
* other (using 2-D Euclidean distance). Results are canonically sorted.
|
|
135
|
+
*
|
|
136
|
+
* Useful for building the `boundaryPairs` argument to `mergePartitions` without
|
|
137
|
+
* manually specifying which pairs cross boundaries.
|
|
138
|
+
*
|
|
139
|
+
* Complexity: O(E²) — only suitable for boundary detection (not the hot path).
|
|
140
|
+
* For large entity counts, build boundary pairs from the spatial index instead.
|
|
141
|
+
*
|
|
142
|
+
* @param partitions Post-step (or pre-step) partition slices.
|
|
143
|
+
* @param range_m Maximum inter-entity distance to consider a boundary pair
|
|
144
|
+
* (in fixed-point metres, same scale as `entity.position_m`).
|
|
145
|
+
* @returns Canonically sorted cross-partition boundary pairs.
|
|
146
|
+
*/
|
|
147
|
+
export declare function detectBoundaryPairs(partitions: WorldState[], range_m: number): [number, number][];
|
|
148
|
+
/**
|
|
149
|
+
* Simple spatial partitioning helper — assigns entities to N partitions based
|
|
150
|
+
* on their X position (vertical strip partitioning).
|
|
151
|
+
*
|
|
152
|
+
* Entities are sorted by ascending `position_m.x` and divided into `n` roughly
|
|
153
|
+
* equal buckets. This is suitable for combat scenarios where combatants are
|
|
154
|
+
* spread along the X axis. For 2-D grid layouts, call this function twice
|
|
155
|
+
* (once per axis) and intersect the results.
|
|
156
|
+
*
|
|
157
|
+
* @param world World state whose entities should be partitioned.
|
|
158
|
+
* @param n Number of partitions (≥ 1).
|
|
159
|
+
* @returns `PartitionSpec` array ready for `partitionWorld`.
|
|
160
|
+
*/
|
|
161
|
+
export declare function assignEntitiesToPartitions(world: WorldState, n: number): PartitionSpec[];
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CE-7 — Multi-threading / WebWorker Support
|
|
3
|
+
*
|
|
4
|
+
* Spatial partitioning utilities for running `stepWorld` in parallel Workers.
|
|
5
|
+
*
|
|
6
|
+
* ## Threading Model
|
|
7
|
+
*
|
|
8
|
+
* ```
|
|
9
|
+
* Host thread
|
|
10
|
+
* 1. partitionWorld(world, specs) → N WorldState slices
|
|
11
|
+
* 2. postMessage(slice_i, commandSubset_i) to Worker i
|
|
12
|
+
*
|
|
13
|
+
* Worker i
|
|
14
|
+
* 3. stepWorld(slice_i, commands_i, ctx) → stepped WorldState
|
|
15
|
+
* 4. postMessage(steppedSlice_i) back to host
|
|
16
|
+
*
|
|
17
|
+
* Host thread
|
|
18
|
+
* 5. mergePartitions([steppedSlice_0, …, steppedSlice_N-1], boundaryPairs)
|
|
19
|
+
* → merged WorldState
|
|
20
|
+
* 6. (optional) run a cross-partition boundary-pair resolution pass using
|
|
21
|
+
* the sorted boundary pairs returned by canonicaliseBoundaryPairs()
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* ## Determinism guarantee
|
|
25
|
+
*
|
|
26
|
+
* Each partition is fully deterministic in isolation: same seed + same commands
|
|
27
|
+
* always produces the same output. Cross-partition boundary pairs **must** be
|
|
28
|
+
* resolved in canonical order (lower entity id first) after merging to avoid
|
|
29
|
+
* seed divergence. Use `canonicaliseBoundaryPairs()` before any boundary
|
|
30
|
+
* resolution step.
|
|
31
|
+
*
|
|
32
|
+
* ## Partition sizing guidelines
|
|
33
|
+
*
|
|
34
|
+
* | Entity count | Suggested partitions |
|
|
35
|
+
* |-------------|---------------------|
|
|
36
|
+
* | < 200 | 1 (no benefit) |
|
|
37
|
+
* | 200–500 | 2 |
|
|
38
|
+
* | 500–2 000 | 4 |
|
|
39
|
+
* | > 2 000 | 8 |
|
|
40
|
+
*
|
|
41
|
+
* Keep each partition roughly equal in entity count for best load balance.
|
|
42
|
+
* Avoid partitions with < 25 entities (thread-overhead exceeds compute savings).
|
|
43
|
+
*/
|
|
44
|
+
// ── partitionWorld ─────────────────────────────────────────────────────────────
|
|
45
|
+
/**
|
|
46
|
+
* Split a `WorldState` into N independent partition slices.
|
|
47
|
+
*
|
|
48
|
+
* Each partition slice contains:
|
|
49
|
+
* - All world-level scalar / subsystem fields (shared, copy-on-write).
|
|
50
|
+
* - Only the entities whose ids appear in `spec.entities`.
|
|
51
|
+
*
|
|
52
|
+
* Entity ids not referenced in any spec are silently dropped from all slices.
|
|
53
|
+
* If you need unassigned entities preserved, include them in one of the specs.
|
|
54
|
+
*
|
|
55
|
+
* @param world The current world state to partition.
|
|
56
|
+
* @param specs One spec per desired partition. Must have at least one entry.
|
|
57
|
+
* @returns Array of `WorldState` slices, one per spec, in the same order.
|
|
58
|
+
*/
|
|
59
|
+
export function partitionWorld(world, specs) {
|
|
60
|
+
if (specs.length === 0) {
|
|
61
|
+
throw new RangeError("parallel: partitionWorld requires at least one PartitionSpec");
|
|
62
|
+
}
|
|
63
|
+
// Build O(1) entity lookup
|
|
64
|
+
const entityMap = new Map(world.entities.map(e => [e.id, e]));
|
|
65
|
+
return specs.map(spec => {
|
|
66
|
+
// Collect entities for this partition; skip unknown ids
|
|
67
|
+
const entities = [];
|
|
68
|
+
for (const id of spec.entities) {
|
|
69
|
+
const e = entityMap.get(id);
|
|
70
|
+
if (e !== undefined)
|
|
71
|
+
entities.push(e);
|
|
72
|
+
}
|
|
73
|
+
// Maintain canonical ascending-id sort within partition
|
|
74
|
+
entities.sort((a, b) => a.id - b.id);
|
|
75
|
+
return {
|
|
76
|
+
...world,
|
|
77
|
+
entities,
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
// ── mergePartitions ────────────────────────────────────────────────────────────
|
|
82
|
+
/**
|
|
83
|
+
* Merge N independently-stepped partition slices back into a single
|
|
84
|
+
* `WorldState`, and return the boundary pairs sorted in canonical order.
|
|
85
|
+
*
|
|
86
|
+
* **Entity ownership rule:** If an entity id appears in multiple partition
|
|
87
|
+
* results (which can happen if a host intentionally duplicated a boundary
|
|
88
|
+
* entity as a read-only context ghost), the entity state from the
|
|
89
|
+
* **last partition** in the array wins. To avoid ambiguity, ensure each
|
|
90
|
+
* entity id appears in at most one partition before stepping.
|
|
91
|
+
*
|
|
92
|
+
* World-level fields (`tick`, `seed`, subsystems) are taken from the first
|
|
93
|
+
* non-empty partition. All partitions should have been stepped with the same
|
|
94
|
+
* seed and world-level state; mixing partitions stepped at different ticks
|
|
95
|
+
* produces undefined behaviour.
|
|
96
|
+
*
|
|
97
|
+
* @param partitions Stepped WorldState slices, one per worker.
|
|
98
|
+
* @param boundaryPairs Pairs of entity ids that span partition boundaries.
|
|
99
|
+
* Order within each pair does not matter — they are
|
|
100
|
+
* normalised to `[min, max]` internally.
|
|
101
|
+
* @returns Merged world state + canonically sorted boundary pairs.
|
|
102
|
+
*/
|
|
103
|
+
export function mergePartitions(partitions, boundaryPairs) {
|
|
104
|
+
if (partitions.length === 0) {
|
|
105
|
+
throw new RangeError("parallel: mergePartitions requires at least one partition");
|
|
106
|
+
}
|
|
107
|
+
// Merge entities — last-partition-wins for duplicates
|
|
108
|
+
const entityMap = new Map();
|
|
109
|
+
for (const partition of partitions) {
|
|
110
|
+
for (const e of partition.entities) {
|
|
111
|
+
entityMap.set(e.id, e);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Restore canonical ascending-id sort
|
|
115
|
+
const entities = [...entityMap.values()].sort((a, b) => a.id - b.id);
|
|
116
|
+
// World-level fields from first partition (tick, seed, subsystems)
|
|
117
|
+
const base = partitions[0];
|
|
118
|
+
const world = {
|
|
119
|
+
...base,
|
|
120
|
+
entities,
|
|
121
|
+
};
|
|
122
|
+
// Canonical boundary pairs: normalise order + sort
|
|
123
|
+
const sortedBoundaryPairs = canonicaliseBoundaryPairs(boundaryPairs);
|
|
124
|
+
return { world, sortedBoundaryPairs };
|
|
125
|
+
}
|
|
126
|
+
// ── canonicaliseBoundaryPairs ─────────────────────────────────────────────────
|
|
127
|
+
/**
|
|
128
|
+
* Normalise and sort an array of entity-id pairs into canonical form.
|
|
129
|
+
*
|
|
130
|
+
* Each pair is normalised to `[min(a, b), max(a, b)]` and the array is sorted
|
|
131
|
+
* lexicographically by `[a, b]`. Duplicate pairs are preserved (callers may
|
|
132
|
+
* de-duplicate if needed).
|
|
133
|
+
*
|
|
134
|
+
* Use this before any cross-partition resolution step to guarantee the same
|
|
135
|
+
* pair-processing order regardless of how the host partitioned the world.
|
|
136
|
+
*
|
|
137
|
+
* @param pairs Boundary pairs in any order.
|
|
138
|
+
* @returns New sorted array with each pair normalised.
|
|
139
|
+
*/
|
|
140
|
+
export function canonicaliseBoundaryPairs(pairs) {
|
|
141
|
+
return pairs
|
|
142
|
+
.map(([a, b]) => a <= b ? [a, b] : [b, a])
|
|
143
|
+
.sort(([a1, b1], [a2, b2]) => (a1 - a2) || (b1 - b2));
|
|
144
|
+
}
|
|
145
|
+
// ── detectBoundaryPairs ────────────────────────────────────────────────────────
|
|
146
|
+
/**
|
|
147
|
+
* Automatically detect cross-partition entity pairs within a given range.
|
|
148
|
+
*
|
|
149
|
+
* Scans all entity pairs `(e_i from partition_A, e_j from partition_B)` where
|
|
150
|
+
* `A ≠ B`, and returns any pair whose entities are within `range_m` of each
|
|
151
|
+
* other (using 2-D Euclidean distance). Results are canonically sorted.
|
|
152
|
+
*
|
|
153
|
+
* Useful for building the `boundaryPairs` argument to `mergePartitions` without
|
|
154
|
+
* manually specifying which pairs cross boundaries.
|
|
155
|
+
*
|
|
156
|
+
* Complexity: O(E²) — only suitable for boundary detection (not the hot path).
|
|
157
|
+
* For large entity counts, build boundary pairs from the spatial index instead.
|
|
158
|
+
*
|
|
159
|
+
* @param partitions Post-step (or pre-step) partition slices.
|
|
160
|
+
* @param range_m Maximum inter-entity distance to consider a boundary pair
|
|
161
|
+
* (in fixed-point metres, same scale as `entity.position_m`).
|
|
162
|
+
* @returns Canonically sorted cross-partition boundary pairs.
|
|
163
|
+
*/
|
|
164
|
+
export function detectBoundaryPairs(partitions, range_m) {
|
|
165
|
+
const range2 = range_m * range_m;
|
|
166
|
+
// Build (entityId → partitionIndex) map
|
|
167
|
+
const ownerMap = new Map();
|
|
168
|
+
for (let pi = 0; pi < partitions.length; pi++) {
|
|
169
|
+
for (const e of partitions[pi].entities) {
|
|
170
|
+
ownerMap.set(e.id, pi);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// Flat entity list for pair scanning
|
|
174
|
+
const all = partitions.flatMap(p => p.entities);
|
|
175
|
+
const pairs = [];
|
|
176
|
+
for (let i = 0; i < all.length; i++) {
|
|
177
|
+
const ei = all[i];
|
|
178
|
+
if (ei.injury.dead)
|
|
179
|
+
continue;
|
|
180
|
+
const piOwner = ownerMap.get(ei.id);
|
|
181
|
+
for (let j = i + 1; j < all.length; j++) {
|
|
182
|
+
const ej = all[j];
|
|
183
|
+
if (ej.injury.dead)
|
|
184
|
+
continue;
|
|
185
|
+
if (ownerMap.get(ej.id) === piOwner)
|
|
186
|
+
continue; // same partition — skip
|
|
187
|
+
const dx = ei.position_m.x - ej.position_m.x;
|
|
188
|
+
const dy = ei.position_m.y - ej.position_m.y;
|
|
189
|
+
const dist2 = dx * dx + dy * dy;
|
|
190
|
+
if (dist2 <= range2) {
|
|
191
|
+
const a = Math.min(ei.id, ej.id);
|
|
192
|
+
const b = Math.max(ei.id, ej.id);
|
|
193
|
+
pairs.push([a, b]);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return canonicaliseBoundaryPairs(pairs);
|
|
198
|
+
}
|
|
199
|
+
// ── assignEntitiesToPartitions ────────────────────────────────────────────────
|
|
200
|
+
/**
|
|
201
|
+
* Simple spatial partitioning helper — assigns entities to N partitions based
|
|
202
|
+
* on their X position (vertical strip partitioning).
|
|
203
|
+
*
|
|
204
|
+
* Entities are sorted by ascending `position_m.x` and divided into `n` roughly
|
|
205
|
+
* equal buckets. This is suitable for combat scenarios where combatants are
|
|
206
|
+
* spread along the X axis. For 2-D grid layouts, call this function twice
|
|
207
|
+
* (once per axis) and intersect the results.
|
|
208
|
+
*
|
|
209
|
+
* @param world World state whose entities should be partitioned.
|
|
210
|
+
* @param n Number of partitions (≥ 1).
|
|
211
|
+
* @returns `PartitionSpec` array ready for `partitionWorld`.
|
|
212
|
+
*/
|
|
213
|
+
export function assignEntitiesToPartitions(world, n) {
|
|
214
|
+
if (n < 1)
|
|
215
|
+
throw new RangeError("parallel: partition count must be ≥ 1");
|
|
216
|
+
const live = world.entities
|
|
217
|
+
.filter(e => !e.injury.dead)
|
|
218
|
+
.sort((a, b) => a.position_m.x - b.position_m.x);
|
|
219
|
+
const specs = Array.from({ length: n }, (_, i) => ({
|
|
220
|
+
regionIds: [`strip-${i}`],
|
|
221
|
+
entities: [],
|
|
222
|
+
}));
|
|
223
|
+
// Assign dead entities to first partition so they're not dropped
|
|
224
|
+
const dead = world.entities.filter(e => e.injury.dead);
|
|
225
|
+
for (const e of dead) {
|
|
226
|
+
specs[0].entities.push(e.id);
|
|
227
|
+
}
|
|
228
|
+
// Round-robin striped assignment for live entities
|
|
229
|
+
const bucketSize = Math.ceil(live.length / n);
|
|
230
|
+
for (let i = 0; i < live.length; i++) {
|
|
231
|
+
const bucket = Math.min(Math.trunc(i / bucketSize), n - 1);
|
|
232
|
+
specs[bucket].entities.push(live[i].id);
|
|
233
|
+
}
|
|
234
|
+
return specs;
|
|
235
|
+
}
|
|
@@ -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;
|