@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,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestrates an army-list import: decode → parse → resolve.
|
|
3
|
+
*
|
|
4
|
+
* The adapter seam ({@link FormatAdapter}) lets every supported source format
|
|
5
|
+
* plug in here without touching {@link decode} or {@link resolve}. Adapters are
|
|
6
|
+
* registered in priority order, and every adapter's `matches()` predicate is
|
|
7
|
+
* tight enough that **at most one** matches any given decoded payload —
|
|
8
|
+
* {@link tryImportRoster} relies on that disjointness to short-circuit on the
|
|
9
|
+
* first match.
|
|
10
|
+
*
|
|
11
|
+
* @packageDocumentation
|
|
12
|
+
*/
|
|
13
|
+
import { Dataset } from "../data/dataset.js";
|
|
14
|
+
import { selectAdapter } from "./adapter.js";
|
|
15
|
+
import { decodeListForge } from "./decode.js";
|
|
16
|
+
import { gwAdapter } from "./gw.js";
|
|
17
|
+
import { listForgeAdapter } from "./listforge.js";
|
|
18
|
+
import { newRecruitJsonAdapter } from "./newrecruit-json.js";
|
|
19
|
+
import { newRecruitSimpleAdapter } from "./newrecruit-simple.js";
|
|
20
|
+
import { newRecruitWtcCompactAdapter, newRecruitWtcFullAdapter, } from "./newrecruit-wtc.js";
|
|
21
|
+
import { resolve } from "./resolve.js";
|
|
22
|
+
import { rosterizerAdapter } from "./rosterizer.js";
|
|
23
|
+
/**
|
|
24
|
+
* Adapters available to {@link importRoster}, in match-priority order.
|
|
25
|
+
*
|
|
26
|
+
* NewRecruit-JSON runs ahead of ListForge because both recognise a
|
|
27
|
+
* `roster.forces` BattleScribe payload, and the NewRecruit signature is more
|
|
28
|
+
* specific (`xmlns: rosterSchema` or `generatedBy: newrecruit.eu`). The text
|
|
29
|
+
* adapters (`gw` / `wtc-compact` / `wtc-full` / `simple`) only match strings and
|
|
30
|
+
* disambiguate among themselves via structural cues, so their order amongst
|
|
31
|
+
* each other doesn't matter; wtc-full goes before wtc-compact because its
|
|
32
|
+
* matcher is the more specific of the two. GW shares the WTC summary header but
|
|
33
|
+
* carries `•` bullets and no `N with` lines, so it stays disjoint from both wtc
|
|
34
|
+
* matchers. Rosterizer rides at the top of the JSON dispatch — its `rulebook` +
|
|
35
|
+
* `snapshot` signature is structurally distinct from the BattleScribe
|
|
36
|
+
* `roster.forces` shape.
|
|
37
|
+
*/
|
|
38
|
+
const ADAPTERS = [
|
|
39
|
+
rosterizerAdapter,
|
|
40
|
+
newRecruitJsonAdapter,
|
|
41
|
+
gwAdapter,
|
|
42
|
+
newRecruitWtcFullAdapter,
|
|
43
|
+
newRecruitWtcCompactAdapter,
|
|
44
|
+
newRecruitSimpleAdapter,
|
|
45
|
+
listForgeAdapter,
|
|
46
|
+
];
|
|
47
|
+
/**
|
|
48
|
+
* Import a ListForge share payload into a resolved 40kdc {@link Roster}.
|
|
49
|
+
*
|
|
50
|
+
* `input` may be a full ListForge URL, a bare base64 segment, or an
|
|
51
|
+
* already-decoded JSON string — all are handled transparently. For NewRecruit
|
|
52
|
+
* sources, use {@link importNewRecruit} (no base64/gzip decode).
|
|
53
|
+
*/
|
|
54
|
+
export function importListForge(input, opts = {}) {
|
|
55
|
+
const decoded = decodeListForge(input);
|
|
56
|
+
return importRoster(decoded, opts);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Import a NewRecruit export (any of the four formats — JSON, wtc-compact,
|
|
60
|
+
* wtc-full, simple) into a resolved 40kdc {@link Roster}.
|
|
61
|
+
*
|
|
62
|
+
* The JSON form is parsed when `input` is valid JSON; the text forms are
|
|
63
|
+
* dispatched on string content. No base64/gzip decoding is attempted —
|
|
64
|
+
* NewRecruit exports are not encoded.
|
|
65
|
+
*/
|
|
66
|
+
export function importNewRecruit(input, opts = {}) {
|
|
67
|
+
const trimmed = input.trimStart();
|
|
68
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
69
|
+
try {
|
|
70
|
+
return importRoster(JSON.parse(input), opts);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// Fall through to treating the input as raw text.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return importRoster(input, opts);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Import an already-decoded payload. Selects the matching format adapter and
|
|
80
|
+
* resolves the result against the dataset. Accepts either a parsed JSON object
|
|
81
|
+
* (NewRecruit JSON / ListForge) or a string (the three NewRecruit text formats).
|
|
82
|
+
*/
|
|
83
|
+
/**
|
|
84
|
+
* Detect an already-resolved canonical {@link Roster} (the JSON shape produced
|
|
85
|
+
* by `rosterJsonSerializer`). Lets a downstream consumer round-trip canonical
|
|
86
|
+
* Roster JSON through `importRoster` without going through an adapter.
|
|
87
|
+
*/
|
|
88
|
+
function isCanonicalRoster(decoded) {
|
|
89
|
+
if (typeof decoded !== "object" || decoded === null)
|
|
90
|
+
return false;
|
|
91
|
+
const r = decoded;
|
|
92
|
+
const source = r.source;
|
|
93
|
+
return (typeof source === "object" &&
|
|
94
|
+
source !== null &&
|
|
95
|
+
typeof source.format === "string" &&
|
|
96
|
+
Array.isArray(r.units) &&
|
|
97
|
+
"diagnostics" in r);
|
|
98
|
+
}
|
|
99
|
+
export function importRoster(decoded, opts = {}) {
|
|
100
|
+
if (isCanonicalRoster(decoded))
|
|
101
|
+
return decoded;
|
|
102
|
+
const ds = opts.dataset ?? Dataset.embedded();
|
|
103
|
+
const adapter = selectAdapter(decoded, [...ADAPTERS]);
|
|
104
|
+
const parsed = adapter.parse(decoded);
|
|
105
|
+
return resolve(parsed, ds, adapter.id);
|
|
106
|
+
}
|
|
107
|
+
/** Cheap predicate: does the input look like ListForge's URL-or-base64 wrapper? */
|
|
108
|
+
function looksLikeListForgeEncoded(input) {
|
|
109
|
+
if (input.includes("/listforge/"))
|
|
110
|
+
return true;
|
|
111
|
+
if (/^https?:\/\//i.test(input))
|
|
112
|
+
return true;
|
|
113
|
+
// Every gzip-then-base64 payload starts with this prefix.
|
|
114
|
+
if (input.startsWith("H4sIA"))
|
|
115
|
+
return true;
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Auto-detect and import any supported roster format from a single string.
|
|
120
|
+
*
|
|
121
|
+
* Pipeline:
|
|
122
|
+
* 1. Empty input → `empty-input`.
|
|
123
|
+
* 2. Looks like a ListForge URL / base64 payload → decode (base64 + gunzip + JSON.parse).
|
|
124
|
+
* 3. Looks like raw JSON (starts with `{`/`[`) → JSON.parse.
|
|
125
|
+
* 4. Otherwise treat as text.
|
|
126
|
+
* 5. Greedy first-match adapter dispatch. The first adapter whose `matches()`
|
|
127
|
+
* accepts the decoded value wins; subsequent adapters are not tried.
|
|
128
|
+
* 6. If the matched adapter's `parse()` throws, that's a matcher contract
|
|
129
|
+
* violation — surfaced as `parse-failed`, not silently retried.
|
|
130
|
+
*
|
|
131
|
+
* Caller never sees an exception; the discriminated {@link ImportResult} carries
|
|
132
|
+
* either the resolved {@link Roster} (with the detected {@link RosterFormat})
|
|
133
|
+
* or a typed failure plus per-adapter trial info for diagnostics.
|
|
134
|
+
*
|
|
135
|
+
* Prefer this over {@link importListForge} / {@link importNewRecruit} when the
|
|
136
|
+
* caller doesn't know which format the user pasted.
|
|
137
|
+
*/
|
|
138
|
+
export function tryImportRoster(input, opts = {}) {
|
|
139
|
+
const trimmed = input.trim();
|
|
140
|
+
if (trimmed === "") {
|
|
141
|
+
return { ok: false, reason: "empty-input", message: "input is empty", trials: [] };
|
|
142
|
+
}
|
|
143
|
+
let decoded;
|
|
144
|
+
if (looksLikeListForgeEncoded(trimmed)) {
|
|
145
|
+
try {
|
|
146
|
+
decoded = decodeListForge(trimmed);
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
const message = err.message;
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
reason: "decode-failed",
|
|
153
|
+
message: `failed to decode ListForge payload: ${message}`,
|
|
154
|
+
trials: [{ id: "listforge", matched: false, reason: message }],
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
else if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
159
|
+
try {
|
|
160
|
+
decoded = JSON.parse(trimmed);
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
reason: "decode-failed",
|
|
166
|
+
message: `input looks like JSON but failed to parse: ${err.message}`,
|
|
167
|
+
trials: [],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
decoded = input;
|
|
173
|
+
}
|
|
174
|
+
const ds = opts.dataset ?? Dataset.embedded();
|
|
175
|
+
const trials = [];
|
|
176
|
+
for (const adapter of ADAPTERS) {
|
|
177
|
+
if (!adapter.matches(decoded)) {
|
|
178
|
+
trials.push({ id: adapter.id, matched: false });
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
const parsed = adapter.parse(decoded);
|
|
183
|
+
const roster = resolve(parsed, ds, adapter.id);
|
|
184
|
+
return { ok: true, roster, format: adapter.id };
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
const message = err.message;
|
|
188
|
+
trials.push({ id: adapter.id, matched: true, reason: message });
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
reason: "parse-failed",
|
|
192
|
+
message: `${adapter.id}: ${message}`,
|
|
193
|
+
trials,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
ok: false,
|
|
199
|
+
reason: "no-adapter-matched",
|
|
200
|
+
message: `tried ${ADAPTERS.length} formats, none recognised the input`,
|
|
201
|
+
trials,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
/** The adapter list, exposed for tests that need to walk every matcher (e.g.
|
|
205
|
+
* the disjointness invariant test). */
|
|
206
|
+
export const REGISTERED_ADAPTERS = ADAPTERS;
|
|
207
|
+
//# sourceMappingURL=import-roster.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"import-roster.js","sourceRoot":"","sources":["../../src/import/import-roster.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Orchestrates an army-list import: decode → parse → resolve.\n *\n * The adapter seam ({@link FormatAdapter}) lets every supported source format\n * plug in here without touching {@link decode} or {@link resolve}. Adapters are\n * registered in priority order, and every adapter's `matches()` predicate is\n * tight enough that **at most one** matches any given decoded payload —\n * {@link tryImportRoster} relies on that disjointness to short-circuit on the\n * first match.\n *\n * @packageDocumentation\n */\nimport { Dataset } from \"../data/dataset.js\";\nimport type { FormatAdapter } from \"./adapter.js\";\nimport { selectAdapter } from \"./adapter.js\";\nimport { decodeListForge } from \"./decode.js\";\nimport { gwAdapter } from \"./gw.js\";\nimport { listForgeAdapter } from \"./listforge.js\";\nimport { newRecruitJsonAdapter } from \"./newrecruit-json.js\";\nimport { newRecruitSimpleAdapter } from \"./newrecruit-simple.js\";\nimport {\n newRecruitWtcCompactAdapter,\n newRecruitWtcFullAdapter,\n} from \"./newrecruit-wtc.js\";\nimport { resolve } from \"./resolve.js\";\nimport { rosterizerAdapter } from \"./rosterizer.js\";\nimport type { Roster, RosterFormat } from \"./types.js\";\n\n/**\n * Adapters available to {@link importRoster}, in match-priority order.\n *\n * NewRecruit-JSON runs ahead of ListForge because both recognise a\n * `roster.forces` BattleScribe payload, and the NewRecruit signature is more\n * specific (`xmlns: rosterSchema` or `generatedBy: newrecruit.eu`). The text\n * adapters (`gw` / `wtc-compact` / `wtc-full` / `simple`) only match strings and\n * disambiguate among themselves via structural cues, so their order amongst\n * each other doesn't matter; wtc-full goes before wtc-compact because its\n * matcher is the more specific of the two. GW shares the WTC summary header but\n * carries `•` bullets and no `N with` lines, so it stays disjoint from both wtc\n * matchers. Rosterizer rides at the top of the JSON dispatch — its `rulebook` +\n * `snapshot` signature is structurally distinct from the BattleScribe\n * `roster.forces` shape.\n */\nconst ADAPTERS: readonly FormatAdapter[] = [\n rosterizerAdapter,\n newRecruitJsonAdapter,\n gwAdapter,\n newRecruitWtcFullAdapter,\n newRecruitWtcCompactAdapter,\n newRecruitSimpleAdapter,\n listForgeAdapter,\n];\n\nexport interface ImportOptions {\n /** Dataset to resolve against. Defaults to the package's embedded dataset. */\n dataset?: Dataset;\n}\n\n/**\n * Import a ListForge share payload into a resolved 40kdc {@link Roster}.\n *\n * `input` may be a full ListForge URL, a bare base64 segment, or an\n * already-decoded JSON string — all are handled transparently. For NewRecruit\n * sources, use {@link importNewRecruit} (no base64/gzip decode).\n */\nexport function importListForge(input: string, opts: ImportOptions = {}): Roster {\n const decoded = decodeListForge(input);\n return importRoster(decoded, opts);\n}\n\n/**\n * Import a NewRecruit export (any of the four formats — JSON, wtc-compact,\n * wtc-full, simple) into a resolved 40kdc {@link Roster}.\n *\n * The JSON form is parsed when `input` is valid JSON; the text forms are\n * dispatched on string content. No base64/gzip decoding is attempted —\n * NewRecruit exports are not encoded.\n */\nexport function importNewRecruit(input: string, opts: ImportOptions = {}): Roster {\n const trimmed = input.trimStart();\n if (trimmed.startsWith(\"{\") || trimmed.startsWith(\"[\")) {\n try {\n return importRoster(JSON.parse(input), opts);\n } catch {\n // Fall through to treating the input as raw text.\n }\n }\n return importRoster(input, opts);\n}\n\n/**\n * Import an already-decoded payload. Selects the matching format adapter and\n * resolves the result against the dataset. Accepts either a parsed JSON object\n * (NewRecruit JSON / ListForge) or a string (the three NewRecruit text formats).\n */\n/**\n * Detect an already-resolved canonical {@link Roster} (the JSON shape produced\n * by `rosterJsonSerializer`). Lets a downstream consumer round-trip canonical\n * Roster JSON through `importRoster` without going through an adapter.\n */\nfunction isCanonicalRoster(decoded: unknown): decoded is Roster {\n if (typeof decoded !== \"object\" || decoded === null) return false;\n const r = decoded as Record<string, unknown>;\n const source = r.source as Record<string, unknown> | undefined;\n return (\n typeof source === \"object\" &&\n source !== null &&\n typeof source.format === \"string\" &&\n Array.isArray(r.units) &&\n \"diagnostics\" in r\n );\n}\n\nexport function importRoster(decoded: unknown, opts: ImportOptions = {}): Roster {\n if (isCanonicalRoster(decoded)) return decoded;\n const ds = opts.dataset ?? Dataset.embedded();\n const adapter = selectAdapter(decoded, [...ADAPTERS]);\n const parsed = adapter.parse(decoded);\n return resolve(parsed, ds, adapter.id);\n}\n\n// ---------------------------------------------------------------------------\n// tryImportRoster — single string-in, structured-result-out entry point.\n// ---------------------------------------------------------------------------\n\n/** Why a {@link tryImportRoster} call did not produce a roster. */\nexport type ImportFailureReason =\n | \"empty-input\"\n | \"decode-failed\"\n | \"no-adapter-matched\"\n | \"parse-failed\";\n\n/** Per-adapter outcome from a {@link tryImportRoster} dispatch. */\nexport interface AdapterTrial {\n id: RosterFormat;\n /** True iff this adapter's `matches()` predicate accepted the decoded input. */\n matched: boolean;\n /** Present when {@link matched} is true and `parse()` then threw — the matcher\n * violated its contract. Absent for clean rejections. */\n reason?: string;\n}\n\n/** Discriminated result returned by {@link tryImportRoster}. */\nexport type ImportResult =\n | { ok: true; roster: Roster; format: RosterFormat }\n | {\n ok: false;\n reason: ImportFailureReason;\n message: string;\n trials: AdapterTrial[];\n };\n\n/** Cheap predicate: does the input look like ListForge's URL-or-base64 wrapper? */\nfunction looksLikeListForgeEncoded(input: string): boolean {\n if (input.includes(\"/listforge/\")) return true;\n if (/^https?:\\/\\//i.test(input)) return true;\n // Every gzip-then-base64 payload starts with this prefix.\n if (input.startsWith(\"H4sIA\")) return true;\n return false;\n}\n\n/**\n * Auto-detect and import any supported roster format from a single string.\n *\n * Pipeline:\n * 1. Empty input → `empty-input`.\n * 2. Looks like a ListForge URL / base64 payload → decode (base64 + gunzip + JSON.parse).\n * 3. Looks like raw JSON (starts with `{`/`[`) → JSON.parse.\n * 4. Otherwise treat as text.\n * 5. Greedy first-match adapter dispatch. The first adapter whose `matches()`\n * accepts the decoded value wins; subsequent adapters are not tried.\n * 6. If the matched adapter's `parse()` throws, that's a matcher contract\n * violation — surfaced as `parse-failed`, not silently retried.\n *\n * Caller never sees an exception; the discriminated {@link ImportResult} carries\n * either the resolved {@link Roster} (with the detected {@link RosterFormat})\n * or a typed failure plus per-adapter trial info for diagnostics.\n *\n * Prefer this over {@link importListForge} / {@link importNewRecruit} when the\n * caller doesn't know which format the user pasted.\n */\nexport function tryImportRoster(\n input: string,\n opts: ImportOptions = {},\n): ImportResult {\n const trimmed = input.trim();\n if (trimmed === \"\") {\n return { ok: false, reason: \"empty-input\", message: \"input is empty\", trials: [] };\n }\n\n let decoded: unknown;\n if (looksLikeListForgeEncoded(trimmed)) {\n try {\n decoded = decodeListForge(trimmed);\n } catch (err) {\n const message = (err as Error).message;\n return {\n ok: false,\n reason: \"decode-failed\",\n message: `failed to decode ListForge payload: ${message}`,\n trials: [{ id: \"listforge\", matched: false, reason: message }],\n };\n }\n } else if (trimmed.startsWith(\"{\") || trimmed.startsWith(\"[\")) {\n try {\n decoded = JSON.parse(trimmed);\n } catch (err) {\n return {\n ok: false,\n reason: \"decode-failed\",\n message: `input looks like JSON but failed to parse: ${(err as Error).message}`,\n trials: [],\n };\n }\n } else {\n decoded = input;\n }\n\n const ds = opts.dataset ?? Dataset.embedded();\n const trials: AdapterTrial[] = [];\n for (const adapter of ADAPTERS) {\n if (!adapter.matches(decoded)) {\n trials.push({ id: adapter.id, matched: false });\n continue;\n }\n try {\n const parsed = adapter.parse(decoded);\n const roster = resolve(parsed, ds, adapter.id);\n return { ok: true, roster, format: adapter.id };\n } catch (err) {\n const message = (err as Error).message;\n trials.push({ id: adapter.id, matched: true, reason: message });\n return {\n ok: false,\n reason: \"parse-failed\",\n message: `${adapter.id}: ${message}`,\n trials,\n };\n }\n }\n\n return {\n ok: false,\n reason: \"no-adapter-matched\",\n message: `tried ${ADAPTERS.length} formats, none recognised the input`,\n trials,\n };\n}\n\n/** The adapter list, exposed for tests that need to walk every matcher (e.g.\n * the disjointness invariant test). */\nexport const REGISTERED_ADAPTERS: readonly FormatAdapter[] = ADAPTERS;\n"]}
|
package/dist/import/index.d.ts
CHANGED
|
@@ -9,10 +9,14 @@
|
|
|
9
9
|
*
|
|
10
10
|
* @packageDocumentation
|
|
11
11
|
*/
|
|
12
|
-
export { importListForge, importRoster } from "./import-
|
|
13
|
-
export type { ImportOptions } from "./import-
|
|
12
|
+
export { importListForge, importNewRecruit, importRoster, tryImportRoster, REGISTERED_ADAPTERS, } from "./import-roster.js";
|
|
13
|
+
export type { ImportOptions, ImportResult, ImportFailureReason, AdapterTrial, } from "./import-roster.js";
|
|
14
14
|
export { decodeListForge } from "./decode.js";
|
|
15
15
|
export { resolve } from "./resolve.js";
|
|
16
16
|
export { listForgeAdapter } from "./listforge.js";
|
|
17
|
+
export { newRecruitJsonAdapter } from "./newrecruit-json.js";
|
|
18
|
+
export { newRecruitSimpleAdapter } from "./newrecruit-simple.js";
|
|
19
|
+
export { newRecruitWtcCompactAdapter, newRecruitWtcFullAdapter, } from "./newrecruit-wtc.js";
|
|
17
20
|
export type { FormatAdapter } from "./adapter.js";
|
|
18
|
-
export type { Roster, RosterUnit, RosterWargear, RosterSource, RosterPoints, ResolvedRef, Candidate, RosterLeaderAttachment, Diagnostics, Warning, WarningCode, BattleSize, GameVersionRef, ParsedRoster, ParsedUnit, ParsedWargear, } from "./types.js";
|
|
21
|
+
export type { Roster, RosterUnit, RosterWargear, RosterSource, RosterFormat, RosterPoints, ResolvedRef, Candidate, RosterLeaderAttachment, Diagnostics, Warning, WarningCode, BattleSize, GameVersionRef, ParsedRoster, ParsedUnit, ParsedWargear, } from "./types.js";
|
|
22
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/import/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,EACL,eAAe,EACf,gBAAgB,EAChB,YAAY,EACZ,eAAe,EACf,mBAAmB,GACpB,MAAM,oBAAoB,CAAC;AAC5B,YAAY,EACV,aAAa,EACb,YAAY,EACZ,mBAAmB,EACnB,YAAY,GACb,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACvC,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAC7D,OAAO,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AACjE,OAAO,EACL,2BAA2B,EAC3B,wBAAwB,GACzB,MAAM,qBAAqB,CAAC;AAC7B,YAAY,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAClD,YAAY,EACV,MAAM,EACN,UAAU,EACV,aAAa,EACb,YAAY,EACZ,YAAY,EACZ,YAAY,EACZ,WAAW,EACX,SAAS,EACT,sBAAsB,EACtB,WAAW,EACX,OAAO,EACP,WAAW,EACX,UAAU,EACV,cAAc,EACd,YAAY,EACZ,UAAU,EACV,aAAa,GACd,MAAM,YAAY,CAAC"}
|
package/dist/import/index.js
CHANGED
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
*
|
|
10
10
|
* @packageDocumentation
|
|
11
11
|
*/
|
|
12
|
-
export { importListForge, importRoster } from "./import-
|
|
12
|
+
export { importListForge, importNewRecruit, importRoster, tryImportRoster, REGISTERED_ADAPTERS, } from "./import-roster.js";
|
|
13
13
|
export { decodeListForge } from "./decode.js";
|
|
14
14
|
export { resolve } from "./resolve.js";
|
|
15
15
|
export { listForgeAdapter } from "./listforge.js";
|
|
16
|
+
export { newRecruitJsonAdapter } from "./newrecruit-json.js";
|
|
17
|
+
export { newRecruitSimpleAdapter } from "./newrecruit-simple.js";
|
|
18
|
+
export { newRecruitWtcCompactAdapter, newRecruitWtcFullAdapter, } from "./newrecruit-wtc.js";
|
|
19
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/import/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,EACL,eAAe,EACf,gBAAgB,EAChB,YAAY,EACZ,eAAe,EACf,mBAAmB,GACpB,MAAM,oBAAoB,CAAC;AAO5B,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACvC,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAC7D,OAAO,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AACjE,OAAO,EACL,2BAA2B,EAC3B,wBAAwB,GACzB,MAAM,qBAAqB,CAAC","sourcesContent":["/**\n * Army-list importer: turn an external list-builder export into a resolved\n * 40kdc roster.\n *\n * v1 supports ListForge's \"share JSON\" payload. The output is a {@link Roster}\n * keyed on 40kdc entity ids and validatable against\n * `schemas/core/roster.schema.json`. Resolution is lenient — unmatched names are\n * retained with candidate suggestions and summarised in diagnostics.\n *\n * @packageDocumentation\n */\nexport {\n importListForge,\n importNewRecruit,\n importRoster,\n tryImportRoster,\n REGISTERED_ADAPTERS,\n} from \"./import-roster.js\";\nexport type {\n ImportOptions,\n ImportResult,\n ImportFailureReason,\n AdapterTrial,\n} from \"./import-roster.js\";\nexport { decodeListForge } from \"./decode.js\";\nexport { resolve } from \"./resolve.js\";\nexport { listForgeAdapter } from \"./listforge.js\";\nexport { newRecruitJsonAdapter } from \"./newrecruit-json.js\";\nexport { newRecruitSimpleAdapter } from \"./newrecruit-simple.js\";\nexport {\n newRecruitWtcCompactAdapter,\n newRecruitWtcFullAdapter,\n} from \"./newrecruit-wtc.js\";\nexport type { FormatAdapter } from \"./adapter.js\";\nexport type {\n Roster,\n RosterUnit,\n RosterWargear,\n RosterSource,\n RosterFormat,\n RosterPoints,\n ResolvedRef,\n Candidate,\n RosterLeaderAttachment,\n Diagnostics,\n Warning,\n WarningCode,\n BattleSize,\n GameVersionRef,\n ParsedRoster,\n ParsedUnit,\n ParsedWargear,\n} from \"./types.js\";\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"listforge.d.ts","sourceRoot":"","sources":["../../src/import/listforge.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAiNlD,eAAO,MAAM,gBAAgB,EAAE,aA4D9B,CAAC"}
|
package/dist/import/listforge.js
CHANGED
|
@@ -4,6 +4,8 @@ const POINTS_LIMIT = /(\d[\d,]*)\s*Point/i;
|
|
|
4
4
|
const ENHANCEMENT_GROUP_PREFIX = "Enhancements";
|
|
5
5
|
const CHARACTER_CATEGORIES = new Set(["Character", "Epic Hero"]);
|
|
6
6
|
const WEAPON_CATEGORY_SUFFIX = " Weapon"; // "Ranged Weapon", "Melee Weapon", "Psychic Weapon"
|
|
7
|
+
const NEWRECRUIT_XMLNS = "http://www.battlescribe.net/schema/rosterSchema";
|
|
8
|
+
const NEWRECRUIT_HOST_PREFIX = "https://newrecruit";
|
|
7
9
|
function asArray(value) {
|
|
8
10
|
return Array.isArray(value) ? value : [];
|
|
9
11
|
}
|
|
@@ -71,11 +73,15 @@ function modelCount(unit) {
|
|
|
71
73
|
function parseUnit(unit) {
|
|
72
74
|
const wargear = [];
|
|
73
75
|
let enhancement_raw_name = null;
|
|
76
|
+
let enhancement_points = null;
|
|
74
77
|
let is_warlord = false;
|
|
75
78
|
for (const node of childSelections(unit)) {
|
|
76
79
|
walk(node, (s) => {
|
|
77
80
|
if (isEnhancementSelection(s)) {
|
|
78
|
-
enhancement_raw_name
|
|
81
|
+
if (enhancement_raw_name === null) {
|
|
82
|
+
enhancement_raw_name = selectionName(s);
|
|
83
|
+
enhancement_points = pointsOf(s);
|
|
84
|
+
}
|
|
79
85
|
return;
|
|
80
86
|
}
|
|
81
87
|
if (selectionName(s) === "Warlord") {
|
|
@@ -94,6 +100,7 @@ function parseUnit(unit) {
|
|
|
94
100
|
points: pointsOf(unit),
|
|
95
101
|
is_warlord,
|
|
96
102
|
enhancement_raw_name,
|
|
103
|
+
enhancement_points,
|
|
97
104
|
wargear,
|
|
98
105
|
};
|
|
99
106
|
}
|
|
@@ -139,10 +146,22 @@ function rosterOf(decoded) {
|
|
|
139
146
|
return null;
|
|
140
147
|
return roster;
|
|
141
148
|
}
|
|
149
|
+
/** Detect a NewRecruit-flavoured BattleScribe payload. ListForge's matcher
|
|
150
|
+
* excludes these so the greedy first-match dispatcher routes them to the
|
|
151
|
+
* NewRecruit adapter without falling through to here. */
|
|
152
|
+
function hasNewRecruitSignature(decoded, roster) {
|
|
153
|
+
if (asString(roster.xmlns) === NEWRECRUIT_XMLNS)
|
|
154
|
+
return true;
|
|
155
|
+
const genBy = asString(decoded.generatedBy) ?? asString(roster.generatedBy);
|
|
156
|
+
return genBy !== null && genBy.toLowerCase().startsWith(NEWRECRUIT_HOST_PREFIX);
|
|
157
|
+
}
|
|
142
158
|
export const listForgeAdapter = {
|
|
143
159
|
id: "listforge",
|
|
144
160
|
matches(decoded) {
|
|
145
|
-
|
|
161
|
+
const roster = rosterOf(decoded);
|
|
162
|
+
if (!roster)
|
|
163
|
+
return false;
|
|
164
|
+
return !hasNewRecruitSignature(decoded, roster);
|
|
146
165
|
},
|
|
147
166
|
parse(decoded) {
|
|
148
167
|
const payload = decoded;
|
|
@@ -193,3 +212,4 @@ export const listForgeAdapter = {
|
|
|
193
212
|
};
|
|
194
213
|
},
|
|
195
214
|
};
|
|
215
|
+
//# sourceMappingURL=listforge.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"listforge.js","sourceRoot":"","sources":["../../src/import/listforge.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * ListForge adapter: lower a decoded ListForge \"share JSON\" payload (a\n * BattleScribe-derived roster tree) to a {@link ParsedRoster}.\n *\n * The walk reads an ALLOWLIST of fields only — `name`, `number`, `type`,\n * `categories[].name`, `group`, and `costs` point values — and never touches\n * `rules[].description` or ability `profiles[].characteristics[].$text`, which\n * carry reproduced rules text. This keeps the importer's output free of\n * copyrighted prose by construction.\n *\n * Selection-tree shape (recursive `selections`):\n * - Configuration nodes (`type: \"upgrade\"`) named \"Detachment\" / \"Battle Size\"\n * carry the chosen value as their first child selection.\n * - Unit nodes (`type: \"model\" | \"unit\"`) carry role categories, a points cost,\n * and — nested anywhere beneath them — their wargear (weapon-category\n * selections), enhancement (a selection whose `group` starts \"Enhancements\"),\n * the \"Warlord\" marker, and model sub-selections.\n * - Every unit carries a `\"Faction: <Name>\"` category.\n *\n * @packageDocumentation\n */\nimport type { FormatAdapter } from \"./adapter.js\";\nimport type { ParsedRoster, ParsedUnit, ParsedWargear } from \"./types.js\";\n\nconst PTS_COST_NAME = \"pts\";\nconst FACTION_CATEGORY = /^Faction:\\s*(.+)$/;\nconst POINTS_LIMIT = /(\\d[\\d,]*)\\s*Point/i;\nconst ENHANCEMENT_GROUP_PREFIX = \"Enhancements\";\nconst CHARACTER_CATEGORIES = new Set([\"Character\", \"Epic Hero\"]);\nconst WEAPON_CATEGORY_SUFFIX = \" Weapon\"; // \"Ranged Weapon\", \"Melee Weapon\", \"Psychic Weapon\"\nconst NEWRECRUIT_XMLNS = \"http://www.battlescribe.net/schema/rosterSchema\";\nconst NEWRECRUIT_HOST_PREFIX = \"https://newrecruit\";\n\n// --- Minimal structural views of the parts of the payload we read. ----------\n\ninterface RawCategory {\n name?: unknown;\n}\ninterface RawCost {\n name?: unknown;\n value?: unknown;\n}\ninterface RawSelection {\n name?: unknown;\n type?: unknown;\n number?: unknown;\n group?: unknown;\n categories?: unknown;\n costs?: unknown;\n selections?: unknown;\n}\n\nfunction asArray(value: unknown): unknown[] {\n return Array.isArray(value) ? value : [];\n}\n\nfunction asString(value: unknown): string | null {\n return typeof value === \"string\" ? value : null;\n}\n\nfunction selectionName(sel: RawSelection): string {\n return asString(sel.name) ?? \"\";\n}\n\nfunction selectionType(sel: RawSelection): string {\n return asString(sel.type) ?? \"\";\n}\n\n/** A selection's multiplicity (`number`), defaulting to 1. */\nfunction selectionCount(sel: RawSelection): number {\n return typeof sel.number === \"number\" && sel.number > 0 ? sel.number : 1;\n}\n\n/** Point value from a selection's cost block, or null when absent. */\nfunction pointsOf(sel: RawSelection): number | null {\n for (const raw of asArray(sel.costs)) {\n const cost = raw as RawCost;\n if (asString(cost.name) === PTS_COST_NAME && typeof cost.value === \"number\") {\n return cost.value;\n }\n }\n return null;\n}\n\nfunction categoryNames(sel: RawSelection): string[] {\n return asArray(sel.categories)\n .map((c) => asString((c as RawCategory).name))\n .filter((n): n is string => n !== null);\n}\n\nfunction childSelections(sel: RawSelection): RawSelection[] {\n return asArray(sel.selections) as RawSelection[];\n}\n\n/** Depth-first visit of a selection and everything beneath it. */\nfunction walk(sel: RawSelection, visit: (s: RawSelection) => void): void {\n visit(sel);\n for (const child of childSelections(sel)) walk(child, visit);\n}\n\nfunction isUnitSelection(sel: RawSelection): boolean {\n const type = selectionType(sel);\n return type === \"model\" || type === \"unit\";\n}\n\nfunction isCharacter(sel: RawSelection): boolean {\n return categoryNames(sel).some((n) => CHARACTER_CATEGORIES.has(n));\n}\n\nfunction isWeaponSelection(sel: RawSelection): boolean {\n return categoryNames(sel).some((n) => n.endsWith(WEAPON_CATEGORY_SUFFIX));\n}\n\nfunction isEnhancementSelection(sel: RawSelection): boolean {\n const group = asString(sel.group);\n return group !== null && group.startsWith(ENHANCEMENT_GROUP_PREFIX);\n}\n\n/** Sum the model count of a unit from its nested model selections. */\nfunction modelCount(unit: RawSelection): number {\n let total = 0;\n walk(unit, (s) => {\n if (selectionType(s) === \"model\") total += selectionCount(s);\n });\n return total > 0 ? total : selectionCount(unit);\n}\n\n/** Build a parsed unit from a top-level unit selection. */\nfunction parseUnit(unit: RawSelection): ParsedUnit {\n const wargear: ParsedWargear[] = [];\n let enhancement_raw_name: string | null = null;\n let enhancement_points: number | null = null;\n let is_warlord = false;\n\n for (const node of childSelections(unit)) {\n walk(node, (s) => {\n if (isEnhancementSelection(s)) {\n if (enhancement_raw_name === null) {\n enhancement_raw_name = selectionName(s);\n enhancement_points = pointsOf(s);\n }\n return;\n }\n if (selectionName(s) === \"Warlord\") {\n is_warlord = true;\n return;\n }\n if (isWeaponSelection(s)) {\n wargear.push({ raw_name: selectionName(s), count: selectionCount(s) });\n }\n });\n }\n\n return {\n raw_name: selectionName(unit),\n is_character: isCharacter(unit),\n model_count: modelCount(unit),\n points: pointsOf(unit),\n is_warlord,\n enhancement_raw_name,\n enhancement_points,\n wargear,\n };\n}\n\n/** Value carried as the first child of a named configuration selection. */\nfunction configValue(\n selections: RawSelection[],\n configName: string,\n): string | null {\n const node = selections.find((s) => selectionName(s) === configName);\n if (!node) return null;\n const child = childSelections(node)[0];\n return child ? selectionName(child) : null;\n}\n\nfunction parseLimit(label: string | null): number | null {\n if (!label) return null;\n const match = POINTS_LIMIT.exec(label);\n if (!match) return null;\n return Number.parseInt(match[1].replace(/,/g, \"\"), 10);\n}\n\n/** First `\"Faction: X\"` category found anywhere; reports all distinct names. */\nfunction collectFactions(forces: RawSelection[]): string[] {\n const seen = new Set<string>();\n for (const force of forces) {\n for (const sel of childSelections(force)) {\n walk(sel, (s) => {\n for (const name of categoryNames(s)) {\n const match = FACTION_CATEGORY.exec(name);\n if (match) seen.add(match[1].trim());\n }\n });\n }\n }\n return [...seen];\n}\n\ninterface RawRoster {\n name?: unknown;\n costs?: unknown;\n forces?: unknown;\n xmlns?: unknown;\n generatedBy?: unknown;\n}\ninterface RawPayload {\n name?: unknown;\n generatedBy?: unknown;\n roster?: unknown;\n}\n\nfunction rosterOf(decoded: unknown): RawRoster | null {\n if (!decoded || typeof decoded !== \"object\") return null;\n const roster = (decoded as RawPayload).roster;\n if (!roster || typeof roster !== \"object\") return null;\n if (!Array.isArray((roster as RawRoster).forces)) return null;\n return roster as RawRoster;\n}\n\n/** Detect a NewRecruit-flavoured BattleScribe payload. ListForge's matcher\n * excludes these so the greedy first-match dispatcher routes them to the\n * NewRecruit adapter without falling through to here. */\nfunction hasNewRecruitSignature(decoded: unknown, roster: RawRoster): boolean {\n if (asString(roster.xmlns) === NEWRECRUIT_XMLNS) return true;\n const genBy =\n asString((decoded as RawPayload).generatedBy) ?? asString(roster.generatedBy);\n return genBy !== null && genBy.toLowerCase().startsWith(NEWRECRUIT_HOST_PREFIX);\n}\n\nexport const listForgeAdapter: FormatAdapter = {\n id: \"listforge\",\n\n matches(decoded: unknown): boolean {\n const roster = rosterOf(decoded);\n if (!roster) return false;\n return !hasNewRecruitSignature(decoded, roster);\n },\n\n parse(decoded: unknown): ParsedRoster {\n const payload = decoded as RawPayload;\n const roster = rosterOf(decoded);\n if (!roster) {\n throw new Error(\"listforge: payload has no roster.forces array\");\n }\n\n const forces = asArray(roster.forces) as RawSelection[];\n\n // Configuration lives among each force's top-level selections.\n let detachment_raw_name: string | null = null;\n let battle_size_raw: string | null = null;\n const units: ParsedUnit[] = [];\n for (const force of forces) {\n const top = childSelections(force);\n detachment_raw_name ??= configValue(top, \"Detachment\");\n battle_size_raw ??= configValue(top, \"Battle Size\");\n for (const sel of top) {\n if (isUnitSelection(sel)) units.push(parseUnit(sel));\n }\n }\n\n const factions = collectFactions(forces);\n const total_reported = pointsOf(roster as RawSelection);\n\n // Honest computed total: sum every cost line in the tree. A unit's own cost\n // and its nested enhancement's cost are distinct lines that together make up\n // the unit's army contribution, so a full walk reproduces the army total.\n let total_computed = 0;\n for (const force of forces) {\n for (const sel of childSelections(force)) {\n walk(sel, (s) => {\n const pts = pointsOf(s);\n if (pts) total_computed += pts;\n });\n }\n }\n\n return {\n name: asString(payload.name) ?? asString(roster.name) ?? \"Imported roster\",\n generated_by: asString(payload.generatedBy),\n faction_raw_name: factions[0] ?? null,\n detachment_raw_name,\n battle_size_raw,\n declared_limit: parseLimit(battle_size_raw),\n total_reported,\n total_computed,\n units,\n multi_force: factions.length > 1,\n };\n },\n};\n"]}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NewRecruit JSON adapter: lower a decoded NewRecruit roster export (a
|
|
3
|
+
* BattleScribe-derived tree, same outer shape as ListForge) to a {@link ParsedRoster}.
|
|
4
|
+
*
|
|
5
|
+
* NewRecruit-specific signals used to detect the format:
|
|
6
|
+
* - `generatedBy` reports the NewRecruit URL ("https://newrecruit.eu"), and/or
|
|
7
|
+
* - `roster.xmlns` is set to the BattleScribe rosterSchema namespace.
|
|
8
|
+
*
|
|
9
|
+
* The primary faction surfaces in `forces[].catalogueName` (e.g.
|
|
10
|
+
* "Chaos - Chaos Knights") — we take the segment after the final " - ". Falls
|
|
11
|
+
* back to the first `"Faction: X"` category if no catalogueName is present.
|
|
12
|
+
*
|
|
13
|
+
* The walk reads the same ALLOWLIST as the ListForge adapter — `name`,
|
|
14
|
+
* `number`, `type`, `categories[].name`, `group`, `costs` point values, and
|
|
15
|
+
* `catalogueName`. `rules[].description`, ability `profiles[].characteristics[].$text`,
|
|
16
|
+
* and every other prose field are never touched, so the importer's output is
|
|
17
|
+
* free of copyrighted prose by construction.
|
|
18
|
+
*
|
|
19
|
+
* Selection-tree shape (recursive `selections`) is identical to ListForge:
|
|
20
|
+
* - Configuration nodes (`type: "upgrade"`) named "Detachment" / "Battle Size"
|
|
21
|
+
* carry the chosen value as their first child selection.
|
|
22
|
+
* - Unit nodes (`type: "model" | "unit"`) carry role categories, a points cost,
|
|
23
|
+
* and — nested anywhere beneath them — their wargear (weapon-category
|
|
24
|
+
* selections), enhancement (a selection whose `group` starts "Enhancements"),
|
|
25
|
+
* the "Warlord" marker, and model sub-selections.
|
|
26
|
+
*
|
|
27
|
+
* @packageDocumentation
|
|
28
|
+
*/
|
|
29
|
+
import type { FormatAdapter } from "./adapter.js";
|
|
30
|
+
export declare const newRecruitJsonAdapter: FormatAdapter;
|
|
31
|
+
//# sourceMappingURL=newrecruit-json.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"newrecruit-json.d.ts","sourceRoot":"","sources":["../../src/import/newrecruit-json.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AA4NlD,eAAO,MAAM,qBAAqB,EAAE,aA4DnC,CAAC"}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
const PTS_COST_NAME = "pts";
|
|
2
|
+
const FACTION_CATEGORY = /^Faction:\s*(.+)$/;
|
|
3
|
+
const POINTS_LIMIT = /(\d[\d,]*)\s*Point/i;
|
|
4
|
+
const ENHANCEMENT_GROUP_PREFIX = "Enhancements";
|
|
5
|
+
const CHARACTER_CATEGORIES = new Set(["Character", "Epic Hero"]);
|
|
6
|
+
const WEAPON_CATEGORY_SUFFIX = " Weapon"; // "Ranged Weapon", "Melee Weapon", "Psychic Weapon"
|
|
7
|
+
const NEWRECRUIT_XMLNS = "http://www.battlescribe.net/schema/rosterSchema";
|
|
8
|
+
const NEWRECRUIT_HOST_PREFIX = "https://newrecruit";
|
|
9
|
+
function asArray(value) {
|
|
10
|
+
return Array.isArray(value) ? value : [];
|
|
11
|
+
}
|
|
12
|
+
function asString(value) {
|
|
13
|
+
return typeof value === "string" ? value : null;
|
|
14
|
+
}
|
|
15
|
+
function selectionName(sel) {
|
|
16
|
+
return asString(sel.name) ?? "";
|
|
17
|
+
}
|
|
18
|
+
function selectionType(sel) {
|
|
19
|
+
return asString(sel.type) ?? "";
|
|
20
|
+
}
|
|
21
|
+
function selectionCount(sel) {
|
|
22
|
+
return typeof sel.number === "number" && sel.number > 0 ? sel.number : 1;
|
|
23
|
+
}
|
|
24
|
+
function pointsOf(sel) {
|
|
25
|
+
for (const raw of asArray(sel.costs)) {
|
|
26
|
+
const cost = raw;
|
|
27
|
+
if (asString(cost.name) === PTS_COST_NAME && typeof cost.value === "number") {
|
|
28
|
+
return cost.value;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
function categoryNames(sel) {
|
|
34
|
+
return asArray(sel.categories)
|
|
35
|
+
.map((c) => asString(c.name))
|
|
36
|
+
.filter((n) => n !== null);
|
|
37
|
+
}
|
|
38
|
+
function childSelections(sel) {
|
|
39
|
+
return asArray(sel.selections);
|
|
40
|
+
}
|
|
41
|
+
function walk(sel, visit) {
|
|
42
|
+
visit(sel);
|
|
43
|
+
for (const child of childSelections(sel))
|
|
44
|
+
walk(child, visit);
|
|
45
|
+
}
|
|
46
|
+
function isUnitSelection(sel) {
|
|
47
|
+
const type = selectionType(sel);
|
|
48
|
+
return type === "model" || type === "unit";
|
|
49
|
+
}
|
|
50
|
+
function isCharacter(sel) {
|
|
51
|
+
return categoryNames(sel).some((n) => CHARACTER_CATEGORIES.has(n));
|
|
52
|
+
}
|
|
53
|
+
function isWeaponSelection(sel) {
|
|
54
|
+
return categoryNames(sel).some((n) => n.endsWith(WEAPON_CATEGORY_SUFFIX));
|
|
55
|
+
}
|
|
56
|
+
function isEnhancementSelection(sel) {
|
|
57
|
+
const group = asString(sel.group);
|
|
58
|
+
return group !== null && group.startsWith(ENHANCEMENT_GROUP_PREFIX);
|
|
59
|
+
}
|
|
60
|
+
function modelCount(unit) {
|
|
61
|
+
let total = 0;
|
|
62
|
+
walk(unit, (s) => {
|
|
63
|
+
if (selectionType(s) === "model")
|
|
64
|
+
total += selectionCount(s);
|
|
65
|
+
});
|
|
66
|
+
return total > 0 ? total : selectionCount(unit);
|
|
67
|
+
}
|
|
68
|
+
function parseUnit(unit) {
|
|
69
|
+
const wargear = [];
|
|
70
|
+
let enhancement_raw_name = null;
|
|
71
|
+
let enhancement_points = null;
|
|
72
|
+
let is_warlord = false;
|
|
73
|
+
for (const node of childSelections(unit)) {
|
|
74
|
+
walk(node, (s) => {
|
|
75
|
+
if (isEnhancementSelection(s)) {
|
|
76
|
+
if (enhancement_raw_name === null) {
|
|
77
|
+
enhancement_raw_name = selectionName(s);
|
|
78
|
+
enhancement_points = pointsOf(s);
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (selectionName(s) === "Warlord") {
|
|
83
|
+
is_warlord = true;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (isWeaponSelection(s)) {
|
|
87
|
+
wargear.push({ raw_name: selectionName(s), count: selectionCount(s) });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
raw_name: selectionName(unit),
|
|
93
|
+
is_character: isCharacter(unit),
|
|
94
|
+
model_count: modelCount(unit),
|
|
95
|
+
points: pointsOf(unit),
|
|
96
|
+
is_warlord,
|
|
97
|
+
enhancement_raw_name,
|
|
98
|
+
enhancement_points,
|
|
99
|
+
wargear,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function configValue(selections, configName) {
|
|
103
|
+
const node = selections.find((s) => selectionName(s) === configName);
|
|
104
|
+
if (!node)
|
|
105
|
+
return null;
|
|
106
|
+
const child = childSelections(node)[0];
|
|
107
|
+
return child ? selectionName(child) : null;
|
|
108
|
+
}
|
|
109
|
+
function parseLimit(label) {
|
|
110
|
+
if (!label)
|
|
111
|
+
return null;
|
|
112
|
+
const match = POINTS_LIMIT.exec(label);
|
|
113
|
+
if (!match)
|
|
114
|
+
return null;
|
|
115
|
+
return Number.parseInt(match[1].replace(/,/g, ""), 10);
|
|
116
|
+
}
|
|
117
|
+
function collectFactions(forces) {
|
|
118
|
+
const seen = new Set();
|
|
119
|
+
for (const force of forces) {
|
|
120
|
+
for (const sel of childSelections(force)) {
|
|
121
|
+
walk(sel, (s) => {
|
|
122
|
+
for (const name of categoryNames(s)) {
|
|
123
|
+
const match = FACTION_CATEGORY.exec(name);
|
|
124
|
+
if (match)
|
|
125
|
+
seen.add(match[1].trim());
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return [...seen];
|
|
131
|
+
}
|
|
132
|
+
/** Primary faction from a force's `catalogueName` (e.g. "Chaos - Chaos Knights"
|
|
133
|
+
* → "Chaos Knights"). Returns null when no force has a catalogueName. */
|
|
134
|
+
function primaryFactionFromCatalogue(forces) {
|
|
135
|
+
for (const force of forces) {
|
|
136
|
+
const name = asString(force.catalogueName);
|
|
137
|
+
if (!name)
|
|
138
|
+
continue;
|
|
139
|
+
const parts = name.split(" - ");
|
|
140
|
+
const last = parts[parts.length - 1]?.trim();
|
|
141
|
+
if (last)
|
|
142
|
+
return last;
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
function rosterOf(decoded) {
|
|
147
|
+
if (!decoded || typeof decoded !== "object")
|
|
148
|
+
return null;
|
|
149
|
+
const roster = decoded.roster;
|
|
150
|
+
if (!roster || typeof roster !== "object")
|
|
151
|
+
return null;
|
|
152
|
+
if (!Array.isArray(roster.forces))
|
|
153
|
+
return null;
|
|
154
|
+
return roster;
|
|
155
|
+
}
|
|
156
|
+
/** Detect a NewRecruit payload: BattleScribe `rosterSchema` xmlns or a
|
|
157
|
+
* `generatedBy` URL pointing at newrecruit.eu. */
|
|
158
|
+
function hasNewRecruitSignature(decoded, roster) {
|
|
159
|
+
const payload = decoded;
|
|
160
|
+
const xmlns = asString(roster.xmlns);
|
|
161
|
+
if (xmlns === NEWRECRUIT_XMLNS)
|
|
162
|
+
return true;
|
|
163
|
+
const genBy = asString(payload.generatedBy) ?? asString(roster.generatedBy);
|
|
164
|
+
if (genBy && genBy.toLowerCase().startsWith(NEWRECRUIT_HOST_PREFIX)) {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
export const newRecruitJsonAdapter = {
|
|
170
|
+
id: "newrecruit-json",
|
|
171
|
+
matches(decoded) {
|
|
172
|
+
const roster = rosterOf(decoded);
|
|
173
|
+
if (!roster)
|
|
174
|
+
return false;
|
|
175
|
+
return hasNewRecruitSignature(decoded, roster);
|
|
176
|
+
},
|
|
177
|
+
parse(decoded) {
|
|
178
|
+
const payload = decoded;
|
|
179
|
+
const roster = rosterOf(decoded);
|
|
180
|
+
if (!roster) {
|
|
181
|
+
throw new Error("newrecruit-json: payload has no roster.forces array");
|
|
182
|
+
}
|
|
183
|
+
const forces = asArray(roster.forces);
|
|
184
|
+
let detachment_raw_name = null;
|
|
185
|
+
let battle_size_raw = null;
|
|
186
|
+
const units = [];
|
|
187
|
+
for (const force of forces) {
|
|
188
|
+
const top = childSelections(force);
|
|
189
|
+
detachment_raw_name ??= configValue(top, "Detachment");
|
|
190
|
+
battle_size_raw ??= configValue(top, "Battle Size");
|
|
191
|
+
for (const sel of top) {
|
|
192
|
+
if (isUnitSelection(sel))
|
|
193
|
+
units.push(parseUnit(sel));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const factions = collectFactions(forces);
|
|
197
|
+
const primaryFaction = primaryFactionFromCatalogue(forces) ?? factions[0] ?? null;
|
|
198
|
+
const total_reported = pointsOf(roster);
|
|
199
|
+
let total_computed = 0;
|
|
200
|
+
for (const force of forces) {
|
|
201
|
+
for (const sel of childSelections(force)) {
|
|
202
|
+
walk(sel, (s) => {
|
|
203
|
+
const pts = pointsOf(s);
|
|
204
|
+
if (pts)
|
|
205
|
+
total_computed += pts;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const generated_by = asString(payload.generatedBy) ?? asString(roster.generatedBy);
|
|
210
|
+
return {
|
|
211
|
+
name: asString(payload.name) ?? asString(roster.name) ?? "Imported roster",
|
|
212
|
+
generated_by,
|
|
213
|
+
faction_raw_name: primaryFaction,
|
|
214
|
+
detachment_raw_name,
|
|
215
|
+
battle_size_raw,
|
|
216
|
+
declared_limit: parseLimit(battle_size_raw),
|
|
217
|
+
total_reported,
|
|
218
|
+
total_computed,
|
|
219
|
+
units,
|
|
220
|
+
multi_force: factions.length > 1,
|
|
221
|
+
};
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
//# sourceMappingURL=newrecruit-json.js.map
|