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
41 changes: 31 additions & 10 deletions schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -646,21 +646,42 @@ Characters and units should both be defined by quantity, however represent any u
| `characters` | Selected characters |
| `notes` | Optional user notes |
| `allowMercenaries` | Whether the catalog offers Regiments of Renown for hire (optional; absent reads as off) |
| `folderId` | Folder the list is filed under (optional; absent or null means the rail's top level) |
| `sortIndex` | Position among the lists in the same folder (optional; lists without one sort last, in saved order) |

## Folder Schema

Folders organise the list rail. They hold no lists themselves — a list names its folder through `folderId` — and they belong to a rule set, like the lists they hold, so each rule set keeps its own organisation. Folders are saved in their own localStorage key (`warmuster.folders.v1`), separate from the lists.

{
"id": "folder-8fq2k1xa",
"ruleSet": "warmaster-revolution",
"name": "Tournament",
"sortIndex": 0
}

| Field | Purpose |
| ----------- | -------------------------------------------------------------- |
| `id` | Stable id referenced by a list's `folderId` |
| `ruleSet` | Rule set whose rail shows this folder |
| `name` | User's folder name. `Imports` is created on demand by share-link imports and reused by later ones |
| `sortIndex` | Position among the rule set's folders |

Deleting a folder deletes the lists filed under it. A list whose folder is missing — a corrupt store, or a backup carrying the list but not its folder — is shown at the top level rather than disappearing.

## Backup File Schema

A backup is the browser's whole collection in one file — every saved list, from every rule set. Importing one *replaces* the collection rather than merging into it, so a backup taken on one computer restores onto another as an exact mirror. List `id`s are therefore preserved (unlike share codes, which regenerate ids because they add a single list to an existing collection).
A backup is the browser's whole collection in one file — every saved list, from every rule set, plus the folders they are filed under. Importing one *replaces* the collection rather than merging into it, so a backup taken on one computer restores onto another as an exact mirror. List and folder `id`s are therefore preserved (unlike share codes, which regenerate ids because they add a single list to an existing collection).

```json
{
"kind": "warmuster/backup",
"backupVersion": 1,
"exportedAt": "2026-08-06T12:00:00.000Z",
"lists": []
}
```
{
"kind": "warmuster/backup",
"backupVersion": 1,
"exportedAt": "2026-08-06T12:00:00.000Z",
"lists": [],
"folders": []
}

The `lists` array contains `SavedList` objects, exactly as described above.
The `lists` array contains `SavedList` objects and the `folders` array `Folder` objects, exactly as described above. `folders` is absent from backups taken before folders existed; those restore as a flat collection.

| Field | Purpose |
| --------------- | ------------------------------------------------------------- |
Expand Down
101 changes: 94 additions & 7 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ import PrintView, {
import Roster from "./components/Roster";
import { consumeShareHash, decodeShareCode } from "./domain/shareCode";
import { getArmy, ruleSets } from "./data/gameData";
import {
createFolder,
deleteFolder,
foldersForRuleSet,
normalizeImportFolderTarget,
moveFolder,
moveList,
renameFolder,
resolveImportFolder,
type ImportFolderTarget,
} from "./domain/folders";
import {
addCharacter,
addUnit,
Expand All @@ -31,14 +42,21 @@ import {
totalPoints,
} from "./domain/lists";
import { validateList } from "./domain/validation";
import { loadFolders, saveFolders } from "./storage/folderRepository";
import {
importFolderPreference,
loadImportFolderPreferences,
saveImportFolderPreferences,
} from "./storage/importFolderRepository";
import {
deleteList,
listsForRuleSet,
loadLists,
replaceAllLists,
saveLists,
upsertList,
} from "./storage/listRepository";
import type { SavedList } from "./types";
import type { Folder, SavedList } from "./types";

type Theme = "light" | "dark";

Expand All @@ -63,6 +81,14 @@ function Home() {

export default function App() {
const [lists, setLists] = useState<SavedList[]>(() => loadLists());
const [folders, setFolders] = useState<Folder[]>(() => loadFolders());
// Mirrors `folders` so the share import, which resolves after its effect's
// closure was captured, always reads the current folders.
const foldersRef = useRef(folders);
const [importFolderPreferences, setImportFolderPreferences] = useState(() =>
loadImportFolderPreferences(),
);
const importFolderPreferencesRef = useRef(importFolderPreferences);
const [activeListId, setActiveListId] = useState<string | null>(null);
const [theme, setTheme] = useState<Theme>(() => initialTheme());
const [exportOpen, setExportOpen] = useState(false);
Expand Down Expand Up @@ -118,14 +144,25 @@ export default function App() {
return () => window.removeEventListener("popstate", onPop);
}, []);

// Every folder write goes through here: ref, storage, then state.
const updateFolders = (next: Folder[]) => {
foldersRef.current = next;
saveFolders(next);
setFolders(next);
};

// Import a shared list from the URL hash on first load.
useEffect(() => {
const code = consumeShareHash();
if (!code) return;
const generation = restoreGeneration.current;
void decodeShareCode(code).then((imported) => {
if (!imported || generation !== restoreGeneration.current) return;
setLists((prev) => upsertList(prev, imported));
const current = foldersRef.current;
const target = importFolderPreference(importFolderPreferencesRef.current, imported.ruleSet);
const { folders: next, folderId } = resolveImportFolder(current, imported.ruleSet, target);
if (next !== current) updateFolders(next);
setLists((prev) => upsertList(prev, { ...imported, folderId }));
// Show the set the imported list belongs to so it's visible in the rail.
if (ruleSets.some((rs) => rs.id === imported.ruleSet)) setActiveRuleSet(imported.ruleSet);
setActiveListId(imported.id);
Expand All @@ -143,6 +180,10 @@ export default function App() {
() => listsForRuleSet(lists, activeRuleSet),
[lists, activeRuleSet],
);
const visibleFolders = useMemo(
() => foldersForRuleSet(folders, activeRuleSet),
[folders, activeRuleSet],
);

const issues = useMemo(
() => (activeList && army ? validateList(activeList, army) : []),
Expand Down Expand Up @@ -175,13 +216,39 @@ export default function App() {
if (activeListId === id) setActiveListId(null);
};

const handleCreateFolder = (name: string) =>
updateFolders([...folders, createFolder(folders, activeRuleSet, name)]);

const handleRenameFolder = (id: string, name: string) =>
updateFolders(renameFolder(folders, id, name));

// A folder holds its lists, so deleting it deletes them too — the rail asks
// for confirmation, naming the count, before this runs.
const handleDeleteFolder = (id: string) => {
const next = deleteFolder(folders, lists, id);
updateFolders(next.folders);
saveLists(next.lists);
setLists(next.lists);
if (!next.lists.some((l) => l.id === activeListId)) setActiveListId(null);
};

const handleMoveList = (listId: string, folderId: string | null, index: number) => {
const next = moveList(lists, listId, folderId, index);
setLists(next);
saveLists(next);
};

const handleMoveFolder = (folderId: string, index: number) =>
updateFolders(moveFolder(folders, folderId, index));

// Restoring a backup swaps in another browser's whole collection, so the
// list open at the time is gone unless the backup happens to carry it.
const handleReplaceAllLists = (imported: SavedList[]) => {
if (!replaceAllLists(imported)) return false;
const handleReplaceAll = (importedLists: SavedList[], importedFolders: Folder[]) => {
if (!replaceAllLists(importedLists)) return false;
updateFolders(importedFolders);
restoreGeneration.current += 1;
setLists(imported);
if (!imported.some((l) => l.id === activeListId)) setActiveListId(null);
setLists(importedLists);
if (!importedLists.some((l) => l.id === activeListId)) setActiveListId(null);
return true;
};

Expand All @@ -192,6 +259,13 @@ export default function App() {
setActiveListId(null);
};

const handleSelectImportFolder = (target: ImportFolderTarget) => {
const next = { ...importFolderPreferencesRef.current, [activeRuleSet]: target };
importFolderPreferencesRef.current = next;
saveImportFolderPreferences(next);
setImportFolderPreferences(next);
};

const toggleTheme = () => setTheme(theme === "dark" ? "light" : "dark");

return (
Expand Down Expand Up @@ -230,13 +304,19 @@ export default function App() {
ruleSets={ruleSets}
activeRuleSet={activeRuleSet}
lists={visibleLists}
folders={visibleFolders}
activeListId={activeListId}
onSelect={(id) => {
setActiveListId(id);
setMenuOpen(false);
}}
onCreate={handleCreate}
onDelete={handleDelete}
onCreateFolder={handleCreateFolder}
onRenameFolder={handleRenameFolder}
onDeleteFolder={handleDeleteFolder}
onMoveList={handleMoveList}
onMoveFolder={handleMoveFolder}
onInfo={setInfoTopic}
theme={theme}
onToggleTheme={toggleTheme}
Expand Down Expand Up @@ -303,7 +383,14 @@ export default function App() {
simplifiedView={simplifiedView}
onToggleSimplifiedView={setSimplifiedView}
lists={lists}
onReplaceAllLists={handleReplaceAllLists}
folders={folders}
importFolderTarget={normalizeImportFolderTarget(
folders,
activeRuleSet,
importFolderPreference(importFolderPreferences, activeRuleSet),
)}
onSelectImportFolder={handleSelectImportFolder}
onReplaceAll={handleReplaceAll}
onClose={() => setConfigOpen(false)}
/>
)}
Expand Down
50 changes: 50 additions & 0 deletions src/components/Catalog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { render, screen, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { getArmy, getRuleSet } from "../data/gameData";
import { createList } from "../domain/lists";
import Catalog from "./Catalog";

function renderCatalog(ruleSetId: string) {
const ruleSet = getRuleSet(ruleSetId)!;
const army = getArmy(ruleSetId, "dwarfs")!;
const list = createList(ruleSetId, ruleSet.version, "dwarfs", "Test Dwarfs", 2000);
render(
<Catalog
army={army}
list={list}
onAddUnit={vi.fn()}
onAddCharacter={vi.fn()}
onOpenMagicItems={vi.fn()}
/>,
);
}

describe("Catalog", () => {
it("separates custom Dwarf characters in the right rail", () => {
renderCatalog("warmaster-custom");

const standardList = screen.getByRole("heading", { name: "Characters" }).nextElementSibling!;
const customList = screen.getByRole("heading", {
name: "Custom Characters",
}).nextElementSibling!;

expect(within(standardList as HTMLElement).getByText("Runesmith")).toBeInTheDocument();
expect(within(standardList as HTMLElement).queryByText("Dramar Thungnisson")).toBeNull();
expect(within(customList as HTMLElement).getByText("Dramar Thungnisson")).toBeInTheDocument();
expect(within(customList as HTMLElement).getByText("Roknar Gromdal")).toBeInTheDocument();
});

it("does not add a custom heading to the standard ruleset", () => {
renderCatalog("warmaster-revolution");
expect(screen.queryByRole("heading", { name: "Custom Characters" })).toBeNull();
});

it("displays flat and per-1000 maximums at their correct 2000-point values", () => {
renderCatalog("warmaster-custom");

const fixedCapRow = screen.getByText("Dramar Thungnisson", { exact: true }).closest("li")!;
const scaledCapRow = screen.getByText("Ram Riders", { exact: true }).closest("li")!;
expect(fixedCapRow).toHaveTextContent("Min/Max -/1");
expect(scaledCapRow).toHaveTextContent("Min/Max -/4");
});
});
28 changes: 25 additions & 3 deletions src/components/Catalog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ function CatalogRow({
onAdd?: () => void;
}) {
const isGeneral = unit.type === "General";
const max = unit.max != null ? (isGeneral ? unit.max : unit.max * scale) : null;
const max =
unit.max != null ? (isGeneral || unit.maxPerArmy ? unit.max : unit.max * scale) : null;

const atMax = max != null && count >= max;
// What hiring this regiment will cost the army's own allowances. Only
Expand Down Expand Up @@ -120,7 +121,12 @@ export default function Catalog({
// army, so nothing is held back.
const own = army.units.filter((u) => !isHired(u, army));
const units = own.filter((u) => u.category === "unit");
const characters = own.filter((u) => u.category === "character");
const characters = own.filter(
(u) => u.category === "character" && u.ruleSet !== "warmaster-custom",
);
const customCharacters = own.filter(
(u) => u.category === "character" && u.ruleSet === "warmaster-custom",
);
const upgrades = own.filter((u) => u.category === "upgrade");
const regiments = list.allowMercenaries ? hireableFor(army) : [];
const hired = hiredCount(list, army);
Expand Down Expand Up @@ -157,6 +163,23 @@ export default function Catalog({
/>
))}
</ul>
{customCharacters.length > 0 && (
<>
<h3 className="panel-heading">Custom Characters</h3>
<ul className="catalog-list">
{customCharacters.map((unit) => (
<CatalogRow
key={unit.unitId}
unit={unit}
army={army}
count={countOf(list, unit.unitId)}
scale={scale}
onAdd={() => onAddCharacter(unit.unitId)}
/>
))}
</ul>
</>
)}
{upgrades.length > 0 && (
<>
<h3 className="panel-heading">Mounts &amp; upgrades</h3>
Expand Down Expand Up @@ -238,4 +261,3 @@ export default function Catalog({
</div>
);
}

52 changes: 52 additions & 0 deletions src/components/ConfigDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { IMPORTS_FOLDER_TARGET, folderImportTarget } from "../domain/folders";
import type { Folder, RuleSetInfo } from "../types";
import ConfigDialog from "./ConfigDialog";

const ruleSets = [
{ id: "warmaster-custom", name: "A Matter of Mustaches", version: "1", armies: [] },
{ id: "warmaster-revolution", name: "Warmaster Revolution", version: "1", armies: [] },
] satisfies RuleSetInfo[];

function renderDialog(folders: Folder[], onSelectImportFolder = vi.fn()) {
render(
<ConfigDialog
ruleSets={ruleSets}
activeRuleSet="warmaster-custom"
onSelectRuleSet={vi.fn()}
simplifiedView={false}
onToggleSimplifiedView={vi.fn()}
lists={[]}
folders={folders}
importFolderTarget={IMPORTS_FOLDER_TARGET}
onSelectImportFolder={onSelectImportFolder}
onReplaceAll={vi.fn()}
onClose={vi.fn()}
/>,
);
return onSelectImportFolder;
}

describe("ConfigDialog default import folder", () => {
it("defaults to Imports even when no Imports folder exists", () => {
renderDialog([]);
const select = screen.getByRole("combobox", { name: "Default import folder" });
expect(select).toHaveValue(IMPORTS_FOLDER_TARGET);
expect(screen.getByRole("option", { name: "Imports" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "No folder" })).toBeInTheDocument();
});

it("offers existing folders from only the active rule set", () => {
const onSelect = renderDialog([
{ id: "events", ruleSet: "warmaster-custom", name: "Events", sortIndex: 0 },
{ id: "other", ruleSet: "warmaster-revolution", name: "Other", sortIndex: 0 },
]);
const select = screen.getByRole("combobox", { name: "Default import folder" });
expect(screen.getByRole("option", { name: "Events" })).toBeInTheDocument();
expect(screen.queryByRole("option", { name: "Other" })).toBeNull();

fireEvent.change(select, { target: { value: folderImportTarget("events") } });
expect(onSelect).toHaveBeenCalledWith(folderImportTarget("events"));
});
});
Loading