2026 10 13 town hall wp bridge - xopherdeep/do-it-for-the-xp GitHub Wiki

Town Hall WordPress Bridge Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make the Town Hall the game's bridge to doit.forthexp.com: read news, patch notes and the community boards; let a Game Master link the family's site account; let anyone post ideas, comments and bug reports, with heroes' public posts approved by their Game Master first.

Architecture: Pure modules under src/lib/services/townhall/ hold every rule (site settings, HTTP client, feed mapping, link URLs, post bodies, the outbox state machine), each with a jest spec. A Pinia store (townHall) owns the link and the posts, persisted in its own Ionic Storage database. The Town Hall page and its tab components stay thin. On the site, one small class in xophz-compass-xp makes a parent who links from the game a Contributor on doit.forthexp.com. A throwaway WordPress network in Docker is the rehearsal before anything touches the live site.

Tech Stack: Vue 3 + Ionic 7 + Pinia + TypeScript (Vue CLI 5), jest, Cypress 8, Capacitor 6; WordPress REST API with Application Passwords; PHP for the plugin hook, tested through the COMPASS node harness (runPhpJson).

Spec: docs/superpowers/specs/2026-10-13-town-hall-wp-bridge-design.md

Global Constraints

  • Gates and reads go through chemx (chemx test <spec>, chemx typecheck, chemx lint, chemx verify); never raw pnpm test, pnpm jest, pnpm typecheck or pnpm lint. Run chemx verify before every commit in this repo.

  • After editing a page's .ts whose .vue uses <script src="./X.ts">, run its spec with npx jest <spec> --no-cache once before chemx verify (jest caches the .vue transform).

  • One dev server: if curl -s localhost:8100 answers, use it; never start another.

  • Cypress: pnpm vue-cli-service test:e2e --headless --browser chrome --url http://localhost:8100 --spec tests/e2e/specs/town-hall.spec.js (Electron cannot parse three; --browser chrome is required).

  • Commits in this repo use a private index (other sessions keep staged work in the shared index). Never git add, git stash or plain git commit here. Define once per shell and use as commit_paths "<message>" <paths...>:

    commit_paths() {
      local msg="$1"; shift
      local idx=$(mktemp) old=$(git rev-parse HEAD) p
      GIT_INDEX_FILE=$idx git read-tree HEAD
      for p in "$@"; do
        if [ -e "$p" ]; then GIT_INDEX_FILE=$idx git update-index --add --cacheinfo 100644,$(git hash-object -w "$p"),"$p"
        else GIT_INDEX_FILE=$idx git update-index --force-remove "$p"; fi
      done
      local new=$(printf '%s\n\nCo-Authored-By: Claude Opus 5.5 <[email protected]>\n' "$msg" \
        | git commit-tree $(GIT_INDEX_FILE=$idx git write-tree) -p "$old")
      git update-ref refs/heads/master "$new" "$old"
      for p in "$@"; do [ -e "$p" ] && git update-index --add --cacheinfo 100644,$(git rev-parse "HEAD:$p"),"$p"; done
      rm -f "$idx"
    }

    Before each commit run chemx team lock check-staged <paths>. If a file you are about to commit also holds another session's uncommitted edits (git diff <file> shows hunks you did not write), stop and ask the owner.

  • The docs submodule commits with git -C docs commit -- <paths> (add a new file with git -C docs add -- <path> first). The plugin repo (../Xophz-COMPASS/wp-content/plugins/xophz-compass-xp, its own git repo) commits the same way with git -C <plugin>; it also holds other people's uncommitted edits, so commit only your paths. No pushes unless the owner asks.

  • Never change the live site. Every site change is the owner's, listed at the end of its phase.

  • Exact values from the spec:

    • TOWN_HALL_APP_ID = "7606ce60-29c7-43b6-9533-2063ee726df4" (app and plugin)
    • default site https://doit.forthexp.com; build env VUE_APP_TOWN_HALL_SITE; development override localStorage["xp:townHallSite"]
    • sign-up page https://www.forthexp.com/wp-signup.php
    • boards, in order: feature-requests, ideas-suggestions, comments-feedback, suggestion-box; Patch Notes category slug patch-notes
    • request timeout 8000 ms; title ≤ 100 characters, message ≤ 2,000, bug message ≤ 4,000 (characters, not bytes)
    • signature appended to topics and comments: \n\n— {hero nickname}, from Do It For The XP
    • connection name: Do It For The XP – {browser} on {device} (en dash)
    • device storage database __xp_town_hall; it is never added to BACKUP_STORES
    • new site members get the role contributor; plugin filter xp_town_hall_site_urls
  • UI rules: new components are <script setup lang="ts">; pages are defineComponent with sibling .ts and _X.scss (<script lang="ts" src="./X.ts" />). The clerk speaks through XpRevealPrompt with lines from src/lib/services/hometown/hometownPrompts.ts (speaker: CLERK, accent: "townHall", sprite: "tablet", the *action* line voice); no toasts or native alerts. Controls (tabs, board picker, Send) sit at the bottom of the screen; the shared bottom nav is not touched. Sounds only through play$fx(key) with keys exported by src/assets/fx/index.ts.

  • Test credentials for the rehearsal are generated by its setup script into a gitignored file and never pasted into chat or committed.

