@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/CHANGELOG.md +47 -1
- package/apps/APPS.md +1 -1
- package/bin/homie-studio.mjs +14 -4
- package/guides/app/SKILL.md +1 -1
- package/guides/game/RULES.md +15 -1
- package/lib/agent-seat.mjs +10 -3
- package/lib/template-history.json +84 -0
- package/netplay/NETPLAY.md +10 -0
- package/netplay/netplay.ts +30 -9
- package/package.json +1 -1
- package/rules/browser.ts +2 -0
- package/rules/core.ts +3 -0
- package/rules/host.ts +170 -16
- package/rules/rules.ts +16 -3
- package/rules/view.ts +39 -7
- package/starters/coin-dash/game.json +2 -1
- package/starters/ember-vale/game.json +2 -1
- package/starters/gem-rush/game.json +2 -1
- package/starters/gem-rush-3d/game.json +2 -1
- package/starters/hero-rush-3d/game.json +2 -1
- package/worker/agents.mjs +20 -4
- package/worker/app-records.mjs +58 -6
- package/worker/build-id.mjs +1 -1
- package/worker/limits.mjs +3 -1
- package/worker/room-sessions.mjs +1 -1
- package/worker/room.mjs +64 -9
- package/worker/route-auth.mjs +1 -1
- package/worker/table.mjs +51 -18
- package/worker/version.mjs +1 -1
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) ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1052
|
-
if (
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
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
|
|