@homie-rocks/studio 0.46.0 → 0.46.3

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +3 -2
  3. package/docs/rooms-milestone-2-notes.md +345 -0
  4. package/guides/game/RULES.md +115 -5
  5. package/guides/game/SKILL.md +5 -0
  6. package/lib/build-identity.mjs +43 -0
  7. package/lib/cloudflare.mjs +13 -11
  8. package/lib/crowd-cloud.mjs +88 -0
  9. package/lib/crowd-diagnostics.mjs +73 -0
  10. package/lib/crowd-load.mjs +603 -0
  11. package/lib/crowd-probe/README.md +61 -0
  12. package/lib/crowd-probe/client.mjs +96 -0
  13. package/lib/crowd-probe/worker.mjs +72 -0
  14. package/lib/crowd-probe/wrangler.jsonc +12 -0
  15. package/lib/crowd-summary.mjs +140 -0
  16. package/lib/crowd-worker.mjs +496 -0
  17. package/lib/rules-check-worker.mjs +1 -1
  18. package/lib/rules-check.mjs +2 -2
  19. package/lib/rules-types.mjs +1 -1
  20. package/lib/scaffold.mjs +5 -5
  21. package/lib/template-history.json +84 -0
  22. package/netplay/netplay.ts +19 -6
  23. package/package.json +4 -2
  24. package/rules/core.ts +29 -4
  25. package/rules/host.ts +119 -21
  26. package/rules/interest.mjs +320 -51
  27. package/rules/pack.ts +1 -0
  28. package/rules/rules.ts +29 -8
  29. package/rules/timing.mjs +20 -0
  30. package/rules/types.ts +5 -0
  31. package/rules/view.ts +229 -25
  32. package/saves/SAVES.md +83 -3
  33. package/saves/saves.ts +2 -0
  34. package/worker/build-id.mjs +2 -0
  35. package/worker/gate.mjs +436 -53
  36. package/worker/index.mjs +92 -10
  37. package/worker/office-page.mjs +11 -0
  38. package/worker/office.mjs +27 -0
  39. package/worker/players.mjs +13 -15
  40. package/worker/room.mjs +113 -19
  41. package/worker/saves-shell.mjs +22 -2
  42. package/worker/saves.mjs +282 -19
  43. package/worker/version.mjs +1 -1
  44. package/worker/world-wire.mjs +110 -0
@@ -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
  }
package/worker/saves.mjs CHANGED
@@ -13,6 +13,10 @@
13
13
  * `homie-studio storage add`) a value up to 1 MiB goes to R2 instead (4 MiB a game per player), under a random
14
14
  * object key that the public /media/ route refuses. Keys are 1 to 64 of A-Z a-z 0-9 _ . : -.
15
15
  * A write is atomic per key, not across keys: keep a character in ONE key.
16
+ *
17
+ * A GUEST WHO SIGNS IN to an account that already has saves loses nothing: adoptGuest combines a key both have by
18
+ * the game's declared rules (mergeSaves), in the sign-in's one transaction, and a room's record that lands late for
19
+ * the guest's id follows them (appendServerRecord). Two rows under game '' are that bookkeeping, nobody's save.
16
20
  */
17
21
  import { randomToken } from './webauthn.mjs';
18
22
 
@@ -234,7 +238,7 @@ export async function fallenList(env, game, { limit = 20, player = null } = {})
234
238
 
235
239
  /** Everything this player has in every game, for their export (values included, large ones from R2). */
