diff --git a/README.md b/README.md index fb91d9d..09dd41c 100644 --- a/README.md +++ b/README.md @@ -41,16 +41,26 @@ Configuration offers three independently saved rule sets: **Warmaster Revolution copy of Warmaster Revolution but has its own rule-set ID, army data objects, lists, folders, and import preferences so it can diverge safely. +### Direct rule-set paths + +Each path opens Warmuster with that rule set selected, saves the selection, and then returns the +address bar to the base URL: + +- **Warmaster Revolution:** [warmuster.net/WMR](https://warmuster.net/WMR) +- **WMR - 2026 Playtest:** [warmuster.net/WMR-2026-playtest](https://warmuster.net/WMR-2026-playtest) +- **A Matter of Mustaches:** [warmuster.net/A-Matter-of-Mustaches](https://warmuster.net/A-Matter-of-Mustaches) + ## Optional Scouting rules Enable **Scouting** from Configuration to show generated scouting points in the unit catalog, -totals for each configured stack in the roster, and the army's available scouting total at the -top of the rules printout. The adjacent information button contains the deployment procedure and -the Flyers/Scouts/Patrols table. - -Scouting is a display preference stored in the browser; it does not change the saved-list schema. -The army total assumes every eligible entry in the list is committed. Flying mounts and attached -Scout/Patrol upgrades use the higher of the parent and upgrade values. +commit checkboxes and totals for each configured stack in the roster, and the committed scouting +total at the top of the rules printout. The adjacent information button contains the deployment +procedure and the Flyers/Scouts/Patrols table. + +The Scouting display preference is stored in the browser. Unit and character commitments are +stored on the army list, including backups and shared lists. Toggling a multi-copy stack commits +one copy and splits it from the uncommitted copies. Flying mounts and attached Scout/Patrol +upgrades use the higher of the parent and upgrade values. `scripts/apply-scouting.mjs` assigns values programmatically from structured troop type, armour, Flying subtype, and the named exceptions in the rule table. It updates the Revolution army JSON, diff --git a/schema.md b/schema.md index b17ca0f..080ff7f 100644 --- a/schema.md +++ b/schema.md @@ -617,7 +617,8 @@ Take this as a baseline but make any changes needed: { "unitId": "chaos:chaos-warriors", "quantity": 4, - "upgrades": [] + "upgrades": [], + "scoutingCommitted": false } ], "characters": [ @@ -626,7 +627,8 @@ Take this as a baseline but make any changes needed: "unitId": "chaos:general", "upgrades": [ "chaos:chaos-dragon" - ] + ], + "scoutingCommitted": true } ], "notes": null @@ -634,6 +636,10 @@ Take this as a baseline but make any changes needed: Characters and units should both be defined by quantity, however represent any units with upgrades as a seperate entry. +Unit and character entries may include `scoutingCommitted`. It is optional and absent values read +as `false`. Commitment is part of a unit stack's identity, so otherwise identical committed and +uncommitted units remain as separate entries. + | Field | Purpose | | --------------- | ------------------------------------- | diff --git a/src/App.test.ts b/src/App.test.ts new file mode 100644 index 0000000..d3c26ea --- /dev/null +++ b/src/App.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { initialRuleSet } from "./App"; + +describe("initial rule set", () => { + it("defaults to Warmaster Revolution", () => { + expect(initialRuleSet(null)).toBe("warmaster-revolution"); + expect(initialRuleSet("unknown-rule-set")).toBe("warmaster-revolution"); + }); + + it("preserves a valid saved rule-set selection", () => { + expect(initialRuleSet("wmr-2026-playtest")).toBe("wmr-2026-playtest"); + }); + + it("lets a direct-entry path override the saved selection", () => { + expect(initialRuleSet("warmaster-custom", "warmaster-revolution")).toBe( + "warmaster-revolution", + ); + }); +}); diff --git a/src/App.tsx b/src/App.tsx index 3f0e12a..5c48662 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import PrintView, { } from "./components/PrintView"; import Roster from "./components/Roster"; import { consumeShareHash, decodeShareCode } from "./domain/shareCode"; +import { baseAppUrl, ruleSetIdFromPath } from "./domain/ruleSetPath"; import { getArmy, ruleSets } from "./data/gameData"; import { createFolder, @@ -37,7 +38,9 @@ import { setAllowMercenaries, setNotes, setPointsLimit, + toggleCharacterScouting, toggleCharacterUpgrade, + toggleUnitScouting, toggleUnitUpgrade, totalPoints, } from "./domain/lists"; @@ -59,6 +62,12 @@ import { import type { Folder, SavedList } from "./types"; type Theme = "light" | "dark"; +export const DEFAULT_RULE_SET_ID = "warmaster-revolution"; + +export function initialRuleSet(stored: string | null, routed: string | null = null): string { + if (routed && ruleSets.some((ruleSet) => ruleSet.id === routed)) return routed; + return ruleSets.some((ruleSet) => ruleSet.id === stored) ? stored! : DEFAULT_RULE_SET_ID; +} function initialTheme(): Theme { const stored = localStorage.getItem("warmuster.theme"); @@ -99,11 +108,20 @@ export default function App() { const [configOpen, setConfigOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false); // mobile list drawer const restoreGeneration = useRef(0); + const routedRuleSet = useRef(ruleSetIdFromPath(window.location.pathname)); // Active rule set: each set has its own saved lists. Persisted per browser. - const [activeRuleSet, setActiveRuleSet] = useState(() => { - const stored = localStorage.getItem("warmuster.ruleSet"); - return ruleSets.some((rs) => rs.id === stored) ? stored! : ruleSets[0].id; - }); + const [activeRuleSet, setActiveRuleSet] = useState(() => + initialRuleSet(localStorage.getItem("warmuster.ruleSet"), routedRuleSet.current), + ); + + useEffect(() => { + if (!routedRuleSet.current) return; + window.history.replaceState( + null, + "", + baseAppUrl(window.location.search, window.location.hash), + ); + }, []); useEffect(() => { localStorage.setItem("warmuster.ruleSet", activeRuleSet); @@ -345,8 +363,10 @@ export default function App() { issues={issues} onRemoveUnit={(i) => mutate((l) => removeUnit(l, i))} onAddUnitCopy={(i) => mutate((l) => addUnitCopy(l, i))} + onToggleUnitScouting={(i) => mutate((l) => toggleUnitScouting(l, i))} onToggleUnitUpgrade={(i, upgradeId) => mutate((l) => toggleUnitUpgrade(l, i, upgradeId))} onRemoveCharacter={(id) => mutate((l) => removeCharacter(l, id))} + onToggleCharacterScouting={(id) => mutate((l) => toggleCharacterScouting(l, id))} onToggleCharacterUpgrade={(id, upgradeId) => mutate((l) => toggleCharacterUpgrade(l, id, upgradeId)) } diff --git a/src/components/ConfigDialog.tsx b/src/components/ConfigDialog.tsx index 839f7b0..699e653 100644 --- a/src/components/ConfigDialog.tsx +++ b/src/components/ConfigDialog.tsx @@ -182,7 +182,7 @@ export default function ConfigDialog({

