diff --git a/src/components/CardGallery.tsx b/src/components/CardGallery.tsx index 954ad9b..f7c0f13 100644 --- a/src/components/CardGallery.tsx +++ b/src/components/CardGallery.tsx @@ -2,6 +2,7 @@ import { ruleSets } from "../data/gameData"; import { magicItems } from "../domain/magicItems"; import { buildCard, + buildCannonRulesCard, buildChartCard, buildMagicItemCard, buildSpellCard, @@ -29,6 +30,8 @@ export default function CardGallery() { cards.push(buildCard(unit)); const chartCard = buildChartCard(unit); if (chartCard) cards.push(chartCard); + const cannonRulesCard = buildCannonRulesCard(unit); + if (cannonRulesCard) cards.push(cannonRulesCard); } for (const spell of army.spells) { if (seenSpells.has(spell.name + spell.text)) continue; diff --git a/src/components/PrintView.tsx b/src/components/PrintView.tsx index d6573c7..ae575f0 100644 --- a/src/components/PrintView.tsx +++ b/src/components/PrintView.tsx @@ -6,6 +6,7 @@ import { getMagicItem } from "../domain/magicItems"; import { totalScoutingPoints } from "../domain/scouting"; import { buildCard, + buildCannonRulesCard, buildChartCard, buildMagicItemCard, buildSpellCard, @@ -347,11 +348,13 @@ export function listCards( if (seen.has(card.unitId)) return; seen.add(card.unitId); cards.push(card); - // Units with a roll chart (the Giants) get a second, two-sided chart card. - const chartCard = buildChartCard(unit); - if (chartCard && !seen.has(chartCard.unitId)) { - seen.add(chartCard.unitId); - cards.push(chartCard); + // Long shared references print as companion cards. Each reference is + // included only once even when the list contains several eligible units. + for (const referenceCard of [buildChartCard(unit), buildCannonRulesCard(unit)]) { + if (referenceCard && !seen.has(referenceCard.unitId)) { + seen.add(referenceCard.unitId); + cards.push(referenceCard); + } } }; for (const entry of [...list.characters, ...list.units]) { diff --git a/src/content/info/changelog.md b/src/content/info/changelog.md index a2ba875..dc808a5 100644 --- a/src/content/info/changelog.md +++ b/src/content/info/changelog.md @@ -1,3 +1,6 @@ +## Unreleased +- Cannon and Galloper Gun lists now print a compact Cannon Rules reference card covering armour, cover, bounce-through hits, friendly and engaged units, and grapeshot. + ## 0.9.0 2026-08-16 - Added option for Scouting Points - Added Playtest Rules 2026 data set (High Elf General and 4 Heavy Cav units changed) diff --git a/src/content/info/roadmap.md b/src/content/info/roadmap.md index c1a58b5..ad2d5c0 100644 --- a/src/content/info/roadmap.md +++ b/src/content/info/roadmap.md @@ -5,9 +5,6 @@ ## List Building 1. Add optional rules from p.115 -## Unit Cards -1. Print cannon rules on cannon cards - ## Misc 1. Look into this build warning: - Using dynamic imports to split large chunks. diff --git a/src/domain/unitCard.test.ts b/src/domain/unitCard.test.ts index 667a669..7fb3c08 100644 --- a/src/domain/unitCard.test.ts +++ b/src/domain/unitCard.test.ts @@ -2,6 +2,7 @@ import { getArmy } from "../data/gameData"; import { buildCard, + buildCannonRulesCard, buildChartCard, buildSpellCard, cardStats, @@ -105,6 +106,39 @@ describe("unit cards", () => { expect(cards.filter((c) => c.unitId === "orcs:giant:chart")).toHaveLength(1); }); + it("adds one shared Cannon Rules card for cannon-profile units", () => { + const cannon = unit(empire, "Cannon"); + const reminder = "Full rules for Cannons are in the main Rulebook (p.74) or on the Cannon Rules card."; + for (const armyId of ["empire", "dwarfs", "witch-hunters"]) { + const army = getArmy("warmaster-revolution", armyId)!; + const card = buildCard(unit(army, "Cannon")); + expect([...card.frontRules, ...card.backRules].map((rule) => rule.text), armyId).toContain(reminder); + } + const reference = buildCannonRulesCard(cannon)!; + const text = [...reference.frontRules, ...reference.backRules] + .map((rule) => `${rule.title}: ${rule.text}`) + .join(" "); + + expect(reference.name).toBe("Cannon Rules"); + expect(reference.fits).toBe(true); + expect(text).toContain("Armour & cover"); + expect(text).toContain("continues 5cm"); + expect(text).toContain("friendly units and units in combat"); + expect(text).toContain("Grapeshot"); + expect(buildCannonRulesCard(unit(chaos, "Chaos Warriors"))).toBeNull(); + + // The bounce profile also opts Galloper Guns into the shared rules. + const dogs = getArmy("warmaster-revolution", "dogs-of-war")!; + expect(buildCannonRulesCard(unit(dogs, "Galloper Guns"))).not.toBeNull(); + + // Repeated cannon units still require only one reference card. + let list = createList("warmaster-revolution", "2.2.6", "empire", "Battery", 2000); + list = addUnit(list, "empire:cannon"); + list = addUnit(list, "empire:cannon"); + const cards = listCards(list, empire); + expect(cards.filter((card) => card.unitId === "warmaster-revolution:cannon-rules")).toHaveLength(1); + }); + it("uses full font size and a logo back for short rules", () => { const card = buildCard(unit(chaos, "Chaos Warriors")); expect(card.fitLevel).toBe(0); diff --git a/src/domain/unitCard.ts b/src/domain/unitCard.ts index 6b2e428..b3fab80 100644 --- a/src/domain/unitCard.ts +++ b/src/domain/unitCard.ts @@ -95,6 +95,46 @@ const GROUND_TERRAIN_RULES: Record = { }; const FLYER_RULE = "Flyer: uses the flying movement rules and can move over units and terrain."; +const CANNON_RULE_REFERENCE = + "Full rules for Cannons are in the main Rulebook (p.74) or on the Cannon Rules card."; + +/** Operational cannon rules from the core rulebook, condensed into a + * card-sized field reference. A "bounce" attack profile is the stable data + * signal shared by Cannons and both kinds of Galloper Gun. */ +const CANNON_RULES: CardRule[] = [ + { + title: "Armour & cover", + text: + "Cannon balls ignore Armour: no Armour roll is made. Fortified targets count as defended (5+ to hit), and defended targets count as in the open (4+ to hit).", + }, + { + title: "Bounce", + text: + "Trace the shot from the closest firing cannon to the closest point of the closest target stand. It passes through that stand and continues 5cm in the same direction. Every cannon in the firing unit uses this same path. For each cannon, the target unit suffers its 2 listed attacks, plus 1 attack for every additional stand from that unit crossed by the path.", + }, + { + title: "Other units in the path", + text: + "A shot that crosses another unit makes 1 attack for each stand crossed, including friendly units and units in combat. Resolve the attacks against each affected unit separately. Drive back friendly unengaged units after all enemy drive backs. Do not drive back engaged units; carry their hits into combat, where they count toward the combat result.", + }, + { + title: "Grapeshot", + text: + "When charged, cannons can stand and shoot with grapeshot. Each cannon has 2 attacks; grapeshot does not bounce, and the target uses its full Armour value.", + }, +]; + +function usesCannonRules(unit: UnitData): boolean { + return /bounce/i.test(unit.rangedAttackProfile ?? ""); +} + +function cannonRuleReference(text: string): string { + return /^(?:full rules for cannons are in the main rulebook \(p\.74\)|see warmaster rulebook p\.74\.)$/i.test( + text, + ) + ? CANNON_RULE_REFERENCE + : text; +} /** * Shooting range. Prefer an explicit "range of Ncm" in the unit's own rules, @@ -165,7 +205,7 @@ export function cardRules(unit: UnitData): string[] { const rules: string[] = []; if (unit.subType === "Flying") rules.push(FLYER_RULE); if (unit.specials.length > 0) { - rules.push(...unit.specials); + rules.push(...unit.specials.map(cannonRuleReference)); } else { // A flyer moves over terrain, so its ground limits would contradict the // flyer rule above. @@ -176,6 +216,9 @@ export function cardRules(unit: UnitData): string[] { const typeRule = TYPE_RULES[unit.type]; if (typeRule) rules.push(typeRule); } + if (usesCannonRules(unit) && !rules.includes(CANNON_RULE_REFERENCE)) { + rules.push(CANNON_RULE_REFERENCE); + } return rules; } @@ -569,6 +612,23 @@ export function buildChartCard(unit: UnitData): CardModel | null { ); } +/** Shared field-reference card included whenever a list has a cannon or + * Galloper Gun. Its rules are kept separate, like the Giant Goes Wild chart, + * so the unit's stats and any unit-specific rules remain easy to scan. */ +export function buildCannonRulesCard(unit: UnitData): CardModel | null { + if (!usesCannonRules(unit)) return null; + return assembleCard( + `${unit.ruleSet}:cannon-rules`, + "Cannon Rules", + "Artillery reference", + null, + [], + CANNON_RULES, + { kind: "none", count: 0, orientation: "horizontal" }, + true, + ); +} + export function buildMagicItemCard(item: MagicItemData, bearer?: UnitData): CardModel { const kind = item.kind === "standard" ? "standard" : item.kind; const assignedCost = bearer ? item.cost(bearer) : null;