@its-not-rocket-science/ananke 0.1.51 → 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 CHANGED
@@ -6,6 +6,21 @@ 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
+
9
24
  ## [0.1.51] — 2026-03-28
10
25
 
11
26
  ### Added
@@ -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,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.51",
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,6 +170,10 @@
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
  },
175
179
  "workspaces": [
@@ -187,6 +191,9 @@
187
191
  "docs/emergent-validation-report.md",
188
192
  "docs/package-architecture.md",
189
193
  "docs/migration-monolith-to-modular.md",
194
+ "docs/wire-protocol.md",
195
+ "schema/world.schema.json",
196
+ "schema/replay.schema.json",
190
197
  "CHANGELOG.md",
191
198
  "STABLE_API.md"
192
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
+ }