@its-not-rocket-science/ananke 0.1.51 → 0.1.53
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 +32 -0
- package/dist/src/content-pack.d.ts +105 -0
- package/dist/src/content-pack.js +261 -0
- package/dist/src/schema-migration.d.ts +90 -0
- package/dist/src/schema-migration.js +162 -0
- package/dist/src/world-factory.d.ts +16 -0
- package/dist/src/world-factory.js +32 -5
- package/dist/tools/pack-cli.js +155 -0
- package/docs/wire-protocol.md +209 -0
- package/package.json +17 -1
- package/schema/pack.schema.json +138 -0
- package/schema/replay.schema.json +70 -0
- package/schema/world.schema.json +105 -0
|
@@ -60,6 +60,33 @@ function buildItemMap() {
|
|
|
60
60
|
}
|
|
61
61
|
/** Map of item id → Item for weapons and armour usable with createWorld(). */
|
|
62
62
|
export const ITEM_MAP = buildItemMap();
|
|
63
|
+
// ── Content-pack extension registries ────────────────────────────────────────
|
|
64
|
+
// Dynamic additions from loadPack(); checked after the static maps.
|
|
65
|
+
const _archetypeExtensions = new Map();
|
|
66
|
+
const _itemExtensions = new Map();
|
|
67
|
+
/**
|
|
68
|
+
* Register an archetype so it is resolvable by `createWorld` and `loadScenario`.
|
|
69
|
+
* Called automatically by `loadPack` in `content-pack.ts`.
|
|
70
|
+
*/
|
|
71
|
+
export function registerWorldArchetype(id, archetype) {
|
|
72
|
+
_archetypeExtensions.set(id, archetype);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Register a weapon or armour so it is resolvable by `createWorld` and `loadScenario`.
|
|
76
|
+
* Called automatically by `loadPack` in `content-pack.ts`.
|
|
77
|
+
*/
|
|
78
|
+
export function registerWorldItem(id, item) {
|
|
79
|
+
_itemExtensions.set(id, item);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Remove all content-pack extensions from the world-factory lookup tables.
|
|
83
|
+
* Does NOT affect the static `ARCHETYPE_MAP` or `ITEM_MAP`.
|
|
84
|
+
* Call in test `afterEach` alongside `clearCatalog()` and `clearPackRegistry()`.
|
|
85
|
+
*/
|
|
86
|
+
export function clearWorldExtensions() {
|
|
87
|
+
_archetypeExtensions.clear();
|
|
88
|
+
_itemExtensions.clear();
|
|
89
|
+
}
|
|
63
90
|
// ── createWorld ───────────────────────────────────────────────────────────────
|
|
64
91
|
/**
|
|
65
92
|
* Build a deterministic WorldState from a declarative entity spec list.
|
|
@@ -72,21 +99,21 @@ export const ITEM_MAP = buildItemMap();
|
|
|
72
99
|
export function createWorld(seed, entities) {
|
|
73
100
|
const built = [];
|
|
74
101
|
for (const spec of entities) {
|
|
75
|
-
// ── Archetype lookup
|
|
76
|
-
const archetype = ARCHETYPE_MAP.get(spec.archetype);
|
|
102
|
+
// ── Archetype lookup (static map + content-pack extensions) ──────────────
|
|
103
|
+
const archetype = ARCHETYPE_MAP.get(spec.archetype) ?? _archetypeExtensions.get(spec.archetype);
|
|
77
104
|
if (archetype === undefined) {
|
|
78
105
|
throw new Error(`createWorld: unknown archetype "${spec.archetype}". ` +
|
|
79
106
|
`Valid keys: ${[...ARCHETYPE_MAP.keys()].join(", ")}`);
|
|
80
107
|
}
|
|
81
|
-
// ── Weapon lookup
|
|
82
|
-
const weapon = ITEM_MAP.get(spec.weaponId);
|
|
108
|
+
// ── Weapon lookup (static map + content-pack extensions) ─────────────────
|
|
109
|
+
const weapon = ITEM_MAP.get(spec.weaponId) ?? _itemExtensions.get(spec.weaponId);
|
|
83
110
|
if (weapon === undefined) {
|
|
84
111
|
throw new Error(`createWorld: unknown weaponId "${spec.weaponId}"`);
|
|
85
112
|
}
|
|
86
113
|
// ── Optional armour lookup ────────────────────────────────────────────────
|
|
87
114
|
let armour;
|
|
88
115
|
if (spec.armourId !== undefined) {
|
|
89
|
-
armour = ITEM_MAP.get(spec.armourId);
|
|
116
|
+
armour = ITEM_MAP.get(spec.armourId) ?? _itemExtensions.get(spec.armourId);
|
|
90
117
|
if (armour === undefined) {
|
|
91
118
|
throw new Error(`createWorld: unknown armourId "${spec.armourId}"`);
|
|
92
119
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// tools/pack-cli.ts — PA-4: Ananke content pack CLI
|
|
3
|
+
//
|
|
4
|
+
// Usage (after npm run build):
|
|
5
|
+
// node dist/tools/pack-cli.js pack validate <file.json>
|
|
6
|
+
// node dist/tools/pack-cli.js pack bundle <directory>
|
|
7
|
+
//
|
|
8
|
+
// Or via the installed binary:
|
|
9
|
+
// npx ananke pack validate <file.json>
|
|
10
|
+
// npx ananke pack bundle <directory>
|
|
11
|
+
import { readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { join, resolve, extname, basename } from "node:path";
|
|
13
|
+
import { validatePack, loadPack } from "../src/content-pack.js";
|
|
14
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
15
|
+
function readJson(filePath) {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(readFileSync(filePath, "utf8"));
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
console.error(`Error reading ${filePath}: ${String(e)}`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function printErrors(errors) {
|
|
25
|
+
for (const err of errors) {
|
|
26
|
+
console.error(` ${err.path}: ${err.message}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// ── Commands ──────────────────────────────────────────────────────────────────
|
|
30
|
+
function cmdValidate(args) {
|
|
31
|
+
const filePath = args[0];
|
|
32
|
+
if (!filePath) {
|
|
33
|
+
console.error("Usage: ananke pack validate <file.json>");
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
const manifest = readJson(resolve(filePath));
|
|
37
|
+
const errors = validatePack(manifest);
|
|
38
|
+
if (errors.length === 0) {
|
|
39
|
+
const m = manifest;
|
|
40
|
+
console.log(`✓ ${m["name"] ?? "pack"}@${m["version"] ?? "?"} — valid`);
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
console.error(`✗ ${errors.length} error(s) in ${filePath}:`);
|
|
45
|
+
printErrors(errors);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function cmdBundle(args) {
|
|
50
|
+
const dir = args[0];
|
|
51
|
+
const outFile = args[1] ?? "bundle.ananke-pack.json";
|
|
52
|
+
if (!dir) {
|
|
53
|
+
console.error("Usage: ananke pack bundle <directory> [output.json]");
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
const dirPath = resolve(dir);
|
|
57
|
+
let entries;
|
|
58
|
+
try {
|
|
59
|
+
entries = readdirSync(dirPath).filter(f => extname(f) === ".json");
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
console.error(`Cannot read directory ${dirPath}: ${String(e)}`);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
if (entries.length === 0) {
|
|
66
|
+
console.error(`No .json files found in ${dirPath}`);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
const bundle = {
|
|
70
|
+
name: basename(dirPath),
|
|
71
|
+
version: "1.0.0",
|
|
72
|
+
description: `Bundled from ${entries.length} file(s) in ${dirPath}`,
|
|
73
|
+
weapons: [],
|
|
74
|
+
armour: [],
|
|
75
|
+
archetypes: [],
|
|
76
|
+
scenarios: [],
|
|
77
|
+
};
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
const filePath = join(dirPath, entry);
|
|
80
|
+
if (!statSync(filePath).isFile())
|
|
81
|
+
continue;
|
|
82
|
+
const raw = readJson(filePath);
|
|
83
|
+
const partial = raw;
|
|
84
|
+
if (Array.isArray(partial.weapons))
|
|
85
|
+
bundle.weapons.push(...partial.weapons);
|
|
86
|
+
if (Array.isArray(partial.armour))
|
|
87
|
+
bundle.armour.push(...partial.armour);
|
|
88
|
+
if (Array.isArray(partial.archetypes))
|
|
89
|
+
bundle.archetypes.push(...partial.archetypes);
|
|
90
|
+
if (Array.isArray(partial.scenarios))
|
|
91
|
+
bundle.scenarios.push(...partial.scenarios);
|
|
92
|
+
// Use name/version from first file that has them
|
|
93
|
+
if (!bundle.name && typeof partial.name === "string")
|
|
94
|
+
bundle.name = partial.name;
|
|
95
|
+
}
|
|
96
|
+
// Pre-validate before writing
|
|
97
|
+
const errors = validatePack(bundle);
|
|
98
|
+
if (errors.length > 0) {
|
|
99
|
+
console.warn(` ${errors.length} validation warning(s) in bundle:`);
|
|
100
|
+
printErrors(errors);
|
|
101
|
+
}
|
|
102
|
+
const json = JSON.stringify(bundle, null, 2);
|
|
103
|
+
writeFileSync(outFile, json, "utf8");
|
|
104
|
+
console.log(`✓ Bundle written to ${outFile}`);
|
|
105
|
+
console.log(` weapons: ${bundle.weapons.length}, armour: ${bundle.armour.length}, archetypes: ${bundle.archetypes.length}, scenarios: ${bundle.scenarios.length}`);
|
|
106
|
+
}
|
|
107
|
+
function cmdLoad(args) {
|
|
108
|
+
const filePath = args[0];
|
|
109
|
+
if (!filePath) {
|
|
110
|
+
console.error("Usage: ananke pack load <file.json>");
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
const manifest = readJson(resolve(filePath));
|
|
114
|
+
const result = loadPack(manifest);
|
|
115
|
+
if (result.errors.length > 0) {
|
|
116
|
+
console.error(`✗ Load failed:`);
|
|
117
|
+
printErrors(result.errors);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
console.log(`✓ ${result.packId} loaded`);
|
|
121
|
+
console.log(` registered: ${result.registeredIds.join(", ") || "none"}`);
|
|
122
|
+
console.log(` scenarios: ${result.scenarioIds.join(", ") || "none"}`);
|
|
123
|
+
console.log(` fingerprint: ${result.fingerprint}`);
|
|
124
|
+
}
|
|
125
|
+
// ── Entry point ───────────────────────────────────────────────────────────────
|
|
126
|
+
function main() {
|
|
127
|
+
const argv = process.argv.slice(2);
|
|
128
|
+
if (argv[0] !== "pack") {
|
|
129
|
+
console.log("Ananke CLI");
|
|
130
|
+
console.log("");
|
|
131
|
+
console.log("Commands:");
|
|
132
|
+
console.log(" pack validate <file.json> — validate a pack manifest");
|
|
133
|
+
console.log(" pack bundle <directory> [out.json] — merge JSON files into one pack");
|
|
134
|
+
console.log(" pack load <file.json> — load a pack and report registered ids");
|
|
135
|
+
process.exit(0);
|
|
136
|
+
}
|
|
137
|
+
const sub = argv[1];
|
|
138
|
+
const rest = argv.slice(2);
|
|
139
|
+
switch (sub) {
|
|
140
|
+
case "validate":
|
|
141
|
+
cmdValidate(rest);
|
|
142
|
+
break;
|
|
143
|
+
case "bundle":
|
|
144
|
+
cmdBundle(rest);
|
|
145
|
+
break;
|
|
146
|
+
case "load":
|
|
147
|
+
cmdLoad(rest);
|
|
148
|
+
break;
|
|
149
|
+
default:
|
|
150
|
+
console.error(`Unknown subcommand: pack ${sub ?? ""}`);
|
|
151
|
+
console.error("Available: validate, bundle, load");
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
main();
|
|
@@ -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,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@its-not-rocket-science/ananke",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.53",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deterministic lockstep-friendly SI-units RPG/physics core (fixed-point TS)",
|
|
6
6
|
"license": "MIT",
|
|
7
|
+
"bin": {
|
|
8
|
+
"ananke": "./dist/tools/pack-cli.js"
|
|
9
|
+
},
|
|
7
10
|
"main": "./dist/src/index.js",
|
|
8
11
|
"types": "./dist/src/index.d.ts",
|
|
9
12
|
"exports": {
|
|
@@ -170,6 +173,14 @@
|
|
|
170
173
|
"./monetary": {
|
|
171
174
|
"import": "./dist/src/monetary.js",
|
|
172
175
|
"types": "./dist/src/monetary.d.ts"
|
|
176
|
+
},
|
|
177
|
+
"./schema": {
|
|
178
|
+
"import": "./dist/src/schema-migration.js",
|
|
179
|
+
"types": "./dist/src/schema-migration.d.ts"
|
|
180
|
+
},
|
|
181
|
+
"./content-pack": {
|
|
182
|
+
"import": "./dist/src/content-pack.js",
|
|
183
|
+
"types": "./dist/src/content-pack.d.ts"
|
|
173
184
|
}
|
|
174
185
|
},
|
|
175
186
|
"workspaces": [
|
|
@@ -187,6 +198,10 @@
|
|
|
187
198
|
"docs/emergent-validation-report.md",
|
|
188
199
|
"docs/package-architecture.md",
|
|
189
200
|
"docs/migration-monolith-to-modular.md",
|
|
201
|
+
"docs/wire-protocol.md",
|
|
202
|
+
"schema/world.schema.json",
|
|
203
|
+
"schema/replay.schema.json",
|
|
204
|
+
"schema/pack.schema.json",
|
|
190
205
|
"CHANGELOG.md",
|
|
191
206
|
"STABLE_API.md"
|
|
192
207
|
],
|
|
@@ -232,6 +247,7 @@
|
|
|
232
247
|
"example:campaign": "node dist/examples/quickstart-campaign.js",
|
|
233
248
|
"example:species": "node dist/examples/quickstart-species.js",
|
|
234
249
|
"generate-module-index": "node dist/tools/generate-module-index.js",
|
|
250
|
+
"pack": "node dist/tools/pack-cli.js pack",
|
|
235
251
|
"generate-fixtures": "node dist/tools/generate-fixtures.js",
|
|
236
252
|
"generate-zoo": "node dist/tools/generate-zoo.js",
|
|
237
253
|
"generate-playground": "node dist/tools/generate-playground.js",
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://its-not-rocket-science.github.io/ananke/schema/pack.schema.json",
|
|
4
|
+
"title": "AnankePackManifest",
|
|
5
|
+
"description": "Ananke content pack manifest — a runtime-loadable bundle of weapons, armour, archetypes, and scenarios. Load with loadPack() from @its-not-rocket-science/ananke/content-pack.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["name", "version"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"$schema": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"description": "Link to this schema for editor validation."
|
|
12
|
+
},
|
|
13
|
+
"name": {
|
|
14
|
+
"type": "string",
|
|
15
|
+
"minLength": 1,
|
|
16
|
+
"description": "Unique pack identifier (kebab-case recommended), e.g. \"weapons-medieval\"."
|
|
17
|
+
},
|
|
18
|
+
"version": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"pattern": "^\\d+\\.\\d+(\\.\\d+)?$",
|
|
21
|
+
"description": "Semantic version, e.g. \"1.0.0\". Used as part of the packId \"name@version\"."
|
|
22
|
+
},
|
|
23
|
+
"description": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"description": "Human-readable summary of pack contents."
|
|
26
|
+
},
|
|
27
|
+
"anankeVersion": {
|
|
28
|
+
"type": "string",
|
|
29
|
+
"description": "Minimum Ananke version required (semver range), e.g. \">=0.1\"."
|
|
30
|
+
},
|
|
31
|
+
"weapons": {
|
|
32
|
+
"type": "array",
|
|
33
|
+
"items": { "$ref": "#/$defs/WeaponEntry" },
|
|
34
|
+
"description": "Weapon definitions — each registered via registerWeapon()."
|
|
35
|
+
},
|
|
36
|
+
"armour": {
|
|
37
|
+
"type": "array",
|
|
38
|
+
"items": { "$ref": "#/$defs/ArmourEntry" },
|
|
39
|
+
"description": "Armour definitions — each registered via registerArmour()."
|
|
40
|
+
},
|
|
41
|
+
"archetypes": {
|
|
42
|
+
"type": "array",
|
|
43
|
+
"items": { "$ref": "#/$defs/ArchetypeEntry" },
|
|
44
|
+
"description": "Archetype definitions — each registered via registerArchetype()."
|
|
45
|
+
},
|
|
46
|
+
"scenarios": {
|
|
47
|
+
"type": "array",
|
|
48
|
+
"items": { "$ref": "#/$defs/Scenario" },
|
|
49
|
+
"description": "Scenario definitions — stored in the pack registry; instantiate with instantiatePackScenario()."
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"unevaluatedProperties": true,
|
|
53
|
+
"$defs": {
|
|
54
|
+
"WeaponDamageProfile": {
|
|
55
|
+
"type": "object",
|
|
56
|
+
"required": ["surfaceFrac", "internalFrac", "structuralFrac", "bleedFactor", "penetrationBias"],
|
|
57
|
+
"properties": {
|
|
58
|
+
"surfaceFrac": { "type": "number", "minimum": 0, "maximum": 1, "description": "Surface damage fraction [0, 1]." },
|
|
59
|
+
"internalFrac": { "type": "number", "minimum": 0, "maximum": 1, "description": "Internal damage fraction [0, 1]." },
|
|
60
|
+
"structuralFrac": { "type": "number", "minimum": 0, "maximum": 1, "description": "Structural (bone) damage fraction [0, 1]." },
|
|
61
|
+
"bleedFactor": { "type": "number", "minimum": 0, "maximum": 1, "description": "Bleed factor [0, 1]." },
|
|
62
|
+
"penetrationBias": { "type": "number", "minimum": 0, "maximum": 1, "description": "Armour penetration bias [0, 1]." }
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"WeaponEntry": {
|
|
66
|
+
"type": "object",
|
|
67
|
+
"required": ["id", "name", "mass_kg", "damage"],
|
|
68
|
+
"properties": {
|
|
69
|
+
"id": { "type": "string", "minLength": 1 },
|
|
70
|
+
"name": { "type": "string" },
|
|
71
|
+
"mass_kg": { "type": "number", "exclusiveMinimum": 0, "description": "Real-world mass in kilograms." },
|
|
72
|
+
"bulk": { "type": "number", "minimum": 0, "maximum": 1, "description": "Handling difficulty factor [0, 1]." },
|
|
73
|
+
"reach_m": { "type": "number", "exclusiveMinimum": 0, "description": "Strike reach in metres." },
|
|
74
|
+
"readyTime_s": { "type": "number", "exclusiveMinimum": 0, "description": "Wind-up time in seconds." },
|
|
75
|
+
"handedness": { "type": "string", "enum": ["oneHand", "twoHand", "mounted", "natural"] },
|
|
76
|
+
"strikeEffectiveMassFrac": { "type": "number", "minimum": 0, "maximum": 1 },
|
|
77
|
+
"strikeSpeedMul": { "type": "number", "minimum": 0 },
|
|
78
|
+
"handlingMul": { "type": "number", "minimum": 0 },
|
|
79
|
+
"damage": { "$ref": "#/$defs/WeaponDamageProfile" }
|
|
80
|
+
},
|
|
81
|
+
"unevaluatedProperties": true
|
|
82
|
+
},
|
|
83
|
+
"ArmourEntry": {
|
|
84
|
+
"type": "object",
|
|
85
|
+
"required": ["id", "name", "mass_kg", "resist_J", "protectedDamageMul", "coverageByRegion"],
|
|
86
|
+
"properties": {
|
|
87
|
+
"id": { "type": "string", "minLength": 1 },
|
|
88
|
+
"name": { "type": "string" },
|
|
89
|
+
"mass_kg": { "type": "number", "exclusiveMinimum": 0, "description": "Real-world mass in kilograms." },
|
|
90
|
+
"bulk": { "type": "number", "minimum": 0, "maximum": 1 },
|
|
91
|
+
"resist_J": { "type": "number", "exclusiveMinimum": 0, "description": "Energy absorption capacity in real-world Joules." },
|
|
92
|
+
"protectedDamageMul": { "type": "number", "minimum": 0, "maximum": 1, "description": "Damage multiplier applied when armour absorbs a hit [0, 1]." },
|
|
93
|
+
"coverageByRegion": { "type": "object", "additionalProperties": { "type": "number", "minimum": 0, "maximum": 1 }, "description": "Region name → coverage fraction [0, 1]." },
|
|
94
|
+
"protects": { "type": "array", "items": { "type": "string" }, "description": "Damage channel names this armour blocks, e.g. [\"Kinetic\", \"Thermal\"]." },
|
|
95
|
+
"mobilityMul": { "type": "number", "minimum": 0 },
|
|
96
|
+
"fatigueMul": { "type": "number", "minimum": 0 },
|
|
97
|
+
"reflectivity": { "type": "number", "minimum": 0, "maximum": 1 },
|
|
98
|
+
"ablative": { "type": "boolean" },
|
|
99
|
+
"insulation_m2KW": { "type": "number", "minimum": 0 }
|
|
100
|
+
},
|
|
101
|
+
"unevaluatedProperties": true
|
|
102
|
+
},
|
|
103
|
+
"ArchetypeEntry": {
|
|
104
|
+
"type": "object",
|
|
105
|
+
"required": ["id"],
|
|
106
|
+
"properties": {
|
|
107
|
+
"id": { "type": "string", "minLength": 1, "description": "Unique catalog id for this archetype." },
|
|
108
|
+
"base": { "type": "string", "description": "Built-in base to inherit from: HUMAN_BASE, KNIGHT_INFANTRY, AMATEUR_BOXER, PRO_BOXER, GRECO_WRESTLER, etc." },
|
|
109
|
+
"overrides": { "type": "object", "description": "Field → real-world SI value overrides, e.g. {\"mass_kg\": 80, \"height_m\": 1.85}." }
|
|
110
|
+
},
|
|
111
|
+
"unevaluatedProperties": true
|
|
112
|
+
},
|
|
113
|
+
"ScenarioEntity": {
|
|
114
|
+
"type": "object",
|
|
115
|
+
"required": ["id", "teamId", "archetype", "weapon"],
|
|
116
|
+
"properties": {
|
|
117
|
+
"id": { "type": "integer", "minimum": 1 },
|
|
118
|
+
"teamId": { "type": "integer" },
|
|
119
|
+
"archetype": { "type": "string", "description": "Catalog archetype id." },
|
|
120
|
+
"weapon": { "type": "string", "description": "Catalog weapon id." },
|
|
121
|
+
"armour": { "type": "string", "description": "Catalog armour id (optional)." },
|
|
122
|
+
"x_m": { "type": "number" },
|
|
123
|
+
"y_m": { "type": "number" }
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
"Scenario": {
|
|
127
|
+
"type": "object",
|
|
128
|
+
"required": ["id", "seed", "maxTicks", "entities"],
|
|
129
|
+
"properties": {
|
|
130
|
+
"id": { "type": "string", "minLength": 1 },
|
|
131
|
+
"seed": { "type": "integer" },
|
|
132
|
+
"maxTicks": { "type": "integer", "minimum": 1 },
|
|
133
|
+
"tractionCoeff": { "type": "number", "minimum": 0, "maximum": 1 },
|
|
134
|
+
"entities": { "type": "array", "items": { "$ref": "#/$defs/ScenarioEntity" }, "minItems": 1 }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|