@its-not-rocket-science/ananke 0.1.3 → 0.1.5
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 +217 -177
- 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 +1 -0
- package/dist/src/index.js +1 -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/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/docs/versioning.md +2 -2
- package/package.json +21 -3
|
@@ -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,4 @@ 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()
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 68 — Multi-Biome Physics
|
|
3
|
+
*
|
|
4
|
+
* Defines BiomeContext: a set of optional physics overrides that alter gravity,
|
|
5
|
+
* thermal conduction, sound propagation, and drag relative to the standard
|
|
6
|
+
* Earth-surface defaults. Pass a BiomeContext via KernelContext.biome to apply
|
|
7
|
+
* these overrides to every entity in the simulation.
|
|
8
|
+
*
|
|
9
|
+
* Three built-in profiles cover the most common non-standard environments:
|
|
10
|
+
* BIOME_UNDERWATER — buoyancy-reduced gravity, severe drag, rapid heat exchange
|
|
11
|
+
* BIOME_LUNAR — 1/6 g, vacuum, radiation-only heat transfer
|
|
12
|
+
* BIOME_VACUUM — microgravity, vacuum, extreme thermal isolation
|
|
13
|
+
*/
|
|
14
|
+
import { type Q, type I32 } from "../units.js";
|
|
15
|
+
export interface BiomeContext {
|
|
16
|
+
/**
|
|
17
|
+
* Gravitational acceleration in SCALE.mps2 units.
|
|
18
|
+
* Default (when absent): G_mps2 ≈ 98 067 (9.807 m/s²).
|
|
19
|
+
* Affects jump height and traction-limited acceleration.
|
|
20
|
+
*/
|
|
21
|
+
gravity_mps2?: I32;
|
|
22
|
+
/**
|
|
23
|
+
* Base skin-layer thermal resistance (°C/W).
|
|
24
|
+
* Replaces the default 0.09 (still-air skin layer).
|
|
25
|
+
* Lower values = faster heat exchange (e.g. water ≈ 0.003).
|
|
26
|
+
* Higher values = slower exchange (e.g. vacuum ≈ 50 — radiation only).
|
|
27
|
+
*/
|
|
28
|
+
thermalResistanceBase?: number;
|
|
29
|
+
/**
|
|
30
|
+
* Sound propagation multiplier stored as a raw Q multiple.
|
|
31
|
+
* q(1.0) = normal air propagation; q(0.0) = no sound (vacuum);
|
|
32
|
+
* q(4.0) = four-times-faster (water).
|
|
33
|
+
* Used to scale auditory sensory range.
|
|
34
|
+
*/
|
|
35
|
+
soundPropagation?: Q;
|
|
36
|
+
/**
|
|
37
|
+
* Velocity drag factor applied to entity velocity each tick (Q, [0..SCALE.Q]).
|
|
38
|
+
* q(1.0) = no drag (default); q(0.3) = retain 30% velocity each tick (heavy drag).
|
|
39
|
+
* Applied by the movement step when non-default.
|
|
40
|
+
*/
|
|
41
|
+
dragMul?: Q;
|
|
42
|
+
/**
|
|
43
|
+
* When true, entities without pressurised equipment cannot breathe.
|
|
44
|
+
* Unequipped entities accumulate fatigue at a fixed rate each tick.
|
|
45
|
+
*/
|
|
46
|
+
isVacuum?: boolean;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Deep ocean / underwater environment.
|
|
50
|
+
* Net downward acceleration ≈ 1 m/s² (buoyancy cancels most gravity).
|
|
51
|
+
* Water conducts heat ~25× faster than still air.
|
|
52
|
+
* Sound travels ~4× faster in water than in air.
|
|
53
|
+
* Severe hydrodynamic drag (30% velocity retention per tick).
|
|
54
|
+
*/
|
|
55
|
+
export declare const BIOME_UNDERWATER: BiomeContext;
|
|
56
|
+
/**
|
|
57
|
+
* Lunar surface — 1/6 Earth gravity, vacuum atmosphere.
|
|
58
|
+
* Heat transfer only via radiation → very high thermal resistance.
|
|
59
|
+
* No air, no sound. Entities without pressurised suits suffer vacuum fatigue.
|
|
60
|
+
*/
|
|
61
|
+
export declare const BIOME_LUNAR: BiomeContext;
|
|
62
|
+
/**
|
|
63
|
+
* Microgravity / open space environment.
|
|
64
|
+
* Near-zero gravity, no atmosphere, extreme thermal isolation.
|
|
65
|
+
* Entities without pressurised suits suffer vacuum fatigue.
|
|
66
|
+
*/
|
|
67
|
+
export declare const BIOME_VACUUM: BiomeContext;
|
|
68
|
+
/** Effective gravitational acceleration for this biome (falls back to standard G). */
|
|
69
|
+
export declare function biomeGravity(biome?: BiomeContext): I32;
|
|
70
|
+
/** Effective base thermal resistance for this biome (falls back to still-air default 0.09 °C/W). */
|
|
71
|
+
export declare function biomeThermalResistanceBase(biome?: BiomeContext): number;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 68 — Multi-Biome Physics
|
|
3
|
+
*
|
|
4
|
+
* Defines BiomeContext: a set of optional physics overrides that alter gravity,
|
|
5
|
+
* thermal conduction, sound propagation, and drag relative to the standard
|
|
6
|
+
* Earth-surface defaults. Pass a BiomeContext via KernelContext.biome to apply
|
|
7
|
+
* these overrides to every entity in the simulation.
|
|
8
|
+
*
|
|
9
|
+
* Three built-in profiles cover the most common non-standard environments:
|
|
10
|
+
* BIOME_UNDERWATER — buoyancy-reduced gravity, severe drag, rapid heat exchange
|
|
11
|
+
* BIOME_LUNAR — 1/6 g, vacuum, radiation-only heat transfer
|
|
12
|
+
* BIOME_VACUUM — microgravity, vacuum, extreme thermal isolation
|
|
13
|
+
*/
|
|
14
|
+
import { G_mps2, q, SCALE } from "../units.js";
|
|
15
|
+
// ── Built-in profiles ─────────────────────────────────────────────────────────
|
|
16
|
+
/**
|
|
17
|
+
* Deep ocean / underwater environment.
|
|
18
|
+
* Net downward acceleration ≈ 1 m/s² (buoyancy cancels most gravity).
|
|
19
|
+
* Water conducts heat ~25× faster than still air.
|
|
20
|
+
* Sound travels ~4× faster in water than in air.
|
|
21
|
+
* Severe hydrodynamic drag (30% velocity retention per tick).
|
|
22
|
+
*/
|
|
23
|
+
export const BIOME_UNDERWATER = {
|
|
24
|
+
gravity_mps2: Math.round(1.0 * SCALE.mps2),
|
|
25
|
+
thermalResistanceBase: 0.003,
|
|
26
|
+
soundPropagation: q(4.0),
|
|
27
|
+
dragMul: q(0.30),
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Lunar surface — 1/6 Earth gravity, vacuum atmosphere.
|
|
31
|
+
* Heat transfer only via radiation → very high thermal resistance.
|
|
32
|
+
* No air, no sound. Entities without pressurised suits suffer vacuum fatigue.
|
|
33
|
+
*/
|
|
34
|
+
export const BIOME_LUNAR = {
|
|
35
|
+
gravity_mps2: Math.round(1.62 * SCALE.mps2),
|
|
36
|
+
thermalResistanceBase: 50.0,
|
|
37
|
+
soundPropagation: q(0),
|
|
38
|
+
dragMul: SCALE.Q, // no air resistance
|
|
39
|
+
isVacuum: true,
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Microgravity / open space environment.
|
|
43
|
+
* Near-zero gravity, no atmosphere, extreme thermal isolation.
|
|
44
|
+
* Entities without pressurised suits suffer vacuum fatigue.
|
|
45
|
+
*/
|
|
46
|
+
export const BIOME_VACUUM = {
|
|
47
|
+
gravity_mps2: 0,
|
|
48
|
+
thermalResistanceBase: 100.0,
|
|
49
|
+
soundPropagation: q(0),
|
|
50
|
+
dragMul: SCALE.Q,
|
|
51
|
+
isVacuum: true,
|
|
52
|
+
};
|
|
53
|
+
// ── Accessor helpers ──────────────────────────────────────────────────────────
|
|
54
|
+
/** Effective gravitational acceleration for this biome (falls back to standard G). */
|
|
55
|
+
export function biomeGravity(biome) {
|
|
56
|
+
return (biome?.gravity_mps2 ?? G_mps2);
|
|
57
|
+
}
|
|
58
|
+
/** Effective base thermal resistance for this biome (falls back to still-air default 0.09 °C/W). */
|
|
59
|
+
export function biomeThermalResistanceBase(biome) {
|
|
60
|
+
return biome?.thermalResistanceBase ?? 0.09;
|
|
61
|
+
}
|
|
@@ -7,6 +7,7 @@ import type { ObstacleGrid, ElevationGrid, SlopeGrid, HazardGrid } from "./terra
|
|
|
7
7
|
import type { TechContext } from "./tech.js";
|
|
8
8
|
import type { WeatherState } from "./weather.js";
|
|
9
9
|
import type { Q, I32 } from "../units.js";
|
|
10
|
+
import type { BiomeContext } from "./biome.js";
|
|
10
11
|
export interface KernelContext {
|
|
11
12
|
tractionCoeff: Q;
|
|
12
13
|
tuning?: SimulationTuning;
|
|
@@ -65,4 +66,11 @@ export interface KernelContext {
|
|
|
65
66
|
* When absent, weather has no effect (backward-compatible).
|
|
66
67
|
*/
|
|
67
68
|
weather?: WeatherState;
|
|
69
|
+
/**
|
|
70
|
+
* Phase 68: biome physics overrides.
|
|
71
|
+
* When present, adjusts gravity (jump height, traction), thermal resistance,
|
|
72
|
+
* sound propagation, and velocity drag for all entities this tick.
|
|
73
|
+
* When absent, standard Earth-surface physics apply.
|
|
74
|
+
*/
|
|
75
|
+
biome?: BiomeContext;
|
|
68
76
|
}
|
package/dist/src/sim/kernel.js
CHANGED
|
@@ -506,8 +506,14 @@ export function stepWorld(world, cmds, ctx) {
|
|
|
506
506
|
stepSubstances(e, ctx.ambientTemperature_Q);
|
|
507
507
|
stepEnergy(e, ctx);
|
|
508
508
|
// Phase 29: advance core temperature once per tick (Phase 31: skip ectotherms)
|
|
509
|
+
// Phase 68: pass biome thermal resistance base when a biome is active
|
|
509
510
|
if (ctx.thermalAmbient_Q !== undefined && !e.physiology?.coldBlooded) {
|
|
510
|
-
stepCoreTemp(e, ctx.thermalAmbient_Q, 1 / TICK_HZ);
|
|
511
|
+
stepCoreTemp(e, ctx.thermalAmbient_Q, 1 / TICK_HZ, ctx.biome?.thermalResistanceBase);
|
|
512
|
+
}
|
|
513
|
+
// Phase 68: vacuum fatigue — entities in a vacuum accumulate fatigue each tick.
|
|
514
|
+
// Rate: ~3 Q/tick = 60 Q/s = 0.6 %/s → full incapacitation in ~167 s without protection.
|
|
515
|
+
if (ctx.biome?.isVacuum) {
|
|
516
|
+
e.energy.fatigue = clampQ((e.energy.fatigue + 3), 0, SCALE.Q);
|
|
511
517
|
}
|
|
512
518
|
stepCapabilitySources(e, world, ctx); // Phase 12
|
|
513
519
|
// Phase 13: emit KO and Death events so metrics/replay consumers can track incapacitation
|
|
@@ -10,7 +10,10 @@ import { findExoskeleton } from "../../equipment.js";
|
|
|
10
10
|
export function stepMovement(e, world, ctx, tuning) {
|
|
11
11
|
const cellSize = ctx.cellSize_m ?? Math.trunc(4 * SCALE.m);
|
|
12
12
|
const traction = tractionAtPosition(ctx.terrainGrid, cellSize, e.position_m.x, e.position_m.y, ctx.tractionCoeff);
|
|
13
|
-
const caps = deriveMovementCaps(e.attributes, e.loadout, {
|
|
13
|
+
const caps = deriveMovementCaps(e.attributes, e.loadout, {
|
|
14
|
+
tractionCoeff: traction,
|
|
15
|
+
...(ctx.biome?.gravity_mps2 !== undefined ? { gravity_mps2: ctx.biome.gravity_mps2 } : {}),
|
|
16
|
+
});
|
|
14
17
|
const func = deriveFunctionalState(e, tuning);
|
|
15
18
|
// Capability gating
|
|
16
19
|
if (!func.canAct) {
|
|
@@ -151,6 +154,15 @@ export function stepMovement(e, world, ctx, tuning) {
|
|
|
151
154
|
const targetVel = scaleDirToSpeed(dir, vTargetMag);
|
|
152
155
|
e.velocity_mps = accelToward(e.velocity_mps, targetVel, effAmax);
|
|
153
156
|
e.velocity_mps = clampSpeed(e.velocity_mps, effVmax);
|
|
157
|
+
// Phase 68: biome drag — attenuate velocity when dragMul < SCALE.Q (e.g. underwater).
|
|
158
|
+
const dragMul = ctx.biome?.dragMul;
|
|
159
|
+
if (dragMul !== undefined && dragMul < SCALE.Q) {
|
|
160
|
+
e.velocity_mps = {
|
|
161
|
+
x: mulDiv(e.velocity_mps.x, dragMul, SCALE.Q),
|
|
162
|
+
y: mulDiv(e.velocity_mps.y, dragMul, SCALE.Q),
|
|
163
|
+
z: mulDiv(e.velocity_mps.z, dragMul, SCALE.Q),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
154
166
|
// Phase 6: obstacle blocking — impassable cells (coverFraction = q(1.0)) prevent entry.
|
|
155
167
|
const nextPos = integratePos(e.position_m, e.velocity_mps, DT_S);
|
|
156
168
|
if (ctx.obstacleGrid) {
|