@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 +100 -0
- package/STABLE_API.md +42 -3
- package/dist/src/emotional-contagion.d.ts +3 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/modding.d.ts +137 -0
- package/dist/src/modding.js +188 -0
- package/dist/src/polity.d.ts +15 -2
- package/dist/src/polity.js +11 -0
- package/package.json +2 -1
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;
|
package/dist/src/index.d.ts
CHANGED
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/dist/src/polity.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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";
|
package/dist/src/polity.js
CHANGED
|
@@ -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.
|
|
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",
|