@homie-rocks/studio 0.58.0 → 0.58.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -1
- package/apps/APPS.md +1 -1
- package/bin/homie-studio.mjs +14 -4
- package/guides/app/SKILL.md +1 -1
- package/guides/game/RULES.md +15 -1
- package/lib/agent-seat.mjs +10 -3
- package/lib/template-history.json +84 -0
- package/netplay/NETPLAY.md +10 -0
- package/netplay/netplay.ts +30 -9
- package/package.json +1 -1
- package/rules/browser.ts +2 -0
- package/rules/core.ts +3 -0
- package/rules/host.ts +170 -16
- package/rules/rules.ts +16 -3
- package/rules/view.ts +39 -7
- package/starters/coin-dash/game.json +2 -1
- package/starters/ember-vale/game.json +2 -1
- package/starters/gem-rush/game.json +2 -1
- package/starters/gem-rush-3d/game.json +2 -1
- package/starters/hero-rush-3d/game.json +2 -1
- package/worker/agents.mjs +20 -4
- package/worker/app-records.mjs +58 -6
- package/worker/build-id.mjs +1 -1
- package/worker/limits.mjs +3 -1
- package/worker/room-sessions.mjs +1 -1
- package/worker/room.mjs +64 -9
- package/worker/route-auth.mjs +1 -1
- package/worker/table.mjs +51 -18
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,7 +17,53 @@ 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.2] - 2026-10-17
|
|
21
|
+
|
|
22
|
+
**Plugin 0.58.2**
|
|
23
|
+
|
|
24
|
+
The studio owner opens a private app role. A staff link can expire, and it can be listed or revoked on its own.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- A signed-in studio owner opens a private app role with the owner session. Everyone else still needs that role's link.
|
|
29
|
+
- `homie-studio app role <id> --list` lists each private role's link and expiry. `--expires` takes a day count (`30d`) or a unix time in milliseconds. `--revoke-link` revokes the link and leaves a player's own grant as it was.
|
|
30
|
+
|
|
31
|
+
### Upgrade notes
|
|
32
|
+
|
|
33
|
+
- Upgrade the studio to pick this up. A link issued before this version has no expiry until one is set.
|
|
34
|
+
|
|
35
|
+
## [0.58.1] - 2026-10-17
|
|
36
|
+
|
|
37
|
+
**Plugin 0.58.1**
|
|
38
|
+
|
|
39
|
+
A room nobody is playing rests, and the next touch wakes it with that input still applied.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- Idle rooms sleep. A room whose people have all stopped playing now rests: nothing held, pressed or sent for a
|
|
44
|
+
minute (`room.restSeconds`), or every page in the background for ten seconds. Its clock stops with everybody still
|
|
45
|
+
connected and the room hibernates, so it is not charged for duration while it sleeps. It still wakes about once a
|
|
46
|
+
minute for a liveness alarm (one request, and the CPU of that short wake). A deliberate rest is restored as the
|
|
47
|
+
same world, under the same epoch, without rewriting the save. The first touch, command, AI command, returning page
|
|
48
|
+
or new arrival starts it again on the tick it stopped on, and a phone that reconnects keeps its seat. By default
|
|
49
|
+
(`room.rest: "quiet"`) a room rests only when its world has nothing of its own to do: no round clock, no event,
|
|
50
|
+
timer, spawn or ask waiting, nothing moving, and no AI command for that minute. `"idle"` rests anyway and pauses
|
|
51
|
+
round clocks, timers, bots and AI with it; Coin Dash, Ember Vale, Gem Rush, Gem Rush 3D and Hero Rush 3D use it.
|
|
52
|
+
`"never"` keeps a room going while anybody is there (a turn timer, a show). Browser-hosted rules games rest the
|
|
53
|
+
same way. A spectator or agent on an older build keeps pinging the room itself, so the object stays in memory
|
|
54
|
+
while that socket is open. Rebuild your games to get it: a room rests only when every seated browser is on this
|
|
55
|
+
version.
|
|
56
|
+
|
|
57
|
+
### Fixed
|
|
58
|
+
|
|
59
|
+
- A browser-hosted rest stays asleep when the hosting tab is hidden or sends no snapshots. The next browser takes
|
|
60
|
+
the same checkpoint and does not run until somebody plays. A backgrounded tab no longer ends the rest while people
|
|
61
|
+
are still in the room. Waking writes a new checkpoint, so a later handoff does not put the room back to sleep.
|
|
62
|
+
|
|
63
|
+
### Upgrade notes
|
|
64
|
+
|
|
65
|
+
- Rebuild your games. Set `room.rest` to `"never"` (or `restSeconds` to 0) for a turn timer or a show that must keep
|
|
66
|
+
going while people are present. A lasting world never rests.
|
|
21
67
|
|
|
22
68
|
## [0.58.0] - 2026-10-16
|
|
23
69
|
|
package/apps/APPS.md
CHANGED
|
@@ -26,7 +26,7 @@ Use existing netplay events for refresh notices and shared animation. A missed o
|
|
|
26
26
|
|
|
27
27
|
## Staff
|
|
28
28
|
|
|
29
|
-
`homie-studio app role welcome staff --url <origin>` returns an unpredictable role address for the owner. Add `--player <account-id>` to grant an existing non-guest account, or `--revoke` with that player to remove it. The command reuses the office's short-lived owner key. Accounts/passkeys and the office owner's existing session are reused. An anonymous browser with the link goes to `/account/` to sign in; a signed-in account without a grant receives 403. Every record call rechecks the grant, so revocation applies to open tablets too.
|
|
29
|
+
`homie-studio app role welcome staff --url <origin>` returns an unpredictable role address for the owner. Add `--player <account-id>` to grant an existing non-guest account, or `--revoke` with that player to remove it. `--list` lists each sign-in role's link and expiry. `--revoke-link` deletes that role's link, so the old address answers 404. `--expires <ms>` or `--expires 7d` sets an expiry within 366 days; omit it for a link that does not expire. The command reuses the office's short-lived owner key. A signed-in studio owner opens their own private roles without a link. Accounts/passkeys and the office owner's existing session are reused. An anonymous browser with the link goes to `/account/` to sign in; a signed-in account without a grant receives 403. An expired or revoked link answers 404. Every record call rechecks the grant, so revocation applies to open tablets too.
|
|
30
30
|
|
|
31
31
|
The private address hides the staff entrance; it is not a bearer permission. It is never in public metadata. Passkeys require HTTPS or localhost. `dev --lan` exposes the local site to the same Wi-Fi, but does not weaken staff authentication. On local HTTP, provision test sessions explicitly in tests or use a secure local origin for real sign-in.
|
|
32
32
|
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* extension, Claude Code or any MCP client builds in the same chat that shows
|
|
15
15
|
* the cards (lib/mcp.mjs); --studios: the folder the studios live in
|
|
16
16
|
* homie-studio app new <id> [--from welcome|gathering|giving|quiz|jobs] [--name "<App Name>"]
|
|
17
|
-
* homie-studio app role <id> <role> [--player <account>] [--revoke] [--url <site>]
|
|
17
|
+
* homie-studio app role <id> [--list] | <id> <role> [--player <account>] [--revoke] [--revoke-link] [--expires <ms|Nd>] [--url <site>]
|
|
18
18
|
* homie-studio game new <id> [--from gem-rush] [--name "<Game Name>"]
|
|
19
19
|
* homie-studio games
|
|
20
20
|
* homie-studio build [<id>] [--maps] [--types] [--long-check] [--quick] (--maps: also keep each bundle's source map and
|
|
@@ -382,7 +382,7 @@ import { standaloneDeployNotes } from '../lib/standalone.mjs';
|
|
|
382
382
|
const argv = process.argv.slice(2);
|
|
383
383
|
const flags = new Map();
|
|
384
384
|
// build --quick keeps compilation and type checks, skips rules generated play/smoke checks.
|
|
385
|
-
const BOOL_FLAGS = ['webgl-failure', 'quick', 'public', 'lan', 'off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'no-rig', 'supporter', 'giving', 'managed', 'live', 'manual', 'renew', 'send', 'accept-tos', 'quiet', 'no-local-ai', 'timestamps', 'overwrite', 'own-route', 'before', 'release', 'device'];
|
|
385
|
+
const BOOL_FLAGS = ['webgl-failure', 'quick', 'public', 'lan', 'off', 'revoke', 'revoke-link', 'list', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'no-rig', 'supporter', 'giving', 'managed', 'live', 'manual', 'renew', 'send', 'accept-tos', 'quiet', 'no-local-ai', 'timestamps', 'overwrite', 'own-route', 'before', 'release', 'device'];
|
|
386
386
|
const positional = [];
|
|
387
387
|
for (let i = 0; i < argv.length; i++) {
|
|
388
388
|
const a = argv[i];
|
|
@@ -950,8 +950,18 @@ async function main() {
|
|
|
950
950
|
if (cmd === 'app' && sub === 'role') {
|
|
951
951
|
const { withKey } = await import('../lib/office.mjs');
|
|
952
952
|
const id = positional[2]; const role = positional[3];
|
|
953
|
-
|
|
954
|
-
|
|
953
|
+
const usage = 'app role <id> --list | app role <id> <role> [--player <account>] [--revoke] [--revoke-link] [--expires <ms|Nd>] [--url <site>]';
|
|
954
|
+
if (!/^[a-z0-9][a-z0-9-]{0,39}$/.test(id ?? '')) throw new Error(usage);
|
|
955
|
+
if (flags.has('list')) return withKey(root, flags.get('url'), (call) => call(`/${id}/api/app/roles`, { list: true }));
|
|
956
|
+
if (!/^[a-z][a-z0-9-]{0,39}$/.test(role ?? '')) throw new Error(usage);
|
|
957
|
+
let expires;
|
|
958
|
+
if (flags.has('expires')) {
|
|
959
|
+
const raw = String(flags.get('expires') ?? '');
|
|
960
|
+
const days = /^(\d{1,3})d$/.exec(raw);
|
|
961
|
+
expires = days ? Date.now() + Number(days[1]) * 86400000 : Number(raw);
|
|
962
|
+
if (!Number.isSafeInteger(expires) || (days && (Number(days[1]) < 1 || Number(days[1]) > 366))) throw new Error(usage);
|
|
963
|
+
}
|
|
964
|
+
return withKey(root, flags.get('url'), (call) => call(`/${id}/api/app/roles`, { role, studio:flags.has('studio'), ...(flags.get('player') ? { player: flags.get('player') } : {}), revoke: flags.has('revoke'), ...(flags.has('revoke-link') ? { revokeLink: true } : {}), ...(expires !== undefined ? { expires } : {}) }));
|
|
955
965
|
}
|
|
956
966
|
if (cmd === 'app' && sub === 'new') return newApp(root, positional[2], { name: flags.get('name'), from: flags.get('from') ?? 'welcome' });
|
|
957
967
|
if (cmd === 'game' && sub === 'new') return newGame(root, positional[2], { from: flags.get('from') ?? 'gem-rush', name: flags.get('name') });
|
package/guides/app/SKILL.md
CHANGED
|
@@ -162,7 +162,7 @@ Use `createAppRecords` from `@homie-rocks/studio/apps` for lasting records. Decl
|
|
|
162
162
|
|
|
163
163
|
Public collections reach everyone allowed to read them. A ticket number or filtering the HUD is not privacy. Keep personal/private records in a different collection whose read permission excludes customers and walls. Do not put contact details in the starter's public queue.
|
|
164
164
|
|
|
165
|
-
`homie-studio app role <id> staff --player <account-id> --url <site>` grants an existing signed-in account and returns its private role address. Without `--player` it returns the owner's role address. The existing owner check protects issuance; the link alone grants nothing. `--revoke` removes a named account's grant. Staff use the studio's existing account/passkey sign-in. Keep these links private.
|
|
165
|
+
`homie-studio app role <id> staff --player <account-id> --url <site>` grants an existing signed-in account and returns its private role address. Without `--player` it returns the owner's role address. The existing owner check protects issuance; the link alone grants nothing. `--revoke` removes a named account's grant. `--list` lists links, `--revoke-link` deletes one, and `--expires 7d` (or a unix millisecond time) sets an expiry within 366 days. A signed-in studio owner opens their own private roles without a link. Staff use the studio's existing account/passkey sign-in. Keep these links private.
|
|
166
166
|
|
|
167
167
|
The complete helper and limits are in `node_modules/@homie-rocks/studio/apps/APPS.md`. Import `appLink`, `qrSvg`, and `randomId` from `@homie-rocks/studio/links` for a ticket URL with parameters. Build customer links from a clean public address, never by copying a staff URL. `randomId` works on plain HTTP.
|
|
168
168
|
|
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,90 @@
|
|
|
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
|
+
}
|
|
4221
|
+
},
|
|
4222
|
+
"0.58.2": {
|
|
4223
|
+
"files": {
|
|
4224
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
4225
|
+
"README.md": "684b3cca8001673c",
|
|
4226
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
4227
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
4228
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
4229
|
+
"videos/README.md": "16babc3437d5545b",
|
|
4230
|
+
"posts/README.md": "37841aae476bd540",
|
|
4231
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
4232
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
4233
|
+
},
|
|
4234
|
+
"sections": {
|
|
4235
|
+
"AGENTS.md": {
|
|
4236
|
+
"(intro)": "af7f2f732505d379",
|
|
4237
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
4238
|
+
"## Layout": "f3a280b8c96cd068",
|
|
4239
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
4240
|
+
"## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
|
|
4241
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
4242
|
+
"## Making games": "bb715272fd1a615b",
|
|
4243
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
4244
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
4245
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
4246
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
4247
|
+
"## Selling things (the shop)": "0df5a5fe1833c624",
|
|
4248
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
4249
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
4250
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
4251
|
+
"## Beta": "9d02638207a26e73"
|
|
4252
|
+
}
|
|
4253
|
+
},
|
|
4254
|
+
"named": {
|
|
4255
|
+
"Homie Arcade": {
|
|
4256
|
+
"sections": {
|
|
4257
|
+
"AGENTS.md": {
|
|
4258
|
+
"## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
|
|
4259
|
+
}
|
|
4260
|
+
}
|
|
4261
|
+
}
|
|
4262
|
+
}
|
|
4179
4263
|
}
|
|
4180
4264
|
}
|
|
4181
4265
|
}
|
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.2",
|
|
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
|