2026 10 13 multiplayer shared base - xopherdeep/do-it-for-the-xp GitHub Wiki

Multiplayer Shared Base Implementation Plan

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

Goal: 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).

Global Constraints

  • Gates and reads go through chemx (chemx test <spec>, chemx typecheck, chemx lint, chemx verify, chemx read, chemx q); never raw pnpm test, pnpm jest, pnpm typecheck, pnpm lint, grep or cat for code. Run chemx verify before every commit in the app repo.
  • After editing a page's .ts whose .vue uses <script src="./X.ts">, run its spec with npx jest <spec> --no-cache once before chemx verify.
  • One dev server: if curl -s localhost:8100 answers, use it; never start another.
  • Cypress: pnpm vue-cli-service test:e2e --headless --browser chrome --url http://localhost:8100 --spec <spec>.
  • App repo commits: define commit_paths exactly as in TH's Global Constraints and use it; never git add, git stash or plain git commit. Run chemx 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> then git -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.mjs from ../Xophz-COMPASS (the My Compass u-wordpress container must be up). Never loadWp in 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 in apply_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; key xph_ + 32 random bytes base64url without padding (47 characters); stored as SHA-256 hex
    • link code: 8 characters from ABCDEFGHJKMNPQRSTUVWXYZ23456789, shown ABCD-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, data at most 8192 bytes as JSON, 120 per hero and 600 per family per hour (429 with Retry-After: 600), at no 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 hook xp_family_cleanup (events over 90 days, expired codes, devices revoked over 30 days)
    • app flag: process.env.VUE_APP_MULTIPLAYER === "true", or in development only localStorage["xp:multiplayer"] === "on"
    • device databases __xp_family_events (key state) and __xp_hero_device (key device); neither is ever added to BACKUP_STORES
    • app routes /link (name link-phone) and /hero-phone (name hero-phone), both meta.requiresAuth: false
    • QR link: {origin}{pathname}#/link?code=ABCDEFGH with hash history, {origin}/link?code=ABCDEFGH otherwise; &site= is read only outside production
    • TOWN_HALL_APP_ID = "7606ce60-29c7-43b6-9533-2063ee726df4"
  • Wire format is snake_case (family_id, device_id, hero_id, age_group, expires_at, received_at); the app is camelCase. familyServer.ts is the only place that converts.
  • Site error codes: xp_family_not_member 403, xp_family_taken 403, xp_family_forbidden 403, xp_family_no_family 404, xp_family_unknown_hero 404, xp_family_code_invalid 404, xp_family_moved 409, xp_family_key_revoked 401, xp_family_rate_limited 429, xp_family_bad_request 400; per-event rejections use xp_family_event_invalid.
  • UI: new components are <script setup lang="ts">; pages are defineComponent with sibling .ts and _X.scss. Every message is a Town Clerk line through XpRevealPrompt (speaker: CLERK, accent: "townHall", sprite: "tablet"), worded in src/lib/services/copy/catalog/family.ts; no toasts or native alerts. Sounds only through play$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 tests require_once them alone.

Review Focus

  1. 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.
  2. The app starts with an empty users map 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.
  3. The app is closed while events are sending: on the next start they are queued again, not stuck. Pinned in Task 10.
  4. 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.
  5. Kids type codes as abcd efgh or ABCD-EFGH, and pick emoji nicknames: both work; a 41-emoji nickname is refused. Pinned in Task 1 and Task 14.

Before you start

  • TH1 and TH2 are committed (src/lib/services/townhall/townHallSite.ts, townHallClient.ts): chemx log -n 30 lists 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:8100 prints 200.

Phase 1: The server (plugin repo)