236
240
  export async function playerData(env, player) {
237
- const rows = (await env.DB.prepare('SELECT game, key, value, bytes, version, updated_at, blob FROM saves WHERE player = ?1 ORDER BY game, key').bind(player).all()).results ?? [];
241
+ const rows = (await env.DB.prepare("SELECT game, key, value, bytes, version, updated_at, blob FROM saves WHERE player = ?1 AND game != '' ORDER BY game, key").bind(player).all()).results ?? [];
238
242
  const stats = (await env.DB.prepare('SELECT game, name, n, updated_at FROM player_stats WHERE player = ?1 ORDER BY game, name').bind(player).all()).results ?? [];
239
243
  const mem = (await env.DB.prepare('SELECT game, character, player_name, summary, at FROM memorials WHERE player = ?1 ORDER BY at').bind(player).all()).results ?? [];
240
244
  const games = {};
@@ -255,54 +259,313 @@ export async function playerData(env, player) {
255
259
 
256
260
  /** Per game: keys, bytes and the last write, for the owner's view of a player (never the values). */
257
261
  export async function savesSummary(env, player) {
258
- const rows = (await env.DB.prepare('SELECT game, COUNT(*) AS keys, SUM(bytes) AS bytes, MAX(updated_at) AS at FROM saves WHERE player = ?1 GROUP BY game').bind(player).all()).results ?? [];
262
+ const rows = (await env.DB.prepare("SELECT game, COUNT(*) AS keys, SUM(bytes) AS bytes, MAX(updated_at) AS at FROM saves WHERE player = ?1 AND game != '' GROUP BY game").bind(player).all()).results ?? [];
259
263
  return rows.map((r) => ({ game: r.game, keys: Number(r.keys), bytes: Number(r.bytes) || 0, updatedAt: Number(r.at) || null }));
260
264
  }
261
265
 
262
- /** The statements that delete a player's saves, stats and memorials (and their large saves' objects, after). */
266
+ /**
267
+ * The statements that delete a player's saves, stats and memorials (and their large saves' objects, after). The
268
+ * forwarding rows of the guests this account once adopted go too, found by the account's own `server:from:` rows,
269
+ * so no row is left pointing at a player who is gone.
270
+ */
263
271
  export async function dropPlayerData(env, player) {
264
- const blobs = (await env.DB.prepare('SELECT value, blob FROM saves WHERE player = ?1 AND blob = 1').bind(player).all()).results ?? [];
272
+ const rows = (await env.DB.prepare("SELECT game, key, value, blob FROM saves WHERE player = ?1 AND (blob = 1 OR game = '')").bind(player).all()).results ?? [];
273
+ const guests = rows.filter((r) => r.game === '' && String(r.key).startsWith(FROM_KEY)).map((r) => String(r.key).slice(FROM_KEY.length));
274
+ const moved = [];
275
+ for (let i = 0; i < guests.length; i += 50) {
276
+ const ids = guests.slice(i, i + 50);
277
+ moved.push(env.DB.prepare(`DELETE FROM saves WHERE game = '' AND key = '${MOVED_KEY}' AND player IN (${ids.map((_, n) => `?${n + 1}`).join(', ')})`).bind(...ids));
278
+ }
265
279
  return {
266
280
  statements: [
267
281
  env.DB.prepare('DELETE FROM saves WHERE player = ?1').bind(player),
268
282
  env.DB.prepare('DELETE FROM player_stats WHERE player = ?1').bind(player),
269
283
  env.DB.prepare('DELETE FROM memorials WHERE player = ?1').bind(player),
284
+ ...moved,
270
285
  ],
271
- after: () => dropBlobs(env, blobs.map(blobKey)),
286
+ after: () => dropBlobs(env, rows.map(blobKey)),
287
+ };
288
+ }
289
+
290
+ /* ------------------------------------------------------------------ a guest signs in to their account */
291
+
292
+ /** Where an adopted guest went: saves row (guest, '', 'server:moved') = {"to": account, "at": ms}. */
293
+ const MOVED_KEY = 'server:moved';
294
+ /** The way back, kept with the account so deleting it finds those rows by key: (account, '', 'server:from:<guest>'). */
295
+ const FROM_KEY = 'server:from:';
296
+ /** What the guard's deliberate SQLite error is recognised by (json_extract of text that is not JSON). */
297
+ const CHANGED = /malformed JSON/i;
298
+ const ADOPT_TRIES = 4;
299
+ const KEPT_BYTES = 256 * 1024;
300
+ /** The shape appendServerRecord itself writes: { matches: [{ id, endedAt, … }] }, newest 100. */
301
+ const RECORDS = Object.freeze({ rule: 'union', union: 'matches', by: 'id', order: 'endedAt', keep: 100 });
302
+ const SAVE_RULES = ['account', 'guest', 'newer', 'larger', 'smaller', 'sum', 'union'];
303
+ const STAT_RULES = ['account', 'guest', 'larger', 'smaller', 'sum'];
304
+ const STAT_SQL = { sum: 'player_stats.n + excluded.n', larger: 'max(player_stats.n, excluded.n)', smaller: 'min(player_stats.n, excluded.n)', guest: 'excluded.n' };
305
+
306
+ /** game id → { saves, stats }, each { exact: Map, prefix: [[prefix, rule]] longest first }. */
307
+ const merges = new Map();
308
+
309
+ function ruleOf(raw, where, stat) {
310
+ if (typeof raw === 'function' && !stat) return { rule: 'function', fn: raw };
311
+ if (typeof raw === 'string' && (stat ? STAT_RULES : SAVE_RULES).includes(raw)) return raw === 'union' ? RECORDS : { rule: raw };
312
+ if (!stat && raw && typeof raw === 'object') {
313
+ if (typeof raw.union === 'string' && raw.union) return { rule: 'union', union: raw.union, by: typeof raw.by === 'string' && raw.by ? raw.by : 'id', order: typeof raw.order === 'string' && raw.order ? raw.order : null, keep: Number.isInteger(raw.keep) && raw.keep > 0 ? raw.keep : null };
314
+ if ((raw.rule === 'larger' || raw.rule === 'smaller') && typeof raw.by === 'string' && raw.by) return { rule: raw.rule, by: raw.by };
315
+ if (typeof raw.rule === 'string' && SAVE_RULES.includes(raw.rule)) return ruleOf(raw.rule, where, stat);
316
+ }
317
+ throw new Error(`mergeSaves: ${where} is not a rule (${(stat ? STAT_RULES : [...SAVE_RULES, '{ union, by, order, keep }', '{ rule, by }', 'a function']).join(', ')})`);
318
+ }
319
+
320
+ function patternsOf(obj, where, stat) {
321
+ const exact = new Map(); const prefix = [];
322
+ if (obj && typeof obj === 'object') for (const [name, raw] of Object.entries(obj)) {
323
+ const rule = ruleOf(raw, `${where}['${name}']`, stat);
324
+ if (name.endsWith('*')) prefix.push([name.slice(0, -1), rule]); else exact.set(name, rule);
325
+ }
326
+ prefix.sort((a, b) => b[0].length - a[0].length);
327
+ return { exact, prefix };
328
+ }
329
+
330
+ /** The rule for one key or stat: its exact name first, then the longest `prefix*` that fits. */
331
+ const ruleFor = (set, name) => (set ? set.exact.get(name) ?? set.prefix.find(([p]) => name.startsWith(p))?.[1] ?? null : null);
332
+
333
+ /**
334
+ * How a guest's saves and stats combine with the account's when the guest signs in to an account that already has
335
+ * the same key (saves/SAVES.md, "When a guest signs in to their account"). The studio's Worker calls it once, at
336
+ * load:
337
+ *
338
+ * import { mergeSaves } from '@homie-rocks/studio/worker';
339
+ * mergeSaves({ '<game id>': { saves: { '<key or prefix*>': rule }, stats: { '<name or prefix*>': rule } } });
340
+ *
341
+ * A save rule is 'account', 'guest', 'newer', 'larger', 'smaller', 'sum', 'union', { rule: 'larger' | 'smaller', by },
342
+ * { union, by, order, keep } or a function (guest, account, ctx) => value. A stat rule is 'account', 'guest',
343
+ * 'larger', 'smaller' or 'sum'. A rule that is none of these throws here, naming it, so a typing mistake never
344
+ * turns into a lost save later. Returns the number of games.
345
+ */
346
+ export function mergeSaves(table) {
347
+ merges.clear();
348
+ if (!table || typeof table !== 'object') return 0;
349
+ for (const [game, entry] of Object.entries(table)) {
350
+ if (!entry || typeof entry !== 'object') continue;
351
+ merges.set(game, { saves: patternsOf(entry.saves, `${game}.saves`, false), stats: patternsOf(entry.stats, `${game}.stats`, true) });
352
+ }
353
+ return merges.size;
354
+ }
355
+
356
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
357
+ const isNumber = (v) => typeof v === 'number' && Number.isFinite(v);
358
+ /** Newest first, then by id: the order of a server record's list (appendServerRecord and a union use the same). */
359
+ const newestFirst = (order, by) => (a, b) => ((Number(b[order]) || 0) - (Number(a[order]) || 0)) || String(a[by]).localeCompare(String(b[by]));
360
+
361
+ function sameValue(a, b) {
362
+ if (a === b) return true;
363
+ if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object' || Array.isArray(a) !== Array.isArray(b)) return false;
364
+ const ka = Object.keys(a); const kb = Object.keys(b);
365
+ return ka.length === kb.length && ka.every((k) => Object.hasOwn(b, k) && sameValue(a[k], b[k]));
366
+ }
367
+
368
+ /** The account's object with its list joined to the guest's by id (the account's entry wins), or undefined. */
369
+ function unionOf(guest, account, { union, by, order, keep }, ids = (id) => typeof id === 'string' || isNumber(id)) {
370
+ if (!isObject(guest) || !isObject(account) || !Array.isArray(guest[union]) || !Array.isArray(account[union])) return undefined;
371
+ const seen = new Set(); const list = [];
372
+ for (const entry of [...account[union], ...guest[union]]) {
373
+ if (!isObject(entry) || !ids(entry[by])) return undefined;
374
+ const id = String(entry[by]);
375
+ if (seen.has(id)) continue;
376
+ seen.add(id); list.push(entry);
377
+ }
378
+ if (order) list.sort(newestFirst(order, by));
379
+ return { ...account, [union]: keep ? list.slice(0, keep) : list };
380
+ }
381
+
382
+ /** One declared rule on one key: { pick: 'account' | 'guest' }, { value, list? } or { failed: why }. */
383
+ async function applyRule(rule, guest, account, ctx) {
384
+ if (rule.rule === 'account' || rule.rule === 'guest') return { pick: rule.rule };
385
+ if (rule.rule === 'newer') return { pick: ctx.guest.updatedAt > ctx.account.updatedAt ? 'guest' : 'account' };
386
+ if (rule.rule === 'larger' || rule.rule === 'smaller') {
387
+ const g = rule.by ? (isObject(guest) ? guest[rule.by] : undefined) : guest;
388
+ const a = rule.by ? (isObject(account) ? account[rule.by] : undefined) : account;
389
+ if (!isNumber(g) || !isNumber(a)) return { failed: 'the two values are not numbers to compare' };
390
+ return { pick: (rule.rule === 'larger' ? g > a : g < a) ? 'guest' : 'account' };
391
+ }
392
+ if (rule.rule === 'sum') {
393
+ if (isNumber(guest) && isNumber(account)) return { value: guest + account };
394
+ if (!isObject(guest) || !isObject(account)) return { failed: 'the two values are not numbers or objects to add' };
395
+ const out = { ...guest, ...account };
396
+ for (const [k, n] of Object.entries(guest)) if (isNumber(n) && isNumber(account[k])) out[k] = n + account[k];
397
+ return { value: out };
398
+ }
399
+ if (rule.rule === 'union') {
400
+ const value = unionOf(guest, account, rule);
401
+ return value === undefined ? { failed: `the two values do not both hold a list of objects under '${rule.union}', each with '${rule.by}'` } : { value, list: rule.union };
402
+ }
403
+ try {
404
+ const value = await rule.fn(structuredClone(guest), structuredClone(account), ctx);
405
+ return value === undefined ? { failed: 'the function returned nothing' } : { value };
406
+ } catch (e) { return { failed: `the function threw: ${String(e?.message ?? e).slice(0, 200)}` }; }
407
+ }
408
+
409
+ /** A merged value as the text to store, a list cut from its end until it fits; null when it cannot be stored. */
410
+ function fitted(value, list) {
411
+ let text;
412
+ try { text = JSON.stringify(value); } catch { return null; }
413
+ if (text === undefined) return null;
414
+ if (list && bytesOf(text) > SAVE_LIMITS.valueBytes) {
415
+ const kept = [...value[list]];
416
+ while (bytesOf(text) > SAVE_LIMITS.valueBytes && kept.length > 1) { kept.pop(); text = JSON.stringify({ ...value, [list]: kept }); }
417
+ }
418
+ return bytesOf(text) > SAVE_LIMITS.valueBytes ? null : text;
419
+ }
420
+
421
+ /**
422
+ * What one key both players have becomes: { took: 'same' } (nothing to do), { took: 'account', rule? } (the
423
+ * account's stays, by a declared rule), { took: 'guest', rule } (the guest's row replaces it), { took: 'merged',
424
+ * rule, text } (a new value), or { took: 'kept', rule?, error? } (no rule could combine them: the account's stays
425
+ * and the guest's value goes back to the browser).
426
+ */
427
+ async function outcomeOf(rule, guest, account, ctx) {
428
+ const settle = (out, name) => {
429
+ if (out.pick) return sameValue(guest, account) ? { took: 'same' } : { took: out.pick, rule: name };
430
+ const text = fitted(out.value, out.list);
431
+ if (text === null) return { failed: 'the merged value is not JSON that fits a save' };
432
+ const value = JSON.parse(text);
433
+ if (sameValue(value, account)) return sameValue(guest, account) ? { took: 'same' } : { took: 'account', rule: name };
434
+ return sameValue(value, guest) ? { took: 'guest', rule: name } : { took: 'merged', rule: name, text };
272
435
  };
436
+ let failed = null;
437
+ if (rule) {
438
+ const out = await applyRule(rule, guest, account, ctx);
439
+ const done = out.failed ? out : settle(out, rule.rule);
440
+ if (!done.failed) return done;
441
+ failed = { rule: rule.rule, error: done.failed };
442
+ }
443
+ if (sameValue(guest, account)) return { took: 'same' };
444
+ // The toolkit's own record shape: nothing but this can ever combine a `server:` record, so it needs no rule.
445
+ const records = unionOf(guest, account, { ...RECORDS, keep: ctx.key.startsWith('server:') ? RECORDS.keep : null }, (id) => typeof id === 'string');
446
+ if (records !== undefined) { const done = settle({ value: records, list: RECORDS.union }, 'union'); if (!done.failed) return done; }
447
+ return { took: 'kept', ...(failed ?? {}) };
273
448
  }
274
449
 
275
450
  /**
276
- * A guest signs in to an account they already have: the account keeps what it has, and whatever only the guest
277
- * had moves over (saves of keys the account lacks, stats it lacks, the guest's memorials). Statements for one
278
- * batch, and the guest's large saves that the account already had (to drop after).
451
+ * A guest signs in to an account they already have. Whatever only the guest had moves over (saves, stats,
452
+ * memorials). A save both have is combined by the game's declared rule (mergeSaves); with no rule, two server
453
+ * records are joined by id, and for anything else the account's stays and the guest's value is reported (`kept`),
454
+ * so the play page can hand it to the game's onConflict. A stat both have keeps the account's number unless a rule
455
+ * says otherwise.
456
+ *
457
+ * Statements for ONE batch (a transaction), computed from one read. A guard per clashing key, and one inside the
458
+ * move for the set of clashing keys, make the batch fail when a room wrote a record to either player in between
459
+ * (adoptGuest reads again and retries). The move also leaves the guest's forwarding row for a record that lands
460
+ * late (appendServerRecord follows it). `plain: true` is the last resort: no guards and no merging.
279
461
  */
280
- export async function adoptStatements(env, from, to) {
281
- const clash = (await env.DB.prepare('SELECT g.value, g.blob FROM saves g JOIN saves a ON a.player = ?2 AND a.game = g.game AND a.key = g.key WHERE g.player = ?1 AND g.blob = 1').bind(from, to).all()).results ?? [];
462
+ export async function adoptStatements(env, from, to, { plain = false } = {}) {
463
+ const now = Date.now();
464
+ const clash = (await env.DB.prepare(`SELECT 0 AS stat, g.game AS game, g.key AS key, g.value AS gv, g.bytes AS gbytes, g.version AS gver, g.updated_at AS gat, g.blob AS gblob, a.value AS av, a.version AS aver, a.updated_at AS aat, a.blob AS ablob FROM saves g JOIN saves a ON a.player = ?2 AND a.game = g.game AND a.key = g.key WHERE g.player = ?1
465
+ UNION ALL SELECT 1, g.game, g.name, g.n, 0, 0, g.updated_at, 0, a.n, 0, a.updated_at, 0 FROM player_stats g JOIN player_stats a ON a.player = ?2 AND a.game = g.game AND a.name = g.name WHERE g.player = ?1`).bind(from, to).all()).results ?? [];
466
+ const pairs = clash.filter((c) => Number(c.stat) === 0);
467
+ const report = { merged: [], moved: 0, kept: [], stats: { merged: [], kept: [] } };
468
+ if (plain) report.fallback = true;
469
+ const guards = []; const writes = []; const drop = [];
470
+ let keptBytes = 0;
471
+ for (const c of pairs) {
472
+ const guestBlob = blobKey({ value: c.gv, blob: c.gblob }); const accountBlob = blobKey({ value: c.av, blob: c.ablob });
473
+ let out = { took: 'same' };
474
+ let guest;
475
+ if (guestBlob || accountBlob || c.gv !== c.av) {
476
+ guest = await valueOf(env, { value: c.gv, blob: c.gblob });
477
+ const account = await valueOf(env, { value: c.av, blob: c.ablob });
478
+ const ctx = { game: c.game, key: c.key, guest: { updatedAt: Number(c.gat), version: Number(c.gver) }, account: { updatedAt: Number(c.aat), version: Number(c.aver) } };
479
+ out = plain ? (sameValue(guest, account) ? { took: 'same' } : { took: 'kept' }) : await outcomeOf(ruleFor(merges.get(c.game)?.saves, c.key), guest, account, ctx);
480
+ }
481
+ if (!plain) guards.push(env.DB.prepare(`SELECT json_extract(CASE WHEN (SELECT COUNT(*) FROM saves WHERE game = ?3 AND key = ?4 AND ((player = ?1 AND version = ?5 AND updated_at = ?6) OR (player = ?2 AND version = ?7 AND updated_at = ?8))) = 2 THEN '0' ELSE 'saves changed during sign-in' END, '$')`).bind(from, to, c.game, c.key, Number(c.gver), Number(c.gat), Number(c.aver), Number(c.aat)));
482
+ if (out.took === 'guest') {
483
+ // The guest's row as it is (a large one keeps its object); the account's own large object goes.
484
+ writes.push(env.DB.prepare('UPDATE saves SET (value, bytes, blob) = (SELECT value, bytes, blob FROM saves WHERE player = ?1 AND game = ?3 AND key = ?4), version = version + 1, updated_at = ?5 WHERE player = ?2 AND game = ?3 AND key = ?4').bind(from, to, c.game, c.key, now));
485
+ drop.push(accountBlob);
486
+ } else if (out.took === 'merged') {
487
+ writes.push(env.DB.prepare('UPDATE saves SET value = ?4, bytes = ?5, version = version + 1, updated_at = ?6, blob = 0 WHERE player = ?1 AND game = ?2 AND key = ?3').bind(to, c.game, c.key, out.text, bytesOf(out.text), now));
488
+ drop.push(guestBlob, accountBlob);
489
+ } else drop.push(guestBlob);
490
+ if (out.took === 'kept') {
491
+ const entry = { game: c.game, key: c.key };
492
+ if (keptBytes + Number(c.gbytes) <= KEPT_BYTES) { keptBytes += Number(c.gbytes); entry.value = guest; } else entry.truncated = true;
493
+ if (out.rule) { entry.rule = out.rule; entry.error = out.error; }
494
+ report.kept.push(entry);
495
+ } else if (out.took !== 'same') report.merged.push({ game: c.game, key: c.key, rule: out.rule, took: out.took });
496
+ }
497
+ for (const c of clash) {
498
+ if (Number(c.stat) !== 1) continue;
499
+ const rule = plain ? null : ruleFor(merges.get(c.game)?.stats, c.key)?.rule ?? null;
500
+ if (rule && STAT_SQL[rule]) {
501
+ writes.push(env.DB.prepare(`INSERT INTO player_stats (player, game, name, n, updated_at) SELECT ?2, game, name, n, ?5 FROM player_stats WHERE player = ?1 AND game = ?3 AND name = ?4 ON CONFLICT(player, game, name) DO UPDATE SET n = ${STAT_SQL[rule]}, updated_at = excluded.updated_at`).bind(from, to, c.game, c.key, now));
502
+ }
503
+ if (rule) report.stats.merged.push({ game: c.game, name: c.key, rule });
504
+ else if (Number(c.gv) !== Number(c.av)) report.stats.kept.push({ game: c.game, name: c.key, value: Number(c.gv) });
505
+ }
506
+ // One statement moves what only the guest had and leaves the forwarding rows; unless `plain`, the forwarding
507
+ // row's value is also the guard: it stops being JSON, and the batch fails, when the clashing keys are no longer
508
+ // the ones read above.
509
+ const moved = JSON.stringify({ to, at: now });
510
+ const guard = plain ? '?3' : `json_extract(CASE WHEN (SELECT COUNT(*) FROM saves g JOIN saves a ON a.player = ?2 AND a.game = g.game AND a.key = g.key WHERE g.player = ?1) = ?6 THEN ?3 ELSE 'saves changed during sign-in' END, '$')`;
511
+ const move = env.DB.prepare(`INSERT OR IGNORE INTO saves (player, game, key, value, bytes, version, updated_at, blob) SELECT ?1, '', '${MOVED_KEY}', ${guard}, ?4, 1, ?5, 0 UNION ALL SELECT ?2, '', '${FROM_KEY}' || ?1, '1', 1, 1, ?5, 0 UNION ALL SELECT ?2, game, key, value, bytes, version, updated_at, blob FROM saves WHERE player = ?1 AND game != '' RETURNING game`)
512
+ .bind(...[from, to, moved, bytesOf(moved), now], ...(plain ? [] : [pairs.length]));
282
513
  return {
283
514
  statements: [
284
- env.DB.prepare('INSERT OR IGNORE INTO saves (player, game, key, value, bytes, version, updated_at, blob) SELECT ?2, game, key, value, bytes, version, updated_at, blob FROM saves WHERE player = ?1').bind(from, to),
515
+ ...guards, ...writes, move,
285
516
  env.DB.prepare('INSERT OR IGNORE INTO player_stats (player, game, name, n, updated_at) SELECT ?2, game, name, n, updated_at FROM player_stats WHERE player = ?1').bind(from, to),
286
517
  env.DB.prepare('UPDATE memorials SET player = ?2 WHERE player = ?1').bind(from, to),
287
- env.DB.prepare('DELETE FROM saves WHERE player = ?1').bind(from),
518
+ env.DB.prepare("DELETE FROM saves WHERE player = ?1 AND game != ''").bind(from),
288
519
  env.DB.prepare('DELETE FROM player_stats WHERE player = ?1').bind(from),
289
520
  ],
290
- after: () => dropBlobs(env, clash.map(blobKey)),
521
+ move, report, plain,
522
+ after: () => dropBlobs(env, drop),
291
523
  };
292
524
  }
293
525
 
526
+ /**
527
+ * Adopt a guest into an account in ONE batch, exactly once: `around(statements)` returns the whole batch (the
528
+ * sign-in's own writes around the adoption's). When a room wrote to either player between the read and the batch,
529
+ * the guard fails the batch, nothing is written, and it is read and tried again; after a few such tries the plain
530
+ * statements run (the account keeps a key both have, reported with `fallback: true`) rather than refusing the
531
+ * sign-in. Returns what happened: { merged, moved, kept, stats: { merged, kept }, fallback? }.
532
+ */
533
+ export async function adoptGuest(env, from, to, around = (statements) => statements) {
534
+ for (let attempt = 0; ; attempt++) {
535
+ const plan = await adoptStatements(env, from, to, { plain: attempt >= ADOPT_TRIES - 1 });
536
+ const batch = around(plan.statements);
537
+ let results;
538
+ try { results = await env.DB.batch(batch); } catch (e) {
539
+ if (plan.plain || !CHANGED.test(String(e?.message ?? e))) throw e;
540
+ continue;
541
+ }
542
+ plan.report.moved = (results?.[batch.indexOf(plan.move)]?.results ?? []).filter((r) => r.game !== '').length;
543
+ await plan.after();
544
+ return plan.report;
545
+ }
546
+ }
547
+
294
548
  /** Server-only, idempotent bounded round receipts. Optimistic versions also
295
- * serialize simultaneous rooms without dropping either room's result. */
549
+ * serialize simultaneous rooms without dropping either room's result. A player who was a guest and has since
550
+ * signed in to their account is followed there (the forwarding row adoptStatements leaves), in the same query
551
+ * that reads the record. */
296
552
  export async function appendServerRecord(env,player,game,key,record){
297
- if(!key.startsWith('server:')||!SAVE_KEY.test(key)||typeof record.id!=='string')throw new Error('invalid server record');
298
- for(let attempt=0;attempt<16;attempt++){
299
- const old=await getSave(env,player,game,key),matches=Array.isArray(old.value?.matches)?old.value.matches:[];
553
+ if(!game||!key.startsWith('server:')||!SAVE_KEY.test(key)||typeof record.id!=='string')throw new Error('invalid server record');
554
+ for(let attempt=0,hops=0;attempt<16;attempt++){
555
+ const rows=(await env.DB.prepare(`SELECT game,value,version,blob FROM saves WHERE player=?1 AND ((game=?2 AND key=?3) OR (game='' AND key='${MOVED_KEY}'))`).bind(player,game,key).all()).results??[];
556
+ const moved=rows.find(r=>r.game==='');
557
+ if(moved){
558
+ let to=null;try{to=JSON.parse(moved.value)?.to;}catch{to=null;}
559
+ if(typeof to!=='string'||!to||to===player||++hops>4)throw new Error('server record forwarding');
560
+ player=to;attempt--;continue;
561
+ }
562
+ const row=rows[0]??null,old={value:row?await valueOf(env,row):null,version:row?Number(row.version):0},matches=Array.isArray(old.value?.matches)?old.value.matches:[];
300
563
  if(matches.some(r=>r.id===record.id))return;
301
- const kept=[record,...matches].sort((a,b)=>b.endedAt-a.endedAt||a.id.localeCompare(b.id)).slice(0,100);
564
+ const kept=[record,...matches].sort(newestFirst(RECORDS.order,RECORDS.by)).slice(0,RECORDS.keep);
302
565
  let value=JSON.stringify({v:1,matches:kept}),bytes=bytesOf(value);
303
566
  while(bytes>SAVE_LIMITS.valueBytes&&kept.length>1){kept.pop();value=JSON.stringify({v:1,matches:kept});bytes=bytesOf(value);}
304
567
  if(bytes>SAVE_LIMITS.valueBytes)throw new Error('server record size');
305
- const result=old.version?await env.DB.prepare('UPDATE saves SET value=?4,bytes=?5,version=version+1,updated_at=?6 WHERE player=?1 AND game=?2 AND key=?3 AND version=?7 RETURNING version').bind(player,game,key,value,bytes,Date.now(),old.version).first():await env.DB.prepare('INSERT INTO saves(player,game,key,value,bytes,version,updated_at,blob) VALUES(?1,?2,?3,?4,?5,1,?6,0) ON CONFLICT(player,game,key) DO NOTHING RETURNING version').bind(player,game,key,value,bytes,Date.now()).first();
568
+ const result=old.version?await env.DB.prepare('UPDATE saves SET value=?4,bytes=?5,version=version+1,updated_at=?6 WHERE player=?1 AND game=?2 AND key=?3 AND version=?7 RETURNING version').bind(player,game,key,value,bytes,Date.now(),old.version).first():await env.DB.prepare(`INSERT INTO saves(player,game,key,value,bytes,version,updated_at,blob) SELECT ?1,?2,?3,?4,?5,1,?6,0 WHERE NOT EXISTS(SELECT 1 FROM saves WHERE player=?1 AND game='' AND key='${MOVED_KEY}') ON CONFLICT(player,game,key) DO NOTHING RETURNING version`).bind(player,game,key,value,bytes,Date.now()).first();
306
569
  if(result)return;
307
570
  }
308
571
  throw new Error('server record conflict; retry the durable outbox');
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.46.0';
2
+ export const STUDIO_VERSION_TAG = '0.46.3';
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The world between the room and a Gate (0.46.2).
3
+ *
4
+ * The room used to send every Gate the whole world as JSON each tick: 150 KB for a thousand players, to sixteen
5
+ * Gates, each of which parsed it. Most of that text was six numbers a body (where it is, how fast, which way), and
6
+ * the rest (its fields, its seat) changes rarely. So the motion goes as one block of numbers, the same block for
7
+ * every Gate, and each Gate is told only the rest of a body when it has changed since that Gate last heard: about
8
+ * 33 KB a tick for a thousand players, and one string to read. The numbers are exact: 32-bit when every one of
9
+ * them is (a rules host's are), 64-bit when any is not. The block travels inside the frame, as base64: a frame
10
+ * of bytes beside the text would have to arrive just before it, and not every path between two objects keeps a
11
+ * binary frame and the text after it together (a local run of 300 players lost four links that way).
12
+ *
13
+ * `worldSource()` belongs to the room, one for all its Gate links. `worldSink()` belongs to a Gate.
14
+ */
15
+ const sameValue = (a, b) => {
16
+ if (a === b) return true;
17
+ if (Array.isArray(a) && Array.isArray(b)) { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) if (!sameValue(a[i], b[i])) return false; return true; }
18
+ if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false;
19
+ return JSON.stringify(a) === JSON.stringify(b);
20
+ };
21
+ /** Everything of a body but its motion: the same unless a field, a revision, a seat or a driver changed. */
22
+ const sameTail = (a, b) => {
23
+ if (a.length !== b.length || a[1] !== b[1] || a[2] !== b[2] || a[6] !== b[6]) return false;
24
+ for (let i = 7; i < a.length; i++) if (!sameValue(a[i], b[i])) return false;
25
+ return true;
26
+ };
27
+ const base64 = (bytes) => { let text = ''; for (let i = 0; i < bytes.length; i += 0x8000) text += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000)); return btoa(text); };
28
+ const bytesOf = (text) => { const raw = atob(text), out = new Uint8Array(raw.length); for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); return out; };
29
+ const numbers = (v) => Array.isArray(v) && v.every((n) => typeof n === 'number' && Number.isFinite(n));
30
+
31
+ export function worldSource() {
32
+ const known = new Map();
33
+ let seq = 0, orderSeq = 0, restSeq = 0, ids = null, restText = null, last = null, lastOut = null;
34
+ return {
35
+ /** Once a world, whichever link asks first: the block of motion, and what changed since the world before. */
36
+ encode(snap) {
37
+ if (snap === last) return lastOut;
38
+ const entities = snap.d?.[1];
39
+ // A world this cannot carry exactly (no bodies array, a body without three vectors of one size) goes as it is.
40
+ const dims = entities?.[0]?.[3]?.length ?? 2;
41
+ if (!Array.isArray(entities) || !entities.every((e) => Array.isArray(e) && e.length >= 9 && typeof e[0] === 'string' && numbers(e[3]) && numbers(e[4]) && numbers(e[5]) && e[3].length === dims && e[4].length === dims && e[5].length === dims)) { last = snap; lastOut = null; return null; }
42
+ seq += 1;
43
+ const n = entities.length, width = dims * 3, wide = new Float64Array(n * width), order = new Array(n), changed = [];
44
+ let same = ids !== null && ids.length === n, narrow = true;
45
+ for (let i = 0; i < n; i++) {
46
+ const e = entities[i], id = e[0]; order[i] = id;
47
+ if (same && ids[i] !== id) same = false;
48
+ for (let a = 0; a < dims; a++) { wide[i * width + a] = e[3][a]; wide[i * width + dims + a] = e[4][a]; wide[i * width + 2 * dims + a] = e[5][a]; }
49
+ const held = known.get(id);
50
+ if (!held) { known.set(id, { row: e, changed: seq }); changed.push(i); }
51
+ else { if (held.row !== e && !sameTail(held.row, e)) { held.changed = seq; changed.push(i); } held.row = e; }
52
+ }
53
+ if (!same) {
54
+ orderSeq = seq;
55
+ if (known.size > n) { const live = new Set(order); for (const id of known.keys()) if (!live.has(id)) known.delete(id); }
56
+ }
57
+ ids = order;
58
+ for (let i = 0; i < wide.length; i++) if (Math.fround(wide[i]) !== wide[i]) { narrow = false; break; }
59
+ const text = snap.d.length > 2 ? JSON.stringify(snap.d.slice(2)) : '';
60
+ if (text !== restText) { restText = text; restSeq = seq; }
61
+ last = snap;
62
+ const motion = narrow ? Float32Array.from(wide).buffer : wide.buffer;
63
+ lastOut = { seq, n, dims, narrow, motion, block: base64(new Uint8Array(motion)), entities, changed, control: new Map((snap.c ?? []).map((row) => [row[0], row])), snap };
64
+ return lastOut;
65
+ },
66
+ /** What one Gate needs beside the block: the bodies whose rest changed since world `synced`, and its own players' control rows. */
67
+ header(world, synced, seats) {
68
+ const { snap, entities } = world, tails = [];
69
+ const tail = (i) => { const e = entities[i].slice(); e[3] = e[4] = e[5] = 0; tails.push([i, e]); };
70
+ if (synced === world.seq - 1) for (const i of world.changed) tail(i);
71
+ else for (let i = 0; i < entities.length; i++) if (known.get(entities[i][0]).changed > synced) tail(i);
72
+ const c = [];
73
+ for (const seat of seats) { const row = world.control.get(seat); if (row) c.push(row); }
74
+ return {
75
+ k: snap.k, st: snap.st, ...(snap.e !== undefined ? { e: snap.e } : {}), round: snap.d[0], c,
76
+ w: { seq: world.seq, n: world.n, dims: world.dims, narrow: world.narrow, ...(orderSeq > synced ? { ids } : {}), tails, ...(restSeq > synced ? { rest: snap.d.slice(2) } : {}) },
77
+ };
78
+ },
79
+ };
80
+ }
81
+
82
+ export function worldSink() {
83
+ let ids = [], tails = [], rest = [];
84
+ return {
85
+ /** The world a header and its block describe, as the room held it. Throws when they do not fit each other. */
86
+ decode(header, block64) {
87
+ const w = header.w;
88
+ if (w.ids) {
89
+ const held = new Map(ids.map((id, i) => [id, tails[i]]));
90
+ ids = w.ids; tails = ids.map((id) => held.get(id));
91
+ }
92
+ for (const [i, tail] of w.tails) tails[i] = tail;
93
+ if (w.rest) rest = w.rest;
94
+ const width = w.dims * 3, bytes = typeof block64 === 'string' ? bytesOf(block64) : null;
95
+ if (!bytes || ids.length !== w.n || bytes.length !== w.n * width * (w.narrow ? 4 : 8)) throw new Error(`world block does not fit its header (${bytes?.length} bytes for ${w.n} bodies, ${ids.length} known)`);
96
+ const block = bytes.buffer;
97
+ const a = w.narrow ? new Float32Array(block) : new Float64Array(block), entities = new Array(w.n), d = w.dims;
98
+ for (let i = 0; i < w.n; i++) {
99
+ const tail = tails[i];
100
+ if (!tail) throw new Error('world names a body this Gate was never told of');
101
+ const row = tail.slice(), o = i * width;
102
+ row[3] = d === 2 ? [a[o], a[o + 1]] : [a[o], a[o + 1], a[o + 2]];
103
+ row[4] = d === 2 ? [a[o + d], a[o + d + 1]] : [a[o + d], a[o + d + 1], a[o + d + 2]];
104
+ row[5] = d === 2 ? [a[o + 2 * d], a[o + 2 * d + 1]] : [a[o + 2 * d], a[o + 2 * d + 1], a[o + 2 * d + 2]];
105
+ entities[i] = row;
106
+ }
107
+ return { k: header.k, st: header.st, ...(header.e !== undefined ? { e: header.e } : {}), d: [header.round, entities, ...rest], c: header.c };
108
+ },
109
+ };
110
+ }