@its-not-rocket-science/ananke 0.1.9 → 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
@@ -10,6 +10,20 @@ Versioning follows [Semantic Versioning](https://semver.org/).
10
10
 
11
11
  ### Added
12
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
+
13
27
  - **CE-14 · Socio-Economic Campaign Layer → Stable Promotion**
14
28
  - Promote `stepPolityDay`, `declareWar`, `makePeace`, `areAtWar`,
15
29
  `createPolity`, `createPolityRegistry`, `Polity`, `PolityRegistry`,
@@ -84,6 +98,18 @@ Adding new **optional** fields to these interfaces is never a breaking change.
84
98
 
85
99
  ---
86
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
+
111
+ ---
112
+
87
113
  ## [0.1.8] — 2026-03-24
88
114
 
89
115
  ### Added
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@its-not-rocket-science/ananke",
3
- "version": "0.1.9",
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",