@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.
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Phase 69 — Macro-Scale Formation Combat
3
+ *
4
+ * A tactical abstraction layer between individual entity simulation (20 Hz) and
5
+ * polity-level conflict (1 tick/day). Squads and companies resolve combat as
6
+ * cohesive units via Lanchester's square law, adjusted for terrain and morale.
7
+ *
8
+ * When a named entity (id < NAMED_ENTITY_THRESHOLD, or in a caller-supplied set)
9
+ * participates in the engagement, the resolver marks them in `namedEntityIds` so the
10
+ * host can run a full per-entity micro-simulation frame at the decisive tick.
11
+ *
12
+ * Lanchester's Square Law:
13
+ * Attrition per tick ∝ opponent_strength² / own_strength
14
+ * δA = k × B² δB = k × A²
15
+ *
16
+ * where k is derived from aggregated combat effectiveness (force_N × endurance × morale).
17
+ */
18
+ import { type Q, type I32 } from "../units.js";
19
+ import type { Archetype } from "../archetypes.js";
20
+ /**
21
+ * A squad- or company-level unit in a formation engagement.
22
+ * All numeric fields that represent ratios use Q; headcounts are plain integers.
23
+ */
24
+ export interface FormationUnit {
25
+ /** Unique identifier within the engagement. */
26
+ id: string;
27
+ /** Polity / faction identifier. Units with the same factionId fight together. */
28
+ factionId: string;
29
+ /** Current headcount (soldiers present and capable of fighting). */
30
+ strength: number;
31
+ /** Sum of `peakForce_N` across all members (SCALE.N units). */
32
+ aggregatedForce_N: I32;
33
+ /**
34
+ * Average continuous endurance as a Q fraction [0..SCALE.Q].
35
+ * Derived from avg(continuousPower_W) / HUMAN_CONT_W_REFERENCE.
36
+ */
37
+ aggregatedEndurance: Q;
38
+ /** Formation morale Q [0..SCALE.Q]. Collapse below `breakThreshold` triggers rout. */
39
+ moraleQ: Q;
40
+ /**
41
+ * Representative archetype for the unit. Used by the host to spawn micro-simulation
42
+ * entities at the decisive tick when named characters are present.
43
+ */
44
+ archetype: Archetype;
45
+ /**
46
+ * Optional list of named entity ids (from the micro-simulation) embedded in this unit.
47
+ * Any id below NAMED_ENTITY_THRESHOLD is treated as named automatically.
48
+ */
49
+ namedEntityIds?: readonly number[];
50
+ }
51
+ /** Terrain type affects defender effectiveness multiplier. */
52
+ export type TacticalTerrain = "open" | "difficult" | "fortified";
53
+ /**
54
+ * An engagement between two (or more) sides.
55
+ * `attackers` and `defenders` are lists of `FormationUnit`.
56
+ * All units sharing a `factionId` within a side fight cohesively.
57
+ */
58
+ export interface TacticalEngagement {
59
+ attackers: FormationUnit[];
60
+ defenders: FormationUnit[];
61
+ /** Terrain favours the defender. */
62
+ terrain: TacticalTerrain;
63
+ /**
64
+ * How many tactical ticks to resolve (1 tactical tick ≈ 1 real second at this scale).
65
+ * Typical engagement: 30–600 ticks.
66
+ */
67
+ durationTicks: number;
68
+ /**
69
+ * World seed — used for deterministic morale collapse rolls.
70
+ * If omitted, morale collapse is deterministic via threshold comparison only (no randomness).
71
+ */
72
+ seed?: number;
73
+ }
74
+ /** Per-side outcome summary from `resolveTacticalEngagement`. */
75
+ export interface TacticalSideResult {
76
+ casualties: number;
77
+ survivingStrength: number;
78
+ finalMoraleQ: Q;
79
+ routed: boolean;
80
+ }
81
+ export interface TacticalResult {
82
+ attackerResult: TacticalSideResult;
83
+ defenderResult: TacticalSideResult;
84
+ /** Faction ids that routed (morale collapsed below `breakThreshold`). */
85
+ routedFactions: string[];
86
+ /**
87
+ * Named entity ids present in the engagement that require micro-simulation resolution.
88
+ * The host should run `stepWorld` for these entities at the decisive tick.
89
+ */
90
+ namedEntityIds: number[];
91
+ /**
92
+ * The tick number (0-based) at which the decisive moment occurred.
93
+ * If no side routed or was wiped out, equals `durationTicks`.
94
+ */
95
+ decisiveTick: number;
96
+ }
97
+ /**
98
+ * Entity ids strictly below this value are treated as "named" and trigger
99
+ * micro-simulation delegation. Hosts may override via `FormationUnit.namedEntityIds`.
100
+ */
101
+ export declare const NAMED_ENTITY_THRESHOLD = 1000;
102
+ /**
103
+ * Morale Q threshold below which a unit routs.
104
+ * Matches Phase 32D formation morale `BASE_DECAY` model.
105
+ */
106
+ export declare const ROUT_THRESHOLD: Q;
107
+ /**
108
+ * Base morale decay per tactical tick due to casualties (Q per tick per 1% casualty rate).
109
+ * A unit sustaining 10% casualties/tick loses ~q(0.10) morale/tick.
110
+ */
111
+ export declare const MORALE_CASUALTY_DECAY_PER_PCT: Q;
112
+ /**
113
+ * Lanchester attrition rate (fraction of effective opponent strength killed per tick).
114
+ *
115
+ * Lanchester's Square Law differential form:
116
+ * dA/dt = -rate × B_eff (attacker casualties ∝ effective defender count)
117
+ * dB/dt = -rate × A_eff (defender casualties ∝ effective attacker count)
118
+ *
119
+ * The "square law" refers to the conservation integral (A²-B²=const), not squared
120
+ * differentials. rate=0.01 gives ~100-tick engagements for equal 100-person units.
121
+ */
122
+ export declare const LANCHESTER_RATE = 0.1;
123
+ /**
124
+ * Reference combat power of a single standard human soldier at q(1.0) morale.
125
+ * Used to convert aggregated sidePower() to "effective fighter count" for attrition.
126
+ * Units: same as aggregatedForce_N × conversionEfficiency / SCALE.Q (SCALE.N units).
127
+ */
128
+ export declare const REFERENCE_POWER_PER_SOLDIER: number;
129
+ /**
130
+ * Defender effectiveness multiplier per terrain type.
131
+ * Applied to the defender's combat power (force × endurance × morale).
132
+ * Attackers always use multiplier q(1.0).
133
+ */
134
+ export declare const TERRAIN_DEFENDER_MUL: Record<TacticalTerrain, Q>;
135
+ /**
136
+ * Build a `FormationUnit` from headcount and archetype.
137
+ *
138
+ * @param id Unique unit identifier.
139
+ * @param factionId Faction / polity identifier.
140
+ * @param strength Number of combatants.
141
+ * @param archetype Representative archetype (used for force and endurance derivation).
142
+ * @param moraleQ Initial morale (defaults to q(0.70)).
143
+ */
144
+ export declare function createFormationUnit(id: string, factionId: string, strength: number, archetype: Archetype, moraleQ?: Q): FormationUnit;
145
+ /**
146
+ * Resolve a tactical engagement over `durationTicks` using Lanchester's square law.
147
+ *
148
+ * The engagement proceeds tick-by-tick:
149
+ * 1. Compute combat power for each side (aggregated force × endurance × morale × terrain).
150
+ * 2. Apply Lanchester attrition: δA = k × B²/A, δB = k × A²/B.
151
+ * 3. Apply morale pressure proportional to casualty rate.
152
+ * 4. Check rout conditions — any unit below ROUT_THRESHOLD is considered routed.
153
+ * 5. Stop early if all units on one side are routed or wiped out.
154
+ *
155
+ * **Note:** This mutates `strength` and `moraleQ` on the supplied `FormationUnit` objects.
156
+ * Clone them before calling if you need to preserve original state.
157
+ *
158
+ * @param engagement - The engagement parameters.
159
+ * @returns `TacticalResult` with per-side outcomes, routed factions, and named entity ids.
160
+ */
161
+ export declare function resolveTacticalEngagement(engagement: TacticalEngagement): TacticalResult;
162
+ /**
163
+ * Apply the tactical result back to polity military strength (Q).
164
+ *
165
+ * @param currentStrength_Q - Current `polity.militaryStrength_Q`.
166
+ * @param initialStrength - Headcount at engagement start.
167
+ * @param result - Side result from `resolveTacticalEngagement`.
168
+ * @returns Updated `militaryStrength_Q`.
169
+ */
170
+ export declare function applyTacticalResultToPolity(currentStrength_Q: Q, initialStrength: number, result: TacticalSideResult): Q;
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Phase 69 — Macro-Scale Formation Combat
3
+ *
4
+ * A tactical abstraction layer between individual entity simulation (20 Hz) and
5
+ * polity-level conflict (1 tick/day). Squads and companies resolve combat as
6
+ * cohesive units via Lanchester's square law, adjusted for terrain and morale.
7
+ *
8
+ * When a named entity (id < NAMED_ENTITY_THRESHOLD, or in a caller-supplied set)
9
+ * participates in the engagement, the resolver marks them in `namedEntityIds` so the
10
+ * host can run a full per-entity micro-simulation frame at the decisive tick.
11
+ *
12
+ * Lanchester's Square Law:
13
+ * Attrition per tick ∝ opponent_strength² / own_strength
14
+ * δA = k × B² δB = k × A²
15
+ *
16
+ * where k is derived from aggregated combat effectiveness (force_N × endurance × morale).
17
+ */
18
+ import { q, SCALE, clampQ, qMul, mulDiv } from "../units.js";
19
+ import { HUMAN_BASE } from "../archetypes.js";
20
+ // ── Constants ─────────────────────────────────────────────────────────────────
21
+ /**
22
+ * Entity ids strictly below this value are treated as "named" and trigger
23
+ * micro-simulation delegation. Hosts may override via `FormationUnit.namedEntityIds`.
24
+ */
25
+ export const NAMED_ENTITY_THRESHOLD = 1000;
26
+ /**
27
+ * Morale Q threshold below which a unit routs.
28
+ * Matches Phase 32D formation morale `BASE_DECAY` model.
29
+ */
30
+ export const ROUT_THRESHOLD = q(0.20);
31
+ /**
32
+ * Base morale decay per tactical tick due to casualties (Q per tick per 1% casualty rate).
33
+ * A unit sustaining 10% casualties/tick loses ~q(0.10) morale/tick.
34
+ */
35
+ export const MORALE_CASUALTY_DECAY_PER_PCT = q(0.010);
36
+ /**
37
+ * Lanchester attrition rate (fraction of effective opponent strength killed per tick).
38
+ *
39
+ * Lanchester's Square Law differential form:
40
+ * dA/dt = -rate × B_eff (attacker casualties ∝ effective defender count)
41
+ * dB/dt = -rate × A_eff (defender casualties ∝ effective attacker count)
42
+ *
43
+ * The "square law" refers to the conservation integral (A²-B²=const), not squared
44
+ * differentials. rate=0.01 gives ~100-tick engagements for equal 100-person units.
45
+ */
46
+ export const LANCHESTER_RATE = 0.10;
47
+ /**
48
+ * Reference combat power of a single standard human soldier at q(1.0) morale.
49
+ * Used to convert aggregated sidePower() to "effective fighter count" for attrition.
50
+ * Units: same as aggregatedForce_N × conversionEfficiency / SCALE.Q (SCALE.N units).
51
+ */
52
+ export const REFERENCE_POWER_PER_SOLDIER = Math.round((HUMAN_BASE.peakForce_N * HUMAN_BASE.conversionEfficiency) / SCALE.Q);
53
+ // ── Terrain multipliers ───────────────────────────────────────────────────────
54
+ /**
55
+ * Defender effectiveness multiplier per terrain type.
56
+ * Applied to the defender's combat power (force × endurance × morale).
57
+ * Attackers always use multiplier q(1.0).
58
+ */
59
+ export const TERRAIN_DEFENDER_MUL = {
60
+ open: q(1.00), // no terrain advantage
61
+ difficult: q(1.30), // broken ground, forest, river — 30% defender bonus
62
+ fortified: q(2.00), // walls, prepared positions — 2× defender effectiveness
63
+ };
64
+ // ── Internal helpers ──────────────────────────────────────────────────────────
65
+ /**
66
+ * Aggregate combat power for a side (Q-scaled, relative units).
67
+ * power = sum(force_N × endurance × morale / SCALE.Q²)
68
+ * Returns an integer in SCALE.N × Q units (before the /SCALE.Q² normalisation).
69
+ */
70
+ function sidePower(units, terrainMul) {
71
+ let total = 0;
72
+ for (const u of units) {
73
+ if (u.strength <= 0 || u.moraleQ <= 0)
74
+ continue;
75
+ // effective = force_N × (endurance / SCALE.Q) × (morale / SCALE.Q) × (terrain / SCALE.Q)
76
+ const effForce = mulDiv(u.aggregatedForce_N, u.aggregatedEndurance, SCALE.Q);
77
+ const withMorale = mulDiv(effForce, u.moraleQ, SCALE.Q);
78
+ total += mulDiv(withMorale, terrainMul, SCALE.Q);
79
+ }
80
+ return Math.max(0, total);
81
+ }
82
+ /** Total headcount across all units on a side. */
83
+ function sideStrength(units) {
84
+ return units.reduce((s, u) => s + Math.max(0, u.strength), 0);
85
+ }
86
+ /**
87
+ * Distribute `casualties` proportionally across units by current strength.
88
+ * Mutates unit.strength in-place. Returns actual casualties applied.
89
+ */
90
+ function distributeCasualties(units, casualties) {
91
+ const total = sideStrength(units);
92
+ if (total <= 0 || casualties <= 0)
93
+ return 0;
94
+ let applied = 0;
95
+ for (const u of units) {
96
+ if (u.strength <= 0)
97
+ continue;
98
+ const share = Math.round((casualties * u.strength) / total);
99
+ const actual = Math.min(share, u.strength);
100
+ u.strength -= actual;
101
+ applied += actual;
102
+ }
103
+ return applied;
104
+ }
105
+ /**
106
+ * Apply morale pressure to all units on a side.
107
+ * Pressure = casualty rate × MORALE_CASUALTY_DECAY_PER_PCT.
108
+ */
109
+ function applyMoralePressure(units, casualtiesThisTick) {
110
+ const total = sideStrength(units) + casualtiesThisTick;
111
+ if (total <= 0)
112
+ return;
113
+ const pct = Math.round((casualtiesThisTick * SCALE.Q) / total);
114
+ const decay = qMul(MORALE_CASUALTY_DECAY_PER_PCT, pct);
115
+ for (const u of units) {
116
+ u.moraleQ = clampQ((u.moraleQ - decay), 0, SCALE.Q);
117
+ }
118
+ }
119
+ /** Collect named entity ids from all units across both sides. */
120
+ function collectNamedIds(attackers, defenders) {
121
+ const ids = new Set();
122
+ for (const u of [...attackers, ...defenders]) {
123
+ // Explicit named ids
124
+ if (u.namedEntityIds) {
125
+ for (const id of u.namedEntityIds)
126
+ ids.add(id);
127
+ }
128
+ }
129
+ return [...ids];
130
+ }
131
+ // ── Public API ────────────────────────────────────────────────────────────────
132
+ /**
133
+ * Build a `FormationUnit` from headcount and archetype.
134
+ *
135
+ * @param id Unique unit identifier.
136
+ * @param factionId Faction / polity identifier.
137
+ * @param strength Number of combatants.
138
+ * @param archetype Representative archetype (used for force and endurance derivation).
139
+ * @param moraleQ Initial morale (defaults to q(0.70)).
140
+ */
141
+ export function createFormationUnit(id, factionId, strength, archetype, moraleQ = q(0.70)) {
142
+ return {
143
+ id,
144
+ factionId,
145
+ strength: Math.max(0, strength),
146
+ aggregatedForce_N: Math.round(archetype.peakForce_N * strength),
147
+ aggregatedEndurance: archetype.conversionEfficiency,
148
+ moraleQ,
149
+ archetype,
150
+ };
151
+ }
152
+ /**
153
+ * Resolve a tactical engagement over `durationTicks` using Lanchester's square law.
154
+ *
155
+ * The engagement proceeds tick-by-tick:
156
+ * 1. Compute combat power for each side (aggregated force × endurance × morale × terrain).
157
+ * 2. Apply Lanchester attrition: δA = k × B²/A, δB = k × A²/B.
158
+ * 3. Apply morale pressure proportional to casualty rate.
159
+ * 4. Check rout conditions — any unit below ROUT_THRESHOLD is considered routed.
160
+ * 5. Stop early if all units on one side are routed or wiped out.
161
+ *
162
+ * **Note:** This mutates `strength` and `moraleQ` on the supplied `FormationUnit` objects.
163
+ * Clone them before calling if you need to preserve original state.
164
+ *
165
+ * @param engagement - The engagement parameters.
166
+ * @returns `TacticalResult` with per-side outcomes, routed factions, and named entity ids.
167
+ */
168
+ export function resolveTacticalEngagement(engagement) {
169
+ const { attackers, defenders, terrain, durationTicks } = engagement;
170
+ const terrainMul = TERRAIN_DEFENDER_MUL[terrain];
171
+ const namedEntityIds = collectNamedIds(attackers, defenders);
172
+ let totalAttackerCasualties = 0;
173
+ let totalDefenderCasualties = 0;
174
+ const routedFactions = new Set();
175
+ let decisiveTick = durationTicks;
176
+ for (let tick = 0; tick < durationTicks; tick++) {
177
+ const aStrength = sideStrength(attackers);
178
+ const dStrength = sideStrength(defenders);
179
+ // Stop if one side is wiped out
180
+ if (aStrength <= 0 || dStrength <= 0) {
181
+ decisiveTick = tick;
182
+ break;
183
+ }
184
+ // Combat power for each side
185
+ const aPower = sidePower(attackers, SCALE.Q); // attackers: no terrain bonus
186
+ const dPower = sidePower(defenders, terrainMul); // defenders: terrain multiplier
187
+ // Lanchester's Square Law — linear differential form:
188
+ // δA = rate × dEff (attacker casualties ∝ effective defender count)
189
+ // δB = rate × aEff (defender casualties ∝ effective attacker count)
190
+ //
191
+ // Convert aggregated power to "effective fighter count" by dividing by the
192
+ // reference combat power of one standard soldier.
193
+ const ref = Math.max(1, REFERENCE_POWER_PER_SOLDIER);
194
+ const aEff = Math.max(1, Math.round(aPower / ref));
195
+ const dEff = Math.max(1, Math.round(dPower / ref));
196
+ const aCas = Math.max(0, Math.round(LANCHESTER_RATE * dEff));
197
+ const dCas = Math.max(0, Math.round(LANCHESTER_RATE * aEff));
198
+ // Distribute casualties
199
+ totalAttackerCasualties += distributeCasualties(attackers, aCas);
200
+ totalDefenderCasualties += distributeCasualties(defenders, dCas);
201
+ // Morale pressure
202
+ applyMoralePressure(attackers, aCas);
203
+ applyMoralePressure(defenders, dCas);
204
+ // Rout check
205
+ for (const u of attackers) {
206
+ if (u.moraleQ < ROUT_THRESHOLD || u.strength <= 0)
207
+ routedFactions.add(u.factionId);
208
+ }
209
+ for (const u of defenders) {
210
+ if (u.moraleQ < ROUT_THRESHOLD || u.strength <= 0)
211
+ routedFactions.add(u.factionId);
212
+ }
213
+ // Early stop: all attacker or all defender factions routed
214
+ const aAllRouted = attackers.every(u => routedFactions.has(u.factionId) || u.strength <= 0);
215
+ const dAllRouted = defenders.every(u => routedFactions.has(u.factionId) || u.strength <= 0);
216
+ if (aAllRouted || dAllRouted) {
217
+ decisiveTick = tick + 1;
218
+ break;
219
+ }
220
+ }
221
+ const attackerResult = {
222
+ casualties: totalAttackerCasualties,
223
+ survivingStrength: sideStrength(attackers),
224
+ finalMoraleQ: Math.round(attackers.reduce((s, u) => s + u.moraleQ, 0) / Math.max(1, attackers.length)),
225
+ routed: attackers.every(u => routedFactions.has(u.factionId) || u.strength <= 0),
226
+ };
227
+ const defenderResult = {
228
+ casualties: totalDefenderCasualties,
229
+ survivingStrength: sideStrength(defenders),
230
+ finalMoraleQ: Math.round(defenders.reduce((s, u) => s + u.moraleQ, 0) / Math.max(1, defenders.length)),
231
+ routed: defenders.every(u => routedFactions.has(u.factionId) || u.strength <= 0),
232
+ };
233
+ return {
234
+ attackerResult,
235
+ defenderResult,
236
+ routedFactions: [...routedFactions],
237
+ namedEntityIds,
238
+ decisiveTick,
239
+ };
240
+ }
241
+ /**
242
+ * Apply the tactical result back to polity military strength (Q).
243
+ *
244
+ * @param currentStrength_Q - Current `polity.militaryStrength_Q`.
245
+ * @param initialStrength - Headcount at engagement start.
246
+ * @param result - Side result from `resolveTacticalEngagement`.
247
+ * @returns Updated `militaryStrength_Q`.
248
+ */
249
+ export function applyTacticalResultToPolity(currentStrength_Q, initialStrength, result) {
250
+ if (initialStrength <= 0)
251
+ return currentStrength_Q;
252
+ const survivorFrac = Math.round((result.survivingStrength * SCALE.Q) / initialStrength);
253
+ const moraleAdj = qMul(survivorFrac, result.finalMoraleQ);
254
+ return clampQ(Math.round((currentStrength_Q * moraleAdj) / SCALE.Q), 0, SCALE.Q);
255
+ }
@@ -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, { tractionCoeff: traction });
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) {
@@ -41,7 +41,7 @@ export declare function sumArmourInsulation(items: Item[]): number;
41
41
  */
42
42
  export declare function computeNewCoreQ(coreQ: number, massReal_kg: number, // real kg (entity.attributes.morphology.mass_kg / SCALE.kg)
43
43
  armourInsulation: number, isActive: boolean, // true if entity velocity ≥ 1 m/s
44
- ambientTemp: Q, delta_s: number): Q;
44
+ ambientTemp: Q, delta_s: number, thermalResistanceBase?: number): Q;
45
45
  /**
46
46
  * Advance an entity's core temperature by `delta_s` seconds given the ambient temperature.
47
47
  *
@@ -49,7 +49,8 @@ ambientTemp: Q, delta_s: number): Q;
49
49
  * Writes the new value back to `entity.condition.coreTemp_Q` and returns it.
50
50
  */
