@homie-rocks/studio 0.32.0 → 0.33.0

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 (70) hide show
  1. package/CHANGELOG.md +85 -2
  2. package/README.md +34 -9
  3. package/agents/agents.ts +57 -24
  4. package/bin/homie-studio.mjs +13 -4
  5. package/lib/build.mjs +64 -9
  6. package/lib/cf-usage.mjs +76 -0
  7. package/lib/cloudflare.mjs +25 -2
  8. package/lib/dev.mjs +45 -4
  9. package/lib/lab-check.mjs +3 -2
  10. package/lib/mcp-tools.mjs +1 -1
  11. package/lib/mcp.mjs +1 -1
  12. package/lib/office.mjs +7 -0
  13. package/lib/perf.mjs +8 -3
  14. package/lib/player-image.mjs +40 -0
  15. package/lib/rules-build.mjs +184 -0
  16. package/lib/rules-guard.mjs +770 -0
  17. package/lib/scaffold.mjs +43 -16
  18. package/lib/shop-catalog.mjs +10 -9
  19. package/lib/shop.mjs +32 -15
  20. package/lib/standalone-cli.mjs +30 -2
  21. package/lib/standalone-tools.mjs +20 -2
  22. package/lib/standalone.mjs +215 -9
  23. package/lib/studio.mjs +21 -1
  24. package/lib/template-history.json +90 -0
  25. package/lib/upgrade.mjs +19 -15
  26. package/lib/version.mjs +2 -7
  27. package/netplay/NETPLAY.md +249 -4
  28. package/netplay/netplay.ts +55 -13
  29. package/package.json +23 -1
  30. package/rules/core.ts +1250 -0
  31. package/rules/guard.ts +419 -0
  32. package/rules/host.ts +707 -0
  33. package/rules/math.ts +266 -0
  34. package/rules/pack.ts +292 -0
  35. package/rules/rules.ts +544 -0
  36. package/rules/view.ts +447 -0
  37. package/shop/SELLING.md +14 -16
  38. package/shop/SHOP.md +119 -30
  39. package/shop/shop.ts +6 -8
  40. package/site/SITE.md +119 -2
  41. package/standalone/STANDALONE.md +61 -6
  42. package/starters/coin-dash/game.json +11 -0
  43. package/starters/coin-dash/index.html +17 -0
  44. package/starters/coin-dash/map/main.json +16 -0
  45. package/starters/coin-dash/src/move.ts +27 -0
  46. package/starters/coin-dash/src/rules.ts +86 -0
  47. package/starters/coin-dash/src/view.ts +106 -0
  48. package/starters/coin-dash/tunables.json +5 -0
  49. package/starters/ember-vale/index.html +11 -0
  50. package/worker/brain.mjs +8 -7
  51. package/worker/chat-page.mjs +1 -1
  52. package/worker/embed.mjs +135 -0
  53. package/worker/hosted.mjs +56 -0
  54. package/worker/index.mjs +413 -22
  55. package/worker/office-page.mjs +1 -0
  56. package/worker/office.mjs +24 -7
  57. package/worker/pages.mjs +189 -43
  58. package/worker/players.mjs +10 -6
  59. package/worker/referrals.mjs +192 -57
  60. package/worker/room-store.mjs +54 -0
  61. package/worker/room.mjs +294 -119
  62. package/worker/saves-shell.mjs +7 -1
  63. package/worker/schema.mjs +4 -3
  64. package/worker/shop-page.mjs +103 -37
  65. package/worker/shop-rules.mjs +156 -117
  66. package/worker/shop-store.mjs +70 -9
  67. package/worker/shop.mjs +275 -143
  68. package/worker/site.mjs +26 -5
  69. package/worker/stripe.mjs +9 -6
  70. package/worker/version.mjs +1 -1
package/worker/index.mjs CHANGED
@@ -83,9 +83,12 @@
83
83
  * on the Table's alarm: Workers AI through the optional AI binding (deploy adds
84
84
  * it when a server uses it), or the owner's own key (secret HOMIE_BRAIN_KEY).
85
85
  */
86
+ import { playerEnabled, EMBED_GAME_JS } from './embed.mjs';
86
87
  import { NetRoom, WATCH_POLICIES, departure, errorLine, versionOf } from './room.mjs';
88
+ import { hostedGame, hostedBuild, startHost } from './hosted.mjs';
89
+ import { roomStore, restoreDelay } from './room-store.mjs';
87
90
  import { appCors, isAppOrigin } from './standalone.mjs';
