@its-not-rocket-science/ananke 0.1.7 → 0.1.9

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
@@ -8,6 +8,93 @@ Versioning follows [Semantic Versioning](https://semver.org/).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ### Added
12
+
13
+ - **CE-14 · Socio-Economic Campaign Layer → Stable Promotion**
14
+ - Promote `stepPolityDay`, `declareWar`, `makePeace`, `areAtWar`,
15
+ `createPolity`, `createPolityRegistry`, `Polity`, `PolityRegistry`,
16
+ `PolityPair` (`src/polity.ts`), `stepTechDiffusion`, `computeDiffusionPressure`,
17
+ `totalInboundPressure`, `techEraName` (`src/tech-diffusion.ts`), and
18
+ `applyEmotionalContagion`, `stepEmotionalWaves`, `computeEmotionalSpread`,
19
+ `triggerMilitaryRout`, `triggerVictoryRally`, `netEmotionalPressure`,
20
+ `EmotionalWave` (`src/emotional-contagion.ts`) from Tier 2 (Experimental)
21
+ to Tier 1 (Stable) in `STABLE_API.md`.
22
+ - Add `export *` re-exports to `src/polity.ts` so the `ananke/polity` subpath
23
+ delivers the complete Socio-Economic Campaign Layer in one import.
24
+ - Freeze `Polity`, `PolityRegistry`, `PolityPair` and `EmotionalWave` interfaces
25
+ with `@stable CE-14` JSDoc annotations — no required-field additions without a
26
+ minor bump, no renames without a major bump.
27
+
28
+ ### Migration guide — v0.1.x → v0.2.0
29
+
30
+ This is a **non-breaking promotion**. No existing code needs to change.
31
+
32
+ #### What is new
33
+
34
+ The Socio-Economic Campaign Layer (`polity`, `tech-diffusion`, `emotional-contagion`)
35
+ is now Tier 1 (Stable). You can depend on it without fear of silent API churn.
36
+
37
+ #### Import change (optional)
38
+
39
+ Instead of importing from the package root:
40
+
41
+ ```typescript
42
+ import { stepPolityDay } from "@its-not-rocket-science/ananke";
43
+ import { stepTechDiffusion } from "@its-not-rocket-science/ananke";
44
+ import { applyEmotionalContagion } from "@its-not-rocket-science/ananke";
45
+ ```
46
+
47
+ You may now import from the dedicated subpath (recommended for tree-shaking):
48
+
49
+ ```typescript
50
+ import {
51
+ stepPolityDay,
52
+ stepTechDiffusion,
53
+ applyEmotionalContagion,
54
+ EmotionalWave,
55
+ } from "@its-not-rocket-science/ananke/polity";
56
+ ```
57
+
58
+ Both forms remain supported indefinitely.
59
+
60
+ #### Interface freeze guarantees (from v0.2.0)
61
+
62
+ | Interface | Guarantee |
63
+ |-----------|-----------|
64
+ | `Polity` | Existing fields never renamed/removed without major bump |
65
+ | `PolityRegistry` | `polities`, `activeWars`, `alliances` fields frozen |
66
+ | `PolityPair` | `polityAId`, `polityBId`, `sharedLocations`, `routeQuality_Q` frozen |
67
+ | `EmotionalWave` | `profileId`, `sourcePolityId`, `intensity_Q`, `daysActive` frozen |
68
+
69
+ Adding new **optional** fields to these interfaces is never a breaking change.
70
+
71
+ ---
72
+
73
+ ## [0.1.9] — 2026-03-24
74
+
75
+ ### Added
76
+
77
+ - **CE-14 · Promote Socio-economic Campaign Layer to Tier 1 Stable** (`src/parallel.ts`)
78
+ - Freeze Polity, PolityRegistry, PolityPair, EmotionalWave interfaces.
79
+ - Promote stepPolityDay, stepTechDiffusion, applyEmotionalContagion,
80
+ declareWar, makePeace to Tier 1 in STABLE_API.md.
81
+ - Re-export tech-diffusion and emotional-contagion from src/polity.ts so
82
+ ananke/polity is a single-import campaign layer entry point.
83
+ - Add v0.1.x -> v0.2.0 migration guide to CHANGELOG.md.
84
+
85
+ ---
86
+
87
+ ## [0.1.8] — 2026-03-24
88
+
89
+ ### Added
90
+
91
+ - **CE-7 · Spatial Partitioning API for WebWorker Support** (`src/parallel.ts`)
92
+ - Add partitionWorld / mergePartitions / detectBoundaryPairs /
93
+ assignEntitiesToPartitions / canonicaliseBoundaryPairs. Boundary pairs
94
+ are sorted in canonical (min-id first) order to preserve determinism
95
+ across partitions.
96
+ - Export via src/index.ts
97
+
11
98
  ---
12
99
 
13
100
  ## [0.1.7] — 2026-03-23
@@ -28,8 +115,6 @@ Versioning follows [Semantic Versioning](https://semver.org/).
28
115
 
29
116
  ---
30
117
 
31
- ---
32
-
33
118
  ## [0.1.6] — 2026-03-23
34
119
 
35
120
  ### Added
package/STABLE_API.md CHANGED
@@ -112,6 +112,48 @@ including `AnimationHints`, `GrapplePoseConstraint`, and `InterpolatedState`.
112
112
  | `formatOneLine(desc)` | One-line summary |
113
113
  | `CharacterDescription`, `AttributeRating` | Types |
114
114
 
115
+ ### Socio-Economic Campaign Layer (`ananke/polity` subpath — CE-14)
116
+
117
+ All of the following are available via `import { … } from "ananke/polity"` as a single
118
+ entry point. The frozen interfaces (`Polity`, `PolityRegistry`, `PolityPair`,
119
+ `EmotionalWave`) will not gain required fields or lose existing fields without a minor
120
+ version bump; renames require a major bump and migration guide.
121
+
122
+ #### Polity system (`src/polity.ts`)
123
+
124
+ | Export | Description |
125
+ |--------|-------------|
126
+ | `Polity` _(frozen)_ | Geopolitical entity: city, nation, or empire |
127
+ | `PolityRegistry` _(frozen)_ | Container for all polities and active wars/alliances |
128
+ | `PolityPair` _(frozen)_ | Trade/proximity link between two polities |
129
+ | `createPolity(spec)` | Construct a `Polity` with sensible defaults |
130
+ | `createPolityRegistry(polities)` | Construct an empty registry |
131
+ | `stepPolityDay(registry, pairs, worldSeed, tick)` | Advance all polities by one simulated day |
132
+ | `declareWar(registry, aId, bId)` | Record a war between two polities |
133
+ | `makePeace(registry, aId, bId)` | End a war between two polities |
134
+ | `areAtWar(registry, aId, bId)` | Query war status |
135
+
136
+ #### Technology diffusion (`src/tech-diffusion.ts`)
137
+
138
+ | Export | Description |
139
+ |--------|-------------|
140
+ | `stepTechDiffusion(registry, pairs, worldSeed, tick)` | Spread technology between polities for one day |
141
+ | `computeDiffusionPressure(source, target, pair)` | Per-pair pressure score |
142
+ | `totalInboundPressure(registry, pairs, targetId)` | Sum of all inbound pressure toward a polity |
143
+ | `techEraName(era)` | Human-readable era label |
144
+
145
+ #### Emotional contagion (`src/emotional-contagion.ts`)
146
+
147
+ | Export | Description |
148
+ |--------|-------------|
149
+ | `EmotionalWave` _(frozen)_ | Active emotional event propagating across polities |
150
+ | `applyEmotionalContagion(registry, waves, worldSeed, tick)` | Apply all active waves to polity morale |
151
+ | `stepEmotionalWaves(waves, worldSeed, tick)` | Advance wave intensities by one day |
152
+ | `computeEmotionalSpread(source, target, wave, profile)` | Spread probability for one polity pair |
153
+ | `triggerMilitaryRout(sourcePolityId)` | Emit a fear wave from a battlefield loss |
154
+ | `triggerVictoryRally(sourcePolityId, leaderId?)` | Emit a hope wave from a victory |
155
+ | `netEmotionalPressure(registry, waves, polityId)` | Net morale pressure on a polity |
156
+
115
157
  ---
116
158
 
117
159
  ## Tier 2 — Experimental extension API
@@ -121,9 +163,6 @@ A `CHANGELOG.md` entry will document any breaking change.
121
163
 
122
164
  | Module | Key exports |
123
165
  |--------|------------|
124
- | `src/polity.ts` | `createPolity`, `createPolityRegistry`, `stepPolityDay`, `declareWar`, `areAtWar`, `Polity`, `PolityRegistry`, `PolityPair` |
125
- | `src/tech-diffusion.ts` | `computeDiffusionPressure`, `stepTechDiffusion`, `totalInboundPressure`, `techEraName` |
126
- | `src/emotional-contagion.ts` | `applyEmotionalContagion`, `stepEmotionalWaves`, `computeEmotionalSpread`, `triggerMilitaryRout`, `triggerVictoryRally`, `netEmotionalPressure` |
127
166
  | `src/mythology.ts` | `compressMythsFromHistory`, `stepMythologyYear`, `aggregateFactionMythEffect`, `scaledMythEffect` |
128
167
  | `src/narrative-stress.ts` | `runNarrativeStressTest`, `scoreNarrativePush` |
129
168
  | `src/campaign.ts` | `Campaign`, `stepCampaignDay`, `advanceCampaignClock`, `serializeCampaign`, `deserializeCampaign` |
@@ -36,6 +36,9 @@ export interface EmotionalContagionProfile {
36
36
  /**
37
37
  * An active emotional event originating from one polity.
38
38
  * Decays each day; removed when intensity_Q reaches 0.
39
+ *
40
+ * @stable CE-14 — frozen from v0.2.0. Also exported as the nominal
41
+ * `ContagionWave` type referenced in the Campaign Layer documentation.
39
42
  */
40
43
  export interface EmotionalWave {
41
44
  profileId: string;
@@ -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
+ }
@@ -7,6 +7,9 @@ import type { FactionRegistry } from "./faction.js";
7
7
  *
8
8
  * Operates at 1 tick per simulated day. All Q fields are fixed-point
9
9
  * fractions in [0, SCALE.Q] unless documented otherwise.
10
+ *
11
+ * @stable CE-14 — fields are frozen from v0.2.0. New fields require a minor
12
+ * version bump; removals or renames require a major bump and migration guide.
10
13
  */
11
14
  export interface Polity {
12
15
  id: string;
@@ -31,7 +34,11 @@ export interface Polity {
31
34
  /** Population morale [0, SCALE.Q]. Low morale → weak military and stability decay. */
32
35
  moraleQ: Q;
33
36
  }
34
- /** Registry of all active polities and their geopolitical relationships. */
37
+ /**
38
+ * Registry of all active polities and their geopolitical relationships.
39
+ *
40
+ * @stable CE-14 — frozen from v0.2.0.
41
+ */
35
42
  export interface PolityRegistry {
36
43
  polities: Map<string, Polity>;
37
44
  /**
@@ -42,7 +49,11 @@ export interface PolityRegistry {
42
49
  /** Diplomatic alliances: polityId → Set of allied polityIds. */
43
50
  alliances: Map<string, Set<string>>;
44
51
  }
45
- /** A trade/proximity link between two polities in the Campaign graph. */
52
+ /**
53
+ * A trade/proximity link between two polities in the Campaign graph.
54
+ *
55
+ * @stable CE-14 — frozen from v0.2.0.
56
+ */
46
57
  export interface PolityPair {
47
58
  polityAId: string;
48
59
  polityBId: string;
@@ -260,3 +271,5 @@ export declare function areAtWar(registry: PolityRegistry, polityAId: string, po
260
271
  * Use this as `currentStanding_Q` for `resolveDiplomacy`.
261
272
  */
262
273
  export declare function polityFactionStanding(factionRegistry: FactionRegistry, polityA: Polity, polityB: Polity): Q;
274
+ export * from "./tech-diffusion.js";
275
+ export * from "./emotional-contagion.js";
@@ -396,3 +396,14 @@ export function polityFactionStanding(factionRegistry, polityA, polityB) {
396
396
  return factionRegistry.globalStanding
397
397
  .get(polityA.factionId)?.get(polityB.factionId) ?? STANDING_NEUTRAL;
398
398
  }
399
+ // ── Campaign Layer barrel (CE-14) ──────────────────────────────────────────────
400
+ //
401
+ // The `ananke/polity` subpath re-exports the full Socio-Economic Campaign Layer
402
+ // so that a host can import everything from one entry point:
403
+ //
404
+ // import { stepPolityDay, stepTechDiffusion, applyEmotionalContagion }
405
+ // from "ananke/polity";
406
+ //
407
+ // Both modules are Tier 1 (Stable) from v0.2.0.
408
+ export * from "./tech-diffusion.js";
409
+ export * from "./emotional-contagion.js";
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.9",
4
4
  "type": "module",
5
5
  "description": "Deterministic lockstep-friendly SI-units RPG/physics core (fixed-point TS)",
6
6
  "license": "MIT",
@@ -81,10 +81,12 @@
81
81
  "generate-zoo": "node dist/tools/generate-zoo.js",
82
82
  "generate-map": "node dist/tools/generate-map.js",
83
83
  "world-server": "node dist/tools/world-server.js",
84
+ "replication-server": "node dist/tools/replication-server.js",
84
85
  "benchmark-check": "node dist/tools/benchmark-check.js",
85
86
  "benchmark-check:strict": "node dist/tools/benchmark-check.js --threshold=0.10",
86
87
  "benchmark-check:update": "node dist/tools/benchmark-check.js --update-baseline",
87
- "benchmark:guide": "node dist/tools/benchmark-guide.js"
88
+ "benchmark:guide": "node dist/tools/benchmark-guide.js",
89
+ "benchmark:parallel": "node dist/tools/benchmark-parallel.js"
88
90
  },
89
91
  "devDependencies": {
90
92
  "@eslint/js": "^9.39.3",