Skip to content

Commit 924afa7

Browse files
committed
feat(preferences): introduce mod preset persistence using localStorage
closes #109 - Add support for saving and clearing default mod presets. - Persist default mods via `preferences.svelte.ts` and inject into bare URLs during bootstrap. - Update tests to cover mod persistence behavior. - Document the feature with ADR-008: Mod Preset Persistence.
1 parent f9ff880 commit 924afa7

File tree

7 files changed

+258
-3
lines changed

7 files changed

+258
-3
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# ADR-008: Mod Preset Persistence
2+
3+
- **Status:** Accepted
4+
- **Context:** Users repeatedly configure the same modset. The URL is the source of truth (ADR-002), but bare URLs have no mods.
5+
- **Decision:** `localStorage` stores a default mod preset under `cbn-guide:default-mods`. On bootstrap, bare URLs (no `mods` param) get the saved preset injected via `replaceState`. Applying mods in the ModSelector automatically saves them. Saving an empty modset clears the preset.
6+
- **Consequences:**
7+
- Positive: users configure once, bare URLs auto-load their preset.
8+
- Negative: `localStorage` adds a persistence dependency; clearing browser data loses the preset.
9+
- Neutral: shared URLs with explicit `?mods=` are unaffected.
10+
- **References:** `src/preferences.svelte.ts`, `src/navigation.svelte.ts`, ADR-002.

docs/adr/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@ An Architecture Decision Record captures an important architectural decision mad
2020
- [ADR-005](ADR-005_data_inheritance.md) - Robust Inheritance: Migrations and Self-Copy Handling
2121
- [ADR-006](ADR-006_layered_navigation_bootstrap_and_page_metadata.md) - Layered Navigation Bootstrap and Page Metadata
2222
- [ADR-007](ADR-007_data_loading_orchestration.md) - Data Loading Orchestration, Fetch Boundary, and Retry Removal
23+
- [ADR-008](ADR-008_mod_preset_persistence.md) - Mod Preset Persistence

docs/routing.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ That separation matters because the app should preserve what the user is actuall
9999
| State kind | Owner | Lifetime | Notes |
100100
| :--------------------------- | :---------------------------------------------------------- | :------------------- | :------------------------------------------------------------------------------------------ |
101101
| Raw route state | [`src/routing.svelte.ts`](../src/routing.svelte.ts) | Browser session | Mirrors the current URL and history entry. |
102-
| Persisted preference state | [`src/preferences.svelte.ts`](../src/preferences.svelte.ts) | Across sessions | Currently stores the preferred tileset only. |
102+
| Persisted preference state | [`src/preferences.svelte.ts`](../src/preferences.svelte.ts) | Across sessions | Stores the preferred tileset and an optional default mod preset. |
103103
| Bootstrap metadata | [`src/builds.svelte.ts`](../src/builds.svelte.ts) | Per page load | Resolves aliases like `stable` and `nightly` into concrete builds. |
104104
| Effective navigation context | [`src/navigation.svelte.ts`](../src/navigation.svelte.ts) | Derived at read time | Combines route state, preferences, and build metadata into the values the UI actually uses. |
105105

@@ -232,7 +232,7 @@ Choose the navigation transport by intent:
232232

233233
- Version aliases depend on build metadata. Until that metadata is available, the app only knows the requested version, not the resolved concrete build.
234234
- Malformed version URLs are canonicalized after build metadata loads, so invalid or missing version segments do not surface as user-facing bootstrap failures.
235-
- The app does not persist locale or mods as browser preferences.
235+
- The app does not persist locale as a browser preference. Mods can be persisted as a default preset via `preferences.svelte.ts`.
236236
- The route layer is browser-oriented state, so server-side initialization uses a safe placeholder URL.
237237
- History synchronization is global, so the routing module installs exactly one popstate listener.
238238

