@homie-rocks/studio 0.57.0 → 0.58.1

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 (52) hide show
  1. package/CHANGELOG.md +56 -4
  2. package/docs/toolkit-place-patterns.md +16 -0
  3. package/guides/game/RULES.md +15 -1
  4. package/lib/agent-seat.mjs +10 -3
  5. package/lib/template-history.json +84 -0
  6. package/netplay/NETPLAY.md +10 -0
  7. package/netplay/netplay.ts +30 -9
  8. package/package.json +1 -1
  9. package/parts/README.md +28 -0
  10. package/parts/catalogue.json +236 -0
  11. package/parts/owner-action-checklist/LICENSE +202 -0
  12. package/parts/owner-action-checklist/NOTICE +3 -0
  13. package/parts/owner-action-checklist/README.md +68 -0
  14. package/parts/owner-action-checklist/part.json +95 -0
  15. package/parts/owner-action-checklist/preview/index.html +16 -0
  16. package/parts/owner-action-checklist/src/handler.mjs +44 -0
  17. package/parts/owner-action-checklist/src/index.mjs +96 -0
  18. package/parts/owner-action-checklist/src/style.css +3 -0
  19. package/parts/owner-action-checklist/tuning.json +3 -0
  20. package/parts/terrain-route-map/LICENSE +202 -0
  21. package/parts/terrain-route-map/NOTICE +3 -0
  22. package/parts/terrain-route-map/README.md +67 -0
  23. package/parts/terrain-route-map/THIRD-PARTY-LICENSE +21 -0
  24. package/parts/terrain-route-map/part.json +133 -0
  25. package/parts/terrain-route-map/preview/demo.js +4127 -0
  26. package/parts/terrain-route-map/preview/heightmap.png +0 -0
  27. package/parts/terrain-route-map/preview/index.html +1 -0
  28. package/parts/terrain-route-map/preview/main.mjs +4 -0
  29. package/parts/terrain-route-map/preview/route.json +1 -0
  30. package/parts/terrain-route-map/src/data.mjs +81 -0
  31. package/parts/terrain-route-map/src/index.mjs +131 -0
  32. package/parts/terrain-route-map/src/style.css +3 -0
  33. package/parts/terrain-route-map/tuning.json +5 -0
  34. package/rules/browser.ts +2 -0
  35. package/rules/core.ts +3 -0
  36. package/rules/host.ts +170 -16
  37. package/rules/rules.ts +16 -3
  38. package/rules/view.ts +39 -7
  39. package/starters/coin-dash/game.json +2 -1
  40. package/starters/ember-vale/game.json +2 -1
  41. package/starters/gem-rush/game.json +2 -1
  42. package/starters/gem-rush-3d/game.json +2 -1
  43. package/starters/hero-rush-3d/game.json +2 -1
  44. package/tools/tools.ts +1 -1
  45. package/worker/agents.mjs +20 -4
  46. package/worker/booking-calendar.mjs +15 -2
  47. package/worker/build-id.mjs +1 -1
  48. package/worker/limits.mjs +3 -1
  49. package/worker/room-sessions.mjs +1 -1
  50. package/worker/room.mjs +64 -9
  51. package/worker/table.mjs +51 -18
  52. package/worker/version.mjs +1 -1