Task 1: Family rules

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 (all public 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 by mb_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-family or taken; $account_family is the family row owned by this user, $id_owner the row that already holds $family_id
    • is_family_site( string $home_url, array $allowed ): bool
    • may( string $caller, string $action ): bool, callers parent, hero, none; actions bind, 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)
  • Step 1: Write the failing tests (runPhpJson, require_once of 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 and tests/run-tests.mjs.

Task 2: Event rules

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 (all public static):

    • KINDS: kind → [ 'scope' => 'public'|'family', 'writer' => 'home'|'server'|'hero', 'fields' => [ name => type ] ], types int, bool, str40, str64, look:

      kind scope writer fields
      hero.card public home nickname str40, look look, jobClass str40, level int
      hero.leveled public home level int, jobClass str40
      quest.done public home difficulty int, xp int, fight bool
      reward.granted family server xp int, ap int, reason str40, sourceId str64
    • 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' => ... ]. $e is 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, or family of its own hero)

    • over_limit( array $hero_counts, int $family_count, array $incoming_by_hero ): bool

  • Step 1: Write the failing tests:

    • a hero.card from home with an extra birthday field and a look with an extra secret key: ok; data has no birthday, the look has no secret
    • id "abc" → not ok; unknown kind hero.secret → not ok; reward.granted with writer home → not ok, with writer server → ok
    • a kind with writer hero passed in $kinds: writer hero with key_hero_id h-1 and hero_id h-2 → not ok; with h-1 → ok
    • hero_id not in hero_ids → not ok; at = now + 86401 → not ok; at = now − 30 days → ok
    • data of 9000 bytes → not ok
    • quest.done with xp: "40" → xp dropped; xp: -5 → 0; xp: 999999 → 100000
    • readable_by: parent reads a sibling's family row; a hero reads public rows and its own family rows, not a sibling's family row
    • 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
  • Step 2: Run. Expected: FAIL.

  • Step 3: Implement the class. at is ISO 8601; parse with strtotime, refuse false. Measure the size with strlen( 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).

Task 3: The schema

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 TABLE SQL for {prefix}xp_families, {prefix}xp_family_heroes, {prefix}xp_family_devices, {prefix}xp_family_link_codes, {prefix}xp_family_events with the columns of spec section 2 (dbDelta style: two spaces after PRIMARY 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 from VERSION, dbDelta each table, update the option, schedule CLEANUP_HOOK daily if not scheduled
    • cleanup(): void: the three deletions of the Global Constraints
  • Step 1: Write the failing test: tables( 'wp_5_', 'DEFAULT CHARSET=utf8mb4' ) has the five names; xp_families contains UNIQUE KEY owner_user_id (owner_user_id); xp_family_heroes contains PRIMARY KEY (family_id,hero_id); xp_family_devices contains UNIQUE KEY key_hash (key_hash); xp_family_events contains UNIQUE KEY event_id (event_id) and KEY family_seq (family_id,seq); lintPhpFile passes.

  • 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).

Task 4: The family and its heroes on the site

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; hook Xophz_Compass_Xp_Family_Rest)

Interfaces:

  • Consumes: Tasks 1 and 3; TH7's rehearsal (http://localhost:8090/doit, the wpcli service, user famparent); TH8's class

  • Produces:

    • Xophz_Compass_Xp_Family_Store (instance methods, $wpdb->prefix tables): 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 unique owner_user_id makes 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 $at is newer than card_at; also sets nickname from 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 checks Family_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_group only 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 but POST /family → xp_family_no_family; taken → 403 xp_family_taken
    • family-smoke.sh [family|phones|events|all]: env SITE (default http://localhost:8090/doit) and COMPOSE (default $HOME/www/x/do-it-for-the-xp/tools/townhall-rehearsal/docker-compose.yml); helper wp() = docker compose -f "$COMPOSE" run --rm -T wpcli wp --url="$SITE" "$@"; parents get Application Passwords with wp user application-password create <user> smoke --app-id=<TOWN_HALL_APP_ID> --porcelain (TH8 makes them members); prints PASS <name> / FAIL <name> and exits 1 on any failure; each section starts with reset_family, which deletes the smoke families with wp db query, binds F1 with home D1 and registers h-1, h-2, h-3, so a section runs on its own
  • Step 1: Write the family section of the smoke script (fails now: the routes are missing). Checks, with family ids F1, F2, devices D1, D2, users famparent and a script-made smokeparent2:

    • no auth POST /family → 401
    • famparent POST /family {F1, D1} → created; again → home; with D2 → home-elsewhere; {F2, D2} → other-family
    • smokeparent2 POST /family {F1, D2} → 403 xp_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/) → 403 xp_family_not_member
    • famparent POST /heroes/h-1 {nickname: "Mia", age_group: "child"} → ok; {nickname: ""} → 400 xp_family_bad_request; {age_group: "toddler"} → 400; GET /family lists h-1 with age_group; DELETE /heroes/h-1 → gone; POST /heroes/h-1 again → back
    • POST /family/move {F1, D2} → ok; POST /family {F1, D2} → home
  • 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 family section. 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).

