@its-not-rocket-science/ananke 0.1.52 → 0.1.54

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 CHANGED
@@ -6,6 +6,38 @@ Versioning follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ---
8
8
 
9
+ ## [0.1.54] — 2026-03-28
10
+
11
+ ### Added
12
+
13
+ - **PA-5 — Campaign ↔ Tactical Terrain Bridge (complete):**
14
+ - `src/terrain-bridge.ts` (new): maps campaign hex tiles to tactical battlefield parameters consumable by `KernelContext`, and merges tactical battle results back into `CampaignState`.
15
+ - `extractTerrainParams(hexType)` → deterministic 10×8-cell (100 m × 80 m) battlefield with `TerrainGrid`, `ObstacleGrid`, `ElevationGrid`, `SlopeGrid`, and `CoverSegment[]` for all 8 hex types: `plains`, `forest`, `hills`, `marsh`, `urban`, `mountain`, `river_crossing`, `coastal`.
16
+ - `generateBattleSite(ctx)` → full `BattleTerrainParams` including `EntryVector[]` — attacker/defender spawn positions (y=5 m south, y=75 m north) with `facingY` direction.
17
+ - `mergeBattleOutcome(campaign, outcome)` → merges post-battle `WorldState` into `CampaignState`: removes `injury.dead` entities, copies post-battle `injury`/`condition` onto survivors, transfers looted weapons/items from captured entities to winner's inventory, advances `worldTime_s`, appends a log entry.
18
+ - Exports: `CampaignHexType`, `EntryVector`, `BattleTerrainParams`, `BattleSiteContext`, `BattleOutcome`; field constants `FIELD_WIDTH_Sm`, `FIELD_HEIGHT_Sm`, `CELL_SIZE_Sm`, `GRID_COLS`, `GRID_ROWS`.
19
+ - `"./terrain-bridge"` subpath export added to `package.json`.
20
+ - 67 new tests (185 test files, 5,397 tests total). Coverage: 97.05% stmt, 87.88% branch, 95.75% func, 97.05% lines. Build: clean.
21
+
22
+ ---
23
+
24
+ ## [0.1.53] — 2026-03-28
25
+
26
+ ### Added
27
+
28
+ - **PA-4 — Scenario & Content Pack System (complete):**
29
+ - `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.
30
+ - `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.
31
+ - `schema/pack.schema.json` (new): JSON Schema 2020-12 for pack manifests (weapons, armour, archetypes, scenarios sections; full per-field documentation).
32
+ - `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.
33
+ - `examples/packs/weapons-medieval.json`: 5 medieval weapons + 3 armours.
34
+ - `examples/packs/species-humanoids.json`: 4 humanoid archetype variants.
35
+ - `examples/packs/scenarios-duel.json`: 3 duel scenarios, self-contained with own archetypes and weapons.
36
+ - `"./content-pack"` subpath, `schema/pack.schema.json`, and `bin.ananke` added to `package.json`.
37
+ - 32 new tests (184 test files, 5,332 tests total). Build: clean.
38
+
39
+ ---
40
+
9
41
  ## [0.1.52] — 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,161 @@
1
+ import { type I32 } from "./units.js";
2
+ import { type TerrainGrid, type ObstacleGrid, type ElevationGrid, type SlopeGrid, type SurfaceType } from "./sim/terrain.js";
3
+ import { type CoverSegment } from "./sim/cover.js";
4
+ import type { CampaignState } from "./campaign.js";
5
+ import type { WorldState } from "./sim/world.js";
6
+ /** Battlefield width [SCALE.m]. 100 m. */
7
+ export declare const FIELD_WIDTH_Sm: I32;
8
+ /** Battlefield depth [SCALE.m]. 80 m. */
9
+ export declare const FIELD_HEIGHT_Sm: I32;
10
+ /** Terrain cell size [SCALE.m]. 10 m per cell → 10 × 8 grid. */
11
+ export declare const CELL_SIZE_Sm: I32;
12
+ /** Number of grid columns (field width / cell size). */
13
+ export declare const GRID_COLS = 10;
14
+ /** Number of grid rows (field height / cell size). */
15
+ export declare const GRID_ROWS = 8;
16
+ /** Attacker spawn y [SCALE.m] — 5 m from the south (y=0) edge. */
17
+ export declare const ATTACKER_SPAWN_Y_Sm: I32;
18
+ /** Defender spawn y [SCALE.m] — 5 m from the north edge. */
19
+ export declare const DEFENDER_SPAWN_Y_Sm: I32;
20
+ /**
21
+ * Campaign map tile type.
22
+ *
23
+ * Each hex type produces a distinct battlefield layout — surface type, cover
24
+ * density, elevation profile, and obstacle placement.
25
+ */
26
+ export type CampaignHexType = "plains" | "forest" | "hills" | "marsh" | "urban" | "mountain" | "river_crossing" | "coastal";
27
+ /**
28
+ * Initial spawn position and facing for one team entering the battlefield.
29
+ *
30
+ * Attackers (south-entry) have `facingY: 1` (moving toward increasing y).
31
+ * Defenders (north-entry) have `facingY: -1`.
32
+ */
33
+ export interface EntryVector {
34
+ /** Team identifier matching `entity.teamId`. */
35
+ teamId: number;
36
+ /** Spawn x-coordinate [SCALE.m]. */
37
+ x_Sm: number;
38
+ /** Spawn y-coordinate [SCALE.m]. */
39
+ y_Sm: number;
40
+ /**
41
+ * Initial movement direction along the y-axis.
42
+ * `1` = attacker advancing north.
43
+ * `-1` = defender holding or advancing south.
44
+ */
45
+ facingY: 1 | -1;
46
+ }
47
+ /**
48
+ * Complete tactical battlefield specification derived from a campaign hex encounter.
49
+ *
50
+ * ## Integration
51
+ * ```ts
52
+ * const site = generateBattleSite({ hexType: "forest", ... });
53
+ * const ctx: KernelContext = {
54
+ * tractionCoeff: SURFACE_TRACTION[site.dominantSurface],
55
+ * cellSize_m: site.cellSize_Sm,
56
+ * terrainGrid: site.terrainGrid,
57
+ * obstacleGrid: site.obstacleGrid,
58
+ * elevationGrid: site.elevationGrid,
59
+ * slopeGrid: site.slopeGrid,
60
+ * };
61
+ * // Position entities at site.entryVectors[n].x_Sm / y_Sm before first stepWorld.
62
+ * ```
63
+ */
64
+ export interface BattleTerrainParams {
65
+ /** Total battlefield width [SCALE.m]. */
66
+ width_Sm: number;
67
+ /** Total battlefield depth [SCALE.m]. */
68
+ height_Sm: number;
69
+ /** Cell size used for terrain grid lookups [SCALE.m]. */
70
+ cellSize_Sm: number;
71
+ /** Per-cell surface type — pass to `KernelContext.terrainGrid`. */
72
+ terrainGrid: TerrainGrid;
73
+ /** Per-cell cover fraction — pass to `KernelContext.obstacleGrid`. */
74
+ obstacleGrid: ObstacleGrid;
75
+ /** Per-cell elevation above ground [SCALE.m] — pass to `KernelContext.elevationGrid`. */
76
+ elevationGrid: ElevationGrid;
77
+ /** Per-cell slope direction and grade — pass to `KernelContext.slopeGrid`. */
78
+ slopeGrid: SlopeGrid;
79
+ /** Structural cover segments to place in the world before battle begins. */
80
+ coverSegments: CoverSegment[];
81
+ /** Entry spawn positions for each team. */
82
+ entryVectors: EntryVector[];
83
+ /**
84
+ * Dominant surface type — use `SURFACE_TRACTION[dominantSurface]` as
85
+ * the default `KernelContext.tractionCoeff`.
86
+ */
87
+ dominantSurface: SurfaceType;
88
+ }
89
+ /**
90
+ * Context provided to `generateBattleSite`.
91
+ */
92
+ export interface BattleSiteContext {
93
+ /** Campaign hex tile where the battle occurs. */
94
+ hexType: CampaignHexType;
95
+ /** Attacking team ids — they enter from the south (y ≈ 0). */
96
+ attackerTeamIds: number[];
97
+ /** Defending team ids — they enter from the north (y ≈ FIELD_HEIGHT). */
98
+ defenderTeamIds: number[];
99
+ /**
100
+ * World seed from the campaign — reserved for future micro-variance.
101
+ * Currently unused but forwarded for determinism documentation purposes.
102
+ */
103
+ seed?: number;
104
+ }
105
+ /**
106
+ * Outcome produced by the tactical simulation, passed to `mergeBattleOutcome`.
107
+ */
108
+ export interface BattleOutcome {
109
+ /** Final `WorldState` after the tactical battle ends. */
110
+ worldState: WorldState;
111
+ /**
112
+ * Battle duration in simulated seconds, added to `CampaignState.worldTime_s`.
113
+ */
114
+ elapsedSeconds: number;
115
+ /**
116
+ * Entity ids of combatants incapacitated or captured on the losing side.
117
+ * Their weapons and armour will be transferred to the winning team's inventory.
118
+ */
119
+ capturedEntityIds?: number[];
120
+ /**
121
+ * Team id of the victorious side. When `undefined` the battle is a draw and
122
+ * no equipment transfer occurs.
123
+ */
124
+ winnerTeamId?: number;
125
+ }
126
+ /**
127
+ * Build terrain, obstacle, elevation, and slope grids for a campaign hex type.
128
+ *
129
+ * Fully deterministic — produces the same output for the same `hexType` every
130
+ * call. Does not include `entryVectors`; use `generateBattleSite` for a full
131
+ * site including team spawn positions.
132
+ *
133
+ * Grid layout: 10 columns × 8 rows, each cell 10 m (100 000 SCALE.m).
134
+ * Total field: 100 m wide × 80 m deep.
135
+ */
136
+ export declare function extractTerrainParams(hexType: CampaignHexType): Omit<BattleTerrainParams, "entryVectors">;
137
+ /**
138
+ * Generate a complete battle site for a campaign encounter.
139
+ *
140
+ * Calls `extractTerrainParams` and appends `EntryVector` entries for each
141
+ * attacking and defending team.
142
+ *
143
+ * Teams with more than three members reuse spawn positions (cyclic).
144
+ */
145
+ export declare function generateBattleSite(ctx: BattleSiteContext): BattleTerrainParams;
146
+ /**
147
+ * Merge a completed tactical battle back into campaign state.
148
+ *
149
+ * **What this does:**
150
+ * - Advances `campaign.worldTime_s` by `outcome.elapsedSeconds`.
151
+ * - Removes entities that died in battle (`injury.dead === true`) from the
152
+ * campaign entity registry, location map, and inventory map.
153
+ * - Copies post-battle `injury` and `condition` state onto surviving campaign
154
+ * entities so wounds persist between encounters.
155
+ * - Transfers weapons and armour from `capturedEntityIds` to the winning
156
+ * team's first surviving entity inventory (item id → count).
157
+ * - Appends a human-readable battle summary to `campaign.log`.
158
+ *
159
+ * Mutates `campaign` in-place.
160
+ */
161
+ export declare function mergeBattleOutcome(campaign: CampaignState, outcome: BattleOutcome): void;
@@ -0,0 +1,322 @@
1
+ // src/terrain-bridge.ts — PA-5: Campaign ↔ Tactical Terrain Bridge
2
+ //
3
+ // Maps campaign hex tiles to tactical battlefield parameters consumable by
4
+ // KernelContext, and merges tactical battle results back into CampaignState.
5
+ //
6
+ // Typical workflow:
7
+ // 1. Army enters a forest hex on the campaign map.
8
+ // 2. Call generateBattleSite({ hexType: "forest", ... }) → BattleTerrainParams.
9
+ // 3. Build KernelContext from terrain params; run stepWorld until battle ends.
10
+ // 4. Call mergeBattleOutcome(campaign, { worldState, elapsedSeconds }) to
11
+ // apply casualties, injuries, and looted equipment back to campaign.
12
+ import { q } from "./units.js";
13
+ import { buildTerrainGrid, buildObstacleGrid, buildElevationGrid, buildSlopeGrid, terrainKey, } from "./sim/terrain.js";
14
+ import { createCoverSegment } from "./sim/cover.js";
15
+ // ── Field constants ────────────────────────────────────────────────────────────
16
+ /** Battlefield width [SCALE.m]. 100 m. */
17
+ export const FIELD_WIDTH_Sm = 1_000_000;
18
+ /** Battlefield depth [SCALE.m]. 80 m. */
19
+ export const FIELD_HEIGHT_Sm = 800_000;
20
+ /** Terrain cell size [SCALE.m]. 10 m per cell → 10 × 8 grid. */
21
+ export const CELL_SIZE_Sm = 100_000;
22
+ /** Number of grid columns (field width / cell size). */
23
+ export const GRID_COLS = 10;
24
+ /** Number of grid rows (field height / cell size). */
25
+ export const GRID_ROWS = 8;
26
+ /** Attacker spawn y [SCALE.m] — 5 m from the south (y=0) edge. */
27
+ export const ATTACKER_SPAWN_Y_Sm = 50_000;
28
+ /** Defender spawn y [SCALE.m] — 5 m from the north edge. */
29
+ export const DEFENDER_SPAWN_Y_Sm = (FIELD_HEIGHT_Sm - 50_000);
30
+ // ── extractTerrainParams ──────────────────────────────────────────────────────
31
+ /**
32
+ * Build terrain, obstacle, elevation, and slope grids for a campaign hex type.
33
+ *
34
+ * Fully deterministic — produces the same output for the same `hexType` every
35
+ * call. Does not include `entryVectors`; use `generateBattleSite` for a full
36
+ * site including team spawn positions.
37
+ *
38
+ * Grid layout: 10 columns × 8 rows, each cell 10 m (100 000 SCALE.m).
39
+ * Total field: 100 m wide × 80 m deep.
40
+ */
41
+ export function extractTerrainParams(hexType) {
42
+ switch (hexType) {
43
+ case "plains": return _plains();
44
+ case "forest": return _forest();
45
+ case "hills": return _hills();
46
+ case "marsh": return _marsh();
47
+ case "urban": return _urban();
48
+ case "mountain": return _mountain();
49
+ case "river_crossing": return _riverCrossing();
50
+ case "coastal": return _coastal();
51
+ }
52
+ }
53
+ // ── generateBattleSite ────────────────────────────────────────────────────────
54
+ /** Three evenly-spaced x spawn positions (15 m, 50 m, 85 m). */
55
+ const SPAWN_X_Sm = [150_000, 500_000, 850_000];
56
+ /**
57
+ * Generate a complete battle site for a campaign encounter.
58
+ *
59
+ * Calls `extractTerrainParams` and appends `EntryVector` entries for each
60
+ * attacking and defending team.
61
+ *
62
+ * Teams with more than three members reuse spawn positions (cyclic).
63
+ */
64
+ export function generateBattleSite(ctx) {
65
+ const base = extractTerrainParams(ctx.hexType);
66
+ const entryVectors = [];
67
+ for (let i = 0; i < ctx.attackerTeamIds.length; i++) {
68
+ entryVectors.push({
69
+ teamId: ctx.attackerTeamIds[i],
70
+ x_Sm: SPAWN_X_Sm[i % SPAWN_X_Sm.length],
71
+ y_Sm: ATTACKER_SPAWN_Y_Sm,
72
+ facingY: 1,
73
+ });
74
+ }
75
+ for (let i = 0; i < ctx.defenderTeamIds.length; i++) {
76
+ entryVectors.push({
77
+ teamId: ctx.defenderTeamIds[i],
78
+ x_Sm: SPAWN_X_Sm[i % SPAWN_X_Sm.length],
79
+ y_Sm: DEFENDER_SPAWN_Y_Sm,
80
+ facingY: -1,
81
+ });
82
+ }
83
+ return { ...base, entryVectors };
84
+ }
85
+ // ── mergeBattleOutcome ────────────────────────────────────────────────────────
86
+ /**
87
+ * Merge a completed tactical battle back into campaign state.
88
+ *
89
+ * **What this does:**
90
+ * - Advances `campaign.worldTime_s` by `outcome.elapsedSeconds`.
91
+ * - Removes entities that died in battle (`injury.dead === true`) from the
92
+ * campaign entity registry, location map, and inventory map.
93
+ * - Copies post-battle `injury` and `condition` state onto surviving campaign
94
+ * entities so wounds persist between encounters.
95
+ * - Transfers weapons and armour from `capturedEntityIds` to the winning
96
+ * team's first surviving entity inventory (item id → count).
97
+ * - Appends a human-readable battle summary to `campaign.log`.
98
+ *
99
+ * Mutates `campaign` in-place.
100
+ */
101
+ export function mergeBattleOutcome(campaign, outcome) {
102
+ campaign.worldTime_s += outcome.elapsedSeconds;
103
+ const { worldState, capturedEntityIds = [], winnerTeamId } = outcome;
104
+ let killed = 0;
105
+ let survived = 0;
106
+ for (const entity of worldState.entities) {
107
+ const campaignEntity = campaign.entities.get(entity.id);
108
+ if (campaignEntity === undefined)
109
+ continue; // not in this campaign
110
+ if (_isDead(entity)) {
111
+ campaign.entities.delete(entity.id);
112
+ campaign.entityLocations.delete(entity.id);
113
+ campaign.entityInventories.delete(entity.id);
114
+ killed++;
115
+ }
116
+ else {
117
+ // Carry forward post-battle wounds and psychological state
118
+ if (entity.injury !== undefined)
119
+ campaignEntity.injury = entity.injury;
120
+ if (entity.condition !== undefined)
121
+ campaignEntity.condition = entity.condition;
122
+ survived++;
123
+ }
124
+ }
125
+ // Equipment transfer: looted gear from captured/incapacitated enemies
126
+ if (winnerTeamId !== undefined && capturedEntityIds.length > 0) {
127
+ const winnerId = _firstAliveInTeam(worldState, winnerTeamId);
128
+ if (winnerId !== undefined) {
129
+ const winInv = campaign.entityInventories.get(winnerId) ?? new Map();
130
+ for (const capturedId of capturedEntityIds) {
131
+ // Carry items from campaign inventory
132
+ const inv = campaign.entityInventories.get(capturedId);
133
+ if (inv !== undefined) {
134
+ for (const [itemId, count] of inv) {
135
+ winInv.set(itemId, (winInv.get(itemId) ?? 0) + count);
136
+ }
137
+ }
138
+ // Transfer equipped items (weapons, armour) by id
139
+ const capEntity = campaign.entities.get(capturedId);
140
+ if (capEntity !== undefined) {
141
+ for (const item of capEntity.loadout.items) {
142
+ winInv.set(item.id, (winInv.get(item.id) ?? 0) + 1);
143
+ }
144
+ }
145
+ }
146
+ campaign.entityInventories.set(winnerId, winInv);
147
+ }
148
+ }
149
+ const outcome_note = winnerTeamId !== undefined
150
+ ? ` Team ${winnerTeamId} victorious.`
151
+ : " Draw.";
152
+ campaign.log.push({
153
+ worldTime_s: campaign.worldTime_s,
154
+ text: `Battle concluded (${survived} survived, ${killed} killed).${outcome_note}`,
155
+ });
156
+ }
157
+ // ── Internal helpers ──────────────────────────────────────────────────────────
158
+ function _isDead(entity) {
159
+ return entity.injury?.dead === true;
160
+ }
161
+ function _firstAliveInTeam(world, teamId) {
162
+ for (const e of world.entities) {
163
+ if (e.teamId === teamId && !_isDead(e))
164
+ return e.id;
165
+ }
166
+ return undefined;
167
+ }
168
+ function _base() {
169
+ return {
170
+ width_Sm: FIELD_WIDTH_Sm,
171
+ height_Sm: FIELD_HEIGHT_Sm,
172
+ cellSize_Sm: CELL_SIZE_Sm,
173
+ terrainGrid: new Map(),
174
+ obstacleGrid: new Map(),
175
+ elevationGrid: new Map(),
176
+ slopeGrid: new Map(),
177
+ coverSegments: [],
178
+ dominantSurface: "normal",
179
+ };
180
+ }
181
+ function ck(col, row) {
182
+ return terrainKey(col, row);
183
+ }
184
+ /** Plains: open ground, two dirt berm defensive lines. */
185
+ function _plains() {
186
+ const r = _base();
187
+ r.coverSegments.push(createCoverSegment("plains_berm_s", 200_000, 250_000, 600_000, 8_000, "dirt"), createCoverSegment("plains_berm_n", 200_000, 550_000, 600_000, 8_000, "dirt"));
188
+ return r;
189
+ }
190
+ /** Forest: muddy undergrowth, partial cover, two wood tree-lines flanking a central path. */
191
+ function _forest() {
192
+ const r = _base();
193
+ r.dominantSurface = "mud";
194
+ const terrain = {};
195
+ const obstacles = {};
196
+ for (let col = 0; col < GRID_COLS; col++) {
197
+ for (let row = 0; row < GRID_ROWS; row++) {
198
+ const key = ck(col, row);
199
+ if (row !== 3 && row !== 4) { // rows 3-4 = natural path (no mud)
200
+ terrain[key] = "mud";
201
+ obstacles[key] = q(0.40); // dense undergrowth — 40% cover
202
+ }
203
+ }
204
+ }
205
+ r.terrainGrid = buildTerrainGrid(terrain);
206
+ r.obstacleGrid = buildObstacleGrid(obstacles);
207
+ // Tree-lines flanking the path (y = 25 m and y = 50 m)
208
+ r.coverSegments.push(createCoverSegment("forest_tree_s", 0, 250_000, 1_000_000, 12_000, "wood"), createCoverSegment("forest_tree_n", 0, 500_000, 1_000_000, 12_000, "wood"));
209
+ return r;
210
+ }
211
+ /** Hills: gradient elevation south-to-north, stone cover on the crest. */
212
+ function _hills() {
213
+ const r = _base();
214
+ r.dominantSurface = "slope_up";
215
+ const terrain = {};
216
+ const elevation = {};
217
+ const slopes = {};
218
+ for (let col = 0; col < GRID_COLS; col++) {
219
+ for (let row = 0; row < GRID_ROWS; row++) {
220
+ const key = ck(col, row);
221
+ if (row < 4) {
222
+ // South half — uphill approach; elevation rises 5 m per row (0–15 m)
223
+ terrain[key] = "slope_up";
224
+ elevation[key] = (row * 50_000);
225
+ slopes[key] = { type: "uphill", grade: q(0.50) };
226
+ }
227
+ else {
228
+ // North half — downhill on far side of crest
229
+ terrain[key] = "slope_down";
230
+ elevation[key] = ((GRID_ROWS - 1 - row) * 50_000);
231
+ slopes[key] = { type: "downhill", grade: q(0.50) };
232
+ }
233
+ }
234
+ }
235
+ r.terrainGrid = buildTerrainGrid(terrain);
236
+ r.elevationGrid = buildElevationGrid(elevation);
237
+ r.slopeGrid = buildSlopeGrid(slopes);
238
+ // Stone wall along the ridgeline (y ≈ 40 m)
239
+ r.coverSegments.push(createCoverSegment("hills_ridgeline", 100_000, 400_000, 800_000, 10_000, "stone"));
240
+ return r;
241
+ }
242
+ /** Marsh: all-mud terrain, no cover — speed heavily penalised. */
243
+ function _marsh() {
244
+ const r = _base();
245
+ r.dominantSurface = "mud";
246
+ const terrain = {};
247
+ for (let col = 0; col < GRID_COLS; col++) {
248
+ for (let row = 0; row < GRID_ROWS; row++) {
249
+ terrain[ck(col, row)] = "mud";
250
+ }
251
+ }
252
+ r.terrainGrid = buildTerrainGrid(terrain);
253
+ return r;
254
+ }
255
+ /** Urban: dense stone and wood cover forming a street grid; partial obstacle cells. */
256
+ function _urban() {
257
+ const r = _base();
258
+ // Stone building walls — south block, mid block, north block
259
+ r.coverSegments.push(createCoverSegment("urban_s1", 0, 200_000, 350_000, 20_000, "stone"), createCoverSegment("urban_s2", 450_000, 200_000, 350_000, 20_000, "stone"), createCoverSegment("urban_s3", 200_000, 300_000, 200_000, 20_000, "stone"), createCoverSegment("urban_m1", 0, 400_000, 250_000, 20_000, "stone"), createCoverSegment("urban_m2", 350_000, 400_000, 300_000, 20_000, "stone"), createCoverSegment("urban_m3", 750_000, 400_000, 250_000, 20_000, "stone"), createCoverSegment("urban_n1", 100_000, 600_000, 350_000, 20_000, "stone"), createCoverSegment("urban_n2", 550_000, 600_000, 350_000, 20_000, "stone"), createCoverSegment("urban_barr1", 350_000, 350_000, 100_000, 10_000, "wood"), createCoverSegment("urban_barr2", 350_000, 500_000, 100_000, 10_000, "wood"));
260
+ // Partial obstacles in building interiors (~50% cover)
261
+ r.obstacleGrid = buildObstacleGrid({
262
+ [ck(1, 2)]: q(0.50),
263
+ [ck(5, 2)]: q(0.50),
264
+ [ck(2, 4)]: q(0.50),
265
+ [ck(7, 4)]: q(0.50),
266
+ });
267
+ return r;
268
+ }
269
+ /** Mountain: steep icy ascent, high elevation, rocky outcrops. */
270
+ function _mountain() {
271
+ const r = _base();
272
+ r.dominantSurface = "slope_up";
273
+ const terrain = {};
274
+ const elevation = {};
275
+ const slopes = {};
276
+ for (let col = 0; col < GRID_COLS; col++) {
277
+ for (let row = 0; row < GRID_ROWS; row++) {
278
+ const key = ck(col, row);
279
+ elevation[key] = (row * 100_000); // 0–70 m rise
280
+ terrain[key] = row >= 4 ? "ice" : "slope_up";
281
+ slopes[key] = {
282
+ type: "uphill",
283
+ grade: (row >= 4 ? q(0.80) : q(0.60)),
284
+ };
285
+ }
286
+ }
287
+ r.terrainGrid = buildTerrainGrid(terrain);
288
+ r.elevationGrid = buildElevationGrid(elevation);
289
+ r.slopeGrid = buildSlopeGrid(slopes);
290
+ // Rocky outcrops for cover
291
+ r.coverSegments.push(createCoverSegment("mtn_rock1", 100_000, 200_000, 150_000, 15_000, "stone"), createCoverSegment("mtn_rock2", 600_000, 350_000, 200_000, 15_000, "stone"), createCoverSegment("mtn_rock3", 300_000, 500_000, 150_000, 15_000, "stone"));
292
+ return r;
293
+ }
294
+ /** River crossing: muddy ford in the center, sandbag cover on the defending bank. */
295
+ function _riverCrossing() {
296
+ const r = _base();
297
+ r.dominantSurface = "mud";
298
+ // Mud band at rows 3-4 (30–50 m) — the ford
299
+ const terrain = {};
300
+ for (let col = 0; col < GRID_COLS; col++) {
301
+ terrain[ck(col, 3)] = "mud";
302
+ terrain[ck(col, 4)] = "mud";
303
+ }
304
+ r.terrainGrid = buildTerrainGrid(terrain);
305
+ // Sandbag defensive line on the far (north) bank
306
+ r.coverSegments.push(createCoverSegment("river_cover_w", 50_000, 500_000, 350_000, 12_000, "sandbag"), createCoverSegment("river_cover_e", 600_000, 500_000, 350_000, 12_000, "sandbag"));
307
+ return r;
308
+ }
309
+ /** Coastal: muddy beach approach (south 2 rows), dunes and rocky outcrops for cover. */
310
+ function _coastal() {
311
+ const r = _base();
312
+ // Soft sand / surf zone — rows 0-1 (0–20 m from south)
313
+ const terrain = {};
314
+ for (let col = 0; col < GRID_COLS; col++) {
315
+ terrain[ck(col, 0)] = "mud";
316
+ terrain[ck(col, 1)] = "mud";
317
+ }
318
+ r.terrainGrid = buildTerrainGrid(terrain);
319
+ // Dunes and rocky coastal outcrops
320
+ r.coverSegments.push(createCoverSegment("coastal_dune1", 100_000, 150_000, 300_000, 8_000, "dirt"), createCoverSegment("coastal_dune2", 600_000, 150_000, 300_000, 8_000, "dirt"), createCoverSegment("coastal_rock1", 0, 350_000, 200_000, 12_000, "stone"), createCoverSegment("coastal_rock2", 800_000, 450_000, 200_000, 12_000, "stone"));
321
+ return r;
322
+ }
@@ -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.52",
3
+ "version": "0.1.54",
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,14 @@
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"
184
+ },
185
+ "./terrain-bridge": {
186
+ "import": "./dist/src/terrain-bridge.js",
187
+ "types": "./dist/src/terrain-bridge.d.ts"
177
188
  }
178
189
  },
179
190
  "workspaces": [
@@ -194,6 +205,7 @@
194
205
  "docs/wire-protocol.md",
195
206
  "schema/world.schema.json",
196
207
  "schema/replay.schema.json",
208
+ "schema/pack.schema.json",
197
209
  "CHANGELOG.md",
198
210
  "STABLE_API.md"
199
211
  ],
@@ -239,6 +251,7 @@
239
251
  "example:campaign": "node dist/examples/quickstart-campaign.js",
240
252
  "example:species": "node dist/examples/quickstart-species.js",
241
253
  "generate-module-index": "node dist/tools/generate-module-index.js",
254
+ "pack": "node dist/tools/pack-cli.js pack",
242
255
  "generate-fixtures": "node dist/tools/generate-fixtures.js",
243
256
  "generate-zoo": "node dist/tools/generate-zoo.js",
244
257
  "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
+ }