@alpaca-software/40kdc-data 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- 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 +73 -0
- package/dist/abilities-resolver/resolver.d.ts.map +1 -0
- package/dist/abilities-resolver/resolver.js +142 -0
- package/dist/abilities-resolver/resolver.js.map +1 -0
- package/dist/audit-coverage.d.ts +78 -0
- package/dist/audit-coverage.d.ts.map +1 -0
- package/dist/audit-coverage.js +341 -0
- package/dist/audit-coverage.js.map +1 -0
- package/dist/author-batch.d.ts +147 -0
- package/dist/author-batch.d.ts.map +1 -0
- package/dist/author-batch.js +675 -0
- package/dist/author-batch.js.map +1 -0
- package/dist/author-input.d.ts +37 -0
- package/dist/author-input.d.ts.map +1 -0
- package/dist/author-input.js +162 -0
- package/dist/author-input.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 +1 -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 +9 -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 +1 -0
- package/dist/commands/import.d.ts.map +1 -0
- package/dist/commands/import.js +1 -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 +10 -4
- 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/attribution.d.ts +66 -0
- package/dist/cruncher/attribution.d.ts.map +1 -0
- package/dist/cruncher/attribution.js +88 -0
- package/dist/cruncher/attribution.js.map +1 -0
- package/dist/cruncher/buffs.d.ts +206 -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 +101 -0
- package/dist/cruncher/from-dsl.d.ts.map +1 -0
- package/dist/cruncher/from-dsl.js +968 -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 +12 -0
- package/dist/cruncher/index.d.ts.map +1 -0
- package/dist/cruncher/index.js +12 -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 +10 -0
- package/dist/data/collection.d.ts.map +1 -0
- package/dist/data/collection.js +15 -0
- package/dist/data/collection.js.map +1 -0
- package/dist/data/dataset.d.ts +132 -2
- package/dist/data/dataset.d.ts.map +1 -0
- package/dist/data/dataset.js +248 -1
- package/dist/data/dataset.js.map +1 -0
- package/dist/data/entities.d.ts +67 -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 +10 -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 +58 -0
- package/dist/data/roster-resolve.d.ts.map +1 -0
- package/dist/data/roster-resolve.js +82 -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 +22 -0
- package/dist/export/index.d.ts.map +1 -0
- package/dist/export/index.js +28 -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/rosterizer.d.ts +3 -0
- package/dist/export/rosterizer.d.ts.map +1 -0
- package/dist/export/rosterizer.js +144 -0
- package/dist/export/rosterizer.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 +1 -0
- package/dist/gen-conformance.d.ts.map +1 -0
- package/dist/gen-conformance.js +274 -12
- 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 +4 -3
- package/dist/import/adapter.d.ts.map +1 -0
- package/dist/import/adapter.js +1 -0
- package/dist/import/adapter.js.map +1 -0
- package/dist/import/decode.d.ts +1 -0
- package/dist/import/decode.d.ts.map +1 -0
- package/dist/import/decode.js +1 -0
- package/dist/import/decode.js.map +1 -0
- package/dist/import/gw.d.ts +69 -0
- package/dist/import/gw.d.ts.map +1 -0
- package/dist/import/gw.js +245 -0
- package/dist/import/gw.js.map +1 -0
- package/dist/import/import-roster.d.ts +84 -0
- package/dist/import/import-roster.d.ts.map +1 -0
- package/dist/import/import-roster.js +207 -0
- package/dist/import/import-roster.js.map +1 -0
- package/dist/import/index.d.ts +7 -3
- package/dist/import/index.d.ts.map +1 -0
- package/dist/import/index.js +5 -1
- package/dist/import/index.js.map +1 -0
- package/dist/import/listforge.d.ts +1 -0
- package/dist/import/listforge.d.ts.map +1 -0
- package/dist/import/listforge.js +22 -2
- 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 +51 -0
- package/dist/import/newrecruit-text.d.ts.map +1 -0
- package/dist/import/newrecruit-text.js +102 -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 +337 -0
- package/dist/import/newrecruit-wtc.js.map +1 -0
- package/dist/import/resolve.d.ts +3 -2
- package/dist/import/resolve.d.ts.map +1 -0
- package/dist/import/resolve.js +5 -2
- package/dist/import/resolve.js.map +1 -0
- package/dist/import/rosterizer.d.ts +70 -0
- package/dist/import/rosterizer.d.ts.map +1 -0
- package/dist/import/rosterizer.js +348 -0
- package/dist/import/rosterizer.js.map +1 -0
- package/dist/import/types.d.ts +11 -1
- package/dist/import/types.d.ts.map +1 -0
- package/dist/import/types.js +1 -0
- package/dist/import/types.js.map +1 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -1
- 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 +247 -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/runner.d.ts +38 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +492 -0
- package/dist/runner.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/scrub-ip.d.ts +14 -0
- package/dist/scrub-ip.d.ts.map +1 -0
- package/dist/scrub-ip.js +88 -0
- package/dist/scrub-ip.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 +15 -3
- package/schemas/core/roster.schema.json +19 -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
- package/dist/import/import-listforge.d.ts +0 -23
- package/dist/import/import-listforge.js +0 -32
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { crunch } from "./engine.js";
|
|
2
|
+
const DEFAULT_EPSILON = 1e-6;
|
|
3
|
+
/**
|
|
4
|
+
* Buffs the UI toggles on/off — the only kinds we attribute. Weapon-keyword
|
|
5
|
+
* buffs (intrinsics) are left in place for every recompute, so they never show
|
|
6
|
+
* up as a lift and never perturb the baseline.
|
|
7
|
+
*/
|
|
8
|
+
function isGroupable(source) {
|
|
9
|
+
return source.kind === "ability" || source.kind === "manual";
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Stable grouping key. Every `Buff` a single UI toggle flatMaps to shares one
|
|
13
|
+
* key, so a LOO pass removes the whole toggle, never a fragment of it.
|
|
14
|
+
*/
|
|
15
|
+
function groupKey(source) {
|
|
16
|
+
switch (source.kind) {
|
|
17
|
+
case "ability":
|
|
18
|
+
return `a:${source.abilityId}:${source.sourceUnitId ?? ""}`;
|
|
19
|
+
case "manual":
|
|
20
|
+
return `m:${source.label}`;
|
|
21
|
+
case "weapon-keyword":
|
|
22
|
+
return `w:${source.weaponId}:${source.keywordId}`;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Decompose each pipeline stage of `crunch(input)` into the marginal lift of
|
|
27
|
+
* every toggleable buff group, via leave-one-out recompute.
|
|
28
|
+
*
|
|
29
|
+
* Cost is `groups + 2` `crunch` calls (full + baseline + one per group); the
|
|
30
|
+
* engine is closed-form, so this is cheap to call per weapon line.
|
|
31
|
+
*
|
|
32
|
+
* @param input The same {@link EngineInput} you'd pass to {@link crunch}.
|
|
33
|
+
* @param dataset Optional dataset override (defaults to the embedded one).
|
|
34
|
+
* @param opts `epsilon` — lifts/residuals at or below this magnitude are
|
|
35
|
+
* treated as zero (default 1e-6).
|
|
36
|
+
*/
|
|
37
|
+
export function attributeStages(input, dataset, opts) {
|
|
38
|
+
const epsilon = opts?.epsilon ?? DEFAULT_EPSILON;
|
|
39
|
+
const full = crunch(input, dataset);
|
|
40
|
+
// First-seen order of groupable buff groups, with a representative source.
|
|
41
|
+
const order = [];
|
|
42
|
+
const repSource = new Map();
|
|
43
|
+
for (const b of input.buffs) {
|
|
44
|
+
if (!isGroupable(b.source))
|
|
45
|
+
continue;
|
|
46
|
+
const key = groupKey(b.source);
|
|
47
|
+
if (!repSource.has(key)) {
|
|
48
|
+
repSource.set(key, b.source);
|
|
49
|
+
order.push(key);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// Baseline keeps only non-groupable buffs (weapon-keyword passthroughs) plus
|
|
53
|
+
// the engine's auto-injected intrinsics.
|
|
54
|
+
const baseline = crunch({ ...input, buffs: input.buffs.filter((b) => !isGroupable(b.source)) }, dataset);
|
|
55
|
+
// Leave-one-out: drop one whole group, keep the rest.
|
|
56
|
+
const loo = new Map();
|
|
57
|
+
for (const key of order) {
|
|
58
|
+
const without = input.buffs.filter((b) => !isGroupable(b.source) || groupKey(b.source) !== key);
|
|
59
|
+
loo.set(key, crunch({ ...input, buffs: without }, dataset));
|
|
60
|
+
}
|
|
61
|
+
const intrinsics = full.resolved.extraKeywords.map((e) => e.keywordRef.keyword_id);
|
|
62
|
+
// crunch always emits the same seven stages in the same order, so index
|
|
63
|
+
// alignment across full / baseline / loo is sound.
|
|
64
|
+
return full.stages.map((s, i) => {
|
|
65
|
+
const expected = s.expected;
|
|
66
|
+
const baseExpected = baseline.stages[i].expected;
|
|
67
|
+
let totalLift = 0;
|
|
68
|
+
const lifts = [];
|
|
69
|
+
for (const key of order) {
|
|
70
|
+
const delta = expected - loo.get(key).stages[i].expected;
|
|
71
|
+
totalLift += delta;
|
|
72
|
+
if (Math.abs(delta) > epsilon) {
|
|
73
|
+
lifts.push({ source: repSource.get(key), delta });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const residual = expected - baseExpected - totalLift;
|
|
77
|
+
return {
|
|
78
|
+
name: s.name,
|
|
79
|
+
expected,
|
|
80
|
+
detail: s.detail,
|
|
81
|
+
baseline: baseExpected,
|
|
82
|
+
lifts,
|
|
83
|
+
residual: Math.abs(residual) > epsilon ? residual : 0,
|
|
84
|
+
intrinsics,
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=attribution.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"attribution.js","sourceRoot":"","sources":["../../src/cruncher/attribution.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Per-stage buff attribution by leave-one-out (LOO) recompute.\n *\n * The engine is closed-form, so the honest way to answer \"how much did this\n * buff lift this stage?\" is to re-run {@link crunch} with that buff removed and\n * diff the stage value. LOO is exactly correct through every non-linearity the\n * pipeline has — the ±1 hit/wound caps, the wound-threshold table, save clamps,\n * FNP, the models-killed cap — and it respects the resolver's non-additive\n * rules for free: a buff that grants a keyword the weapon already carries (e.g.\n * a second Sustained Hits 1) is deduped inside `resolveBuffs`, so its LOO delta\n * comes out ≈ 0 rather than double-counting.\n *\n * Only *toggleable* buffs are attributed — abilities (army / detachment /\n * stratagem / unit / attached / support) and manual UI toggles. The weapon's\n * intrinsic keywords are auto-injected inside `crunch`, are not levers, and are\n * never removed; they're reported by id in {@link AttributedStage.intrinsics}\n * so a UI can explain an elevated baseline without splitting them out.\n *\n * @packageDocumentation\n */\nimport type { Dataset } from \"../data/dataset.js\";\nimport type { BuffSource } from \"./buffs.js\";\nimport { crunch, type EngineInput, type EngineOutput, type Stage } from \"./engine.js\";\n\n/** One toggleable buff group's marginal effect on a single stage. */\nexport type StageLift = {\n /** Representative source of the group (all its `Buff`s share a group key). */\n source: BuffSource;\n /** `stageValue(all buffs) − stageValue(all buffs minus this group)`. */\n delta: number;\n};\n\n/** A pipeline stage with its value decomposed across the toggleable buffs. */\nexport type AttributedStage = {\n name: Stage[\"name\"];\n /** Stage value with every buff on — identical to {@link crunch}'s stage. */\n expected: number;\n /** The engine's stage detail string, unchanged. */\n detail: string;\n /** Stage value with all groupable buffs removed (intrinsics kept). */\n baseline: number;\n /** Per-group marginal effect; groups whose |delta| ≤ epsilon are dropped. */\n lifts: StageLift[];\n /**\n * `expected − baseline − Σ lifts`. Non-zero when buffs collide under a cap\n * (two +1s sharing one ±1 cap each show ≈0 lift; the real +1 lands here),\n * so a UI can surface it honestly as \"overlap (capped)\".\n */\n residual: number;\n /** Active weapon-keyword ids (intrinsic, auto-injected); display-only. */\n intrinsics: string[];\n};\n\nconst DEFAULT_EPSILON = 1e-6;\n\n/**\n * Buffs the UI toggles on/off — the only kinds we attribute. Weapon-keyword\n * buffs (intrinsics) are left in place for every recompute, so they never show\n * up as a lift and never perturb the baseline.\n */\nfunction isGroupable(source: BuffSource): boolean {\n return source.kind === \"ability\" || source.kind === \"manual\";\n}\n\n/**\n * Stable grouping key. Every `Buff` a single UI toggle flatMaps to shares one\n * key, so a LOO pass removes the whole toggle, never a fragment of it.\n */\nfunction groupKey(source: BuffSource): string {\n switch (source.kind) {\n case \"ability\":\n return `a:${source.abilityId}:${source.sourceUnitId ?? \"\"}`;\n case \"manual\":\n return `m:${source.label}`;\n case \"weapon-keyword\":\n return `w:${source.weaponId}:${source.keywordId}`;\n }\n}\n\n/**\n * Decompose each pipeline stage of `crunch(input)` into the marginal lift of\n * every toggleable buff group, via leave-one-out recompute.\n *\n * Cost is `groups + 2` `crunch` calls (full + baseline + one per group); the\n * engine is closed-form, so this is cheap to call per weapon line.\n *\n * @param input The same {@link EngineInput} you'd pass to {@link crunch}.\n * @param dataset Optional dataset override (defaults to the embedded one).\n * @param opts `epsilon` — lifts/residuals at or below this magnitude are\n * treated as zero (default 1e-6).\n */\nexport function attributeStages(\n input: EngineInput,\n dataset?: Dataset,\n opts?: { epsilon?: number },\n): AttributedStage[] {\n const epsilon = opts?.epsilon ?? DEFAULT_EPSILON;\n const full = crunch(input, dataset);\n\n // First-seen order of groupable buff groups, with a representative source.\n const order: string[] = [];\n const repSource = new Map<string, BuffSource>();\n for (const b of input.buffs) {\n if (!isGroupable(b.source)) continue;\n const key = groupKey(b.source);\n if (!repSource.has(key)) {\n repSource.set(key, b.source);\n order.push(key);\n }\n }\n\n // Baseline keeps only non-groupable buffs (weapon-keyword passthroughs) plus\n // the engine's auto-injected intrinsics.\n const baseline = crunch(\n { ...input, buffs: input.buffs.filter((b) => !isGroupable(b.source)) },\n dataset,\n );\n\n // Leave-one-out: drop one whole group, keep the rest.\n const loo = new Map<string, EngineOutput>();\n for (const key of order) {\n const without = input.buffs.filter(\n (b) => !isGroupable(b.source) || groupKey(b.source) !== key,\n );\n loo.set(key, crunch({ ...input, buffs: without }, dataset));\n }\n\n const intrinsics = full.resolved.extraKeywords.map((e) => e.keywordRef.keyword_id);\n\n // crunch always emits the same seven stages in the same order, so index\n // alignment across full / baseline / loo is sound.\n return full.stages.map((s, i) => {\n const expected = s.expected;\n const baseExpected = baseline.stages[i].expected;\n let totalLift = 0;\n const lifts: StageLift[] = [];\n for (const key of order) {\n const delta = expected - loo.get(key)!.stages[i].expected;\n totalLift += delta;\n if (Math.abs(delta) > epsilon) {\n lifts.push({ source: repSource.get(key)!, delta });\n }\n }\n const residual = expected - baseExpected - totalLift;\n return {\n name: s.name,\n expected,\n detail: s.detail,\n baseline: baseExpected,\n lifts,\n residual: Math.abs(residual) > epsilon ? residual : 0,\n intrinsics,\n };\n });\n}\n"]}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The flat `Buff` type every contribution flows through, and the
|
|
3
|
+
* {@link resolveBuffs} resolver that collapses a stack into a
|
|
4
|
+
* {@link ResolvedModifiers} read-out the engine can consume.
|
|
5
|
+
*
|
|
6
|
+
* The same shape carries weapon-keyword effects, ability buffs, stratagem
|
|
7
|
+
* effects, and manual UI toggles — reroll-stacking, hit/wound caps, and
|
|
8
|
+
* feel-no-pain-best-threshold all fall out of one resolver rather than each
|
|
9
|
+
* source kind reinventing precedence.
|
|
10
|
+
*
|
|
11
|
+
* @packageDocumentation
|
|
12
|
+
*/
|
|
13
|
+
import type { Phase } from "../generated.js";
|
|
14
|
+
/** Where a buff originated. Drives stable tie-breaking inside `resolveBuffs`. */
|
|
15
|
+
export type BuffSource = {
|
|
16
|
+
kind: "weapon-keyword";
|
|
17
|
+
weaponId: string;
|
|
18
|
+
keywordId: string;
|
|
19
|
+
} | {
|
|
20
|
+
kind: "ability";
|
|
21
|
+
abilityId: string;
|
|
22
|
+
abilityKind: "army" | "detachment" | "detachment-stratagem" | "unit" | "attached" | "support";
|
|
23
|
+
/**
|
|
24
|
+
* For `abilityKind: "attached"`, the combined-unit member the ability
|
|
25
|
+
* came from (so the UI can name it and show its leader/bodyguard role).
|
|
26
|
+
* Absent for other kinds.
|
|
27
|
+
*/
|
|
28
|
+
sourceUnitId?: string;
|
|
29
|
+
} | {
|
|
30
|
+
kind: "manual";
|
|
31
|
+
label: string;
|
|
32
|
+
};
|
|
33
|
+
/** A weapon-keyword reference (id + parameter map), as found on weapon profiles. */
|
|
34
|
+
export type WeaponKeywordRef = {
|
|
35
|
+
keyword_id: string;
|
|
36
|
+
parameters?: Record<string, unknown>;
|
|
37
|
+
};
|
|
38
|
+
/** One typed contribution; the engine reads `ResolvedModifiers` for the rest. */
|
|
39
|
+
export type BuffContribution = {
|
|
40
|
+
type: "hit-mod";
|
|
41
|
+
value: number;
|
|
42
|
+
} | {
|
|
43
|
+
type: "wound-mod";
|
|
44
|
+
value: number;
|
|
45
|
+
} | {
|
|
46
|
+
type: "save-mod";
|
|
47
|
+
value: number;
|
|
48
|
+
} | {
|
|
49
|
+
type: "cover";
|
|
50
|
+
} | {
|
|
51
|
+
type: "reroll";
|
|
52
|
+
roll: "hit" | "wound" | "save" | "damage";
|
|
53
|
+
subset: "ones" | "all-failures";
|
|
54
|
+
} | {
|
|
55
|
+
type: "extra-keyword";
|
|
56
|
+
keywordRef: WeaponKeywordRef;
|
|
57
|
+
} | {
|
|
58
|
+
type: "feel-no-pain";
|
|
59
|
+
threshold: number;
|
|
60
|
+
} | {
|
|
61
|
+
type: "damage-mod";
|
|
62
|
+
value: number;
|
|
63
|
+
}
|
|
64
|
+
/** Additive modifier to the attacker's per-model attack count (A stat). */
|
|
65
|
+
| {
|
|
66
|
+
type: "attacks-mod";
|
|
67
|
+
value: number;
|
|
68
|
+
}
|
|
69
|
+
/** Additive modifier to the attacker's Strength stat. */
|
|
70
|
+
| {
|
|
71
|
+
type: "strength-mod";
|
|
72
|
+
value: number;
|
|
73
|
+
}
|
|
74
|
+
/** Additive modifier to the defender's Toughness stat. */
|
|
75
|
+
| {
|
|
76
|
+
type: "toughness-mod";
|
|
77
|
+
value: number;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Additive modifier to the attacker's weapon AP. AP is signed against the
|
|
81
|
+
* defender's save (negative = more piercing), so a value of `-1` here makes
|
|
82
|
+
* the weapon one AP more piercing.
|
|
83
|
+
*/
|
|
84
|
+
| {
|
|
85
|
+
type: "ap-mod";
|
|
86
|
+
value: number;
|
|
87
|
+
};
|
|
88
|
+
/** Optional gating; the resolver drops buffs whose gate fails. */
|
|
89
|
+
export type BuffApplicability = {
|
|
90
|
+
phases?: Phase[];
|
|
91
|
+
rollType?: "hit" | "wound" | "save" | "damage";
|
|
92
|
+
/** Target must carry this keyword (case-insensitive). */
|
|
93
|
+
requiresTargetKeyword?: string;
|
|
94
|
+
/** Attacker must carry this keyword (case-insensitive). */
|
|
95
|
+
requiresAttackerKeyword?: string;
|
|
96
|
+
};
|
|
97
|
+
/** A single buff: where it came from, when it applies, what it contributes. */
|
|
98
|
+
export type Buff = {
|
|
99
|
+
source: BuffSource;
|
|
100
|
+
applicableWhen?: BuffApplicability;
|
|
101
|
+
contribution: BuffContribution;
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* Shared engine context. Carries the phase plus a few attacker/target flags
|
|
105
|
+
* the keyword translator and the resolver both need. The engine fills it from
|
|
106
|
+
* its `EngineInput.context` plus the unit-keyword unions; the resolver reads
|
|
107
|
+
* only the subset relevant to its `applicableWhen` checks.
|
|
108
|
+
*/
|
|
109
|
+
export type EngineContext = {
|
|
110
|
+
phase: Phase;
|
|
111
|
+
/** Attacker has not moved this turn — Heavy fires its +1 to hit. */
|
|
112
|
+
attackerStationary?: boolean;
|
|
113
|
+
/**
|
|
114
|
+
* Attacker made a charge move this turn — drives the `charged-this-turn`
|
|
115
|
+
* condition (e.g. World Eaters' Relentless Rage). Left undefined when the
|
|
116
|
+
* caller can't determine it — the condition then evaluates as `"unknown"` and
|
|
117
|
+
* the SPA surfaces a diagnostic (mirrors `attackerStationary` / `timing`).
|
|
118
|
+
*/
|
|
119
|
+
attackerCharged?: boolean;
|
|
120
|
+
/** Within half the weapon's range — Melta / Rapid Fire fire. */
|
|
121
|
+
withinHalfRange?: boolean;
|
|
122
|
+
/** Attacker benefits from cover (mostly informational; cover applies to defenders). */
|
|
123
|
+
attackerInCover?: boolean;
|
|
124
|
+
/** Target is in cover — the resolver flips on `cover`, the engine applies +1 to save. */
|
|
125
|
+
targetInCover?: boolean;
|
|
126
|
+
/** Attacker keywords (union of unit.keywords + faction_keywords), lower-cased. */
|
|
127
|
+
attackerKeywords?: ReadonlyArray<string>;
|
|
128
|
+
/** Target keywords (union of unit.keywords + faction_keywords), lower-cased. */
|
|
129
|
+
targetKeywords?: ReadonlyArray<string>;
|
|
130
|
+
/**
|
|
131
|
+
* Sub-phase timing flag (e.g. `"start-of-phase"`, `"end-of-phase"`,
|
|
132
|
+
* `"on-destroyed"`). Consumed by the `timing-is` condition. Left undefined
|
|
133
|
+
* when the caller can't pin a sub-phase down — the condition then evaluates
|
|
134
|
+
* as `"unknown"` and the SPA surfaces a diagnostic.
|
|
135
|
+
*/
|
|
136
|
+
timing?: string;
|
|
137
|
+
/**
|
|
138
|
+
* The buffed unit is part of a combined ("attached") unit — a leader is
|
|
139
|
+
* attached to a bodyguard, or vice-versa. Drives the `is-attached` and
|
|
140
|
+
* `model-is-leader` conditions. Derived from a non-empty
|
|
141
|
+
* `EligibilityInput.attachedUnitIds`. Left undefined when the caller can't
|
|
142
|
+
* determine attachment — the conditions then evaluate as `"unknown"` and the
|
|
143
|
+
* SPA surfaces a diagnostic (mirrors how `timing` undefined behaves).
|
|
144
|
+
*/
|
|
145
|
+
attackerAttached?: boolean;
|
|
146
|
+
};
|
|
147
|
+
/** Back-compat alias — `resolveBuffs` accepts the shared engine context. */
|
|
148
|
+
export type ResolveContext = EngineContext;
|
|
149
|
+
/** Read-out of a resolved buff stack, with provenance per field. */
|
|
150
|
+
export type ResolvedModifiers = {
|
|
151
|
+
hitMod: {
|
|
152
|
+
value: number;
|
|
153
|
+
dominantSource: BuffSource | null;
|
|
154
|
+
};
|
|
155
|
+
woundMod: {
|
|
156
|
+
value: number;
|
|
157
|
+
dominantSource: BuffSource | null;
|
|
158
|
+
};
|
|
159
|
+
saveMod: {
|
|
160
|
+
value: number;
|
|
161
|
+
sources: BuffSource[];
|
|
162
|
+
};
|
|
163
|
+
cover: {
|
|
164
|
+
active: boolean;
|
|
165
|
+
source: BuffSource | null;
|
|
166
|
+
};
|
|
167
|
+
rerolls: Partial<Record<"hit" | "wound" | "save" | "damage", {
|
|
168
|
+
subset: "ones" | "all-failures";
|
|
169
|
+
dominantSource: BuffSource;
|
|
170
|
+
}>>;
|
|
171
|
+
extraKeywords: {
|
|
172
|
+
keywordRef: WeaponKeywordRef;
|
|
173
|
+
source: BuffSource;
|
|
174
|
+
}[];
|
|
175
|
+
feelNoPain: {
|
|
176
|
+
threshold: number;
|
|
177
|
+
dominantSource: BuffSource;
|
|
178
|
+
} | null;
|
|
179
|
+
damageMod: {
|
|
180
|
+
value: number;
|
|
181
|
+
sources: BuffSource[];
|
|
182
|
+
};
|
|
183
|
+
attacksMod: {
|
|
184
|
+
value: number;
|
|
185
|
+
sources: BuffSource[];
|
|
186
|
+
};
|
|
187
|
+
strengthMod: {
|
|
188
|
+
value: number;
|
|
189
|
+
sources: BuffSource[];
|
|
190
|
+
};
|
|
191
|
+
toughnessMod: {
|
|
192
|
+
value: number;
|
|
193
|
+
sources: BuffSource[];
|
|
194
|
+
};
|
|
195
|
+
apMod: {
|
|
196
|
+
value: number;
|
|
197
|
+
sources: BuffSource[];
|
|
198
|
+
};
|
|
199
|
+
};
|
|
200
|
+
/**
|
|
201
|
+
* Collapse a flat buff stack into a {@link ResolvedModifiers} read-out. Pure
|
|
202
|
+
* function; the engine — and any UI that wants to render the resolved table
|
|
203
|
+
* before crunching — both go through this.
|
|
204
|
+
*/
|
|
205
|
+
export declare function resolveBuffs(buffs: Buff[], ctx: ResolveContext): ResolvedModifiers;
|
|
206
|
+
//# sourceMappingURL=buffs.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"buffs.d.ts","sourceRoot":"","sources":["../../src/cruncher/buffs.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/** Stable ordering used to break ties when multiple buffs claim the same field. */
|
|
2
|
+
const SOURCE_KIND_RANK = {
|
|
3
|
+
"ability:army": 0,
|
|
4
|
+
"ability:detachment": 1,
|
|
5
|
+
"ability:detachment-stratagem": 2,
|
|
6
|
+
"ability:unit": 3,
|
|
7
|
+
"ability:attached": 4,
|
|
8
|
+
"ability:support": 5,
|
|
9
|
+
manual: 6,
|
|
10
|
+
"weapon-keyword": 7,
|
|
11
|
+
};
|
|
12
|
+
function rank(s) {
|
|
13
|
+
if (s.kind === "ability")
|
|
14
|
+
return SOURCE_KIND_RANK[`ability:${s.abilityKind}`] ?? 99;
|
|
15
|
+
return SOURCE_KIND_RANK[s.kind] ?? 99;
|
|
16
|
+
}
|
|
17
|
+
function applies(buff, ctx) {
|
|
18
|
+
const w = buff.applicableWhen;
|
|
19
|
+
if (!w)
|
|
20
|
+
return true;
|
|
21
|
+
if (w.phases && w.phases.length > 0 && !w.phases.includes(ctx.phase))
|
|
22
|
+
return false;
|
|
23
|
+
if (w.rollType && buff.contribution.type === "reroll" && buff.contribution.roll !== w.rollType) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
if (w.requiresTargetKeyword) {
|
|
27
|
+
const target = ctx.targetKeywords ?? [];
|
|
28
|
+
if (!target.includes(w.requiresTargetKeyword.toLowerCase()))
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
if (w.requiresAttackerKeyword) {
|
|
32
|
+
const attacker = ctx.attackerKeywords ?? [];
|
|
33
|
+
if (!attacker.includes(w.requiresAttackerKeyword.toLowerCase()))
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Collapse a flat buff stack into a {@link ResolvedModifiers} read-out. Pure
|
|
40
|
+
* function; the engine — and any UI that wants to render the resolved table
|
|
41
|
+
* before crunching — both go through this.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveBuffs(buffs, ctx) {
|
|
44
|
+
const live = buffs.filter((b) => applies(b, ctx));
|
|
45
|
+
const out = {
|
|
46
|
+
hitMod: { value: 0, dominantSource: null },
|
|
47
|
+
woundMod: { value: 0, dominantSource: null },
|
|
48
|
+
saveMod: { value: 0, sources: [] },
|
|
49
|
+
cover: { active: false, source: null },
|
|
50
|
+
rerolls: {},
|
|
51
|
+
extraKeywords: [],
|
|
52
|
+
feelNoPain: null,
|
|
53
|
+
damageMod: { value: 0, sources: [] },
|
|
54
|
+
attacksMod: { value: 0, sources: [] },
|
|
55
|
+
strengthMod: { value: 0, sources: [] },
|
|
56
|
+
toughnessMod: { value: 0, sources: [] },
|
|
57
|
+
apMod: { value: 0, sources: [] },
|
|
58
|
+
};
|
|
59
|
+
// Hit / wound mods: sum, then cap at ±1, with dominant source picked from
|
|
60
|
+
// the contributors whose sign matches the surviving value.
|
|
61
|
+
const hitContribs = [];
|
|
62
|
+
const woundContribs = [];
|
|
63
|
+
for (const b of live) {
|
|
64
|
+
const c = b.contribution;
|
|
65
|
+
switch (c.type) {
|
|
66
|
+
case "hit-mod":
|
|
67
|
+
hitContribs.push({ value: c.value, source: b.source });
|
|
68
|
+
break;
|
|
69
|
+
case "wound-mod":
|
|
70
|
+
woundContribs.push({ value: c.value, source: b.source });
|
|
71
|
+
break;
|
|
72
|
+
case "save-mod":
|
|
73
|
+
out.saveMod.value += c.value;
|
|
74
|
+
out.saveMod.sources.push(b.source);
|
|
75
|
+
break;
|
|
76
|
+
case "cover":
|
|
77
|
+
if (!out.cover.active || rank(b.source) < rank(out.cover.source)) {
|
|
78
|
+
out.cover = { active: true, source: b.source };
|
|
79
|
+
}
|
|
80
|
+
break;
|
|
81
|
+
case "reroll": {
|
|
82
|
+
const cur = out.rerolls[c.roll];
|
|
83
|
+
const incoming = c.subset;
|
|
84
|
+
if (!cur) {
|
|
85
|
+
out.rerolls[c.roll] = { subset: incoming, dominantSource: b.source };
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
const incomingStronger = (incoming === "all-failures" && cur.subset === "ones") ||
|
|
89
|
+
(incoming === cur.subset && rank(b.source) < rank(cur.dominantSource));
|
|
90
|
+
if (incomingStronger) {
|
|
91
|
+
out.rerolls[c.roll] = { subset: incoming, dominantSource: b.source };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
case "extra-keyword": {
|
|
97
|
+
const key = `${c.keywordRef.keyword_id}::${JSON.stringify(c.keywordRef.parameters ?? {})}`;
|
|
98
|
+
if (!out.extraKeywords.some((e) => keyOf(e.keywordRef) === key)) {
|
|
99
|
+
out.extraKeywords.push({ keywordRef: c.keywordRef, source: b.source });
|
|
100
|
+
}
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
case "feel-no-pain":
|
|
104
|
+
if (out.feelNoPain === null || c.threshold < out.feelNoPain.threshold) {
|
|
105
|
+
out.feelNoPain = { threshold: c.threshold, dominantSource: b.source };
|
|
106
|
+
}
|
|
107
|
+
break;
|
|
108
|
+
case "damage-mod":
|
|
109
|
+
out.damageMod.value += c.value;
|
|
110
|
+
out.damageMod.sources.push(b.source);
|
|
111
|
+
break;
|
|
112
|
+
case "attacks-mod":
|
|
113
|
+
out.attacksMod.value += c.value;
|
|
114
|
+
out.attacksMod.sources.push(b.source);
|
|
115
|
+
break;
|
|
116
|
+
case "strength-mod":
|
|
117
|
+
out.strengthMod.value += c.value;
|
|
118
|
+
out.strengthMod.sources.push(b.source);
|
|
119
|
+
break;
|
|
120
|
+
case "toughness-mod":
|
|
121
|
+
out.toughnessMod.value += c.value;
|
|
122
|
+
out.toughnessMod.sources.push(b.source);
|
|
123
|
+
break;
|
|
124
|
+
case "ap-mod":
|
|
125
|
+
out.apMod.value += c.value;
|
|
126
|
+
out.apMod.sources.push(b.source);
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
out.hitMod = capModifier(hitContribs);
|
|
131
|
+
out.woundMod = capModifier(woundContribs);
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
function keyOf(ref) {
|
|
135
|
+
return `${ref.keyword_id}::${JSON.stringify(ref.parameters ?? {})}`;
|
|
136
|
+
}
|
|
137
|
+
/** Sum, clamp to ±1, then pick the dominant contributing source by rank. */
|
|
138
|
+
function capModifier(contribs) {
|
|
139
|
+
if (contribs.length === 0)
|
|
140
|
+
return { value: 0, dominantSource: null };
|
|
141
|
+
const sum = contribs.reduce((a, c) => a + c.value, 0);
|
|
142
|
+
const capped = Math.max(-1, Math.min(1, sum));
|
|
143
|
+
if (capped === 0)
|
|
144
|
+
return { value: 0, dominantSource: null };
|
|
145
|
+
const sign = Math.sign(capped);
|
|
146
|
+
const matching = contribs.filter((c) => Math.sign(c.value) === sign);
|
|
147
|
+
matching.sort((a, b) => rank(a.source) - rank(b.source));
|
|
148
|
+
return { value: capped, dominantSource: matching[0]?.source ?? null };
|
|
149
|
+
}
|
|
150
|
+
//# sourceMappingURL=buffs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"buffs.js","sourceRoot":"","sources":["../../src/cruncher/buffs.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The flat `Buff` type every contribution flows through, and the\n * {@link resolveBuffs} resolver that collapses a stack into a\n * {@link ResolvedModifiers} read-out the engine can consume.\n *\n * The same shape carries weapon-keyword effects, ability buffs, stratagem\n * effects, and manual UI toggles — reroll-stacking, hit/wound caps, and\n * feel-no-pain-best-threshold all fall out of one resolver rather than each\n * source kind reinventing precedence.\n *\n * @packageDocumentation\n */\nimport type { Phase } from \"../generated.js\";\n\n/** Where a buff originated. Drives stable tie-breaking inside `resolveBuffs`. */\nexport type BuffSource =\n | { kind: \"weapon-keyword\"; weaponId: string; keywordId: string }\n | {\n kind: \"ability\";\n abilityId: string;\n abilityKind:\n | \"army\"\n | \"detachment\"\n | \"detachment-stratagem\"\n | \"unit\"\n | \"attached\"\n | \"support\";\n /**\n * For `abilityKind: \"attached\"`, the combined-unit member the ability\n * came from (so the UI can name it and show its leader/bodyguard role).\n * Absent for other kinds.\n */\n sourceUnitId?: string;\n }\n | { kind: \"manual\"; label: string };\n\n/** A weapon-keyword reference (id + parameter map), as found on weapon profiles. */\nexport type WeaponKeywordRef = {\n keyword_id: string;\n parameters?: Record<string, unknown>;\n};\n\n/** One typed contribution; the engine reads `ResolvedModifiers` for the rest. */\nexport type BuffContribution =\n | { type: \"hit-mod\"; value: number }\n | { type: \"wound-mod\"; value: number }\n | { type: \"save-mod\"; value: number }\n | { type: \"cover\" }\n | {\n type: \"reroll\";\n roll: \"hit\" | \"wound\" | \"save\" | \"damage\";\n subset: \"ones\" | \"all-failures\";\n }\n | { type: \"extra-keyword\"; keywordRef: WeaponKeywordRef }\n | { type: \"feel-no-pain\"; threshold: number }\n | { type: \"damage-mod\"; value: number }\n /** Additive modifier to the attacker's per-model attack count (A stat). */\n | { type: \"attacks-mod\"; value: number }\n /** Additive modifier to the attacker's Strength stat. */\n | { type: \"strength-mod\"; value: number }\n /** Additive modifier to the defender's Toughness stat. */\n | { type: \"toughness-mod\"; value: number }\n /**\n * Additive modifier to the attacker's weapon AP. AP is signed against the\n * defender's save (negative = more piercing), so a value of `-1` here makes\n * the weapon one AP more piercing.\n */\n | { type: \"ap-mod\"; value: number };\n\n/** Optional gating; the resolver drops buffs whose gate fails. */\nexport type BuffApplicability = {\n phases?: Phase[];\n rollType?: \"hit\" | \"wound\" | \"save\" | \"damage\";\n /** Target must carry this keyword (case-insensitive). */\n requiresTargetKeyword?: string;\n /** Attacker must carry this keyword (case-insensitive). */\n requiresAttackerKeyword?: string;\n};\n\n/** A single buff: where it came from, when it applies, what it contributes. */\nexport type Buff = {\n source: BuffSource;\n applicableWhen?: BuffApplicability;\n contribution: BuffContribution;\n};\n\n/**\n * Shared engine context. Carries the phase plus a few attacker/target flags\n * the keyword translator and the resolver both need. The engine fills it from\n * its `EngineInput.context` plus the unit-keyword unions; the resolver reads\n * only the subset relevant to its `applicableWhen` checks.\n */\nexport type EngineContext = {\n phase: Phase;\n /** Attacker has not moved this turn — Heavy fires its +1 to hit. */\n attackerStationary?: boolean;\n /**\n * Attacker made a charge move this turn — drives the `charged-this-turn`\n * condition (e.g. World Eaters' Relentless Rage). Left undefined when the\n * caller can't determine it — the condition then evaluates as `\"unknown\"` and\n * the SPA surfaces a diagnostic (mirrors `attackerStationary` / `timing`).\n */\n attackerCharged?: boolean;\n /** Within half the weapon's range — Melta / Rapid Fire fire. */\n withinHalfRange?: boolean;\n /** Attacker benefits from cover (mostly informational; cover applies to defenders). */\n attackerInCover?: boolean;\n /** Target is in cover — the resolver flips on `cover`, the engine applies +1 to save. */\n targetInCover?: boolean;\n /** Attacker keywords (union of unit.keywords + faction_keywords), lower-cased. */\n attackerKeywords?: ReadonlyArray<string>;\n /** Target keywords (union of unit.keywords + faction_keywords), lower-cased. */\n targetKeywords?: ReadonlyArray<string>;\n /**\n * Sub-phase timing flag (e.g. `\"start-of-phase\"`, `\"end-of-phase\"`,\n * `\"on-destroyed\"`). Consumed by the `timing-is` condition. Left undefined\n * when the caller can't pin a sub-phase down — the condition then evaluates\n * as `\"unknown\"` and the SPA surfaces a diagnostic.\n */\n timing?: string;\n /**\n * The buffed unit is part of a combined (\"attached\") unit — a leader is\n * attached to a bodyguard, or vice-versa. Drives the `is-attached` and\n * `model-is-leader` conditions. Derived from a non-empty\n * `EligibilityInput.attachedUnitIds`. Left undefined when the caller can't\n * determine attachment — the conditions then evaluate as `\"unknown\"` and the\n * SPA surfaces a diagnostic (mirrors how `timing` undefined behaves).\n */\n attackerAttached?: boolean;\n};\n\n/** Back-compat alias — `resolveBuffs` accepts the shared engine context. */\nexport type ResolveContext = EngineContext;\n\n/** Read-out of a resolved buff stack, with provenance per field. */\nexport type ResolvedModifiers = {\n hitMod: { value: number; dominantSource: BuffSource | null };\n woundMod: { value: number; dominantSource: BuffSource | null };\n saveMod: { value: number; sources: BuffSource[] };\n cover: { active: boolean; source: BuffSource | null };\n rerolls: Partial<\n Record<\n \"hit\" | \"wound\" | \"save\" | \"damage\",\n { subset: \"ones\" | \"all-failures\"; dominantSource: BuffSource }\n >\n >;\n extraKeywords: { keywordRef: WeaponKeywordRef; source: BuffSource }[];\n feelNoPain: { threshold: number; dominantSource: BuffSource } | null;\n damageMod: { value: number; sources: BuffSource[] };\n attacksMod: { value: number; sources: BuffSource[] };\n strengthMod: { value: number; sources: BuffSource[] };\n toughnessMod: { value: number; sources: BuffSource[] };\n apMod: { value: number; sources: BuffSource[] };\n};\n\n/** Stable ordering used to break ties when multiple buffs claim the same field. */\nconst SOURCE_KIND_RANK: Record<string, number> = {\n \"ability:army\": 0,\n \"ability:detachment\": 1,\n \"ability:detachment-stratagem\": 2,\n \"ability:unit\": 3,\n \"ability:attached\": 4,\n \"ability:support\": 5,\n manual: 6,\n \"weapon-keyword\": 7,\n};\n\nfunction rank(s: BuffSource): number {\n if (s.kind === \"ability\") return SOURCE_KIND_RANK[`ability:${s.abilityKind}`] ?? 99;\n return SOURCE_KIND_RANK[s.kind] ?? 99;\n}\n\nfunction applies(buff: Buff, ctx: ResolveContext): boolean {\n const w = buff.applicableWhen;\n if (!w) return true;\n if (w.phases && w.phases.length > 0 && !w.phases.includes(ctx.phase)) return false;\n if (w.rollType && buff.contribution.type === \"reroll\" && buff.contribution.roll !== w.rollType) {\n return false;\n }\n if (w.requiresTargetKeyword) {\n const target = ctx.targetKeywords ?? [];\n if (!target.includes(w.requiresTargetKeyword.toLowerCase())) return false;\n }\n if (w.requiresAttackerKeyword) {\n const attacker = ctx.attackerKeywords ?? [];\n if (!attacker.includes(w.requiresAttackerKeyword.toLowerCase())) return false;\n }\n return true;\n}\n\n/**\n * Collapse a flat buff stack into a {@link ResolvedModifiers} read-out. Pure\n * function; the engine — and any UI that wants to render the resolved table\n * before crunching — both go through this.\n */\nexport function resolveBuffs(buffs: Buff[], ctx: ResolveContext): ResolvedModifiers {\n const live = buffs.filter((b) => applies(b, ctx));\n\n const out: ResolvedModifiers = {\n hitMod: { value: 0, dominantSource: null },\n woundMod: { value: 0, dominantSource: null },\n saveMod: { value: 0, sources: [] },\n cover: { active: false, source: null },\n rerolls: {},\n extraKeywords: [],\n feelNoPain: null,\n damageMod: { value: 0, sources: [] },\n attacksMod: { value: 0, sources: [] },\n strengthMod: { value: 0, sources: [] },\n toughnessMod: { value: 0, sources: [] },\n apMod: { value: 0, sources: [] },\n };\n\n // Hit / wound mods: sum, then cap at ±1, with dominant source picked from\n // the contributors whose sign matches the surviving value.\n const hitContribs: { value: number; source: BuffSource }[] = [];\n const woundContribs: { value: number; source: BuffSource }[] = [];\n\n for (const b of live) {\n const c = b.contribution;\n switch (c.type) {\n case \"hit-mod\":\n hitContribs.push({ value: c.value, source: b.source });\n break;\n case \"wound-mod\":\n woundContribs.push({ value: c.value, source: b.source });\n break;\n case \"save-mod\":\n out.saveMod.value += c.value;\n out.saveMod.sources.push(b.source);\n break;\n case \"cover\":\n if (!out.cover.active || rank(b.source) < rank(out.cover.source!)) {\n out.cover = { active: true, source: b.source };\n }\n break;\n case \"reroll\": {\n const cur = out.rerolls[c.roll];\n const incoming = c.subset;\n if (!cur) {\n out.rerolls[c.roll] = { subset: incoming, dominantSource: b.source };\n } else {\n const incomingStronger =\n (incoming === \"all-failures\" && cur.subset === \"ones\") ||\n (incoming === cur.subset && rank(b.source) < rank(cur.dominantSource));\n if (incomingStronger) {\n out.rerolls[c.roll] = { subset: incoming, dominantSource: b.source };\n }\n }\n break;\n }\n case \"extra-keyword\": {\n const key = `${c.keywordRef.keyword_id}::${JSON.stringify(c.keywordRef.parameters ?? {})}`;\n if (!out.extraKeywords.some((e) => keyOf(e.keywordRef) === key)) {\n out.extraKeywords.push({ keywordRef: c.keywordRef, source: b.source });\n }\n break;\n }\n case \"feel-no-pain\":\n if (out.feelNoPain === null || c.threshold < out.feelNoPain.threshold) {\n out.feelNoPain = { threshold: c.threshold, dominantSource: b.source };\n }\n break;\n case \"damage-mod\":\n out.damageMod.value += c.value;\n out.damageMod.sources.push(b.source);\n break;\n case \"attacks-mod\":\n out.attacksMod.value += c.value;\n out.attacksMod.sources.push(b.source);\n break;\n case \"strength-mod\":\n out.strengthMod.value += c.value;\n out.strengthMod.sources.push(b.source);\n break;\n case \"toughness-mod\":\n out.toughnessMod.value += c.value;\n out.toughnessMod.sources.push(b.source);\n break;\n case \"ap-mod\":\n out.apMod.value += c.value;\n out.apMod.sources.push(b.source);\n break;\n }\n }\n\n out.hitMod = capModifier(hitContribs);\n out.woundMod = capModifier(woundContribs);\n\n return out;\n}\n\nfunction keyOf(ref: WeaponKeywordRef): string {\n return `${ref.keyword_id}::${JSON.stringify(ref.parameters ?? {})}`;\n}\n\n/** Sum, clamp to ±1, then pick the dominant contributing source by rank. */\nfunction capModifier(\n contribs: { value: number; source: BuffSource }[],\n): { value: number; dominantSource: BuffSource | null } {\n if (contribs.length === 0) return { value: 0, dominantSource: null };\n const sum = contribs.reduce((a, c) => a + c.value, 0);\n const capped = Math.max(-1, Math.min(1, sum));\n if (capped === 0) return { value: 0, dominantSource: null };\n const sign = Math.sign(capped);\n const matching = contribs.filter((c) => Math.sign(c.value) === sign);\n matching.sort((a, b) => rank(a.source) - rank(b.source));\n return { value: capped, dominantSource: matching[0]?.source ?? null };\n}\n"]}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The expected-value damage engine.
|
|
3
|
+
*
|
|
4
|
+
* Closed-form math over schema profiles + a flat {@link Buff} stack. No
|
|
5
|
+
* sampling, no I/O. Auto-injects every weapon-keyword on the attacker's
|
|
6
|
+
* profile as a buff (so callers don't have to enumerate intrinsics), then
|
|
7
|
+
* resolves the stack via {@link resolveBuffs}, then walks
|
|
8
|
+
* attacks → hits → wounds → unsaved → damage → after-fnp → models-killed.
|
|
9
|
+
*
|
|
10
|
+
* The dataset is required (and defaults to the embedded one) — without it
|
|
11
|
+
* the engine can't look up weapon-keyword effects.
|
|
12
|
+
*/
|
|
13
|
+
import type { Phase, Unit, Weapon } from "../generated.js";
|
|
14
|
+
import { type Buff, type EngineContext, type ResolvedModifiers } from "./buffs.js";
|
|
15
|
+
import { Dataset } from "../data/dataset.js";
|
|
16
|
+
export type AttackProfileRef = {
|
|
17
|
+
weapon: Weapon;
|
|
18
|
+
profileIndex: number;
|
|
19
|
+
};
|
|
20
|
+
export type TargetProfileRef = {
|
|
21
|
+
unit: Unit;
|
|
22
|
+
profileIndex: number;
|
|
23
|
+
/** Override target model count (otherwise read from `unit.model_count.min`). */
|
|
24
|
+
modelCount?: number;
|
|
25
|
+
};
|
|
26
|
+
export type Stage = {
|
|
27
|
+
name: "attacks" | "hits" | "wounds" | "unsaved" | "damage" | "after-fnp" | "models-killed";
|
|
28
|
+
expected: number;
|
|
29
|
+
detail: string;
|
|
30
|
+
};
|
|
31
|
+
export type EngineInput = {
|
|
32
|
+
attacker: AttackProfileRef;
|
|
33
|
+
target: TargetProfileRef;
|
|
34
|
+
modelsFiring: number;
|
|
35
|
+
/** User / ability / manual buffs. Weapon-keyword buffs are auto-injected. */
|
|
36
|
+
buffs: Buff[];
|
|
37
|
+
context: EngineContext;
|
|
38
|
+
};
|
|
39
|
+
export type EngineOutput = {
|
|
40
|
+
stages: Stage[];
|
|
41
|
+
resolved: ResolvedModifiers;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Compute the expected per-stage projection for one (attacker, target, buffs)
|
|
45
|
+
* triple. The dataset defaults to the embedded one — pass an alternate when
|
|
46
|
+
* crunching against a different bundle (e.g. tests).
|
|
47
|
+
*/
|
|
48
|
+
export declare function crunch(input: EngineInput, dataset?: Dataset): EngineOutput;
|
|
49
|
+
export type { Phase };
|
|
50
|
+
//# sourceMappingURL=engine.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../../src/cruncher/engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAC3D,OAAO,EACL,KAAK,IAAI,EACT,KAAK,aAAa,EAElB,KAAK,iBAAiB,EAEvB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAE7C,MAAM,MAAM,gBAAgB,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,CAAC;AACxE,MAAM,MAAM,gBAAgB,GAAG;IAC7B,IAAI,EAAE,IAAI,CAAC;IACX,YAAY,EAAE,MAAM,CAAC;IACrB,gFAAgF;IAChF,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,KAAK,GAAG;IAClB,IAAI,EAAE,SAAS,GAAG,MAAM,GAAG,QAAQ,GAAG,SAAS,GAAG,QAAQ,GAAG,WAAW,GAAG,eAAe,CAAC;IAC3F,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG;IACxB,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,MAAM,EAAE,gBAAgB,CAAC;IACzB,YAAY,EAAE,MAAM,CAAC;IACrB,6EAA6E;IAC7E,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,OAAO,EAAE,aAAa,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG;IAAE,MAAM,EAAE,KAAK,EAAE,CAAC;IAAC,QAAQ,EAAE,iBAAiB,CAAA;CAAE,CAAC;AAE5E;;;;GAIG;AACH,wBAAgB,MAAM,CAAC,KAAK,EAAE,WAAW,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,YAAY,CA0L1E;AAgKD,YAAY,EAAE,KAAK,EAAE,CAAC"}
|