2026 10 13 town hall wp bridge - xopherdeep/do-it-for-the-xp GitHub Wiki
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
-
Gates and reads go through
chemx(chemx test <spec>,chemx typecheck,chemx lint,chemx verify); never rawpnpm test,pnpm jest,pnpm typecheckorpnpm lint. Runchemx verifybefore every commit in this repo. -
After editing a page's
.tswhose.vueuses<script src="./X.ts">, run its spec withnpx jest <spec> --no-cacheonce beforechemx verify(jest caches the.vuetransform). -
One dev server: if
curl -s localhost:8100answers, 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 chromeis required). -
Commits in this repo use a private index (other sessions keep staged work in the shared index). Never
git add,git stashor plaingit commithere. Define once per shell and use ascommit_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
docssubmodule commits withgit -C docs commit -- <paths>(add a new file withgit -C docs add -- <path>first). The plugin repo (../Xophz-COMPASS/wp-content/plugins/xophz-compass-xp, its own git repo) commits the same way withgit -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 envVUE_APP_TOWN_HALL_SITE; development overridelocalStorage["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 slugpatch-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 toBACKUP_STORES - new site members get the role
contributor; plugin filterxp_town_hall_site_urls
-
-
UI rules: new components are
<script setup lang="ts">; pages aredefineComponentwith sibling.tsand_X.scss(<script lang="ts" src="./X.ts" />). The clerk speaks throughXpRevealPromptwith lines fromsrc/lib/services/hometown/hometownPrompts.ts(speaker: CLERK,accent: "townHall",sprite: "tablet", the*action* linevoice); 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 throughplay$fx(key)with keys exported bysrc/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.
- Two sends at once (the Town Hall opening while the browser's
onlineevent fires, or a double tap on Send): each post must reach the site once. Pinned in Task 16. - The site returns the password with spaces encoded as
+, and logins like[email protected]orjosé: linking must work and the Basic header must be UTF-8 base64 (btoaalone throws oné). Pinned in Tasks 2 and 10. - 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.
- 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.
- Bug Net is not active on the site yet (
POST /wp/v2/compass_buganswers 404): the bug stays queued for a retry; it is not marked failed. Pinned in Task 15.
- 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/XpApprovalsExpected: 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:8100prints200(the shared dev server is up). If not, ask the owner before runningpnpm serve.
Files:
- Create:
src/lib/services/townhall/townHallSite.ts - Modify:
vue.config.js(top of file) - Test:
tests/unit/townHallSite.spec.ts
Interfaces:
-
Consumes:
WpApiSettingsfromsrc/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 | nullconnectionName(userAgent: string, platform: string): string-
appVersion(): string→process.env.VUE_APP_VERSION || "dev"
- constants
-
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/orCriOS), Safari, elseBrowser. Device order: Android, iPhone, iPad, Windows, Mac, Linux, elsethis device. Platformsiosandandroidmap toiPhone appandAndroid 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
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 }>—pathis 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 viaTextEncoderandbtoaover the bytes. Abort with anAbortControlleraftertimeoutMs(defaultTOWN_HALL_TIMEOUT_MS); clear the timer infinally. A body that is not JSON reads asnull. - 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
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=pluscategories_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 toTOWN_HALL_BOARDSin 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
fetchImplthat answers by URL:-
fetchBoardsgiven boardsnoo,suggestion-box,feature-requests,comments-feedback(noideas-suggestions) returns slugs["feature-requests", "comments-feedback", "suggestion-box"]. -
fetchNews(1)whencategories?slug=patch-notesreturns[{ id: 7 }]requests a URL containingcategories_exclude=7; when it returns[], the posts URL has nocategories_exclude. -
fetchNewsmapstitle.rendered,excerpt.rendered,_embedded["wp:featuredmedia"][0].source_urltoimageUrl,_embedded["wp:term"][0][].nametocategories, and readstotalPagesfromX-WP-TotalPages(1 when absent). -
fetchPatchNoteswith no category returns[]and makes exactly one request. -
fetchTopicsmaps_embedded.author[0].nametoauthor("Unknown"when absent). -
fetchComments(12)mapsauthor_nameandcontent.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
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., choicesretry"Try again" (redo) andnever"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)hasspeaker: CLERK,accent: "townHall",cancelId: "never";courierLatePrompt().choices.map(c => c.id)equals["retry", "never"]; the oldannounceid 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.vueimports the old names.
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 | updatesin anion-segmentinside anion-footer(data-cy="th-tab-<id>") - each tab component exposes
reload(): Promise<void>and emitstroublewhen aTownHallErrorwith codeofflineorserverstops it loading; the page answerstroublewithcourierLatePrompt()and callsreload()onretry -
TownHallBoardprops{ boardSlug: TownHallBoardSlug }; the board picker (data-cy="th-board-<slug>") sits in the footer above the tabs while the Board tab is active, built fromfetchBoards()(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()), thencomingSoonPrompt(CLERK_ERRANDS[pick])(posting and registration arrive in Phases 2–3)
- tab ids
-
Step 1: Write the fixture. Copy
tests/e2e/fixtures/battle-backup.jsonand add a second profilegm-1(nickDad,isAdult: true, no passcode) next tohero-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 titled0.3.0;**/wp/v2/cafeteria_board*→ the four boards plusnoo;**/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 agetBoundingClientRect().topgreater 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.0and the stats card - as
hero-1, the clerk menu has no "Family Registration"; asgm-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"
- the four tabs are visible at the bottom:
-
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.tsunchanged and hand the results toTownHallEventsandTownHallUpdatesas props. Render site HTML only throughsanitizeHtml. With no patch notes, Updates showsNo patch notes yetabove the stats card. Remove the hardcodedwpBaseUrl, theWordPressPosttypes and the fake2.1.0update. -
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>
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/chatand "Family Chat" is visible; the close button returns to/town-hall/hero-1. -
Step 2: Run
chemx test tests/unit/parentOf.spec.tsand the Cypress spec. Expected: FAIL. -
Step 3: Add the route and the rule.
XpChathas 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.
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 athttp://localhost:8090/doit/, wherexophz-compass,xophz-compass-xp,xophz-compass-bulletin-boardandxophz-compass-bugnetare active; network registration set to user accounts; categorypatch-noteswith one note; one news post; an mu-plugin addinghttp://localhost:8090/doittoxp_town_hall_site_urls;WP_ENVIRONMENT_TYPElocal. Credentials for the network admin and a parent accountfamparent(a network user who is not a member of/doit/) are written totools/townhall-rehearsal/.rehearsal-credentials. -
Step 1: Check the port:
lsof -ti:8090prints nothing. -
Step 2: Write the compose file: services
db(mariadb 11) andwordpress(officialwordpressimage, PHP 8.2, Apache, port8090:80) plus awpcliservice (officialwordpress: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.shwithwp 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), andwp user remove-role famparentso 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 -uExpected: 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
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_dependenciesanddefine_compass_hooks),$P/tests/run-tests.mjs - Test:
$P/tests/town-hall-membership.test.mjs
Interfaces:
-
Produces:
-
class Xophz_Compass_Xp_Town_Hallwithconst APP_ID = '7606ce60-29c7-43b6-9533-2063ee726df4'andconst 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'sdefine_class_hooksformat) -
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 ): voidandpublic function on_authenticated( WP_User $user, array $item ): void, both calling a privatemaybe_join( int $user_id, array $item )that passesis_multisite(),$item['app_id'],home_url(),apply_filters( 'xp_town_hall_site_urls', self::DEFAULT_SITE_URLS )andis_user_member_of_blog( $user_id, get_current_blog_id() )toshould_join, thenadd_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_onceof 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.mjsfrom../Xophz-COMPASS(the My Compassu-wordpresscontainer 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
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): stringauthorizeUrl(opts: { site: string; appName: string; returnUrl: string }): stringreadLinkReturn(search: string): LinkReturnstrippedUrl(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.tswithURLandURLSearchParams; the user id isencodeURIComponent-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
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(asProDbuses it) -
Produces:
-
townHallTypes.ts:TownHallLinkandTownHallPostexactly as in spec section 7, plustype TownHallPostKind = TownHallPost["kind"]andtype PostStatus = TownHallPost["status"] -
TownHallDb(Ionic Storage name__xp_town_hall, keyslinkandposts):getLink(): Promise<TownHallLink | null>,saveLink(link: TownHallLink),clearLink(),getPosts(): Promise<TownHallPost[]>,savePosts(posts: TownHallPost[]);getTownHallDb(): TownHallDb - store
useTownHallStore(setup style, likepro.ts): statelink,posts,lastLinkResult; computedauth: TownHallAuth(link → basic, else host nonce → nonce, else none),isLinked(link or nonce),signedInThroughWebsite(nonce and no link); actionsinit(): 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/TownHallDbandtownHallFetch(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 hasuserLogin,appPassword,displayName: "Mom",appPasswordUuid: "u-1";authis{ kind: "basic", ... }; both requests used Basic auth with the new credentials -
users/merejects withunauthorized: returns"failed"; nothing saved - introspect fails but
users/mesucceeds: still"linked",appPasswordUuidundefined -
?success=false→"rejected", no request, nothing saved -
unlinkwith a uuid andDELETE wp/v2/users/me/application-passwords/u-1succeeding →"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:authis{ kind: "nonce", nonce: "n1" },isLinkedandsignedInThroughWebsiteare true -
BACKUP_STORES(src/lib/services/core/BackupService.ts) has no entry named__xp_town_hall
- a linked query,
-
Step 2: Run
chemx test tests/unit/townHallStore.spec.ts. Expected: FAIL. -
Step 3: Implement the types, the database and the store.
completeLinknever reads or writeswindow.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
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.andYour display name on the site is public, so pick one that isn't your full name.; withviaWebsite, firstYou're signed in through the website. Registering lets your family post from any device.; choiceshave"I have an account" (sign-in-alt),create"Create one" (user-plus),never -
linkedStatusPrompt(displayName: string): RevealPromptOptions—Your family is registered as ${displayName}.; choicesunlink"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, asgm-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/#aspro.spec.jsdoes); expect "Welcome, Mom!", the URL no longer containspassword, and Family Registration now offers "Unlink this device". Rejection:?success=falseshows "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(); readwindow.location.search; ifreadLinkReturnis notnone, callhistory.replaceState(history.state, "", strippedUrl(location))before anyawait, thencompleteLinkwith the captured search and showlinkResultNotice. Family Registration (Game Master only): linked →linkedStatusPrompt→unlink→unlinkResultNotice; not linked →registrationPrompt(townHall.signedInThroughWebsite)→createopensSIGNUP_URLin a new tab;haveon the web assignswindow.locationtoauthorizeUrl({ site: townHallSiteUrl(), appName: connectionName(navigator.userAgent, Capacitor.getPlatform()), returnUrl: townHallReturnUrl(userId, location, usesHashHistory()) });havein a native app showsphoneAppRegistrationNotice(). -
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>
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, runlocalStorage.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.phpshow the Approve screen tofamparent, 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, changeauthorizeUrl'ssiteto the network's main site (http://localhost:8090, livehttps://www.forthexp.com) in Task 11's wiring, re-run Tasks 9–11's tests, and rely onon_authenticatedto 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_loginprintsfamparent. - Step 5: Unlink. Expected:
wp user application-password list famparent --format=countprints0(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.
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 linesApp: …,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 givesKeep the title under 100 characters. - the problem order above, one case each
- a topic post for board
feature-requestswithboardIds = { "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 titledBug report from Max -
requestForthrows 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
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— statusqueuedfor a bug or a Game Master, elseawaiting-gm -
approvePost(post),rejectPost(post)— act only onawaiting-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 yetsent, newest first -
editPost(post, draft: PostDraft, isGameMaster: boolean): TownHallPost— afailedpost with new text goes back toqueued(Game Master) orawaiting-gm(hero); error cleared
-
Step 1: Write the failing tests: one per rule:
-
newPoststatuses for (hero, topic)awaiting-gm, (hero, bug)queued, (GM, comment)queued - approve and reject from
awaiting-gm; both leave aqueuedpost unchanged -
settleok →sentwithremoteId,errorundefined -
offlineandserver→queuedwitherrorset to the code,unlink: false - a 404 from an inactive Bug Net arrives as
serverand keeps the bugqueued(Review Focus 5) -
unauthorized→queued,unlink: true -
forbidden→queued,error: "forbidden" -
invalidwith messageTitle is required→failed,error: "Title is required" -
recoverturnssendingintoqueued(Review Focus 4) -
sendableorder -
waitingForfilters by hero and excludessent -
editPoston 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
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 fromappVersion(),Capacitor.getPlatform(),navigator.userAgent,Date.now(); ids fromuuidv4 -
approve(id: string),reject(id: string),dismiss(id: string)(removes arejected,failedorsentpost),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 fromfetchBoards()once per flush that has topics; persists after each post -
init()now also runsrecover()on the loaded posts
-
-
Step 1: Write the failing tests:
- a hero's topic is
awaiting-gmand no request is made -
approvethenflushPOSTs towp/v2/cafeteria_topicwith Basic auth andcafeteria_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 returnsunlinked: true - a 403 returns
forbidden: true - not linked:
flushmakes no request -
initturns a storedsendingpost back intoqueued -
submitreturns thedraftProblemtext and stores nothing for an empty body
- a hero's topic is
-
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
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 anion-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 whiledraftProblemis not null or while sending; the problem text shown under the message; emitssend(draft: PostDraft)with trimmed text -
TownHallWaiting: the hero'swaitingFor()posts at the top of the Board tab, status chipsWaiting for your Game Master,Not approved,Waiting to send,Couldn't send: {error}; Dismiss onrejectedandfailed; Edit onfailed(opens the compose withinitial) - 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?withredo/never; hero:Your family's registration isn't finished. Ask your Game Master.) - wiring: the clerk's
boardandbugopen the compose; News and topic cards get Comment / Reply (data-cy="th-comment"); after a send,flush(), thenregisterAgainPromptifunlinked,forbiddenPromptifforbidden;windowonlinelistener callingflush()while the page is mounted, removed on unmount; the page also flushes afterinitand right aftercompleteLinkreturnslinked
-
-
Step 1: Write the failing tests:
TownHallComposewith an empty message has Send disabled and shows0 / 2000; a typed message enables it; Send emits{ kind: "topic", board: "feature-requests", title: "More dragons", body: "Please" }from padded input; a bug compose shows0 / 4000and 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-cacheonce 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>
Files:
- Create:
src/app/Admin/XpApprovals/components/XpTownHallApprovals.vue - Modify:
src/app/Admin/XpApprovals/XpApprovals.vue(one segment button and onev-ifblock) - Test:
tests/unit/XpTownHallApprovals.spec.ts
Interfaces:
-
Consumes: Task 15
-
Produces: segment value
town-halllabelled "Town Hall" with a badge ofawaiting-gmposts; the component lists each with hero name, kind, board or "Comment on …", title and plain-text message; Approve (data-cy="th-approve") →approve(id)thenflush(); Reject (data-cy="th-reject") →reject(id) -
Step 1: Write the failing test with a real Pinia store and mocked database: two
awaiting-gmposts render two rows; Approve on the first callsapprovewith its id and thenflush; Reject on the second callsreject. -
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
Files:
-
Test:
tests/e2e/specs/town-hall.spec.js -
Step 1: Write the failing Cypress cases (link
gm-1through the return query as in Task 11, thenwin.__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_topichappens -
gm-1approves it on/game-master/approvals(Town Hall segment): onePOST **/wp/v2/cafeteria_topicwhose body hasstatus: "pending",cafeteria_board: [<feature-requests id>], acontentending with— Max, from Do It For The XP, and anAuthorizationheader starting withBasic - Max reports a bug: one
POST **/wp/v2/compass_bugat once, withbug_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"
- Max posts a topic on Feature Requests: the clerk says "Your Game Master will read it first", and no
-
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 (wpcliservice,--url=http://localhost:8090/doit):wp post list --post_type=cafeteria_topic --post_status=pending --field=post_titlelists the topic;wp post list --post_type=compass_bug --post_status=pending --field=post_titlelists the bug;wp comment list --status=hold --field=comment_contentshows 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.
Files:
- Modify:
package.json(add@capacitor/browserat the@capacitor/coremajor, 6),ios/App/App/Info.plist(CFBundleURLTypeswith schemecom.forthexp.doit),android/app/src/main/AndroidManifest.xml(aVIEW/DEFAULT/BROWSABLEintent filter for schemecom.forthexp.doit, hosttown-hall),src/lib/services/townhall/townHallLink.ts,TownHall.ts,src/main.ts,src/lib/services/hometown/hometownPrompts.ts(removephoneAppRegistrationNotice) - 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"andsearchOf(url: string): stringintownHallLink.ts -
listenForTownHallReturn(onReturn: (search: string) => void): Promise<() => void>innativeReturn.ts: anApp.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 setslastLinkResult; the Town Hall page watcheslastLinkResultand showslinkResultNotice -
haveon native opensBrowser.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 thatreadLinkReturnreads aslinkedwith passworda b; the listener (with@capacitor/appand@capacitor/browsermocked) callsonReturnfor acom.forthexp.doit://town-hall?...URL, not forhttps://example.com, and callsBrowser.close. -
Step 2: Run both specs. Expected: FAIL.
-
Step 3: Implement, install with
pnpm add @capacitor/browser@^6, thennpx 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.