@its-not-rocket-science/ananke 0.1.50 → 0.1.52
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 +33 -0
- package/dist/src/schema-migration.d.ts +90 -0
- package/dist/src/schema-migration.js +162 -0
- package/docs/migration-monolith-to-modular.md +120 -0
- package/docs/package-architecture.md +323 -0
- package/docs/wire-protocol.md +209 -0
- package/package.json +13 -1
- package/schema/replay.schema.json +70 -0
- package/schema/world.schema.json +105 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,39 @@ Versioning follows [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
+
## [0.1.52] — 2026-03-28
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **PA-3 — Stable Schema, Save & Wire Contract (complete):**
|
|
14
|
+
- `src/schema-migration.ts` (new): schema versioning and migration utilities — `SCHEMA_VERSION`, `stampSnapshot`, `validateSnapshot` (returns `ValidationError[]` with JSONPath paths), `migrateWorld` (chains registered migrations; legacy saves treated as version `"0.0"`), `registerMigration`, `detectVersion`, `isValidSnapshot`.
|
|
15
|
+
- `schema/world.schema.json` (new): JSON Schema 2020-12 for `WorldState` — documents `@core` fields (`tick`, `seed`, `entities` with per-entity validation), `@subsystem` fields, and Q-value semantics.
|
|
16
|
+
- `schema/replay.schema.json` (new): JSON Schema 2020-12 for `Replay` / `ReplayFrame` / `Command`.
|
|
17
|
+
- `docs/wire-protocol.md` (new): Q-value serialisation rules (store raw integers, never divide by `SCALE.Q`), binary diff format (ANKD magic, tag-value encoding), multiplayer lockstep message types (`cmd`/`ack`/`resync`/`hash_mismatch`), save-format recommendations, and full load-with-migration code sample.
|
|
18
|
+
- `"./schema"` subpath added to `package.json` exports.
|
|
19
|
+
- `schema/` directory and `docs/wire-protocol.md` added to `package.json` `"files"`.
|
|
20
|
+
- 39 new tests (183 test files, 5,300 tests total). Build: clean.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## [0.1.51] — 2026-03-28
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **PA-2 — Modular Package Architecture (Phase 1 complete):**
|
|
29
|
+
- `packages/core/` — `@ananke/core` stub; re-exports the full main `"."` entry point (kernel, entity model, units, RNG, replay, bridge).
|
|
30
|
+
- `packages/combat/` — `@ananke/combat` stub; re-exports `"./combat"`, `"./anatomy"`, `"./competence"`, `"./wasm-kernel"`.
|
|
31
|
+
- `packages/campaign/` — `@ananke/campaign` stub; re-exports all 32 campaign-scale subpaths (polity, social, narrative, feudal, demography, economy, military…).
|
|
32
|
+
- `packages/content/` — `@ananke/content` stub; re-exports `"./species"`, `"./catalog"`, `"./character"`, `"./crafting"`.
|
|
33
|
+
- Each stub ships a pre-built `index.js` + `index.d.ts` (no separate compilation step); `@its-not-rocket-science/ananke` is a peer dependency.
|
|
34
|
+
- Root `package.json` gains `"workspaces": ["packages/*"]` for local linking.
|
|
35
|
+
- `docs/package-architecture.md` (new): canonical package boundary design — dependency graph, monolith subpath → package mapping table, full source-file → package mapping for Phase 2 migration, and a before/after import example.
|
|
36
|
+
- `docs/migration-monolith-to-modular.md` (new): step-by-step migration guide from the monolith to `@ananke/*` packages, with a complete old-import → new-package lookup table and Phase 2 expectations.
|
|
37
|
+
- `docs/package-architecture.md` and `docs/migration-monolith-to-modular.md` added to `package.json` `"files"` so they ship with the published package.
|
|
38
|
+
- Build: clean. Tests: 5,261 passing. Coverage unchanged.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
9
42
|
## [0.1.50] — 2026-03-28
|
|
10
43
|
|
|
11
44
|
### Docs
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Current schema major.minor version.
|
|
3
|
+
*
|
|
4
|
+
* Patch releases (0.1.x → 0.1.y) never change the schema.
|
|
5
|
+
* Minor releases (0.1.x → 0.2.0) may add optional fields (non-breaking).
|
|
6
|
+
* Major releases (0.x → 1.0.0) may alter required fields (breaking; migration required).
|
|
7
|
+
*/
|
|
8
|
+
export declare const SCHEMA_VERSION = "0.1";
|
|
9
|
+
/** Schema discrimination tag added by `stampSnapshot`. */
|
|
10
|
+
export type SchemaKind = "world" | "replay" | "campaign";
|
|
11
|
+
/**
|
|
12
|
+
* Metadata fields stamped onto a persisted snapshot.
|
|
13
|
+
* Present on any object returned by `stampSnapshot`.
|
|
14
|
+
*/
|
|
15
|
+
export interface VersionedSnapshot {
|
|
16
|
+
/** Schema version at save time, e.g. `"0.1"`. */
|
|
17
|
+
_ananke_version: string;
|
|
18
|
+
/** Which schema this snapshot conforms to. */
|
|
19
|
+
_schema: SchemaKind;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A single actionable validation failure.
|
|
23
|
+
*
|
|
24
|
+
* `path` uses JSONPath dot-notation, e.g. `"$.entities[2].id"`.
|
|
25
|
+
*/
|
|
26
|
+
export interface ValidationError {
|
|
27
|
+
path: string;
|
|
28
|
+
message: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Add `_ananke_version` and `_schema` metadata to a snapshot before persisting.
|
|
32
|
+
*
|
|
33
|
+
* Does not mutate the original object.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* const save = JSON.stringify(stampSnapshot(world, "world"));
|
|
37
|
+
*/
|
|
38
|
+
export declare function stampSnapshot<T extends Record<string, unknown>>(snapshot: T, schema: SchemaKind): T & VersionedSnapshot;
|
|
39
|
+
/**
|
|
40
|
+
* Check structural conformance of a deserialized world snapshot.
|
|
41
|
+
*
|
|
42
|
+
* Validates only the `@core` fields that `stepWorld` requires. Subsystem
|
|
43
|
+
* fields are not validated — unknown extra fields are silently permitted
|
|
44
|
+
* (hosts may attach extension data).
|
|
45
|
+
*
|
|
46
|
+
* @returns An array of `ValidationError`. An empty array means valid.
|
|
47
|
+
*/
|
|
48
|
+
export declare function validateSnapshot(snapshot: unknown): ValidationError[];
|
|
49
|
+
type MigrationFn = (snapshot: Record<string, unknown>) => Record<string, unknown>;
|
|
50
|
+
/**
|
|
51
|
+
* Register a migration function between two schema versions.
|
|
52
|
+
*
|
|
53
|
+
* Migrations are chained automatically when `migrateWorld` is called.
|
|
54
|
+
* For a simple non-breaking addition, the migration only needs to add
|
|
55
|
+
* default values for the new fields.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* registerMigration("0.1", "0.2", snap => ({
|
|
59
|
+
* ...snap,
|
|
60
|
+
* __newField: snap["__newField"] ?? 0,
|
|
61
|
+
* }));
|
|
62
|
+
*/
|
|
63
|
+
export declare function registerMigration(fromVersion: string, toVersion: string, fn: MigrationFn): void;
|
|
64
|
+
/**
|
|
65
|
+
* Migrate a deserialized world snapshot to `toVersion` (default: current `SCHEMA_VERSION`).
|
|
66
|
+
*
|
|
67
|
+
* - If the snapshot already carries `_ananke_version === toVersion`, it is returned unchanged.
|
|
68
|
+
* - Legacy snapshots without `_ananke_version` are treated as version `"0.0"`.
|
|
69
|
+
* - Throws a descriptive error when no registered migration path exists.
|
|
70
|
+
*
|
|
71
|
+
* The snapshot is not mutated; a new object is returned.
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* const raw = JSON.parse(fs.readFileSync("save.json", "utf8"));
|
|
75
|
+
* const world = migrateWorld(raw) as WorldState;
|
|
76
|
+
*/
|
|
77
|
+
export declare function migrateWorld(snapshot: Record<string, unknown>, toVersion?: string): Record<string, unknown>;
|
|
78
|
+
/**
|
|
79
|
+
* Read the `_ananke_version` stamp from a deserialized snapshot.
|
|
80
|
+
* Returns `undefined` for legacy snapshots saved before PA-3.
|
|
81
|
+
*/
|
|
82
|
+
export declare function detectVersion(snapshot: unknown): string | undefined;
|
|
83
|
+
/**
|
|
84
|
+
* Returns `true` when the snapshot carries a valid version stamp and passes
|
|
85
|
+
* structural validation (no `ValidationError` entries).
|
|
86
|
+
*
|
|
87
|
+
* Convenience wrapper for `detectVersion` + `validateSnapshot`.
|
|
88
|
+
*/
|
|
89
|
+
export declare function isValidSnapshot(snapshot: unknown): boolean;
|
|
90
|
+
export {};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// src/schema-migration.ts — PA-3: Stable Schema, Save & Wire Contract
|
|
2
|
+
//
|
|
3
|
+
// Provides schema versioning, structural validation, and migration utilities
|
|
4
|
+
// for WorldState snapshots, Campaign saves, and Replay files.
|
|
5
|
+
//
|
|
6
|
+
// Usage pattern:
|
|
7
|
+
// const raw = JSON.parse(saveFile);
|
|
8
|
+
// const migrated = migrateWorld(raw);
|
|
9
|
+
// const errors = validateSnapshot(migrated);
|
|
10
|
+
// if (errors.length === 0) stepWorld(migrated as WorldState, commands);
|
|
11
|
+
// ── Version ───────────────────────────────────────────────────────────────────
|
|
12
|
+
/**
|
|
13
|
+
* Current schema major.minor version.
|
|
14
|
+
*
|
|
15
|
+
* Patch releases (0.1.x → 0.1.y) never change the schema.
|
|
16
|
+
* Minor releases (0.1.x → 0.2.0) may add optional fields (non-breaking).
|
|
17
|
+
* Major releases (0.x → 1.0.0) may alter required fields (breaking; migration required).
|
|
18
|
+
*/
|
|
19
|
+
export const SCHEMA_VERSION = "0.1";
|
|
20
|
+
// ── Stamp ─────────────────────────────────────────────────────────────────────
|
|
21
|
+
/**
|
|
22
|
+
* Add `_ananke_version` and `_schema` metadata to a snapshot before persisting.
|
|
23
|
+
*
|
|
24
|
+
* Does not mutate the original object.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* const save = JSON.stringify(stampSnapshot(world, "world"));
|
|
28
|
+
*/
|
|
29
|
+
export function stampSnapshot(snapshot, schema) {
|
|
30
|
+
return {
|
|
31
|
+
...snapshot,
|
|
32
|
+
_ananke_version: SCHEMA_VERSION,
|
|
33
|
+
_schema: schema,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
// ── Validate ──────────────────────────────────────────────────────────────────
|
|
37
|
+
/**
|
|
38
|
+
* Check structural conformance of a deserialized world snapshot.
|
|
39
|
+
*
|
|
40
|
+
* Validates only the `@core` fields that `stepWorld` requires. Subsystem
|
|
41
|
+
* fields are not validated — unknown extra fields are silently permitted
|
|
42
|
+
* (hosts may attach extension data).
|
|
43
|
+
*
|
|
44
|
+
* @returns An array of `ValidationError`. An empty array means valid.
|
|
45
|
+
*/
|
|
46
|
+
export function validateSnapshot(snapshot) {
|
|
47
|
+
const errors = [];
|
|
48
|
+
if (typeof snapshot !== "object" || snapshot === null || Array.isArray(snapshot)) {
|
|
49
|
+
errors.push({ path: "$", message: "must be a plain object" });
|
|
50
|
+
return errors;
|
|
51
|
+
}
|
|
52
|
+
const s = snapshot;
|
|
53
|
+
// tick
|
|
54
|
+
if (typeof s["tick"] !== "number" || !Number.isInteger(s["tick"]) || s["tick"] < 0) {
|
|
55
|
+
errors.push({ path: "$.tick", message: "must be a non-negative integer" });
|
|
56
|
+
}
|
|
57
|
+
// seed
|
|
58
|
+
if (typeof s["seed"] !== "number" || !Number.isInteger(s["seed"])) {
|
|
59
|
+
errors.push({ path: "$.seed", message: "must be an integer" });
|
|
60
|
+
}
|
|
61
|
+
// entities
|
|
62
|
+
if (!Array.isArray(s["entities"])) {
|
|
63
|
+
errors.push({ path: "$.entities", message: "must be an array" });
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
for (let i = 0; i < s["entities"].length; i++) {
|
|
67
|
+
const e = s["entities"][i];
|
|
68
|
+
if (typeof e !== "object" || e === null || Array.isArray(e)) {
|
|
69
|
+
errors.push({ path: `$.entities[${i}]`, message: "must be a plain object" });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const ent = e;
|
|
73
|
+
if (typeof ent["id"] !== "number" || !Number.isInteger(ent["id"]) || ent["id"] < 0) {
|
|
74
|
+
errors.push({ path: `$.entities[${i}].id`, message: "must be a non-negative integer" });
|
|
75
|
+
}
|
|
76
|
+
if (typeof ent["teamId"] !== "number" || !Number.isInteger(ent["teamId"])) {
|
|
77
|
+
errors.push({ path: `$.entities[${i}].teamId`, message: "must be an integer" });
|
|
78
|
+
}
|
|
79
|
+
if (typeof ent["attributes"] !== "object" || ent["attributes"] === null) {
|
|
80
|
+
errors.push({ path: `$.entities[${i}].attributes`, message: "must be an object" });
|
|
81
|
+
}
|
|
82
|
+
if (typeof ent["energy"] !== "object" || ent["energy"] === null) {
|
|
83
|
+
errors.push({ path: `$.entities[${i}].energy`, message: "must be an object" });
|
|
84
|
+
}
|
|
85
|
+
if (typeof ent["loadout"] !== "object" || ent["loadout"] === null) {
|
|
86
|
+
errors.push({ path: `$.entities[${i}].loadout`, message: "must be an object" });
|
|
87
|
+
}
|
|
88
|
+
if (!Array.isArray(ent["traits"])) {
|
|
89
|
+
errors.push({ path: `$.entities[${i}].traits`, message: "must be an array" });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return errors;
|
|
94
|
+
}
|
|
95
|
+
/** Internal registry: `"from->to"` → migration function. */
|
|
96
|
+
const MIGRATIONS = new Map();
|
|
97
|
+
/**
|
|
98
|
+
* Register a migration function between two schema versions.
|
|
99
|
+
*
|
|
100
|
+
* Migrations are chained automatically when `migrateWorld` is called.
|
|
101
|
+
* For a simple non-breaking addition, the migration only needs to add
|
|
102
|
+
* default values for the new fields.
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* registerMigration("0.1", "0.2", snap => ({
|
|
106
|
+
* ...snap,
|
|
107
|
+
* __newField: snap["__newField"] ?? 0,
|
|
108
|
+
* }));
|
|
109
|
+
*/
|
|
110
|
+
export function registerMigration(fromVersion, toVersion, fn) {
|
|
111
|
+
MIGRATIONS.set(`${fromVersion}->${toVersion}`, fn);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Migrate a deserialized world snapshot to `toVersion` (default: current `SCHEMA_VERSION`).
|
|
115
|
+
*
|
|
116
|
+
* - If the snapshot already carries `_ananke_version === toVersion`, it is returned unchanged.
|
|
117
|
+
* - Legacy snapshots without `_ananke_version` are treated as version `"0.0"`.
|
|
118
|
+
* - Throws a descriptive error when no registered migration path exists.
|
|
119
|
+
*
|
|
120
|
+
* The snapshot is not mutated; a new object is returned.
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* const raw = JSON.parse(fs.readFileSync("save.json", "utf8"));
|
|
124
|
+
* const world = migrateWorld(raw) as WorldState;
|
|
125
|
+
*/
|
|
126
|
+
export function migrateWorld(snapshot, toVersion = SCHEMA_VERSION) {
|
|
127
|
+
const fromVersion = typeof snapshot["_ananke_version"] === "string"
|
|
128
|
+
? snapshot["_ananke_version"]
|
|
129
|
+
: "0.0";
|
|
130
|
+
if (fromVersion === toVersion)
|
|
131
|
+
return snapshot;
|
|
132
|
+
const key = `${fromVersion}->${toVersion}`;
|
|
133
|
+
const fn = MIGRATIONS.get(key);
|
|
134
|
+
if (fn === undefined) {
|
|
135
|
+
const known = [...MIGRATIONS.keys()];
|
|
136
|
+
throw new Error(`No migration from schema ${fromVersion} to ${toVersion}.` +
|
|
137
|
+
(known.length > 0 ? ` Registered paths: ${known.join(", ")}` : " No migrations registered."));
|
|
138
|
+
}
|
|
139
|
+
return fn(snapshot);
|
|
140
|
+
}
|
|
141
|
+
// ── Utilities ─────────────────────────────────────────────────────────────────
|
|
142
|
+
/**
|
|
143
|
+
* Read the `_ananke_version` stamp from a deserialized snapshot.
|
|
144
|
+
* Returns `undefined` for legacy snapshots saved before PA-3.
|
|
145
|
+
*/
|
|
146
|
+
export function detectVersion(snapshot) {
|
|
147
|
+
if (typeof snapshot !== "object" || snapshot === null)
|
|
148
|
+
return undefined;
|
|
149
|
+
const v = snapshot["_ananke_version"];
|
|
150
|
+
return typeof v === "string" ? v : undefined;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Returns `true` when the snapshot carries a valid version stamp and passes
|
|
154
|
+
* structural validation (no `ValidationError` entries).
|
|
155
|
+
*
|
|
156
|
+
* Convenience wrapper for `detectVersion` + `validateSnapshot`.
|
|
157
|
+
*/
|
|
158
|
+
export function isValidSnapshot(snapshot) {
|
|
159
|
+
if (detectVersion(snapshot) === undefined)
|
|
160
|
+
return false;
|
|
161
|
+
return validateSnapshot(snapshot).length === 0;
|
|
162
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Migration Guide: Monolith to Modular Packages
|
|
2
|
+
|
|
3
|
+
This guide covers how to migrate from `@its-not-rocket-science/ananke` to the
|
|
4
|
+
focused `@ananke/*` packages. Migration is optional — the monolith package is
|
|
5
|
+
maintained indefinitely for backwards compatibility.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Should you migrate?
|
|
10
|
+
|
|
11
|
+
| Situation | Recommendation |
|
|
12
|
+
|-----------|----------------|
|
|
13
|
+
| Existing project, everything working | Stay on the monolith. No action required. |
|
|
14
|
+
| New project, only needs combat | Start with `@ananke/combat` + `@ananke/core` |
|
|
15
|
+
| New project, full simulation stack | Use the monolith; migrate to modular when convenient |
|
|
16
|
+
| Bundle size is a concern (Phase 2+) | Migrate after Phase 2 (source migration) for real tree-shaking |
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Phase 1 Migration (stubs — available now)
|
|
21
|
+
|
|
22
|
+
Phase 1 packages are thin wrappers that re-export from the monolith. Bundle
|
|
23
|
+
size is unchanged, but import paths are cleaner and signal which API tier you
|
|
24
|
+
depend on.
|
|
25
|
+
|
|
26
|
+
### Step 1 — Install the sub-package(s) you need
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# Combat-only host
|
|
30
|
+
npm install @ananke/core @ananke/combat
|
|
31
|
+
|
|
32
|
+
# Full world simulation
|
|
33
|
+
npm install @ananke/core @ananke/combat @ananke/campaign @ananke/content
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The monolith is installed automatically as a peer dependency.
|
|
37
|
+
|
|
38
|
+
### Step 2 — Update imports
|
|
39
|
+
|
|
40
|
+
Replace monolith paths with the appropriate package name:
|
|
41
|
+
|
|
42
|
+
| Old import | New import |
|
|
43
|
+
|-----------|-----------|
|
|
44
|
+
| `@its-not-rocket-science/ananke` | `@ananke/core` |
|
|
45
|
+
| `@its-not-rocket-science/ananke/combat` | `@ananke/combat` |
|
|
46
|
+
| `@its-not-rocket-science/ananke/anatomy` | `@ananke/combat` |
|
|
47
|
+
| `@its-not-rocket-science/ananke/competence` | `@ananke/combat` |
|
|
48
|
+
| `@its-not-rocket-science/ananke/wasm-kernel` | `@ananke/combat` |
|
|
49
|
+
| `@its-not-rocket-science/ananke/species` | `@ananke/content` |
|
|
50
|
+
| `@its-not-rocket-science/ananke/catalog` | `@ananke/content` |
|
|
51
|
+
| `@its-not-rocket-science/ananke/character` | `@ananke/content` |
|
|
52
|
+
| `@its-not-rocket-science/ananke/crafting` | `@ananke/content` |
|
|
53
|
+
| `@its-not-rocket-science/ananke/campaign` | `@ananke/campaign` |
|
|
54
|
+
| `@its-not-rocket-science/ananke/polity` | `@ananke/campaign` |
|
|
55
|
+
| `@its-not-rocket-science/ananke/social` | `@ananke/campaign` |
|
|
56
|
+
| `@its-not-rocket-science/ananke/narrative` | `@ananke/campaign` |
|
|
57
|
+
| `@its-not-rocket-science/ananke/narrative-prose` | `@ananke/campaign` |
|
|
58
|
+
| `@its-not-rocket-science/ananke/renown` | `@ananke/campaign` |
|
|
59
|
+
| `@its-not-rocket-science/ananke/kinship` | `@ananke/campaign` |
|
|
60
|
+
| `@its-not-rocket-science/ananke/succession` | `@ananke/campaign` |
|
|
61
|
+
| `@its-not-rocket-science/ananke/calendar` | `@ananke/campaign` |
|
|
62
|
+
| `@its-not-rocket-science/ananke/feudal` | `@ananke/campaign` |
|
|
63
|
+
| `@its-not-rocket-science/ananke/diplomacy` | `@ananke/campaign` |
|
|
64
|
+
| `@its-not-rocket-science/ananke/migration` | `@ananke/campaign` |
|
|
65
|
+
| `@its-not-rocket-science/ananke/espionage` | `@ananke/campaign` |
|
|
66
|
+
| `@its-not-rocket-science/ananke/trade-routes` | `@ananke/campaign` |
|
|
67
|
+
| `@its-not-rocket-science/ananke/siege` | `@ananke/campaign` |
|
|
68
|
+
| `@its-not-rocket-science/ananke/faith` | `@ananke/campaign` |
|
|
69
|
+
| `@its-not-rocket-science/ananke/demography` | `@ananke/campaign` |
|
|
70
|
+
| `@its-not-rocket-science/ananke/granary` | `@ananke/campaign` |
|
|
71
|
+
| `@its-not-rocket-science/ananke/epidemic` | `@ananke/campaign` |
|
|
72
|
+
| `@its-not-rocket-science/ananke/infrastructure` | `@ananke/campaign` |
|
|
73
|
+
| `@its-not-rocket-science/ananke/unrest` | `@ananke/campaign` |
|
|
74
|
+
| `@its-not-rocket-science/ananke/research` | `@ananke/campaign` |
|
|
75
|
+
| `@its-not-rocket-science/ananke/taxation` | `@ananke/campaign` |
|
|
76
|
+
| `@its-not-rocket-science/ananke/military-campaign` | `@ananke/campaign` |
|
|
77
|
+
| `@its-not-rocket-science/ananke/governance` | `@ananke/campaign` |
|
|
78
|
+
| `@its-not-rocket-science/ananke/resources` | `@ananke/campaign` |
|
|
79
|
+
| `@its-not-rocket-science/ananke/climate` | `@ananke/campaign` |
|
|
80
|
+
| `@its-not-rocket-science/ananke/famine` | `@ananke/campaign` |
|
|
81
|
+
| `@its-not-rocket-science/ananke/containment` | `@ananke/campaign` |
|
|
82
|
+
| `@its-not-rocket-science/ananke/mercenaries` | `@ananke/campaign` |
|
|
83
|
+
| `@its-not-rocket-science/ananke/wonders` | `@ananke/campaign` |
|
|
84
|
+
| `@its-not-rocket-science/ananke/monetary` | `@ananke/campaign` |
|
|
85
|
+
|
|
86
|
+
### Step 3 — Verify
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
npm run build # or tsc --noEmit
|
|
90
|
+
npm test
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
No other changes are needed.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Phase 2 Migration (source migration — planned)
|
|
98
|
+
|
|
99
|
+
When Phase 2 lands, `@ananke/combat` will no longer depend on the monolith.
|
|
100
|
+
The import paths remain identical — only the transitive dependency graph changes.
|
|
101
|
+
|
|
102
|
+
**No code changes required in your project for Phase 2.**
|
|
103
|
+
|
|
104
|
+
Run `npm update` when the new versions are published; your bundler will
|
|
105
|
+
automatically produce a smaller output.
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Staying on the monolith
|
|
110
|
+
|
|
111
|
+
If you prefer to keep using `@its-not-rocket-science/ananke` directly, nothing
|
|
112
|
+
changes. The 41 subpath exports are stable and will not be removed.
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## See also
|
|
117
|
+
|
|
118
|
+
- [`docs/package-architecture.md`](package-architecture.md) — full design document and source file mapping
|
|
119
|
+
- [`docs/module-index.md`](module-index.md) — all exports with stability tiers and use cases
|
|
120
|
+
- [`STABLE_API.md`](../STABLE_API.md) — stable API surface (Tier 1)
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
# Ananke — Modular Package Architecture
|
|
2
|
+
|
|
3
|
+
> **Status: Phase 1 complete** — Package stubs with re-exports are published.
|
|
4
|
+
> Phase 2 (source migration) moves code into individual packages so combat has
|
|
5
|
+
> no campaign dependency at the module level.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Problem
|
|
10
|
+
|
|
11
|
+
`@its-not-rocket-science/ananke` ships 41 subpath exports in a single package.
|
|
12
|
+
A host that only needs tactical combat transitively depends on feudal succession,
|
|
13
|
+
epidemic simulation, and monetary policy. A renderer integration author has no
|
|
14
|
+
clean way to depend only on the bridge layer.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Package Overview
|
|
19
|
+
|
|
20
|
+
| Package | Stability | Description | Key entry point(s) |
|
|
21
|
+
|---------|-----------|-------------|-------------------|
|
|
22
|
+
| `@ananke/core` | **Stable** | Kernel, entity model, fixed-point units, RNG, replay | `"@ananke/core"` |
|
|
23
|
+
| `@ananke/combat` | Experimental | Combat resolution, anatomy, grapple, ranged, competence | `"@ananke/combat"` |
|
|
24
|
+
| `@ananke/campaign` | Experimental | World simulation — polity, economy, social, demography | `"@ananke/campaign"` |
|
|
25
|
+
| `@ananke/content` | Experimental | Species, equipment catalogue, archetypes, crafting | `"@ananke/content"` |
|
|
26
|
+
| `@ananke/bridge` | Experimental | Renderer bridge, interpolation, animation hints *(Phase 2)* | `"@ananke/bridge"` |
|
|
27
|
+
| `@its-not-rocket-science/ananke` | **Meta-package** | Re-exports all of the above for backwards compatibility | unchanged |
|
|
28
|
+
|
|
29
|
+
> `@ananke/bridge` is not yet a standalone stub — bridge exports are part of
|
|
30
|
+
> `@ananke/core` until Phase 2 adds a dedicated `"./bridge"` subpath to the
|
|
31
|
+
> monolith.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Package Dependency Graph
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
@ananke/core
|
|
39
|
+
│
|
|
40
|
+
├── @ananke/combat (peer: @ananke/core)
|
|
41
|
+
├── @ananke/campaign (peer: @ananke/core)
|
|
42
|
+
├── @ananke/content (peer: @ananke/core)
|
|
43
|
+
└── @ananke/bridge (peer: @ananke/core) [Phase 2]
|
|
44
|
+
|
|
45
|
+
@its-not-rocket-science/ananke (meta: re-exports all four)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Monolith Subpath → Package Mapping
|
|
51
|
+
|
|
52
|
+
### @ananke/core
|
|
53
|
+
|
|
54
|
+
| Monolith subpath | Notes |
|
|
55
|
+
|-----------------|-------|
|
|
56
|
+
| `"."` | Entire main export — kernel, entity, units, RNG, replay, bridge |
|
|
57
|
+
|
|
58
|
+
### @ananke/combat
|
|
59
|
+
|
|
60
|
+
| Monolith subpath | Notes |
|
|
61
|
+
|-----------------|-------|
|
|
62
|
+
| `"./combat"` | resolveHit, resolveBlock, CombatContext |
|
|
63
|
+
| `"./anatomy"` | BodyPlan, AnatomyRegion, injury regions |
|
|
64
|
+
| `"./competence"` | skill contest resolution, interspecies signalling |
|
|
65
|
+
| `"./wasm-kernel"` | WASM-accelerated combat math |
|
|
66
|
+
|
|
67
|
+
### @ananke/campaign
|
|
68
|
+
|
|
69
|
+
| Monolith subpath | Notes |
|
|
70
|
+
|-----------------|-------|
|
|
71
|
+
| `"./campaign"` | Campaign layer, strategic tick |
|
|
72
|
+
| `"./polity"` | Polity, stepPolityDay, tech diffusion |
|
|
73
|
+
| `"./social"` | Social relationships, dialogue |
|
|
74
|
+
| `"./narrative"` | Narrative event system |
|
|
75
|
+
| `"./narrative-prose"` | Prose generation |
|
|
76
|
+
| `"./renown"` | Fame and reputation |
|
|
77
|
+
| `"./kinship"` | Family trees, genealogy |
|
|
78
|
+
| `"./succession"` | Inheritance rules |
|
|
79
|
+
| `"./calendar"` | In-world calendar and date tracking |
|
|
80
|
+
| `"./feudal"` | Feudal hierarchy |
|
|
81
|
+
| `"./diplomacy"` | Treaties and diplomatic acts |
|
|
82
|
+
| `"./migration"` | Population movement |
|
|
83
|
+
| `"./espionage"` | Espionage and spycraft |
|
|
84
|
+
| `"./trade-routes"` | Trade route simulation |
|
|
85
|
+
| `"./siege"` | Siege warfare mechanics |
|
|
86
|
+
| `"./faith"` | Religion and doctrine |
|
|
87
|
+
| `"./demography"` | Population simulation |
|
|
88
|
+
| `"./granary"` | Food storage and distribution |
|
|
89
|
+
| `"./epidemic"` | Disease spread |
|
|
90
|
+
| `"./infrastructure"` | Buildings and construction |
|
|
91
|
+
| `"./unrest"` | Civil unrest |
|
|
92
|
+
| `"./research"` | Technology research |
|
|
93
|
+
| `"./taxation"` | Tax collection |
|
|
94
|
+
| `"./military-campaign"` | Military campaign mechanics |
|
|
95
|
+
| `"./governance"` | Governance and edicts |
|
|
96
|
+
| `"./resources"` | Resource management |
|
|
97
|
+
| `"./climate"` | Climate and weather effects |
|
|
98
|
+
| `"./famine"` | Famine simulation |
|
|
99
|
+
| `"./containment"` | Disease containment |
|
|
100
|
+
| `"./mercenaries"` | Mercenary companies |
|
|
101
|
+
| `"./wonders"` | Wonders and monuments |
|
|
102
|
+
| `"./monetary"` | Monetary policy and currency |
|
|
103
|
+
|
|
104
|
+
### @ananke/content
|
|
105
|
+
|
|
106
|
+
| Monolith subpath | Notes |
|
|
107
|
+
|-----------------|-------|
|
|
108
|
+
| `"./species"` | Species definitions, stat profiles |
|
|
109
|
+
| `"./catalog"` | Equipment and item catalogue |
|
|
110
|
+
| `"./character"` | Character generation and archetypes |
|
|
111
|
+
| `"./crafting"` | Crafting recipes, workshops, manufacturing |
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Source File → Package Mapping (Phase 2 migration)
|
|
116
|
+
|
|
117
|
+
### @ananke/core
|
|
118
|
+
```
|
|
119
|
+
src/units.ts
|
|
120
|
+
src/rng.ts
|
|
121
|
+
src/types.ts
|
|
122
|
+
src/replay.ts
|
|
123
|
+
src/sim/entity.ts
|
|
124
|
+
src/sim/kernel.ts
|
|
125
|
+
src/sim/seeds.ts
|
|
126
|
+
src/sim/world.ts
|
|
127
|
+
src/sim/kinds.ts
|
|
128
|
+
src/sim/condition.ts
|
|
129
|
+
src/sim/body.ts
|
|
130
|
+
src/sim/bodyplan.ts
|
|
131
|
+
src/sim/limb.ts
|
|
132
|
+
src/sim/tick.ts
|
|
133
|
+
src/sim/indexing.ts
|
|
134
|
+
src/sim/events.ts
|
|
135
|
+
src/sim/commands.ts
|
|
136
|
+
src/sim/commandBuilders.ts
|
|
137
|
+
src/sim/context.ts
|
|
138
|
+
src/sim/intent.ts
|
|
139
|
+
src/sim/vec3.ts
|
|
140
|
+
src/sim/spatial.ts
|
|
141
|
+
src/sim/skills.ts
|
|
142
|
+
src/sim/traits.ts
|
|
143
|
+
src/sim/terrain.ts
|
|
144
|
+
src/sim/action.ts
|
|
145
|
+
src/sim/step/ (all 10 step files)
|
|
146
|
+
src/bridge/ (all 5 bridge files)
|
|
147
|
+
src/presets.ts
|
|
148
|
+
src/generate.ts
|
|
149
|
+
src/derive.ts
|
|
150
|
+
src/describe.ts
|
|
151
|
+
src/traits.ts
|
|
152
|
+
src/metrics.ts
|
|
153
|
+
src/dist.ts
|
|
154
|
+
src/wasm-kernel.ts
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### @ananke/combat
|
|
158
|
+
```
|
|
159
|
+
src/sim/combat.ts
|
|
160
|
+
src/sim/injury.ts
|
|
161
|
+
src/sim/wound-aging.ts
|
|
162
|
+
src/sim/medical.ts
|
|
163
|
+
src/sim/morale.ts
|
|
164
|
+
src/sim/grapple.ts
|
|
165
|
+
src/sim/ranged.ts
|
|
166
|
+
src/sim/stamina.ts (if present)
|
|
167
|
+
src/sim/impairment.ts
|
|
168
|
+
src/sim/knockback.ts
|
|
169
|
+
src/sim/cover.ts
|
|
170
|
+
src/sim/cone.ts
|
|
171
|
+
src/sim/formation.ts
|
|
172
|
+
src/sim/formation-combat.ts
|
|
173
|
+
src/sim/formation-unit.ts
|
|
174
|
+
src/sim/frontage.ts
|
|
175
|
+
src/sim/density.ts
|
|
176
|
+
src/sim/occlusion.ts
|
|
177
|
+
src/sim/ai/ (all 8 AI files)
|
|
178
|
+
src/combat.ts
|
|
179
|
+
src/equipment.ts
|
|
180
|
+
src/weapons.ts
|
|
181
|
+
src/anatomy/ (all 5 anatomy files)
|
|
182
|
+
src/competence/ (all 13 competence files)
|
|
183
|
+
src/arena.ts
|
|
184
|
+
src/dialogue.ts
|
|
185
|
+
src/party.ts
|
|
186
|
+
src/faction.ts
|
|
187
|
+
src/downtime.ts
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### @ananke/campaign
|
|
191
|
+
```
|
|
192
|
+
src/campaign.ts
|
|
193
|
+
src/campaign-layer.ts
|
|
194
|
+
src/polity.ts
|
|
195
|
+
src/polity-vassals.ts
|
|
196
|
+
src/social.ts
|
|
197
|
+
src/relationships.ts
|
|
198
|
+
src/relationships-effects.ts
|
|
199
|
+
src/emotional-contagion.ts
|
|
200
|
+
src/narrative.ts
|
|
201
|
+
src/narrative-layer.ts
|
|
202
|
+
src/narrative-prose.ts
|
|
203
|
+
src/narrative-render.ts
|
|
204
|
+
src/narrative-stress.ts
|
|
205
|
+
src/story-arcs.ts
|
|
206
|
+
src/quest.ts
|
|
207
|
+
src/quest-generators.ts
|
|
208
|
+
src/chronicle.ts
|
|
209
|
+
src/legend.ts
|
|
210
|
+
src/mythology.ts
|
|
211
|
+
src/renown.ts
|
|
212
|
+
src/kinship.ts
|
|
213
|
+
src/succession.ts
|
|
214
|
+
src/calendar.ts
|
|
215
|
+
src/feudal.ts
|
|
216
|
+
src/diplomacy.ts
|
|
217
|
+
src/migration.ts
|
|
218
|
+
src/espionage.ts
|
|
219
|
+
src/trade-routes.ts
|
|
220
|
+
src/siege.ts
|
|
221
|
+
src/faith.ts
|
|
222
|
+
src/demography.ts
|
|
223
|
+
src/granary.ts
|
|
224
|
+
src/epidemic.ts
|
|
225
|
+
src/infrastructure.ts
|
|
226
|
+
src/unrest.ts
|
|
227
|
+
src/research.ts
|
|
228
|
+
src/taxation.ts
|
|
229
|
+
src/military-campaign.ts
|
|
230
|
+
src/governance.ts
|
|
231
|
+
src/resources.ts
|
|
232
|
+
src/climate.ts
|
|
233
|
+
src/famine.ts
|
|
234
|
+
src/containment.ts
|
|
235
|
+
src/mercenaries.ts
|
|
236
|
+
src/wonders.ts
|
|
237
|
+
src/monetary.ts
|
|
238
|
+
src/collective-activities.ts
|
|
239
|
+
src/economy.ts
|
|
240
|
+
src/economy-gen.ts
|
|
241
|
+
src/tech-diffusion.ts
|
|
242
|
+
src/culture.ts
|
|
243
|
+
src/settlement.ts
|
|
244
|
+
src/settlement-services.ts
|
|
245
|
+
src/channels.ts
|
|
246
|
+
src/inheritance.ts
|
|
247
|
+
src/progression.ts
|
|
248
|
+
src/sim/disease.ts
|
|
249
|
+
src/sim/aging.ts
|
|
250
|
+
src/sim/sleep.ts
|
|
251
|
+
src/sim/mount.ts
|
|
252
|
+
src/sim/hazard.ts
|
|
253
|
+
src/sim/nutrition.ts
|
|
254
|
+
src/sim/thermoregulation.ts
|
|
255
|
+
src/sim/toxicology.ts
|
|
256
|
+
src/sim/systemic-toxicology.ts
|
|
257
|
+
src/sim/substance.ts
|
|
258
|
+
src/sim/weather.ts
|
|
259
|
+
src/sim/biome.ts
|
|
260
|
+
src/sim/tech.ts
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### @ananke/content
|
|
264
|
+
```
|
|
265
|
+
src/species.ts
|
|
266
|
+
src/catalog.ts
|
|
267
|
+
src/character.ts
|
|
268
|
+
src/archetypes.ts
|
|
269
|
+
src/crafting/ (all 5 crafting files)
|
|
270
|
+
src/inventory.ts
|
|
271
|
+
src/item-durability.ts
|
|
272
|
+
src/snapshot.ts
|
|
273
|
+
src/world-generation.ts
|
|
274
|
+
src/world-factory.ts
|
|
275
|
+
src/scenario.ts
|
|
276
|
+
src/modding.ts
|
|
277
|
+
src/lod.ts
|
|
278
|
+
src/model3d.ts
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
## Phase 2: Source Migration Plan
|
|
284
|
+
|
|
285
|
+
1. **Create workspace package directories** with their own `tsconfig.build.json`.
|
|
286
|
+
2. **Move source files** from `src/` into `packages/NAME/src/` following the table above.
|
|
287
|
+
3. **Update internal imports** — use `@ananke/core` etc. instead of relative paths that cross
|
|
288
|
+
package boundaries.
|
|
289
|
+
4. **Wire inter-package dependencies** — `@ananke/combat` lists `@ananke/core` as a dependency.
|
|
290
|
+
5. **Update the monolith meta-package** (`@its-not-rocket-science/ananke`) to re-export from
|
|
291
|
+
the five sub-packages instead of from `src/`.
|
|
292
|
+
6. **Verify** that all existing tests pass without modification (test paths remain unchanged).
|
|
293
|
+
|
|
294
|
+
The most complex step is (3) — identifying which imports cross package boundaries. A planned
|
|
295
|
+
tool (`tools/check-package-boundaries.ts`) will analyse the import graph and report violations.
|
|
296
|
+
|
|
297
|
+
---
|
|
298
|
+
|
|
299
|
+
## What Changes for Package Consumers
|
|
300
|
+
|
|
301
|
+
### Phase 1 (now — stubs)
|
|
302
|
+
```typescript
|
|
303
|
+
// Before (monolith)
|
|
304
|
+
import { resolveHit } from "@its-not-rocket-science/ananke/combat";
|
|
305
|
+
|
|
306
|
+
// After (modular stub — same bundle size, cleaner import path)
|
|
307
|
+
import { resolveHit } from "@ananke/combat";
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
### Phase 2 (source migration — smaller bundles)
|
|
311
|
+
```typescript
|
|
312
|
+
// Same import — but now @ananke/combat has no campaign dependency
|
|
313
|
+
import { resolveHit } from "@ananke/combat";
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
The import path is the same in Phase 1 and Phase 2; only the bundle contents change.
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
## Backwards Compatibility
|
|
321
|
+
|
|
322
|
+
`@its-not-rocket-science/ananke` will remain published indefinitely as a meta-package.
|
|
323
|
+
All 41 subpath exports will continue to work. Existing hosts do not need to migrate.
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# Ananke — Wire Protocol & Save Format
|
|
2
|
+
|
|
3
|
+
This document specifies how Ananke state is serialised for persistence, replay, and
|
|
4
|
+
network transport. All formats are deterministic: the same simulation state always
|
|
5
|
+
produces the same bytes.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 1. Concepts
|
|
10
|
+
|
|
11
|
+
| Term | Meaning |
|
|
12
|
+
|------|---------|
|
|
13
|
+
| **Snapshot** | A serialised `WorldState` — complete enough to resume simulation |
|
|
14
|
+
| **Replay** | An initial snapshot + a sequence of command frames |
|
|
15
|
+
| **Diff** | A compact binary diff between two consecutive snapshots (CE-9) |
|
|
16
|
+
| **Wire message** | A single unit transmitted between host and client over the network |
|
|
17
|
+
| **Q value** | A fixed-point integer scaled by `SCALE.Q = 10 000` (e.g. `q(0.75) = 7500`) |
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## 2. JSON Snapshot Format
|
|
22
|
+
|
|
23
|
+
JSON is the recommended format for long-term save files and editor tooling.
|
|
24
|
+
|
|
25
|
+
### 2.1 Deterministic key ordering
|
|
26
|
+
|
|
27
|
+
When computing hash-checks across clients, keys must appear in insertion order.
|
|
28
|
+
The canonical TypeScript implementation (`JSON.stringify`) preserves insertion
|
|
29
|
+
order for string keys. Third-party deserializers must preserve or sort keys
|
|
30
|
+
identically.
|
|
31
|
+
|
|
32
|
+
### 2.2 Q values
|
|
33
|
+
|
|
34
|
+
All `Q`-typed fields are serialised as plain integers. Do **not** divide by
|
|
35
|
+
`SCALE.Q` before saving — the raw integer is the canonical representation.
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{ "fearQ": 7500 } // correct — q(0.75)
|
|
39
|
+
{ "fearQ": 0.75 } // WRONG — will cause precision loss and replay divergence
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### 2.3 Maps
|
|
43
|
+
|
|
44
|
+
JavaScript `Map` instances do not serialise to JSON automatically. Ananke
|
|
45
|
+
serialises `Map<K, V>` as an array of `[K, V]` pairs:
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{ "__nutritionAccum": 0 }
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
> Note: `__nutritionAccum` was simplified to a scalar in v0.1. If a `Map`
|
|
52
|
+
> field is added in a future version, its pairs will use the array format above.
|
|
53
|
+
|
|
54
|
+
### 2.4 Version stamping
|
|
55
|
+
|
|
56
|
+
Always call `stampSnapshot(world, "world")` before persisting. This adds
|
|
57
|
+
`_ananke_version` and `_schema` fields that enable forward migration:
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
import { stampSnapshot } from "@its-not-rocket-science/ananke/schema";
|
|
61
|
+
// or: import { stampSnapshot } from "@ananke/core"; (when published)
|
|
62
|
+
|
|
63
|
+
const save = JSON.stringify(stampSnapshot(world, "world"), null, 2);
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### 2.5 JSON Schema files
|
|
67
|
+
|
|
68
|
+
Canonical schemas ship with the package:
|
|
69
|
+
|
|
70
|
+
| File | Validates |
|
|
71
|
+
|------|-----------|
|
|
72
|
+
| `schema/world.schema.json` | `WorldState` snapshots |
|
|
73
|
+
| `schema/replay.schema.json` | `Replay` objects |
|
|
74
|
+
|
|
75
|
+
Use `validateSnapshot(raw)` from `@its-not-rocket-science/ananke/schema` to
|
|
76
|
+
check conformance programmatically before calling `stepWorld`.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## 3. Binary Diff Format
|
|
81
|
+
|
|
82
|
+
For tick-to-tick state synchronisation (multiplayer, streaming), use the binary
|
|
83
|
+
diff format implemented in `src/snapshot.ts`.
|
|
84
|
+
|
|
85
|
+
### 3.1 Encoding
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
[magic: "ANKD" (4 bytes)] [version: 1 (u8)] [payload: tag-value stream]
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Tag values:
|
|
92
|
+
|
|
93
|
+
| Tag | Byte | Encodes |
|
|
94
|
+
|-----|------|---------|
|
|
95
|
+
| NULL | 0x00 | `null` |
|
|
96
|
+
| TRUE | 0x01 | `true` |
|
|
97
|
+
| FALSE | 0x02 | `false` |
|
|
98
|
+
| UINT8 | 0x10 | Unsigned integer 0–255 |
|
|
99
|
+
| INT32 | 0x11 | Signed 32-bit integer (big-endian) |
|
|
100
|
+
| FLOAT64 | 0x12 | IEEE 754 double (big-endian) — use only for non-Q floats |
|
|
101
|
+
| STRING | 0x20 | Length-prefixed UTF-8 |
|
|
102
|
+
| ARRAY | 0x30 | Length-prefixed sequence of tag-value items |
|
|
103
|
+
| OBJECT | 0x40 | Length-prefixed sequence of (string key, tag-value) pairs |
|
|
104
|
+
|
|
105
|
+
### 3.2 Usage
|
|
106
|
+
|
|
107
|
+
```typescript
|
|
108
|
+
import { diffWorldState, packDiff, unpackDiff, applyDiff } from "@its-not-rocket-science/ananke";
|
|
109
|
+
|
|
110
|
+
// Sender
|
|
111
|
+
const diff = diffWorldState(prevState, nextState);
|
|
112
|
+
const bytes = packDiff(diff);
|
|
113
|
+
socket.send(bytes);
|
|
114
|
+
|
|
115
|
+
// Receiver
|
|
116
|
+
const diff2 = unpackDiff(bytes);
|
|
117
|
+
const state2 = applyDiff(prevState, diff2);
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### 3.3 Determinism guarantee
|
|
121
|
+
|
|
122
|
+
A diff produced from identical states must produce identical bytes. Do not
|
|
123
|
+
include wall-clock timestamps or random nonces in diff payloads.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## 4. Multiplayer Message Protocol
|
|
128
|
+
|
|
129
|
+
For lockstep multiplayer, hosts exchange command frames rather than full state.
|
|
130
|
+
|
|
131
|
+
### 4.1 Message types
|
|
132
|
+
|
|
133
|
+
| `kind` | Direction | Payload |
|
|
134
|
+
|--------|-----------|---------|
|
|
135
|
+
| `"cmd"` | Client → Server | `{ tick, commands: Command[] }` |
|
|
136
|
+
| `"ack"` | Server → Client | `{ tick, stateHash: number }` |
|
|
137
|
+
| `"resync"` | Server → Client | `{ tick, snapshot: WorldState }` |
|
|
138
|
+
| `"hash_mismatch"` | Server → Client | `{ tick, expected: number, got: number }` |
|
|
139
|
+
|
|
140
|
+
### 4.2 State hash
|
|
141
|
+
|
|
142
|
+
Use the built-in tick counter and entity count as a cheap hash for divergence
|
|
143
|
+
detection:
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
function stateHash(world: WorldState): number {
|
|
147
|
+
return world.tick * 0x10000 + (world.entities.length & 0xFFFF);
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
A full structural hash is more robust but expensive; use it only on resync.
|
|
152
|
+
|
|
153
|
+
### 4.3 Lockstep loop
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
┌──────────────────────────────────────────────────────────┐
|
|
157
|
+
│ Client Server │
|
|
158
|
+
│ │
|
|
159
|
+
│ collect commands ──── cmd ──► apply to authoritative │
|
|
160
|
+
│ state │
|
|
161
|
+
│ ◄── ack ─── broadcast stateHash │
|
|
162
|
+
│ verify hash │
|
|
163
|
+
│ if mismatch ─── resync req ─► send full snapshot │
|
|
164
|
+
│ ◄── resync ── │
|
|
165
|
+
│ restore snapshot │
|
|
166
|
+
└──────────────────────────────────────────────────────────┘
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### 4.4 Transport encoding
|
|
170
|
+
|
|
171
|
+
Use JSON for development and debugging. For production, encode wire messages
|
|
172
|
+
as CBOR (RFC 8949) or MessagePack for ~30% size reduction. The message
|
|
173
|
+
structure is identical; only the outer encoding changes.
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## 5. Save File Recommendations
|
|
178
|
+
|
|
179
|
+
| Scenario | Format | Compression |
|
|
180
|
+
|----------|--------|-------------|
|
|
181
|
+
| Development / debugging | JSON (pretty-printed) | none |
|
|
182
|
+
| Production saves | JSON (compact) | gzip or zstd |
|
|
183
|
+
| Network sync (full state) | JSON or CBOR | none (already compact) |
|
|
184
|
+
| Network sync (incremental) | Binary diff (`packDiff`) | none |
|
|
185
|
+
| Replay archives | JSON replay schema | zstd |
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## 6. Migration
|
|
190
|
+
|
|
191
|
+
Load a save and bring it to the current schema version before simulating:
|
|
192
|
+
|
|
193
|
+
```typescript
|
|
194
|
+
import {
|
|
195
|
+
migrateWorld, validateSnapshot, stampSnapshot,
|
|
196
|
+
} from "@its-not-rocket-science/ananke/schema";
|
|
197
|
+
|
|
198
|
+
function loadSave(json: string): WorldState {
|
|
199
|
+
const raw = JSON.parse(json) as Record<string, unknown>;
|
|
200
|
+
const migrated = migrateWorld(raw); // no-op until 0.2 is released
|
|
201
|
+
const errors = validateSnapshot(migrated);
|
|
202
|
+
if (errors.length > 0) {
|
|
203
|
+
throw new Error(`Invalid save: ${errors.map(e => `${e.path}: ${e.message}`).join("; ")}`);
|
|
204
|
+
}
|
|
205
|
+
return migrated as WorldState;
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
See `docs/migration-monolith-to-modular.md` for package-level migration guidance.
|
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.52",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deterministic lockstep-friendly SI-units RPG/physics core (fixed-point TS)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -170,8 +170,15 @@
|
|
|
170
170
|
"./monetary": {
|
|
171
171
|
"import": "./dist/src/monetary.js",
|
|
172
172
|
"types": "./dist/src/monetary.d.ts"
|
|
173
|
+
},
|
|
174
|
+
"./schema": {
|
|
175
|
+
"import": "./dist/src/schema-migration.js",
|
|
176
|
+
"types": "./dist/src/schema-migration.d.ts"
|
|
173
177
|
}
|
|
174
178
|
},
|
|
179
|
+
"workspaces": [
|
|
180
|
+
"packages/*"
|
|
181
|
+
],
|
|
175
182
|
"files": [
|
|
176
183
|
"dist/src",
|
|
177
184
|
"dist/as",
|
|
@@ -182,6 +189,11 @@
|
|
|
182
189
|
"docs/performance.md",
|
|
183
190
|
"docs/versioning.md",
|
|
184
191
|
"docs/emergent-validation-report.md",
|
|
192
|
+
"docs/package-architecture.md",
|
|
193
|
+
"docs/migration-monolith-to-modular.md",
|
|
194
|
+
"docs/wire-protocol.md",
|
|
195
|
+
"schema/world.schema.json",
|
|
196
|
+
"schema/replay.schema.json",
|
|
185
197
|
"CHANGELOG.md",
|
|
186
198
|
"STABLE_API.md"
|
|
187
199
|
],
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://its-not-rocket-science.github.io/ananke/schema/replay.schema.json",
|
|
4
|
+
"title": "Replay",
|
|
5
|
+
"description": "Ananke deterministic replay — an initial WorldState plus a sequence of command frames. Replaying all frames against the initial state must reproduce the exact final state (same tick, same entity conditions, same RNG sequence).",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["initialState", "frames"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"_ananke_version": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"pattern": "^\\d+\\.\\d+$",
|
|
12
|
+
"description": "Schema version stamped by stampSnapshot()."
|
|
13
|
+
},
|
|
14
|
+
"_schema": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"const": "replay",
|
|
17
|
+
"description": "Schema discrimination tag — always \"replay\" for Replay snapshots."
|
|
18
|
+
},
|
|
19
|
+
"initialState": {
|
|
20
|
+
"$ref": "world.schema.json",
|
|
21
|
+
"description": "The WorldState at tick 0 (or whatever tick the recording began). Must itself be a valid world snapshot."
|
|
22
|
+
},
|
|
23
|
+
"frames": {
|
|
24
|
+
"type": "array",
|
|
25
|
+
"items": { "$ref": "#/$defs/ReplayFrame" },
|
|
26
|
+
"description": "Ordered sequence of command frames. Frame indices correspond to ticks relative to initialState.tick."
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"unevaluatedProperties": true,
|
|
30
|
+
"$defs": {
|
|
31
|
+
"ReplayFrame": {
|
|
32
|
+
"title": "ReplayFrame",
|
|
33
|
+
"type": "object",
|
|
34
|
+
"required": ["tick", "commands"],
|
|
35
|
+
"properties": {
|
|
36
|
+
"tick": {
|
|
37
|
+
"type": "integer",
|
|
38
|
+
"minimum": 0,
|
|
39
|
+
"description": "Absolute simulation tick this frame was recorded at."
|
|
40
|
+
},
|
|
41
|
+
"commands": {
|
|
42
|
+
"type": "array",
|
|
43
|
+
"items": { "$ref": "#/$defs/Command" },
|
|
44
|
+
"description": "Commands dispatched during this tick. May be empty."
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"Command": {
|
|
49
|
+
"title": "Command",
|
|
50
|
+
"description": "A single world-mutating command dispatched to stepWorld(). Commands are discriminated by their `kind` field.",
|
|
51
|
+
"type": "object",
|
|
52
|
+
"required": ["kind"],
|
|
53
|
+
"properties": {
|
|
54
|
+
"kind": {
|
|
55
|
+
"type": "string",
|
|
56
|
+
"description": "Command type identifier, e.g. \"attack\", \"move\", \"grapple\"."
|
|
57
|
+
},
|
|
58
|
+
"entityId": {
|
|
59
|
+
"type": "integer",
|
|
60
|
+
"description": "Issuing entity id (where applicable)."
|
|
61
|
+
},
|
|
62
|
+
"targetId": {
|
|
63
|
+
"type": "integer",
|
|
64
|
+
"description": "Target entity id (where applicable)."
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"unevaluatedProperties": true
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://its-not-rocket-science.github.io/ananke/schema/world.schema.json",
|
|
4
|
+
"title": "WorldState",
|
|
5
|
+
"description": "Ananke world snapshot — the complete state required to resume or replay a simulation. See docs/wire-protocol.md for serialisation rules.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["tick", "seed", "entities"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"_ananke_version": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"pattern": "^\\d+\\.\\d+$",
|
|
12
|
+
"description": "Schema version stamped by stampSnapshot(), e.g. \"0.1\". Absent on legacy saves."
|
|
13
|
+
},
|
|
14
|
+
"_schema": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"const": "world",
|
|
17
|
+
"description": "Schema discrimination tag — always \"world\" for WorldState snapshots."
|
|
18
|
+
},
|
|
19
|
+
"tick": {
|
|
20
|
+
"type": "integer",
|
|
21
|
+
"minimum": 0,
|
|
22
|
+
"description": "@core — Current simulation tick. Incremented by stepWorld() each call."
|
|
23
|
+
},
|
|
24
|
+
"seed": {
|
|
25
|
+
"type": "integer",
|
|
26
|
+
"description": "@core — Deterministic RNG seed. Same seed + same commands → identical output."
|
|
27
|
+
},
|
|
28
|
+
"entities": {
|
|
29
|
+
"type": "array",
|
|
30
|
+
"items": { "$ref": "#/$defs/EntityCore" },
|
|
31
|
+
"description": "@core — All live and dead entities."
|
|
32
|
+
},
|
|
33
|
+
"activeFieldEffects": {
|
|
34
|
+
"type": "array",
|
|
35
|
+
"items": { "type": "object" },
|
|
36
|
+
"description": "@subsystem(capability) — Active suppression zones and field-effect modifiers."
|
|
37
|
+
},
|
|
38
|
+
"__sensoryEnv": {
|
|
39
|
+
"type": "object",
|
|
40
|
+
"description": "@subsystem(sensory) — Ambient lighting and visibility environment."
|
|
41
|
+
},
|
|
42
|
+
"__factionRegistry": {
|
|
43
|
+
"type": "object",
|
|
44
|
+
"description": "@subsystem(faction) — Global faction standing registry."
|
|
45
|
+
},
|
|
46
|
+
"__partyRegistry": {
|
|
47
|
+
"type": "object",
|
|
48
|
+
"description": "@subsystem(party) — Global party registry for morale and formation bonuses."
|
|
49
|
+
},
|
|
50
|
+
"__relationshipGraph": {
|
|
51
|
+
"type": "object",
|
|
52
|
+
"description": "@subsystem(relationships) — Inter-entity relationship graph."
|
|
53
|
+
},
|
|
54
|
+
"__nutritionAccum": {
|
|
55
|
+
"type": "number",
|
|
56
|
+
"description": "@subsystem(nutrition) — Cross-tick nutrition accumulator (scalar)."
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"unevaluatedProperties": true,
|
|
60
|
+
"$defs": {
|
|
61
|
+
"EntityCore": {
|
|
62
|
+
"title": "Entity (@core fields)",
|
|
63
|
+
"description": "Core entity fields required by stepWorld() every tick. Subsystem and host-extension fields are permitted but not listed here — see src/sim/entity.ts.",
|
|
64
|
+
"type": "object",
|
|
65
|
+
"required": ["id", "teamId", "attributes", "energy", "loadout", "traits"],
|
|
66
|
+
"properties": {
|
|
67
|
+
"id": {
|
|
68
|
+
"type": "integer",
|
|
69
|
+
"minimum": 0,
|
|
70
|
+
"description": "Unique entity identifier (positive I32)."
|
|
71
|
+
},
|
|
72
|
+
"teamId": {
|
|
73
|
+
"type": "integer",
|
|
74
|
+
"description": "Team allegiance. Entities on the same teamId do not auto-target each other."
|
|
75
|
+
},
|
|
76
|
+
"attributes": {
|
|
77
|
+
"type": "object",
|
|
78
|
+
"description": "IndividualAttributes — physical and cognitive stats. All Q values are fixed-point integers scaled by SCALE.Q = 10 000."
|
|
79
|
+
},
|
|
80
|
+
"energy": {
|
|
81
|
+
"type": "object",
|
|
82
|
+
"description": "EnergyState — current stamina and metabolic state."
|
|
83
|
+
},
|
|
84
|
+
"loadout": {
|
|
85
|
+
"type": "object",
|
|
86
|
+
"description": "Loadout — equipped weapons and armour."
|
|
87
|
+
},
|
|
88
|
+
"traits": {
|
|
89
|
+
"type": "array",
|
|
90
|
+
"items": { "type": "string" },
|
|
91
|
+
"description": "Trait string identifiers, e.g. [\"leader\", \"berserker\"]."
|
|
92
|
+
},
|
|
93
|
+
"condition": {
|
|
94
|
+
"type": "object",
|
|
95
|
+
"description": "@subsystem(condition) — fear, morale, consciousness, surrender state."
|
|
96
|
+
},
|
|
97
|
+
"injury": {
|
|
98
|
+
"type": "object",
|
|
99
|
+
"description": "@subsystem(injury) — per-region damage fractions (all Q values)."
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
"unevaluatedProperties": true
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|