@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/worker/app-records.mjs
CHANGED
|
@@ -19,7 +19,7 @@ CREATE TABLE IF NOT EXISTS app_roles (
|
|
|
19
19
|
PRIMARY KEY (app, role, player)
|
|
20
20
|
);
|
|
21
21
|
CREATE TABLE IF NOT EXISTS app_role_links (
|
|
22
|
-
app TEXT NOT NULL, role TEXT NOT NULL, token TEXT NOT NULL,
|
|
22
|
+
app TEXT NOT NULL, role TEXT NOT NULL, token TEXT NOT NULL, expires_at INTEGER,
|
|
23
23
|
PRIMARY KEY (app, role)
|
|
24
24
|
);
|
|
25
25
|
`;
|
|
@@ -35,8 +35,37 @@ async function bodyOf(request) {
|
|
|
35
35
|
for (const chunk of chunks) { bytes.set(chunk, at); at += chunk.length; }
|
|
36
36
|
return JSON.parse(new TextDecoder().decode(bytes) || '{}');
|
|
37
37
|
}
|
|
38
|
+
const ROLE_LINK_YEAR = 366 * 24 * 60 * 60 * 1000;
|
|
39
|
+
const roleLinkReady = new WeakSet();
|
|
40
|
+
/** Old studios have no expires_at. One ALTER per database; a duplicate column is the column already being there. */
|
|
41
|
+
async function ensureRoleLinkExpiry(db) {
|
|
42
|
+
if (!db || roleLinkReady.has(db)) return true;
|
|
43
|
+
try {
|
|
44
|
+
await db.prepare('ALTER TABLE app_role_links ADD COLUMN expires_at INTEGER').run();
|
|
45
|
+
roleLinkReady.add(db);
|
|
46
|
+
return true;
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (/duplicate column/i.test(String(error?.message ?? error))) { roleLinkReady.add(db); return true; }
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async function roleLink(env, app, role) {
|
|
53
|
+
if (await ensureRoleLinkExpiry(env.DB)) {
|
|
54
|
+
try { return await env.DB.prepare('SELECT token, expires_at FROM app_role_links WHERE app = ?1 AND role = ?2').bind(app, role).first(); } catch { /* column still absent */ }
|
|
55
|
+
}
|
|
56
|
+
const row = await env.DB.prepare('SELECT token FROM app_role_links WHERE app = ?1 AND role = ?2').bind(app, role).first();
|
|
57
|
+
return row ? { token: row.token, expires_at: null } : null;
|
|
58
|
+
}
|
|
59
|
+
const linkLive = (row) => !!row && (row.expires_at == null || Number(row.expires_at) > Date.now());
|
|
60
|
+
const linkUrl = (url, meta, role, token) => `${url.origin.replace('127.0.0.1', 'localhost')}/${meta.id}/open?role=${role}&access=${token}`;
|
|
61
|
+
function expiryOf(value) {
|
|
62
|
+
if (value == null) return null;
|
|
63
|
+
const n = typeof value === 'number' ? value : Number(value);
|
|
64
|
+
if (!Number.isSafeInteger(n) || n <= Date.now() || n > Date.now() + ROLE_LINK_YEAR) return undefined;
|
|
65
|
+
return n;
|
|
66
|
+
}
|
|
38
67
|
/** Private links conceal staff addresses; a signed-in grant is still required on every request. */
|
|
39
|
-
export async function appAccess(request, env, meta, url) {
|
|
68
|
+
export async function appAccess(request, env, meta, url, options = {}) {
|
|
40
69
|
const role = appRole(meta, url);
|
|
41
70
|
const declared = Object.hasOwn(meta.roles ?? {}, role) ? meta.roles[role] : null;
|
|
42
71
|
if (!declared) return { ok: false, status: 404, error: 'unknown role' };
|
|
@@ -54,8 +83,10 @@ export async function appAccess(request, env, meta, url) {
|
|
|
54
83
|
}
|
|
55
84
|
const handoff=cookieValues(request,`studio_app_${meta.id}_${role}`)[0];
|
|
56
85
|
if(handoff&&await appAssetTicket(env,meta.id,handoff,{meta,purpose:'handoff',expectedRole:role,launch:launchOf(meta,await settingsOf(env),env)}))return {ok:true,role,can:declared.can};
|
|
57
|
-
|
|
58
|
-
if (
|
|
86
|
+
// Opening the app: the signed-in studio owner needs no link. hasRole passes owner:false so a custom route still requires that role's own link.
|
|
87
|
+
if (options.owner !== false && await isOwner(request, env)) return { ok: true, role, can: declared.can };
|
|
88
|
+
const link = await roleLink(env, meta.id, role);
|
|
89
|
+
if (!linkLive(link) || link.token !== url.searchParams.get('access')) return { ok: false, status: 404, error: 'private role link required' };
|
|
59
90
|
if (await isOwner(request, env)) return { ok: true, role, can: declared.can };
|
|
60
91
|
const person = await players.of(request, env);
|
|
61
92
|
if (!person || person.guest) return { ok: false, status: 401, error: 'sign in to this studio' };
|
|
@@ -72,6 +103,17 @@ export async function appRecordsRoute(request, env, meta, url, sub) {
|
|
|
72
103
|
if (request.method !== 'POST') return fail(405, 'POST required');
|
|
73
104
|
if (!key && !sameOrigin(request, url)) return fail(403, 'same origin required');
|
|
74
105
|
const b = await bodyOf(request);
|
|
106
|
+
await ensureRoleLinkExpiry(env.DB);
|
|
107
|
+
if (b.list === true) {
|
|
108
|
+
const roles = [];
|
|
109
|
+
for (const [role, declared] of Object.entries(meta.roles ?? {})) {
|
|
110
|
+
if (!declared?.signIn) continue;
|
|
111
|
+
const row = await roleLink(env, meta.id, role);
|
|
112
|
+
const live = linkLive(row);
|
|
113
|
+
roles.push({ role, expiresAt: row?.expires_at == null ? null : Number(row.expires_at), link: live ? linkUrl(url, meta, role, row.token) : null });
|
|
114
|
+
}
|
|
115
|
+
return json({ ok: true, roles });
|
|
116
|
+
}
|
|
75
117
|
if (!Object.hasOwn(meta.roles, b.role) || !meta.roles[b.role].signIn) return fail(400, 'name a sign-in role');
|
|
76
118
|
if (b.player !== undefined) {
|
|
77
119
|
if (!ID.test(b.player)) return fail(400, 'invalid account');
|
|
@@ -79,11 +121,21 @@ export async function appRecordsRoute(request, env, meta, url, sub) {
|
|
|
79
121
|
if (!p || p.guest) return fail(400, 'role grants require an existing signed-in account');
|
|
80
122
|
await env.DB.prepare(b.revoke === true ? 'DELETE FROM app_roles WHERE app = ?1 AND role = ?2 AND player = ?3' : 'INSERT OR IGNORE INTO app_roles (app, role, player) VALUES (?1, ?2, ?3)').bind(b.studio === true ? '*' : meta.id, b.role, b.player).run();
|
|
81
123
|
}
|
|
124
|
+
if (b.revokeLink === true) {
|
|
125
|
+
await env.DB.prepare('DELETE FROM app_role_links WHERE app = ?1 AND role = ?2').bind(meta.id, b.role).run();
|
|
126
|
+
return json({ ok: true, role: b.role, revoked: true });
|
|
127
|
+
}
|
|
128
|
+
let expires;
|
|
129
|
+
if (Object.hasOwn(b, 'expires')) {
|
|
130
|
+
expires = expiryOf(b.expires);
|
|
131
|
+
if (expires === undefined) return fail(400, 'expiry must be a future time within 366 days');
|
|
132
|
+
}
|
|
82
133
|
const token = crypto.randomUUID().replaceAll('-', '');
|
|
83
134
|
if (b.rotate === true) await env.DB.prepare('DELETE FROM app_role_links WHERE app = ?1 AND role = ?2').bind(meta.id, b.role).run();
|
|
84
135
|
await env.DB.prepare('INSERT OR IGNORE INTO app_role_links (app, role, token) VALUES (?1, ?2, ?3)').bind(meta.id, b.role, token).run();
|
|
85
|
-
|
|
86
|
-
|
|
136
|
+
if (expires !== undefined) await env.DB.prepare('UPDATE app_role_links SET expires_at = ?3 WHERE app = ?1 AND role = ?2').bind(meta.id, b.role, expires).run();
|
|
137
|
+
const row = await roleLink(env, meta.id, b.role);
|
|
138
|
+
return json({ ok: true, link: linkUrl(url, meta, b.role, row.token), role: b.role, expiresAt: row.expires_at == null ? null : Number(row.expires_at) });
|
|
87
139
|
}
|
|
88
140
|
const match = /^api\/app\/records\/([a-z][a-z0-9-]{0,39})(?:\/([A-Za-z0-9_-]{1,64}))?$/.exec(sub);
|
|
89
141
|
if(!match && /^api\/app\/records\/[^/]+\//.test(sub))return fail(400,'record id: 1–64 letters, digits, underscore or hyphen');
|
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.58.
|
|
2
|
+
export const STUDIO_BUILD = Object.freeze({"version":"0.58.2","sha256":"5186ba06317a7de1677209dbbba72ee69d4e1cf79a08b46c74e9c20bd92e74a7"});
|
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');
|
package/worker/route-auth.mjs
CHANGED
|
@@ -13,7 +13,7 @@ export async function hasRole(request, env, app, role) {
|
|
|
13
13
|
const launch = launchOf(meta, await settingsOf(env, {fresh:true}), env);
|
|
14
14
|
if (launch !== 'public' && !(await accessOf(request, env, app, launch)).ok) return false;
|
|
15
15
|
url.searchParams.set('role', role);
|
|
16
|
-
return (await (await import('./app-records.mjs')).appAccess(request, env, meta, url)).ok === true;
|
|
16
|
+
return (await (await import('./app-records.mjs')).appAccess(request, env, meta, url, { owner: false })).ok === true;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/** Return a refusal response, or null when the browser supplied same-origin evidence. */
|
package/worker/table.mjs
CHANGED
|
@@ -71,6 +71,8 @@ export class Table {
|
|
|
71
71
|
this.gateHeld = new Map();
|
|
72
72
|
this.gatePromised = new Map();
|
|
73
73
|
this.roomAlarmAt = 0;
|
|
74
|
+
/** The alarm a house guide asked for. A rest replaces it with the room's own deadline, or the guide would wake the object every few seconds. */
|
|
75
|
+
this.guideAlarmAt = 0;
|
|
74
76
|
this.durable = null;
|
|
75
77
|
this.roomSave = null;
|
|
76
78
|
this.endedRoom = null;
|
|
@@ -175,6 +177,8 @@ export class Table {
|
|
|
175
177
|
this.room.onUnsaid = (ids) => this.keptQueue(() => forgetLines(this.env, this.game, this.code, ids));
|
|
176
178
|
// A game's own decisions (section 20): the host's typed questions, for a game whose game.json opts in.
|
|
177
179
|
this.room.decider = (state, questions, opts) => this.decide(state, questions, opts);
|
|
180
|
+
// Server and browser hosts both rest through the relay. Guides stop here, including when the host is a page.
|
|
181
|
+
this.room.onRest = (on) => { this.house?.rest(on); };
|
|
178
182
|
// A server-hosted room that forgets everything (nobody came back, or the owner closed it) ends its host runtime.
|
|
179
183
|
this.room.onForget = () => { if (this.hostRt) this.finishRoom('forgotten'); };
|
|
180
184
|
if (this.isPersistent()) this.room.forgetMs = Infinity;
|
|
@@ -216,7 +220,9 @@ export class Table {
|
|
|
216
220
|
// A restore's provisional pause is not a recorded departure. A stored alarm,
|
|
217
221
|
// even one now due, has not yet confirmed expiry. The alarm handler owns that decision.
|
|
218
222
|
const restorePause = saved?.pauseFromRestore === true && Date.now() >= saved.pausedAt + ROOM_PAUSE_MS && this.recoveredAlarmAt !== null ? null : saved?.pausedAt;
|
|
219
|
-
|
|
223
|
+
// A resting room was saved with its people still here, and hibernated on purpose: its age is no absence.
|
|
224
|
+
const rested = saved?.resting === true && saved.pausedAt === null;
|
|
225
|
+
const restoredEnd = saved ? roomEndAt({ people: 0, pausedAt: restorePause, restoredAt: Date.now(), savedAt: saved.at, alarmAt: rested ? Date.now() : this.recoveredAlarmAt }) : Infinity;
|
|
220
226
|
const expired = Date.now() >= restoredEnd;
|
|
221
227
|
if (saved && persistent && saved.stateHash !== build.stateHash) throw new Error('This lasting world needs its previous state schema. Restore the compatible build; the saved world has been retained.');
|
|
222
228
|
if (saved && !persistent && (saved.stateHash !== build.stateHash || boot.count >= 2 || expired)) {
|
|
@@ -234,14 +240,21 @@ export class Table {
|
|
|
234
240
|
// The seat map and the world came from one transaction. No unrelated net write can take a body from its token.
|
|
235
241
|
room.seats.clear(); room.state.clear(); room.stateBytes = 0;
|
|
236
242
|
room.restore({ ...saved.net, savedAt: Date.now(), durable: true });
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
243
|
+
if (rested) {
|
|
244
|
+
// Rebuilt after hibernating on purpose: this save is exactly the world every browser holds. It comes back under
|
|
245
|
+
// the same epoch, so the input that wakes it applies, and nothing is written (the room wakes about once a minute).
|
|
246
|
+
this.pausedAt = null; this.pauseFromRestore = false;
|
|
247
|
+
} else {
|
|
248
|
+
this.pausedAt = persistent ? Date.now() : restoredEnd - ROOM_PAUSE_MS;
|
|
249
|
+
this.pauseFromRestore = saved.pauseFromRestore = saved.pausedAt === null || saved.pauseFromRestore === true;
|
|
250
|
+
saved.pausedAt = this.pausedAt;
|
|
251
|
+
// A failed pause write must never be mistaken for an invalid host save.
|
|
252
|
+
try { this.durable.write(saved); } catch (error) { if (persistent) throw error; pauseWriteFailed = true; this.storageProblem(error); }
|
|
253
|
+
}
|
|
242
254
|
}
|
|
243
|
-
|
|
244
|
-
|
|
255
|
+
// A rested save is replayed under its own epoch; any other restore (a crash, a deploy) takes a new one.
|
|
256
|
+
const epoch = saved ? (rested ? saved.host.core.epoch : Math.max(saved.host.core.epoch, boot.epoch) + 1) : undefined;
|
|
257
|
+
if (saved && !rested) this.durable.boot({ epoch, count: boot.count });
|
|
245
258
|
this.hostCreatedAt = Date.now();
|
|
246
259
|
let ticks = 0;
|
|
247
260
|
this.hostRt = startHost(this.game, {
|
|
@@ -257,8 +270,17 @@ export class Table {
|
|
|
257
270
|
// Alarm-driven suspension requires the native hibernation lifecycle.
|
|
258
271
|
// Legacy adapters keep their host clock, as browser hosts do.
|
|
259
272
|
...(this.ctx.acceptWebSocket ? { onRoundIdle: until => { room.tellWatchers(); this.report(); this.recordRound(); this.armRoom(until); this.ctx.waitUntil(this.sleepRoom()); } } : {}),
|
|
273
|
+
// A room nobody is playing rests with its people connected (rules/host.ts), and hibernates like a round break.
|
|
274
|
+
// A deliberate rest is a clean stop: the restore-loop count starts again, so waking it is never a crash.
|
|
275
|
+
...(this.ctx.acceptWebSocket && this.ctx.storage.sql ? { onRest: on => {
|
|
276
|
+
// Guides stop with the room: a think alarm would wake the object, and the command it sent would wake the sim.
|
|
277
|
+
this.house?.rest(on);
|
|
278
|
+
if (!on) { this.armRoom(Date.now() + ROOM_ALARM_MS); this.start(); return; }
|
|
279
|
+
try { this.durable?.boot({ epoch: this.hostRt?.epoch ?? epoch, count: 0 }); this.pendingBoot = null; } catch (error) { this.storageProblem(error); }
|
|
280
|
+
room.tellWatchers(); this.report(); this.ctx.waitUntil(this.sleepRoom());
|
|
281
|
+
} } : {}),
|
|
260
282
|
onTick: () => {
|
|
261
|
-
if (!saved) return;
|
|
283
|
+
if (!saved || rested) return;
|
|
262
284
|
ticks += 1;
|
|
263
285
|
if (ticks === 1) this.pendingBoot = { epoch, count: saved.buildHash !== build.build ? 0 : boot.count + 1 };
|
|
264
286
|
if (ticks === 10 * build.settings.tickHz) this.pendingBoot = { epoch, count: 0 };
|
|
@@ -315,7 +337,7 @@ export class Table {
|
|
|
315
337
|
const tokens = new Set((net.seats ?? []).map(seat => seat[1]));
|
|
316
338
|
for (const [account, token] of Object.entries(this.accountTokens)) if (!tokens.has(token)) delete this.accountTokens[account];
|
|
317
339
|
}
|
|
318
|
-
const saved = { ...(build.settings.characters ? { accountTokens: { ...this.accountTokens } } : {}), v: 1, game: this.game, room: this.code, at: now, pauseFromRestore: this.hostRt?.paused === true && this.pauseFromRestore === true, pausedAt: this.hostRt?.paused ? this.pausedAt ?? now : null, stateHash: build.stateHash, buildHash: build.build, host, net };
|
|
340
|
+
const saved = { ...(build.settings.characters ? { accountTokens: { ...this.accountTokens } } : {}), v: 1, game: this.game, room: this.code, at: now, pauseFromRestore: this.hostRt?.paused === true && this.pauseFromRestore === true, pausedAt: this.hostRt?.paused ? this.pausedAt ?? now : null, ...(this.hostRt?.resting ? { resting: true } : {}), stateHash: build.stateHash, buildHash: build.build, host, net };
|
|
319
341
|
const clearing = this.cleanupPending;
|
|
320
342
|
if (clearing) this.durable.clear();
|
|
321
343
|
if (this.isPersistent()) {
|
|
@@ -370,7 +392,7 @@ export class Table {
|
|
|
370
392
|
if (saved.v !== 1 || typeof saved.game !== 'string' || typeof saved.room !== 'string' ||
|
|
371
393
|
this.game && saved.game !== this.game || this.code && saved.room !== this.code ||
|
|
372
394
|
!Number.isFinite(saved.at) || saved.at < 0 || saved.at > Date.now() + 5000 || saved.pausedAt !== null && (!Number.isFinite(saved.pausedAt) || saved.pausedAt < 0 || saved.pausedAt > Date.now() + 5000) ||
|
|
373
|
-
saved.pauseFromRestore !== undefined && typeof saved.pauseFromRestore !== 'boolean' ||
|
|
395
|
+
saved.pauseFromRestore !== undefined && typeof saved.pauseFromRestore !== 'boolean' || saved.resting !== undefined && saved.resting !== true ||
|
|
374
396
|
saved.host?.v !== 1 || !saved.net || typeof saved.stateHash !== 'string' || typeof saved.buildHash !== 'string') throw new Error('save envelope does not fit this room');
|
|
375
397
|
saved.at = Math.min(saved.at, Date.now());
|
|
376
398
|
if (saved.pausedAt !== null) saved.pausedAt = Math.min(saved.pausedAt, Date.now());
|
|
@@ -796,7 +818,10 @@ export class Table {
|
|
|
796
818
|
this.house = new HouseAgents({
|
|
797
819
|
room, env: this.env,
|
|
798
820
|
// The room's own alarm (a paused server-hosted room ends on it) is never pushed back by a guide's.
|
|
799
|
-
setAlarm: (at) => {
|
|
821
|
+
setAlarm: (at) => {
|
|
822
|
+
this.guideAlarmAt = at;
|
|
823
|
+
this.ctx.storage.setAlarm(this.roomAlarmAt > Date.now() ? Math.min(at, this.roomAlarmAt) : at).catch(error => this.say({ev:'background-failed',error:errorLine(error)}));
|
|
824
|
+
},
|
|
800
825
|
budget: { left: (kind) => this.brainCap[kind] - this.brainUsed[kind], spend: (c) => this.brainSpend(c) },
|
|
801
826
|
log: (line) => { try { console.log(JSON.stringify(line)); } catch { /* no console */ } },
|
|
802
827
|
});
|
|
@@ -1191,7 +1216,7 @@ export class Table {
|
|
|
1191
1216
|
} else {
|
|
1192
1217
|
const h = room.attach(conn,server.resume);
|
|
1193
1218
|
server.remember?.(h.id);
|
|
1194
|
-
if (server.resume && room.server) room.joinServer(room.clients.get(h.id));
|
|
1219
|
+
if (server.resume && room.server) room.joinServer(room.clients.get(h.id), true);
|
|
1195
1220
|
// A room is OPENED when the first seat is taken in an empty one (a room restored after a deploy keeps its
|
|
1196
1221
|
// seats, so it is not counted twice). One counter write per room.
|
|
1197
1222
|
const fresh = room.seats.size === 0;
|
|
@@ -1281,7 +1306,8 @@ export class Table {
|
|
|
1281
1306
|
// Older ports publish snapshots without a round protocol. Once play starts,
|
|
1282
1307
|
// treat them as active until empty: persisting every frame would turn their
|
|
1283
1308
|
// ordinary network traffic into a stream of SQLite writes.
|
|
1284
|
-
|
|
1309
|
+
// A browser host that rested its room (rules/host.ts in the page) sends nothing until somebody plays: it sleeps.
|
|
1310
|
+
const active = this.hostRt ? this.hostRt.running : !this.room?.resting && (this.room?.lastRound?.phase === 'live' || (!this.room?.lastRound && this.room?.lastSnap != null && this.room?.clients.size > 0));
|
|
1285
1311
|
return this.room && this.hibernationReady && this.sessionStore && this.ctx.storage.sql && this.ctx.acceptWebSocket && (!this.relayed || this.relays.size > 0) && (!this.hostRt || this.hostRt.roundIdleUntil !== null || !this.hostRt.running) && !active && !this.restoringSockets;
|
|
1286
1312
|
}
|
|
1287
1313
|
async sleepRoom() {
|
|
@@ -1304,12 +1330,19 @@ export class Table {
|
|
|
1304
1330
|
}
|
|
1305
1331
|
this.saveSessions();
|
|
1306
1332
|
for(const relay of this.relays.values()){relay.session.flush();relay.socket.sleep();}
|
|
1307
|
-
// Edge-answered heartbeats need only a minute's liveness sweep
|
|
1308
|
-
// client still keeps its
|
|
1309
|
-
|
|
1333
|
+
// Edge-answered heartbeats need only a minute's liveness sweep: a browser that has pinged the edge, or one that
|
|
1334
|
+
// understands a resting room (it pings the edge from the moment it rests). A legacy client still keeps its
|
|
1335
|
+
// original hello/heartbeat deadline.
|
|
1336
|
+
const due=[...(this.hostRt?.roundIdleUntil != null ? [this.hostRt.roundIdleUntil] : []),now+(room.officeDirty?1000:60000),...[...room.clients.values()].map(c=>{const ping=c.conn.lastPing?.()??0;return c.helloed?Math.max(c.lastSeen,ping)+(ping||c.rest&&c.conn.idlePing?Math.max(room.idleMs,60000):room.idleMs):c.joinedAt+5000;})];
|
|
1310
1337
|
for(const control of [room.vote?.open?room.vote:null,room.regate,room.agentsOut])if(control?.until)due.push(control.until);
|
|
1311
1338
|
const at=Math.max(now+1,Math.min(...due)),alarm=await this.ctx.storage.getAlarm();
|
|
1312
|
-
|
|
1339
|
+
// A guide's think is sooner than the liveness sweep, and sleep only moves an alarm earlier. While the host rests,
|
|
1340
|
+
// that think must not survive: it would wake the object every few seconds and its command would start the room.
|
|
1341
|
+
// A spectator's own shorter deadline stays, because `at` is already the soonest real one.
|
|
1342
|
+
const roomResting=this.hostRt?.resting===true||this.room?.resting===true;
|
|
1343
|
+
const guideSoon=roomResting && this.guideAlarmAt && alarm!=null && alarm<=this.guideAlarmAt+1000;
|
|
1344
|
+
if(alarm==null||alarm>at||guideSoon)await this.ctx.storage.setAlarm(at);
|
|
1345
|
+
if(guideSoon)this.guideAlarmAt=0;
|
|
1313
1346
|
}
|
|
1314
1347
|
start() {
|
|
1315
1348
|
if(this.restoringSockets)return;
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.58.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.58.2';
|