@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.
- package/dist/abilities-resolver/index.d.ts +9 -0
- package/dist/abilities-resolver/index.d.ts.map +1 -0
- package/dist/abilities-resolver/index.js +9 -0
- package/dist/abilities-resolver/index.js.map +1 -0
- package/dist/abilities-resolver/resolver.d.ts +64 -0
- package/dist/abilities-resolver/resolver.d.ts.map +1 -0
- package/dist/abilities-resolver/resolver.js +135 -0
- package/dist/abilities-resolver/resolver.js.map +1 -0
- package/dist/bundle-schemas.d.ts +1 -0
- package/dist/bundle-schemas.d.ts.map +1 -0
- package/dist/bundle-schemas.js +12 -0
- package/dist/bundle-schemas.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +10 -0
- package/dist/cli.js.map +1 -0
- package/dist/codegen-data.d.ts +1 -0
- package/dist/codegen-data.d.ts.map +1 -0
- package/dist/codegen-data.js +2 -0
- package/dist/codegen-data.js.map +1 -0
- package/dist/commands/import.d.ts +7 -0
- package/dist/commands/import.d.ts.map +1 -0
- package/dist/commands/import.js +103 -0
- package/dist/commands/import.js.map +1 -0
- package/dist/commands/translate.d.ts +1 -0
- package/dist/commands/translate.d.ts.map +1 -0
- package/dist/commands/translate.js +1 -0
- package/dist/commands/translate.js.map +1 -0
- package/dist/commands/validate-all.d.ts +1 -0
- package/dist/commands/validate-all.d.ts.map +1 -0
- package/dist/commands/validate-all.js +1 -0
- package/dist/commands/validate-all.js.map +1 -0
- package/dist/commands/validate-core.d.ts +1 -0
- package/dist/commands/validate-core.d.ts.map +1 -0
- package/dist/commands/validate-core.js +1 -0
- package/dist/commands/validate-core.js.map +1 -0
- package/dist/commands/validate-enrichment.d.ts +1 -0
- package/dist/commands/validate-enrichment.d.ts.map +1 -0
- package/dist/commands/validate-enrichment.js +1 -0
- package/dist/commands/validate-enrichment.js.map +1 -0
- package/dist/convert-faction.d.ts +1 -0
- package/dist/convert-faction.d.ts.map +1 -0
- package/dist/convert-faction.js +1 -0
- package/dist/convert-faction.js.map +1 -0
- package/dist/converters/configs/adepta-sororitas.d.ts +1 -0
- package/dist/converters/configs/adepta-sororitas.d.ts.map +1 -0
- package/dist/converters/configs/adepta-sororitas.js +1 -0
- package/dist/converters/configs/adepta-sororitas.js.map +1 -0
- package/dist/converters/configs/adeptus-astartes.d.ts +1 -0
- package/dist/converters/configs/adeptus-astartes.d.ts.map +1 -0
- package/dist/converters/configs/adeptus-astartes.js +1 -0
- package/dist/converters/configs/adeptus-astartes.js.map +1 -0
- package/dist/converters/configs/adeptus-custodes.d.ts +1 -0
- package/dist/converters/configs/adeptus-custodes.d.ts.map +1 -0
- package/dist/converters/configs/adeptus-custodes.js +1 -0
- package/dist/converters/configs/adeptus-custodes.js.map +1 -0
- package/dist/converters/configs/adeptus-mechanicus.d.ts +1 -0
- package/dist/converters/configs/adeptus-mechanicus.d.ts.map +1 -0
- package/dist/converters/configs/adeptus-mechanicus.js +1 -0
- package/dist/converters/configs/adeptus-mechanicus.js.map +1 -0
- package/dist/converters/configs/aeldari.d.ts +1 -0
- package/dist/converters/configs/aeldari.d.ts.map +1 -0
- package/dist/converters/configs/aeldari.js +1 -0
- package/dist/converters/configs/aeldari.js.map +1 -0
- package/dist/converters/configs/agents-of-the-imperium.d.ts +1 -0
- package/dist/converters/configs/agents-of-the-imperium.d.ts.map +1 -0
- package/dist/converters/configs/agents-of-the-imperium.js +1 -0
- package/dist/converters/configs/agents-of-the-imperium.js.map +1 -0
- package/dist/converters/configs/astra-militarum.d.ts +1 -0
- package/dist/converters/configs/astra-militarum.d.ts.map +1 -0
- package/dist/converters/configs/astra-militarum.js +1 -0
- package/dist/converters/configs/astra-militarum.js.map +1 -0
- package/dist/converters/configs/black-templars.d.ts +1 -0
- package/dist/converters/configs/black-templars.d.ts.map +1 -0
- package/dist/converters/configs/black-templars.js +1 -0
- package/dist/converters/configs/black-templars.js.map +1 -0
- package/dist/converters/configs/blood-angels.d.ts +1 -0
- package/dist/converters/configs/blood-angels.d.ts.map +1 -0
- package/dist/converters/configs/blood-angels.js +1 -0
- package/dist/converters/configs/blood-angels.js.map +1 -0
- package/dist/converters/configs/chaos-daemons.d.ts +1 -0
- package/dist/converters/configs/chaos-daemons.d.ts.map +1 -0
- package/dist/converters/configs/chaos-daemons.js +1 -0
- package/dist/converters/configs/chaos-daemons.js.map +1 -0
- package/dist/converters/configs/chaos-knights.d.ts +1 -0
- package/dist/converters/configs/chaos-knights.d.ts.map +1 -0
- package/dist/converters/configs/chaos-knights.js +1 -0
- package/dist/converters/configs/chaos-knights.js.map +1 -0
- package/dist/converters/configs/chaos-space-marines.d.ts +1 -0
- package/dist/converters/configs/chaos-space-marines.d.ts.map +1 -0
- package/dist/converters/configs/chaos-space-marines.js +1 -0
- package/dist/converters/configs/chaos-space-marines.js.map +1 -0
- package/dist/converters/configs/crimson-fists.d.ts +1 -0
- package/dist/converters/configs/crimson-fists.d.ts.map +1 -0
- package/dist/converters/configs/crimson-fists.js +1 -0
- package/dist/converters/configs/crimson-fists.js.map +1 -0
- package/dist/converters/configs/dark-angels.d.ts +1 -0
- package/dist/converters/configs/dark-angels.d.ts.map +1 -0
- package/dist/converters/configs/dark-angels.js +1 -0
- package/dist/converters/configs/dark-angels.js.map +1 -0
- package/dist/converters/configs/death-guard.d.ts +1 -0
- package/dist/converters/configs/death-guard.d.ts.map +1 -0
- package/dist/converters/configs/death-guard.js +1 -0
- package/dist/converters/configs/death-guard.js.map +1 -0
- package/dist/converters/configs/deathwatch.d.ts +1 -0
- package/dist/converters/configs/deathwatch.d.ts.map +1 -0
- package/dist/converters/configs/deathwatch.js +1 -0
- package/dist/converters/configs/deathwatch.js.map +1 -0
- package/dist/converters/configs/drukhari.d.ts +1 -0
- package/dist/converters/configs/drukhari.d.ts.map +1 -0
- package/dist/converters/configs/drukhari.js +1 -0
- package/dist/converters/configs/drukhari.js.map +1 -0
- package/dist/converters/configs/emperors-children.d.ts +1 -0
- package/dist/converters/configs/emperors-children.d.ts.map +1 -0
- package/dist/converters/configs/emperors-children.js +1 -0
- package/dist/converters/configs/emperors-children.js.map +1 -0
- package/dist/converters/configs/genestealer-cults.d.ts +1 -0
- package/dist/converters/configs/genestealer-cults.d.ts.map +1 -0
- package/dist/converters/configs/genestealer-cults.js +1 -0
- package/dist/converters/configs/genestealer-cults.js.map +1 -0
- package/dist/converters/configs/grey-knights.d.ts +1 -0
- package/dist/converters/configs/grey-knights.d.ts.map +1 -0
- package/dist/converters/configs/grey-knights.js +1 -0
- package/dist/converters/configs/grey-knights.js.map +1 -0
- package/dist/converters/configs/imperial-fists.d.ts +1 -0
- package/dist/converters/configs/imperial-fists.d.ts.map +1 -0
- package/dist/converters/configs/imperial-fists.js +1 -0
- package/dist/converters/configs/imperial-fists.js.map +1 -0
- package/dist/converters/configs/imperial-knights.d.ts +1 -0
- package/dist/converters/configs/imperial-knights.d.ts.map +1 -0
- package/dist/converters/configs/imperial-knights.js +1 -0
- package/dist/converters/configs/imperial-knights.js.map +1 -0
- package/dist/converters/configs/iron-hands.d.ts +1 -0
- package/dist/converters/configs/iron-hands.d.ts.map +1 -0
- package/dist/converters/configs/iron-hands.js +1 -0
- package/dist/converters/configs/iron-hands.js.map +1 -0
- package/dist/converters/configs/leagues-of-votann.d.ts +1 -0
- package/dist/converters/configs/leagues-of-votann.d.ts.map +1 -0
- package/dist/converters/configs/leagues-of-votann.js +1 -0
- package/dist/converters/configs/leagues-of-votann.js.map +1 -0
- package/dist/converters/configs/necrons.d.ts +1 -0
- package/dist/converters/configs/necrons.d.ts.map +1 -0
- package/dist/converters/configs/necrons.js +1 -0
- package/dist/converters/configs/necrons.js.map +1 -0
- package/dist/converters/configs/orks.d.ts +1 -0
- package/dist/converters/configs/orks.d.ts.map +1 -0
- package/dist/converters/configs/orks.js +1 -0
- package/dist/converters/configs/orks.js.map +1 -0
- package/dist/converters/configs/raven-guard.d.ts +1 -0
- package/dist/converters/configs/raven-guard.d.ts.map +1 -0
- package/dist/converters/configs/raven-guard.js +1 -0
- package/dist/converters/configs/raven-guard.js.map +1 -0
- package/dist/converters/configs/salamanders.d.ts +1 -0
- package/dist/converters/configs/salamanders.d.ts.map +1 -0
- package/dist/converters/configs/salamanders.js +1 -0
- package/dist/converters/configs/salamanders.js.map +1 -0
- package/dist/converters/configs/space-wolves.d.ts +1 -0
- package/dist/converters/configs/space-wolves.d.ts.map +1 -0
- package/dist/converters/configs/space-wolves.js +1 -0
- package/dist/converters/configs/space-wolves.js.map +1 -0
- package/dist/converters/configs/tau-empire.d.ts +1 -0
- package/dist/converters/configs/tau-empire.d.ts.map +1 -0
- package/dist/converters/configs/tau-empire.js +1 -0
- package/dist/converters/configs/tau-empire.js.map +1 -0
- package/dist/converters/configs/thousand-sons.d.ts +1 -0
- package/dist/converters/configs/thousand-sons.d.ts.map +1 -0
- package/dist/converters/configs/thousand-sons.js +1 -0
- package/dist/converters/configs/thousand-sons.js.map +1 -0
- package/dist/converters/configs/tyranids.d.ts +1 -0
- package/dist/converters/configs/tyranids.d.ts.map +1 -0
- package/dist/converters/configs/tyranids.js +1 -0
- package/dist/converters/configs/tyranids.js.map +1 -0
- package/dist/converters/configs/ultramarines.d.ts +1 -0
- package/dist/converters/configs/ultramarines.d.ts.map +1 -0
- package/dist/converters/configs/ultramarines.js +1 -0
- package/dist/converters/configs/ultramarines.js.map +1 -0
- package/dist/converters/configs/white-scars.d.ts +1 -0
- package/dist/converters/configs/white-scars.d.ts.map +1 -0
- package/dist/converters/configs/white-scars.js +1 -0
- package/dist/converters/configs/white-scars.js.map +1 -0
- package/dist/converters/configs/world-eaters.d.ts +1 -0
- package/dist/converters/configs/world-eaters.d.ts.map +1 -0
- package/dist/converters/configs/world-eaters.js +1 -0
- package/dist/converters/configs/world-eaters.js.map +1 -0
- package/dist/converters/faction-config.d.ts +1 -0
- package/dist/converters/faction-config.d.ts.map +1 -0
- package/dist/converters/faction-config.js +1 -0
- package/dist/converters/faction-config.js.map +1 -0
- package/dist/converters/id-generator.d.ts +1 -0
- package/dist/converters/id-generator.d.ts.map +1 -0
- package/dist/converters/id-generator.js +1 -0
- package/dist/converters/id-generator.js.map +1 -0
- package/dist/converters/keyword-filter.d.ts +1 -0
- package/dist/converters/keyword-filter.d.ts.map +1 -0
- package/dist/converters/keyword-filter.js +1 -0
- package/dist/converters/keyword-filter.js.map +1 -0
- package/dist/converters/stat-parser.d.ts +1 -0
- package/dist/converters/stat-parser.d.ts.map +1 -0
- package/dist/converters/stat-parser.js +1 -0
- package/dist/converters/stat-parser.js.map +1 -0
- package/dist/converters/view-selector.d.ts +1 -0
- package/dist/converters/view-selector.d.ts.map +1 -0
- package/dist/converters/view-selector.js +1 -0
- package/dist/converters/view-selector.js.map +1 -0
- package/dist/converters/weapon-dedup.d.ts +1 -0
- package/dist/converters/weapon-dedup.d.ts.map +1 -0
- package/dist/converters/weapon-dedup.js +1 -0
- package/dist/converters/weapon-dedup.js.map +1 -0
- package/dist/cruncher/buffs.d.ts +184 -0
- package/dist/cruncher/buffs.d.ts.map +1 -0
- package/dist/cruncher/buffs.js +150 -0
- package/dist/cruncher/buffs.js.map +1 -0
- package/dist/cruncher/engine.d.ts +50 -0
- package/dist/cruncher/engine.d.ts.map +1 -0
- package/dist/cruncher/engine.js +312 -0
- package/dist/cruncher/engine.js.map +1 -0
- package/dist/cruncher/from-dsl.d.ts +69 -0
- package/dist/cruncher/from-dsl.d.ts.map +1 -0
- package/dist/cruncher/from-dsl.js +523 -0
- package/dist/cruncher/from-dsl.js.map +1 -0
- package/dist/cruncher/from-keyword.d.ts +35 -0
- package/dist/cruncher/from-keyword.d.ts.map +1 -0
- package/dist/cruncher/from-keyword.js +159 -0
- package/dist/cruncher/from-keyword.js.map +1 -0
- package/dist/cruncher/get-buffs.d.ts +12 -0
- package/dist/cruncher/get-buffs.d.ts.map +1 -0
- package/dist/cruncher/get-buffs.js +7 -0
- package/dist/cruncher/get-buffs.js.map +1 -0
- package/dist/cruncher/index.d.ts +11 -0
- package/dist/cruncher/index.d.ts.map +1 -0
- package/dist/cruncher/index.js +11 -0
- package/dist/cruncher/index.js.map +1 -0
- package/dist/data/bundle.generated.d.ts +1 -0
- package/dist/data/bundle.generated.d.ts.map +1 -0
- package/dist/data/bundle.generated.js +2 -1
- package/dist/data/bundle.generated.js.map +1 -0
- package/dist/data/collection.d.ts +1 -0
- package/dist/data/collection.d.ts.map +1 -0
- package/dist/data/collection.js +1 -0
- package/dist/data/collection.js.map +1 -0
- package/dist/data/dataset.d.ts +54 -2
- package/dist/data/dataset.d.ts.map +1 -0
- package/dist/data/dataset.js +111 -1
- package/dist/data/dataset.js.map +1 -0
- package/dist/data/entities.d.ts +70 -2
- package/dist/data/entities.d.ts.map +1 -0
- package/dist/data/entities.js +122 -0
- package/dist/data/entities.js.map +1 -0
- package/dist/data/index.d.ts +9 -1
- package/dist/data/index.d.ts.map +1 -0
- package/dist/data/index.js +14 -1
- package/dist/data/index.js.map +1 -0
- package/dist/data/normalize.d.ts +1 -0
- package/dist/data/normalize.d.ts.map +1 -0
- package/dist/data/normalize.js +1 -0
- package/dist/data/normalize.js.map +1 -0
- package/dist/data/roster-resolve.d.ts +33 -0
- package/dist/data/roster-resolve.d.ts.map +1 -0
- package/dist/data/roster-resolve.js +36 -0
- package/dist/data/roster-resolve.js.map +1 -0
- package/dist/data/types.d.ts +4 -1
- package/dist/data/types.d.ts.map +1 -0
- package/dist/data/types.js +2 -0
- package/dist/data/types.js.map +1 -0
- package/dist/export/helpers.d.ts +33 -0
- package/dist/export/helpers.d.ts.map +1 -0
- package/dist/export/helpers.js +57 -0
- package/dist/export/helpers.js.map +1 -0
- package/dist/export/index.d.ts +21 -0
- package/dist/export/index.d.ts.map +1 -0
- package/dist/export/index.js +25 -0
- package/dist/export/index.js.map +1 -0
- package/dist/export/newrecruit-json.d.ts +3 -0
- package/dist/export/newrecruit-json.d.ts.map +1 -0
- package/dist/export/newrecruit-json.js +140 -0
- package/dist/export/newrecruit-json.js.map +1 -0
- package/dist/export/newrecruit-simple.d.ts +3 -0
- package/dist/export/newrecruit-simple.d.ts.map +1 -0
- package/dist/export/newrecruit-simple.js +76 -0
- package/dist/export/newrecruit-simple.js.map +1 -0
- package/dist/export/newrecruit-wtc.d.ts +4 -0
- package/dist/export/newrecruit-wtc.d.ts.map +1 -0
- package/dist/export/newrecruit-wtc.js +142 -0
- package/dist/export/newrecruit-wtc.js.map +1 -0
- package/dist/export/roster-json.d.ts +3 -0
- package/dist/export/roster-json.d.ts.map +1 -0
- package/dist/export/roster-json.js +8 -0
- package/dist/export/roster-json.js.map +1 -0
- package/dist/export/serializer.d.ts +27 -0
- package/dist/export/serializer.d.ts.map +1 -0
- package/dist/export/serializer.js +2 -0
- package/dist/export/serializer.js.map +1 -0
- package/dist/gen-conformance.d.ts +2 -0
- package/dist/gen-conformance.d.ts.map +1 -0
- package/dist/gen-conformance.js +131 -0
- package/dist/gen-conformance.js.map +1 -0
- package/dist/generated.d.ts +194 -118
- package/dist/generated.d.ts.map +1 -0
- package/dist/generated.js +1 -0
- package/dist/generated.js.map +1 -0
- package/dist/import/adapter.d.ts +27 -0
- package/dist/import/adapter.d.ts.map +1 -0
- package/dist/import/adapter.js +10 -0
- package/dist/import/adapter.js.map +1 -0
- package/dist/import/decode.d.ts +7 -0
- package/dist/import/decode.d.ts.map +1 -0
- package/dist/import/decode.js +73 -0
- package/dist/import/decode.js.map +1 -0
- package/dist/import/import-roster.d.ts +35 -0
- package/dist/import/import-roster.d.ts.map +1 -0
- package/dist/import/import-roster.js +97 -0
- package/dist/import/import-roster.js.map +1 -0
- package/dist/import/index.d.ts +22 -0
- package/dist/import/index.d.ts.map +1 -0
- package/dist/import/index.js +19 -0
- package/dist/import/index.js.map +1 -0
- package/dist/import/listforge.d.ts +24 -0
- package/dist/import/listforge.d.ts.map +1 -0
- package/dist/import/listforge.js +201 -0
- package/dist/import/listforge.js.map +1 -0
- package/dist/import/newrecruit-json.d.ts +31 -0
- package/dist/import/newrecruit-json.d.ts.map +1 -0
- package/dist/import/newrecruit-json.js +224 -0
- package/dist/import/newrecruit-json.js.map +1 -0
- package/dist/import/newrecruit-simple.d.ts +29 -0
- package/dist/import/newrecruit-simple.d.ts.map +1 -0
- package/dist/import/newrecruit-simple.js +200 -0
- package/dist/import/newrecruit-simple.js.map +1 -0
- package/dist/import/newrecruit-text.d.ts +48 -0
- package/dist/import/newrecruit-text.d.ts.map +1 -0
- package/dist/import/newrecruit-text.js +96 -0
- package/dist/import/newrecruit-text.js.map +1 -0
- package/dist/import/newrecruit-wtc.d.ts +36 -0
- package/dist/import/newrecruit-wtc.d.ts.map +1 -0
- package/dist/import/newrecruit-wtc.js +334 -0
- package/dist/import/newrecruit-wtc.js.map +1 -0
- package/dist/import/resolve.d.ts +20 -0
- package/dist/import/resolve.d.ts.map +1 -0
- package/dist/import/resolve.js +190 -0
- package/dist/import/resolve.js.map +1 -0
- package/dist/import/types.d.ts +153 -0
- package/dist/import/types.d.ts.map +1 -0
- package/dist/import/types.js +20 -0
- package/dist/import/types.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/known-support-10e.d.ts +1 -0
- package/dist/known-support-10e.d.ts.map +1 -0
- package/dist/known-support-10e.js +1 -0
- package/dist/known-support-10e.js.map +1 -0
- package/dist/link-abilities.d.ts +41 -0
- package/dist/link-abilities.d.ts.map +1 -0
- package/dist/link-abilities.js +159 -0
- package/dist/link-abilities.js.map +1 -0
- package/dist/migrations/2026-weapon-keywords.d.ts +2 -0
- package/dist/migrations/2026-weapon-keywords.d.ts.map +1 -0
- package/dist/migrations/2026-weapon-keywords.js +243 -0
- package/dist/migrations/2026-weapon-keywords.js.map +1 -0
- package/dist/port-10e-faction.d.ts +1 -0
- package/dist/port-10e-faction.d.ts.map +1 -0
- package/dist/port-10e-faction.js +1 -0
- package/dist/port-10e-faction.js.map +1 -0
- package/dist/report.d.ts +1 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +1 -0
- package/dist/report.js.map +1 -0
- package/dist/rube-goldberg.d.ts +3 -0
- package/dist/rube-goldberg.d.ts.map +1 -0
- package/dist/rube-goldberg.js +109 -0
- package/dist/rube-goldberg.js.map +1 -0
- package/dist/schema-loader.d.ts +1 -0
- package/dist/schema-loader.d.ts.map +1 -0
- package/dist/schema-loader.js +1 -0
- package/dist/schema-loader.js.map +1 -0
- package/dist/validate.d.ts +1 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +2 -0
- package/dist/validate.js.map +1 -0
- package/package.json +8 -2
- package/schemas/core/roster.schema.json +17 -4
- package/schemas/core/weapon-keyword.schema.json +31 -0
- package/schemas/core/weapon.schema.json +22 -1
- 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
|