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
4 changes: 2 additions & 2 deletions public/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions public/modules/ui/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions src/generators/burgs-generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
14 changes: 14 additions & 0 deletions src/generators/burgs-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 [
{
Expand Down
43 changes: 43 additions & 0 deletions src/generators/labels-generator.test.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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);
});
});
24 changes: 24 additions & 0 deletions src/generators/labels-generator.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5196,8 +5196,8 @@
<script defer src="libs/polylabel.min.js?v1.105.0"></script>
<script defer src="libs/simplify.js?v1.105.6"></script>
<script defer src="modules/ui/style-presets.js?v=75cc6072"></script>
<script defer src="modules/ui/options.js?v=0068b486"></script>
<script defer src="main.js?v=49e5199d"></script>
<script defer src="modules/ui/options.js?v=ac09c6c9"></script>
<script defer src="main.js?v=307c9c9f"></script>
<script defer src="modules/ui/style.js?v=75385c7b"></script>
<script defer src="libs/rgbquant.min.js"></script>
<script defer src="libs/jquery.ui.touch-punch.min.js"></script>
Expand Down
41 changes: 41 additions & 0 deletions tests/e2e/stored-options.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading