@homie-rocks/studio 0.55.0 → 0.56.0
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 +39 -0
- package/README.md +2 -0
- package/bin/homie-studio.mjs +1 -1
- package/docs/custom-routes.md +72 -0
- package/guides/app/SKILL.md +7 -0
- package/guides/playtest/scripts/playtest.mjs +7 -5
- package/lib/check.mjs +8 -1
- package/lib/cloudflare.mjs +49 -22
- package/lib/dev.mjs +6 -8
- package/lib/local-database-start.mjs +6 -0
- package/lib/mcp-tools.mjs +3 -3
- package/lib/port-check.mjs +3 -2
- package/lib/scaffold.mjs +2 -0
- package/lib/template-history.json +46 -1
- package/lib/worker-build.mjs +4 -1
- package/lib/worker-config.mjs +7 -0
- package/netplay/NETPLAY.md +5 -1
- package/netplay/netplay.ts +24 -9
- package/package.json +1 -1
- package/rules/core.ts +9 -0
- package/rules/host.ts +40 -11
- package/rules/view.ts +1 -0
- package/tools/TOOLS.md +6 -1
- package/worker/background-queue.mjs +1 -0
- package/worker/build-id.mjs +1 -1
- package/worker/custom-routes.mjs +16 -0
- package/worker/gate.mjs +94 -23
- package/worker/idle-chat.mjs +56 -0
- package/worker/index.mjs +2 -0
- package/worker/lazy-objects.mjs +3 -0
- package/worker/lobby-peak.mjs +44 -0
- package/worker/lobby-shards.mjs +43 -0
- package/worker/lobby-store.mjs +35 -0
- package/worker/lobby-summary.mjs +28 -0
- package/worker/lobby.mjs +86 -18
- package/worker/lounge-store.mjs +2 -2
- package/worker/match-router.mjs +74 -0
- package/worker/mcp-room.mjs +2 -1
- package/worker/mcp-tools.mjs +4 -2
- package/worker/office.mjs +2 -1
- package/worker/operational-schema.mjs +2 -0
- package/worker/pages.mjs +1 -1
- package/worker/relay-channel.mjs +119 -0
- package/worker/request-db.mjs +4 -3
- package/worker/room-chat.mjs +2 -1
- package/worker/room-sessions.mjs +65 -0
- package/worker/room.mjs +16 -9
- package/worker/route-auth.mjs +22 -0
- package/worker/routes.mjs +7 -4
- package/worker/runtime-support.mjs +5 -3
- package/worker/session-codec.mjs +24 -0
- package/worker/stats.mjs +2 -1
- package/worker/table.mjs +277 -41
- package/worker/version.mjs +1 -1
- package/worker/watch-sockets.mjs +75 -0
package/netplay/netplay.ts
CHANGED
|
@@ -170,7 +170,7 @@ export type LinkState = 'connecting' | 'online' | 'reconnecting' | 'alone' | 'of
|
|
|
170
170
|
export interface LinkChange {
|
|
171
171
|
state: LinkState;
|
|
172
172
|
prev: LinkState;
|
|
173
|
-
/**
|
|
173
|
+
/** `subscribe` is the current state delivered immediately on subscription (prev equals state). Otherwise, why it changed: `welcome`, `lost` (the socket closed), `stale` (it went silent), `relay-timeout` (the room never answered), `reconnect-timeout` (it was in the room and the room did not come back in `reconnectMaxMs`), `no-shell`, or a refusal's code. */
|
|
174
174
|
why: string;
|
|
175
175
|
/** This browser is running the rules right now (alone, or as the room's host): two browsers can both say so while one is cut off. */
|
|
176
176
|
hosting: boolean;
|
|
@@ -749,7 +749,7 @@ export interface NetHandlers<S, A, C> {
|
|
|
749
749
|
unchat: (e: { ids: string[] }) => void;
|
|
750
750
|
/** Revision 8: a message of mine was not sent (slow mode, sign in to type, a word, the studio's filter…). */
|
|
751
751
|
held: (h: ChatHeld) => void;
|
|
752
|
-
/**
|
|
752
|
+
/** Current link on subscription (why: subscribe), then changes (online, reconnecting, alone, closed). */
|
|
753
753
|
link: (e: LinkChange) => void;
|
|
754
754
|
/** Revision 9: a newer build of the game is live; this tab should reload (section 23). */
|
|
755
755
|
stale: (e: StaleNotice) => void;
|
|
@@ -797,7 +797,7 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
|
|
|
797
797
|
* THE LINK (revision 9, NETPLAY.md section 22). `offline` is true when this browser is NOT in a room: there is no
|
|
798
798
|
* shell at all, or the room never answered and it plays alone (`link === 'alone'`). `connected` is true only while
|
|
799
799
|
* the socket is up and welcomed. Between the two (`!offline && !connected`) the browser was in a room and is
|
|
800
|
-
* knocking again: `link === 'reconnecting'`. `link` says which in one word, and `on('link')`
|
|
800
|
+
* knocking again: `link === 'reconnecting'`. `link` says which in one word, and `on('link')` immediately reports the current state, then each change.
|
|
801
801
|
*/
|
|
802
802
|
readonly link: LinkState;
|
|
803
803
|
/** Times the helper has scheduled another knock at the relay (the same number as `stats().reconnects`). 0: never interrupted. */
|
|
@@ -1713,6 +1713,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1713
1713
|
const peers = new Map<string, Peer>();
|
|
1714
1714
|
let heldPeers: Peer[] = [];
|
|
1715
1715
|
let roundInfo: RoundInfo | null = null;
|
|
1716
|
+
let idlePing = false, lastClockPing = -Infinity;
|
|
1716
1717
|
let slots: Slot[] | null = null;
|
|
1717
1718
|
let lastCkpt: Checkpoint<C> | null = null;
|
|
1718
1719
|
|
|
@@ -2352,12 +2353,13 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2352
2353
|
|
|
2353
2354
|
// ---------------------------------------------------------------- wire in
|
|
2354
2355
|
function onMessage(data: unknown): void {
|
|
2355
|
-
lastMsgAt = wall();
|
|
2356
|
+
if (data === 'homie-pong' && idlePing) { lastMsgAt = wall(); return; }
|
|
2356
2357
|
const text = typeof data === 'string' ? data : String(data);
|
|
2357
2358
|
countBytes(bytesIn, text.length);
|
|
2358
2359
|
let m: Record<string, unknown>;
|
|
2359
2360
|
try { m = JSON.parse(text) as Record<string, unknown>; } catch { return; }
|
|
2360
2361
|
if (!m || typeof m['t'] !== 'string') return;
|
|
2362
|
+
lastMsgAt = wall();
|
|
2361
2363
|
switch (m['t']) {
|
|
2362
2364
|
case 'welcome': return onWelcome(m);
|
|
2363
2365
|
case 'snap': return onSnap(m, text.length);
|
|
@@ -2583,6 +2585,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2583
2585
|
}
|
|
2584
2586
|
|
|
2585
2587
|
function onWelcome(m: Record<string, unknown>): void {
|
|
2588
|
+
idlePing = m.idlePing === true; lastClockPing = -Infinity;
|
|
2586
2589
|
post?.({ what: 'build', ver: version });
|
|
2587
2590
|
clearTimeout(connectTimer);
|
|
2588
2591
|
const next = m['role'] as Role;
|
|
@@ -2810,7 +2813,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2810
2813
|
const st = Number(m['st']);
|
|
2811
2814
|
const sent = pingsOut.get(c);
|
|
2812
2815
|
if (sent === undefined || !Number.isFinite(st)) return;
|
|
2813
|
-
pingsOut.
|
|
2816
|
+
for (const key of pingsOut.keys()) if (key <= c) pingsOut.delete(key);
|
|
2814
2817
|
const recv = Date.now();
|
|
2815
2818
|
const r = recv - sent;
|
|
2816
2819
|
const off = st + r / 2 - recv;
|
|
@@ -2869,7 +2872,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2869
2872
|
}
|
|
2870
2873
|
|
|
2871
2874
|
function hidden(): boolean { try { return Boolean(g.document?.hidden); } catch { return false; } }
|
|
2872
|
-
function ping(): void {
|
|
2875
|
+
function ping(force = false): void {
|
|
2873
2876
|
// This runs every two seconds at least. A gap of more than three and a half means the page itself was held.
|
|
2874
2877
|
const beat = wall();
|
|
2875
2878
|
const pingHeld = beat - lastPingTick > 2000 + 1500;
|
|
@@ -2885,12 +2888,22 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2885
2888
|
// the word of a timer that itself ran seconds late: the page was blocked (a heavy boot, shaders compiling) and
|
|
2886
2889
|
// could not have heard anything, and what the relay sent meanwhile is still queued behind this very timer.
|
|
2887
2890
|
// Dropping a healthy socket there turned every long hitch into a reconnect.
|
|
2888
|
-
|
|
2891
|
+
const oldestPing = pingsOut.values().next().value;
|
|
2892
|
+
if (ws && oldestPing !== undefined && Date.now() - oldestPing >= 30_000 && !pingHeld) { lost(ws, 'stale'); return; }
|
|
2893
|
+
const silence = idlePing && (roundInfo?.phase !== 'live' || wall() - lastClockPing > staleMs) ? Math.max(staleMs, 60_000) : staleMs;
|
|
2894
|
+
if (ws && lastMsgAt && wall() - lastMsgAt > silence) {
|
|
2889
2895
|
if (!pingHeld) { lost(ws, 'stale'); return; }
|
|
2890
2896
|
// Two and a half seconds to be heard from (a pong answers the ping below): a socket that is really dead is
|
|
2891
2897
|
// still dropped, two beats from now.
|
|
2892
2898
|
lastMsgAt = wall() - staleMs + 2500;
|
|
2893
2899
|
}
|
|
2900
|
+
// An idle hibernatable room can answer this at the edge. Keep a regular
|
|
2901
|
+
// clock/visibility sample on visibility changes, and every heartbeat during play.
|
|
2902
|
+
if (idlePing && roundInfo?.phase !== 'live' && !(roundInfo?.phase === 'over' && now() >= roundInfo.endsAt) && !force) {
|
|
2903
|
+
try { ws?.send('homie-ping'); } catch { if(ws)lost(ws,'send'); }
|
|
2904
|
+
return;
|
|
2905
|
+
}
|
|
2906
|
+
lastClockPing = wall();
|
|
2894
2907
|
const c = Date.now();
|
|
2895
2908
|
pingsOut.set(c, c);
|
|
2896
2909
|
if (pingsOut.size > 16) { const first = pingsOut.keys().next().value; if (first !== undefined) pingsOut.delete(first); }
|
|
@@ -3026,6 +3039,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
3026
3039
|
ws = sock; socketStartedAt = wall();
|
|
3027
3040
|
sock.onopen = () => {
|
|
3028
3041
|
lastMsgAt = wall();
|
|
3042
|
+
pingsOut.clear();
|
|
3029
3043
|
raw({
|
|
3030
3044
|
t: 'hello', rules: rulesGame, v: NETPLAY_VERSION, rev: NETPLAY_REVISION, token, name: name || undefined, device, want, canHost: asAgent && asAgent.hands === 'host' ? false : canHost, game: opts.game, max: opts.maxPlayers,
|
|
3031
3045
|
...(rulesGame ? { coast: 1 } : {}),
|
|
@@ -3101,8 +3115,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
3101
3115
|
if (hidden()) {
|
|
3102
3116
|
// A hidden phone tab loses its timers and rAF: hand the round on before it stalls.
|
|
3103
3117
|
if (role === 'host' && peers.size > 1) yieldHost();
|
|
3104
|
-
ping();
|
|
3105
|
-
} else ping();
|
|
3118
|
+
ping(true);
|
|
3119
|
+
} else ping(true);
|
|
3106
3120
|
});
|
|
3107
3121
|
g.addEventListener?.('online', () => { if (!closed && !connected && rulesGame) { if (ws) lost(ws, 'online'); if (retryTimer !== null) clearTimeout(retryTimer); retryTimer = null; attempt = 0; open(); } });
|
|
3108
3122
|
g.addEventListener?.('pagehide', () => { if (role === 'host') { sendCheckpoint(); rulesSender?.leave(); } raw({ t: 'bye' }); });
|
|
@@ -3353,6 +3367,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
3353
3367
|
on(kind, fn) {
|
|
3354
3368
|
(handlers[kind] as Set<unknown>).add(fn);
|
|
3355
3369
|
if (kind === 'view') markFollows();
|
|
3370
|
+
if (kind === 'link') try { (fn as NetHandlers<S, A, C>['link'])({state:link,prev:link,why:'subscribe',hosting:roleKnown && role === 'host',downMs:connected || !downSince ? 0 : Math.round(wall() - downSince)}); } catch { /* A failed UI listener must still be removable. */ }
|
|
3356
3371
|
return () => { (handlers[kind] as Set<unknown>).delete(fn); };
|
|
3357
3372
|
},
|
|
3358
3373
|
now,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.56.0",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/rules/core.ts
CHANGED
|
@@ -173,6 +173,8 @@ export interface Core {
|
|
|
173
173
|
shared(): unknown[];
|
|
174
174
|
/** The current round, for a host rebasing wall-clock deadlines after a pause or restore. */
|
|
175
175
|
round(): Extract<CoreOut, { t: 'round' }>;
|
|
176
|
+
/** End a settled wall-clock break without simulating its idle ticks. */
|
|
177
|
+
resumeRound(): boolean;
|
|
176
178
|
bodies(): Body[];
|
|
177
179
|
/** Every person's body with where it stands: who an effect with an audience reaches (0.46.3). */
|
|
178
180
|
listeners(): { seat: number; id: string; pos: Vec3 }[];
|
|
@@ -1537,6 +1539,13 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
1537
1539
|
}
|
|
1538
1540
|
return {
|
|
1539
1541
|
round: () => ({ t: 'round', ...round, ...(round.phase === 'over' && !overAt ? { results: results() } : {}) }),
|
|
1542
|
+
resumeRound() {
|
|
1543
|
+
if (round.phase !== 'over' || overAt) return false;
|
|
1544
|
+
// Preserve queued events and their simulation-time deadlines. Nothing
|
|
1545
|
+
// moves during the wall-clock break; the next step delivers them.
|
|
1546
|
+
if (playing) startRound(); else restartAt = tick;
|
|
1547
|
+
return true;
|
|
1548
|
+
},
|
|
1540
1549
|
get tick() { return tick; },
|
|
1541
1550
|
reviseMovement,
|
|
1542
1551
|
get epoch() { return epoch; },
|
package/rules/host.ts
CHANGED
|
@@ -87,6 +87,8 @@ export interface HostOptions {
|
|
|
87
87
|
restoreEpoch?: number;
|
|
88
88
|
startDelayMs?: number;
|
|
89
89
|
onTick?(): void;
|
|
90
|
+
/** Suspend settled round breaks; the owner persists and wakes this deadline. */
|
|
91
|
+
onRoundIdle?(until: number): void;
|
|
90
92
|
/** Only the shared server isolate can attribute timer lateness to another tick. */
|
|
91
93
|
enforceOverrun?: boolean;
|
|
92
94
|
log?(line: Record<string, unknown>): void;
|
|
@@ -111,6 +113,8 @@ export interface Host {
|
|
|
111
113
|
readonly epoch: number;
|
|
112
114
|
readonly running: boolean;
|
|
113
115
|
readonly paused: boolean;
|
|
116
|
+
readonly roundIdleUntil: number | null;
|
|
117
|
+
wakeRound(): void;
|
|
114
118
|
readonly people: number;
|
|
115
119
|
readonly viewRadiusM: number | null;
|
|
116
120
|
readonly privateDelivery: boolean;
|
|
@@ -199,13 +203,14 @@ export function createHost(o: HostOptions): Host {
|
|
|
199
203
|
// A log that throws takes nothing down with it.
|
|
200
204
|
const log = (line: Record<string, unknown>): void => { try { o.log?.(line); } catch { /* nothing to say it with */ } };
|
|
201
205
|
const random = o.random ?? Math.random;
|
|
202
|
-
const saved = o.restore ? fromBytes(o.restore) as { v: 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;
|
|
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;
|
|
203
207
|
if (o.restore && (!saved || typeof saved !== 'object' || Array.isArray(saved))) throw new Error('saved host is not an object');
|
|
204
208
|
if (saved) {
|
|
205
209
|
const check = (ok: unknown): void => { if (!ok) throw new Error('saved host inputs are invalid'); };
|
|
206
210
|
const uint = (n: unknown): boolean => Number.isSafeInteger(n) && (n as number) >= 0;
|
|
207
|
-
check(Object.keys(saved).every(k => ['v', 'core', 'names', 'agents', 'guideViews', 'queues', 'inputs', 'characters'].includes(k)));
|
|
211
|
+
check(Object.keys(saved).every(k => ['v', 'core', 'names', 'agents', 'guideViews', 'queues', 'inputs', 'characters', 'roundIdleUntil'].includes(k)));
|
|
208
212
|
check(saved.core && Array.isArray(saved.names) && Array.isArray(saved.queues));
|
|
213
|
+
if (saved.roundIdleUntil !== undefined) check(Number.isFinite(saved.roundIdleUntil) && saved.roundIdleUntil >= 0 && saved.core.round[1] === 0 && !saved.core.overAt);
|
|
209
214
|
if (saved.characters !== undefined) {
|
|
210
215
|
check(c.settings.characters && Array.isArray(saved.characters));
|
|
211
216
|
check(new Set(saved.characters.map(c => c.account)).size === saved.characters.length);
|
|
@@ -396,6 +401,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
396
401
|
core.goal(b.seat, g?.state === 'active' ? { goal: g.goal, args: g.args, from: g.from, at: Math.round(g.at / period), asked: g.asked === true } : null);
|
|
397
402
|
}
|
|
398
403
|
}
|
|
404
|
+
let roundIdleUntil: number | null = o.onRoundIdle && Number.isFinite(saved?.roundIdleUntil) ? saved!.roundIdleUntil! : null;
|
|
399
405
|
let running = false;
|
|
400
406
|
let paused = Boolean(saved && (o.startPaused ?? o.restoreEpoch !== undefined));
|
|
401
407
|
let ended = false;
|
|
@@ -594,7 +600,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
594
600
|
core.seatLeave(held.seat);
|
|
595
601
|
tick(o.clock.now());
|
|
596
602
|
} else core.seatAway(m.seat as number, true);
|
|
597
|
-
if (running && people() === 0) pause();
|
|
603
|
+
if ((running || roundIdleUntil !== null) && people() === 0) pause();
|
|
598
604
|
return;
|
|
599
605
|
}
|
|
600
606
|
case 'free': {
|
|
@@ -832,7 +838,17 @@ export function createHost(o: HostOptions): Host {
|
|
|
832
838
|
}
|
|
833
839
|
timing.record('tick', tickAt);
|
|
834
840
|
if (ended) return;
|
|
835
|
-
if (!faulted && core.stats.ticksCut === cut) {
|
|
841
|
+
if (!faulted && core.stats.ticksCut === cut) {
|
|
842
|
+
failingSince = null;
|
|
843
|
+
const round = core.round();
|
|
844
|
+
if (o.onRoundIdle && running && round.phase === 'over' && round.results && round.endsAt > core.tick) {
|
|
845
|
+
roundIdleUntil = now + (round.endsAt - core.tick) * period;
|
|
846
|
+
running = false; disarm();
|
|
847
|
+
try { o.store?.save(save()); o.onRoundIdle(roundIdleUntil); }
|
|
848
|
+
catch (error) { fault('round-idle', error); stop(); o.onEnd?.('storage-or-tick-failed', { tick: core.tick }); }
|
|
849
|
+
}
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
836
852
|
if (failingSince === null) failingSince = now;
|
|
837
853
|
if (now - failingSince < FAIL_MS) return;
|
|
838
854
|
if (faulted) end('fault', { error: stats.lastFault, faults: stats.faults });
|
|
@@ -913,7 +929,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
913
929
|
const self = { blame: (): void => { blamed = true; } };
|
|
914
930
|
|
|
915
931
|
function start(): void {
|
|
916
|
-
if (running || ended) return;
|
|
932
|
+
if (running || ended || roundIdleUntil !== null) return;
|
|
917
933
|
running = true; paused = false;
|
|
918
934
|
try {
|
|
919
935
|
base = { ms: o.clock.now() + (firstTick ? o.startDelayMs ?? 0 : 0), tick: core.tick };
|
|
@@ -926,26 +942,37 @@ export function createHost(o: HostOptions): Host {
|
|
|
926
942
|
} catch (error) { fault('start', error); } finally { keepTime(); }
|
|
927
943
|
}
|
|
928
944
|
function pause(): void {
|
|
929
|
-
if (!running)
|
|
945
|
+
if (!running) {
|
|
946
|
+
if (roundIdleUntil !== null && !paused) { paused = true; checkpoint(true); o.onPause?.(); }
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
930
949
|
running = false; paused = true;
|
|
931
950
|
// A pending timer would keep the object in memory and billed: the pause clears it.
|
|
932
951
|
disarm();
|
|
933
952
|
log({ ev: 'host-pause', game: o.game, tick: core.tick });
|
|
934
953
|
try { checkpoint(true); o.onPause?.(); } catch (error) { fault('onPause', error); if (persistent) { stop(); o.onEnd?.('storage-or-tick-failed', { tick: core.tick }); } }
|
|
935
954
|
}
|
|
936
|
-
function resume(): void {
|
|
937
|
-
if (running || ended || !paused) return;
|
|
955
|
+
function resume(announceRound = true): void {
|
|
956
|
+
if (running || ended || !paused || roundIdleUntil !== null) return;
|
|
938
957
|
running = true; paused = false;
|
|
939
958
|
try {
|
|
940
959
|
// The room resumes at the tick it paused on: no tick is skipped and no timer fires for the gap.
|
|
941
960
|
base = { ms: o.clock.now() + (firstTick ? o.startDelayMs ?? 0 : 0), tick: core.tick };
|
|
942
961
|
overSince = null; blamed = false; failingSince = null; lastStart = base.ms;
|
|
943
|
-
handle([core.round()], base.ms);
|
|
962
|
+
if (announceRound) handle([core.round()], base.ms);
|
|
944
963
|
for (const q of queues.values()) q.lastFrameTick = core.tick;
|
|
945
964
|
log({ ev: 'host-resume', game: o.game, tick: core.tick });
|
|
946
965
|
o.onResume?.();
|
|
947
966
|
} catch (error) { fault('resume', error); } finally { keepTime(); }
|
|
948
967
|
}
|
|
968
|
+
function wakeRound(): void {
|
|
969
|
+
if (roundIdleUntil === null || o.clock.now() < roundIdleUntil || ended) return;
|
|
970
|
+
if (!core.resumeRound()) throw new Error('Round is not settled');
|
|
971
|
+
roundIdleUntil = null;
|
|
972
|
+
handle(core.drain(), o.clock.now());
|
|
973
|
+
if (people() > 0) { paused = true; resume(false); }
|
|
974
|
+
else { paused = true; checkpoint(true); }
|
|
975
|
+
}
|
|
949
976
|
/** What a relay keeps for whoever joins next, said again: the round with its results, the roster, the shared state, the capabilities. */
|
|
950
977
|
function announce(): void {
|
|
951
978
|
if (ended) return;
|
|
@@ -975,7 +1002,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
975
1002
|
|
|
976
1003
|
function save(): Uint8Array {
|
|
977
1004
|
const reserved = new Set(core.bodies().filter(b => b.owner === 'reserved').map(b => b.seat));
|
|
978
|
-
return toBytes({ v: 1, ...(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] });
|
|
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] });
|
|
979
1006
|
}
|
|
980
1007
|
function checkpoint(force = false): void {
|
|
981
1008
|
if (persistent) {
|
|
@@ -1022,7 +1049,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
1022
1049
|
held.character = core.character(held.seat) ?? held.character; held.returning = true;
|
|
1023
1050
|
present.delete(held.seat); queues.delete(held.seat); names.delete(held.seat);
|
|
1024
1051
|
if (held.seat >= 0) core.seatLeave(held.seat); tick(o.clock.now());
|
|
1025
|
-
if (running && people() === 0) pause();
|
|
1052
|
+
if ((running || roundIdleUntil !== null) && people() === 0) pause();
|
|
1026
1053
|
},
|
|
1027
1054
|
characterReturned(account, lease) { if (characters.get(account)?.returning && characters.get(account)?.lease === lease) characters.delete(account); },
|
|
1028
1055
|
get viewSettings() { return c.settings.view; },
|
|
@@ -1032,6 +1059,8 @@ export function createHost(o: HostOptions): Host {
|
|
|
1032
1059
|
get epoch() { return epoch; },
|
|
1033
1060
|
get running() { return running; },
|
|
1034
1061
|
get paused() { return paused; },
|
|
1062
|
+
get roundIdleUntil() { return roundIdleUntil; },
|
|
1063
|
+
wakeRound,
|
|
1035
1064
|
get people() { return people(); },
|
|
1036
1065
|
frame, start, pause, resume, announce,
|
|
1037
1066
|
// 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.
|
package/rules/view.ts
CHANGED
|
@@ -941,6 +941,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
941
941
|
net.on('snapshot', onSnapshot);
|
|
942
942
|
net.on('role', (e) => { if (e.snap) onSnapshot(e.snap); if (net.offline && !net.rulesHosting) setStatus(net.closedWhy ? 'closed' : 'offline'); });
|
|
943
943
|
net.on('link', (e) => {
|
|
944
|
+
if (e.why === 'subscribe') return;
|
|
944
945
|
if (e.state === 'online') { returningUntil = clock() + 3000; base = null; if (status !== 'playing') setStatus('connecting'); } else if (e.state === 'closed') setStatus('closed'); else if (e.state === 'alone' || e.state === 'offline') setStatus('offline');
|
|
945
946
|
});
|
|
946
947
|
net.on('round', (r) => { if (r.results) { results = r.results; if (round) { round = { ...round, results }; emit('round', roundNow()); } } });
|
package/tools/TOOLS.md
CHANGED
|
@@ -53,7 +53,12 @@ Workers AI requires the studio's own binding and owner authority, or an explicit
|
|
|
53
53
|
never exposes the environment or raw credentials. A named secret capability in
|
|
54
54
|
`studio.json` (`mcp.secrets.NAME: { origin, header, prefix, tools }`) lets an owner tool
|
|
55
55
|
make an HTTPS request to one declared origin; `tools` can explicitly delegate this capability to named staff tools; redirects and credential echoes are
|
|
56
|
-
refused.
|
|
56
|
+
refused. Use `origin: 'self'` for the studio's own API: the capability resolves to
|
|
57
|
+
the current Worker request origin (or the configured studio origin for scheduled
|
|
58
|
+
functions), so a workers.dev address need not be known at build time. Calls may
|
|
59
|
+
use a relative URL such as `ctx.fetchWithSecret('API_TOKEN', '/api/sync')`.
|
|
60
|
+
HTTPS is still required; credentials in URLs and cross-origin targets are refused.
|
|
61
|
+
The studio’s AI can generate an integration secret and place it in Cloudflare and the sender through their authorized APIs. Never ask the person to find or paste a key, and never put a secret in source files.
|
|
57
62
|
|
|
58
63
|
Studio handlers and imported parts are trusted Worker code, not sandboxed plugins.
|
|
59
64
|
Review shared code before adding it: JavaScript wrappers cannot confine malicious
|
|
@@ -22,6 +22,7 @@ export async function consumeBackground(batch, env) {
|
|
|
22
22
|
}
|
|
23
23
|
for(const message of batch.messages)if(!changes.includes(message)) {
|
|
24
24
|
try {
|
|
25
|
+
if(message.body?.v===1&&message.body.type==='lobby.snapshot'){await (await import('./lobby-summary.mjs')).storeLobbySummary(env,message.body);message.ack();continue;}
|
|
25
26
|
if(message.body?.v!==1||message.body.type!=='stripe.event')throw new Error('Unknown background message type');
|
|
26
27
|
await (await import('./stripe-intake.mjs')).reconcileStripeEvent(env,message.body.id,await catalogue(env));
|
|
27
28
|
message.ack();
|
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.56.0","sha256":"9b000afecb22ce5781d6499a02f44ffcc255c61907e9f7d469dd63d0bd9f35d1"});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Studio configuration only: register once at module evaluation, never per request. */
|
|
2
|
+
let handler = null;
|
|
3
|
+
export function useRoutes(routes) {
|
|
4
|
+
if (routes !== null && typeof routes !== 'function') throw new TypeError('useRoutes expects a function or null');
|
|
5
|
+
handler = routes;
|
|
6
|
+
}
|
|
7
|
+
export async function customRoute(request, env, ctx) {
|
|
8
|
+
const response = await handler?.(request, env, ctx);
|
|
9
|
+
if (response == null) return null;
|
|
10
|
+
if (!(response instanceof Response)) throw new TypeError('A custom route must return a Response, null, or undefined');
|
|
11
|
+
if (response.status === 101 || response.webSocket) return response;
|
|
12
|
+
const headers = new Headers(response.headers);
|
|
13
|
+
headers.set('x-content-type-options', 'nosniff');
|
|
14
|
+
if (!headers.has('referrer-policy')) headers.set('referrer-policy', 'strict-origin-when-cross-origin');
|
|
15
|
+
return new Response(response.body, {status:response.status,statusText:response.statusText,headers});
|
|
16
|
+
}
|