2026 10 22 family sync - 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: Every family device (Game Master phones and tablets, kid phones) plays the whole game on one shared save held by the WordPress site, with a hero playing on one device at a time, behind XP Pro.
Architecture: The site (xophz-compass-xp, xp-family/v1) keeps the family's save as keyed records with a per-family change number, and a per-hero play turn that makes one device the only writer of that hero's records. The app captures every store write through DbStorageApi, uploads changed records from an outbox, downloads records after its last number and applies them into the same Ionic stores the game reads. Claims and verdicts stay events (Part 1); the device holding a hero's turn applies verdicts and pays.
Tech Stack: PHP 8 (WordPress REST, wpdb, dbDelta, Gatekeeper), TypeScript, Vue 3 + Ionic 7 + Pinia, @ionic/storage, jest, Cypress 8, the plugin's Node test harness (docker exec u-wordpress php).
Spec: docs/superpowers/specs/2026-10-22-family-sync-design.md (docs commit 17ece7e). Part 1's spec and src/lib/services/multiplayer/FAMILY_EVENTS.md are its background.
- Chemical X only:
chemx typecheck,chemx lint,chemx test tests/unit/Foo.spec.ts,chemx q,chemx read. Never rawpnpm test,pnpm jest,pnpm typecheck,pnpm lint, and never the whole suite locally; the PR's Tests check is the gate (CLAUDE.md). - The app checkout is shared: work on branch
feat/family-syncin a git worktree ofdo-it-for-the-xp(never switch the shared checkout); lock files withchemx team lockbefore editing them; commit only your own paths (git commit -- <paths>), nevergit add -Aorgit stash. Open the PR with auto-merge (gh pr merge <n> --auto --squash). Task 1 alone goes straight tomasteras a path-scoped commit. - The plugin lives in a separate repo:
/home/xopher/www/x/Xophz-COMPASS/wp-content/plugins/xophz-compass-xp(branchmain); work on branchfeat/family-syncthere, in a worktree too. Its tests run withnode tests/run-tests.mjsfrom the plugin folder and need the local Docker WordPress (u-wordpress) up. The release to live is the owner's (GitHub release, COMPASS updater): never touch the live site. - Copy: every line a person reads is a clerk line through the reveal prompt (
useRevealPrompt().showPrompt), never an Ionic alert or a native alert; toasts only withrpgToastOptions. Adventure voice insrc/lib/services/copy/catalog/, other voices fall back. No em dashes anywhere (the audit and the plugin tests both refuse them). Exact lines are in spec section 5. - Code rules the audit ratchet enforces: a compound boolean gets a name before it is used (
const isHeld = ...), at most 5 directuse*()calls per function, no new hooks indefineComponentpages beyond that, pure rules insrc/lib/services/**with a jest spec beside the feature intests/unit/. - Phone first: Cypress runs at 390x844; controls stay at the bottom of the screen; the bottom nav bars are never changed.
-
multiplayerEnabled()(src/lib/services/multiplayer/multiplayerFlag.ts) stays the master switch; with it off nothing here runs. - Record limits, verbatim from the spec: 50 records and 1 MB per put; 512 KB per record body;
GET /recordslimit 100 by default, 200 at most; 5 pages per download tick; uploads 5 s after the last change.
- A record whose body is over 512 KB (a profile with a large face image): the server refuses it with
xp_family_record_invalidand the device drops the key instead of resending it forever (Task 3, Task 12). - Pro lapses while a kid phone is in the full portal: the next site call answers 403
xp_family_pro_required, the sync store turns itself off, and the next route change lands on the board withsync.proRequired(Task 14, Task 22). - Two Game Master devices settle the same claim within a minute: the second one finds the entry already settled, or the turn busy, and nothing is paid twice (Task 18).
- A tombstone for the signed-in hero's own profile arrives (the hero was deleted on another device): the profile is removed, the user store reloads and signs out instead of crashing on a missing profile (Task 13, Task 14).
- A kid phone's first open with Pro while offline: nothing can be pulled, so the phone shows the board with
sync.offlineand tries again ononline; the portal opens only oncefirstPullDone(Task 20).
Plugin (xophz-compass-xp)
| File | Responsibility |
|---|---|
includes/family/class-xophz-compass-xp-family-schema.php (modify) |
xp_family_records, records_seq on families, the turn columns on heroes, schema VERSION 2, tombstone cleanup |
includes/family/class-xophz-compass-xp-family-rules.php (modify) |
POLICY: the new actions per caller |
includes/family/class-xophz-compass-xp-family-record-rules.php (create) |
Pure: the synced stores with scope and hero-id rule, record validation, who may write and read a record |
includes/family/class-xophz-compass-xp-family-event-rules.php (modify) |
synced on quest.claimed
|
includes/family/class-xophz-compass-xp-family-store.php (modify) |
upsert_records under the family lock, records_after, take_turn, rest_turn, turns cleared on revoke/remove |
includes/family/class-xophz-compass-xp-family-pro.php (create) |
entitled( $family_id, $now ) through Gatekeeper, with the xp_family_pro_entitled filter |
includes/family/class-xophz-compass-xp-family-records.php (create) |
put, read, play, rest: the handlers' logic over the store, as Family_Events is for events |
includes/family/class-xophz-compass-xp-family-events.php (modify) |
The home rule relaxed for entitled families |
includes/family/class-xophz-compass-xp-family-rest.php (modify) |
The four routes |
includes/class-xophz-compass-xp.php (modify) |
require_once the three new files |
tests/family-records.test.mjs, tests/family-turn.test.mjs, tests/family-pro.test.mjs (create); tests/family-schema.test.mjs, tests/family-event-rules.test.mjs, tests/run-tests.mjs (modify) |
Tests |
tests/rehearsal/family-smoke.sh (modify) |
A records section |
App (do-it-for-the-xp)
| File | Responsibility |
|---|---|
src/lib/services/sync/recordSignals.ts (create) |
recordWritten and storeRestored signals |
src/lib/databases/DbStorageApi.ts (modify) |
Emits recordWritten on set, remove, clear |
src/lib/services/core/BackupService.ts (modify) |
Emits storeRestored per store with the keys a restore removed |
src/lib/services/sync/syncScopes.ts (create) |
The synced stores, scopes, hero id per record, what a phone may write |
src/lib/databases/SyncDb.ts (create) |
__xp_sync state |
src/lib/services/sync/familySyncClient.ts (create) |
The four routes through townHallFetch
|
src/lib/services/multiplayer/familyServer.ts (modify) |
pro-required in FamilyProblem
|
src/lib/services/sync/recordOutbox.ts (create) |
The queue of changed keys, batching, settling a put |
src/lib/services/sync/recordApply.ts (create) |
Writing received records into the raw stores, mirrors |
src/lib/services/sync/heroTurn.ts (create) |
The turn state machine |
src/lib/services/sync/syncPrompts.ts (create) |
The clerk prompts (busy, took over, first pull, Pro required, offline) and agoOf
|
src/lib/services/copy/catalog/sync.ts (create), catalog/index.ts (modify) |
The lines |
src/lib/services/sync/verdictApply.ts (create) |
Applying a claim.settled once |
src/lib/store/stores/sync.ts (create) |
useSyncStore: on/off, upload, download, first push and pull, turns, verdicts, the device label |
src/lib/store/stores/family.ts (modify) |
canQueue and the home question for Pro families; phone claims taken by the turn holder; loadPhones on the tick |
src/lib/services/quests/approvalFlow.ts (modify) |
The remote verdict branch |
src/lib/services/multiplayer/phoneClaimIntake.ts, familyEvents.ts (modify) |
synced on claims; synced claims skipped |
src/lib/services/notifications/questWatch.ts (modify) |
Clocks only for held heroes |
src/router/_Guards/heroPhone.guard.ts, index.ts (modify) |
The guard lifts for a full portal |
src/app/SideMenu/HeroPhone/HeroPhone.ts/.vue, components/HeroPhoneBoard.vue (modify) |
First pull, Pro footer, the portal hand-off |
src/app/SideMenu/SwitchProfile/SwitchProfile.vue, src/app/SideMenu/XpSettings/components/XpSettingsHome/settingsSections.ts, src/app/SideMenu/XpSettings/components/XpFamilyPhones.vue (modify) |
Kid mode in the full portal |
src/app/Admin/XpApprovals/XpApprovals.vue (modify) |
sync.verdictSent |
src/app/SideMenu/XpSettings/components/XpHeroPhonesModal.vue (modify) |
Task 1's poll |
tests/unit/*.spec.ts, tests/e2e/specs/family-sync.spec.js, tests/e2e/fixtures/family/*.json
|
Tests |
Files:
- Modify:
src/lib/store/stores/family.ts(thelisten()interval, around line 790) - Modify:
src/app/SideMenu/XpSettings/components/XpHeroPhonesModal.vue(createCode,onMounted,onUnmounted) - Test:
tests/unit/familyStore.spec.ts,tests/unit/XpHeroPhonesModal.spec.ts
Interfaces:
-
Consumes:
useFamilyStore().loadPhones(),queueSheets(),download()(existing). -
Produces: nothing new.
-
Step 1: Write the failing tests
In familyStore.spec.ts: it("the periodic tick reads the phone list before the sheet pass"): advance fake timers by 120 000 ms after init() on a bound home device; assert server.listPhones was called once more than before and that queueSheets ran after it (spy order).
In XpHeroPhonesModal.spec.ts: it("polls the phone list every 10 s while a code is live and runs the sheet pass when a phone appears"): mount with a mocked store whose phones grows from 0 to 1 on the second loadPhones; advance 10 000 ms twice; assert loadPhones called 3 times (mount + 2 polls) and queueSheets once.
- Step 2: Run them to see them fail
Run: chemx test tests/unit/familyStore.spec.ts -t "periodic tick" and chemx test tests/unit/XpHeroPhonesModal.spec.ts -t "polls the phone list"
Expected: FAIL (listPhones not called on the tick; no polling).
- Step 3: Implement
In listen(): the interval becomes download().then(() => loadPhones()).then(() => queueSheets()). In the modal: const PHONES_POLL_MS = 10_000; createCode starts phonesTimer = setInterval(pollPhones, PHONES_POLL_MS) where pollPhones compares family.phones.length before and after family.loadPhones() and calls family.queueSheets() when it grew; onUnmounted clears it.
- Step 4: Run the two specs, then
chemx lint
Expected: PASS, no lint errors.
- Step 5: Commit to master (path-scoped)
git commit -m "fix(multiplayer): the family device re-reads its phone list on the tick and while a link code is live, so a new phone gets its sheet" -- src/lib/store/stores/family.ts src/app/SideMenu/XpSettings/components/XpHeroPhonesModal.vue tests/unit/familyStore.spec.ts tests/unit/XpHeroPhonesModal.spec.tsFiles:
- Modify:
includes/family/class-xophz-compass-xp-family-schema.php(VERSION,tables(),cleanup()) - Test:
tests/family-schema.test.mjs
Interfaces:
-
Produces: table
{prefix}xp_family_recordswith the columns of spec section 2 (seq bigint unsigned NOT NULL AUTO_INCREMENTis not used:seq bigint unsigned NOT NULLset by the store), unique keyfamily_store_key (family_id, store, record_key), keysfamily_seq (family_id, seq)andfamily_hero_seq (family_id, hero_id, seq);records_seq bigint unsigned NOT NULL DEFAULT 0onxp_families;turn_device_id varchar(16) NULL,turn_label varchar(60) NOT NULL DEFAULT '',turn_taken_at datetime NULL,turn_seen_at datetime NULLonxp_family_heroes.VERSION = '2'.const TOMBSTONE_DAYS = 90. -
Step 1: Write the failing tests in
family-schema.test.mjs:it("version 2 declares six tables, records with its three keys")(count theCREATE TABLEstatements fromtables(), assert the records statement containsUNIQUE KEY family_store_key,KEY family_seq,KEY family_hero_seq,body mediumtext);it("families carry records_seq and heroes the four turn columns");it("cleanup deletes tombstones older than 90 days")(the fake wpdb records aDELETE FROM wp_5_xp_family_records WHERE deleted = 1 AND saved_at < %squery). -
Step 2: Run
node tests/run-tests.mjsfrom the plugin folder. Expected: the three new tests FAIL. -
Step 3: Implement the statements in
tables()(dbDelta form: one column per line, two spaces beforeKEY), bumpVERSION, add the tombstone delete tocleanup(). -
Step 4: Run the tests. Expected: PASS, and
lintPhpFileclean. -
Step 5: Commit
feat(family): records table, the records counter and the hero turn columns (schema 2).
Files:
- Create:
includes/family/class-xophz-compass-xp-family-record-rules.php - Modify:
includes/family/class-xophz-compass-xp-family-rules.php(POLICY),includes/family/class-xophz-compass-xp-family-event-rules.php(quest.claimedfields),includes/class-xophz-compass-xp.php(require) - Test:
tests/family-records.test.mjs(create),tests/family-event-rules.test.mjs,tests/family-rules.test.mjs
Interfaces:
-
Produces, class
Xophz_Compass_Xp_Family_Record_Rules(pure, no WordPress calls):-
const STORES= the spec's table: store name =>[ 'scope' => 'family'|'family-any'|'hero', 'hero_from' => 'key'|'key_prefix'|'userId'|'heroId'|null ]. const MAX_BODY_BYTES = 524288; MAX_BATCH = 50; MAX_BATCH_BYTES = 1048576; MAX_KEY = 191;-
public static function check( array $raw, array $caller_ctx ): array|stringreturns the clean record[ 'store', 'key', 'hero_id', 'deleted', 'body' ]or a refusal code (xp_family_store_unknown,xp_family_record_invalid,xp_family_scope).$caller_ctx=[ 'writer' => 'parent'|'hero', 'key_hero_id' => string|null ]. -
public static function hero_id_of( string $store, string $key, $body ): ?string. -
public static function may_read( array $row, array $caller_ctx ): bool.
-
-
POLICY gains
put_records,read_records,play,restforparentandhero. -
quest.claimedfields gain'synced' => 'bool'. -
Step 1: Write the failing tests.
family-records.test.mjs, suite "Record rules":it("knows the 17 stores and their scopes");it("derives the hero id per store")(profiles → key; templeProgressh-1:temple-x→h-1; questLog body{userId:"h-2"}→h-2; latePenalties body{heroId:"h-3"}→h-3; notifications → key; achievements → null);it("refuses an unknown store, a key over 191 chars, a body over 512 KB, a body that is not an object");it("a hero key may write only __chat and its own hero's records")(own profile accepted; another hero's questLog →xp_family_scope;__achievements→xp_family_scope;__chataccepted);it("a parent may write every store");it("may_read: a hero key reads family stores and its own hero's rows only"). Infamily-event-rules.test.mjs:it("quest.claimed keeps synced as a bool"). Infamily-rules.test.mjs:it("parent and hero may put_records, read_records, play and rest; none may not"). -
Step 2: Run the harness. Expected: the new tests FAIL (class missing).
-
Step 3: Implement the class and the two table edits; add the
require_oncein the loader before the store's. -
Step 4: Run the harness. Expected: PASS.
-
Step 5: Commit
feat(family): record rules (stores, scopes, hero id, size caps) and synced on quest.claimed.
Files:
- Modify:
includes/family/class-xophz-compass-xp-family-store.php - Test:
tests/family-records.test.mjs(suite "Store"),tests/family-turn.test.mjs(create)
Interfaces:
-
Produces, on
Xophz_Compass_Xp_Family_Store:-
public function upsert_records( string $family_id, string $device_id, array $records, int $now ): array|WP_Error→[ [ 'store', 'key', 'seq' ], ... ]in input order;WP_Error xp_family_busy(503) when the lock is not had. UnderGET_LOCK("xp_family_{id}"):SELECT records_seq FROM xp_families ... FOR UPDATE, number the recordscounter+1 ...,INSERT ... ON DUPLICATE KEY UPDATE seq=VALUES(seq), hero_id=VALUES(hero_id), device_id=VALUES(device_id), saved_at=VALUES(saved_at), deleted=VALUES(deleted), body=VALUES(body),UPDATE xp_families SET records_seq = counter+n. -
public function records_after( string $family_id, int $since, int $limit, ?string $hero_id = null ): array→ rows ordered byseq; with$hero_id, rows wherehero_id IS NULL OR hero_id = %s(the hero key's view). -
public function take_turn( string $family_id, string $hero_id, string $device_id, string $label, bool $force, int $now ): array→[ 'answer' => 'granted' ]or[ 'answer' => 'busy', 'device' => [ 'label', 'last_seen_at' ] ](ISO). -
public function rest_turn( string $family_id, string $hero_id, string $device_id ): void. -
public function touch_turn( string $family_id, string $hero_id, string $device_id, int $now ): void(refreshesturn_seen_atwhen this device holds it). -
public function turn_holder( string $family_id, string $hero_id ): ?string(device id). -
revoke_deviceandremove_heroalso clear a turn the device or hero held.
-
-
Step 1: Write the failing tests against the fake wpdb (the pattern of
family-two-phone-events.test.mjs's in-memory stand-in): "Store":it("numbers a batch from the family counter and writes it back");it("an existing key is overwritten and gets the new number");it("records_after returns rows after since in order, with a hero filter"); "Turn":it("granted when free, busy with the holder's label and last seen, granted with force, same device re-takes silently");it("rest from a non-holder changes nothing");it("revoking the holding device clears the turn"). -
Step 2: Run. Expected: FAIL.
-
Step 3: Implement the methods (the
append_eventslock pattern;db_time()/iso()helpers for dates). -
Step 4: Run. Expected: PASS.
-
Step 5: Commit
feat(family): the store writes records under the family lock and keeps each hero's turn.
Files:
- Create:
includes/family/class-xophz-compass-xp-family-pro.php - Modify:
includes/family/class-xophz-compass-xp-family-events.php(send(), the home check),includes/class-xophz-compass-xp.php(require) - Test:
tests/family-pro.test.mjs(create),tests/family-two-phone-events.test.mjs
Interfaces:
-
Produces:
Xophz_Compass_Xp_Family_Pro::entitled( string $family_id, int $now ): bool:apply_filters( 'xp_family_pro_entitled', $default, $family_id )where$defaultisclass_exists( 'Gatekeeper_Xp_Pro_Licenses' ) && ( $r = Gatekeeper_Xp_Pro_Licenses::get( $family_id ) ) && Gatekeeper_Xp_Pro_Licenses::is_entitled( $r, $now ); cached per request in a static array. -
Family_Events::sendskips the home check whenFamily_Pro::entitled( $family_id, $now ). -
Step 1: Write the failing tests.
family-pro.test.mjs:it("false without Gatekeeper, true with an active record, false past grace")(stub the Gatekeeper class in the snippet);it("the filter can force it on and off"). Infamily-two-phone-events.test.mjs:it("an entitled family may send from a device that is not home")(stubFamily_Pro::entitledtrue via the filter; a non-home parent batch is accepted). -
Step 2: Run. Expected: FAIL.
-
Step 3: Implement.
-
Step 4: Run. Expected: PASS.
-
Step 5: Commit
feat(family): the Pro gate through Gatekeeper, and every parent device may send for an entitled family.
Files:
- Create:
includes/family/class-xophz-compass-xp-family-records.php - Modify:
includes/family/class-xophz-compass-xp-family-rest.php(register_routes, four handlers),includes/class-xophz-compass-xp.php - Test:
tests/family-records.test.mjs(suite "Routes"),tests/family-turn.test.mjs
Interfaces:
-
Produces,
Xophz_Compass_Xp_Family_Records(static, over the store):-
put( $store, array $family, array $caller, string $device_id, $records, int $now ): array|WP_Error→[ 'accepted' => [...], 'refused' => [...] ]per spec; refuses the whole request withxp_family_pro_required(403) when not entitled,xp_family_record_invalid(400) when$recordsis not a list or exceeds 50 / 1 MB; per record appliesRecord_Rules::check, then for hero-scope recordsxp_family_turnunlessturn_holder() === $device_id; touches the turn for each hero written. -
read( $store, array $family, array $caller, int $since, int $limit ): array|WP_Error→ wire rows{ seq, store, key, hero_id, device_id, saved_at, deleted, body }; touches the turn of the caller's held heroes is not done here (onlyturn_seen_atof heroes this device holds: calltouch_turnfor each hero whoseturn_device_idis the caller's device, found with one query). -
play( $store, $family, $caller, string $hero_id, string $device_id, string $label, bool $force, int $now ): array|WP_Error(a hero key only for its own hero). -
rest( $store, $family, $caller, string $hero_id, string $device_id ): array|WP_Error→[ 'answer' => 'rested' ].
-
-
REST:
PUT /records,GET /records,POST /heroes/{hero_id}/play,POST /heroes/{hero_id}/rest, guarded byput_records,read_records,play,rest. -
Step 1: Write the failing tests ("Routes", through the classes with the fake store, as the events tests do):
it("put refuses an unentitled family with 403 xp_family_pro_required");it("put answers accepted and refused per record and carries on after a refusal");it("a hero record from a non-holder is refused with xp_family_turn");it("read filters by the hero key's hero");it("play and rest answer as the spec's table, and a hero key cannot play another hero"). -
Step 2: Run. Expected: FAIL.
-
Step 3: Implement the class and the handlers (copy the shape of
send_events/read_events). -
Step 4: Run the whole harness. Expected: all PASS.
-
Step 5: Commit
feat(family): PUT/GET records and the hero's play and rest routes.
Files:
-
Modify:
tests/rehearsal/family-smoke.sh(arecordssection),CHANGELOG.mdor the readme's changelog section (whichever the repo keeps), the plugin version per its convention. -
Step 1: Add the
recordssection: with the filter forced on (wp eval "add_filter('xp_family_pro_entitled','__return_true');"is not persistent, so setwp option update xp_family_pro_override 1and read that option inFamily_Pro::entitledas a rehearsal-only override that only applies whenWP_DEBUGis true): put 3 records as the parent (one hero record without the turn → refusedxp_family_turn), play as the parent device, put again (accepted), read since 0 as the parent and as a hero key (the hero sees only its own), play from a second device (busy), play with force (granted), rest. -
Step 2: Run
tests/rehearsal/family-smoke.sh recordsagainst the rehearsal network (tools/townhall-rehearsal). Expected: every line PASS. -
Step 3: Commit, push, open the PR on the plugin repo with the summary of Tasks 2 to 7 and the smoke output; leave the release to the owner (note it in the PR body).
Files:
- Create:
src/lib/services/sync/recordSignals.ts - Modify:
src/lib/databases/DbStorageApi.ts,src/lib/services/core/BackupService.ts(restore, after each store is written) - Test:
tests/unit/recordSignals.spec.ts(create),tests/unit/BackupService.spec.ts
Interfaces:
-
Produces:
interface RecordWrite { store: string; key: string; value: unknown | null };onRecordWritten(fn: (w: RecordWrite) => void): () => void;emitRecordWritten(w: RecordWrite): void;interface StoreRestored { store: string; removedKeys: string[] };onStoreRestored,emitStoreRestored(the same tiny emittergameSignals.tsbuilds its signals with).DbStorageApi.set/removeemit after the write withstorage.config?.name ?? "";clearreadskeys()first and emits one removal per key. -
Step 1: Write the failing tests.
recordSignals.spec.ts:it("set emits the store name, key and value; remove emits null; clear emits a removal per key")with aDbStorageApiover an in-memory Storage fake (the oneBackupService.spec.tsuses).BackupService.spec.ts:it("a replace restore emits storeRestored with the keys the file did not carry")(store had a, b; file has a →removedKeys: ["b"]);it("a merge restore emits none removed"). -
Step 2: Run both specs. Expected: FAIL.
-
Step 3: Implement.
-
Step 4: Run both specs. Expected: PASS.
-
Step 5: Commit
feat(sync): the storage layer announces every record write; a restore announces the keys it removed.
Files:
- Create:
src/lib/services/sync/syncScopes.ts - Test:
tests/unit/syncScopes.spec.ts
Interfaces:
-
Produces:
type SyncScope = "family" | "family-any" | "hero";interface SyncStoreRule { store: string; scope: SyncScope; heroIdOf?: (key: string, value: unknown) => string | null };const SYNC_STORES: readonly SyncStoreRule[](the spec's table, inBACKUP_STORESorder, without__localSettings);isSyncedStore(store): boolean;scopeOf(store): SyncScope | null;heroIdOf(store, key, value): string | null;mayPhoneWrite(store, heroId: string | null, ownHeroId: string): boolean(true forfamily-any, and forherowhenheroId === ownHeroId). -
Step 1: Write the failing tests:
it("lists every backup store but Settings, with the spec's scopes")(compare againstBACKUP_STORES.map(s => s.name)minus__localSettings);it("derives the hero id per store")(the same six cases as Task 3);it("mayPhoneWrite")(chat yes, own profile yes, other hero no, achievements no). -
Step 2: Run
chemx test tests/unit/syncScopes.spec.ts. Expected: FAIL. -
Step 3: Implement.
-
Step 4: Run. Expected: PASS.
-
Step 5: Commit
feat(sync): the synced stores, their scopes and each record's hero.
Files:
- Create:
src/lib/databases/SyncDb.ts - Test:
tests/unit/SyncDb.spec.ts,tests/unit/BackupService.spec.ts(the "not in BACKUP_STORES" check)
Interfaces:
-
Produces:
syncStorage(__xp_sync);interface OutboxItem { store: string; key: string; queuedAt: string };interface SyncState { outbox: OutboxItem[]; lastSeq: number; firstPushDone: boolean; firstPullDone: boolean; turns: Record<string, "held" | "resting">; verdictsSent: Record<string, { approved: boolean; at: string }>; pendingVerdicts: Record<string, ClaimSettled & { heroId: string; seq: number }> };emptySyncState(): SyncState;readSyncState(stored: unknown): SyncState(tolerant, asreadPhoneState);class SyncDb extends DbStorageApi { getState(); saveState(s); clearState() };getSyncDb(): SyncDb. -
Step 1: Write the failing tests:
it("reads a missing or partial state as empty");it("round-trips a state"); inBackupService.spec.ts:it("__xp_sync is not a backup store"). -
Step 2: Run. Expected: FAIL.
-
Step 3: Implement (the shape of
HeroPhoneDb). -
Step 4: Run. Expected: PASS.
-
Step 5: Commit
feat(sync): SyncDb keeps the outbox, the last number, the turns and the verdicts sent.
Files:
- Create:
src/lib/services/sync/familySyncClient.ts - Modify:
src/lib/services/multiplayer/familyServer.ts(FamilyProblemgains"pro-required";PROBLEM_BY_SITE_CODE.xp_family_pro_required) - Test:
tests/unit/familySyncClient.spec.ts,tests/unit/familyServer.spec.ts
Interfaces:
-
Produces:
interface WireRecord { store: string; key: string; heroId: string | null; deleted: boolean; body?: unknown };interface ReceivedRecord extends WireRecord { seq: number; deviceId: string; savedAt: string };interface PutResult { accepted: { store: string; key: string; seq: number }[]; refused: { store: string; key: string; code: string; message: string }[] };type PlayAnswer = { answer: "granted" } | { answer: "busy"; device: { label: string; lastSeenAt: string | null } };putRecords(auth: TownHallAuth, deviceId: string, records: WireRecord[]): Promise<PutResult>;fetchRecords(auth, since: number, limit = 100): Promise<ReceivedRecord[]>;playHero(auth, heroId, deviceId, label, force = false): Promise<PlayAnswer>;restHero(auth, heroId, deviceId): Promise<void>;MAX_RECORD_BATCH = 50,MAX_BATCH_BYTES = 1_000_000,MAX_RECORD_BYTES = 524_288. Wire conversion snake_case ↔ camelCase here only (hero_id,device_id,saved_at,last_seen_at). -
Step 1: Write the failing tests with a
fetchImplfake (the pattern offamilyServer.spec.ts):it("putRecords sends snake_case and reads accepted and refused");it("fetchRecords passes since and limit and converts rows");it("playHero reads granted and busy");it("a 403 xp_family_pro_required reads as pro-required"). -
Step 2: Run. Expected: FAIL.
-
Step 3: Implement.
-
Step 4: Run both specs. Expected: PASS.
-
Step 5: Commit
feat(sync): the records and turn routes as a client.
Files:
- Create:
src/lib/services/sync/recordOutbox.ts - Test:
tests/unit/recordOutbox.spec.ts
Interfaces:
-
Produces:
enqueueKey(outbox: OutboxItem[], store, key, now: Date): OutboxItem[](one item per store+key; a repeat moves it to the end with the new time);dropItems(outbox, items: { store; key }[]): OutboxItem[];dropWhere(outbox, pred: (item) => boolean): OutboxItem[];interface BatchRecord extends WireRecord { item: OutboxItem; bytes: number };nextBatch(outbox, read: (item) => Promise<BatchRecord | null>, limits = { count: 50, bytes: 1_000_000, record: 524_288 }): Promise<{ records: BatchRecord[]; oversize: OutboxItem[] }>(a record over the per-record cap is returned inoversizeand left out);settlePut(outbox, sent: BatchRecord[], result: PutResult): { outbox: OutboxItem[]; takenOver: string[] }(accepted and every refusal drop the item;xp_family_turnrefusals also list their hero ids intakenOver, deduped). -
Step 1: Write the failing tests:
it("enqueueKey keeps one item per key and moves a repeat to the end");it("nextBatch stops at 50 records or 1 MB and sets oversize aside");it("settlePut drops accepted and refused and names the heroes taken over");it("a key refused as invalid is not queued again by settlePut"). -
Step 2: Run. Expected: FAIL.
-
Step 3: Implement.
-
Step 4: Run. Expected: PASS.
-
Step 5: Commit
feat(sync): the outbox of changed records.
Files:
- Create:
src/lib/services/sync/recordApply.ts - Test:
tests/unit/recordApply.spec.ts
Interfaces:
-
Produces:
interface ApplyTarget { set(key: string, value: unknown): Promise<void>; remove(key: string): Promise<void> };rawTargetFor(store: string): ApplyTarget | null(theBACKUP_STORESentry's raw Ionicstorage, soDbStorageApinever sees the write and nothing echoes);applyRecords(records: ReceivedRecord[], ownDeviceId: string, targetFor = rawTargetFor): Promise<ApplyOutcome>withinterface ApplyOutcome { applied: number; skipped: number; stores: string[]; heroes: string[]; removedProfiles: string[] }(own-device records skipped; unknown stores skipped; a tombstone removes; a failing write is logged and skipped);refreshMirrors(outcome: ApplyOutcome): Promise<void>: whenstoreshas__profiles,StorageService.saveProfiles(all profiles)thenuseUserStore().loadUsers(); whenremovedProfileshas the signed-in hero,logoutUser()first. -
Step 1: Write the failing tests with in-memory targets:
it("writes records by key and removes tombstones");it("skips this device's own records and unknown stores");it("a failing write is skipped and counted");it("refreshMirrors reloads users for profiles and signs out a removed current hero")(mock the user store). -
Step 2: Run. Expected: FAIL.
-
Step 3: Implement.
-
Step 4: Run. Expected: PASS.
-
Step 5: Commit
feat(sync): received records land in the raw stores and the user store reloads.
Files:
- Create:
src/lib/store/stores/sync.ts - Modify:
src/lib/store/stores/family.ts(canQueue,runBind's home question,initstarts the sync store),src/main.tsor whereveruseFamilyStore().init()first runs (no change iffamily.initstarts it) - Test:
tests/unit/syncStore.spec.ts,tests/unit/familyStore.spec.ts
Interfaces:
-
Consumes: Tasks 8 to 13.
-
Produces,
useSyncStore():isOn: ComputedRef<boolean>(multiplayerEnabled() && useProStore().isPro && (family.parentAuth !== null && family.home !== "unknown" || family.isKidMode));isFamilyDevice(on, parent auth);isFullPortal(on, kid mode,firstPullDone);state: Ref<SyncState>;deviceLabel: ComputedRef<string>(kid:family.device.label; parent:the family {tablet|phone|computer}fromCapacitor.getPlatform()andwindow.innerWidth >= 768);init(): Promise<void>(loads state, listens toonRecordWritten/onStoreRestored, the 5 s upload timer, the 60 s tick shared with the family store's download,online,visibilitychange; first push on a family device when!firstPushDone; first pull on a kid phone when!firstPullDone);flush(): Promise<void>(upload then download);download(): Promise<void>(5 pages,applyRecords+refreshMirrors, saveslastSeqper page);upload(): Promise<void>;firstPullProgress: Ref<number>;problem: Ref<FamilyProblem | null>;turns,holds(heroId),signIn,takeOver,rest,withTurn,sendVerdict(Task 17 and 18 fill these; declare the names now as no-ops that Task 17 replaces). -
family.ts:canQueue()becomesenabled.value && (home.value === "home" || useSyncStore().isFamilyDevice);runBindskips the home question (setHome("home")onhome-elsewhere) whenuseSyncStore().isOn;init()callsuseSyncStore().init()afterlisten()and after a kid phone'sphone.load(). -
A 403
pro-requiredfrom any sync call setsproblem = "pro-required", callsuseProStore().recheck(), and the store stays off untilisProis true again. -
A kid phone that is unlinked (
family.forgetDevice, also reached from the 401revoked()path) clearsSyncDb(getSyncDb().clearState()) and the turns; the family store callsuseSyncStore().reset()fromforgetDevice. -
Step 1: Write the failing tests (
syncStore.spec.ts, mocks asfamilyStore.spec.ts):it("off without Pro, off with the flag off, on for a bound parent and for a linked kid phone");it("a record write queues its key and uploads 5 s later with the hero id");it("download applies pages in order and saves lastSeq after each");it("the first push sends every record of every synced store once");it("the first pull downloads from 0 and sets firstPullDone");it("a restore re-sends everything and tombstones the removed keys");it("pro-required turns the store off and rechecks the licence");it("forgetDevice clears the sync state").familyStore.spec.ts:it("a Pro family device queues events although it is not home, and is not asked the home question"). -
Step 2: Run both specs. Expected: FAIL.
-
Step 3: Implement (setup store, the shape of
useFamilyStore; one send at a time;onScopeDisposestops timers). -
Step 4: Run both specs, then
chemx typecheckandchemx lint. Expected: PASS, clean. -
Step 5: Commit
feat(sync): the sync store uploads changed records, downloads the family's save and runs the first push and pull.
Files:
- Create:
src/lib/services/copy/catalog/sync.ts - Modify:
src/lib/services/copy/catalog/index.ts - Test: the catalogue spec that checks unique keys (find it with
chemx q CATALOG_PARTS), plustests/unit/syncCopy.spec.ts
Interfaces:
-
Produces: keys
sync.busy,sync.tookOver,sync.firstPull,sync.firstPush,sync.proRequired,sync.offline,sync.verdictSent,sync.verdictRejectedSent,sync.playHere("Play here"),sync.notNow("Not now"),sync.thisPhone("This is {nick}'s phone"), with the adventure lines of spec section 5 verbatim. -
Step 1: Write the failing test:
it("every sync line exists in the adventure voice and has no em dash"). -
Step 2: Run. Expected: FAIL.
-
Step 3: Implement and add
synctoCATALOG_PARTSandCATALOG. -
Step 4: Run the two specs. Expected: PASS.
-
Step 5: Commit
feat(sync): the clerk's sync lines.
Files:
- Create:
src/lib/services/sync/heroTurn.ts - Test:
tests/unit/heroTurn.spec.ts
Interfaces:
-
Produces:
type TurnState = "resting" | "asking" | "held" | "busy" | "takenOver";interface TurnBusy { label: string; lastSeenAt: string | null };interface HeroTurn { state: TurnState; busy: TurnBusy | null; provisional: boolean };type TurnEvent = { type: "signIn" } | { type: "granted" } | { type: "busy"; device: TurnBusy } | { type: "force" } | { type: "decline" } | { type: "signOut" } | { type: "offline" } | { type: "refused" } | { type: "caughtUp" };initialTurn(): HeroTurn;turnReducer(turn: HeroTurn, e: TurnEvent): HeroTurn(the table in spec section 5;offlinewhileasking→heldwithprovisional: true;refusedfromheld→takenOver;caughtUp→resting);maySend(turn): boolean(held, provisional or not). -
Step 1: Write the failing tests: one
itper row of the spec's table, plusit("an unknown transition leaves the turn unchanged"). -
Step 2: Run. Expected: FAIL.
-
Step 3: Implement.
-
Step 4: Run. Expected: PASS.
-
Step 5: Commit
feat(sync): the hero's turn as a state machine.
Files:
- Create:
src/lib/services/sync/syncPrompts.ts - Modify:
src/lib/store/stores/sync.ts(signIn,takeOver,rest,withTurn, the watchers),src/app/SideMenu/SwitchProfile/SwitchProfile.vueor the sign-in path it uses (the busy prompt after a pick),src/app/Console/MyPortal/MyPortal.ts(the took-over line whenturns[current].state === "takenOver") - Test:
tests/unit/syncPrompts.spec.ts,tests/unit/syncStore.spec.ts
Interfaces:
-
Produces,
syncPrompts.ts:agoOf(iso: string | null, now: Date): string("just now", "{n} min ago", "{n} h ago", "yesterday", "{n} days ago");busyPrompt(nick, device: TurnBusy, now, voice = "adventure"): RevealPromptOptions(speaker "Town Clerk", choicesplay=sync.playHeretone primary,later=sync.notNow,cancelId: "later");tookOverPrompt(nick, label);firstPullPrompt(count);proRequiredPrompt();offlinePrompt(). -
Sync store:
signIn(heroId): Promise<"granted" | "busy" | "offline">(reducersignIn,playHero(auth, heroId, deviceId, deviceLabel), thengranted/busy/offline; on granteddownload());takeOver(heroId)(reducerforce,playHero(..., true));rest(heroId)(upload,restHero, reducersignOut);withTurn(heroId, fn: () => Promise<void>): Promise<boolean>(free → take, run, rest; busy → false); a watcher onuseUserStore().currentUser.idrests the old hero and signs in the new;visibilitychangehidden rests the current hero, visible signs in again;onlinere-asks a provisional turn.upload()sends a hero's records only whilemaySend(turns[heroId]);settlePut'stakenOverheroes get reducerrefused, thendownload()and reducercaughtUp, and the took-over line is shown once on the portal. -
The busy answer on a family device: the profile picker shows
busyPrompt;play→takeOver;later→ stays on the picker (sign-out). On a kid phone:play→takeOver;later→ the board, read-only. -
Step 1: Write the failing tests:
syncPrompts.spec.ts:it("agoOf")(5 cases);it("busyPrompt names the device and the time, cancel is later").syncStore.spec.ts:it("signIn asks the site and holds on granted, downloads at once");it("busy keeps the hero out until play here forces it");it("offline holds provisionally and re-asks on online");it("rest uploads what waits then tells the site");it("a record refused with xp_family_turn marks the hero taken over, downloads and rests");it("hero records are not sent while the hero is resting");it("withTurn takes, runs, rests, and answers false when busy"). -
Step 2: Run. Expected: FAIL.
-
Step 3: Implement (prompts through
useRevealPrompt().showPrompt, ashometownPrompts.tsand its screens do). -
Step 4: Run the specs,
chemx typecheck,chemx lint. Expected: PASS, clean. -
Step 5: Commit
feat(sync): a hero is taken and rested with sign-in, switch, Save & Quit and the background; the busy and took-over lines.
Files:
- Create:
src/lib/services/sync/verdictApply.ts - Modify:
src/lib/services/quests/approvalFlow.ts(approveClaim,rejectClaim),src/lib/store/stores/sync.ts(sendVerdict, applyingclaim.settledandreward.grantedfrom the family store's download),src/lib/store/stores/family.ts(takePhoneClaimsthroughuseSyncStore().withTurnon a Pro device;claim.settledandreward.grantedhanded to the sync store when it is on),src/lib/services/multiplayer/familyEvents.ts(syncedfield onquest.claimed;questClaimedEventgainssynced?: boolean),src/lib/services/multiplayer/phoneClaimIntake.ts(isPhoneClaimfalse whendata.synced === true),src/app/Admin/XpApprovals/XpApprovals.vue(an entry inverdictsSentreadssync.verdictSent/sync.verdictRejectedSentand its buttons are hidden) - Test:
tests/unit/verdictApply.spec.ts,tests/unit/approvalFlow.spec.ts(exists? else create),tests/unit/phoneClaims.spec.tsorphoneClaimIntake.spec.ts,tests/unit/familyEvents.spec.ts,tests/unit/syncStore.spec.ts
Interfaces:
-
Produces:
applyVerdict(settled: ClaimSettled, deps: { entry(id: string): Promise<QuestLogEntry | null>; approve(id: string): Promise<unknown>; reject(id: string, note: string): Promise<unknown> }): Promise<"applied" | "already" | "missing">(claimIdis the entry id;alreadywhen the entry's status is notpending;pendingstatus insettledmeans nothing to apply →already). -
approvalFlow: beforeapproveCompletion,const sync = useSyncStore(); const isRemote = sync.isOn && !sync.holds(entry.userId);→ when remote:await sync.sendVerdict({ claimId: entryId, questId, status: "approved", note: "", xp: 0, gp: 0, ap: 0 }, entry.userId)(the pay is computed by the holder),emitQuestReviewedas today, return{ ok: true, entry, line: "", remote: true }(ReviewOutcomegainsremote?: boolean). Same for reject with the note. -
sync.sendVerdict(settled, heroId): queuesclaimSettledEventthrough the family store'squeueApprovalEvent, recordsverdictsSent[claimId], thenwithTurn(heroId, () => applyVerdict(...))(pays when nobody holds the hero; when busy, the holder applies it). -
Applying on the holder: in the sync store's download of events (the family store hands over
claim.settledandreward.grantedfor heroes this device holds, plus those with no holder throughwithTurn):applyVerdictwithapproveCompletion/rejectCompletion; amissingverdict is kept inpendingVerdictsand retried after each records download;verdictsSent[claimId]is dropped when the entry arrives settled. -
questClaimedEvent(..., { synced: true })fromQuestCompletionServicethrough the family store whenuseSyncStore().isOn;isPhoneClaimreturns false fordata.synced === true. -
The takeover re-send (spec section 5): when
settlePutnames a hero taken over, the sync store reads each refused__questLogrecord of that hero whosestatusispendingand queues a freshquestClaimedEvent(heroId, deviceId, { questId, difficulty, requiresApproval: true })withoutsynced, through the family store'squeueApprovalEvent; the holder takes it as a phone claim.recordOutbox.settlePuttherefore also returnsrefusedRecords: BatchRecord[]so the store can read their bodies. -
Step 1: Write the failing tests:
verdictApply.spec.ts:it("applies an approval once; a repeat answers already");it("a rejection carries the note");it("missing when the entry is not here yet").familyEvents.spec.ts:it("quest.claimed keeps synced").phoneClaims/intake spec:it("a synced claim is not taken as a phone claim").approvalFlowspec:it("a verdict for a hero this device does not hold is sent, not written");it("a verdict for a held hero is written as before").syncStore.spec.ts:it("sendVerdict pays through withTurn when nobody holds the hero");it("a verdict for an entry not yet here waits in pendingVerdicts and applies after the download");it("two devices settling one claim pay once")(the second findsalready);it("a pending claim refused in a takeover is re-sent as an unsynced claim event"). -
Step 2: Run. Expected: FAIL.
-
Step 3: Implement.
-
Step 4: Run the specs,
chemx typecheck,chemx lint. Expected: PASS, clean. -
Step 5: Commit
feat(sync): a verdict is applied and paid by the device holding the hero; synced claims are not taken twice.
Files:
- Modify:
src/lib/services/notifications/questWatch.ts(around line 132,chargeLatePenalties, and the deadline and encounter checks in the same pass) - Test:
tests/unit/questWatch.spec.ts(exists? else create)
Interfaces:
-
Consumes:
useSyncStore().isOn,holds(heroId). -
Produces:
heroesToWatch(allHeroIds: string[], sync: { isOn: boolean; holds(id: string): boolean }): string[](pure, exported fromquestWatch.ts: every hero when sync is off; held heroes when on). -
Step 1: Write the failing test:
it("with sync on, only held heroes are charged and watched");it("with sync off, every hero is"). -
Step 2: Run. Expected: FAIL.
-
Step 3: Implement.
-
Step 4: Run. Expected: PASS.
-
Step 5: Commit
feat(sync): late penalties, deadlines and encounters run only for heroes this device holds.
Files:
- Modify:
src/router/_Guards/heroPhone.guard.ts(handleHeroPhoneGuard(to, isKidMode, isFullPortal)),src/router/_Guards/index.ts,src/app/SideMenu/HeroPhone/HeroPhone.ts/.vue(the first pull:sync.firstPullPromptprogress line, thenloginUser(hero)androuter.replace("/console/my-portal")onceisFullPortal; offline: the board withsync.offline) - Test:
tests/unit/heroPhoneGuard.spec.ts,tests/unit/HeroPhone.spec.ts
Interfaces:
-
Produces:
handleHeroPhoneGuard(to, isKidMode: boolean, isFullPortal: boolean): withisFullPortalevery route passes except the Game Master routes (gm.guardhandles those already). -
Step 1: Write the failing tests: guard:
it("a full portal passes every route");it("kid mode without the portal still redirects"). HeroPhone:it("shows the first pull line while pulling, then signs the hero in and opens the portal");it("offline on the first pull shows the board with the offline line"). -
Step 2: Run. Expected: FAIL.
-
Step 3: Implement.
-
Step 4: Run both specs. Expected: PASS.
-
Step 5: Commit
feat(sync): a Pro kid phone pulls the family's world and opens the full portal.
Files:
- Modify:
src/app/SideMenu/SwitchProfile/SwitchProfile.vue(kid mode: only the linked hero,sync.thisPhoneline, no restore door),src/app/SideMenu/XpSettings/components/XpSettingsHome/settingsSections.ts(sectionsFor({ isKidMode }): withoutfamily,packs,general's backup/restore,account; with adeviceitem Unlink this phone →family.forgetDevice()aftersync.restandgetSyncDb().clearState()),src/app/SideMenu/XpSettings/components/XpFamilyPhones.vue(hidden in kid mode),src/components/molecules/Modals/SaveAndQuitModal.vueor its handler (on a kid phone, Save & Quit rests and returns to/hero-phone) - Test:
tests/unit/settingsSections.spec.ts(create),tests/unit/SwitchProfile.spec.ts(exists? else create)
Interfaces:
-
Produces:
sectionsFor(opts: { isKidMode: boolean }): SettingsSection[](pure, exported besideSETTINGS_SECTIONS). -
Step 1: Write the failing tests:
it("kid mode hides family, packs, general backup and account, and shows Unlink this phone");it("the picker in kid mode shows the one hero and no restore door"). -
Step 2: Run. Expected: FAIL.
-
Step 3: Implement.
-
Step 4: Run the specs,
chemx lint. Expected: PASS, clean. -
Step 5: Commit
feat(sync): kid mode inside the portal: one hero, no Game Master bits, Unlink this phone.
Files:
-
Modify:
src/app/SideMenu/HeroPhone/components/HeroPhoneBoard.vue(footersync.proRequiredwhenfamily.isKidMode && !useProStore().isPro),src/router/_Guards/heroPhone.guard.ts(already: no portal without Pro) -
Test:
tests/unit/HeroPhone.spec.ts -
Step 1: Write the failing test:
it("a kid phone without Pro shows the board with the Pro line and the guard keeps it there"). -
Step 2: Run. Expected: FAIL.
-
Step 3: Implement.
-
Step 4: Run. Expected: PASS.
-
Step 5: Commit
feat(sync): without Pro a kid phone keeps the board and says why.
Files:
-
Create:
tests/e2e/specs/family-sync.spec.js, fixturestests/e2e/fixtures/family/records-page.json,play-granted.json,play-busy.json,pro-required.json -
Modify:
docs/Roadmap.md(the multiplayer section: part 3 built),src/lib/services/multiplayer/FAMILY_EVENTS.md(thesyncedfield; a pointer to the spec for records and turns) -
Step 1: Write the Cypress spec (390x844,
cy.intercepton${LIVE_SITE}/**asfamily.spec.js): "a kid phone with Pro pulls the world and opens the portal"; "the busy line offers Play here and Not now"; "the took-over line shows once after a refused record"; "without Pro the board shows the Pro line". -
Step 2: Run
pnpm vue-cli-service test:e2e --headless --browser chrome --url http://localhost:8100 --spec tests/e2e/specs/family-sync.spec.jsagainst the shared dev server (curl -s localhost:8100answers; never start a second one). Expected: 4 passing. -
Step 3: The rehearsal run on the local Docker WordPress with the plugin branch installed: two browser profiles as parent devices (two Town Hall links, same account) and one as a kid phone; walk spec section 12's list (play on the tablet then the phone; approve from the second parent while the hero plays and while nobody does; force a takeover with one device offline; lapse Pro through the filter). Record each step's outcome in the PR body.
-
Step 4:
chemx typecheck,chemx lint, then pushfeat/family-syncand open the PR with auto-merge (gh pr merge <n> --auto --squash); bind it with the ccd_pr tools and read the Tests check. Fix and push until green. -
Step 5: Hand the owner the release checklist in the final message: merge the plugin PR and cut its release (the COMPASS updater delivers it to doit), confirm the APK rebuild and upload, and that
app.doit.forthexp.comdeploys on the merge.