@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.
- package/CHANGELOG.md +56 -4
- package/docs/toolkit-place-patterns.md +16 -0
- 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/parts/README.md +28 -0
- package/parts/catalogue.json +236 -0
- package/parts/owner-action-checklist/LICENSE +202 -0
- package/parts/owner-action-checklist/NOTICE +3 -0
- package/parts/owner-action-checklist/README.md +68 -0
- package/parts/owner-action-checklist/part.json +95 -0
- package/parts/owner-action-checklist/preview/index.html +16 -0
- package/parts/owner-action-checklist/src/handler.mjs +44 -0
- package/parts/owner-action-checklist/src/index.mjs +96 -0
- package/parts/owner-action-checklist/src/style.css +3 -0
- package/parts/owner-action-checklist/tuning.json +3 -0
- package/parts/terrain-route-map/LICENSE +202 -0
- package/parts/terrain-route-map/NOTICE +3 -0
- package/parts/terrain-route-map/README.md +67 -0
- package/parts/terrain-route-map/THIRD-PARTY-LICENSE +21 -0
- package/parts/terrain-route-map/part.json +133 -0
- package/parts/terrain-route-map/preview/demo.js +4127 -0
- package/parts/terrain-route-map/preview/heightmap.png +0 -0
- package/parts/terrain-route-map/preview/index.html +1 -0
- package/parts/terrain-route-map/preview/main.mjs +4 -0
- package/parts/terrain-route-map/preview/route.json +1 -0
- package/parts/terrain-route-map/src/data.mjs +81 -0
- package/parts/terrain-route-map/src/index.mjs +131 -0
- package/parts/terrain-route-map/src/style.css +3 -0
- package/parts/terrain-route-map/tuning.json +5 -0
- 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/tools/tools.ts +1 -1
- package/worker/agents.mjs +20 -4
- package/worker/booking-calendar.mjs +15 -2
- 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/table.mjs +51 -18
- package/worker/version.mjs +1 -1
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/tools/tools.ts
CHANGED
|
@@ -16,7 +16,7 @@ export interface ToolContext {
|
|
|
16
16
|
};
|
|
17
17
|
bookings: {
|
|
18
18
|
availability(args: {app: string; collection: string; after?: string; quantity?: number; option?: string; resource?: string; addons?: Record<string,number>}): Promise<BookingAvailability>;
|
|
19
|
-
calendar(args: {app:string;startDate:string;endDate:string;timeZone?:string;time?:string;days?:number;durationMinutes?:number;capacity?:number;weekdays?:number[];resources?:string[];options?:unknown[];prefix?:string}): Array<{id:string;start:string;end:string;capacity:number;date:string;timeZone:string}>;
|
|
19
|
+
calendar(args: {app:string;startDate:string;endDate:string;timeZone?:string;time?:string;days?:number;durationMinutes?:number;capacity?:number;weekdays?:number[];resources?:string[];options?:unknown[];prefix?:string;exceptions?:Record<string,null|{time?:string;capacity?:number;durationMinutes?:number}>}): Array<{id:string;start:string;end:string;capacity:number;date:string;timeZone:string}>;
|
|
20
20
|
hold(args: {app: string; collection: string; slot?: string; time?: string; option?: string; resource?: string; preferredResource?: string; addons?: string[] | Record<string,number>; addOns?: string[] | Record<string,number>; quantity?: number; id: string; token?: string; expiresAt: number}): Promise<BookingHold>;
|
|
21
21
|
get(args: {id: string; token?: string}): Promise<BookingHold>;
|
|
22
22
|
confirm(args: {id: string; token?: string; version: number}): Promise<BookingHold>;
|
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
|
|
|
@@ -11,15 +11,28 @@ function instant(day,time,format) {
|
|
|
11
11
|
return matches[0];
|
|
12
12
|
}
|
|
13
13
|
/** Inclusive season dates; each returned slot may span several local calendar days. */
|
|
14
|
-
export function calendarSlots({startDate,endDate,timeZone,time='09:00',days,durationMinutes=60,capacity=1,weekdays,resources,options,prefix='slot'}) {
|
|
14
|
+
export function calendarSlots({startDate,endDate,timeZone,time='09:00',days,durationMinutes=60,capacity=1,weekdays,resources,options,prefix='slot',exceptions={}}) {
|
|
15
15
|
const start=date(startDate),end=date(endDate);
|
|
16
|
+
if(!exceptions||Array.isArray(exceptions)||typeof exceptions!=='object')throw new Error('Invalid calendar exceptions');
|
|
17
|
+
for(const [day,change] of Object.entries(exceptions)){
|
|
18
|
+
const at=date(day);
|
|
19
|
+
if(at<start||at>end||weekdays&&!weekdays.includes(new Date(at).getUTCDay()))throw new Error('Exception is outside the series');
|
|
20
|
+
if(change!==null&&(!change||typeof change!=='object'||Array.isArray(change)||Object.keys(change).some(k=>!['time','capacity','durationMinutes'].includes(k)||change[k]===undefined)))throw new Error('Invalid calendar exception');
|
|
21
|
+
}
|
|
16
22
|
if(end<start||end-start>3660*dayMs||!/^([01]\d|2[0-3]):[0-5]\d$/.test(time)||!Number.isSafeInteger(capacity)||capacity<1||days!==undefined&&(!Number.isSafeInteger(days)||days<1)||!Number.isSafeInteger(durationMinutes)||durationMinutes<1)throw new Error('Invalid calendar range, time, duration or capacity');
|
|
17
23
|
if(weekdays&&(!Array.isArray(weekdays)||weekdays.some(n=>!Number.isInteger(n)||n<0||n>6)))throw new Error('weekdays uses 0 (Sunday) through 6 (Saturday)');
|
|
18
24
|
if(typeof timeZone!=='string'||!timeZone)throw new Error('Declare the place timeZone');
|
|
19
25
|
const format=new Intl.DateTimeFormat('en-CA',{timeZone,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hourCycle:'h23'}),slots=[];
|
|
20
26
|
for(let at=start;at<=end;at+=dayMs) {
|
|
21
27
|
if(weekdays&&!weekdays.includes(new Date(at).getUTCDay()))continue;
|
|
22
|
-
const day=new Date(at).toISOString().slice(0,10)
|
|
28
|
+
const day=new Date(at).toISOString().slice(0,10);
|
|
29
|
+
if(Object.hasOwn(exceptions,day)){
|
|
30
|
+
const change=exceptions[day];if(change===null)continue;
|
|
31
|
+
const [slot]=calendarSlots({startDate:day,endDate:day,timeZone,time,days:Object.hasOwn(change,'durationMinutes')?undefined:days,durationMinutes,capacity,resources,options,prefix,...change});
|
|
32
|
+
// Identity follows the series occurrence, even when its wall time changes.
|
|
33
|
+
slots.push({...slot,id:`${prefix}-${day}-${time.replace(':','')}`});continue;
|
|
34
|
+
}
|
|
35
|
+
const from=instant(day,time,format),to=days?instant(new Date(at+days*dayMs).toISOString().slice(0,10),time,format):from+durationMinutes*60000;
|
|
23
36
|
slots.push({id:`${prefix}-${day}-${time.replace(':','')}`,date:day,timeZone,start:new Date(from).toISOString(),end:new Date(to).toISOString(),capacity,...(resources?{resources}:{}),...(options?{options}:{})});
|
|
24
37
|
}
|
|
25
38
|
return slots;
|
package/worker/build-id.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
|
|
2
|
-
export const STUDIO_BUILD = Object.freeze({"version":"0.
|
|
2
|
+
export const STUDIO_BUILD = Object.freeze({"version":"0.58.1","sha256":"b3944d94ae64b4e7755614a9801c54216f14fcb741e9598bd5c4eab117deae50"});
|
package/worker/limits.mjs
CHANGED
|
@@ -20,13 +20,15 @@ export const EV_RATES = Object.freeze({ host: 30, replica: 10, screen: 2 });
|
|
|
20
20
|
/** The keyed state channel: at most this many keys and bytes per room. */
|
|
21
21
|
export const STATE_CAPS = Object.freeze({ keys: 64, bytes: 65536 });
|
|
22
22
|
/** Runtime output only. Player allowances above remain unchanged. */
|
|
23
|
-
export const RULES_FRAMES = Object.freeze(['snap', 'ckpt', 'state', 'ev', 'round', 'roster', 'decide', 'caps', 'rules-end']);
|
|
23
|
+
export const RULES_FRAMES = Object.freeze(['snap', 'ckpt', 'state', 'ev', 'round', 'roster', 'decide', 'caps', 'rules-end', 'rest']);
|
|
24
24
|
export const rulesRates = (tickHz = 20, seats = 8) => ({
|
|
25
25
|
// A snapshot a tick, as the server sends, and a quarter over for the ticks a late timer runs together.
|
|
26
26
|
snap: Math.max(30, Math.ceil(tickHz * 1.25)), ckpt: 4, state: tickHz + 64,
|
|
27
27
|
ev: Math.ceil((tickHz + seats * EV_RATES.replica + 32) / 0.8), round: 8,
|
|
28
28
|
// A decision a tick is what one declared ask can make (the next waits for the answer), as on a server host.
|
|
29
29
|
roster: seats * 2 + 8, decide: Math.ceil((tickHz + seats * 5 + 8) / 0.8), caps: 4, 'rules-end': 4,
|
|
30
|
+
// A browser host rests its room and wakes it: at most a few times a second.
|
|
31
|
+
rest: 4,
|
|
30
32
|
});
|
|
31
33
|
export const rulesCaps = seats => Object.fromEntries(RULES_FRAMES.map(t => [t, capOf(t, seats)]));
|
|
32
34
|
/** Two seconds of headroom absorb timer stalls and transport batching. */
|
package/worker/room-sessions.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
// Only protocol identity, admission, controls and round deadlines survive here.
|
|
5
5
|
// World snapshots/checkpoints belong to the existing host/room save, not sessions.
|
|
6
|
-
const fields = new Set(`rawVocab vocab snapAt rates agentsAloneMs voteMs voteCooldownMs autoVoteMs policy level levelBy caps vote lastVoteOpen lastAutoVote lastHumanAt agentsOut officeDirty endedMatch code rules tickHz seatCap maxPlayers maxScreens perIp stallMs silentMs idleMs holdMs forgetMs persistMs gameVer currentVer occSeq seats hostId hostSince lastJoinAt lastRound lastRoster emptySince preferHost seatsDirty persistDirty lastPersistAt bans mutes announcement closedUntil closedWhy openedAt askedMax regate decideTimes defaultChat`.split(' '));
|
|
6
|
+
const fields = new Set(`rawVocab vocab snapAt rates agentsAloneMs voteMs voteCooldownMs autoVoteMs policy level levelBy caps vote lastVoteOpen lastAutoVote lastHumanAt agentsOut officeDirty endedMatch code rules tickHz seatCap maxPlayers maxScreens perIp stallMs silentMs idleMs holdMs forgetMs persistMs gameVer currentVer occSeq seats hostId hostSince lastJoinAt lastRound lastRoster emptySince preferHost seatsDirty persistDirty lastPersistAt bans mutes announcement closedUntil closedWhy openedAt askedMax regate decideTimes defaultChat resting`.split(' '));
|
|
7
7
|
function urlDelta(base, value) {
|
|
8
8
|
const a=new URL(base),b=new URL(value),changes=[];
|
|
9
9
|
for(const key of new Set([...a.searchParams.keys(),...b.searchParams.keys()])) {
|
package/worker/room.mjs
CHANGED
|
@@ -154,6 +154,10 @@ export class NetRoom {
|
|
|
154
154
|
* room forgets everything (`onForget`: it ends the host runtime).
|
|
155
155
|
*/
|
|
156
156
|
this.server = null;
|
|
157
|
+
/** The room's host has rested it (rules/host.ts): nobody is playing, its clock is stopped and it sends nothing. */
|
|
158
|
+
this.resting = false;
|
|
159
|
+
/** The Table hears a rest too (its house guides share the object's alarm). Null where nothing is listening. */
|
|
160
|
+
this.onRest = null;
|
|
157
161
|
this.hosted = new Map();
|
|
158
162
|
this.hostFailed = null;
|
|
159
163
|
this.onForget = null;
|
|
@@ -162,7 +166,7 @@ export class NetRoom {
|
|
|
162
166
|
/* ------------------------------------------------------------ the server as host (revision 10, section 29) */
|
|
163
167
|
|
|
164
168
|
/** The host runtime of a server-hosted room (`{ frame(m), facts() }`), or null. Seats already here are said as joins. */
|
|
165
|
-
setServerHost(h) {
|
|
169
|
+
setServerHost(h, { restored = false } = {}) {
|
|
166
170
|
this.server = h;
|
|
167
171
|
if (h?.rates) this.rates = h.rates;
|
|
168
172
|
this.hostFailed = null;
|
|
@@ -175,7 +179,7 @@ export class NetRoom {
|
|
|
175
179
|
for (const [seat, s] of this.seats) this.hosted.set(seat, s.occ ?? null);
|
|
176
180
|
this.toServer({ t: 'policy', policy: this.policyOut() });
|
|
177
181
|
if (this.rawVocab) this.toServer({ t: 'vocabulary', vocab: this.rawVocab });
|
|
178
|
-
for (const c of this.live()) if (c.seat !== null && !c.watch) this.joinServer(c);
|
|
182
|
+
for (const c of this.live()) if (c.seat !== null && !c.watch) this.joinServer(c, restored);
|
|
179
183
|
}
|
|
180
184
|
|
|
181
185
|
/** The rules could not start: the room refuses joins and says why. It never falls back to a browser by itself. */
|
|
@@ -191,9 +195,26 @@ export class NetRoom {
|
|
|
191
195
|
try { this.server.frame(m); } catch (error) { this.stats.failed = (this.stats.failed ?? 0) + 1; this.log({ ev: 'failed', room: this.code, op: 'host-frame', frame: m.t, error: errorLine(error) }); }
|
|
192
196
|
}
|
|
193
197
|
|
|
194
|
-
|
|
198
|
+
/** `restored`: the same socket, back after the room hibernated (a resting room rests on), not somebody arriving. */
|
|
199
|
+
joinServer(c, restored = false) {
|
|
195
200
|
this.hosted.set(c.seat, this.seats.get(c.seat)?.occ ?? null);
|
|
196
|
-
this.toServer({ t: 'join', peer: this.peer(c) });
|
|
201
|
+
this.toServer({ t: 'join', peer: this.peer(c), ...(c.rest ? { rest: true } : {}), ...(c.hidden ? { hidden: true } : {}), ...(restored ? { restored: true } : {}) });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The host (the server's, or a browser host `c`) rested the room or woke it. Everybody hears it, and a page that
|
|
206
|
+
* arrives meanwhile is told in its welcome; browsers then ping the edge and send nothing until somebody plays. A
|
|
207
|
+
* browser host's snapshots stop while it rests, so its silence is not a stall.
|
|
208
|
+
*/
|
|
209
|
+
rest(c, on) {
|
|
210
|
+
if (on === this.resting) return;
|
|
211
|
+
this.resting = on;
|
|
212
|
+
if (c && !on) c.lastSnapAt = this.now();
|
|
213
|
+
const line = JSON.stringify({ t: 'rest', on });
|
|
214
|
+
for (const o of this.live()) if (o !== c) this.sendText(o.conn, line);
|
|
215
|
+
// A browser host rests in the page, so the Table's own `onRest` (the server host) never runs. The guides still
|
|
216
|
+
// have to stop: their think alarm would wake the object, and the command it sent would wake the room.
|
|
217
|
+
try { this.onRest?.(on); } catch { /* a listener that throws does not keep the room awake */ }
|
|
197
218
|
}
|
|
198
219
|
|
|
199
220
|
/** A seat the host runtime knows that is no longer held (its hold ran out, the owner removed its player): its body's seat is given up. */
|
|
@@ -220,6 +241,7 @@ export class NetRoom {
|
|
|
220
241
|
case 'roster': return this.hostRoster(null, m);
|
|
221
242
|
case 'caps': return this.hostCaps(null, m);
|
|
222
243
|
case 'decide': return this.onDecide(null, m, true);
|
|
244
|
+
case 'rest': return this.rest(null, m.on === true);
|
|
223
245
|
case 'ev': {
|
|
224
246
|
const kind = String(m.k ?? '').slice(0, 64);
|
|
225
247
|
if (SPEECH.test(kind) && (this.policy.speech === 'off' || (this.policy.speech === 'lines' && /^chat/i.test(kind)))) { this.stats.speechDrops += 1; return; }
|
|
@@ -535,7 +557,7 @@ export class NetRoom {
|
|
|
535
557
|
peer(c) {
|
|
536
558
|
// Revision 9: which stay in the seat this is (`occ`), the peer's game revision and what its build can do.
|
|
537
559
|
const occ = c.seat !== null ? this.seats.get(c.seat)?.occ : undefined;
|
|
538
|
-
return { id: c.id, seat: c.seat, name: c.name, colour: c.colour, device: c.device, want: c.want, role: this.roleOf(c), ...(c.watch ? { watch: true } : {}), ...(c.agent ? { agent: { ...c.agent } } : {}), ...(this.muteOf(c) ? { muted: true } : {}), ...(c.badge && !c.agent ? { badge: c.badge } : {}), ...(Number.isInteger(occ) ? { occ } : {}), ...(c.ver ? { ver: c.ver } : {}), ...(c.feat.length ? { feat: [...c.feat] } : {}) };
|
|
560
|
+
return { id: c.id, seat: c.seat, name: c.name, colour: c.colour, device: c.device, want: c.want, role: this.roleOf(c), ...(c.watch ? { watch: true } : {}), ...(c.agent ? { agent: { ...c.agent } } : {}), ...(this.muteOf(c) ? { muted: true } : {}), ...(c.badge && !c.agent ? { badge: c.badge } : {}), ...(Number.isInteger(occ) ? { occ } : {}), ...(c.ver ? { ver: c.ver } : {}), ...(c.feat.length ? { feat: [...c.feat] } : {}), ...(c.rest ? { rest: true } : {}) };
|
|
539
561
|
}
|
|
540
562
|
/** A hands-`host` agent (section 17): no game client of its own, so no snapshots, checkpoints or state. */
|
|
541
563
|
lite(c) { return Boolean(c.agent && c.agent.hands === 'host'); }
|
|
@@ -809,11 +831,22 @@ export class NetRoom {
|
|
|
809
831
|
const wasHidden = c.hidden;
|
|
810
832
|
c.hidden = m.hid === true;
|
|
811
833
|
this.send(c, { t: 'pong', c: m.c, st: now });
|
|
834
|
+
// The server's host rests a room whose pages are all in the background, and wakes it when one comes back.
|
|
835
|
+
if (c.seat !== null && c.hidden !== Boolean(wasHidden)) {
|
|
836
|
+
if (this.server) this.toServer({ t: 'hidden', seat: c.seat, on: c.hidden });
|
|
837
|
+
else if (this.rules && this.host()?.rules && this.host() !== c) this.send(this.host(), { t: 'hidden', seat: c.seat, on: c.hidden });
|
|
838
|
+
}
|
|
812
839
|
if(this.server && (!c.probe || now-c.probe.at>10000)){c.probe={n:crypto.randomUUID(),at:now};this.send(c,{t:'probe',n:c.probe.n});}
|
|
813
|
-
// A host whose tab went to the background will stop
|
|
840
|
+
// A host whose tab went to the background will stop running: hand the round on now. A rest goes with
|
|
841
|
+
// the checkpoint, so the next host stays asleep until somebody plays.
|
|
814
842
|
if (c.id === this.hostId && c.hidden && !wasHidden) this.yieldHost(c, 'host-hidden');
|
|
815
843
|
return;
|
|
816
844
|
}
|
|
845
|
+
case 'rest': {
|
|
846
|
+
// A browser host rested its room, or woke it (rules/host.ts, run in the page).
|
|
847
|
+
if (!this.server && this.rules && c.rules && c.id === this.hostId) this.rest(c, m.on === true);
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
817
850
|
case 'yield': {
|
|
818
851
|
if (c.id === this.hostId && this.rules && c.rules && m.slow === true) {
|
|
819
852
|
c.rulesSpeed = Math.max(0, Math.min(1, Number(m.speed) || 0));
|
|
@@ -918,6 +951,9 @@ export class NetRoom {
|
|
|
918
951
|
if (this.perIp && c.ip && live.filter((o) => o.ip === c.ip).length >= this.perIp) return refuse('too-many', `at most ${this.perIp} sockets per address in one room`);
|
|
919
952
|
if (live.length >= this.maxPlayers + this.maxScreens) return refuse('room-full', 'this room is full; try another');
|
|
920
953
|
c.rules = m.rules === true;
|
|
954
|
+
// A browser that understands a resting room (rules/host.ts): only a room of them rests.
|
|
955
|
+
// A rules browser, or an agent, that says it understands resting. An older socket keeps its own pings, and the bill.
|
|
956
|
+
c.rest = m.rest === 1 && (c.rules || Boolean(c.agent));
|
|
921
957
|
c.device = m.device === 'phone' || m.device === 'tv' ? m.device : 'desk';
|
|
922
958
|
// A watcher is a screen that never takes a seat, whatever else its hello says. An agent always plays.
|
|
923
959
|
c.watch = watching;
|
|
@@ -971,6 +1007,11 @@ export class NetRoom {
|
|
|
971
1007
|
let role = c.seat === null ? 'screen' : 'replica';
|
|
972
1008
|
let why = 'joined';
|
|
973
1009
|
let deposed = null;
|
|
1010
|
+
// An edge ping is answered without a message, so lastSeen lags it between ticks. A fresh ping is the host still here.
|
|
1011
|
+
for (const o of live) {
|
|
1012
|
+
const ping = o.conn.lastPing?.() ?? 0;
|
|
1013
|
+
if (ping > 0) o.lastSeen = Math.max(o.lastSeen, ping);
|
|
1014
|
+
}
|
|
974
1015
|
const h = this.host();
|
|
975
1016
|
const preferred = this.preferHost && now < this.preferHost.until ? this.preferHost.seat : undefined;
|
|
976
1017
|
// A watcher hosts only a room nobody else can host (a player who can is elected first), and never deposes one;
|
|
@@ -995,6 +1036,8 @@ export class NetRoom {
|
|
|
995
1036
|
}
|
|
996
1037
|
}
|
|
997
1038
|
if (role === 'host') {
|
|
1039
|
+
// A rest with no rest checkpoint would leave this host running and everyone else holding their clock.
|
|
1040
|
+
if (this.resting && !this.restSaved(this.lastCkpt)) this.rest(null, false);
|
|
998
1041
|
// Welcome contains the current holders; old occupancy notices must precede it.
|
|
999
1042
|
this.hosted = new Map([...this.seats].map(([seat, s]) => [seat, s.occ ?? null]));
|
|
1000
1043
|
this.hostId = c.id;
|
|
@@ -1009,7 +1052,7 @@ export class NetRoom {
|
|
|
1009
1052
|
const behind = !lite && this.currentVer !== undefined && c.ver !== this.currentVer;
|
|
1010
1053
|
if (behind) c.staleTold = true;
|
|
1011
1054
|
this.send(c, {
|
|
1012
|
-
t: 'welcome', v: NET_VERSION, rev: NET_REVISION, ...(c.conn.idlePing ? {idlePing:true} : {}), id: c.id, room: this.code, seat: c.seat, token: c.token, name: c.name, colour: c.colour,
|
|
1055
|
+
t: 'welcome', v: NET_VERSION, rev: NET_REVISION, ...(c.conn.idlePing ? {idlePing:true} : {}), ...(this.resting ? { rest: true } : {}), id: c.id, room: this.code, seat: c.seat, token: c.token, name: c.name, colour: c.colour,
|
|
1013
1056
|
...(this.gameVer ? { ver: this.gameVer } : {}), ...(behind ? { stale: { ver: this.currentVer } } : {}), stall: this.stallMs,
|
|
1014
1057
|
role, why, host: this.hostRef(), peers: this.peers(), ...(this.rules ? { held: this.heldPeers() } : {}), st: now, max: this.maxPlayers,
|
|
1015
1058
|
round: this.lastRound, roster: this.lastRoster, snap: lite ? null : this.playerSnapshot(c, this.lastSnap, true), state: lite ? {} : this.stateObject(),
|
|
@@ -1322,6 +1365,15 @@ export class NetRoom {
|
|
|
1322
1365
|
Math.min(candidate.rulesSpeed ?? 1, candidate.readySpeed ?? 0) > (current?.rulesSpeed ?? 0) * 1.5 + 0.1;
|
|
1323
1366
|
}
|
|
1324
1367
|
|
|
1368
|
+
/** The checkpoint a browser host sent while resting: its save says `rest.on`. */
|
|
1369
|
+
restSaved(ckpt) {
|
|
1370
|
+
const d = ckpt?.d;
|
|
1371
|
+
if (!d || typeof d !== 'object') return false;
|
|
1372
|
+
const data = d.data && typeof d.data === 'object' ? d.data : d;
|
|
1373
|
+
const rest = data.rest;
|
|
1374
|
+
return Boolean(rest && typeof rest === 'object' && rest.on === true);
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1325
1377
|
/**
|
|
1326
1378
|
* Choose a host. Visible and responsive first; then tenure in 30 s steps (a newcomer cannot jump the queue by
|
|
1327
1379
|
* calling itself a desktop); then desk > tv > phone as a tie-break; then whoever came first. The chosen browser
|
|
@@ -1357,6 +1409,9 @@ export class NetRoom {
|
|
|
1357
1409
|
// What the room's game reads (the dial, agent bodies) is what its new host's build says.
|
|
1358
1410
|
this.caps = new Set(next.caps);
|
|
1359
1411
|
next.lastSnapAt = 0;
|
|
1412
|
+
// A rest stays with the room when the checkpoint the new host restores is that rest: it stays asleep.
|
|
1413
|
+
// With no such save the new host would run while everyone else still holds their clock, so the rest ends.
|
|
1414
|
+
if (this.resting && !this.restSaved(this.lastCkpt)) this.rest(null, false);
|
|
1360
1415
|
this.stats.promotions += 1;
|
|
1361
1416
|
this.stats.elections.push({ at: now, id: next.id, seat: next.seat, why });
|
|
1362
1417
|
if (this.stats.elections.length > 32) this.stats.elections.shift();
|
|
@@ -1390,9 +1445,9 @@ export class NetRoom {
|
|
|
1390
1445
|
// A loopback (the Table's own house agent) cannot be half-open: it is never closed for silence.
|
|
1391
1446
|
if (now - c.lastSeen > this.idleMs && !c.conn.loopback) this.kick(c, 'silent', 4000);
|
|
1392
1447
|
}
|
|
1393
|
-
// A host that stopped sending snapshots while others are present.
|
|
1448
|
+
// A host that stopped sending snapshots while others are present. A resting host sends none on purpose.
|
|
1394
1449
|
const h = this.host();
|
|
1395
|
-
if (!idle && h && this.live().length > 1) {
|
|
1450
|
+
if (!idle && !this.resting && h && this.live().length > 1) {
|
|
1396
1451
|
const since = Math.max(h.lastSnapAt, this.hostSince, this.lastJoinAt);
|
|
1397
1452
|
if (now - since > this.stallMs) {
|
|
1398
1453
|
const next = this.elect(h.id, 'host-stalled');
|