@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.
@@ -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.51",
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
+ }