Task 5: Kid phones on the site

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 (one UPDATE ... 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() reads X-XP-Hero-Key: unknown or revoked → 401 xp_family_key_revoked; found → hero caller, touch_device at 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 transient xp_family_claim_fail_{md5( $ip )} for an hour; over 10 → 429 xp_family_rate_limited. A hero caller on a parent route → 403 xp_family_forbidden
  • Step 1: Write the phones section: make a code for h-1 (8 alphabet characters, expires_at about 10 minutes ahead); claim it with ABCD-EFGH formatting applied → key xph_…; claim it again → 404 xp_family_code_invalid; a code whose expires_at was moved into the past with wp db query → 404; a second code for h-1 makes the first one fail; the key on GET /family → 200 with no age_group; the key on POST /heroes/h-1/link-codes → 403 xp_family_forbidden; GET /devices lists the phone; DELETE /devices/{id} → the key now gets 401 xp_family_key_revoked; DELETE /heroes/h-1 revokes a second phone of h-1; 10 bad claims then an 11th → 429.

  • Step 2: Run the phones section. Expected: FAIL.

  • Step 3: Implement the store methods, the key branch of caller() and the four routes.

  • Step 4: Run family and phones. Expected: all PASS.

  • Step 5: Commit in $P: feat(family): link codes, hero keys and the phone list (MP5).

Task 6: The event stream and rewards on the site

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 ], inside GET_LOCK( 'xp_family_{family_id}', 5 ) / RELEASE_LOCK (pattern: Gatekeeper_Xp_Pro_Db); a hero.card also calls set_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 (writer server, device server, id wp_generate_uuid4())
    • routes POST /events { events: [...] } → { accepted, duplicate, rejected: [ { id, code, message } ] }: more than 50 → 400 xp_family_bad_request; a parent sending from a device that is not home_device_id → 409 xp_family_moved; over the limits → 429 xp_family_rate_limited with Retry-After: 600. GET /events?since=&limit= → [ { seq, id, hero_id, device_id, kind, scope, at, received_at, data } ], oldest first, limit default 100, at most 200, filtered by readable_by
  • Step 1: Write the events section: a hero.card for h-1 with an extra birthday → accepted, and GET /family shows the card without it; the same id again → duplicate; GET /events?since=0 has it once, without birthday; a reward.granted from 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; for h-3, 120 events in three requests (50, 50, 20), then one more → 429 with Retry-After: 600; after POST /family/move to D2, a send from D1 → 409 xp_family_moved; an event aged 91 days with wp db query, then wp eval 'do_action( "xp_family_cleanup" );' → gone.

  • Step 2: Run the events section. 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).

Phase 2: The family on the server (app repo)

Task 7: The flag and the event rules in the app

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 read process.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 without name.nick; level from stats.level, default 1; look DEFAULT_LOOK when missing), cardKey(card: HeroCard): string (JSON of every field but updatedAt)
    • 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 writer server), 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 uuid v4; at is now.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>

Task 8: Talking to the family routes

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:

    • TownHallAuth gains | { kind: "hero"; key: string } → header X-XP-Hero-Key; TownHallError constructor (code, message?, status?, siteCode?: string, retryAfterSec?: number), filled from the body's code and the Retry-After header
    • 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): FamilyProblem and retryAfterMs(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" } and Retry-After: 600 rejects with { code: "server", status: 429, siteCode: "xp_family_rate_limited", retryAfterSec: 600 }. With townHallFetch mocked: bindFamily posts xp-family/v1/family with { family_id, device_id } and returns answer; saveHero posts xp-family/v1/heroes/h-1 with { nickname: "Mia", age_group: "child" }; claimDevice uses { kind: "none" } and maps family_id/hero_id; sendEvents posts { events: [{ id, hero_id, device_id, kind, at, data }] }; fetchEvents(auth, 41) sends since=41&limit=100 and maps received_at; familyProblem maps: offline→offline; siteCode xp_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; else server.

  • 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>

Task 9: Game signals

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/unit finds 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 with debug and 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; completeQuest paid at once emits { heroId, questId, xp: <owed xp>, fight: false } (with source: "battle", fight: true); a held completeQuest emits nothing; approveCompletion emits once, and a second approval of the same entry emits nothing; recordBattleCompletion emits with fight: true; ProfileDb.removeProfile("h-1") emits h-1.

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

  • Step 3: Implement the bus and add the four emit calls (completeQuest when it pays, approveCompletion when 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>

Task 10: The send queue

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, oldest at first); 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, key state): 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: nextBatch returns at most 50, skips sending and not-yet-due items, oldest first; settle drops accepted, duplicate and rejected ids and keeps the rest; backoff puts items back to queued with tries + 1 and nextTryAt = now + backoffMs(tries + 1), or now + retryAfterMs when given; resetSending turns every sending into queued (Review Focus 3); backoffMs for 1..6 is [5000, 30000, 120000, 600000, 1800000, 1800000]; the db returns EMPTY_FAMILY_EVENTS when empty and round-trips a state; BACKUP_STORES has 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>

