@its-not-rocket-science/ananke 0.1.7 → 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 CHANGED
@@ -10,6 +10,19 @@ 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
+
13
26
  ## [0.1.7] — 2026-03-23
14
27
 
15
28
  ### Added
@@ -28,8 +41,6 @@ Versioning follows [Semantic Versioning](https://semver.org/).
28
41
 
29
42
  ---
30
43
 
31
- ---
32
-
33
44
  ## [0.1.6] — 2026-03-23
34
45
 
35
46
  ### Added
@@ -22,3 +22,4 @@ export * from "./sim/formation-combat.js";
22
22
  export * from "./sim/cover.js";
23
23
  export * from "./sim/ai/behavior-trees.js";
24
24
  export * from "./snapshot.js";
25
+ export * from "./parallel.js";
package/dist/src/index.js CHANGED
@@ -31,3 +31,4 @@ export * from "./sim/formation-combat.js"; // Phase 69: FormationUnit, TacticalE
31
31
  export * from "./sim/cover.js"; // CE-15: CoverSegment, computeCoverProtection(), isLineOfSightBlocked(), applyExplosionToTerrain()
32
32
  export * from "./sim/ai/behavior-trees.js"; // CE-10: BehaviorNode, FlankTarget, RetreatTo, ProtectAlly, GuardPosition, HealTarget, Sequence, Fallback
33
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@its-not-rocket-science/ananke",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "type": "module",
5
5
  "description": "Deterministic lockstep-friendly SI-units RPG/physics core (fixed-point TS)",
6
6
  "license": "MIT",
@@ -84,7 +84,8 @@
84
84
  "benchmark-check": "node dist/tools/benchmark-check.js",
85
85
  "benchmark-check:strict": "node dist/tools/benchmark-check.js --threshold=0.10",
86
86
  "benchmark-check:update": "node dist/tools/benchmark-check.js --update-baseline",
87
- "benchmark:guide": "node dist/tools/benchmark-guide.js"
87
+ "benchmark:guide": "node dist/tools/benchmark-guide.js",
88
+ "benchmark:parallel": "node dist/tools/benchmark-parallel.js"
88
89
  },
89
90
  "devDependencies": {
90
91
  "@eslint/js": "^9.39.3",