@homie-rocks/studio 0.58.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 +32 -1
- package/guides/game/RULES.md +15 -1
- package/lib/agent-seat.mjs +10 -3
- package/lib/template-history.json +42 -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/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/CHANGELOG.md
CHANGED
|
@@ -17,7 +17,38 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
|
|
|
17
17
|
`npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
|
|
18
18
|
file) and what the upgrade would change, and changes nothing until you agree.
|
|
19
19
|
|
|
20
|
-
## [
|
|
20
|
+
## [0.58.1] - 2026-10-17
|
|
21
|
+
|
|
22
|
+
**Plugin 0.58.1**
|
|
23
|
+
|
|
24
|
+
A room nobody is playing rests, and the next touch wakes it with that input still applied.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- Idle rooms sleep. A room whose people have all stopped playing now rests: nothing held, pressed or sent for a
|
|
29
|
+
minute (`room.restSeconds`), or every page in the background for ten seconds. Its clock stops with everybody still
|
|
30
|
+
connected and the room hibernates, so it is not charged for duration while it sleeps. It still wakes about once a
|
|
31
|
+
minute for a liveness alarm (one request, and the CPU of that short wake). A deliberate rest is restored as the
|
|
32
|
+
same world, under the same epoch, without rewriting the save. The first touch, command, AI command, returning page
|
|
33
|
+
or new arrival starts it again on the tick it stopped on, and a phone that reconnects keeps its seat. By default
|
|
34
|
+
(`room.rest: "quiet"`) a room rests only when its world has nothing of its own to do: no round clock, no event,
|
|
35
|
+
timer, spawn or ask waiting, nothing moving, and no AI command for that minute. `"idle"` rests anyway and pauses
|
|
36
|
+
round clocks, timers, bots and AI with it; Coin Dash, Ember Vale, Gem Rush, Gem Rush 3D and Hero Rush 3D use it.
|
|
37
|
+
`"never"` keeps a room going while anybody is there (a turn timer, a show). Browser-hosted rules games rest the
|
|
38
|
+
same way. A spectator or agent on an older build keeps pinging the room itself, so the object stays in memory
|
|
39
|
+
while that socket is open. Rebuild your games to get it: a room rests only when every seated browser is on this
|
|
40
|
+
version.
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- A browser-hosted rest stays asleep when the hosting tab is hidden or sends no snapshots. The next browser takes
|
|
45
|
+
the same checkpoint and does not run until somebody plays. A backgrounded tab no longer ends the rest while people
|
|
46
|
+
are still in the room. Waking writes a new checkpoint, so a later handoff does not put the room back to sleep.
|
|
47
|
+
|
|
48
|
+
### Upgrade notes
|
|
49
|
+
|
|
50
|
+
- Rebuild your games. Set `room.rest` to `"never"` (or `restSeconds` to 0) for a turn timer or a show that must keep
|
|
51
|
+
going while people are present. A lasting world never rests.
|
|
21
52
|
|
|
22
53
|
## [0.58.0] - 2026-10-16
|
|
23
54
|
|
package/guides/game/RULES.md
CHANGED
|
@@ -780,7 +780,21 @@ score and other shared outcomes still arrive with the network. The studio choose
|
|
|
780
780
|
Room state is saved automatically (default movementSeconds: 1, plus round end,
|
|
781
781
|
pause and finish; a lasting world instead journals its durable fields at once and
|
|
782
782
|
checkpoints the rest every 30 seconds). After 60 seconds with no person the room ends even if screens or
|
|
783
|
-
AI remain.
|
|
783
|
+
AI remain. A room whose people have all stopped playing rests: its clock stops with everybody still
|
|
784
|
+
connected and the room hibernates, so it is not charged for duration while it sleeps. It still wakes about once a
|
|
785
|
+
minute for a liveness alarm (one request, and the CPU of that short wake). A deliberate rest is restored as the same
|
|
786
|
+
world, under the same epoch, without rewriting the save. The first input, command, AI command, returning page
|
|
787
|
+
or new arrival starts it again on the tick it rested on. "Stopped playing" is nothing held, pressed or sent for
|
|
788
|
+
`room.restSeconds` (default 60), or ten seconds with every page in the background. An AI command counts as playing
|
|
789
|
+
and wakes a room that is already resting. `room.rest` says what else must be true. `"quiet"` (the default): the
|
|
790
|
+
world has nothing of its own to do, meaning no round clock running, no event, timer, spawn or ask waiting, nothing
|
|
791
|
+
moving, and no AI command for `restSeconds`. `"idle"`: rest anyway, pausing round clocks, timers, bots and AI with
|
|
792
|
+
it; Coin Dash, Ember Vale, Gem Rush, Gem Rush 3D and Hero Rush 3D use it, as an away-from-keyboard pause.
|
|
793
|
+
`"never"` (or `restSeconds: 0`): never while somebody is here, for a world that must keep going untouched, such as a
|
|
794
|
+
turn timer that skips an absent player or a show people watch. A lasting world never rests. A spectator or agent on
|
|
795
|
+
an older build keeps pinging the room itself, so the object stays in memory while that socket is open. Browser-hosted
|
|
796
|
+
rules games rest the same way. A resting host's silence is not a stall. If its tab is hidden, another browser takes
|
|
797
|
+
the same rest and stays asleep until somebody plays. Use browser createSaves for player-owned character progress; it is not
|
|
784
798
|
server-verified money. Entity fields default to public. Declare visibility explicitly for private information (below); shared state and motion remain public. Use declared server round records for verified match history.
|
|
785
799
|
|
|
786
800
|
|
package/lib/agent-seat.mjs
CHANGED
|
@@ -57,6 +57,8 @@ export class AgentSeat {
|
|
|
57
57
|
this.errors = [];
|
|
58
58
|
this.vocab = null;
|
|
59
59
|
this.ping = null;
|
|
60
|
+
/** The room said it is resting (welcome or `{ t: 'rest' }`). Pings then stay at the edge and do not wake it. */
|
|
61
|
+
this.resting = false;
|
|
60
62
|
/** brain 'local': Clef on this computer ({ base, model }) decides; its last decisions, and who said no thanks. */
|
|
61
63
|
this.brain = brain === 'local' && local ? 'local' : null;
|
|
62
64
|
this.local = local;
|
|
@@ -97,7 +99,7 @@ export class AgentSeat {
|
|
|
97
99
|
this.ws = ws;
|
|
98
100
|
const welcome = await new Promise((done) => {
|
|
99
101
|
const timer = setTimeout(() => done({ t: 'timeout' }), 12_000);
|
|
100
|
-
ws.onopen = () => ws.send(JSON.stringify({ ...sat.hello, device: 'desk' }));
|
|
102
|
+
ws.onopen = () => ws.send(JSON.stringify({ ...sat.hello, device: 'desk', rest: 1 }));
|
|
101
103
|
ws.onmessage = (e) => {
|
|
102
104
|
let m; try { m = JSON.parse(String(e.data)); } catch { return; }
|
|
103
105
|
if (m.t === 'welcome' || (m.t === 'error' && !this.seat)) { clearTimeout(timer); done(m); }
|
|
@@ -107,7 +109,9 @@ export class AgentSeat {
|
|
|
107
109
|
ws.onerror = () => {};
|
|
108
110
|
});
|
|
109
111
|
if (welcome.t !== 'welcome') { this.close(); await this.revoke(); return { ok: false, why: welcome.message ?? `no seat (${welcome.code ?? welcome.t})`, error: welcome.code ?? welcome.t }; }
|
|
110
|
-
this.ping = setInterval(() => {
|
|
112
|
+
this.ping = setInterval(() => {
|
|
113
|
+
try { ws.send(this.resting ? 'homie-ping' : JSON.stringify({ t: 'ping', c: Date.now() })); } catch { /* closed */ }
|
|
114
|
+
}, 2000);
|
|
111
115
|
// The host shows a guide the game every 2 s: wait for the first look (up to 6 s).
|
|
112
116
|
for (let i = 0; i < 30 && !this.view && !this.closed; i += 1) await wait(200);
|
|
113
117
|
if (this.brain === 'local') this.autopilot();
|
|
@@ -122,6 +126,8 @@ export class AgentSeat {
|
|
|
122
126
|
autopilot() {
|
|
123
127
|
const tick = async () => {
|
|
124
128
|
if (this.closed) { this.stopAuto(); return; }
|
|
129
|
+
// A resting room paused its AI with the people. The next decision waits until somebody plays.
|
|
130
|
+
if (this.resting) return;
|
|
125
131
|
const now = Date.now();
|
|
126
132
|
const a = this.auto;
|
|
127
133
|
const asks = this.asks.filter((x) => now - x.at < 30_000);
|
|
@@ -162,7 +168,8 @@ export class AgentSeat {
|
|
|
162
168
|
|
|
163
169
|
onFrame(m) {
|
|
164
170
|
switch (m.t) {
|
|
165
|
-
case 'welcome': this.seat = m.seat; this.name = m.name; this.policy = m.policy ?? null; return;
|
|
171
|
+
case 'welcome': this.seat = m.seat; this.name = m.name; this.policy = m.policy ?? null; this.resting = m.rest === true; return;
|
|
172
|
+
case 'rest': this.resting = m.on === true; return;
|
|
166
173
|
case 'policy': this.policy = m.policy ?? this.policy; return;
|
|
167
174
|
case 'error': this.errors = [...this.errors, { code: m.code, message: m.message, at: Date.now() }].slice(-6); if (['agents-alone', 'agents-off', 'kicked', 'room-closed', 'agent-yield'].includes(m.code)) this.closed = m.code; return;
|
|
168
175
|
case 'ev': {
|
|
@@ -4176,6 +4176,48 @@
|
|
|
4176
4176
|
}
|
|
4177
4177
|
}
|
|
4178
4178
|
}
|
|
4179
|
+
},
|
|
4180
|
+
"0.58.1": {
|
|
4181
|
+
"files": {
|
|
4182
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
4183
|
+
"README.md": "684b3cca8001673c",
|
|
4184
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
4185
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
4186
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
4187
|
+
"videos/README.md": "16babc3437d5545b",
|
|
4188
|
+
"posts/README.md": "37841aae476bd540",
|
|
4189
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
4190
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
4191
|
+
},
|
|
4192
|
+
"sections": {
|
|
4193
|
+
"AGENTS.md": {
|
|
4194
|
+
"(intro)": "af7f2f732505d379",
|
|
4195
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
4196
|
+
"## Layout": "f3a280b8c96cd068",
|
|
4197
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
4198
|
+
"## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
|
|
4199
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
4200
|
+
"## Making games": "bb715272fd1a615b",
|
|
4201
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
4202
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
4203
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
4204
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
4205
|
+
"## Selling things (the shop)": "0df5a5fe1833c624",
|
|
4206
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
4207
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
4208
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
4209
|
+
"## Beta": "9d02638207a26e73"
|
|
4210
|
+
}
|
|
4211
|
+
},
|
|
4212
|
+
"named": {
|
|
4213
|
+
"Homie Arcade": {
|
|
4214
|
+
"sections": {
|
|
4215
|
+
"AGENTS.md": {
|
|
4216
|
+
"## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
|
|
4217
|
+
}
|
|
4218
|
+
}
|
|
4219
|
+
}
|
|
4220
|
+
}
|
|
4179
4221
|
}
|
|
4180
4222
|
}
|
|
4181
4223
|
}
|
package/netplay/NETPLAY.md
CHANGED
|
@@ -27,6 +27,13 @@ welcome/promotion, explicit `rules: true` on runtime output, `hosted` relay even
|
|
|
27
27
|
The hosting page's unmarked frames have ordinary player allowances. Old-style games
|
|
28
28
|
keep revision 10's character counts and rates.
|
|
29
29
|
|
|
30
|
+
**Resting rooms** need no new revision, since each side ignores what it does not know: a rules
|
|
31
|
+
browser's hello says `rest: 1`, and a room of such browsers rests while nobody plays, sending
|
|
32
|
+
`{ t: 'rest', on }` when it stops and starts again, and `rest: true` in the welcome of a page that
|
|
33
|
+
arrives meanwhile (section 29.3). A room with an older seated browser never rests. An older watching
|
|
34
|
+
page or agent socket in a resting room keeps pinging the room itself, so the object stays in memory
|
|
35
|
+
while it is there.
|
|
36
|
+
|
|
30
37
|
**What revision 10 added** (a game that knows none of it plays exactly as before, hosted by a player's
|
|
31
38
|
browser; nothing here changes a frame such a game sends or receives):
|
|
32
39
|
- **The server as host** (section 29): a game written as rules plus view (`src/rules.ts`, `src/move.ts`,
|
|
@@ -2063,6 +2070,9 @@ through a wall. New Coin Dash and Gem Rush use authoritative server movement ins
|
|
|
2063
2070
|
| The seat's hold runs out | The body is removed, or stays as a bot (`player: { leave: 'bot' }`) |
|
|
2064
2071
|
| The last person's socket closes | **The room pauses.** The tick timer is cleared and the clock stops. Watchers and AI seats never keep it ticking |
|
|
2065
2072
|
| A person returns within 60 s | The room resumes at the tick it paused on. No tick is skipped and none runs for the gap |
|
|
2073
|
+
| People are here and nobody plays | **The room rests** (the game's `room.rest`). Nobody has held, pressed or sent anything (a command, a pulse, chat) and no AI has sent a command for `room.restSeconds` (60 by default; 0 never rests), or every page has been in the background for ten seconds. With `rest: 'quiet'` (the default) the world must also have nothing of its own to do: no round clock running, no event, timer, spawn or ask waiting, and nothing moving (bots, physics, tick handlers). With `rest: 'idle'` it rests regardless, and round clocks, timers, bots and AI pause with it (an away-from-keyboard pause). The host sends `{ t: 'rest', on: true }`, saves the room and clears its tick timer; the relay sleeps with everybody still connected, so the object hibernates and is not charged for duration while it sleeps. A page that arrives meanwhile is told in its welcome (`rest: true`), a spectator included. Browsers stop their input clock, ping the edge (`homie-ping`) and send nothing until a control is held or pressed. Only browsers whose hello said `rest: 1` let a room rest. A lasting world never rests. A resting room still wakes about once a minute for its liveness sweep (one alarm request, and the CPU of that short wake). That wake does not rewrite the save or change the epoch. Current agents ping the edge while the room rests. A spectator or agent on an older build keeps pinging the room itself, so the object stays in memory while that socket is open |
|
|
2074
|
+
| Somebody plays again | An input that holds or presses something, a command, an AI's command, a page brought to the front or a new arrival wakes the room: `{ t: 'rest', on: false }`, a retimed `round`, and ticks from the tick it rested on, on a clock started now. The first entry is stamped on that tick, so it applies. A room rebuilt from its rest's save comes back under the same epoch, so the waking frame is not dropped; any other restore takes a new one. A socket kept through hibernation is the same person, still resting; a phone that reconnects with its token gets its own seat and wakes the room |
|
|
2075
|
+
| A browser hosts the rules (`room.host: 'browser'`) | It rests and wakes its room the same way, in the page: it sends the relay `{ t: 'rest', on }`, stops its snapshots and checkpoints, and hears which pages went to the background (`{ t: 'hidden', seat, on }`). The relay fans the rest out and sleeps. That silence is not a stall. A hidden hosting tab hands the rest to another browser with the rest's checkpoint; the new host stays asleep until somebody plays. A handoff with no rest checkpoint ends the rest, so every clock matches a host that will run. Waking writes a new checkpoint, so a later handoff does not restore the rest |
|
|
2066
2076
|
| Nobody returns within 60 s | **The room ends.** The relay forgets it and its stored seats are deleted (`office` and `recorded` are kept). The next visitor gets a fresh room, with a new epoch |
|
|
2067
2077
|
| A restart or a deploy | The whole room restores at its saved tick, under a new epoch. Seats keep their tokens. A deploy that changed the state shape ends the match instead (section 29.6) |
|
|
2068
2078
|
| The rules fail again and again | Every tick failing for two seconds of the clock (a handler that used its whole share, or a fault in the runtime itself), or ticks that ran slow for five: the room ends, its players get the error `room-over`, and the log names the game, its build, the kind and the handler (`why`: `budget`, `fault`, `overrun`). Both are seconds on the clock, not a count of ticks |
|
package/netplay/netplay.ts
CHANGED
|
@@ -590,7 +590,7 @@ export interface RulesHost {
|
|
|
590
590
|
readonly tick: number;
|
|
591
591
|
facts(): Record<string, unknown>;
|
|
592
592
|
}
|
|
593
|
-
export type RulesHostFactory = (o: { now(): number; send(m: Record<string, unknown>): void; restore: unknown; peers?: Peer[]; held?: Peer[]; policy?: Policy; onEnd(why: string): void }) => RulesHost;
|
|
593
|
+
export type RulesHostFactory = (o: { now(): number; send(m: Record<string, unknown>): void; restore: unknown; peers?: Peer[]; held?: Peer[]; policy?: Policy; onEnd(why: string): void; onRest?(on: boolean): void }) => RulesHost;
|
|
594
594
|
|
|
595
595
|
export interface RulesOutputSender {
|
|
596
596
|
push(frame: Record<string, unknown>): void;
|
|
@@ -938,6 +938,11 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
|
|
|
938
938
|
readonly chatShown: boolean;
|
|
939
939
|
/** Server time in ms (relay clock). Offline: Date.now(). */
|
|
940
940
|
now(): number;
|
|
941
|
+
/**
|
|
942
|
+
* A server-hosted room is resting: nobody here has played for a while (or every page is in the background), so its
|
|
943
|
+
* clock is stopped and it sends nothing. Send input only when it changes: the first change wakes the room.
|
|
944
|
+
*/
|
|
945
|
+
resting(): boolean;
|
|
941
946
|
/** Host: true when the next `snapshot()` would be sent. Build the state only then. */
|
|
942
947
|
snapshotDue(): boolean;
|
|
943
948
|
/** Host: broadcast fast state (live bodies, projectiles); throttled to snapshotHz (1 Hz while alone). */
|
|
@@ -1626,7 +1631,9 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1626
1631
|
rulesPace = null;
|
|
1627
1632
|
if (!opts.rulesLimits) throw new Error('Rules output limits are missing');
|
|
1628
1633
|
rulesSender = opts.rulesLimits.output({ rates: opts.rulesLimits.rates, send: raw, now: () => performance.now() });
|
|
1629
|
-
|
|
1634
|
+
// A browser host rests its room as the server does (rules/host.ts): its own page then pings the edge and holds
|
|
1635
|
+
// its clock like everybody else's, and its relay can sleep. Never offline: nobody pays for a page alone.
|
|
1636
|
+
const options = { now: () => performance.now(), restore, peers: rulesPeers(), held: offline ? [] : heldPeers, policy: offline ? DEFAULT_POLICY : policy, send: rulesOut, onEnd: (why: string) => endRules(why), ...(offline ? {} : { onRest: (on: boolean) => { if (on) sendCheckpoint(); resting = on; if (!on) sendCheckpoint(); } }) };
|
|
1630
1637
|
try { rulesHost = make(options); }
|
|
1631
1638
|
catch (error) {
|
|
1632
1639
|
if (!restore) throw error;
|
|
@@ -1714,6 +1721,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1714
1721
|
let heldPeers: Peer[] = [];
|
|
1715
1722
|
let roundInfo: RoundInfo | null = null;
|
|
1716
1723
|
let idlePing = false, lastClockPing = -Infinity;
|
|
1724
|
+
/** The server's room is resting (`rest` frames): pings go to the edge and silence is expected, as between rounds. */
|
|
1725
|
+
let resting = false;
|
|
1717
1726
|
let slots: Slot[] | null = null;
|
|
1718
1727
|
let lastCkpt: Checkpoint<C> | null = null;
|
|
1719
1728
|
|
|
@@ -2363,6 +2372,9 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2363
2372
|
switch (m['t']) {
|
|
2364
2373
|
case 'welcome': return onWelcome(m);
|
|
2365
2374
|
case 'snap': return onSnap(m, text.length);
|
|
2375
|
+
case 'rest': resting = rulesGame && m.on === true; return;
|
|
2376
|
+
// A browser host hears which pages went to the background, as the server's host does.
|
|
2377
|
+
case 'hidden': rulesFrame(m); return;
|
|
2366
2378
|
case 'free': if (![...peers.values()].some((p) => p.seat === m.seat)) rulesFrame(m); return;
|
|
2367
2379
|
case 'in': if (rulesFrame(m)) return; return onInput(m);
|
|
2368
2380
|
case 'ev': {
|
|
@@ -2585,7 +2597,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2585
2597
|
}
|
|
2586
2598
|
|
|
2587
2599
|
function onWelcome(m: Record<string, unknown>): void {
|
|
2588
|
-
|
|
2600
|
+
// A room that rests when this page arrives (a watcher, a page back after a rest) says so in its welcome.
|
|
2601
|
+
idlePing = m.idlePing === true; lastClockPing = -Infinity; resting = rulesGame && m.rest === true;
|
|
2589
2602
|
post?.({ what: 'build', ver: version });
|
|
2590
2603
|
clearTimeout(connectTimer);
|
|
2591
2604
|
const next = m['role'] as Role;
|
|
@@ -2890,7 +2903,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2890
2903
|
// Dropping a healthy socket there turned every long hitch into a reconnect.
|
|
2891
2904
|
const oldestPing = pingsOut.values().next().value;
|
|
2892
2905
|
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;
|
|
2906
|
+
const silence = idlePing && (resting || roundInfo?.phase !== 'live' || wall() - lastClockPing > staleMs) ? Math.max(staleMs, 60_000) : staleMs;
|
|
2894
2907
|
if (ws && lastMsgAt && wall() - lastMsgAt > silence) {
|
|
2895
2908
|
if (!pingHeld) { lost(ws, 'stale'); return; }
|
|
2896
2909
|
// Two and a half seconds to be heard from (a pong answers the ping below): a socket that is really dead is
|
|
@@ -2899,7 +2912,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2899
2912
|
}
|
|
2900
2913
|
// An idle hibernatable room can answer this at the edge. Keep a regular
|
|
2901
2914
|
// clock/visibility sample on visibility changes, and every heartbeat during play.
|
|
2902
|
-
if (idlePing && roundInfo?.phase !== 'live' && !(roundInfo?.phase === 'over' && now() >= roundInfo.endsAt) && !force) {
|
|
2915
|
+
if (idlePing && (resting || roundInfo?.phase !== 'live' && !(roundInfo?.phase === 'over' && now() >= roundInfo.endsAt)) && !force) {
|
|
2903
2916
|
try { ws?.send('homie-ping'); } catch { if(ws)lost(ws,'send'); }
|
|
2904
2917
|
return;
|
|
2905
2918
|
}
|
|
@@ -2912,7 +2925,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2912
2925
|
}
|
|
2913
2926
|
|
|
2914
2927
|
function sendCheckpoint(): void {
|
|
2915
|
-
|
|
2928
|
+
// A resting room's world does not change: its last checkpoint is still the room.
|
|
2929
|
+
if (role !== 'host' || (!opts.checkpoint && !rulesHost) || offline || !connected || resting) return;
|
|
2916
2930
|
let d: C;
|
|
2917
2931
|
try { d = (rulesHost ? rulesHost.save() : opts.checkpoint!()) as C; } catch (err) { console.warn('[netplay] checkpoint()', err); return; }
|
|
2918
2932
|
const c: Checkpoint<C> = { k: rulesHost?.tick ?? tick, st: now(), d, c: ctlWire() };
|
|
@@ -3042,7 +3056,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
3042
3056
|
pingsOut.clear();
|
|
3043
3057
|
raw({
|
|
3044
3058
|
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,
|
|
3045
|
-
|
|
3059
|
+
// A browser that understands a resting room (the server's `rest` frames) says so; an older relay ignores it.
|
|
3060
|
+
...(rulesGame ? { coast: 1, rest: 1 } : {}),
|
|
3046
3061
|
...(watching ? { watch: true } : {}), ...(caps.size ? { caps: [...caps] } : {}), ...(asAgent ? { agent: asAgent } : {}),
|
|
3047
3062
|
// Revision 9 (an older relay ignores both): the game's revision and what this build can do.
|
|
3048
3063
|
...(version ? { ver: version } : {}), ...(features.length ? { feat: features } : {}),
|
|
@@ -3109,11 +3124,16 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
3109
3124
|
try {
|
|
3110
3125
|
g.document?.addEventListener('visibilitychange', () => {
|
|
3111
3126
|
if (disposed) return;
|
|
3112
|
-
|
|
3127
|
+
// A rest already stopped this clock. Hiding records the page; showing it is somebody looking, which wakes
|
|
3128
|
+
// the room. Pausing here would tell everyone the room is awake while the next host restores the rest and sleeps.
|
|
3129
|
+
if (rulesHost && resting && seat !== null) rulesHost.frame({ t: 'hidden', seat, on: hidden() });
|
|
3130
|
+
else if (hidden()) rulesHost?.pause();
|
|
3131
|
+
else rulesHost?.resume();
|
|
3113
3132
|
if (!hidden() && waitingVisible) { waitingVisible = false; attempt = 0; open(); return; }
|
|
3114
3133
|
if (!connected) return;
|
|
3115
3134
|
if (hidden()) {
|
|
3116
|
-
// A hidden phone tab loses its timers and rAF: hand the round on before it stalls.
|
|
3135
|
+
// A hidden phone tab loses its timers and rAF: hand the round on before it stalls. A rest goes with
|
|
3136
|
+
// that handoff; the next host restores the checkpoint and stays asleep until somebody plays.
|
|
3117
3137
|
if (role === 'host' && peers.size > 1) yieldHost();
|
|
3118
3138
|
ping(true);
|
|
3119
3139
|
} else ping(true);
|
|
@@ -3433,6 +3453,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
3433
3453
|
},
|
|
3434
3454
|
stateOf<T = unknown>(key: string) { return stateMap.get(key) as T | undefined; },
|
|
3435
3455
|
stateKeys() { return [...stateMap.keys()]; },
|
|
3456
|
+
resting: () => resting,
|
|
3436
3457
|
input(a, held) {
|
|
3437
3458
|
if (rulesGame) return;
|
|
3438
3459
|
pendingA = a;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.58.
|
|
3
|
+
"version": "0.58.1",
|
|
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/browser.ts
CHANGED
|
@@ -71,6 +71,8 @@ export function browserHost(def: RulesDef, game: BrowserGame): RulesHostFactory
|
|
|
71
71
|
stage: lab.stage ?? undefined, startPaused: false, restoreEpoch: (Math.floor(Math.random() * 4294967296) >>> 0) || 1,
|
|
72
72
|
clock: { now: o.now, setTimer: (fn, ms) => setTimeout(fn, ms), clearTimer: (h) => clearTimeout(h as ReturnType<typeof setTimeout>) },
|
|
73
73
|
onEnd: o.onEnd,
|
|
74
|
+
// Online, the page rests its room as the server does; offline nobody is charged for it, and it never rests.
|
|
75
|
+
...(o.onRest ? { onRest: o.onRest } : {}),
|
|
74
76
|
});
|
|
75
77
|
if (game.vocab) host.frame({ t: 'vocabulary', vocab: game.vocab });
|
|
76
78
|
const present = new Set<number>();
|
package/rules/core.ts
CHANGED
|
@@ -175,6 +175,8 @@ export interface Core {
|
|
|
175
175
|
round(): Extract<CoreOut, { t: 'round' }>;
|
|
176
176
|
/** End a settled wall-clock break without simulating its idle ticks. */
|
|
177
177
|
resumeRound(): boolean;
|
|
178
|
+
/** What the world has waiting on its own clock: events and timers on the queue, bodies yet to spawn, asks unanswered, and a running round's deadline. */
|
|
179
|
+
waiting(): number;
|
|
178
180
|
bodies(): Body[];
|
|
179
181
|
/** Every person's body with where it stands: who an effect with an audience reaches (0.46.3). */
|
|
180
182
|
listeners(): { seat: number; id: string; pos: Vec3 }[];
|
|
@@ -1539,6 +1541,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
1539
1541
|
}
|
|
1540
1542
|
return {
|
|
1541
1543
|
round: () => ({ t: 'round', ...round, ...(round.phase === 'over' && !overAt ? { results: results() } : {}) }),
|
|
1544
|
+
waiting: () => queue.length + spawns.length + pendingAsks.length + (round.phase === 'live' ? (round.endsAt > 0 ? 1 : 0) : (round.endsAt > tick ? 1 : 0)) + (restartAt > tick ? 1 : 0),
|
|
1542
1545
|
resumeRound() {
|
|
1543
1546
|
if (round.phase !== 'over' || overAt) return false;
|
|
1544
1547
|
// Preserve queued events and their simulation-time deadlines. Nothing
|