diff --git a/public/main.js b/public/main.js index 65a059d3a1..1a92a274fb 100644 --- a/public/main.js +++ b/public/main.js @@ -71,9 +71,9 @@ let options = { prec: 100, // precipitation modifier in % showBurgPreview: true, burgs: { - groups: JSON.safeParse(localStorage.getItem("burg-groups")) || Burgs.getDefaultGroups() + groups: Burgs.parseStoredGroups(localStorage.getItem("burg-groups")) }, - labels: JSON.safeParse(localStorage.getItem("options-labels")) || Labels.getDefaultOptions(), + labels: Labels.parseStoredOptions(localStorage.getItem("options-labels")), emblems: { showAll: false }, trade: { animation: JSON.safeParse(localStorage.getItem("trade-animation")) || TradeAnimation.getDefaultOptions() diff --git a/public/modules/ui/options.js b/public/modules/ui/options.js index 91203b4f1f..4325ac9e42 100644 --- a/public/modules/ui/options.js +++ b/public/modules/ui/options.js @@ -615,8 +615,8 @@ function randomizeOptions() { // a loaded map's migrated group registries are map data, not session preferences: re-seed // from the same source boot uses, so new maps get the user's saved groups or the defaults - options.burgs.groups = JSON.safeParse(localStorage.getItem("burg-groups")) || Burgs.getDefaultGroups(); - options.labels = JSON.safeParse(localStorage.getItem("options-labels")) || Labels.getDefaultOptions(); + options.burgs.groups = Burgs.parseStoredGroups(localStorage.getItem("burg-groups")); + options.labels = Labels.parseStoredOptions(localStorage.getItem("options-labels")); // 'Options' settings if (randomize || !stored("points")) changeCellsDensity(4); // reset to default, no need to randomize diff --git a/src/generators/burgs-generator.test.ts b/src/generators/burgs-generator.test.ts index aa872483e9..a938731a30 100644 --- a/src/generators/burgs-generator.test.ts +++ b/src/generators/burgs-generator.test.ts @@ -704,3 +704,34 @@ describe("ensureBurgGroupStyles", () => { expect(anchors.groups.fortresses).toEqual(townAnchor); }); }); + +describe("BurgsModule.parseStoredGroups", () => { + let Burgs: any; + + beforeEach(async () => { + globalThis.TIME = false; + globalThis.window = globalThis.window || ({} as any); + await import("./burgs-generator"); + Burgs = (globalThis as any).Burgs; + }); + + it("falls back to the defaults when the stored value holds no usable group", () => { + const defaults = Burgs.getDefaultGroups(); + expect(Burgs.parseStoredGroups(null)).toEqual(defaults); + expect(Burgs.parseStoredGroups("[]")).toEqual(defaults); + expect(Burgs.parseStoredGroups("not json")).toEqual(defaults); + expect(Burgs.parseStoredGroups(JSON.stringify([{ order: 1 }]))).toEqual(defaults); + }); + + it("keeps usable stored groups and drops the rest", () => { + const usable = { name: "outpost", order: 3 }; + const stored = JSON.stringify([usable, { order: 4 }, { name: "noOrder" }]); + const groups = Burgs.parseStoredGroups(stored); + expect(groups.map((group: any) => group.name)).toEqual(["outpost"]); + }); + + it("always leaves a default group for burg assignment to fall back on", () => { + const groups = Burgs.parseStoredGroups(JSON.stringify([{ name: "outpost", order: 3 }])); + expect(groups.filter((group: any) => group.isDefault).length).toBe(1); + }); +}); diff --git a/src/generators/burgs-generator.ts b/src/generators/burgs-generator.ts index 7d2e3416b7..53328977c8 100644 --- a/src/generators/burgs-generator.ts +++ b/src/generators/burgs-generator.ts @@ -3,6 +3,7 @@ import { Emblems } from "@/generators/emblems-generator"; import { buildSettlemakerUrl } from "@/services/previews/settlemaker"; import type { BurgGroup } from "@/types/burg-groups"; import type { Emblem } from "@/types/emblems"; +import { safeParseJSON } from "@/utils/stringUtils"; import { each, ensureEl, gauss, minmax, normalize, P, rn } from "../utils"; import { buildBurgContext } from "./burg-context"; import { type CultureType, DEFAULT_CULTURE_TYPE } from "./cultures-generator"; @@ -1024,6 +1025,19 @@ class BurgModule { } } + /** burg assignment needs a named, ordered group and a default to fall back on: a value persisted + * by an older build can satisfy neither and still parse */ + parseStoredGroups(stored: string | null): BurgGroup[] { + const parsed = stored ? safeParseJSON(stored) : null; + const groups: BurgGroup[] = Array.isArray(parsed) + ? parsed.filter(group => typeof group?.name === "string" && typeof group?.order === "number") + : []; + if (!groups.length) return this.getDefaultGroups(); + + if (!groups.some(group => group.isDefault)) groups[0].isDefault = true; + return groups; + } + getDefaultGroups(): BurgGroup[] { return [ { diff --git a/src/generators/labels-generator.test.ts b/src/generators/labels-generator.test.ts index 5e35cf26cf..2ad6a5dd30 100644 --- a/src/generators/labels-generator.test.ts +++ b/src/generators/labels-generator.test.ts @@ -1,5 +1,7 @@ import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { LABEL_TYPES } from "./labels-generator"; + let LabelsModule: typeof import("./labels-generator").LabelsModule; let labels: import("./labels-generator").LabelsModule; @@ -80,3 +82,44 @@ describe("ensureBurgLabelGroups", () => { expect(options.labels.groups.length).toBe(count); }); }); + +describe("parseStoredOptions", () => { + it("restores label types whose groups are missing from the stored value", () => { + const stored = JSON.stringify({ resizeOnZoom: true, showAll: false, groups: [] }); + const parsed = labels.parseStoredOptions(stored); + for (const type of LABEL_TYPES) { + expect(parsed.groups.some(group => group.type === type)).toBe(true); + } + }); + + it("keeps stored groups the renderer can use", () => { + const custom = { name: "outpost", type: "burg", zoom: { min: 3, max: 40 } }; + const parsed = labels.parseStoredOptions(JSON.stringify({ groups: [custom] })); + expect(parsed.groups).toContainEqual(custom); + }); + + it("drops groups that are missing a name, a known type or zoom bounds", () => { + const groups = [ + { type: "burg", zoom: { min: 1, max: 2 } }, + { name: "ghost", type: "spaceship", zoom: { min: 1, max: 2 } }, + { name: "noZoom", type: "burg" } + ]; + const parsed = labels.parseStoredOptions(JSON.stringify({ groups })); + const names = parsed.groups.map(group => group.name); + expect(names.includes("ghost")).toBe(false); + expect(names.includes("noZoom")).toBe(false); + }); + + it("falls back to the defaults when the stored value is absent or unusable", () => { + const defaults = labels.getDefaultOptions(); + expect(labels.parseStoredOptions(null)).toEqual(defaults); + expect(labels.parseStoredOptions("not json")).toEqual(defaults); + expect(labels.parseStoredOptions("[1,2,3]")).toEqual(defaults); + }); + + it("keeps stored flags but repairs ones of the wrong type", () => { + const parsed = labels.parseStoredOptions(JSON.stringify({ resizeOnZoom: false, showAll: "yes" })); + expect(parsed.resizeOnZoom).toBe(false); + expect(parsed.showAll).toBe(labels.getDefaultOptions().showAll); + }); +}); diff --git a/src/generators/labels-generator.ts b/src/generators/labels-generator.ts index 96f426ed35..85d0ffdfa3 100644 --- a/src/generators/labels-generator.ts +++ b/src/generators/labels-generator.ts @@ -1,5 +1,6 @@ import type { LayerId } from "@/components/layers"; import type { Point } from "@/types/global"; +import { safeParseJSON } from "@/utils/stringUtils"; export const LABEL_TYPES = ["state", "province", "burg", "river", "route", "added"] as const; @@ -137,6 +138,29 @@ export class LabelsModule { }; } + /** a value persisted by an older build can be structurally valid and still leave the renderer with + * nothing to draw, so drop groups it cannot use and restore any type left without one */ + parseStoredOptions(stored: string | null) { + const defaults = this.getDefaultOptions(); + const parsed = stored ? safeParseJSON(stored) : null; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return defaults; + + const isUsable = (group: LabelGroup) => + Boolean(group?.name) && (LABEL_TYPES as readonly string[]).includes(group?.type) && Boolean(group?.zoom); + const groups: LabelGroup[] = Array.isArray(parsed.groups) ? parsed.groups.filter(isUsable) : []; + for (const type of LABEL_TYPES) { + if (groups.some(group => group.type === type)) continue; + groups.push(...defaults.groups.filter(group => group.type === type)); + } + + const flag = (value: unknown, fallback: boolean) => (typeof value === "boolean" ? value : fallback); + return { + resizeOnZoom: flag(parsed.resizeOnZoom, defaults.resizeOnZoom), + showAll: flag(parsed.showAll, defaults.showAll), + groups + }; + } + /** burgs can be assigned to groups the label registry has never seen (old maps, the Burg * Groups editor) - without an entry the renderer draws no label at all */ ensureBurgLabelGroups(): void { diff --git a/src/index.html b/src/index.html index 8b288c8fdd..5781ca2ec1 100644 --- a/src/index.html +++ b/src/index.html @@ -5196,8 +5196,8 @@ - - + + diff --git a/tests/e2e/stored-options.spec.ts b/tests/e2e/stored-options.spec.ts new file mode 100644 index 0000000000..c746f01ed2 --- /dev/null +++ b/tests/e2e/stored-options.spec.ts @@ -0,0 +1,41 @@ +import { expect, test } from "@playwright/test"; + +// a value written by an older build can be structurally valid and still leave the renderer with +// nothing to draw; it is read on boot and again in randomizeOptions, so both sites have to validate +const STALE = { + "options-labels": '{"resizeOnZoom":true,"showAll":false,"groups":[]}', + "burg-groups": "[]" +}; + +async function generateMap(page: import("@playwright/test").Page) { + await page.goto("/"); + await page.waitForSelector("#mapToLoad", { state: "attached", timeout: 60000 }); + await page.waitForFunction(() => (window as any).mapId !== undefined, { timeout: 120000 }); + await page.waitForTimeout(1000); + + return page.evaluate(() => { + const labels = document.getElementById("labels"); + return { + groups: labels?.children.length ?? 0, + labelTexts: labels?.querySelectorAll("text").length ?? 0, + // `options` is a global binding from main.js, not a window property + labelGroups: (0, eval)("options.labels.groups.length"), + burgGroups: (0, eval)("options.burgs.groups.length") + }; + }); +} + +test("a stale stored group registry does not leave a new map without labels", async ({ page }) => { + await page.goto("/"); + await page.evaluate(stale => { + localStorage.clear(); + for (const [key, value] of Object.entries(stale)) localStorage.setItem(key, value); + }, STALE); + + const { groups, labelTexts, labelGroups, burgGroups } = await generateMap(page); + + expect(labelGroups).toBeGreaterThan(0); + expect(burgGroups).toBeGreaterThan(0); + expect(groups).toBeGreaterThan(0); + expect(labelTexts).toBeGreaterThan(0); +});