@alpaca-software/40kdc-data 0.5.12 → 0.5.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/abilities-resolver/resolver.d.ts.map +1 -1
- package/dist/abilities-resolver/resolver.js +6 -2
- package/dist/abilities-resolver/resolver.js.map +1 -1
- package/dist/audit-coverage.d.ts.map +1 -1
- package/dist/audit-coverage.js +90 -1
- package/dist/audit-coverage.js.map +1 -1
- package/dist/audit-phrasing.d.ts +64 -0
- package/dist/audit-phrasing.d.ts.map +1 -0
- package/dist/audit-phrasing.js +208 -0
- package/dist/audit-phrasing.js.map +1 -0
- package/dist/author-input-pack.d.ts +27 -0
- package/dist/author-input-pack.d.ts.map +1 -0
- package/dist/author-input-pack.js +198 -0
- package/dist/author-input-pack.js.map +1 -0
- package/dist/build-share-registry.d.ts +2 -0
- package/dist/build-share-registry.d.ts.map +1 -0
- package/dist/build-share-registry.js +115 -0
- package/dist/build-share-registry.js.map +1 -0
- package/dist/cli.js +7 -0
- package/dist/cli.js.map +1 -1
- package/dist/codegen-data.js +23 -0
- package/dist/codegen-data.js.map +1 -1
- package/dist/commands/translate.d.ts.map +1 -1
- package/dist/commands/translate.js +2 -5
- package/dist/commands/translate.js.map +1 -1
- package/dist/commands/validate-all.d.ts.map +1 -1
- package/dist/commands/validate-all.js +8 -3
- package/dist/commands/validate-all.js.map +1 -1
- package/dist/data/bundle.generated.js +1 -1
- package/dist/data/bundle.generated.js.map +1 -1
- package/dist/data/dataset.d.ts +33 -1
- package/dist/data/dataset.d.ts.map +1 -1
- package/dist/data/dataset.js +107 -1
- package/dist/data/dataset.js.map +1 -1
- package/dist/data/index.d.ts +5 -1
- package/dist/data/index.d.ts.map +1 -1
- package/dist/data/index.js +5 -1
- package/dist/data/index.js.map +1 -1
- package/dist/data/roster-resolve.d.ts +16 -1
- package/dist/data/roster-resolve.d.ts.map +1 -1
- package/dist/data/roster-resolve.js +19 -0
- package/dist/data/roster-resolve.js.map +1 -1
- package/dist/data/types.d.ts +3 -1
- package/dist/data/types.d.ts.map +1 -1
- package/dist/data/types.js +1 -0
- package/dist/data/types.js.map +1 -1
- package/dist/extract-faction-pack.d.ts +16 -11
- package/dist/extract-faction-pack.d.ts.map +1 -1
- package/dist/extract-faction-pack.js +24 -80
- package/dist/extract-faction-pack.js.map +1 -1
- package/dist/gen-conformance.js +162 -0
- package/dist/gen-conformance.js.map +1 -1
- package/dist/generated.d.ts +129 -0
- package/dist/generated.d.ts.map +1 -1
- package/dist/generated.js.map +1 -1
- package/dist/import/gw-headerless.d.ts +70 -0
- package/dist/import/gw-headerless.d.ts.map +1 -0
- package/dist/import/gw-headerless.js +343 -0
- package/dist/import/gw-headerless.js.map +1 -0
- package/dist/import/import-roster.d.ts.map +1 -1
- package/dist/import/import-roster.js +8 -0
- package/dist/import/import-roster.js.map +1 -1
- package/dist/integrity.d.ts +32 -0
- package/dist/integrity.d.ts.map +1 -0
- package/dist/integrity.js +120 -0
- package/dist/integrity.js.map +1 -0
- package/dist/merge-faction-pack.d.ts +2 -0
- package/dist/merge-faction-pack.d.ts.map +1 -0
- package/dist/merge-faction-pack.js +157 -0
- package/dist/merge-faction-pack.js.map +1 -0
- package/dist/pack-blocks.d.ts +32 -0
- package/dist/pack-blocks.d.ts.map +1 -0
- package/dist/pack-blocks.js +99 -0
- package/dist/pack-blocks.js.map +1 -0
- package/dist/report.d.ts +1 -1
- package/dist/report.d.ts.map +1 -1
- package/dist/report.js +2 -2
- package/dist/report.js.map +1 -1
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +42 -0
- package/dist/runner.js.map +1 -1
- package/dist/seed-detachment-abilities.d.ts +2 -0
- package/dist/seed-detachment-abilities.d.ts.map +1 -0
- package/dist/seed-detachment-abilities.js +137 -0
- package/dist/seed-detachment-abilities.js.map +1 -0
- package/dist/share/codec.d.ts +83 -0
- package/dist/share/codec.d.ts.map +1 -0
- package/dist/share/codec.js +230 -0
- package/dist/share/codec.js.map +1 -0
- package/dist/share/index.d.ts +30 -0
- package/dist/share/index.d.ts.map +1 -0
- package/dist/share/index.js +36 -0
- package/dist/share/index.js.map +1 -0
- package/dist/share/registry.d.ts +42 -0
- package/dist/share/registry.d.ts.map +1 -0
- package/dist/share/registry.generated.d.ts +4 -0
- package/dist/share/registry.generated.d.ts.map +1 -0
- package/dist/share/registry.generated.js +4 -0
- package/dist/share/registry.generated.js.map +1 -0
- package/dist/share/registry.js +68 -0
- package/dist/share/registry.js.map +1 -0
- package/dist/translate/condition.d.ts +1 -0
- package/dist/translate/condition.d.ts.map +1 -1
- package/dist/translate/condition.js +101 -7
- package/dist/translate/condition.js.map +1 -1
- package/dist/translate/effect.d.ts +28 -20
- package/dist/translate/effect.d.ts.map +1 -1
- package/dist/translate/effect.js +483 -114
- package/dist/translate/effect.js.map +1 -1
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +1 -0
- package/dist/validate.js.map +1 -1
- package/package.json +4 -1
- package/schemas/core/allied-rule.schema.json +115 -0
- package/schemas/core/detachment.schema.json +44 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"roster-resolve.js","sourceRoot":"","sources":["../../src/data/roster-resolve.ts"],"names":[],"mappings":"AAYA;;;;;;;;;GASG;AACH,MAAM,UAAU,iBAAiB,CAC/B,UAAsB,EACtB,OAAgB;IAEhB,MAAM,EAAE,GAAG,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;IAC7B,IAAI,EAAE,KAAK,IAAI;QAAE,OAAO,SAAS,CAAC;IAClC,OAAO,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;AAC/B,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,oBAAoB,CAClC,OAAwB,EACxB,OAAgB;IAEhB,MAAM,GAAG,GAA4C,EAAE,CAAC;IACxD,KAAK,MAAM,CAAC,IAAI,OAAO,EAAE,CAAC;QACxB,MAAM,EAAE,GAAG,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC;QACpB,IAAI,EAAE,KAAK,IAAI;YAAE,SAAS;QAC1B,MAAM,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QACvC,IAAI,CAAC,MAAM;YAAE,SAAS;QACtB,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC;IACvC,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,qBAAqB,CACnC,MAAc,EACd,eAAuB;IAEvB,OAAO,MAAM,CAAC,KAAK,CAAC,IAAI,CACtB,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,iBAAiB,EAAE,aAAa,CAAC,EAAE,KAAK,eAAe,CACjE,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,yBAAyB,CACvC,MAAc,EACd,MAAc;IAEd,MAAM,IAAI,GAAG,IAAI,GAAG,EAAc,CAAC;IACnC,MAAM,GAAG,GAAiB,EAAE,CAAC;IAC7B,MAAM,GAAG,GAAG,CAAC,CAAyB,EAAE,EAAE;QACxC,IAAI,CAAC,CAAC,IAAI,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;YAAE,OAAO;QAC9B,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;QACZ,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IACd,CAAC,CAAC;IAEF,KAAK,MAAM,CAAC,IAAI,MAAM,CAAC,KAAK,EAAE,CAAC;QAC7B,iDAAiD;QACjD,IAAI,CAAC,CAAC,iBAAiB,EAAE,aAAa,CAAC,EAAE,KAAK,MAAM;YAAE,GAAG,CAAC,CAAC,CAAC,CAAC;QAC7D,iEAAiE;QACjE,IAAI,CAAC,CAAC,GAAG,CAAC,EAAE,KAAK,MAAM,IAAI,CAAC,CAAC,iBAAiB,EAAE,CAAC;YAC/C,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,KAAK,CAAC,CAAC,iBAAkB,CAAC,aAAa,CAAC,EAAE,CAAC,CAAC,CAAC;QACpF,CAAC;IACH,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC","sourcesContent":["/**\n * Bridge helpers between the importer's flat-data {@link Roster} types and\n * the linked {@link UnitView} the cruncher consumes. The importer ships\n * unit entries as plain interfaces (`RosterUnit` is data, not behaviour),\n * so the lookup is a free function rather than a method.\n *\n * @packageDocumentation\n */\nimport type { Roster, RosterUnit, RosterWargear } from \"../import/types.js\";\nimport type { Dataset } from \"./dataset.js\";\nimport type { UnitView, WeaponView } from \"./entities.js\";\n\n/**\n * Resolve a roster's unit entry against the dataset, returning the linked\n * {@link UnitView}. Returns `undefined` when:\n * - the roster's `ref.id` is `null` (the importer couldn't match the unit), or\n * - the id doesn't appear in the dataset (e.g. the roster was authored\n * against an older dataslate than the bundled one).\n *\n * Doesn't surface diagnostics — the caller already has them on the roster's\n * own `diagnostics` field.\n */\nexport function resolveRosterUnit(\n rosterUnit: RosterUnit,\n dataset: Dataset,\n): UnitView | undefined {\n const id = rosterUnit.ref.id;\n if (id === null) return undefined;\n return dataset.units.get(id);\n}\n\n/**\n * Resolve every wargear entry on a roster unit to a {@link WeaponView},\n * keeping each entry's count alongside. Unresolved entries are dropped\n * silently (matching {@link resolveRosterUnit}). Useful when the SPA\n * needs to enumerate firing options after the user picks a roster unit.\n */\nexport function resolveRosterWargear(\n wargear: RosterWargear[],\n dataset: Dataset,\n): { weapon: WeaponView; count: number }[] {\n const out: { weapon: WeaponView; count: number }[] = [];\n for (const w of wargear) {\n const id = w.ref.id;\n if (id === null) continue;\n const weapon = dataset.weapons.get(id);\n if (!weapon) continue;\n out.push({ weapon, count: w.count });\n }\n return out;\n}\n\n/**\n * The roster's leader entry attached to `bodyguardUnitId`, if any. Import\n * stores the inferred (always-provisional) attachment on the *leader's*\n * {@link RosterUnit}, pointing down to its bodyguard via\n * `leader_attachment.bodyguard_ref`. Selection UIs start from the body unit,\n * so this scans for the leader whose `bodyguard_ref.id` matches. Returns\n * `undefined` when no leader in the roster is attached to that unit (the\n * common case — attachments are optional at game start).\n */\nexport function resolveAttachedLeader(\n roster: Roster,\n bodyguardUnitId: string,\n): RosterUnit | undefined {\n return roster.units.find(\n (u) => u.leader_attachment?.bodyguard_ref.id === bodyguardUnitId,\n );\n}\n\n/**\n * Every roster unit attached to `unitId`, resolved from *either* end of the\n * attachment. A leader+bodyguard are one combined unit, so a selection UI may\n * start from either half:\n * - `unitId` is the **bodyguard** → the leader(s) whose\n * `leader_attachment.bodyguard_ref.id` points at it (body-first, the\n * {@link resolveAttachedLeader} direction), and\n * - `unitId` is the **leader** → the bodyguard its own `leader_attachment`\n * points to.\n * Returns the partner {@link RosterUnit}s (deduped, source order). Empty when\n * the unit has no attachment in this roster — the common case, since\n * attachments are optional at game start. Shaped as a list to carry 11th\n * edition's multi-member attachments without an API change.\n */\nexport function resolveAttachmentPartners(\n roster: Roster,\n unitId: string,\n): RosterUnit[] {\n const seen = new Set<RosterUnit>();\n const out: RosterUnit[] = [];\n const add = (u: RosterUnit | undefined) => {\n if (!u || seen.has(u)) return;\n seen.add(u);\n out.push(u);\n };\n\n for (const u of roster.units) {\n // Body-first: leaders pointing down at `unitId`.\n if (u.leader_attachment?.bodyguard_ref.id === unitId) add(u);\n // Leader-first: `unitId`'s own entry points down at a bodyguard.\n if (u.ref.id === unitId && u.leader_attachment) {\n add(roster.units.find((b) => b.ref.id === u.leader_attachment!.bodyguard_ref.id));\n }\n }\n return out;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"roster-resolve.js","sourceRoot":"","sources":["../../src/data/roster-resolve.ts"],"names":[],"mappings":"AAYA;;;;;;;;;GASG;AACH,MAAM,UAAU,iBAAiB,CAC/B,UAAsB,EACtB,OAAgB;IAEhB,MAAM,EAAE,GAAG,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;IAC7B,IAAI,EAAE,KAAK,IAAI;QAAE,OAAO,SAAS,CAAC;IAClC,OAAO,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;AAC/B,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,oBAAoB,CAClC,OAAwB,EACxB,OAAgB;IAEhB,MAAM,GAAG,GAA4C,EAAE,CAAC;IACxD,KAAK,MAAM,CAAC,IAAI,OAAO,EAAE,CAAC;QACxB,MAAM,EAAE,GAAG,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC;QACpB,IAAI,EAAE,KAAK,IAAI;YAAE,SAAS;QAC1B,MAAM,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QACvC,IAAI,CAAC,MAAM;YAAE,SAAS;QACtB,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC;IACvC,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,qBAAqB,CACnC,MAAc,EACd,eAAuB;IAEvB,OAAO,MAAM,CAAC,KAAK,CAAC,IAAI,CACtB,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,iBAAiB,EAAE,aAAa,CAAC,EAAE,KAAK,eAAe,CACjE,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,yBAAyB,CACvC,MAAc,EACd,MAAc;IAEd,MAAM,IAAI,GAAG,IAAI,GAAG,EAAc,CAAC;IACnC,MAAM,GAAG,GAAiB,EAAE,CAAC;IAC7B,MAAM,GAAG,GAAG,CAAC,CAAyB,EAAE,EAAE;QACxC,IAAI,CAAC,CAAC,IAAI,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;YAAE,OAAO;QAC9B,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;QACZ,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IACd,CAAC,CAAC;IAEF,KAAK,MAAM,CAAC,IAAI,MAAM,CAAC,KAAK,EAAE,CAAC;QAC7B,iDAAiD;QACjD,IAAI,CAAC,CAAC,iBAAiB,EAAE,aAAa,CAAC,EAAE,KAAK,MAAM;YAAE,GAAG,CAAC,CAAC,CAAC,CAAC;QAC7D,iEAAiE;QACjE,IAAI,CAAC,CAAC,GAAG,CAAC,EAAE,KAAK,MAAM,IAAI,CAAC,CAAC,iBAAiB,EAAE,CAAC;YAC/C,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,KAAK,CAAC,CAAC,iBAAkB,CAAC,aAAa,CAAC,EAAE,CAAC,CAAC,CAAC;QACpF,CAAC;IACH,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,iBAAiB,CAAC,MAAc;IAC9C,OAAO,MAAM,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC;AAC/B,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,mBAAmB,CAAC,MAAc;IAChD,OAAO,MAAM,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,IAAI,IAAI,CAAC;AAC/C,CAAC","sourcesContent":["/**\n * Bridge helpers between the importer's flat-data {@link Roster} types and\n * the linked {@link UnitView} the cruncher consumes. The importer ships\n * unit entries as plain interfaces (`RosterUnit` is data, not behaviour),\n * so the lookup is a free function rather than a method.\n *\n * @packageDocumentation\n */\nimport type { Roster, RosterDetachment, RosterUnit, RosterWargear } from \"../import/types.js\";\nimport type { Dataset } from \"./dataset.js\";\nimport type { UnitView, WeaponView } from \"./entities.js\";\n\n/**\n * Resolve a roster's unit entry against the dataset, returning the linked\n * {@link UnitView}. Returns `undefined` when:\n * - the roster's `ref.id` is `null` (the importer couldn't match the unit), or\n * - the id doesn't appear in the dataset (e.g. the roster was authored\n * against an older dataslate than the bundled one).\n *\n * Doesn't surface diagnostics — the caller already has them on the roster's\n * own `diagnostics` field.\n */\nexport function resolveRosterUnit(\n rosterUnit: RosterUnit,\n dataset: Dataset,\n): UnitView | undefined {\n const id = rosterUnit.ref.id;\n if (id === null) return undefined;\n return dataset.units.get(id);\n}\n\n/**\n * Resolve every wargear entry on a roster unit to a {@link WeaponView},\n * keeping each entry's count alongside. Unresolved entries are dropped\n * silently (matching {@link resolveRosterUnit}). Useful when the SPA\n * needs to enumerate firing options after the user picks a roster unit.\n */\nexport function resolveRosterWargear(\n wargear: RosterWargear[],\n dataset: Dataset,\n): { weapon: WeaponView; count: number }[] {\n const out: { weapon: WeaponView; count: number }[] = [];\n for (const w of wargear) {\n const id = w.ref.id;\n if (id === null) continue;\n const weapon = dataset.weapons.get(id);\n if (!weapon) continue;\n out.push({ weapon, count: w.count });\n }\n return out;\n}\n\n/**\n * The roster's leader entry attached to `bodyguardUnitId`, if any. Import\n * stores the inferred (always-provisional) attachment on the *leader's*\n * {@link RosterUnit}, pointing down to its bodyguard via\n * `leader_attachment.bodyguard_ref`. Selection UIs start from the body unit,\n * so this scans for the leader whose `bodyguard_ref.id` matches. Returns\n * `undefined` when no leader in the roster is attached to that unit (the\n * common case — attachments are optional at game start).\n */\nexport function resolveAttachedLeader(\n roster: Roster,\n bodyguardUnitId: string,\n): RosterUnit | undefined {\n return roster.units.find(\n (u) => u.leader_attachment?.bodyguard_ref.id === bodyguardUnitId,\n );\n}\n\n/**\n * Every roster unit attached to `unitId`, resolved from *either* end of the\n * attachment. A leader+bodyguard are one combined unit, so a selection UI may\n * start from either half:\n * - `unitId` is the **bodyguard** → the leader(s) whose\n * `leader_attachment.bodyguard_ref.id` points at it (body-first, the\n * {@link resolveAttachedLeader} direction), and\n * - `unitId` is the **leader** → the bodyguard its own `leader_attachment`\n * points to.\n * Returns the partner {@link RosterUnit}s (deduped, source order). Empty when\n * the unit has no attachment in this roster — the common case, since\n * attachments are optional at game start. Shaped as a list to carry 11th\n * edition's multi-member attachments without an API change.\n */\nexport function resolveAttachmentPartners(\n roster: Roster,\n unitId: string,\n): RosterUnit[] {\n const seen = new Set<RosterUnit>();\n const out: RosterUnit[] = [];\n const add = (u: RosterUnit | undefined) => {\n if (!u || seen.has(u)) return;\n seen.add(u);\n out.push(u);\n };\n\n for (const u of roster.units) {\n // Body-first: leaders pointing down at `unitId`.\n if (u.leader_attachment?.bodyguard_ref.id === unitId) add(u);\n // Leader-first: `unitId`'s own entry points down at a bodyguard.\n if (u.ref.id === unitId && u.leader_attachment) {\n add(roster.units.find((b) => b.ref.id === u.leader_attachment!.bodyguard_ref.id));\n }\n }\n return out;\n}\n\n/**\n * The roster's **primary detachment** — the first in source order. 11th\n * edition rosters may field several detachments under a detachment-point cap,\n * but single-detachment consumers (and every pre-11e list) just want \"the\"\n * detachment. This names that choice so callers stop reaching into\n * `detachments[0]` directly. Returns `undefined` only when the roster carries\n * no detachment at all (the source declared none, or none parsed).\n */\nexport function primaryDetachment(roster: Roster): RosterDetachment | undefined {\n return roster.detachments[0];\n}\n\n/**\n * The resolved entity id of the {@link primaryDetachment}. `null` when the\n * roster carries no detachment, or when the primary one failed to resolve to a\n * known id (the raw name is still retained on the detachment's `ref`).\n */\nexport function primaryDetachmentId(roster: Roster): string | null {\n return roster.detachments[0]?.ref.id ?? null;\n}\n"]}
|
package/dist/data/types.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* @packageDocumentation
|
|
9
9
|
*/
|
|
10
|
-
import type { AbilityDSLEntry, DeploymentPattern, Detachment, Enhancement, Faction, ForceDisposition, GameVersion, HullShape, InteractionFlag, LeaderAttachment, Mission, MissionMatchup, PhaseMapping, ResourcePool, SecondaryCard, Stratagem, TerrainLayout, TargetProfile, TerrainTemplate, TimingFlag, Unit, UnitComposition, Wargear, WargearOption, Weapon, WeaponKeyword } from "../generated.js";
|
|
10
|
+
import type { AbilityDSLEntry, AlliedRule, DeploymentPattern, Detachment, Enhancement, Faction, ForceDisposition, GameVersion, HullShape, InteractionFlag, LeaderAttachment, Mission, MissionMatchup, PhaseMapping, ResourcePool, SecondaryCard, Stratagem, TerrainLayout, TargetProfile, TerrainTemplate, TimingFlag, Unit, UnitComposition, Wargear, WargearOption, Weapon, WeaponKeyword } from "../generated.js";
|
|
11
11
|
/**
|
|
12
12
|
* Every entity collection in the dataset, keyed by camelCase collection name.
|
|
13
13
|
*
|
|
@@ -28,6 +28,8 @@ export interface RawData {
|
|
|
28
28
|
/** Phase assignments, joined to abilities/stratagems/etc. via `source_id`. */
|
|
29
29
|
phaseMappings: PhaseMapping[];
|
|
30
30
|
detachments: Detachment[];
|
|
31
|
+
/** Allied-detachment / 'soup' rules: how units lacking the army faction keyword may be included. */
|
|
32
|
+
alliedRules: AlliedRule[];
|
|
31
33
|
stratagems: Stratagem[];
|
|
32
34
|
enhancements: Enhancement[];
|
|
33
35
|
leaderAttachments: LeaderAttachment[];
|
package/dist/data/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/data/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EACV,eAAe,EACf,iBAAiB,EACjB,UAAU,EACV,WAAW,EACX,OAAO,EACP,gBAAgB,EAChB,WAAW,EACX,SAAS,EACT,eAAe,EACf,gBAAgB,EAChB,OAAO,EACP,cAAc,EACd,YAAY,EACZ,YAAY,EACZ,aAAa,EACb,SAAS,EACT,aAAa,EACb,aAAa,EACb,eAAe,EACf,UAAU,EACV,IAAI,EACJ,eAAe,EACf,OAAO,EACP,aAAa,EACb,MAAM,EACN,aAAa,EACd,MAAM,iBAAiB,CAAC;AAEzB;;;;;;GAMG;AACH,MAAM,WAAW,OAAO;IACtB,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,6EAA6E;IAC7E,cAAc,EAAE,aAAa,EAAE,CAAC;IAChC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,kFAAkF;IAClF,cAAc,EAAE,aAAa,EAAE,CAAC;IAChC,QAAQ,EAAE,OAAO,EAAE,CAAC;IACpB,4EAA4E;IAC5E,SAAS,EAAE,eAAe,EAAE,CAAC;IAC7B,8EAA8E;IAC9E,aAAa,EAAE,YAAY,EAAE,CAAC;IAC9B,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,UAAU,EAAE,SAAS,EAAE,CAAC;IACxB,YAAY,EAAE,WAAW,EAAE,CAAC;IAC5B,iBAAiB,EAAE,gBAAgB,EAAE,CAAC;IACtC,gBAAgB,EAAE,eAAe,EAAE,CAAC;IACpC,cAAc,EAAE,aAAa,EAAE,CAAC;IAChC,mFAAmF;IACnF,OAAO,EAAE,OAAO,EAAE,CAAC;IACnB,YAAY,EAAE,WAAW,EAAE,CAAC;IAC5B,QAAQ,EAAE,OAAO,EAAE,CAAC;IACpB,eAAe,EAAE,cAAc,EAAE,CAAC;IAClC,YAAY,EAAE,aAAa,EAAE,CAAC;IAC9B,kBAAkB,EAAE,iBAAiB,EAAE,CAAC;IACxC,iBAAiB,EAAE,gBAAgB,EAAE,CAAC;IACtC,qEAAqE;IACrE,gBAAgB,EAAE,eAAe,EAAE,CAAC;IACpC,2EAA2E;IAC3E,cAAc,EAAE,aAAa,EAAE,CAAC;IAChC,4EAA4E;IAC5E,UAAU,EAAE,SAAS,EAAE,CAAC;IACxB,aAAa,EAAE,YAAY,EAAE,CAAC;IAC9B,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,gBAAgB,EAAE,eAAe,EAAE,CAAC;CACrC;AAED,uEAAuE;AACvE,wBAAgB,YAAY,IAAI,OAAO,
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/data/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EACV,eAAe,EACf,UAAU,EACV,iBAAiB,EACjB,UAAU,EACV,WAAW,EACX,OAAO,EACP,gBAAgB,EAChB,WAAW,EACX,SAAS,EACT,eAAe,EACf,gBAAgB,EAChB,OAAO,EACP,cAAc,EACd,YAAY,EACZ,YAAY,EACZ,aAAa,EACb,SAAS,EACT,aAAa,EACb,aAAa,EACb,eAAe,EACf,UAAU,EACV,IAAI,EACJ,eAAe,EACf,OAAO,EACP,aAAa,EACb,MAAM,EACN,aAAa,EACd,MAAM,iBAAiB,CAAC;AAEzB;;;;;;GAMG;AACH,MAAM,WAAW,OAAO;IACtB,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,6EAA6E;IAC7E,cAAc,EAAE,aAAa,EAAE,CAAC;IAChC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,kFAAkF;IAClF,cAAc,EAAE,aAAa,EAAE,CAAC;IAChC,QAAQ,EAAE,OAAO,EAAE,CAAC;IACpB,4EAA4E;IAC5E,SAAS,EAAE,eAAe,EAAE,CAAC;IAC7B,8EAA8E;IAC9E,aAAa,EAAE,YAAY,EAAE,CAAC;IAC9B,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,oGAAoG;IACpG,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,UAAU,EAAE,SAAS,EAAE,CAAC;IACxB,YAAY,EAAE,WAAW,EAAE,CAAC;IAC5B,iBAAiB,EAAE,gBAAgB,EAAE,CAAC;IACtC,gBAAgB,EAAE,eAAe,EAAE,CAAC;IACpC,cAAc,EAAE,aAAa,EAAE,CAAC;IAChC,mFAAmF;IACnF,OAAO,EAAE,OAAO,EAAE,CAAC;IACnB,YAAY,EAAE,WAAW,EAAE,CAAC;IAC5B,QAAQ,EAAE,OAAO,EAAE,CAAC;IACpB,eAAe,EAAE,cAAc,EAAE,CAAC;IAClC,YAAY,EAAE,aAAa,EAAE,CAAC;IAC9B,kBAAkB,EAAE,iBAAiB,EAAE,CAAC;IACxC,iBAAiB,EAAE,gBAAgB,EAAE,CAAC;IACtC,qEAAqE;IACrE,gBAAgB,EAAE,eAAe,EAAE,CAAC;IACpC,2EAA2E;IAC3E,cAAc,EAAE,aAAa,EAAE,CAAC;IAChC,4EAA4E;IAC5E,UAAU,EAAE,SAAS,EAAE,CAAC;IACxB,aAAa,EAAE,YAAY,EAAE,CAAC;IAC9B,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,gBAAgB,EAAE,eAAe,EAAE,CAAC;CACrC;AAED,uEAAuE;AACvE,wBAAgB,YAAY,IAAI,OAAO,CA8BtC"}
|
package/dist/data/types.js
CHANGED
package/dist/data/types.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/data/types.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/data/types.ts"],"names":[],"mappings":"AAqFA,uEAAuE;AACvE,MAAM,UAAU,YAAY;IAC1B,OAAO;QACL,KAAK,EAAE,EAAE;QACT,cAAc,EAAE,EAAE;QAClB,OAAO,EAAE,EAAE;QACX,cAAc,EAAE,EAAE;QAClB,QAAQ,EAAE,EAAE;QACZ,SAAS,EAAE,EAAE;QACb,aAAa,EAAE,EAAE;QACjB,WAAW,EAAE,EAAE;QACf,WAAW,EAAE,EAAE;QACf,UAAU,EAAE,EAAE;QACd,YAAY,EAAE,EAAE;QAChB,iBAAiB,EAAE,EAAE;QACrB,gBAAgB,EAAE,EAAE;QACpB,cAAc,EAAE,EAAE;QAClB,OAAO,EAAE,EAAE;QACX,YAAY,EAAE,EAAE;QAChB,QAAQ,EAAE,EAAE;QACZ,eAAe,EAAE,EAAE;QACnB,YAAY,EAAE,EAAE;QAChB,kBAAkB,EAAE,EAAE;QACtB,iBAAiB,EAAE,EAAE;QACrB,gBAAgB,EAAE,EAAE;QACpB,cAAc,EAAE,EAAE;QAClB,UAAU,EAAE,EAAE;QACd,aAAa,EAAE,EAAE;QACjB,WAAW,EAAE,EAAE;QACf,gBAAgB,EAAE,EAAE;KACrB,CAAC;AACJ,CAAC","sourcesContent":["/**\n * The shape of the embedded data bundle: one named array per entity collection.\n *\n * `RawData` is the boundary between the generated JSON-Schema types and the\n * linked view layer. The codegen ({@link file://../codegen-data.ts}) emits a\n * `RAW_DATA: RawData` constant; {@link Dataset} wraps it with linked accessors.\n *\n * @packageDocumentation\n */\nimport type {\n AbilityDSLEntry,\n AlliedRule,\n DeploymentPattern,\n Detachment,\n Enhancement,\n Faction,\n ForceDisposition,\n GameVersion,\n HullShape,\n InteractionFlag,\n LeaderAttachment,\n Mission,\n MissionMatchup,\n PhaseMapping,\n ResourcePool,\n SecondaryCard,\n Stratagem,\n TerrainLayout,\n TargetProfile,\n TerrainTemplate,\n TimingFlag,\n Unit,\n UnitComposition,\n Wargear,\n WargearOption,\n Weapon,\n WeaponKeyword,\n} from \"../generated.js\";\n\n/**\n * Every entity collection in the dataset, keyed by camelCase collection name.\n *\n * Collections with no authored data yet (e.g. `interactionFlags`) are present\n * as empty arrays so the API surface is stable and new data flows through\n * automatically once authored.\n */\nexport interface RawData {\n units: Unit[];\n /** Named target archetypes referencing real units (faction_id + unit_id). */\n targetProfiles: TargetProfile[];\n weapons: Weapon[];\n /** Catalog of weapon keywords (Lethal Hits, Sustained Hits N, Anti-X N+, ...). */\n weaponKeywords: WeaponKeyword[];\n factions: Faction[];\n /** Community-authored ability mechanics (key is `ability_id`, not `id`). */\n abilities: AbilityDSLEntry[];\n /** Phase assignments, joined to abilities/stratagems/etc. via `source_id`. */\n phaseMappings: PhaseMapping[];\n detachments: Detachment[];\n /** Allied-detachment / 'soup' rules: how units lacking the army faction keyword may be included. */\n alliedRules: AlliedRule[];\n stratagems: Stratagem[];\n enhancements: Enhancement[];\n leaderAttachments: LeaderAttachment[];\n unitCompositions: UnitComposition[];\n wargearOptions: WargearOption[];\n /** Non-weapon wargear items (icons, attachments) referenced by wargear options. */\n wargear: Wargear[];\n gameVersions: GameVersion[];\n missions: Mission[];\n missionMatchups: MissionMatchup[];\n missionCards: SecondaryCard[];\n deploymentPatterns: DeploymentPattern[];\n forceDispositions: ForceDisposition[];\n /** Reusable terrain catalog: standard areas and scenery features. */\n terrainTemplates: TerrainTemplate[];\n /** Terrain layouts: arrangements of catalog/inline pieces on the board. */\n terrainLayouts: TerrainLayout[];\n /** Reusable model collision hulls (polygon footprints) referenced by id. */\n hullShapes: HullShape[];\n resourcePools: ResourcePool[];\n timingFlags: TimingFlag[];\n interactionFlags: InteractionFlag[];\n}\n\n/** A `RawData` with every collection initialised to an empty array. */\nexport function emptyRawData(): RawData {\n return {\n units: [],\n targetProfiles: [],\n weapons: [],\n weaponKeywords: [],\n factions: [],\n abilities: [],\n phaseMappings: [],\n detachments: [],\n alliedRules: [],\n stratagems: [],\n enhancements: [],\n leaderAttachments: [],\n unitCompositions: [],\n wargearOptions: [],\n wargear: [],\n gameVersions: [],\n missions: [],\n missionMatchups: [],\n missionCards: [],\n deploymentPatterns: [],\n forceDispositions: [],\n terrainTemplates: [],\n terrainLayouts: [],\n hullShapes: [],\n resourcePools: [],\n timingFlags: [],\n interactionFlags: [],\n };\n}\n"]}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type Block } from "./pack-blocks.js";
|
|
2
|
+
export { slug, titleCase } from "./pack-blocks.js";
|
|
1
3
|
export interface ExtractedStratagem {
|
|
2
4
|
id: string;
|
|
3
5
|
name: string;
|
|
@@ -27,16 +29,6 @@ export interface PackExtract {
|
|
|
27
29
|
source_pack: string;
|
|
28
30
|
detachments: ExtractedDetachment[];
|
|
29
31
|
}
|
|
30
|
-
/** A positioned text block from `pdftotext -bbox-layout`. `gy` is page-globalised y. */
|
|
31
|
-
interface Block {
|
|
32
|
-
x: number;
|
|
33
|
-
gy: number;
|
|
34
|
-
text: string;
|
|
35
|
-
}
|
|
36
|
-
/** Title/caps name → kebab id, matching existing entity-id conventions. */
|
|
37
|
-
export declare function slug(name: string): string;
|
|
38
|
-
/** Title-case a pack ALL-CAPS header for storage as a display name. */
|
|
39
|
-
export declare function titleCase(raw: string): string;
|
|
40
32
|
/** Extract stratagems, enhancements, and rule name from a detachment's blocks. */
|
|
41
33
|
export declare function parseDetachmentBlocks(blocks: Block[], detachmentId: string): Omit<ExtractedDetachment, "id" | "name">;
|
|
42
34
|
/**
|
|
@@ -51,6 +43,19 @@ export declare function parseToc(tocText: string): Array<{
|
|
|
51
43
|
page: number;
|
|
52
44
|
section?: boolean;
|
|
53
45
|
}>;
|
|
46
|
+
/** One detachment's slice of the pack: its id, display name, and bounded blocks. */
|
|
47
|
+
export interface DetachmentSegment {
|
|
48
|
+
id: string;
|
|
49
|
+
name: string;
|
|
50
|
+
blocks: Block[];
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Segment a pack into per-detachment block slices. Detachment names come (ordered)
|
|
54
|
+
* from the TOC; content boundaries come from block positions: each detachment runs
|
|
55
|
+
* from its title block to the next detachment, the next non-detachment section
|
|
56
|
+
* marker, or a ~2-page backstop — whichever comes first. Shared by `extractPack`
|
|
57
|
+
* (names/metadata) and `author-input-pack` (rule bodies) so both segment identically.
|
|
58
|
+
*/
|
|
59
|
+
export declare function detachmentSegments(pdf: string): DetachmentSegment[];
|
|
54
60
|
export declare function extractPack(pdf: string, faction: string): PackExtract;
|
|
55
|
-
export {};
|
|
56
61
|
//# sourceMappingURL=extract-faction-pack.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"extract-faction-pack.d.ts","sourceRoot":"","sources":["../src/extract-faction-pack.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"extract-faction-pack.d.ts","sourceRoot":"","sources":["../src/extract-faction-pack.ts"],"names":[],"mappings":"AAoCA,OAAO,EACL,KAAK,KAAK,EASX,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AA6BnD,MAAM,WAAW,kBAAkB;IACjC,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AACD,MAAM,WAAW,oBAAoB;IACnC,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,IAAI,CAAC;IACX,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AACD,MAAM,WAAW,mBAAmB;IAClC,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,oBAAoB,EAAE,MAAM,GAAG,IAAI,CAAC;IACpC,UAAU,EAAE,kBAAkB,EAAE,CAAC;IACjC,YAAY,EAAE,oBAAoB,EAAE,CAAC;IACrC,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AACD,MAAM,WAAW,WAAW;IAC1B,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,mBAAmB,EAAE,CAAC;CACpC;AAsBD,kFAAkF;AAClF,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,KAAK,EAAE,EAAE,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC,mBAAmB,EAAE,IAAI,GAAG,MAAM,CAAC,CAsGrH;AAED;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC,CAsBlG;AAED,oFAAoF;AACpF,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,KAAK,EAAE,CAAC;CACjB;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,iBAAiB,EAAE,CAkCnE;AAED,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,WAAW,CAOrE"}
|
|
@@ -31,10 +31,11 @@
|
|
|
31
31
|
* npx tsx tools/src/extract-faction-pack.ts <pdf-path> --faction <faction-id>
|
|
32
32
|
* npx tsx tools/src/extract-faction-pack.ts --all --dir <pack-pdf-dir>
|
|
33
33
|
*/
|
|
34
|
-
import { execFileSync } from "node:child_process";
|
|
35
34
|
import { readdirSync, writeFileSync, mkdirSync } from "node:fs";
|
|
36
35
|
import { resolve, basename } from "node:path";
|
|
37
36
|
import { fileURLToPath } from "node:url";
|
|
37
|
+
import { runPdftotext, allBlocks, slug, titleCase, normCaps, isCapsHeader, sameColumn, stripCp, } from "./pack-blocks.js";
|
|
38
|
+
export { slug, titleCase } from "./pack-blocks.js";
|
|
38
39
|
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
39
40
|
const DATA_ROOT = resolve(__dirname, "../../data");
|
|
40
41
|
const OUT_DIR = resolve(DATA_ROOT, "_audit", "faction-pack-input");
|
|
@@ -56,69 +57,9 @@ const STRAT_TYPE = {
|
|
|
56
57
|
};
|
|
57
58
|
const TYPE_RE = /(BATTLE TACTIC|STRATEGIC PLOY|EPIC DEED|WARGEAR)\s+STRATAGEM$/;
|
|
58
59
|
const PHASES = ["command", "movement", "shooting", "charge", "fight"];
|
|
59
|
-
const SECTION_WORDS = /^(DETACHMENT RULES?|ENHANCEMENTS?|STRATAGEMS?|KEYWORDS?|RESTRICTIONS?)$/;
|
|
60
60
|
// Note: "Legends Datasheets" (not bare "Legends") — else it eats the detachment
|
|
61
61
|
// "Legends of Saga and Song".
|
|
62
62
|
const SECTION_TERMINATORS = /^(Datasheets|Rules Updates|Legends Datasheets|Imperial Armour|Index)\b/i;
|
|
63
|
-
/** Title/caps name → kebab id, matching existing entity-id conventions. */
|
|
64
|
-
export function slug(name) {
|
|
65
|
-
return name
|
|
66
|
-
.toLowerCase()
|
|
67
|
-
.replace(/[’']/g, "")
|
|
68
|
-
.replace(/&/g, " and ")
|
|
69
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
70
|
-
.replace(/^-+|-+$/g, "");
|
|
71
|
-
}
|
|
72
|
-
/** Title-case a pack ALL-CAPS header for storage as a display name. */
|
|
73
|
-
export function titleCase(raw) {
|
|
74
|
-
const small = new Set(["of", "the", "and", "to", "a", "in", "for"]);
|
|
75
|
-
return raw
|
|
76
|
-
.trim()
|
|
77
|
-
.toLowerCase()
|
|
78
|
-
.split(/\s+/)
|
|
79
|
-
.map((w, i) => (i > 0 && small.has(w) ? w : w.charAt(0).toUpperCase() + w.slice(1)))
|
|
80
|
-
.join(" ");
|
|
81
|
-
}
|
|
82
|
-
function runPdftotext(args) {
|
|
83
|
-
try {
|
|
84
|
-
return execFileSync("pdftotext", args, { encoding: "utf-8", maxBuffer: 128 * 1024 * 1024 });
|
|
85
|
-
}
|
|
86
|
-
catch (err) {
|
|
87
|
-
if (err.code === "ENOENT") {
|
|
88
|
-
throw new Error("pdftotext not found — install poppler (e.g. `brew install poppler`).");
|
|
89
|
-
}
|
|
90
|
-
throw err;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
const decodeEntities = (s) => s
|
|
94
|
-
.replace(/&/g, "&")
|
|
95
|
-
.replace(/</g, "<")
|
|
96
|
-
.replace(/>/g, ">")
|
|
97
|
-
.replace(/'/g, "'")
|
|
98
|
-
.replace(/"/g, '"');
|
|
99
|
-
/**
|
|
100
|
-
* Parse the whole pack's bbox-layout HTML into positioned blocks. `gy` globalises
|
|
101
|
-
* y across pages (pageIndex × 100000 + y) so blocks sort in reading order without
|
|
102
|
-
* relying on printed page numbers (which drift from physical pages on later pages
|
|
103
|
-
* when full-bleed art is inserted).
|
|
104
|
-
*/
|
|
105
|
-
function allBlocks(pdf) {
|
|
106
|
-
const html = runPdftotext(["-bbox-layout", pdf, "-"]);
|
|
107
|
-
const blocks = [];
|
|
108
|
-
html.split(/<page\b/).slice(1).forEach((pageHtml, pageIdx) => {
|
|
109
|
-
const offset = pageIdx * 100000;
|
|
110
|
-
for (const m of pageHtml.matchAll(/<block\b([^>]*)>([\s\S]*?)<\/block>/g)) {
|
|
111
|
-
const x = Number(/xMin="([\d.]+)"/.exec(m[1])?.[1] ?? "0");
|
|
112
|
-
const y = Number(/yMin="([\d.]+)"/.exec(m[1])?.[1] ?? "0");
|
|
113
|
-
const words = [...m[2].matchAll(/<word[^>]*>([^<]*)<\/word>/g)].map((w) => decodeEntities(w[1]));
|
|
114
|
-
const text = words.join(" ").replace(/\s+/g, " ").trim();
|
|
115
|
-
if (text)
|
|
116
|
-
blocks.push({ x, gy: offset + y, text });
|
|
117
|
-
}
|
|
118
|
-
});
|
|
119
|
-
return blocks;
|
|
120
|
-
}
|
|
121
|
-
const normCaps = (s) => s.toUpperCase().replace(/[’']/g, "'").replace(/\s+/g, " ").trim();
|
|
122
63
|
/** Datasheet/table headers that can masquerade as caps "enhancement" names. */
|
|
123
64
|
const DATASHEET_NOISE = new Set([
|
|
124
65
|
"RANGED WEAPONS", "MELEE WEAPONS", "RANGE", "WARGEAR OPTIONS", "UNIT COMPOSITION",
|
|
@@ -126,19 +67,6 @@ const DATASHEET_NOISE = new Set([
|
|
|
126
67
|
]);
|
|
127
68
|
/** Non-detachment section headers that bound the last detachment's content. */
|
|
128
69
|
const SECTION_MARKER = /^(DATASHEETS|RULES UPDATES|LEGENDS DATASHEETS|IMPERIAL ARMOUR|INDEX)\b/;
|
|
129
|
-
const isCapsHeader = (t) => /^[A-Z0-9][A-Z0-9'’.,!&/()\- ]*$/.test(t) &&
|
|
130
|
-
/[A-Z]{3,}/.test(t) && // a real word, not a "1CP"/"D6" token
|
|
131
|
-
t.length >= 3 &&
|
|
132
|
-
t.length <= 46 &&
|
|
133
|
-
t.split(" ").length <= 7 &&
|
|
134
|
-
!SECTION_WORDS.test(t) &&
|
|
135
|
-
!/^\d+\s?CP$/.test(t) &&
|
|
136
|
-
!/\bSTRATAGEM$/.test(t);
|
|
137
|
-
const sameColumn = (a, b) => Math.abs(a - b) < 46;
|
|
138
|
-
const stripCp = (t) => {
|
|
139
|
-
const m = t.match(/^(.*?)[\s]+(\d+)\s?CP$/);
|
|
140
|
-
return m ? { name: m[1].trim(), cp: Number(m[2]) } : { name: t, cp: null };
|
|
141
|
-
};
|
|
142
70
|
/** Derive phases / player-turn from a `WHEN:` line (the prose itself is discarded). */
|
|
143
71
|
function parseWhen(when) {
|
|
144
72
|
const low = when.toLowerCase();
|
|
@@ -294,8 +222,14 @@ export function parseToc(tocText) {
|
|
|
294
222
|
}
|
|
295
223
|
return rows;
|
|
296
224
|
}
|
|
297
|
-
|
|
298
|
-
|
|
225
|
+
/**
|
|
226
|
+
* Segment a pack into per-detachment block slices. Detachment names come (ordered)
|
|
227
|
+
* from the TOC; content boundaries come from block positions: each detachment runs
|
|
228
|
+
* from its title block to the next detachment, the next non-detachment section
|
|
229
|
+
* marker, or a ~2-page backstop — whichever comes first. Shared by `extractPack`
|
|
230
|
+
* (names/metadata) and `author-input-pack` (rule bodies) so both segment identically.
|
|
231
|
+
*/
|
|
232
|
+
export function detachmentSegments(pdf) {
|
|
299
233
|
const names = parseToc(runPdftotext(["-f", "1", "-l", "2", pdf, "-"]))
|
|
300
234
|
.filter((t) => !t.section)
|
|
301
235
|
.map((t) => t.name);
|
|
@@ -311,7 +245,7 @@ export function extractPack(pdf, faction) {
|
|
|
311
245
|
.filter((s) => s !== null)
|
|
312
246
|
.sort((a, b) => a.gy - b.gy);
|
|
313
247
|
const sectionGys = blocks.filter((b) => SECTION_MARKER.test(normCaps(b.text))).map((b) => b.gy).sort((a, b) => a - b);
|
|
314
|
-
const
|
|
248
|
+
const segments = [];
|
|
315
249
|
for (let i = 0; i < starts.length; i++) {
|
|
316
250
|
const start = starts[i].gy;
|
|
317
251
|
const nextDet = starts[i + 1]?.gy ?? Infinity;
|
|
@@ -320,10 +254,20 @@ export function extractPack(pdf, faction) {
|
|
|
320
254
|
// Bounds the last detachment when no datasheets/section divider block is
|
|
321
255
|
// detected, so it can't bleed into the datasheet pages that follow.
|
|
322
256
|
const end = Math.min(nextDet, nextSection, start + 200000);
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
257
|
+
segments.push({
|
|
258
|
+
id: slug(starts[i].name),
|
|
259
|
+
name: starts[i].name,
|
|
260
|
+
blocks: blocks.filter((b) => b.gy >= start && b.gy < end),
|
|
261
|
+
});
|
|
326
262
|
}
|
|
263
|
+
return segments;
|
|
264
|
+
}
|
|
265
|
+
export function extractPack(pdf, faction) {
|
|
266
|
+
const detachments = detachmentSegments(pdf).map((seg) => ({
|
|
267
|
+
id: seg.id,
|
|
268
|
+
name: seg.name,
|
|
269
|
+
...parseDetachmentBlocks(seg.blocks, seg.id),
|
|
270
|
+
}));
|
|
327
271
|
return { faction_id: faction, source_pack: basename(pdf), detachments };
|
|
328
272
|
}
|
|
329
273
|
function factionForPack(file) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"extract-faction-pack.js","sourceRoot":"","sources":["../src/extract-faction-pack.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Standardized extractor for GW 11e Faction Pack PDFs → IP-safe staging JSON.\n *\n * The faction packs are the structural source of truth for detachments,\n * stratagems, and enhancements. This tool pulls the *facts* out of a pack —\n * detachment names, stratagem names + CP cost + type + phase, enhancement names,\n * detachment-rule names — and writes them to `data/_audit/faction-pack-input/\n * <faction>.json` for human review before they are merged into `data/core/`.\n *\n * IP firewall: this tool MUST NOT emit GW rules/effect prose. It reads the\n * `WHEN:` line of a stratagem card only to *derive* the structured phase /\n * player-turn fields; the prose itself is never written to the staging file.\n * Stored output is names + numeric/enum metadata only.\n *\n * The packs are *supplements*, not complete codexes: a pack contains only the\n * extra/updated detachments. Absence from a pack does not imply removal. This\n * tool reports only what a pack actually contains; completeness is reconciled\n * against GW's full Detachment-Points list separately.\n *\n * Parsing uses `pdftotext -bbox-layout`, which emits per-word coordinates. GW\n * lays each card out as discrete text blocks; we locate the \"<DET> – <TYPE>\n * STRATAGEM\" label block, then read the name block directly above it, the CP\n * block in the column gutter, and the `WHEN:` block below — all by coordinate.\n * This is layout-independent (column positions differ across packs and pages).\n * Anything the parser cannot determine is left null and recorded in a per-entry\n * `flags` array for the reviewer.\n *\n * Requires `pdftotext` (poppler) on PATH — authoring-time only, not CI.\n *\n * Usage:\n * npx tsx tools/src/extract-faction-pack.ts <pdf-path> --faction <faction-id>\n * npx tsx tools/src/extract-faction-pack.ts --all --dir <pack-pdf-dir>\n */\nimport { execFileSync } from \"node:child_process\";\nimport { readdirSync, writeFileSync, mkdirSync } from \"node:fs\";\nimport { resolve, basename } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nconst __dirname = fileURLToPath(new URL(\".\", import.meta.url));\nconst DATA_ROOT = resolve(__dirname, \"../../data\");\nconst OUT_DIR = resolve(DATA_ROOT, \"_audit\", \"faction-pack-input\");\n\n/** Pack-filename fragment → 40kdc faction id. `--all` uses this to route packs. */\nconst PACK_FACTION: Array<[fragment: string, faction: string]> = [\n [\"space-marines\", \"adeptus-astartes\"],\n [\"black_templars\", \"black-templars\"],\n [\"blood_angels\", \"blood-angels\"],\n [\"dark_angels\", \"dark-angels\"],\n [\"space_wolves\", \"space-wolves\"],\n [\"deathwatch\", \"deathwatch\"],\n [\"grey_knights\", \"grey-knights\"],\n];\n\nconst STRAT_TYPE: Record<string, string> = {\n \"BATTLE TACTIC\": \"battle-tactic\",\n \"STRATEGIC PLOY\": \"strategic-ploy\",\n \"EPIC DEED\": \"epic-deed\",\n WARGEAR: \"wargear\",\n};\nconst TYPE_RE = /(BATTLE TACTIC|STRATEGIC PLOY|EPIC DEED|WARGEAR)\\s+STRATAGEM$/;\nconst PHASES = [\"command\", \"movement\", \"shooting\", \"charge\", \"fight\"] as const;\nconst SECTION_WORDS = /^(DETACHMENT RULES?|ENHANCEMENTS?|STRATAGEMS?|KEYWORDS?|RESTRICTIONS?)$/;\n// Note: \"Legends Datasheets\" (not bare \"Legends\") — else it eats the detachment\n// \"Legends of Saga and Song\".\nconst SECTION_TERMINATORS = /^(Datasheets|Rules Updates|Legends Datasheets|Imperial Armour|Index)\\b/i;\n\nexport interface ExtractedStratagem {\n id: string;\n name: string;\n type: string | null;\n cp_cost: number | null;\n phases: string[];\n player_turn: string | null;\n timing: string | null;\n flags: string[];\n}\nexport interface ExtractedEnhancement {\n id: string;\n name: string;\n cost: null; // never in the pack — comes from the Munitorum Field Manual\n flags: string[];\n}\nexport interface ExtractedDetachment {\n id: string;\n name: string;\n detachment_rule_name: string | null;\n stratagems: ExtractedStratagem[];\n enhancements: ExtractedEnhancement[];\n flags: string[];\n}\nexport interface PackExtract {\n faction_id: string;\n source_pack: string;\n detachments: ExtractedDetachment[];\n}\n\n/** A positioned text block from `pdftotext -bbox-layout`. `gy` is page-globalised y. */\ninterface Block {\n x: number;\n gy: number;\n text: string;\n}\n\n/** Title/caps name → kebab id, matching existing entity-id conventions. */\nexport function slug(name: string): string {\n return name\n .toLowerCase()\n .replace(/[’']/g, \"\")\n .replace(/&/g, \" and \")\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\");\n}\n\n/** Title-case a pack ALL-CAPS header for storage as a display name. */\nexport function titleCase(raw: string): string {\n const small = new Set([\"of\", \"the\", \"and\", \"to\", \"a\", \"in\", \"for\"]);\n return raw\n .trim()\n .toLowerCase()\n .split(/\\s+/)\n .map((w, i) => (i > 0 && small.has(w) ? w : w.charAt(0).toUpperCase() + w.slice(1)))\n .join(\" \");\n}\n\nfunction runPdftotext(args: string[]): string {\n try {\n return execFileSync(\"pdftotext\", args, { encoding: \"utf-8\", maxBuffer: 128 * 1024 * 1024 });\n } catch (err: unknown) {\n if ((err as { code?: string }).code === \"ENOENT\") {\n throw new Error(\"pdftotext not found — install poppler (e.g. `brew install poppler`).\");\n }\n throw err;\n }\n}\n\nconst decodeEntities = (s: string): string =>\n s\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/'/g, \"'\")\n .replace(/"/g, '\"');\n\n/**\n * Parse the whole pack's bbox-layout HTML into positioned blocks. `gy` globalises\n * y across pages (pageIndex × 100000 + y) so blocks sort in reading order without\n * relying on printed page numbers (which drift from physical pages on later pages\n * when full-bleed art is inserted).\n */\nfunction allBlocks(pdf: string): Block[] {\n const html = runPdftotext([\"-bbox-layout\", pdf, \"-\"]);\n const blocks: Block[] = [];\n html.split(/<page\\b/).slice(1).forEach((pageHtml, pageIdx) => {\n const offset = pageIdx * 100000;\n for (const m of pageHtml.matchAll(/<block\\b([^>]*)>([\\s\\S]*?)<\\/block>/g)) {\n const x = Number(/xMin=\"([\\d.]+)\"/.exec(m[1])?.[1] ?? \"0\");\n const y = Number(/yMin=\"([\\d.]+)\"/.exec(m[1])?.[1] ?? \"0\");\n const words = [...m[2].matchAll(/<word[^>]*>([^<]*)<\\/word>/g)].map((w) => decodeEntities(w[1]));\n const text = words.join(\" \").replace(/\\s+/g, \" \").trim();\n if (text) blocks.push({ x, gy: offset + y, text });\n }\n });\n return blocks;\n}\n\nconst normCaps = (s: string): string => s.toUpperCase().replace(/[’']/g, \"'\").replace(/\\s+/g, \" \").trim();\n/** Datasheet/table headers that can masquerade as caps \"enhancement\" names. */\nconst DATASHEET_NOISE = new Set([\n \"RANGED WEAPONS\", \"MELEE WEAPONS\", \"RANGE\", \"WARGEAR OPTIONS\", \"UNIT COMPOSITION\",\n \"KEYWORDS\", \"LEADER\", \"TRANSPORT\", \"DAMAGED\", \"SUPREME COMMANDER\",\n]);\n/** Non-detachment section headers that bound the last detachment's content. */\nconst SECTION_MARKER = /^(DATASHEETS|RULES UPDATES|LEGENDS DATASHEETS|IMPERIAL ARMOUR|INDEX)\\b/;\n\nconst isCapsHeader = (t: string): boolean =>\n /^[A-Z0-9][A-Z0-9'’.,!&/()\\- ]*$/.test(t) &&\n /[A-Z]{3,}/.test(t) && // a real word, not a \"1CP\"/\"D6\" token\n t.length >= 3 &&\n t.length <= 46 &&\n t.split(\" \").length <= 7 &&\n !SECTION_WORDS.test(t) &&\n !/^\\d+\\s?CP$/.test(t) &&\n !/\\bSTRATAGEM$/.test(t);\n\nconst sameColumn = (a: number, b: number): boolean => Math.abs(a - b) < 46;\nconst stripCp = (t: string): { name: string; cp: number | null } => {\n const m = t.match(/^(.*?)[\\s]+(\\d+)\\s?CP$/);\n return m ? { name: m[1].trim(), cp: Number(m[2]) } : { name: t, cp: null };\n};\n\n/** Derive phases / player-turn from a `WHEN:` line (the prose itself is discarded). */\nfunction parseWhen(when: string): { phases: string[]; player_turn: string | null; flag?: string } {\n const low = when.toLowerCase();\n const phases = PHASES.filter((p) => low.includes(`${p} phase`));\n let player_turn: string | null = null;\n if (/opponent['’]?s?\\b/.test(low)) player_turn = \"opponent-turn\";\n else if (/\\byour\\b/.test(low) && phases.length) player_turn = \"your-turn\";\n else if (phases.length) player_turn = \"either\";\n const flag = phases.length ? undefined : \"phase-review\"; // e.g. \"any phase\"\n return { phases, player_turn, flag };\n}\n\n/** Extract stratagems, enhancements, and rule name from a detachment's blocks. */\nexport function parseDetachmentBlocks(blocks: Block[], detachmentId: string): Omit<ExtractedDetachment, \"id\" | \"name\"> {\n const flags: string[] = [];\n const labels = blocks.filter((b) => /\\bSTRATAGEM$/.test(b.text) && !/^STRATAGEMS?$/.test(b.text));\n const cpBlocks = blocks.filter((b) => /^\\d+\\s?CP$/.test(b.text));\n const whenBlocks = blocks.filter((b) => /^WHEN:/i.test(b.text));\n\n // --- detachment rule name: first caps header under a DETACHMENT RULE(S) marker\n let detachment_rule_name: string | null = null;\n const ruleHeader = blocks.filter((b) => /^DETACHMENT RULES?$/.test(b.text)).sort((a, b) => a.gy - b.gy)[0];\n if (ruleHeader) {\n const rn = blocks\n .filter((b) => sameColumn(b.x, ruleHeader.x) && b.gy > ruleHeader.gy && isCapsHeader(b.text))\n .sort((a, b) => a.gy - b.gy)[0];\n if (rn) detachment_rule_name = titleCase(rn.text);\n }\n\n // --- stratagems: keyed off each \"<…> <TYPE> STRATAGEM\" label block.\n const stratagems: ExtractedStratagem[] = [];\n const seenS = new Set<string>();\n for (const label of labels) {\n const typeM = label.text.match(TYPE_RE);\n const type = typeM ? STRAT_TYPE[typeM[1]] : null;\n\n // name: nearest caps-header block directly above the label, same column\n const nameBlock = blocks\n .filter((b) => sameColumn(b.x, label.x) && b.gy < label.gy && isCapsHeader(stripCp(b.text).name))\n .sort((a, b) => b.gy - a.gy)[0];\n if (!nameBlock) continue;\n const { name, cp: inlineCp } = stripCp(nameBlock.text);\n const id = slug(name);\n if (!id || seenS.has(id)) continue;\n seenS.add(id);\n\n // cp: inline on the name block, else the CP block within the card window.\n // Card layouts differ: new detachments put CP at the name's right edge on\n // the name row; reprints put it in the left gutter below the label. So scan\n // a window [name row → just below label] × [card width] and pick the CP\n // nearest the name horizontally (avoids grabbing the adjacent column's CP).\n let cp = inlineCp;\n if (cp === null) {\n const inCard = cpBlocks\n .filter((b) => b.gy >= nameBlock.gy - 15 && b.gy <= label.gy + 70 && b.x >= nameBlock.x - 70 && b.x <= nameBlock.x + 260)\n .sort((a, b) => Math.abs(a.x - nameBlock.x) - Math.abs(b.x - nameBlock.x))[0];\n if (inCard) cp = Number(inCard.text.match(/(\\d+)/)![1]);\n }\n\n // phase: the first WHEN: block below the label in the same column\n const when = whenBlocks\n .filter((b) => sameColumn(b.x, label.x) && b.gy >= label.gy)\n .sort((a, b) => a.gy - b.gy)[0];\n const sFlags: string[] = [];\n let phases: string[] = [];\n let player_turn: string | null = null;\n if (when) {\n const w = parseWhen(when.text);\n phases = w.phases;\n player_turn = w.player_turn;\n if (w.flag) sFlags.push(w.flag);\n } else {\n sFlags.push(\"phase-missing\");\n }\n if (!type) sFlags.push(\"type-missing\");\n if (cp === null) sFlags.push(\"cp-missing\");\n sFlags.push(\"timing-missing\"); // pack rarely states once-per-phase/turn/battle\n\n stratagems.push({ id, name: titleCase(name), type, cp_cost: cp, phases, player_turn, timing: null, flags: sFlags });\n }\n\n if (!detachment_rule_name) flags.push(\"rule-name-missing\");\n\n // --- enhancements (subtractive): a detachment page's caps headers are the\n // title, tagline, rule name, section words, stratagem names, type labels,\n // and enhancement names. Remove every known category; the rest are\n // enhancements. Each must be a name+description pair, i.e. followed by a\n // mixed-case prose block in its column. Cost is never printed in the pack.\n const ruleSlug = detachment_rule_name ? slug(detachment_rule_name) : \"\";\n const hasProseBelow = (b: Block): boolean =>\n blocks\n .filter((o) => sameColumn(o.x, b.x) && o.gy > b.gy)\n .sort((p, q) => p.gy - q.gy)\n .some((o, i) => i === 0 && /[a-z]/.test(o.text)); // nearest block below is prose\n const enhancements: ExtractedEnhancement[] = [];\n const seenE = new Set<string>();\n for (const b of blocks) {\n if (!isCapsHeader(b.text) || DATASHEET_NOISE.has(normCaps(b.text))) continue;\n const isUpgrade = / UPGRADE$/.test(b.text);\n const name = b.text.replace(/ UPGRADE$/, \"\").trim();\n const id = slug(name);\n if (!id || seenE.has(id) || seenS.has(id)) continue; // skip stratagem names\n if (id === detachmentId || id === ruleSlug) continue; // skip title / rule name\n if (!hasProseBelow(b)) continue; // enhancements always have a description\n seenE.add(id);\n enhancements.push({\n id,\n name: titleCase(name),\n cost: null,\n flags: [\"cost-missing\", \"name-needs-review\", ...(isUpgrade ? [\"upgrade-tag?\"] : [])],\n });\n }\n if (!enhancements.length) flags.push(\"no-enhancements-parsed\");\n\n return { detachment_rule_name, stratagems, enhancements, flags };\n}\n\n/**\n * Parse the pack's table of contents (first two pages) into the ordered list of\n * detachments with their start pages. Uses non-layout extraction so the two\n * columns don't interleave; dot leaders are private-font glyphs (non-word\n * chars), so a TOC row is `<Name><2+ non-word><page#>`. Entries are kept only\n * between the \"Detachments\" header and the first non-detachment section.\n */\nexport function parseToc(tocText: string): Array<{ name: string; page: number; section?: boolean }> {\n const rows: Array<{ name: string; page: number; section?: boolean }> = [];\n let inDetachments = false;\n let sawHeader = false;\n for (const line of tocText.split(\"\\n\")) {\n const m = line.match(/^\\s*([A-Z][A-Za-z0-9'’ \\-]+?)[^\\w\\s]{2,}\\s*(\\d+)\\s*$/);\n if (!m) continue;\n const name = m[1].trim();\n const page = Number(m[2]);\n if (/^Detachments$/i.test(name)) {\n inDetachments = true;\n sawHeader = true;\n continue;\n }\n if (sawHeader && SECTION_TERMINATORS.test(name)) {\n if (inDetachments) rows.push({ name, page, section: true }); // bounds the last detachment\n inDetachments = false;\n continue;\n }\n if (inDetachments) rows.push({ name, page });\n }\n return rows;\n}\n\nexport function extractPack(pdf: string, faction: string): PackExtract {\n // detachment names (ordered) from the TOC; content boundaries from block positions\n const names = parseToc(runPdftotext([\"-f\", \"1\", \"-l\", \"2\", pdf, \"-\"]))\n .filter((t) => !t.section)\n .map((t) => t.name);\n const blocks = allBlocks(pdf);\n\n // start of each detachment = its title block (exact uppercase name match, first\n // occurrence). Stratagem labels like \"NAME – TYPE STRATAGEM\" are not exact, so\n // they don't collide with the title.\n const starts = names\n .map((name) => {\n const title = blocks.filter((b) => normCaps(b.text) === normCaps(name)).sort((a, b) => a.gy - b.gy)[0];\n return title ? { name, gy: title.gy } : null;\n })\n .filter((s): s is { name: string; gy: number } => s !== null)\n .sort((a, b) => a.gy - b.gy);\n const sectionGys = blocks.filter((b) => SECTION_MARKER.test(normCaps(b.text))).map((b) => b.gy).sort((a, b) => a - b);\n\n const detachments: ExtractedDetachment[] = [];\n for (let i = 0; i < starts.length; i++) {\n const start = starts[i].gy;\n const nextDet = starts[i + 1]?.gy ?? Infinity;\n const nextSection = sectionGys.find((gy) => gy > start) ?? Infinity;\n // Backstop: a detachment is at most ~2 pages (gy page stride is 100000).\n // Bounds the last detachment when no datasheets/section divider block is\n // detected, so it can't bleed into the datasheet pages that follow.\n const end = Math.min(nextDet, nextSection, start + 200000);\n const id = slug(starts[i].name);\n const body = parseDetachmentBlocks(\n blocks.filter((b) => b.gy >= start && b.gy < end),\n id,\n );\n detachments.push({ id, name: starts[i].name, ...body });\n }\n return { faction_id: faction, source_pack: basename(pdf), detachments };\n}\n\nfunction factionForPack(file: string): string | undefined {\n const lower = file.toLowerCase();\n return PACK_FACTION.find(([frag]) => lower.includes(frag))?.[1];\n}\n\nfunction main(): void {\n const argv = process.argv.slice(2);\n mkdirSync(OUT_DIR, { recursive: true });\n\n let jobs: Array<{ pdf: string; faction: string }> = [];\n if (argv[0] === \"--all\") {\n const dirIdx = argv.indexOf(\"--dir\");\n const dir = dirIdx === -1 ? undefined : argv[dirIdx + 1];\n if (!dir) {\n console.error(\"Usage: extract-faction-pack.ts --all --dir <pack-pdf-dir>\");\n process.exit(1);\n }\n for (const f of readdirSync(dir)) {\n if (!/faction_pack.*\\.pdf$/i.test(f)) continue;\n const faction = factionForPack(f);\n if (faction) jobs.push({ pdf: resolve(dir, f), faction });\n }\n } else {\n const facIdx = argv.indexOf(\"--faction\");\n const pdf = argv[0];\n if (!pdf || facIdx === -1) {\n console.error(\"Usage: extract-faction-pack.ts <pdf-path> --faction <faction-id>\");\n process.exit(1);\n }\n jobs = [{ pdf: resolve(pdf), faction: argv[facIdx + 1] }];\n }\n\n for (const { pdf, faction } of jobs) {\n const result = extractPack(pdf, faction);\n const out = resolve(OUT_DIR, `${faction}.json`);\n writeFileSync(out, JSON.stringify(result, null, 2) + \"\\n\");\n const nStrat = result.detachments.reduce((a, d) => a + d.stratagems.length, 0);\n const nEnh = result.detachments.reduce((a, d) => a + d.enhancements.length, 0);\n console.log(\n ` ${faction}: ${result.detachments.length} detachments, ${nStrat} stratagems, ${nEnh} enhancements → ${out}`,\n );\n }\n}\n\nconst isMain =\n process.argv[1] &&\n resolve(process.argv[1]).replace(/\\.\\w+$/, \"\") === fileURLToPath(import.meta.url).replace(/\\.\\w+$/, \"\");\nif (isMain) main();\n"]}
|
|
1
|
+
{"version":3,"file":"extract-faction-pack.js","sourceRoot":"","sources":["../src/extract-faction-pack.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Standardized extractor for GW 11e Faction Pack PDFs → IP-safe staging JSON.\n *\n * The faction packs are the structural source of truth for detachments,\n * stratagems, and enhancements. This tool pulls the *facts* out of a pack —\n * detachment names, stratagem names + CP cost + type + phase, enhancement names,\n * detachment-rule names — and writes them to `data/_audit/faction-pack-input/\n * <faction>.json` for human review before they are merged into `data/core/`.\n *\n * IP firewall: this tool MUST NOT emit GW rules/effect prose. It reads the\n * `WHEN:` line of a stratagem card only to *derive* the structured phase /\n * player-turn fields; the prose itself is never written to the staging file.\n * Stored output is names + numeric/enum metadata only.\n *\n * The packs are *supplements*, not complete codexes: a pack contains only the\n * extra/updated detachments. Absence from a pack does not imply removal. This\n * tool reports only what a pack actually contains; completeness is reconciled\n * against GW's full Detachment-Points list separately.\n *\n * Parsing uses `pdftotext -bbox-layout`, which emits per-word coordinates. GW\n * lays each card out as discrete text blocks; we locate the \"<DET> – <TYPE>\n * STRATAGEM\" label block, then read the name block directly above it, the CP\n * block in the column gutter, and the `WHEN:` block below — all by coordinate.\n * This is layout-independent (column positions differ across packs and pages).\n * Anything the parser cannot determine is left null and recorded in a per-entry\n * `flags` array for the reviewer.\n *\n * Requires `pdftotext` (poppler) on PATH — authoring-time only, not CI.\n *\n * Usage:\n * npx tsx tools/src/extract-faction-pack.ts <pdf-path> --faction <faction-id>\n * npx tsx tools/src/extract-faction-pack.ts --all --dir <pack-pdf-dir>\n */\nimport { readdirSync, writeFileSync, mkdirSync } from \"node:fs\";\nimport { resolve, basename } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport {\n type Block,\n runPdftotext,\n allBlocks,\n slug,\n titleCase,\n normCaps,\n isCapsHeader,\n sameColumn,\n stripCp,\n} from \"./pack-blocks.js\";\n\nexport { slug, titleCase } from \"./pack-blocks.js\";\n\nconst __dirname = fileURLToPath(new URL(\".\", import.meta.url));\nconst DATA_ROOT = resolve(__dirname, \"../../data\");\nconst OUT_DIR = resolve(DATA_ROOT, \"_audit\", \"faction-pack-input\");\n\n/** Pack-filename fragment → 40kdc faction id. `--all` uses this to route packs. */\nconst PACK_FACTION: Array<[fragment: string, faction: string]> = [\n [\"space-marines\", \"adeptus-astartes\"],\n [\"black_templars\", \"black-templars\"],\n [\"blood_angels\", \"blood-angels\"],\n [\"dark_angels\", \"dark-angels\"],\n [\"space_wolves\", \"space-wolves\"],\n [\"deathwatch\", \"deathwatch\"],\n [\"grey_knights\", \"grey-knights\"],\n];\n\nconst STRAT_TYPE: Record<string, string> = {\n \"BATTLE TACTIC\": \"battle-tactic\",\n \"STRATEGIC PLOY\": \"strategic-ploy\",\n \"EPIC DEED\": \"epic-deed\",\n WARGEAR: \"wargear\",\n};\nconst TYPE_RE = /(BATTLE TACTIC|STRATEGIC PLOY|EPIC DEED|WARGEAR)\\s+STRATAGEM$/;\nconst PHASES = [\"command\", \"movement\", \"shooting\", \"charge\", \"fight\"] as const;\n// Note: \"Legends Datasheets\" (not bare \"Legends\") — else it eats the detachment\n// \"Legends of Saga and Song\".\nconst SECTION_TERMINATORS = /^(Datasheets|Rules Updates|Legends Datasheets|Imperial Armour|Index)\\b/i;\n\nexport interface ExtractedStratagem {\n id: string;\n name: string;\n type: string | null;\n cp_cost: number | null;\n phases: string[];\n player_turn: string | null;\n timing: string | null;\n flags: string[];\n}\nexport interface ExtractedEnhancement {\n id: string;\n name: string;\n cost: null; // never in the pack — comes from the Munitorum Field Manual\n flags: string[];\n}\nexport interface ExtractedDetachment {\n id: string;\n name: string;\n detachment_rule_name: string | null;\n stratagems: ExtractedStratagem[];\n enhancements: ExtractedEnhancement[];\n flags: string[];\n}\nexport interface PackExtract {\n faction_id: string;\n source_pack: string;\n detachments: ExtractedDetachment[];\n}\n\n/** Datasheet/table headers that can masquerade as caps \"enhancement\" names. */\nconst DATASHEET_NOISE = new Set([\n \"RANGED WEAPONS\", \"MELEE WEAPONS\", \"RANGE\", \"WARGEAR OPTIONS\", \"UNIT COMPOSITION\",\n \"KEYWORDS\", \"LEADER\", \"TRANSPORT\", \"DAMAGED\", \"SUPREME COMMANDER\",\n]);\n/** Non-detachment section headers that bound the last detachment's content. */\nconst SECTION_MARKER = /^(DATASHEETS|RULES UPDATES|LEGENDS DATASHEETS|IMPERIAL ARMOUR|INDEX)\\b/;\n\n/** Derive phases / player-turn from a `WHEN:` line (the prose itself is discarded). */\nfunction parseWhen(when: string): { phases: string[]; player_turn: string | null; flag?: string } {\n const low = when.toLowerCase();\n const phases = PHASES.filter((p) => low.includes(`${p} phase`));\n let player_turn: string | null = null;\n if (/opponent['’]?s?\\b/.test(low)) player_turn = \"opponent-turn\";\n else if (/\\byour\\b/.test(low) && phases.length) player_turn = \"your-turn\";\n else if (phases.length) player_turn = \"either\";\n const flag = phases.length ? undefined : \"phase-review\"; // e.g. \"any phase\"\n return { phases, player_turn, flag };\n}\n\n/** Extract stratagems, enhancements, and rule name from a detachment's blocks. */\nexport function parseDetachmentBlocks(blocks: Block[], detachmentId: string): Omit<ExtractedDetachment, \"id\" | \"name\"> {\n const flags: string[] = [];\n const labels = blocks.filter((b) => /\\bSTRATAGEM$/.test(b.text) && !/^STRATAGEMS?$/.test(b.text));\n const cpBlocks = blocks.filter((b) => /^\\d+\\s?CP$/.test(b.text));\n const whenBlocks = blocks.filter((b) => /^WHEN:/i.test(b.text));\n\n // --- detachment rule name: first caps header under a DETACHMENT RULE(S) marker\n let detachment_rule_name: string | null = null;\n const ruleHeader = blocks.filter((b) => /^DETACHMENT RULES?$/.test(b.text)).sort((a, b) => a.gy - b.gy)[0];\n if (ruleHeader) {\n const rn = blocks\n .filter((b) => sameColumn(b.x, ruleHeader.x) && b.gy > ruleHeader.gy && isCapsHeader(b.text))\n .sort((a, b) => a.gy - b.gy)[0];\n if (rn) detachment_rule_name = titleCase(rn.text);\n }\n\n // --- stratagems: keyed off each \"<…> <TYPE> STRATAGEM\" label block.\n const stratagems: ExtractedStratagem[] = [];\n const seenS = new Set<string>();\n for (const label of labels) {\n const typeM = label.text.match(TYPE_RE);\n const type = typeM ? STRAT_TYPE[typeM[1]] : null;\n\n // name: nearest caps-header block directly above the label, same column\n const nameBlock = blocks\n .filter((b) => sameColumn(b.x, label.x) && b.gy < label.gy && isCapsHeader(stripCp(b.text).name))\n .sort((a, b) => b.gy - a.gy)[0];\n if (!nameBlock) continue;\n const { name, cp: inlineCp } = stripCp(nameBlock.text);\n const id = slug(name);\n if (!id || seenS.has(id)) continue;\n seenS.add(id);\n\n // cp: inline on the name block, else the CP block within the card window.\n // Card layouts differ: new detachments put CP at the name's right edge on\n // the name row; reprints put it in the left gutter below the label. So scan\n // a window [name row → just below label] × [card width] and pick the CP\n // nearest the name horizontally (avoids grabbing the adjacent column's CP).\n let cp = inlineCp;\n if (cp === null) {\n const inCard = cpBlocks\n .filter((b) => b.gy >= nameBlock.gy - 15 && b.gy <= label.gy + 70 && b.x >= nameBlock.x - 70 && b.x <= nameBlock.x + 260)\n .sort((a, b) => Math.abs(a.x - nameBlock.x) - Math.abs(b.x - nameBlock.x))[0];\n if (inCard) cp = Number(inCard.text.match(/(\\d+)/)![1]);\n }\n\n // phase: the first WHEN: block below the label in the same column\n const when = whenBlocks\n .filter((b) => sameColumn(b.x, label.x) && b.gy >= label.gy)\n .sort((a, b) => a.gy - b.gy)[0];\n const sFlags: string[] = [];\n let phases: string[] = [];\n let player_turn: string | null = null;\n if (when) {\n const w = parseWhen(when.text);\n phases = w.phases;\n player_turn = w.player_turn;\n if (w.flag) sFlags.push(w.flag);\n } else {\n sFlags.push(\"phase-missing\");\n }\n if (!type) sFlags.push(\"type-missing\");\n if (cp === null) sFlags.push(\"cp-missing\");\n sFlags.push(\"timing-missing\"); // pack rarely states once-per-phase/turn/battle\n\n stratagems.push({ id, name: titleCase(name), type, cp_cost: cp, phases, player_turn, timing: null, flags: sFlags });\n }\n\n if (!detachment_rule_name) flags.push(\"rule-name-missing\");\n\n // --- enhancements (subtractive): a detachment page's caps headers are the\n // title, tagline, rule name, section words, stratagem names, type labels,\n // and enhancement names. Remove every known category; the rest are\n // enhancements. Each must be a name+description pair, i.e. followed by a\n // mixed-case prose block in its column. Cost is never printed in the pack.\n const ruleSlug = detachment_rule_name ? slug(detachment_rule_name) : \"\";\n const hasProseBelow = (b: Block): boolean =>\n blocks\n .filter((o) => sameColumn(o.x, b.x) && o.gy > b.gy)\n .sort((p, q) => p.gy - q.gy)\n .some((o, i) => i === 0 && /[a-z]/.test(o.text)); // nearest block below is prose\n const enhancements: ExtractedEnhancement[] = [];\n const seenE = new Set<string>();\n for (const b of blocks) {\n if (!isCapsHeader(b.text) || DATASHEET_NOISE.has(normCaps(b.text))) continue;\n const isUpgrade = / UPGRADE$/.test(b.text);\n const name = b.text.replace(/ UPGRADE$/, \"\").trim();\n const id = slug(name);\n if (!id || seenE.has(id) || seenS.has(id)) continue; // skip stratagem names\n if (id === detachmentId || id === ruleSlug) continue; // skip title / rule name\n if (!hasProseBelow(b)) continue; // enhancements always have a description\n seenE.add(id);\n enhancements.push({\n id,\n name: titleCase(name),\n cost: null,\n flags: [\"cost-missing\", \"name-needs-review\", ...(isUpgrade ? [\"upgrade-tag?\"] : [])],\n });\n }\n if (!enhancements.length) flags.push(\"no-enhancements-parsed\");\n\n return { detachment_rule_name, stratagems, enhancements, flags };\n}\n\n/**\n * Parse the pack's table of contents (first two pages) into the ordered list of\n * detachments with their start pages. Uses non-layout extraction so the two\n * columns don't interleave; dot leaders are private-font glyphs (non-word\n * chars), so a TOC row is `<Name><2+ non-word><page#>`. Entries are kept only\n * between the \"Detachments\" header and the first non-detachment section.\n */\nexport function parseToc(tocText: string): Array<{ name: string; page: number; section?: boolean }> {\n const rows: Array<{ name: string; page: number; section?: boolean }> = [];\n let inDetachments = false;\n let sawHeader = false;\n for (const line of tocText.split(\"\\n\")) {\n const m = line.match(/^\\s*([A-Z][A-Za-z0-9'’ \\-]+?)[^\\w\\s]{2,}\\s*(\\d+)\\s*$/);\n if (!m) continue;\n const name = m[1].trim();\n const page = Number(m[2]);\n if (/^Detachments$/i.test(name)) {\n inDetachments = true;\n sawHeader = true;\n continue;\n }\n if (sawHeader && SECTION_TERMINATORS.test(name)) {\n if (inDetachments) rows.push({ name, page, section: true }); // bounds the last detachment\n inDetachments = false;\n continue;\n }\n if (inDetachments) rows.push({ name, page });\n }\n return rows;\n}\n\n/** One detachment's slice of the pack: its id, display name, and bounded blocks. */\nexport interface DetachmentSegment {\n id: string;\n name: string;\n blocks: Block[];\n}\n\n/**\n * Segment a pack into per-detachment block slices. Detachment names come (ordered)\n * from the TOC; content boundaries come from block positions: each detachment runs\n * from its title block to the next detachment, the next non-detachment section\n * marker, or a ~2-page backstop — whichever comes first. Shared by `extractPack`\n * (names/metadata) and `author-input-pack` (rule bodies) so both segment identically.\n */\nexport function detachmentSegments(pdf: string): DetachmentSegment[] {\n const names = parseToc(runPdftotext([\"-f\", \"1\", \"-l\", \"2\", pdf, \"-\"]))\n .filter((t) => !t.section)\n .map((t) => t.name);\n const blocks = allBlocks(pdf);\n\n // start of each detachment = its title block (exact uppercase name match, first\n // occurrence). Stratagem labels like \"NAME – TYPE STRATAGEM\" are not exact, so\n // they don't collide with the title.\n const starts = names\n .map((name) => {\n const title = blocks.filter((b) => normCaps(b.text) === normCaps(name)).sort((a, b) => a.gy - b.gy)[0];\n return title ? { name, gy: title.gy } : null;\n })\n .filter((s): s is { name: string; gy: number } => s !== null)\n .sort((a, b) => a.gy - b.gy);\n const sectionGys = blocks.filter((b) => SECTION_MARKER.test(normCaps(b.text))).map((b) => b.gy).sort((a, b) => a - b);\n\n const segments: DetachmentSegment[] = [];\n for (let i = 0; i < starts.length; i++) {\n const start = starts[i].gy;\n const nextDet = starts[i + 1]?.gy ?? Infinity;\n const nextSection = sectionGys.find((gy) => gy > start) ?? Infinity;\n // Backstop: a detachment is at most ~2 pages (gy page stride is 100000).\n // Bounds the last detachment when no datasheets/section divider block is\n // detected, so it can't bleed into the datasheet pages that follow.\n const end = Math.min(nextDet, nextSection, start + 200000);\n segments.push({\n id: slug(starts[i].name),\n name: starts[i].name,\n blocks: blocks.filter((b) => b.gy >= start && b.gy < end),\n });\n }\n return segments;\n}\n\nexport function extractPack(pdf: string, faction: string): PackExtract {\n const detachments: ExtractedDetachment[] = detachmentSegments(pdf).map((seg) => ({\n id: seg.id,\n name: seg.name,\n ...parseDetachmentBlocks(seg.blocks, seg.id),\n }));\n return { faction_id: faction, source_pack: basename(pdf), detachments };\n}\n\nfunction factionForPack(file: string): string | undefined {\n const lower = file.toLowerCase();\n return PACK_FACTION.find(([frag]) => lower.includes(frag))?.[1];\n}\n\nfunction main(): void {\n const argv = process.argv.slice(2);\n mkdirSync(OUT_DIR, { recursive: true });\n\n let jobs: Array<{ pdf: string; faction: string }> = [];\n if (argv[0] === \"--all\") {\n const dirIdx = argv.indexOf(\"--dir\");\n const dir = dirIdx === -1 ? undefined : argv[dirIdx + 1];\n if (!dir) {\n console.error(\"Usage: extract-faction-pack.ts --all --dir <pack-pdf-dir>\");\n process.exit(1);\n }\n for (const f of readdirSync(dir)) {\n if (!/faction_pack.*\\.pdf$/i.test(f)) continue;\n const faction = factionForPack(f);\n if (faction) jobs.push({ pdf: resolve(dir, f), faction });\n }\n } else {\n const facIdx = argv.indexOf(\"--faction\");\n const pdf = argv[0];\n if (!pdf || facIdx === -1) {\n console.error(\"Usage: extract-faction-pack.ts <pdf-path> --faction <faction-id>\");\n process.exit(1);\n }\n jobs = [{ pdf: resolve(pdf), faction: argv[facIdx + 1] }];\n }\n\n for (const { pdf, faction } of jobs) {\n const result = extractPack(pdf, faction);\n const out = resolve(OUT_DIR, `${faction}.json`);\n writeFileSync(out, JSON.stringify(result, null, 2) + \"\\n\");\n const nStrat = result.detachments.reduce((a, d) => a + d.stratagems.length, 0);\n const nEnh = result.detachments.reduce((a, d) => a + d.enhancements.length, 0);\n console.log(\n ` ${faction}: ${result.detachments.length} detachments, ${nStrat} stratagems, ${nEnh} enhancements → ${out}`,\n );\n }\n}\n\nconst isMain =\n process.argv[1] &&\n resolve(process.argv[1]).replace(/\\.\\w+$/, \"\") === fileURLToPath(import.meta.url).replace(/\\.\\w+$/, \"\");\nif (isMain) main();\n"]}
|