@homie-rocks/studio 0.46.2 → 0.46.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/saves/SAVES.md CHANGED
@@ -64,8 +64,8 @@ landing. Pressing Play never needs an account.
64
64
  security key. No password, no email. A guest who makes an account keeps everything: it is the same player.
65
65
  - **Another device**: "Sign in" with the same passkey. The device's own passkey sync carries it (iCloud Keychain,
66
66
  Google Password Manager, a password manager), or the browser offers a phone's QR code. The same saves are there.
67
- A guest who signs in to an account they already have keeps the account's saves, and anything only the guest had
68
- (keys, stats, memorials the account lacks) moves over.
67
+ A guest who signs in to an account they already have loses nothing: what only the guest had (keys, stats,
68
+ memorials) moves over, and a key both have is combined ("When a guest signs in to their account", below).
69
69
  - **Recovery**: add another passkey on another device or a security key (`/account/`). A **recovery email** is
70
70
  offered only when the studio has a mail sender: a Cloudflare Email Service binding named `PLAYER_MAIL`
71
71
  (`"send_email": [{ "name": "PLAYER_MAIL" }]` in wrangler.jsonc; Email Sending needs the Workers paid plan and a
@@ -130,6 +130,82 @@ an old offline copy of a dead hero is a conflict with the wipe, and the wipe win
130
130
 
131
131
  A write is atomic **per key**, not across keys: keep one character in one key.
132
132
 
133
+ ## When a guest signs in to their account
134
+
135
+ Somebody plays as a guest, then signs in to the account they made on another device. Both players have saves.
136
+ Whatever only the guest had moves to the account as it is (keys, stats, memorials), and the guest is gone. For a
137
+ key **both** have, the game says how the two combine, once, when the studio's Worker loads
138
+ (`site/src/worker.mjs`):
139
+
140
+ ```js
141
+ import { mergeSaves } from '@homie-rocks/studio/worker';
142
+
143
+ mergeSaves({
144
+ 'night-run': {
145
+ saves: {
146
+ 'server:career-v1': { union: 'matches', by: 'id', order: 'endedAt', keep: 100 },
147
+ settings: 'newer', 'best-level': 'larger', coins: 'sum', 'slot-*': 'account',
148
+ inventory: (guest, account, ctx) => ({ items: [...new Set([...account.items, ...guest.items])] }),
149
+ },
150
+ stats: { kills: 'sum', level: 'larger', 'best-time': 'smaller' },
151
+ },
152
+ });
153
+ ```
154
+
155
+ A name is the exact key, or a prefix ending in `*`. The exact key beats a prefix; a longer prefix beats a shorter
156
+ one. A rule that is none of the ones below throws when the Worker loads, naming it.
157
+
158
+ | A save's rule | The account ends with |
159
+ | --- | --- |
160
+ | `'account'`, `'guest'` | that side's value. |
161
+ | `'newer'` | the one written last (a tie: the account's). |
162
+ | `'larger'`, `'smaller'` | the larger or smaller of two numbers. `{ rule: 'larger', by: 'level' }` compares that number in two objects and keeps the whole object. |
163
+ | `'sum'` | two numbers added; or two objects with every number they share added, and for anything else the account's (the guest's where the account has none). |
164
+ | `{ union: 'matches', by: 'id', order: 'endedAt', keep: 100 }` | the account's object with that list joined to the guest's by `by` (on the same id, the account's entry), newest `order` first, cut to `keep`. `order` and `keep` are optional; `'union'` alone is exactly the rule shown. A joined list too large for a save loses entries from its end until it fits. |
165
+ | `(guest, account, ctx) => value` | what the game's own function returns, run on the server. It may be async. `ctx` is `{ game, key, guest: { updatedAt, version }, account: { updatedAt, version } }`. |
166
+
167
+ A stat's rule is `'sum'`, `'larger'`, `'smaller'`, `'guest'` or `'account'`.
168
+
169
+ **With no rule**, and whenever a rule cannot apply (two values that are not numbers to compare, a function that
170
+ throws, returns nothing, or returns more than a save holds), the default applies. It never fails the sign-in:
171
+
172
+ 1. Equal values: nothing to do.
173
+ 2. Two values of the **record shape** the room server writes (`{ matches: [{ id, … }] }`, every `id` a text):
174
+ joined by id, newest first. For a `server:` key the newest 100 are kept, the record's own bound; only the server
175
+ can write such a key, so nothing else could combine the two. A key the browser wrote that happens to have this
176
+ shape is joined the same way and not cut (it is cut only if it would not fit a save). Declare `'account'` for a
177
+ key of that shape that must not be joined.
178
+ 3. Anything else: the account's value stays, and the guest's is **handed back** to the browser that signed in. The
179
+ play page raises it as an ordinary conflict, `mine` the guest's value and `theirs` the account's: a game with
180
+ `onConflict` keeps or merges it, and its answer is written on top; without `onConflict` the account's stays and
181
+ the game hears `conflict` with `kept: 'theirs'`. A stat keeps the account's number.
182
+
183
+ The sign-in answers with what happened, beside `adoptedGuest: true`:
184
+
185
+ ```js
186
+ adopted: {
187
+ merged: [{ game, key, rule, took }], // took: 'merged', 'guest' or 'account' (a rule chose it)
188
+ moved: 3, // keys only the guest had
189
+ kept: [{ game, key, value }], // case 3; `rule` and `error` when a declared rule could not apply
190
+ stats: { merged: [{ game, name, rule }], kept: [{ game, name, value }] },
191
+ }
192
+ ```
193
+
194
+ `kept` carries at most 256 KiB of values; past that a key is named with `truncated: true` and the play page uses
195
+ its own copy of the guest's value. A sign-in on `/account/` has no game to ask, so there the account's simply stays.
196
+
197
+ It happens **exactly once**, in the one database transaction of the sign-in. If a room writes a record to either
198
+ player between the read and that transaction, the transaction fails whole, and it is read and tried again; after
199
+ four tries the account keeps every key both have and the answer says `fallback: true`. A round that ended a moment
200
+ before the sign-in writes its record a moment after, under the guest's id: the guest leaves a forwarding row
201
+ (`saves`, game `''`, key `server:moved`) and the record follows it to the account.
202
+
203
+ **What is new and what is a fix.** `mergeSaves` and its rules are opt-in: a studio that declares nothing has the
204
+ results it always had for every key and stat (the account's stays). Two things changed for everyone, both fixes:
205
+ server records are joined (a guest's verified matches used to vanish), and a value the account did not take is
206
+ handed back instead of dropped. A merged value larger than 64 KiB falls to the default; a merge never counts
207
+ against a game's 256 KiB.
208
+
133
209
  ## The hall of the fallen: a hardcore recipe
134
210
 
135
211
  ```ts
@@ -182,6 +258,10 @@ Migration `site/migrations/0004_players.sql` (deploy applies it; an older studio
182
258
  | `player_emails` | a recovery email, only if the player added one | |
183
259
  | `saves`, `player_stats`, `memorials` | what the game saved | anything the game did not |
184
260
 
261
+ A guest who signed in to their account leaves two small rows in `saves` under game `''`: `server:moved` under the
262
+ guest's id (the account's id and when) and `server:from:<guest>` under the account's. They are in no export and no
263
+ count, and they are deleted with the account.
264
+
185
265
  - **The player** sees all of it at `/account/`, downloads it as one JSON file (`/api/player/export`), and deletes
186
266
  it all (`/api/player/delete`): their passkeys, sessions, saves, stats, memorials and email, and their large
187
267
  saves in R2. The page tells them to remove the passkey from their device too, and tells the device itself where
@@ -227,7 +307,7 @@ Same origin only (a POST must come from this site's own pages, as JSON). For ref
227
307
  | --- | --- |
228
308
  | `GET /api/player/me` | the signed-in player, the features (`accounts`, `email`), the limits |
229
309
  | `POST /api/player/signup/options`, `POST /api/player/signup` | make an account, or add a passkey to this browser's guest or account |
230
- | `POST /api/player/signin/options`, `POST /api/player/signin` | sign in with a passkey (discoverable: no name typed) |
310
+ | `POST /api/player/signin/options`, `POST /api/player/signin` | sign in with a passkey (discoverable: no name typed): `{ ok, player, adoptedGuest, adopted? }` |
231
311
  | `POST /api/player/signout`, `POST /api/player/name`, `GET /api/player/passkeys`, `POST /api/player/passkeys/remove` | |
232
312
  | `GET /api/player/export`, `POST /api/player/delete` (`{ "confirm": "delete" }`) | everything, and nothing |
233
313
  | `GET /api/player/saves/<game>[?values=1]`, `GET /api/player/saves/<game>/<key>` | read |
package/saves/saves.ts CHANGED
@@ -54,6 +54,8 @@ export interface SavesOptions {
54
54
  * Another device saved a key since this one's copy (this browser was offline, or two devices played at once).
55
55
  * Return the value to keep (merge them, or return `mine`), or `undefined` for the cloud's (the default: a stale
56
56
  * offline copy never overwrites newer progress, so a hardcore character cannot come back from the dead).
57
+ * It is also asked when a guest signs in to an account that has the same key with another value and no
58
+ * `mergeSaves` rule combined them on the server: `mine` is the guest's value, `theirs` the account's.
57
59
  */
58
60
  onConflict?: (c: Conflict) => unknown | Promise<unknown>;
59
61
  /** Tests and custom shells: where messages go (default: the parent frame) and the shell's answer window. */
@@ -1,2 +1,2 @@
1
1
  // Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
2
- export const STUDIO_BUILD = Object.freeze({"version":"0.46.2","sha256":"335192af92b18b0857b7a32abc28017670a609737ef888a592f78e75561d16f0"});
2
+ export const STUDIO_BUILD = Object.freeze({"version":"0.46.5","sha256":"6e01cf0c45cbe8649134cb3332feed0ba34c9ac39f3431d4c8374e0237345c0b"});
package/worker/index.mjs CHANGED
@@ -92,7 +92,7 @@ import { NetRoom, WATCH_POLICIES, departure, errorLine, versionOf } from './room
92
92
  import { hostedGame, hostedBuild, compiledFor, startHost } from './hosted.mjs';
93
93
  import { roomStore, restoreDelay } from './room-store.mjs';
94
94
  import { appCors, isAppOrigin } from './standalone.mjs';
95
- import { ROOM_ID, badRoomPage, embedAncestors, embedPreview, frameAncestors, noWatchPage, playPage, watchPage } from './pages.mjs';
95
+ import { ROOM_ID, badRoomPage, embedAncestors, embedPreview, frameAncestors, noWatchPage, playPage, shellKey, shellStandIn, watchPage } from './pages.mjs';
96
96
  import {
97
97
  PUBLIC_SERVER, SERVER_ID, homeOf, memberCounts, memberOf, noteMember, policyOf, pooledRoom, roomCode, roomServer, serverAccess, serverPassOf, serverView, serversOf,
98
98
  setMembership,
@@ -143,6 +143,8 @@ export { usePlayers } from './office.mjs';
143
143
  export { countVisit } from './stats.mjs';
144
144
  /** Players and cloud saves (saves/SAVES.md): `players.list`, `players.get`, `players.isOwner`… for the back office. */
145
145
  export { players, cleanName } from './players.mjs';
146
+ /** How a guest's saves combine with the account's when they sign in (saves/SAVES.md): the studio's Worker calls it once, at load. */
147
+ export { mergeSaves } from './saves.mjs';
146
148
 
147
149
  const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
148
150
  const POST_SLUG = /^[a-z0-9][a-z0-9-]{0,79}$/;
@@ -1224,6 +1226,17 @@ async function route(request, env, ctx) {
1224
1226
  if (sub === '__game' || sub === '__game/' || sub === '__game/index.html') {
1225
1227
  if (url.searchParams.get('embed') === '1' && (!playerEnabled(cat, meta) || launch !== 'public')) return notFoundPage('Embedding is off for this game.', cat);
1226
1228
  const who = await ticketSub(env, game, url.searchParams.get('t'));
1229
+ // A play page left open across a deploy (0.46.5). One that names what it was made for (`sh`) and is behind is
1230
+ // told so, and reloads itself whole, once, to the same room and seat. One from before that handshake is sent
1231
+ // to a fresh page only when this game cannot work inside it: the game keeps verified results for a player, and
1232
+ // the old page has no player ticket to give its frame (a result would have nobody to be written for).
1233
+ if (url.searchParams.get('pf') === '1' && url.searchParams.get('embed') !== '1' && url.searchParams.get('want') !== 'screen' && !url.searchParams.has('watch')) {
1234
+ const said = url.searchParams.get('sh'), now = shellKey(meta);
1235
+ const ticketless = Boolean(meta.room?.records && env.DB) && !holders(who).some((x) => x.startsWith('p-'));
1236
+ if (said !== null ? said !== now : ticketless) {
1237
+ return new Response(shellStandIn(url.searchParams.get('room') ?? '', said !== null ? now : null), { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } });
1238
+ }
1239
+ }
1227
1240
  if (launch !== 'public' && !(await ticketAllows(env, who, launch))) return new Response('This game is not open to you.\n', { status: 403, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } });
1228
1241
  // An agent's frame (hands `self`): the game plays as that AI.
1229
1242
  const agentPart = holders(who).find((x) => x.startsWith('a-'));
@@ -1776,7 +1789,12 @@ export class Table {
1776
1789
  sql.exec('CREATE TABLE IF NOT EXISTS round_players(owner TEXT PRIMARY KEY,player TEXT)');
1777
1790
  const client=[...this.room.clients.values()].find(c=>c.seat!==null&&'p'+this.room.seats.get(c.seat)?.occ===owner);
1778
1791
  const player=client?.player??sql.exec('SELECT player FROM round_players WHERE owner=?',owner).toArray()[0]?.player;
1779
- if(!player)return;
1792
+ if(!player){
1793
+ // Never dropped in silence (0.46.5): the log says so, and the person is told on their own screen.
1794
+ try{console.error(JSON.stringify({at:new Date().toISOString(),ev:'record-unsaved',why:'no-player',game:this.game??null,room:this.code??null,key,id:value?.id??null}));}catch{}
1795
+ if(client)this.room.error(client,'unsaved','This round was not saved to your history. Reload the page to keep the next one.');
1796
+ return;
1797
+ }
1780
1798
  sql.exec('CREATE TABLE IF NOT EXISTS round_outbox(id TEXT PRIMARY KEY,player TEXT,key TEXT,value TEXT)');
1781
1799
  sql.exec('INSERT OR IGNORE INTO round_outbox VALUES(?,?,?,?)',player+':'+key+':'+value.id,player,key,JSON.stringify(value));
1782
1800
  this.ctx.waitUntil(this.flushRoundRecords());
package/worker/pages.mjs CHANGED
@@ -8,6 +8,36 @@ import { playerAncestors, PLAYER_SANDBOX, PLAYER_SIZE } from './embed.mjs';
8
8
  import { esc, layout, gameSocialTags } from './site.mjs';
9
9
  import { NET_PALETTE } from './room.mjs';
10
10
  import { PARAM_VALUE, PREFS_LIMITS, playParams } from './seats.mjs';
11
+ import { STUDIO_VERSION_TAG } from './version.mjs';
12
+
13
+ /**
14
+ * What a play page was made for (0.46.5): the toolkit that wrote it and the parts of the game's manifest it is built
15
+ * around (who may sit, what the room keeps, whether the player has saves and a ticket). The page hands it to the
16
+ * game's frame (`sh`), and the Worker that serves the frame knows what a page made now would say. A page left open
17
+ * across a deploy says something older: it is brought up to date with one full reload, to the same room and seat,
18
+ * instead of hosting a newer game inside a page that lacks what that game needs (a player ticket, for one).
19
+ * It does not change when only the game's code or art does: that is still a reload of the frame alone.
20
+ */
21
+ export function shellKey(g) {
22
+ // Never what names a build (the rules' build and state hashes, the bundle's version): those change with every
23
+ // edit, and a newer build of the same game inside the same page is exactly what the frame's own reload is for.
24
+ const { build: _build, stateHash: _state, ...room } = g?.room ?? {};
25
+ const { version: _version, ...netplay } = g?.netplay ?? {};
26
+ const text = JSON.stringify([g?.kind ?? null, g?.players ?? null, room, g?.saves ?? null, g?.agents ?? null, g?.screen ?? null, netplay, g?.launch ?? null]);
27
+ let h = 2166136261;
28
+ for (let i = 0; i < text.length; i += 1) { h ^= text.charCodeAt(i); h = Math.imul(h, 16777619); }
29
+ return `${STUDIO_VERSION_TAG}-${(h >>> 0).toString(36)}`;
30
+ }
31
+ /**
32
+ * The frame's stand-in when its page is older than the game it would host. It tells the page, and loads no game.
33
+ * `key`: the page understands the handshake and reloads itself once. No key: a page from before the handshake, whose
34
+ * only way to load a whole new page is the one it has for leaving a finished room; it is used only when the game cannot
35
+ * work inside that page at all.
36
+ */
37
+ export function shellStandIn(room, key) {
38
+ const message = key ? { t: 'homie-net', what: 'shell', key } : { t: 'homie-net', what: 'rematch', room };
39
+ return `<!doctype html><meta charset="utf-8"><title>Updating</title><body style="margin:0;background:#0b0d14;color:#cfd6e6;font:15px system-ui;display:grid;place-items:center;height:100vh"><p>This game was updated. Loading it again…</p><script>try{parent.postMessage(${JSON.stringify(message).replace(/</g, '\\u003c')},'*');}catch(e){}</script>`;
40
+ }
11
41
  import { SKILLS } from './agents.mjs';
12
42
  import { KIDS_LINE, POLICY_WORDS } from './servers.mjs';
13
43
  import { SAVES_SHELL_CSS, SAVES_SHELL_JS } from './saves-shell.mjs';
@@ -180,7 +210,7 @@ ${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${shop ? SHO
180
210
  // game.json "agents": { "vote": "game" } draws its own vote card; false turns the vote off (section 17).
181
211
  const vote = g.agents?.vote === 'game' || g.agents?.vote === false ? g.agents.vote : 'shell';
182
212
  // The address's switches the game's frame is handed (NETPLAY.md section 24): the defaults and the game's own names.
183
- const boot = { ...(g.kind === 'app' ? { kind: 'app', appWords: appWords(g) } : {}), embed, game: g.id, name: g.name, screen: Boolean(screen), share: places, params: playParams(g), paramValue: PARAM_VALUE, prefs: PREFS_LIMITS, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}), ...(owner ? { owner: true, launch } : {}), ...(srv ? { server: srv } : {}), vote, skills: SKILLS.map((k) => ({ level: k.level, name: k.name, card: k.card })), words: POLICY_WORDS, kidsLine: KIDS_LINE, ...(shop ? { shop } : {}) };
213
+ const boot = { ...(g.kind === 'app' ? { kind: 'app', appWords: appWords(g) } : {}), embed, game: g.id, name: g.name, screen: Boolean(screen), share: places, params: playParams(g), paramValue: PARAM_VALUE, prefs: PREFS_LIMITS, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}), sh: shellKey(g), ...(owner ? { owner: true, launch } : {}), ...(srv ? { server: srv } : {}), vote, skills: SKILLS.map((k) => ({ level: k.level, name: k.name, card: k.card })), words: POLICY_WORDS, kidsLine: KIDS_LINE, ...(shop ? { shop } : {}) };
184
214
  // game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
185
215
  const joinCorner = corner('join', 'bottom-right');
186
216
  const first = places.desk;
@@ -497,6 +527,8 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
497
527
  q.set('b', roomKey);
498
528
  if (boot.embed) q.set('name', guestName);
499
529
  if (boot.t) q.set('t', boot.t);
530
+ // What this page was made for (0.46.5): the Worker answers with a stand-in when a page made now would differ.
531
+ if (boot.sh) q.set('sh', shellSaid || boot.sh);
500
532
  if (!boot.embed && params.get('name')) q.set('name', params.get('name'));
501
533
  else if (!boot.embed) {
502
534
  // A player with an account (or a named guest) on this studio plays under their own name in every room.
@@ -517,6 +549,18 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
517
549
  }
518
550
  state.params = {};
519
551
  (boot.params || []).forEach(function (k) { var v = params.get(k); if (v !== null && new RegExp(boot.paramValue || '^$').test(v)) state.params[k] = v; });
552
+ // The Worker says this page is older than the game (a deploy while it was open): one full reload, which keeps the
553
+ // room (it is in the address) and the seat (its token is in this tab's storage). Once for each newer page: if the
554
+ // reload came back the same, the frame is asked for as the Worker described it and play goes on.
555
+ var shellSaid = '';
556
+ function shellBehind(key) {
557
+ var tried = '';
558
+ try { tried = sessionStorage.getItem('homie-shell') || ''; } catch (e) {}
559
+ if (tried === key) { shellSaid = key; frame.src = frameUrl(); return; }
560
+ try { sessionStorage.setItem('homie-shell', key); } catch (e) { shellSaid = key; frame.src = frameUrl(); return; }
561
+ state.shellReload = key;
562
+ location.reload();
563
+ }
520
564
  var updating = buildRetry(function () {
521
565
  state.stale = null; state.closed = null; state.reloaded = (state.reloaded || 0) + 1;
522
566
  frame.src = frameUrl();
@@ -532,6 +576,7 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
532
576
  var m = ev.data;
533
577
  if (!m || typeof m !== 'object' || m.t !== 'homie-net') return;
534
578
  A.message(m);
579
+ if (m.what === 'shell' && typeof m.key === 'string' && /^[A-Za-z0-9._-]{1,64}$/.test(m.key)) { shellBehind(m.key); return; }
535
580
  if (m.what === 'wait') updating.stop();
536
581
  if (m.what === 'build' && updating.acknowledge(m.ver) && toast) toast.hidden = true;
537
582
  if (m.what === 'attached' || m.what === 'ready') updating.reported();
@@ -28,7 +28,7 @@ import { toolIdentity } from './tool-identity.mjs';
28
28
  import { handleFor } from './room.mjs';
29
29
  import { isAiName } from './agents.mjs';
30
30
  import { cookieValue } from './stats.mjs';
31
- import { adoptStatements, bumpPlayerStats, dropPlayerData, fall, fallenList, getSave, listSaves, playerData, readPlayerStats, SAVE_LIMITS, savesSummary, wipeSaves, writeSaves } from './saves.mjs';
31
+ import { adoptGuest, bumpPlayerStats, dropPlayerData, fall, fallenList, getSave, listSaves, playerData, readPlayerStats, SAVE_LIMITS, savesSummary, wipeSaves, writeSaves } from './saves.mjs';
32
32
  import { bytesToB64url, randomToken, sha256Hex, verifyAssertion, verifyRegistration } from './webauthn.mjs';
33
33
  import { ACCOUNT_JS, accountPage } from './account-page.mjs';
34
34
  import { adoptShopStatements, migrated as shopMigrated, forgetPlayerShop, livePurchases, shopDataOf } from './shop-store.mjs';
@@ -693,21 +693,19 @@ async function apiRoute(request, env, ctx, url, parts, { catalogueOf, read, f })
693
693
  session.statement,
694
694
  ];
695
695
  let adopted = null;
696
- if (current && current.player.id !== key.player) {
697
- writes.push(env.DB.prepare('DELETE FROM player_sessions WHERE hash = ?1').bind(current.hash));
698
- // A guest signing in to their account: what only the guest had moves over, then the guest is gone.
699
- if (current.player.guest) {
700
- adopted = await adoptStatements(env, current.player.id, key.player);
701
- writes.push(...adopted.statements, ...(await adoptShopStatements(env, current.player.id, key.player)),
702
- env.DB.prepare('DELETE FROM player_sessions WHERE player = ?1').bind(current.player.id),
703
- env.DB.prepare('DELETE FROM player_challenges WHERE player = ?1').bind(current.player.id),
704
- env.DB.prepare('DELETE FROM players WHERE id = ?1 AND guest = 1').bind(current.player.id));
705
- }
706
- }
707
- await env.DB.batch(writes);
708
- if (adopted) await adopted.after();
696
+ if (current && current.player.id !== key.player) writes.push(env.DB.prepare('DELETE FROM player_sessions WHERE hash = ?1').bind(current.hash));
697
+ if (current && current.player.id !== key.player && current.player.guest) {
698
+ // A guest signing in to their account: what only the guest had moves over, what both have is combined by the
699
+ // game's rules (mergeSaves), then the guest is gone. One batch; read and tried again if a room wrote in between.
700
+ const guest = current.player.id;
701
+ const shop = await adoptShopStatements(env, guest, key.player);
702
+ adopted = await adoptGuest(env, guest, key.player, (statements) => [...writes, ...statements, ...shop,
703
+ env.DB.prepare('DELETE FROM player_sessions WHERE player = ?1').bind(guest),
704
+ env.DB.prepare('DELETE FROM player_challenges WHERE player = ?1').bind(guest),
705
+ env.DB.prepare('DELETE FROM players WHERE id = ?1 AND guest = 1').bind(guest)]);
706
+ } else await env.DB.batch(writes);
709
707
  const player = { id: key.player, name: key.name, named: Number(key.named) === 1, guest: false, owner: Number(key.owner) === 1, since: Number(key.created_at) };
710
- return withCookies(json({ ok: true, player: await meOf(env, player), adoptedGuest: Boolean(adopted) }), [session.cookie]);
708
+ return withCookies(json({ ok: true, player: await meOf(env, player), adoptedGuest: Boolean(adopted), ...(adopted ? { adopted } : {}) }), [session.cookie]);
711
709
  }
712
710
 
713
711
  /* ---------------- a recovery email, signed out: the link in the mail is the proof */
package/worker/room.mjs CHANGED
@@ -452,7 +452,8 @@ export class NetRoom {
452
452
  this.stats.evs += 1;
453
453
  const out = { t: 'ev', from: Number.isInteger(m.from) ? m.from : null, k: kind, d: m.d ?? null };
454
454
  const line = JSON.stringify(out);
455
- if (Number.isInteger(m.to)) { for (const o of this.live()) if (o.seat === m.to) this.sendText(o.conn, line); return; }
455
+ // `to: -1` (0.46.3): everyone with no seat, a watcher. An effect with an audience reaches them whole.
456
+ if (Number.isInteger(m.to)) { for (const o of this.live()) if (m.to === -1 ? o.seat === null : o.seat === m.to) this.sendText(o.conn, line); return; }
456
457
  for (const o of this.live()) if (!this.lite(o) || this.liteHears(kind)) this.sendText(o.conn, line);
457
458
  return;
458
459
  }
@@ -460,6 +461,25 @@ export class NetRoom {
460
461
  }
461
462
  }
462
463
 
464
+ /**
465
+ * One measurement of a connection's round trip (0.46.3), and what the rules are told: a LOW PERCENTILE of the last ten
466
+ * seconds of them. A measurement is never shorter than the real round trip, so the low end of a window is close to
467
+ * it and one slow answer does not raise it; a window, so a connection that really got slower or faster is followed.
468
+ * A client can still pretend to be slower by answering late every time: what that buys is bounded by the game's
469
+ * `room.rewindSeconds`, and with acknowledgements on the input frames it has to send its input late as well.
470
+ */
471
+ latencySample(c, ms, now) {
472
+ if (!(ms >= 0 && ms <= 10000)) return;
473
+ const w = (c.rttWindow ??= []);
474
+ w.push([now, ms]);
475
+ while (w.length > 256 || (w.length > 1 && w[0][0] < now - 10000)) w.shift();
476
+ const sorted = w.map((x) => x[1]).sort((a, b) => a - b);
477
+ const low = sorted[Math.floor((sorted.length - 1) * 0.1)];
478
+ if (c.measuredRtt !== undefined && Math.abs(low - c.measuredRtt) < 2 && now - (c.rttToldAt ?? 0) < 1000) return;
479
+ c.measuredRtt = low; c.rttToldAt = now;
480
+ if (c.seat !== null) this.toServer({ t: 'latency', seat: c.seat, ms: low });
481
+ }
482
+
463
483
  /** A snapshot from the host (a browser's socket `c`, or the server: `c` null), kept for joiners and fanned out. */
464
484
  hostSnap(c, m, now, text = null) {
465
485
  // The server's stamp is this relay's own clock; a browser's is its estimate of it, and is checked.
@@ -468,6 +488,7 @@ export class NetRoom {
468
488
  // Revision 10: a rules game's snapshot names the room's epoch.
469
489
  if ((!c || this.rules && c.rules) && Number.isFinite(m.e)) snap.e = m.e;
470
490
  this.lastSnap = snap;
491
+ if (!c) { const sentAt = (this.snapAt ??= new Map()); sentAt.set(snap.k, now); if (sentAt.size > 128) sentAt.delete(sentAt.keys().next().value); }
471
492
  if (c) c.lastSnapAt = now;
472
493
  this.stats.snaps += 1;
473
494
  this.snapTimes.push(now);
@@ -870,6 +891,9 @@ export class NetRoom {
870
891
  if (this.server) {
871
892
  if (!Array.isArray(m.s)) return;
872
893
  this.stats.ins += 1;
894
+ // 0.46.3: the frame names the newest snapshot this browser had when it sent it (`g`). From when that snapshot
895
+ // left here to when this input arrived is this connection's round trip, measured on the input's own path.
896
+ if (Number.isInteger(m.g)) { c.inBand = true; const sent = this.snapAt?.get(m.g); if (sent !== undefined) this.latencySample(c, now - sent, now); }
873
897
  this.toServer({ t: 'in', from: c.seat, e: Number(m.e) || 0, k: Number(m.k) || 0, s: m.s.slice(0, 64), r: Number(m.r) || 0 });
874
898
  return;
875
899
  }
@@ -909,7 +933,7 @@ export class NetRoom {
909
933
  const recipients = isHost && this.rules && c.rules ? this.live() : this.others(c);
910
934
  if (isHost) {
911
935
  if (typeof m.to === 'string') { const o = this.clients.get(m.to); if (o && o.helloed && (o !== c || (this.rules && c.rules))) this.send(o, out); }
912
- else if (Number.isInteger(m.to)) { for (const o of recipients) if (o.seat === m.to) this.send(o, out); }
936
+ else if (Number.isInteger(m.to)) { for (const o of recipients) if (m.to === -1 ? o.seat === null : o.seat === m.to) this.send(o, out); }
913
937
  // An agent with no game client hears only what is addressed to its seat (the lite feed), and the party's lines.
914
938
  else for (const o of recipients) { if (!this.lite(o) || this.liteHears(kind)) this.send(o, out); }
915
939
  } else {
@@ -958,7 +982,8 @@ export class NetRoom {
958
982
  case 'probeAck': {
959
983
  if (c.probe && m.n === c.probe.n) {
960
984
  const ms = now - c.probe.at; c.probe = null;
961
- if(ms>=0 && ms<=10000){ c.measuredRtt=Math.min(c.measuredRtt??Infinity,ms); if(c.seat!==null)this.toServer({t:'latency',seat:c.seat,ms:c.measuredRtt}); }
985
+ // A browser that acknowledges on its input frames is measured there; this separate answer is for one that does not.
986
+ if(ms>=0 && ms<=10000 && !c.inBand) this.latencySample(c, ms, now);
962
987
  }
963
988
  return;
964
989
  }
@@ -321,6 +321,25 @@ export const SAVES_SHELL_JS = `${PASSKEY_JS}
321
321
  });
322
322
  }
323
323
 
324
+ /**
325
+ * This browser's guest signed in to an account that had some of the same keys with other values, and no rule of
326
+ * the game's combined them on the server (mergeSaves): the account's stayed, and the sign-in answered with the
327
+ * guest's. Each is an ordinary conflict, the guest's value (mine) against the account's (theirs), so a game with
328
+ * onConflict keeps or merges it. Never a server: key: a browser cannot write one.
329
+ */
330
+ function keptByAccount(adopted, guestCopy) {
331
+ var asked = [];
332
+ ((adopted && adopted.kept) || []).forEach(function (k) {
333
+ var key = String(k.key);
334
+ if (k.game !== GAME || key.indexOf('server:') === 0 || !KEY.test(key)) return;
335
+ var mine = k.value !== undefined ? k.value : guestCopy[key];
336
+ if (mine === undefined || mine === null) return;
337
+ var c = S.cache[key];
338
+ asked.push(resolve(key, { op: 'set', v: mine }, { value: c ? c.v : null, version: c ? c.ver : 0 }, false));
339
+ });
340
+ if (asked.length) Promise.all(asked).then(function () { flush(); });
341
+ }
342
+
324
343
  addEventListener('online', function () { online(true); S.backoff = 0; flush(); });
325
344
  addEventListener('offline', function () { online(false); });
326
345
  document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'hidden') flush(true); });
@@ -385,9 +404,10 @@ export const SAVES_SHELL_JS = `${PASSKEY_JS}
385
404
  };
386
405
  inn.onclick = function () {
387
406
  busy(true); said.className = 'said'; said.textContent = 'Follow your device…';
388
- flush().then(function () { return P.signIn(); }).then(function (r) {
407
+ var guestCopy = {};
408
+ flush().then(function () { guestCopy = snapshot(); return P.signIn(); }).then(function (r) {
389
409
  setPlayer({ id: r.player.id, name: r.player.name, guest: false, owner: r.player.owner }, false);
390
- return refresh().then(function () { tell('player', { player: me(), cache: snapshot() }); close(); });
410
+ return refresh().then(function () { tell('player', { player: me(), cache: snapshot() }); close(); keptByAccount(r.adopted, guestCopy); });
391
411
  }).catch(function (e) { said.className = 'said bad'; said.textContent = e && e.name ? P.said(e) : (e && e.message) || 'That did not work.'; busy(false); P.prime('in', '/api/player/signin/options'); });
392
412
  };
393
413
  }