diff --git a/src/utils/markdown.test.ts b/src/utils/markdown.test.ts
index 59fcc09754..657875faa1 100644
--- a/src/utils/markdown.test.ts
+++ b/src/utils/markdown.test.ts
@@ -62,6 +62,14 @@ describe("renderMarkdown", () => {
expect(renderMarkdown("[bad](javascript:alert(1))")).toBe("
[bad](javascript:alert(1))
");
});
+ it("cannot break out of the href attribute to inject a second attribute", () => {
+ const output = renderMarkdown('[t](https://a"onmouseover=alert(1))');
+ expect(output).toBe(
+ '
t )
'
+ );
+ expect((output.match(/ [a-z-]+="/g) || []).length).toBe(3); // href, target, rel — no extra attribute
+ });
+
it("escapes markup so model output cannot inject HTML", () => {
expect(renderMarkdown('
')).toBe(
"
<img src=x onerror="alert(1)">
"
diff --git a/tests/e2e/controller-launchers.spec.ts b/tests/e2e/controller-launchers.spec.ts
index 7eda40f299..e811221a0c 100644
--- a/tests/e2e/controller-launchers.spec.ts
+++ b/tests/e2e/controller-launchers.spec.ts
@@ -11,7 +11,6 @@ test.describe("controller launchers", () => {
await page.click("#toolsTab");
await page.click("#overviewMarkersButton");
await expect(page.locator("#markersOverview")).toBeVisible();
- await expect(page.locator("#chat-widget-container")).toBeVisible();
await page.click("#markersGenerationConfig");
From 4291667378fdcf9aabeb24ddcce202050699fd66 Mon Sep 17 00:00:00 2001
From: barrulus
Date: Wed, 2 Sep 2026 09:04:12 +0100
Subject: [PATCH 10/37] fix: lift assistant bubble clear of the scale bar,
compact unlisted-origin panel
---
public/index.css | 6 +++++-
src/controllers/help-assistant.ts | 2 +-
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/public/index.css b/public/index.css
index be34ae6a61..03af7817d5 100644
--- a/public/index.css
+++ b/public/index.css
@@ -2635,7 +2635,7 @@ body.tour-free-roam * {
#helpAssistantBubble {
position: fixed;
right: 16px;
- bottom: 16px;
+ bottom: 64px; /* clear of the scale bar in the corner */
width: 44px;
height: 44px;
border-radius: 50%;
@@ -2649,6 +2649,10 @@ body.tour-free-roam * {
z-index: 99;
}
+#helpAssistant .helpAssistantUnlisted {
+ max-width: 20em;
+}
+
#helpAssistant .helpAssistantLog {
max-height: 40vh;
min-height: 8em;
diff --git a/src/controllers/help-assistant.ts b/src/controllers/help-assistant.ts
index 938bbfe77c..fb3fe348fc 100644
--- a/src/controllers/help-assistant.ts
+++ b/src/controllers/help-assistant.ts
@@ -95,7 +95,7 @@ function renderDialog(): void {
// Self-hosted copies are not on the gateway's origin allowlist: explain, don't error
const unlisted = /* html */ `
-
+
The free assistant is only available on the official site:
azgaar.github.io/Fantasy-Map-Generator .
From e3095133d4ffcc00bd868224652ba0c6ebf18c97 Mon Sep 17 00:00:00 2001
From: barrulus
Date: Wed, 2 Sep 2026 09:14:24 +0100
Subject: [PATCH 11/37] fix: fixed help dialog width and footer spacing
---
public/index.css | 2 ++
src/controllers/help-assistant.ts | 1 +
2 files changed, 3 insertions(+)
diff --git a/public/index.css b/public/index.css
index 03af7817d5..872a88bff4 100644
--- a/public/index.css
+++ b/public/index.css
@@ -2669,6 +2669,8 @@ body.tour-free-roam * {
display: flex;
justify-content: space-between;
align-items: center;
+ gap: 0.8em;
+ width: 100%;
margin-top: 0.3em;
}
diff --git a/src/controllers/help-assistant.ts b/src/controllers/help-assistant.ts
index fb3fe348fc..ef41feb9a8 100644
--- a/src/controllers/help-assistant.ts
+++ b/src/controllers/help-assistant.ts
@@ -63,6 +63,7 @@ function open(): void {
$("#helpAssistant").dialog({
title: "Azgaar's Assistant",
position: { my: "center", at: "center", of: "svg" },
+ width: Math.min(420, window.innerWidth - 20), // fixed sane width — FMG dialogs otherwise grow with content
resizable: false,
close: () => {
if (retryTimer) {
From 90d9186bf5c519cbf9badc772eb8f06576f19e4c Mon Sep 17 00:00:00 2001
From: barrulus
Date: Wed, 2 Sep 2026 09:19:51 +0100
Subject: [PATCH 12/37] feat: community help links (GitHub, Discord, Reddit) in
the assistant dialog
---
public/index.css | 10 ++++++++++
src/controllers/help-assistant.ts | 9 +++++++++
2 files changed, 19 insertions(+)
diff --git a/public/index.css b/public/index.css
index 872a88bff4..b248caa232 100644
--- a/public/index.css
+++ b/public/index.css
@@ -2649,6 +2649,16 @@ body.tour-free-roam * {
z-index: 99;
}
+#helpAssistant .helpAssistantLinks {
+ display: flex;
+ gap: 1.2em;
+ justify-content: center;
+ margin-top: 0.6em;
+ padding-top: 0.5em;
+ border-top: 1px solid rgb(0 0 0 / 12%);
+ font-size: 0.92em;
+}
+
#helpAssistant .helpAssistantUnlisted {
max-width: 20em;
}
diff --git a/src/controllers/help-assistant.ts b/src/controllers/help-assistant.ts
index ef41feb9a8..bbdbff4aec 100644
--- a/src/controllers/help-assistant.ts
+++ b/src/controllers/help-assistant.ts
@@ -94,6 +94,14 @@ function renderDialog(): void {
Questions are kept for 90 days to help improve the documentation.
`;
+ // The community channels the OpenWidget panel used to offer — alternative ways to get help
+ const links = /* html */ `
+
`;
+
// Self-hosted copies are not on the gateway's origin allowlist: explain, don't error
const unlisted = /* html */ `
@@ -107,6 +115,7 @@ function renderDialog(): void {
const html = /* html */ `
${isOfficialOrigin() ? form : unlisted}
+ ${links}
`;
ensureEl("dialogs").insertAdjacentHTML("beforeend", html);
From f91820f47d46b2d949721b682341aa0958420df5 Mon Sep 17 00:00:00 2001
From: barrulus
Date: Wed, 2 Sep 2026 10:09:59 +0100
Subject: [PATCH 13/37] fix: help links point GitHub at the wiki, add Patreon
to the footer
---
src/controllers/help-assistant.ts | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/src/controllers/help-assistant.ts b/src/controllers/help-assistant.ts
index bbdbff4aec..aa75307cd2 100644
--- a/src/controllers/help-assistant.ts
+++ b/src/controllers/help-assistant.ts
@@ -94,12 +94,15 @@ function renderDialog(): void {
Questions are kept for 90 days to help improve the documentation.
`;
- // The community channels the OpenWidget panel used to offer — alternative ways to get help
+ // The community channels the OpenWidget panel used to offer — alternative ways to get help.
+ // GitHub points at the wiki (a turned-away user wants docs, not source). Patreon lives HERE
+ // deliberately and must NOT be added to the cap_reached text — that asymmetry is a ruling.
const links = /* html */ `
`;
// Self-hosted copies are not on the gateway's origin allowlist: explain, don't error
From 0f7445eb5b3f0807f9fa4fb352a226aae665d1a4 Mon Sep 17 00:00:00 2001
From: barrulus
Date: Wed, 2 Sep 2026 10:16:16 +0100
Subject: [PATCH 14/37] feat: help gateway token storage
---
src/services/help/auth.test.ts | 46 ++++++++++++++++++++++++++++++++++
src/services/help/auth.ts | 29 +++++++++++++++++++++
2 files changed, 75 insertions(+)
create mode 100644 src/services/help/auth.test.ts
create mode 100644 src/services/help/auth.ts
diff --git a/src/services/help/auth.test.ts b/src/services/help/auth.test.ts
new file mode 100644
index 0000000000..1a8f4a4f29
--- /dev/null
+++ b/src/services/help/auth.test.ts
@@ -0,0 +1,46 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { clearToken, getToken, storeToken, TOKEN_STORAGE } from "./auth";
+
+afterEach(() => vi.unstubAllGlobals());
+
+const fakeStorage = () => {
+ const map = new Map();
+ return {
+ getItem: (k: string) => map.get(k) ?? null,
+ setItem: (k: string, v: string) => void map.set(k, v),
+ removeItem: (k: string) => void map.delete(k)
+ };
+};
+
+describe("token storage", () => {
+ it("pins the storage key main.js must match", () => {
+ expect(TOKEN_STORAGE).toBe("fmg-help-token");
+ });
+
+ it("stores, reads back, and clears the token", () => {
+ vi.stubGlobal("localStorage", fakeStorage());
+ expect(getToken()).toBeNull();
+ storeToken("tok-123");
+ expect(getToken()).toBe("tok-123");
+ clearToken();
+ expect(getToken()).toBeNull();
+ });
+
+ it("survives a throwing storage (private mode) without throwing", () => {
+ const throwing = {
+ getItem: () => {
+ throw new Error("denied");
+ },
+ setItem: () => {
+ throw new Error("denied");
+ },
+ removeItem: () => {
+ throw new Error("denied");
+ }
+ };
+ vi.stubGlobal("localStorage", throwing);
+ expect(getToken()).toBeNull();
+ expect(() => storeToken("x")).not.toThrow();
+ expect(() => clearToken()).not.toThrow();
+ });
+});
diff --git a/src/services/help/auth.ts b/src/services/help/auth.ts
new file mode 100644
index 0000000000..2fa0047b57
--- /dev/null
+++ b/src/services/help/auth.ts
@@ -0,0 +1,29 @@
+// Bearer-token storage for the help gateway's Discord sign-in. public/main.js stashes the
+// token from the OAuth callback's URL fragment at startup under the SAME key (it is classic
+// JS and cannot import this constant — keep them in sync by hand).
+
+export const TOKEN_STORAGE = "fmg-help-token";
+
+export function getToken(): string | null {
+ try {
+ return localStorage.getItem(TOKEN_STORAGE);
+ } catch {
+ return null;
+ }
+}
+
+export function storeToken(token: string): void {
+ try {
+ localStorage.setItem(TOKEN_STORAGE, token);
+ } catch {
+ // storage unavailable — the user simply stays signed out
+ }
+}
+
+export function clearToken(): void {
+ try {
+ localStorage.removeItem(TOKEN_STORAGE);
+ } catch {
+ // nothing to clear
+ }
+}
From 504fcb43a4c5d37e3bca65da47e1113a22672d1d Mon Sep 17 00:00:00 2001
From: barrulus
Date: Wed, 2 Sep 2026 10:21:44 +0100
Subject: [PATCH 15/37] feat: bearer auth on gateway calls with 401 signed-out
handling
---
src/controllers/help-assistant.test.ts | 4 +++
src/services/help/api.test.ts | 48 ++++++++++++++++++++++++++
src/services/help/api.ts | 33 ++++++++++++++++--
3 files changed, 83 insertions(+), 2 deletions(-)
diff --git a/src/controllers/help-assistant.test.ts b/src/controllers/help-assistant.test.ts
index a2ef77da18..3b373b49df 100644
--- a/src/controllers/help-assistant.test.ts
+++ b/src/controllers/help-assistant.test.ts
@@ -39,6 +39,10 @@ describe("noticeFor", () => {
const notice = noticeFor(new HelpApiError("provider_error", ' '));
expect(notice.html).not.toContain(" {
+ expect(noticeFor(new HelpApiError("unauthorized", "Session expired.")).askDisabled).toBe(false);
+ });
});
describe("limitsLabel", () => {
diff --git a/src/services/help/api.test.ts b/src/services/help/api.test.ts
index 4537ab9623..67bf278a41 100644
--- a/src/services/help/api.test.ts
+++ b/src/services/help/api.test.ts
@@ -104,3 +104,51 @@ describe("getLimits", () => {
expect(limits.remaining).toBe(3);
});
});
+
+describe("bearer token", () => {
+ it("attaches Authorization when a token is stored", async () => {
+ vi.stubGlobal("localStorage", {
+ getItem: () => "tok-abc",
+ setItem: () => {},
+ removeItem: () => {}
+ });
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(jsonResponse(200, { tier: "member", remaining: 10, resetsAt: "2026-09-03T00:00:00.000Z" }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ await getLimits();
+
+ const [, init] = fetchMock.mock.calls[0];
+ expect((init.headers as Record).Authorization).toBe("Bearer tok-abc");
+ });
+
+ it("sends no Authorization header when no token is stored", async () => {
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, { tier: "anonymous", remaining: 5, resetsAt: "x" }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ await getLimits();
+
+ const [, init] = fetchMock.mock.calls[0];
+ expect((init.headers as Record | undefined)?.Authorization).toBeUndefined();
+ });
+
+ it("maps 401 to unauthorized and clears the stored token", async () => {
+ const removed: string[] = [];
+ vi.stubGlobal("localStorage", {
+ getItem: () => "tok-expired",
+ setItem: () => {},
+ removeItem: (k: string) => void removed.push(k)
+ });
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(jsonResponse(401, { error: { code: "unauthorized", message: "Session expired." } }))
+ );
+
+ const error = await ask("q").catch((e: unknown) => e);
+
+ expect(error).toBeInstanceOf(HelpApiError);
+ expect((error as HelpApiError).code).toBe("unauthorized");
+ expect(removed.includes("fmg-help-token")).toBe(true);
+ });
+});
diff --git a/src/services/help/api.ts b/src/services/help/api.ts
index cae4d99d3b..0e71360122 100644
--- a/src/services/help/api.ts
+++ b/src/services/help/api.ts
@@ -2,6 +2,8 @@
// docs/superpowers/specs/2026-09-01-web-help-endpoint-design.md). Everything is server-pinned;
// the request body is exactly {question} by contract — unknown fields are a 400.
+import { clearToken, getToken } from "./auth";
+
export const GATEWAY_URL = "https://ask.azgaarsfmg.com";
// Scheme + host only — the origin the gateway allows, NOT where requests go
@@ -31,7 +33,8 @@ export type HelpErrorCode =
| "blocked"
| "provider_error"
| "invalid_request"
- | "unreachable";
+ | "unreachable"
+ | "unauthorized";
export class HelpApiError extends Error {
code: HelpErrorCode;
@@ -59,9 +62,15 @@ function gatewayBase(): string {
}
async function request(path: string, init: RequestInit): Promise {
+ const token = getToken();
+ const headers: Record = {
+ ...(init.headers as Record | undefined),
+ ...(token ? { Authorization: `Bearer ${token}` } : {})
+ };
+
let response: Response;
try {
- response = await fetch(`${gatewayBase()}${path}`, init);
+ response = await fetch(`${gatewayBase()}${path}`, { ...init, headers });
} catch {
throw new HelpApiError("unreachable", "The assistant is unreachable. Check your connection and try again.");
}
@@ -74,6 +83,11 @@ async function request(path: string, init: RequestInit): Promise {
}
}
+ if (response.status === 401) {
+ clearToken();
+ throw new HelpApiError("unauthorized", "Your sign-in has expired. Sign in with Discord again for more questions.");
+ }
+
let code: HelpErrorCode = "provider_error";
let message = `The assistant returned an error (${response.status}).`;
let retryAfter: number | undefined;
@@ -98,3 +112,18 @@ export const ask = (question: string): Promise =>
});
export const getLimits = (): Promise => request("/v1/limits", { method: "GET" });
+
+// Sign-in is a full-page redirect; the gateway lands the user back on the app URL with
+// #token=… in the fragment (server-configured target — the client passes nothing).
+export function signIn(): void {
+ location.assign(`${gatewayBase()}/v1/auth/discord`);
+}
+
+export async function signOut(): Promise {
+ try {
+ await request("/v1/auth/logout", { method: "POST" });
+ } catch {
+ // signing out locally still works when the server is unreachable
+ }
+ clearToken();
+}
From b870fd4237fbc55d979d8b50fdb493dcdb97da30 Mon Sep 17 00:00:00 2001
From: barrulus
Date: Wed, 2 Sep 2026 10:28:24 +0100
Subject: [PATCH 16/37] =?UTF-8?q?feat:=20Discord=20sign-in=20plumbing=20?=
=?UTF-8?q?=E2=80=94=20fragment=20token,=20auth=20row,=20signed-out=20hand?=
=?UTF-8?q?ling?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
public/index.css | 6 +++++
public/main.js | 9 ++++++++
src/controllers/help-assistant.ts | 38 ++++++++++++++++++++++++++++++-
3 files changed, 52 insertions(+), 1 deletion(-)
diff --git a/public/index.css b/public/index.css
index b248caa232..b71e131f6b 100644
--- a/public/index.css
+++ b/public/index.css
@@ -2684,6 +2684,12 @@ body.tour-free-roam * {
margin-top: 0.3em;
}
+#helpAssistant .helpAssistantFooter #helpAssistantAuth {
+ flex: 1;
+ text-align: right;
+ font-size: 0.92em;
+}
+
#helpAssistant .helpAssistantDisclosure {
font-size: 0.85em;
opacity: 0.7;
diff --git a/public/main.js b/public/main.js
index 9ddfd3638f..bca4ae7bf2 100644
--- a/public/main.js
+++ b/public/main.js
@@ -116,6 +116,15 @@ d3.select("#oceanLayers")
.attr("height", graphHeight);
document.addEventListener("DOMContentLoaded", async () => {
+ // OAuth callback from the help gateway: stash the fragment token and scrub the URL.
+ // Storage key must match TOKEN_STORAGE in src/services/help/auth.ts.
+ if (location.hash.startsWith("#token=")) {
+ try {
+ localStorage.setItem("fmg-help-token", location.hash.slice("#token=".length));
+ } catch {}
+ history.replaceState(null, "", location.pathname + location.search);
+ }
+
// binds the zoom behaviour and its handlers (see src/components/viewbox-events.ts), so it has to
// run before checkLoadParameters - deep links (MFCG, a stored view position) zoom the map on load
applyDefaultViewboxEvents();
diff --git a/src/controllers/help-assistant.ts b/src/controllers/help-assistant.ts
index aa75307cd2..ccd04a71e7 100644
--- a/src/controllers/help-assistant.ts
+++ b/src/controllers/help-assistant.ts
@@ -4,7 +4,8 @@
import { destroyDialog } from "@/components/dialog/dialog-helpers";
import type { Limits } from "@/services/help/api";
-import { ask, getLimits, HelpApiError, OFFICIAL_ORIGIN } from "@/services/help/api";
+import { ask, getLimits, HelpApiError, OFFICIAL_ORIGIN, signIn, signOut } from "@/services/help/api";
+import { getToken } from "@/services/help/auth";
import { renderMarkdown } from "@/utils/markdown";
import { ensureEl } from "../utils";
@@ -90,6 +91,7 @@ function renderDialog(): void {
placeholder="e.g. How do I export my map as SVG?">
Questions are kept for 90 days to help improve the documentation.
`;
@@ -237,12 +239,46 @@ function applyNotice(notice: WidgetNotice, error: HelpApiError, question: string
}, 1000);
}
+// Sign-in is shown only on the exact official origin (or DEV, where the stub closes the
+// loop) — NOT via isOfficialOrigin(): a staging build widens that gate, and sign-in from
+// staging would land the user on production with their token (server redirect is fixed).
+const canSignIn = (): boolean => import.meta.env.DEV || location.origin === OFFICIAL_ORIGIN;
+
+function renderAuth(tier: string): void {
+ const host = document.getElementById("helpAssistantAuth");
+ if (!host) return;
+ host.textContent = "";
+
+ if (tier === "anonymous") {
+ if (!canSignIn()) return;
+ const button = document.createElement("button");
+ button.textContent = "Sign in with Discord for more";
+ button.addEventListener("click", signIn);
+ host.appendChild(button);
+ return;
+ }
+
+ const label = document.createElement("span");
+ label.textContent = `Signed in (${tier}) · `;
+ const out = document.createElement("a");
+ out.href = "#";
+ out.textContent = "Sign out";
+ out.addEventListener("click", event => {
+ event.preventDefault();
+ void signOut().then(() => refreshLimits());
+ });
+ host.appendChild(label);
+ host.appendChild(out);
+}
+
async function refreshLimits(): Promise {
try {
const limits = await getLimits();
ensureEl("helpAssistantLimits").textContent = limitsLabel(limits);
+ renderAuth(limits.tier);
} catch {
// limits are a nicety; asking still reports the authoritative state
+ if (!getToken()) renderAuth("anonymous");
}
}
From b2cd0faf01fda83fe88bb5bab66c2d07727dfa1e Mon Sep 17 00:00:00 2001
From: barrulus
Date: Wed, 2 Sep 2026 10:39:03 +0100
Subject: [PATCH 17/37] fix: guard help sign-in token stash against fixation,
refresh stamps
- signIn() marks a pending flag before redirecting; main.js only stores
the fragment token when that flag is set, always scrubs the hash
either way (token fixation guard)
- drop "moderator" from Limits.tier (web tier is anonymous|member)
- refreshLimits renders auth from local token state on a failed
/v1/limits instead of dropping the sign-out affordance
- refresh stale index.html asset cache stamps for main.js/index.css
---
public/main.js | 27 ++++++++++++++++++++++++---
src/controllers/help-assistant.ts | 6 ++++--
src/index.html | 4 ++--
src/services/help/api.test.ts | 7 +++++++
src/services/help/api.ts | 10 +++++++++-
5 files changed, 46 insertions(+), 8 deletions(-)
diff --git a/public/main.js b/public/main.js
index bca4ae7bf2..135183580d 100644
--- a/public/main.js
+++ b/public/main.js
@@ -117,11 +117,32 @@ d3.select("#oceanLayers")
document.addEventListener("DOMContentLoaded", async () => {
// OAuth callback from the help gateway: stash the fragment token and scrub the URL.
- // Storage key must match TOKEN_STORAGE in src/services/help/auth.ts.
+ // Storage key must match TOKEN_STORAGE in src/services/help/auth.ts. The token is taken
+ // verbatim after "#token=" (opaque token assumed; revisit if the gateway ever appends more
+ // fragment params).
if (location.hash.startsWith("#token=")) {
+ // Token-fixation guard: only accept the fragment token if THIS client initiated sign-in
+ // (flag set in signIn(), src/services/help/api.ts) — otherwise a third party could plant
+ // #token= in a link and silently sign the victim in as them.
+ let signInPending = false;
try {
- localStorage.setItem("fmg-help-token", location.hash.slice("#token=".length));
- } catch {}
+ signInPending = sessionStorage.getItem("fmg-help-signin-pending") === "1";
+ } catch {
+ // storage unavailable — treat as not pending, i.e. do not accept the token
+ }
+ try {
+ sessionStorage.removeItem("fmg-help-signin-pending");
+ } catch {
+ // nothing to clear
+ }
+ if (signInPending) {
+ try {
+ localStorage.setItem("fmg-help-token", location.hash.slice("#token=".length));
+ } catch {
+ // storage unavailable — the user simply stays signed out
+ }
+ }
+ // Always scrub the fragment, accepted or not — an unexpected token must not linger in the URL.
history.replaceState(null, "", location.pathname + location.search);
}
diff --git a/src/controllers/help-assistant.ts b/src/controllers/help-assistant.ts
index ccd04a71e7..5680f24931 100644
--- a/src/controllers/help-assistant.ts
+++ b/src/controllers/help-assistant.ts
@@ -277,8 +277,10 @@ async function refreshLimits(): Promise {
ensureEl("helpAssistantLimits").textContent = limitsLabel(limits);
renderAuth(limits.tier);
} catch {
- // limits are a nicety; asking still reports the authoritative state
- if (!getToken()) renderAuth("anonymous");
+ // limits are a nicety; asking still reports the authoritative state. Render auth from
+ // local state rather than dropping it — a signed-in user must keep the sign-out affordance
+ // even when /v1/limits is failing.
+ renderAuth(getToken() ? "member" : "anonymous");
}
}
diff --git a/src/index.html b/src/index.html
index 4117cf022b..5624a822dc 100644
--- a/src/index.html
+++ b/src/index.html
@@ -133,7 +133,7 @@
-
+
diff --git a/src/services/help/api.test.ts b/src/services/help/api.test.ts
index 67bf278a41..8133200b52 100644
--- a/src/services/help/api.test.ts
+++ b/src/services/help/api.test.ts
@@ -124,6 +124,13 @@ describe("bearer token", () => {
});
it("sends no Authorization header when no token is stored", async () => {
+ // Explicit empty storage — not reliance on node lacking localStorage — so this asserts
+ // "no token stored" rather than "no storage available".
+ vi.stubGlobal("localStorage", {
+ getItem: () => null,
+ setItem: () => {},
+ removeItem: () => {}
+ });
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, { tier: "anonymous", remaining: 5, resetsAt: "x" }));
vi.stubGlobal("fetch", fetchMock);
diff --git a/src/services/help/api.ts b/src/services/help/api.ts
index 0e71360122..be8e346a2a 100644
--- a/src/services/help/api.ts
+++ b/src/services/help/api.ts
@@ -21,7 +21,7 @@ export interface AskResponse {
}
export interface Limits {
- tier: "anonymous" | "member" | "moderator";
+ tier: "anonymous" | "member";
remaining: number;
resetsAt: string;
}
@@ -116,6 +116,14 @@ export const getLimits = (): Promise => request("/v1/limits", {
// Sign-in is a full-page redirect; the gateway lands the user back on the app URL with
// #token=… in the fragment (server-configured target — the client passes nothing).
export function signIn(): void {
+ // Marks that THIS client initiated sign-in, so the fragment-token stash in public/main.js
+ // can refuse a #token= planted by a third party (token-fixation guard) — see the matching
+ // comment there.
+ try {
+ sessionStorage.setItem("fmg-help-signin-pending", "1");
+ } catch {
+ // storage unavailable — the stash falls back to treating this as an unsolicited token
+ }
location.assign(`${gatewayBase()}/v1/auth/discord`);
}
From 8386693b9f84f061f4c19d6b7295771cf5d63fd2 Mon Sep 17 00:00:00 2001
From: barrulus
Date: Wed, 2 Sep 2026 10:39:03 +0100
Subject: [PATCH 18/37] test: e2e coverage for help sign-in fragment token
stash
---
tests/e2e/help-token-stash.spec.ts | 43 ++++++++++++++++++++++++++++++
1 file changed, 43 insertions(+)
create mode 100644 tests/e2e/help-token-stash.spec.ts
diff --git a/tests/e2e/help-token-stash.spec.ts b/tests/e2e/help-token-stash.spec.ts
new file mode 100644
index 0000000000..fdb8bd060c
--- /dev/null
+++ b/tests/e2e/help-token-stash.spec.ts
@@ -0,0 +1,43 @@
+import {expect, test} from "@playwright/test";
+
+// Covers public/main.js's fragment-token stash (OAuth callback from the help gateway) and its
+// token-fixation guard: the token is only accepted when this client set the signin-pending flag
+// (src/services/help/api.ts signIn()) before redirecting. See docs/superpowers/specs for the
+// slice 2a design.
+
+test.describe("help gateway fragment token stash", () => {
+ test("stores the token and scrubs the hash when sign-in was pending", async ({page}) => {
+ await page.addInitScript(() => {
+ sessionStorage.setItem("fmg-help-signin-pending", "1");
+ });
+
+ await page.goto("/?seed=e2e-help-token-stash#token=e2e-test-token");
+ await page.waitForFunction(() => (window as any).mapId !== undefined, {timeout: 60000});
+
+ const token = await page.evaluate(() => localStorage.getItem("fmg-help-token"));
+ expect(token).toBe("e2e-test-token");
+
+ const hash = await page.evaluate(() => location.hash);
+ expect(hash).toBe("");
+
+ const pathname = await page.evaluate(() => location.pathname);
+ expect(pathname.endsWith("/")).toBe(true);
+
+ const pending = await page.evaluate(() => sessionStorage.getItem("fmg-help-signin-pending"));
+ expect(pending).toBeNull();
+ });
+
+ test("ignores an unsolicited token but still scrubs the hash", async ({page}) => {
+ await page.goto("/?seed=e2e-help-token-stash-unsolicited#token=e2e-test-token");
+ await page.waitForFunction(() => (window as any).mapId !== undefined, {timeout: 60000});
+
+ const token = await page.evaluate(() => localStorage.getItem("fmg-help-token"));
+ expect(token).toBeNull();
+
+ const hash = await page.evaluate(() => location.hash);
+ expect(hash).toBe("");
+
+ const pathname = await page.evaluate(() => location.pathname);
+ expect(pathname.endsWith("/")).toBe(true);
+ });
+});
From 48883a62d0d8134d12c57a9f4672f4e9fe789f6a Mon Sep 17 00:00:00 2001
From: barrulus
Date: Wed, 2 Sep 2026 11:51:50 +0100
Subject: [PATCH 19/37] fix: let help dialog children wrap inside the fixed
width
---
public/index.css | 6 ++++++
src/index.html | 2 +-
2 files changed, 7 insertions(+), 1 deletion(-)
diff --git a/public/index.css b/public/index.css
index b71e131f6b..65922ac2e5 100644
--- a/public/index.css
+++ b/public/index.css
@@ -2649,6 +2649,12 @@ body.tour-free-roam * {
z-index: 99;
}
+/* the dialog is fixed-width; undo the global .dialog > div { width: max-content } so
+ children wrap instead of overflowing */
+#helpAssistant > div {
+ width: auto;
+}
+
#helpAssistant .helpAssistantLinks {
display: flex;
gap: 1.2em;
diff --git a/src/index.html b/src/index.html
index 5624a822dc..df1caf1bc8 100644
--- a/src/index.html
+++ b/src/index.html
@@ -133,7 +133,7 @@
{
describe("ask", () => {
it("POSTs exactly {question} to /v1/ask and returns the parsed answer", async () => {
- const fetchMock = vi
- .fn()
- .mockResolvedValue(jsonResponse(200, { requestId: 4711, answer: "**hi**", model: "m", usage: { prompt: 1 } }));
+ const fetchMock = vi.fn().mockResolvedValue(
+ jsonResponse(200, {
+ conversationId: "x".repeat(16),
+ requestId: 4711,
+ answer: "**hi**",
+ model: "m",
+ usage: { prompt: 1 }
+ })
+ );
vi.stubGlobal("fetch", fetchMock);
const result = await ask("How do I export SVG?");
@@ -41,7 +47,13 @@ describe("ask", () => {
});
it("tolerates nullable requestId/model/usage on a 200 (contract allows null)", async () => {
- const refusal = { requestId: null, answer: "I can't help with topics unrelated to FMG.", model: null, usage: null };
+ const refusal = {
+ conversationId: "x".repeat(16),
+ requestId: null,
+ answer: "I can't help with topics unrelated to FMG.",
+ model: null,
+ usage: null
+ };
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(200, refusal)));
const result = await ask("recipe for baked potatoes");
@@ -58,7 +70,10 @@ describe("ask", () => {
["provider_error", 502, undefined],
["invalid_request", 400, undefined]
])("maps a %s error body to HelpApiError with verbatim message", async (code, status, retryAfter) => {
- const errorBody = { error: { code, message: `server text for ${code}`, ...(retryAfter ? { retryAfter } : {}) } };
+ const errorBody = {
+ conversationId: "x".repeat(16),
+ error: { code, message: `server text for ${code}`, ...(retryAfter ? { retryAfter } : {}) }
+ };
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(status, errorBody)));
const error = await ask("q").catch((e: unknown) => e);
@@ -112,9 +127,14 @@ describe("bearer token", () => {
setItem: () => {},
removeItem: () => {}
});
- const fetchMock = vi
- .fn()
- .mockResolvedValue(jsonResponse(200, { tier: "member", remaining: 10, resetsAt: "2026-09-03T00:00:00.000Z" }));
+ const fetchMock = vi.fn().mockResolvedValue(
+ jsonResponse(200, {
+ conversationId: "x".repeat(16),
+ tier: "member",
+ remaining: 10,
+ resetsAt: "2026-09-03T00:00:00.000Z"
+ })
+ );
vi.stubGlobal("fetch", fetchMock);
await getLimits();
@@ -131,7 +151,11 @@ describe("bearer token", () => {
setItem: () => {},
removeItem: () => {}
});
- const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, { tier: "anonymous", remaining: 5, resetsAt: "x" }));
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(
+ jsonResponse(200, { conversationId: "x".repeat(16), tier: "anonymous", remaining: 5, resetsAt: "x" })
+ );
vi.stubGlobal("fetch", fetchMock);
await getLimits();
@@ -159,3 +183,29 @@ describe("bearer token", () => {
expect(removed.includes("fmg-help-token")).toBe(true);
});
});
+
+describe("conversation id", () => {
+ const okBody = { conversationId: "abc123DEF456ghi789JKL0-_", requestId: 7, answer: "a", model: "m", usage: null };
+
+ it("omits conversationId entirely when none is given", async () => {
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, okBody));
+ vi.stubGlobal("fetch", fetchMock);
+ await ask("q");
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body as string);
+ expect(Object.keys(body)).toEqual(["question"]);
+ });
+
+ it("sends conversationId alongside the question when given", async () => {
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, okBody));
+ vi.stubGlobal("fetch", fetchMock);
+ await ask("q", "abc123DEF456ghi789JKL0-_");
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body as string);
+ expect(body).toEqual({ question: "q", conversationId: "abc123DEF456ghi789JKL0-_" });
+ });
+
+ it("returns the conversationId from the response", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(200, okBody)));
+ const result = await ask("q");
+ expect(result.conversationId).toBe("abc123DEF456ghi789JKL0-_");
+ });
+});
diff --git a/src/services/help/api.ts b/src/services/help/api.ts
index be8e346a2a..0f5d35be11 100644
--- a/src/services/help/api.ts
+++ b/src/services/help/api.ts
@@ -14,6 +14,8 @@ export const OFFICIAL_ORIGIN = "https://azgaar.github.io";
// rateable. A refusal or empty reply is a normal 200, never an error state. requestId is
// slice 3's feedback handle; hide feedback only when it is actually null.
export interface AskResponse {
+ // always present on a 200, refusals included — always adopt the returned id
+ conversationId: string;
requestId: number | null;
answer: string;
model: string | null;
@@ -104,11 +106,12 @@ async function request(path: string, init: RequestInit): Promise {
throw new HelpApiError(code, message, retryAfter);
}
-export const ask = (question: string): Promise =>
+export const ask = (question: string, conversationId?: string): Promise =>
request("/v1/ask", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ question })
+ // exact schema: the field is present or absent, never null
+ body: JSON.stringify(conversationId ? { question, conversationId } : { question })
});
export const getLimits = (): Promise => request("/v1/limits", { method: "GET" });
diff --git a/src/services/help/conversation.test.ts b/src/services/help/conversation.test.ts
new file mode 100644
index 0000000000..79eb610944
--- /dev/null
+++ b/src/services/help/conversation.test.ts
@@ -0,0 +1,64 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ adoptConversationId,
+ CONVERSATION_STORAGE,
+ clearConversationId,
+ getConversationId,
+ isNewConversation
+} from "./conversation";
+
+afterEach(() => vi.unstubAllGlobals());
+
+const fakeStorage = () => {
+ const map = new Map();
+ return {
+ getItem: (k: string) => map.get(k) ?? null,
+ setItem: (k: string, v: string) => void map.set(k, v),
+ removeItem: (k: string) => void map.delete(k)
+ };
+};
+
+describe("conversation id storage", () => {
+ it("pins the storage key", () => {
+ expect(CONVERSATION_STORAGE).toBe("fmg-help-conversation");
+ });
+
+ it("adopts, reads back, and clears via sessionStorage", () => {
+ vi.stubGlobal("sessionStorage", fakeStorage());
+ expect(getConversationId()).toBeNull();
+ adoptConversationId("id-1234567890123456");
+ expect(getConversationId()).toBe("id-1234567890123456");
+ clearConversationId();
+ expect(getConversationId()).toBeNull();
+ });
+
+ it("survives a throwing storage without throwing", () => {
+ const throwing = {
+ getItem: () => {
+ throw new Error("denied");
+ },
+ setItem: () => {
+ throw new Error("denied");
+ },
+ removeItem: () => {
+ throw new Error("denied");
+ }
+ };
+ vi.stubGlobal("sessionStorage", throwing);
+ expect(getConversationId()).toBeNull();
+ expect(() => adoptConversationId("x-1234567890123456")).not.toThrow();
+ expect(() => clearConversationId()).not.toThrow();
+ });
+});
+
+describe("isNewConversation", () => {
+ it("is false for the first question (nothing sent)", () => {
+ expect(isNewConversation(null, "fresh-id-123456789")).toBe(false);
+ });
+ it("is false when the sent id survived", () => {
+ expect(isNewConversation("same-id-1234567890", "same-id-1234567890")).toBe(false);
+ });
+ it("is true when a sent id came back different (expiry/unknown-id rollover)", () => {
+ expect(isNewConversation("old-id-12345678901", "new-id-12345678901")).toBe(true);
+ });
+});
diff --git a/src/services/help/conversation.ts b/src/services/help/conversation.ts
new file mode 100644
index 0000000000..1539ecf5a5
--- /dev/null
+++ b/src/services/help/conversation.ts
@@ -0,0 +1,35 @@
+// Server-side conversation memory: the client holds only the server-issued id — never
+// content (a caller-supplied history would be a jailbreak vector; the server refuses it).
+// sessionStorage by ruling: one sitting/one tab, matching the server's 2h-from-last-turn
+// lifetime. The id changes ONLY on a genuine new conversation (expiry/unknown id) — content
+// trimming keeps it — so an id change is exactly when the UI shows a boundary.
+
+export const CONVERSATION_STORAGE = "fmg-help-conversation";
+
+export function getConversationId(): string | null {
+ try {
+ return sessionStorage.getItem(CONVERSATION_STORAGE);
+ } catch {
+ return null;
+ }
+}
+
+export function adoptConversationId(id: string): void {
+ try {
+ sessionStorage.setItem(CONVERSATION_STORAGE, id);
+ } catch {
+ // storage unavailable — the conversation just won't survive a reload
+ }
+}
+
+export function clearConversationId(): void {
+ try {
+ sessionStorage.removeItem(CONVERSATION_STORAGE);
+ } catch {
+ // nothing to clear
+ }
+}
+
+export function isNewConversation(sentId: string | null, returnedId: string): boolean {
+ return sentId !== null && sentId !== returnedId;
+}
From 462ebfd088dc58919961eefb367cd252eefe51e0 Mon Sep 17 00:00:00 2001
From: barrulus
Date: Wed, 2 Sep 2026 12:39:10 +0100
Subject: [PATCH 22/37] feat: conversation memory in the help dialog with
boundary divider
---
public/index.css | 14 +++++++++++++
src/controllers/help-assistant.ts | 35 +++++++++++++++++++++++++++++--
src/index.html | 2 +-
3 files changed, 48 insertions(+), 3 deletions(-)
diff --git a/public/index.css b/public/index.css
index 522b213c98..3afe649f23 100644
--- a/public/index.css
+++ b/public/index.css
@@ -2676,6 +2676,20 @@ body.tour-free-roam * {
margin-bottom: 0.5em;
}
+#helpAssistant .helpAssistantDivider {
+ text-align: center;
+ opacity: 0.55;
+ font-size: 0.85em;
+ margin: 0.5em 0;
+}
+
+#helpAssistant .helpAssistantNewChat {
+ display: block;
+ text-align: right;
+ font-size: 0.85em;
+ margin-bottom: 0.2em;
+}
+
#helpAssistant .helpAssistantAsked {
font-style: italic;
opacity: 0.8;
diff --git a/src/controllers/help-assistant.ts b/src/controllers/help-assistant.ts
index 5680f24931..2eeabb237c 100644
--- a/src/controllers/help-assistant.ts
+++ b/src/controllers/help-assistant.ts
@@ -6,6 +6,12 @@ import { destroyDialog } from "@/components/dialog/dialog-helpers";
import type { Limits } from "@/services/help/api";
import { ask, getLimits, HelpApiError, OFFICIAL_ORIGIN, signIn, signOut } from "@/services/help/api";
import { getToken } from "@/services/help/auth";
+import {
+ adoptConversationId,
+ clearConversationId,
+ getConversationId,
+ isNewConversation
+} from "@/services/help/conversation";
import { renderMarkdown } from "@/utils/markdown";
import { ensureEl } from "../utils";
@@ -83,6 +89,7 @@ function renderDialog(): void {
destroyDialog("helpAssistant");
const form = /* html */ `
+ New chat
Ask anything about using the Fantasy Map Generator.
@@ -134,6 +141,16 @@ function renderDialog(): void {
void submit(normalizeQuestion(getQuestionInput()));
}
});
+ ensureEl("helpAssistantNewChat").addEventListener("click", event => {
+ event.preventDefault();
+ clearConversationId();
+ const log = ensureEl("helpAssistantLog");
+ log.textContent = "";
+ const welcome = document.createElement("p");
+ welcome.textContent = "Ask anything about using the Fantasy Map Generator.";
+ log.appendChild(welcome);
+ setNotice(null);
+ });
}
function getQuestionInput(): string {
@@ -150,9 +167,12 @@ async function submit(question: string | null, isRetry = false): Promise {
button.textContent = "Asking…";
if (!isRetry) appendEntry("helpAssistantAsked", question);
+ const sentId = getConversationId();
try {
- const { answer } = await ask(question);
+ const { answer, conversationId } = await ask(question, sentId ?? undefined);
if (!isMounted()) return;
+ if (isNewConversation(sentId, conversationId)) appendDivider();
+ adoptConversationId(conversationId);
appendAnswer(renderMarkdown(answer));
ensureEl("helpAssistantQuestion").value = "";
setNotice(null);
@@ -180,6 +200,13 @@ function appendEntry(className: string, text: string): void {
appendToLog(entry);
}
+function appendDivider(): void {
+ const divider = document.createElement("div");
+ divider.className = "helpAssistantDivider";
+ divider.textContent = "— new conversation —";
+ appendToLog(divider);
+}
+
// renderMarkdown output only — the renderer escapes every leaf
function appendAnswer(safeHtml: string): void {
const entry = document.createElement("div");
@@ -253,7 +280,10 @@ function renderAuth(tier: string): void {
if (!canSignIn()) return;
const button = document.createElement("button");
button.textContent = "Sign in with Discord for more";
- button.addEventListener("click", signIn);
+ button.addEventListener("click", () => {
+ clearConversationId();
+ signIn();
+ });
host.appendChild(button);
return;
}
@@ -265,6 +295,7 @@ function renderAuth(tier: string): void {
out.textContent = "Sign out";
out.addEventListener("click", event => {
event.preventDefault();
+ clearConversationId();
void signOut().then(() => refreshLimits());
});
host.appendChild(label);
diff --git a/src/index.html b/src/index.html
index 000f706f7d..368b24cf3d 100644
--- a/src/index.html
+++ b/src/index.html
@@ -133,7 +133,7 @@
{
event.preventDefault();
- clearConversationId();
- const log = ensureEl("helpAssistantLog");
- log.textContent = "";
- const welcome = document.createElement("p");
- welcome.textContent = "Ask anything about using the Fantasy Map Generator.";
- log.appendChild(welcome);
- setNotice(null);
+ resetConversationLog();
});
}
+// Rollover must be SHOWN, not silent: whenever the conversation id is dropped, the old
+// transcript is cleared too — otherwise the next exchange reads as one continuous thread
+// that stopped making sense. Used by both "New chat" and sign-out; NOT sign-in (the page
+// navigates away anyway).
+function resetConversationLog(): void {
+ clearConversationId();
+ const log = ensureEl("helpAssistantLog");
+ log.textContent = "";
+ const welcome = document.createElement("p");
+ welcome.textContent = "Ask anything about using the Fantasy Map Generator.";
+ log.appendChild(welcome);
+ setNotice(null);
+}
+
function getQuestionInput(): string {
return ensureEl("helpAssistantQuestion").value;
}
@@ -170,17 +178,25 @@ async function submit(question: string | null, isRetry = false): Promise {
const sentId = getConversationId();
try {
const { answer, conversationId } = await ask(question, sentId ?? undefined);
- if (!isMounted()) return;
- if (isNewConversation(sentId, conversationId)) appendDivider();
+ const isNew = isNewConversation(sentId, conversationId);
+ // Pure storage — safe to do even if the dialog was closed during a slow ask, so it runs
+ // before the isMounted() guard: otherwise closing the dialog mid-ask would lose the
+ // server-issued id and silently orphan the conversation.
adoptConversationId(conversationId);
+ if (!isMounted()) return;
+ if (isNew) appendDivider();
appendAnswer(renderMarkdown(answer));
ensureEl("helpAssistantQuestion").value = "";
setNotice(null);
autoRetried = false;
} catch (error) {
if (!isMounted()) return;
- if (error instanceof HelpApiError) applyNotice(noticeFor(error), error, question);
- else console.error(error);
+ if (error instanceof HelpApiError) {
+ // A poisoned/rejected id is the server's most likely reason for invalid_request — start
+ // the next ask clean rather than repeating the same 400 forever.
+ if (error.code === "invalid_request") clearConversationId();
+ applyNotice(noticeFor(error), error, question);
+ } else console.error(error);
} finally {
if (isMounted()) {
if (!button.dataset.locked) {
@@ -295,8 +311,10 @@ function renderAuth(tier: string): void {
out.textContent = "Sign out";
out.addEventListener("click", event => {
event.preventDefault();
- clearConversationId();
- void signOut().then(() => refreshLimits());
+ void signOut().then(() => {
+ resetConversationLog();
+ void refreshLimits();
+ });
});
host.appendChild(label);
host.appendChild(out);
diff --git a/src/services/help/api.test.ts b/src/services/help/api.test.ts
index eeba0033d0..573120a802 100644
--- a/src/services/help/api.test.ts
+++ b/src/services/help/api.test.ts
@@ -71,7 +71,6 @@ describe("ask", () => {
["invalid_request", 400, undefined]
])("maps a %s error body to HelpApiError with verbatim message", async (code, status, retryAfter) => {
const errorBody = {
- conversationId: "x".repeat(16),
error: { code, message: `server text for ${code}`, ...(retryAfter ? { retryAfter } : {}) }
};
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(status, errorBody)));
@@ -129,7 +128,6 @@ describe("bearer token", () => {
});
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse(200, {
- conversationId: "x".repeat(16),
tier: "member",
remaining: 10,
resetsAt: "2026-09-03T00:00:00.000Z"
@@ -151,11 +149,7 @@ describe("bearer token", () => {
setItem: () => {},
removeItem: () => {}
});
- const fetchMock = vi
- .fn()
- .mockResolvedValue(
- jsonResponse(200, { conversationId: "x".repeat(16), tier: "anonymous", remaining: 5, resetsAt: "x" })
- );
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, { tier: "anonymous", remaining: 5, resetsAt: "x" }));
vi.stubGlobal("fetch", fetchMock);
await getLimits();
@@ -164,12 +158,18 @@ describe("bearer token", () => {
expect((init.headers as Record | undefined)?.Authorization).toBeUndefined();
});
- it("maps 401 to unauthorized and clears the stored token", async () => {
- const removed: string[] = [];
+ it("maps 401 to unauthorized and clears both the stored token and the conversation id", async () => {
+ const removedFromLocal: string[] = [];
+ const removedFromSession: string[] = [];
vi.stubGlobal("localStorage", {
getItem: () => "tok-expired",
setItem: () => {},
- removeItem: (k: string) => void removed.push(k)
+ removeItem: (k: string) => void removedFromLocal.push(k)
+ });
+ vi.stubGlobal("sessionStorage", {
+ getItem: () => "convo-expired",
+ setItem: () => {},
+ removeItem: (k: string) => void removedFromSession.push(k)
});
vi.stubGlobal(
"fetch",
@@ -180,7 +180,8 @@ describe("bearer token", () => {
expect(error).toBeInstanceOf(HelpApiError);
expect((error as HelpApiError).code).toBe("unauthorized");
- expect(removed.includes("fmg-help-token")).toBe(true);
+ expect(removedFromLocal.includes("fmg-help-token")).toBe(true);
+ expect(removedFromSession.includes("fmg-help-conversation")).toBe(true);
});
});
diff --git a/src/services/help/api.ts b/src/services/help/api.ts
index 0f5d35be11..1ea31153cd 100644
--- a/src/services/help/api.ts
+++ b/src/services/help/api.ts
@@ -3,6 +3,7 @@
// the request body is exactly {question} by contract — unknown fields are a 400.
import { clearToken, getToken } from "./auth";
+import { clearConversationId } from "./conversation";
export const GATEWAY_URL = "https://ask.azgaarsfmg.com";
@@ -87,6 +88,7 @@ async function request(path: string, init: RequestInit): Promise {
if (response.status === 401) {
clearToken();
+ clearConversationId();
throw new HelpApiError("unauthorized", "Your sign-in has expired. Sign in with Discord again for more questions.");
}
diff --git a/src/services/help/conversation.test.ts b/src/services/help/conversation.test.ts
index 79eb610944..73fab461ce 100644
--- a/src/services/help/conversation.test.ts
+++ b/src/services/help/conversation.test.ts
@@ -32,6 +32,20 @@ describe("conversation id storage", () => {
expect(getConversationId()).toBeNull();
});
+ it("rejects a malformed id (storage stays empty)", () => {
+ vi.stubGlobal("sessionStorage", fakeStorage());
+ adoptConversationId("not valid!");
+ expect(getConversationId()).toBeNull();
+ });
+
+ it("accepts a 24-char base64url id", () => {
+ vi.stubGlobal("sessionStorage", fakeStorage());
+ const id = "abcDEF123_-abcDEF123_-ab";
+ expect(id).toHaveLength(24);
+ adoptConversationId(id);
+ expect(getConversationId()).toBe(id);
+ });
+
it("survives a throwing storage without throwing", () => {
const throwing = {
getItem: () => {
diff --git a/src/services/help/conversation.ts b/src/services/help/conversation.ts
index 1539ecf5a5..52e9af9dbc 100644
--- a/src/services/help/conversation.ts
+++ b/src/services/help/conversation.ts
@@ -14,7 +14,13 @@ export function getConversationId(): string | null {
}
}
+// The server's id format. Defensive against regressions/tampering — a poisoned or malformed
+// stored value would otherwise 400 every ask for the rest of the session, so a bad value is
+// silently dropped rather than stored.
+const VALID_ID = /^[A-Za-z0-9_-]{16,64}$/;
+
export function adoptConversationId(id: string): void {
+ if (!VALID_ID.test(id)) return;
try {
sessionStorage.setItem(CONVERSATION_STORAGE, id);
} catch {
From ccdcc67e7fae9b75c3f764624911360b0a1de2e6 Mon Sep 17 00:00:00 2001
From: barrulus
Date: Wed, 2 Sep 2026 17:49:14 +0100
Subject: [PATCH 24/37] feat: feedback transport with proper bodyless-204
handling
---
src/services/help/api.test.ts | 33 ++++++++++++++++++++++++++++++++-
src/services/help/api.ts | 11 +++++++++++
2 files changed, 43 insertions(+), 1 deletion(-)
diff --git a/src/services/help/api.test.ts b/src/services/help/api.test.ts
index 573120a802..4094bc24a0 100644
--- a/src/services/help/api.test.ts
+++ b/src/services/help/api.test.ts
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
-import { ask, GATEWAY_URL, getLimits, HelpApiError, OFFICIAL_ORIGIN } from "./api";
+import { ask, GATEWAY_URL, getLimits, HelpApiError, OFFICIAL_ORIGIN, sendFeedback, signOut } from "./api";
const jsonResponse = (status: number, body: unknown): Response =>
new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
@@ -210,3 +210,34 @@ describe("conversation id", () => {
expect(result.conversationId).toBe("abc123DEF456ghi789JKL0-_");
});
});
+
+describe("sendFeedback", () => {
+ it("POSTs exactly {requestId, rating} and resolves on a bodyless 204", async () => {
+ const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ await expect(sendFeedback(41, "up")).resolves.toBeUndefined();
+
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(url).toBe(`${GATEWAY_URL}/v1/feedback`);
+ expect(init.method).toBe("POST");
+ const body = JSON.parse(init.body as string);
+ expect(body).toEqual({ requestId: 41, rating: "up" });
+ expect(Object.keys(body).sort()).toEqual(["rating", "requestId"]);
+ });
+
+ it("maps a feedback error body to HelpApiError as usual", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(jsonResponse(400, { error: { code: "invalid_request", message: "bad rating" } }))
+ );
+ const error = await sendFeedback(41, "down").catch((e: unknown) => e);
+ expect((error as HelpApiError).code).toBe("invalid_request");
+ });
+
+ it("resolves any bodyless 2xx from the transport without a parse error", async () => {
+ // signOut's /v1/auth/logout is a 204 — previously survived only via a swallowed SyntaxError
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 204 })));
+ await expect(signOut()).resolves.toBeUndefined();
+ });
+});
diff --git a/src/services/help/api.ts b/src/services/help/api.ts
index 1ea31153cd..c3c3cac6da 100644
--- a/src/services/help/api.ts
+++ b/src/services/help/api.ts
@@ -79,6 +79,7 @@ async function request(path: string, init: RequestInit): Promise {
}
if (response.ok) {
+ if (response.status === 204) return undefined as T;
try {
return (await response.json()) as T;
} catch {
@@ -116,6 +117,16 @@ export const ask = (question: string, conversationId?: string): Promise =>
+ request("/v1/feedback", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ requestId, rating })
+ });
+
export const getLimits = (): Promise => request("/v1/limits", { method: "GET" });
// Sign-in is a full-page redirect; the gateway lands the user back on the app URL with
From ae984e7a9c3a8d4efbdc7b591792de1b0c597b92 Mon Sep 17 00:00:00 2001
From: barrulus
Date: Wed, 2 Sep 2026 17:55:59 +0100
Subject: [PATCH 25/37] feat: thumbs feedback on help answers
---
public/index.css | 24 +++++++++++++
src/controllers/help-assistant.test.ts | 48 ++++++++++++++++++++++++--
src/controllers/help-assistant.ts | 34 +++++++++++++++---
src/index.html | 2 +-
4 files changed, 101 insertions(+), 7 deletions(-)
diff --git a/public/index.css b/public/index.css
index 3afe649f23..1c5bbe5647 100644
--- a/public/index.css
+++ b/public/index.css
@@ -2695,6 +2695,30 @@ body.tour-free-roam * {
opacity: 0.8;
}
+#helpAssistant .helpAssistantFeedback {
+ display: flex;
+ gap: 0.3em;
+ justify-content: flex-end;
+ margin-top: 0.2em;
+}
+
+#helpAssistant .helpAssistantFeedback button {
+ background: none;
+ border: none;
+ cursor: pointer;
+ opacity: 0.4;
+ font-size: 1em;
+ padding: 0 0.15em;
+}
+
+#helpAssistant .helpAssistantFeedback button:hover {
+ opacity: 0.8;
+}
+
+#helpAssistant .helpAssistantFeedback button.selected {
+ opacity: 1;
+}
+
#helpAssistant .helpAssistantFooter {
display: flex;
flex-wrap: wrap;
diff --git a/src/controllers/help-assistant.test.ts b/src/controllers/help-assistant.test.ts
index 3b373b49df..c6467e3203 100644
--- a/src/controllers/help-assistant.test.ts
+++ b/src/controllers/help-assistant.test.ts
@@ -1,9 +1,11 @@
// @vitest-environment jsdom
-import { describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import { HelpApiError } from "@/services/help/api";
import { renderMarkdown } from "@/utils/markdown";
-import { limitsLabel, normalizeQuestion, noticeFor, shouldAutoRetry } from "./help-assistant";
+import { buildFeedbackControl, limitsLabel, normalizeQuestion, noticeFor, shouldAutoRetry } from "./help-assistant";
+
+afterEach(() => vi.unstubAllGlobals());
describe("noticeFor", () => {
// Budget-refusal text is the server's to write (it carries wiki/Discord links as live
@@ -83,3 +85,45 @@ describe("normalizeQuestion", () => {
expect(normalizeQuestion("a".repeat(1001))).toBeNull();
});
});
+
+describe("buildFeedbackControl", () => {
+ it("renders both thumbs unselected", () => {
+ const row = buildFeedbackControl(41);
+ const buttons = row.querySelectorAll("button");
+ expect(buttons.length).toBe(2);
+ expect(row.querySelector(".selected")).toBeNull();
+ });
+
+ it("marks the clicked rating selected and posts it", async () => {
+ const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
+ vi.stubGlobal("fetch", fetchMock);
+ const row = buildFeedbackControl(41);
+ const [up] = Array.from(row.querySelectorAll("button"));
+ up.click();
+ await Promise.resolve();
+ expect(up.classList.contains("selected")).toBe(true);
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body as string);
+ expect(body).toEqual({ requestId: 41, rating: "up" });
+ });
+
+ it("reverts the selection when the post fails", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("down")));
+ const row = buildFeedbackControl(41);
+ const [up] = Array.from(row.querySelectorAll("button"));
+ up.click();
+ await new Promise(resolve => setTimeout(resolve, 0));
+ expect(up.classList.contains("selected")).toBe(false);
+ });
+
+ it("moves the selection when the user switches rating", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 204 })));
+ const row = buildFeedbackControl(41);
+ const [up, down] = Array.from(row.querySelectorAll("button"));
+ up.click();
+ await new Promise(resolve => setTimeout(resolve, 0));
+ down.click();
+ await new Promise(resolve => setTimeout(resolve, 0));
+ expect(up.classList.contains("selected")).toBe(false);
+ expect(down.classList.contains("selected")).toBe(true);
+ });
+});
diff --git a/src/controllers/help-assistant.ts b/src/controllers/help-assistant.ts
index e7a5ff37fa..5a9a7d15b4 100644
--- a/src/controllers/help-assistant.ts
+++ b/src/controllers/help-assistant.ts
@@ -4,7 +4,7 @@
import { destroyDialog } from "@/components/dialog/dialog-helpers";
import type { Limits } from "@/services/help/api";
-import { ask, getLimits, HelpApiError, OFFICIAL_ORIGIN, signIn, signOut } from "@/services/help/api";
+import { ask, getLimits, HelpApiError, OFFICIAL_ORIGIN, sendFeedback, signIn, signOut } from "@/services/help/api";
import { getToken } from "@/services/help/auth";
import {
adoptConversationId,
@@ -177,7 +177,7 @@ async function submit(question: string | null, isRetry = false): Promise {
const sentId = getConversationId();
try {
- const { answer, conversationId } = await ask(question, sentId ?? undefined);
+ const { answer, conversationId, requestId } = await ask(question, sentId ?? undefined);
const isNew = isNewConversation(sentId, conversationId);
// Pure storage — safe to do even if the dialog was closed during a slow ask, so it runs
// before the isMounted() guard: otherwise closing the dialog mid-ask would lose the
@@ -185,7 +185,7 @@ async function submit(question: string | null, isRetry = false): Promise {
adoptConversationId(conversationId);
if (!isMounted()) return;
if (isNew) appendDivider();
- appendAnswer(renderMarkdown(answer));
+ appendAnswer(renderMarkdown(answer), requestId);
ensureEl("helpAssistantQuestion").value = "";
setNotice(null);
autoRetried = false;
@@ -224,13 +224,39 @@ function appendDivider(): void {
}
// renderMarkdown output only — the renderer escapes every leaf
-function appendAnswer(safeHtml: string): void {
+function appendAnswer(safeHtml: string, requestId: number | null): void {
const entry = document.createElement("div");
entry.className = "helpAssistantAnswer";
entry.innerHTML = safeHtml;
+ // requestId null means there is nothing server-side to rate — no control (never post null)
+ if (requestId !== null) entry.appendChild(buildFeedbackControl(requestId));
appendToLog(entry);
}
+export function buildFeedbackControl(requestId: number): HTMLElement {
+ const row = document.createElement("div");
+ row.className = "helpAssistantFeedback";
+
+ for (const rating of ["up", "down"] as const) {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.textContent = rating === "up" ? "👍" : "👎";
+ button.setAttribute("aria-label", rating === "up" ? "Good answer" : "Bad answer");
+ button.addEventListener("click", () => {
+ const previous = row.querySelector(".selected");
+ previous?.classList.remove("selected");
+ button.classList.add("selected");
+ // a failed post is a silent nicety-miss: revert the selection, never a widget state
+ sendFeedback(requestId, rating).catch(() => {
+ button.classList.remove("selected");
+ previous?.classList.add("selected");
+ });
+ });
+ row.appendChild(button);
+ }
+ return row;
+}
+
function appendToLog(node: HTMLElement): void {
const log = ensureEl("helpAssistantLog");
log.appendChild(node);
diff --git a/src/index.html b/src/index.html
index 368b24cf3d..a6cc7a041f 100644
--- a/src/index.html
+++ b/src/index.html
@@ -133,7 +133,7 @@
{
const buttons = row.querySelectorAll("button");
expect(buttons.length).toBe(2);
expect(row.querySelector(".selected")).toBeNull();
+ for (const button of Array.from(buttons)) expect(button.getAttribute("aria-pressed")).toBe("false");
});
it("marks the clicked rating selected and posts it", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal("fetch", fetchMock);
const row = buildFeedbackControl(41);
- const [up] = Array.from(row.querySelectorAll("button"));
+ const [up, down] = Array.from(row.querySelectorAll("button"));
up.click();
await Promise.resolve();
expect(up.classList.contains("selected")).toBe(true);
+ expect(up.getAttribute("aria-pressed")).toBe("true");
+ expect(down.getAttribute("aria-pressed")).toBe("false");
const body = JSON.parse(fetchMock.mock.calls[0][1].body as string);
expect(body).toEqual({ requestId: 41, rating: "up" });
});
@@ -113,6 +116,7 @@ describe("buildFeedbackControl", () => {
up.click();
await new Promise(resolve => setTimeout(resolve, 0));
expect(up.classList.contains("selected")).toBe(false);
+ expect(up.getAttribute("aria-pressed")).toBe("false");
});
it("moves the selection when the user switches rating", async () => {
@@ -124,6 +128,41 @@ describe("buildFeedbackControl", () => {
down.click();
await new Promise(resolve => setTimeout(resolve, 0));
expect(up.classList.contains("selected")).toBe(false);
+ expect(up.getAttribute("aria-pressed")).toBe("false");
expect(down.classList.contains("selected")).toBe(true);
+ expect(down.getAttribute("aria-pressed")).toBe("true");
+ });
+
+ it("refreshes limits after an unauthorized feedback rejection (token already cleared by the transport)", async () => {
+ const fetchMock = vi.fn((url: string) => {
+ if (String(url).includes("/v1/feedback")) {
+ return Promise.resolve(
+ new Response(JSON.stringify({ error: { code: "unauthorized", message: "Session expired." } }), {
+ status: 401,
+ headers: { "Content-Type": "application/json" }
+ })
+ );
+ }
+ if (String(url).includes("/v1/limits")) {
+ return Promise.resolve(
+ new Response(JSON.stringify({ tier: "anonymous", remaining: 3, resetsAt: "2026-09-03T00:00:00Z" }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" }
+ })
+ );
+ }
+ return Promise.reject(new Error(`unexpected fetch: ${url}`));
+ });
+ vi.stubGlobal("fetch", fetchMock);
+
+ const row = buildFeedbackControl(41);
+ const [up] = Array.from(row.querySelectorAll("button"));
+ up.click();
+ await new Promise(resolve => setTimeout(resolve, 0));
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(up.classList.contains("selected")).toBe(false);
+ expect(up.getAttribute("aria-pressed")).toBe("false");
+ expect(fetchMock.mock.calls.some(([url]) => String(url).includes("/v1/limits"))).toBe(true);
});
});
diff --git a/src/controllers/help-assistant.ts b/src/controllers/help-assistant.ts
index 5a9a7d15b4..075d968fc0 100644
--- a/src/controllers/help-assistant.ts
+++ b/src/controllers/help-assistant.ts
@@ -242,14 +242,22 @@ export function buildFeedbackControl(requestId: number): HTMLElement {
button.type = "button";
button.textContent = rating === "up" ? "👍" : "👎";
button.setAttribute("aria-label", rating === "up" ? "Good answer" : "Bad answer");
+ button.setAttribute("aria-pressed", "false");
button.addEventListener("click", () => {
const previous = row.querySelector(".selected");
previous?.classList.remove("selected");
+ previous?.setAttribute("aria-pressed", "false");
button.classList.add("selected");
+ button.setAttribute("aria-pressed", "true");
// a failed post is a silent nicety-miss: revert the selection, never a widget state
- sendFeedback(requestId, rating).catch(() => {
+ sendFeedback(requestId, rating).catch((error: unknown) => {
button.classList.remove("selected");
+ button.setAttribute("aria-pressed", "false");
previous?.classList.add("selected");
+ previous?.setAttribute("aria-pressed", "true");
+ // the shared transport already cleared the token on a 401 — resync the footer
+ // instead of leaving it stuck claiming "Signed in"
+ if (error instanceof HelpApiError && error.code === "unauthorized") void refreshLimits();
});
});
row.appendChild(button);
diff --git a/src/services/help/api.test.ts b/src/services/help/api.test.ts
index 4094bc24a0..ec0758ef5d 100644
--- a/src/services/help/api.test.ts
+++ b/src/services/help/api.test.ts
@@ -101,6 +101,13 @@ describe("ask", () => {
const error = await ask("q").catch((e: unknown) => e);
expect((error as HelpApiError).code).toBe("unreachable");
});
+
+ it("rejects a contract-violating bodyless 204 with provider_error instead of resolving undefined", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 204 })));
+ const error = await ask("q").catch((e: unknown) => e);
+ expect(error).toBeInstanceOf(HelpApiError);
+ expect((error as HelpApiError).code).toBe("provider_error");
+ });
});
describe("getLimits", () => {
diff --git a/src/services/help/api.ts b/src/services/help/api.ts
index c3c3cac6da..5a0c926139 100644
--- a/src/services/help/api.ts
+++ b/src/services/help/api.ts
@@ -109,13 +109,18 @@ async function request(path: string, init: RequestInit): Promise {
throw new HelpApiError(code, message, retryAfter);
}
-export const ask = (question: string, conversationId?: string): Promise =>
- request("/v1/ask", {
+export const ask = async (question: string, conversationId?: string): Promise => {
+ const result = await request("/v1/ask", {
method: "POST",
headers: { "Content-Type": "application/json" },
// exact schema: the field is present or absent, never null
body: JSON.stringify(conversationId ? { question, conversationId } : { question })
});
+ // /v1/ask is contractually always-bodied on a 200; a bodyless 204 (the transport's
+ // shortcut resolves undefined) is a contract violation, not a silent empty answer.
+ if (!result) throw new HelpApiError("provider_error", "The assistant returned an unreadable response.");
+ return result;
+};
export type FeedbackRating = "up" | "down";
From 28ac79680148bb3916ffb41a920211f61492dcb1 Mon Sep 17 00:00:00 2001
From: barrulus
Date: Fri, 4 Sep 2026 18:53:25 +0100
Subject: [PATCH 27/37] feat: annex states and provinces by clicking on the map
Both editors get an Annex button beside Merge. Click the annexing state
or province, then the ones it absorbs; Shift keeps the mode open. Nothing
changes until the session ends, when the existing merge confirmation
lists what will be removed, so a mis-click can be cancelled.
The mode lives in components/annex-mode and is driven by each editor
with its own owner lookup and merge call. mergeStates is hoisted out of
the dialog and the two merge confirmations are extracted so the dialog
and the annex mode share them. Three copies of the animated red outline
collapse into highlightOutline.
Provinces refuse a pick from another state, matching the merge dialog.
---
docs/architecture/architecture.md | 3 +
docs/wiki/Knowledge Base.md | 4 +-
src/components/annex-mode.ts | 127 ++++++++++++++++
src/controllers/provinces-editor.ts | 85 ++++++-----
src/controllers/states-editor.ts | 217 +++++++++++++---------------
src/renderers/overlays/highlight.ts | 24 ++-
tests/e2e/annex-mode.spec.ts | 202 ++++++++++++++++++++++++++
7 files changed, 506 insertions(+), 156 deletions(-)
create mode 100644 src/components/annex-mode.ts
create mode 100644 tests/e2e/annex-mode.spec.ts
diff --git a/docs/architecture/architecture.md b/docs/architecture/architecture.md
index 73b0ef6c22..6492efd65b 100644
--- a/docs/architecture/architecture.md
+++ b/docs/architecture/architecture.md
@@ -356,6 +356,9 @@ state**._ A controller does **not** hold pure static data, services, or serializ
- **App-level UI** — dialogs and widgets that are opened over the map but say nothing about it:
the About dialog (`app-info`). They have a controller's lifecycle but not a controller's
subject, so they live here and load with the shell.
+- **Shared editor behaviour** — interaction helpers several editors call rather than copy:
+ dialog helpers, tooltips, the default map events (`viewbox-events`), and map modes such as
+ `annex-mode`, which the States and Provinces editors both drive with their own merge logic.
Widgets like `hierarchy-tree` and `minimap` may move to `components/` if they generalize.
diff --git a/docs/wiki/Knowledge Base.md b/docs/wiki/Knowledge Base.md
index 25bcc395c8..e20e212fba 100644
--- a/docs/wiki/Knowledge Base.md
+++ b/docs/wiki/Knowledge Base.md
@@ -338,7 +338,7 @@ Go to Tools -> Units and change the '1 map pixel' distance scale. To change how
### How to merge provinces?
-Open the Provinces Editor, filter the list by the state the provinces belong to and click on the 'Merge several provinces into one' button at the dialog bottom. Provinces of different states cannot be merged, reassign them in the States Editor first. Alternatively remove a province and repaint its territory to another one with the Paint brush
+Open the Provinces Editor and click the 'Annex provinces' button (crown icon) at the dialog bottom. Click the province that absorbs the others on the map, then click the provinces to annex; hold Shift to keep annexing several. A confirmation lists what will be merged, so a mis-click can be cancelled. To pick from a list instead, filter by the state the provinces belong to and use the 'Merge several provinces into one' button. Provinces of different states cannot be merged, reassign them in the States Editor first. Alternatively remove a province and repaint its territory to another one with the Paint brush
### Can you colour in the relief icons?
@@ -550,7 +550,7 @@ You can create a new Regiment using the Regiments Overview. In can open if you c
### How do I merge countries?
-To merge states (countries) open the States Editor from Tools and click on the "Merge several states into one" button at the dialog bottom. Then select states you want to merge
+To merge states (countries) open the States Editor from Tools and click the "Annex states" button (crown icon) at the dialog bottom. Click the state that annexes the others on the map, then click the states to annex; hold Shift to keep annexing several. A confirmation lists what will be removed, so a mis-click can be cancelled. To pick from a list instead, use the "Merge several states into one" button next to it and tick the states to merge
### How to start a war?
diff --git a/src/components/annex-mode.ts b/src/components/annex-mode.ts
new file mode 100644
index 0000000000..2a00ed90b6
--- /dev/null
+++ b/src/components/annex-mode.ts
@@ -0,0 +1,127 @@
+// Map mode shared by the states and provinces editors: click the annexing entity, then the ones it absorbs
+import { select } from "d3";
+import { ensureEl, getPointer } from "@/utils";
+import { clearMainTip, tip } from "./tooltips";
+import { applyDefaultViewboxEvents } from "./viewbox-events";
+
+interface AnnexModeOptions {
+ buttonId: string;
+ bodySectionId: string;
+ mode: number;
+ noun: string;
+ ownerOf: (cellId: number) => number;
+ colorOf: (id: number) => string;
+ nameOf: (id: number) => string;
+ rejectReason?: (parentId: number, id: number) => string | undefined;
+ commit: (parentId: number, annexed: number[]) => void;
+}
+
+export function createAnnexMode(options: AnnexModeOptions) {
+ const { buttonId, bodySectionId, mode, noun, ownerOf, colorOf, nameOf, rejectReason, commit } = options;
+ let parent = 0;
+ const staged = new Set();
+
+ const isActive = () => customization === mode;
+ const preview = () => select("#debug").select("g.annex-preview");
+
+ function toggle(): void {
+ if (isActive()) finish();
+ else enter();
+ }
+
+ function enter(): void {
+ customization = mode;
+ ensureEl(buttonId).classList.add("pressed");
+ select("#debug").append("g").attr("class", "annex-preview");
+ tip(`Click the ${noun} that annexes, then the ${noun}s it absorbs. Hold Shift to keep annexing`, true);
+ select("#viewbox").style("cursor", "crosshair").on("click", onClick);
+ setRowsInert(true);
+ }
+
+ function onClick(this: SVGElement, event: MouseEvent): void {
+ const [x, y] = getPointer(event, this);
+ const cell = Pack.findCell(x, y);
+ if (cell === undefined || pack.cells.h[cell] < 20) {
+ tip(`Click on a land cell to pick a ${noun}`, false, "error");
+ return;
+ }
+
+ const id = ownerOf(cell);
+ if (!id) {
+ tip(`There is no ${noun} here`, false, "error");
+ return;
+ }
+
+ if (!parent) {
+ parent = id;
+ drawEntity(id, "annex-parent", 0.3);
+ tip(`Annexing into ${nameOf(id)}. Click the ${noun}s to annex. Hold Shift to keep annexing`, true);
+ return;
+ }
+
+ if (id === parent) {
+ tip(`${nameOf(id)} is the annexing ${noun}`, false, "error");
+ return;
+ }
+
+ const reason = rejectReason?.(parent, id);
+ if (reason) {
+ tip(reason, false, "error");
+ return;
+ }
+
+ if (staged.has(id)) {
+ staged.delete(id);
+ preview().select(`g[data-id='${id}']`).remove();
+ } else {
+ staged.add(id);
+ drawEntity(id, "annex-child", 0.7);
+ }
+
+ if (!event.shiftKey) finish();
+ }
+
+ function drawEntity(id: number, className: string, opacity: number): void {
+ const color = colorOf(parent);
+ const group = preview().append("g").attr("data-id", id).attr("class", className).attr("opacity", opacity);
+ const { h } = pack.cells;
+ for (let i = 0; i < h.length; i++) {
+ if (h[i] < 20 || ownerOf(i) !== id) continue;
+ group
+ .append("polygon")
+ .attr("points", String(Pack.getPolygon(i)))
+ .attr("fill", color)
+ .attr("stroke", color);
+ }
+ }
+
+ function setRowsInert(inert: boolean): void {
+ ensureEl(bodySectionId)
+ .querySelectorAll("div > input, select, span, svg")
+ .forEach(e => {
+ if (inert) e.style.pointerEvents = "none";
+ else e.style.removeProperty("pointer-events");
+ });
+ }
+
+ function finish(): void {
+ const parentId = parent;
+ const annexed = [...staged];
+ exit();
+ if (parentId && annexed.length) commit(parentId, annexed);
+ }
+
+ function exit(): void {
+ if (!isActive()) return;
+ customization = 0;
+ parent = 0;
+ staged.clear();
+ preview().remove();
+ applyDefaultViewboxEvents();
+ clearMainTip();
+ setRowsInert(false);
+ ensureEl(buttonId).classList.remove("pressed");
+ }
+
+ return { toggle, exit };
+}
diff --git a/src/controllers/provinces-editor.ts b/src/controllers/provinces-editor.ts
index 646cf4ec76..b116abdd12 100644
--- a/src/controllers/provinces-editor.ts
+++ b/src/controllers/provinces-editor.ts
@@ -1,4 +1,5 @@
-import { color as d3Color, easeSinIn, interpolate, interpolateString, select, stratify, transition, treemap } from "d3";
+import { color as d3Color, easeSinIn, interpolate, select, stratify, transition, treemap } from "d3";
+import { createAnnexMode } from "@/components/annex-mode";
import { closeDialogs, confirmationDialog, destroyDialog, updateDialog } from "@/components/dialog/dialog-helpers";
import { applyLineHighlighting } from "@/components/dialog/highlighting";
import { bindColumnSorting, sortDataByColumns } from "@/components/dialog/sorting";
@@ -21,7 +22,7 @@ import type { Province } from "@/generators/provinces-generator";
import { redrawEmblem, redrawEmblems, removeEmblem } from "@/renderers/draw-emblems";
import { EmblemRenderer } from "@/renderers/emblems/renderer";
import { fog, unfog } from "@/renderers/overlays/fogging";
-import { highlightElement } from "@/renderers/overlays/highlight";
+import { highlightElement, highlightOutline } from "@/renderers/overlays/highlight";
import { applyOption, downloadFile, getArea, getAreaUnit, getFileName, speak } from "@/utils";
import { ensureEl, findEl, getPointer, getRandomColor, isLand, P, rand, rn, si, unique } from "../utils";
@@ -156,6 +157,11 @@ function renderDialog(): void {
class="icon-plus"
>
+
{
@@ -1250,6 +1257,7 @@ function removeAllProvinces(): void {
function closeProvincesEditor(): void {
if (customization === 12) exitAddProvinceMode();
+ provincesAnnex.exit();
$("#provincesEditor").dialog("destroy");
ensureEl("provincesEditor").remove();
}
@@ -1282,15 +1290,13 @@ function openProvinceMergeDialog(): void {
return;
}
- const emblem = (i: number): string =>
- /* html */ ` `;
const provincesSelector = provincesToMerge
.map(
p => /* html */ `
- ${emblem(p.i)}${p.name}
+ ${provinceEmblem(p.i)}${p.name}
`
)
@@ -1338,20 +1344,7 @@ function openProvinceMergeDialog(): void {
return;
}
- confirmationDialog({
- title: "Merge provinces",
- message: /* html */ `
- The following provinces will be removed : ${provincesToMergeIds
- .map(provinceId => `${emblem(provinceId)}${pack.provinces[provinceId].name}`)
- .join(", ")}.
- Removed provinces data (burgs and cells) will be assigned to ${emblem(primaryProvinceId)}${pack.provinces[primaryProvinceId].name}.
- Are you sure you want to merge provinces? This action cannot be reverted.
`,
- confirm: "Merge",
- onConfirm: () => {
- mergeProvinces(provincesToMergeIds, primaryProvinceId);
- $(this).dialog("close");
- }
- });
+ confirmProvincesMerge(provincesToMergeIds, primaryProvinceId, () => $(this).dialog("close"));
},
Cancel: function (this: HTMLElement) {
$(this).dialog("close");
@@ -1360,6 +1353,41 @@ function openProvinceMergeDialog(): void {
});
}
+const provinceEmblem = (i: number): string =>
+ /* html */ ` `;
+
+function confirmProvincesMerge(provincesToMerge: number[], primaryProvinceId: number, onConfirm?: () => void): void {
+ confirmationDialog({
+ title: "Merge provinces",
+ message: /* html */ `
+ The following provinces will be removed : ${provincesToMerge
+ .map(provinceId => `${provinceEmblem(provinceId)}${pack.provinces[provinceId].name}`)
+ .join(", ")}.
+ Removed provinces data (burgs and cells) will be assigned to ${provinceEmblem(primaryProvinceId)}${pack.provinces[primaryProvinceId].name}.
+ Are you sure you want to merge provinces? This action cannot be reverted.
`,
+ confirm: "Merge",
+ onConfirm: () => {
+ mergeProvinces(provincesToMerge, primaryProvinceId);
+ onConfirm?.();
+ }
+ });
+}
+
+const provincesAnnex = createAnnexMode({
+ buttonId: "provincesAnnex",
+ bodySectionId: "provincesBodySection",
+ mode: 18,
+ noun: "province",
+ ownerOf: cellId => pack.cells.province[cellId],
+ colorOf: provinceId => pack.provinces[provinceId].color,
+ nameOf: provinceId => pack.provinces[provinceId].name,
+ rejectReason: (primaryId, provinceId) =>
+ pack.provinces[provinceId].state === pack.provinces[primaryId].state
+ ? undefined
+ : `${pack.provinces[provinceId].name} belongs to another state. Merge states first, or pick a province of ${pack.states[pack.provinces[primaryId].state].name}`,
+ commit: (primaryProvinceId, provincesToMerge) => confirmProvincesMerge(provincesToMerge, primaryProvinceId)
+});
+
function highlightProvinceOnMergeHover(event: Event): void {
if (!Layers.isOn("provinces")) return;
const province = +(event.currentTarget as HTMLElement).dataset.id!;
@@ -1368,24 +1396,7 @@ function highlightProvinceOnMergeHover(event: Event): void {
if (!d) return;
provinceHighlightOff(event);
-
- const path = select("#debug")
- .append("path")
- .attr("class", "highlight")
- .attr("d", d)
- .attr("fill", "none")
- .attr("stroke", "red")
- .attr("stroke-width", 1)
- .attr("opacity", 1)
- .attr("filter", "url(#blur1)");
-
- const totalLength = (path.node() as SVGPathElement).getTotalLength();
- const duration = (totalLength + 5000) / 2;
- const interp = interpolateString(`0, ${totalLength}`, `${totalLength}, ${totalLength}`);
- path
- .transition()
- .duration(duration)
- .attrTween("stroke-dasharray", () => interp);
+ highlightOutline(d);
}
function cleanupMergedProvince(provinceId: number): void {
diff --git a/src/controllers/states-editor.ts b/src/controllers/states-editor.ts
index 27afd8e56c..99c48f0cd4 100644
--- a/src/controllers/states-editor.ts
+++ b/src/controllers/states-editor.ts
@@ -1,4 +1,5 @@
-import { interpolateString, max, pack as packLayout, select, stratify } from "d3";
+import { max, pack as packLayout, select, stratify } from "d3";
+import { createAnnexMode } from "@/components/annex-mode";
import { closeDialogs, confirmationDialog, destroyDialog, updateDialog } from "@/components/dialog/dialog-helpers";
import { applyLineHighlighting } from "@/components/dialog/highlighting";
import { bindColumnSorting, sortDataByColumns } from "@/components/dialog/sorting";
@@ -22,7 +23,7 @@ import { redrawEmblem, redrawEmblems, removeEmblem } from "@/renderers/draw-embl
import { clearLegend, drawLegend } from "@/renderers/draw-legend";
import { EmblemRenderer } from "@/renderers/emblems/renderer";
import { fog, unfog } from "@/renderers/overlays/fogging";
-import { highlightElement } from "@/renderers/overlays/highlight";
+import { highlightElement, highlightOutline } from "@/renderers/overlays/highlight";
import { applyOption, downloadFile, getArea, getAreaUnit, getFileName, speak } from "@/utils";
import {
ensureEl,
@@ -203,6 +204,7 @@ function renderDialog(): void {
+
`;
@@ -229,6 +231,7 @@ function renderDialog(): void {
ensureEl("statesManually").addEventListener("click", openPaintEditor);
ensureEl("statesAdd").addEventListener("click", enterAddStateMode);
ensureEl("statesMerge").addEventListener("click", openStateMergeDialog);
+ ensureEl("statesAnnex").addEventListener("click", statesAnnex.toggle);
ensureEl("statesExport").addEventListener("click", downloadStatesCsv);
ensureEl("statesBodySection").addEventListener("click", event => {
@@ -267,6 +270,7 @@ function renderDialog(): void {
function closeStatesEditor(): void {
if (customization === 3) exitAddStateMode();
+ statesAnnex.exit();
select("#debug").selectAll(".highlight").remove();
destroyDialog(dialogId);
}
@@ -480,25 +484,7 @@ function stateHighlightOn(event: any): void {
const state = +event.target.dataset.id;
if (customization || !state) return;
- const d = select("#regions").select(`#state${state}`).attr("d");
-
- const path = select("#debug")
- .append("path")
- .attr("class", "highlight")
- .attr("d", d)
- .attr("fill", "none")
- .attr("stroke", "red")
- .attr("stroke-width", 1)
- .attr("opacity", 1)
- .attr("filter", "url(#blur1)");
-
- const totalLength = (path.node() as SVGPathElement).getTotalLength();
- const duration = (totalLength + 5000) / 2;
- const interpolate = interpolateString(`0, ${totalLength}`, `${totalLength}, ${totalLength}`);
- path
- .transition()
- .duration(duration)
- .attrTween("stroke-dasharray", () => interpolate);
+ highlightOutline(select("#regions").select(`#state${state}`).attr("d"));
}
function stateHighlightOff(): void {
@@ -1588,9 +1574,10 @@ function exitAddStateMode(): void {
if (statesAdd.classList.contains("pressed")) statesAdd.classList.remove("pressed");
}
+const stateEmblem = (i: number) =>
+ /* html */ `
`;
+
function openStateMergeDialog(): void {
- const emblem = (i: number) =>
- /* html */ `
`;
const validStates = pack.states.filter(s => s.i && !s.removed);
const statesSelector = validStates
@@ -1599,7 +1586,7 @@ function openStateMergeDialog(): void {
- ${emblem(s.i)}${s.fullName}
+ ${stateEmblem(s.i)}${s.fullName}
`
)
@@ -1634,24 +1621,7 @@ function openStateMergeDialog(): void {
if (!d) return;
stateHighlightOff();
-
- const path = select("#debug")
- .append("path")
- .attr("class", "highlight")
- .attr("d", d)
- .attr("fill", "none")
- .attr("stroke", "red")
- .attr("stroke-width", 1)
- .attr("opacity", 1)
- .attr("filter", "url(#blur1)");
-
- const totalLength = (path.node() as SVGPathElement).getTotalLength();
- const duration = (totalLength + 5000) / 2;
- const interpolate = interpolateString(`0, ${totalLength}`, `${totalLength}, ${totalLength}`);
- path
- .transition()
- .duration(duration)
- .attrTween("stroke-dasharray", () => interpolate);
+ highlightOutline(d);
}
$("#alert").dialog({
@@ -1667,7 +1637,6 @@ function openStateMergeDialog(): void {
tip("Please select a state to merge into", false, "error");
return;
}
- const rullingState = pack.states[rulingStateId];
const statesToMerge = formData
.getAll("statesToMerge")
@@ -1678,96 +1647,112 @@ function openStateMergeDialog(): void {
return;
}
- confirmationDialog({
- title: "Merge states",
- // prettier-ignore
- message: /* html */ `
-
The following states will be removed : ${statesToMerge.map(stateId => `${emblem(stateId)}${(pack.states)[stateId].name}`).join(", ")}.
-
Removed states data (burgs, provinces, regiments) will be assigned to ${emblem(rullingState.i)}${rullingState.name}.
-
Are you sure you want to merge states? This action cannot be reverted.
`,
- confirm: "Merge",
- onConfirm: () => {
- mergeStates(statesToMerge, rulingStateId);
- $(this).dialog("close");
- }
- });
+ confirmStatesMerge(statesToMerge, rulingStateId, () => $(this).dialog("close"));
},
Cancel: function (this: HTMLElement) {
$(this).dialog("close");
}
}
});
+}
- function mergeStates(statesToMerge: number[], rulingStateId: number) {
- const rulingState = pack.states[rulingStateId];
- const rulingStateArmy = ensureEl(`army${rulingStateId}`);
-
- // remove states to be merged
- statesToMerge.forEach(stateId => {
- const state = pack.states[stateId];
- state.removed = true;
-
- select("#statesBody").select(`#state${stateId}`).remove();
- select("#statesBody").select(`#state-gap${stateId}`).remove();
- select("#statesHalo").select(`#state-border${stateId}`).remove();
- delete pack.states[stateId].label;
-
- removeEmblem("state", stateId);
-
- // add merged state regiments to the ruling state
- (state.military || []).forEach((regiment: any) => {
- const oldId = `regiment${stateId}-${regiment.i}`;
- const newIndex = (rulingState.military || []).length;
- (rulingState.military || []).push({ ...regiment, i: newIndex });
- const newId = `regiment${rulingStateId}-${newIndex}`;
-
- const note = notes.find(n => n.id === oldId);
- if (note) note.id = newId;
-
- const element = document.getElementById(oldId);
- if (element) {
- element.id = newId;
- element.dataset.state = String(rulingStateId);
- element.dataset.id = String(newIndex);
- rulingStateArmy.appendChild(element);
- }
- });
+function confirmStatesMerge(statesToMerge: number[], rulingStateId: number, onConfirm?: () => void): void {
+ const rulingState = pack.states[rulingStateId];
+ confirmationDialog({
+ title: "Merge states",
+ // prettier-ignore
+ message: /* html */ `
+
The following states will be removed : ${statesToMerge.map(stateId => `${stateEmblem(stateId)}${(pack.states)[stateId].name}`).join(", ")}.
+
Removed states data (burgs, provinces, regiments) will be assigned to ${stateEmblem(rulingState.i)}${rulingState.name}.
+
Are you sure you want to merge states? This action cannot be reverted.
`,
+ confirm: "Merge",
+ onConfirm: () => {
+ mergeStates(statesToMerge, rulingStateId);
+ onConfirm?.();
+ }
+ });
+}
- select(`#armies g#army${stateId}`).remove();
- });
+const statesAnnex = createAnnexMode({
+ buttonId: "statesAnnex",
+ bodySectionId: "statesBodySection",
+ mode: 17,
+ noun: "state",
+ ownerOf: cellId => pack.cells.state[cellId],
+ colorOf: stateId => pack.states[stateId].color ?? "#999999",
+ nameOf: stateId => pack.states[stateId].name,
+ commit: (rulingStateId, statesToMerge) => confirmStatesMerge(statesToMerge, rulingStateId)
+});
- // reassing burgs
- pack.burgs.forEach(burg => {
- if (statesToMerge.includes(burg.state ?? 0)) {
- if (burg.capital) {
- burg.capital = 0;
- Burgs.changeGroup(burg, null);
- }
- burg.state = rulingStateId;
+function mergeStates(statesToMerge: number[], rulingStateId: number): void {
+ const rulingState = pack.states[rulingStateId];
+ const rulingStateArmy = ensureEl(`army${rulingStateId}`);
+
+ // remove states to be merged
+ statesToMerge.forEach(stateId => {
+ const state = pack.states[stateId];
+ state.removed = true;
+
+ select("#statesBody").select(`#state${stateId}`).remove();
+ select("#statesBody").select(`#state-gap${stateId}`).remove();
+ select("#statesHalo").select(`#state-border${stateId}`).remove();
+ delete pack.states[stateId].label;
+
+ removeEmblem("state", stateId);
+
+ // add merged state regiments to the ruling state
+ (state.military || []).forEach((regiment: any) => {
+ const oldId = `regiment${stateId}-${regiment.i}`;
+ const newIndex = (rulingState.military || []).length;
+ (rulingState.military || []).push({ ...regiment, i: newIndex });
+ const newId = `regiment${rulingStateId}-${newIndex}`;
+
+ const note = notes.find(n => n.id === oldId);
+ if (note) note.id = newId;
+
+ const element = document.getElementById(oldId);
+ if (element) {
+ element.id = newId;
+ element.dataset.state = String(rulingStateId);
+ element.dataset.id = String(newIndex);
+ rulingStateArmy.appendChild(element);
}
});
- // reassign provinces
- pack.provinces.forEach(province => {
- if (statesToMerge.includes(province.state)) province.state = rulingStateId;
- });
+ select(`#armies g#army${stateId}`).remove();
+ });
- // reassing cells
- pack.cells.state.forEach((s: number, i: number) => {
- if (statesToMerge.includes(s)) pack.cells.state[i] = rulingStateId;
- });
+ // reassing burgs
+ pack.burgs.forEach(burg => {
+ if (statesToMerge.includes(burg.state ?? 0)) {
+ if (burg.capital) {
+ burg.capital = 0;
+ Burgs.changeGroup(burg, null);
+ }
+ burg.state = rulingStateId;
+ }
+ });
- unfog();
- select("#debug").selectAll(".highlight").remove();
+ // reassign provinces
+ pack.provinces.forEach(province => {
+ if (statesToMerge.includes(province.state)) province.state = rulingStateId;
+ });
- States.getPoles();
+ // reassing cells
+ pack.cells.state.forEach((s: number, i: number) => {
+ if (statesToMerge.includes(s)) pack.cells.state[i] = rulingStateId;
+ });
- if (!pack.states[rulingStateId].label) delete pack.states[rulingStateId].label;
+ unfog();
+ select("#debug").selectAll(".highlight").remove();
- Layers.show("states", "borders");
- Layers.draw("burgIcons", "labels", "provinces");
- refreshStatesEditor();
- }
+ States.getPoles();
+
+ if (!pack.states[rulingStateId].label) delete pack.states[rulingStateId].label;
+
+ Layers.show("states", "borders");
+ Layers.draw("burgIcons", "labels", "provinces");
+ refreshStatesEditor();
}
function downloadStatesCsv(): void {
diff --git a/src/renderers/overlays/highlight.ts b/src/renderers/overlays/highlight.ts
index be48e2c392..63191966b4 100644
--- a/src/renderers/overlays/highlight.ts
+++ b/src/renderers/overlays/highlight.ts
@@ -1,4 +1,4 @@
-import { easeBounceOut, easeLinear, easeSinIn, select, transition } from "d3";
+import { easeBounceOut, easeLinear, easeSinIn, interpolateString, select, transition } from "d3";
import { parseTransform } from "@/utils";
const debugLayer = () => select
("#debug");
@@ -103,3 +103,25 @@ export function highlightEmblemElement(type: string, element: { i: number; [key:
.attr("opacity", 0)
.remove();
}
+
+/** Trace a path outline in red, animated along its length. Removed by the callers' highlight-off */
+export function highlightOutline(d: string | null): void {
+ if (!d) return;
+ const path = debugLayer()
+ .append("path")
+ .attr("class", "highlight")
+ .attr("d", d)
+ .attr("fill", "none")
+ .attr("stroke", "red")
+ .attr("stroke-width", 1)
+ .attr("opacity", 1)
+ .attr("filter", "url(#blur1)");
+
+ const totalLength = (path.node() as SVGPathElement).getTotalLength();
+ const duration = (totalLength + 5000) / 2;
+ const interpolate = interpolateString(`0, ${totalLength}`, `${totalLength}, ${totalLength}`);
+ path
+ .transition()
+ .duration(duration)
+ .attrTween("stroke-dasharray", () => interpolate);
+}
diff --git a/tests/e2e/annex-mode.spec.ts b/tests/e2e/annex-mode.spec.ts
new file mode 100644
index 0000000000..3599bc545e
--- /dev/null
+++ b/tests/e2e/annex-mode.spec.ts
@@ -0,0 +1,202 @@
+import {test, expect, type Page} from "@playwright/test";
+
+// Map click at a pack coordinate; callers filter out points hidden under a dialog or the options panel
+async function clickMapAt(page: Page, point: [number, number]) {
+ const screen = await page.evaluate(([x, y]) => {
+ const viewbox = document.getElementById("viewbox") as unknown as SVGGraphicsElement;
+ const p = new DOMPoint(x, y).matrixTransform(viewbox.getScreenCTM()!);
+ return {x: p.x, y: p.y};
+ }, point);
+ await page.mouse.click(screen.x, screen.y);
+}
+
+async function isMapVisibleAt(page: Page, point: [number, number]) {
+ return page.evaluate(([x, y]) => {
+ const viewbox = document.getElementById("viewbox") as unknown as SVGGraphicsElement;
+ const p = new DOMPoint(x, y).matrixTransform(viewbox.getScreenCTM()!);
+ return Boolean(document.elementFromPoint(p.x, p.y)?.closest("#map"));
+ }, point);
+}
+
+async function openEditor(page: Page, buttonId: string, dialogId: string) {
+ await page.click("#optionsTrigger");
+ await page.click("#toolsTab");
+ await page.click(`#${buttonId}`);
+ await page.waitForSelector(`#${dialogId}`, {state: "visible", timeout: 5000});
+ await page.waitForTimeout(300);
+}
+
+const confirmButton = ".ui-dialog:has(#alert) .ui-dialog-buttonpane button:first-child";
+const cancelButton = ".ui-dialog:has(#alert) .ui-dialog-buttonpane button:last-child";
+
+test.describe("Annex by clicking on the map", () => {
+ test.beforeEach(async ({context, page}) => {
+ await context.clearCookies();
+ await page.goto("/");
+ await page.evaluate(() => {
+ localStorage.clear();
+ sessionStorage.clear();
+ });
+ await page.goto("/?seed=test-annex&width=1280&height=720");
+ await page.waitForFunction(() => (window as any).mapId !== undefined, {timeout: 60000});
+ await page.waitForTimeout(500);
+ });
+
+ async function pickTwoStates(page: Page) {
+ const candidates: {i: number; pole: [number, number]}[] = await page.evaluate(() =>
+ (window as any).pack.states
+ .filter((s: any) => s.i && !s.removed)
+ .map((s: any) => ({i: s.i, pole: (window as any).pack.cells.p[s.center]}))
+ );
+ const visible: {i: number; pole: [number, number]}[] = [];
+ for (const c of candidates) {
+ if (await isMapVisibleAt(page, c.pole)) visible.push(c);
+ if (visible.length === 2) break;
+ }
+ expect(visible.length).toBe(2);
+ return visible;
+ }
+
+ test("annexes a state into the first clicked state after confirmation", async ({page}) => {
+ await openEditor(page, "editStatesButton", "statesEditor");
+ const [parent, child] = await pickTwoStates(page);
+
+ await page.click("#statesAnnex");
+ await expect(page.locator("#statesAnnex")).toHaveClass(/pressed/);
+
+ await clickMapAt(page, parent.pole);
+ await clickMapAt(page, child.pole);
+
+ await page.waitForSelector(confirmButton, {state: "visible", timeout: 3000});
+ await expect(page.locator(".ui-dialog:has(#alert)")).toContainText("removed");
+ await page.click(confirmButton);
+ await page.waitForTimeout(300);
+
+ const result = await page.evaluate(
+ ([p, c]) => {
+ const {states, cells} = (window as any).pack;
+ let childCells = 0;
+ for (let i = 0; i < cells.state.length; i++) if (cells.state[i] === c) childCells++;
+ return {removed: Boolean(states[c].removed), parentAlive: !states[p].removed, childCells};
+ },
+ [parent.i, child.i]
+ );
+ expect(result).toEqual({removed: true, parentAlive: true, childCells: 0});
+ await expect(page.locator("#statesAnnex")).not.toHaveClass(/pressed/);
+ expect(await page.evaluate(() => (0, eval)("customization"))).toBe(0);
+ });
+
+ test("cancelling the confirmation leaves states untouched and clears the preview", async ({page}) => {
+ await openEditor(page, "editStatesButton", "statesEditor");
+ const [parent, child] = await pickTwoStates(page);
+
+ await page.click("#statesAnnex");
+ await clickMapAt(page, parent.pole);
+ await clickMapAt(page, child.pole);
+
+ await page.waitForSelector(cancelButton, {state: "visible", timeout: 3000});
+ await page.click(cancelButton);
+ await page.waitForTimeout(300);
+
+ const removed = await page.evaluate(c => Boolean((window as any).pack.states[c].removed), child.i);
+ expect(removed).toBe(false);
+ expect(await page.locator("#debug .annex-preview").count()).toBe(0);
+ await expect(page.locator("#statesAnnex")).not.toHaveClass(/pressed/);
+ });
+
+ test("shift keeps the mode open so several states can be staged", async ({page}) => {
+ await openEditor(page, "editStatesButton", "statesEditor");
+ const [parent, child] = await pickTwoStates(page);
+
+ await page.click("#statesAnnex");
+ await clickMapAt(page, parent.pole);
+ await page.keyboard.down("Shift");
+ await clickMapAt(page, child.pole);
+ await page.keyboard.up("Shift");
+
+ expect(await page.locator(".ui-dialog:has(#alert):visible").count()).toBe(0);
+ await expect(page.locator("#statesAnnex")).toHaveClass(/pressed/);
+ expect(await page.locator("#debug .annex-preview polygon").count()).toBeGreaterThan(0);
+
+ // pressing the button again ends the session and asks for confirmation
+ await page.click("#statesAnnex");
+ await page.waitForSelector(confirmButton, {state: "visible", timeout: 3000});
+ await page.click(cancelButton);
+ });
+
+ test("annexes a province into another province of the same state", async ({page}) => {
+ await openEditor(page, "editProvincesButton", "provincesEditor");
+
+ const candidates: {i: number; state: number; pole: [number, number]}[] = await page.evaluate(() =>
+ (window as any).pack.provinces
+ .filter((p: any) => p.i && !p.removed)
+ .map((p: any) => ({i: p.i, state: p.state, pole: (window as any).pack.cells.p[p.center]}))
+ );
+ let pair: typeof candidates | undefined;
+ for (const a of candidates) {
+ if (!(await isMapVisibleAt(page, a.pole))) continue;
+ for (const b of candidates) {
+ if (b.i === a.i || b.state !== a.state) continue;
+ if (await isMapVisibleAt(page, b.pole)) {
+ pair = [a, b];
+ break;
+ }
+ }
+ if (pair) break;
+ }
+ expect(pair).toBeDefined();
+ const [parent, child] = pair!;
+
+ await page.click("#provincesAnnex");
+ await clickMapAt(page, parent.pole);
+ await clickMapAt(page, child.pole);
+
+ await page.waitForSelector(confirmButton, {state: "visible", timeout: 3000});
+ await page.click(confirmButton);
+ await page.waitForTimeout(300);
+
+ const result = await page.evaluate(
+ ([p, c]) => {
+ const {provinces, cells} = (window as any).pack;
+ let childCells = 0;
+ for (let i = 0; i < cells.province.length; i++) if (cells.province[i] === c) childCells++;
+ return {removed: Boolean(provinces[c].removed), parentAlive: !provinces[p].removed, childCells};
+ },
+ [parent.i, child.i]
+ );
+ expect(result).toEqual({removed: true, parentAlive: true, childCells: 0});
+ await expect(page.locator("#provincesAnnex")).not.toHaveClass(/pressed/);
+ });
+
+ test("refuses to annex a province from a different state", async ({page}) => {
+ await openEditor(page, "editProvincesButton", "provincesEditor");
+
+ const candidates: {i: number; state: number; pole: [number, number]}[] = await page.evaluate(() =>
+ (window as any).pack.provinces
+ .filter((p: any) => p.i && !p.removed)
+ .map((p: any) => ({i: p.i, state: p.state, pole: (window as any).pack.cells.p[p.center]}))
+ );
+ let pair: typeof candidates | undefined;
+ for (const a of candidates) {
+ if (!(await isMapVisibleAt(page, a.pole))) continue;
+ const b = candidates.find(x => x.state !== a.state);
+ if (b && (await isMapVisibleAt(page, b.pole))) {
+ pair = [a, b];
+ break;
+ }
+ }
+ expect(pair).toBeDefined();
+ const [parent, foreign] = pair!;
+
+ await page.click("#provincesAnnex");
+ await clickMapAt(page, parent.pole);
+ await clickMapAt(page, foreign.pole);
+ await page.waitForTimeout(200);
+
+ expect(await page.locator(".ui-dialog:has(#alert):visible").count()).toBe(0);
+ await expect(page.locator("#provincesAnnex")).toHaveClass(/pressed/);
+ expect(await page.locator("#debug .annex-preview .annex-child").count()).toBe(0);
+ const removed = await page.evaluate(c => Boolean((window as any).pack.provinces[c].removed), foreign.i);
+ expect(removed).toBe(false);
+ });
+});
From 90063f28753660190a4519270d527b8f14ef7dfa Mon Sep 17 00:00:00 2001
From: barrulus
Date: Fri, 4 Sep 2026 20:45:11 +0100
Subject: [PATCH 28/37] fix: fit label slider ranges to the group font size
The stroke width slider spans 0-10 for every label group while group
font sizes run from 2 (river, route) to 22 (state). On a river label a
single pixel of slider travel already exceeds the usable band.
Values stay absolute and unscaled. Only the drag range follows the
selected group: stroke width up to half the font size, letter spacing
from a tenth below zero to half above, and never narrower than the
stored value so it stays reachable. Every other element gets the ranges
declared in index.html back, and changing the font size refits live.
slider-input now observes min, max and step, which it read only once
in the constructor.
Fixes #1592
---
public/modules/ui/style.js | 27 +++++++++++-
src/components/slider-input.ts | 7 +++
src/index.html | 2 +-
tests/e2e/style-label-ranges.spec.ts | 64 ++++++++++++++++++++++++++++
4 files changed, 98 insertions(+), 2 deletions(-)
create mode 100644 tests/e2e/style-label-ranges.spec.ts
diff --git a/public/modules/ui/style.js b/public/modules/ui/style.js
index 51ad93305b..faae86c716 100644
--- a/public/modules/ui/style.js
+++ b/public/modules/ui/style.js
@@ -84,6 +84,27 @@ function getColor(value, scheme = getColorScheme("bright")) {
// Toggle style sections on element select
styleElementSelect.addEventListener("change", selectStyleElement);
+// label groups differ ~10x in font size, so the absolute sliders get a drag range fitted to the group;
+// values are stored unscaled and a stored value beyond the fitted range keeps the range wide enough
+const defaultRanges = {
+ strokeMax: styleStrokeWidthInput.getAttribute("max"),
+ spacingMin: styleLetterSpacingInput.getAttribute("min"),
+ spacingMax: styleLetterSpacingInput.getAttribute("max")
+};
+
+function fitLabelRanges(fontSize, attrs) {
+ const spacing = +attrs["letter-spacing"] || 0;
+ styleStrokeWidthInput.setAttribute("max", Math.max(rn(fontSize / 2, 2), +attrs["stroke-width"] || 0));
+ styleLetterSpacingInput.setAttribute("min", Math.min(-rn(fontSize / 10, 2), spacing));
+ styleLetterSpacingInput.setAttribute("max", Math.max(rn(fontSize / 2, 2), spacing));
+}
+
+function resetLabelRanges() {
+ styleStrokeWidthInput.setAttribute("max", defaultRanges.strokeMax);
+ styleLetterSpacingInput.setAttribute("min", defaultRanges.spacingMin);
+ styleLetterSpacingInput.setAttribute("max", defaultRanges.spacingMax);
+}
+
// groups the editor addresses by name; everything else is styled as a whole
const GROUPED_STYLE_ELEMENTS = ["anchors", "borders", "burgIcons", "coastline", "lakes", "labels", "routes", "terrs"];
@@ -95,6 +116,7 @@ function selectStyleElement() {
const el = d3.select("#" + styleElement);
styleElements.querySelectorAll("tbody").forEach(e => (e.style.display = "none")); // hide all sections
+ resetLabelRanges();
// show alert line if layer is not visible
const isLayerOff = styleElement !== "ocean" && (el.style("display") === "none" || !el.selectAll("*").size());
@@ -274,13 +296,15 @@ function selectStyleElement() {
styleSize.style.display = "block";
styleFillInput.value = styleFillOutput.value = attrs.fill || "#3e3e4b";
styleStrokeInput.value = styleStrokeOutput.value = attrs.stroke || "#3a3a3a";
+ const fontSize = parseFloat(attrs["font-size"]) || 18;
+ fitLabelRanges(fontSize, attrs);
styleStrokeWidthInput.value = attrs["stroke-width"] ?? 0;
styleLetterSpacingInput.value = attrs["letter-spacing"] ?? 0;
styleShadowInput.value = getTextShadow(attrs.style);
styleFont.style.display = "block";
styleSelectFont.value = attrs["font-family"];
- styleFontSize.value = parseFloat(attrs["font-size"]) || 18;
+ styleFontSize.value = fontSize;
styleFontShift.style.display = "block";
const { dx, dy } = getLabelShift(attrs.style);
@@ -973,6 +997,7 @@ function changeFontSize(el, size) {
if (styleElementSelect.value === "labels") {
el.attr("font-size", `${size}%`).attr("data-size", null);
if (groupStyle) groupStyle.attrs["font-size"] = `${size}%`;
+ fitLabelRanges(size, groupStyle?.attrs || {});
return;
}
diff --git a/src/components/slider-input.ts b/src/components/slider-input.ts
index 8b7930df51..7bee8254d5 100644
--- a/src/components/slider-input.ts
+++ b/src/components/slider-input.ts
@@ -21,6 +21,8 @@ template.innerHTML = /* html */ `
`;
class SliderInput extends HTMLElement {
+ static observedAttributes = ["min", "max", "step"];
+
constructor() {
super();
this.appendChild(template.content.cloneNode(true));
@@ -39,6 +41,11 @@ class SliderInput extends HTMLElement {
number.addEventListener("change", this.handleEvent.bind(this));
}
+ attributeChangedCallback(name: string, _old: string | null, value: string | null) {
+ if (value === null) return;
+ for (const input of this.querySelectorAll("input")) input.setAttribute(name, value);
+ }
+
handleEvent(e: Event) {
const value = (e.target as HTMLInputElement).value;
const isInvalid = Number.isNaN(Number(value));
diff --git a/src/index.html b/src/index.html
index c0a1db6812..7baab49c02 100644
--- a/src/index.html
+++ b/src/index.html
@@ -5179,7 +5179,7 @@
-
+