- Shows each unit’s scouting value and army scouting totals. + Shows scouting values and lets you commit units to the army’s scouting total.

Backup

diff --git a/src/components/PrintView.test.tsx b/src/components/PrintView.test.tsx new file mode 100644 index 0000000..e4d7ae6 --- /dev/null +++ b/src/components/PrintView.test.tsx @@ -0,0 +1,25 @@ +import { render, screen, within } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { getArmy } from "../data/gameData"; +import { addUnit, createList, toggleUnitScouting } from "../domain/lists"; +import { totalScoutingPoints } from "../domain/scouting"; +import { PrintList } from "./PrintView"; + +describe("PrintList scouting commitments", () => { + it("prints committed scouting units and their total", () => { + const army = getArmy("warmaster-revolution", "dwarfs")!; + let list = createList("warmaster-revolution", army.version, army.army, "Scouts", 1000); + list = addUnit(list, "dwarfs:rangers"); + list = addUnit(list, "dwarfs:rangers"); + list = toggleUnitScouting(list, 0); + + render(); + + expect(screen.getByText(/Scouting units:/)).toHaveTextContent( + `Scouting units: ${totalScoutingPoints(list, army)} SP`, + ); + const table = screen.getByRole("table"); + expect(within(table).getByRole("columnheader", { name: "SP" })).toBeInTheDocument(); + expect(within(table).getAllByText(/^✓ \d+$/)).toHaveLength(1); + }); +}); diff --git a/src/components/PrintView.tsx b/src/components/PrintView.tsx index d6573c7..d217e89 100644 --- a/src/components/PrintView.tsx +++ b/src/components/PrintView.tsx @@ -3,7 +3,7 @@ import type { ArmyData, SavedCharacterEntry, SavedList, SavedUnitEntry } from ". import { getUnit } from "../data/gameData"; import { entryPoints, totalPoints } from "../domain/lists"; import { getMagicItem } from "../domain/magicItems"; -import { totalScoutingPoints } from "../domain/scouting"; +import { entryScoutingPoints, totalScoutingPoints } from "../domain/scouting"; import { buildCard, buildChartCard, @@ -49,7 +49,14 @@ export function PrintList({ army: ArmyData; scoutingEnabled?: boolean; }) { - const rows: Array<{ label: string; quantity: number; points: number; unitId: string }> = []; + const rows: Array<{ + label: string; + quantity: number; + points: number; + unitId: string; + scoutingCommitted: boolean; + scoutingPoints: number; + }> = []; for (const entry of list.characters) { const unit = getUnit(army, entry.unitId); if (!unit) continue; @@ -59,6 +66,8 @@ export function PrintList({ quantity: 1, points: entryPoints(army, entry, unit), unitId: entry.unitId, + scoutingCommitted: entry.scoutingCommitted === true, + scoutingPoints: entryScoutingPoints(army, entry, unit), }); } for (const entry of list.units) { @@ -70,6 +79,8 @@ export function PrintList({ quantity: entry.quantity, points: entryPoints(army, entry, unit), unitId: entry.unitId, + scoutingCommitted: entry.scoutingCommitted === true, + scoutingPoints: entryScoutingPoints(army, entry, unit), }); } const usedMagicItems = [...list.characters, ...list.units] @@ -93,7 +104,7 @@ export function PrintList({

