2026 10 22 family sync design - xopherdeep/do-it-for-the-xp GitHub Wiki
Date: 2026-10-22. Status: approach and design approved in chat by the owner (2026-10-21/22); this spec awaits review.
Board: app task #1337 (epic #1333, sprint mp-next); server epic #6080 on the xophz-compass-xp board.
Part 3 of 3 of multiplayer. Part 1, the shared base (2026-10-13-multiplayer-shared-base-design.md), is live: the
family on the site, kid phones with hero keys, the event stream, hero cards, server rewards, and two-phone play
(src/lib/services/multiplayer/FAMILY_EVENTS.md: the sheet, phone claims, verdicts). Part 2 (A, the party and body
double) has its own spec to come and is not needed by this one.
The owner linked a kid's phone and the kid got a card, a sheet of chores and a Done button, with the line "Your quests are on the family device". That is Part 1's stand-in. The owner wants the kids to play the whole game on their own phones and on the family tablet, with the WordPress site carrying the sync, and is fine with that behind XP Pro. The requirement is that it works flawlessly: no lost rewards and never two versions of a hero.
- Approach: the hero follows the player. Every family device holds a copy of the family's data and of every hero. A hero plays on one device at a time: the device that holds the hero's turn is the only writer of that hero's records. Opening the hero elsewhere takes the turn (after a warning when the other device is still playing). Nothing is merged, so nothing conflicts. Chore claims and Game Master verdicts travel as events, never as part of a save, so they are never lost. This was chosen over merge rules for every record type.
- A Game Master's approval wins (decided on the board for #1337).
- XP Pro, per family (Part 1's table): Free keeps the whole game on one device plus today's kid-phone board; Pro turns every family device into the full game. Kid phones inherit the family's licence (they adopt the family id when linked).
- A takeover is always explicit, never automatic after a timeout: the second device is told the hero is still playing elsewhere and asked.
- Chat is writable from kid phones.
- A lapsed Pro sends a kid phone back to today's board rather than freezing the game.
-
No new backend: everything on the site lives in
xophz-compass-xpunderxp-family/v1, beside Part 1. - The free kid-phone board gets its bug fixed in the same effort: the family device re-reads its phone list after a phone links, so the sheet arrives (today it waits for an app restart or the Phones modal).
In:
- The site keeps the family's save as records (section 2) with a per-family change number, a hero's play turn (section 3), the Pro gate on the new routes and the relaxed "home device" rule for entitled families.
- The app syncs its stores: change capture, an outbox, download and apply, the live mirrors (section 4).
- The hero's turn on the device: taking, resting, the busy question, the takeover case (section 5).
- The approval loop under single-writer: who applies a verdict and pays (section 6).
- Kid phones with Pro become the full portal; without Pro they keep today's board (section 7).
- Every Game Master device is a family device: a second Game Master's phone, remote approvals (section 8).
- The free sheet fix (section 9).
Out (later specs or tasks): Fridge Mode (#1206), push notifications (#1166), Game Master roles (#1208), two heroes on one kid phone, kids posting to the Town Hall from their phones, encryption of records at rest, Part 2 (parties).
Needs first: nothing new. Part 1 is live on doit.forthexp.com (the owner linked a phone on 2026-10-21).
Rehearsals run on the local Docker WordPress ("My Compass", http://localhost) as before.
{prefix}xp_family_records, created by the schema class on activation and on the stored schema version check, beside
Part 1's tables:
| Column | Type | Meaning |
|---|---|---|
seq |
bigint | The family's change number for this write (section 2, "numbering"); per family, so the primary key is (family_id, seq) |
family_id |
char(36) | |
store |
varchar(40) | The store name as the app's backup uses it (__profiles, __questLog, ...) |
record_key |
varchar(191) | The record's key in that store |
hero_id |
varchar(64), nullable | The hero a hero-scope record belongs to; null for family records |
device_id |
varchar(16) | The device that wrote it |
saved_at |
datetime | Server time of the write |
deleted |
tinyint | 1 for a tombstone (the record was removed); body is then empty |
body |
mediumtext | The record as JSON, at most 512 KB |
Primary key (family_id, seq); unique key (family_id, store, record_key) with record_key in a binary collation (keys that
differ only by case never collide); index (family_id, hero_id, seq); index (deleted, saved_at) for the tombstone cleanup.
Numbering. xp_families gains records_seq bigint default 0. A batch write runs under the family's named lock
(GET_LOCK("xp_family_{id}"), as events do): read the counter, give each record the next number, upsert the rows (an
existing key is overwritten and gets the new number), write the counter back. A reader asking for "after N" can
therefore never miss a record that was still being written. Numbers only grow; gaps are normal.
Tombstones. A removal is a row with deleted = 1 and an empty body, numbered like any write, so devices learn
about it. The cleanup job (Part 1's WP-Cron) deletes tombstones older than 90 days. Live records are never aged out:
they are the save.
The sync covers the stores a backup file carries (BACKUP_STORES in BackupService.ts), except __localSettings
(device matters: sound, this device's look of the UI). The server knows the list and refuses any other store name.
| Scope | Stores | Hero id of a record |
|---|---|---|
| family |
__achievementCategories, __abilities, __accessories, __shops, __temples, __dosDonts, __family_settings, __packs
|
none |
| family, any writer |
__chat, __achievements, __beasts
|
none for a parent's rows; a hero key's rows carry its own hero and it may only create or rewrite those (chat messages, the quests a temple run or a quick task makes, beasts it painted); parents may write any row |
| hero | __profiles |
the record key (the profile id) |
| hero |
__questLog, __purchaseLog, __castLog
|
the record's userId
|
| hero | __latePenalties |
the record's heroId
|
| hero | __templeProgress |
the part of the key before the first : ({userId}:{templeId}) |
| hero | __notifications |
the record key (the hero id) |
The app derives the hero id per store (syncScopes.ts, section 4) and sends it; the server checks it against the
store's rule where it can (the key-based ones) and against the caller (a hero key may only name its own hero).
All under xp-family/v1, registered only on the family sites (the Town Hall's list), CORS as Part 1's routes.
| Method and route | Caller | Does |
|---|---|---|
PUT /records {device_id, records: [...]}
|
parent, hero key | Writes up to 50 records (at most 1 MB in all); answers per record |
GET /records?since={seq}&limit={n} |
parent, hero key | Records after seq that the caller may read, oldest first; limit up to 200, default 100, and a page stops at 1 MB of bodies (at least one row), so a few large records can never wedge a device |
POST /heroes/{hero_id}/play {device_id, label, force}
|
parent, the hero's own key | Takes the hero's turn (section 3); a hero key's label is its device row's, never the body's |
POST /heroes/{hero_id}/rest {device_id}
|
parent, the hero's own key | Gives the turn back if this device holds it; otherwise nothing |
A record on the wire: { store, key, hero_id, deleted, body }, where body is the record as a JSON string (the app
JSON.stringifys it; the site checks that it decodes to an object, measures the 512 KB cap on the string, reads userId
or heroId from it where the store's rule says so, and stores the string verbatim; a tombstone carries no body). The
same string comes back from GET /records and the app parses it. This keeps PHP's array-or-object ambiguity out of the
save. The answer to a put:
{ "accepted": [ { "store": "__questLog", "key": "…", "seq": 1041 } ],
"refused": [ { "store": "__profiles", "key": "…", "code": "xp_family_turn", "message": "…" } ] }Refusal codes per record: xp_family_turn (a hero record from a device that does not hold the hero's turn),
xp_family_scope (a hero key writing a family store other than __chat, or another hero's record, or a hero id that
does not match the key), xp_family_store_unknown, xp_family_record_invalid (not an object, too big, a bad key).
A refused record does not stop the rest of the batch. There is no "stale" answer: hero records have one writer, and
family records are last write wins per record (two Game Masters editing the same quest in the same minute is rare and
the later edit stands).
Who may read. A parent (the family's owner, as in Part 1) reads every record of the family. A hero key reads the
family-scope records and the hero-scope records of its own hero. GET /records filters by that; since works the
same for both because the numbering is per family.
Who may write. Family-scope records: a parent. __chat: a parent, or a hero key for its own messages (a hero key's
chat rows carry its hero id; it may add a message or rewrite its own, never another member's or a parent's). Hero-scope
records: the device that holds that hero's turn, whether a parent device or the hero's own key; the server compares
device_id with the hero's turn_device_id and refuses otherwise. The single-writer rule is thereby enforced by the
site, not by trust in the app.
The four routes above require the family to be entitled: Gatekeeper_Xp_Pro_Licenses::get( $family_id ) and
::is_entitled( $record, time() ) (the xophz-compass-gatekeeper plugin). A family that is not entitled gets 403
xp_family_pro_required for the whole request. The check is cached per request. The free tier's routes (Part 1) are
unchanged.
The home rule relaxes for entitled families. Part 1 refuses a parent's events from any device but the family's
home_device_id (409 xp_family_moved). For an entitled family every parent device may send events and read them,
because every parent device is a family device (section 8). POST /family still answers as before; the app no longer
asks the home question for an entitled family.
The records are the family's own data, written to the family's own account on the owner's own site: the same content
as a backup file. PINs travel sealed, the way backups seal them (sealProfileSecrets); claim photos never travel
(they are not in a synced store); heroes still have no accounts and no email. Quest names, which Part 1 kept off the
public events, are family-scope records and reach only the family's own devices.
xp_family_heroes gains four columns: turn_device_id varchar(16) null, turn_label varchar(60), turn_taken_at datetime null, turn_seen_at datetime null.
POST /heroes/{id}/play with { device_id, label, force } answers:
| Situation | Answer |
|---|---|
| No device holds the turn, or this device does |
{ "answer": "granted" }; turn_* set to this device and now |
Another device holds it and force is false |
{ "answer": "busy", "device": { "label": "Mia's iPhone", "last_seen_at": "…" } }, HTTP 200 |
Another device holds it and force is true |
granted; the turn moves to this device |
A hero key may only ask for its own hero (otherwise xp_family_scope, 403). label is what the busy line names:
a kid phone sends the label it was linked with ("Mia's iPhone"); a parent device sends "the family {platform}"
("the family tablet", "the family phone", "the family computer", from the platform the app runs on). rest clears the turn when device_id
holds it and does nothing otherwise, so a late rest from a device that lost the turn cannot clear the new holder.
turn_seen_at is refreshed by the holder's record writes and by its GET /records calls (the holder downloads every
minute while open), so "last seen" in the busy answer is honest to the minute. There is no lease: the site never frees
a turn by itself (decision above). Removing a hero or revoking a device clears a turn it held.
New pure modules in src/lib/services/sync/, each with a jest spec, and one setup-style store. The family store
(src/lib/store/stores/family.ts, already 900 lines) keeps binding, events, cards, sheets and the phone; the sync is
its own store, useSyncStore (src/lib/store/stores/sync.ts), bound to the family store for auth and the device.
| Module | Holds |
|---|---|
syncScopes.ts |
The store list with scope and hero-id rule (the table in section 2); heroIdOf(store, key, value); which stores a kid phone may write |
recordSignals.ts |
A tiny signal: recordWritten(store, key, value | null); emitted by the storage layer, listened to by the sync store. No imports from stores, so DbStorageApi can import it |
recordOutbox.ts |
The queue of changed keys per store (a key appears once; a later change replaces the earlier), batching to 50 records and 1 MB, what each answer does (accepted drops the key; xp_family_turn drops it and marks the hero taken over; other refusals drop it and log), the reset on start |
recordApply.ts |
Writing downloaded records into the local stores by key (a tombstone removes), skipping records this device wrote (its own device_id), and the list of mirrors to refresh afterwards per store |
heroTurn.ts |
The turn state machine per hero on this device (section 5): resting, asking, held, busy, takenOver, with the transitions and what each site answer does |
verdictApply.ts |
Applying a claim.settled to a quest log entry once, with the pay (section 6) |
familySyncClient.ts |
The four routes through townHallFetch with the parent's auth or the hero key; snake_case to camelCase, as familyServer.ts does for Part 1 |
Change capture. Every write in the game goes through DbStorageApi.set/remove/clear
(src/lib/databases/DbStorageApi.ts); profiles too, since ProfileDb extends it and the user store's persistUser
flushes through ProfileDb (StorageService.saveProfiles is only the reinstall mirror in Capacitor Preferences).
DbStorageApi calls recordWritten with the store's name (storage.config.name), the key and the value (null for a
removal; clear emits one removal per key it held). The sync store listens and, for a synced store, puts the key in
the outbox. Writes made while applying downloaded records are not captured (the apply runs under a flag), so a
download never echoes. A backup restore writes the raw Ionic stores and bypasses the hook: on a synced device a
restore sets firstPushDone back to false, so the whole save is re-sent, and queues a tombstone for every key the
restore removed (it holds the before-snapshot of each store for its own rollback).
Uploading. 5 seconds after the last change, on Save & Quit, when the app goes to the background, and on the
browser's online event; one request at a time; a call while one runs joins it. Each record is read from its store at
send time (so a key changed twice is sent once, with its latest value), tagged with its hero id, and sent with the
device id. A hero record is sent only while this device holds that hero's turn (heroTurn says held); records of a
hero whose turn this device lost are dropped (section 5 explains why that is safe).
Downloading. Every minute while open (the family store's existing download tick; the two timers are merged into
one tick for a Pro family), when the app comes to the front, after each upload, and at once when a hero's turn is
granted. GET /records?since=lastSeq paged, up to 5 pages per tick, as the phone sync does. Records are applied in
seq order; lastSeq is saved after each page. Applying a record writes the store (not captured), then refreshes the
mirror the backup restore refreshes for that store (BACKUP_STORES[...].afterRestore: profiles into the user store;
the other stores are read on demand by their screens and need no mirror). The current hero's profile, if it arrived,
is reloaded into useUserStore the way a restore reloads users.
The first sync. When Family Sync turns on for a family (Pro and linked) the family device sends every record of
every synced store (sync.firstPushDone false): the whole save, in batches, with a clerk line while it runs. A kid
phone's first open with Pro downloads from 0 behind a clerk line, then signs the hero in (section 7). A second Game
Master device downloads from 0 after its Town Hall link and keeps its own local records too (both sides then upload
what the other lacks: records are keyed, so nothing doubles).
State on the device, an Ionic Storage store named __xp_sync (SyncDb, on DbStorageApi), not in BACKUP_STORES
(a restored queue would resend, and the turns belong to this device):
interface SyncState {
outbox: Array<{ store: string; key: string; heroId: string | null; queuedAt: string }>;
lastSeq: number;
firstPushDone: boolean;
firstPullDone: boolean;
/** heroId -> the turn this device believes it has, for a restart; the site is asked again on the next sign-in */
turns: Record<string, "held" | "resting">;
/** Verdicts this Game Master device sent for entries it does not own, for the Approvals screen until the mirror arrives */
verdictsSent: Record<string, { approved: boolean; at: string }>;
/** `claim.settled` events for entries that have not arrived yet, applied once they do */
pendingVerdicts: Record<string, ClaimSettled & { heroId: string; seq: number }>;
}When the sync runs. multiplayerEnabled() is on, the family is bound (a parent device) or linked (a kid phone),
and useProStore().isPro is true. With any of them false the sync store does nothing and the game behaves as today.
heroTurn.ts, one machine per hero id, driven by the sync store:
| Event | From | To | Does |
|---|---|---|---|
| A hero signs in (profile picked on a family device; a kid phone opens its hero) | resting |
asking |
POST play without force |
granted |
asking |
held |
Download at once, then play; the outbox may send this hero's records |
busy |
asking |
busy |
The clerk asks (copy below); play is blocked behind the prompt |
| The player says "Play here anyway" | busy |
asking → held
|
POST play with force |
| The player says no | busy |
resting |
Back to the profile picker (family device) or the mirror of the hero (kid phone: the board, read-only) |
| Sign-out, profile switch, Save & Quit, the app goes to the background | held |
resting |
Upload what waits, then POST rest
|
| The app comes back to the front with the same hero | resting |
asking → held or busy
|
Silent when granted |
The site refuses a hero record with xp_family_turn
|
held |
takenOver |
The outbox drops this hero's keys; the device downloads from lastSeq and replaces its copy of the hero; the clerk says so (copy below); then resting
|
| Offline when asking | asking |
held (provisional) |
Local-first: play goes on; the ask is retried on online; a later busy is shown then, a later xp_family_turn is the takeover case |
The one loss case. A hero forced onto device B while device A still plays offline. A's later writes are refused;
A replaces its copy from the site and tells the player. A's unsaved play since B took over is gone (items bought,
rooms cleared, HP). A's claims are not: a claim is also a quest.claimed event, and its quest log entry, if refused as
a record, is still in A's events; the Game Master sees the claim in Approvals from B's mirror only if B also made it,
so the spec keeps the claim alive this way: a quest log entry refused with xp_family_turn whose status is
pending is re-sent as a phone-style claim event (a fresh quest.claimed with requiresApproval and without the
synced mark of section 6), and the device holding the turn takes it exactly as Part 1 takes a phone claim
(phoneClaimIntake.takePhoneClaim), which writes the entry on the owner's side. Paid honor-system claims lost to a takeover are not re-paid (the pay happened on A, in play that
is gone; the quest is open again on B, and the hero can claim it there). This case needs a hero on two devices in the
same session with one of them offline, and the busy question makes it a choice.
A mirror yields. A device that does not hold a hero's turn is a mirror of that hero. A local change it makes to that hero's records (a Game Master editing a profile while the kid plays elsewhere) is sent by borrowing the turn when the hero is free (take, send, rest) and otherwise waits; if the holder's record arrives first, the waiting change yields to it and is dropped. A family device's first push sends every hero's records the same way, borrowing each free turn in turn, so a kid phone that links afterwards pulls its hero.
Game Master actions on a hero played elsewhere. A purchase fulfilled or refunded, a Dashboard bonus or penalty, or a profile edit in the Game Master console changes the hero's records, so it runs the same way a verdict does: when the hero is free the device takes the turn, applies it, uploads and rests; when another device holds the hero the clerk says "{nick} is playing on {device}. Try again when they rest." and nothing is written.
The holder's copy is the truth. Nothing is merged record by record. When the player's own offline play is confirmed, or the player answers Play here, that device's copy of the hero's records is the truth: the catch-up read never lands the site's copies of that hero over them, they are uploaded whole, and verdicts or rewards kept meanwhile re-apply on this copy (a verdict applies once, by the entry's status). A plain grant or a borrowed turn never pushes a whole copy, so a stray write on a mirror cannot overwrite the hero. After a takeover or a Not now, the device's next read fetches the family's records from the start once, to repair the keys it dropped; a paid honor-system claim the site never received is removed locally by that repair (the spec's loss case).
Late penalties, deadlines, encounters. A device charges late penalties, runs the chore deadline clock, wall-clock
encounters and notifications only for heroes whose turn it holds, so a penalty is charged once and an ambush fires on
the phone in the hero's hand. Late penalties, deadline alerts and the recap run only while the hero is held (never
charged twice; at worst delayed while the turn is re-asked). Phone reminders and the encounter clock for the hero signed
in on a device follow a softer rule, "unless another device is known to hold it": a hero that is resting (the app in
the background) or re-asking for its turn keeps its reminders and its ambush-on-return on this device; only a busy
or took-over answer cancels them. Other heroes on a family device are watched only while held.
Copy, adventure voice in src/lib/services/copy/catalog/sync.ts (the other voices fall back), spoken by the
clerk through the reveal prompt, never a toast or native alert (the in-world UI rules):
| Key | Line |
|---|---|
sync.busy |
{nick} is still playing on {device} (last seen {ago}). Play here anyway? with Play here / Not now
|
sync.tookOver |
{device} took over {nick}'s game while this one was away. This device has caught up. |
sync.firstPull |
Fetching your family's world… (with the count of records so far) |
sync.firstPush |
Sending the family's save to the site… |
sync.proRequired |
Playing the whole game on this phone needs XP Pro. Ask your Game Master. |
sync.heroBusyForGm |
{nick} is playing on {device}. Try again when they rest. (a Game Master change to a hero played elsewhere) |
sync.heroUnreachableForGm |
the line when the turn cannot be had at all (offline, or the site refused), same place |
sync.verdictSent |
Approved · paying on {nick}'s device (Approvals, until the mirror arrives; Not this time · sent for a rejection) |
sync.offline |
No signal. Play on; this device tells the others when it's back. |
A claim made on any device writes a pending quest log entry (a hero record, synced) and queues the existing
quest.claimed event, so the family feed and Part 1's rules keep working. Every Game Master device sees the pending
entries of every hero in Approvals, because hero records are applied into the same __questLog store the screen
reads.
Telling synced claims apart. Part 1's intake (isPhoneClaim) takes every quest.claimed from another device as a
phone claim and logs it; with several synced devices that would log each claim twice (once as the record, once as the
intake). quest.claimed therefore gains one kept field, synced (boolean), in both lists (EVENT_KINDS in
familyEvents.ts and Xophz_Compass_Xp_Family_Event_Rules::KINDS): a device that syncs records sets it, and the
intake skips a synced claim, since its entry arrives as a record. A free kid phone's claim, and the takeover re-send of
section 5, carry no mark and are taken as today. In a Pro family the intake runs on the device holding the hero's
turn, or on a Game Master device that takes the turn for the moment, the same as a verdict.
A verdict (approveClaim / rejectClaim in approvalFlow.ts), on a Pro family device:
- If this device holds the hero's turn (the kid is playing on this tablet): apply and pay as today, and queue
quest.approved/quest.rejectedas today. - Otherwise queue
claim.settled(Part 1's kind,claimId= the entry id, with the pay or the note) and record the verdict inverdictsSent, so Approvals showssync.verdictSentat once. Then:- nobody holds the turn: this device takes it (
playwithout force answersgranted), downloads, applies the verdict throughverdictApply, pays, uploads, rests. The hero is paid within the minute, with no kid action. - another device holds it: that device applies the verdict on its next download (within the minute), pays, and its upload updates every mirror.
- nobody holds the turn: this device takes it (
-
verdictApplyis idempotent: it reads the entry, and does nothing when the entry is already settled; it marksrewardsAppliedwith the pay, asapprovalFlowdoes. Repeats and replays cannot double-pay. Aclaim.settledfor an entry the device does not have yet waits (kept by event id inSyncDb) until the entry arrives.
reward.granted (server rewards, Part 1 section 8) follows the same rule: paid by the device holding the hero's turn,
or by a Game Master device that takes it for the moment; the receipt rule from Part 1 keeps it to once.
Phone claims from Part 1 (a free family's kid phone, or a Pro kid phone whose Pro lapsed) are taken as today by the family device, which for a Pro family is any device holding or taking the hero's turn.
With Pro: the first open after linking downloads the family and the hero (sync.firstPull), then signs the hero in
(loginUser(heroId)), asks for the turn, and opens the portal. The hero-phone guard
(src/router/_Guards/heroPhone.guard.ts) lets every route through when the sync store says the phone is a full
portal (isPro && firstPullDone); canAccessGameMaster still answers denied in kid mode, so the Game Master console
stays shut whatever the phone holds. The profile picker shows only this hero with the line "This is {nick}'s phone";
backup and restore, Family settings, the Town Hall link and the Phones panel are hidden in kid mode; Settings keeps
Unlink this phone (Part 1's forget, now also resting the hero and clearing __xp_sync). Save & Quit uploads, rests
and returns to the hero-phone screen with the card. Chat posts from the phone are __chat records (any writer).
The first pull is done only when the hero has arrived. Until the hero's profile lands, the phone keeps pulling each round behind the progress line; after five minutes it shows the card with Play disabled and the line "Your quests are on the family device" as its footer, and keeps trying.
Without Pro (never had it, or lapsed past isPro's grace): today's board, unchanged, with sync.proRequired as
its footer line when the family device has Pro turned off; the records already on the phone stay until it is relinked.
The phone claims the family's licence as it does today (useProStore, adoptFamily at link time).
A second phone linked with the same site account (the Town Hall link) is a parent device: it binds (POST /family,
any answer but taken/other-family is fine for an entitled family; the home question is not asked), downloads the
whole save, and has the Game Master console, Approvals (remote approvals, #1207) and the Phones panel. Both parents'
devices may edit family records (last write wins per record) and send events. The hero turn rules are the same for a
parent device playing a hero as for a kid phone. Game Master roles (#1208) are a later spec; today every parent
device has every power, as the shared device does.
For a free family nothing changes: one home device, the home question, Part 1's rules.
Two small changes on master before the rest (the complaint that started this):
- The family store's download tick also refreshes the phone list (
loadPhones()) before the sheet pass, so a phone linked since the last bind gets its sheet within two minutes without an app restart. - The Phones modal polls the phone list every 10 seconds while a link code is live and runs the sheet pass when a new phone appears, so the kid sees the sheet about as soon as the code is claimed.
| Situation | What happens |
|---|---|
| Offline, timeout, 5xx | Local-first: play goes on, the outbox waits and retries with Part 1's backoff; the turn ask is retried on online
|
403 xp_family_pro_required
|
The sync store turns itself off and refreshes the licence (useProStore); a kid phone shows the board with sync.proRequired; a family device plays as a free family |
| 401 on the parent's link | The Town Hall's rule (the link is removed, the Game Master registers again); the outbox is kept |
| 401 on a hero key | Part 1's unlinked screen with Start fresh; __xp_sync is cleared |
xp_family_turn on a record |
The takeover case (section 5) |
xp_family_scope, xp_family_store_unknown, xp_family_record_invalid
|
The record is dropped from the outbox and logged with debug; the batch carries on |
| 429 | Waits for Retry-After, as Part 1 |
| A record that cannot be applied (a store refuses the value) | Logged, skipped, lastSeq still advances; the next write of that key tries again |
| The site's clock and the device's disagree | Only seq orders records; saved_at is for people |
- The free sheet fix (section 9), straight to master as a small path-scoped commit.
-
The server: schema (
xp_family_records,records_seq, the turn columns), the four routes, writer and reader rules, the Pro gate, the relaxed home rule, tombstone cleanup, PHP tests, the rehearsal smoke script extended. -
The app's sync core:
syncScopes,recordSignalswired intoDbStorageApiandStorageService.saveProfiles,recordOutbox,recordApply,familySyncClient,SyncDb, the sync store with upload, download, first push and first pull; every GM device a family device (the relaxed home question). -
The turn and the verdicts:
heroTurn, taking and resting on sign-in, switch, Save & Quit and background, the busy and took-over clerk lines,verdictApply,approvalFlowunder one writer, penalties and clocks only for held heroes. - Kid phones as the full portal: the guard, the first pull, the one-hero picker, hidden Game Master bits, Unlink, the Pro fallback to the board.
- Rehearsal and release: the local Docker WordPress with two browsers (family device and kid phone) and a third (second Game Master), then the plugin release through the COMPASS updater (owner's go), the web deploy on push, the APK rebuild and upload.
-
Jest for every module in section 4: each store's scope and hero id (including the
templeProgresskey and__chat's any-writer); the outbox (a key once, latest value at send time, batching limits, each answer, the reset on start, hero records only while held); apply (tombstones, own-device skip, mirrors per store, no echo); the turn machine (every transition in section 5, including offline and the takeover);verdictApplyidempotence (repeat, replay, entry not yet arrived); the sync store off without Pro;__xp_syncnot inBACKUP_STORES. -
PHP, through the plugin's
tests/run-tests.mjs: every route refuses the wrong caller and an unentitled family; a hero key reads only its hero's records and the family's; a hero record from a non-holder is refused withxp_family_turnwhile the batch carries on;__chatfrom a hero key is accepted;playanswers granted, busy and forced;restfrom a non-holder changes nothing; numbering under the lock;sincepaging; tombstones and their cleanup; the relaxed home rule only for entitled families. -
Cypress at 390×844 with
cy.intercept(never the live site): the busy question and both answers, the took-over line, the first pull on a kid phone opening the portal, the board withsync.proRequired. - The rehearsal network: link two parent devices and one kid phone; play a hero on the tablet, then on the phone (busy, take over); approve a claim from the second parent's phone while the hero plays elsewhere, and while nobody does; force a takeover with one device offline and read the took-over line; lapse Pro and see the board.
-
chemx lintand the PR's Tests check gate each slice;chemx verifyruns on GitHub.