src/navigation.svelte.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { resolveTileset } from "./tile-data";
22
import {
33
initializePreferences,
44
preferences,
5+
setDefaultMods,
56
setPreferredTileset,
67
} from "./preferences.svelte";
78
import {
@@ -146,6 +147,7 @@ export function changeVersion(buildVersion: string): void {
146147
}
147148

148149
export function changeMods(mods: string[]): void {
150+
setDefaultMods(mods);
149151
location.href = buildURL(
150152
navigation.buildRequestedVersion,
151153
navigation.target,
@@ -165,6 +167,22 @@ export function changeMods(mods: string[]): void {
165167
export async function bootstrapApplication(): Promise<void> {
166168
initializeRouting();
167169
initializePreferences();
170+
171+
if (
172+
page.route.modsParam.length === 0 &&
173+
preferences.defaultMods !== null &&
174+
preferences.defaultMods.length > 0
175+
) {
176+
const urlWithMods = buildURL(
177+
page.route.versionSlug,
178+
page.route.target,
179+
page.route.localeParam,
180+
page.route.tilesetParam,
181+
preferences.defaultMods,
182+
);
183+
navigateToURL(urlWithMods, "replace");
184+
}
185+
168186
const versionState = await initializeBuildsState();
169187

170188
const canonicalURL = canonicalizeMalformedVersionURL(

src/navigation.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ describe("navigation", () => {
4848
_resetPreferences();
4949
_resetBuildsState();
5050
localStorage.removeItem?.("cbn-guide:tileset");
51+
localStorage.removeItem?.("cbn-guide:default-mods");
5152
global.fetch = defaultFetchMock;
5253
});
5354

@@ -87,6 +88,7 @@ describe("navigation", () => {
8788
});
8889
expect(preferences).toEqual({
8990
preferredTileset: "retrodays",
91+
defaultMods: null,
9092
});
9193
expect(buildLinkTo({ kind: "home" })).toBe("/stable/?t=ultica");
9294
});
@@ -190,6 +192,7 @@ describe("navigation", () => {
190192
expect(navigation.tileset).toBe("retrodays");
191193
expect(preferences).toEqual({
192194
preferredTileset: "retrodays",
195+
defaultMods: null,
193196
});
194197
expect(window.location.search).toContain("t=retrodays");
195198
expect(replaceStateSpy).toHaveBeenCalledOnce();
@@ -225,4 +228,65 @@ describe("navigation", () => {
225228
target: { kind: "item", type: "item", id: "rock" },
226229
});
227230
});
231+
232+
test("bootstrap injects saved mods into bare URL", async () => {
233+
localStorage.setItem(
234+
"cbn-guide:default-mods",
235+
JSON.stringify(["aftershock"]),
236+
);
237+
setWindowLocation("stable/");
238+
_resetRouting();
239+
const replaceStateSpy = vi
240+
.spyOn(history, "replaceState")
241+
.mockImplementation((_, __, url) => {
242+
const nextUrl = new URL(String(url), window.location.origin);
243+
window.location.pathname = nextUrl.pathname;
244+
window.location.search = nextUrl.search;
245+
window.location.href = nextUrl.toString();
246+
});
247+
248+
await bootstrapApplication();
249+
250+
expect(replaceStateSpy).toHaveBeenCalledWith(
251+
null,
252+
"",
253+
"/stable/?mods=aftershock",
254+
);
255+
expect(page.route.modsParam).toEqual(["aftershock"]);
256+
});
257+
258+
test("bootstrap does not inject when URL already has mods", async () => {
259+
localStorage.setItem(
260+
"cbn-guide:default-mods",
261+
JSON.stringify(["magiclysm"]),
262+
);
263+
setWindowLocation("stable/", "?mods=aftershock");
264+
_resetRouting();
265+
const replaceStateSpy = vi.spyOn(history, "replaceState");
266+
267+
await bootstrapApplication();
268+
269+
expect(replaceStateSpy).not.toHaveBeenCalledWith(
270+
null,
271+
"",
272+
expect.stringContaining("magiclysm"),
273+
);
274+
expect(page.route.modsParam).toEqual(["aftershock"]);
275+
});
276+
277+
test("bootstrap does not inject when no preset saved", async () => {
278+
localStorage.removeItem("cbn-guide:default-mods");
279+
setWindowLocation("stable/");
280+
_resetRouting();
281+
const replaceStateSpy = vi.spyOn(history, "replaceState");
282+
283+
await bootstrapApplication();
284+
285+
expect(replaceStateSpy).not.toHaveBeenCalledWith(
286+
null,
287+
"",
288+
expect.stringContaining("mods="),
289+
);
290+
expect(page.route.modsParam).toEqual([]);
291+
});
228292
});

src/preferences.svelte.test.ts

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,15 @@
55
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
66
import {
77
_resetPreferences,
8+
clearSavedDefaultMods,
89
initializePreferences,
910
preferences,
11+
setDefaultMods,
1012
setPreferredTileset,
1113
} from "./preferences.svelte";
1214

1315
const STORAGE_KEY = "cbn-guide:tileset";
16+
const DEFAULT_MODS_STORAGE_KEY = "cbn-guide:default-mods";
1417
const DEFAULT_TILESET = "undead_people";
1518

1619
function installMockStorage() {
@@ -46,19 +49,21 @@ describe("preferences", () => {
4649
});
4750

4851
afterEach(() => {
52+
vi.restoreAllMocks();
4953
localStorage.removeItem?.(STORAGE_KEY);
5054
_resetPreferences();
51-
vi.restoreAllMocks();
5255
});
5356

5457
test("loads the persisted tileset preference when the route omits it", () => {
5558
localStorage.setItem(STORAGE_KEY, "retrodays");
5659

5760
expect(initializePreferences()).toEqual({
5861
preferredTileset: "retrodays",
62+
defaultMods: null,
5963
});
6064
expect(preferences).toEqual({
6165
preferredTileset: "retrodays",
66+
defaultMods: null,
6267
});
6368
});
6469

@@ -67,6 +72,7 @@ describe("preferences", () => {
6772

6873
expect(initializePreferences()).toEqual({
6974
preferredTileset: DEFAULT_TILESET,
75+
defaultMods: null,
7076
});
7177
expect(localStorage.getItem(STORAGE_KEY)).toBe(DEFAULT_TILESET);
7278
});
@@ -75,6 +81,7 @@ describe("preferences", () => {
7581
expect(setPreferredTileset("bad-input")).toBe(false);
7682
expect(preferences).toEqual({
7783
preferredTileset: DEFAULT_TILESET,
84+
defaultMods: null,
7885
});
7986
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
8087
});
@@ -89,11 +96,113 @@ describe("preferences", () => {
8996

9097
expect(initializePreferences()).toEqual({
9198
preferredTileset: DEFAULT_TILESET,
99+
defaultMods: null,
92100
});
93101

94102
expect(() => setPreferredTileset("retrodays")).not.toThrow();
95103
expect(preferences).toEqual({
96104
preferredTileset: "retrodays",
105+
defaultMods: null,
106+
});
107+
});
108+
});
109+
110+
describe("defaultMods", () => {
111+
beforeEach(() => {
112+
installMockStorage();
113+
localStorage.removeItem?.(DEFAULT_MODS_STORAGE_KEY);
114+
_resetPreferences();
115+
});
116+
117+
afterEach(() => {
118+
vi.restoreAllMocks();
119+
localStorage.removeItem?.(DEFAULT_MODS_STORAGE_KEY);
120+
_resetPreferences();
121+
});
122+
123+
test("loads saved modset from localStorage", () => {
124+
localStorage.setItem(
125+
DEFAULT_MODS_STORAGE_KEY,
126+
JSON.stringify(["aftershock", "magiclysm"]),
127+
);
128+
129+
expect(initializePreferences().defaultMods).toEqual([
130+
"aftershock",
131+
"magiclysm",
132+
]);
133+
expect(preferences.defaultMods).toEqual(["aftershock", "magiclysm"]);
134+
});
135+
136+
test("returns null when no key exists", () => {
137+
expect(initializePreferences().defaultMods).toBeNull();
138+
expect(preferences.defaultMods).toBeNull();
139+
});
140+
141+
test("discards malformed JSON gracefully", () => {
142+
localStorage.setItem(DEFAULT_MODS_STORAGE_KEY, "{ bad json }");
143+
144+
expect(initializePreferences().defaultMods).toBeNull();
145+
expect(preferences.defaultMods).toBeNull();
146+
});
147+
148+
test("discards non-array JSON gracefully", () => {
149+
localStorage.setItem(
150+
DEFAULT_MODS_STORAGE_KEY,
151+
JSON.stringify({ mods: ["aftershock"] }),
152+
);
153+
154+
expect(initializePreferences().defaultMods).toBeNull();
155+
expect(preferences.defaultMods).toBeNull();
156+
});
157+
158+
test("setDefaultMods persists and updates state", () => {
159+
setDefaultMods(["aftershock"]);
160+
161+
expect(preferences.defaultMods).toEqual(["aftershock"]);
162+
expect(localStorage.getItem(DEFAULT_MODS_STORAGE_KEY)).toBe(
163+
JSON.stringify(["aftershock"]),
164+
);
165+
});
166+
167+
test("setDefaultMods([]) clears the preset", () => {
168+
localStorage.setItem(
169+
DEFAULT_MODS_STORAGE_KEY,
170+
JSON.stringify(["aftershock"]),
171+
);
172+
setDefaultMods([]);
173+
174+
expect(preferences.defaultMods).toBeNull();
175+
expect(localStorage.getItem(DEFAULT_MODS_STORAGE_KEY)).toBeNull();
176+
});
177+
178+
test("clearSavedDefaultMods removes key and state", () => {
179+
localStorage.setItem(
180+
DEFAULT_MODS_STORAGE_KEY,
181+
JSON.stringify(["aftershock"]),
182+
);
183+
clearSavedDefaultMods();
184+
185+
expect(preferences.defaultMods).toBeNull();
186+
expect(localStorage.getItem(DEFAULT_MODS_STORAGE_KEY)).toBeNull();
187+
});
188+
189+
test("swallows storage failures silently", () => {
190+
vi.spyOn(localStorage, "getItem").mockImplementation(() => {
191+
throw new Error("storage denied");
192+
});
193+
vi.spyOn(localStorage, "setItem").mockImplementation(() => {
194+
throw new Error("storage denied");
97195
});
196+
vi.spyOn(localStorage, "removeItem").mockImplementation(() => {
197+
throw new Error("storage denied");
198+
});
199+
200+
expect(initializePreferences().defaultMods).toBeNull();
201+
202+
expect(() => setDefaultMods(["aftershock"])).not.toThrow();
203+
expect(preferences.defaultMods).toEqual(["aftershock"]);
204+
205+
expect(() => clearSavedDefaultMods()).not.toThrow();
206+
expect(preferences.defaultMods).toBeNull();
98207
});
99208
});

0 commit comments

Comments
 (0)