{army.name} · Warmaster Revolution {list.ruleVersion} · {totalPoints(list, army)}/ {list.pointsLimit} pts - {scoutingEnabled ? ` · Scouting: ${totalScoutingPoints(list, army)} SP` : ""} + {scoutingEnabled ? ` · Scouting units: ${totalScoutingPoints(list, army)} SP` : ""}

@@ -108,6 +119,7 @@ export function PrintList({ + {scoutingEnabled && } @@ -125,6 +137,9 @@ export function PrintList({ + {scoutingEnabled && ( + + )} ); diff --git a/src/components/Roster.test.tsx b/src/components/Roster.test.tsx new file mode 100644 index 0000000..d5cc1d8 --- /dev/null +++ b/src/components/Roster.test.tsx @@ -0,0 +1,49 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { getArmy } from "../data/gameData"; +import { addUnit, createList, toggleUnitScouting } from "../domain/lists"; +import { totalScoutingPoints } from "../domain/scouting"; +import Roster from "./Roster"; + +describe("Roster scouting commitments", () => { + it("shows separate committed and uncommitted stacks with a live total", () => { + const army = getArmy("warmaster-revolution", "dwarfs")!; + let list = createList("warmaster-revolution", army.version, army.army, "Scouts", 1000); + list = addUnit(list, "dwarfs:rangers"); + list = addUnit(list, "dwarfs:rangers"); + list = toggleUnitScouting(list, 0); + const onToggleUnitScouting = vi.fn(); + + render( + , + ); + + expect(screen.getByText(`Scouting: ${totalScoutingPoints(list, army)} SP`)).toBeInTheDocument(); + const uncommitted = screen.getByRole("checkbox", { name: "Commit one Rangers to scouting" }); + const committed = screen.getByRole("checkbox", { name: "Remove one Rangers from scouting" }); + expect(uncommitted).not.toBeChecked(); + expect(uncommitted.closest("label")).not.toHaveClass("committed"); + expect(committed).toBeChecked(); + expect(committed.closest("label")).toHaveClass("committed"); + + fireEvent.click(uncommitted); + expect(onToggleUnitScouting).toHaveBeenCalledWith(0); + }); +}); diff --git a/src/components/Roster.tsx b/src/components/Roster.tsx index 378e866..bc5c35f 100644 --- a/src/components/Roster.tsx +++ b/src/components/Roster.tsx @@ -5,7 +5,7 @@ import { isHired } from "../data/mercenaries"; import { armySizeMultiplier } from "../domain/armySize"; import { hiredCount } from "../domain/hiring"; import { breakPoint, entryPoints, entryStands, totalPoints } from "../domain/lists"; -import { entryScoutingPoints } from "../domain/scouting"; +import { entryScoutingPoints, totalScoutingPoints } from "../domain/scouting"; import { getMagicItem, magicItemCost, type MagicItemData } from "../domain/magicItems"; import SpecialRules, { UnitDetailsDialog } from "./SpecialRules"; import UnitStats from "./UnitStats"; @@ -16,8 +16,10 @@ interface RosterProps { issues: ValidationIssue[]; onRemoveUnit: (entryIndex: number) => void; onAddUnitCopy: (entryIndex: number) => void; + onToggleUnitScouting: (entryIndex: number) => void; onToggleUnitUpgrade: (entryIndex: number, upgradeId: string) => void; onRemoveCharacter: (id: string) => void; + onToggleCharacterScouting: (id: string) => void; onToggleCharacterUpgrade: (id: string, upgradeId: string) => void; onRemoveMagicItem: (itemId: string) => void; onRename: (name: string) => void; @@ -193,6 +195,7 @@ function RosterUnitRow({ scale, onRemove, onAdd, + onToggleScouting, onToggleUpgrade, onRemoveMagicItem, scoutingEnabled, @@ -203,6 +206,7 @@ function RosterUnitRow({ scale: number; onRemove: () => void; onAdd: () => void; + onToggleScouting: () => void; onToggleUpgrade: (upgradeId: string) => void; onRemoveMagicItem: (itemId: string) => void; scoutingEnabled: boolean; @@ -219,6 +223,8 @@ function RosterUnitRow({ ); } const stands = entryStands(army, entry, unit); + const scoutingPoints = entryScoutingPoints(army, entry, unit); + const scoutingCommitted = entry.scoutingCommitted === true; return (
  • @@ -248,9 +254,20 @@ function RosterUnitRow({
    {scoutingEnabled && ( - - {entryScoutingPoints(army, entry, unit)} SP - + )} {stands != null && ( {entry.quantity * stands} Stands @@ -275,6 +292,7 @@ function RosterCharacterRow({ entry, scale, onRemove, + onToggleScouting, onToggleUpgrade, onRemoveMagicItem, scoutingEnabled, @@ -283,6 +301,7 @@ function RosterCharacterRow({ entry: SavedCharacterEntry; scale: number; onRemove: () => void; + onToggleScouting: () => void; onToggleUpgrade: (upgradeId: string) => void; onRemoveMagicItem: (itemId: string) => void; scoutingEnabled: boolean; @@ -298,6 +317,8 @@ function RosterCharacterRow({
  • ); } + const scoutingPoints = entryScoutingPoints(army, entry, unit); + const scoutingCommitted = entry.scoutingCommitted === true; return (
  • @@ -325,9 +346,17 @@ function RosterCharacterRow({
    {scoutingEnabled && ( - - {entryScoutingPoints(army, entry, unit)} SP - + )} {entryPoints(army, entry, unit)} pts
  • Arm Cmd SpdSPPts
    {unit?.armour ?? "-"} {unit?.command ?? (unit?.bonusCommand != null ? signedLabel(unit.bonusCommand) : "-")} {unit?.speed != null ? `${unit.speed}cm` : "-"}{row.scoutingCommitted ? `✓ ${row.scoutingPoints}` : "-"}{row.points}