@homie-rocks/studio 0.58.0 → 0.58.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/rules/host.ts CHANGED
@@ -31,6 +31,19 @@ import { stageTimings } from './timing.mjs';
31
31
  * timer would keep the `Table` in memory and billed), the clock stops, and a person who returns finds the tick it
32
32
  * paused on. Watchers and AI seats never keep the world ticking.
33
33
  *
34
+ * A ROOM NOBODY IS PLAYING RESTS. When every person here has stopped playing (nothing held, pressed or sent for the
35
+ * game's `room.restSeconds`, 60 by default, or every page in the background for ten seconds) and, unless the game
36
+ * asked for an away-from-keyboard pause, no AI has sent a command for as long, the clock stops as it does for an
37
+ * empty room, with everybody still connected: no timer is pending, so the `Table` hibernates. It is not charged for
38
+ * duration while it sleeps; a liveness alarm still wakes it about once a minute (one request, and the CPU of that
39
+ * short wake). A deliberate rest is restored under the same epoch and does not rewrite its save. With `room.rest:
40
+ * 'quiet'` (the default) the world must also have nothing of its own to do (no round clock, nothing waiting on the
41
+ * queue, nothing moving). `'idle'` rests regardless and pauses round clocks, timers, bots and AI with it.
42
+ * `'never'` never rests. The first input that holds or presses something, a command, an AI's command, a page coming
43
+ * to the front or a new arrival starts it again at the tick it rested on.
44
+ * Only browsers that said they understand resting (`rest` in their hello) let a room rest, and only when the caller
45
+ * asks for it (`onRest`: the server's Table, or an online browser host). A lasting world never rests.
46
+ *
34
47
  * THE CHAIN CANNOT BREAK. One rule holds whenever this module hands control back to its caller: A ROOM THAT IS RUNNING
35
48
  * HAS EXACTLY ONE TIMER PENDING, AND A ROOM THAT IS PAUSED OR ENDED HAS NONE. A running room with no timer would stand
36
49
  * still for good with its players connected and its object billed. So every way in (`wake`, `frame`, `start`,
@@ -89,6 +102,8 @@ export interface HostOptions {
89
102
  onTick?(): void;
90
103
  /** Suspend settled round breaks; the owner persists and wakes this deadline. */
91
104
  onRoundIdle?(until: number): void;
105
+ /** Rest a room nobody is playing (`true`), and start it again (`false`). Without it a room never rests. */
106
+ onRest?(resting: boolean): void;
92
107
  /** Only the shared server isolate can attribute timer lateness to another tick. */
93
108
  enforceOverrun?: boolean;
94
109
  log?(line: Record<string, unknown>): void;
@@ -115,6 +130,8 @@ export interface Host {
115
130
  readonly paused: boolean;
116
131
  readonly roundIdleUntil: number | null;
117
132
  wakeRound(): void;
133
+ /** Nobody is playing: the clock is stopped with people still here (see A ROOM NOBODY IS PLAYING RESTS). */
134
+ readonly resting: boolean;
118
135
  readonly people: number;
119
136
  readonly viewRadiusM: number | null;
120
137
  readonly privateDelivery: boolean;
@@ -157,6 +174,10 @@ interface SeatIn {
157
174
 
158
175
  /** Every tick failing (cut short by the budget, or faulted) for this long ends the room. */
159
176
  export const FAIL_MS = 2000;
177
+ /** How long a room plays on with nobody playing before it rests, unless the game says otherwise (`room.restSeconds`). */
178
+ export const REST_SECONDS = 60;
179
+ /** How long a room plays on with every page in the background before it rests (never longer than `restSeconds`). */
180
+ export const REST_HIDDEN_MS = 10_000;
160
181
  /** Every tick blamed for running longer than a period for this long ends the room. */
161
182
  export const OVERRUN_MS = 5000;
162
183
 
@@ -203,12 +224,13 @@ export function createHost(o: HostOptions): Host {
203
224
  // A log that throws takes nothing down with it.
204
225
  const log = (line: Record<string, unknown>): void => { try { o.log?.(line); } catch { /* nothing to say it with */ } };
205
226
  const random = o.random ?? Math.random;
206
- const saved = o.restore ? fromBytes(o.restore) as { v: number; roundIdleUntil?: number; characters?: CharacterLease[]; core: SavedCore; names: [number, string][]; agents?: AgentsSaved; guideViews?: [number, { id: string; at: number; value: Record<string, unknown> }][]; queues: [number, Record<string, unknown>, StepInput['claim'], number, number][]; inputs?: [number, SeatIn][] } : null;
227
+ const saved = o.restore ? fromBytes(o.restore) as { v: number; roundIdleUntil?: number; rest?: { on: boolean; at: [number, number][] }; characters?: CharacterLease[]; core: SavedCore; names: [number, string][]; agents?: AgentsSaved; guideViews?: [number, { id: string; at: number; value: Record<string, unknown> }][]; queues: [number, Record<string, unknown>, StepInput['claim'], number, number][]; inputs?: [number, SeatIn][] } : null;
207
228
  if (o.restore && (!saved || typeof saved !== 'object' || Array.isArray(saved))) throw new Error('saved host is not an object');
208
229
  if (saved) {
209
230
  const check = (ok: unknown): void => { if (!ok) throw new Error('saved host inputs are invalid'); };
210
231
  const uint = (n: unknown): boolean => Number.isSafeInteger(n) && (n as number) >= 0;
211
- check(Object.keys(saved).every(k => ['v', 'core', 'names', 'agents', 'guideViews', 'queues', 'inputs', 'characters', 'roundIdleUntil'].includes(k)));
232
+ check(Object.keys(saved).every(k => ['v', 'core', 'names', 'agents', 'guideViews', 'queues', 'inputs', 'characters', 'roundIdleUntil', 'rest'].includes(k)));
233
+ if (saved.rest !== undefined) check(saved.rest && typeof saved.rest.on === 'boolean' && Array.isArray(saved.rest.at) && saved.rest.at.length <= c.seats && saved.rest.at.every(row => Array.isArray(row) && row.length === 2 && uint(row[0]) && row[0] < c.seats && Number.isFinite(row[1])));
212
234
  check(saved.core && Array.isArray(saved.names) && Array.isArray(saved.queues));
213
235
  if (saved.roundIdleUntil !== undefined) check(Number.isFinite(saved.roundIdleUntil) && saved.roundIdleUntil >= 0 && saved.core.round[1] === 0 && !saved.core.overAt);
214
236
  if (saved.characters !== undefined) {
@@ -402,8 +424,23 @@ export function createHost(o: HostOptions): Host {
402
424
  }
403
425
  }
404
426
  let roundIdleUntil: number | null = o.onRoundIdle && Number.isFinite(saved?.roundIdleUntil) ? saved!.roundIdleUntil! : null;
427
+ /*
428
+ * Resting (A ROOM NOBODY IS PLAYING RESTS): how long a room plays on with nobody playing (0: never rests), whether it
429
+ * rests now, when each person last played, since when each page has been in the background, which browsers can rest,
430
+ * and when an AI with its own hands last played. A restored room that was resting rests on.
431
+ */
432
+ const restMode = c.settings.rest ?? 'quiet';
433
+ const restMs = o.onRest && !persistent && restMode !== 'never' ? Math.max(0, c.settings.restSeconds ?? REST_SECONDS) * 1000 : 0;
434
+ /** The world as the last rest check saw it (a quiet room must also be a still one). */
435
+ let stillAs = '';
436
+ let resting = restMs > 0 && saved?.rest?.on === true && roundIdleUntil === null;
437
+ const playedAt = new Map<number, number>(restMs ? saved?.rest?.at ?? [] : []);
438
+ const hiddenSince = new Map<number, number>();
439
+ const restful = new Set<number>();
440
+ let handsAt = -Infinity;
441
+ let restCheckAt = 0;
405
442
  let running = false;
406
- let paused = Boolean(saved && (o.startPaused ?? o.restoreEpoch !== undefined));
443
+ let paused = !resting && Boolean(saved && (o.startPaused ?? o.restoreEpoch !== undefined));
407
444
  let ended = false;
408
445
  /** The pending timer, and whether there is one. `armed` is the truth: a clock may hand back any value as its handle. */
409
446
  let timer: unknown = null;
@@ -471,12 +508,96 @@ export function createHost(o: HostOptions): Host {
471
508
  }
472
509
  const people = (): number => { let n = 0; for (const person of present.values()) if (person) n += 1; return n; };
473
510
 
511
+ /* ---------------------------------------------------------------- resting */
512
+
513
+ /** Each kind's inputs at rest, one text a field: nothing held, nothing pressed. */
514
+ const restingInput = new Map<KindTable, string[]>();
515
+ /** Whether an input frame holds or presses anything (its newest entry's values, and a press in any entry). */
516
+ function engaged(kind: KindTable, rows: unknown[]): boolean {
517
+ let idle = restingInput.get(kind);
518
+ if (!idle) { const v = neutral(kind); idle = kind.input.map(([name]) => JSON.stringify(v[name])); restingInput.set(kind, idle); }
519
+ const last = rows.length - 1;
520
+ for (let n = 0; n < rows.length && n < 64; n += 1) {
521
+ const raw = rows[n];
522
+ if (!Array.isArray(raw)) return false;
523
+ for (let i = 0; i < kind.input.length; i += 1) {
524
+ const fd = kind.input[i][1];
525
+ if (fd.t === 'press') { if (raw[i + 1] === 1) return true; }
526
+ else if (n === last && fd.t !== 'pulse' && JSON.stringify(coerce(fd, raw[i + 1], c.dims)) !== idle[i]) return true;
527
+ }
528
+ }
529
+ return false;
530
+ }
531
+ /** Somebody played: a person (their seat) or an AI with its own hands. A resting room wakes. */
532
+ function played(seat: number, driver: Driver, now: number): void {
533
+ if (driver === 'person') playedAt.set(seat, now); else handsAt = now;
534
+ if (resting) wakeUp('played');
535
+ }
536
+ /**
537
+ * Nobody is playing: every person here has a resting browser and has not played for `restMs` (or hid their page),
538
+ * and, unless this is an away-from-keyboard pause, no AI has played for as long. A `quiet` room (the default)
539
+ * must also have nothing of its own to do: no running round clock, no event, timer, spawn or ask waiting, and a
540
+ * world that stood still since the last check (bots, physics and anything a `tick` handler moves keep it running).
541
+ * An `idle` room rests regardless, pausing all of that, guides included, until somebody plays.
542
+ */
543
+ function quiet(now: number): boolean {
544
+ // A command already sent keeps a quiet room awake. An idle room pauses AI with the people, so that command does
545
+ // not keep the pause from starting. A command that arrives while the room rests still wakes it (`played`).
546
+ if (restMode !== 'idle' && now - handsAt < restMs) return false;
547
+ let anyone = false;
548
+ for (const [seat, person] of present) {
549
+ if (!person) continue;
550
+ anyone = true;
551
+ if (!restful.has(seat)) return false;
552
+ const hid = hiddenSince.get(seat);
553
+ if (hid !== undefined && now - hid >= Math.min(restMs, REST_HIDDEN_MS)) continue;
554
+ if (now - (playedAt.get(seat) ?? now) < restMs) return false;
555
+ }
556
+ if (!anyone || restMode === 'idle') { stillAs = ''; return anyone; }
557
+ if (core.waiting() > 0) { stillAs = ''; return false; }
558
+ const was = stillAs;
559
+ stillAs = JSON.stringify([core.snapshot(), core.shared()]);
560
+ return was === stillAs;
561
+ }
562
+ /** The clock stops with everybody still here. The room is saved first, so the object may be put away at once. */
563
+ function rest(): void {
564
+ resting = true; running = false;
565
+ disarm();
566
+ log({ ev: 'host-rest', game: o.game, tick: core.tick, people: people() });
567
+ try { o.send({ t: 'rest', on: true }); checkpoint(true); o.onRest?.(true); } catch (error) { fault('rest', error); }
568
+ }
569
+ /** Somebody plays again: the room carries on from the tick it rested on, on a clock started now. */
570
+ function wakeUp(why: string): void {
571
+ if (!resting || ended) return;
572
+ resting = false; running = true; paused = false;
573
+ try {
574
+ const now = o.clock.now();
575
+ base = { ms: now, tick: core.tick };
576
+ overSince = null; blamed = false; failingSince = null; lastStart = now;
577
+ for (const q of queues.values()) q.lastFrameTick = core.tick;
578
+ o.send({ t: 'rest', on: false });
579
+ roundRebaseAt = -Infinity; publishRebase(now);
580
+ // The save on disk says it rests, and a rested save comes back under its own epoch. Once the room moves on it
581
+ // must not: a restart now restores this save, under a new epoch, as any crash does.
582
+ checkpoint(true);
583
+ log({ ev: 'host-wake', game: o.game, tick: core.tick, why });
584
+ o.onRest?.(false);
585
+ } catch (error) { fault('wake', error); } finally { keepTime(); }
586
+ }
587
+
474
588
  /* ---------------------------------------------------------------- frames in */
475
589
 
476
590
  function onIn(m: Record<string, unknown>): void {
477
591
  const seat = m.from;
478
- if (!Number.isInteger(seat) || m.e !== epoch || !Array.isArray(m.s)) { stats.dropped += 1; return; }
592
+ if (!Number.isInteger(seat) || !Array.isArray(m.s)) { stats.dropped += 1; return; }
479
593
  const body = core.bodyOf(seat as number);
594
+ // Somebody playing is seen before the epoch is: the first touch after a rest (which a restore may have given a new
595
+ // epoch) wakes the room even when the frame itself is from before it. Read at most once a second a seat.
596
+ if (restMs && body && hasHands(seat as number, body.driver)) {
597
+ const now = o.clock.now();
598
+ if ((resting || now - (playedAt.get(seat as number) ?? -Infinity) >= 1000) && engaged(body.kind, m.s as unknown[])) played(seat as number, body.driver, now);
599
+ }
600
+ if (m.e !== epoch) { stats.dropped += 1; return; }
480
601
  if (!body || !hasHands(seat as number, body.driver)) return;
481
602
  const kind = body.kind;
482
603
  const q = queueOf(seat as number, kind);
@@ -504,7 +625,9 @@ export function createHost(o: HostOptions): Host {
504
625
  if (seq && r !== body.r && !action) continue;
505
626
  if (seq ? q.received?.includes(seq) : j <= q.newest) continue;
506
627
  // Too early: more than a second ahead. This entry and the rest of the frame are dropped.
507
- if (j > K + tickHz) { stats.dropped += 1; break; }
628
+ // An entry more than a second ahead is still reported, as the furthest lead there is: a browser whose clock ran on
629
+ // (a rest, a stalled host clock) sets its clock again instead of being dropped without a word.
630
+ if (j > K + tickHz) { stats.dropped += 1; q.lead = Math.min(q.lead, 126); break; }
508
631
  if(j >= q.newest && lead < q.lead)q.lead=lead;
509
632
  q.newest = Math.max(q.newest,j);
510
633
  if(seq){q.received??=[];q.received.push(seq);if(q.received.length>256)q.received.shift();}
@@ -585,7 +708,13 @@ export function createHost(o: HostOptions): Host {
585
708
  core.seatAway(seat, false);
586
709
  if (!previous || previous.driver !== driver || previous.owner !== owner) queues.delete(seat);
587
710
  rosterText = '';
588
- if (people() > 0) { if (!running && !paused) start(); else if (paused) resume(); }
711
+ // A socket the room restored after hibernating is the same person, still resting; anybody else has just come.
712
+ const now = o.clock.now();
713
+ // The relay says whether the browser understands resting: on the server's join, or on the peer a browser host hears.
714
+ if (m.rest === true || (p as { rest?: unknown }).rest === true) restful.add(seat); else restful.delete(seat);
715
+ if (m.hidden === true) { if (!hiddenSince.has(seat)) hiddenSince.set(seat, now); } else hiddenSince.delete(seat);
716
+ if (m.restored !== true || !playedAt.has(seat)) playedAt.set(seat, now);
717
+ if (people() > 0) { if (resting) { if (m.restored !== true) wakeUp('join'); } else if (!running && !paused) start(); else if (paused) resume(); }
589
718
  return;
590
719
  }
591
720
  case 'leave': {
@@ -598,9 +727,10 @@ export function createHost(o: HostOptions): Host {
598
727
  held.character = core.character(held.seat) ?? held.character;
599
728
  held.returning = true;
600
729
  core.seatLeave(held.seat);
601
- tick(o.clock.now());
730
+ if (!resting) tick(o.clock.now());
602
731
  } else core.seatAway(m.seat as number, true);
603
- if ((running || roundIdleUntil !== null) && people() === 0) pause();
732
+ playedAt.delete(m.seat as number); hiddenSince.delete(m.seat as number); restful.delete(m.seat as number);
733
+ if ((running || resting || roundIdleUntil !== null) && people() === 0) pause();
604
734
  return;
605
735
  }
606
736
  case 'free': {
@@ -609,16 +739,30 @@ export function createHost(o: HostOptions): Host {
609
739
  for (const [id, p] of peers) if (p.seat === m.seat) peers.delete(id);
610
740
  present.delete(m.seat as number);
611
741
  queues.delete(m.seat as number);
742
+ playedAt.delete(m.seat as number); hiddenSince.delete(m.seat as number); restful.delete(m.seat as number);
612
743
  const held = [...characters.values()].find(c => c.seat === m.seat && !c.returning);
613
744
  if (held) { held.character = core.character(held.seat) ?? held.character; held.returning = true; }
614
745
  core.seatLeave(m.seat as number);
615
- if (held) tick(o.clock.now());
746
+ if (held && !resting) tick(o.clock.now());
616
747
  rosterText = '';
748
+ if (resting && people() === 0) pause();
749
+ return;
750
+ }
751
+ case 'hidden': {
752
+ // A page went to the background, or came back (the relay hears it on the page's clock pings). Coming back wakes a
753
+ // resting room: somebody is looking at it again.
754
+ if (!Number.isInteger(m.seat) || !present.get(m.seat as number)) return;
755
+ const now = o.clock.now();
756
+ if (m.on === true) { if (!hiddenSince.has(m.seat as number)) hiddenSince.set(m.seat as number, now); }
757
+ else if (hiddenSince.delete(m.seat as number)) played(m.seat as number, 'person', now);
617
758
  return;
618
759
  }
619
760
  case 'ev': {
620
761
  // What a player sends is read as what it is: a kind or a command's name that is not a text is no kind and no command.
621
762
  const kind = typeof m.k === 'string' ? m.k : '';
763
+ // Anything a seated person sends (a command, a pulse, a line of chat) is somebody playing, and so is an AI's
764
+ // command, whichever hands it plays with: a resting room wakes for a guide as for a person.
765
+ if (restMs && Number.isInteger(m.from)) { const b = core.bodyOf(m.from as number); if (b && (present.get(m.from as number) || hasHands(m.from as number, b.driver) || kind === 'agent:do' || /^ask:/.test(kind))) played(m.from as number, present.get(m.from as number) ? 'person' : b.driver, o.clock.now()); }
622
766
  if (!Number.isInteger(m.from)) {
623
767
  if (m.from === null && SPEECH.test(kind)) o.send({ t: 'ev', from: null, k: kind, d: cleanSpeech(m.d) });
624
768
  return;
@@ -846,6 +990,9 @@ export function createHost(o: HostOptions): Host {
846
990
  running = false; disarm();
847
991
  try { o.store?.save(save()); o.onRoundIdle(roundIdleUntil); }
848
992
  catch (error) { fault('round-idle', error); stop(); o.onEnd?.('storage-or-tick-failed', { tick: core.tick }); }
993
+ } else if (restMs && running && now >= restCheckAt) {
994
+ restCheckAt = now + 1000;
995
+ if (quiet(now)) rest();
849
996
  }
850
997
  return;
851
998
  }
@@ -929,7 +1076,7 @@ export function createHost(o: HostOptions): Host {
929
1076
  const self = { blame: (): void => { blamed = true; } };
930
1077
 
931
1078
  function start(): void {
932
- if (running || ended || roundIdleUntil !== null) return;
1079
+ if (running || resting || ended || roundIdleUntil !== null) return;
933
1080
  running = true; paused = false;
934
1081
  try {
935
1082
  base = { ms: o.clock.now() + (firstTick ? o.startDelayMs ?? 0 : 0), tick: core.tick };
@@ -943,6 +1090,10 @@ export function createHost(o: HostOptions): Host {
943
1090
  }
944
1091
  function pause(): void {
945
1092
  if (!running) {
1093
+ // Everybody left a resting room: it is an empty room now, paused like one. People still here (a tab
1094
+ // went to the background) stay resting. Ending it would start every clock while the next host restores
1095
+ // the rest save and stays asleep, so the input that should wake the room is dropped.
1096
+ if (resting) { if (people() > 0) return; resting = false; paused = true; log({ ev: 'host-pause', game: o.game, tick: core.tick }); try { o.send({ t: 'rest', on: false }); checkpoint(true); o.onPause?.(); } catch (error) { fault('onPause', error); } return; }
946
1097
  if (roundIdleUntil !== null && !paused) { paused = true; checkpoint(true); o.onPause?.(); }
947
1098
  return;
948
1099
  }
@@ -953,7 +1104,7 @@ export function createHost(o: HostOptions): Host {
953
1104
  try { checkpoint(true); o.onPause?.(); } catch (error) { fault('onPause', error); if (persistent) { stop(); o.onEnd?.('storage-or-tick-failed', { tick: core.tick }); } }
954
1105
  }
955
1106
  function resume(announceRound = true): void {
956
- if (running || ended || !paused || roundIdleUntil !== null) return;
1107
+ if (running || resting || ended || !paused || roundIdleUntil !== null) return;
957
1108
  running = true; paused = false;
958
1109
  try {
959
1110
  // The room resumes at the tick it paused on: no tick is skipped and no timer fires for the gap.
@@ -986,7 +1137,7 @@ export function createHost(o: HostOptions): Host {
986
1137
  } catch (error) { fault('announce', error); }
987
1138
  }
988
1139
  function stop(): void {
989
- running = false; paused = false; ended = true;
1140
+ running = false; paused = false; resting = false; ended = true;
990
1141
  core.dispose();
991
1142
  disarm();
992
1143
  agents?.stop();
@@ -1002,7 +1153,7 @@ export function createHost(o: HostOptions): Host {
1002
1153
 
1003
1154
  function save(): Uint8Array {
1004
1155
  const reserved = new Set(core.bodies().filter(b => b.owner === 'reserved').map(b => b.seat));
1005
- return toBytes({ v: 1, ...(roundIdleUntil !== null ? { roundIdleUntil } : {}), ...(characters.size ? { characters: [...characters.values()] } : {}), core: core.save(), guideViews: [...guideViews], ...(agents ? { agents: agents.save() } : savedAgents ? { agents: savedAgents } : {}), names: [...names].filter(([seat]) => !reserved.has(seat)), queues: [...queues].map(([seat, q]) => [seat, q.held, q.claim, q.newest, q.ack]), inputs: [...queues] });
1156
+ return toBytes({ v: 1, ...(roundIdleUntil !== null ? { roundIdleUntil } : {}), ...(restMs ? { rest: { on: resting, at: [...playedAt] } } : {}), ...(characters.size ? { characters: [...characters.values()] } : {}), core: core.save(), guideViews: [...guideViews], ...(agents ? { agents: agents.save() } : savedAgents ? { agents: savedAgents } : {}), names: [...names].filter(([seat]) => !reserved.has(seat)), queues: [...queues].map(([seat, q]) => [seat, q.held, q.claim, q.newest, q.ack]), inputs: [...queues] });
1006
1157
  }
1007
1158
  function checkpoint(force = false): void {
1008
1159
  if (persistent) {
@@ -1048,8 +1199,10 @@ export function createHost(o: HostOptions): Host {
1048
1199
  if (!held || held.returning || ended) return;
1049
1200
  held.character = core.character(held.seat) ?? held.character; held.returning = true;
1050
1201
  present.delete(held.seat); queues.delete(held.seat); names.delete(held.seat);
1051
- if (held.seat >= 0) core.seatLeave(held.seat); tick(o.clock.now());
1052
- if ((running || roundIdleUntil !== null) && people() === 0) pause();
1202
+ playedAt.delete(held.seat); hiddenSince.delete(held.seat); restful.delete(held.seat);
1203
+ if (held.seat >= 0) core.seatLeave(held.seat);
1204
+ if (!resting) tick(o.clock.now());
1205
+ if ((running || resting || roundIdleUntil !== null) && people() === 0) pause();
1053
1206
  },
1054
1207
  characterReturned(account, lease) { if (characters.get(account)?.returning && characters.get(account)?.lease === lease) characters.delete(account); },
1055
1208
  get viewSettings() { return c.settings.view; },
@@ -1061,6 +1214,7 @@ export function createHost(o: HostOptions): Host {
1061
1214
  get paused() { return paused; },
1062
1215
  get roundIdleUntil() { return roundIdleUntil; },
1063
1216
  wakeRound,
1217
+ get resting() { return resting; },
1064
1218
  get people() { return people(); },
1065
1219
  frame, start, pause, resume, announce,
1066
1220
  // An orderly stop of a lasting world folds what moved since the last checkpoint. One that follows a failed write, or arrives during one, writes nothing.
@@ -1071,7 +1225,7 @@ export function createHost(o: HostOptions): Host {
1071
1225
  try { now = o.clock.now(); tick(now); } catch (error) { fault('tickNow', error); } finally { keepTime(); }
1072
1226
  },
1073
1227
  save,
1074
- facts: () => ({ timing: timing.facts(), tick: core.tick, epoch, running, paused, ended, armed, people: people(), tickHz, ...stats, core: { ...core.stats } }),
1228
+ facts: () => ({ timing: timing.facts(), tick: core.tick, epoch, running, paused, resting, ended, armed, people: people(), tickHz, ...stats, core: { ...core.stats } }),
1075
1229
  core,
1076
1230
  };
1077
1231
  }
package/rules/rules.ts CHANGED
@@ -214,6 +214,19 @@ export interface RoomSettings {
214
214
  predict: { idleHold?: boolean; catchM: number | null; catchUp: number; snapM: number | null; blendMs: number; interpMs: number | null };
215
215
  /** The studio's own pace on what one player sends, messages a second by frame kind (`in`, `ev`, ...). Absent: none. */
216
216
  rates?: Record<string, number>;
217
+ /**
218
+ * When a room with people in it rests (rules/host.ts): its clock stops, its people keep their sockets and seats, and
219
+ * the first input, command, AI command or returning page starts it again where it stopped.
220
+ * 'quiet' (absent): nobody has played for `restSeconds`, and the world has nothing of its own to do: no round clock
221
+ * running, no event, timer, spawn or ask waiting, nothing moving (bots, physics, tick handlers).
222
+ * 'idle': nobody has played for `restSeconds`, whatever the world is doing: round clocks, timers, bots
223
+ * and AI pause with it (an away-from-keyboard pause, for arcade games where that is right).
224
+ * 'never': never rests while somebody is here.
225
+ * Every page in the background counts as nobody playing after ten seconds. A lasting world never rests.
226
+ */
227
+ rest?: 'quiet' | 'idle' | 'never';
228
+ /** How long nobody plays before a room rests (`rest`). Absent: 60. 0: never rests. */
229
+ restSeconds?: number;
217
230
  }
218
231
  /**
219
232
  * The default `budget.tick`: 500,000 units at 20 ticks a second or fewer, and less at a faster rate, so a second of
@@ -247,8 +260,8 @@ export function roomSettings(raw: unknown): { settings: RoomSettings; problems:
247
260
  if (!Number.isFinite(n) || n < 0) { problems.push(`"room.${name}" is a number, zero or more; using its default`); return d; }
248
261
  return n;
249
262
  };
250
- const known = new Set(['host', 'offline', 'tickHz', 'inputHz', 'durability', 'budget', 'predict', 'view', 'lifetime', 'characters', 'rates']);
251
- for (const key of Object.keys(r)) if (!known.has(key)) problems.push(`"room.${key}" is not a setting (host, offline, tickHz, inputHz, durability, budget, predict, view, rates)`);
263
+ const known = new Set(['host', 'offline', 'tickHz', 'inputHz', 'durability', 'budget', 'predict', 'view', 'lifetime', 'characters', 'rates', 'rest', 'restSeconds']);
264
+ for (const key of Object.keys(r)) if (!known.has(key)) problems.push(`"room.${key}" is not a setting (host, offline, tickHz, inputHz, durability, budget, predict, view, rates, rest, restSeconds)`);
252
265
  let rates: Record<string, number> | undefined;
253
266
  if (r.rates !== undefined) {
254
267
  if (!r.rates || typeof r.rates !== 'object' || Array.isArray(r.rates)) problems.push('"room.rates" is an object of messages a second per player by kind ({ "in": 60 }); using none');
@@ -275,7 +288,7 @@ export function roomSettings(raw: unknown): { settings: RoomSettings; problems:
275
288
  const p = r.predict && typeof r.predict === 'object' ? r.predict : {};
276
289
  return {
277
290
  settings: {
278
- host, offline, tickHz, inputHz, ...(rates ? { rates } : {}), ...(r.lifetime === 'persistent' ? { lifetime: 'persistent' as const } : {}), ...(r.characters === true ? { characters: true } : {}),
291
+ host, offline, tickHz, inputHz, ...(rates ? { rates } : {}), ...(r.rest !== undefined ? { rest: ['quiet', 'idle', 'never'].includes(r.rest) ? r.rest : (problems.push('"room.rest" is "quiet", "idle" or "never"; using "quiet"'), 'quiet') } : {}), ...(r.restSeconds !== undefined ? { restSeconds: whole('restSeconds', r.restSeconds, 0, 3600, 60) } : {}), ...(r.lifetime === 'persistent' ? { lifetime: 'persistent' as const } : {}), ...(r.characters === true ? { characters: true } : {}),
279
292
  view: { radiusM: r.view?.radiusM === null ? null : num('view.radiusM', r.view?.radiusM, null), ...(r.view?.precisionM !== undefined ? { precisionM: num('view.precisionM', r.view.precisionM, 0) as number } : {}), ...(r.view?.nearM !== undefined ? { nearM: num('view.nearM', r.view.nearM, null) } : {}), ...(r.view?.farHz !== undefined ? { farHz: whole('view.farHz', r.view.farHz, 1, tickHz, tickHz) } : {}) },
280
293
  durability: { movementSeconds: whole('durability.movementSeconds', r.durability?.movementSeconds, 1, 60, r.lifetime === 'persistent' ? 30 : 1) },
281
294
  budget: { tick: Math.max(1, Math.floor(num('budget.tick', r.budget?.tick, budgetFor(tickHz)) as number)) },
package/rules/view.ts CHANGED
@@ -294,6 +294,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
294
294
  let controlChanges: number[][] = [];
295
295
  let sentHistory: { t: number; row: number[] }[] = [];
296
296
  let hiddenSent = false;
297
+ /** The room rests (net.resting): when this page last sent the change that wakes it, and whether its clock is held. */
298
+ let restWokeAt = -Infinity;
299
+ let restHeld = false;
297
300
  const history = new Map<number, Mine>();
298
301
  let pending: { seq?: number; applied?: number; t: number; input: Readonly<Record<string, unknown>>; pulses?: Record<string, number>; pulseTicks?: Record<string, number> }[] = [];
299
302
  /** Valued pulses sent, numbered: the server's control row names the newest it has acted on (0.46.3). */
@@ -423,7 +426,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
423
426
  leadAfter = clock() + 2 * rtt + period;
424
427
  base = { tick: continuousTick ?? k + Math.min(tickHz, Math.ceil(rtt / period + target) + sendEvery - 1), at: clock() };
425
428
  stepped = Math.max(k, Math.floor(base.tick) - (continuousTick === null ? 1 : 0));
426
- entries = []; sentHistory = []; controlChanges = []; pending = []; history.clear(); replayHeld = {}; serverHeld = {}; held = {}; lastSent = ''; leads.length = 0; leadTarget = .5; leadMedian = 0; pressed = new Map(); pulseNumber = new Map(); heldTrack.clear(); if (reason !== 'lead') dropPredictions();
429
+ entries = []; sentHistory = []; controlChanges = []; pending = []; history.clear(); replayHeld = {}; serverHeld = {}; held = {}; lastSent = ''; leads.length = 0; leadTarget = .5; leadMedian = 0; pressed = new Map(); pulseNumber = new Map(); heldTrack.clear(); if (reason !== 'lead' && reason !== 'rest') dropPredictions();
427
430
  speed = 1; phases.length = 0; phases.push({ ...base, rate: 1 }); catchTick = null; offsets = []; headingBlend = null; correction.rebases += 1;
428
431
  const u = mine && latest?.ents.get(mine.id); if (u) adopt(u);
429
432
  if (mine) {
@@ -637,14 +640,16 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
637
640
  /**
638
641
  * The end of a send period (`sendEvery` ticks, counted from tick 0): one frame with the entries made in it, `k` the
639
642
  * period's first tick. A period with no entry sends nothing; after 250 ms without a frame the held values are sent
640
- * again as an ordinary entry on the current tick.
643
+ * again as an ordinary entry on the current tick. A resting room (nobody played for a while) is sent nothing until
644
+ * an entry changes: that first change is what wakes it.
641
645
  */
642
646
  function flush(now: number, t: number): void {
643
647
  if (!mine) { entries = []; return; }
644
- if (!entries.length && now - lastSendAt >= 250) keepalive(t);
648
+ const resting = net.resting();
649
+ if (!entries.length && now - lastSendAt >= 250 && !resting) keepalive(t);
645
650
  const marker = myKind()!.input.length + (myKind()!.owner ? 9 : 0);
646
651
  // A newer tick's acknowledgement cannot acknowledge a hole in the history.
647
- if (!entries.length && !sentHistory.some(e => !applications.has(e.row[marker + 1]))) return;
652
+ if (!entries.length && (resting || !sentHistory.some(e => !applications.has(e.row[marker + 1])))) return;
648
653
  const k = Math.min(entries[0]?.t ?? t, t - (t % sendEvery));
649
654
  for (const entry of entries) {
650
655
  const old = sentHistory.findIndex(e => e.t === entry.t);
@@ -710,7 +715,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
710
715
  const hidden = typeof document !== 'undefined' && document.hidden === true;
711
716
  // Effects play for everyone who draws the room: a watcher has no body and still sees them.
712
717
  fire();
713
- if (!mine || !base || net.seat === null || (!net.connected && !net.rulesHosting)) return;
718
+ if (!mine || net.seat === null || (!net.connected && !net.rulesHosting)) return;
719
+ if (!hidden && net.resting()) { restPump(now); return; }
720
+ if (!base) return;
714
721
  if (hidden) {
715
722
  // A hidden tab's timers are too slow to keep input alive, and a body must not run on without its player:
716
723
  // one neutral entry at once (every field at its init, no press), then no more steps until the tab shows again.
@@ -736,6 +743,28 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
736
743
  }
737
744
  }
738
745
 
746
+ /**
747
+ * A resting room (nothing is simulated until somebody plays): this page's input clock stops with it, so what it
748
+ * sends next is stamped on the tick the room rested on, never seconds ahead of it. Nothing is stepped or sent until
749
+ * a control is held or pressed; then that input goes at once, as one entry on the next tick (again a second later
750
+ * if the room has not woken yet), and it wakes the room. The first snapshot after the room wakes sets the clock
751
+ * again (applySnapshot: no base), keeping what is predicted.
752
+ */
753
+ function restPump(now: number): void {
754
+ const kind = myKind();
755
+ base = null; restHeld = true;
756
+ if (!kind || now - restWokeAt < 1000) return;
757
+ const engaged = pressed.size > 0 || pulseQueue.length > 0 || kind.input.some(([name, fd]) => fd.t !== 'press' && fd.t !== 'pulse' && Number(coerce(fd, fd.t === 'held' ? heldNumber(sample[name], fd.init) : sample[name] ?? fd.init, dims)) !== Number(coerce(fd, fd.init, dims)));
758
+ if (!engaged) return;
759
+ restWokeAt = now;
760
+ const t = stepped + 1;
761
+ const values = kind.input.map(([name, fd]) => fd.t === 'press' ? (pressed.get(name) ? 1 : 0) : fd.t === 'pulse' ? 0 : fd.t === 'held' ? Number(coerce(fd, heldNumber(sample[name], fd.init), dims)) : Number(coerce(fd, sample[name] ?? fd.init, dims)));
762
+ pressed = new Map();
763
+ const claim = kind.owner ? [mine!.pos.x, mine!.pos.y, mine!.pos.z, mine!.vel.x, mine!.vel.y, mine!.vel.z, mine!.heading.x, mine!.heading.y, mine!.heading.z] : [];
764
+ entries = [{ t, row: [...values, ...claim, -471, ++inputSequence, 0] }]; lastSent = JSON.stringify([values, claim]);
765
+ lastSendAt = 0; flush(now, t);
766
+ }
767
+
739
768
  function onSnapshot(s: Snapshot<unknown>): void {
740
769
  const wasPumping = pumping;
741
770
  pumping = true;
@@ -780,7 +809,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
780
809
  returningUntil = 0;
781
810
  }
782
811
  if (placed || !base) {
783
- adopt(me); rebase(f.k, changedEpoch ? 'epoch' : 'placement');
812
+ const rested = restHeld && !placed; restHeld = false;
813
+ adopt(me); rebase(f.k, changedEpoch ? 'epoch' : rested ? 'rest' : 'placement');
784
814
  if (placed) emit('placed', entityOf(me, true));
785
815
  } else if (kindOf.get(me.kind)?.owner) {
786
816
  mine!.motion = motionOf(me.kind, me.motion);
@@ -886,7 +916,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
886
916
  }
887
917
  }
888
918
  if (!latencyCalibrated && authorityRtt() > calibratedRtt + period && !Object.values(sample).some(Boolean) && !pending.some(e=>Object.values(e.input).some(Boolean))) { rebase(f.k, 'lead'); latencyCalibrated = true; }
889
- if (row && row[3] !== -128 && base && clock() >= leadAfter) {
919
+ // The furthest lead there is: this clock is more than a second ahead of the room (its input is being dropped).
920
+ if (row && row[3] >= 126 && base && clock() >= leadAfter) rebase(f.k, 'lead');
921
+ else if (row && row[3] !== -128 && base && clock() >= leadAfter) {
890
922
  const now = clock(), rtt = authorityRtt();
891
923
  // A reported lead describes an input sent roughly one authority round trip ago. Remove that
892
924
  // clock's phase before measuring jitter; project the control median onto today's phase. Otherwise
@@ -9,7 +9,8 @@
9
9
  "entry": "src/view.ts",
10
10
  "example": true,
11
11
  "room": {
12
- "host": "server"
12
+ "host": "server",
13
+ "rest": "idle"
13
14
  },
14
15
  "landing": {
15
16
  "pitch": "Run for the coins. First one there takes it.",
@@ -24,6 +24,7 @@
24
24
  }
25
25
  },
26
26
  "room": {
27
- "host": "server"
27
+ "host": "server",
28
+ "rest": "idle"
28
29
  }
29
30
  }
@@ -16,6 +16,7 @@
16
16
  }
17
17
  },
18
18
  "room": {
19
- "host": "server"
19
+ "host": "server",
20
+ "rest": "idle"
20
21
  }
21
22
  }
@@ -21,6 +21,7 @@
21
21
  }
22
22
  },
23
23
  "room": {
24
- "host": "server"
24
+ "host": "server",
25
+ "rest": "idle"
25
26
  }
26
27
  }
@@ -21,6 +21,7 @@
21
21
  }
22
22
  },
23
23
  "room": {
24
- "host": "server"
24
+ "host": "server",
25
+ "rest": "idle"
25
26
  }
26
27
  }
package/worker/agents.mjs CHANGED
@@ -363,6 +363,7 @@ export class HouseAgents {
363
363
  this.agents = [];
364
364
  this.decisions = [];
365
365
  this.alarmAt = null;
366
+ this.resting = false;
366
367
  this.lastAlarmAt = -Infinity;
367
368
  this.backoffUntil = 0;
368
369
  /** pass id → until: a guide the owner kicked stays out until its hold ends. */
@@ -423,7 +424,7 @@ export class HouseAgents {
423
424
  };
424
425
  this.agents.push(a);
425
426
  a.h = this.room.attach(a.conn);
426
- a.h.onMessage(JSON.stringify({ t: 'hello', v: 1, rev: 7, device: 'desk', want: 'play', canHost: false, agent: { hands: 'host', role: 'guide' } }));
427
+ a.h.onMessage(JSON.stringify({ t: 'hello', v: 1, rev: 7, device: 'desk', want: 'play', canHost: false, rest: 1, agent: { hands: 'host', role: 'guide' } }));
427
428
  return !a.closed;
428
429
  }
429
430
 
@@ -449,7 +450,8 @@ export class HouseAgents {
449
450
  try { m = JSON.parse(text); } catch { return; }
450
451
  const now = this.now();
451
452
  switch (m.t) {
452
- case 'welcome': a.seat = m.seat; if (typeof m.name === 'string') a.name = m.name; return;
453
+ case 'welcome': a.seat = m.seat; if (typeof m.name === 'string') a.name = m.name; if (m.rest === true) this.rest(true); return;
454
+ case 'rest': this.rest(m.on === true); return;
453
455
  case 'error':
454
456
  if (m.code === 'kicked') this.held.set(a.pass, Number(m.until) || now + 10 * 60_000);
455
457
  else if (['agents-off', 'agents-unsupported', 'agent-pass', 'room-closed', 'room-full', 'too-many'].includes(m.code)) this.backoffUntil = now + 60_000;
@@ -524,8 +526,19 @@ export class HouseAgents {
524
526
  return Math.max(a.lastCallAt + BRAIN_CADENCE.gapMs, cap);
525
527
  }
526
528
 
529
+ /**
530
+ * The room rested, or woke. While it rests, no guide thinks and none keeps an alarm: a think would be a command,
531
+ * and a command would wake the room. The next decision waits until somebody plays.
532
+ */
533
+ rest(on) {
534
+ this.resting = on === true;
535
+ if (this.resting) { this.alarmAt = null; return; }
536
+ this.schedule();
537
+ }
538
+
527
539
  /** One alarm for the room: the earliest due guide, never sooner than 3 s after the last alarm. */
528
540
  schedule() {
541
+ if (this.resting) return null;
529
542
  const now = this.now();
530
543
  let next = Infinity;
531
544
  for (const a of this.live()) if (a.seat !== null && a.dueAt !== Infinity && !a.inFlight) next = Math.min(next, Math.max(a.dueAt, this.readyAt(a, now)));
@@ -539,6 +552,7 @@ export class HouseAgents {
539
552
 
540
553
  /** The Durable Object's alarm: every guide due by now (or within 1.5 s) decides, then the next alarm is set. */
541
554
  async onAlarm() {
555
+ if (this.resting) { this.alarmAt = null; return 0; }
542
556
  const now = this.now();
543
557
  this.lastAlarmAt = now;
544
558
  this.alarmAt = null;
@@ -568,7 +582,7 @@ export class HouseAgents {
568
582
  const goalNow = a.view?.goal && typeof a.view.goal === 'object' ? a.view.goal : null;
569
583
  if (!asks.length && a.carry && now < a.carry.until && (!goalNow || (goalNow.goal === a.carry.goal && goalNow.state === 'active'))) {
570
584
  a.inFlight = false;
571
- if (this.near(a) && !a.closed) this.due(a, now + BRAIN_CADENCE.everyMs, 'every 12 s');
585
+ if (!this.resting && this.near(a) && !a.closed) this.due(a, now + BRAIN_CADENCE.everyMs, 'every 12 s');
572
586
  return null;
573
587
  }
574
588
  if (a.carry && (now >= a.carry.until || (goalNow && (goalNow.goal !== a.carry.goal || goalNow.state !== 'active')))) a.carry = null;
@@ -584,6 +598,8 @@ export class HouseAgents {
584
598
  const newHere = [...a.seen].filter(([seat, at]) => now - at < 20_000 && players.includes(seat)).map(([seat]) => seat);
585
599
  const ctx = { view: a.view, me: { seat: a.seat }, goal: a.view?.goal ?? null, asks, party: a.party, speech: this.room.policy.speech, kids: this.room.policy.kids, quiet, players, avoid, said, newHere };
586
600
  const t = await think(vocab, { mode, brain: p, ctx, ms: BRAIN_CADENCE.timeoutMs, now: this.now });
601
+ // The room rested while the brain was thinking: drop the decision. Sending it would wake the room.
602
+ if (this.resting || a.closed) { a.inFlight = false; return null; }
587
603
  const { decision, provider, why, ms, cost } = t;
588
604
  if (t.engine) {
589
605
  this.calls += 1;
@@ -606,7 +622,7 @@ export class HouseAgents {
606
622
  ...(t.own && /\+floor$/.test(provider) ? { own: { goal: t.own.goal, args: t.own.args } } : {}), ...(t.p ? { p: t.p } : {}), ...(t.model ? { model: t.model } : {}),
607
623
  }].slice(-LOG_MAX);
608
624
  a.inFlight = false;
609
- if (this.near(a) && !a.closed) this.due(a, now + BRAIN_CADENCE.everyMs, 'every 12 s');
625
+ if (!this.resting && this.near(a) && !a.closed) this.due(a, now + BRAIN_CADENCE.everyMs, 'every 12 s');
610
626
  return decision;
611
627
  }
612
628