@alpaca-software/40kdc-data 0.1.0 → 0.1.2

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 (385) hide show
  1. package/dist/abilities-resolver/index.d.ts +9 -0
  2. package/dist/abilities-resolver/index.d.ts.map +1 -0
  3. package/dist/abilities-resolver/index.js +9 -0
  4. package/dist/abilities-resolver/index.js.map +1 -0
  5. package/dist/abilities-resolver/resolver.d.ts +64 -0
  6. package/dist/abilities-resolver/resolver.d.ts.map +1 -0
  7. package/dist/abilities-resolver/resolver.js +135 -0
  8. package/dist/abilities-resolver/resolver.js.map +1 -0
  9. package/dist/bundle-schemas.d.ts +1 -0
  10. package/dist/bundle-schemas.d.ts.map +1 -0
  11. package/dist/bundle-schemas.js +12 -0
  12. package/dist/bundle-schemas.js.map +1 -0
  13. package/dist/cli.d.ts +2 -0
  14. package/dist/cli.d.ts.map +1 -0
  15. package/dist/cli.js +10 -0
  16. package/dist/cli.js.map +1 -0
  17. package/dist/codegen-data.d.ts +1 -0
  18. package/dist/codegen-data.d.ts.map +1 -0
  19. package/dist/codegen-data.js +2 -0
  20. package/dist/codegen-data.js.map +1 -0
  21. package/dist/commands/import.d.ts +7 -0
  22. package/dist/commands/import.d.ts.map +1 -0
  23. package/dist/commands/import.js +103 -0
  24. package/dist/commands/import.js.map +1 -0
  25. package/dist/commands/translate.d.ts +1 -0
  26. package/dist/commands/translate.d.ts.map +1 -0
  27. package/dist/commands/translate.js +1 -0
  28. package/dist/commands/translate.js.map +1 -0
  29. package/dist/commands/validate-all.d.ts +1 -0
  30. package/dist/commands/validate-all.d.ts.map +1 -0
  31. package/dist/commands/validate-all.js +1 -0
  32. package/dist/commands/validate-all.js.map +1 -0
  33. package/dist/commands/validate-core.d.ts +1 -0
  34. package/dist/commands/validate-core.d.ts.map +1 -0
  35. package/dist/commands/validate-core.js +1 -0
  36. package/dist/commands/validate-core.js.map +1 -0
  37. package/dist/commands/validate-enrichment.d.ts +1 -0
  38. package/dist/commands/validate-enrichment.d.ts.map +1 -0
  39. package/dist/commands/validate-enrichment.js +1 -0
  40. package/dist/commands/validate-enrichment.js.map +1 -0
  41. package/dist/convert-faction.d.ts +1 -0
  42. package/dist/convert-faction.d.ts.map +1 -0
  43. package/dist/convert-faction.js +1 -0
  44. package/dist/convert-faction.js.map +1 -0
  45. package/dist/converters/configs/adepta-sororitas.d.ts +1 -0
  46. package/dist/converters/configs/adepta-sororitas.d.ts.map +1 -0
  47. package/dist/converters/configs/adepta-sororitas.js +1 -0
  48. package/dist/converters/configs/adepta-sororitas.js.map +1 -0
  49. package/dist/converters/configs/adeptus-astartes.d.ts +1 -0
  50. package/dist/converters/configs/adeptus-astartes.d.ts.map +1 -0
  51. package/dist/converters/configs/adeptus-astartes.js +1 -0
  52. package/dist/converters/configs/adeptus-astartes.js.map +1 -0
  53. package/dist/converters/configs/adeptus-custodes.d.ts +1 -0
  54. package/dist/converters/configs/adeptus-custodes.d.ts.map +1 -0
  55. package/dist/converters/configs/adeptus-custodes.js +1 -0
  56. package/dist/converters/configs/adeptus-custodes.js.map +1 -0
  57. package/dist/converters/configs/adeptus-mechanicus.d.ts +1 -0
  58. package/dist/converters/configs/adeptus-mechanicus.d.ts.map +1 -0
  59. package/dist/converters/configs/adeptus-mechanicus.js +1 -0
  60. package/dist/converters/configs/adeptus-mechanicus.js.map +1 -0
  61. package/dist/converters/configs/aeldari.d.ts +1 -0
  62. package/dist/converters/configs/aeldari.d.ts.map +1 -0
  63. package/dist/converters/configs/aeldari.js +1 -0
  64. package/dist/converters/configs/aeldari.js.map +1 -0
  65. package/dist/converters/configs/agents-of-the-imperium.d.ts +1 -0
  66. package/dist/converters/configs/agents-of-the-imperium.d.ts.map +1 -0
  67. package/dist/converters/configs/agents-of-the-imperium.js +1 -0
  68. package/dist/converters/configs/agents-of-the-imperium.js.map +1 -0
  69. package/dist/converters/configs/astra-militarum.d.ts +1 -0
  70. package/dist/converters/configs/astra-militarum.d.ts.map +1 -0
  71. package/dist/converters/configs/astra-militarum.js +1 -0
  72. package/dist/converters/configs/astra-militarum.js.map +1 -0
  73. package/dist/converters/configs/black-templars.d.ts +1 -0
  74. package/dist/converters/configs/black-templars.d.ts.map +1 -0
  75. package/dist/converters/configs/black-templars.js +1 -0
  76. package/dist/converters/configs/black-templars.js.map +1 -0
  77. package/dist/converters/configs/blood-angels.d.ts +1 -0
  78. package/dist/converters/configs/blood-angels.d.ts.map +1 -0
  79. package/dist/converters/configs/blood-angels.js +1 -0
  80. package/dist/converters/configs/blood-angels.js.map +1 -0
  81. package/dist/converters/configs/chaos-daemons.d.ts +1 -0
  82. package/dist/converters/configs/chaos-daemons.d.ts.map +1 -0
  83. package/dist/converters/configs/chaos-daemons.js +1 -0
  84. package/dist/converters/configs/chaos-daemons.js.map +1 -0
  85. package/dist/converters/configs/chaos-knights.d.ts +1 -0
  86. package/dist/converters/configs/chaos-knights.d.ts.map +1 -0
  87. package/dist/converters/configs/chaos-knights.js +1 -0
  88. package/dist/converters/configs/chaos-knights.js.map +1 -0
  89. package/dist/converters/configs/chaos-space-marines.d.ts +1 -0
  90. package/dist/converters/configs/chaos-space-marines.d.ts.map +1 -0
  91. package/dist/converters/configs/chaos-space-marines.js +1 -0
  92. package/dist/converters/configs/chaos-space-marines.js.map +1 -0
  93. package/dist/converters/configs/crimson-fists.d.ts +1 -0
  94. package/dist/converters/configs/crimson-fists.d.ts.map +1 -0
  95. package/dist/converters/configs/crimson-fists.js +1 -0
  96. package/dist/converters/configs/crimson-fists.js.map +1 -0
  97. package/dist/converters/configs/dark-angels.d.ts +1 -0
  98. package/dist/converters/configs/dark-angels.d.ts.map +1 -0
  99. package/dist/converters/configs/dark-angels.js +1 -0
  100. package/dist/converters/configs/dark-angels.js.map +1 -0
  101. package/dist/converters/configs/death-guard.d.ts +1 -0
  102. package/dist/converters/configs/death-guard.d.ts.map +1 -0
  103. package/dist/converters/configs/death-guard.js +1 -0
  104. package/dist/converters/configs/death-guard.js.map +1 -0
  105. package/dist/converters/configs/deathwatch.d.ts +1 -0
  106. package/dist/converters/configs/deathwatch.d.ts.map +1 -0
  107. package/dist/converters/configs/deathwatch.js +1 -0
  108. package/dist/converters/configs/deathwatch.js.map +1 -0
  109. package/dist/converters/configs/drukhari.d.ts +1 -0
  110. package/dist/converters/configs/drukhari.d.ts.map +1 -0
  111. package/dist/converters/configs/drukhari.js +1 -0
  112. package/dist/converters/configs/drukhari.js.map +1 -0
  113. package/dist/converters/configs/emperors-children.d.ts +1 -0
  114. package/dist/converters/configs/emperors-children.d.ts.map +1 -0
  115. package/dist/converters/configs/emperors-children.js +1 -0
  116. package/dist/converters/configs/emperors-children.js.map +1 -0
  117. package/dist/converters/configs/genestealer-cults.d.ts +1 -0
  118. package/dist/converters/configs/genestealer-cults.d.ts.map +1 -0
  119. package/dist/converters/configs/genestealer-cults.js +1 -0
  120. package/dist/converters/configs/genestealer-cults.js.map +1 -0
  121. package/dist/converters/configs/grey-knights.d.ts +1 -0
  122. package/dist/converters/configs/grey-knights.d.ts.map +1 -0
  123. package/dist/converters/configs/grey-knights.js +1 -0
  124. package/dist/converters/configs/grey-knights.js.map +1 -0
  125. package/dist/converters/configs/imperial-fists.d.ts +1 -0
  126. package/dist/converters/configs/imperial-fists.d.ts.map +1 -0
  127. package/dist/converters/configs/imperial-fists.js +1 -0
  128. package/dist/converters/configs/imperial-fists.js.map +1 -0
  129. package/dist/converters/configs/imperial-knights.d.ts +1 -0
  130. package/dist/converters/configs/imperial-knights.d.ts.map +1 -0
  131. package/dist/converters/configs/imperial-knights.js +1 -0
  132. package/dist/converters/configs/imperial-knights.js.map +1 -0
  133. package/dist/converters/configs/iron-hands.d.ts +1 -0
  134. package/dist/converters/configs/iron-hands.d.ts.map +1 -0
  135. package/dist/converters/configs/iron-hands.js +1 -0
  136. package/dist/converters/configs/iron-hands.js.map +1 -0
  137. package/dist/converters/configs/leagues-of-votann.d.ts +1 -0
  138. package/dist/converters/configs/leagues-of-votann.d.ts.map +1 -0
  139. package/dist/converters/configs/leagues-of-votann.js +1 -0
  140. package/dist/converters/configs/leagues-of-votann.js.map +1 -0
  141. package/dist/converters/configs/necrons.d.ts +1 -0
  142. package/dist/converters/configs/necrons.d.ts.map +1 -0
  143. package/dist/converters/configs/necrons.js +1 -0
  144. package/dist/converters/configs/necrons.js.map +1 -0
  145. package/dist/converters/configs/orks.d.ts +1 -0
  146. package/dist/converters/configs/orks.d.ts.map +1 -0
  147. package/dist/converters/configs/orks.js +1 -0
  148. package/dist/converters/configs/orks.js.map +1 -0
  149. package/dist/converters/configs/raven-guard.d.ts +1 -0
  150. package/dist/converters/configs/raven-guard.d.ts.map +1 -0
  151. package/dist/converters/configs/raven-guard.js +1 -0
  152. package/dist/converters/configs/raven-guard.js.map +1 -0
  153. package/dist/converters/configs/salamanders.d.ts +1 -0
  154. package/dist/converters/configs/salamanders.d.ts.map +1 -0
  155. package/dist/converters/configs/salamanders.js +1 -0
  156. package/dist/converters/configs/salamanders.js.map +1 -0
  157. package/dist/converters/configs/space-wolves.d.ts +1 -0
  158. package/dist/converters/configs/space-wolves.d.ts.map +1 -0
  159. package/dist/converters/configs/space-wolves.js +1 -0
  160. package/dist/converters/configs/space-wolves.js.map +1 -0
  161. package/dist/converters/configs/tau-empire.d.ts +1 -0
  162. package/dist/converters/configs/tau-empire.d.ts.map +1 -0
  163. package/dist/converters/configs/tau-empire.js +1 -0
  164. package/dist/converters/configs/tau-empire.js.map +1 -0
  165. package/dist/converters/configs/thousand-sons.d.ts +1 -0
  166. package/dist/converters/configs/thousand-sons.d.ts.map +1 -0
  167. package/dist/converters/configs/thousand-sons.js +1 -0
  168. package/dist/converters/configs/thousand-sons.js.map +1 -0
  169. package/dist/converters/configs/tyranids.d.ts +1 -0
  170. package/dist/converters/configs/tyranids.d.ts.map +1 -0
  171. package/dist/converters/configs/tyranids.js +1 -0
  172. package/dist/converters/configs/tyranids.js.map +1 -0
  173. package/dist/converters/configs/ultramarines.d.ts +1 -0
  174. package/dist/converters/configs/ultramarines.d.ts.map +1 -0
  175. package/dist/converters/configs/ultramarines.js +1 -0
  176. package/dist/converters/configs/ultramarines.js.map +1 -0
  177. package/dist/converters/configs/white-scars.d.ts +1 -0
  178. package/dist/converters/configs/white-scars.d.ts.map +1 -0
  179. package/dist/converters/configs/white-scars.js +1 -0
  180. package/dist/converters/configs/white-scars.js.map +1 -0
  181. package/dist/converters/configs/world-eaters.d.ts +1 -0
  182. package/dist/converters/configs/world-eaters.d.ts.map +1 -0
  183. package/dist/converters/configs/world-eaters.js +1 -0
  184. package/dist/converters/configs/world-eaters.js.map +1 -0
  185. package/dist/converters/faction-config.d.ts +1 -0
  186. package/dist/converters/faction-config.d.ts.map +1 -0
  187. package/dist/converters/faction-config.js +1 -0
  188. package/dist/converters/faction-config.js.map +1 -0
  189. package/dist/converters/id-generator.d.ts +1 -0
  190. package/dist/converters/id-generator.d.ts.map +1 -0
  191. package/dist/converters/id-generator.js +1 -0
  192. package/dist/converters/id-generator.js.map +1 -0
  193. package/dist/converters/keyword-filter.d.ts +1 -0
  194. package/dist/converters/keyword-filter.d.ts.map +1 -0
  195. package/dist/converters/keyword-filter.js +1 -0
  196. package/dist/converters/keyword-filter.js.map +1 -0
  197. package/dist/converters/stat-parser.d.ts +1 -0
  198. package/dist/converters/stat-parser.d.ts.map +1 -0
  199. package/dist/converters/stat-parser.js +1 -0
  200. package/dist/converters/stat-parser.js.map +1 -0
  201. package/dist/converters/view-selector.d.ts +1 -0
  202. package/dist/converters/view-selector.d.ts.map +1 -0
  203. package/dist/converters/view-selector.js +1 -0
  204. package/dist/converters/view-selector.js.map +1 -0
  205. package/dist/converters/weapon-dedup.d.ts +1 -0
  206. package/dist/converters/weapon-dedup.d.ts.map +1 -0
  207. package/dist/converters/weapon-dedup.js +1 -0
  208. package/dist/converters/weapon-dedup.js.map +1 -0
  209. package/dist/cruncher/buffs.d.ts +184 -0
  210. package/dist/cruncher/buffs.d.ts.map +1 -0
  211. package/dist/cruncher/buffs.js +150 -0
  212. package/dist/cruncher/buffs.js.map +1 -0
  213. package/dist/cruncher/engine.d.ts +50 -0
  214. package/dist/cruncher/engine.d.ts.map +1 -0
  215. package/dist/cruncher/engine.js +312 -0
  216. package/dist/cruncher/engine.js.map +1 -0
  217. package/dist/cruncher/from-dsl.d.ts +69 -0
  218. package/dist/cruncher/from-dsl.d.ts.map +1 -0
  219. package/dist/cruncher/from-dsl.js +523 -0
  220. package/dist/cruncher/from-dsl.js.map +1 -0
  221. package/dist/cruncher/from-keyword.d.ts +35 -0
  222. package/dist/cruncher/from-keyword.d.ts.map +1 -0
  223. package/dist/cruncher/from-keyword.js +159 -0
  224. package/dist/cruncher/from-keyword.js.map +1 -0
  225. package/dist/cruncher/get-buffs.d.ts +12 -0
  226. package/dist/cruncher/get-buffs.d.ts.map +1 -0
  227. package/dist/cruncher/get-buffs.js +7 -0
  228. package/dist/cruncher/get-buffs.js.map +1 -0
  229. package/dist/cruncher/index.d.ts +11 -0
  230. package/dist/cruncher/index.d.ts.map +1 -0
  231. package/dist/cruncher/index.js +11 -0
  232. package/dist/cruncher/index.js.map +1 -0
  233. package/dist/data/bundle.generated.d.ts +1 -0
  234. package/dist/data/bundle.generated.d.ts.map +1 -0
  235. package/dist/data/bundle.generated.js +2 -1
  236. package/dist/data/bundle.generated.js.map +1 -0
  237. package/dist/data/collection.d.ts +1 -0
  238. package/dist/data/collection.d.ts.map +1 -0
  239. package/dist/data/collection.js +1 -0
  240. package/dist/data/collection.js.map +1 -0
  241. package/dist/data/dataset.d.ts +54 -2
  242. package/dist/data/dataset.d.ts.map +1 -0
  243. package/dist/data/dataset.js +111 -1
  244. package/dist/data/dataset.js.map +1 -0
  245. package/dist/data/entities.d.ts +70 -2
  246. package/dist/data/entities.d.ts.map +1 -0
  247. package/dist/data/entities.js +122 -0
  248. package/dist/data/entities.js.map +1 -0
  249. package/dist/data/index.d.ts +9 -1
  250. package/dist/data/index.d.ts.map +1 -0
  251. package/dist/data/index.js +14 -1
  252. package/dist/data/index.js.map +1 -0
  253. package/dist/data/normalize.d.ts +1 -0
  254. package/dist/data/normalize.d.ts.map +1 -0
  255. package/dist/data/normalize.js +1 -0
  256. package/dist/data/normalize.js.map +1 -0
  257. package/dist/data/roster-resolve.d.ts +33 -0
  258. package/dist/data/roster-resolve.d.ts.map +1 -0
  259. package/dist/data/roster-resolve.js +36 -0
  260. package/dist/data/roster-resolve.js.map +1 -0
  261. package/dist/data/types.d.ts +4 -1
  262. package/dist/data/types.d.ts.map +1 -0
  263. package/dist/data/types.js +2 -0
  264. package/dist/data/types.js.map +1 -0
  265. package/dist/export/helpers.d.ts +33 -0
  266. package/dist/export/helpers.d.ts.map +1 -0
  267. package/dist/export/helpers.js +57 -0
  268. package/dist/export/helpers.js.map +1 -0
  269. package/dist/export/index.d.ts +21 -0
  270. package/dist/export/index.d.ts.map +1 -0
  271. package/dist/export/index.js +25 -0
  272. package/dist/export/index.js.map +1 -0
  273. package/dist/export/newrecruit-json.d.ts +3 -0
  274. package/dist/export/newrecruit-json.d.ts.map +1 -0
  275. package/dist/export/newrecruit-json.js +140 -0
  276. package/dist/export/newrecruit-json.js.map +1 -0
  277. package/dist/export/newrecruit-simple.d.ts +3 -0
  278. package/dist/export/newrecruit-simple.d.ts.map +1 -0
  279. package/dist/export/newrecruit-simple.js +76 -0
  280. package/dist/export/newrecruit-simple.js.map +1 -0
  281. package/dist/export/newrecruit-wtc.d.ts +4 -0
  282. package/dist/export/newrecruit-wtc.d.ts.map +1 -0
  283. package/dist/export/newrecruit-wtc.js +142 -0
  284. package/dist/export/newrecruit-wtc.js.map +1 -0
  285. package/dist/export/roster-json.d.ts +3 -0
  286. package/dist/export/roster-json.d.ts.map +1 -0
  287. package/dist/export/roster-json.js +8 -0
  288. package/dist/export/roster-json.js.map +1 -0
  289. package/dist/export/serializer.d.ts +27 -0
  290. package/dist/export/serializer.d.ts.map +1 -0
  291. package/dist/export/serializer.js +2 -0
  292. package/dist/export/serializer.js.map +1 -0
  293. package/dist/gen-conformance.d.ts +2 -0
  294. package/dist/gen-conformance.d.ts.map +1 -0
  295. package/dist/gen-conformance.js +131 -0
  296. package/dist/gen-conformance.js.map +1 -0
  297. package/dist/generated.d.ts +194 -118
  298. package/dist/generated.d.ts.map +1 -0
  299. package/dist/generated.js +1 -0
  300. package/dist/generated.js.map +1 -0
  301. package/dist/import/adapter.d.ts +27 -0
  302. package/dist/import/adapter.d.ts.map +1 -0
  303. package/dist/import/adapter.js +10 -0
  304. package/dist/import/adapter.js.map +1 -0
  305. package/dist/import/decode.d.ts +7 -0
  306. package/dist/import/decode.d.ts.map +1 -0
  307. package/dist/import/decode.js +73 -0
  308. package/dist/import/decode.js.map +1 -0
  309. package/dist/import/import-roster.d.ts +35 -0
  310. package/dist/import/import-roster.d.ts.map +1 -0
  311. package/dist/import/import-roster.js +97 -0
  312. package/dist/import/import-roster.js.map +1 -0
  313. package/dist/import/index.d.ts +22 -0
  314. package/dist/import/index.d.ts.map +1 -0
  315. package/dist/import/index.js +19 -0
  316. package/dist/import/index.js.map +1 -0
  317. package/dist/import/listforge.d.ts +24 -0
  318. package/dist/import/listforge.d.ts.map +1 -0
  319. package/dist/import/listforge.js +201 -0
  320. package/dist/import/listforge.js.map +1 -0
  321. package/dist/import/newrecruit-json.d.ts +31 -0
  322. package/dist/import/newrecruit-json.d.ts.map +1 -0
  323. package/dist/import/newrecruit-json.js +224 -0
  324. package/dist/import/newrecruit-json.js.map +1 -0
  325. package/dist/import/newrecruit-simple.d.ts +29 -0
  326. package/dist/import/newrecruit-simple.d.ts.map +1 -0
  327. package/dist/import/newrecruit-simple.js +200 -0
  328. package/dist/import/newrecruit-simple.js.map +1 -0
  329. package/dist/import/newrecruit-text.d.ts +48 -0
  330. package/dist/import/newrecruit-text.d.ts.map +1 -0
  331. package/dist/import/newrecruit-text.js +96 -0
  332. package/dist/import/newrecruit-text.js.map +1 -0
  333. package/dist/import/newrecruit-wtc.d.ts +36 -0
  334. package/dist/import/newrecruit-wtc.d.ts.map +1 -0
  335. package/dist/import/newrecruit-wtc.js +334 -0
  336. package/dist/import/newrecruit-wtc.js.map +1 -0
  337. package/dist/import/resolve.d.ts +20 -0
  338. package/dist/import/resolve.d.ts.map +1 -0
  339. package/dist/import/resolve.js +190 -0
  340. package/dist/import/resolve.js.map +1 -0
  341. package/dist/import/types.d.ts +153 -0
  342. package/dist/import/types.d.ts.map +1 -0
  343. package/dist/import/types.js +20 -0
  344. package/dist/import/types.js.map +1 -0
  345. package/dist/index.d.ts +6 -0
  346. package/dist/index.d.ts.map +1 -0
  347. package/dist/index.js +7 -0
  348. package/dist/index.js.map +1 -0
  349. package/dist/known-support-10e.d.ts +1 -0
  350. package/dist/known-support-10e.d.ts.map +1 -0
  351. package/dist/known-support-10e.js +1 -0
  352. package/dist/known-support-10e.js.map +1 -0
  353. package/dist/link-abilities.d.ts +41 -0
  354. package/dist/link-abilities.d.ts.map +1 -0
  355. package/dist/link-abilities.js +159 -0
  356. package/dist/link-abilities.js.map +1 -0
  357. package/dist/migrations/2026-weapon-keywords.d.ts +2 -0
  358. package/dist/migrations/2026-weapon-keywords.d.ts.map +1 -0
  359. package/dist/migrations/2026-weapon-keywords.js +243 -0
  360. package/dist/migrations/2026-weapon-keywords.js.map +1 -0
  361. package/dist/port-10e-faction.d.ts +1 -0
  362. package/dist/port-10e-faction.d.ts.map +1 -0
  363. package/dist/port-10e-faction.js +1 -0
  364. package/dist/port-10e-faction.js.map +1 -0
  365. package/dist/report.d.ts +1 -0
  366. package/dist/report.d.ts.map +1 -0
  367. package/dist/report.js +1 -0
  368. package/dist/report.js.map +1 -0
  369. package/dist/rube-goldberg.d.ts +3 -0
  370. package/dist/rube-goldberg.d.ts.map +1 -0
  371. package/dist/rube-goldberg.js +109 -0
  372. package/dist/rube-goldberg.js.map +1 -0
  373. package/dist/schema-loader.d.ts +1 -0
  374. package/dist/schema-loader.d.ts.map +1 -0
  375. package/dist/schema-loader.js +1 -0
  376. package/dist/schema-loader.js.map +1 -0
  377. package/dist/validate.d.ts +1 -0
  378. package/dist/validate.d.ts.map +1 -0
  379. package/dist/validate.js +2 -0
  380. package/dist/validate.js.map +1 -0
  381. package/package.json +8 -2
  382. package/schemas/core/roster.schema.json +17 -4
  383. package/schemas/core/weapon-keyword.schema.json +31 -0
  384. package/schemas/core/weapon.schema.json +22 -1
  385. package/schemas/enrichment/ability-dsl/effect.schema.json +23 -1
