@its-not-rocket-science/ananke 0.1.52 → 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 +17 -0
- package/dist/src/content-pack.d.ts +105 -0
- package/dist/src/content-pack.js +261 -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/package.json +10 -1
- package/schema/pack.schema.json +138 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,23 @@ 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
|
+
|
|
9
26
|
## [0.1.52] — 2026-03-28
|
|
10
27
|
|
|
11
28
|
### 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
|
+
}
|
|
@@ -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;
|
|
@@ -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();
|
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": {
|
|
@@ -174,6 +177,10 @@
|
|
|
174
177
|
"./schema": {
|
|
175
178
|
"import": "./dist/src/schema-migration.js",
|
|
176
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"
|
|
177
184
|
}
|
|
178
185
|
},
|
|
179
186
|
"workspaces": [
|
|
@@ -194,6 +201,7 @@
|
|
|
194
201
|
"docs/wire-protocol.md",
|
|
195
202
|
"schema/world.schema.json",
|
|
196
203
|
"schema/replay.schema.json",
|
|
204
|
+
"schema/pack.schema.json",
|
|
197
205
|
"CHANGELOG.md",
|
|
198
206
|
"STABLE_API.md"
|
|
199
207
|
],
|
|
@@ -239,6 +247,7 @@
|
|
|
239
247
|
"example:campaign": "node dist/examples/quickstart-campaign.js",
|
|
240
248
|
"example:species": "node dist/examples/quickstart-species.js",
|
|
241
249
|
"generate-module-index": "node dist/tools/generate-module-index.js",
|
|
250
|
+
"pack": "node dist/tools/pack-cli.js pack",
|
|
242
251
|
"generate-fixtures": "node dist/tools/generate-fixtures.js",
|
|
243
252
|
"generate-zoo": "node dist/tools/generate-zoo.js",
|
|
244
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
|
+
}
|