@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 +43 -0
- package/dist/src/catalog.d.ts +86 -0
- package/dist/src/catalog.js +393 -0
- package/dist/src/derive.d.ts +5 -3
- package/dist/src/derive.js +10 -8
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +3 -0
- package/dist/src/sim/biome.d.ts +71 -0
- package/dist/src/sim/biome.js +61 -0
- package/dist/src/sim/context.d.ts +8 -0
- package/dist/src/sim/cover.d.ts +186 -0
- package/dist/src/sim/cover.js +290 -0
- package/dist/src/sim/formation-combat.d.ts +170 -0
- package/dist/src/sim/formation-combat.js +255 -0
- package/dist/src/sim/kernel.js +7 -1
- package/dist/src/sim/step/movement.js +13 -1
- package/dist/src/sim/thermoregulation.d.ts +3 -2
- package/dist/src/sim/thermoregulation.js +6 -4
- package/package.json +13 -3
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
|
+
}
|
package/dist/src/derive.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/derive.js
CHANGED
|
@@ -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
|
|
14
|
-
const normalForce_N_scaled = mulDiv(m,
|
|
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 =
|
|
39
|
-
|
|
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),
|
package/dist/src/index.d.ts
CHANGED
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()
|