@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 +15 -0
- package/dist/src/schema-migration.d.ts +90 -0
- package/dist/src/schema-migration.js +162 -0
- package/docs/wire-protocol.md +209 -0
- package/package.json +8 -1
- package/schema/replay.schema.json +70 -0
- package/schema/world.schema.json +105 -0
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.
|
|
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
|
+
}
|