@alpaca-software/40kdc-data 0.1.1 → 0.1.3

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 (424) hide show
  1. package/README.md +2 -0
  2. package/dist/abilities-resolver/index.d.ts +9 -0
  3. package/dist/abilities-resolver/index.d.ts.map +1 -0
  4. package/dist/abilities-resolver/index.js +9 -0
  5. package/dist/abilities-resolver/index.js.map +1 -0
  6. package/dist/abilities-resolver/resolver.d.ts +73 -0
  7. package/dist/abilities-resolver/resolver.d.ts.map +1 -0
  8. package/dist/abilities-resolver/resolver.js +142 -0
  9. package/dist/abilities-resolver/resolver.js.map +1 -0
  10. package/dist/audit-coverage.d.ts +78 -0
  11. package/dist/audit-coverage.d.ts.map +1 -0
  12. package/dist/audit-coverage.js +341 -0
  13. package/dist/audit-coverage.js.map +1 -0
  14. package/dist/author-batch.d.ts +147 -0
  15. package/dist/author-batch.d.ts.map +1 -0
  16. package/dist/author-batch.js +675 -0
  17. package/dist/author-batch.js.map +1 -0
  18. package/dist/author-input.d.ts +37 -0
  19. package/dist/author-input.d.ts.map +1 -0
  20. package/dist/author-input.js +162 -0
  21. package/dist/author-input.js.map +1 -0
  22. package/dist/bundle-schemas.d.ts +1 -0
  23. package/dist/bundle-schemas.d.ts.map +1 -0
  24. package/dist/bundle-schemas.js +1 -0
  25. package/dist/bundle-schemas.js.map +1 -0
  26. package/dist/cli.d.ts +2 -0
  27. package/dist/cli.d.ts.map +1 -0
  28. package/dist/cli.js +9 -0
  29. package/dist/cli.js.map +1 -0
  30. package/dist/codegen-data.d.ts +1 -0
  31. package/dist/codegen-data.d.ts.map +1 -0
  32. package/dist/codegen-data.js +2 -0
  33. package/dist/codegen-data.js.map +1 -0
  34. package/dist/commands/import.d.ts +1 -0
  35. package/dist/commands/import.d.ts.map +1 -0
  36. package/dist/commands/import.js +1 -0
  37. package/dist/commands/import.js.map +1 -0
  38. package/dist/commands/translate.d.ts +1 -0
  39. package/dist/commands/translate.d.ts.map +1 -0
  40. package/dist/commands/translate.js +10 -4
  41. package/dist/commands/translate.js.map +1 -0
  42. package/dist/commands/validate-all.d.ts +1 -0
  43. package/dist/commands/validate-all.d.ts.map +1 -0
  44. package/dist/commands/validate-all.js +1 -0
  45. package/dist/commands/validate-all.js.map +1 -0
  46. package/dist/commands/validate-core.d.ts +1 -0
  47. package/dist/commands/validate-core.d.ts.map +1 -0
  48. package/dist/commands/validate-core.js +1 -0
  49. package/dist/commands/validate-core.js.map +1 -0
  50. package/dist/commands/validate-enrichment.d.ts +1 -0
  51. package/dist/commands/validate-enrichment.d.ts.map +1 -0
  52. package/dist/commands/validate-enrichment.js +1 -0
  53. package/dist/commands/validate-enrichment.js.map +1 -0
  54. package/dist/convert-faction.d.ts +1 -0
  55. package/dist/convert-faction.d.ts.map +1 -0
  56. package/dist/convert-faction.js +1 -0
  57. package/dist/convert-faction.js.map +1 -0
  58. package/dist/converters/configs/adepta-sororitas.d.ts +1 -0
  59. package/dist/converters/configs/adepta-sororitas.d.ts.map +1 -0
  60. package/dist/converters/configs/adepta-sororitas.js +1 -0
  61. package/dist/converters/configs/adepta-sororitas.js.map +1 -0
  62. package/dist/converters/configs/adeptus-astartes.d.ts +1 -0
  63. package/dist/converters/configs/adeptus-astartes.d.ts.map +1 -0
  64. package/dist/converters/configs/adeptus-astartes.js +1 -0
  65. package/dist/converters/configs/adeptus-astartes.js.map +1 -0
  66. package/dist/converters/configs/adeptus-custodes.d.ts +1 -0
  67. package/dist/converters/configs/adeptus-custodes.d.ts.map +1 -0
  68. package/dist/converters/configs/adeptus-custodes.js +1 -0
  69. package/dist/converters/configs/adeptus-custodes.js.map +1 -0
  70. package/dist/converters/configs/adeptus-mechanicus.d.ts +1 -0
  71. package/dist/converters/configs/adeptus-mechanicus.d.ts.map +1 -0
  72. package/dist/converters/configs/adeptus-mechanicus.js +1 -0
  73. package/dist/converters/configs/adeptus-mechanicus.js.map +1 -0
  74. package/dist/converters/configs/aeldari.d.ts +1 -0
  75. package/dist/converters/configs/aeldari.d.ts.map +1 -0
  76. package/dist/converters/configs/aeldari.js +1 -0
  77. package/dist/converters/configs/aeldari.js.map +1 -0
  78. package/dist/converters/configs/agents-of-the-imperium.d.ts +1 -0
  79. package/dist/converters/configs/agents-of-the-imperium.d.ts.map +1 -0
  80. package/dist/converters/configs/agents-of-the-imperium.js +1 -0
  81. package/dist/converters/configs/agents-of-the-imperium.js.map +1 -0
  82. package/dist/converters/configs/astra-militarum.d.ts +1 -0
  83. package/dist/converters/configs/astra-militarum.d.ts.map +1 -0
  84. package/dist/converters/configs/astra-militarum.js +1 -0
  85. package/dist/converters/configs/astra-militarum.js.map +1 -0
  86. package/dist/converters/configs/black-templars.d.ts +1 -0
  87. package/dist/converters/configs/black-templars.d.ts.map +1 -0
  88. package/dist/converters/configs/black-templars.js +1 -0
  89. package/dist/converters/configs/black-templars.js.map +1 -0
  90. package/dist/converters/configs/blood-angels.d.ts +1 -0
  91. package/dist/converters/configs/blood-angels.d.ts.map +1 -0
  92. package/dist/converters/configs/blood-angels.js +1 -0
  93. package/dist/converters/configs/blood-angels.js.map +1 -0
  94. package/dist/converters/configs/chaos-daemons.d.ts +1 -0
  95. package/dist/converters/configs/chaos-daemons.d.ts.map +1 -0
  96. package/dist/converters/configs/chaos-daemons.js +1 -0
  97. package/dist/converters/configs/chaos-daemons.js.map +1 -0
  98. package/dist/converters/configs/chaos-knights.d.ts +1 -0
  99. package/dist/converters/configs/chaos-knights.d.ts.map +1 -0
  100. package/dist/converters/configs/chaos-knights.js +1 -0
  101. package/dist/converters/configs/chaos-knights.js.map +1 -0
  102. package/dist/converters/configs/chaos-space-marines.d.ts +1 -0
  103. package/dist/converters/configs/chaos-space-marines.d.ts.map +1 -0
  104. package/dist/converters/configs/chaos-space-marines.js +1 -0
  105. package/dist/converters/configs/chaos-space-marines.js.map +1 -0
  106. package/dist/converters/configs/crimson-fists.d.ts +1 -0
  107. package/dist/converters/configs/crimson-fists.d.ts.map +1 -0
  108. package/dist/converters/configs/crimson-fists.js +1 -0
  109. package/dist/converters/configs/crimson-fists.js.map +1 -0
  110. package/dist/converters/configs/dark-angels.d.ts +1 -0
  111. package/dist/converters/configs/dark-angels.d.ts.map +1 -0
  112. package/dist/converters/configs/dark-angels.js +1 -0
  113. package/dist/converters/configs/dark-angels.js.map +1 -0
  114. package/dist/converters/configs/death-guard.d.ts +1 -0
  115. package/dist/converters/configs/death-guard.d.ts.map +1 -0
  116. package/dist/converters/configs/death-guard.js +1 -0
  117. package/dist/converters/configs/death-guard.js.map +1 -0
  118. package/dist/converters/configs/deathwatch.d.ts +1 -0
  119. package/dist/converters/configs/deathwatch.d.ts.map +1 -0
  120. package/dist/converters/configs/deathwatch.js +1 -0
  121. package/dist/converters/configs/deathwatch.js.map +1 -0
  122. package/dist/converters/configs/drukhari.d.ts +1 -0
  123. package/dist/converters/configs/drukhari.d.ts.map +1 -0
  124. package/dist/converters/configs/drukhari.js +1 -0
  125. package/dist/converters/configs/drukhari.js.map +1 -0
  126. package/dist/converters/configs/emperors-children.d.ts +1 -0
  127. package/dist/converters/configs/emperors-children.d.ts.map +1 -0
  128. package/dist/converters/configs/emperors-children.js +1 -0
  129. package/dist/converters/configs/emperors-children.js.map +1 -0
  130. package/dist/converters/configs/genestealer-cults.d.ts +1 -0
  131. package/dist/converters/configs/genestealer-cults.d.ts.map +1 -0
  132. package/dist/converters/configs/genestealer-cults.js +1 -0
  133. package/dist/converters/configs/genestealer-cults.js.map +1 -0
  134. package/dist/converters/configs/grey-knights.d.ts +1 -0
  135. package/dist/converters/configs/grey-knights.d.ts.map +1 -0
  136. package/dist/converters/configs/grey-knights.js +1 -0
  137. package/dist/converters/configs/grey-knights.js.map +1 -0
  138. package/dist/converters/configs/imperial-fists.d.ts +1 -0
  139. package/dist/converters/configs/imperial-fists.d.ts.map +1 -0
  140. package/dist/converters/configs/imperial-fists.js +1 -0
  141. package/dist/converters/configs/imperial-fists.js.map +1 -0
  142. package/dist/converters/configs/imperial-knights.d.ts +1 -0
  143. package/dist/converters/configs/imperial-knights.d.ts.map +1 -0
  144. package/dist/converters/configs/imperial-knights.js +1 -0
  145. package/dist/converters/configs/imperial-knights.js.map +1 -0
  146. package/dist/converters/configs/iron-hands.d.ts +1 -0
  147. package/dist/converters/configs/iron-hands.d.ts.map +1 -0
  148. package/dist/converters/configs/iron-hands.js +1 -0
  149. package/dist/converters/configs/iron-hands.js.map +1 -0
  150. package/dist/converters/configs/leagues-of-votann.d.ts +1 -0
  151. package/dist/converters/configs/leagues-of-votann.d.ts.map +1 -0
  152. package/dist/converters/configs/leagues-of-votann.js +1 -0
  153. package/dist/converters/configs/leagues-of-votann.js.map +1 -0
  154. package/dist/converters/configs/necrons.d.ts +1 -0
  155. package/dist/converters/configs/necrons.d.ts.map +1 -0
  156. package/dist/converters/configs/necrons.js +1 -0
  157. package/dist/converters/configs/necrons.js.map +1 -0
  158. package/dist/converters/configs/orks.d.ts +1 -0
  159. package/dist/converters/configs/orks.d.ts.map +1 -0
  160. package/dist/converters/configs/orks.js +1 -0
  161. package/dist/converters/configs/orks.js.map +1 -0
  162. package/dist/converters/configs/raven-guard.d.ts +1 -0
  163. package/dist/converters/configs/raven-guard.d.ts.map +1 -0
  164. package/dist/converters/configs/raven-guard.js +1 -0
  165. package/dist/converters/configs/raven-guard.js.map +1 -0
  166. package/dist/converters/configs/salamanders.d.ts +1 -0
  167. package/dist/converters/configs/salamanders.d.ts.map +1 -0
  168. package/dist/converters/configs/salamanders.js +1 -0
  169. package/dist/converters/configs/salamanders.js.map +1 -0
  170. package/dist/converters/configs/space-wolves.d.ts +1 -0
  171. package/dist/converters/configs/space-wolves.d.ts.map +1 -0
  172. package/dist/converters/configs/space-wolves.js +1 -0
  173. package/dist/converters/configs/space-wolves.js.map +1 -0
  174. package/dist/converters/configs/tau-empire.d.ts +1 -0
  175. package/dist/converters/configs/tau-empire.d.ts.map +1 -0
  176. package/dist/converters/configs/tau-empire.js +1 -0
  177. package/dist/converters/configs/tau-empire.js.map +1 -0
  178. package/dist/converters/configs/thousand-sons.d.ts +1 -0
  179. package/dist/converters/configs/thousand-sons.d.ts.map +1 -0
  180. package/dist/converters/configs/thousand-sons.js +1 -0
  181. package/dist/converters/configs/thousand-sons.js.map +1 -0
  182. package/dist/converters/configs/tyranids.d.ts +1 -0
  183. package/dist/converters/configs/tyranids.d.ts.map +1 -0
  184. package/dist/converters/configs/tyranids.js +1 -0
  185. package/dist/converters/configs/tyranids.js.map +1 -0
  186. package/dist/converters/configs/ultramarines.d.ts +1 -0
  187. package/dist/converters/configs/ultramarines.d.ts.map +1 -0
  188. package/dist/converters/configs/ultramarines.js +1 -0
  189. package/dist/converters/configs/ultramarines.js.map +1 -0
  190. package/dist/converters/configs/white-scars.d.ts +1 -0
  191. package/dist/converters/configs/white-scars.d.ts.map +1 -0
  192. package/dist/converters/configs/white-scars.js +1 -0
  193. package/dist/converters/configs/white-scars.js.map +1 -0
  194. package/dist/converters/configs/world-eaters.d.ts +1 -0
  195. package/dist/converters/configs/world-eaters.d.ts.map +1 -0
  196. package/dist/converters/configs/world-eaters.js +1 -0
  197. package/dist/converters/configs/world-eaters.js.map +1 -0
  198. package/dist/converters/faction-config.d.ts +1 -0
  199. package/dist/converters/faction-config.d.ts.map +1 -0
  200. package/dist/converters/faction-config.js +1 -0
  201. package/dist/converters/faction-config.js.map +1 -0
  202. package/dist/converters/id-generator.d.ts +1 -0
  203. package/dist/converters/id-generator.d.ts.map +1 -0
  204. package/dist/converters/id-generator.js +1 -0
  205. package/dist/converters/id-generator.js.map +1 -0
  206. package/dist/converters/keyword-filter.d.ts +1 -0
  207. package/dist/converters/keyword-filter.d.ts.map +1 -0
  208. package/dist/converters/keyword-filter.js +1 -0
  209. package/dist/converters/keyword-filter.js.map +1 -0
  210. package/dist/converters/stat-parser.d.ts +1 -0
  211. package/dist/converters/stat-parser.d.ts.map +1 -0
  212. package/dist/converters/stat-parser.js +1 -0
  213. package/dist/converters/stat-parser.js.map +1 -0
  214. package/dist/converters/view-selector.d.ts +1 -0
  215. package/dist/converters/view-selector.d.ts.map +1 -0
  216. package/dist/converters/view-selector.js +1 -0
  217. package/dist/converters/view-selector.js.map +1 -0
  218. package/dist/converters/weapon-dedup.d.ts +1 -0
  219. package/dist/converters/weapon-dedup.d.ts.map +1 -0
  220. package/dist/converters/weapon-dedup.js +1 -0
  221. package/dist/converters/weapon-dedup.js.map +1 -0
  222. package/dist/cruncher/attribution.d.ts +66 -0
  223. package/dist/cruncher/attribution.d.ts.map +1 -0
  224. package/dist/cruncher/attribution.js +88 -0
  225. package/dist/cruncher/attribution.js.map +1 -0
  226. package/dist/cruncher/buffs.d.ts +206 -0
  227. package/dist/cruncher/buffs.d.ts.map +1 -0
  228. package/dist/cruncher/buffs.js +150 -0
  229. package/dist/cruncher/buffs.js.map +1 -0
  230. package/dist/cruncher/engine.d.ts +50 -0
  231. package/dist/cruncher/engine.d.ts.map +1 -0
  232. package/dist/cruncher/engine.js +312 -0
  233. package/dist/cruncher/engine.js.map +1 -0
  234. package/dist/cruncher/from-dsl.d.ts +101 -0
  235. package/dist/cruncher/from-dsl.d.ts.map +1 -0
  236. package/dist/cruncher/from-dsl.js +968 -0
  237. package/dist/cruncher/from-dsl.js.map +1 -0
  238. package/dist/cruncher/from-keyword.d.ts +35 -0
  239. package/dist/cruncher/from-keyword.d.ts.map +1 -0
  240. package/dist/cruncher/from-keyword.js +159 -0
  241. package/dist/cruncher/from-keyword.js.map +1 -0
  242. package/dist/cruncher/get-buffs.d.ts +12 -0
  243. package/dist/cruncher/get-buffs.d.ts.map +1 -0
  244. package/dist/cruncher/get-buffs.js +7 -0
  245. package/dist/cruncher/get-buffs.js.map +1 -0
  246. package/dist/cruncher/index.d.ts +12 -0
  247. package/dist/cruncher/index.d.ts.map +1 -0
  248. package/dist/cruncher/index.js +12 -0
  249. package/dist/cruncher/index.js.map +1 -0
  250. package/dist/data/bundle.generated.d.ts +1 -0
  251. package/dist/data/bundle.generated.d.ts.map +1 -0
  252. package/dist/data/bundle.generated.js +2 -1
  253. package/dist/data/bundle.generated.js.map +1 -0
  254. package/dist/data/collection.d.ts +10 -0
  255. package/dist/data/collection.d.ts.map +1 -0
  256. package/dist/data/collection.js +15 -0
  257. package/dist/data/collection.js.map +1 -0
  258. package/dist/data/dataset.d.ts +132 -2
  259. package/dist/data/dataset.d.ts.map +1 -0
  260. package/dist/data/dataset.js +248 -1
  261. package/dist/data/dataset.js.map +1 -0
  262. package/dist/data/entities.d.ts +67 -2
  263. package/dist/data/entities.d.ts.map +1 -0
  264. package/dist/data/entities.js +122 -0
  265. package/dist/data/entities.js.map +1 -0
  266. package/dist/data/index.d.ts +10 -1
  267. package/dist/data/index.d.ts.map +1 -0
  268. package/dist/data/index.js +14 -1
  269. package/dist/data/index.js.map +1 -0
  270. package/dist/data/normalize.d.ts +1 -0
  271. package/dist/data/normalize.d.ts.map +1 -0
  272. package/dist/data/normalize.js +1 -0
  273. package/dist/data/normalize.js.map +1 -0
  274. package/dist/data/roster-resolve.d.ts +58 -0
  275. package/dist/data/roster-resolve.d.ts.map +1 -0
  276. package/dist/data/roster-resolve.js +82 -0
  277. package/dist/data/roster-resolve.js.map +1 -0
  278. package/dist/data/types.d.ts +4 -1
  279. package/dist/data/types.d.ts.map +1 -0
  280. package/dist/data/types.js +2 -0
  281. package/dist/data/types.js.map +1 -0
  282. package/dist/export/helpers.d.ts +33 -0
  283. package/dist/export/helpers.d.ts.map +1 -0
  284. package/dist/export/helpers.js +57 -0
  285. package/dist/export/helpers.js.map +1 -0
  286. package/dist/export/index.d.ts +22 -0
  287. package/dist/export/index.d.ts.map +1 -0
  288. package/dist/export/index.js +28 -0
  289. package/dist/export/index.js.map +1 -0
  290. package/dist/export/newrecruit-json.d.ts +3 -0
  291. package/dist/export/newrecruit-json.d.ts.map +1 -0
  292. package/dist/export/newrecruit-json.js +140 -0
  293. package/dist/export/newrecruit-json.js.map +1 -0
  294. package/dist/export/newrecruit-simple.d.ts +3 -0
  295. package/dist/export/newrecruit-simple.d.ts.map +1 -0
  296. package/dist/export/newrecruit-simple.js +76 -0
  297. package/dist/export/newrecruit-simple.js.map +1 -0
  298. package/dist/export/newrecruit-wtc.d.ts +4 -0
  299. package/dist/export/newrecruit-wtc.d.ts.map +1 -0
  300. package/dist/export/newrecruit-wtc.js +142 -0
  301. package/dist/export/newrecruit-wtc.js.map +1 -0
  302. package/dist/export/roster-json.d.ts +3 -0
  303. package/dist/export/roster-json.d.ts.map +1 -0
  304. package/dist/export/roster-json.js +8 -0
  305. package/dist/export/roster-json.js.map +1 -0
  306. package/dist/export/rosterizer.d.ts +3 -0
  307. package/dist/export/rosterizer.d.ts.map +1 -0
  308. package/dist/export/rosterizer.js +144 -0
  309. package/dist/export/rosterizer.js.map +1 -0
  310. package/dist/export/serializer.d.ts +27 -0
  311. package/dist/export/serializer.d.ts.map +1 -0
  312. package/dist/export/serializer.js +2 -0
  313. package/dist/export/serializer.js.map +1 -0
  314. package/dist/gen-conformance.d.ts +1 -0
  315. package/dist/gen-conformance.d.ts.map +1 -0
  316. package/dist/gen-conformance.js +274 -12
  317. package/dist/gen-conformance.js.map +1 -0
  318. package/dist/generated.d.ts +194 -118
  319. package/dist/generated.d.ts.map +1 -0
  320. package/dist/generated.js +1 -0
  321. package/dist/generated.js.map +1 -0
  322. package/dist/import/adapter.d.ts +4 -3
  323. package/dist/import/adapter.d.ts.map +1 -0
  324. package/dist/import/adapter.js +1 -0
  325. package/dist/import/adapter.js.map +1 -0
  326. package/dist/import/decode.d.ts +1 -0
  327. package/dist/import/decode.d.ts.map +1 -0
  328. package/dist/import/decode.js +1 -0
  329. package/dist/import/decode.js.map +1 -0
  330. package/dist/import/gw.d.ts +69 -0
  331. package/dist/import/gw.d.ts.map +1 -0
  332. package/dist/import/gw.js +245 -0
  333. package/dist/import/gw.js.map +1 -0
  334. package/dist/import/import-roster.d.ts +84 -0
  335. package/dist/import/import-roster.d.ts.map +1 -0
  336. package/dist/import/import-roster.js +207 -0
  337. package/dist/import/import-roster.js.map +1 -0
  338. package/dist/import/index.d.ts +7 -3
  339. package/dist/import/index.d.ts.map +1 -0
  340. package/dist/import/index.js +5 -1
  341. package/dist/import/index.js.map +1 -0
  342. package/dist/import/listforge.d.ts +1 -0
  343. package/dist/import/listforge.d.ts.map +1 -0
  344. package/dist/import/listforge.js +22 -2
  345. package/dist/import/listforge.js.map +1 -0
  346. package/dist/import/newrecruit-json.d.ts +31 -0
  347. package/dist/import/newrecruit-json.d.ts.map +1 -0
  348. package/dist/import/newrecruit-json.js +224 -0
  349. package/dist/import/newrecruit-json.js.map +1 -0
  350. package/dist/import/newrecruit-simple.d.ts +29 -0
  351. package/dist/import/newrecruit-simple.d.ts.map +1 -0
  352. package/dist/import/newrecruit-simple.js +200 -0
  353. package/dist/import/newrecruit-simple.js.map +1 -0
  354. package/dist/import/newrecruit-text.d.ts +51 -0
  355. package/dist/import/newrecruit-text.d.ts.map +1 -0
  356. package/dist/import/newrecruit-text.js +102 -0
  357. package/dist/import/newrecruit-text.js.map +1 -0
  358. package/dist/import/newrecruit-wtc.d.ts +36 -0
  359. package/dist/import/newrecruit-wtc.d.ts.map +1 -0
  360. package/dist/import/newrecruit-wtc.js +337 -0
  361. package/dist/import/newrecruit-wtc.js.map +1 -0
  362. package/dist/import/resolve.d.ts +3 -2
  363. package/dist/import/resolve.d.ts.map +1 -0
  364. package/dist/import/resolve.js +5 -2
  365. package/dist/import/resolve.js.map +1 -0
  366. package/dist/import/rosterizer.d.ts +70 -0
  367. package/dist/import/rosterizer.d.ts.map +1 -0
  368. package/dist/import/rosterizer.js +348 -0
  369. package/dist/import/rosterizer.js.map +1 -0
  370. package/dist/import/types.d.ts +11 -1
  371. package/dist/import/types.d.ts.map +1 -0
  372. package/dist/import/types.js +1 -0
  373. package/dist/import/types.js.map +1 -0
  374. package/dist/index.d.ts +5 -2
  375. package/dist/index.d.ts.map +1 -0
  376. package/dist/index.js +4 -1
  377. package/dist/index.js.map +1 -0
  378. package/dist/known-support-10e.d.ts +1 -0
  379. package/dist/known-support-10e.d.ts.map +1 -0
  380. package/dist/known-support-10e.js +1 -0
  381. package/dist/known-support-10e.js.map +1 -0
  382. package/dist/link-abilities.d.ts +41 -0
  383. package/dist/link-abilities.d.ts.map +1 -0
  384. package/dist/link-abilities.js +159 -0
  385. package/dist/link-abilities.js.map +1 -0
  386. package/dist/migrations/2026-weapon-keywords.d.ts +2 -0
  387. package/dist/migrations/2026-weapon-keywords.d.ts.map +1 -0
  388. package/dist/migrations/2026-weapon-keywords.js +247 -0
  389. package/dist/migrations/2026-weapon-keywords.js.map +1 -0
  390. package/dist/port-10e-faction.d.ts +1 -0
  391. package/dist/port-10e-faction.d.ts.map +1 -0
  392. package/dist/port-10e-faction.js +1 -0
  393. package/dist/port-10e-faction.js.map +1 -0
  394. package/dist/report.d.ts +1 -0
  395. package/dist/report.d.ts.map +1 -0
  396. package/dist/report.js +1 -0
  397. package/dist/report.js.map +1 -0
  398. package/dist/rube-goldberg.d.ts +3 -0
  399. package/dist/rube-goldberg.d.ts.map +1 -0
  400. package/dist/rube-goldberg.js +109 -0
  401. package/dist/rube-goldberg.js.map +1 -0
  402. package/dist/runner.d.ts +38 -0
  403. package/dist/runner.d.ts.map +1 -0
  404. package/dist/runner.js +492 -0
  405. package/dist/runner.js.map +1 -0
  406. package/dist/schema-loader.d.ts +1 -0
  407. package/dist/schema-loader.d.ts.map +1 -0
  408. package/dist/schema-loader.js +1 -0
  409. package/dist/schema-loader.js.map +1 -0
  410. package/dist/scrub-ip.d.ts +14 -0
  411. package/dist/scrub-ip.d.ts.map +1 -0
  412. package/dist/scrub-ip.js +88 -0
  413. package/dist/scrub-ip.js.map +1 -0
  414. package/dist/validate.d.ts +1 -0
  415. package/dist/validate.d.ts.map +1 -0
  416. package/dist/validate.js +2 -0
  417. package/dist/validate.js.map +1 -0
  418. package/package.json +15 -3
  419. package/schemas/core/roster.schema.json +19 -4
  420. package/schemas/core/weapon-keyword.schema.json +31 -0
  421. package/schemas/core/weapon.schema.json +22 -1
  422. package/schemas/enrichment/ability-dsl/effect.schema.json +23 -1
  423. package/dist/import/import-listforge.d.ts +0 -23
  424. package/dist/import/import-listforge.js +0 -32
