@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,69 @@
1
+ /**
2
+ * Translate an Ability DSL `effect` tree into the {@link Buff} stack it
3
+ * contributes (for an attacker-perspective crunch) along with a list of
4
+ * effect fragments the translator could not auto-apply.
5
+ *
6
+ * The buff layer is intentionally a subset of the DSL: it covers the math the
7
+ * cruncher's expected-value engine reads (rerolls, die-roll modifiers, S/A/T
8
+ * stat shifts, FNP, granted weapon keywords, cover) and reports everything
9
+ * else — choice nodes (player decisions), dice-gated effects (stochastic),
10
+ * defender-side bs-modifier, attack-restrictions, ability grants, mortal
11
+ * wound triggers — as `unsupported` so the SPA can surface "this ability has
12
+ * effects we can't auto-apply" rather than silently dropping them.
13
+ *
14
+ * The walker classifies an effect's `target` against the attacker
15
+ * perspective: `self`, `bearer`, `unit`, `attached-unit`, `attacker`, and
16
+ * `friendly-within-aura` are all treated as "applies to my unit". `defender`,
17
+ * `enemy-within-aura`, and `all-enemy` are dropped without being marked
18
+ * unsupported — those are defender-side mods and would surface from the
19
+ * target's perspective (M3 work), not the attacker's.
20
+ *
21
+ * @packageDocumentation
22
+ */
23
+ import type { Buff, BuffSource, EngineContext, WeaponKeywordRef } from "./buffs.js";
24
+ /** A fragment we couldn't translate. The SPA can render these as warnings. */
25
+ export type UnsupportedFragment = {
26
+ reason: string;
27
+ effectFragment: unknown;
28
+ };
29
+ export type EffectTranslation = {
30
+ applied: Buff[];
31
+ unsupported: UnsupportedFragment[];
32
+ };
33
+ /**
34
+ * Whose perspective the translation runs from.
35
+ *
36
+ * - `"attacker"`: the buffed unit is *firing*. `target: "unit"/"self"` etc.
37
+ * become attacker-side mods (re-rolls, hit/wound mods, A/S shifts, granted
38
+ * keywords). `target: "defender"` is silently dropped — that's incoming
39
+ * penalty math relevant when the buffed unit is the *target*, surfaced via
40
+ * the `"target"` perspective instead.
41
+ *
42
+ * - `"target"`: the buffed unit is *being shot at*. Defensive mods on the
43
+ * buffed unit (`stat-modifier T`, `stat-modifier Sv`, `feel-no-pain`,
44
+ * `roll-modifier save`) become defender-side buffs. Conversely, attacker-
45
+ * only mods (re-rolls, hit/wound mods, A/S shifts) drop silently because
46
+ * they describe what the buffed unit does when *attacking*.
47
+ *
48
+ * The bs-modifier effect (a -1 to incoming hit rolls, e.g. Benefit of Cover)
49
+ * becomes a `hit-mod` buff under target perspective so it stacks correctly
50
+ * with attacker-side modifiers in the resolver's ±1 cap.
51
+ */
52
+ export type TranslationPerspective = "attacker" | "target";
53
+ /**
54
+ * Walk an ability DSL `effect` tree and produce the buff stack it contributes
55
+ * against `context` from the given `perspective`, plus an `unsupported` list
56
+ * naming any branches the buff layer can't express today.
57
+ */
58
+ export declare function effectToBuffs(effect: unknown, source: BuffSource, context: EngineContext, perspective?: TranslationPerspective): EffectTranslation;
59
+ /**
60
+ * Parse a printed weapon-keyword string (e.g. `"Sustained Hits 1"`,
61
+ * `"Anti-INFANTRY 4+"`, `"Lethal Hits"`) into a `{keyword_id, parameters?}`
62
+ * catalog reference, or `null` if the form is unrecognised.
63
+ *
64
+ * Reverses the conventions baked into the M0 catalog: kebab-case ids,
65
+ * trailing number → `value`, embedded keyword + threshold → `target_keyword`
66
+ * + `threshold`.
67
+ */
68
+ export declare function parseKeywordGrant(raw: string): WeaponKeywordRef | null;
69
+ //# sourceMappingURL=from-dsl.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"from-dsl.d.ts","sourceRoot":"","sources":["../../src/cruncher/from-dsl.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,OAAO,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAEpF,8EAA8E;AAC9E,MAAM,MAAM,mBAAmB,GAAG;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,EAAE,OAAO,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,OAAO,EAAE,IAAI,EAAE,CAAC;IAChB,WAAW,EAAE,mBAAmB,EAAE,CAAC;CACpC,CAAC;AAEF;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,MAAM,sBAAsB,GAAG,UAAU,GAAG,QAAQ,CAAC;AAiB3D;;;;GAIG;AACH,wBAAgB,aAAa,CAC3B,MAAM,EAAE,OAAO,EACf,MAAM,EAAE,UAAU,EAClB,OAAO,EAAE,aAAa,EACtB,WAAW,GAAE,sBAAmC,GAC/C,iBAAiB,CAInB;AAyeD;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,gBAAgB,GAAG,IAAI,CAuBtE"}
@@ -0,0 +1,523 @@
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: [] };
21
+ walk(effect, source, { context, perspective }, out);
22
+ return out;
23
+ }
24
+ function walk(node, source, opts, out) {
25
+ if (!isObject(node))
26
+ return;
27
+ const type = node.type;
28
+ switch (type) {
29
+ case "re-roll":
30
+ translateReroll(node, source, opts, out);
31
+ return;
32
+ case "roll-modifier":
33
+ translateRollModifier(node, source, opts, out);
34
+ return;
35
+ case "stat-modifier":
36
+ translateStatModifier(node, source, opts, out);
37
+ return;
38
+ case "feel-no-pain":
39
+ translateFeelNoPain(node, source, opts, out);
40
+ return;
41
+ case "keyword-grant":
42
+ translateKeywordGrant(node, source, opts, out);
43
+ return;
44
+ case "bs-modifier":
45
+ translateBsModifier(node, source, opts, out);
46
+ return;
47
+ case "conditional":
48
+ translateConditional(node, source, opts, out);
49
+ return;
50
+ case "sequence":
51
+ for (const step of node.steps ?? [])
52
+ walk(step, source, opts, out);
53
+ return;
54
+ case "choice":
55
+ // Player decision — auto-applying every branch would double-count.
56
+ out.unsupported.push({
57
+ reason: "choice: player picks one option; the buff layer can't choose",
58
+ effectFragment: node,
59
+ });
60
+ return;
61
+ case "dice-gated":
62
+ // Probabilistic; the buff layer is deterministic.
63
+ out.unsupported.push({
64
+ reason: "dice-gated effect: stochastic; not expressible as a buff",
65
+ effectFragment: node,
66
+ });
67
+ return;
68
+ case "dice-pool-allocation":
69
+ out.unsupported.push({
70
+ reason: "dice-pool-allocation: player allocates dice at runtime",
71
+ effectFragment: node,
72
+ });
73
+ return;
74
+ default:
75
+ // Unknown effect — record it. Covers ability-grant, deep-strike,
76
+ // mortal-wounds, cp-gain, movement-modifier, etc.; the buff layer
77
+ // doesn't model these as deterministic mods to a single shot.
78
+ out.unsupported.push({
79
+ reason: `effect type "${String(type)}" is not modelled by the buff layer`,
80
+ effectFragment: node,
81
+ });
82
+ return;
83
+ }
84
+ }
85
+ // ---------------------------------------------------------------------------
86
+ // Leaf translators
87
+ // ---------------------------------------------------------------------------
88
+ /**
89
+ * Classify a node's `target` field against the perspective we're translating
90
+ * for. Returns:
91
+ * - `"self"`: the node targets the buffed unit (apply attacker-side or
92
+ * defender-side translation, depending on perspective + stat).
93
+ * - `"attacker"` / `"defender"`: the node targets the other party explicitly.
94
+ * - `"unknown"`: missing/malformed target.
95
+ */
96
+ function classifyTarget(node) {
97
+ const target = node.target;
98
+ if (typeof target !== "string")
99
+ return "unknown";
100
+ if (target === ATTACKER_TARGET)
101
+ return "attacker";
102
+ if (DEFENDER_TARGETS.has(target))
103
+ return "defender";
104
+ if (SELF_TARGETS.has(target))
105
+ return "self";
106
+ return "unknown";
107
+ }
108
+ /**
109
+ * Does this node's target match the buffed unit under the current
110
+ * perspective? Used for symmetric roll/keyword translations where the same
111
+ * effect is "self" in either direction.
112
+ */
113
+ function appliesToBuffedUnit(node, perspective) {
114
+ const cls = classifyTarget(node);
115
+ if (cls === "self")
116
+ return true;
117
+ if (cls === "attacker")
118
+ return perspective === "attacker";
119
+ if (cls === "defender")
120
+ return perspective === "target";
121
+ return false;
122
+ }
123
+ function translateReroll(node, source, opts, out) {
124
+ // Rerolls are inherently attacker-side (you re-roll your own hit/wound/
125
+ // damage; save rerolls fire when *you* are the target). Apply only under
126
+ // the matching perspective so a target-perspective walk doesn't grab the
127
+ // attacker's reroll-failed-hits buff.
128
+ if (opts.perspective === "attacker" && !appliesToBuffedUnit(node, "attacker"))
129
+ return;
130
+ const modifier = node.modifier;
131
+ if (!isObject(modifier)) {
132
+ out.unsupported.push({ reason: "re-roll: missing modifier object", effectFragment: node });
133
+ return;
134
+ }
135
+ const roll = modifier.roll;
136
+ const subset = modifier.subset;
137
+ // Under target perspective, only "save" rerolls fire on the buffed unit.
138
+ if (opts.perspective === "target" && roll !== "save")
139
+ return;
140
+ if ((roll === "hit" || roll === "wound" || roll === "save" || roll === "damage") &&
141
+ (subset === "ones" || subset === "all-failures")) {
142
+ out.applied.push({ source, contribution: { type: "reroll", roll, subset } });
143
+ return;
144
+ }
145
+ out.unsupported.push({
146
+ reason: `re-roll on "${String(roll)}" (subset "${String(subset)}") is outside the damage path`,
147
+ effectFragment: node,
148
+ });
149
+ }
150
+ function translateRollModifier(node, source, opts, out) {
151
+ const modifier = node.modifier;
152
+ if (!isObject(modifier)) {
153
+ out.unsupported.push({
154
+ reason: "roll-modifier: missing modifier object",
155
+ effectFragment: node,
156
+ });
157
+ return;
158
+ }
159
+ const value = signedValue(modifier);
160
+ if (value === null) {
161
+ out.unsupported.push({
162
+ reason: `roll-modifier: operation "${String(modifier.operation)}" not supported`,
163
+ effectFragment: node,
164
+ });
165
+ return;
166
+ }
167
+ const roll = modifier.roll;
168
+ // Each roll type is intrinsically on one side. Hit / wound / damage are
169
+ // attacker-side; save is defender-side. The perspective decides whether the
170
+ // buffed unit's `target` is the right party for that roll type.
171
+ if (opts.perspective === "attacker") {
172
+ if (!appliesToBuffedUnit(node, "attacker"))
173
+ return;
174
+ if (roll === "save")
175
+ return; // saves apply to the defender, not the attacker.
176
+ }
177
+ else {
178
+ // target perspective: only `save` rolls on the buffed unit fire here.
179
+ if (roll !== "save")
180
+ return;
181
+ if (!appliesToBuffedUnit(node, "target"))
182
+ return;
183
+ }
184
+ switch (roll) {
185
+ case "hit":
186
+ out.applied.push({ source, contribution: { type: "hit-mod", value } });
187
+ return;
188
+ case "wound":
189
+ out.applied.push({ source, contribution: { type: "wound-mod", value } });
190
+ return;
191
+ case "save":
192
+ out.applied.push({ source, contribution: { type: "save-mod", value } });
193
+ return;
194
+ case "damage":
195
+ out.applied.push({ source, contribution: { type: "damage-mod", value } });
196
+ return;
197
+ default:
198
+ out.unsupported.push({
199
+ reason: `roll-modifier on "${String(roll)}" is outside the damage path`,
200
+ effectFragment: node,
201
+ });
202
+ }
203
+ }
204
+ function translateStatModifier(node, source, opts, out) {
205
+ const modifier = node.modifier;
206
+ if (!isObject(modifier)) {
207
+ out.unsupported.push({
208
+ reason: "stat-modifier: missing modifier object",
209
+ effectFragment: node,
210
+ });
211
+ return;
212
+ }
213
+ const value = signedValue(modifier);
214
+ if (value === null) {
215
+ out.unsupported.push({
216
+ reason: `stat-modifier: operation "${String(modifier.operation)}" not supported`,
217
+ effectFragment: node,
218
+ });
219
+ return;
220
+ }
221
+ const stat = modifier.stat;
222
+ const isOnBuffedUnit = appliesToBuffedUnit(node, opts.perspective);
223
+ switch (stat) {
224
+ case "A":
225
+ if (opts.perspective !== "attacker" || !isOnBuffedUnit)
226
+ return;
227
+ out.applied.push({ source, contribution: { type: "attacks-mod", value } });
228
+ return;
229
+ case "S":
230
+ if (opts.perspective !== "attacker" || !isOnBuffedUnit)
231
+ return;
232
+ out.applied.push({ source, contribution: { type: "strength-mod", value } });
233
+ return;
234
+ case "T":
235
+ // Defender stat. Only relevant under target perspective.
236
+ if (opts.perspective !== "target") {
237
+ out.unsupported.push({
238
+ reason: "stat-modifier T: defender-side stat; applies when the buffed unit is the target",
239
+ effectFragment: node,
240
+ });
241
+ return;
242
+ }
243
+ if (!isOnBuffedUnit)
244
+ return;
245
+ out.applied.push({ source, contribution: { type: "toughness-mod", value } });
246
+ return;
247
+ case "Sv":
248
+ // Saves improve when the *defender* gets +Sv. A +1 to Sv in printed
249
+ // rules means "improve the save by 1", which maps to a `save-mod` of
250
+ // `-value` since save-mod is signed against the *needed roll*.
251
+ // (Equivalent: a -1 Sv penalty is a +1 save-mod.) We translate
252
+ // "Sv add 1" → save-mod -1 to keep the resolver's sign convention.
253
+ if (opts.perspective !== "target") {
254
+ out.unsupported.push({
255
+ reason: "stat-modifier Sv: defender-side stat; applies when the buffed unit is the target",
256
+ effectFragment: node,
257
+ });
258
+ return;
259
+ }
260
+ if (!isOnBuffedUnit)
261
+ return;
262
+ out.applied.push({ source, contribution: { type: "save-mod", value: -value } });
263
+ return;
264
+ case "AP":
265
+ // AP rides on the attacker's weapon profile and is stored as a negative
266
+ // number in the data (e.g. AP -1). The data's `{operation:"add", value:-1}`
267
+ // form means "AP becomes one more negative" → more piercing. `signedValue`
268
+ // already returns that negative number directly, so pass it through.
269
+ if (opts.perspective !== "attacker" || !isOnBuffedUnit)
270
+ return;
271
+ out.applied.push({ source, contribution: { type: "ap-mod", value } });
272
+ return;
273
+ default:
274
+ out.unsupported.push({
275
+ reason: `stat-modifier on "${String(stat)}" is outside the damage path`,
276
+ effectFragment: node,
277
+ });
278
+ }
279
+ }
280
+ function translateFeelNoPain(node, source, opts, out) {
281
+ // FNP applies when the buffed unit is the *target* — it ablates incoming
282
+ // damage. Under attacker perspective the FNP is irrelevant (the unit is
283
+ // firing, not taking damage). Drop silently rather than as `unsupported`
284
+ // so attacker-perspective walks don't surface a spurious diagnostic for
285
+ // every unit that happens to have a FNP rule.
286
+ if (opts.perspective !== "target")
287
+ return;
288
+ const modifier = node.modifier;
289
+ if (!isObject(modifier)) {
290
+ out.unsupported.push({
291
+ reason: "feel-no-pain: missing modifier object",
292
+ effectFragment: node,
293
+ });
294
+ return;
295
+ }
296
+ const threshold = Number(modifier.threshold);
297
+ if (!Number.isFinite(threshold)) {
298
+ out.unsupported.push({
299
+ reason: "feel-no-pain: threshold not numeric",
300
+ effectFragment: node,
301
+ });
302
+ return;
303
+ }
304
+ out.applied.push({ source, contribution: { type: "feel-no-pain", threshold } });
305
+ }
306
+ function translateKeywordGrant(node, source, opts, out) {
307
+ // Weapon-keyword grants ride with the attacker's profile (e.g. "your
308
+ // weapons gain [Sustained Hits 1]"). Defender-perspective walks ignore
309
+ // them — the keyword applies when the buffed unit fires, not when it's
310
+ // shot at.
311
+ if (opts.perspective !== "attacker")
312
+ return;
313
+ if (!appliesToBuffedUnit(node, "attacker"))
314
+ return;
315
+ const modifier = node.modifier;
316
+ if (!isObject(modifier))
317
+ return;
318
+ const keywords = modifier.keywords;
319
+ if (!Array.isArray(keywords))
320
+ return;
321
+ for (const raw of keywords) {
322
+ if (typeof raw !== "string")
323
+ continue;
324
+ const ref = parseKeywordGrant(raw);
325
+ if (!ref) {
326
+ out.unsupported.push({
327
+ reason: `keyword-grant: cannot parse "${raw}" to a catalog keyword`,
328
+ effectFragment: { keyword: raw },
329
+ });
330
+ continue;
331
+ }
332
+ out.applied.push({ source, contribution: { type: "extra-keyword", keywordRef: ref } });
333
+ }
334
+ }
335
+ function translateBsModifier(node, source, opts, out) {
336
+ // A bs-modifier on `target: "attacker"` is a defender-side rule: it
337
+ // penalises *incoming* hit rolls (e.g. Benefit of Cover). Translate it
338
+ // as a `hit-mod` buff under target perspective so the resolver's ±1 cap
339
+ // composes with attacker-side mods.
340
+ if (opts.perspective !== "target")
341
+ return;
342
+ const cls = classifyTarget(node);
343
+ if (cls !== "attacker")
344
+ return; // a bs-modifier on self wouldn't make sense.
345
+ const modifier = node.modifier;
346
+ if (!isObject(modifier))
347
+ return;
348
+ const value = signedValue(modifier);
349
+ if (value === null)
350
+ return;
351
+ out.applied.push({ source, contribution: { type: "hit-mod", value } });
352
+ }
353
+ function translateConditional(node, source, opts, out) {
354
+ const condition = node.condition;
355
+ const effect = node.effect;
356
+ if (!isObject(condition))
357
+ return;
358
+ const negated = condition.negated === true;
359
+ const verdict = evaluateCondition(condition, opts.context);
360
+ if (verdict === "unknown") {
361
+ out.unsupported.push({
362
+ reason: `conditional: cannot evaluate condition "${String(condition.type)}" against current context`,
363
+ effectFragment: node,
364
+ });
365
+ return;
366
+ }
367
+ const active = negated ? !verdict : verdict;
368
+ if (!active)
369
+ return;
370
+ walk(effect, source, opts, out);
371
+ }
372
+ // ---------------------------------------------------------------------------
373
+ // Condition evaluator
374
+ // ---------------------------------------------------------------------------
375
+ function evaluateCondition(condition, ctx) {
376
+ // Compound conditions use {operator, operands} rather than {type, parameters}.
377
+ // The schema's `condition-node` oneOf doesn't guarantee discrimination by a
378
+ // single field, so dispatch on shape: presence of `operator` + `operands`
379
+ // wins over the simple-condition switch below.
380
+ if (typeof condition.operator === "string" &&
381
+ Array.isArray(condition.operands)) {
382
+ return evaluateCompound(condition.operator, condition.operands, ctx);
383
+ }
384
+ switch (condition.type) {
385
+ case "phase-is": {
386
+ const wanted = condition.parameters?.phase;
387
+ if (typeof wanted !== "string")
388
+ return "unknown";
389
+ return ctx.phase === wanted;
390
+ }
391
+ case "timing-is": {
392
+ const wanted = condition.parameters?.timing;
393
+ if (typeof wanted !== "string")
394
+ return "unknown";
395
+ if (ctx.timing === undefined)
396
+ return "unknown";
397
+ return ctx.timing === wanted;
398
+ }
399
+ case "remained-stationary":
400
+ return ctx.attackerStationary === true;
401
+ case "target-has-keyword": {
402
+ const kw = condition.parameters?.keyword;
403
+ if (typeof kw !== "string")
404
+ return "unknown";
405
+ return (ctx.targetKeywords ?? []).includes(kw.toLowerCase());
406
+ }
407
+ case "unit-has-keyword": {
408
+ const kw = condition.parameters?.keyword;
409
+ if (typeof kw !== "string")
410
+ return "unknown";
411
+ return (ctx.attackerKeywords ?? []).includes(kw.toLowerCase());
412
+ }
413
+ case "is-attached":
414
+ // The resolver knows whether a leader is attached; absent that signal
415
+ // here, treat as unknown so the SPA can surface the gap.
416
+ return "unknown";
417
+ default:
418
+ return "unknown";
419
+ }
420
+ }
421
+ /**
422
+ * Kleene three-valued evaluator for compound conditions. `and` short-circuits
423
+ * to `false` as soon as any operand is false (an unknown operand is then
424
+ * irrelevant); `or` short-circuits to `true` symmetrically. `not` flips its
425
+ * single operand and leaves `"unknown"` as `"unknown"`. Unknown operands that
426
+ * don't get short-circuited propagate as `"unknown"` so the SPA can surface
427
+ * the gap rather than collapsing it into a misleading false.
428
+ */
429
+ function evaluateCompound(operator, operands, ctx) {
430
+ if (operator === "not") {
431
+ const first = operands[0];
432
+ if (!isObject(first))
433
+ return "unknown";
434
+ const v = evaluateCondition(first, ctx);
435
+ if (v === "unknown")
436
+ return "unknown";
437
+ return !v;
438
+ }
439
+ if (operator !== "and" && operator !== "or")
440
+ return "unknown";
441
+ let sawUnknown = false;
442
+ for (const operand of operands) {
443
+ if (!isObject(operand)) {
444
+ sawUnknown = true;
445
+ continue;
446
+ }
447
+ const v = evaluateCondition(operand, ctx);
448
+ if (v === "unknown") {
449
+ sawUnknown = true;
450
+ continue;
451
+ }
452
+ if (operator === "and" && v === false)
453
+ return false;
454
+ if (operator === "or" && v === true)
455
+ return true;
456
+ }
457
+ if (sawUnknown)
458
+ return "unknown";
459
+ return operator === "and"; // all true for AND, all false for OR
460
+ }
461
+ // ---------------------------------------------------------------------------
462
+ // Helpers
463
+ // ---------------------------------------------------------------------------
464
+ /**
465
+ * Read a signed numeric value out of a modifier `{operation, value}` pair.
466
+ * "add"/"subtract" become the matching sign; "set" / "multiply" / etc. return
467
+ * `null` (translator surfaces them as unsupported).
468
+ */
469
+ function signedValue(modifier) {
470
+ const value = Number(modifier.value);
471
+ if (!Number.isFinite(value))
472
+ return null;
473
+ switch (modifier.operation) {
474
+ case "add":
475
+ return value;
476
+ case "subtract":
477
+ return -value;
478
+ default:
479
+ return null;
480
+ }
481
+ }
482
+ /**
483
+ * Parse a printed weapon-keyword string (e.g. `"Sustained Hits 1"`,
484
+ * `"Anti-INFANTRY 4+"`, `"Lethal Hits"`) into a `{keyword_id, parameters?}`
485
+ * catalog reference, or `null` if the form is unrecognised.
486
+ *
487
+ * Reverses the conventions baked into the M0 catalog: kebab-case ids,
488
+ * trailing number → `value`, embedded keyword + threshold → `target_keyword`
489
+ * + `threshold`.
490
+ */
491
+ export function parseKeywordGrant(raw) {
492
+ const trimmed = raw.trim();
493
+ if (trimmed === "")
494
+ return null;
495
+ // Anti-X N+ → { anti, target_keyword: X, threshold: N }
496
+ const antiMatch = /^anti-([A-Z][A-Z\s-]*)\s+(\d+)\+?$/i.exec(trimmed);
497
+ if (antiMatch) {
498
+ return {
499
+ keyword_id: "anti",
500
+ parameters: { target_keyword: antiMatch[1].trim(), threshold: Number(antiMatch[2]) },
501
+ };
502
+ }
503
+ // "Lethal Hits", "Twin-linked", "Heavy" → kebab-case lookup, no params.
504
+ // "Sustained Hits 1", "Rapid Fire 2", "Melta 2" → kebab-case + value.
505
+ const valueMatch = /^(.+?)\s+(\d+)$/.exec(trimmed);
506
+ if (valueMatch) {
507
+ return {
508
+ keyword_id: toKebabCase(valueMatch[1]),
509
+ parameters: { value: Number(valueMatch[2]) },
510
+ };
511
+ }
512
+ return { keyword_id: toKebabCase(trimmed) };
513
+ }
514
+ function toKebabCase(s) {
515
+ return s
516
+ .toLowerCase()
517
+ .replace(/[\s_]+/g, "-")
518
+ .replace(/[^a-z0-9-]/g, "");
519
+ }
520
+ function isObject(value) {
521
+ return typeof value === "object" && value !== null && !Array.isArray(value);
522
+ }
523
+ //# sourceMappingURL=from-dsl.js.map