Review Focus

  1. Two sends at once (the Town Hall opening while the browser's online event fires, or a double tap on Send): each post must reach the site once. Pinned in Task 16.
  2. The site returns the password with spaces encoded as +, and logins like [email protected] or josé: linking must work and the Basic header must be UTF-8 base64 (btoa alone throws on é). Pinned in Tasks 2 and 10.
  3. Kids type emoji and accented letters: limits count characters, so a 100-emoji title is allowed and a 101-emoji title is not. Pinned in Task 14.
  4. The app is closed while a post is sending: on the next start the post is queued again, not stuck in "sending" forever. Pinned in Tasks 15 and 16.
  5. Bug Net is not active on the site yet (POST /wp/v2/compass_bug answers 404): the bug stays queued for a retry; it is not marked failed. Pinned in Task 15.

Before you start

  • The clerk's dialog work belongs to another session and must be committed first. Run: git status --short -- src/components/organisms/RevealPrompt src/lib/services/hometown/hometownPrompts.ts tests/unit/HometownPrompts.spec.ts src/app/Console/MyPortal/HomeTown/TownHall src/app/Admin/XpApprovals Expected: no output. If anything is listed, stop and ask the owner to commit or sweep it.
  • curl -s -o /dev/null -w '%{http_code}' localhost:8100 prints 200 (the shared dev server is up). If not, ask the owner before running pnpm serve.

Phase 1: Read-only Town Hall

Task 1: Site settings

Files:

  • Create: src/lib/services/townhall/townHallSite.ts
  • Modify: vue.config.js (top of file)
  • Test: tests/unit/townHallSite.spec.ts

Interfaces:

  • Consumes: WpApiSettings from src/lib/api/apiHost.ts

  • Produces:

    • constants TOWN_HALL_APP_ID, DEFAULT_TOWN_HALL_SITE, SITE_OVERRIDE_KEY = "xp:townHallSite", SIGNUP_URL, TOWN_HALL_BOARDS (readonly tuple), type TownHallBoardSlug, PATCH_NOTES_SLUG, TOWN_HALL_TIMEOUT_MS = 8000
    • townHallSiteUrl(opts?: { env?: string; override?: string | null; production?: boolean }): string (defaults: process.env.VUE_APP_TOWN_HALL_SITE, the localStorage override read in a try/catch, process.env.NODE_ENV === "production"); trailing slashes removed; the override is ignored in production
    • townHallRestRoot(site?: string): string → ${site}/wp-json
    • hostNonce(settings: WpApiSettings | undefined, site?: string): string | null
    • connectionName(userAgent: string, platform: string): string
    • appVersion(): string → process.env.VUE_APP_VERSION || "dev"
  • Step 1: Write the failing tests

describe("townHallSiteUrl", () => {
  it("defaults to doit.forthexp.com", () => expect(townHallSiteUrl({ env: undefined, override: null, production: false })).toBe("https://doit.forthexp.com"));
  it("trims a trailing slash from the build setting", () => expect(townHallSiteUrl({ env: "https://example.test/", override: null, production: true })).toBe("https://example.test"));
  it("takes the localStorage override in development", () => expect(townHallSiteUrl({ override: "http://localhost:8090/doit/", production: false })).toBe("http://localhost:8090/doit"));
  it("ignores the override in production", () => expect(townHallSiteUrl({ override: "http://localhost:8090/doit", production: true })).toBe("https://doit.forthexp.com"));
});
describe("hostNonce", () => {
  const site = "https://doit.forthexp.com";
  it("uses the host page's nonce when it serves the Town Hall site", () => expect(hostNonce({ root: "https://doit.forthexp.com/wp-json/", nonce: "n1" }, site)).toBe("n1"));
  it("ignores another site's nonce", () => expect(hostNonce({ root: "https://www.forthexp.com/wp-json/", nonce: "n1" }, site)).toBeNull());
  it("is null with no host page", () => expect(hostNonce(undefined, site)).toBeNull());
});
describe("connectionName", () => {
  const chromeAndroid = "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Mobile Safari/537.36";
  const safariIphone = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1";
  it("names the browser and device", () => expect(connectionName(chromeAndroid, "web")).toBe("Do It For The XP – Chrome on Android"));
  it("names Safari on an iPhone", () => expect(connectionName(safariIphone, "web")).toBe("Do It For The XP – Safari on iPhone"));
  it("names the phone apps by platform", () => {
    expect(connectionName(chromeAndroid, "android")).toBe("Do It For The XP – Android app");
    expect(connectionName(safariIphone, "ios")).toBe("Do It For The XP – iPhone app");
  });
  it("falls back for unknown agents", () => expect(connectionName("curl/8", "web")).toBe("Do It For The XP – Browser on this device"));
});
it("appVersion reads the build's version", () => {
  process.env.VUE_APP_VERSION = "0.3.0";
  expect(appVersion()).toBe("0.3.0");
  delete process.env.VUE_APP_VERSION;
  expect(appVersion()).toBe("dev");
});
  • Step 2: Run chemx test tests/unit/townHallSite.spec.ts. Expected: FAIL, module not found.
  • Step 3: Implement townHallSite.ts. Browser order for detection: Edge (Edg/), Firefox, Chrome (Chrome/ or CriOS), Safari, else Browser. Device order: Android, iPhone, iPad, Windows, Mac, Linux, else this device. Platforms ios and android map to iPhone app and Android app.
  • Step 4: Add the app version as the first statement of vue.config.js: process.env.VUE_APP_VERSION = require("./package.json").version;
  • Step 5: Run chemx test tests/unit/townHallSite.spec.ts. Expected: PASS.
  • Step 6: Commit after chemx verify: commit_paths "feat(town-hall): site settings, connection name and app version (TH1)" src/lib/services/townhall/townHallSite.ts tests/unit/townHallSite.spec.ts vue.config.js

Task 2: The HTTP client

Files:

  • Create: src/lib/services/townhall/townHallClient.ts
  • Test: tests/unit/townHallClient.spec.ts

Interfaces:

  • Consumes: townHallRestRoot, TOWN_HALL_TIMEOUT_MS (Task 1)

  • Produces:

    • type TownHallAuth = { kind: "none" } | { kind: "nonce"; nonce: string } | { kind: "basic"; userLogin: string; appPassword: string }
    • type TownHallErrorCode = "offline" | "unauthorized" | "forbidden" | "invalid" | "server"
    • class TownHallError extends Error { code: TownHallErrorCode; status?: number } (constructor (code, message?, status?))
    • errorCodeFor(status: number): TownHallErrorCode — 400 → invalid, 401 → unauthorized, 403 → forbidden, anything else (404, 5xx) → server
    • authHeaders(auth: TownHallAuth): Record<string, string>
    • interface TownHallRequest { auth?: TownHallAuth; method?: "GET" | "POST" | "DELETE"; query?: Record<string, string | number | undefined>; body?: unknown; root?: string; fetchImpl?: typeof fetch; timeoutMs?: number }
    • townHallFetch<T>(path: string, req?: TownHallRequest): Promise<{ data: T; headers: Headers }> — path is relative to the REST root ("wp/v2/posts")
  • Step 1: Write the failing tests

it("maps statuses to error codes", () => {
  expect([400, 401, 403, 404, 500, 502].map(errorCodeFor)).toEqual(["invalid", "unauthorized", "forbidden", "server", "server", "server"]);
});
it("encodes Basic auth as UTF-8", () => {
  const h = authHeaders({ kind: "basic", userLogin: "josé", appPassword: "abcd efgh" });
  expect(h.Authorization).toBe("Basic " + Buffer.from("josé:abcd efgh", "utf8").toString("base64"));
});
it("sends the nonce header", () => expect(authHeaders({ kind: "nonce", nonce: "n1" })).toEqual({ "X-WP-Nonce": "n1" }));
it("builds the URL and skips empty query values", async () => {
  const fetchImpl = jest.fn(async () => new Response(JSON.stringify([{ id: 1 }]), { status: 200, headers: { "X-WP-TotalPages": "3" } }));
  const res = await townHallFetch<{ id: number }[]>("wp/v2/posts", { root: "https://doit.test/wp-json", query: { per_page: 5, categories_exclude: undefined }, fetchImpl });
  expect(fetchImpl.mock.calls[0][0]).toBe("https://doit.test/wp-json/wp/v2/posts?per_page=5");
  expect(res.data).toEqual([{ id: 1 }]);
  expect(res.headers.get("X-WP-TotalPages")).toBe("3");
});
it("posts JSON", async () => {
  const fetchImpl = jest.fn(async () => new Response("{}", { status: 201 }));
  await townHallFetch("wp/v2/comments", { root: "https://doit.test/wp-json", method: "POST", body: { post: 1 }, fetchImpl });
  const init = fetchImpl.mock.calls[0][1] as RequestInit;
  expect(init.method).toBe("POST");
  expect((init.headers as Record<string, string>)["Content-Type"]).toBe("application/json");
  expect(init.body).toBe(JSON.stringify({ post: 1 }));
});
it("turns a network failure into offline", async () => {
  const fetchImpl = jest.fn(async () => { throw new TypeError("Failed to fetch"); });
  await expect(townHallFetch("wp/v2/posts", { fetchImpl })).rejects.toMatchObject({ code: "offline" });
});
it("turns a timeout into offline", async () => {
  const fetchImpl = jest.fn((_u: RequestInfo, init?: RequestInit) => new Promise<Response>((_r, reject) => init?.signal?.addEventListener("abort", () => reject(new Error("aborted")))));
  await expect(townHallFetch("wp/v2/posts", { fetchImpl, timeoutMs: 10 })).rejects.toMatchObject({ code: "offline" });
});
it("carries the site's message and status", async () => {
  const fetchImpl = jest.fn(async () => new Response(JSON.stringify({ code: "rest_cannot_create", message: "Sorry" }), { status: 403 }));
  await expect(townHallFetch("wp/v2/cafeteria_topic", { fetchImpl, method: "POST", body: {} })).rejects.toMatchObject({ code: "forbidden", message: "Sorry", status: 403 });
});
  • Step 2: Run chemx test tests/unit/townHallClient.spec.ts. Expected: FAIL, module not found.
  • Step 3: Implement townHallClient.ts. UTF-8 base64 via TextEncoder and btoa over the bytes. Abort with an AbortController after timeoutMs (default TOWN_HALL_TIMEOUT_MS); clear the timer in finally. A body that is not JSON reads as null.
  • Step 4: Run the spec. Expected: PASS.
  • Step 5: Commit after chemx verify: commit_paths "feat(town-hall): the site client with auth, timeout and error codes (TH2)" src/lib/services/townhall/townHallClient.ts tests/unit/townHallClient.spec.ts

Task 3: Reading the site

Files:

  • Create: src/lib/services/townhall/townHallFeed.ts
  • Test: tests/unit/townHallFeed.spec.ts

Interfaces:

  • Consumes: townHallFetch, TownHallRequest (Task 2); TOWN_HALL_BOARDS, PATCH_NOTES_SLUG, TownHallBoardSlug (Task 1)

  • Produces (every function takes an optional req?: Pick<TownHallRequest, "auth" | "fetchImpl" | "root">):

    • interface NewsItem { id: number; date: string; title: string; excerpt: string; content: string; link: string; imageUrl?: string; categories: string[] }
    • interface CommentItem { id: number; author: string; date: string; content: string }
    • interface BoardInfo { id: number; slug: TownHallBoardSlug; name: string; description: string; count: number }
    • interface TopicItem { id: number; date: string; title: string; content: string; author: string }
    • patchNotesCategoryId(req?): Promise<number | null> — GET wp/v2/categories?slug=patch-notes
    • fetchNews(page: number, req?): Promise<{ items: NewsItem[]; totalPages: number }> — GET wp/v2/posts?_embed=true&per_page=5&page= plus categories_exclude=<id> when the category exists
    • fetchPatchNotes(req?): Promise<NewsItem[]> — GET wp/v2/posts?categories=<id>&per_page=10; [] without a request when the category is missing
    • fetchComments(postId: number, req?): Promise<CommentItem[]> — GET wp/v2/comments?post=&per_page=50&order=asc
    • fetchBoards(req?): Promise<BoardInfo[]> — GET wp/v2/cafeteria_board?per_page=100, kept to TOWN_HALL_BOARDS in that order
    • fetchTopics(boardId: number, page: number, req?): Promise<{ items: TopicItem[]; totalPages: number }> — GET wp/v2/cafeteria_topic?cafeteria_board=&_embed=true&per_page=10&page=
  • Step 1: Write the failing tests with a fake fetchImpl that answers by URL:

    • fetchBoards given boards noo, suggestion-box, feature-requests, comments-feedback (no ideas-suggestions) returns slugs ["feature-requests", "comments-feedback", "suggestion-box"].
    • fetchNews(1) when categories?slug=patch-notes returns [{ id: 7 }] requests a URL containing categories_exclude=7; when it returns [], the posts URL has no categories_exclude.
    • fetchNews maps title.rendered, excerpt.rendered, _embedded["wp:featuredmedia"][0].source_url to imageUrl, _embedded["wp:term"][0][].name to categories, and reads totalPages from X-WP-TotalPages (1 when absent).
    • fetchPatchNotes with no category returns [] and makes exactly one request.
    • fetchTopics maps _embedded.author[0].name to author ("Unknown" when absent).
    • fetchComments(12) maps author_name and content.rendered.
  • Step 2: Run chemx test tests/unit/townHallFeed.spec.ts. Expected: FAIL.

  • Step 3: Implement townHallFeed.ts.

  • Step 4: Run the spec. Expected: PASS.

  • Step 5: Commit after chemx verify: commit_paths "feat(town-hall): read news, patch notes, boards, topics and comments (TH3)" src/lib/services/townhall/townHallFeed.ts tests/unit/townHallFeed.spec.ts

Task 4: The clerk's new menu

Files:

  • Modify: src/lib/services/hometown/hometownPrompts.ts (the Town Hall section)
  • Test: tests/unit/HometownPrompts.spec.ts (replace the clerk cases)

Interfaces:

  • Consumes: RevealChoice, RevealPromptOptions, noticeOf (existing)

  • Produces:

    • type ClerkMenuId = "board" | "bug" | "registration" | "never"
    • CLERK_ERRANDS: Record<Exclude<ClerkMenuId, "never">, string> = { board: "Posting on the Board", bug: "Bug reports", registration: "Family registration" }
    • clerkMenuChoices(isGameMaster: boolean): RevealChoice[] — board "Post on the Board" (thumbtack), bug "Report a Bug" (bug), registration "Family Registration" (file-signature, Game Master only), never "Never mind" (times, ghost)
    • clerkMenuPrompt(isGameMaster: boolean): RevealPromptOptions — same line as today, cancelId: "never"
    • comingSoonPrompt(what: string) (unchanged)
    • courierLatePrompt(): RevealPromptOptions — line *peers out the window* The courier hasn't arrived from the city yet., choices retry "Try again" (redo) and never "Never mind"
  • Step 1: Update the tests: clerkMenuChoices(false).map(c => c.id) equals ["board", "bug", "never"]; clerkMenuChoices(true) equals ["board", "bug", "registration", "never"]; clerkMenuPrompt(true) has speaker: CLERK, accent: "townHall", cancelId: "never"; courierLatePrompt().choices.map(c => c.id) equals ["retry", "never"]; the old announce id no longer exists.

  • Step 2: Run chemx test tests/unit/HometownPrompts.spec.ts. Expected: the new cases FAIL.

  • Step 3: Implement the Town Hall section changes.

  • Step 4: Run the spec. Expected: PASS.

  • Step 5: Commit together with Task 5, because TownHall.vue imports the old names.

Task 5: The read-only Town Hall page

Files:

  • Modify: src/app/Console/MyPortal/HomeTown/TownHall/TownHall.vue (template only)
  • Create: src/app/Console/MyPortal/HomeTown/TownHall/TownHall.ts, _TownHall.scss
  • Create: src/app/Console/MyPortal/HomeTown/TownHall/components/TownHallNews.vue, TownHallBoard.vue, TownHallUpdates.vue, TownHallEvents.vue
  • Create: tests/e2e/fixtures/town-hall-backup.json, tests/e2e/specs/town-hall.spec.js

Interfaces:

  • Consumes: Tasks 1–4; canAccessGameMaster (src/router/_Guards/gm.guard.ts); useRevealPrompt; the quest engine code already in the page

  • Produces:

    • tab ids news | board | events | updates in an ion-segment inside an ion-footer (data-cy="th-tab-<id>")
    • each tab component exposes reload(): Promise<void> and emits trouble when a TownHallError with code offline or server stops it loading; the page answers trouble with courierLatePrompt() and calls reload() on retry
    • TownHallBoard props { boardSlug: TownHallBoardSlug }; the board picker (data-cy="th-board-<slug>") sits in the footer above the tabs while the Board tab is active, built from fetchBoards() (missing boards are absent)
    • header button data-cy="th-family-chat" → router.push({ name: "town-hall-chat", params: { userId } }) (route added in Task 6)
    • the clerk: clerkMenuPrompt(canAccessGameMaster()), then comingSoonPrompt(CLERK_ERRANDS[pick]) (posting and registration arrive in Phases 2–3)
  • Step 1: Write the fixture. Copy tests/e2e/fixtures/battle-backup.json and add a second profile gm-1 (nick Dad, isAdult: true, no passcode) next to hero-1 (Max).

  • Step 2: Write the failing Cypress spec tests/e2e/specs/town-hall.spec.js. Intercept the site with fixtures (never the live site): **/wp-json/wp/v2/categories?slug=patch-notes* → [{ id: 7 }]; **/wp/v2/posts?_embed* → two posts (X-WP-TotalPages: 1); **/wp/v2/posts?categories=7* → one patch note titled 0.3.0; **/wp/v2/cafeteria_board* → the four boards plus noo; **/wp/v2/cafeteria_topic* → two topics; **/wp/v2/comments?post=* → one comment. Cases:

    • the four tabs are visible at the bottom: [data-cy=th-tab-news] has a getBoundingClientRect().top greater than half the viewport height (390×844)
    • News shows both post titles; tapping a post's comments toggle shows the comment
    • Board shows four board chips and no noo; the topics of the first board are listed
    • Updates shows 0.3.0 and the stats card
    • as hero-1, the clerk menu has no "Family Registration"; as gm-1, it does (seedWorld("town-hall-backup.json", "gm-1"))
    • when **/wp/v2/posts?_embed* answers 503, the clerk says "The courier hasn't arrived"
  • Step 3: Run the Cypress command from the Global Constraints. Expected: FAIL.

  • Step 4: Implement the page split and the four components. Move the Events code (quest board, stats) into TownHall.ts unchanged and hand the results to TownHallEvents and TownHallUpdates as props. Render site HTML only through sanitizeHtml. With no patch notes, Updates shows No patch notes yet above the stats card. Remove the hardcoded wpBaseUrl, the WordPressPost types and the fake 2.1.0 update.

  • Step 5: Run npx jest tests/unit/HometownPrompts.spec.ts --no-cache, then the Cypress spec. Expected: PASS.

  • Step 6: Commit after chemx verify: commit_paths "feat(town-hall): bottom tabs, the Board, patch notes and the clerk's new menu (TH4-5)" <the files of Tasks 4 and 5>

Task 6: Family Chat for heroes

Files:

  • Modify: src/router/Console/hometown.routes.ts, src/router/parentOf.ts
  • Test: tests/unit/parentOf.spec.ts, tests/e2e/specs/town-hall.spec.js

Interfaces:

  • Produces: route { path: "/town-hall/:userId/chat", name: "town-hall-chat", component: () => import("@/app/Admin/XpChat/XpChat.vue"), props: true }; parent rule "town-hall-chat": ({ heroId }) => \/town-hall/${heroId}``

  • Step 1: Write the failing tests: parentOf({ name: "town-hall-chat", path: "/town-hall/hero-1/chat", params: { userId: "hero-1" } }) returns "/town-hall/hero-1". Cypress: from /town-hall/hero-1, tap [data-cy=th-family-chat]; the URL ends with /town-hall/hero-1/chat and "Family Chat" is visible; the close button returns to /town-hall/hero-1.

  • Step 2: Run chemx test tests/unit/parentOf.spec.ts and the Cypress spec. Expected: FAIL.

  • Step 3: Add the route and the rule. XpChat has no Game-Master-only actions (checked while writing the plan); reuse it as is.

  • Step 4: Run both. Expected: PASS.

  • Step 5: Commit after chemx verify: commit_paths "feat(town-hall): a door to Family Chat for heroes (TH6)" src/router/Console/hometown.routes.ts src/router/parentOf.ts tests/unit/parentOf.spec.ts tests/e2e/specs/town-hall.spec.js

Phase 1 owner steps: on doit.forthexp.com create the category Patch Notes (slug patch-notes) and post the first notes.


Phase 2: Family registration

Task 7: The rehearsal network

Files:

  • Create: tools/townhall-rehearsal/docker-compose.yml, tools/townhall-rehearsal/setup.sh, tools/townhall-rehearsal/README.md, tools/townhall-rehearsal/.gitignore (ignores .rehearsal-credentials)

Interfaces:

  • Produces: a subdirectory WordPress network on http://localhost:8090 (main site) with a site at http://localhost:8090/doit/, where xophz-compass, xophz-compass-xp, xophz-compass-bulletin-board and xophz-compass-bugnet are active; network registration set to user accounts; category patch-notes with one note; one news post; an mu-plugin adding http://localhost:8090/doit to xp_town_hall_site_urls; WP_ENVIRONMENT_TYPE local. Credentials for the network admin and a parent account famparent (a network user who is not a member of /doit/) are written to tools/townhall-rehearsal/.rehearsal-credentials.

  • Step 1: Check the port: lsof -ti:8090 prints nothing.

  • Step 2: Write the compose file: services db (mariadb 11) and wordpress (official wordpress image, PHP 8.2, Apache, port 8090:80) plus a wpcli service (official wordpress:cli) sharing the volume. Mount the four plugins read-only from ../../../Xophz-COMPASS/wp-content/plugins/. Nothing from the owner's My Compass stack is reused or changed.

  • Step 3: Write setup.sh with wp core multisite-install (subdirectory), wp site create --slug=doit, plugin activation on /doit/, wp site option update registration user, the category, posts, the mu-plugin, the two users with generated passwords (openssl rand -base64 18), and wp user remove-role famparent so the parent is a member of no site. It is idempotent: a second run changes nothing.

  • Step 4: Verify: docker compose -f tools/townhall-rehearsal/docker-compose.yml up -d && tools/townhall-rehearsal/setup.sh && curl -s http://localhost:8090/doit/wp-json/ | grep -o -E 'application-passwords|/wp/v2/cafeteria_topic"|/wp/v2/compass_bug"|"xp/v1"' | sort -u Expected: all four strings. If a plugin fails to activate, record the error in the README and stop.

  • Step 5: Commit: commit_paths "chore(town-hall): a throwaway WordPress network for the rehearsal (TH7)" tools/townhall-rehearsal/docker-compose.yml tools/townhall-rehearsal/setup.sh tools/townhall-rehearsal/README.md tools/townhall-rehearsal/.gitignore

Task 8: The membership hook

Work in the plugin repo P=../Xophz-COMPASS/wp-content/plugins/xophz-compass-xp.

Files:

  • Create: $P/includes/class-xophz-compass-xp-town-hall.php
  • Modify: $P/includes/class-xophz-compass-xp.php (load_dependencies and define_compass_hooks), $P/tests/run-tests.mjs
  • Test: $P/tests/town-hall-membership.test.mjs

Interfaces:

  • Produces:

    • class Xophz_Compass_Xp_Town_Hall with const APP_ID = '7606ce60-29c7-43b6-9533-2063ee726df4' and const DEFAULT_SITE_URLS = [ 'https://doit.forthexp.com' ]
    • public $action_hooks = [ 'wp_create_application_password' => [ 'on_password_created', 10, 2 ], 'application_password_did_authenticate' => [ 'on_authenticated', 10, 2 ] ] (the loader's define_class_hooks format)
    • public static function should_join( bool $is_multisite, string $app_id, string $site_url, array $allowed, bool $is_member ): bool — pure; compares site addresses without trailing slashes
    • public function on_password_created( int $user_id, array $item ): void and public function on_authenticated( WP_User $user, array $item ): void, both calling a private maybe_join( int $user_id, array $item ) that passes is_multisite(), $item['app_id'], home_url(), apply_filters( 'xp_town_hall_site_urls', self::DEFAULT_SITE_URLS ) and is_user_member_of_blog( $user_id, get_current_blog_id() ) to should_join, then add_user_to_blog( get_current_blog_id(), $user_id, 'contributor' )
  • Step 1: Write the failing test in the style of xp-pro-buy.test.mjs (runPhpJson, no WordPress loaded, require_once of the new class):

is_multisite app_id site_url is_member expected
true APP_ID https://doit.forthexp.com false true
true APP_ID https://doit.forthexp.com/ false true
false APP_ID https://doit.forthexp.com false false
true other-app https://doit.forthexp.com false false
true `` https://doit.forthexp.com false false
true APP_ID https://www.forthexp.com false false
true APP_ID https://doit.forthexp.com true false

Also lintPhpFile on the new file passes. Add the import to run-tests.mjs.

  • Step 2: Run node wp-content/plugins/xophz-compass-xp/tests/run-tests.mjs from ../Xophz-COMPASS (the My Compass u-wordpress container must be up). Expected: FAIL.
  • Step 3: Implement the class with the same file header as class-xophz-compass-xp-pro.php (it loads without WordPress), and register it in the loader.
  • Step 4: Run the tests. Expected: PASS.
  • Step 5: Commit in the plugin repo: git -C $P add -- includes/class-xophz-compass-xp-town-hall.php tests/town-hall-membership.test.mjs && git -C $P commit -m "feat(town-hall): make a parent who links from the game a Contributor on doit (TH8)" -m "Co-Authored-By: Claude Opus 5.5 <[email protected]>" -- includes/class-xophz-compass-xp-town-hall.php includes/class-xophz-compass-xp.php tests/run-tests.mjs tests/town-hall-membership.test.mjs

Task 9: Link URLs

Files:

  • Create: src/lib/services/townhall/townHallLink.ts
  • Test: tests/unit/townHallLink.spec.ts

Interfaces:

  • Consumes: TOWN_HALL_APP_ID (Task 1)

  • Produces:

    • type LinkReturn = { kind: "linked"; siteUrl: string; userLogin: string; appPassword: string } | { kind: "rejected" } | { kind: "none" }
    • townHallReturnUrl(userId: string, loc: { origin: string; pathname: string }, hash: boolean): string
    • authorizeUrl(opts: { site: string; appName: string; returnUrl: string }): string
    • readLinkReturn(search: string): LinkReturn
    • strippedUrl(loc: { origin: string; pathname: string; hash: string }): string
  • Step 1: Write the failing tests

const loc = { origin: "https://app.forthexp.com", pathname: "/" };
it("returns to the Town Hall in hash mode", () => expect(townHallReturnUrl("gm-1", loc, true)).toBe("https://app.forthexp.com/#/town-hall/gm-1"));
it("returns to the Town Hall in path mode", () => expect(townHallReturnUrl("gm-1", loc, false)).toBe("https://app.forthexp.com/town-hall/gm-1"));
it("builds the authorize URL", () => {
  const u = new URL(authorizeUrl({ site: "https://doit.forthexp.com", appName: "Do It For The XP – Chrome on Android", returnUrl: "https://app.forthexp.com/#/town-hall/gm-1" }));
  expect(u.origin + u.pathname).toBe("https://doit.forthexp.com/wp-admin/authorize-application.php");
  expect(u.searchParams.get("app_name")).toBe("Do It For The XP – Chrome on Android");
  expect(u.searchParams.get("app_id")).toBe(TOWN_HALL_APP_ID);
  expect(u.searchParams.get("success_url")).toBe("https://app.forthexp.com/#/town-hall/gm-1");
  expect(u.searchParams.get("reject_url")).toBe("https://app.forthexp.com/#/town-hall/gm-1");
});
it("reads a link, decoding + as a space", () => {
  expect(readLinkReturn("?site_url=https%3A%2F%2Fdoit.forthexp.com&user_login=mom%40example.com&password=abcd+efgh+ijkl+mnop"))
    .toEqual({ kind: "linked", siteUrl: "https://doit.forthexp.com", userLogin: "[email protected]", appPassword: "abcd efgh ijkl mnop" });
});
it("reads a rejection", () => expect(readLinkReturn("?success=false")).toEqual({ kind: "rejected" }));
it("ignores a partial or unrelated query", () => {
  expect(readLinkReturn("")).toEqual({ kind: "none" });
  expect(readLinkReturn("?user_login=mom")).toEqual({ kind: "none" });
  expect(readLinkReturn("?foo=1")).toEqual({ kind: "none" });
});
it("strips the query and keeps the route", () => expect(strippedUrl({ ...loc, hash: "#/town-hall/gm-1" })).toBe("https://app.forthexp.com/#/town-hall/gm-1"));
  • Step 2: Run chemx test tests/unit/townHallLink.spec.ts. Expected: FAIL.
  • Step 3: Implement townHallLink.ts with URL and URLSearchParams; the user id is encodeURIComponent-ed.
  • Step 4: Run the spec. Expected: PASS.
  • Step 5: Commit after chemx verify: commit_paths "feat(town-hall): authorize and return URLs for linking the family (TH9)" src/lib/services/townhall/townHallLink.ts tests/unit/townHallLink.spec.ts

Task 10: The link on the device

Files:

  • Create: src/lib/services/townhall/townHallTypes.ts, src/lib/databases/TownHallDb.ts, src/lib/store/stores/townHall.ts
  • Test: tests/unit/townHallStore.spec.ts

Interfaces:

  • Consumes: Tasks 1, 2, 9; DbStorageApi (as ProDb uses it)

  • Produces:

    • townHallTypes.ts: TownHallLink and TownHallPost exactly as in spec section 7, plus type TownHallPostKind = TownHallPost["kind"] and type PostStatus = TownHallPost["status"]
    • TownHallDb (Ionic Storage name __xp_town_hall, keys link and posts): getLink(): Promise<TownHallLink | null>, saveLink(link: TownHallLink), clearLink(), getPosts(): Promise<TownHallPost[]>, savePosts(posts: TownHallPost[]); getTownHallDb(): TownHallDb
    • store useTownHallStore (setup style, like pro.ts): state link, posts, lastLinkResult; computed auth: TownHallAuth (link → basic, else host nonce → nonce, else none), isLinked (link or nonce), signedInThroughWebsite (nonce and no link); actions init(): Promise<void> (loads once), completeLink(search: string): Promise<"linked" | "rejected" | "none" | "failed">, unlink(): Promise<"forgotten" | "removed-here-only">, forgetLink(): Promise<void>
  • Step 1: Write the failing tests, mocking @/lib/databases/TownHallDb and townHallFetch (pattern: tests/unit/proStore.spec.ts):

    • a linked query, GET wp/v2/users/me?context=edit → { name: "Mom" }, GET wp/v2/users/me/application-passwords/introspect → { uuid: "u-1" }: returns "linked"; the saved link has userLogin, appPassword, displayName: "Mom", appPasswordUuid: "u-1"; auth is { kind: "basic", ... }; both requests used Basic auth with the new credentials
    • users/me rejects with unauthorized: returns "failed"; nothing saved
    • introspect fails but users/me succeeds: still "linked", appPasswordUuid undefined
    • ?success=false → "rejected", no request, nothing saved
    • unlink with a uuid and DELETE wp/v2/users/me/application-passwords/u-1 succeeding → "forgotten"; with the DELETE rejecting → "removed-here-only"; the link is cleared in both cases
    • with window.wpApiSettings = { root: "https://doit.forthexp.com/wp-json", nonce: "n1" } and no link: auth is { kind: "nonce", nonce: "n1" }, isLinked and signedInThroughWebsite are true
    • BACKUP_STORES (src/lib/services/core/BackupService.ts) has no entry named __xp_town_hall
  • Step 2: Run chemx test tests/unit/townHallStore.spec.ts. Expected: FAIL.

  • Step 3: Implement the types, the database and the store. completeLink never reads or writes window.location; the page owns the address bar.

  • Step 4: Run the spec. Expected: PASS.

  • Step 5: Commit after chemx verify: commit_paths "feat(town-hall): store the family's link on the device, check it, unlink it (TH10)" src/lib/services/townhall/townHallTypes.ts src/lib/databases/TownHallDb.ts src/lib/store/stores/townHall.ts tests/unit/townHallStore.spec.ts

Task 11: Family registration in the Town Hall

Files:

  • Modify: src/lib/services/hometown/hometownPrompts.ts, src/app/Console/MyPortal/HomeTown/TownHall/TownHall.ts
  • Test: tests/unit/HometownPrompts.spec.ts, tests/e2e/specs/town-hall.spec.js

Interfaces:

  • Consumes: Tasks 1, 9, 10

  • Produces (prompts):

    • type RegistrationMenuId = "have" | "create" | "never"
    • registrationPrompt(viaWebsite: boolean): RevealPromptOptions — lines: To post for your family, register it with the Town Hall. You'll need a For the XP account. and Your display name on the site is public, so pick one that isn't your full name.; with viaWebsite, first You're signed in through the website. Registering lets your family post from any device.; choices have "I have an account" (sign-in-alt), create "Create one" (user-plus), never
    • linkedStatusPrompt(displayName: string): RevealPromptOptions — Your family is registered as ${displayName}.; choices unlink "Unlink this device" (unlink), never "OK"
    • linkResultNotice(result: "linked" | "rejected" | "failed", displayName?: string) — linked: *stamps the ledger* Welcome, ${displayName}! Your family is registered at the Town Hall.; rejected: *closes the ledger* Maybe another time.; failed: *squints at the papers* The registration didn't go through, and nothing was saved. Try again.
    • unlinkResultNotice(result: "forgotten" | "removed-here-only") — forgotten: *tears up the card* This device no longer posts for your family.; removed-here-only: *tears up the card* Done here. To finish, remove "Do It For The XP" from your profile on doit.forthexp.com.
    • phoneAppRegistrationNotice() — For now, register from the web version of the game. The phone app will learn it soon. (removed in Task 19)
  • Step 1: Write the failing tests: prompt ids and copy (display name in the greeting; the website line only with viaWebsite). Cypress, as gm-1, with **/wp/v2/users/me?context=edit* → { name: "Mom" } and **/application-passwords/introspect* → { uuid: "u-1" }: visit /town-hall/gm-1?site_url=https%3A%2F%2Fdoit.forthexp.com&user_login=mom&password=abcd+efgh (hash-mode runs prefix /# as pro.spec.js does); expect "Welcome, Mom!", the URL no longer contains password, and Family Registration now offers "Unlink this device". Rejection: ?success=false shows "Maybe another time."

  • Step 2: Run the prompts spec and the Cypress spec. Expected: FAIL.

  • Step 3: Wire the page. On mount: await townHall.init(); read window.location.search; if readLinkReturn is not none, call history.replaceState(history.state, "", strippedUrl(location)) before any await, then completeLink with the captured search and show linkResultNotice. Family Registration (Game Master only): linked → linkedStatusPrompt → unlink → unlinkResultNotice; not linked → registrationPrompt(townHall.signedInThroughWebsite) → create opens SIGNUP_URL in a new tab; have on the web assigns window.location to authorizeUrl({ site: townHallSiteUrl(), appName: connectionName(navigator.userAgent, Capacitor.getPlatform()), returnUrl: townHallReturnUrl(userId, location, usesHashHistory()) }); have in a native app shows phoneAppRegistrationNotice().

  • Step 4: Run npx jest tests/unit/HometownPrompts.spec.ts --no-cache, then Cypress. Expected: PASS.

  • Step 5: Commit after chemx verify: commit_paths "feat(town-hall): family registration with the clerk (TH11)" <the files above>

Task 12: Rehearsal 1 (linking)

No code unless a defect turns up; a defect goes back to its task.

  • Step 1: In the built-in browser on http://localhost:8100, run localStorage.setItem("xp:townHallSite", "http://localhost:8090/doit") and reload. Sign in to the game as a Game Master.
  • Step 2: Town Hall → clerk → Family Registration → I have an account. Sign in as famparent (credentials from .rehearsal-credentials).
  • Step 3: Answer the spec's open question. Does /doit/wp-admin/authorize-application.php show the Approve screen to famparent, who is not yet a member of /doit/? Write the answer into spec section 4 in place of the "To verify in the local rehearsal" paragraph. If WordPress turns the parent away, change authorizeUrl's site to the network's main site (http://localhost:8090, live https://www.forthexp.com) in Task 11's wiring, re-run Tasks 9–11's tests, and rely on on_authenticated to add membership on the first call.
  • Step 4: Approve. Expected: back in the Town Hall, "Welcome, …!", and docker compose -f tools/townhall-rehearsal/docker-compose.yml run --rm wpcli wp user list --url=http://localhost:8090/doit --role=contributor --field=user_login prints famparent.
  • Step 5: Unlink. Expected: wp user application-password list famparent --format=count prints 0 (or the clerk showed the "remove from your profile" line; note which in the spec).
  • Step 6: Commit the spec note: git -C docs commit -m "spec(town-hall): rehearsal answer on the approve screen" -- superpowers/specs/2026-10-13-town-hall-wp-bridge-design.md

Phase 2 owner steps: deploy the updated xophz-compass-xp to the live network; link our family from the Town Hall on doit.forthexp.com; confirm the account shows as a Contributor on doit.forthexp.com.


Phase 3: Posting

Task 13: Post bodies

Files:

  • Create: src/lib/services/townhall/townHallPosts.ts
  • Test: tests/unit/townHallPosts.spec.ts

Interfaces:

  • Consumes: TownHallPost, TownHallPostKind (Task 10); TownHallBoardSlug (Task 1)

  • Produces:

    • TITLE_MAX = 100, BODY_MAX = 2000, BUG_BODY_MAX = 4000
    • interface PostDraft { kind: TownHallPostKind; title?: string; body: string; board?: TownHallBoardSlug; postId?: number }
    • charCount(text: string): number (code points)
    • draftProblem(draft: PostDraft): string | null — in this order: topic without board → Pick a board.; topic without title → Give it a title.; title over 100 → Keep the title under 100 characters.; empty body (after trim) → Write something first.; body over its limit → Keep it under 2,000 characters. / Keep it under 4,000 characters.
    • signature(heroName: string): string → \n\n— ${heroName}, from Do It For The XP
    • bugEnvironment(info: { version: string; route: string; platform: string; userAgent: string; at: number }): string → four lines App: …, Screen: …, Device: {platform}, {userAgent}, Time: {ISO string}
    • requestFor(post: TownHallPost, boardIds: Partial<Record<TownHallBoardSlug, number>>): { path: string; body: Record<string, unknown> }
  • Step 1: Write the failing tests:

    • charCount("🐉🐉") === 2; a title of 100 dragons has no problem; 101 gives Keep the title under 100 characters.
    • the problem order above, one case each
    • a topic post for board feature-requests with boardIds = { "feature-requests": 3 } gives { path: "wp/v2/cafeteria_topic", body: { title, content: body + signature("Max"), status: "pending", cafeteria_board: [3] } }
    • a comment gives { path: "wp/v2/comments", body: { post: 12, content: body + signature("Max") } }
    • a bug gives { path: "wp/v2/compass_bug", body: { title, content: body, status: "pending", bug_status: "new", bug_priority: "medium", bug_type: "bug", bug_plugin: "do-it-for-the-xp", bug_url: "/town-hall/hero-1", bug_environment: <the env string> } }; a bug without a title is titled Bug report from Max
    • requestFor throws for a topic whose board has no id
    • the limit applies to the body without the signature
  • Step 2: Run chemx test tests/unit/townHallPosts.spec.ts. Expected: FAIL.

  • Step 3: Implement townHallPosts.ts.

  • Step 4: Run the spec. Expected: PASS.

  • Step 5: Commit after chemx verify: commit_paths "feat(town-hall): post bodies, signatures, limits and bug details (TH13)" src/lib/services/townhall/townHallPosts.ts tests/unit/townHallPosts.spec.ts

Task 14: The outbox rules

Files:

  • Create: src/lib/services/townhall/townHallOutbox.ts
  • Test: tests/unit/townHallOutbox.spec.ts

Interfaces:

  • Consumes: Tasks 2, 10, 13

  • Produces:

    • type SendResult = { ok: true; remoteId: number } | { ok: false; code: TownHallErrorCode; message?: string }
    • newPost(draft: PostDraft, hero: { id: string; name: string }, isGameMaster: boolean, now: number, id: string, bug?: { route: string; environment: string }): TownHallPost — status queued for a bug or a Game Master, else awaiting-gm
    • approvePost(post), rejectPost(post) — act only on awaiting-gm (→ queued / rejected), else return the post unchanged
    • startSending(post) — queued → sending
    • settle(post, result: SendResult): { post: TownHallPost; unlink: boolean }
    • recover(posts: TownHallPost[]): TownHallPost[] — sending → queued
    • sendable(posts): TownHallPost[] — queued, oldest first
    • waitingFor(posts, heroId: string): TownHallPost[] — the hero's posts not yet sent, newest first
    • editPost(post, draft: PostDraft, isGameMaster: boolean): TownHallPost — a failed post with new text goes back to queued (Game Master) or awaiting-gm (hero); error cleared
  • Step 1: Write the failing tests: one per rule:

    • newPost statuses for (hero, topic) awaiting-gm, (hero, bug) queued, (GM, comment) queued
    • approve and reject from awaiting-gm; both leave a queued post unchanged
    • settle ok → sent with remoteId, error undefined
    • offline and server → queued with error set to the code, unlink: false
    • a 404 from an inactive Bug Net arrives as server and keeps the bug queued (Review Focus 5)
    • unauthorized → queued, unlink: true
    • forbidden → queued, error: "forbidden"
    • invalid with message Title is required → failed, error: "Title is required"
    • recover turns sending into queued (Review Focus 4)
    • sendable order
    • waitingFor filters by hero and excludes sent
    • editPost on a failed post
  • Step 2: Run chemx test tests/unit/townHallOutbox.spec.ts. Expected: FAIL.

  • Step 3: Implement as pure functions returning new objects.

  • Step 4: Run the spec. Expected: PASS.

  • Step 5: Commit after chemx verify: commit_paths "feat(town-hall): the outbox state machine (TH14)" src/lib/services/townhall/townHallOutbox.ts tests/unit/townHallOutbox.spec.ts

Task 15: Posting through the store

Files:

  • Modify: src/lib/store/stores/townHall.ts
  • Test: tests/unit/townHallStore.spec.ts

Interfaces:

  • Consumes: Tasks 3, 13, 14

  • Produces (store actions):

    • submit(draft: PostDraft, hero: { id: string; name: string }, isGameMaster: boolean, route: string): Promise<{ problem: string | null; post?: TownHallPost }> — bug details from appVersion(), Capacitor.getPlatform(), navigator.userAgent, Date.now(); ids from uuid v4
    • approve(id: string), reject(id: string), dismiss(id: string) (removes a rejected, failed or sent post), resubmit(id: string, draft: PostDraft, isGameMaster: boolean): Promise<string | null>
    • flush(): Promise<{ sent: number; unlinked: boolean; forbidden: boolean }> — no-op when !isLinked; a call made while a flush runs returns that same promise; board ids come from fetchBoards() once per flush that has topics; persists after each post
    • init() now also runs recover() on the loaded posts
  • Step 1: Write the failing tests:

    • a hero's topic is awaiting-gm and no request is made
    • approve then flush POSTs to wp/v2/cafeteria_topic with Basic auth and cafeteria_board: [3]
    • two flush() calls at once send each post once (Review Focus 1): the POST mock is called once per queued post
    • a 401 clears the link, keeps the post queued, and returns unlinked: true
    • a 403 returns forbidden: true
    • not linked: flush makes no request
    • init turns a stored sending post back into queued
    • submit returns the draftProblem text and stores nothing for an empty body
  • Step 2: Run chemx test tests/unit/townHallStore.spec.ts. Expected: the new cases FAIL.

  • Step 3: Implement the actions.

  • Step 4: Run the spec. Expected: PASS.

  • Step 5: Commit after chemx verify: commit_paths "feat(town-hall): submit, approve and send posts from the outbox (TH15)" src/lib/store/stores/townHall.ts tests/unit/townHallStore.spec.ts

Task 16: Writing posts in the Town Hall

Files:

  • Create: src/app/Console/MyPortal/HomeTown/TownHall/components/TownHallCompose.vue, TownHallWaiting.vue
  • Modify: TownHall.ts, TownHall.vue, components/TownHallNews.vue, components/TownHallBoard.vue, src/lib/services/hometown/hometownPrompts.ts
  • Test: tests/unit/TownHallCompose.spec.ts, tests/unit/HometownPrompts.spec.ts

Interfaces:

  • Consumes: Task 15

  • Produces:

    • TownHallCompose (shown in an ion-modal): props { kind: TownHallPostKind; board?: TownHallBoardSlug; postId?: number; replyingTo?: string; initial?: PostDraft; boards: BoardInfo[] }; fields title (topic and bug), board picker (topic), message with a counter {n} / {max}; the Send button (data-cy="th-send") at the bottom, disabled while draftProblem is not null or while sending; the problem text shown under the message; emits send(draft: PostDraft) with trimmed text
    • TownHallWaiting: the hero's waitingFor() posts at the top of the Board tab, status chips Waiting for your Game Master, Not approved, Waiting to send, Couldn't send: {error}; Dismiss on rejected and failed; Edit on failed (opens the compose with initial)
    • prompts: postedNotice(outcome: "to-gm" | "to-site" | "bug" | "on-file-hero" | "on-file-gm") with lines: to-gm *pins it to the desk* Your Game Master will read it first.; to-site *stamps it* Sent! It shows up once it's approved.; bug *files it carefully* Thanks! The developers will see it.; on-file-hero *files it* I'll keep it until your Game Master registers the family.; on-file-gm *files it* I'll send it once your family is registered.; registerAgainPrompt() The Town Hall no longer recognizes your family. Your Game Master needs to register again.; forbiddenPrompt(isGameMaster: boolean) (Game Master: Your registration isn't finished. Shall we redo it? with redo / never; hero: Your family's registration isn't finished. Ask your Game Master.)
    • wiring: the clerk's board and bug open the compose; News and topic cards get Comment / Reply (data-cy="th-comment"); after a send, flush(), then registerAgainPrompt if unlinked, forbiddenPrompt if forbidden; window online listener calling flush() while the page is mounted, removed on unmount; the page also flushes after init and right after completeLink returns linked
  • Step 1: Write the failing tests: TownHallCompose with an empty message has Send disabled and shows 0 / 2000; a typed message enables it; Send emits { kind: "topic", board: "feature-requests", title: "More dragons", body: "Please" } from padded input; a bug compose shows 0 / 4000 and no board picker. Prompt ids and copy.

  • Step 2: Run both specs. Expected: FAIL.

  • Step 3: Implement the components and the wiring.

  • Step 4: Run both specs (--no-cache once for the page). Expected: PASS.

  • Step 5: Commit after chemx verify: commit_paths "feat(town-hall): write topics, comments and bug reports; the hero's waiting list (TH16)" <the files above>

Task 17: The Game Master's Town Hall approvals

Files:

  • Create: src/app/Admin/XpApprovals/components/XpTownHallApprovals.vue
  • Modify: src/app/Admin/XpApprovals/XpApprovals.vue (one segment button and one v-if block)
  • Test: tests/unit/XpTownHallApprovals.spec.ts

Interfaces:

  • Consumes: Task 15

  • Produces: segment value town-hall labelled "Town Hall" with a badge of awaiting-gm posts; the component lists each with hero name, kind, board or "Comment on …", title and plain-text message; Approve (data-cy="th-approve") → approve(id) then flush(); Reject (data-cy="th-reject") → reject(id)

  • Step 1: Write the failing test with a real Pinia store and mocked database: two awaiting-gm posts render two rows; Approve on the first calls approve with its id and then flush; Reject on the second calls reject.

  • Step 2: Run chemx test tests/unit/XpTownHallApprovals.spec.ts. Expected: FAIL.

  • Step 3: Implement the component and the segment.

  • Step 4: Run the spec. Expected: PASS.

  • Step 5: Commit after chemx verify: commit_paths "feat(town-hall): Game Master approvals for heroes' posts (TH17)" src/app/Admin/XpApprovals/components/XpTownHallApprovals.vue src/app/Admin/XpApprovals/XpApprovals.vue tests/unit/XpTownHallApprovals.spec.ts

Task 18: Posting end to end, then rehearsal 2

Files:

  • Test: tests/e2e/specs/town-hall.spec.js

  • Step 1: Write the failing Cypress cases (link gm-1 through the return query as in Task 11, then win.__xp.loginUser("hero-1")):

    • Max posts a topic on Feature Requests: the clerk says "Your Game Master will read it first", and no POST **/wp/v2/cafeteria_topic happens
    • gm-1 approves it on /game-master/approvals (Town Hall segment): one POST **/wp/v2/cafeteria_topic whose body has status: "pending", cafeteria_board: [<feature-requests id>], a content ending with — Max, from Do It For The XP, and an Authorization header starting with Basic
    • Max reports a bug: one POST **/wp/v2/compass_bug at once, with bug_plugin: "do-it-for-the-xp"
    • a POST answered 401: the clerk says the family must register again, and Family Registration offers "I have an account"
  • Step 2: Run the Cypress spec. Expected: FAIL first, PASS once any wiring gaps found here are fixed in their tasks.

  • Step 3: Rehearsal 2 on the network from Task 7 (override set as in Task 12): link famparent, post a topic as a hero, approve it as the Game Master, post a comment and a bug. Expected (wpcli service, --url=http://localhost:8090/doit): wp post list --post_type=cafeteria_topic --post_status=pending --field=post_title lists the topic; wp post list --post_type=compass_bug --post_status=pending --field=post_title lists the bug; wp comment list --status=hold --field=comment_content shows the signed comment.

  • Step 4: Commit after chemx verify: commit_paths "test(town-hall): posting end to end (TH18)" tests/e2e/specs/town-hall.spec.js

Phase 3 owner steps: confirm "Comment must be manually approved" is on (Settings → Discussion on doit.forthexp.com); activate Bug Net on doit.forthexp.com after checking which GitHub repo it copies bugs to (private repo, or the GitHub copy off on this site); keep bugs Pending. Then the live check: post one test topic and one bug from the game, approve the topic, delete both.


Phase 4: The phone apps

Task 19: Returning to the phone app

Files:

  • Modify: package.json (add @capacitor/browser at the @capacitor/core major, 6), ios/App/App/Info.plist (CFBundleURLTypes with scheme com.forthexp.doit), android/app/src/main/AndroidManifest.xml (a VIEW / DEFAULT / BROWSABLE intent filter for scheme com.forthexp.doit, host town-hall), src/lib/services/townhall/townHallLink.ts, TownHall.ts, src/main.ts, src/lib/services/hometown/hometownPrompts.ts (remove phoneAppRegistrationNotice)
  • Create: src/lib/services/townhall/nativeReturn.ts
  • Test: tests/unit/townHallLink.spec.ts, tests/unit/nativeReturn.spec.ts

Interfaces:

  • Produces:

    • NATIVE_RETURN_URL = "com.forthexp.doit://town-hall" and searchOf(url: string): string in townHallLink.ts
    • listenForTownHallReturn(onReturn: (search: string) => void): Promise<() => void> in nativeReturn.ts: an App.addListener("appUrlOpen") that ignores other URLs, closes the in-app browser (Browser.close(), errors ignored) and passes the query on
    • main.ts (native only) registers it once: completeLink(search) on the store, which sets lastLinkResult; the Town Hall page watches lastLinkResult and shows linkResultNotice
    • have on native opens Browser.open({ url: authorizeUrl({ ..., returnUrl: NATIVE_RETURN_URL }) })
  • Step 1: Write the failing tests: searchOf("com.forthexp.doit://town-hall?site_url=x&user_login=mom&password=a+b") returns a search that readLinkReturn reads as linked with password a b; the listener (with @capacitor/app and @capacitor/browser mocked) calls onReturn for a com.forthexp.doit://town-hall?... URL, not for https://example.com, and calls Browser.close.

  • Step 2: Run both specs. Expected: FAIL.

  • Step 3: Implement, install with pnpm add @capacitor/browser@^6, then npx cap sync.

  • Step 4: Run both specs, then chemx verify --build. Expected: PASS.

  • Step 5: Commit: commit_paths "feat(town-hall): family registration from the phone apps (TH19)" <the files above, plus pnpm-lock.yaml>

Phase 4 owner steps: on an Android and an iOS device, Family Registration → I have an account → Approve returns to the game with the greeting.

⚠️ **GitHub.com Fallback** ⚠️