51
51
  export declare function stepCoreTemp(entity: Entity, ambientTemp: Q, // Phase 29 Q-coded temperature (same scale as coreTemp_Q)
52
- delta_s: number): Q;
52
+ delta_s: number, // elapsed seconds
53
+ thermalResistanceBase?: number): Q;
53
54
  export interface TempModifiers {
54
55
  /** Effective multiplier on peakPower_W for action resolution. */
55
56
  powerMul: Q;
@@ -72,7 +72,7 @@ const ACTIVE_VEL_THRESH = Math.trunc(1.0 * SCALE.mps); // 10000
72
72
  */
73
73
  export function computeNewCoreQ(coreQ, massReal_kg, // real kg (entity.attributes.morphology.mass_kg / SCALE.kg)
74
74
  armourInsulation, isActive, // true if entity velocity ≥ 1 m/s
75
- ambientTemp, delta_s) {
75
+ ambientTemp, delta_s, thermalResistanceBase) {
76
76
  if (massReal_kg <= 0)
77
77
  return coreQ;
78
78
  const coreC = qToC(coreQ);
@@ -80,7 +80,8 @@ ambientTemp, delta_s) {
80
80
  // Metabolic heat: mass-proportional (independent of combat peak power)
81
81
  const specificW = isActive ? ACT_SPECIFIC_W : REST_SPECIFIC_W;
82
82
  const metabolicHeat = massReal_kg * specificW; // W
83
- const thermalResistance = 0.09 + armourInsulation; // °C/W
83
+ const baseR = thermalResistanceBase ?? 0.09;
84
+ const thermalResistance = baseR + armourInsulation; // °C/W
84
85
  const thermalMass = massReal_kg * 3500; // J/°C
85
86
  const conductiveLoss = (coreC - ambC) / thermalResistance; // W
86
87
  const deltaTc = (metabolicHeat - conductiveLoss) * delta_s / thermalMass; // °C
@@ -95,7 +96,8 @@ ambientTemp, delta_s) {
95
96
  * Writes the new value back to `entity.condition.coreTemp_Q` and returns it.
96
97
  */
97
98
  export function stepCoreTemp(entity, ambientTemp, // Phase 29 Q-coded temperature (same scale as coreTemp_Q)
98
- delta_s) {
99
+ delta_s, // elapsed seconds
100
+ thermalResistanceBase) {
99
101
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
100
102
  const cond = entity.condition;
101
103
  const coreQ = cond.coreTemp_Q ?? CORE_TEMP_NORMAL_Q;
@@ -105,7 +107,7 @@ delta_s) {
105
107
  const vx = entity.velocity_mps.x;
106
108
  const vy = entity.velocity_mps.y;
107
109
  const vMag = Math.sqrt(vx * vx + vy * vy);
108
- const newCoreQ = computeNewCoreQ(coreQ, mReal, insul, vMag >= ACTIVE_VEL_THRESH, ambientTemp, delta_s);
110
+ const newCoreQ = computeNewCoreQ(coreQ, mReal, insul, vMag >= ACTIVE_VEL_THRESH, ambientTemp, delta_s, thermalResistanceBase);
109
111
  cond.coreTemp_Q = newCoreQ;
110
112
  return newCoreQ;
111
113
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@its-not-rocket-science/ananke",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "description": "Deterministic lockstep-friendly SI-units RPG/physics core (fixed-point TS)",
6
6
  "license": "MIT",
@@ -18,6 +18,10 @@
18
18
  "./polity": {
19
19
  "import": "./dist/src/polity.js",
20
20
  "types": "./dist/src/polity.d.ts"
21
+ },
22
+ "./catalog": {
23
+ "import": "./dist/src/catalog.js",
24
+ "types": "./dist/src/catalog.d.ts"
21
25
  }
22
26
  },
23
27
  "files": [
@@ -40,8 +44,13 @@
40
44
  "url": "https://github.com/its-not-rocket-science/ananke.git"
41
45
  },
42
46
  "keywords": [
43
- "simulation", "physics", "combat", "rpg",
44
- "deterministic", "fixed-point", "game-engine"
47
+ "simulation",
48
+ "physics",
49
+ "combat",
50
+ "rpg",
51
+ "deterministic",
52
+ "fixed-point",
53
+ "game-engine"
45
54
  ],
46
55
  "scripts": {
47
56
  "prepublishOnly": "npm run build && npm run test:coverage",
@@ -82,6 +91,7 @@
82
91
  "@types/node": "^20.0.0",
83
92
  "@vitest/coverage-v8": "^2.1.8",
84
93
  "eslint": "^9.39.3",
94
+ "fast-check": "^4.6.0",
85
95
  "typescript": "^5.5.4",
86
96
  "typescript-eslint": "^8.56.1",
87
97
  "vitest": "^2.1.8"