@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
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,38 @@ Versioning follows [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
+
## [0.1.53] — 2026-03-28
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **PA-4 — Scenario & Content Pack System (complete):**
|
|
14
|
+
- `src/content-pack.ts` (new): runtime `.ananke-pack` loader — `validatePack`, `loadPack`, `getPackScenario`, `instantiatePackScenario`, `listLoadedPacks`, `getLoadedPack`, `clearPackRegistry`. `loadPack` registers weapons/armour/archetypes into the global catalog AND into the world-factory extension tables so they are immediately usable in `loadScenario` scenarios.
|
|
15
|
+
- `src/world-factory.ts`: added `registerWorldArchetype`, `registerWorldItem`, `clearWorldExtensions` extension hooks so content packs can make their items available to `createWorld` / `loadScenario` without a source build.
|
|
16
|
+
- `schema/pack.schema.json` (new): JSON Schema 2020-12 for pack manifests (weapons, armour, archetypes, scenarios sections; full per-field documentation).
|
|
17
|
+
- `tools/pack-cli.ts` (new): `ananke pack validate <file>`, `ananke pack bundle <dir>`, `ananke pack load <file>`. Registered as `bin.ananke` in `package.json` so `npx ananke pack validate` works after install.
|
|
18
|
+
- `examples/packs/weapons-medieval.json`: 5 medieval weapons + 3 armours.
|
|
19
|
+
- `examples/packs/species-humanoids.json`: 4 humanoid archetype variants.
|
|
20
|
+
- `examples/packs/scenarios-duel.json`: 3 duel scenarios, self-contained with own archetypes and weapons.
|
|
21
|
+
- `"./content-pack"` subpath, `schema/pack.schema.json`, and `bin.ananke` added to `package.json`.
|
|
22
|
+
- 32 new tests (184 test files, 5,332 tests total). Build: clean.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## [0.1.52] — 2026-03-28
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- **PA-3 — Stable Schema, Save & Wire Contract (complete):**
|
|
31
|
+
- `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`.
|
|
32
|
+
- `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.
|
|
33
|
+
- `schema/replay.schema.json` (new): JSON Schema 2020-12 for `Replay` / `ReplayFrame` / `Command`.
|
|
34
|
+
- `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.
|
|
35
|
+
- `"./schema"` subpath added to `package.json` exports.
|
|
36
|
+
- `schema/` directory and `docs/wire-protocol.md` added to `package.json` `"files"`.
|
|
37
|
+
- 39 new tests (183 test files, 5,300 tests total). Build: clean.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
9
41
|
## [0.1.51] — 2026-03-28
|
|
10
42
|
|
|
11
43
|
### Added
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { WorldState } from "./sim/world.js";
|
|
2
|
+
/** A single actionable validation failure from `validatePack`. */
|
|
3
|
+
export interface PackValidationError {
|
|
4
|
+
/** JSONPath-style location, e.g. `"$.weapons[2].mass_kg"`. */
|
|
5
|
+
path: string;
|
|
6
|
+
/** Human-readable explanation of what is wrong. */
|
|
7
|
+
message: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The `.ananke-pack` manifest schema.
|
|
11
|
+
*
|
|
12
|
+
* All numeric fields in `weapons`, `armour`, and `archetypes` use real-world
|
|
13
|
+
* SI units (kg, m, J, s) and Q ratios in [0, 1]. See `docs/wire-protocol.md`
|
|
14
|
+
* for the full serialisation contract.
|
|
15
|
+
*/
|
|
16
|
+
export interface AnankePackManifest {
|
|
17
|
+
/** Optional link to `schema/pack.schema.json` for editor tooling. */
|
|
18
|
+
$schema?: string;
|
|
19
|
+
/** Unique pack name (kebab-case recommended), e.g. `"weapons-medieval"`. */
|
|
20
|
+
name: string;
|
|
21
|
+
/** Semantic version string, e.g. `"1.0.0"`. */
|
|
22
|
+
version: string;
|
|
23
|
+
/** Human-readable summary. */
|
|
24
|
+
description?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Minimum Ananke version required, as a semver range string.
|
|
27
|
+
* Used for documentation only — not enforced at runtime in v0.1.
|
|
28
|
+
*/
|
|
29
|
+
anankeVersion?: string;
|
|
30
|
+
/** Weapon definitions — each passed to `registerWeapon`. */
|
|
31
|
+
weapons?: unknown[];
|
|
32
|
+
/** Armour definitions — each passed to `registerArmour`. */
|
|
33
|
+
armour?: unknown[];
|
|
34
|
+
/** Archetype definitions — each passed to `registerArchetype`. */
|
|
35
|
+
archetypes?: unknown[];
|
|
36
|
+
/**
|
|
37
|
+
* Scenario definitions — stored in the pack registry and retrievable via
|
|
38
|
+
* `getPackScenario`. NOT loaded into the catalog (scenarios have no global
|
|
39
|
+
* registry); instantiate on demand with `instantiatePackScenario`.
|
|
40
|
+
*/
|
|
41
|
+
scenarios?: unknown[];
|
|
42
|
+
}
|
|
43
|
+
/** Result of a `loadPack` call. */
|
|
44
|
+
export interface LoadPackResult {
|
|
45
|
+
/**
|
|
46
|
+
* Canonical pack identifier: `"${name}@${version}"`.
|
|
47
|
+
* Use this as the first argument to `getPackScenario`.
|
|
48
|
+
*/
|
|
49
|
+
packId: string;
|
|
50
|
+
/**
|
|
51
|
+
* IDs of all catalog entries registered, prefixed by kind.
|
|
52
|
+
* e.g. `["weapon:medieval_longsword", "armour:medieval_gambeson"]`.
|
|
53
|
+
*/
|
|
54
|
+
registeredIds: string[];
|
|
55
|
+
/** IDs of all scenarios stored in the pack registry. */
|
|
56
|
+
scenarioIds: string[];
|
|
57
|
+
/** 8-character hex fingerprint of the manifest (FNV-1a over canonical JSON). */
|
|
58
|
+
fingerprint: string;
|
|
59
|
+
/** Validation and registration errors. Empty on full success. */
|
|
60
|
+
errors: PackValidationError[];
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Validate a pack manifest for structural conformance without loading it.
|
|
64
|
+
*
|
|
65
|
+
* Checks required top-level fields, array element shapes, and runs
|
|
66
|
+
* `validateScenario` on each scenario entry.
|
|
67
|
+
*
|
|
68
|
+
* @returns Array of `PackValidationError`. Empty means valid.
|
|
69
|
+
*/
|
|
70
|
+
export declare function validatePack(manifest: unknown): PackValidationError[];
|
|
71
|
+
/**
|
|
72
|
+
* Validate and load a pack manifest into the active catalogues.
|
|
73
|
+
*
|
|
74
|
+
* - Weapons, armour, and archetypes are registered into the global catalog.
|
|
75
|
+
* - Scenarios are stored in the pack registry; retrieve with `getPackScenario`.
|
|
76
|
+
* - If `validatePack` reports errors the pack is NOT loaded and `errors` is
|
|
77
|
+
* populated in the result.
|
|
78
|
+
* - Loading a pack with the same `name@version` id a second time is a no-op
|
|
79
|
+
* (returns the original result with `errors` empty).
|
|
80
|
+
*/
|
|
81
|
+
export declare function loadPack(manifest: AnankePackManifest): LoadPackResult;
|
|
82
|
+
/** Returns the pack registry entry for a previously-loaded pack, or `undefined`. */
|
|
83
|
+
export declare function getLoadedPack(packId: string): LoadPackResult | undefined;
|
|
84
|
+
/** Returns the `"name@version"` ids of all currently loaded packs. */
|
|
85
|
+
export declare function listLoadedPacks(): string[];
|
|
86
|
+
/**
|
|
87
|
+
* Returns the raw scenario JSON stored in a pack.
|
|
88
|
+
*
|
|
89
|
+
* @param packId — `"name@version"` as returned by `loadPack`.
|
|
90
|
+
* @param scenarioId — the scenario's `id` field.
|
|
91
|
+
*/
|
|
92
|
+
export declare function getPackScenario(packId: string, scenarioId: string): unknown | undefined;
|
|
93
|
+
/**
|
|
94
|
+
* Instantiate a packed scenario into a live `WorldState`.
|
|
95
|
+
*
|
|
96
|
+
* Equivalent to `loadScenario(getPackScenario(packId, scenarioId))`.
|
|
97
|
+
* Throws if the pack or scenario does not exist.
|
|
98
|
+
*/
|
|
99
|
+
export declare function instantiatePackScenario(packId: string, scenarioId: string): WorldState;
|
|
100
|
+
/**
|
|
101
|
+
* Remove all entries from the pack registry.
|
|
102
|
+
* Does NOT un-register catalog entries — call `clearCatalog()` separately if needed.
|
|
103
|
+
* Primarily for testing.
|
|
104
|
+
*/
|
|
105
|
+
export declare function clearPackRegistry(): void;
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// src/content-pack.ts — PA-4: Scenario & Content Pack System
|
|
2
|
+
//
|
|
3
|
+
// Runtime loader for `.ananke-pack` JSON manifests. A content pack can
|
|
4
|
+
// add weapons, armour, archetypes, and scenarios to the active catalogues
|
|
5
|
+
// without touching source code.
|
|
6
|
+
//
|
|
7
|
+
// Integration pattern:
|
|
8
|
+
// const manifest = JSON.parse(fs.readFileSync("weapons-medieval.json", "utf8"));
|
|
9
|
+
// const result = loadPack(manifest);
|
|
10
|
+
// if (result.errors.length > 0) throw new Error(result.errors[0]!.message);
|
|
11
|
+
// // catalog now contains the new weapons
|
|
12
|
+
import { registerWeapon, registerArmour, registerArchetype } from "./catalog.js";
|
|
13
|
+
import { validateScenario, loadScenario } from "./scenario.js";
|
|
14
|
+
import { hashMod } from "./modding.js";
|
|
15
|
+
import { registerWorldArchetype, registerWorldItem } from "./world-factory.js";
|
|
16
|
+
const _packs = new Map();
|
|
17
|
+
// ── Validation ────────────────────────────────────────────────────────────────
|
|
18
|
+
/**
|
|
19
|
+
* Validate a pack manifest for structural conformance without loading it.
|
|
20
|
+
*
|
|
21
|
+
* Checks required top-level fields, array element shapes, and runs
|
|
22
|
+
* `validateScenario` on each scenario entry.
|
|
23
|
+
*
|
|
24
|
+
* @returns Array of `PackValidationError`. Empty means valid.
|
|
25
|
+
*/
|
|
26
|
+
export function validatePack(manifest) {
|
|
27
|
+
const errors = [];
|
|
28
|
+
if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest)) {
|
|
29
|
+
errors.push({ path: "$", message: "pack manifest must be a plain object" });
|
|
30
|
+
return errors;
|
|
31
|
+
}
|
|
32
|
+
const m = manifest;
|
|
33
|
+
// Required: name
|
|
34
|
+
if (typeof m["name"] !== "string" || m["name"].trim() === "") {
|
|
35
|
+
errors.push({ path: "$.name", message: "must be a non-empty string" });
|
|
36
|
+
}
|
|
37
|
+
// Required: version (semver-ish)
|
|
38
|
+
if (typeof m["version"] !== "string" ||
|
|
39
|
+
!/^\d+\.\d+(\.\d+)?$/.test(m["version"])) {
|
|
40
|
+
errors.push({ path: "$.version", message: 'must be a semver string like "1.0.0" or "1.0"' });
|
|
41
|
+
}
|
|
42
|
+
// Optional arrays — must be arrays if present
|
|
43
|
+
for (const key of ["weapons", "armour", "archetypes", "scenarios"]) {
|
|
44
|
+
if (m[key] !== undefined && !Array.isArray(m[key])) {
|
|
45
|
+
errors.push({ path: `$.${key}`, message: "must be an array if present" });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// Validate weapon entries
|
|
49
|
+
if (Array.isArray(m["weapons"])) {
|
|
50
|
+
for (let i = 0; i < m["weapons"].length; i++) {
|
|
51
|
+
const w = m["weapons"][i];
|
|
52
|
+
errors.push(...validateWeaponEntry(w, i));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Validate armour entries
|
|
56
|
+
if (Array.isArray(m["armour"])) {
|
|
57
|
+
for (let i = 0; i < m["armour"].length; i++) {
|
|
58
|
+
const a = m["armour"][i];
|
|
59
|
+
errors.push(...validateArmourEntry(a, i));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Validate archetype entries (minimal — full validation is in registerArchetype)
|
|
63
|
+
if (Array.isArray(m["archetypes"])) {
|
|
64
|
+
for (let i = 0; i < m["archetypes"].length; i++) {
|
|
65
|
+
const arch = m["archetypes"][i];
|
|
66
|
+
if (typeof arch !== "object" || arch === null) {
|
|
67
|
+
errors.push({ path: `$.archetypes[${i}]`, message: "must be an object" });
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const o = arch;
|
|
71
|
+
if (typeof o["id"] !== "string" || o["id"].trim() === "") {
|
|
72
|
+
errors.push({ path: `$.archetypes[${i}].id`, message: "must be a non-empty string" });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Validate scenario entries via existing validateScenario
|
|
77
|
+
if (Array.isArray(m["scenarios"])) {
|
|
78
|
+
for (let i = 0; i < m["scenarios"].length; i++) {
|
|
79
|
+
const scenErrors = validateScenario(m["scenarios"][i]);
|
|
80
|
+
for (const msg of scenErrors) {
|
|
81
|
+
errors.push({ path: `$.scenarios[${i}]`, message: msg });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return errors;
|
|
86
|
+
}
|
|
87
|
+
function validateWeaponEntry(w, i) {
|
|
88
|
+
const errors = [];
|
|
89
|
+
if (typeof w !== "object" || w === null) {
|
|
90
|
+
errors.push({ path: `$.weapons[${i}]`, message: "must be an object" });
|
|
91
|
+
return errors;
|
|
92
|
+
}
|
|
93
|
+
const o = w;
|
|
94
|
+
if (typeof o["id"] !== "string" || o["id"].trim() === "") {
|
|
95
|
+
errors.push({ path: `$.weapons[${i}].id`, message: "must be a non-empty string" });
|
|
96
|
+
}
|
|
97
|
+
if (typeof o["name"] !== "string") {
|
|
98
|
+
errors.push({ path: `$.weapons[${i}].name`, message: "must be a string" });
|
|
99
|
+
}
|
|
100
|
+
if (typeof o["mass_kg"] !== "number" || o["mass_kg"] <= 0) {
|
|
101
|
+
errors.push({ path: `$.weapons[${i}].mass_kg`, message: "must be a positive number (real-world kg)" });
|
|
102
|
+
}
|
|
103
|
+
if (typeof o["damage"] !== "object" || o["damage"] === null) {
|
|
104
|
+
errors.push({ path: `$.weapons[${i}].damage`, message: "must be an object" });
|
|
105
|
+
}
|
|
106
|
+
return errors;
|
|
107
|
+
}
|
|
108
|
+
function validateArmourEntry(a, i) {
|
|
109
|
+
const errors = [];
|
|
110
|
+
if (typeof a !== "object" || a === null) {
|
|
111
|
+
errors.push({ path: `$.armour[${i}]`, message: "must be an object" });
|
|
112
|
+
return errors;
|
|
113
|
+
}
|
|
114
|
+
const o = a;
|
|
115
|
+
if (typeof o["id"] !== "string" || o["id"].trim() === "") {
|
|
116
|
+
errors.push({ path: `$.armour[${i}].id`, message: "must be a non-empty string" });
|
|
117
|
+
}
|
|
118
|
+
if (typeof o["name"] !== "string") {
|
|
119
|
+
errors.push({ path: `$.armour[${i}].name`, message: "must be a string" });
|
|
120
|
+
}
|
|
121
|
+
if (typeof o["mass_kg"] !== "number" || o["mass_kg"] <= 0) {
|
|
122
|
+
errors.push({ path: `$.armour[${i}].mass_kg`, message: "must be a positive number (real-world kg)" });
|
|
123
|
+
}
|
|
124
|
+
if (typeof o["resist_J"] !== "number" || o["resist_J"] <= 0) {
|
|
125
|
+
errors.push({ path: `$.armour[${i}].resist_J`, message: "must be a positive number (real-world Joules)" });
|
|
126
|
+
}
|
|
127
|
+
return errors;
|
|
128
|
+
}
|
|
129
|
+
// ── Load ──────────────────────────────────────────────────────────────────────
|
|
130
|
+
/**
|
|
131
|
+
* Validate and load a pack manifest into the active catalogues.
|
|
132
|
+
*
|
|
133
|
+
* - Weapons, armour, and archetypes are registered into the global catalog.
|
|
134
|
+
* - Scenarios are stored in the pack registry; retrieve with `getPackScenario`.
|
|
135
|
+
* - If `validatePack` reports errors the pack is NOT loaded and `errors` is
|
|
136
|
+
* populated in the result.
|
|
137
|
+
* - Loading a pack with the same `name@version` id a second time is a no-op
|
|
138
|
+
* (returns the original result with `errors` empty).
|
|
139
|
+
*/
|
|
140
|
+
export function loadPack(manifest) {
|
|
141
|
+
const packId = `${manifest.name}@${manifest.version}`;
|
|
142
|
+
// Already loaded — return stored summary
|
|
143
|
+
const existing = _packs.get(packId);
|
|
144
|
+
if (existing !== undefined) {
|
|
145
|
+
return {
|
|
146
|
+
packId,
|
|
147
|
+
registeredIds: existing.registeredIds,
|
|
148
|
+
scenarioIds: existing.scenarioIds,
|
|
149
|
+
fingerprint: existing.fingerprint,
|
|
150
|
+
errors: [],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
// Validate first
|
|
154
|
+
const errors = validatePack(manifest);
|
|
155
|
+
if (errors.length > 0) {
|
|
156
|
+
return { packId, registeredIds: [], scenarioIds: [], fingerprint: "", errors };
|
|
157
|
+
}
|
|
158
|
+
const registeredIds = [];
|
|
159
|
+
const loadErrors = [];
|
|
160
|
+
// Register weapons — into both the catalog and the world-factory lookup table
|
|
161
|
+
for (const w of manifest.weapons ?? []) {
|
|
162
|
+
try {
|
|
163
|
+
const weapon = registerWeapon(w);
|
|
164
|
+
const id = weapon.id;
|
|
165
|
+
registerWorldItem(id, weapon);
|
|
166
|
+
registeredIds.push(`weapon:${id}`);
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
loadErrors.push({ path: "$.weapons", message: String(e) });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Register armour — into both the catalog and the world-factory lookup table
|
|
173
|
+
for (const a of manifest.armour ?? []) {
|
|
174
|
+
try {
|
|
175
|
+
const armour = registerArmour(a);
|
|
176
|
+
const id = armour.id;
|
|
177
|
+
registerWorldItem(id, armour);
|
|
178
|
+
registeredIds.push(`armour:${id}`);
|
|
179
|
+
}
|
|
180
|
+
catch (e) {
|
|
181
|
+
loadErrors.push({ path: "$.armour", message: String(e) });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Register archetypes — into both the catalog and the world-factory lookup table
|
|
185
|
+
for (const arch of manifest.archetypes ?? []) {
|
|
186
|
+
try {
|
|
187
|
+
const archetype = registerArchetype(arch);
|
|
188
|
+
const id = arch["id"];
|
|
189
|
+
registerWorldArchetype(id, archetype);
|
|
190
|
+
registeredIds.push(`archetype:${id}`);
|
|
191
|
+
}
|
|
192
|
+
catch (e) {
|
|
193
|
+
loadErrors.push({ path: "$.archetypes", message: String(e) });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// Store scenarios (not in global catalog — retrieved on demand)
|
|
197
|
+
const scenarioMap = new Map();
|
|
198
|
+
const scenarioIds = [];
|
|
199
|
+
for (const scen of manifest.scenarios ?? []) {
|
|
200
|
+
const id = scen["id"];
|
|
201
|
+
scenarioMap.set(id, scen);
|
|
202
|
+
scenarioIds.push(id);
|
|
203
|
+
}
|
|
204
|
+
const fingerprint = hashMod(manifest);
|
|
205
|
+
_packs.set(packId, {
|
|
206
|
+
manifest,
|
|
207
|
+
registeredIds,
|
|
208
|
+
scenarioIds,
|
|
209
|
+
fingerprint,
|
|
210
|
+
scenarios: scenarioMap,
|
|
211
|
+
});
|
|
212
|
+
return { packId, registeredIds, scenarioIds, fingerprint, errors: loadErrors };
|
|
213
|
+
}
|
|
214
|
+
// ── Query API ─────────────────────────────────────────────────────────────────
|
|
215
|
+
/** Returns the pack registry entry for a previously-loaded pack, or `undefined`. */
|
|
216
|
+
export function getLoadedPack(packId) {
|
|
217
|
+
const e = _packs.get(packId);
|
|
218
|
+
if (e === undefined)
|
|
219
|
+
return undefined;
|
|
220
|
+
return {
|
|
221
|
+
packId,
|
|
222
|
+
registeredIds: e.registeredIds,
|
|
223
|
+
scenarioIds: e.scenarioIds,
|
|
224
|
+
fingerprint: e.fingerprint,
|
|
225
|
+
errors: [],
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
/** Returns the `"name@version"` ids of all currently loaded packs. */
|
|
229
|
+
export function listLoadedPacks() {
|
|
230
|
+
return [..._packs.keys()];
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Returns the raw scenario JSON stored in a pack.
|
|
234
|
+
*
|
|
235
|
+
* @param packId — `"name@version"` as returned by `loadPack`.
|
|
236
|
+
* @param scenarioId — the scenario's `id` field.
|
|
237
|
+
*/
|
|
238
|
+
export function getPackScenario(packId, scenarioId) {
|
|
239
|
+
return _packs.get(packId)?.scenarios.get(scenarioId);
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Instantiate a packed scenario into a live `WorldState`.
|
|
243
|
+
*
|
|
244
|
+
* Equivalent to `loadScenario(getPackScenario(packId, scenarioId))`.
|
|
245
|
+
* Throws if the pack or scenario does not exist.
|
|
246
|
+
*/
|
|
247
|
+
export function instantiatePackScenario(packId, scenarioId) {
|
|
248
|
+
const scen = getPackScenario(packId, scenarioId);
|
|
249
|
+
if (scen === undefined) {
|
|
250
|
+
throw new Error(`instantiatePackScenario: scenario "${scenarioId}" not found in pack "${packId}"`);
|
|
251
|
+
}
|
|
252
|
+
return loadScenario(scen);
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Remove all entries from the pack registry.
|
|
256
|
+
* Does NOT un-register catalog entries — call `clearCatalog()` separately if needed.
|
|
257
|
+
* Primarily for testing.
|
|
258
|
+
*/
|
|
259
|
+
export function clearPackRegistry() {
|
|
260
|
+
_packs.clear();
|
|
261
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -12,6 +12,22 @@ import type { WorldState } from "./sim/world.js";
|
|
|
12
12
|
export declare const ARCHETYPE_MAP: ReadonlyMap<string, Archetype>;
|
|
13
13
|
/** Map of item id → Item for weapons and armour usable with createWorld(). */
|
|
14
14
|
export declare const ITEM_MAP: ReadonlyMap<string, Item>;
|
|
15
|
+
/**
|
|
16
|
+
* Register an archetype so it is resolvable by `createWorld` and `loadScenario`.
|
|
17
|
+
* Called automatically by `loadPack` in `content-pack.ts`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function registerWorldArchetype(id: string, archetype: Archetype): void;
|
|
20
|
+
/**
|
|
21
|
+
* Register a weapon or armour so it is resolvable by `createWorld` and `loadScenario`.
|
|
22
|
+
* Called automatically by `loadPack` in `content-pack.ts`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function registerWorldItem(id: string, item: Item): void;
|
|
25
|
+
/**
|
|
26
|
+
* Remove all content-pack extensions from the world-factory lookup tables.
|
|
27
|
+
* Does NOT affect the static `ARCHETYPE_MAP` or `ITEM_MAP`.
|
|
28
|
+
* Call in test `afterEach` alongside `clearCatalog()` and `clearPackRegistry()`.
|
|
29
|
+
*/
|
|
30
|
+
export declare function clearWorldExtensions(): void;
|
|
15
31
|
export interface EntitySpec {
|
|
16
32
|
id: number;
|
|
17
33
|
teamId: number;
|