2026 10 13 multiplayer shared base - 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: Put the family on the server: the family and its heroes on doit.forthexp.com, kid phones linked by QR with hero-only keys, an append-only event stream from each device, hero cards, and server rewards paid once on the home device.
Architecture: On the site, five classes in xophz-compass-xp/includes/family/: two pure rule classes (tested with runPhpJson, no WordPress), the schema, the store ($wpdb) and the REST routes under xp-family/v1; a rehearsal smoke script drives the real routes on the Town Hall's throwaway network. In the app, pure modules in src/lib/services/multiplayer/ (each with a jest spec), two Ionic Storage databases, a setup-style Pinia store (family), and thin screens: a card in Family Settings, the Link a phone modal, /link and /hero-phone. Everything is behind a flag.
Tech Stack: Vue 3 + Ionic 7 + Pinia + TypeScript (Vue CLI 5), jest, Cypress 8, Capacitor 6, qrcode, @capacitor-mlkit/barcode-scanning; WordPress REST API, PHP 8, MySQL GET_LOCK, WP-Cron; the COMPASS node test harness.
Spec: docs/superpowers/specs/2026-10-13-multiplayer-shared-base-design.md (builds on 2026-10-13-town-hall-wp-bridge-design.md and its plan 2026-10-13-town-hall-wp-bridge.md, "TH" below).
- Gates and reads go through
chemx(chemx test <spec>,chemx typecheck,chemx lint,chemx verify,chemx read,chemx q); never rawpnpm test,pnpm jest,pnpm typecheck,pnpm lint,greporcatfor code. Runchemx verifybefore every commit in the app repo. - After editing a page's
.tswhose.vueuses<script src="./X.ts">, run its spec withnpx jest <spec> --no-cacheonce beforechemx verify. - 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 <spec>. - App repo commits: define
commit_pathsexactly as in TH's Global Constraints and use it; nevergit add,git stashor plaingit commit. Runchemx team lock check-staged <paths>first. If a file holds another session's uncommitted edits, stop and ask the owner. - Plugin repo
P=../Xophz-COMPASS/wp-content/plugins/xophz-compass-xp(its own git repo, holding other people's edits):git -C $P add -- <new files>thengit -C $P commit -m "<msg>" -m "Co-Authored-By: Claude Opus 5.5 <[email protected]>" -- <paths>. PHP tests:node wp-content/plugins/xophz-compass-xp/tests/run-tests.mjsfrom../Xophz-COMPASS(the My Compassu-wordpresscontainer must be up). NeverloadWpin these tests: the owner's My Compass database is not touched. - No pushes unless the owner asks. Never change the live site.
- Exact values from the spec:
- routes
xp-family/v1/..., only on sites inapply_filters( 'xp_town_hall_site_urls', Xophz_Compass_Xp_Town_Hall::DEFAULT_SITE_URLS )(TH8), compared without trailing slashes - hero key header
X-XP-Hero-Key; keyxph_+ 32 random bytes base64url without padding (47 characters); stored as SHA-256 hex - link code: 8 characters from
ABCDEFGHJKMNPQRSTUVWXYZ23456789, shownABCD-EFGH, valid 600 seconds and once; a new code for a hero deletes that hero's unused codes; 10 failed claims per address per hour, then 429 - events: at most 50 per request,
dataat most 8192 bytes as JSON, 120 per hero and 600 per family per hour (429 withRetry-After: 600),atno later than server time + 86400 seconds; kept 90 days - nickname 1 to 40 characters (characters, not bytes); age groups
child(under 13),teen(13 to 17),adult - schema option
xp_family_db_version="1"; daily cron hookxp_family_cleanup(events over 90 days, expired codes, devices revoked over 30 days) - app flag:
process.env.VUE_APP_MULTIPLAYER === "true", or in development onlylocalStorage["xp:multiplayer"] === "on" - device databases
__xp_family_events(keystate) and__xp_hero_device(keydevice); neither is ever added toBACKUP_STORES - app routes
/link(namelink-phone) and/hero-phone(namehero-phone), bothmeta.requiresAuth: false - QR link:
{origin}{pathname}#/link?code=ABCDEFGHwith hash history,{origin}/link?code=ABCDEFGHotherwise;&site=is read only outside production TOWN_HALL_APP_ID = "7606ce60-29c7-43b6-9533-2063ee726df4"
- routes
- Wire format is snake_case (
family_id,device_id,hero_id,age_group,expires_at,received_at); the app is camelCase.familyServer.tsis the only place that converts. - Site error codes:
xp_family_not_member403,xp_family_taken403,xp_family_forbidden403,xp_family_no_family404,xp_family_unknown_hero404,xp_family_code_invalid404,xp_family_moved409,xp_family_key_revoked401,xp_family_rate_limited429,xp_family_bad_request400; per-event rejections usexp_family_event_invalid. - UI: new components are
<script setup lang="ts">; pages aredefineComponentwith sibling.tsand_X.scss. Every message is a Town Clerk line throughXpRevealPrompt(speaker: CLERK,accent: "townHall",sprite: "tablet"), worded insrc/lib/services/copy/catalog/family.ts; no toasts or native alerts. Sounds only throughplay$fx(key). - PHP: new files start with the header style of
includes/class-xophz-compass-xp-pro.php. The two rule classes call no WordPress function, so testsrequire_oncethem alone.
- A hero key sending an event for a sibling, or a
reward.granted: the server refuses that event and keeps the rest of the batch. Pinned in Task 2 and Task 6. - The app starts with an empty
usersmap while profiles load: no hero is removed on the server and no blank card is sent; removal only comes from an explicit profile delete. Pinned in Task 11. - The app is closed while events are sending: on the next start they are queued again, not stuck. Pinned in Task 10.
- A reward is downloaded twice, or the app dies between paying and saving, or the last-seen number is lost: the hero is paid exactly once. Pinned in Task 18.
- Kids type codes as
abcd efghorABCD-EFGH, and pick emoji nicknames: both work; a 41-emoji nickname is refused. Pinned in Task 1 and Task 14.
- TH1 and TH2 are committed (
src/lib/services/townhall/townHallSite.ts,townHallClient.ts):chemx log -n 30lists both. - Tasks 4 to 6 and 20 need TH7 (rehearsal network) and TH8 (
Xophz_Compass_Xp_Town_Hall); Task 11 needs TH10 (useTownHallStore). Until those land, those tasks stay blocked on the board. -
curl -s -o /dev/null -w '%{http_code}' localhost:8100prints200.
Files:
- Create:
$P/includes/family/class-xophz-compass-xp-family-rules.php - Modify:
$P/tests/run-tests.mjs - Test:
$P/tests/family-rules.test.mjs
Interfaces:
-
Produces
class Xophz_Compass_Xp_Family_Rules(allpublic static):- consts
CODE_ALPHABET,CODE_LENGTH = 8,CODE_TTL = 600,KEY_PREFIX = 'xph_',AGE_GROUPS = [ 'child', 'teen', 'adult' ],NICKNAME_MAX = 40,LABEL_MAX = 60,CLAIM_FAILS_PER_HOUR = 10 -
new_code( callable $rand = 'random_int' ): string($rand( 0, 30 )per character) -
normalize_code( string $typed ): ?string(uppercase; drops spaces and-; null unless exactly 8 alphabet characters) -
hash_secret( string $s ): string(SHA-256 hex) -
new_key( callable $bytes = 'random_bytes' ): string,is_key_shaped( string $s ): bool -
clean_nickname( $raw ): ?string(strip tags, trim, collapse runs of spaces, 1 to 40 characters bymb_strlen) -
is_age_group( $v ): bool,is_family_id( string $s ): bool(lowercase v4 UUID),is_device_id( string $s ): bool(16 of[A-Za-z0-9]),is_hero_id( string $s ): bool([A-Za-z0-9_-]{1,64}) -
bind_answer( ?array $account_family, ?array $id_owner, int $user_id, string $family_id, string $device_id ): string→created,home,home-elsewhere,other-familyortaken;$account_familyis the family row owned by this user,$id_ownerthe row that already holds$family_id is_family_site( string $home_url, array $allowed ): bool-
may( string $caller, string $action ): bool, callersparent,hero,none; actionsbind,move,read_family,save_hero,remove_hero,make_code,claim,list_devices,unlink_device,send_events,read_events -
device_label( $raw ): string(trimmed to 60; empty →A hero phone)
- consts
-
Step 1: Write the failing tests (
runPhpJson,require_onceof the class only), as tables:
// normalize_code
[['abcd-efgh', 'ABCDEFGH'], [' ab cd ef gh ', 'ABCDEFGH'], ['ABCDEFG', null], ['ABCDEFG0', null], ['ABCDEFGHJ', null], ['ABCD-EFGI', null]]
// new_code with $rand always 0 -> 'AAAAAAAA'; 50 real codes all match /^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{8}$/
// new_key -> /^xph_[A-Za-z0-9_-]{43}$/; is_key_shaped(new_key()) true; 'xph_short', 'XP-PRO-AAAA-BBBB-CCCC', '' false
// clean_nickname
[[' Mia ', 'Mia'], ['<b>Mia</b>', 'Mia'], ['A B', 'A B'], ['', null], [' ', null], ['😀'.repeat(40), '😀'.repeat(40)], ['😀'.repeat(41), null]]
// bind_answer (F1/F2 family ids, D1/D2 devices, user 7 and 8)
[[null, null, 7, F1, D1, 'created'],
[{family_id: F1, owner_user_id: 7, home_device_id: D1}, same, 7, F1, D1, 'home'],
[{family_id: F1, owner_user_id: 7, home_device_id: D1}, same, 7, F1, D2, 'home-elsewhere'],
[{family_id: F1, owner_user_id: 7, home_device_id: D1}, null, 7, F2, D2, 'other-family'],
[null, {family_id: F1, owner_user_id: 8, home_device_id: D1}, 7, F1, D2, 'taken']]
// is_family_site: ('https://doit.forthexp.com/', ['https://doit.forthexp.com']) true; ('https://www.forthexp.com', same) false
// may: parent may every action; hero may read_family, unlink_device, send_events, read_events and nothing else; none may only claim
// device_label: '' -> 'A hero phone'; 70 x 'a' -> 60 x 'a'Plus lintPhpFile on the class. Add the import to run-tests.mjs.
- Step 2: Run the PHP tests. Expected: FAIL (class file missing).
- Step 3: Implement the class. Base64url:
rtrim( strtr( base64_encode( $bytes( 32 ) ), '+/', '-_' ), '=' ). - Step 4: Run the PHP tests. Expected: PASS.
- Step 5: Commit in
$P:feat(family): identity rules for families, heroes, link codes and hero keys (MP1)with the class, the test andtests/run-tests.mjs.
Files:
- Create:
$P/includes/family/class-xophz-compass-xp-family-event-rules.php - Modify:
$P/tests/run-tests.mjs - Test:
$P/tests/family-event-rules.test.mjs
Interfaces:
-
Produces
class Xophz_Compass_Xp_Family_Event_Rules(allpublic static):-
KINDS: kind →[ 'scope' => 'public'|'family', 'writer' => 'home'|'server'|'hero', 'fields' => [ name => type ] ], typesint,bool,str40,str64,look:kind scope writer fields hero.cardpublic home nicknamestr40,looklook,jobClassstr40,levelinthero.leveledpublic home levelint,jobClassstr40quest.donepublic home difficultyint,xpint,fightboolreward.grantedfamily server xpint,apint,reasonstr40,sourceIdstr64 -
consts
LOOK_FIELDS(skin,hair,hairColor,face,eyeColor,outfit,outfitColor,outfitAccent,hat,hatColor,leftHand,rightHand; string values up to 32 characters),MAX_BATCH = 50,MAX_DATA_BYTES = 8192,HERO_PER_HOUR = 120,FAMILY_PER_HOUR = 600,FUTURE_SLACK = 86400,RETRY_AFTER = 600,INT_MAX = 100000 -
filter_data( string $kind, array $data, array $kinds = self::KINDS ): array(keeps listed fields of the right type; ints clamped to 0..INT_MAX; JSON strings are not ints) -
check_event( array $e, array $ctx, array $kinds = self::KINDS ): array→[ 'ok' => true, 'event' => [ 'event_id', 'hero_id', 'device_id', 'kind', 'scope', 'occurred_at' (unix), 'data' ] ]or[ 'ok' => false, 'id' => ..., 'code' => 'xp_family_event_invalid', 'message' => ... ].$eis the wire event (id,hero_id,device_id,kind,at,data);$ctx=[ 'writer' => 'home'|'hero'|'server', 'hero_ids' => string[], 'key_hero_id' => ?string, 'now' => int ] -
readable_by( array $row, string $caller, ?string $key_hero_id ): bool(parent: all; hero:public, orfamilyof its own hero) -
over_limit( array $hero_counts, int $family_count, array $incoming_by_hero ): bool
-
-
Step 1: Write the failing tests:
- a
hero.cardfromhomewith an extrabirthdayfield and a look with an extrasecretkey: ok;datahas nobirthday, the look has nosecret - id
"abc"→ not ok; unknown kindhero.secret→ not ok;reward.grantedwith writerhome→ not ok, with writerserver→ ok - a kind with writer
heropassed in$kinds: writerherowithkey_hero_idh-1andhero_idh-2→ not ok; withh-1→ ok -
hero_idnot inhero_ids→ not ok;at= now + 86401 → not ok;at= now − 30 days → ok -
dataof 9000 bytes → not ok -
quest.donewithxp: "40"→xpdropped;xp: -5→ 0;xp: 999999→ 100000 -
readable_by: parent reads a sibling'sfamilyrow; a hero readspublicrows and its ownfamilyrows, not a sibling'sfamilyrow -
over_limit( ['h-1' => 119], 0, ['h-1' => 1] )false;( ['h-1' => 120], 0, ['h-1' => 1] )true;( [], 599, ['h-1' => 1] )false;( [], 600, ['h-1' => 1] )true
- a
-
Step 2: Run. Expected: FAIL.
-
Step 3: Implement the class.
atis ISO 8601; parse withstrtotime, refusefalse. Measure the size withstrlen( json_encode( $data ) )(no WordPress functions in this class). -
Step 4: Run. Expected: PASS.
-
Step 5: Commit in
$P:feat(family): event kinds, field filtering and limits (MP2).
Files:
- Create:
$P/includes/family/class-xophz-compass-xp-family-schema.php - Modify:
$P/includes/class-xophz-compass-xp.php(load_dependencies: require the two rule classes and the schema;define_compass_hooks:Xophz_Compass_Xp_Family_Schema),$P/tests/run-tests.mjs - Test:
$P/tests/family-schema.test.mjs
Interfaces:
-
Produces
class Xophz_Compass_Xp_Family_Schema:- consts
VERSION = '1',OPTION = 'xp_family_db_version',CLEANUP_HOOK = 'xp_family_cleanup' -
public static function tables( string $prefix, string $charset ): array→ name →CREATE TABLESQL for{prefix}xp_families,{prefix}xp_family_heroes,{prefix}xp_family_devices,{prefix}xp_family_link_codes,{prefix}xp_family_eventswith the columns of spec section 2 (dbDelta style: two spaces afterPRIMARY KEY) public $action_hooks = [ 'init' => [ 'maybe_install', 5 ], 'xp_family_cleanup' => 'cleanup' ]-
maybe_install(): void: only on a family site (Family_Rules::is_family_site( home_url(), apply_filters( 'xp_town_hall_site_urls', Xophz_Compass_Xp_Town_Hall::DEFAULT_SITE_URLS ) )); when the option differs fromVERSION,dbDeltaeach table, update the option, scheduleCLEANUP_HOOKdaily if not scheduled -
cleanup(): void: the three deletions of the Global Constraints
- consts
-
Step 1: Write the failing test:
tables( 'wp_5_', 'DEFAULT CHARSET=utf8mb4' )has the five names;xp_familiescontainsUNIQUE KEY owner_user_id (owner_user_id);xp_family_heroescontainsPRIMARY KEY (family_id,hero_id);xp_family_devicescontainsUNIQUE KEY key_hash (key_hash);xp_family_eventscontainsUNIQUE KEY event_id (event_id)andKEY family_seq (family_id,seq);lintPhpFilepasses. -
Step 2: Run. Expected: FAIL.
-
Step 3: Implement the class and the loader lines.
-
Step 4: Run. Expected: PASS.
-
Step 5: Commit in
$P:feat(family): tables, install on family sites, daily cleanup (MP3).
Files:
- Create:
$P/includes/family/class-xophz-compass-xp-family-store.php,$P/includes/family/class-xophz-compass-xp-family-rest.php,$P/tests/rehearsal/family-smoke.sh - Modify:
$P/includes/class-xophz-compass-xp.php(require both; hookXophz_Compass_Xp_Family_Rest)
Interfaces:
-
Consumes: Tasks 1 and 3; TH7's rehearsal (
http://localhost:8090/doit, thewpcliservice, userfamparent); TH8's class -
Produces:
-
Xophz_Compass_Xp_Family_Store(instance methods,$wpdb->prefixtables):family_of_user( int $user_id ): ?array,family( string $family_id ): ?array,create_family( string $family_id, int $user_id, string $device_id ): void(INSERT IGNORE; the uniqueowner_user_idmakes a second concurrent bind a no-op),move_home( string $family_id, string $device_id ): void,save_hero( string $family_id, string $hero_id, string $nickname, string $age_group ): void(a removed hero comes back),remove_hero( string $family_id, string $hero_id ): void(removed_at, card cleared, its devices revoked),heroes( string $family_id ): array(live only),set_card( string $family_id, string $hero_id, array $card, int $at ): void(only when$atis newer thancard_at; also setsnicknamefrom the card) -
Xophz_Compass_Xp_Family_Rest:const NAMESPACE = 'xp-family/v1';public $action_hooks = [ 'rest_api_init' => 'register_routes' ](registers nothing off a family site);caller( WP_REST_Request $r ): array|WP_Error→[ 'kind' => 'parent', 'user_id' => int ],[ 'kind' => 'hero', 'device' => array ](Task 5) or[ 'kind' => 'none' ]; a signed-in non-member →xp_family_not_member; every handler checksFamily_Rules::may - routes
POST /family→{ answer, family_id };POST /family/move→{ ok: true };GET /family→{ family_id, heroes: [ { hero_id, nickname, age_group, card } ] }(age_grouponly for a parent);POST /heroes/{hero_id}{ nickname, age_group }→{ ok: true };DELETE /heroes/{hero_id}→{ ok: true }. A parent with no family on any route butPOST /family→xp_family_no_family;taken→ 403xp_family_taken -
family-smoke.sh [family|phones|events|all]: envSITE(defaulthttp://localhost:8090/doit) andCOMPOSE(default$HOME/www/x/do-it-for-the-xp/tools/townhall-rehearsal/docker-compose.yml); helperwp()=docker compose -f "$COMPOSE" run --rm -T wpcli wp --url="$SITE" "$@"; parents get Application Passwords withwp user application-password create <user> smoke --app-id=<TOWN_HALL_APP_ID> --porcelain(TH8 makes them members); printsPASS <name>/FAIL <name>and exits 1 on any failure; each section starts withreset_family, which deletes the smoke families withwp db query, bindsF1with homeD1and registersh-1,h-2,h-3, so a section runs on its own
-
-
Step 1: Write the
familysection of the smoke script (fails now: the routes are missing). Checks, with family idsF1,F2, devicesD1,D2, usersfamparentand a script-madesmokeparent2:- no auth
POST /family→ 401 -
famparentPOST /family {F1, D1}→created; again →home; withD2→home-elsewhere;{F2, D2}→other-family -
smokeparent2POST /family {F1, D2}→ 403xp_family_taken - a user created on the main site only, whose Application Password has a different
app_id(so TH8 does not add them to/doit/) → 403xp_family_not_member -
famparentPOST /heroes/h-1 {nickname: "Mia", age_group: "child"}→ ok;{nickname: ""}→ 400xp_family_bad_request;{age_group: "toddler"}→ 400;GET /familylistsh-1withage_group;DELETE /heroes/h-1→ gone;POST /heroes/h-1again → back -
POST /family/move {F1, D2}→ ok;POST /family {F1, D2}→home
- no auth
-
Step 2: Run
$P/tests/rehearsal/family-smoke.sh family. Expected: FAIL on the first route check. -
Step 3: Implement the store, the REST class and the loader lines.
-
Step 4: Run the
familysection. Expected: all PASS. Run the PHP tests again. Expected: PASS. -
Step 5: Commit in
$P:feat(family): bind a family to its account, register heroes, move home (MP4).
Files:
- Modify:
$P/includes/family/class-xophz-compass-xp-family-store.php,$P/includes/family/class-xophz-compass-xp-family-rest.php,$P/tests/rehearsal/family-smoke.sh
Interfaces:
-
Consumes: Tasks 1 and 4
-
Produces:
- store:
put_code( string $code_hash, string $family_id, string $hero_id, int $user_id, int $expires_at ): void,take_code( string $code_hash, int $now ): ?array(oneUPDATE ... SET used_at = now WHERE code_hash = %s AND used_at IS NULL AND expires_at > now, then read the row only when one row changed),add_device( string $family_id, string $hero_id, string $device_id, string $label, string $key_hash ): int,device_by_key_hash( string $hash ): ?array(unrevoked, hero not removed),devices( string $family_id ): array,revoke_device( string $family_id, int $id ): bool,touch_device( int $id, int $now ): void -
caller()readsX-XP-Hero-Key: unknown or revoked → 401xp_family_key_revoked; found → hero caller,touch_deviceat most once a minute - routes
POST /heroes/{hero_id}/link-codes→{ code, expires_at }(ISO);POST /devices/claim{ code, device_id, label }→{ key, family_id, hero_id, card };GET /devices→[ { id, hero_id, label, created_at, last_seen_at } ](unrevoked);DELETE /devices/{id}(parent, or the hero key of that device) →{ ok: true }. Claim failures count in a transientxp_family_claim_fail_{md5( $ip )}for an hour; over 10 → 429xp_family_rate_limited. A hero caller on a parent route → 403xp_family_forbidden
- store:
-
Step 1: Write the
phonessection: make a code forh-1(8 alphabet characters,expires_atabout 10 minutes ahead); claim it withABCD-EFGHformatting applied → keyxph_…; claim it again → 404xp_family_code_invalid; a code whoseexpires_atwas moved into the past withwp db query→ 404; a second code forh-1makes the first one fail; the key onGET /family→ 200 with noage_group; the key onPOST /heroes/h-1/link-codes→ 403xp_family_forbidden;GET /deviceslists the phone;DELETE /devices/{id}→ the key now gets 401xp_family_key_revoked;DELETE /heroes/h-1revokes a second phone ofh-1; 10 bad claims then an 11th → 429. -
Step 2: Run the
phonessection. Expected: FAIL. -
Step 3: Implement the store methods, the key branch of
caller()and the four routes. -
Step 4: Run
familyandphones. Expected: all PASS. -
Step 5: Commit in
$P:feat(family): link codes, hero keys and the phone list (MP5).
Files:
- Create:
$P/includes/family/class-xophz-compass-xp-family-events.php - Modify:
$P/includes/family/class-xophz-compass-xp-family-store.php,$P/includes/family/class-xophz-compass-xp-family-rest.php,$P/includes/class-xophz-compass-xp.php,$P/tests/rehearsal/family-smoke.sh
Interfaces:
-
Consumes: Tasks 2, 4, 5
-
Produces:
- store:
append_events( string $family_id, array $events ): array→[ 'accepted' => ids, 'duplicate' => ids ], insideGET_LOCK( 'xp_family_{family_id}', 5 )/RELEASE_LOCK(pattern:Gatekeeper_Xp_Pro_Db); ahero.cardalso callsset_card;events_after( string $family_id, int $since, int $limit ): array;counts_last_hour( string $family_id, int $now ): array→[ 'family' => int, 'by_hero' => [ id => int ] ] -
Xophz_Compass_Xp_Family_Events::grant_reward( string $family_id, string $hero_id, int $xp, int $ap, string $reason, string $source_id ): array|WP_Error(writerserver, deviceserver, idwp_generate_uuid4()) - routes
POST /events{ events: [...] }→{ accepted, duplicate, rejected: [ { id, code, message } ] }: more than 50 → 400xp_family_bad_request; a parent sending from a device that is nothome_device_id→ 409xp_family_moved; over the limits → 429xp_family_rate_limitedwithRetry-After: 600.GET /events?since=&limit=→[ { seq, id, hero_id, device_id, kind, scope, at, received_at, data } ], oldest first,limitdefault 100, at most 200, filtered byreadable_by
- store:
-
Step 1: Write the
eventssection: ahero.cardforh-1with an extrabirthday→ accepted, andGET /familyshows the card without it; the same id again →duplicate;GET /events?since=0has it once, withoutbirthday; areward.grantedfrom the parent →rejected;wp eval 'Xophz_Compass_Xp_Family_Events::grant_reward( "F1", "h-1", 25, 2, "party-bonus", "smoke-1" );'→h-1's phone reads it,h-2's phone does not, the parent does; 51 events → 400; forh-3, 120 events in three requests (50, 50, 20), then one more → 429 withRetry-After: 600; afterPOST /family/movetoD2, a send fromD1→ 409xp_family_moved; an event aged 91 days withwp db query, thenwp eval 'do_action( "xp_family_cleanup" );'→ gone. -
Step 2: Run the
eventssection. Expected: FAIL. -
Step 3: Implement the store methods, the events class and the two routes.
-
Step 4: Run
all. Expected: all PASS. PHP tests: PASS. -
Step 5: Commit in
$P:feat(family): the append-only event stream and server rewards (MP6).
Files:
- Create:
src/lib/services/multiplayer/multiplayerFlag.ts,src/lib/services/multiplayer/ageGroup.ts,src/lib/services/multiplayer/heroCard.ts,src/lib/services/multiplayer/familyEvents.ts - Test:
tests/unit/multiplayerFlag.spec.ts,tests/unit/ageGroup.spec.ts,tests/unit/heroCard.spec.ts,tests/unit/familyEvents.spec.ts
Interfaces:
-
Consumes:
HeroLook(src/lib/hero-look/types.ts) -
Produces:
-
multiplayerEnabled(opts?: { env?: string; override?: string | null; production?: boolean }): boolean(defaults readprocess.env,localStorage["xp:multiplayer"]in a try/catch,NODE_ENV) -
type AgeGroup = "child" | "teen" | "adult",AGE_GROUPS,ageGroupOf(profile: { isAdult?: unknown; birthday?: string }, picked?: AgeGroup, today?: Date): AgeGroup,needsAgePick(profile): boolean(not adult and no valid birthday) -
interface HeroCard { heroId: string; nickname: string; look: HeroLook; jobClass: string; level: number; updatedAt: string },cardOf(profile: any, now?: Date): HeroCard | null(null withoutname.nick;levelfromstats.level, default 1; lookDEFAULT_LOOKwhen missing),cardKey(card: HeroCard): string(JSON of every field butupdatedAt) -
type FamilyEventKind = "hero.card" | "hero.leveled" | "quest.done" | "reward.granted",interface FamilyEvent { id: string; heroId: string; deviceId: string; kind: FamilyEventKind; at: string; data: Record<string, unknown> },EVENT_KINDS(the Task 2 table: scope, writer, field names),newEvent(kind, heroId, deviceId, data, now?: Date, id?: string): FamilyEvent(keeps listed fields; throws for writerserver),cardEvent(card: HeroCard, deviceId: string): FamilyEvent,leveledEvent(heroId, level, jobClass, deviceId): FamilyEvent,questDoneEvent(heroId, deviceId, d: { difficulty: number; xp: number; fight: boolean }): FamilyEvent
-
-
Step 1: Write the failing tests:
it("is off by default and on with the build flag", () => {
expect(multiplayerEnabled({ env: undefined, override: null, production: true })).toBe(false);
expect(multiplayerEnabled({ env: "true", override: null, production: true })).toBe(true);
});
it("takes the override only in development", () => {
expect(multiplayerEnabled({ env: undefined, override: "on", production: false })).toBe(true);
expect(multiplayerEnabled({ env: undefined, override: "on", production: true })).toBe(false);
});
const today = new Date("2026-10-13T12:00:00");
it.each([
[{ isAdult: true }, undefined, "adult"],
[{ birthday: "2013-10-14" }, undefined, "child"],
[{ birthday: "2013-10-13" }, undefined, "teen"],
[{ birthday: "2008-10-13" }, undefined, "adult"],
[{}, "teen", "teen"],
[{}, undefined, "child"],
[{ birthday: "soon" }, "teen", "teen"],
])("ageGroupOf(%j, %s) is %s", (p, picked, group) => expect(ageGroupOf(p, picked as AgeGroup, today)).toBe(group));
it("a card holds only its fields", () => {
const card = cardOf({ id: "h-1", name: { nick: "Mia", full: "Mia Pollard" }, birthday: "2015-01-01", email: "[email protected]", look: DEFAULT_LOOK, jobClass: "warrior", stats: { level: 4 } }, today)!;
expect(Object.keys(card).sort()).toEqual(["heroId", "jobClass", "level", "look", "nickname", "updatedAt"]);
expect(card.level).toBe(4);
});
it("no nickname, no card", () => expect(cardOf({ id: "h-1", name: { nick: "" } })).toBeNull());
it("cardKey ignores the time", () => expect(cardKey({ ...card, updatedAt: "x" })).toBe(cardKey({ ...card, updatedAt: "y" })));
it("mirrors the server's kinds", () => expect(EVENT_KINDS).toEqual({
"hero.card": { scope: "public", writer: "home", fields: ["nickname", "look", "jobClass", "level"] },
"hero.leveled": { scope: "public", writer: "home", fields: ["level", "jobClass"] },
"quest.done": { scope: "public", writer: "home", fields: ["difficulty", "xp", "fight"] },
"reward.granted": { scope: "family", writer: "server", fields: ["xp", "ap", "reason", "sourceId"] },
}));
it("drops unlisted fields", () => expect(newEvent("quest.done", "h-1", "d1", { xp: 40, questName: "Take medicine", difficulty: 2, fight: false }).data).toEqual({ xp: 40, difficulty: 2, fight: false }));
it("never builds a server kind", () => expect(() => newEvent("reward.granted", "h-1", "d1", {})).toThrow());- Step 2: Run
chemx test tests/unit/multiplayerFlag.spec.ts tests/unit/ageGroup.spec.ts tests/unit/heroCard.spec.ts tests/unit/familyEvents.spec.ts. Expected: FAIL. - Step 3: Implement the four modules. Event ids from
uuidv4;atisnow.toISOString(). - Step 4: Run the specs. Expected: PASS.
- Step 5: Commit after
chemx verify:commit_paths "feat(multiplayer): the flag, age groups, hero cards and event kinds (MP7)" <the eight files>
Files:
- Create:
src/lib/services/multiplayer/familyServer.ts - Modify:
src/lib/services/townhall/townHallClient.ts - Test:
tests/unit/townHallClient.spec.ts(add cases),tests/unit/familyServer.spec.ts
Interfaces:
-
Consumes: Task 7 types;
townHallFetch,TownHallAuth,TownHallError(TH2) -
Produces:
-
TownHallAuthgains| { kind: "hero"; key: string }→ headerX-XP-Hero-Key;TownHallErrorconstructor(code, message?, status?, siteCode?: string, retryAfterSec?: number), filled from the body'scodeand theRetry-Afterheader -
familyServer.ts:type BindAnswer = "created" | "home" | "home-elsewhere" | "other-family";interface ServerHero { heroId: string; nickname: string; ageGroup?: AgeGroup; card: HeroCard | null };interface LinkCode { code: string; expiresAt: string };interface HeroPhone { id: number; heroId: string; label: string; createdAt: string; lastSeenAt: string | null };interface Claimed { key: string; familyId: string; heroId: string; card: HeroCard | null };interface SendResult { accepted: string[]; duplicate: string[]; rejected: { id: string; code: string; message: string }[] };interface ReceivedEvent extends FamilyEvent { seq: number; scope: "public" | "family"; receivedAt: string };type FamilyProblem = "offline" | "not-member" | "taken" | "moved" | "code-invalid" | "key-revoked" | "unlinked" | "rate-limited" | "invalid" | "server" -
bindFamily(auth, familyId, deviceId): Promise<BindAnswer>,moveHome(auth, familyId, deviceId): Promise<void>,getFamily(auth): Promise<{ familyId: string; heroes: ServerHero[] }>,saveHero(auth, heroId, nickname, ageGroup): Promise<void>,removeHero(auth, heroId): Promise<void>,makeLinkCode(auth, heroId): Promise<LinkCode>,claimDevice(code, deviceId, label): Promise<Claimed>,listPhones(auth): Promise<HeroPhone[]>,removePhone(auth, id): Promise<void>,sendEvents(auth, events: FamilyEvent[]): Promise<SendResult>,fetchEvents(auth, since: number, limit?: number): Promise<ReceivedEvent[]> -
familyProblem(e: unknown): FamilyProblemandretryAfterMs(e: unknown): number | undefined
-
-
Step 1: Write the failing tests: hero auth →
{ "X-XP-Hero-Key": "xph_k" }; a 429 with body{ code: "xp_family_rate_limited" }andRetry-After: 600rejects with{ code: "server", status: 429, siteCode: "xp_family_rate_limited", retryAfterSec: 600 }. WithtownHallFetchmocked:bindFamilypostsxp-family/v1/familywith{ family_id, device_id }and returnsanswer;saveHeropostsxp-family/v1/heroes/h-1with{ nickname: "Mia", age_group: "child" };claimDeviceuses{ kind: "none" }and mapsfamily_id/hero_id;sendEventsposts{ events: [{ id, hero_id, device_id, kind, at, data }] };fetchEvents(auth, 41)sendssince=41&limit=100and mapsreceived_at;familyProblemmaps:offline→offline; siteCodexp_family_not_member→not-member,xp_family_taken→taken,xp_family_moved→moved,xp_family_code_invalid→code-invalid,xp_family_key_revoked→key-revoked; any other 401→unlinked; 429→rate-limited; 400→invalid; elseserver. -
Step 2: Run both specs. Expected: FAIL.
-
Step 3: Implement the client additions and
familyServer.ts. -
Step 4: Run both specs and
tests/unit/townHallClient.spec.ts's existing cases. Expected: PASS. -
Step 5: Commit after
chemx verify:commit_paths "feat(multiplayer): the family routes client; hero keys and site error codes in the Town Hall client (MP8)" <the four files>
Files:
- Create:
src/lib/services/multiplayer/gameSignals.ts - Modify:
src/lib/services/quests/QuestCompletionService.ts,src/lib/databases/ProfileDb.ts - Test:
tests/unit/gameSignals.spec.ts, and the existing QuestCompletionService spec (chemx q -g QuestCompletionService tests/unitfinds it)
Interfaces:
-
Produces:
interface QuestPaid { heroId: string; questId: string; xp: number; fight: boolean };onQuestPaid(fn): () => void,emitQuestPaid(p: QuestPaid): void,onHeroRemoved(fn: (heroId: string) => void): () => void,emitHeroRemoved(heroId: string): void. A listener that throws is logged withdebugand never breaks the game. -
Step 1: Write the failing tests: a listener hears each emit and stops after its unsubscribe; a throwing listener does not stop the next one;
completeQuestpaid at once emits{ heroId, questId, xp: <owed xp>, fight: false }(withsource: "battle",fight: true); a heldcompleteQuestemits nothing;approveCompletionemits once, and a second approval of the same entry emits nothing;recordBattleCompletionemits withfight: true;ProfileDb.removeProfile("h-1")emitsh-1. -
Step 2: Run both specs. Expected: FAIL.
-
Step 3: Implement the bus and add the four emit calls (
completeQuestwhen it pays,approveCompletionwhen it pays,recordBattleCompletion,removeProfile), each after the change it reports. -
Step 4: Run. Expected: PASS.
-
Step 5: Commit after
chemx verify:commit_paths "feat(multiplayer): signals when a quest pays and when a hero is removed (MP9)" <files>
Files:
- Create:
src/lib/services/multiplayer/eventOutbox.ts,src/lib/databases/FamilyEventsDb.ts - Test:
tests/unit/eventOutbox.spec.ts,tests/unit/FamilyEventsDb.spec.ts
Interfaces:
-
Consumes: Tasks 7 and 8
-
Produces:
-
interface QueuedEvent extends FamilyEvent { status: "queued" | "sending"; tries: number; nextTryAt?: number };enqueue(outbox, e: FamilyEvent): QueuedEvent[];resetSending(outbox): QueuedEvent[];nextBatch(outbox, now: number, max = 50): QueuedEvent[](queued, due, oldestatfirst);markSending(outbox, ids): QueuedEvent[];settle(outbox, r: SendResult): QueuedEvent[];backoff(outbox, ids, now, retryAfterMs?: number): QueuedEvent[];backoffMs(tries: number): number(5 s, 30 s, 2 min, 10 min, then 30 min) -
FamilyEventsDb(__xp_family_events, keystate):type HomeStatus = "home" | "home-elsewhere" | "other-family" | "unknown";interface FamilyEventsState { outbox: QueuedEvent[]; lastSeq: number; paidRewards: { id: string; at: string }[]; lastCards: Record<string, string>; ageGroupPicks: Record<string, AgeGroup>; home: HomeStatus };EMPTY_FAMILY_EVENTS: FamilyEventsState;getState()(empty state when nothing is stored),saveState(s);getFamilyEventsDb()
-
-
Step 1: Write the failing tests:
nextBatchreturns at most 50, skipssendingand not-yet-due items, oldest first;settledrops accepted, duplicate and rejected ids and keeps the rest;backoffputs items back toqueuedwithtries + 1andnextTryAt = now + backoffMs(tries + 1), ornow + retryAfterMswhen given;resetSendingturns everysendingintoqueued(Review Focus 3);backoffMsfor 1..6 is[5000, 30000, 120000, 600000, 1800000, 1800000]; the db returnsEMPTY_FAMILY_EVENTSwhen empty and round-trips a state;BACKUP_STOREShas no__xp_family_events. -
Step 2: Run. Expected: FAIL.
-
Step 3: Implement (database pattern:
ProDb.ts). -
Step 4: Run. Expected: PASS.
-
Step 5: Commit after
chemx verify:commit_paths "feat(multiplayer): the event send queue and its database (MP10)" <files>
Files:
- Create:
src/lib/store/stores/family.ts,src/lib/services/copy/catalog/family.ts,src/lib/services/multiplayer/familyPrompts.ts - Modify:
src/lib/services/copy/catalog/index.ts - Test:
tests/unit/familyStore.spec.ts,tests/unit/familyPrompts.spec.ts
Interfaces:
-
Consumes: Tasks 7 to 10;
useTownHallStore(TH10:auth,isLinked);ensureIdentity(familyIdentity.ts);useUserStore().users;CLERK,noticeOf,yesNoOf -
Produces:
-
useFamilyStore(setup style, likepro.ts): statehome: HomeStatus,heroes: ServerHero[],problem: FamilyProblem | null; computedenabled,parentAuth: TownHallAuth | null(the Town Hall'sauthwhenisLinked); actionsinit(): Promise<void>(once; returns the same promise),bind(): Promise<void>,makeHome(): Promise<void>,registerHeroes(): Promise<void>,setAgeGroup(heroId: string, group: AgeGroup): Promise<void> -
bind:createdorhome→home = "home",registerHeroes();home-elsewhere/other-family→ that status, nothing registered;taken/not-member→problem.registerHeroessends every profile with a card, Game Masters included, withageGroupOf(profile, picks[id]) -
onHeroRemoved→removeHero, only whilehome === "home" - copy keys (adventure voice; the other voices fall back):
family.homeElsewhere"This family's home is another device. Make this one home?",family.otherFamily"This account already runs another family. Bring it here with a backup file, or link this phone as a hero's phone with a code from the family's device.",family.taken"This family is registered to another account.",family.notMember"The site doesn't know your family yet. Register the family with me first.",family.offline"The courier hasn't arrived. I'll keep trying.",family.needsNickname"{name} needs a nickname first. Friends will see it, so not their full name.",family.codeInvalid"That code didn't work. Ask your Game Master for a new one.",family.unlinked"This phone was unlinked. Ask your Game Master to link it again.",family.questsElsewhere"Your quests are on the family device.",family.linked"Welcome, {nickname}! This phone is yours now.",family.removePhone"Unlink {label}? It stops working right away." -
familyPrompts.ts:homeQuestionPrompt(voice?)(yes/no),familyNotice(key, vars?, voice?)(a clerk notice for anyfamily.*key)
-
-
Step 1: Write the failing tests (mocks:
familyServer,FamilyEventsDb,familyIdentity,townHallstore; patterntests/unit/proStore.spec.ts): flag off → no server call; linked +created→saveHerofor each profile with a nickname (a GM asadult, a kid with no birthday aschild), none for a hero without one;home-elsewhere→ nosaveHero;makeHomecallsmoveHomethenbindFamily; atakenrejection setsproblem: "taken";setAgeGroupstores the pick and callssaveHero;emitHeroRemoved("h-1")callsremoveHero; withusersempty at init and filled a tick later,removeHerois never called (Review Focus 2);init()twice binds once.familyPrompts: each key renders throughcopyFor; the home question has Yes and No. -
Step 2: Run. Expected: FAIL.
-
Step 3: Implement the store, the copy file (added to
CATALOG_PARTSandCATALOG) and the prompts.init()runs fromAppsetup only whenenabled. -
Step 4: Run both specs and the copy catalogue spec. Expected: PASS.
-
Step 5: Commit after
chemx verify:commit_paths "feat(multiplayer): bind the family, register heroes, the clerk's lines (MP11)" <files>
Files:
- Modify:
src/lib/store/stores/family.ts - Test:
tests/unit/familyStore.spec.ts
Interfaces:
-
Consumes: Tasks 9 to 11;
AchievementDbwithachievementStorage(get(questId)fordifficulty) -
Produces: actions
queueCards(): Promise<void>,flush(): Promise<void>(single-flight; batches until the queue is empty or a send fails); the moments: afterinit, 5 s after something is queued, the browseronlineevent,documentbecoming visible; a deep watch onuseUserStore().users(debounced 2 s) callsqueueCards. Nothing is queued unlessenabledandhome === "home".-
queueCards: a hero with nolastCardsentry is first registered withsaveHero(a hero added after binding); a hero whosecardKeydiffers fromlastCards[id]gets ahero.card; a level higher than the last card's adds ahero.leveled; thenlastCardsis saved - failures:
unlinkedcalls the Town Hall store'sforgetLink()and keeps the queue;not-memberandtakensetproblem; rejected ids are logged withdebug -
onQuestPaid→quest.donewith the quest'sdifficulty(0 when the quest is gone)
-
-
Step 1: Write the failing tests: after
bind, onehero.cardper hero; an unchanged profile queues nothing; a hero added afterbindis saved withsaveHero, then its card queued; anunlinkedfailure callsforgetLinkand keeps the events; level 3 → 4 queueshero.cardandhero.leveled;emitQuestPaidqueuesquest.donewith the stored difficulty;flushsends one batch and settles it; twoflush()calls at once send once; anofflinefailure leaves the events queued with anextTryAt; arate-limitedfailure waitsretryAfterMs;home: "home-elsewhere"queues nothing; amovedfailure setshometo"home-elsewhere"and stops sending. -
Step 2: Run. Expected: FAIL.
-
Step 3: Implement.
-
Step 4: Run. Expected: PASS.
-
Step 5: Commit after
chemx verify:commit_paths "feat(multiplayer): hero cards, quest events and the send moments (MP12)" src/lib/store/stores/family.ts tests/unit/familyStore.spec.ts
Files:
- Create:
src/app/SideMenu/XpSettings/components/XpFamilyPhones.vue - Modify:
src/app/SideMenu/XpSettings/components/FamilySettings.vue(one line: the card, under the heroes, whencanManage) - Test:
tests/unit/XpFamilyPhones.spec.ts(patterntests/unit/XpRevealPrompt.spec.ts)
Interfaces:
-
Consumes: Task 11 (
useFamilyStore),needsAgePick,cardOf,familyNotice,homeQuestionPrompt -
Produces:
<xp-family-phones />, rendered only whenenabledand the family is linked; emits nothing. Each hero row: nickname (or thefamily.needsNicknameline with an Edit button that opens the existing profile form), an age-group picker whenneedsAgePick, and a Phones button (opens Task 15's modal; disabled until Task 15 exists, and while the hero has no nickname). Ahome-elsewherebanner with Make this home (homeQuestionPrompt, thenmakeHome);other-family,takenandnot-memberbanners with their lines (not-memberlinks to the Town Hall's Family registration). -
Step 1: Write the failing tests: rows for two heroes; one without a nickname shows the nickname line and no Phones button; the age picker shows for a kid with no birthday, not for a GM; picking
teencallssetAgeGroup("h-2", "teen");home: "home-elsewhere"shows the banner and Yes callsmakeHome. -
Step 2: Run. Expected: FAIL.
-
Step 3: Implement.
-
Step 4: Run. Expected: PASS.
-
Step 5: Commit after
chemx verify:commit_paths "feat(multiplayer): the family card in Family Settings (MP13)" <files>
Files:
- Create:
src/lib/services/multiplayer/deviceLink.ts,src/lib/databases/HeroDeviceDb.ts - Modify:
src/lib/store/stores/family.ts - Test:
tests/unit/deviceLink.spec.ts,tests/unit/HeroDeviceDb.spec.ts,tests/unit/familyStore.spec.ts
Interfaces:
-
Consumes: Tasks 8 and 11;
usesHashHistory;adoptFamily;townHallSiteUrl -
Produces:
-
deviceLink.ts:CODE_ALPHABET,normalizeCode(typed: string): string | null,formatCode(code: string): string,linkUrl(code: string, loc: { origin: string; pathname: string }, hash: boolean, site?: string): string,readLink(href: string, production: boolean): { code: string; site?: string } | null(reads the query in either the search or the hash),phoneLabel(nickname: string, userAgent: string, platform: string): string(ios→{n}'s iPhone,android→{n}'s Android; web: Android, iPhone, iPad, Windows PC, Mac, Linux, else{n}'s phone) -
HeroDeviceDb(__xp_hero_device, keydevice):interface HeroDevice { siteUrl: string; familyId: string; heroId: string; deviceId: string; key: string; label: string; linkedAt: number; card: HeroCard | null; unlinked?: boolean };getDevice(),saveDevice(d),clearDevice();getHeroDeviceDb() - store: state
device: HeroDevice | null,phones: HeroPhone[]; computedisKidMode(device !== null); actionsclaim(code: string): Promise<"linked" | FamilyProblem>,refreshCard(): Promise<void>(hero auth;key-revokedmarksunlinked),forgetDevice(): Promise<void>,linkCode(heroId): Promise<LinkCode>,loadPhones(): Promise<void>,removePhone(id: number): Promise<void>. In kid modeinitloads the device, callsrefreshCard, and binds nothing.
-
-
Step 1: Write the failing tests:
normalizeCode("abcd efgh")and("ABCD-EFGH")→ABCDEFGH,("ABCDEFG0")→ null (Review Focus 5);formatCode("ABCDEFGH")→ABCD-EFGH;linkUrl("ABCDEFGH", { origin: "https://app.forthexp.com", pathname: "/" }, true)→https://app.forthexp.com/#/link?code=ABCDEFGH, path mode →https://app.forthexp.com/link?code=ABCDEFGH;readLinkof both, andsiteread in development and dropped in production;phoneLabel("Mia", "", "ios")→Mia's iPhone; store:claimsaves the device, callsadoptFamily(familyId)and setsisKidMode; acode-invalidrejection saves nothing;refreshCardupdates the card; akey-revokedsetsunlinked: true;forgetDeviceclears it;BACKUP_STOREShas no__xp_hero_device. -
Step 2: Run. Expected: FAIL.
-
Step 3: Implement.
-
Step 4: Run. Expected: PASS.
-
Step 5: Commit after
chemx verify:commit_paths "feat(multiplayer): link codes, the phone's key and kid-mode state (MP14)" <files>
Files:
- Create:
src/app/SideMenu/XpSettings/components/XpHeroPhonesModal.vue - Modify:
package.json(qrcode,@types/qrcode),src/app/SideMenu/XpSettings/components/XpFamilyPhones.vue(the Phones button opens the modal) - Test:
tests/unit/XpHeroPhonesModal.spec.ts
Interfaces:
-
Consumes: Task 14 (
linkCode,loadPhones,removePhone,linkUrl,formatCode);QRCode.toDataURL(url, { margin: 1, width: 240 }) -
Produces: modal props
{ heroId: string; nickname: string }. Link a phone makes a code and shows the QR image,ABCD-EFGHand am:sscountdown toexpiresAt; at 0:00 the QR goes and New code appears. The phone list shows label, linked and last seen; Remove asksfamily.removePhoneand callsremovePhone. -
Step 1: Write the failing tests (
qrcodemocked, fake timers): Link a phone shows the formatted code and an<img>fromtoDataURLwith the hash-mode link; the countdown reads10:00, then9:59; at expiry the image is gone and New code shows; Remove with Yes callsremovePhone(3), with No does not. -
Step 2: Run. Expected: FAIL.
-
Step 3:
pnpm add qrcode && pnpm add -D @types/qrcode, then implement. -
Step 4: Run. Expected: PASS.
-
Step 5: Commit after
chemx verify:commit_paths "feat(multiplayer): Link a phone with a QR, a code and the phone list (MP15)" package.json pnpm-lock.yaml <files>
Files:
- Create:
src/app/SideMenu/HeroPhone/LinkPhone.vue,LinkPhone.ts,_LinkPhone.scss - Modify:
src/router/SideMenu/side-menu.routes.ts(/link,link-phone,meta.requiresAuth: false),src/app/SideMenu/SwitchProfile/SwitchProfile.vue(empty state: I'm a hero with a family code, whenenabled),package.json(@capacitor-mlkit/barcode-scanning6.x),ios/App/App/Info.plist(NSCameraUsageDescription),android/app/src/main/AndroidManifest.xml(camera permission) - Test:
tests/unit/LinkPhone.spec.ts
Interfaces:
-
Consumes: Task 14 (
claim,normalizeCode,readLink) -
Produces:
/linkclaims at once when the route has a validcode(andsiteoutside production, saved asxp:townHallSite); otherwise it shows a code field and, on the phone apps, Scan (BarcodeScanner.scan({ formats: [BarcodeFormat.QrCode] }), the result read withreadLink).linked→family.linked, thenrouter.replace({ name: "hero-phone" }); a problem → itsfamily.*line. -
Step 1: Write the failing tests (store and scanner mocked):
?code=abcd-efghclaimsABCDEFGHon mount and goes tohero-phone; no code shows the field; typingABCD EFGHand Link claims;code-invalidshowsfamily.codeInvalidand stays; Scan is hidden on the web. -
Step 2: Run. Expected: FAIL.
-
Step 3:
pnpm add @capacitor-mlkit/barcode-scanning@^6,npx cap sync, then implement. -
Step 4: Run. Expected: PASS.
-
Step 5: Commit after
chemx verify:commit_paths "feat(multiplayer): a kid's phone claims its hero by QR or code (MP16)" <files>
Files:
- Create:
src/router/_Guards/heroPhone.guard.ts,src/app/SideMenu/HeroPhone/HeroPhone.vue,HeroPhone.ts,_HeroPhone.scss - Modify:
src/router/_Guards/index.ts(the hero-phone guard runs first),src/router/_Guards/gm.guard.ts(canAccessGameMaster(view = useUserStore(), kidMode = useFamilyStore().isKidMode): false in kid mode),src/router/SideMenu/side-menu.routes.ts(/hero-phone,hero-phone,meta.requiresAuth: false) - Test:
tests/unit/heroPhoneGuard.spec.ts,tests/unit/GmGuard.spec.ts(add a case),tests/unit/HeroPhone.spec.ts
Interfaces:
-
Consumes: Task 14
-
Produces:
handleHeroPhoneGuard(to: { name?: unknown }, isKidMode: boolean): { name: string } | void(kid mode:hero-phoneandlink-phonepass, everything else goes tohero-phone); the guardsawait useFamilyStore().init()before it./hero-phoneshows<xp-hero-model :look="card.look" animation="idle" />, the nickname, class and level, andfamily.questsElsewhere; it callsrefreshCardon mount and when the app returns to the front. Withunlinked, it showsfamily.unlinkedand Start fresh (forgetDevice, thenrouter.replace("/xp-profile")). -
Step 1: Write the failing tests: the guard sends
xp-dashboard,my-portalandlog-intohero-phonein kid mode and passes everything outside it;canAccessGameMaster(gmView, true)is false; the page shows the card's nickname and level; withunlinkedit shows the line and Start fresh clears the device and leaves. -
Step 2: Run. Expected: FAIL.
-
Step 3: Implement.
-
Step 4: Run the three specs. Expected: PASS.
-
Step 5: Commit after
chemx verify:commit_paths "feat(multiplayer): kid mode: one hero, no Game Master area, the unlinked screen (MP17)" <files>
Files:
- Create:
src/lib/services/multiplayer/eventInbox.ts - Modify:
src/lib/databases/QuestLogDb.ts(QuestLogSourcegains"party"),src/lib/store/stores/family.ts - Test:
tests/unit/eventInbox.spec.ts,tests/unit/familyStore.spec.ts
Interfaces:
-
Consumes: Tasks 8, 10, 12;
applyQuestRewards,getQuestLogDb(logCompletion,updateEntry,getAll) -
Produces:
-
eventInbox.ts:interface RewardToPay { id: string; heroId: string; xp: number; ap: number; reason: string };rewardsToPay(events: ReceivedEvent[], paid: { id: string }[]): RewardToPay[];newestSeq(events, lastSeq): number;markPaid(paid, ids, now: Date);trimPaid(paid, now: Date)(drops entries over 90 days) - store action
download(): Promise<void>(home only; pages throughfetchEventsfromlastSeq). For each reward, the quest log entry withquestId: "party:{event id}"is the receipt: none →logCompletion({ questId, questName: "Party bonus", userId, status: "approved", source: "party", rewards: { xp, gp: 0, ap } }), thenapplyQuestRewards(userId, rewards, questId), thenupdateEntry(id, { rewardsApplied: true }); an entry withrewardsApplied: falseis paid and marked; withtrueit is only marked. ThenpaidRewardsandlastSeqare saved. Runs afterinit, after aflush, when the app returns to the front, and every 5 minutes while open.
-
-
Step 1: Write the failing tests:
rewardsToPayskips paid ids and non-reward kinds;newestSeq;trimPaidat 91 days; store: a reward pays XP and AP once and writes a Party bonus log line; downloading the same event again pays nothing; a lostlastSeq(0) with the id inpaidRewardspays nothing; a log entry already markedrewardsAppliedpays nothing; one withrewardsApplied: falseis paid once (Review Focus 4); GP is never paid. -
Step 2: Run. Expected: FAIL.
-
Step 3: Implement.
-
Step 4: Run. Expected: PASS.
-
Step 5: Commit after
chemx verify:commit_paths "feat(multiplayer): server rewards are paid once on the home device (MP18)" <files>
Files:
- Create:
tests/e2e/specs/family.spec.js,tests/e2e/fixtures/family/*.json
Interfaces:
-
Consumes: Tasks 13 to 17;
cy.seedWorld(); the Town Hall link step oftests/e2e/specs/town-hall.spec.js(visit the Town Hall return address with the three query values, interceptusers/meandintrospect) -
Produces: a spec that sets
localStorage["xp:multiplayer"] = "on", intercepts**/wp-json/xp-family/v1/**from fixtures (never the live site) and covers: Family Settings shows the card with the heroes and the age picker; Phones → Link a phone shows a QR image,ABCD-EFGHand the countdown;/link?code=ABCDEFGH(fresh storage) lands on/hero-phonewith the nickname and level; a bad code shows the clerk's line;GET /familyanswering 401xp_family_key_revokedshows the unlinked screen, and Start fresh returns to the profile chooser. -
Step 1: Write the spec.
-
Step 2: Run it. Expected: PASS (it exercises shipped code; a failure is a bug in Tasks 13 to 17, fixed there).
-
Step 3: Commit:
commit_paths "test(multiplayer): the family card, Link a phone and kid mode in Cypress (MP19)" <files>
Files:
- Create:
tests/e2e/specs/family-rehearsal.spec.js - Modify:
tools/townhall-rehearsal/README.md(a "Family" section)
Interfaces:
-
Consumes: everything above; the rehearsal network;
$P/tests/rehearsal/family-smoke.sh -
Produces: a spec run only with
--env rehearsal=1(it is skipped otherwise), againsthttp://localhost:8090/doitwithxp:townHallSiteset and no intercepts. Sequence:cy.execmakes a Town Hall Application Password forfamparentwithwp user application-password create … --porcelain(written to the gitignored credentials file, never logged); the parent's device links through the return address; Family Settings shows the heroes registered (checked withcy.execwp db query); Link a phone gives a code;grant_rewardthroughcy.execwp eval, then the hero's XP rises once and the quest log has one Party bonus; storage is cleared and/link?code=…lands on/hero-phone; the phone is removed withcy.requestDELETE /devices/{id}as the parent, and the phone shows the unlinked screen. -
Step 1:
$P/tests/rehearsal/family-smoke.sh all. Expected: all PASS. -
Step 2: Write the rehearsal spec and the README section (how to run both, how to reset the network).
-
Step 3: Run
pnpm vue-cli-service test:e2e --headless --browser chrome --url http://localhost:8100 --spec tests/e2e/specs/family-rehearsal.spec.js --env rehearsal=1. Expected: PASS. -
Step 4: Commit:
commit_paths "test(multiplayer): the shared base end to end on the rehearsal network (MP20)" tests/e2e/specs/family-rehearsal.spec.js tools/townhall-rehearsal/README.md -
Step 5: Owner's site change (listed, not done): install the updated
xophz-compass-xpon doit.forthexp.com after this run passes. The app flag stays off in production until part 2 (A) ships.