package/worker/table.mjs CHANGED
@@ -71,6 +71,8 @@ export class Table {
71
71
  this.gateHeld = new Map();
72
72
  this.gatePromised = new Map();
73
73
  this.roomAlarmAt = 0;
74
+ /** The alarm a house guide asked for. A rest replaces it with the room's own deadline, or the guide would wake the object every few seconds. */
75
+ this.guideAlarmAt = 0;
74
76
  this.durable = null;
75
77
  this.roomSave = null;
76
78
  this.endedRoom = null;
@@ -175,6 +177,8 @@ export class Table {
175
177
  this.room.onUnsaid = (ids) => this.keptQueue(() => forgetLines(this.env, this.game, this.code, ids));
176
178
  // A game's own decisions (section 20): the host's typed questions, for a game whose game.json opts in.
177
179
  this.room.decider = (state, questions, opts) => this.decide(state, questions, opts);
180
+ // Server and browser hosts both rest through the relay. Guides stop here, including when the host is a page.
181
+ this.room.onRest = (on) => { this.house?.rest(on); };
178
182
  // A server-hosted room that forgets everything (nobody came back, or the owner closed it) ends its host runtime.
179
183
  this.room.onForget = () => { if (this.hostRt) this.finishRoom('forgotten'); };
180
184
  if (this.isPersistent()) this.room.forgetMs = Infinity;
@@ -216,7 +220,9 @@ export class Table {
216
220
  // A restore's provisional pause is not a recorded departure. A stored alarm,
217
221
  // even one now due, has not yet confirmed expiry. The alarm handler owns that decision.
218
222
  const restorePause = saved?.pauseFromRestore === true && Date.now() >= saved.pausedAt + ROOM_PAUSE_MS && this.recoveredAlarmAt !== null ? null : saved?.pausedAt;
219
- const restoredEnd = saved ? roomEndAt({ people: 0, pausedAt: restorePause, restoredAt: Date.now(), savedAt: saved.at, alarmAt: this.recoveredAlarmAt }) : Infinity;
223
+ // A resting room was saved with its people still here, and hibernated on purpose: its age is no absence.
224
+ const rested = saved?.resting === true && saved.pausedAt === null;
225
+ const restoredEnd = saved ? roomEndAt({ people: 0, pausedAt: restorePause, restoredAt: Date.now(), savedAt: saved.at, alarmAt: rested ? Date.now() : this.recoveredAlarmAt }) : Infinity;
220
226
  const expired = Date.now() >= restoredEnd;
221
227
  if (saved && persistent && saved.stateHash !== build.stateHash) throw new Error('This lasting world needs its previous state schema. Restore the compatible build; the saved world has been retained.');
222
228
  if (saved && !persistent && (saved.stateHash !== build.stateHash || boot.count >= 2 || expired)) {
@@ -234,14 +240,21 @@ export class Table {
234
240
  // The seat map and the world came from one transaction. No unrelated net write can take a body from its token.
235
241
  room.seats.clear(); room.state.clear(); room.stateBytes = 0;
236
242
  room.restore({ ...saved.net, savedAt: Date.now(), durable: true });
237
- this.pausedAt = persistent ? Date.now() : restoredEnd - ROOM_PAUSE_MS;
238
- this.pauseFromRestore = saved.pauseFromRestore = saved.pausedAt === null || saved.pauseFromRestore === true;
239
- saved.pausedAt = this.pausedAt;
240
- // A failed pause write must never be mistaken for an invalid host save.
241
- try { this.durable.write(saved); } catch (error) { if (persistent) throw error; pauseWriteFailed = true; this.storageProblem(error); }
243
+ if (rested) {
244
+ // Rebuilt after hibernating on purpose: this save is exactly the world every browser holds. It comes back under
245
+ // the same epoch, so the input that wakes it applies, and nothing is written (the room wakes about once a minute).
246
+ this.pausedAt = null; this.pauseFromRestore = false;
247
+ } else {
248
+ this.pausedAt = persistent ? Date.now() : restoredEnd - ROOM_PAUSE_MS;
249
+ this.pauseFromRestore = saved.pauseFromRestore = saved.pausedAt === null || saved.pauseFromRestore === true;
250
+ saved.pausedAt = this.pausedAt;
251
+ // A failed pause write must never be mistaken for an invalid host save.
252
+ try { this.durable.write(saved); } catch (error) { if (persistent) throw error; pauseWriteFailed = true; this.storageProblem(error); }
253
+ }
242
254
  }
243
- const epoch = saved ? Math.max(saved.host.core.epoch, boot.epoch) + 1 : undefined;
244
- if (saved) this.durable.boot({ epoch, count: boot.count });
255
+ // A rested save is replayed under its own epoch; any other restore (a crash, a deploy) takes a new one.
256
+ const epoch = saved ? (rested ? saved.host.core.epoch : Math.max(saved.host.core.epoch, boot.epoch) + 1) : undefined;
257
+ if (saved && !rested) this.durable.boot({ epoch, count: boot.count });
245
258
  this.hostCreatedAt = Date.now();
246
259
  let ticks = 0;
247
260
  this.hostRt = startHost(this.game, {
@@ -257,8 +270,17 @@ export class Table {
257
270
  // Alarm-driven suspension requires the native hibernation lifecycle.
258
271
  // Legacy adapters keep their host clock, as browser hosts do.
259
272
  ...(this.ctx.acceptWebSocket ? { onRoundIdle: until => { room.tellWatchers(); this.report(); this.recordRound(); this.armRoom(until); this.ctx.waitUntil(this.sleepRoom()); } } : {}),
273
+ // A room nobody is playing rests with its people connected (rules/host.ts), and hibernates like a round break.
274
+ // A deliberate rest is a clean stop: the restore-loop count starts again, so waking it is never a crash.
275
+ ...(this.ctx.acceptWebSocket && this.ctx.storage.sql ? { onRest: on => {
276
+ // Guides stop with the room: a think alarm would wake the object, and the command it sent would wake the sim.
277
+ this.house?.rest(on);
278
+ if (!on) { this.armRoom(Date.now() + ROOM_ALARM_MS); this.start(); return; }
279
+ try { this.durable?.boot({ epoch: this.hostRt?.epoch ?? epoch, count: 0 }); this.pendingBoot = null; } catch (error) { this.storageProblem(error); }
280
+ room.tellWatchers(); this.report(); this.ctx.waitUntil(this.sleepRoom());
281
+ } } : {}),
260
282
  onTick: () => {
261
- if (!saved) return;
283
+ if (!saved || rested) return;
262
284
  ticks += 1;
263
285
  if (ticks === 1) this.pendingBoot = { epoch, count: saved.buildHash !== build.build ? 0 : boot.count + 1 };
264
286
  if (ticks === 10 * build.settings.tickHz) this.pendingBoot = { epoch, count: 0 };
@@ -315,7 +337,7 @@ export class Table {
315
337
  const tokens = new Set((net.seats ?? []).map(seat => seat[1]));
316
338
  for (const [account, token] of Object.entries(this.accountTokens)) if (!tokens.has(token)) delete this.accountTokens[account];
317
339
  }
318
- const saved = { ...(build.settings.characters ? { accountTokens: { ...this.accountTokens } } : {}), 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 };
340
+ const saved = { ...(build.settings.characters ? { accountTokens: { ...this.accountTokens } } : {}), 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, ...(this.hostRt?.resting ? { resting: true } : {}), stateHash: build.stateHash, buildHash: build.build, host, net };
319
341
  const clearing = this.cleanupPending;
320
342
  if (clearing) this.durable.clear();
321
343
  if (this.isPersistent()) {
@@ -370,7 +392,7 @@ export class Table {
370
392
  if (saved.v !== 1 || typeof saved.game !== 'string' || typeof saved.room !== 'string' ||
371
393
  this.game && saved.game !== this.game || this.code && saved.room !== this.code ||
372
394
  !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) ||
373
- saved.pauseFromRestore !== undefined && typeof saved.pauseFromRestore !== 'boolean' ||
395
+ saved.pauseFromRestore !== undefined && typeof saved.pauseFromRestore !== 'boolean' || saved.resting !== undefined && saved.resting !== true ||
374
396
  saved.host?.v !== 1 || !saved.net || typeof saved.stateHash !== 'string' || typeof saved.buildHash !== 'string') throw new Error('save envelope does not fit this room');
375
397
  saved.at = Math.min(saved.at, Date.now());
376
398
  if (saved.pausedAt !== null) saved.pausedAt = Math.min(saved.pausedAt, Date.now());
@@ -796,7 +818,10 @@ export class Table {
796
818
  this.house = new HouseAgents({
797
819
  room, env: this.env,
798
820
  // The room's own alarm (a paused server-hosted room ends on it) is never pushed back by a guide's.
799
- setAlarm: (at) => { this.ctx.storage.setAlarm(this.roomAlarmAt > Date.now() ? Math.min(at, this.roomAlarmAt) : at).catch(error => this.say({ev:'background-failed',error:errorLine(error)})); },
821
+ setAlarm: (at) => {
822
+ this.guideAlarmAt = at;
823
+ this.ctx.storage.setAlarm(this.roomAlarmAt > Date.now() ? Math.min(at, this.roomAlarmAt) : at).catch(error => this.say({ev:'background-failed',error:errorLine(error)}));
824
+ },
800
825
  budget: { left: (kind) => this.brainCap[kind] - this.brainUsed[kind], spend: (c) => this.brainSpend(c) },
801
826
  log: (line) => { try { console.log(JSON.stringify(line)); } catch { /* no console */ } },
802
827
  });
@@ -1191,7 +1216,7 @@ export class Table {
1191
1216
  } else {
1192
1217
  const h = room.attach(conn,server.resume);
1193
1218
  server.remember?.(h.id);
1194
- if (server.resume && room.server) room.joinServer(room.clients.get(h.id));
1219
+ if (server.resume && room.server) room.joinServer(room.clients.get(h.id), true);
1195
1220
  // A room is OPENED when the first seat is taken in an empty one (a room restored after a deploy keeps its
1196
1221
  // seats, so it is not counted twice). One counter write per room.
1197
1222
  const fresh = room.seats.size === 0;
@@ -1281,7 +1306,8 @@ export class Table {
1281
1306
  // Older ports publish snapshots without a round protocol. Once play starts,
1282
1307
  // treat them as active until empty: persisting every frame would turn their
1283
1308
  // ordinary network traffic into a stream of SQLite writes.
1284
- const active = this.hostRt ? this.hostRt.running : this.room?.lastRound?.phase === 'live' || (!this.room?.lastRound && this.room?.lastSnap != null && this.room?.clients.size > 0);
1309
+ // A browser host that rested its room (rules/host.ts in the page) sends nothing until somebody plays: it sleeps.
1310
+ const active = this.hostRt ? this.hostRt.running : !this.room?.resting && (this.room?.lastRound?.phase === 'live' || (!this.room?.lastRound && this.room?.lastSnap != null && this.room?.clients.size > 0));
1285
1311
  return this.room && this.hibernationReady && this.sessionStore && this.ctx.storage.sql && this.ctx.acceptWebSocket && (!this.relayed || this.relays.size > 0) && (!this.hostRt || this.hostRt.roundIdleUntil !== null || !this.hostRt.running) && !active && !this.restoringSockets;
1286
1312
  }
1287
1313
  async sleepRoom() {
@@ -1304,12 +1330,19 @@ export class Table {
1304
1330
  }
1305
1331
  this.saveSessions();
1306
1332
  for(const relay of this.relays.values()){relay.session.flush();relay.socket.sleep();}
1307
- // Edge-answered heartbeats need only a minute's liveness sweep. A legacy
1308
- // client still keeps its original hello/heartbeat deadline.
1309
- const due=[...(this.hostRt?.roundIdleUntil != null ? [this.hostRt.roundIdleUntil] : []),now+(room.officeDirty?1000:60000),...[...room.clients.values()].map(c=>{const ping=c.conn.lastPing?.()??0;return c.helloed?Math.max(c.lastSeen,ping)+(ping?Math.max(room.idleMs,60000):room.idleMs):c.joinedAt+5000;})];
1333
+ // Edge-answered heartbeats need only a minute's liveness sweep: a browser that has pinged the edge, or one that
1334
+ // understands a resting room (it pings the edge from the moment it rests). A legacy client still keeps its
1335
+ // original hello/heartbeat deadline.
1336
+ const due=[...(this.hostRt?.roundIdleUntil != null ? [this.hostRt.roundIdleUntil] : []),now+(room.officeDirty?1000:60000),...[...room.clients.values()].map(c=>{const ping=c.conn.lastPing?.()??0;return c.helloed?Math.max(c.lastSeen,ping)+(ping||c.rest&&c.conn.idlePing?Math.max(room.idleMs,60000):room.idleMs):c.joinedAt+5000;})];
1310
1337
  for(const control of [room.vote?.open?room.vote:null,room.regate,room.agentsOut])if(control?.until)due.push(control.until);
1311
1338
  const at=Math.max(now+1,Math.min(...due)),alarm=await this.ctx.storage.getAlarm();
1312
- if(alarm==null||alarm>at)await this.ctx.storage.setAlarm(at);
1339
+ // A guide's think is sooner than the liveness sweep, and sleep only moves an alarm earlier. While the host rests,
1340
+ // that think must not survive: it would wake the object every few seconds and its command would start the room.
1341
+ // A spectator's own shorter deadline stays, because `at` is already the soonest real one.
1342
+ const roomResting=this.hostRt?.resting===true||this.room?.resting===true;
1343
+ const guideSoon=roomResting && this.guideAlarmAt && alarm!=null && alarm<=this.guideAlarmAt+1000;
1344
+ if(alarm==null||alarm>at||guideSoon)await this.ctx.storage.setAlarm(at);
1345
+ if(guideSoon)this.guideAlarmAt=0;
1313
1346
  }
1314
1347
  start() {
1315
1348
  if(this.restoringSockets)return;
@@ -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.57.0';
2
+ export const STUDIO_VERSION_TAG = '0.58.1';