@its-not-rocket-science/ananke 0.1.8 → 0.1.10

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,106 @@ Versioning follows [Semantic Versioning](https://semver.org/).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ### Added
12
+
13
+ - **CE-16 · Modding Support** (`src/modding.ts`)
14
+ - Layer 1 — `hashMod(json)`: deterministic FNV-1a fingerprint (8-char hex) for any
15
+ parsed JSON mod file; canonical key-sorted serialisation ensures order-independence.
16
+ - Layer 2 — Post-tick behavior hooks: `registerPostTickHook / unregisterPostTickHook /
17
+ runPostTickHooks / listPostTickHooks / clearPostTickHooks`; hooks fire after
18
+ `stepWorld`, are purely observational (logging, analytics, renderer updates).
19
+ - Layer 3 — AI behavior node registry: `registerBehaviorNode / unregisterBehaviorNode /
20
+ getBehaviorNode / listBehaviorNodes / clearBehaviorNodes`; custom `BehaviorNode`
21
+ factories registered by id for scenario and behavior-tree composition.
22
+ - Session fingerprint: `computeModManifest(catalogIds)` returns sorted id lists and a
23
+ single fingerprint covering all three layers for multiplayer client validation.
24
+ - `clearAllMods()` resets hooks and behavior nodes (catalog unchanged).
25
+ - 42 tests in `test/modding.test.ts`; exported via `src/index.ts`.
26
+
27
+ - **CE-14 · Socio-Economic Campaign Layer → Stable Promotion**
28
+ - Promote `stepPolityDay`, `declareWar`, `makePeace`, `areAtWar`,
29
+ `createPolity`, `createPolityRegistry`, `Polity`, `PolityRegistry`,
30
+ `PolityPair` (`src/polity.ts`), `stepTechDiffusion`, `computeDiffusionPressure`,
31
+ `totalInboundPressure`, `techEraName` (`src/tech-diffusion.ts`), and
32
+ `applyEmotionalContagion`, `stepEmotionalWaves`, `computeEmotionalSpread`,
33
+ `triggerMilitaryRout`, `triggerVictoryRally`, `netEmotionalPressure`,
34
+ `EmotionalWave` (`src/emotional-contagion.ts`) from Tier 2 (Experimental)
35
+ to Tier 1 (Stable) in `STABLE_API.md`.
36
+ - Add `export *` re-exports to `src/polity.ts` so the `ananke/polity` subpath
37
+ delivers the complete Socio-Economic Campaign Layer in one import.
38
+ - Freeze `Polity`, `PolityRegistry`, `PolityPair` and `EmotionalWave` interfaces
39
+ with `@stable CE-14` JSDoc annotations — no required-field additions without a
40
+ minor bump, no renames without a major bump.
41
+
42
+ ### Migration guide — v0.1.x → v0.2.0
43
+
44
+ This is a **non-breaking promotion**. No existing code needs to change.
45
+
46
+ #### What is new
47
+
48
+ The Socio-Economic Campaign Layer (`polity`, `tech-diffusion`, `emotional-contagion`)
49
+ is now Tier 1 (Stable). You can depend on it without fear of silent API churn.
50
+
51
+ #### Import change (optional)
52
+
53
+ Instead of importing from the package root:
54
+
55
+ ```typescript
56
+ import { stepPolityDay } from "@its-not-rocket-science/ananke";
57
+ import { stepTechDiffusion } from "@its-not-rocket-science/ananke";
58
+ import { applyEmotionalContagion } from "@its-not-rocket-science/ananke";
59
+ ```
60
+
61
+ You may now import from the dedicated subpath (recommended for tree-shaking):
62
+
63
+ ```typescript
64
+ import {
65
+ stepPolityDay,
66
+ stepTechDiffusion,
67
+ applyEmotionalContagion,
68
+ EmotionalWave,
69
+ } from "@its-not-rocket-science/ananke/polity";
70
+ ```
71
+
72
+ Both forms remain supported indefinitely.
73
+
74
+ #### Interface freeze guarantees (from v0.2.0)
75
+
76
+ | Interface | Guarantee |
77
+ |-----------|-----------|
78
+ | `Polity` | Existing fields never renamed/removed without major bump |
79
+ | `PolityRegistry` | `polities`, `activeWars`, `alliances` fields frozen |
80
+ | `PolityPair` | `polityAId`, `polityBId`, `sharedLocations`, `routeQuality_Q` frozen |
81
+ | `EmotionalWave` | `profileId`, `sourcePolityId`, `intensity_Q`, `daysActive` frozen |
82
+
83
+ Adding new **optional** fields to these interfaces is never a breaking change.
84
+
85
+ ---
86
+
87
+ ## [0.1.9] — 2026-03-24
88
+
89
+ ### Added
90
+
91
+ - **CE-14 · Promote Socio-economic Campaign Layer to Tier 1 Stable** (`src/parallel.ts`)
92
+ - Freeze Polity, PolityRegistry, PolityPair, EmotionalWave interfaces.
93
+ - Promote stepPolityDay, stepTechDiffusion, applyEmotionalContagion,
94
+ declareWar, makePeace to Tier 1 in STABLE_API.md.
95
+ - Re-export tech-diffusion and emotional-contagion from src/polity.ts so
96
+ ananke/polity is a single-import campaign layer entry point.
97
+ - Add v0.1.x -> v0.2.0 migration guide to CHANGELOG.md.
98
+
99
+ ---
100
+
101
+ ## [0.1.10] — 2026-03-24
102
+
103
+ ### Added
104
+
105
+ - **CE-16 · Modding Support — HashMod, Post-tick Hooks, Behaviour Node Registry** (`src/parallel.ts`)
106
+ - Three-layer modding contract: FNV-1a data fingerprinting, observational
107
+ post-tick hooks, and named AI behavior node factories. computeModManifest()
108
+ provides a single session fingerprint for multiplayer client validation.
109
+ - exported via src/index.ts.
110
+
11
111
  ---
12
112
 
13
113
  ## [0.1.8] — 2026-03-24
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;
@@ -23,3 +23,4 @@ export * from "./sim/cover.js";
23
23
  export * from "./sim/ai/behavior-trees.js";
24
24
  export * from "./snapshot.js";
25
25
  export * from "./parallel.js";
26
+ export * from "./modding.js";
package/dist/src/index.js CHANGED
@@ -32,3 +32,4 @@ export * from "./sim/cover.js"; // CE-15: CoverSegment, computeCoverProtection()
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
34
  export * from "./parallel.js"; // CE-7: partitionWorld(), mergePartitions(), detectBoundaryPairs(), assignEntitiesToPartitions()
35
+ export * from "./modding.js"; // CE-16: hashMod(), registerPostTickHook(), runPostTickHooks(), registerBehaviorNode(), computeModManifest()
@@ -0,0 +1,137 @@
1
+ /**
2
+ * CE-16 — Modding Support
3
+ *
4
+ * Three-layer modding contract built on CE-12 (data-driven catalog) and the stable API:
5
+ *
6
+ * **Layer 1 — Data mod fingerprinting**
7
+ * `hashMod(json)` produces a deterministic 8-char hex fingerprint of any parsed JSON
8
+ * mod file. The network replication layer (CE-11) compares fingerprints across clients
9
+ * to guarantee all participants use identical mod definitions.
10
+ *
11
+ * **Layer 2 — Post-tick behavior hooks**
12
+ * `registerPostTickHook(id, fn)` registers an observer callback that the host fires
13
+ * after each `stepWorld` call via `runPostTickHooks(world)`. Hooks are purely
14
+ * observational — they MUST NOT mutate `WorldState` during the call. Because they run
15
+ * outside the kernel path they cannot break determinism.
16
+ *
17
+ * **Layer 3 — AI behavior node overrides**
18
+ * `registerBehaviorNode(id, factory)` installs a named factory for custom
19
+ * `BehaviorNode` implementations. `loadScenario` (CE-3) can reference them by id in
20
+ * scenario JSON. AI overrides require explicit host opt-in.
21
+ *
22
+ * **Session fingerprint**
23
+ * `computeModManifest()` returns a single fingerprint covering all three registries,
24
+ * suitable for multiplayer session validation.
25
+ */
26
+ import type { WorldState } from "./sim/world.js";
27
+ import type { BehaviorNode } from "./sim/ai/behavior-trees.js";
28
+ /**
29
+ * Produce a deterministic 8-character hex fingerprint for a parsed JSON mod
30
+ * object (archetype, weapon, armour, or any CE-12 catalog entry).
31
+ *
32
+ * Keys are sorted before hashing so `{ a:1, b:2 }` and `{ b:2, a:1 }` produce
33
+ * the same fingerprint. The result is stable across JS engines and Node versions
34
+ * as long as the JSON content is identical.
35
+ *
36
+ * Use `computeModManifest()` to fingerprint the full active mod set for session
37
+ * validation.
38
+ */
39
+ export declare function hashMod(json: unknown): string;
40
+ export type PostTickHook = (world: WorldState) => void;
41
+ /**
42
+ * Register an observational callback that fires after each `stepWorld` tick.
43
+ *
44
+ * The host is responsible for calling `runPostTickHooks(world)` immediately after
45
+ * `stepWorld(world, cmds, ctx)`. Hooks MUST NOT mutate `WorldState`; they are
46
+ * intended for analytics, logging, renderer updates, and network broadcast.
47
+ *
48
+ * Re-registering an existing id overwrites the previous hook.
49
+ */
50
+ export declare function registerPostTickHook(id: string, fn: PostTickHook): void;
51
+ /**
52
+ * Remove a previously registered post-tick hook.
53
+ * Returns `true` if the hook existed and was removed.
54
+ */
55
+ export declare function unregisterPostTickHook(id: string): boolean;
56
+ /**
57
+ * Invoke all registered post-tick hooks in registration order.
58
+ *
59
+ * Call this immediately after `stepWorld`:
60
+ * ```typescript
61
+ * stepWorld(world, cmds, ctx);
62
+ * runPostTickHooks(world);
63
+ * ```
64
+ *
65
+ * Errors thrown by individual hooks are caught and re-thrown after all hooks
66
+ * have been attempted, to avoid silently dropping subsequent hooks.
67
+ */
68
+ export declare function runPostTickHooks(world: WorldState): void;
69
+ /** Return the ids of all registered post-tick hooks in registration order. */
70
+ export declare function listPostTickHooks(): string[];
71
+ /** Remove all post-tick hooks (useful for testing and hot-reload scenarios). */
72
+ export declare function clearPostTickHooks(): void;
73
+ export type BehaviorNodeFactory = (...args: unknown[]) => BehaviorNode;
74
+ /**
75
+ * Register a named factory for a custom `BehaviorNode` implementation.
76
+ *
77
+ * The factory will be looked up by id when `loadScenario` (CE-3) encounters an
78
+ * `"aiOverride"` reference in scenario JSON, or when a host builds a behavior
79
+ * tree programmatically:
80
+ *
81
+ * ```typescript
82
+ * registerBehaviorNode("patrol_guard", (waypointX, waypointY) =>
83
+ * PatrolGuard(Number(waypointX), Number(waypointY))
84
+ * );
85
+ * const factory = getBehaviorNode("patrol_guard");
86
+ * const node = factory?.(1000, 2000);
87
+ * ```
88
+ *
89
+ * **Deterministic multiplayer**: AI overrides affect simulation output. All
90
+ * clients must register the same behavior nodes (verified via `computeModManifest`)
91
+ * before joining a session.
92
+ *
93
+ * Re-registering an existing id overwrites the previous factory.
94
+ */
95
+ export declare function registerBehaviorNode(id: string, factory: BehaviorNodeFactory): void;
96
+ /**
97
+ * Remove a previously registered behavior node factory.
98
+ * Returns `true` if the factory existed and was removed.
99
+ */
100
+ export declare function unregisterBehaviorNode(id: string): boolean;
101
+ /**
102
+ * Look up a registered behavior node factory by id.
103
+ * Returns `undefined` if not found.
104
+ */
105
+ export declare function getBehaviorNode(id: string): BehaviorNodeFactory | undefined;
106
+ /** Return the ids of all registered behavior node factories in registration order. */
107
+ export declare function listBehaviorNodes(): string[];
108
+ /** Remove all behavior node factories (useful for testing and hot-reload scenarios). */
109
+ export declare function clearBehaviorNodes(): void;
110
+ export interface ModManifest {
111
+ /** Sorted list of all data mod ids currently in the CE-12 catalog. */
112
+ dataIds: string[];
113
+ /** Sorted list of all registered post-tick hook ids. */
114
+ hookIds: string[];
115
+ /** Sorted list of all registered behavior node ids. */
116
+ behaviorIds: string[];
117
+ /**
118
+ * Single fingerprint covering all three id lists.
119
+ * Two clients are mod-compatible iff their `fingerprint` values match.
120
+ */
121
+ fingerprint: string;
122
+ }
123
+ /**
124
+ * Compute a session manifest covering all active mods (CE-12 catalog entries,
125
+ * post-tick hooks, and AI behavior node overrides).
126
+ *
127
+ * The `fingerprint` is a deterministic 8-char hex string suitable for
128
+ * multiplayer session comparison. Clients are considered mod-compatible iff
129
+ * their fingerprints match.
130
+ *
131
+ * @param catalogIds Sorted list of CE-12 catalog entry ids (pass `listCatalog()`).
132
+ * Provided as a parameter to keep this module free of a circular dependency
133
+ * on `catalog.ts`.
134
+ */
135
+ export declare function computeModManifest(catalogIds?: string[]): ModManifest;
136
+ /** Remove all hooks and behavior node factories. Does not affect the CE-12 catalog. */
137
+ export declare function clearAllMods(): void;
@@ -0,0 +1,188 @@
1
+ /**
2
+ * CE-16 — Modding Support
3
+ *
4
+ * Three-layer modding contract built on CE-12 (data-driven catalog) and the stable API:
5
+ *
6
+ * **Layer 1 — Data mod fingerprinting**
7
+ * `hashMod(json)` produces a deterministic 8-char hex fingerprint of any parsed JSON
8
+ * mod file. The network replication layer (CE-11) compares fingerprints across clients
9
+ * to guarantee all participants use identical mod definitions.
10
+ *
11
+ * **Layer 2 — Post-tick behavior hooks**
12
+ * `registerPostTickHook(id, fn)` registers an observer callback that the host fires
13
+ * after each `stepWorld` call via `runPostTickHooks(world)`. Hooks are purely
14
+ * observational — they MUST NOT mutate `WorldState` during the call. Because they run
15
+ * outside the kernel path they cannot break determinism.
16
+ *
17
+ * **Layer 3 — AI behavior node overrides**
18
+ * `registerBehaviorNode(id, factory)` installs a named factory for custom
19
+ * `BehaviorNode` implementations. `loadScenario` (CE-3) can reference them by id in
20
+ * scenario JSON. AI overrides require explicit host opt-in.
21
+ *
22
+ * **Session fingerprint**
23
+ * `computeModManifest()` returns a single fingerprint covering all three registries,
24
+ * suitable for multiplayer session validation.
25
+ */
26
+ // ── Internal: FNV-1a 32-bit hash ──────────────────────────────────────────────
27
+ /** FNV-1a 32-bit hash over a UTF-16 code-unit string. */
28
+ function fnv1a32(s) {
29
+ let h = 0x811c9dc5;
30
+ for (let i = 0; i < s.length; i++) {
31
+ h ^= s.charCodeAt(i);
32
+ h = Math.imul(h, 0x01000193) | 0;
33
+ }
34
+ return h >>> 0;
35
+ }
36
+ /** Serialize any JSON-compatible value in canonical (key-sorted) form. */
37
+ function canonicalJson(v) {
38
+ if (v === null || typeof v !== "object")
39
+ return JSON.stringify(v);
40
+ if (Array.isArray(v))
41
+ return "[" + v.map(canonicalJson).join(",") + "]";
42
+ const obj = v;
43
+ return "{" + Object.keys(obj).sort().map(k => JSON.stringify(k) + ":" + canonicalJson(obj[k])).join(",") + "}";
44
+ }
45
+ // ── Layer 1: Data mod fingerprinting ─────────────────────────────────────────
46
+ /**
47
+ * Produce a deterministic 8-character hex fingerprint for a parsed JSON mod
48
+ * object (archetype, weapon, armour, or any CE-12 catalog entry).
49
+ *
50
+ * Keys are sorted before hashing so `{ a:1, b:2 }` and `{ b:2, a:1 }` produce
51
+ * the same fingerprint. The result is stable across JS engines and Node versions
52
+ * as long as the JSON content is identical.
53
+ *
54
+ * Use `computeModManifest()` to fingerprint the full active mod set for session
55
+ * validation.
56
+ */
57
+ export function hashMod(json) {
58
+ return fnv1a32(canonicalJson(json)).toString(16).padStart(8, "0");
59
+ }
60
+ const _hooks = new Map();
61
+ /**
62
+ * Register an observational callback that fires after each `stepWorld` tick.
63
+ *
64
+ * The host is responsible for calling `runPostTickHooks(world)` immediately after
65
+ * `stepWorld(world, cmds, ctx)`. Hooks MUST NOT mutate `WorldState`; they are
66
+ * intended for analytics, logging, renderer updates, and network broadcast.
67
+ *
68
+ * Re-registering an existing id overwrites the previous hook.
69
+ */
70
+ export function registerPostTickHook(id, fn) {
71
+ if (!id)
72
+ throw new Error("Hook id must be a non-empty string");
73
+ _hooks.set(id, fn);
74
+ }
75
+ /**
76
+ * Remove a previously registered post-tick hook.
77
+ * Returns `true` if the hook existed and was removed.
78
+ */
79
+ export function unregisterPostTickHook(id) {
80
+ return _hooks.delete(id);
81
+ }
82
+ /**
83
+ * Invoke all registered post-tick hooks in registration order.
84
+ *
85
+ * Call this immediately after `stepWorld`:
86
+ * ```typescript
87
+ * stepWorld(world, cmds, ctx);
88
+ * runPostTickHooks(world);
89
+ * ```
90
+ *
91
+ * Errors thrown by individual hooks are caught and re-thrown after all hooks
92
+ * have been attempted, to avoid silently dropping subsequent hooks.
93
+ */
94
+ export function runPostTickHooks(world) {
95
+ const errors = [];
96
+ for (const fn of _hooks.values()) {
97
+ try {
98
+ fn(world);
99
+ }
100
+ catch (e) {
101
+ errors.push(e);
102
+ }
103
+ }
104
+ if (errors.length > 0)
105
+ throw errors[0];
106
+ }
107
+ /** Return the ids of all registered post-tick hooks in registration order. */
108
+ export function listPostTickHooks() {
109
+ return [..._hooks.keys()];
110
+ }
111
+ /** Remove all post-tick hooks (useful for testing and hot-reload scenarios). */
112
+ export function clearPostTickHooks() {
113
+ _hooks.clear();
114
+ }
115
+ const _behaviorNodes = new Map();
116
+ /**
117
+ * Register a named factory for a custom `BehaviorNode` implementation.
118
+ *
119
+ * The factory will be looked up by id when `loadScenario` (CE-3) encounters an
120
+ * `"aiOverride"` reference in scenario JSON, or when a host builds a behavior
121
+ * tree programmatically:
122
+ *
123
+ * ```typescript
124
+ * registerBehaviorNode("patrol_guard", (waypointX, waypointY) =>
125
+ * PatrolGuard(Number(waypointX), Number(waypointY))
126
+ * );
127
+ * const factory = getBehaviorNode("patrol_guard");
128
+ * const node = factory?.(1000, 2000);
129
+ * ```
130
+ *
131
+ * **Deterministic multiplayer**: AI overrides affect simulation output. All
132
+ * clients must register the same behavior nodes (verified via `computeModManifest`)
133
+ * before joining a session.
134
+ *
135
+ * Re-registering an existing id overwrites the previous factory.
136
+ */
137
+ export function registerBehaviorNode(id, factory) {
138
+ if (!id)
139
+ throw new Error("Behavior node id must be a non-empty string");
140
+ _behaviorNodes.set(id, factory);
141
+ }
142
+ /**
143
+ * Remove a previously registered behavior node factory.
144
+ * Returns `true` if the factory existed and was removed.
145
+ */
146
+ export function unregisterBehaviorNode(id) {
147
+ return _behaviorNodes.delete(id);
148
+ }
149
+ /**
150
+ * Look up a registered behavior node factory by id.
151
+ * Returns `undefined` if not found.
152
+ */
153
+ export function getBehaviorNode(id) {
154
+ return _behaviorNodes.get(id);
155
+ }
156
+ /** Return the ids of all registered behavior node factories in registration order. */
157
+ export function listBehaviorNodes() {
158
+ return [..._behaviorNodes.keys()];
159
+ }
160
+ /** Remove all behavior node factories (useful for testing and hot-reload scenarios). */
161
+ export function clearBehaviorNodes() {
162
+ _behaviorNodes.clear();
163
+ }
164
+ /**
165
+ * Compute a session manifest covering all active mods (CE-12 catalog entries,
166
+ * post-tick hooks, and AI behavior node overrides).
167
+ *
168
+ * The `fingerprint` is a deterministic 8-char hex string suitable for
169
+ * multiplayer session comparison. Clients are considered mod-compatible iff
170
+ * their fingerprints match.
171
+ *
172
+ * @param catalogIds Sorted list of CE-12 catalog entry ids (pass `listCatalog()`).
173
+ * Provided as a parameter to keep this module free of a circular dependency
174
+ * on `catalog.ts`.
175
+ */
176
+ export function computeModManifest(catalogIds = []) {
177
+ const dataIds = [...catalogIds].sort();
178
+ const hookIds = listPostTickHooks().slice().sort();
179
+ const behaviorIds = listBehaviorNodes().slice().sort();
180
+ const combined = JSON.stringify({ dataIds, hookIds, behaviorIds });
181
+ const fingerprint = fnv1a32(combined).toString(16).padStart(8, "0");
182
+ return { dataIds, hookIds, behaviorIds, fingerprint };
183
+ }
184
+ /** Remove all hooks and behavior node factories. Does not affect the CE-12 catalog. */
185
+ export function clearAllMods() {
186
+ _hooks.clear();
187
+ _behaviorNodes.clear();
188
+ }
@@ -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.8",
3
+ "version": "0.1.10",
4
4
  "type": "module",
5
5
  "description": "Deterministic lockstep-friendly SI-units RPG/physics core (fixed-point TS)",
6
6
  "license": "MIT",
@@ -81,6 +81,7 @@
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",