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