@@ -0,0 +1,968 @@
1
+ /** Targets that resolve to the buffed unit itself. */
2
+ const SELF_TARGETS = new Set([
3
+ "self",
4
+ "bearer",
5
+ "unit",
6
+ "attached-unit",
7
+ "friendly-within-aura",
8
+ "all-friendly",
9
+ ]);
10
+ /** Aliases the DSL uses when a node specifically calls out "the attacker". */
11
+ const ATTACKER_TARGET = "attacker";
12
+ /** Aliases the DSL uses when a node specifically calls out "the defender". */
13
+ const DEFENDER_TARGETS = new Set(["defender", "enemy-within-aura", "all-enemy"]);
14
+ /**
15
+ * Walk an ability DSL `effect` tree and produce the buff stack it contributes
16
+ * against `context` from the given `perspective`, plus an `unsupported` list
17
+ * naming any branches the buff layer can't express today.
18
+ */
19
+ export function effectToBuffs(effect, source, context, perspective = "attacker") {
20
+ const out = { applied: [], unsupported: [], activatable: [] };
21
+ const abilityId = source.kind === "ability" ? source.abilityId : "effect";
22
+ walk(effect, source, { context, perspective, abilityId }, out);
23
+ return out;
24
+ }
25
+ function walk(node, source, opts, out) {
26
+ if (!isObject(node))
27
+ return;
28
+ const type = node.type;
29
+ switch (type) {
30
+ case "re-roll":
31
+ translateReroll(node, source, opts, out);
32
+ return;
33
+ case "roll-modifier":
34
+ translateRollModifier(node, source, opts, out);
35
+ return;
36
+ case "stat-modifier":
37
+ translateStatModifier(node, source, opts, out);
38
+ return;
39
+ case "feel-no-pain":
40
+ translateFeelNoPain(node, source, opts, out);
41
+ return;
42
+ case "keyword-grant":
43
+ translateKeywordGrant(node, source, opts, out);
44
+ return;
45
+ case "bs-modifier":
46
+ translateBsModifier(node, source, opts, out);
47
+ return;
48
+ case "conditional":
49
+ translateConditional(node, source, opts, out);
50
+ return;
51
+ case "sequence":
52
+ for (const step of node.steps ?? [])
53
+ walk(step, source, opts, out);
54
+ return;
55
+ case "choice":
56
+ // Player decision — each branch becomes an opt-in lever (pick one).
57
+ enumerateChoice(node, source, opts, out);
58
+ return;
59
+ case "dice-gated":
60
+ // Probabilistic; the buff layer is deterministic.
61
+ out.unsupported.push({
62
+ reason: "dice-gated effect: stochastic; not expressible as a buff",
63
+ effectFragment: node,
64
+ });
65
+ return;
66
+ case "dice-pool-allocation":
67
+ // Player spends dice on options at runtime — each buff-bearing option
68
+ // becomes an opt-in lever, grouped under the pool's activation cap.
69
+ enumerateDicePool(node, source, opts, out);
70
+ return;
71
+ default:
72
+ // Unknown effect — record it. Covers ability-grant, deep-strike,
73
+ // mortal-wounds, cp-gain, movement-modifier, etc.; the buff layer
74
+ // doesn't model these as deterministic mods to a single shot.
75
+ out.unsupported.push({
76
+ reason: `effect type "${String(type)}" is not modelled by the buff layer`,
77
+ effectFragment: node,
78
+ });
79
+ return;
80
+ }
81
+ }
82
+ // ---------------------------------------------------------------------------
83
+ // Leaf translators
84
+ // ---------------------------------------------------------------------------
85
+ /**
86
+ * Classify a node's `target` field against the perspective we're translating
87
+ * for. Returns:
88
+ * - `"self"`: the node targets the buffed unit (apply attacker-side or
89
+ * defender-side translation, depending on perspective + stat).
90
+ * - `"attacker"` / `"defender"`: the node targets the other party explicitly.
91
+ * - `"unknown"`: missing/malformed target.
92
+ */
93
+ function classifyTarget(node) {
94
+ const target = node.target;
95
+ if (typeof target !== "string")
96
+ return "unknown";
97
+ if (target === ATTACKER_TARGET)
98
+ return "attacker";
99
+ if (DEFENDER_TARGETS.has(target))
100
+ return "defender";
101
+ if (SELF_TARGETS.has(target))
102
+ return "self";
103
+ return "unknown";
104
+ }
105
+ /**
106
+ * Does this node's target match the buffed unit under the current
107
+ * perspective? Used for symmetric roll/keyword translations where the same
108
+ * effect is "self" in either direction.
109
+ */
110
+ function appliesToBuffedUnit(node, perspective) {
111
+ const cls = classifyTarget(node);
112
+ if (cls === "self")
113
+ return true;
114
+ if (cls === "attacker")
115
+ return perspective === "attacker";
116
+ if (cls === "defender")
117
+ return perspective === "target";
118
+ return false;
119
+ }
120
+ function translateReroll(node, source, opts, out) {
121
+ // Rerolls are inherently attacker-side (you re-roll your own hit/wound/
122
+ // damage; save rerolls fire when *you* are the target). Apply only under
123
+ // the matching perspective so a target-perspective walk doesn't grab the
124
+ // attacker's reroll-failed-hits buff.
125
+ if (opts.perspective === "attacker" && !appliesToBuffedUnit(node, "attacker"))
126
+ return;
127
+ const modifier = node.modifier;
128
+ if (!isObject(modifier)) {
129
+ out.unsupported.push({ reason: "re-roll: missing modifier object", effectFragment: node });
130
+ return;
131
+ }
132
+ const narrowed = unhonorableNarrowing(modifier);
133
+ if (narrowed) {
134
+ out.unsupported.push({ reason: `re-roll: narrows by "${narrowed}" which the cruncher can't resolve here`, effectFragment: node });
135
+ return;
136
+ }
137
+ const roll = modifier.roll;
138
+ // A `value: 1` on a re-roll modifier unambiguously means "re-roll rolls of 1".
139
+ // A historical migration (2026-weapon-keywords) mis-defaulted such nodes to
140
+ // `subset: "all-failures"`; honor the value as the source of truth so any
141
+ // stray data of that shape can't silently over-apply the reroll.
142
+ const subset = modifier.value === 1 ? "ones" : modifier.subset;
143
+ // Under target perspective, only "save" rerolls fire on the buffed unit.
144
+ if (opts.perspective === "target" && roll !== "save")
145
+ return;
146
+ if ((roll === "hit" || roll === "wound" || roll === "save" || roll === "damage") &&
147
+ (subset === "ones" || subset === "all-failures")) {
148
+ out.applied.push({ source, contribution: { type: "reroll", roll, subset } });
149
+ return;
150
+ }
151
+ out.unsupported.push({
152
+ reason: `re-roll on "${String(roll)}" (subset "${String(subset)}") is outside the damage path`,
153
+ effectFragment: node,
154
+ });
155
+ }
156
+ function translateRollModifier(node, source, opts, out) {
157
+ const modifier = node.modifier;
158
+ if (!isObject(modifier)) {
159
+ out.unsupported.push({
160
+ reason: "roll-modifier: missing modifier object",
161
+ effectFragment: node,
162
+ });
163
+ return;
164
+ }
165
+ const narrowed = unhonorableNarrowing(modifier);
166
+ if (narrowed) {
167
+ out.unsupported.push({ reason: `roll-modifier: narrows by "${narrowed}" which the cruncher can't resolve here`, effectFragment: node });
168
+ return;
169
+ }
170
+ const value = signedValue(modifier);
171
+ if (value === null) {
172
+ out.unsupported.push({
173
+ reason: `roll-modifier: operation "${String(modifier.operation)}" not supported`,
174
+ effectFragment: node,
175
+ });
176
+ return;
177
+ }
178
+ const roll = modifier.roll;
179
+ // Each roll type is intrinsically on one side. Hit / wound / damage are
180
+ // attacker-side; save is defender-side. The perspective decides whether the
181
+ // buffed unit's `target` is the right party for that roll type.
182
+ if (opts.perspective === "attacker") {
183
+ if (!appliesToBuffedUnit(node, "attacker"))
184
+ return;
185
+ if (roll === "save")
186
+ return; // saves apply to the defender, not the attacker.
187
+ }
188
+ else {
189
+ // target perspective: only `save` rolls on the buffed unit fire here.
190
+ if (roll !== "save")
191
+ return;
192
+ if (!appliesToBuffedUnit(node, "target"))
193
+ return;
194
+ }
195
+ switch (roll) {
196
+ case "hit":
197
+ out.applied.push({ source, contribution: { type: "hit-mod", value } });
198
+ return;
199
+ case "wound":
200
+ out.applied.push({ source, contribution: { type: "wound-mod", value } });
201
+ return;
202
+ case "save":
203
+ out.applied.push({ source, contribution: { type: "save-mod", value } });
204
+ return;
205
+ case "damage":
206
+ out.applied.push({ source, contribution: { type: "damage-mod", value } });
207
+ return;
208
+ default:
209
+ out.unsupported.push({
210
+ reason: `roll-modifier on "${String(roll)}" is outside the damage path`,
211
+ effectFragment: node,
212
+ });
213
+ }
214
+ }
215
+ function translateStatModifier(node, source, opts, out) {
216
+ const modifier = node.modifier;
217
+ if (!isObject(modifier)) {
218
+ out.unsupported.push({
219
+ reason: "stat-modifier: missing modifier object",
220
+ effectFragment: node,
221
+ });
222
+ return;
223
+ }
224
+ const narrowed = unhonorableNarrowing(modifier);
225
+ if (narrowed) {
226
+ out.unsupported.push({ reason: `stat-modifier: narrows by "${narrowed}" which the cruncher can't resolve here`, effectFragment: node });
227
+ return;
228
+ }
229
+ const stat = modifier.stat;
230
+ const isOnBuffedUnit = appliesToBuffedUnit(node, opts.perspective);
231
+ // `attack_type: melee|ranged` scopes the mod to that attack — express it as a
232
+ // phase gate so e.g. a melee +1 Attack doesn't fire in the shooting phase.
233
+ const applicability = attackTypeApplicability(modifier);
234
+ const emit = (contribution) => {
235
+ const buff = { source, contribution };
236
+ out.applied.push(applicability ? { ...buff, applicableWhen: applicability } : buff);
237
+ };
238
+ // AP has an inverted sign convention (stored negative; more negative = more
239
+ // piercing) and offensive/defensive variants, so it computes its own delta
240
+ // and routes by attacker/defender rather than going through `signedValue`.
241
+ if (stat === "AP") {
242
+ translateApModifier(node, modifier, opts, out, emit);
243
+ return;
244
+ }
245
+ const value = signedValue(modifier);
246
+ if (value === null) {
247
+ out.unsupported.push({
248
+ reason: `stat-modifier: operation "${String(modifier.operation)}" not supported`,
249
+ effectFragment: node,
250
+ });
251
+ return;
252
+ }
253
+ switch (stat) {
254
+ case "A":
255
+ if (opts.perspective !== "attacker" || !isOnBuffedUnit)
256
+ return;
257
+ emit({ type: "attacks-mod", value });
258
+ return;
259
+ case "S":
260
+ if (opts.perspective !== "attacker" || !isOnBuffedUnit)
261
+ return;
262
+ emit({ type: "strength-mod", value });
263
+ return;
264
+ case "T":
265
+ // Defender stat. Only relevant under target perspective.
266
+ if (opts.perspective !== "target") {
267
+ out.unsupported.push({
268
+ reason: "stat-modifier T: defender-side stat; applies when the buffed unit is the target",
269
+ effectFragment: node,
270
+ });
271
+ return;
272
+ }
273
+ if (!isOnBuffedUnit)
274
+ return;
275
+ emit({ type: "toughness-mod", value });
276
+ return;
277
+ case "Sv":
278
+ // Saves improve when the *defender* gets +Sv. A +1 to Sv in printed
279
+ // rules means "improve the save by 1", which maps to a `save-mod` of
280
+ // `-value` since save-mod is signed against the *needed roll*.
281
+ // (Equivalent: a -1 Sv penalty is a +1 save-mod.) We translate
282
+ // "Sv add 1" → save-mod -1 to keep the resolver's sign convention.
283
+ if (opts.perspective !== "target") {
284
+ out.unsupported.push({
285
+ reason: "stat-modifier Sv: defender-side stat; applies when the buffed unit is the target",
286
+ effectFragment: node,
287
+ });
288
+ return;
289
+ }
290
+ if (!isOnBuffedUnit)
291
+ return;
292
+ emit({ type: "save-mod", value: -value });
293
+ return;
294
+ default:
295
+ out.unsupported.push({
296
+ reason: `stat-modifier on "${String(stat)}" is outside the damage path`,
297
+ effectFragment: node,
298
+ });
299
+ }
300
+ }
301
+ /**
302
+ * Translate an `AP` stat-modifier. AP rides on the attacker's weapon profile and
303
+ * is stored as a negative number (e.g. AP -1); more negative = more piercing.
304
+ *
305
+ * Two variants exist in the data:
306
+ * - **offensive** (`target` self/unit): the buffed unit's own weapons gain AP —
307
+ * an attacker-side `ap-mod`. `improve N` → `-N` (more piercing), `worsen N` →
308
+ * `+N`, and the legacy `add`/`subtract` forms (which already pass a signed,
309
+ * usually negative, value) flow through `apDelta` unchanged.
310
+ * - **defensive** (`target: "attacker"`): "enemy weapons targeting this unit
311
+ * have AP worsened". This applies when the buffed unit is the *target*; we do
312
+ * not model it as an attacker-side buff (that would wrongly weaken the buffed
313
+ * unit's own attacks), so it is surfaced as `unsupported`.
314
+ */
315
+ function translateApModifier(node, modifier, opts, out, emit) {
316
+ if (classifyTarget(node) === "attacker") {
317
+ out.unsupported.push({
318
+ reason: "stat-modifier AP on the attacker: defender-side AP reduction is not modelled by the buff layer",
319
+ effectFragment: node,
320
+ });
321
+ return;
322
+ }
323
+ if (opts.perspective !== "attacker" || !appliesToBuffedUnit(node, "attacker"))
324
+ return;
325
+ const delta = apDelta(modifier);
326
+ if (delta === null) {
327
+ out.unsupported.push({
328
+ reason: `stat-modifier AP: operation "${String(modifier.operation)}" not supported`,
329
+ effectFragment: node,
330
+ });
331
+ return;
332
+ }
333
+ emit({ type: "ap-mod", value: delta });
334
+ }
335
+ function translateFeelNoPain(node, source, opts, out) {
336
+ // FNP applies when the buffed unit is the *target* — it ablates incoming
337
+ // damage. Under attacker perspective the FNP is irrelevant (the unit is
338
+ // firing, not taking damage). Drop silently rather than as `unsupported`
339
+ // so attacker-perspective walks don't surface a spurious diagnostic for
340
+ // every unit that happens to have a FNP rule.
341
+ if (opts.perspective !== "target")
342
+ return;
343
+ const modifier = node.modifier;
344
+ if (!isObject(modifier)) {
345
+ out.unsupported.push({
346
+ reason: "feel-no-pain: missing modifier object",
347
+ effectFragment: node,
348
+ });
349
+ return;
350
+ }
351
+ const threshold = Number(modifier.threshold);
352
+ if (!Number.isFinite(threshold)) {
353
+ out.unsupported.push({
354
+ reason: "feel-no-pain: threshold not numeric",
355
+ effectFragment: node,
356
+ });
357
+ return;
358
+ }
359
+ out.applied.push({ source, contribution: { type: "feel-no-pain", threshold } });
360
+ }
361
+ function translateKeywordGrant(node, source, opts, out) {
362
+ // Weapon-keyword grants ride with the attacker's profile (e.g. "your
363
+ // weapons gain [Sustained Hits 1]"). Defender-perspective walks ignore
364
+ // them — the keyword applies when the buffed unit fires, not when it's
365
+ // shot at.
366
+ if (opts.perspective !== "attacker")
367
+ return;
368
+ if (!appliesToBuffedUnit(node, "attacker"))
369
+ return;
370
+ const modifier = node.modifier;
371
+ if (!isObject(modifier))
372
+ return;
373
+ // The DSL grants keywords in two shapes: a singular `keyword` string (often
374
+ // with a `weapon_type`) or a `keywords` array. Accept both.
375
+ const raws = keywordGrantList(modifier);
376
+ if (raws.length === 0)
377
+ return;
378
+ // `weapon_type: melee|ranged` scopes the grant to that attack — a melee-only
379
+ // keyword shouldn't fire in the shooting phase. Express it as a phase gate.
380
+ const applicability = weaponTypeApplicability(modifier);
381
+ for (const raw of raws) {
382
+ const ref = parseKeywordGrant(raw);
383
+ if (!ref) {
384
+ out.unsupported.push({
385
+ reason: `keyword-grant: cannot parse "${raw}" to a catalog keyword`,
386
+ effectFragment: { keyword: raw },
387
+ });
388
+ continue;
389
+ }
390
+ const buff = { source, contribution: { type: "extra-keyword", keywordRef: ref } };
391
+ out.applied.push(applicability ? { ...buff, applicableWhen: applicability } : buff);
392
+ }
393
+ }
394
+ /** Normalise a keyword-grant modifier's singular `keyword` and/or `keywords` array. */
395
+ function keywordGrantList(modifier) {
396
+ const out = [];
397
+ if (typeof modifier.keyword === "string")
398
+ out.push(modifier.keyword);
399
+ if (Array.isArray(modifier.keywords)) {
400
+ for (const k of modifier.keywords)
401
+ if (typeof k === "string")
402
+ out.push(k);
403
+ }
404
+ return out;
405
+ }
406
+ /** Map a keyword-grant's `weapon_type` to the phase its weapons fire in. */
407
+ function weaponTypeApplicability(modifier) {
408
+ if (modifier.weapon_type === "melee")
409
+ return { phases: ["fight"] };
410
+ if (modifier.weapon_type === "ranged")
411
+ return { phases: ["shooting"] };
412
+ return undefined;
413
+ }
414
+ /**
415
+ * Map a stat-modifier's `attack_type` (or the equivalent `weapon_type`) to the
416
+ * phase that attack happens in. Both spellings carry the same melee/ranged
417
+ * intent; honoring `weapon_type` lets a "+1 A to melee weapons" mod phase-gate
418
+ * correctly instead of leaking into the shooting phase.
419
+ */
420
+ function attackTypeApplicability(modifier) {
421
+ const kind = modifier.attack_type ?? modifier.weapon_type;
422
+ if (kind === "melee")
423
+ return { phases: ["fight"] };
424
+ if (kind === "ranged")
425
+ return { phases: ["shooting"] };
426
+ return undefined;
427
+ }
428
+ /**
429
+ * Narrowing keys that scope a buff to a named weapon or a model subset the
430
+ * cruncher can't resolve at translation time (it has no weapon/model context
431
+ * here). When present on a damage-path leaf, applying the buff unfiltered would
432
+ * silently OVER-APPLY it, so we surface it as `unsupported` instead — the data
433
+ * stays faithful for other consumers; the optimizer just doesn't assume it.
434
+ * `weapon_type`/`attack_type` are NOT here — those map cleanly to a phase gate.
435
+ */
436
+ const UNHONORABLE_NARROWING = ["weapon_name", "weapon_profile", "weapon_keyword", "weapon_filter", "model_filter", "model_scope"];
437
+ function unhonorableNarrowing(modifier) {
438
+ return UNHONORABLE_NARROWING.find((k) => modifier[k] != null);
439
+ }
440
+ function translateBsModifier(node, source, opts, out) {
441
+ // A bs-modifier on `target: "attacker"` is a defender-side rule: it
442
+ // penalises *incoming* hit rolls (e.g. Benefit of Cover). Translate it
443
+ // as a `hit-mod` buff under target perspective so the resolver's ±1 cap
444
+ // composes with attacker-side mods.
445
+ if (opts.perspective !== "target")
446
+ return;
447
+ const cls = classifyTarget(node);
448
+ if (cls !== "attacker")
449
+ return; // a bs-modifier on self wouldn't make sense.
450
+ const modifier = node.modifier;
451
+ if (!isObject(modifier))
452
+ return;
453
+ const value = signedValue(modifier);
454
+ if (value === null)
455
+ return;
456
+ out.applied.push({ source, contribution: { type: "hit-mod", value } });
457
+ }
458
+ function translateConditional(node, source, opts, out) {
459
+ const condition = node.condition;
460
+ const effect = node.effect;
461
+ if (!isObject(condition))
462
+ return;
463
+ const negated = condition.negated === true;
464
+ const verdict = evaluateCondition(condition, opts.context);
465
+ if (verdict === "unknown") {
466
+ // A timing the player controls (e.g. "start of phase") isn't a wall — it's
467
+ // an activation the player can opt into. Surface it as a lever rather than
468
+ // dropping it. Other unevaluatable conditions stay unsupported.
469
+ if (conditionMentionsTiming(condition)) {
470
+ enumerateTimingGate(node, source, opts, out);
471
+ }
472
+ else {
473
+ out.unsupported.push({
474
+ reason: `conditional: cannot evaluate condition "${String(condition.type)}" against current context`,
475
+ effectFragment: node,
476
+ });
477
+ }
478
+ return;
479
+ }
480
+ const active = negated ? !verdict : verdict;
481
+ if (!active)
482
+ return;
483
+ walk(effect, source, opts, out);
484
+ }
485
+ // ---------------------------------------------------------------------------
486
+ // Activatable-lever enumeration
487
+ //
488
+ // Player-controlled gates — a `timing-is` the context can't pin down, each
489
+ // `dice-pool-allocation` option, each `choice` branch — aren't walls for a
490
+ // damage optimizer; they're the search space. Instead of dropping them to
491
+ // `unsupported`, we descend through them and surface every buff-bearing branch
492
+ // as an opt-in {@link ActivatableBuff}. The descent reuses the normal leaf
493
+ // translators (so a lever applies exactly what it advertises) and turns the
494
+ // conditions a branch still carries (target keyword, phase) into declarative
495
+ // `applicableWhen` so the resolver gates them per-target.
496
+ // ---------------------------------------------------------------------------
497
+ /** Emit one lever per `choice` branch that yields a buff (pick exactly one). */
498
+ function enumerateChoice(node, source, opts, out) {
499
+ const options = Array.isArray(node.options) ? node.options : [];
500
+ options.forEach((opt, i) => {
501
+ const buffs = [];
502
+ collectGatedBuffs(opt, source, opts, {}, buffs);
503
+ if (buffs.length === 0)
504
+ return;
505
+ out.activatable.push({
506
+ id: `${opts.abilityId}?${i}`,
507
+ label: labelForBuffs(buffs),
508
+ buffs,
509
+ group: { id: `${opts.abilityId}?choice`, maxActivations: 1 },
510
+ });
511
+ });
512
+ }
513
+ /** Emit one lever per buff-bearing dice-pool option, capped by `max_activations`. */
514
+ function enumerateDicePool(node, source, opts, out) {
515
+ const options = Array.isArray(node.options) ? node.options : [];
516
+ const maxActivations = typeof node.max_activations === "number" ? node.max_activations : options.length;
517
+ for (const opt of options) {
518
+ if (!isObject(opt))
519
+ continue;
520
+ const buffs = [];
521
+ collectGatedBuffs(opt.effect, source, opts, {}, buffs);
522
+ if (buffs.length === 0)
523
+ continue;
524
+ const name = typeof opt.name === "string" && opt.name ? opt.name : labelForBuffs(buffs);
525
+ out.activatable.push({
526
+ id: `${opts.abilityId}#${name}`,
527
+ label: name,
528
+ buffs,
529
+ group: { id: opts.abilityId, maxActivations },
530
+ });
531
+ }
532
+ }
533
+ /**
534
+ * Surface a timing-gated activation. The timing itself is just "when" — opting
535
+ * in satisfies it — so we descend into the body: an inner `dice-pool-allocation`
536
+ * or `choice` surfaces its *own* option levers (e.g. Blessings of Khorne's
537
+ * three keyword grants), while inner always-on buffs bundle into a single
538
+ * timing lever. A body with no modelable combat buff (a `resurrection` or
539
+ * `dice-gated`, like Berzerker Frenzy) yields nothing.
540
+ */
541
+ function enumerateTimingGate(node, source, opts, out) {
542
+ const condition = node.condition;
543
+ if (!isObject(condition))
544
+ return;
545
+ const sub = { applied: [], unsupported: [], activatable: [] };
546
+ walk(node.effect, source, opts, sub);
547
+ // Inner independent decisions (dice-pool options, choice branches) pass
548
+ // straight through as their own levers.
549
+ out.activatable.push(...sub.activatable);
550
+ // Inner unconditional buffs become one lever gated only on the timing.
551
+ if (sub.applied.length > 0) {
552
+ const timing = extractTiming(condition) ?? "timing";
553
+ out.activatable.push({
554
+ id: `${opts.abilityId}@${timing}`,
555
+ label: labelForBuffs(sub.applied),
556
+ buffs: sub.applied,
557
+ });
558
+ }
559
+ }
560
+ /**
561
+ * Walk the body of a player gate, collecting the buffs it would contribute.
562
+ * Conditions are deferred to `applicableWhen` where expressible; nested
563
+ * decisions and stochastic rolls inside an activation are not modelled.
564
+ */
565
+ function collectGatedBuffs(node, source, opts, applicability, outBuffs) {
566
+ if (!isObject(node))
567
+ return;
568
+ switch (node.type) {
569
+ case "conditional": {
570
+ const condition = node.condition;
571
+ if (!isObject(condition))
572
+ return;
573
+ const app = conditionToApplicability(condition);
574
+ if (app === "gate") {
575
+ // A nested timing gate: opting into the activation satisfies it, so
576
+ // keep descending without adding a constraint.
577
+ collectGatedBuffs(node.effect, source, opts, applicability, outBuffs);
578
+ return;
579
+ }
580
+ if (app === "context") {
581
+ // Can't express as a buff gate — fall back to the current context and
582
+ // only descend when the condition is definitely active.
583
+ if (evaluateCondition(condition, opts.context) === true) {
584
+ collectGatedBuffs(node.effect, source, opts, applicability, outBuffs);
585
+ }
586
+ return;
587
+ }
588
+ collectGatedBuffs(node.effect, source, opts, combineApplicability(applicability, app), outBuffs);
589
+ return;
590
+ }
591
+ case "sequence":
592
+ for (const step of node.steps ?? []) {
593
+ collectGatedBuffs(step, source, opts, applicability, outBuffs);
594
+ }
595
+ return;
596
+ case "choice":
597
+ case "dice-pool-allocation":
598
+ case "dice-gated":
599
+ // A decision (or stochastic roll) nested inside an activation. The outer
600
+ // lever already stands for a player choice; we don't model the inner one.
601
+ return;
602
+ default: {
603
+ // Leaf effect — run the normal leaf translators into a throwaway sink,
604
+ // then attach the accumulated applicability so target/phase gating
605
+ // defers to the resolver instead of vanishing the lever.
606
+ const tmp = { applied: [], unsupported: [], activatable: [] };
607
+ walk(node, source, opts, tmp);
608
+ for (const b of tmp.applied)
609
+ outBuffs.push(applyApplicability(b, applicability));
610
+ return;
611
+ }
612
+ }
613
+ }
614
+ /** Does this condition (or any operand) gate on a player-controlled timing? */
615
+ function conditionMentionsTiming(condition) {
616
+ if (condition.type === "timing-is")
617
+ return true;
618
+ if (typeof condition.operator === "string" && Array.isArray(condition.operands)) {
619
+ return condition.operands.some((o) => isObject(o) && conditionMentionsTiming(o));
620
+ }
621
+ return false;
622
+ }
623
+ /** Pull the first `timing-is` timing value out of a (possibly compound) condition. */
624
+ function extractTiming(condition) {
625
+ if (condition.type === "timing-is") {
626
+ const t = condition.parameters?.timing;
627
+ return typeof t === "string" ? t : undefined;
628
+ }
629
+ if (Array.isArray(condition.operands)) {
630
+ for (const o of condition.operands) {
631
+ if (isObject(o)) {
632
+ const t = extractTiming(o);
633
+ if (t)
634
+ return t;
635
+ }
636
+ }
637
+ }
638
+ return undefined;
639
+ }
640
+ /**
641
+ * Translate a condition into a {@link BuffApplicability} the resolver can gate
642
+ * on. Returns `"gate"` for a player-controlled timing (satisfied by opting in),
643
+ * or `"context"` when the condition has no declarative buff representation and
644
+ * must fall back to context evaluation.
645
+ */
646
+ function conditionToApplicability(condition) {
647
+ if (condition.negated === true)
648
+ return "context";
649
+ if (typeof condition.operator === "string" && Array.isArray(condition.operands)) {
650
+ if (condition.operator !== "and")
651
+ return "context";
652
+ let merged = {};
653
+ for (const operand of condition.operands) {
654
+ if (!isObject(operand))
655
+ return "context";
656
+ const a = conditionToApplicability(operand);
657
+ if (a === "gate")
658
+ continue; // timing operand: satisfied by opting in.
659
+ if (a === "context")
660
+ return "context";
661
+ merged = combineApplicability(merged, a);
662
+ }
663
+ return merged;
664
+ }
665
+ const params = condition.parameters;
666
+ switch (condition.type) {
667
+ case "timing-is":
668
+ return "gate";
669
+ case "phase-is": {
670
+ const phase = params?.phase;
671
+ return typeof phase === "string" ? { phases: [phase] } : "context";
672
+ }
673
+ case "target-has-keyword": {
674
+ const kw = params?.keyword;
675
+ return typeof kw === "string" ? { requiresTargetKeyword: kw } : "context";
676
+ }
677
+ case "unit-has-keyword": {
678
+ const kw = params?.keyword;
679
+ return typeof kw === "string" ? { requiresAttackerKeyword: kw } : "context";
680
+ }
681
+ case "attack-is-type": {
682
+ const t = params?.attack_type;
683
+ if (t === "melee")
684
+ return { phases: ["fight"] };
685
+ if (t === "ranged")
686
+ return { phases: ["shooting"] };
687
+ return "context";
688
+ }
689
+ default:
690
+ return "context";
691
+ }
692
+ }
693
+ /** Merge two applicabilities; `phases` intersect, the rest narrow. */
694
+ function combineApplicability(a, b) {
695
+ const out = { ...a };
696
+ if (b.phases) {
697
+ out.phases = a.phases ? a.phases.filter((p) => b.phases.includes(p)) : b.phases;
698
+ }
699
+ if (b.rollType)
700
+ out.rollType = b.rollType;
701
+ if (b.requiresTargetKeyword)
702
+ out.requiresTargetKeyword = b.requiresTargetKeyword;
703
+ if (b.requiresAttackerKeyword)
704
+ out.requiresAttackerKeyword = b.requiresAttackerKeyword;
705
+ return out;
706
+ }
707
+ /** Attach an accumulated applicability to a buff (no-op when empty). */
708
+ function applyApplicability(buff, applicability) {
709
+ if (Object.keys(applicability).length === 0)
710
+ return buff;
711
+ const merged = buff.applicableWhen
712
+ ? combineApplicability(buff.applicableWhen, applicability)
713
+ : applicability;
714
+ return { ...buff, applicableWhen: merged };
715
+ }
716
+ /** A short, deduped human label summarising a lever's contributions. */
717
+ function labelForBuffs(buffs) {
718
+ const seen = new Set();
719
+ const parts = [];
720
+ for (const b of buffs) {
721
+ const p = describeContribution(b.contribution);
722
+ if (!seen.has(p)) {
723
+ seen.add(p);
724
+ parts.push(p);
725
+ }
726
+ }
727
+ return parts.join(", ") || "buff";
728
+ }
729
+ function describeContribution(c) {
730
+ switch (c.type) {
731
+ case "extra-keyword":
732
+ return keywordLabel(c.keywordRef);
733
+ case "hit-mod":
734
+ return `${signed(c.value)} to hit`;
735
+ case "wound-mod":
736
+ return `${signed(c.value)} to wound`;
737
+ case "save-mod":
738
+ return `${signed(c.value)} to save`;
739
+ case "damage-mod":
740
+ return `${signed(c.value)} damage`;
741
+ case "attacks-mod":
742
+ return `${signed(c.value)} attacks`;
743
+ case "strength-mod":
744
+ return `${signed(c.value)} strength`;
745
+ case "toughness-mod":
746
+ return `${signed(c.value)} toughness`;
747
+ case "ap-mod":
748
+ return `AP ${c.value}`;
749
+ case "reroll":
750
+ return `re-roll ${c.roll}${c.subset === "ones" ? " 1s" : ""}`;
751
+ case "feel-no-pain":
752
+ return `feel no pain ${c.threshold}+`;
753
+ case "cover":
754
+ return "cover";
755
+ }
756
+ }
757
+ function signed(n) {
758
+ return n >= 0 ? `+${n}` : `${n}`;
759
+ }
760
+ /** Render a weapon-keyword ref back to its printed form (best-effort). */
761
+ function keywordLabel(ref) {
762
+ const params = ref.parameters ?? {};
763
+ if (ref.keyword_id === "anti" && typeof params.target_keyword === "string") {
764
+ const th = params.threshold;
765
+ return `Anti-${params.target_keyword}${typeof th === "number" ? ` ${th}+` : ""}`;
766
+ }
767
+ const base = ref.keyword_id
768
+ .split("-")
769
+ .map((w) => (w ? w.charAt(0).toUpperCase() + w.slice(1) : w))
770
+ .join(" ");
771
+ return typeof params.value === "number" ? `${base} ${params.value}` : base;
772
+ }
773
+ // ---------------------------------------------------------------------------
774
+ // Condition evaluator
775
+ // ---------------------------------------------------------------------------
776
+ function evaluateCondition(condition, ctx) {
777
+ // Compound conditions use {operator, operands} rather than {type, parameters}.
778
+ // The schema's `condition-node` oneOf doesn't guarantee discrimination by a
779
+ // single field, so dispatch on shape: presence of `operator` + `operands`
780
+ // wins over the simple-condition switch below.
781
+ if (typeof condition.operator === "string" &&
782
+ Array.isArray(condition.operands)) {
783
+ return evaluateCompound(condition.operator, condition.operands, ctx);
784
+ }
785
+ switch (condition.type) {
786
+ case "phase-is": {
787
+ const wanted = condition.parameters?.phase;
788
+ if (typeof wanted !== "string")
789
+ return "unknown";
790
+ return ctx.phase === wanted;
791
+ }
792
+ case "timing-is": {
793
+ const wanted = condition.parameters?.timing;
794
+ if (typeof wanted !== "string")
795
+ return "unknown";
796
+ if (ctx.timing === undefined)
797
+ return "unknown";
798
+ return ctx.timing === wanted;
799
+ }
800
+ case "remained-stationary":
801
+ return ctx.attackerStationary === true;
802
+ case "charged-this-turn":
803
+ // A player-controlled context flag (did the buffed unit charge this turn?),
804
+ // mirroring `remained-stationary`. Undefined → the caller couldn't pin it
805
+ // down, so stay "unknown" and let the SPA surface the gap.
806
+ if (ctx.attackerCharged === undefined)
807
+ return "unknown";
808
+ return ctx.attackerCharged;
809
+ case "target-has-keyword": {
810
+ const kw = condition.parameters?.keyword;
811
+ if (typeof kw !== "string")
812
+ return "unknown";
813
+ return (ctx.targetKeywords ?? []).includes(kw.toLowerCase());
814
+ }
815
+ case "unit-has-keyword": {
816
+ const kw = condition.parameters?.keyword;
817
+ if (typeof kw !== "string")
818
+ return "unknown";
819
+ return (ctx.attackerKeywords ?? []).includes(kw.toLowerCase());
820
+ }
821
+ case "is-attached":
822
+ case "model-is-leader":
823
+ // True whenever the buffed unit is a combined ("attached") unit. We do
824
+ // not thread per-member leader identity — "attachment present" is the
825
+ // signal both conditions gate on. Undefined flag (caller couldn't
826
+ // determine attachment) stays "unknown" so the SPA surfaces the gap.
827
+ if (ctx.attackerAttached === undefined)
828
+ return "unknown";
829
+ return ctx.attackerAttached;
830
+ default:
831
+ return "unknown";
832
+ }
833
+ }
834
+ /**
835
+ * Kleene three-valued evaluator for compound conditions. `and` short-circuits
836
+ * to `false` as soon as any operand is false (an unknown operand is then
837
+ * irrelevant); `or` short-circuits to `true` symmetrically. `not` flips its
838
+ * single operand and leaves `"unknown"` as `"unknown"`. Unknown operands that
839
+ * don't get short-circuited propagate as `"unknown"` so the SPA can surface
840
+ * the gap rather than collapsing it into a misleading false.
841
+ */
842
+ function evaluateCompound(operator, operands, ctx) {
843
+ if (operator === "not") {
844
+ const first = operands[0];
845
+ if (!isObject(first))
846
+ return "unknown";
847
+ const v = evaluateCondition(first, ctx);
848
+ if (v === "unknown")
849
+ return "unknown";
850
+ return !v;
851
+ }
852
+ if (operator !== "and" && operator !== "or")
853
+ return "unknown";
854
+ let sawUnknown = false;
855
+ for (const operand of operands) {
856
+ if (!isObject(operand)) {
857
+ sawUnknown = true;
858
+ continue;
859
+ }
860
+ const v = evaluateCondition(operand, ctx);
861
+ if (v === "unknown") {
862
+ sawUnknown = true;
863
+ continue;
864
+ }
865
+ if (operator === "and" && v === false)
866
+ return false;
867
+ if (operator === "or" && v === true)
868
+ return true;
869
+ }
870
+ if (sawUnknown)
871
+ return "unknown";
872
+ return operator === "and"; // all true for AND, all false for OR
873
+ }
874
+ // ---------------------------------------------------------------------------
875
+ // Helpers
876
+ // ---------------------------------------------------------------------------
877
+ /**
878
+ * Read a signed numeric value out of a modifier `{operation, value}` pair.
879
+ * "add"/"subtract" become the matching sign; "set" / "multiply" / etc. return
880
+ * `null` (translator surfaces them as unsupported).
881
+ */
882
+ function signedValue(modifier) {
883
+ const value = Number(modifier.value);
884
+ if (!Number.isFinite(value))
885
+ return null;
886
+ switch (modifier.operation) {
887
+ case "add":
888
+ return value;
889
+ case "subtract":
890
+ return -value;
891
+ // For the symmetric stats (A/S/T) and roll-/bs-modifiers, "improve" moves
892
+ // the number up (beneficial) and "worsen" down. AP is handled separately by
893
+ // `apDelta`, which inverts this because AP's beneficial direction is more
894
+ // negative.
895
+ case "improve":
896
+ return value;
897
+ case "worsen":
898
+ return -value;
899
+ default:
900
+ // set / halve / multiply: not a single signed delta — left unsupported.
901
+ return null;
902
+ }
903
+ }
904
+ /**
905
+ * Read the AP delta out of a stat-modifier `{operation, value}` pair. AP is
906
+ * stored negative (more negative = more piercing), so "improve" makes it more
907
+ * negative and "worsen" less. The legacy `add`/`subtract` forms pass a signed
908
+ * value through directly (the data already encodes the sign).
909
+ */
910
+ function apDelta(modifier) {
911
+ const value = Number(modifier.value);
912
+ if (!Number.isFinite(value))
913
+ return null;
914
+ switch (modifier.operation) {
915
+ case "improve":
916
+ return -Math.abs(value);
917
+ case "worsen":
918
+ return Math.abs(value);
919
+ case "add":
920
+ return value;
921
+ case "subtract":
922
+ return -value;
923
+ default:
924
+ return null;
925
+ }
926
+ }
927
+ /**
928
+ * Parse a printed weapon-keyword string (e.g. `"Sustained Hits 1"`,
929
+ * `"Anti-INFANTRY 4+"`, `"Lethal Hits"`) into a `{keyword_id, parameters?}`
930
+ * catalog reference, or `null` if the form is unrecognised.
931
+ *
932
+ * Reverses the conventions baked into the M0 catalog: kebab-case ids,
933
+ * trailing number → `value`, embedded keyword + threshold → `target_keyword`
934
+ * + `threshold`.
935
+ */
936
+ export function parseKeywordGrant(raw) {
937
+ const trimmed = raw.trim();
938
+ if (trimmed === "")
939
+ return null;
940
+ // Anti-X N+ → { anti, target_keyword: X, threshold: N }
941
+ const antiMatch = /^anti-([A-Z][A-Z\s-]*)\s+(\d+)\+?$/i.exec(trimmed);
942
+ if (antiMatch) {
943
+ return {
944
+ keyword_id: "anti",
945
+ parameters: { target_keyword: antiMatch[1].trim(), threshold: Number(antiMatch[2]) },
946
+ };
947
+ }
948
+ // "Lethal Hits", "Twin-linked", "Heavy" → kebab-case lookup, no params.
949
+ // "Sustained Hits 1", "Rapid Fire 2", "Melta 2" → kebab-case + value.
950
+ const valueMatch = /^(.+?)\s+(\d+)$/.exec(trimmed);
951
+ if (valueMatch) {
952
+ return {
953
+ keyword_id: toKebabCase(valueMatch[1]),
954
+ parameters: { value: Number(valueMatch[2]) },
955
+ };
956
+ }
957
+ return { keyword_id: toKebabCase(trimmed) };
958
+ }
959
+ function toKebabCase(s) {
960
+ return s
961
+ .toLowerCase()
962
+ .replace(/[\s_]+/g, "-")
963
+ .replace(/[^a-z0-9-]/g, "");
964
+ }
965
+ function isObject(value) {
966
+ return typeof value === "object" && value !== null && !Array.isArray(value);
967
+ }
968
+ //# sourceMappingURL=from-dsl.js.map