@@ -0,0 +1,150 @@
1
+ /** Stable ordering used to break ties when multiple buffs claim the same field. */
2
+ const SOURCE_KIND_RANK = {
3
+ "ability:army": 0,
4
+ "ability:detachment": 1,
5
+ "ability:detachment-stratagem": 2,
6
+ "ability:unit": 3,
7
+ "ability:leader": 4,
8
+ "ability:support": 5,
9
+ manual: 6,
10
+ "weapon-keyword": 7,
11
+ };
12
+ function rank(s) {
13
+ if (s.kind === "ability")
14
+ return SOURCE_KIND_RANK[`ability:${s.abilityKind}`] ?? 99;
15
+ return SOURCE_KIND_RANK[s.kind] ?? 99;
16
+ }
17
+ function applies(buff, ctx) {
18
+ const w = buff.applicableWhen;
19
+ if (!w)
20
+ return true;
21
+ if (w.phases && w.phases.length > 0 && !w.phases.includes(ctx.phase))
22
+ return false;
23
+ if (w.rollType && buff.contribution.type === "reroll" && buff.contribution.roll !== w.rollType) {
24
+ return false;
25
+ }
26
+ if (w.requiresTargetKeyword) {
27
+ const target = ctx.targetKeywords ?? [];
28
+ if (!target.includes(w.requiresTargetKeyword.toLowerCase()))
29
+ return false;
30
+ }
31
+ if (w.requiresAttackerKeyword) {
32
+ const attacker = ctx.attackerKeywords ?? [];
33
+ if (!attacker.includes(w.requiresAttackerKeyword.toLowerCase()))
34
+ return false;
35
+ }
36
+ return true;
37
+ }
38
+ /**
39
+ * Collapse a flat buff stack into a {@link ResolvedModifiers} read-out. Pure
40
+ * function; the engine — and any UI that wants to render the resolved table
41
+ * before crunching — both go through this.
42
+ */
43
+ export function resolveBuffs(buffs, ctx) {
44
+ const live = buffs.filter((b) => applies(b, ctx));
45
+ const out = {
46
+ hitMod: { value: 0, dominantSource: null },
47
+ woundMod: { value: 0, dominantSource: null },
48
+ saveMod: { value: 0, sources: [] },
49
+ cover: { active: false, source: null },
50
+ rerolls: {},
51
+ extraKeywords: [],
52
+ feelNoPain: null,
53
+ damageMod: { value: 0, sources: [] },
54
+ attacksMod: { value: 0, sources: [] },
55
+ strengthMod: { value: 0, sources: [] },
56
+ toughnessMod: { value: 0, sources: [] },
57
+ apMod: { value: 0, sources: [] },
58
+ };
59
+ // Hit / wound mods: sum, then cap at ±1, with dominant source picked from
60
+ // the contributors whose sign matches the surviving value.
61
+ const hitContribs = [];
62
+ const woundContribs = [];
63
+ for (const b of live) {
64
+ const c = b.contribution;
65
+ switch (c.type) {
66
+ case "hit-mod":
67
+ hitContribs.push({ value: c.value, source: b.source });
68
+ break;
69
+ case "wound-mod":
70
+ woundContribs.push({ value: c.value, source: b.source });
71
+ break;
72
+ case "save-mod":
73
+ out.saveMod.value += c.value;
74
+ out.saveMod.sources.push(b.source);
75
+ break;
76
+ case "cover":
77
+ if (!out.cover.active || rank(b.source) < rank(out.cover.source)) {
78
+ out.cover = { active: true, source: b.source };
79
+ }
80
+ break;
81
+ case "reroll": {
82
+ const cur = out.rerolls[c.roll];
83
+ const incoming = c.subset;
84
+ if (!cur) {
85
+ out.rerolls[c.roll] = { subset: incoming, dominantSource: b.source };
86
+ }
87
+ else {
88
+ const incomingStronger = (incoming === "all-failures" && cur.subset === "ones") ||
89
+ (incoming === cur.subset && rank(b.source) < rank(cur.dominantSource));
90
+ if (incomingStronger) {
91
+ out.rerolls[c.roll] = { subset: incoming, dominantSource: b.source };
92
+ }
93
+ }
94
+ break;
95
+ }
96
+ case "extra-keyword": {
97
+ const key = `${c.keywordRef.keyword_id}::${JSON.stringify(c.keywordRef.parameters ?? {})}`;
98
+ if (!out.extraKeywords.some((e) => keyOf(e.keywordRef) === key)) {
99
+ out.extraKeywords.push({ keywordRef: c.keywordRef, source: b.source });
100
+ }
101
+ break;
102
+ }
103
+ case "feel-no-pain":
104
+ if (out.feelNoPain === null || c.threshold < out.feelNoPain.threshold) {
105
+ out.feelNoPain = { threshold: c.threshold, dominantSource: b.source };
106
+ }
107
+ break;
108
+ case "damage-mod":
109
+ out.damageMod.value += c.value;
110
+ out.damageMod.sources.push(b.source);
111
+ break;
112
+ case "attacks-mod":
113
+ out.attacksMod.value += c.value;
114
+ out.attacksMod.sources.push(b.source);
115
+ break;
116
+ case "strength-mod":
117
+ out.strengthMod.value += c.value;
118
+ out.strengthMod.sources.push(b.source);
119
+ break;
120
+ case "toughness-mod":
121
+ out.toughnessMod.value += c.value;
122
+ out.toughnessMod.sources.push(b.source);
123
+ break;
124
+ case "ap-mod":
125
+ out.apMod.value += c.value;
126
+ out.apMod.sources.push(b.source);
127
+ break;
128
+ }
129
+ }
130
+ out.hitMod = capModifier(hitContribs);
131
+ out.woundMod = capModifier(woundContribs);
132
+ return out;
133
+ }
134
+ function keyOf(ref) {
135
+ return `${ref.keyword_id}::${JSON.stringify(ref.parameters ?? {})}`;
136
+ }
137
+ /** Sum, clamp to ±1, then pick the dominant contributing source by rank. */
138
+ function capModifier(contribs) {
139
+ if (contribs.length === 0)
140
+ return { value: 0, dominantSource: null };
141
+ const sum = contribs.reduce((a, c) => a + c.value, 0);
142
+ const capped = Math.max(-1, Math.min(1, sum));
143
+ if (capped === 0)
144
+ return { value: 0, dominantSource: null };
145
+ const sign = Math.sign(capped);
146
+ const matching = contribs.filter((c) => Math.sign(c.value) === sign);
147
+ matching.sort((a, b) => rank(a.source) - rank(b.source));
148
+ return { value: capped, dominantSource: matching[0]?.source ?? null };
149
+ }
150
+ //# sourceMappingURL=buffs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"buffs.js","sourceRoot":"","sources":["../../src/cruncher/buffs.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The flat `Buff` type every contribution flows through, and the\n * {@link resolveBuffs} resolver that collapses a stack into a\n * {@link ResolvedModifiers} read-out the engine can consume.\n *\n * The same shape carries weapon-keyword effects, ability buffs, stratagem\n * effects, and manual UI toggles — reroll-stacking, hit/wound caps, and\n * feel-no-pain-best-threshold all fall out of one resolver rather than each\n * source kind reinventing precedence.\n *\n * @packageDocumentation\n */\nimport type { Phase } from \"../generated.js\";\n\n/** Where a buff originated. Drives stable tie-breaking inside `resolveBuffs`. */\nexport type BuffSource =\n | { kind: \"weapon-keyword\"; weaponId: string; keywordId: string }\n | {\n kind: \"ability\";\n abilityId: string;\n abilityKind:\n | \"army\"\n | \"detachment\"\n | \"detachment-stratagem\"\n | \"unit\"\n | \"leader\"\n | \"support\";\n }\n | { kind: \"manual\"; label: string };\n\n/** A weapon-keyword reference (id + parameter map), as found on weapon profiles. */\nexport type WeaponKeywordRef = {\n keyword_id: string;\n parameters?: Record<string, unknown>;\n};\n\n/** One typed contribution; the engine reads `ResolvedModifiers` for the rest. */\nexport type BuffContribution =\n | { type: \"hit-mod\"; value: number }\n | { type: \"wound-mod\"; value: number }\n | { type: \"save-mod\"; value: number }\n | { type: \"cover\" }\n | {\n type: \"reroll\";\n roll: \"hit\" | \"wound\" | \"save\" | \"damage\";\n subset: \"ones\" | \"all-failures\";\n }\n | { type: \"extra-keyword\"; keywordRef: WeaponKeywordRef }\n | { type: \"feel-no-pain\"; threshold: number }\n | { type: \"damage-mod\"; value: number }\n /** Additive modifier to the attacker's per-model attack count (A stat). */\n | { type: \"attacks-mod\"; value: number }\n /** Additive modifier to the attacker's Strength stat. */\n | { type: \"strength-mod\"; value: number }\n /** Additive modifier to the defender's Toughness stat. */\n | { type: \"toughness-mod\"; value: number }\n /**\n * Additive modifier to the attacker's weapon AP. AP is signed against the\n * defender's save (negative = more piercing), so a value of `-1` here makes\n * the weapon one AP more piercing.\n */\n | { type: \"ap-mod\"; value: number };\n\n/** Optional gating; the resolver drops buffs whose gate fails. */\nexport type BuffApplicability = {\n phases?: Phase[];\n rollType?: \"hit\" | \"wound\" | \"save\" | \"damage\";\n /** Target must carry this keyword (case-insensitive). */\n requiresTargetKeyword?: string;\n /** Attacker must carry this keyword (case-insensitive). */\n requiresAttackerKeyword?: string;\n};\n\n/** A single buff: where it came from, when it applies, what it contributes. */\nexport type Buff = {\n source: BuffSource;\n applicableWhen?: BuffApplicability;\n contribution: BuffContribution;\n};\n\n/**\n * Shared engine context. Carries the phase plus a few attacker/target flags\n * the keyword translator and the resolver both need. The engine fills it from\n * its `EngineInput.context` plus the unit-keyword unions; the resolver reads\n * only the subset relevant to its `applicableWhen` checks.\n */\nexport type EngineContext = {\n phase: Phase;\n /** Attacker has not moved this turn — Heavy fires its +1 to hit. */\n attackerStationary?: boolean;\n /** Within half the weapon's range — Melta / Rapid Fire fire. */\n withinHalfRange?: boolean;\n /** Attacker benefits from cover (mostly informational; cover applies to defenders). */\n attackerInCover?: boolean;\n /** Target is in cover — the resolver flips on `cover`, the engine applies +1 to save. */\n targetInCover?: boolean;\n /** Attacker keywords (union of unit.keywords + faction_keywords), lower-cased. */\n attackerKeywords?: ReadonlyArray<string>;\n /** Target keywords (union of unit.keywords + faction_keywords), lower-cased. */\n targetKeywords?: ReadonlyArray<string>;\n /**\n * Sub-phase timing flag (e.g. `\"start-of-phase\"`, `\"end-of-phase\"`,\n * `\"on-destroyed\"`). Consumed by the `timing-is` condition. Left undefined\n * when the caller can't pin a sub-phase down — the condition then evaluates\n * as `\"unknown\"` and the SPA surfaces a diagnostic.\n */\n timing?: string;\n};\n\n/** Back-compat alias — `resolveBuffs` accepts the shared engine context. */\nexport type ResolveContext = EngineContext;\n\n/** Read-out of a resolved buff stack, with provenance per field. */\nexport type ResolvedModifiers = {\n hitMod: { value: number; dominantSource: BuffSource | null };\n woundMod: { value: number; dominantSource: BuffSource | null };\n saveMod: { value: number; sources: BuffSource[] };\n cover: { active: boolean; source: BuffSource | null };\n rerolls: Partial<\n Record<\n \"hit\" | \"wound\" | \"save\" | \"damage\",\n { subset: \"ones\" | \"all-failures\"; dominantSource: BuffSource }\n >\n >;\n extraKeywords: { keywordRef: WeaponKeywordRef; source: BuffSource }[];\n feelNoPain: { threshold: number; dominantSource: BuffSource } | null;\n damageMod: { value: number; sources: BuffSource[] };\n attacksMod: { value: number; sources: BuffSource[] };\n strengthMod: { value: number; sources: BuffSource[] };\n toughnessMod: { value: number; sources: BuffSource[] };\n apMod: { value: number; sources: BuffSource[] };\n};\n\n/** Stable ordering used to break ties when multiple buffs claim the same field. */\nconst SOURCE_KIND_RANK: Record<string, number> = {\n \"ability:army\": 0,\n \"ability:detachment\": 1,\n \"ability:detachment-stratagem\": 2,\n \"ability:unit\": 3,\n \"ability:leader\": 4,\n \"ability:support\": 5,\n manual: 6,\n \"weapon-keyword\": 7,\n};\n\nfunction rank(s: BuffSource): number {\n if (s.kind === \"ability\") return SOURCE_KIND_RANK[`ability:${s.abilityKind}`] ?? 99;\n return SOURCE_KIND_RANK[s.kind] ?? 99;\n}\n\nfunction applies(buff: Buff, ctx: ResolveContext): boolean {\n const w = buff.applicableWhen;\n if (!w) return true;\n if (w.phases && w.phases.length > 0 && !w.phases.includes(ctx.phase)) return false;\n if (w.rollType && buff.contribution.type === \"reroll\" && buff.contribution.roll !== w.rollType) {\n return false;\n }\n if (w.requiresTargetKeyword) {\n const target = ctx.targetKeywords ?? [];\n if (!target.includes(w.requiresTargetKeyword.toLowerCase())) return false;\n }\n if (w.requiresAttackerKeyword) {\n const attacker = ctx.attackerKeywords ?? [];\n if (!attacker.includes(w.requiresAttackerKeyword.toLowerCase())) return false;\n }\n return true;\n}\n\n/**\n * Collapse a flat buff stack into a {@link ResolvedModifiers} read-out. Pure\n * function; the engine — and any UI that wants to render the resolved table\n * before crunching — both go through this.\n */\nexport function resolveBuffs(buffs: Buff[], ctx: ResolveContext): ResolvedModifiers {\n const live = buffs.filter((b) => applies(b, ctx));\n\n const out: ResolvedModifiers = {\n hitMod: { value: 0, dominantSource: null },\n woundMod: { value: 0, dominantSource: null },\n saveMod: { value: 0, sources: [] },\n cover: { active: false, source: null },\n rerolls: {},\n extraKeywords: [],\n feelNoPain: null,\n damageMod: { value: 0, sources: [] },\n attacksMod: { value: 0, sources: [] },\n strengthMod: { value: 0, sources: [] },\n toughnessMod: { value: 0, sources: [] },\n apMod: { value: 0, sources: [] },\n };\n\n // Hit / wound mods: sum, then cap at ±1, with dominant source picked from\n // the contributors whose sign matches the surviving value.\n const hitContribs: { value: number; source: BuffSource }[] = [];\n const woundContribs: { value: number; source: BuffSource }[] = [];\n\n for (const b of live) {\n const c = b.contribution;\n switch (c.type) {\n case \"hit-mod\":\n hitContribs.push({ value: c.value, source: b.source });\n break;\n case \"wound-mod\":\n woundContribs.push({ value: c.value, source: b.source });\n break;\n case \"save-mod\":\n out.saveMod.value += c.value;\n out.saveMod.sources.push(b.source);\n break;\n case \"cover\":\n if (!out.cover.active || rank(b.source) < rank(out.cover.source!)) {\n out.cover = { active: true, source: b.source };\n }\n break;\n case \"reroll\": {\n const cur = out.rerolls[c.roll];\n const incoming = c.subset;\n if (!cur) {\n out.rerolls[c.roll] = { subset: incoming, dominantSource: b.source };\n } else {\n const incomingStronger =\n (incoming === \"all-failures\" && cur.subset === \"ones\") ||\n (incoming === cur.subset && rank(b.source) < rank(cur.dominantSource));\n if (incomingStronger) {\n out.rerolls[c.roll] = { subset: incoming, dominantSource: b.source };\n }\n }\n break;\n }\n case \"extra-keyword\": {\n const key = `${c.keywordRef.keyword_id}::${JSON.stringify(c.keywordRef.parameters ?? {})}`;\n if (!out.extraKeywords.some((e) => keyOf(e.keywordRef) === key)) {\n out.extraKeywords.push({ keywordRef: c.keywordRef, source: b.source });\n }\n break;\n }\n case \"feel-no-pain\":\n if (out.feelNoPain === null || c.threshold < out.feelNoPain.threshold) {\n out.feelNoPain = { threshold: c.threshold, dominantSource: b.source };\n }\n break;\n case \"damage-mod\":\n out.damageMod.value += c.value;\n out.damageMod.sources.push(b.source);\n break;\n case \"attacks-mod\":\n out.attacksMod.value += c.value;\n out.attacksMod.sources.push(b.source);\n break;\n case \"strength-mod\":\n out.strengthMod.value += c.value;\n out.strengthMod.sources.push(b.source);\n break;\n case \"toughness-mod\":\n out.toughnessMod.value += c.value;\n out.toughnessMod.sources.push(b.source);\n break;\n case \"ap-mod\":\n out.apMod.value += c.value;\n out.apMod.sources.push(b.source);\n break;\n }\n }\n\n out.hitMod = capModifier(hitContribs);\n out.woundMod = capModifier(woundContribs);\n\n return out;\n}\n\nfunction keyOf(ref: WeaponKeywordRef): string {\n return `${ref.keyword_id}::${JSON.stringify(ref.parameters ?? {})}`;\n}\n\n/** Sum, clamp to ±1, then pick the dominant contributing source by rank. */\nfunction capModifier(\n contribs: { value: number; source: BuffSource }[],\n): { value: number; dominantSource: BuffSource | null } {\n if (contribs.length === 0) return { value: 0, dominantSource: null };\n const sum = contribs.reduce((a, c) => a + c.value, 0);\n const capped = Math.max(-1, Math.min(1, sum));\n if (capped === 0) return { value: 0, dominantSource: null };\n const sign = Math.sign(capped);\n const matching = contribs.filter((c) => Math.sign(c.value) === sign);\n matching.sort((a, b) => rank(a.source) - rank(b.source));\n return { value: capped, dominantSource: matching[0]?.source ?? null };\n}\n"]}
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The expected-value damage engine.
3
+ *
4
+ * Closed-form math over schema profiles + a flat {@link Buff} stack. No
5
+ * sampling, no I/O. Auto-injects every weapon-keyword on the attacker's
6
+ * profile as a buff (so callers don't have to enumerate intrinsics), then
7
+ * resolves the stack via {@link resolveBuffs}, then walks
8
+ * attacks → hits → wounds → unsaved → damage → after-fnp → models-killed.
9
+ *
10
+ * The dataset is required (and defaults to the embedded one) — without it
11
+ * the engine can't look up weapon-keyword effects.
12
+ */
13
+ import type { Phase, Unit, Weapon } from "../generated.js";
14
+ import { type Buff, type EngineContext, type ResolvedModifiers } from "./buffs.js";
15
+ import { Dataset } from "../data/dataset.js";
16
+ export type AttackProfileRef = {
17
+ weapon: Weapon;
18
+ profileIndex: number;
19
+ };
20
+ export type TargetProfileRef = {
21
+ unit: Unit;
22
+ profileIndex: number;
23
+ /** Override target model count (otherwise read from `unit.model_count.min`). */
24
+ modelCount?: number;
25
+ };
26
+ export type Stage = {
27
+ name: "attacks" | "hits" | "wounds" | "unsaved" | "damage" | "after-fnp" | "models-killed";
28
+ expected: number;
29
+ detail: string;
30
+ };
31
+ export type EngineInput = {
32
+ attacker: AttackProfileRef;
33
+ target: TargetProfileRef;
34
+ modelsFiring: number;
35
+ /** User / ability / manual buffs. Weapon-keyword buffs are auto-injected. */
36
+ buffs: Buff[];
37
+ context: EngineContext;
38
+ };
39
+ export type EngineOutput = {
40
+ stages: Stage[];
41
+ resolved: ResolvedModifiers;
42
+ };
43
+ /**
44
+ * Compute the expected per-stage projection for one (attacker, target, buffs)
45
+ * triple. The dataset defaults to the embedded one — pass an alternate when
46
+ * crunching against a different bundle (e.g. tests).
47
+ */
48
+ export declare function crunch(input: EngineInput, dataset?: Dataset): EngineOutput;
49
+ export type { Phase };
50
+ //# sourceMappingURL=engine.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../../src/cruncher/engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAC3D,OAAO,EACL,KAAK,IAAI,EACT,KAAK,aAAa,EAElB,KAAK,iBAAiB,EAEvB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAE7C,MAAM,MAAM,gBAAgB,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,CAAC;AACxE,MAAM,MAAM,gBAAgB,GAAG;IAC7B,IAAI,EAAE,IAAI,CAAC;IACX,YAAY,EAAE,MAAM,CAAC;IACrB,gFAAgF;IAChF,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,KAAK,GAAG;IAClB,IAAI,EAAE,SAAS,GAAG,MAAM,GAAG,QAAQ,GAAG,SAAS,GAAG,QAAQ,GAAG,WAAW,GAAG,eAAe,CAAC;IAC3F,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG;IACxB,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,MAAM,EAAE,gBAAgB,CAAC;IACzB,YAAY,EAAE,MAAM,CAAC;IACrB,6EAA6E;IAC7E,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,OAAO,EAAE,aAAa,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG;IAAE,MAAM,EAAE,KAAK,EAAE,CAAC;IAAC,QAAQ,EAAE,iBAAiB,CAAA;CAAE,CAAC;AAE5E;;;;GAIG;AACH,wBAAgB,MAAM,CAAC,KAAK,EAAE,WAAW,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,YAAY,CA0L1E;AAgKD,YAAY,EAAE,KAAK,EAAE,CAAC"}
@@ -0,0 +1,312 @@
1
+ import { resolveBuffs, } from "./buffs.js";
2
+ import { Dataset } from "../data/dataset.js";
3
+ /**
4
+ * Compute the expected per-stage projection for one (attacker, target, buffs)
5
+ * triple. The dataset defaults to the embedded one — pass an alternate when
6
+ * crunching against a different bundle (e.g. tests).
7
+ */
8
+ export function crunch(input, dataset) {
9
+ const ds = dataset ?? lazyEmbeddedDataset();
10
+ const weaponProfile = input.attacker.weapon.profiles[input.attacker.profileIndex];
11
+ if (!weaponProfile) {
12
+ throw new RangeError(`crunch: attacker.profileIndex=${input.attacker.profileIndex} is out of range for weapon ${input.attacker.weapon.id}`);
13
+ }
14
+ const unitProfile = input.target.unit.profiles[input.target.profileIndex];
15
+ if (!unitProfile) {
16
+ throw new RangeError(`crunch: target.profileIndex=${input.target.profileIndex} is out of range for unit ${input.target.unit.id}`);
17
+ }
18
+ const targetKeywords = unitKeywordsLower(input.target.unit);
19
+ const ctx = {
20
+ ...input.context,
21
+ targetKeywords: input.context.targetKeywords ?? targetKeywords,
22
+ };
23
+ // Auto-inject weapon-keyword buffs from the attacker profile, then append
24
+ // the caller-supplied stack. resolveBuffs deduplicates and ranks them.
25
+ const profileBuffs = profileBuffsFor(input.attacker, ds, ctx);
26
+ const resolved = resolveBuffs([...profileBuffs, ...input.buffs], ctx);
27
+ const stages = [];
28
+ // 1. Attacks
29
+ const isMelee = input.attacker.weapon.type === "melee";
30
+ const baseA = evalStatValue(weaponProfile.stats.A);
31
+ const attacksPerModel = baseA + resolved.attacksMod.value;
32
+ const rapidFire = findKeyword(resolved, "rapid-fire");
33
+ const halfRange = ctx.withinHalfRange === true;
34
+ const rapidFireExtraPerModel = rapidFire && halfRange ? evalStatValue(rapidFire.parameters?.value) : 0;
35
+ const blast = findKeyword(resolved, "blast");
36
+ const targetModelCount = input.target.modelCount ?? input.target.unit.model_count?.min ?? 1;
37
+ const blastExtraPerModel = blast ? Math.floor(targetModelCount / 5) : 0;
38
+ const attacks = input.modelsFiring * (attacksPerModel + rapidFireExtraPerModel + blastExtraPerModel);
39
+ stages.push({
40
+ name: "attacks",
41
+ expected: attacks,
42
+ detail: attacksDetail(input.modelsFiring, attacksPerModel, rapidFireExtraPerModel, blastExtraPerModel),
43
+ });
44
+ // 2. Hits
45
+ const hitStat = isMelee ? weaponProfile.stats.WS : weaponProfile.stats.BS;
46
+ const torrent = !!findKeyword(resolved, "torrent");
47
+ let hits;
48
+ let critHits;
49
+ let hitsDetail;
50
+ if (torrent) {
51
+ hits = attacks;
52
+ critHits = 0;
53
+ hitsDetail = `Torrent: auto-hits (${attacks.toFixed(4)})`;
54
+ }
55
+ else {
56
+ if (typeof hitStat !== "number") {
57
+ throw new Error(`crunch: weapon ${input.attacker.weapon.id} profile ${input.attacker.profileIndex} missing ${isMelee ? "WS" : "BS"}`);
58
+ }
59
+ const probs = checkProbabilities({
60
+ unmodifiedNeeded: hitStat,
61
+ modifier: resolved.hitMod.value,
62
+ reroll: resolved.rerolls.hit?.subset ?? "none",
63
+ autoFailOnOne: true,
64
+ autoPassOnSix: true,
65
+ critThreshold: 6,
66
+ });
67
+ hits = attacks * probs.pass;
68
+ critHits = attacks * probs.crit;
69
+ hitsDetail = `${isMelee ? "WS" : "BS"}${hitStat}+ (mod ${signed(resolved.hitMod.value)}, reroll ${resolved.rerolls.hit?.subset ?? "none"}) → P(hit)=${probs.pass.toFixed(4)}, P(crit)=${probs.crit.toFixed(4)}`;
70
+ }
71
+ const sustained = findKeyword(resolved, "sustained-hits");
72
+ if (sustained) {
73
+ hits += critHits * evalStatValue(sustained.parameters?.value);
74
+ hitsDetail += `; +Sustained Hits ${sustained.parameters?.value ?? 1} on ${critHits.toFixed(4)} crits`;
75
+ }
76
+ stages.push({ name: "hits", expected: hits, detail: hitsDetail });
77
+ // 3. Wounds
78
+ const S = evalStatValue(weaponProfile.stats.S) + resolved.strengthMod.value;
79
+ const T = unitProfile.T + resolved.toughnessMod.value;
80
+ const stdWoundNeeded = woundThreshold(S, T);
81
+ const anti = findKeyword(resolved, "anti");
82
+ let antiThreshold = 7; // unreachable
83
+ if (anti) {
84
+ const targetKw = anti.parameters?.target_keyword?.toLowerCase();
85
+ if (targetKw && targetKeywords.includes(targetKw)) {
86
+ const threshold = Number(anti.parameters?.threshold);
87
+ if (Number.isFinite(threshold))
88
+ antiThreshold = threshold;
89
+ }
90
+ }
91
+ const critWoundThreshold = Math.min(6, antiThreshold);
92
+ const hasLethal = !!findKeyword(resolved, "lethal-hits");
93
+ const hitsForWoundRoll = hasLethal ? hits - critHits : hits;
94
+ const lethalAutoWounds = hasLethal ? critHits : 0;
95
+ const woundProbs = checkProbabilities({
96
+ unmodifiedNeeded: stdWoundNeeded,
97
+ modifier: resolved.woundMod.value,
98
+ reroll: resolved.rerolls.wound?.subset ?? "none",
99
+ autoFailOnOne: true,
100
+ autoPassOnSix: true,
101
+ critThreshold: critWoundThreshold,
102
+ });
103
+ const regularWoundsFromRoll = hitsForWoundRoll * (woundProbs.pass - woundProbs.crit);
104
+ const critWoundsFromRoll = hitsForWoundRoll * woundProbs.crit;
105
+ const totalRegularWounds = regularWoundsFromRoll + lethalAutoWounds;
106
+ const hasDevastating = !!findKeyword(resolved, "devastating-wounds");
107
+ const mortalWoundsStream = hasDevastating ? critWoundsFromRoll : 0;
108
+ const regularWoundsForSaves = hasDevastating ? totalRegularWounds : totalRegularWounds + critWoundsFromRoll;
109
+ const totalWounds = regularWoundsForSaves + mortalWoundsStream;
110
+ stages.push({
111
+ name: "wounds",
112
+ expected: totalWounds,
113
+ detail: `S${S} vs T${T} → need ${stdWoundNeeded}+, anti ${antiThreshold <= 6 ? `${antiThreshold}+ (active)` : "n/a"}, P(wound)=${woundProbs.pass.toFixed(4)} (${critWoundsFromRoll.toFixed(4)} crit), lethal ${hasLethal ? "+" + lethalAutoWounds.toFixed(4) : "—"}, devastating ${hasDevastating ? mortalWoundsStream.toFixed(4) + " MW" : "—"}`,
114
+ });
115
+ // 4. Saves
116
+ const apMod = resolved.apMod.value;
117
+ const AP = weaponProfile.stats.AP + apMod;
118
+ const saveMod = resolved.saveMod.value;
119
+ const armorTargetRaw = unitProfile.Sv - AP - saveMod;
120
+ const ignoresCover = !!findKeyword(resolved, "ignores-cover");
121
+ const covered = resolved.cover.active && !ignoresCover && input.attacker.weapon.type === "ranged";
122
+ const armorAfterCover = covered ? Math.max(3, armorTargetRaw - 1) : armorTargetRaw;
123
+ const armorFinal = clamp(armorAfterCover, 2, 7);
124
+ const invuln = unitProfile.invuln_sv ?? null;
125
+ const effectiveSaveTarget = invuln !== null ? Math.min(armorFinal, invuln) : armorFinal;
126
+ const saveProbs = checkProbabilities({
127
+ unmodifiedNeeded: effectiveSaveTarget,
128
+ modifier: 0,
129
+ reroll: resolved.rerolls.save?.subset ?? "none",
130
+ autoFailOnOne: true,
131
+ autoPassOnSix: false,
132
+ critThreshold: 7,
133
+ });
134
+ const pSaved = effectiveSaveTarget >= 7 ? 0 : saveProbs.pass;
135
+ const unsaved = regularWoundsForSaves * (1 - pSaved);
136
+ stages.push({
137
+ name: "unsaved",
138
+ expected: unsaved,
139
+ detail: `Sv${unitProfile.Sv}+, AP${signed(AP)}${apMod !== 0 ? ` (apmod ${signed(apMod)})` : ""}${saveMod !== 0 ? `, savemod ${signed(saveMod)}` : ""}${covered ? ", cover (+1, cap 3+)" : ""} → effective ${effectiveSaveTarget}+ (P(save)=${pSaved.toFixed(4)})`,
140
+ });
141
+ // 5. Damage
142
+ const baseD = evalStatValue(weaponProfile.stats.D);
143
+ const melta = findKeyword(resolved, "melta");
144
+ const meltaBonus = melta && halfRange ? evalStatValue(melta.parameters?.value) : 0;
145
+ const damagePerHit = Math.max(0, baseD + meltaBonus + resolved.damageMod.value);
146
+ const damageMain = unsaved * damagePerHit;
147
+ const damageMortal = mortalWoundsStream * damagePerHit;
148
+ const damage = damageMain + damageMortal;
149
+ stages.push({
150
+ name: "damage",
151
+ expected: damage,
152
+ detail: `D ${baseD}${meltaBonus ? ` + Melta ${meltaBonus} (half range)` : ""}${resolved.damageMod.value !== 0 ? ` ${signed(resolved.damageMod.value)} (mod)` : ""} = ${damagePerHit} per hit; main ${damageMain.toFixed(4)}, mortal ${damageMortal.toFixed(4)}`,
153
+ });
154
+ // 6. FNP
155
+ let afterFnp = damage;
156
+ let fnpDetail = "no FNP";
157
+ const fnp = resolved.feelNoPain;
158
+ if (fnp) {
159
+ const pSucc = Math.max(0, Math.min(1, (7 - fnp.threshold) / 6));
160
+ afterFnp = damage * (1 - pSucc);
161
+ fnpDetail = `FNP ${fnp.threshold}+ (P=${pSucc.toFixed(4)})`;
162
+ }
163
+ // TODO M2: per-damage-point FNP rolls (e.g. Death Guard 5+ FNP only on
164
+ // mortals); the current model applies FNP linearly to expected damage.
165
+ stages.push({ name: "after-fnp", expected: afterFnp, detail: fnpDetail });
166
+ // 7. Models killed
167
+ const W = unitProfile.W;
168
+ const expectedModelsKilled = W > 0 ? Math.min(targetModelCount, afterFnp / W) : 0;
169
+ stages.push({
170
+ name: "models-killed",
171
+ expected: expectedModelsKilled,
172
+ detail: `W${W} per model, ${targetModelCount} models in target; ${afterFnp.toFixed(4)} damage / ${W} = ${(afterFnp / W).toFixed(4)} (capped at ${targetModelCount})`,
173
+ });
174
+ return { stages, resolved };
175
+ }
176
+ // ---------------------------------------------------------------------------
177
+ // Helpers
178
+ // ---------------------------------------------------------------------------
179
+ /** Lower-cased union of a unit's `keywords` + `faction_keywords`. */
180
+ function unitKeywordsLower(unit) {
181
+ const out = [];
182
+ for (const k of unit.keywords ?? [])
183
+ out.push(String(k).toLowerCase());
184
+ for (const k of unit.faction_keywords ?? [])
185
+ out.push(String(k).toLowerCase());
186
+ return out;
187
+ }
188
+ function profileBuffsFor(attacker, dataset, ctx) {
189
+ const weaponView = dataset.weapons.get(attacker.weapon.id);
190
+ if (!weaponView) {
191
+ // Weapon isn't in the dataset (probably a hand-built test fixture); fall
192
+ // back to walking its catalog keywords manually.
193
+ return manualWeaponKeywordBuffs(attacker, dataset, ctx);
194
+ }
195
+ return weaponView.profileBuffs(attacker.profileIndex, ctx);
196
+ }
197
+ function manualWeaponKeywordBuffs(attacker, dataset, ctx) {
198
+ const profile = attacker.weapon.profiles[attacker.profileIndex];
199
+ if (!profile)
200
+ return [];
201
+ const out = [];
202
+ for (const ref of profile.keywords ?? []) {
203
+ const view = dataset.weaponKeywords.get(ref.keyword_id);
204
+ if (!view)
205
+ continue;
206
+ out.push(...view.getBuffs(ref.parameters, attacker.weapon.id, ctx));
207
+ }
208
+ return out;
209
+ }
210
+ function findKeyword(resolved, keywordId) {
211
+ return resolved.extraKeywords.find((e) => e.keywordRef.keyword_id === keywordId)?.keywordRef;
212
+ }
213
+ /** Standard 10e S-vs-T table → unmodified wound threshold (2..6). */
214
+ function woundThreshold(S, T) {
215
+ if (S >= 2 * T)
216
+ return 2;
217
+ if (S > T)
218
+ return 3;
219
+ if (S === T)
220
+ return 4;
221
+ if (S * 2 > T)
222
+ return 5;
223
+ return 6;
224
+ }
225
+ /** Probability a single die check passes (and the conditional crit rate). */
226
+ function checkProbabilities(args) {
227
+ function outcome(face) {
228
+ if (args.autoFailOnOne && face === 1)
229
+ return { pass: 0, crit: 0 };
230
+ if (face >= args.critThreshold)
231
+ return { pass: 1, crit: 1 };
232
+ if (args.autoPassOnSix && face === 6)
233
+ return { pass: 1, crit: 0 };
234
+ return (face + args.modifier) >= args.unmodifiedNeeded
235
+ ? { pass: 1, crit: 0 }
236
+ : { pass: 0, crit: 0 };
237
+ }
238
+ let pass = 0;
239
+ let crit = 0;
240
+ for (let face = 1; face <= 6; face++) {
241
+ const initial = outcome(face);
242
+ if (initial.pass === 1) {
243
+ pass += 1 / 6;
244
+ crit += initial.crit / 6;
245
+ continue;
246
+ }
247
+ // Failed initial — eligible for reroll?
248
+ const eligible = args.reroll === "all-failures" || (args.reroll === "ones" && face === 1);
249
+ if (!eligible)
250
+ continue;
251
+ // Reroll: uniform over 1..6.
252
+ let rerollPass = 0;
253
+ let rerollCrit = 0;
254
+ for (let f2 = 1; f2 <= 6; f2++) {
255
+ const second = outcome(f2);
256
+ rerollPass += second.pass / 6;
257
+ rerollCrit += second.crit / 6;
258
+ }
259
+ pass += rerollPass / 6;
260
+ crit += rerollCrit / 6;
261
+ }
262
+ return { pass, crit };
263
+ }
264
+ /**
265
+ * Mean value of a stat (number or dice expression like `"D6"`, `"2D6"`,
266
+ * `"D3+1"`, `"D6-1"`). Unrecognised strings throw — better to crash than to
267
+ * silently return 0 and produce a confidently wrong damage projection.
268
+ */
269
+ function evalStatValue(v) {
270
+ if (typeof v === "number")
271
+ return v;
272
+ if (typeof v !== "string")
273
+ return Number(v) || 0;
274
+ const trimmed = v.trim();
275
+ if (trimmed === "")
276
+ return 0;
277
+ const asNumber = Number(trimmed);
278
+ if (Number.isFinite(asNumber))
279
+ return asNumber;
280
+ const match = /^(\d*)D(\d+)([+-]\d+)?$/i.exec(trimmed);
281
+ if (!match)
282
+ throw new Error(`evalStatValue: cannot parse "${v}"`);
283
+ const count = match[1] === "" ? 1 : Number(match[1]);
284
+ const die = Number(match[2]);
285
+ const offset = match[3] ? Number(match[3]) : 0;
286
+ return count * (die + 1) / 2 + offset;
287
+ }
288
+ function clamp(n, lo, hi) {
289
+ return Math.max(lo, Math.min(hi, n));
290
+ }
291
+ function signed(n) {
292
+ if (n > 0)
293
+ return `+${n}`;
294
+ if (n < 0)
295
+ return `${n}`;
296
+ return "0";
297
+ }
298
+ function attacksDetail(models, per, rapidFire, blast) {
299
+ const parts = [`${models} × ${per}`];
300
+ if (rapidFire)
301
+ parts.push(`+ Rapid Fire ${rapidFire} (half range)`);
302
+ if (blast)
303
+ parts.push(`+ Blast ${blast}/model`);
304
+ return parts.join(" ");
305
+ }
306
+ let _embeddedDataset = null;
307
+ function lazyEmbeddedDataset() {
308
+ if (!_embeddedDataset)
309
+ _embeddedDataset = Dataset.embedded();
310
+ return _embeddedDataset;
311
+ }
312
+ //# sourceMappingURL=engine.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"engine.js","sourceRoot":"","sources":["../../src/cruncher/engine.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The expected-value damage engine.\n *\n * Closed-form math over schema profiles + a flat {@link Buff} stack. No\n * sampling, no I/O. Auto-injects every weapon-keyword on the attacker's\n * profile as a buff (so callers don't have to enumerate intrinsics), then\n * resolves the stack via {@link resolveBuffs}, then walks\n * attacks → hits → wounds → unsaved → damage → after-fnp → models-killed.\n *\n * The dataset is required (and defaults to the embedded one) — without it\n * the engine can't look up weapon-keyword effects.\n */\nimport type { Phase, Unit, Weapon } from \"../generated.js\";\nimport {\n type Buff,\n type EngineContext,\n resolveBuffs,\n type ResolvedModifiers,\n type WeaponKeywordRef,\n} from \"./buffs.js\";\nimport { Dataset } from \"../data/dataset.js\";\n\nexport type AttackProfileRef = { weapon: Weapon; profileIndex: number };\nexport type TargetProfileRef = {\n unit: Unit;\n profileIndex: number;\n /** Override target model count (otherwise read from `unit.model_count.min`). */\n modelCount?: number;\n};\n\nexport type Stage = {\n name: \"attacks\" | \"hits\" | \"wounds\" | \"unsaved\" | \"damage\" | \"after-fnp\" | \"models-killed\";\n expected: number;\n detail: string;\n};\n\nexport type EngineInput = {\n attacker: AttackProfileRef;\n target: TargetProfileRef;\n modelsFiring: number;\n /** User / ability / manual buffs. Weapon-keyword buffs are auto-injected. */\n buffs: Buff[];\n context: EngineContext;\n};\n\nexport type EngineOutput = { stages: Stage[]; resolved: ResolvedModifiers };\n\n/**\n * Compute the expected per-stage projection for one (attacker, target, buffs)\n * triple. The dataset defaults to the embedded one — pass an alternate when\n * crunching against a different bundle (e.g. tests).\n */\nexport function crunch(input: EngineInput, dataset?: Dataset): EngineOutput {\n const ds = dataset ?? lazyEmbeddedDataset();\n const weaponProfile = input.attacker.weapon.profiles[input.attacker.profileIndex];\n if (!weaponProfile) {\n throw new RangeError(\n `crunch: attacker.profileIndex=${input.attacker.profileIndex} is out of range for weapon ${input.attacker.weapon.id}`,\n );\n }\n const unitProfile = input.target.unit.profiles[input.target.profileIndex];\n if (!unitProfile) {\n throw new RangeError(\n `crunch: target.profileIndex=${input.target.profileIndex} is out of range for unit ${input.target.unit.id}`,\n );\n }\n\n const targetKeywords = unitKeywordsLower(input.target.unit);\n const ctx: EngineContext = {\n ...input.context,\n targetKeywords: input.context.targetKeywords ?? targetKeywords,\n };\n\n // Auto-inject weapon-keyword buffs from the attacker profile, then append\n // the caller-supplied stack. resolveBuffs deduplicates and ranks them.\n const profileBuffs = profileBuffsFor(input.attacker, ds, ctx);\n const resolved = resolveBuffs([...profileBuffs, ...input.buffs], ctx);\n\n const stages: Stage[] = [];\n\n // 1. Attacks\n const isMelee = input.attacker.weapon.type === \"melee\";\n const baseA = evalStatValue(weaponProfile.stats.A);\n const attacksPerModel = baseA + resolved.attacksMod.value;\n const rapidFire = findKeyword(resolved, \"rapid-fire\");\n const halfRange = ctx.withinHalfRange === true;\n const rapidFireExtraPerModel = rapidFire && halfRange ? evalStatValue(rapidFire.parameters?.value) : 0;\n const blast = findKeyword(resolved, \"blast\");\n const targetModelCount = input.target.modelCount ?? input.target.unit.model_count?.min ?? 1;\n const blastExtraPerModel = blast ? Math.floor(targetModelCount / 5) : 0;\n const attacks = input.modelsFiring * (attacksPerModel + rapidFireExtraPerModel + blastExtraPerModel);\n stages.push({\n name: \"attacks\",\n expected: attacks,\n detail: attacksDetail(input.modelsFiring, attacksPerModel, rapidFireExtraPerModel, blastExtraPerModel),\n });\n\n // 2. Hits\n const hitStat = isMelee ? weaponProfile.stats.WS : weaponProfile.stats.BS;\n const torrent = !!findKeyword(resolved, \"torrent\");\n let hits: number;\n let critHits: number;\n let hitsDetail: string;\n if (torrent) {\n hits = attacks;\n critHits = 0;\n hitsDetail = `Torrent: auto-hits (${attacks.toFixed(4)})`;\n } else {\n if (typeof hitStat !== \"number\") {\n throw new Error(\n `crunch: weapon ${input.attacker.weapon.id} profile ${input.attacker.profileIndex} missing ${isMelee ? \"WS\" : \"BS\"}`,\n );\n }\n const probs = checkProbabilities({\n unmodifiedNeeded: hitStat,\n modifier: resolved.hitMod.value,\n reroll: resolved.rerolls.hit?.subset ?? \"none\",\n autoFailOnOne: true,\n autoPassOnSix: true,\n critThreshold: 6,\n });\n hits = attacks * probs.pass;\n critHits = attacks * probs.crit;\n hitsDetail = `${isMelee ? \"WS\" : \"BS\"}${hitStat}+ (mod ${signed(resolved.hitMod.value)}, reroll ${resolved.rerolls.hit?.subset ?? \"none\"}) → P(hit)=${probs.pass.toFixed(4)}, P(crit)=${probs.crit.toFixed(4)}`;\n }\n const sustained = findKeyword(resolved, \"sustained-hits\");\n if (sustained) {\n hits += critHits * evalStatValue(sustained.parameters?.value);\n hitsDetail += `; +Sustained Hits ${sustained.parameters?.value ?? 1} on ${critHits.toFixed(4)} crits`;\n }\n stages.push({ name: \"hits\", expected: hits, detail: hitsDetail });\n\n // 3. Wounds\n const S = evalStatValue(weaponProfile.stats.S) + resolved.strengthMod.value;\n const T = unitProfile.T + resolved.toughnessMod.value;\n const stdWoundNeeded = woundThreshold(S, T);\n const anti = findKeyword(resolved, \"anti\");\n let antiThreshold = 7; // unreachable\n if (anti) {\n const targetKw = (anti.parameters?.target_keyword as string | undefined)?.toLowerCase();\n if (targetKw && targetKeywords.includes(targetKw)) {\n const threshold = Number(anti.parameters?.threshold);\n if (Number.isFinite(threshold)) antiThreshold = threshold;\n }\n }\n const critWoundThreshold = Math.min(6, antiThreshold);\n\n const hasLethal = !!findKeyword(resolved, \"lethal-hits\");\n const hitsForWoundRoll = hasLethal ? hits - critHits : hits;\n const lethalAutoWounds = hasLethal ? critHits : 0;\n\n const woundProbs = checkProbabilities({\n unmodifiedNeeded: stdWoundNeeded,\n modifier: resolved.woundMod.value,\n reroll: resolved.rerolls.wound?.subset ?? \"none\",\n autoFailOnOne: true,\n autoPassOnSix: true,\n critThreshold: critWoundThreshold,\n });\n const regularWoundsFromRoll = hitsForWoundRoll * (woundProbs.pass - woundProbs.crit);\n const critWoundsFromRoll = hitsForWoundRoll * woundProbs.crit;\n const totalRegularWounds = regularWoundsFromRoll + lethalAutoWounds;\n const hasDevastating = !!findKeyword(resolved, \"devastating-wounds\");\n const mortalWoundsStream = hasDevastating ? critWoundsFromRoll : 0;\n const regularWoundsForSaves = hasDevastating ? totalRegularWounds : totalRegularWounds + critWoundsFromRoll;\n const totalWounds = regularWoundsForSaves + mortalWoundsStream;\n stages.push({\n name: \"wounds\",\n expected: totalWounds,\n detail: `S${S} vs T${T} → need ${stdWoundNeeded}+, anti ${antiThreshold <= 6 ? `${antiThreshold}+ (active)` : \"n/a\"}, P(wound)=${woundProbs.pass.toFixed(4)} (${critWoundsFromRoll.toFixed(4)} crit), lethal ${hasLethal ? \"+\" + lethalAutoWounds.toFixed(4) : \"—\"}, devastating ${hasDevastating ? mortalWoundsStream.toFixed(4) + \" MW\" : \"—\"}`,\n });\n\n // 4. Saves\n const apMod = resolved.apMod.value;\n const AP = weaponProfile.stats.AP + apMod;\n const saveMod = resolved.saveMod.value;\n const armorTargetRaw = unitProfile.Sv - AP - saveMod;\n const ignoresCover = !!findKeyword(resolved, \"ignores-cover\");\n const covered =\n resolved.cover.active && !ignoresCover && input.attacker.weapon.type === \"ranged\";\n const armorAfterCover = covered ? Math.max(3, armorTargetRaw - 1) : armorTargetRaw;\n const armorFinal = clamp(armorAfterCover, 2, 7);\n const invuln = unitProfile.invuln_sv ?? null;\n const effectiveSaveTarget = invuln !== null ? Math.min(armorFinal, invuln) : armorFinal;\n\n const saveProbs = checkProbabilities({\n unmodifiedNeeded: effectiveSaveTarget,\n modifier: 0,\n reroll: resolved.rerolls.save?.subset ?? \"none\",\n autoFailOnOne: true,\n autoPassOnSix: false,\n critThreshold: 7,\n });\n const pSaved = effectiveSaveTarget >= 7 ? 0 : saveProbs.pass;\n const unsaved = regularWoundsForSaves * (1 - pSaved);\n stages.push({\n name: \"unsaved\",\n expected: unsaved,\n detail: `Sv${unitProfile.Sv}+, AP${signed(AP)}${apMod !== 0 ? ` (apmod ${signed(apMod)})` : \"\"}${saveMod !== 0 ? `, savemod ${signed(saveMod)}` : \"\"}${covered ? \", cover (+1, cap 3+)\" : \"\"} → effective ${effectiveSaveTarget}+ (P(save)=${pSaved.toFixed(4)})`,\n });\n\n // 5. Damage\n const baseD = evalStatValue(weaponProfile.stats.D);\n const melta = findKeyword(resolved, \"melta\");\n const meltaBonus = melta && halfRange ? evalStatValue(melta.parameters?.value) : 0;\n const damagePerHit = Math.max(0, baseD + meltaBonus + resolved.damageMod.value);\n const damageMain = unsaved * damagePerHit;\n const damageMortal = mortalWoundsStream * damagePerHit;\n const damage = damageMain + damageMortal;\n stages.push({\n name: \"damage\",\n expected: damage,\n detail: `D ${baseD}${meltaBonus ? ` + Melta ${meltaBonus} (half range)` : \"\"}${resolved.damageMod.value !== 0 ? ` ${signed(resolved.damageMod.value)} (mod)` : \"\"} = ${damagePerHit} per hit; main ${damageMain.toFixed(4)}, mortal ${damageMortal.toFixed(4)}`,\n });\n\n // 6. FNP\n let afterFnp = damage;\n let fnpDetail = \"no FNP\";\n const fnp = resolved.feelNoPain;\n if (fnp) {\n const pSucc = Math.max(0, Math.min(1, (7 - fnp.threshold) / 6));\n afterFnp = damage * (1 - pSucc);\n fnpDetail = `FNP ${fnp.threshold}+ (P=${pSucc.toFixed(4)})`;\n }\n // TODO M2: per-damage-point FNP rolls (e.g. Death Guard 5+ FNP only on\n // mortals); the current model applies FNP linearly to expected damage.\n stages.push({ name: \"after-fnp\", expected: afterFnp, detail: fnpDetail });\n\n // 7. Models killed\n const W = unitProfile.W;\n const expectedModelsKilled = W > 0 ? Math.min(targetModelCount, afterFnp / W) : 0;\n stages.push({\n name: \"models-killed\",\n expected: expectedModelsKilled,\n detail: `W${W} per model, ${targetModelCount} models in target; ${afterFnp.toFixed(4)} damage / ${W} = ${(afterFnp / W).toFixed(4)} (capped at ${targetModelCount})`,\n });\n\n return { stages, resolved };\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/** Lower-cased union of a unit's `keywords` + `faction_keywords`. */\nfunction unitKeywordsLower(unit: Unit): string[] {\n const out: string[] = [];\n for (const k of unit.keywords ?? []) out.push(String(k).toLowerCase());\n for (const k of unit.faction_keywords ?? []) out.push(String(k).toLowerCase());\n return out;\n}\n\nfunction profileBuffsFor(\n attacker: AttackProfileRef,\n dataset: Dataset,\n ctx: EngineContext,\n): Buff[] {\n const weaponView = dataset.weapons.get(attacker.weapon.id);\n if (!weaponView) {\n // Weapon isn't in the dataset (probably a hand-built test fixture); fall\n // back to walking its catalog keywords manually.\n return manualWeaponKeywordBuffs(attacker, dataset, ctx);\n }\n return weaponView.profileBuffs(attacker.profileIndex, ctx);\n}\n\nfunction manualWeaponKeywordBuffs(\n attacker: AttackProfileRef,\n dataset: Dataset,\n ctx: EngineContext,\n): Buff[] {\n const profile = attacker.weapon.profiles[attacker.profileIndex];\n if (!profile) return [];\n const out: Buff[] = [];\n for (const ref of profile.keywords ?? []) {\n const view = dataset.weaponKeywords.get(ref.keyword_id);\n if (!view) continue;\n out.push(\n ...view.getBuffs(\n ref.parameters as Record<string, unknown> | undefined,\n attacker.weapon.id,\n ctx,\n ),\n );\n }\n return out;\n}\n\nfunction findKeyword(\n resolved: ResolvedModifiers,\n keywordId: string,\n): WeaponKeywordRef | undefined {\n return resolved.extraKeywords.find((e) => e.keywordRef.keyword_id === keywordId)?.keywordRef;\n}\n\n/** Standard 10e S-vs-T table → unmodified wound threshold (2..6). */\nfunction woundThreshold(S: number, T: number): number {\n if (S >= 2 * T) return 2;\n if (S > T) return 3;\n if (S === T) return 4;\n if (S * 2 > T) return 5;\n return 6;\n}\n\n/** Probability a single die check passes (and the conditional crit rate). */\nfunction checkProbabilities(args: {\n unmodifiedNeeded: number;\n modifier: number;\n reroll: \"none\" | \"ones\" | \"all-failures\";\n autoFailOnOne: boolean;\n autoPassOnSix: boolean;\n /** Natural roll ≥ this is a crit. Use 7 to disable crits. */\n critThreshold: number;\n}): { pass: number; crit: number } {\n function outcome(face: number): { pass: number; crit: number } {\n if (args.autoFailOnOne && face === 1) return { pass: 0, crit: 0 };\n if (face >= args.critThreshold) return { pass: 1, crit: 1 };\n if (args.autoPassOnSix && face === 6) return { pass: 1, crit: 0 };\n return (face + args.modifier) >= args.unmodifiedNeeded\n ? { pass: 1, crit: 0 }\n : { pass: 0, crit: 0 };\n }\n\n let pass = 0;\n let crit = 0;\n for (let face = 1; face <= 6; face++) {\n const initial = outcome(face);\n if (initial.pass === 1) {\n pass += 1 / 6;\n crit += initial.crit / 6;\n continue;\n }\n // Failed initial — eligible for reroll?\n const eligible =\n args.reroll === \"all-failures\" || (args.reroll === \"ones\" && face === 1);\n if (!eligible) continue;\n // Reroll: uniform over 1..6.\n let rerollPass = 0;\n let rerollCrit = 0;\n for (let f2 = 1; f2 <= 6; f2++) {\n const second = outcome(f2);\n rerollPass += second.pass / 6;\n rerollCrit += second.crit / 6;\n }\n pass += rerollPass / 6;\n crit += rerollCrit / 6;\n }\n return { pass, crit };\n}\n\n/**\n * Mean value of a stat (number or dice expression like `\"D6\"`, `\"2D6\"`,\n * `\"D3+1\"`, `\"D6-1\"`). Unrecognised strings throw — better to crash than to\n * silently return 0 and produce a confidently wrong damage projection.\n */\nfunction evalStatValue(v: unknown): number {\n if (typeof v === \"number\") return v;\n if (typeof v !== \"string\") return Number(v) || 0;\n const trimmed = v.trim();\n if (trimmed === \"\") return 0;\n const asNumber = Number(trimmed);\n if (Number.isFinite(asNumber)) return asNumber;\n const match = /^(\\d*)D(\\d+)([+-]\\d+)?$/i.exec(trimmed);\n if (!match) throw new Error(`evalStatValue: cannot parse \"${v}\"`);\n const count = match[1] === \"\" ? 1 : Number(match[1]);\n const die = Number(match[2]);\n const offset = match[3] ? Number(match[3]) : 0;\n return count * (die + 1) / 2 + offset;\n}\n\nfunction clamp(n: number, lo: number, hi: number): number {\n return Math.max(lo, Math.min(hi, n));\n}\n\nfunction signed(n: number): string {\n if (n > 0) return `+${n}`;\n if (n < 0) return `${n}`;\n return \"0\";\n}\n\nfunction attacksDetail(\n models: number,\n per: number,\n rapidFire: number,\n blast: number,\n): string {\n const parts = [`${models} × ${per}`];\n if (rapidFire) parts.push(`+ Rapid Fire ${rapidFire} (half range)`);\n if (blast) parts.push(`+ Blast ${blast}/model`);\n return parts.join(\" \");\n}\n\nlet _embeddedDataset: Dataset | null = null;\nfunction lazyEmbeddedDataset(): Dataset {\n if (!_embeddedDataset) _embeddedDataset = Dataset.embedded();\n return _embeddedDataset;\n}\n\nexport type { Phase };\n"]}