Task 11: The family store: binding and heroes

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, like pro.ts): state home: HomeStatus, heroes: ServerHero[], problem: FamilyProblem | null; computed enabled, parentAuth: TownHallAuth | null (the Town Hall's auth when isLinked); actions init(): Promise<void> (once; returns the same promise), bind(): Promise<void>, makeHome(): Promise<void>, registerHeroes(): Promise<void>, setAgeGroup(heroId: string, group: AgeGroup): Promise<void>
    • bind: created or home → home = "home", registerHeroes(); home-elsewhere/other-family → that status, nothing registered; taken/not-member → problem. registerHeroes sends every profile with a card, Game Masters included, with ageGroupOf(profile, picks[id])
    • onHeroRemoved → removeHero, only while home === "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 any family.* key)
  • Step 1: Write the failing tests (mocks: familyServer, FamilyEventsDb, familyIdentity, townHall store; pattern tests/unit/proStore.spec.ts): flag off → no server call; linked + created → saveHero for each profile with a nickname (a GM as adult, a kid with no birthday as child), none for a hero without one; home-elsewhere → no saveHero; makeHome calls moveHome then bindFamily; a taken rejection sets problem: "taken"; setAgeGroup stores the pick and calls saveHero; emitHeroRemoved("h-1") calls removeHero; with users empty at init and filled a tick later, removeHero is never called (Review Focus 2); init() twice binds once. familyPrompts: each key renders through copyFor; the home question has Yes and No.

  • Step 2: Run. Expected: FAIL.

  • Step 3: Implement the store, the copy file (added to CATALOG_PARTS and CATALOG) and the prompts. init() runs from App setup only when enabled.

  • 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>

Task 12: The family store: cards, quest events and sending

Files:

  • Modify: src/lib/store/stores/family.ts
  • Test: tests/unit/familyStore.spec.ts

Interfaces:

  • Consumes: Tasks 9 to 11; AchievementDb with achievementStorage (get(questId) for difficulty)

  • Produces: actions queueCards(): Promise<void>, flush(): Promise<void> (single-flight; batches until the queue is empty or a send fails); the moments: after init, 5 s after something is queued, the browser online event, document becoming visible; a deep watch on useUserStore().users (debounced 2 s) calls queueCards. Nothing is queued unless enabled and home === "home".

    • queueCards: a hero with no lastCards entry is first registered with saveHero (a hero added after binding); a hero whose cardKey differs from lastCards[id] gets a hero.card; a level higher than the last card's adds a hero.leveled; then lastCards is saved
    • failures: unlinked calls the Town Hall store's forgetLink() and keeps the queue; not-member and taken set problem; rejected ids are logged with debug
    • onQuestPaid → quest.done with the quest's difficulty (0 when the quest is gone)
  • Step 1: Write the failing tests: after bind, one hero.card per hero; an unchanged profile queues nothing; a hero added after bind is saved with saveHero, then its card queued; an unlinked failure calls forgetLink and keeps the events; level 3 → 4 queues hero.card and hero.leveled; emitQuestPaid queues quest.done with the stored difficulty; flush sends one batch and settles it; two flush() calls at once send once; an offline failure leaves the events queued with a nextTryAt; a rate-limited failure waits retryAfterMs; home: "home-elsewhere" queues nothing; a moved failure sets home to "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

Task 13: The family card in Family Settings

Files:

  • Create: src/app/SideMenu/XpSettings/components/XpFamilyPhones.vue
  • Modify: src/app/SideMenu/XpSettings/components/FamilySettings.vue (one line: the card, under the heroes, when canManage)
  • Test: tests/unit/XpFamilyPhones.spec.ts (pattern tests/unit/XpRevealPrompt.spec.ts)

Interfaces:

  • Consumes: Task 11 (useFamilyStore), needsAgePick, cardOf, familyNotice, homeQuestionPrompt

  • Produces: <xp-family-phones />, rendered only when enabled and the family is linked; emits nothing. Each hero row: nickname (or the family.needsNickname line with an Edit button that opens the existing profile form), an age-group picker when needsAgePick, and a Phones button (opens Task 15's modal; disabled until Task 15 exists, and while the hero has no nickname). A home-elsewhere banner with Make this home (homeQuestionPrompt, then makeHome); other-family, taken and not-member banners with their lines (not-member links 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 teen calls setAgeGroup("h-2", "teen"); home: "home-elsewhere" shows the banner and Yes calls makeHome.

  • 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>

Phase 3: Kid phones (app repo)

Task 14: Linking rules and the phone's key

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, key device): 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[]; computed isKidMode (device !== null); actions claim(code: string): Promise<"linked" | FamilyProblem>, refreshCard(): Promise<void> (hero auth; key-revoked marks unlinked), forgetDevice(): Promise<void>, linkCode(heroId): Promise<LinkCode>, loadPhones(): Promise<void>, removePhone(id: number): Promise<void>. In kid mode init loads the device, calls refreshCard, 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; readLink of both, and site read in development and dropped in production; phoneLabel("Mia", "", "ios") → Mia's iPhone; store: claim saves the device, calls adoptFamily(familyId) and sets isKidMode; a code-invalid rejection saves nothing; refreshCard updates the card; a key-revoked sets unlinked: true; forgetDevice clears it; BACKUP_STORES has 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>

Task 15: Link a phone

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-EFGH and a m:ss countdown to expiresAt; at 0:00 the QR goes and New code appears. The phone list shows label, linked and last seen; Remove asks family.removePhone and calls removePhone.

  • Step 1: Write the failing tests (qrcode mocked, fake timers): Link a phone shows the formatted code and an <img> from toDataURL with the hash-mode link; the countdown reads 10:00, then 9:59; at expiry the image is gone and New code shows; Remove with Yes calls removePhone(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>

Task 16: Claiming on the kid's phone

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, when enabled), package.json (@capacitor-mlkit/barcode-scanning 6.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: /link claims at once when the route has a valid code (and site outside production, saved as xp:townHallSite); otherwise it shows a code field and, on the phone apps, Scan (BarcodeScanner.scan({ formats: [BarcodeFormat.QrCode] }), the result read with readLink). linked → family.linked, then router.replace({ name: "hero-phone" }); a problem → its family.* line.

  • Step 1: Write the failing tests (store and scanner mocked): ?code=abcd-efgh claims ABCDEFGH on mount and goes to hero-phone; no code shows the field; typing ABCD EFGH and Link claims; code-invalid shows family.codeInvalid and 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>

Task 17: Kid mode

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-phone and link-phone pass, everything else goes to hero-phone); the guards await useFamilyStore().init() before it. /hero-phone shows <xp-hero-model :look="card.look" animation="idle" />, the nickname, class and level, and family.questsElsewhere; it calls refreshCard on mount and when the app returns to the front. With unlinked, it shows family.unlinked and Start fresh (forgetDevice, then router.replace("/xp-profile")).

  • Step 1: Write the failing tests: the guard sends xp-dashboard, my-portal and log-in to hero-phone in kid mode and passes everything outside it; canAccessGameMaster(gmView, true) is false; the page shows the card's nickname and level; with unlinked it 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>

Phase 4: Rewards and the rehearsal

Task 18: Paying server rewards once

Files:

  • Create: src/lib/services/multiplayer/eventInbox.ts
  • Modify: src/lib/databases/QuestLogDb.ts (QuestLogSource gains "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 through fetchEvents from lastSeq). For each reward, the quest log entry with questId: "party:{event id}" is the receipt: none → logCompletion({ questId, questName: "Party bonus", userId, status: "approved", source: "party", rewards: { xp, gp: 0, ap } }), then applyQuestRewards(userId, rewards, questId), then updateEntry(id, { rewardsApplied: true }); an entry with rewardsApplied: false is paid and marked; with true it is only marked. Then paidRewards and lastSeq are saved. Runs after init, after a flush, when the app returns to the front, and every 5 minutes while open.
  • Step 1: Write the failing tests: rewardsToPay skips paid ids and non-reward kinds; newestSeq; trimPaid at 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 lost lastSeq (0) with the id in paidRewards pays nothing; a log entry already marked rewardsApplied pays nothing; one with rewardsApplied: false is 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>

Task 19: The screens in Cypress

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 of tests/e2e/specs/town-hall.spec.js (visit the Town Hall return address with the three query values, intercept users/me and introspect)

  • 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-EFGH and the countdown; /link?code=ABCDEFGH (fresh storage) lands on /hero-phone with the nickname and level; a bad code shows the clerk's line; GET /family answering 401 xp_family_key_revoked shows 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>

Task 20: The full rehearsal

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), against http://localhost:8090/doit with xp:townHallSite set and no intercepts. Sequence: cy.exec makes a Town Hall Application Password for famparent with wp 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 with cy.exec wp db query); Link a phone gives a code; grant_reward through cy.exec wp 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 with cy.request DELETE /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-xp on doit.forthexp.com after this run passes. The app flag stays off in production until part 2 (A) ships.

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