88
- import { ROOM_ID, badRoomPage, frameAncestors, noWatchPage, playPage, watchPage } from './pages.mjs';
91
+ import { ROOM_ID, badRoomPage, embedAncestors, embedPreview, frameAncestors, noWatchPage, playPage, watchPage } from './pages.mjs';
89
92
  import {
90
93
  PUBLIC_SERVER, SERVER_ID, homeOf, memberCounts, memberOf, noteMember, policyOf, pooledRoom, roomCode, roomServer, serverAccess, serverPassOf, serverView, serversOf,
91
94
  setMembership,
@@ -124,6 +127,8 @@ import { licenseOf } from './license.mjs';
124
127
  import { PART_FILES, isPartsPath, partsRoutes, withPartsBand } from './parts.mjs';
125
128
 
126
129
  export { SEAT_MAX } from './seats.mjs';
130
+ // Server-hosted games (NETPLAY.md section 29): the studio's site/src/worker.mjs hands the build's rules over with this.
131
+ export { hostRules } from './hosted.mjs';
127
132
  /** For a studio whose Worker has player accounts: hand their server API to the back office once (worker/office.mjs). */
128
133
  export { usePlayers } from './office.mjs';
129
134
  /** For a studio's own wrapper Worker: count its own pages the way the template counts its pages. */
@@ -145,11 +150,17 @@ const json = (body, status = 200, extra = {}) => new Response(`${JSON.stringify(
145
150
  });
146
151
 
147
152
  /** The catalogue `homie-studio build` writes into the site (games.json). */
153
+ const catalogueCache = new WeakMap();
148
154
  async function catalogue(env, origin) {
149
155
  try {
150
156
  const res = await env.ASSETS.fetch(new Request(`${origin}/games.json`));
151
157
  if (!res.ok) return { studio: { name: env.STUDIO_NAME || 'Studio' }, games: [] };
152
- return await res.json();
158
+ const tag = res.headers.get('etag');
159
+ const hit = catalogueCache.get(env.ASSETS);
160
+ if (tag && hit?.tag === tag && hit.origin === origin) { await res.body?.cancel(); return hit.value; }
161
+ const value = await res.json();
162
+ if (tag) catalogueCache.set(env.ASSETS, { tag, origin, value });
163
+ return value;
153
164
  } catch { return { studio: { name: env.STUDIO_NAME || 'Studio' }, games: [] }; }
154
165
  }
155
166
 
@@ -448,7 +459,10 @@ function notAHomie(request) {
448
459
  * frame was opened with an agent's ticket (section 17): the game plays as that AI, named "<label> · AI".
449
460
  */
450
461
  async function gameDocument(request, env, url, game, meta, cat, { agent = null } = {}) {
462
+ const embedded = url.searchParams.get('embed') === '1' && playerEnabled(cat, meta);
451
463
  const asked = url.searchParams.get('room');
464
+ // A player card's game joins a public room, or `main`: the room Play itself falls back to when the lobby does not answer.
465
+ if (embedded && (!/^(?:pub-[1-9][0-9]*|main)$/.test(asked || '') || url.searchParams.has('t') || url.searchParams.has('watch') || url.searchParams.get('want') === 'screen')) return new Response('Player cards join public rooms through the player address.\n', { status: 400 });
452
466
  // A room code the relay cannot use is refused here too, never swapped for another room.
453
467
  if (asked !== null && !ROOM_ID.test(asked)) return new Response('That room link does not work: a room code is 1 to 32 letters, digits, - or _.\n', { status: 400, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } });
454
468
  const res = await env.ASSETS.fetch(new Request(`${url.origin}/games/${game}/index.html`));
@@ -496,11 +510,11 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
496
510
  // game.json "saves": the play shell around this frame answers @homie-rocks/studio/saves (saves/SAVES.md).
497
511
  ...(meta?.saves && want === 'play' ? { saves: true } : {}),
498
512
  // The studio sells something in this game (shop/SHOP.md): the play shell answers @homie-rocks/studio/shop. A
499
- // watcher has no shop; the shell itself says "kids" on a kids server and shows only a code on a television.
513
+ // watcher has no shop; the shell follows the studio shop policy for kids servers and television checkout.
500
514
  ...(!agent && !watching && shellShop(cat, game) ? { shop: true } : {}),
501
515
  };
502
516
  let html = await res.text();
503
- const head = `<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
517
+ const head = `${embedded ? `<script>${EMBED_GAME_JS}</script>` : ''}<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
504
518
  html = /<head[^>]*>/i.test(html) ? html.replace(/<head([^>]*)>/i, `<head$1>${head}`) : head + html;
505
519
  return new Response(html, {
506
520
  headers: {
@@ -508,7 +522,7 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
508
522
  'cache-control': 'no-store, no-transform',
509
523
  'access-control-allow-origin': '*',
510
524
  // The game runs in an opaque origin: it cannot read this site's storage or cookies.
511
- 'content-security-policy': `sandbox allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups; frame-ancestors ${frameAncestors(cat)}`,
525
+ 'content-security-policy': `sandbox allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups; frame-ancestors ${embedded ? `'self' ${embedAncestors(cat, url.origin, env.HOMIE_EMBED_PREVIEW === '1').replace("'none'", '')}` : frameAncestors(cat)}`,
512
526
  },
513
527
  });
514
528
  }
@@ -567,7 +581,8 @@ function finish(res, path) {
567
581
  const cache = res.headers.get('cache-control') ?? '';
568
582
  const addNt = !/no-transform/i.test(cache);
569
583
  // GAME PARTS: a shared part's preview page is framed by the part's page and the hub, sandboxed (worker/parts.mjs).
570
- const addXfo = !res.headers.has('x-frame-options') && !GAME_FILES.test(path) && !PART_FILES.test(path);
584
+ const playerFrame = /^\/[a-z0-9][a-z0-9-]{0,39}\/play\/embed$/.test('/' + path.split('/').filter(Boolean).join('/')) && res.status === 200 && /frame-ancestors/.test(res.headers.get('content-security-policy') ?? '');
585
+ const addXfo = !playerFrame && !res.headers.has('x-frame-options') && !GAME_FILES.test(path) && !PART_FILES.test(path);
571
586
  if (!addNt && !addXfo) return res;
572
587
  const headers = new Headers(res.headers);
573
588
  if (addNt) headers.set('cache-control', cache ? `${cache}, no-transform` : 'no-transform');
@@ -770,7 +785,7 @@ async function route(request, env, ctx) {
770
785
  : want === '/' ? ldScript(homeLd(cat, url.origin))
771
786
  : game && parts.length === 1 ? ldScript(landingLd(cat, game, url.origin, { shop: await sellingShop(env, cat) }))
772
787
  : ldScript([studioNode(cat, url.origin, { full: true })]);
773
- return customPage(cat, html, { active, schema });
788
+ return customPage(cat, html, { active, schema, origin: url.origin, playerGame: game && parts.length === 1 ? game : null });
774
789
  }
775
790
  }
776
791
  }
@@ -836,6 +851,12 @@ async function route(request, env, ctx) {
836
851
  if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/`, 301);
837
852
  const cat = await getCat();
838
853
  let sub = parts.slice(1).join('/');
854
+ if (sub === '__restart') {
855
+ if (env.HOMIE_PREVIEW !== '1' || request.method !== 'POST' || !hostedGame(game)) return new Response('not found', { status: 404 });
856
+ const room = url.searchParams.get('room') ?? '';
857
+ if (!ROOM_ID.test(room)) return new Response('invalid room', { status: 400 });
858
+ return env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`)).fetch(`https://table/__restart?game=${game}&room=${room}`, { method: 'POST' });
859
+ }
839
860
  // Read now, not from a Worker instance's 5 s cache: a game the owner just made private shuts at once everywhere.
840
861
  const settings = await settingsOf(env, { fresh: true });
841
862
  // The owner's launch state and room size (worker/office.mjs): a private or invite-only game lets in only those
@@ -933,9 +954,12 @@ async function route(request, env, ctx) {
933
954
  if (body.join !== false && !d.ok) return json({ ok: false, error: d.why, message: 'This server\'s door does not let you in.' }, 403);
934
955
  try { return json(await setMembership(env, game, srv.id, acct.slice(2), { join: body.join !== false, home: body.home === true })); } catch { return json({ ok: false, error: 'not-migrated', message: 'This studio\'s database has no servers yet (migration 0006).' }, 503); }
935
956
  }
936
- if (sub === 'tv' || sub === 'play') {
937
- const screen = sub === 'tv' || url.searchParams.get('screen') === '1';
938
- const asked = askedRoom;
957
+ if (sub === 'play/preview' && env.HOMIE_EMBED_PREVIEW === '1' && isLocalOrigin(url.origin)) return embedPreview(cat, meta, url.origin);
958
+ if (sub === 'tv' || sub === 'play' || sub === 'play/embed') {
959
+ const embed = sub === 'play/embed';
960
+ if (embed && (!playerEnabled(cat, meta) || launch !== 'public')) return notFoundPage('This game does not play inside a post. Open its studio page to play.', cat);
961
+ const screen = !embed && (sub === 'tv' || url.searchParams.get('screen') === '1');
962
+ const asked = embed ? null : askedRoom;
939
963
  if (asked !== null && !ROOM_ID.test(asked)) return badRoomPage(cat, meta, asked, { screen });
940
964
  if (!door.ok) return shut();
941
965
  // Which server: the path's, the room's, else (Play) the player's home server, else public.
@@ -943,7 +967,7 @@ async function route(request, env, ctx) {
943
967
  if (asked !== null && !srv) return notFoundPage(`${meta.name} has no server for the room "${asked}".`, cat);
944
968
  const named = asked !== null && !pooledRoom(asked);
945
969
  if (!srv) {
946
- const acct = await accountSub(request, env);
970
+ const acct = embed ? null : await accountSub(request, env);
947
971
  const home = acct?.startsWith('p-') ? await homeOf(env, game, acct.slice(2)) : null;
948
972
  const h = home ? serverById(home) : null;
949
973
  srv = h && h.state === 'open' ? h : serverById('public') ?? PUBLIC_SERVER;
@@ -971,15 +995,15 @@ async function route(request, env, ctx) {
971
995
  const local = isLocalOrigin(url.origin);
972
996
  let qr = null;
973
997
  if (!local) try { qr = qrSvg(joinUrl, { title: `Join ${meta.name ?? game}` }); } catch { /* too long for a QR: the address shows as text */ }
974
- // The shop on a television is a code to buy on a phone (the TV never sells; never on a kids server).
998
+ // Television checkout and kids-server visibility follow the studio shop policy.
975
999
  const sh = shellShop(cat, game, { kids: pol.kids });
976
1000
  let shopQr = null;
977
1001
  if (sh && !local) try { shopQr = qrSvg(`${url.origin}/shop/?game=${encodeURIComponent(game)}`, { title: `Shop: ${meta.name ?? game}` }); } catch { shopQr = null; }
978
- return playPage(cat, meta, { screen: true, joinUrl, qr, local, room, ticket, owner: d.owner, launch, server, shop: sh ? { ...sh, qr: shopQr, url: `${url.origin}/shop/?game=${game}` } : null });
1002
+ return playPage(cat, meta, { origin: url.origin, screen: true, joinUrl, qr, local, room, ticket, owner: d.owner, launch, server, shop: sh ? { ...sh, qr: shopQr, url: `${url.origin}/shop/?game=${game}` } : null });
979
1003
  }
980
1004
  await countVisit(request, env, ctx, game, 'play');
981
1005
  shareDaily(cat, url, ctx);
982
- return playPage(cat, meta, { ticket, owner: d.owner, launch, server, ...(await chatWho(env, game, srv, d.acct)), shop: shellShop(cat, game, { kids: pol.kids }) });
1006
+ return playPage(cat, meta, { origin: url.origin, embed, preview: env.HOMIE_EMBED_PREVIEW === '1', ticket: embed ? null : ticket, owner: !embed && d.owner, launch, server, ...(embed ? {} : await chatWho(env, game, srv, d.acct)), shop: shellShop(cat, game, { kids: pol.kids }) });
983
1007
  }
984
1008
  if (sub === 'watch') {
985
1009
  // The same door as Play: a game that is private or an invite-only beta is watched only by whoever may play it.
@@ -996,7 +1020,7 @@ async function route(request, env, ctx) {
996
1020
  const ticket = d.holder ? await ticketFor(env, game, d.holder) : null;
997
1021
  await countVisit(request, env, ctx, game, 'watch');
998
1022
  shareDaily(cat, url, ctx);
999
- return watchPage(cat, meta, { room: asked, ticket, policy, owner: d.owner, ...(await chatWho(env, game, srv, d.acct)) });
1023
+ return watchPage(cat, meta, { origin: url.origin, room: asked, ticket, policy, owner: d.owner, ...(await chatWho(env, game, srv, d.acct)) });
1000
1024
  }
1001
1025
  if (sub === 'api/watch') {
1002
1026
  if (!door.ok || watchOf(meta) === 'off') return json({ ok: false, error: 'not-found' }, 404);
@@ -1030,6 +1054,7 @@ async function route(request, env, ctx) {
1030
1054
  // game that named none then) and is matched with copies of that build. What it says is its own word, so the
1031
1055
  // Lobby is told it came from an app (`app=1`) and keeps only a few such builds' rooms (LOBBY_APP_VERSIONS).
1032
1056
  const ver = app ? versionOf(url.searchParams.get('gv')) : versionOf(meta?.netplay?.version);
1057
+ if (app && meta?.room?.host === 'server' && ver !== meta.room.build) return json({ ok: false, error: 'stale', message: 'Update the app to play online.' }, 409);
1033
1058
  return lobby().fetch(`https://lobby/join?max=${humanSeats(pol)}&server=${srv.id}&rooms=${srv.roomsMax}${not ? `&not=${encodeURIComponent(not)}` : ''}${ver ? `&ver=${encodeURIComponent(ver)}` : ''}${app ? '&app=1' : ''}`, { method: 'POST' });
1034
1059
  }
1035
1060
  if (sub === 'api/agent') {
@@ -1117,7 +1142,7 @@ async function route(request, env, ctx) {
1117
1142
  const cur = versionOf(meta?.netplay?.version) ?? '';
1118
1143
  const gv = versionOf(url.searchParams.get('gv')) ?? '';
1119
1144
  const stall = Number(meta?.netplay?.stallMs) || 0;
1120
- const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}${sub === '__net' ? `&wp=${policy}${w ? '&w=1' : ''}` : ''}&pol=${encodeFacts(pol)}&chat=${encodeFacts(lean)}${acct ? '&acct=1' : ''}${member ? '&mem=1' : ''}${hub ? '&hub=1' : ''}${ag ? `&ag=${encodeFacts(ag)}` : ''}${badge ? `&bd=${encodeURIComponent(badge)}` : ''}&cur=${encodeURIComponent(cur)}${gv ? `&gv=${encodeURIComponent(gv)}` : ''}${stall ? `&stall=${stall}` : ''}`;
1145
+ const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}${sub === '__net' ? `&wp=${policy}${w ? '&w=1' : ''}` : ''}&pol=${encodeFacts(pol)}&chat=${encodeFacts(lean)}${acct ? '&acct=1' : ''}${member ? '&mem=1' : ''}${hub ? '&hub=1' : ''}${ag ? `&ag=${encodeFacts(ag)}` : ''}${badge ? `&bd=${encodeURIComponent(badge)}` : ''}&cur=${encodeURIComponent(cur)}${gv ? `&gv=${encodeURIComponent(gv)}` : ''}${stall ? `&stall=${stall}` : ''}${meta?.room?.host === 'server' ? '&host=server' : ''}`;
1121
1146
  // The room's own failure to answer is told apart from the browser going away while it was connecting (a closed
1122
1147
  // tab, a reload racing its own socket): the second is a departure, said in one line with its room, never an
1123
1148
  // uncaught error with nothing on it.
@@ -1130,6 +1155,7 @@ async function route(request, env, ctx) {
1130
1155
  // The same knock, relative to the game's own page: the same answer (see notAHomie).
1131
1156
  if (sub === '__homie' || sub.startsWith('__homie/')) return notAHomie(request);
1132
1157
  if (sub === '__game' || sub === '__game/' || sub === '__game/index.html') {
1158
+ if (url.searchParams.get('embed') === '1' && (!playerEnabled(cat, meta) || launch !== 'public')) return notFoundPage('Embedding is off for this game.', cat);
1133
1159
  const who = await ticketSub(env, game, url.searchParams.get('t'));
1134
1160
  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' } });
1135
1161
  // An agent's frame (hands `self`): the game plays as that AI.
@@ -1182,6 +1208,18 @@ async function fallback(request, env, url, getCat) {
1182
1208
  }
1183
1209
 
1184
1210
 
1211
+ /** A server-hosted room in play keeps the alarm this far ahead; a paused one ends this long after its pause (the relay's `forgetMs`). */
1212
+ const ROOM_ALARM_MS = 10 * 60_000;
1213
+ const ROOM_PAUSE_MS = 60_000;
1214
+
1215
+ /** Watchers, AI seats and restoration never extend an absence. */
1216
+ export function roomEndAt({ people, pausedAt, createdAt, restoredAt = null, savedAt = null, alarmAt = null }) {
1217
+ // Only people keep play alive. With no durable alarm, the last save bounds an unrecorded departure.
1218
+ const absence = pausedAt ?? (restoredAt !== null && alarmAt === null ? savedAt : null) ?? restoredAt ?? createdAt;
1219
+ return people > 0 ? Infinity : absence + ROOM_PAUSE_MS;
1220
+ }
1221
+
1222
+
1185
1223
  /**
1186
1224
  * TABLE — one public room: the netplay relay (room.mjs) for every browser in it.
1187
1225
  * It keeps nothing per frame; seats and a checkpoint go to storage (a deploy
@@ -1218,8 +1256,23 @@ export class Table {
1218
1256
  this.chatReadAt = 0;
1219
1257
  this.chatPend = { neurons: 0, ok: 0, held: 0, error: 0 };
1220
1258
  this.chatSent = { chatLines: 0, chatReacts: 0, held: {} };
1259
+ /**
1260
+ * Revision 10 (NETPLAY.md section 29): the host runtime of a server-hosted room (rules/host.ts), and when this
1261
+ * room next needs the object's one alarm (the house guides and the day's counters share it).
1262
+ */
1263
+ this.hostRt = null;
1264
+ this.roomAlarmAt = 0;
1265
+ this.durable = null;
1266
+ this.roomSave = null;
1267
+ this.endedRoom = null;
1268
+ this.pausedAt = null;
1221
1269
  ctx.blockConcurrencyWhile(async () => {
1270
+ // Only a confirmed missing alarm permits the save-age fallback. A read failure is not evidence of absence.
1271
+ this.recoveredAlarmAt = Infinity;
1272
+ try { this.recoveredAlarmAt = (await ctx.storage.getAlarm?.()) ?? null; }
1273
+ catch (error) { this.storageProblem(error); }
1222
1274
  this.saved = (await ctx.storage.get('net')) ?? null;
1275
+ if (this.saved?.retired === true) this.cleanupPending = true;
1223
1276
  this.recorded = (await ctx.storage.get('recorded')) ?? 0;
1224
1277
  this.officeSaved = (await ctx.storage.get('office')) ?? null;
1225
1278
  });
@@ -1235,15 +1288,15 @@ export class Table {
1235
1288
  maxPlayers: max,
1236
1289
  perIp: perAddress(max),
1237
1290
  store: {
1238
- save: (o) => { storage.put('net', o).catch(() => {}); },
1239
- clear: () => { storage.delete('net').catch(() => {}); },
1291
+ save: (o) => { if (this.hostRt) this.saveRoom(this.hostRt.save()); else if (!hostedGame(game)) storage.put('net', o).catch(() => {}); },
1292
+ clear: () => { this.clearRoom(); },
1240
1293
  },
1241
1294
  // The room's lifecycle, one line each with its time and room (worker/room.mjs `log`): a hello, a leave, an
1242
1295
  // election, a build change, and `failed` (a room operation that threw). A departure and a failure seconds apart
1243
1296
  // are told apart by these. HOMIE_ROOM_LOG=0 keeps only the failures.
1244
1297
  log: (line) => this.say(line),
1245
1298
  });
1246
- if (this.saved) this.room.restore(this.saved);
1299
+ if (this.saved && !hostedGame(game)) this.room.restore(this.saved);
1247
1300
  if (this.officeSaved) this.room.restoreOffice(this.officeSaved);
1248
1301
  this.vocabRead = this.readVocab(game).catch(() => false);
1249
1302
  // Room chat (section 19): typed text the floor let through waits for the studio's own Workers AI.
@@ -1254,9 +1307,334 @@ export class Table {
1254
1307
  this.room.onUnsaid = (ids) => this.keptQueue(() => forgetLines(this.env, this.game, this.code, ids));
1255
1308
  // A game's own decisions (section 20): the host's typed questions, for a game whose game.json opts in.
1256
1309
  this.room.decider = (state, questions, opts) => this.decide(state, questions, opts);
1310
+ // A server-hosted room that forgets everything (nobody came back, or the owner closed it) ends its host runtime.
1311
+ this.room.onForget = () => { if (this.hostRt) this.finishRoom('forgotten'); };
1257
1312
  return this.room;
1258
1313
  }
1259
1314
 
1315
+ /* ------------------------------------------------------------ the server as host (NETPLAY.md section 29) */
1316
+
1317
+ /**
1318
+ * A room of a server-hosted game gets its host runtime before its first socket is attached, so no browser is ever
1319
+ * elected. `hosted`: the Worker's word that this game's settings say `host: server`. Rules that are missing from
1320
+ * this build, or do not fit the contract, do not start: the room refuses joins and says why.
1321
+ */
1322
+ ensureHost(room, hosted) {
1323
+ if (this.hostRt || !this.game) return;
1324
+ if (!hostedGame(this.game)) {
1325
+ if (hosted) room.failServerHost('This game runs its rules on the server, and this build of the site does not hold them. Build and deploy it again.');
1326
+ return;
1327
+ }
1328
+ let restoring = false;
1329
+ let pauseWriteFailed = false;
1330
+ try {
1331
+ const build = hostedBuild(this.game);
1332
+ try { this.durable ??= roomStore(this.ctx.storage); } catch (error) { this.storageProblem(error); }
1333
+ let saved = this.cleanupPending ? null : this.readRoom();
1334
+ restoring = Boolean(saved);
1335
+ let boot = { epoch: 0, count: 0 };
1336
+ try { boot = this.durable?.boots() ?? boot; } catch (error) { if (saved) throw error; this.storageProblem(error); }
1337
+ if (!Number.isSafeInteger(boot.epoch) || boot.epoch < 0 || !Number.isInteger(boot.count) || boot.count < 0) {
1338
+ const error = new Error('saved boot counter is invalid');
1339
+ if (saved) throw error;
1340
+ this.storageProblem(error); this.clearRoom(); boot = { epoch: 0, count: 0 };
1341
+ }
1342
+ if (saved && saved.buildHash !== build.build) boot.count = 0;
1343
+ // A restore's provisional pause is not a recorded departure. A stored alarm,
1344
+ // even one now due, has not yet confirmed expiry. The alarm handler owns that decision.
1345
+ const restorePause = saved?.pauseFromRestore === true && Date.now() >= saved.pausedAt + ROOM_PAUSE_MS && this.recoveredAlarmAt !== null ? null : saved?.pausedAt;
1346
+ const restoredEnd = saved ? roomEndAt({ people: 0, pausedAt: restorePause, restoredAt: Date.now(), savedAt: saved.at, alarmAt: this.recoveredAlarmAt }) : Infinity;
1347
+ const expired = Date.now() >= restoredEnd;
1348
+ if (saved && (saved.stateHash !== build.stateHash || boot.count >= 2 || expired)) {
1349
+ const why = saved.stateHash !== build.stateHash ? 'state-changed' : boot.count >= 2 ? 'restore loop' : 'nobody-returned';
1350
+ const tokens = new Set((saved.net.seats ?? []).map((s) => s[1]));
1351
+ this.finishRoom(why);
1352
+ room.forget(); room.ended = null;
1353
+ // Only a holder returning to the old match is rematched. A new visitor uses this name immediately.
1354
+ if (!expired) room.endedMatch = { why, ver: build.build, tokens, until: Date.now() + ROOM_PAUSE_MS };
1355
+ this.room = room; saved = null; restoring = false; boot = { epoch: 0, count: 0 };
1356
+ }
1357
+ this.roomSave = saved;
1358
+ if (saved) {
1359
+ // The seat map and the world came from one transaction. No unrelated net write can take a body from its token.
1360
+ room.seats.clear(); room.state.clear(); room.stateBytes = 0;
1361
+ room.restore({ ...saved.net, savedAt: Date.now(), durable: true });
1362
+ this.pausedAt = restoredEnd - ROOM_PAUSE_MS;
1363
+ this.pauseFromRestore = saved.pauseFromRestore = saved.pausedAt === null || saved.pauseFromRestore === true;
1364
+ saved.pausedAt = this.pausedAt;
1365
+ // A failed pause write must never be mistaken for an invalid host save.
1366
+ try { this.durable.write(saved); } catch (error) { pauseWriteFailed = true; this.storageProblem(error); }
1367
+ }
1368
+ const epoch = saved ? Math.max(saved.host.core.epoch, boot.epoch) + 1 : undefined;
1369
+ if (saved) this.durable.boot({ epoch, count: boot.count });
1370
+ this.hostCreatedAt = Date.now();
1371
+ let ticks = 0;
1372
+ this.hostRt = startHost(this.game, {
1373
+ ...(saved ? { restore: new TextEncoder().encode((saved.hostText ?? JSON.stringify(saved.host))), restoreEpoch: epoch, startDelayMs: boot.count === 1 ? restoreDelay(`${this.game}/${this.code}`) : 0 } : {}),
1374
+ store: { save: (bytes) => this.saveRoom(bytes) },
1375
+ onTick: () => {
1376
+ if (!saved) return;
1377
+ ticks += 1;
1378
+ if (ticks === 1) this.pendingBoot = { epoch, count: saved.buildHash !== build.build ? 0 : boot.count + 1 };
1379
+ if (ticks === 10 * build.settings.tickHz) this.pendingBoot = { epoch, count: 0 };
1380
+ if (this.pendingBoot && Date.now() >= (this.bootRetryAt ?? 0)) { try { this.durable.boot(this.pendingBoot); this.pendingBoot = null; this.bootFailures = 0; } catch (error) { this.bootRetryAt = Date.now() + Math.min(60_000, 1000 * 2 ** Math.min(this.bootFailures ?? 0, 6)); this.bootFailures = (this.bootFailures ?? 0) + 1; this.storageProblem(error); } }
1381
+ },
1382
+ send: (m, text) => room.hostFrame(m, text),
1383
+ log: (line) => { if (line.ev === 'persist-failed') this.storageProblem(new Error(line.error)); else this.say(line); },
1384
+ onPause: () => { this.pauseFromRestore = false; this.pausedAt ??= Date.now(); this.armRoom(this.pausedAt + ROOM_PAUSE_MS); },
1385
+ onResume: () => { this.pauseFromRestore = false; this.pausedAt = null; if (this.hostRt) { try { this.saveRoom(this.hostRt.save()); } catch { /* retry already scheduled */ } } this.retimeRound(); this.armRoom(Date.now() + ROOM_ALARM_MS); },
1386
+ onEnd: (why) => this.endHosted(why),
1387
+ });
1388
+ room.setServerHost(this.hostRt);
1389
+ room.gameVer = build.build;
1390
+ room.setCurrent(build.build);
1391
+ if (pauseWriteFailed) { try { this.saveRoom(this.hostRt.save()); } catch { /* the save retry retains the original absence */ } }
1392
+ if (saved) {
1393
+ this.retimeRound();
1394
+ room.hostFrame({ t: 'state', k: 'shared', d: this.hostRt.core.shared() });
1395
+ room.hostFrame({ t: 'snap', from: null, e: epoch, k: this.hostRt.tick, st: Date.now(), d: this.hostRt.core.snapshot(), c: [] });
1396
+ }
1397
+ this.armRoom(roomEndAt({ people: 0, pausedAt: this.pausedAt, createdAt: this.hostCreatedAt }));
1398
+ } catch (error) {
1399
+ this.hostRt?.stop(); this.hostRt = null;
1400
+ if (restoring) {
1401
+ this.storageProblem(error);
1402
+ room.forget(); this.clearRoom();
1403
+ this.skipSave = true;
1404
+ this.ensureHost(room, hosted);
1405
+ return;
1406
+ }
1407
+ room.failServerHost(`This game's rules could not start: ${String(error?.message ?? error).slice(0, 160)}`);
1408
+ }
1409
+ }
1410
+
1411
+ saveRoom(bytes) {
1412
+ const now = Date.now();
1413
+ if (now < (this.saveRetryAt ?? 0)) throw this.saveError ?? new Error('room save retry is waiting');
1414
+ try {
1415
+ this.durable ??= roomStore(this.ctx.storage);
1416
+ const build = hostedBuild(this.game);
1417
+ const host = new TextDecoder().decode(bytes);
1418
+ const saved = { v: 1, game: this.game, room: this.code, at: now, pauseFromRestore: this.hostRt?.paused === true && this.pauseFromRestore === true, pausedAt: this.hostRt?.paused ? this.pausedAt ?? now : null, stateHash: build.stateHash, buildHash: build.build, host, net: this.room.saved() };
1419
+ const clearing = this.cleanupPending;
1420
+ if (clearing) this.durable.clear();
1421
+ this.durable.write(saved);
1422
+ if (clearing) { this.cleanupGeneration = (this.cleanupGeneration ?? 0) + 1; this.queueNetCleanup(false); }
1423
+ this.roomSave = saved; this.cleanupPending = false; clearTimeout(this.cleanupRetry); this.cleanupRetry = null;
1424
+ this.saveFailures = 0; this.saveRetryAt = 0; this.saveError = null;
1425
+ clearTimeout(this.saveRetry); this.saveRetry = null;
1426
+ this.storageHealth = { ok: !this.pendingBoot };
1427
+ } catch (error) {
1428
+ this.saveError = error;
1429
+ const delay = Math.min(60_000, 1000 * 2 ** Math.min(this.saveFailures ?? 0, 6));
1430
+ this.saveFailures = (this.saveFailures ?? 0) + 1; this.saveRetryAt = now + delay;
1431
+ this.storageProblem(error);
1432
+ if (!this.saveRetry) {
1433
+ this.saveRetry = setTimeout(() => {
1434
+ this.saveRetry = null;
1435
+ if (this.hostRt) { try { this.saveRoom(this.hostRt.save()); } catch { /* saveRoom scheduled the next attempt */ } }
1436
+ }, delay);
1437
+ this.saveRetry?.unref?.();
1438
+ }
1439
+ throw error;
1440
+ }
1441
+ }
1442
+
1443
+ retimeRound() {
1444
+ if (!this.hostRt) return;
1445
+ const r = this.hostRt.core.save().round;
1446
+ const period = 1000 / hostedBuild(this.game).settings.tickHz;
1447
+ this.room.hostFrame({ t: 'round', round: { ...this.room.lastRound, n: r[0], phase: r[1] ? 'live' : 'over', endsAt: Date.now() + (r[2] - this.hostRt.tick) * period, startedAt: Date.now() + (r[3] - this.hostRt.tick) * period } });
1448
+ }
1449
+
1450
+ storageProblem(error) {
1451
+ this.storageHealth = { ok: false, message: String(error?.message ?? error).slice(0, 160), since: this.storageHealth?.ok === false ? this.storageHealth.since : Date.now() };
1452
+ if (Date.now() < (this.storageLogAt ?? 0)) return;
1453
+ this.storageLogAt = Date.now() + 1000;
1454
+ this.say({ ev: 'persist-failed', build: hostedBuild(this.game)?.build, error: this.storageHealth.message });
1455
+ }
1456
+
1457
+ readRoom(validateHost = false) {
1458
+ if (this.skipSave) { this.skipSave = false; return null; }
1459
+ try {
1460
+ if (this.durable?.boots()?.retired === true) { this.clearRoom(); return null; }
1461
+ const saved = this.durable?.read();
1462
+ if (!saved) return null;
1463
+ if (saved.v !== 1 || typeof saved.game !== 'string' || typeof saved.room !== 'string' ||
1464
+ this.game && saved.game !== this.game || this.code && saved.room !== this.code ||
1465
+ !Number.isFinite(saved.at) || saved.at < 0 || saved.at > Date.now() + 5000 || saved.pausedAt !== null && (!Number.isFinite(saved.pausedAt) || saved.pausedAt < 0 || saved.pausedAt > Date.now() + 5000) ||
1466
+ saved.pauseFromRestore !== undefined && typeof saved.pauseFromRestore !== 'boolean' ||
1467
+ saved.host?.v !== 1 || !saved.net || typeof saved.stateHash !== 'string' || typeof saved.buildHash !== 'string') throw new Error('save envelope does not fit this room');
1468
+ saved.at = Math.min(saved.at, Date.now());
1469
+ if (saved.pausedAt !== null) saved.pausedAt = Math.min(saved.pausedAt, Date.now());
1470
+ if (hostedGame(saved.game) && saved.stateHash !== hostedBuild(saved.game).stateHash) return saved;
1471
+ this.game ??= saved.game; this.code ??= saved.room;
1472
+ const seats = saved.net.seats;
1473
+ const cap = hostedBuild(saved.game)?.seats ?? 32;
1474
+ if (saved.net.v !== 1 || saved.net.room !== saved.room || !Array.isArray(seats) || seats.length > cap ||
1475
+ new Set(seats.map((s) => s?.[0])).size !== seats.length || new Set(seats.map((s) => s?.[1])).size !== seats.length ||
1476
+ seats.some((s) => !Array.isArray(s) || !Number.isInteger(s[0]) || s[0] < 0 || s[0] >= cap || typeof s[1] !== 'string' || s[1].length > 128 || typeof s[2] !== 'string' || s[2].length > 40 || s[4] !== null && (!Number.isSafeInteger(s[4]) || s[4] < 0) || s[5] !== null && !Number.isFinite(s[5]))) throw new Error('saved seats do not fit this room');
1477
+ if (validateHost && hostedGame(saved.game) && saved.stateHash === hostedBuild(saved.game).stateHash) {
1478
+ const probe = startHost(saved.game, { restore: new TextEncoder().encode(saved.hostText ?? JSON.stringify(saved.host)), send() {} });
1479
+ probe.stop();
1480
+ } else if (!hostedGame(saved.game)) throw new Error('saved game is not in this build');
1481
+ return saved;
1482
+ } catch (error) { this.storageProblem(error); this.clearRoom(); return null; }
1483
+ }
1484
+
1485
+ clearRoom() {
1486
+ this.cleanupGeneration = (this.cleanupGeneration ?? 0) + 1;
1487
+ this.roomSave = null; this.saved = null; this.pendingBoot = null;
1488
+ try { this.durable?.retire(); this.durable?.clear(); this.cleanupPending = false; clearTimeout(this.cleanupRetry); this.cleanupRetry = null; }
1489
+ catch (error) { this.retryCleanup(error); }
1490
+ this.queueNetCleanup(this.cleanupPending);
1491
+ }
1492
+
1493
+ queueNetCleanup(retired) {
1494
+ // The key-value record can preserve retirement when SQL writes, not just deletes, are unavailable.
1495
+ // Serialize it with a later successful save so an older failure cannot retire the new match.
1496
+ const generation = this.cleanupGeneration;
1497
+ this.netCleanup = (this.netCleanup ?? Promise.resolve()).then(() => {
1498
+ if (generation !== this.cleanupGeneration) return;
1499
+ return retired ? this.ctx.storage.put('net', { retired: true }) : this.ctx.storage.delete('net');
1500
+ }).then(() => {
1501
+ if (generation !== this.cleanupGeneration) return;
1502
+ this.netCleanupFailed = false;
1503
+ clearTimeout(this.netCleanupRetry); this.netCleanupRetry = null;
1504
+ if (!this.cleanupPending) this.cleanupFailures = 0;
1505
+ }).catch((error) => {
1506
+ if (generation !== this.cleanupGeneration) return;
1507
+ if (retired) { this.retryCleanup(error); return; }
1508
+ // Removing an old key marker is not permission to delete a newer SQL match.
1509
+ this.netCleanupFailed = true; this.storageProblem(error);
1510
+ const delay = Math.min(60_000, 1000 * 2 ** Math.min(this.cleanupFailures ?? 0, 6));
1511
+ this.cleanupFailures = (this.cleanupFailures ?? 0) + 1;
1512
+ this.armRoom(Date.now() + delay);
1513
+ if (!this.netCleanupRetry) {
1514
+ this.netCleanupRetry = setTimeout(() => { this.netCleanupRetry = null; if (generation === this.cleanupGeneration) this.queueNetCleanup(false); }, delay);
1515
+ this.netCleanupRetry?.unref?.();
1516
+ }
1517
+ });
1518
+ this.ctx.waitUntil(this.netCleanup);
1519
+ }
1520
+
1521
+ retryCleanup(error) {
1522
+ this.cleanupPending = true; this.storageProblem(error);
1523
+ const delay = Math.min(60_000, 1000 * 2 ** Math.min(this.cleanupFailures ?? 0, 6));
1524
+ this.cleanupFailures = (this.cleanupFailures ?? 0) + 1;
1525
+ this.armRoom(Date.now() + delay);
1526
+ // An alarm write can fail as well. Keep the end pending for this object's lifetime.
1527
+ if (!this.cleanupRetry) {
1528
+ this.cleanupRetry = setTimeout(() => { this.cleanupRetry = null; if (this.cleanupPending) this.clearRoom(); }, delay);
1529
+ this.cleanupRetry?.unref?.();
1530
+ }
1531
+ }
1532
+
1533
+ finishRoom(why) {
1534
+ this.hostRt?.stop(); this.hostRt = null;
1535
+ clearInterval(this.timer); this.timer = null;
1536
+ clearTimeout(this.saveRetry); this.saveRetry = null; this.saveRetryAt = 0;
1537
+ this.clearRoom();
1538
+ const room = this.room;
1539
+ if (room) {
1540
+ this.officeSaved = room.officeSaved();
1541
+ room.ended = { why, ver: hostedBuild(this.game)?.build ?? null };
1542
+ for (const w of room.watchers) { try { w.close(1000, 'room-over'); } catch { /* gone */ } }
1543
+ room.watchers.clear();
1544
+ }
1545
+ this.tellLobby({ ended: true });
1546
+ this.say({ ev: 'host-over', why, build: hostedBuild(this.game)?.build, ...(why === 'restore loop' ? { message: 'This room shares its isolate and may not be the cause.' } : {}) });
1547
+ this.room = null; this.endedRoom = null; this.pausedAt = null;
1548
+ }
1549
+
1550
+ /** The room wants the alarm at `at` (ms): set it unless an earlier one is already waiting. */
1551
+ armRoom(at) {
1552
+ this.roomAlarmAt = at;
1553
+ this.alarmWrite = (this.alarmWrite ?? Promise.resolve()).then(async () => {
1554
+ const cur = await this.ctx.storage.getAlarm();
1555
+ if (cur === null || cur > at) await this.ctx.storage.setAlarm(at);
1556
+ this.alarmFailures = 0;
1557
+ }).catch((error) => {
1558
+ this.storageProblem(error);
1559
+ if (this.alarmRetry) return;
1560
+ const delay = Math.min(60_000, 1000 * 2 ** Math.min(this.alarmFailures ?? 0, 6));
1561
+ this.alarmFailures = (this.alarmFailures ?? 0) + 1;
1562
+ this.alarmRetry = setTimeout(() => { this.alarmRetry = null; this.armRoom(this.roomAlarmAt); }, delay);
1563
+ this.alarmRetry?.unref?.();
1564
+ });
1565
+ this.ctx.waitUntil(this.alarmWrite);
1566
+ }
1567
+
1568
+ /**
1569
+ * The rules failed again and again (rules/host.ts: the budget on every tick for two seconds, or slow ticks for
1570
+ * five): the room ends. Its players are told, their seats are freed, and the next visitor starts a fresh room.
1571
+ */
1572
+ endHosted(why) {
1573
+ const room = this.room;
1574
+ if (!room) return;
1575
+ this.finishRoom(why);
1576
+ this.hostRt = null;
1577
+ for (const c of [...room.clients.values()]) {
1578
+ if (c.helloed) room.error(c, 'room-over', 'This room ended. Joining a fresh room.', { rematch: true, ver: room.ended.ver });
1579
+ room.clients.delete(c.id);
1580
+ try { c.conn.close(1011, 'room-over'); } catch { /* gone */ }
1581
+ }
1582
+ room.emptySince = Date.now();
1583
+ room.forget();
1584
+ this.report();
1585
+ }
1586
+
1587
+ /**
1588
+ * The room's part of the alarm (section 4.5 of the design). A room nobody came back to within a minute of its
1589
+ * pause ends: the relay forgets it and its stored seats go (`office` and `recorded` stay). A room in play keeps an
1590
+ * alarm ahead of it, so one that Cloudflare restarted and nobody returned to is still ended. After a restart there
1591
+ * is no room in memory: stored seats of a server-hosted room that nobody reconnected to are deleted.
1592
+ */
1593
+ async roomAlarm() {
1594
+ try { await this.runRoomAlarm(); this.roomAlarmFailures = 0; }
1595
+ catch (error) {
1596
+ this.storageProblem(error);
1597
+ const delay = Math.min(60_000, 1000 * 2 ** Math.min(this.roomAlarmFailures ?? 0, 6));
1598
+ this.roomAlarmFailures = (this.roomAlarmFailures ?? 0) + 1;
1599
+ this.armRoom(Date.now() + delay);
1600
+ }
1601
+ }
1602
+
1603
+ async runRoomAlarm() {
1604
+ if (this.netCleanupFailed) this.queueNetCleanup(false);
1605
+ if (this.cleanupPending) this.clearRoom();
1606
+ const now = Date.now();
1607
+ const room = this.room;
1608
+ if (!room || !this.hostRt) {
1609
+ if (this.ctx.storage.sql?.exec("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'save'").toArray().length) {
1610
+ this.durable ??= roomStore(this.ctx.storage);
1611
+ const saved = this.cleanupPending ? null : this.readRoom(true);
1612
+ if (saved) {
1613
+ this.game = saved.game; this.code = saved.room;
1614
+ if (saved.stateHash !== hostedBuild(saved.game)?.stateHash) { this.finishRoom('state-changed'); return; }
1615
+ // No saved pause means people were present. The first cold wake starts the absence period.
1616
+ if (saved.pausedAt === null) {
1617
+ this.pausedAt ??= now; saved.pausedAt = this.pausedAt; this.durable.write(saved);
1618
+ }
1619
+ const endAt = roomEndAt({ people: 0, pausedAt: saved.pausedAt, createdAt: now });
1620
+ if (now < endAt) { this.armRoom(endAt); return; }
1621
+ this.finishRoom('nobody-returned');
1622
+ }
1623
+ }
1624
+ if (this.saved?.hosted === 'server') { const was = this.saved.room ?? null; this.saved = null; await this.ctx.storage.delete('net').catch(() => {}); this.say({ ev: 'host-over', why: 'nobody-returned', room: was }); }
1625
+ return;
1626
+ }
1627
+ if (!this.hostRt) return;
1628
+ const endAt = roomEndAt({ people: this.hostRt.people, pausedAt: this.pausedAt, createdAt: this.hostCreatedAt });
1629
+ if (endAt === Infinity) { if (now >= this.roomAlarmAt - 1000) this.armRoom(now + ROOM_ALARM_MS); else this.armRoom(this.roomAlarmAt); return; }
1630
+ if (now < endAt) { this.armRoom(endAt); return; }
1631
+ // Nobody returned: neither watchers nor AI seats keep the paused room.
1632
+ this.finishRoom('nobody-returned');
1633
+ for (const c of [...room.clients.values()]) { if (c.helloed) room.error(c, 'room-over', 'Everyone left, so this room ended.', { rematch: true }); room.clients.delete(c.id); try { c.conn.close(1000, 'room-over'); } catch { /* gone */ } }
1634
+ room.forget();
1635
+ this.report();
1636
+ }
1637
+
1260
1638
  /**
1261
1639
  * One log line of this room: `{ at, ev, game, room, … }`. Never a seat token, a ticket, a browser key or an address;
1262
1640
  * a failure goes to the error stream, everything else to the log (and not at all with HOMIE_ROOM_LOG=0).
@@ -1420,7 +1798,8 @@ export class Table {
1420
1798
  if (!talks(room.policy) || !room.vocab || !(room.policy.guides > 0)) return;
1421
1799
  this.house = new HouseAgents({
1422
1800
  room, env: this.env,
1423
- setAlarm: (at) => { this.ctx.storage.setAlarm(at).catch(() => {}); },
1801
+ // The room's own alarm (a paused server-hosted room ends on it) is never pushed back by a guide's.
1802
+ setAlarm: (at) => { this.ctx.storage.setAlarm(this.roomAlarmAt > Date.now() ? Math.min(at, this.roomAlarmAt) : at).catch(() => {}); },
1424
1803
  budget: { left: (kind) => this.brainCap[kind] - this.brainUsed[kind], spend: (c) => this.brainSpend(c) },
1425
1804
  log: (line) => { try { console.log(JSON.stringify(line)); } catch { /* no console */ } },
1426
1805
  });
@@ -1485,6 +1864,7 @@ export class Table {
1485
1864
  // A brain that throws never takes the room with it: the alarm is not retried, the guides answer from the floor.
1486
1865
  try { if (this.house) await this.house.onAlarm(); } catch (error) { try { console.log(JSON.stringify({ ev: 'brain-alarm-failed', room: this.code, error: String(error?.stack ?? error).slice(0, 400) })); } catch { /* no console */ } }
1487
1866
  try { await this.flushBrain(); } catch { /* counted next time */ }
1867
+ try { await this.roomAlarm(); } catch (error) { this.say({ ev: 'failed', op: 'room-alarm', error: errorLine(error) }); }
1488
1868
  }
1489
1869
 
1490
1870
  async fetch(request) {
@@ -1492,10 +1872,16 @@ export class Table {
1492
1872
  const game = url.searchParams.get('game');
1493
1873
  const code = url.searchParams.get('room');
1494
1874
  const max = Math.max(1, Math.min(SEAT_MAX, Math.floor(Number(url.searchParams.get('max'))) || 8));
1875
+ if (url.pathname === '/__restart') {
1876
+ if (this.env.HOMIE_PREVIEW !== '1' || request.method !== 'POST') return new Response('not found', { status: 404 });
1877
+ // No last-chance save: this measures exactly what a real abrupt restart can lose.
1878
+ setTimeout(() => this.ctx.abort('Preview forced restart'), 25);
1879
+ return json({ ok: true });
1880
+ }
1495
1881
  const room = this.roomFor(game, code, max);
1496
1882
  // The back office (worker/office.mjs), from the studio's Worker only: the room as its owner sees it, and the
1497
1883
  // owner's signed controls, which this room verifies before it applies one (NETPLAY.md section 15).
1498
- if (url.pathname === '/__facts') return json({ ...room.officeFacts(), ...(this.house ? { brains: this.house.facts() } : {}) });
1884
+ if (url.pathname === '/__facts') return json({ ...room.officeFacts(), durability: this.storageHealth ?? { ok: true }, ...(this.house ? { brains: this.house.facts() } : {}) });
1499
1885
  // A chat line as the room keeps it (section 19), for a report: the Worker files the room's own copy, never a reporter's.
1500
1886
  if (url.pathname === '/__chat') {
1501
1887
  const id = url.searchParams.get('id');
@@ -1521,6 +1907,8 @@ export class Table {
1521
1907
  // Revision 9: the game's own stall time and the build that is live now, the Worker's word with every socket.
1522
1908
  const said = url.searchParams.has('cur');
1523
1909
  if (said) { room.setStall(url.searchParams.get('stall')); room.setCurrent(url.searchParams.get('cur') || null); }
1910
+ // Restore before applying the Worker's current policy: a save must not replace a newer policy.
1911
+ this.ensureHost(room, url.searchParams.get('host') === 'server');
1524
1912
  // The room's policy (section 17), composed by the Worker for every socket: a newer one than the room's applies.
1525
1913
  const pol = decodeFacts(url.searchParams.get('pol'));
1526
1914
  // The room's chat rules ride beside it (they may be longer than a policy): section 19.
@@ -1633,6 +2021,8 @@ export class Table {
1633
2021
  if (n % 4 === 0) { room.tellWatchers(); this.report(); this.recordRound(); this.syncHouse(); }
1634
2022
  // Room chat's counts, once a minute (section 19).
1635
2023
  if (n % 240 === 0) this.ctx.waitUntil(this.flushChat().catch(() => {}));
2024
+ // A server-hosted room in play keeps its alarm ahead of it (re-armed every five minutes of play).
2025
+ if (n % 1200 === 0 && this.hostRt?.running) this.armRoom(Date.now() + ROOM_ALARM_MS);
1636
2026
  if (room.seats.size === 0 && room.clients.size === 0) this.openCounted = false;
1637
2027
  if (room.clients.size === 0 && room.watchers.size === 0) { clearInterval(this.timer); this.timer = null; this.report(); this.ctx.waitUntil(this.flushChat().catch(() => {})); if (this.house) { this.ctx.waitUntil(this.flushBrain(true).catch(() => {})); this.house = null; } }
1638
2028
  }, 250);
@@ -1665,7 +2055,7 @@ export class Table {
1665
2055
  /** Tell the Lobby how many players this room has, when it changes. */
1666
2056
  report() {
1667
2057
  const room = this.room;
1668
- if (!room || !this.game) return;
2058
+ if (!room || !this.game || this.endedRoom) return;
1669
2059
  const counts = room.facts().counts;
1670
2060
  // Players are people (an agent never counts as one, DESIGN D9); agents and AI bodies are reported apart.
1671
2061
  const players = counts.players;
@@ -1868,6 +2258,7 @@ export class Lobby {
1868
2258
  if (url.pathname === '/report' && request.method === 'POST') {
1869
2259
  const body = await request.json().catch(() => ({}));
1870
2260
  if (typeof body.room !== 'string' || !ROOM_ID.test(body.room)) return json({ ok: false }, 400);
2261
+ if (body.ended === true) { this.rooms.delete(body.room); this.seen.delete(body.room); this.save(); return json({ ok: true }); }
1871
2262
  // The owner closed this room: matched with nobody until then (and forgotten as a public room).
1872
2263
  if (Number.isFinite(body.closed)) {
1873
2264
  if (body.closed > now) { this.closed.set(body.room, body.closed); this.rooms.delete(body.room); } else this.closed.delete(body.room);