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
24 changes: 24 additions & 0 deletions src/components/MagicItemsDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { render, screen, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { getArmy } from "../data/gameData";
import { addCharacter, createList, toggleCharacterUpgrade } from "../domain/lists";
import MagicItemsDialog from "./MagicItemsDialog";

describe("MagicItemsDialog", () => {
it("offers Wizard-only items to a General with the Tzarina upgrade", () => {
const army = getArmy("warmaster-revolution", "kislev")!;
let list = createList("warmaster-revolution", army.version, "kislev", "Kislev", 1000);
list = addCharacter(list, "kislev:general");
list = toggleCharacterUpgrade(list, list.characters[0].id, "kislev:tzarina");

render(
<MagicItemsDialog army={army} list={list} onAssign={vi.fn()} onClose={vi.fn()} />,
);

const wand = screen.getByRole("combobox", { name: "Assign Wand of Power" });
expect(wand).toBeEnabled();
expect(
within(wand).getByRole("option", { name: "General [Tzarina] — 10 pts" }),
).toBeInTheDocument();
});
});
14 changes: 7 additions & 7 deletions src/components/MagicItemsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,29 +64,29 @@ function ItemRow({
list.characters.forEach((entry) => {
const unit = getUnit(army, entry.unitId);
if (!unit) return;
const upgrades = entry.upgrades.map((id) => getUnit(army, id)).filter((u) => u != null);
const key = `char:${entry.id}`;
// A bearer can carry only one item; hide anyone already carrying another.
if (entry.magicItems.length > 0 && key !== bearerKey) return;
if (canBearMagicItem(item, unit, army) || key === bearerKey) {
if (canBearMagicItem(item, unit, army, upgrades) || key === bearerKey) {
const extras = upgrades.map((upgrade) => upgrade.troop).join(", ");
const current = key === bearerKey ? " (current)" : "";
options.push({
key,
label: `${unit.troop} — ${magicItemCost(item.itemId, unit)} pts${current}`,
label: `${unit.troop}${extras ? ` [${extras}]` : ""} — ${magicItemCost(item.itemId, unit)} pts${current}`,
});
}
});
list.units.forEach((entry, index) => {
const unit = getUnit(army, entry.unitId);
if (!unit) return;
const key = `unit:${index}`;
const upgrades = entry.upgrades.map((id) => getUnit(army, id)).filter((u) => u != null);
// Single entries carrying another item are full; a merged stack is fine
// because assigning splits a fresh unit off it.
if (entry.quantity === 1 && entry.magicItems.length > 0 && key !== bearerKey) return;
if (canBearMagicItem(item, unit, army) || key === bearerKey) {
const extras = entry.upgrades
.map((id) => getUnit(army, id)?.troop)
.filter(Boolean)
.join(", ");
if (canBearMagicItem(item, unit, army, upgrades) || key === bearerKey) {
const extras = upgrades.map((upgrade) => upgrade.troop).join(", ");
const current = key === bearerKey ? " (current)" : "";
options.push({
key,
Expand Down
11 changes: 11 additions & 0 deletions src/domain/magicItems.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const chaos = getArmy("warmaster-revolution", "chaos")!;
const dwarfs = getArmy("warmaster-revolution", "dwarfs")!;
const highElves = getArmy("warmaster-revolution", "high-elves")!;
const lizardmen = getArmy("warmaster-revolution", "lizardmen")!;
const kislev = getArmy("warmaster-revolution", "kislev")!;

const warriors = getUnit(chaos, "chaos:chaos-warriors")!; // Infantry, 4 attacks, 4+ armour
const trolls = getUnit(chaos, "chaos:trolls")!; // Infantry, 5 attacks, 5+ armour, 3 hits
Expand Down Expand Up @@ -114,4 +115,14 @@ describe("magic item eligibility", () => {
// Still a General, so General-only items remain available too.
expect(canBearMagicItem(crown, slann, lizardmen)).toBe(true);
});

it("lets a General with the Tzarina upgrade take Wizard-only items", () => {
const kislevGeneral = getUnit(kislev, "kislev:general")!;
const tzarina = getUnit(kislev, "kislev:tzarina")!;
const wand = getMagicItem("magic:wand-of-power")!;

expect(canBearMagicItem(wand, kislevGeneral, kislev)).toBe(false);
expect(canBearMagicItem(wand, kislevGeneral, kislev, [tzarina])).toBe(true);
expect(canBearMagicItem(ring, kislevGeneral, kislev, [tzarina])).toBe(true);
});
});
41 changes: 25 additions & 16 deletions src/domain/magicItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export interface MagicItemData {
text: string;
cost: (unit: UnitData) => number | null;
/** Extra bearer check beyond the kind-level rules (devices only). */
allowed?: (unit: UnitData, army: ArmyData) => boolean;
allowed?: (unit: UnitData, army: ArmyData, upgrades: UnitData[]) => boolean;
}

/** Best (lowest) armour value on the profile: "0 or 6+" -> 6, "4+" -> 4. */
Expand All @@ -46,13 +46,17 @@ function isGeneral(unit: UnitData): boolean {
// Some army generals (Grey Seer, Vampire Lord, …) may take items restricted
// to either a General or a Wizard; their special rules say so explicitly.
// Others (the Lizardmen Slann) simply "cast spells as a Wizard" and so count
// as a Wizard for every Wizard-only item, including the Wand of Power.
function isWizard(unit: UnitData): boolean {
if (unit.type === "Wizard") return true;
return unit.specials.some(
(s) =>
/restricted to (?:either )?a General or (?:a )?Wizard/i.test(s) ||
/casts? spells? as a Wizard/i.test(s),
// as a Wizard for every Wizard-only item, including the Wand of Power. Some
// upgrades, such as the Kislev Tzarina, grant the same permission to a General.
function isWizard(unit: UnitData, upgrades: UnitData[] = []): boolean {
return [unit, ...upgrades].some(
(profile) =>
profile.type === "Wizard" ||
profile.specials.some(
(s) =>
/restricted to (?:either )?(?:(?:a )?General or )?(?:a )?Wizard/i.test(s) ||
/casts? spells? (?:as|like) a Wizard/i.test(s),
),
);
}

Expand Down Expand Up @@ -205,7 +209,7 @@ export const magicItems: MagicItemData[] = [
costLabel: "30 pts",
text: "A Wizard with this ring is able to cast a spell on the roll of 2+. This item will only work once in the entire game and when used it cannot be combined with any other item granting a spell casting bonus.",
cost: () => 30,
allowed: isWizard,
allowed: (unit, _army, upgrades) => isWizard(unit, upgrades),
},
{
itemId: "magic:staff-of-spellbinding",
Expand All @@ -215,7 +219,7 @@ export const magicItems: MagicItemData[] = [
costLabel: "30 pts",
text: "If an enemy Wizard fails to cast a spell, he can be spellbound on the D6 roll of a 4+. A spellbound Wizard suffers a -1 dice penalty each time he tries to cast a spell. The Staff of Spellbinding ceases to work once it has been used successfully.",
cost: () => 30,
allowed: (unit) => isWizard(unit) || isRunesmith(unit),
allowed: (unit, _army, upgrades) => isWizard(unit, upgrades) || isRunesmith(unit),
},
{
itemId: "magic:sceptre-of-sovereignty",
Expand All @@ -235,7 +239,7 @@ export const magicItems: MagicItemData[] = [
costLabel: "20 pts",
text: "The bearer can cause an enemy's spell to fail on the roll of 2+. The Scroll can only be used once to nullify the effect of a spell an enemy Wizard has successfully cast. In the case of a Runesmith, the Scroll can be used after a normal Dwarf anti-magic roll has failed.",
cost: () => 20,
allowed: (unit) => isWizard(unit) || isRunesmith(unit),
allowed: (unit, _army, upgrades) => isWizard(unit, upgrades) || isRunesmith(unit),
},
{
itemId: "magic:wand-of-power",
Expand All @@ -245,7 +249,7 @@ export const magicItems: MagicItemData[] = [
costLabel: "10 pts",
text: "A Wizard with the Wand of Power can add +1 to the chance of a spell working once during the game. The player must decide that he is using the Wand of Power before rolling the dice. As always a spell fails on the roll of a 1.",
cost: () => 10,
allowed: isWizard,
allowed: (unit, _army, upgrades) => isWizard(unit, upgrades),
},
{
itemId: "magic:rod-of-repetition",
Expand All @@ -255,7 +259,7 @@ export const magicItems: MagicItemData[] = [
costLabel: "10 pts",
text: "If a Wizard casts a spell and rolls sufficiently well for it to work then he can cast another spell. He can only do this once during the entire game. The next spell can be the same spell again or a different one and is cast exactly like any other.",
cost: () => 10,
allowed: isWizard,
allowed: (unit, _army, upgrades) => isWizard(unit, upgrades),
},
];

Expand Down Expand Up @@ -296,8 +300,13 @@ function deniesMagicItems(unit: UnitData): boolean {
return unit.specials.some((s) => /cannot (?:be given|have) (?:a )?magic items?/i.test(s));
}

export function canBearMagicItem(item: MagicItemData, unit: UnitData, army: ArmyData): boolean {
if (deniesMagicItems(unit)) return false;
export function canBearMagicItem(
item: MagicItemData,
unit: UnitData,
army: ArmyData,
upgrades: UnitData[] = [],
): boolean {
if ([unit, ...upgrades].some(deniesMagicItems)) return false;
// "No magic item may be given to hired Regiments of Renown units nor heroes."
// The restriction is on hiring, so a list built from the Regiments of Renown
// army itself is unaffected.
Expand All @@ -308,6 +317,6 @@ export function canBearMagicItem(item: MagicItemData, unit: UnitData, army: Army
case "weapon":
return isOrdinaryUnit(unit) || unit.category === "character";
case "device":
return unit.category === "character" && (item.allowed?.(unit, army) ?? true);
return unit.category === "character" && (item.allowed?.(unit, army, upgrades) ?? true);
}
}
3 changes: 2 additions & 1 deletion src/domain/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ export function validateList(list: SavedList, army: ArmyData): ValidationIssue[]
}
for (const entry of [...list.units, ...list.characters]) {
const unit = getUnit(army, entry.unitId);
const upgrades = entry.upgrades.map((id) => getUnit(army, id)).filter((u) => u != null);
const label = unit?.troop ?? entry.unitId;
if (entry.magicItems.length > 1) {
issues.push({
Expand All @@ -298,7 +299,7 @@ export function validateList(list: SavedList, army: ArmyData): ValidationIssue[]
message: `Unknown magic item "${itemId}".`,
unitId: entry.unitId,
});
} else if (unit && !canBearMagicItem(item, unit, army)) {
} else if (unit && !canBearMagicItem(item, unit, army, upgrades)) {
issues.push({
severity: "error",
message: `${label} cannot take ${item.name}.`,
Expand Down