@homie-rocks/studio 0.57.0 → 0.58.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -4
- package/docs/toolkit-place-patterns.md +16 -0
- package/guides/game/RULES.md +15 -1
- package/lib/agent-seat.mjs +10 -3
- package/lib/template-history.json +84 -0
- package/netplay/NETPLAY.md +10 -0
- package/netplay/netplay.ts +30 -9
- package/package.json +1 -1
- package/parts/README.md +28 -0
- package/parts/catalogue.json +236 -0
- package/parts/owner-action-checklist/LICENSE +202 -0
- package/parts/owner-action-checklist/NOTICE +3 -0
- package/parts/owner-action-checklist/README.md +68 -0
- package/parts/owner-action-checklist/part.json +95 -0
- package/parts/owner-action-checklist/preview/index.html +16 -0
- package/parts/owner-action-checklist/src/handler.mjs +44 -0
- package/parts/owner-action-checklist/src/index.mjs +96 -0
- package/parts/owner-action-checklist/src/style.css +3 -0
- package/parts/owner-action-checklist/tuning.json +3 -0
- package/parts/terrain-route-map/LICENSE +202 -0
- package/parts/terrain-route-map/NOTICE +3 -0
- package/parts/terrain-route-map/README.md +67 -0
- package/parts/terrain-route-map/THIRD-PARTY-LICENSE +21 -0
- package/parts/terrain-route-map/part.json +133 -0
- package/parts/terrain-route-map/preview/demo.js +4127 -0
- package/parts/terrain-route-map/preview/heightmap.png +0 -0
- package/parts/terrain-route-map/preview/index.html +1 -0
- package/parts/terrain-route-map/preview/main.mjs +4 -0
- package/parts/terrain-route-map/preview/route.json +1 -0
- package/parts/terrain-route-map/src/data.mjs +81 -0
- package/parts/terrain-route-map/src/index.mjs +131 -0
- package/parts/terrain-route-map/src/style.css +3 -0
- package/parts/terrain-route-map/tuning.json +5 -0
- package/rules/browser.ts +2 -0
- package/rules/core.ts +3 -0
- package/rules/host.ts +170 -16
- package/rules/rules.ts +16 -3
- package/rules/view.ts +39 -7
- package/starters/coin-dash/game.json +2 -1
- package/starters/ember-vale/game.json +2 -1
- package/starters/gem-rush/game.json +2 -1
- package/starters/gem-rush-3d/game.json +2 -1
- package/starters/hero-rush-3d/game.json +2 -1
- package/tools/tools.ts +1 -1
- package/worker/agents.mjs +20 -4
- package/worker/booking-calendar.mjs +15 -2
- package/worker/build-id.mjs +1 -1
- package/worker/limits.mjs +3 -1
- package/worker/room-sessions.mjs +1 -1
- package/worker/room.mjs +64 -9
- package/worker/table.mjs +51 -18
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,15 +17,67 @@ 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.
|
|
21
41
|
|
|
22
42
|
### Fixed
|
|
23
43
|
|
|
24
|
-
-
|
|
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.
|
|
52
|
+
|
|
53
|
+
## [0.58.0] - 2026-10-16
|
|
54
|
+
|
|
55
|
+
**Plugin 0.58.0**
|
|
56
|
+
|
|
57
|
+
Two new shared parts — an owner action checklist and a 3D terrain route map — and booking calendars that can skip or change single dates in a weekly series.
|
|
58
|
+
|
|
59
|
+
### Added
|
|
60
|
+
|
|
61
|
+
- Two Apache-2.0 shared building blocks: an owner action checklist with visible consequences, one-tap confirmation, server-authorized undo and existing studio role/origin checks; and a three.js terrain route map with a local heightmap coastline, route tube, markers and phone/TV camera controls. Both include standard part manifests, catalogue entries, AI integration docs, self-contained previews and fast logic checks. No tile service is required.
|
|
62
|
+
- Weekly booking calendars can omit individual dates or override an occurrence's time, capacity and duration. Occurrence IDs stay stable when a start time changes; undefined overrides, off-series or invalid dates and ambiguous DST times are refused, and a date that already has reservations cannot be silently removed.
|
|
63
|
+
|
|
64
|
+
### Changed
|
|
65
|
+
|
|
66
|
+
- Shared terrain map: smooth high-resolution coast, lit terrain with cavity shading, animated depth-coloured water and foam, sky/haze, flowing elevated route, collision-aware leader labels, intro camera and measured-frame lite fallback. Owner checklist distinguishes pending, confirming, done and undone, announces actual consequences, labels irreversible actions and restores fresh pending actions after undo.
|
|
67
|
+
|
|
68
|
+
### Fixed
|
|
69
|
+
|
|
70
|
+
- Public checklist proposals require per-action staff authorization, validate tokens, retain retry keys and use separate expiring undo grants. Terrain heightmaps restrict origins and PNG types and enforce byte/dimension budgets before decoding.
|
|
71
|
+
- The MCP Events workerd conformance test parses SSE frames across HTTP chunks, including combined event/heartbeat frames and split UTF-8 (the intermittent 0.57.0 publish-gate failure); production delivery and webhook formats are unchanged. PR CI adds a path-filtered Events/webhook gate.
|
|
72
|
+
- CI Chrome launches keep each caller's protocol timeout.
|
|
73
|
+
|
|
74
|
+
### Upgrade notes
|
|
75
|
+
|
|
76
|
+
- No migration and nothing to change. Studios use the new parts by asking their AI to add them from the catalogue; existing weekly calendars keep working, and dated exceptions are optional (see "Weekly calendar exceptions" in `packages/studio/docs/toolkit-place-patterns.md`).
|
|
25
77
|
|
|
26
78
|
## [0.57.0] - 2026-10-16
|
|
27
79
|
|
|
28
|
-
**Plugin 0.57.0**
|
|
80
|
+
**Plugin 0.57.0** · [release-2026-10-16-studio-0.57.0](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-16-studio-0.57.0)
|
|
29
81
|
|
|
30
82
|
Studios can book at each other on behalf of a person, with proven studio identity and signed events, and friends joining a browser-hosted game at the same moment always get their seat.
|
|
31
83
|
|
|
@@ -52,7 +104,7 @@ Studios can book at each other on behalf of a person, with proven studio identit
|
|
|
52
104
|
|
|
53
105
|
## [0.56.0] - 2026-10-15
|
|
54
106
|
|
|
55
|
-
**Plugin 0.56.0**
|
|
107
|
+
**Plugin 0.56.0** · [release-2026-10-15-studio-0.56.0](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-15-studio-0.56.0)
|
|
56
108
|
|
|
57
109
|
Studios scale with hibernating rooms, add custom routes with built-in access checks, and deploy reliably with background queue handlers.
|
|
58
110
|
|
|
@@ -66,3 +66,19 @@ Tokens are bound to this origin, limited to the exact named tools, expire after
|
|
|
66
66
|
## Deployment
|
|
67
67
|
|
|
68
68
|
Cloudflare's official MCP request interface cannot authorize new static-asset uploads with an upload JWT; deploy with Wrangler, Cloudflare's own CLI using browser OAuth.
|
|
69
|
+
|
|
70
|
+
## Weekly calendar exceptions
|
|
71
|
+
|
|
72
|
+
`calendarSlots` (also exposed by `ctx.bookings.calendar`) accepts `exceptions`,
|
|
73
|
+
keyed by local `YYYY-MM-DD`. Use `null` to omit a holiday or an object containing
|
|
74
|
+
`time`, `capacity` and/or `durationMinutes` to override that occurrence. Dates
|
|
75
|
+
must belong to the declared range and weekdays. The occurrence ID remains based
|
|
76
|
+
on the original series time when an exception changes its start time. DST gaps
|
|
77
|
+
and ambiguous times refuse rather than silently changing the booking. Publishing
|
|
78
|
+
new calendar data does not itself move existing reservations or change payments.
|
|
79
|
+
|
|
80
|
+
A `durationMinutes` exception replaces the series `days` duration for that date.
|
|
81
|
+
Explicit `undefined` override values are invalid. Removing a published slot with
|
|
82
|
+
active reservations is refused by the database (`slot has active bookings`).
|
|
83
|
+
Release or cancel those reservations through the payment-aware booking flow before
|
|
84
|
+
deleting the slot; omitting a generated date alone does not delete a published row.
|
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': {
|
|
@@ -4134,6 +4134,90 @@
|
|
|
4134
4134
|
}
|
|
4135
4135
|
}
|
|
4136
4136
|
}
|
|
4137
|
+
},
|
|
4138
|
+
"0.58.0": {
|
|
4139
|
+
"files": {
|
|
4140
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
4141
|
+
"README.md": "684b3cca8001673c",
|
|
4142
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
4143
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
4144
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
4145
|
+
"videos/README.md": "16babc3437d5545b",
|
|
4146
|
+
"posts/README.md": "37841aae476bd540",
|
|
4147
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
4148
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
4149
|
+
},
|
|
4150
|
+
"sections": {
|
|
4151
|
+
"AGENTS.md": {
|
|
4152
|
+
"(intro)": "af7f2f732505d379",
|
|
4153
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
4154
|
+
"## Layout": "f3a280b8c96cd068",
|
|
4155
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
4156
|
+
"## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
|
|
4157
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
4158
|
+
"## Making games": "bb715272fd1a615b",
|
|
4159
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
4160
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
4161
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
4162
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
4163
|
+
"## Selling things (the shop)": "0df5a5fe1833c624",
|
|
4164
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
4165
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
4166
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
4167
|
+
"## Beta": "9d02638207a26e73"
|
|
4168
|
+
}
|
|
4169
|
+
},
|
|
4170
|
+
"named": {
|
|
4171
|
+
"Homie Arcade": {
|
|
4172
|
+
"sections": {
|
|
4173
|
+
"AGENTS.md": {
|
|
4174
|
+
"## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
|
|
4175
|
+
}
|
|
4176
|
+
}
|
|
4177
|
+
}
|
|
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
|
+
}
|
|
4137
4221
|
}
|
|
4138
4222
|
}
|
|
4139
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.
|
|
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/parts/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Toolkit parts catalogue
|
|
2
|
+
|
|
3
|
+
These are reusable files, not new npm packages or complete apps. The catalogue entries
|
|
4
|
+
are the standard `part.json` records, also collected in `catalogue.json` for local browsing.
|
|
5
|
+
|
|
6
|
+
| Part | What it supplies | Source | Licence |
|
|
7
|
+
| --- | --- | --- | --- |
|
|
8
|
+
| [Owner action checklist](owner-action-checklist/README.md) | Consequence-first owner/staff action UI, server integration boundary, optional undo | Owner action checklist / Homie toolkit | Apache-2.0 |
|
|
9
|
+
| [Terrain route map](terrain-route-map/README.md) | Local heightmap coast, draped route, markers and phone/TV camera controls | Terrain route map / Homie toolkit | Apache-2.0 (three.js MIT) |
|
|
10
|
+
|
|
11
|
+
Each folder includes its licence, attribution, AI integration contract, tunables and small
|
|
12
|
+
working preview. Ryan Campbell authored both. To preview locally, serve this directory with
|
|
13
|
+
a static HTTP server, then open `<id>/preview/index.html`.
|
|
14
|
+
|
|
15
|
+
These public source parts are explicitly shared in their manifests. This checkout is not
|
|
16
|
+
a deployed studio and does not invent a catalogue host or claim a live directory listing.
|
|
17
|
+
To distribute from a studio, retain the original provenance/credit, place these original
|
|
18
|
+
source folders under that studio's `parts/`, run the existing `parts share` checks/packing,
|
|
19
|
+
and deploy. The standard well-known catalogue then advertises exact immutable releases;
|
|
20
|
+
consuming studios use `parts_find` / `part_add`, never copy another studio's release by hand.
|
|
21
|
+
Follow [PARTS.md](PARTS.md) for hashes, licence checks, packing and delivery. Do not serve
|
|
22
|
+
unpacked source as an immutable release. Later changes to a shared release need a new part
|
|
23
|
+
version. These initial parts are 1.0.0; no toolkit version is changed.
|
|
24
|
+
|
|
25
|
+
Maintainers: `node scripts/build-shared-parts.mjs` builds the local map preview and rewrites
|
|
26
|
+
hashes with `writeHashes`. `node --test packages/studio/test/shared-parts.test.mjs` checks
|
|
27
|
+
logic, licences and real pack/catalogue/integrity paths. Run
|
|
28
|
+
`node scripts/check-shared-parts.mjs` for one short browser check and 4× CPU measurements.
|