@its-not-rocket-science/ananke 0.1.4 → 0.1.6

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
@@ -10,6 +10,49 @@ Versioning follows [Semantic Versioning](https://semver.org/).
10
10
 
11
11
  ---
12
12
 
13
+ ## [0.1.6] — 2026-03-23
14
+
15
+ ### Added
16
+
17
+ - **CE-15 · Dynamic Terrain Cover System** (`src/sim/cover.ts`)
18
+ - CoverSegment type: axis-aligned obstacle with material, height, burn state
19
+ - isLineOfSightBlocked(): pure integer segment-intersection test (no sqrt)
20
+ - computeCoverProtection(): multiplicative absorption across stacked cover
21
+ - arcClearsCover(): indirect/lob fire height check
22
+ - applyExplosionToTerrain(): proximity-scaled crater + wood ignition
23
+ - stepCoverDecay(): wood burn-out and crater erosion over real time
24
+ - 4 sample presets: stone wall, sandbag barricade, wooden palisade, dirt berm
25
+ - 60 tests
26
+ - Export via src/index.ts
27
+
28
+ ---
29
+
30
+ ## [0.1.5] — 2026-03-21
31
+
32
+ ### Added
33
+
34
+ - **CE-12 · Data-Driven Entity Catalog** (`src/catalog.ts`, `./catalog` subpath export)
35
+ - `registerArchetype(json)` — parse JSON archetype with base inheritance (`HUMAN_BASE`,
36
+ `AMATEUR_BOXER`, `SERVICE_ROBOT`, etc.) and SI → SCALE unit conversion
37
+ - `registerWeapon(json)` — parse JSON weapon with damage profile; `reach_m` / `readyTime_s`
38
+ converted to SCALE; all ratio fields → Q
39
+ - `registerArmour(json)` — parse JSON armour; `protects` from channel-name strings →
40
+ `ChannelMask`; `coverageByRegion` values → Q
41
+ - `getCatalogEntry(id)` / `listCatalog(kind?)` / `unregisterCatalogEntry(id)` /
42
+ `clearCatalog()` for lifecycle management
43
+ - All numeric values in JSON are real-world SI units; conversion is automatic
44
+
45
+ - **Phase 68 · Multi-Biome Physics** (`src/sim/biome.ts`)
46
+ - `BiomeContext` interface with `gravity_mps2`, `thermalResistanceBase`, `dragMul`,
47
+ `soundPropagation`, `isVacuum` overrides
48
+ - Built-in profiles: `BIOME_UNDERWATER`, `BIOME_LUNAR`, `BIOME_VACUUM`
49
+ - Gravity threads into `deriveMovementCaps` (jump height, traction); drag applied per tick
50
+ in movement step; thermal resistance base overrides `stepCoreTemp`; vacuum fatigue
51
+ accumulates in kernel (+3 Q/tick)
52
+ - `KernelContext.biome?` field; fully backwards-compatible (absent = Earth defaults)
53
+
54
+ ---
55
+
13
56
  ## [0.1.4] — 2026-03-20
14
57
 
15
58
  ### Added
@@ -0,0 +1,86 @@
1
+ /**
2
+ * CE-12 — Data-Driven Entity Catalog
3
+ *
4
+ * Allows archetypes, weapons, and armour to be defined in JSON (e.g. loaded from a file
5
+ * or authored by non-TypeScript content creators) and registered at runtime.
6
+ *
7
+ * All numeric values in JSON are **real-world SI units**:
8
+ * mass_kg → real kilograms (e.g. 110)
9
+ * stature_m → real metres (e.g. 1.9)
10
+ * force_N → real Newtons (e.g. 3200)
11
+ * power_W → real Watts (e.g. 1400)
12
+ * energy_J → real Joules (e.g. 25000)
13
+ * time_s → real seconds (e.g. 0.22)
14
+ * Q-fields → ratio [0..1] (e.g. 0.65)
15
+ *
16
+ * The catalog converts these to internal fixed-point SCALE units on registration.
17
+ * getCatalogEntry(id) returns the already-converted typed object.
18
+ */
19
+ import type { Archetype } from "./archetypes.js";
20
+ import type { Weapon, Armour } from "./equipment.js";
21
+ export type CatalogKind = "archetype" | "weapon" | "armour";
22
+ export type CatalogEntry = {
23
+ kind: "archetype";
24
+ id: string;
25
+ archetype: Archetype;
26
+ } | {
27
+ kind: "weapon";
28
+ id: string;
29
+ weapon: Weapon;
30
+ } | {
31
+ kind: "armour";
32
+ id: string;
33
+ armour: Armour;
34
+ };
35
+ /**
36
+ * Parse a JSON archetype definition and register it in the catalog.
37
+ *
38
+ * @param json - Raw JSON value (e.g. from JSON.parse). Must have:
39
+ * - `id` (string): unique catalog identifier
40
+ * - `base` (string, optional): name of a built-in archetype to inherit from
41
+ * - `overrides` (object, optional): field values to override in real SI units
42
+ * @returns The converted Archetype object.
43
+ * @throws If required fields are missing, values are out of range, or `id` already registered.
44
+ */
45
+ export declare function registerArchetype(json: unknown): Archetype;
46
+ /**
47
+ * Parse a JSON weapon definition and register it in the catalog.
48
+ *
49
+ * @param json - Raw JSON value. Required fields:
50
+ * - `id`, `name` (string)
51
+ * - `mass_kg` (real kg), `bulk` (Q 0..1)
52
+ * - `damage` (object with surfaceFrac, internalFrac, structuralFrac, bleedFactor, penetrationBias)
53
+ * @returns The converted Weapon object.
54
+ * @throws If required fields are missing or `id` already registered.
55
+ */
56
+ export declare function registerWeapon(json: unknown): Weapon;
57
+ /**
58
+ * Parse a JSON armour definition and register it in the catalog.
59
+ *
60
+ * @param json - Raw JSON value. Required fields:
61
+ * - `id`, `name` (string)
62
+ * - `mass_kg` (real kg), `bulk` (Q 0..1)
63
+ * - `resist_J` (real Joules), `protectedDamageMul` (Q 0..1)
64
+ * - `coverageByRegion` (object mapping region name → Q 0..1)
65
+ * @returns The converted Armour object.
66
+ * @throws If required fields are missing or `id` already registered.
67
+ */
68
+ export declare function registerArmour(json: unknown): Armour;
69
+ /**
70
+ * Look up a registered entry by id.
71
+ * Returns the CatalogEntry or undefined if not found.
72
+ */
73
+ export declare function getCatalogEntry(id: string): CatalogEntry | undefined;
74
+ /**
75
+ * Return all registered ids of the given kind, or all ids when kind is omitted.
76
+ */
77
+ export declare function listCatalog(kind?: CatalogKind): string[];
78
+ /**
79
+ * Remove a registered entry. Useful in tests.
80
+ * Returns true if the entry existed and was removed.
81
+ */
82
+ export declare function unregisterCatalogEntry(id: string): boolean;
83
+ /**
84
+ * Remove all registered entries. Useful for resetting state in tests.
85
+ */
86
+ export declare function clearCatalog(): void;
@@ -0,0 +1,393 @@
1
+ /**
2
+ * CE-12 — Data-Driven Entity Catalog
3
+ *
4
+ * Allows archetypes, weapons, and armour to be defined in JSON (e.g. loaded from a file
5
+ * or authored by non-TypeScript content creators) and registered at runtime.
6
+ *
7
+ * All numeric values in JSON are **real-world SI units**:
8
+ * mass_kg → real kilograms (e.g. 110)
9
+ * stature_m → real metres (e.g. 1.9)
10
+ * force_N → real Newtons (e.g. 3200)
11
+ * power_W → real Watts (e.g. 1400)
12
+ * energy_J → real Joules (e.g. 25000)
13
+ * time_s → real seconds (e.g. 0.22)
14
+ * Q-fields → ratio [0..1] (e.g. 0.65)
15
+ *
16
+ * The catalog converts these to internal fixed-point SCALE units on registration.
17
+ * getCatalogEntry(id) returns the already-converted typed object.
18
+ */
19
+ import { q, to } from "./units.js";
20
+ import { HUMAN_BASE, AMATEUR_BOXER, PRO_BOXER, GRECO_WRESTLER, KNIGHT_INFANTRY, LARGE_PACIFIC_OCTOPUS, SERVICE_ROBOT, } from "./archetypes.js";
21
+ import { DamageChannel, channelMask } from "./channels.js";
22
+ const _store = new Map();
23
+ // ── Built-in archetype bases ──────────────────────────────────────────────────
24
+ const ARCHETYPE_BASES = {
25
+ HUMAN_BASE,
26
+ AMATEUR_BOXER,
27
+ PRO_BOXER,
28
+ GRECO_WRESTLER,
29
+ KNIGHT_INFANTRY,
30
+ LARGE_PACIFIC_OCTOPUS,
31
+ SERVICE_ROBOT,
32
+ };
33
+ // ── JSON parsing helpers ──────────────────────────────────────────────────────
34
+ function assertObj(v, ctx) {
35
+ if (typeof v !== "object" || v === null || Array.isArray(v))
36
+ throw new Error(`${ctx}: expected object, got ${Array.isArray(v) ? "array" : typeof v}`);
37
+ return v;
38
+ }
39
+ function requireStr(obj, field, ctx) {
40
+ const v = obj[field];
41
+ if (typeof v !== "string")
42
+ throw new Error(`${ctx}: "${field}" must be a string`);
43
+ return v;
44
+ }
45
+ function requireNum(obj, field, ctx) {
46
+ const v = obj[field];
47
+ if (typeof v !== "number" || !Number.isFinite(v))
48
+ throw new Error(`${ctx}: "${field}" must be a finite number`);
49
+ return v;
50
+ }
51
+ function optNum(obj, field) {
52
+ const v = obj[field];
53
+ return typeof v === "number" && Number.isFinite(v) ? v : undefined;
54
+ }
55
+ function optStr(obj, field) {
56
+ const v = obj[field];
57
+ return typeof v === "string" ? v : undefined;
58
+ }
59
+ function optBool(obj, field) {
60
+ const v = obj[field];
61
+ return typeof v === "boolean" ? v : undefined;
62
+ }
63
+ /** Convert a JSON [0..N] float to internal Q integer. Allows values slightly above 1.0 (e.g. q(1.2) for service-robot integrity). */
64
+ function parseQ(v, field, ctx) {
65
+ if (v < 0)
66
+ throw new Error(`${ctx}: Q field "${field}" must be ≥ 0, got ${v}`);
67
+ if (v > 5)
68
+ throw new Error(`${ctx}: Q field "${field}" must be ≤ 5, got ${v}`);
69
+ return q(v);
70
+ }
71
+ function parseQField(obj, field, ctx) {
72
+ return parseQ(requireNum(obj, field, ctx), field, ctx);
73
+ }
74
+ function optQField(obj, field, ctx) {
75
+ const v = optNum(obj, field);
76
+ return v !== undefined ? parseQ(v, field, ctx) : undefined;
77
+ }
78
+ const ARCHETYPE_FIELD_SPEC = {
79
+ stature_m: "m",
80
+ mass_kg: "kg",
81
+ visionRange_m: "m",
82
+ visionArcDeg: "plain",
83
+ hearingRange_m: "m",
84
+ decisionLatency_s: "s",
85
+ attentionDepth: "plain",
86
+ threatHorizon_m: "m",
87
+ statureVar: "Q",
88
+ massVar: "Q",
89
+ reachVar: "Q",
90
+ actuatorScaleVar: "Q",
91
+ structureScaleVar: "Q",
92
+ actuatorMassFrac: "Q",
93
+ actuatorMassVar: "Q",
94
+ peakForce_N: "N",
95
+ peakForceVar: "Q",
96
+ peakPower_W: "W",
97
+ peakPowerVar: "Q",
98
+ continuousPower_W: "W",
99
+ continuousPowerVar: "Q",
100
+ reserveEnergy_J: "J",
101
+ reserveEnergyVar: "Q",
102
+ conversionEfficiency: "Q",
103
+ efficiencyVar: "Q",
104
+ reactionTime_s: "s",
105
+ reactionTimeVar: "Q",
106
+ controlQuality: "Q",
107
+ controlVar: "Q",
108
+ stability: "Q",
109
+ stabilityVar: "Q",
110
+ fineControl: "Q",
111
+ fineControlVar: "Q",
112
+ surfaceIntegrity: "Q",
113
+ surfaceVar: "Q",
114
+ bulkIntegrity: "Q",
115
+ bulkVar: "Q",
116
+ structureIntegrity: "Q",
117
+ structVar: "Q",
118
+ distressTolerance: "Q",
119
+ distressVar: "Q",
120
+ shockTolerance: "Q",
121
+ shockVar: "Q",
122
+ concussionTolerance: "Q",
123
+ concVar: "Q",
124
+ heatTolerance: "Q",
125
+ heatVar: "Q",
126
+ coldTolerance: "Q",
127
+ coldVar: "Q",
128
+ fatigueRate: "Q",
129
+ fatigueVar: "Q",
130
+ recoveryRate: "Q",
131
+ recoveryVar: "Q",
132
+ };
133
+ /** Convert a raw JSON number using the spec for that field. */
134
+ function convertArchField(value, spec, field, ctx) {
135
+ switch (spec) {
136
+ case "m": return to.m(value);
137
+ case "kg": return to.kg(value);
138
+ case "N": return to.N(value);
139
+ case "W": return to.W(value);
140
+ case "J": return to.J(value);
141
+ case "s": return to.s(value);
142
+ case "Q": return parseQ(value, field, ctx);
143
+ case "plain": return Math.round(value);
144
+ }
145
+ }
146
+ /** Parse a JSON CognitiveProfile object (all fields Q). */
147
+ function parseCognition(raw, ctx) {
148
+ const obj = assertObj(raw, `${ctx}.cognition`);
149
+ const fields = [
150
+ "linguistic", "logicalMathematical", "spatial", "bodilyKinesthetic",
151
+ "musical", "interpersonal", "intrapersonal", "naturalist", "interSpecies",
152
+ ];
153
+ const result = {};
154
+ for (const f of fields) {
155
+ result[f] = parseQField(obj, f, `${ctx}.cognition`);
156
+ }
157
+ return result;
158
+ }
159
+ // ── registerArchetype ─────────────────────────────────────────────────────────
160
+ /**
161
+ * Parse a JSON archetype definition and register it in the catalog.
162
+ *
163
+ * @param json - Raw JSON value (e.g. from JSON.parse). Must have:
164
+ * - `id` (string): unique catalog identifier
165
+ * - `base` (string, optional): name of a built-in archetype to inherit from
166
+ * - `overrides` (object, optional): field values to override in real SI units
167
+ * @returns The converted Archetype object.
168
+ * @throws If required fields are missing, values are out of range, or `id` already registered.
169
+ */
170
+ export function registerArchetype(json) {
171
+ const ctx = "registerArchetype";
172
+ const obj = assertObj(json, ctx);
173
+ const id = requireStr(obj, "id", ctx);
174
+ if (_store.has(id))
175
+ throw new Error(`${ctx}: id "${id}" is already registered`);
176
+ // Start from base archetype (all fields present)
177
+ const baseName = optStr(obj, "base");
178
+ const base = baseName
179
+ ? (ARCHETYPE_BASES[baseName] ?? (() => { throw new Error(`${ctx}: unknown base "${baseName}"`); })())
180
+ : HUMAN_BASE;
181
+ const result = { ...base };
182
+ // Apply overrides
183
+ const rawOverrides = obj["overrides"];
184
+ if (rawOverrides !== undefined) {
185
+ const overrides = assertObj(rawOverrides, `${ctx}.overrides`);
186
+ for (const [field, rawVal] of Object.entries(overrides)) {
187
+ if (field === "cognition")
188
+ continue; // handled separately below
189
+ const spec = ARCHETYPE_FIELD_SPEC[field];
190
+ if (spec === undefined)
191
+ throw new Error(`${ctx}: unknown archetype field "${field}"`);
192
+ if (typeof rawVal !== "number")
193
+ throw new Error(`${ctx}: override field "${field}" must be a number`);
194
+ result[field] = convertArchField(rawVal, spec, field, ctx);
195
+ }
196
+ // Cognition override
197
+ if (overrides["cognition"] !== undefined) {
198
+ result.cognition = parseCognition(overrides["cognition"], ctx);
199
+ }
200
+ }
201
+ _store.set(id, { kind: "archetype", id, archetype: result });
202
+ return result;
203
+ }
204
+ // ── registerWeapon ────────────────────────────────────────────────────────────
205
+ /** Valid handedness strings. */
206
+ const VALID_HANDEDNESS = new Set(["oneHand", "twoHand", "mounted", "natural"]);
207
+ /**
208
+ * Parse a JSON weapon definition and register it in the catalog.
209
+ *
210
+ * @param json - Raw JSON value. Required fields:
211
+ * - `id`, `name` (string)
212
+ * - `mass_kg` (real kg), `bulk` (Q 0..1)
213
+ * - `damage` (object with surfaceFrac, internalFrac, structuralFrac, bleedFactor, penetrationBias)
214
+ * @returns The converted Weapon object.
215
+ * @throws If required fields are missing or `id` already registered.
216
+ */
217
+ export function registerWeapon(json) {
218
+ const ctx = "registerWeapon";
219
+ const obj = assertObj(json, ctx);
220
+ const id = requireStr(obj, "id", ctx);
221
+ const name = requireStr(obj, "name", ctx);
222
+ if (_store.has(id))
223
+ throw new Error(`${ctx}: id "${id}" is already registered`);
224
+ const mass_kg = to.kg(requireNum(obj, "mass_kg", ctx));
225
+ const bulk = parseQField(obj, "bulk", ctx);
226
+ // Optional movement/handling fields
227
+ const reach_m_raw = optNum(obj, "reach_m");
228
+ const readyTime_s_raw = optNum(obj, "readyTime_s");
229
+ const handlingMul_raw = optNum(obj, "handlingMul");
230
+ // Damage profile
231
+ const dmgRaw = assertObj(obj["damage"], `${ctx}.damage`);
232
+ const damage = {
233
+ surfaceFrac: parseQField(dmgRaw, "surfaceFrac", `${ctx}.damage`),
234
+ internalFrac: parseQField(dmgRaw, "internalFrac", `${ctx}.damage`),
235
+ structuralFrac: parseQField(dmgRaw, "structuralFrac", `${ctx}.damage`),
236
+ bleedFactor: parseQField(dmgRaw, "bleedFactor", `${ctx}.damage`),
237
+ penetrationBias: parseQField(dmgRaw, "penetrationBias", `${ctx}.damage`),
238
+ };
239
+ const weapon = {
240
+ kind: "weapon",
241
+ id,
242
+ name,
243
+ mass_kg,
244
+ bulk,
245
+ damage,
246
+ ...(reach_m_raw !== undefined ? { reach_m: to.m(reach_m_raw) } : {}),
247
+ ...(readyTime_s_raw !== undefined ? { readyTime_s: to.s(readyTime_s_raw) } : {}),
248
+ ...(handlingMul_raw !== undefined ? { handlingMul: parseQ(handlingMul_raw, "handlingMul", ctx) } : {}),
249
+ };
250
+ // Optional extra fields
251
+ const strikeEffMassFrac = optNum(obj, "strikeEffectiveMassFrac");
252
+ const strikeSpeedMul = optNum(obj, "strikeSpeedMul");
253
+ const momentArm_m = optNum(obj, "momentArm_m");
254
+ const handlingLoadMul = optNum(obj, "handlingLoadMul");
255
+ const shieldBypassQ = optNum(obj, "shieldBypassQ");
256
+ const handedness = optStr(obj, "handedness");
257
+ if (strikeEffMassFrac !== undefined)
258
+ weapon.strikeEffectiveMassFrac = parseQ(strikeEffMassFrac, "strikeEffectiveMassFrac", ctx);
259
+ if (strikeSpeedMul !== undefined)
260
+ weapon.strikeSpeedMul = parseQ(strikeSpeedMul, "strikeSpeedMul", ctx);
261
+ if (momentArm_m !== undefined)
262
+ weapon.momentArm_m = momentArm_m;
263
+ if (handlingLoadMul !== undefined)
264
+ weapon.handlingLoadMul = parseQ(handlingLoadMul, "handlingLoadMul", ctx);
265
+ if (shieldBypassQ !== undefined)
266
+ weapon.shieldBypassQ = parseQ(shieldBypassQ, "shieldBypassQ", ctx);
267
+ if (handedness !== undefined) {
268
+ if (!VALID_HANDEDNESS.has(handedness))
269
+ throw new Error(`${ctx}: "handedness" must be one of ${[...VALID_HANDEDNESS].join("|")}, got "${handedness}"`);
270
+ weapon.handedness = handedness;
271
+ }
272
+ _store.set(id, { kind: "weapon", id, weapon });
273
+ return weapon;
274
+ }
275
+ // ── registerArmour ────────────────────────────────────────────────────────────
276
+ /** Map from JSON channel name strings to DamageChannel enum. */
277
+ const CHANNEL_MAP = {
278
+ Kinetic: DamageChannel.Kinetic,
279
+ Thermal: DamageChannel.Thermal,
280
+ Electrical: DamageChannel.Electrical,
281
+ Chemical: DamageChannel.Chemical,
282
+ Radiation: DamageChannel.Radiation,
283
+ Corrosive: DamageChannel.Corrosive,
284
+ Suffocation: DamageChannel.Suffocation,
285
+ ControlDisruption: DamageChannel.ControlDisruption,
286
+ Energy: DamageChannel.Energy,
287
+ };
288
+ /**
289
+ * Parse a JSON armour definition and register it in the catalog.
290
+ *
291
+ * @param json - Raw JSON value. Required fields:
292
+ * - `id`, `name` (string)
293
+ * - `mass_kg` (real kg), `bulk` (Q 0..1)
294
+ * - `resist_J` (real Joules), `protectedDamageMul` (Q 0..1)
295
+ * - `coverageByRegion` (object mapping region name → Q 0..1)
296
+ * @returns The converted Armour object.
297
+ * @throws If required fields are missing or `id` already registered.
298
+ */
299
+ export function registerArmour(json) {
300
+ const ctx = "registerArmour";
301
+ const obj = assertObj(json, ctx);
302
+ const id = requireStr(obj, "id", ctx);
303
+ const name = requireStr(obj, "name", ctx);
304
+ if (_store.has(id))
305
+ throw new Error(`${ctx}: id "${id}" is already registered`);
306
+ const mass_kg = to.kg(requireNum(obj, "mass_kg", ctx));
307
+ const bulk = parseQField(obj, "bulk", ctx);
308
+ const resist_J = to.J(requireNum(obj, "resist_J", ctx));
309
+ const protectedDamageMul = parseQField(obj, "protectedDamageMul", ctx);
310
+ // protects: array of channel name strings → ChannelMask
311
+ let protects = 0;
312
+ const protectsRaw = obj["protects"];
313
+ if (Array.isArray(protectsRaw)) {
314
+ for (const ch of protectsRaw) {
315
+ if (typeof ch !== "string")
316
+ throw new Error(`${ctx}: "protects" array must contain strings`);
317
+ const ch_enum = CHANNEL_MAP[ch];
318
+ if (ch_enum === undefined)
319
+ throw new Error(`${ctx}: unknown damage channel "${ch}" in "protects"`);
320
+ protects = channelMask(ch_enum) | protects;
321
+ }
322
+ }
323
+ else {
324
+ // Default: Kinetic only
325
+ protects = channelMask(DamageChannel.Kinetic);
326
+ }
327
+ // coverageByRegion: object of region → Q
328
+ const coverageRaw = assertObj(obj["coverageByRegion"], `${ctx}.coverageByRegion`);
329
+ const coverageByRegion = {};
330
+ for (const [region, val] of Object.entries(coverageRaw)) {
331
+ if (typeof val !== "number")
332
+ throw new Error(`${ctx}.coverageByRegion: region "${region}" value must be a number`);
333
+ coverageByRegion[region] = parseQ(val, region, `${ctx}.coverageByRegion`);
334
+ }
335
+ const armour = {
336
+ kind: "armour",
337
+ id,
338
+ name,
339
+ mass_kg,
340
+ bulk,
341
+ resist_J,
342
+ protectedDamageMul,
343
+ protects,
344
+ coverageByRegion,
345
+ };
346
+ // Optional fields
347
+ const mobilityMul = optQField(obj, "mobilityMul", ctx);
348
+ const fatigueMul = optQField(obj, "fatigueMul", ctx);
349
+ const reflectivity = optQField(obj, "reflectivity", ctx);
350
+ const ablative = optBool(obj, "ablative");
351
+ const insulation_m2KW = optNum(obj, "insulation_m2KW");
352
+ if (mobilityMul !== undefined)
353
+ armour.mobilityMul = mobilityMul;
354
+ if (fatigueMul !== undefined)
355
+ armour.fatigueMul = fatigueMul;
356
+ if (reflectivity !== undefined)
357
+ armour.reflectivity = reflectivity;
358
+ if (ablative !== undefined)
359
+ armour.ablative = ablative;
360
+ if (insulation_m2KW !== undefined)
361
+ armour.insulation_m2KW = insulation_m2KW;
362
+ _store.set(id, { kind: "armour", id, armour });
363
+ return armour;
364
+ }
365
+ // ── getCatalogEntry ───────────────────────────────────────────────────────────
366
+ /**
367
+ * Look up a registered entry by id.
368
+ * Returns the CatalogEntry or undefined if not found.
369
+ */
370
+ export function getCatalogEntry(id) {
371
+ return _store.get(id);
372
+ }
373
+ /**
374
+ * Return all registered ids of the given kind, or all ids when kind is omitted.
375
+ */
376
+ export function listCatalog(kind) {
377
+ if (kind === undefined)
378
+ return [..._store.keys()];
379
+ return [..._store.entries()].filter(([, e]) => e.kind === kind).map(([id]) => id);
380
+ }
381
+ /**
382
+ * Remove a registered entry. Useful in tests.
383
+ * Returns true if the entry existed and was removed.
384
+ */
385
+ export function unregisterCatalogEntry(id) {
386
+ return _store.delete(id);
387
+ }
388
+ /**
389
+ * Remove all registered entries. Useful for resetting state in tests.
390
+ */
391
+ export function clearCatalog() {
392
+ _store.clear();
393
+ }
@@ -1,4 +1,4 @@
1
- import { Q } from "./units.js";
1
+ import { Q, type I32 } from "./units.js";
2
2
  import type { IndividualAttributes, EnergyState } from "./types.js";
3
3
  import type { Loadout } from "./equipment.js";
4
4
  import { type CarryRules } from "./equipment.js";
@@ -10,12 +10,14 @@ export interface MovementCaps {
10
10
  export interface DeriveContext {
11
11
  tractionCoeff: Q;
12
12
  carryRules?: CarryRules;
13
+ /** Phase 68: override gravitational acceleration (SCALE.mps2 units). Default: G_mps2. */
14
+ gravity_mps2?: I32;
13
15
  }
14
16
  /** Fraction of reserve energy that can be spent on a single jump (~0.0283). */
15
17
  export declare const JUMP_ENERGY_FRACTION: number;
16
18
  export declare function derivePeakForceEff_N(a: IndividualAttributes): number;
17
- export declare function deriveMaxAcceleration_mps2(a: IndividualAttributes, tractionCoeff: Q): number;
19
+ export declare function deriveMaxAcceleration_mps2(a: IndividualAttributes, tractionCoeff: Q, gravity_mps2?: I32): number;
18
20
  export declare function deriveMaxSprintSpeed_mps(a: IndividualAttributes): number;
19
- export declare function deriveJumpHeight_m(a: IndividualAttributes, reserveSpend_J: number): number;
21
+ export declare function deriveJumpHeight_m(a: IndividualAttributes, reserveSpend_J: number, gravity_mps2?: I32): number;
20
22
  export declare function deriveMovementCaps(a: IndividualAttributes, loadout: Loadout, ctx: DeriveContext): MovementCaps;
21
23
  export declare function stepEnergyAndFatigue(a: IndividualAttributes, state: EnergyState, loadout: Loadout, demandedPower_W: number, dt_s: number, ctx: DeriveContext): void;
@@ -8,10 +8,10 @@ export function derivePeakForceEff_N(a) {
8
8
  const combined = qMul(a.morphology.actuatorScale, controlFactor);
9
9
  return mulDiv(F0, combined, SCALE.Q);
10
10
  }
11
- export function deriveMaxAcceleration_mps2(a, tractionCoeff) {
11
+ export function deriveMaxAcceleration_mps2(a, tractionCoeff, gravity_mps2 = G_mps2) {
12
12
  const m = Math.max(1, a.morphology.mass_kg);
13
- // normalForce ~ m*g. Use g~9.81 and keep deterministic:
14
- const normalForce_N_scaled = mulDiv(m, G_mps2 * SCALE.N, SCALE.mps2); // scaled N
13
+ // normalForce ~ m*g. Use supplied gravity and keep deterministic:
14
+ const normalForce_N_scaled = mulDiv(m, gravity_mps2 * SCALE.N, SCALE.mps2); // scaled N
15
15
  const tractionLimit_N = mulDiv(normalForce_N_scaled, tractionCoeff, SCALE.Q);
16
16
  const F_eff = derivePeakForceEff_N(a);
17
17
  const usable_N = Math.min(F_eff, tractionLimit_N);
@@ -29,14 +29,16 @@ export function deriveMaxSprintSpeed_mps(a) {
29
29
  const mult = qMul(qMul(qMul(qMul(K, c), reachSqrt), controlFactor), a.performance.conversionEfficiency);
30
30
  return mulDiv(mult, SCALE.mps, SCALE.Q);
31
31
  }
32
- export function deriveJumpHeight_m(a, reserveSpend_J) {
32
+ export function deriveJumpHeight_m(a, reserveSpend_J, gravity_mps2 = G_mps2) {
33
33
  const m = Math.max(1, a.morphology.mass_kg);
34
34
  const Euse = Math.min(a.performance.reserveEnergy_J, reserveSpend_J);
35
35
  const controlFactor = q(0.7) + qMul(q(0.3), a.control.controlQuality);
36
36
  const Eeff = mulDiv(mulDiv(Euse, a.performance.conversionEfficiency, SCALE.Q), controlFactor, SCALE.Q);
37
37
  // h = E/(m*g)
38
- // force_real = m * g where m = mass_real (kg), g = G_mps2 / SCALE.mps2
39
- const force_real = mulDiv(m, G_mps2, SCALE.mps2 * SCALE.kg); // integer Newtons
38
+ // force_real = m * g where m = mass_real (kg), g = gravity_mps2 / SCALE.mps2
39
+ // When gravity is 0 (microgravity), avoid division by zero clamp to 1 as a sentinel.
40
+ const gClamped = Math.max(1, gravity_mps2);
41
+ const force_real = mulDiv(m, gClamped, SCALE.mps2 * SCALE.kg); // integer Newtons
40
42
  return mulDiv(Eeff, SCALE.m, Math.max(1, force_real));
41
43
  }
42
44
  export function deriveMovementCaps(a, loadout, ctx) {
@@ -47,8 +49,8 @@ export function deriveMovementCaps(a, loadout, ctx) {
47
49
  const accelMul = qMul(penalties.accelMul, armour.mobilityMul);
48
50
  const jumpMul = qMul(penalties.jumpMul, armour.mobilityMul);
49
51
  const baseV = deriveMaxSprintSpeed_mps(a);
50
- const baseA = deriveMaxAcceleration_mps2(a, ctx.tractionCoeff);
51
- const baseH = deriveJumpHeight_m(a, Math.trunc(a.performance.reserveEnergy_J * JUMP_ENERGY_FRACTION / SCALE.Q));
52
+ const baseA = deriveMaxAcceleration_mps2(a, ctx.tractionCoeff, ctx.gravity_mps2);
53
+ const baseH = deriveJumpHeight_m(a, Math.trunc(a.performance.reserveEnergy_J * JUMP_ENERGY_FRACTION / SCALE.Q), ctx.gravity_mps2);
52
54
  return {
53
55
  maxSprintSpeed_mps: mulDiv(baseV, speedMul, SCALE.Q),
54
56
  maxAcceleration_mps2: mulDiv(baseA, accelMul, SCALE.Q),
@@ -17,3 +17,6 @@ export * from "./replay.js";
17
17
  export * from "./bridge/index.js";
18
18
  export * from "./world-factory.js";
19
19
  export * from "./scenario.js";
20
+ export * from "./catalog.js";
21
+ export * from "./sim/formation-combat.js";
22
+ export * from "./sim/cover.js";
package/dist/src/index.js CHANGED
@@ -26,3 +26,6 @@ export * from "./replay.js"; // ReplayRecorder, replayTo(), serializeReplay(), d
26
26
  export * from "./bridge/index.js"; // BridgeEngine, InterpolatedState, BridgeConfig
27
27
  export * from "./world-factory.js"; // createWorld(), EntitySpec, ARCHETYPE_MAP, ITEM_MAP
28
28
  export * from "./scenario.js"; // loadScenario(), validateScenario(), AnankeScenario
29
+ export * from "./catalog.js"; // CE-12: registerArchetype(), registerWeapon(), registerArmour(), getCatalogEntry()
30
+ export * from "./sim/formation-combat.js"; // Phase 69: FormationUnit, TacticalEngagement, resolveTacticalEngagement()
31
+ export * from "./sim/cover.js"; // CE-15: CoverSegment, computeCoverProtection(), isLineOfSightBlocked(), applyExplosionToTerrain()