@its-not-rocket-science/ananke 0.1.0

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.
Files changed (311) hide show
  1. package/CHANGELOG.md +135 -0
  2. package/LICENSE +21 -0
  3. package/README.md +2199 -0
  4. package/STABLE_API.md +266 -0
  5. package/dist/src/anatomy/anatomy-compiler.d.ts +14 -0
  6. package/dist/src/anatomy/anatomy-compiler.js +277 -0
  7. package/dist/src/anatomy/anatomy-contracts.d.ts +94 -0
  8. package/dist/src/anatomy/anatomy-contracts.js +1 -0
  9. package/dist/src/anatomy/anatomy-helpers.d.ts +82 -0
  10. package/dist/src/anatomy/anatomy-helpers.js +233 -0
  11. package/dist/src/anatomy/anatomy-schema.d.ts +28 -0
  12. package/dist/src/anatomy/anatomy-schema.js +388 -0
  13. package/dist/src/anatomy/index.d.ts +4 -0
  14. package/dist/src/anatomy/index.js +4 -0
  15. package/dist/src/archetypes.d.ts +87 -0
  16. package/dist/src/archetypes.js +285 -0
  17. package/dist/src/arena.d.ts +173 -0
  18. package/dist/src/arena.js +695 -0
  19. package/dist/src/bridge/bridge-engine.d.ts +46 -0
  20. package/dist/src/bridge/bridge-engine.js +252 -0
  21. package/dist/src/bridge/index.d.ts +4 -0
  22. package/dist/src/bridge/index.js +5 -0
  23. package/dist/src/bridge/interpolation.d.ts +64 -0
  24. package/dist/src/bridge/interpolation.js +130 -0
  25. package/dist/src/bridge/mapping.d.ts +33 -0
  26. package/dist/src/bridge/mapping.js +54 -0
  27. package/dist/src/bridge/types.d.ts +94 -0
  28. package/dist/src/bridge/types.js +2 -0
  29. package/dist/src/campaign.d.ts +141 -0
  30. package/dist/src/campaign.js +235 -0
  31. package/dist/src/channels.d.ts +15 -0
  32. package/dist/src/channels.js +20 -0
  33. package/dist/src/chronicle.d.ts +124 -0
  34. package/dist/src/chronicle.js +232 -0
  35. package/dist/src/collective-activities.d.ts +154 -0
  36. package/dist/src/collective-activities.js +247 -0
  37. package/dist/src/competence/acoustic.d.ts +101 -0
  38. package/dist/src/competence/acoustic.js +242 -0
  39. package/dist/src/competence/catalogue.d.ts +30 -0
  40. package/dist/src/competence/catalogue.js +241 -0
  41. package/dist/src/competence/crafting.d.ts +35 -0
  42. package/dist/src/competence/crafting.js +88 -0
  43. package/dist/src/competence/engineering.d.ts +53 -0
  44. package/dist/src/competence/engineering.js +108 -0
  45. package/dist/src/competence/framework.d.ts +68 -0
  46. package/dist/src/competence/framework.js +694 -0
  47. package/dist/src/competence/index.d.ts +12 -0
  48. package/dist/src/competence/index.js +13 -0
  49. package/dist/src/competence/interspecies.d.ts +81 -0
  50. package/dist/src/competence/interspecies.js +108 -0
  51. package/dist/src/competence/language.d.ts +79 -0
  52. package/dist/src/competence/language.js +115 -0
  53. package/dist/src/competence/naturalist.d.ts +97 -0
  54. package/dist/src/competence/naturalist.js +187 -0
  55. package/dist/src/competence/navigation.d.ts +24 -0
  56. package/dist/src/competence/navigation.js +48 -0
  57. package/dist/src/competence/performance.d.ts +125 -0
  58. package/dist/src/competence/performance.js +210 -0
  59. package/dist/src/competence/teaching.d.ts +64 -0
  60. package/dist/src/competence/teaching.js +121 -0
  61. package/dist/src/competence/willpower.d.ts +74 -0
  62. package/dist/src/competence/willpower.js +114 -0
  63. package/dist/src/crafting/index.d.ts +55 -0
  64. package/dist/src/crafting/index.js +229 -0
  65. package/dist/src/crafting/manufacturing.d.ts +83 -0
  66. package/dist/src/crafting/manufacturing.js +165 -0
  67. package/dist/src/crafting/materials.d.ts +53 -0
  68. package/dist/src/crafting/materials.js +120 -0
  69. package/dist/src/crafting/recipes.d.ts +75 -0
  70. package/dist/src/crafting/recipes.js +233 -0
  71. package/dist/src/crafting/workshops.d.ts +61 -0
  72. package/dist/src/crafting/workshops.js +170 -0
  73. package/dist/src/debug.d.ts +86 -0
  74. package/dist/src/debug.js +76 -0
  75. package/dist/src/derive.d.ts +21 -0
  76. package/dist/src/derive.js +88 -0
  77. package/dist/src/describe.d.ts +29 -0
  78. package/dist/src/describe.js +276 -0
  79. package/dist/src/dialogue.d.ts +122 -0
  80. package/dist/src/dialogue.js +266 -0
  81. package/dist/src/dist.d.ts +20 -0
  82. package/dist/src/dist.js +39 -0
  83. package/dist/src/downtime.d.ts +89 -0
  84. package/dist/src/downtime.js +391 -0
  85. package/dist/src/economy.d.ts +116 -0
  86. package/dist/src/economy.js +182 -0
  87. package/dist/src/emotional-contagion.d.ts +142 -0
  88. package/dist/src/emotional-contagion.js +274 -0
  89. package/dist/src/equipment.d.ts +206 -0
  90. package/dist/src/equipment.js +598 -0
  91. package/dist/src/faction.d.ts +102 -0
  92. package/dist/src/faction.js +237 -0
  93. package/dist/src/generate.d.ts +35 -0
  94. package/dist/src/generate.js +166 -0
  95. package/dist/src/index.d.ts +42 -0
  96. package/dist/src/index.js +54 -0
  97. package/dist/src/inheritance.d.ts +69 -0
  98. package/dist/src/inheritance.js +136 -0
  99. package/dist/src/inventory.d.ts +194 -0
  100. package/dist/src/inventory.js +637 -0
  101. package/dist/src/item-durability.d.ts +69 -0
  102. package/dist/src/item-durability.js +308 -0
  103. package/dist/src/legend.d.ts +97 -0
  104. package/dist/src/legend.js +269 -0
  105. package/dist/src/lod.d.ts +9 -0
  106. package/dist/src/lod.js +84 -0
  107. package/dist/src/metrics.d.ts +51 -0
  108. package/dist/src/metrics.js +91 -0
  109. package/dist/src/model3d.d.ts +138 -0
  110. package/dist/src/model3d.js +214 -0
  111. package/dist/src/mythology.d.ts +101 -0
  112. package/dist/src/mythology.js +308 -0
  113. package/dist/src/narrative-render.d.ts +42 -0
  114. package/dist/src/narrative-render.js +194 -0
  115. package/dist/src/narrative-stress.d.ts +123 -0
  116. package/dist/src/narrative-stress.js +183 -0
  117. package/dist/src/narrative.d.ts +44 -0
  118. package/dist/src/narrative.js +257 -0
  119. package/dist/src/party.d.ts +70 -0
  120. package/dist/src/party.js +226 -0
  121. package/dist/src/polity.d.ts +262 -0
  122. package/dist/src/polity.js +398 -0
  123. package/dist/src/presets.d.ts +42 -0
  124. package/dist/src/presets.js +170 -0
  125. package/dist/src/progression.d.ts +170 -0
  126. package/dist/src/progression.js +256 -0
  127. package/dist/src/quest-generators.d.ts +76 -0
  128. package/dist/src/quest-generators.js +534 -0
  129. package/dist/src/quest.d.ts +239 -0
  130. package/dist/src/quest.js +520 -0
  131. package/dist/src/relationships-effects.d.ts +75 -0
  132. package/dist/src/relationships-effects.js +219 -0
  133. package/dist/src/relationships.d.ts +104 -0
  134. package/dist/src/relationships.js +347 -0
  135. package/dist/src/replay.d.ts +47 -0
  136. package/dist/src/replay.js +82 -0
  137. package/dist/src/rng.d.ts +9 -0
  138. package/dist/src/rng.js +37 -0
  139. package/dist/src/settlement-services.d.ts +67 -0
  140. package/dist/src/settlement-services.js +267 -0
  141. package/dist/src/settlement.d.ts +143 -0
  142. package/dist/src/settlement.js +419 -0
  143. package/dist/src/sim/action.d.ts +28 -0
  144. package/dist/src/sim/action.js +12 -0
  145. package/dist/src/sim/aging.d.ts +95 -0
  146. package/dist/src/sim/aging.js +243 -0
  147. package/dist/src/sim/ai/decide.d.ts +10 -0
  148. package/dist/src/sim/ai/decide.js +267 -0
  149. package/dist/src/sim/ai/perception.d.ts +12 -0
  150. package/dist/src/sim/ai/perception.js +54 -0
  151. package/dist/src/sim/ai/personality.d.ts +54 -0
  152. package/dist/src/sim/ai/personality.js +202 -0
  153. package/dist/src/sim/ai/presets.d.ts +2 -0
  154. package/dist/src/sim/ai/presets.js +28 -0
  155. package/dist/src/sim/ai/system.d.ts +6 -0
  156. package/dist/src/sim/ai/system.js +13 -0
  157. package/dist/src/sim/ai/targeting.d.ts +8 -0
  158. package/dist/src/sim/ai/targeting.js +42 -0
  159. package/dist/src/sim/ai/types.d.ts +14 -0
  160. package/dist/src/sim/ai/types.js +1 -0
  161. package/dist/src/sim/body.d.ts +9 -0
  162. package/dist/src/sim/body.js +32 -0
  163. package/dist/src/sim/bodyplan.d.ts +161 -0
  164. package/dist/src/sim/bodyplan.js +677 -0
  165. package/dist/src/sim/capability.d.ts +135 -0
  166. package/dist/src/sim/capability.js +8 -0
  167. package/dist/src/sim/combat.d.ts +21 -0
  168. package/dist/src/sim/combat.js +77 -0
  169. package/dist/src/sim/commandBuilders.d.ts +11 -0
  170. package/dist/src/sim/commandBuilders.js +39 -0
  171. package/dist/src/sim/commands.d.ts +71 -0
  172. package/dist/src/sim/commands.js +8 -0
  173. package/dist/src/sim/condition.d.ts +35 -0
  174. package/dist/src/sim/condition.js +21 -0
  175. package/dist/src/sim/cone.d.ts +40 -0
  176. package/dist/src/sim/cone.js +44 -0
  177. package/dist/src/sim/context.d.ts +68 -0
  178. package/dist/src/sim/context.js +1 -0
  179. package/dist/src/sim/density.d.ts +14 -0
  180. package/dist/src/sim/density.js +33 -0
  181. package/dist/src/sim/disease.d.ts +141 -0
  182. package/dist/src/sim/disease.js +353 -0
  183. package/dist/src/sim/entity.d.ts +251 -0
  184. package/dist/src/sim/entity.js +19 -0
  185. package/dist/src/sim/events.d.ts +25 -0
  186. package/dist/src/sim/events.js +5 -0
  187. package/dist/src/sim/explosion.d.ts +40 -0
  188. package/dist/src/sim/explosion.js +40 -0
  189. package/dist/src/sim/formation-unit.d.ts +138 -0
  190. package/dist/src/sim/formation-unit.js +197 -0
  191. package/dist/src/sim/formation.d.ts +12 -0
  192. package/dist/src/sim/formation.js +54 -0
  193. package/dist/src/sim/frontage.d.ts +30 -0
  194. package/dist/src/sim/frontage.js +84 -0
  195. package/dist/src/sim/grapple.d.ts +100 -0
  196. package/dist/src/sim/grapple.js +480 -0
  197. package/dist/src/sim/hazard.d.ts +104 -0
  198. package/dist/src/sim/hazard.js +201 -0
  199. package/dist/src/sim/hydrostatic.d.ts +58 -0
  200. package/dist/src/sim/hydrostatic.js +117 -0
  201. package/dist/src/sim/impairment.d.ts +20 -0
  202. package/dist/src/sim/impairment.js +162 -0
  203. package/dist/src/sim/indexing.d.ts +7 -0
  204. package/dist/src/sim/indexing.js +7 -0
  205. package/dist/src/sim/injury.d.ts +54 -0
  206. package/dist/src/sim/injury.js +66 -0
  207. package/dist/src/sim/intent.d.ts +26 -0
  208. package/dist/src/sim/intent.js +7 -0
  209. package/dist/src/sim/kernel.d.ts +45 -0
  210. package/dist/src/sim/kernel.js +1992 -0
  211. package/dist/src/sim/kinds.d.ts +64 -0
  212. package/dist/src/sim/kinds.js +56 -0
  213. package/dist/src/sim/knockback.d.ts +50 -0
  214. package/dist/src/sim/knockback.js +82 -0
  215. package/dist/src/sim/limb.d.ts +48 -0
  216. package/dist/src/sim/limb.js +78 -0
  217. package/dist/src/sim/medical.d.ts +32 -0
  218. package/dist/src/sim/medical.js +33 -0
  219. package/dist/src/sim/morale.d.ts +69 -0
  220. package/dist/src/sim/morale.js +92 -0
  221. package/dist/src/sim/mount.d.ts +150 -0
  222. package/dist/src/sim/mount.js +225 -0
  223. package/dist/src/sim/nutrition.d.ts +74 -0
  224. package/dist/src/sim/nutrition.js +168 -0
  225. package/dist/src/sim/occlusion.d.ts +8 -0
  226. package/dist/src/sim/occlusion.js +71 -0
  227. package/dist/src/sim/push.d.ts +11 -0
  228. package/dist/src/sim/push.js +79 -0
  229. package/dist/src/sim/ranged.d.ts +44 -0
  230. package/dist/src/sim/ranged.js +69 -0
  231. package/dist/src/sim/seeds.d.ts +3 -0
  232. package/dist/src/sim/seeds.js +16 -0
  233. package/dist/src/sim/sensory-extended.d.ts +103 -0
  234. package/dist/src/sim/sensory-extended.js +181 -0
  235. package/dist/src/sim/sensory.d.ts +38 -0
  236. package/dist/src/sim/sensory.js +109 -0
  237. package/dist/src/sim/skills.d.ts +70 -0
  238. package/dist/src/sim/skills.js +69 -0
  239. package/dist/src/sim/sleep.d.ts +107 -0
  240. package/dist/src/sim/sleep.js +215 -0
  241. package/dist/src/sim/spatial.d.ts +8 -0
  242. package/dist/src/sim/spatial.js +59 -0
  243. package/dist/src/sim/step/capability.d.ts +8 -0
  244. package/dist/src/sim/step/capability.js +77 -0
  245. package/dist/src/sim/step/concentration.d.ts +9 -0
  246. package/dist/src/sim/step/concentration.js +25 -0
  247. package/dist/src/sim/step/effects.d.ts +17 -0
  248. package/dist/src/sim/step/effects.js +96 -0
  249. package/dist/src/sim/step/energy.d.ts +3 -0
  250. package/dist/src/sim/step/energy.js +31 -0
  251. package/dist/src/sim/step/hazards.d.ts +4 -0
  252. package/dist/src/sim/step/hazards.js +19 -0
  253. package/dist/src/sim/step/injury.d.ts +10 -0
  254. package/dist/src/sim/step/injury.js +353 -0
  255. package/dist/src/sim/step/morale.d.ts +11 -0
  256. package/dist/src/sim/step/morale.js +130 -0
  257. package/dist/src/sim/step/movement.d.ts +5 -0
  258. package/dist/src/sim/step/movement.js +172 -0
  259. package/dist/src/sim/step/push.d.ts +11 -0
  260. package/dist/src/sim/step/push.js +79 -0
  261. package/dist/src/sim/step/substances.d.ts +3 -0
  262. package/dist/src/sim/step/substances.js +75 -0
  263. package/dist/src/sim/substance.d.ts +38 -0
  264. package/dist/src/sim/substance.js +57 -0
  265. package/dist/src/sim/systemic-toxicology.d.ts +109 -0
  266. package/dist/src/sim/systemic-toxicology.js +263 -0
  267. package/dist/src/sim/team.d.ts +9 -0
  268. package/dist/src/sim/team.js +37 -0
  269. package/dist/src/sim/tech.d.ts +36 -0
  270. package/dist/src/sim/tech.js +46 -0
  271. package/dist/src/sim/terrain.d.ts +121 -0
  272. package/dist/src/sim/terrain.js +141 -0
  273. package/dist/src/sim/testing.d.ts +13 -0
  274. package/dist/src/sim/testing.js +100 -0
  275. package/dist/src/sim/thermoregulation.d.ts +77 -0
  276. package/dist/src/sim/thermoregulation.js +161 -0
  277. package/dist/src/sim/tick.d.ts +3 -0
  278. package/dist/src/sim/tick.js +3 -0
  279. package/dist/src/sim/toxicology.d.ts +52 -0
  280. package/dist/src/sim/toxicology.js +104 -0
  281. package/dist/src/sim/trace.d.ts +141 -0
  282. package/dist/src/sim/trace.js +1 -0
  283. package/dist/src/sim/tuning.d.ts +16 -0
  284. package/dist/src/sim/tuning.js +42 -0
  285. package/dist/src/sim/vec3.d.ts +14 -0
  286. package/dist/src/sim/vec3.js +31 -0
  287. package/dist/src/sim/weapon_dynamics.d.ts +102 -0
  288. package/dist/src/sim/weapon_dynamics.js +142 -0
  289. package/dist/src/sim/weather.d.ts +95 -0
  290. package/dist/src/sim/weather.js +105 -0
  291. package/dist/src/sim/world.d.ts +52 -0
  292. package/dist/src/sim/world.js +1 -0
  293. package/dist/src/sim/wound-aging.d.ts +120 -0
  294. package/dist/src/sim/wound-aging.js +223 -0
  295. package/dist/src/species.d.ts +106 -0
  296. package/dist/src/species.js +664 -0
  297. package/dist/src/story-arcs.d.ts +17 -0
  298. package/dist/src/story-arcs.js +276 -0
  299. package/dist/src/tech-diffusion.d.ts +80 -0
  300. package/dist/src/tech-diffusion.js +185 -0
  301. package/dist/src/traits.d.ts +25 -0
  302. package/dist/src/traits.js +178 -0
  303. package/dist/src/types.d.ts +117 -0
  304. package/dist/src/types.js +1 -0
  305. package/dist/src/units.d.ts +41 -0
  306. package/dist/src/units.js +64 -0
  307. package/dist/src/weapons.d.ts +20 -0
  308. package/dist/src/weapons.js +824 -0
  309. package/dist/src/world-generation.d.ts +52 -0
  310. package/dist/src/world-generation.js +301 -0
  311. package/package.json +74 -0
