@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
|
@@ -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
|
+
}
|
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) {
|
|
@@ -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
|
|
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
|
|
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.
|
|
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",
|
|
44
|
-
"
|
|
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"
|