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: 17 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 8 additions & 2 deletions schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -626,14 +627,19 @@ Take this as a baseline but make any changes needed:
"unitId": "chaos:general",
"upgrades": [
"chaos:chaos-dragon"
]
],
"scoutingCommitted": true
}
],
"notes": null
}

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 |
| --------------- | ------------------------------------- |
Expand Down
19 changes: 19 additions & 0 deletions src/App.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
28 changes: 24 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -37,7 +38,9 @@ import {
setAllowMercenaries,
setNotes,
setPointsLimit,
toggleCharacterScouting,
toggleCharacterUpgrade,
toggleUnitScouting,
toggleUnitUpgrade,
totalPoints,
} from "./domain/lists";
Expand All @@ -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");
Expand Down Expand Up @@ -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<string>(() => {
const stored = localStorage.getItem("warmuster.ruleSet");
return ruleSets.some((rs) => rs.id === stored) ? stored! : ruleSets[0].id;
});
const [activeRuleSet, setActiveRuleSet] = useState<string>(() =>
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);
Expand Down Expand Up @@ -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))
}
Expand Down
2 changes: 1 addition & 1 deletion src/components/ConfigDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ export default function ConfigDialog({
</button>
</div>
<p className="config-hint">
Shows each unit’s scouting value and army scouting totals.
Shows scouting values and lets you commit units to the army’s scouting total.
</p>

<h3 className="panel-heading">Backup</h3>
Expand Down
25 changes: 25 additions & 0 deletions src/components/PrintView.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<PrintList list={list} army={army} scoutingEnabled />);

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);
});
});
21 changes: 18 additions & 3 deletions src/components/PrintView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -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]
Expand All @@ -93,7 +104,7 @@ export function PrintList({
<p>
{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` : ""}
</p>
</header>
<table className="print-table">
Expand All @@ -108,6 +119,7 @@ export function PrintList({
<th>Arm</th>
<th>Cmd</th>
<th>Spd</th>
{scoutingEnabled && <th>SP</th>}
<th>Pts</th>
</tr>
</thead>
Expand All @@ -125,6 +137,9 @@ export function PrintList({
<td>{unit?.armour ?? "-"}</td>
<td>{unit?.command ?? (unit?.bonusCommand != null ? signedLabel(unit.bonusCommand) : "-")}</td>
<td>{unit?.speed != null ? `${unit.speed}cm` : "-"}</td>
{scoutingEnabled && (
<td>{row.scoutingCommitted ? `✓ ${row.scoutingPoints}` : "-"}</td>
)}
<td>{row.points}</td>
</tr>
);
Expand Down
49 changes: 49 additions & 0 deletions src/components/Roster.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Roster
army={army}
list={list}
issues={[]}
onRemoveUnit={vi.fn()}
onAddUnitCopy={vi.fn()}
onToggleUnitScouting={onToggleUnitScouting}
onToggleUnitUpgrade={vi.fn()}
onRemoveCharacter={vi.fn()}
onToggleCharacterScouting={vi.fn()}
onToggleCharacterUpgrade={vi.fn()}
onRemoveMagicItem={vi.fn()}
onRename={vi.fn()}
onSetPointsLimit={vi.fn()}
onSetNotes={vi.fn()}
onSetAllowMercenaries={vi.fn()}
scoutingEnabled
/>,
);

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);
});
});
Loading