Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/components/CardGallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ruleSets } from "../data/gameData";
import { magicItems } from "../domain/magicItems";
import {
buildCard,
buildCannonRulesCard,
buildChartCard,
buildMagicItemCard,
buildSpellCard,
Expand Down Expand Up @@ -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;
Expand Down
13 changes: 8 additions & 5 deletions src/components/PrintView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getMagicItem } from "../domain/magicItems";
import { totalScoutingPoints } from "../domain/scouting";
import {
buildCard,
buildCannonRulesCard,
buildChartCard,
buildMagicItemCard,
buildSpellCard,
Expand Down Expand Up @@ -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]) {
Expand Down
3 changes: 3 additions & 0 deletions src/content/info/changelog.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
3 changes: 0 additions & 3 deletions src/content/info/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
34 changes: 34 additions & 0 deletions src/domain/unitCard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { getArmy } from "../data/gameData";
import {
buildCard,
buildCannonRulesCard,
buildChartCard,
buildSpellCard,
cardStats,
Expand Down Expand Up @@ -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);
Expand Down
62 changes: 61 additions & 1 deletion src/domain/unitCard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,46 @@ const GROUND_TERRAIN_RULES: Record<string, string> = {
};

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,
Expand Down Expand Up @@ -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.
Expand All @@ -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;
}

Expand Down Expand Up @@ -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;
Expand Down