@@ -0,0 +1,695 @@
1
+ // src/arena.ts — Phase 20: Arena Simulation Framework
2
+ //
3
+ // Declarative scenario system: define a fight (+ optional recovery), run it over
4
+ // many seeds, validate outcomes against expectations, and produce statistical reports.
5
+ // Integrates Phase 18 (narrative) and Phase 19 (downtime) into one ergonomic tool.
6
+ import { q, SCALE } from "./units.js";
7
+ import { generateIndividual } from "./generate.js";
8
+ import { HUMAN_BASE, KNIGHT_INFANTRY } from "./archetypes.js";
9
+ import { defaultIntent } from "./sim/intent.js";
10
+ import { defaultAction } from "./sim/action.js";
11
+ import { defaultCondition } from "./sim/condition.js";
12
+ import { defaultInjury } from "./sim/injury.js";
13
+ import { HUMANOID_PLAN, segmentIds } from "./sim/bodyplan.js";
14
+ import { v3 } from "./sim/vec3.js";
15
+ import { STARTER_WEAPONS, STARTER_ARMOUR } from "./equipment.js";
16
+ import { buildSkillMap } from "./sim/skills.js";
17
+ import { AI_PRESETS } from "./sim/ai/presets.js";
18
+ import { stepWorld } from "./sim/kernel.js";
19
+ import { buildWorldIndex } from "./sim/indexing.js";
20
+ import { buildSpatialIndex } from "./sim/spatial.js";
21
+ import { buildAICommands } from "./sim/ai/system.js";
22
+ import { TUNING } from "./sim/tuning.js";
23
+ import { TICK_HZ } from "./sim/tick.js";
24
+ import { CollectingTrace } from "./metrics.js";
25
+ import { buildCombatLog } from "./narrative.js";
26
+ import { stepDowntime, } from "./downtime.js";
27
+ export function expectWinRate(teamId, min, max) {
28
+ const desc = max !== undefined
29
+ ? `team ${teamId} wins ${(min * 100).toFixed(0)}–${(max * 100).toFixed(0)}%`
30
+ : `team ${teamId} wins ≥ ${(min * 100).toFixed(0)}%`;
31
+ return {
32
+ description: desc,
33
+ check(r) {
34
+ const wr = r.winRateByTeam.get(teamId) ?? 0;
35
+ return wr >= min && (max === undefined || wr <= max);
36
+ },
37
+ };
38
+ }
39
+ export function expectSurvivalRate(entityId, min) {
40
+ return {
41
+ description: `entity ${entityId} survives ≥ ${(min * 100).toFixed(0)}% of trials`,
42
+ check(r) { return (r.survivalRateByEntity.get(entityId) ?? 0) >= min; },
43
+ };
44
+ }
45
+ export function expectMeanDuration(minSeconds, maxSeconds) {
46
+ return {
47
+ description: `mean combat duration ${minSeconds}–${maxSeconds} s`,
48
+ check(r) {
49
+ return r.meanCombatDuration_s >= minSeconds && r.meanCombatDuration_s <= maxSeconds;
50
+ },
51
+ };
52
+ }
53
+ export function expectRecovery(entityId, maxDays, _careLevel) {
54
+ return {
55
+ description: `entity ${entityId} combat-ready within ${maxDays} days`,
56
+ check(r) {
57
+ const stats = r.recoveryStats?.find(s => s.entityId === entityId);
58
+ if (!stats || stats.meanCombatReadyDays === null)
59
+ return false;
60
+ return stats.meanCombatReadyDays <= maxDays;
61
+ },
62
+ };
63
+ }
64
+ export function expectResourceCost(teamId, maxCostUnits) {
65
+ return {
66
+ description: `team ${teamId} mean resource cost ≤ ${maxCostUnits} units`,
67
+ check(r) {
68
+ const teamEntities = r.scenario.combatants
69
+ .filter(c => c.teamId === teamId)
70
+ .map(c => c.id);
71
+ if (teamEntities.length === 0)
72
+ return true;
73
+ const stats = (r.recoveryStats ?? []).filter(s => teamEntities.includes(s.entityId));
74
+ if (stats.length === 0)
75
+ return true;
76
+ const mean = stats.reduce((sum, s) => sum + s.meanResourceCostUnits, 0) / stats.length;
77
+ return mean <= maxCostUnits;
78
+ },
79
+ };
80
+ }
81
+ // ── Internal helpers ──────────────────────────────────────────────────────────
82
+ const DEFEATED_CONSCIOUSNESS = q(0.10); // tactical threshold
83
+ const DEFAULT_MAX_TICKS = 30 * TICK_HZ; // 30 s × 20 Hz = 600 ticks
84
+ const DEFAULT_CELL_SIZE_M = Math.trunc(4 * SCALE.m);
85
+ function buildTrialEntity(c, trialSeed) {
86
+ const entitySeed = c.seed ?? (trialSeed * 1000 + c.id);
87
+ const attrs = generateIndividual(entitySeed, c.archetype);
88
+ const segs = segmentIds(HUMANOID_PLAN);
89
+ const entity = {
90
+ id: c.id,
91
+ teamId: c.teamId,
92
+ attributes: attrs,
93
+ energy: { reserveEnergy_J: attrs.performance.reserveEnergy_J, fatigue: q(0) },
94
+ loadout: { items: [...c.loadout.items] },
95
+ traits: [],
96
+ ...(c.skills !== undefined && { skills: c.skills }),
97
+ bodyPlan: HUMANOID_PLAN,
98
+ position_m: { ...c.position_m },
99
+ velocity_mps: v3(0, 0, 0),
100
+ intent: defaultIntent(),
101
+ action: defaultAction(),
102
+ condition: defaultCondition(),
103
+ injury: defaultInjury(segs),
104
+ grapple: { holdingTargetId: 0, heldByIds: [], gripQ: q(0), position: "standing" },
105
+ };
106
+ c.mutateOnCreate?.(entity);
107
+ return entity;
108
+ }
109
+ function buildTrialWorld(scenario, trialSeed) {
110
+ const entities = scenario.combatants.map(c => buildTrialEntity(c, trialSeed));
111
+ return { tick: 0, seed: trialSeed, entities };
112
+ }
113
+ function isDefeated(e) {
114
+ return e.injury.dead || e.injury.consciousness <= DEFEATED_CONSCIOUSNESS;
115
+ }
116
+ function detectOutcome(world) {
117
+ const byTeam = new Map();
118
+ for (const e of world.entities) {
119
+ if (!byTeam.has(e.teamId))
120
+ byTeam.set(e.teamId, { alive: 0, total: 0 });
121
+ const t = byTeam.get(e.teamId);
122
+ t.total++;
123
+ if (!isDefeated(e))
124
+ t.alive++;
125
+ }
126
+ const allTeamIds = [...byTeam.keys()];
127
+ const activeTeams = allTeamIds.filter(id => byTeam.get(id).alive > 0);
128
+ // Multiple teams: combat over only when ≤1 team remains active
129
+ if (allTeamIds.length <= 1)
130
+ return null; // single-team — never "over" via team victory
131
+ if (activeTeams.length === 0)
132
+ return "draw";
133
+ if (activeTeams.length === 1) {
134
+ const winner = activeTeams[0];
135
+ if (winner === 1)
136
+ return "team1_wins";
137
+ if (winner === 2)
138
+ return "team2_wins";
139
+ return "draw";
140
+ }
141
+ return null; // still fighting
142
+ }
143
+ function captureArenaInjury(e) {
144
+ const activeBleedingRegions = [];
145
+ const fracturedRegions = [];
146
+ const infectedRegions = [];
147
+ let maxStr = 0;
148
+ for (const [r, ri] of Object.entries(e.injury.byRegion)) {
149
+ if (ri.bleedingRate > 0)
150
+ activeBleedingRegions.push(r);
151
+ if (ri.fractured)
152
+ fracturedRegions.push(r);
153
+ if (ri.infectedTick >= 0)
154
+ infectedRegions.push(r);
155
+ if (ri.structuralDamage > maxStr)
156
+ maxStr = ri.structuralDamage;
157
+ }
158
+ return {
159
+ entityId: e.id,
160
+ dead: e.injury.dead,
161
+ unconscious: e.injury.consciousness <= DEFEATED_CONSCIOUSNESS,
162
+ consciousness: e.injury.consciousness / SCALE.Q,
163
+ fluidLoss: e.injury.fluidLoss / SCALE.Q,
164
+ shock: e.injury.shock / SCALE.Q,
165
+ activeBleedingRegions,
166
+ fracturedRegions,
167
+ infectedRegions,
168
+ maxStructuralDamage: maxStr / SCALE.Q,
169
+ };
170
+ }
171
+ function runTrialRecovery(world, scenario) {
172
+ const rec = scenario.recovery;
173
+ const elapsedSeconds = Math.round(rec.recoveryHours * 3600);
174
+ const treatments = new Map();
175
+ for (const e of world.entities) {
176
+ const careLevel = rec.careByTeam?.get(e.teamId) ?? rec.careLevel;
177
+ const sched = { careLevel };
178
+ if (rec.inventory)
179
+ sched.inventory = new Map(rec.inventory);
180
+ treatments.set(e.id, sched);
181
+ }
182
+ const reports = stepDowntime(world, elapsedSeconds, {
183
+ treatments,
184
+ rest: rec.rest ?? false,
185
+ });
186
+ return reports.map((r) => ({
187
+ entityId: r.entityId,
188
+ died: r.died,
189
+ combatReadyAt_s: r.combatReadyAt_s,
190
+ fullRecoveryAt_s: r.fullRecoveryAt_s,
191
+ resourcesUsed: r.resourcesUsed,
192
+ totalCostUnits: r.totalCostUnits,
193
+ }));
194
+ }
195
+ // ── Main runner ───────────────────────────────────────────────────────────────
196
+ export function runArena(scenario, trials, options) {
197
+ const maxTicks = scenario.maxTicks ?? DEFAULT_MAX_TICKS;
198
+ const cellSize_m = scenario.terrain?.cellSize_m != null
199
+ ? Math.trunc(scenario.terrain.cellSize_m * SCALE.m)
200
+ : DEFAULT_CELL_SIZE_M;
201
+ const baseCtx = {
202
+ tractionCoeff: q(1.0),
203
+ tuning: TUNING.tactical,
204
+ ...(scenario.terrain?.terrainGrid && { terrainGrid: scenario.terrain.terrainGrid }),
205
+ ...(scenario.terrain?.obstacleGrid && { obstacleGrid: scenario.terrain.obstacleGrid }),
206
+ ...(scenario.terrain?.elevationGrid && { elevationGrid: scenario.terrain.elevationGrid }),
207
+ ...(scenario.terrain?.hazardGrid && { hazardGrid: scenario.terrain.hazardGrid }),
208
+ cellSize_m,
209
+ ...options?.ctx,
210
+ };
211
+ const policyMap = new Map();
212
+ for (const c of scenario.combatants) {
213
+ policyMap.set(c.id, (c.aiPolicy ?? AI_PRESETS["lineInfantry"]));
214
+ }
215
+ const trialResults = [];
216
+ for (let i = 0; i < trials; i++) {
217
+ const trialSeed = (options?.seedOffset ?? 0) + i + 1;
218
+ const world = buildTrialWorld(scenario, trialSeed);
219
+ const tracer = options?.narrativeCfg ? new CollectingTrace() : undefined;
220
+ const ctx = tracer
221
+ ? { ...baseCtx, trace: tracer }
222
+ : baseCtx;
223
+ let ticks = 0;
224
+ let outcome = detectOutcome(world);
225
+ while (outcome === null && ticks < maxTicks) {
226
+ const index = buildWorldIndex(world);
227
+ const spatial = buildSpatialIndex(world, cellSize_m);
228
+ const cmds = buildAICommands(world, index, spatial, id => policyMap.get(id));
229
+ stepWorld(world, cmds, ctx);
230
+ ticks++;
231
+ outcome = detectOutcome(world);
232
+ }
233
+ const finalOutcome = outcome ?? "timeout";
234
+ const survivors = world.entities.filter(e => !isDefeated(e)).map(e => e.id);
235
+ const injuries = world.entities.map(e => captureArenaInjury(e));
236
+ // Combat log
237
+ let combatLog;
238
+ if (tracer && options?.narrativeCfg) {
239
+ const lines = [];
240
+ for (const ev of tracer.events) {
241
+ const text = buildCombatLog([ev], options.narrativeCfg)[0];
242
+ if (text)
243
+ lines.push({ tick: (ev).tick ?? 0, text });
244
+ }
245
+ combatLog = lines;
246
+ }
247
+ // Recovery
248
+ let recoveryOutcomes;
249
+ if (scenario.recovery) {
250
+ recoveryOutcomes = runTrialRecovery(world, scenario);
251
+ }
252
+ const trialResult = {
253
+ trialIndex: i,
254
+ seed: trialSeed,
255
+ ticks,
256
+ outcome: finalOutcome,
257
+ survivors,
258
+ injuries,
259
+ };
260
+ if (recoveryOutcomes !== undefined)
261
+ trialResult.recoveryOutcomes = recoveryOutcomes;
262
+ if (combatLog !== undefined)
263
+ trialResult.combatLog = combatLog;
264
+ trialResults.push(trialResult);
265
+ }
266
+ return aggregateResults(scenario, trialResults);
267
+ }
268
+ // ── Aggregation ───────────────────────────────────────────────────────────────
269
+ function aggregateResults(scenario, trialResults) {
270
+ const n = trialResults.length;
271
+ if (n === 0) {
272
+ const empty = {
273
+ scenario, trials: 0, trialResults: [],
274
+ winRateByTeam: new Map(), drawRate: 0, timeoutRate: 0,
275
+ meanCombatDuration_s: 0, p50CombatDuration_s: 0,
276
+ survivalRateByEntity: new Map(), meanTTI_s: new Map(),
277
+ injuryDistribution: [], expectationResults: [],
278
+ };
279
+ return empty;
280
+ }
281
+ // Win rates
282
+ const winCounts = new Map();
283
+ let drawCount = 0;
284
+ let timeoutCount = 0;
285
+ for (const t of trialResults) {
286
+ if (t.outcome === "draw")
287
+ drawCount++;
288
+ else if (t.outcome === "timeout")
289
+ timeoutCount++;
290
+ else {
291
+ // team1_wins or team2_wins
292
+ const teamId = t.outcome === "team1_wins" ? 1 : 2;
293
+ winCounts.set(teamId, (winCounts.get(teamId) ?? 0) + 1);
294
+ }
295
+ }
296
+ const winRateByTeam = new Map();
297
+ for (const [id, cnt] of winCounts)
298
+ winRateByTeam.set(id, cnt / n);
299
+ // Combat durations
300
+ const durations = trialResults.map(t => t.ticks / TICK_HZ);
301
+ const meanCombatDuration_s = durations.reduce((a, b) => a + b, 0) / n;
302
+ const sorted = [...durations].sort((a, b) => a - b);
303
+ const p50CombatDuration_s = sorted[Math.floor(n / 2)] ?? 0;
304
+ // Survival rates
305
+ const survivalRateByEntity = new Map();
306
+ for (const c of scenario.combatants) {
307
+ const alive = trialResults.filter(t => t.survivors.includes(c.id)).length;
308
+ survivalRateByEntity.set(c.id, alive / n);
309
+ }
310
+ // Mean TTI (in seconds)
311
+ const meanTTI_s = new Map();
312
+ for (const c of scenario.combatants) {
313
+ const ttiSums = trialResults.map(t => {
314
+ const inj = t.injuries.find(i => i.entityId === c.id);
315
+ if (!inj)
316
+ return t.ticks / TICK_HZ; // not in trial — survived
317
+ if (inj.dead || inj.unconscious) {
318
+ // Estimate TTI as total ticks (conservative; no per-tick event tracking here)
319
+ return t.ticks / TICK_HZ;
320
+ }
321
+ return t.ticks / TICK_HZ; // survived full duration
322
+ });
323
+ meanTTI_s.set(c.id, ttiSums.reduce((a, b) => a + b, 0) / n);
324
+ }
325
+ // Injury distribution
326
+ const injuryDistribution = scenario.combatants.map(c => {
327
+ const trials_inj = trialResults.map(t => t.injuries.find(i => i.entityId === c.id));
328
+ const meanFluidLoss = avg(trials_inj.map(i => i?.fluidLoss ?? 0));
329
+ const fractureProbability = trials_inj.filter(i => (i?.fracturedRegions.length ?? 0) > 0).length / n;
330
+ const deathProbability = trials_inj.filter(i => i?.dead).length / n;
331
+ return { entityId: c.id, meanFluidLoss, fractureProbability, deathProbability };
332
+ });
333
+ // Recovery stats (if any trial has recoveryOutcomes)
334
+ let recoveryStats;
335
+ const hasRecovery = scenario.recovery != null && trialResults.some(t => t.recoveryOutcomes);
336
+ if (hasRecovery) {
337
+ recoveryStats = scenario.combatants.map(c => {
338
+ const outcomes = trialResults
339
+ .flatMap(t => t.recoveryOutcomes ?? [])
340
+ .filter(o => o.entityId === c.id);
341
+ const survivalRatePostRecovery = outcomes.length === 0
342
+ ? 1.0
343
+ : outcomes.filter(o => !o.died).length / outcomes.length;
344
+ const readyTimes_days = outcomes
345
+ .map(o => o.combatReadyAt_s !== null ? o.combatReadyAt_s / 86400 : null)
346
+ .filter((v) => v !== null);
347
+ const fullTimes_days = outcomes
348
+ .map(o => o.fullRecoveryAt_s !== null ? o.fullRecoveryAt_s / 86400 : null)
349
+ .filter((v) => v !== null);
350
+ const meanCombatReadyDays = readyTimes_days.length > 0
351
+ ? readyTimes_days.reduce((a, b) => a + b, 0) / readyTimes_days.length
352
+ : null;
353
+ const meanFullRecoveryDays = fullTimes_days.length > 0
354
+ ? fullTimes_days.reduce((a, b) => a + b, 0) / fullTimes_days.length
355
+ : null;
356
+ const costs = outcomes.map(o => o.totalCostUnits);
357
+ const meanResourceCostUnits = costs.length > 0 ? avg(costs) : 0;
358
+ const costsSorted = [...costs].sort((a, b) => a - b);
359
+ const p90ResourceCostUnits = costs.length > 0
360
+ ? costsSorted[Math.floor(0.90 * costs.length)] ?? costsSorted.at(-1) ?? 0
361
+ : 0;
362
+ return {
363
+ entityId: c.id,
364
+ survivalRatePostRecovery,
365
+ meanCombatReadyDays,
366
+ meanFullRecoveryDays,
367
+ meanResourceCostUnits,
368
+ p90ResourceCostUnits,
369
+ };
370
+ });
371
+ }
372
+ // Expectation checking
373
+ const result = {
374
+ scenario, trials: n, trialResults,
375
+ winRateByTeam, drawRate: drawCount / n, timeoutRate: timeoutCount / n,
376
+ meanCombatDuration_s, p50CombatDuration_s,
377
+ survivalRateByEntity, meanTTI_s, injuryDistribution,
378
+ expectationResults: [],
379
+ };
380
+ if (recoveryStats !== undefined)
381
+ result.recoveryStats = recoveryStats;
382
+ result.expectationResults = (scenario.expectations ?? []).map(exp => {
383
+ const passed = exp.check(result);
384
+ return {
385
+ description: exp.description,
386
+ passed,
387
+ ...(passed ? {} : { detail: `failed: ${exp.description}` }),
388
+ };
389
+ });
390
+ return result;
391
+ }
392
+ function avg(nums) {
393
+ if (nums.length === 0)
394
+ return 0;
395
+ return nums.reduce((a, b) => a + b, 0) / nums.length;
396
+ }
397
+ // ── Reporting ─────────────────────────────────────────────────────────────────
398
+ /** Machine-readable summary (JSON-safe — no Maps or Functions). */
399
+ export function summariseArena(result) {
400
+ return {
401
+ scenario: result.scenario.name,
402
+ trials: result.trials,
403
+ winRates: Object.fromEntries(result.winRateByTeam),
404
+ drawRate: result.drawRate,
405
+ timeoutRate: result.timeoutRate,
406
+ meanCombatDuration_s: result.meanCombatDuration_s,
407
+ p50CombatDuration_s: result.p50CombatDuration_s,
408
+ survivalRates: Object.fromEntries(result.survivalRateByEntity),
409
+ injuryDistribution: result.injuryDistribution,
410
+ recoveryStats: result.recoveryStats,
411
+ expectations: result.expectationResults,
412
+ };
413
+ }
414
+ /** Human-readable statistical report. */
415
+ export function formatArenaReport(result) {
416
+ const lines = [];
417
+ lines.push(`=== ${result.scenario.name} (${result.trials} trials) ===`);
418
+ if (result.scenario.description)
419
+ lines.push(result.scenario.description);
420
+ lines.push("");
421
+ lines.push("Combat outcomes:");
422
+ for (const [teamId, rate] of result.winRateByTeam) {
423
+ lines.push(` Team ${teamId} wins: ${(rate * 100).toFixed(1)}%`);
424
+ }
425
+ if (result.drawRate > 0)
426
+ lines.push(` Draws: ${(result.drawRate * 100).toFixed(1)}%`);
427
+ if (result.timeoutRate > 0)
428
+ lines.push(` Timeouts: ${(result.timeoutRate * 100).toFixed(1)}%`);
429
+ lines.push(` Mean duration: ${result.meanCombatDuration_s.toFixed(1)} s`);
430
+ lines.push(` p50 duration: ${result.p50CombatDuration_s.toFixed(1)} s`);
431
+ lines.push("");
432
+ lines.push("Injury distribution:");
433
+ for (const d of result.injuryDistribution) {
434
+ lines.push(` Entity ${d.entityId}: fluid loss ${(d.meanFluidLoss * 100).toFixed(1)}% ` +
435
+ `fracture ${(d.fractureProbability * 100).toFixed(0)}% ` +
436
+ `death ${(d.deathProbability * 100).toFixed(0)}%`);
437
+ }
438
+ lines.push("");
439
+ if (result.recoveryStats?.length) {
440
+ lines.push("Recovery stats:");
441
+ for (const s of result.recoveryStats) {
442
+ const crDays = s.meanCombatReadyDays !== null ? s.meanCombatReadyDays.toFixed(2) : "N/A";
443
+ const frDays = s.meanFullRecoveryDays !== null ? s.meanFullRecoveryDays.toFixed(2) : "N/A";
444
+ lines.push(` Entity ${s.entityId}: ` +
445
+ `survival ${(s.survivalRatePostRecovery * 100).toFixed(0)}% ` +
446
+ `combat-ready ${crDays} days ` +
447
+ `full recovery ${frDays} days ` +
448
+ `cost ${s.meanResourceCostUnits.toFixed(1)} units (p90: ${s.p90ResourceCostUnits.toFixed(1)})`);
449
+ }
450
+ lines.push("");
451
+ }
452
+ if (result.expectationResults.length > 0) {
453
+ lines.push("Expectations:");
454
+ for (const e of result.expectationResults) {
455
+ lines.push(` [${e.passed ? "PASS" : "FAIL"}] ${e.description}`);
456
+ if (!e.passed && e.detail)
457
+ lines.push(` ${e.detail}`);
458
+ }
459
+ lines.push("");
460
+ }
461
+ return lines.join("\n");
462
+ }
463
+ /**
464
+ * Full narrative of the median-duration trial (representative fight).
465
+ * Falls back to first trial if no narrative was collected.
466
+ */
467
+ export function narrateRepresentativeTrial(result) {
468
+ if (result.trialResults.length === 0)
469
+ return "(no trials)";
470
+ // Pick the trial closest to median duration
471
+ const sorted = [...result.trialResults].sort((a, b) => a.ticks - b.ticks);
472
+ const rep = sorted[Math.floor(sorted.length / 2)];
473
+ if (rep.combatLog && rep.combatLog.length > 0) {
474
+ return rep.combatLog.map(e => `[t${e.tick}] ${e.text}`).join("\n");
475
+ }
476
+ // Fallback: no narrative collected — build summary from result data
477
+ const lines = [
478
+ `Trial ${rep.trialIndex} (seed ${rep.seed}) — ${rep.ticks} ticks — ${rep.outcome}`,
479
+ `Survivors: ${rep.survivors.length > 0 ? rep.survivors.join(", ") : "none"}`,
480
+ ];
481
+ for (const inj of rep.injuries) {
482
+ lines.push(` Entity ${inj.entityId}: ${inj.dead ? "dead" : inj.unconscious ? "unconscious" : "standing"}`);
483
+ }
484
+ return lines.join("\n");
485
+ }
486
+ // ── Built-in calibration scenarios ───────────────────────────────────────────
487
+ const _longsword = STARTER_WEAPONS.find(w => w.id === "wpn_longsword");
488
+ const _knife = STARTER_WEAPONS.find(w => w.id === "wpn_knife");
489
+ const _plateMail = STARTER_ARMOUR[2]; // arm_plate, resist_J = 800
490
+ /**
491
+ * Armed trained human vs. unarmed untrained human.
492
+ * Source: criminal assault literature, self-defence training studies.
493
+ */
494
+ export const CALIBRATION_ARMED_VS_UNARMED = {
495
+ name: "Armed vs. Unarmed",
496
+ description: "Armed trained human vs. unarmed untrained human.",
497
+ combatants: [
498
+ {
499
+ id: 1, teamId: 1,
500
+ archetype: HUMAN_BASE,
501
+ loadout: { items: [_longsword] },
502
+ skills: buildSkillMap({ meleeCombat: { energyTransferMul: q(1.10) } }),
503
+ position_m: v3(0, 0, 0),
504
+ },
505
+ {
506
+ id: 2, teamId: 2,
507
+ archetype: HUMAN_BASE,
508
+ loadout: { items: [] },
509
+ position_m: v3(Math.trunc(0.85 * SCALE.m), 0, 0),
510
+ },
511
+ ],
512
+ maxTicks: DEFAULT_MAX_TICKS,
513
+ expectations: [
514
+ expectWinRate(1, 0.70),
515
+ expectMeanDuration(1, 30),
516
+ ],
517
+ };
518
+ /**
519
+ * Post-combat entity with severe knife wound, no treatment, 60 min downtime.
520
+ * Source: Sperry (2013) untreated penetrating abdominal trauma mortality.
521
+ */
522
+ export const CALIBRATION_UNTREATED_KNIFE_WOUND = {
523
+ name: "Untreated Knife Wound",
524
+ description: "Severe torso laceration, no treatment, 60 min downtime.",
525
+ combatants: [
526
+ {
527
+ id: 1, teamId: 1,
528
+ archetype: HUMAN_BASE,
529
+ loadout: { items: [] },
530
+ position_m: v3(0, 0, 0),
531
+ mutateOnCreate(e) {
532
+ e.injury.byRegion["torso"].bleedingRate = q(0.06);
533
+ },
534
+ },
535
+ ],
536
+ maxTicks: 0,
537
+ recovery: {
538
+ careLevel: "none",
539
+ recoveryHours: 1,
540
+ },
541
+ expectations: [
542
+ {
543
+ description: "≥ 80% of entities die within 60 simulated minutes",
544
+ check(r) {
545
+ const stats = r.recoveryStats?.find(s => s.entityId === 1);
546
+ return stats !== undefined && stats.survivalRatePostRecovery <= 0.20;
547
+ },
548
+ },
549
+ ],
550
+ };
551
+ /**
552
+ * Same severe knife wound, first_aid applied within onset delay = 0.
553
+ * Source: TCCC tourniquet outcome data.
554
+ */
555
+ export const CALIBRATION_FIRST_AID_SAVES_LIVES = {
556
+ name: "First Aid Saves Lives",
557
+ description: "Severe torso laceration, first aid applied, 60 min downtime.",
558
+ combatants: [
559
+ {
560
+ id: 1, teamId: 1,
561
+ archetype: HUMAN_BASE,
562
+ loadout: { items: [] },
563
+ position_m: v3(0, 0, 0),
564
+ mutateOnCreate(e) {
565
+ e.injury.byRegion["torso"].bleedingRate = q(0.06);
566
+ },
567
+ },
568
+ ],
569
+ maxTicks: 0,
570
+ recovery: {
571
+ careLevel: "first_aid",
572
+ recoveryHours: 1,
573
+ rest: true,
574
+ },
575
+ expectations: [
576
+ {
577
+ description: "≥ 90% survive 60 simulated minutes with first aid",
578
+ check(r) {
579
+ const stats = r.recoveryStats?.find(s => s.entityId === 1);
580
+ return stats !== undefined && stats.survivalRatePostRecovery >= 0.90;
581
+ },
582
+ },
583
+ {
584
+ description: "entity 1 combat-ready within 0.1 simulated days (tourniquet immediate)",
585
+ check(r) {
586
+ const stats = r.recoveryStats?.find(s => s.entityId === 1);
587
+ return stats !== undefined
588
+ && stats.meanCombatReadyDays !== null
589
+ && stats.meanCombatReadyDays <= 0.1;
590
+ },
591
+ },
592
+ ],
593
+ };
594
+ /**
595
+ * Fresh long-bone fracture, field_medicine care, extended downtime.
596
+ * Source: orthopaedic rehabilitation literature.
597
+ */
598
+ export const CALIBRATION_FRACTURE_RECOVERY = {
599
+ name: "Fracture Recovery",
600
+ description: "Long-bone fracture, field_medicine, 6000 s downtime.",
601
+ combatants: [
602
+ {
603
+ id: 1, teamId: 1,
604
+ archetype: HUMAN_BASE,
605
+ loadout: { items: [] },
606
+ position_m: v3(0, 0, 0),
607
+ mutateOnCreate(e) {
608
+ const leg = e.injury.byRegion["leftLeg"] ?? e.injury.byRegion["torso"];
609
+ if (leg) {
610
+ leg.structuralDamage = q(0.75);
611
+ leg.fractured = true;
612
+ }
613
+ },
614
+ },
615
+ ],
616
+ maxTicks: 0,
617
+ recovery: {
618
+ careLevel: "field_medicine",
619
+ recoveryHours: 6000 / 3600,
620
+ },
621
+ expectations: [
622
+ {
623
+ description: "≥ 90% achieve structural recovery within 6000 simulated seconds",
624
+ check(r) {
625
+ const stats = r.recoveryStats?.find(s => s.entityId === 1);
626
+ if (!stats || stats.meanFullRecoveryDays === null)
627
+ return false;
628
+ return stats.meanFullRecoveryDays <= (6000 / 86400) * 1.5; // generous upper bound
629
+ },
630
+ },
631
+ ],
632
+ };
633
+ /**
634
+ * Moderate internal wound with active infection, no antibiotics, 24h downtime.
635
+ * Source: pre-antibiotic era wound infection mortality (Ogston, Lister era data).
636
+ */
637
+ export const CALIBRATION_INFECTION_UNTREATED = {
638
+ name: "Untreated Infection",
639
+ description: "Active infection + internal damage, no treatment, 24 h downtime.",
640
+ combatants: [
641
+ {
642
+ id: 1, teamId: 1,
643
+ archetype: HUMAN_BASE,
644
+ loadout: { items: [] },
645
+ position_m: v3(0, 0, 0),
646
+ mutateOnCreate(e) {
647
+ const torso = e.injury.byRegion["torso"];
648
+ torso.infectedTick = 0;
649
+ torso.internalDamage = q(0.20);
650
+ },
651
+ },
652
+ ],
653
+ maxTicks: 0,
654
+ recovery: {
655
+ careLevel: "none",
656
+ recoveryHours: 24,
657
+ },
658
+ expectations: [
659
+ {
660
+ description: "≥ 60% fatal within 24 simulated hours (untreated sepsis)",
661
+ check(r) {
662
+ const stats = r.recoveryStats?.find(s => s.entityId === 1);
663
+ return stats !== undefined && stats.survivalRatePostRecovery <= 0.40;
664
+ },
665
+ },
666
+ ],
667
+ };
668
+ /**
669
+ * Armoured knight vs. unarmoured swordsman, matched skill and archetype.
670
+ * Source: HEMA literature on plate armour effectiveness.
671
+ */
672
+ export const CALIBRATION_PLATE_ARMOUR = {
673
+ name: "Plate Armour Effectiveness",
674
+ description: "Knight (plate armour) vs. unarmoured swordsman.",
675
+ combatants: [
676
+ {
677
+ id: 1, teamId: 1,
678
+ archetype: KNIGHT_INFANTRY,
679
+ loadout: { items: [_longsword, _plateMail] },
680
+ skills: buildSkillMap({ meleeCombat: { energyTransferMul: q(1.15) } }),
681
+ position_m: v3(0, 0, 0),
682
+ },
683
+ {
684
+ id: 2, teamId: 2,
685
+ archetype: HUMAN_BASE,
686
+ loadout: { items: [_longsword] },
687
+ skills: buildSkillMap({ meleeCombat: { energyTransferMul: q(1.15) } }),
688
+ position_m: v3(Math.trunc(0.85 * SCALE.m), 0, 0),
689
+ },
690
+ ],
691
+ maxTicks: DEFAULT_MAX_TICKS,
692
+ expectations: [
693
+ expectWinRate(1, 0.45),
694
+ ],
695
+ };