@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 +32 -0
- package/dist/src/content-pack.d.ts +105 -0
- package/dist/src/content-pack.js +261 -0
- package/dist/src/terrain-bridge.d.ts +161 -0
- package/dist/src/terrain-bridge.js +322 -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 +14 -1
- package/schema/pack.schema.json +138 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,38 @@ Versioning follows [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
+
## [0.1.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.
|
|
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
|
+
}
|