@homie-rocks/studio 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -2
- package/bin/homie-studio.mjs +49 -9
- package/lib/build.mjs +44 -7
- package/lib/cloudflare.mjs +28 -11
- package/lib/directory.mjs +2 -2
- package/lib/media.mjs +2 -2
- package/lib/scaffold.mjs +47 -3
- package/lib/stats.mjs +125 -0
- package/lib/studio.mjs +42 -0
- package/netplay/NETPLAY.md +55 -12
- package/netplay/netplay.ts +19 -2
- package/package.json +1 -1
- package/worker/index.mjs +145 -19
- package/worker/pages.mjs +8 -2
- package/worker/room.mjs +9 -2
- package/worker/seats.mjs +20 -0
- package/worker/stats-page.mjs +144 -0
- package/worker/stats.mjs +311 -0
package/lib/stats.mjs
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio stats` — the studio's own numbers, for its owner (worker/stats.mjs says what is counted).
|
|
3
|
+
*
|
|
4
|
+
* homie-studio stats [--range 1d|7d|30d|90d] [--game <id> | --song <slug> | --video <slug>]
|
|
5
|
+
* the numbers, read with a key that lives ten minutes and is dropped after
|
|
6
|
+
* homie-studio stats key [--hours 1] a read key for the Homie MCP tool `studio_stats` (up to 30 days)
|
|
7
|
+
* homie-studio stats link a one-time link that opens /_studio/stats in the owner's browser
|
|
8
|
+
* homie-studio stats revoke every key and page session ends now
|
|
9
|
+
* homie-studio stats share on|off tell the homie.rocks directory "played this week" (two numbers), or stop
|
|
10
|
+
*
|
|
11
|
+
* THE PROOF OF OWNERSHIP IS THE STUDIO'S OWN CLOUDFLARE LOGIN. A key is minted here, on this computer, and only
|
|
12
|
+
* its SHA-256 goes into the studio's D1 (through Wrangler, which only the studio's owner is signed in to). The key
|
|
13
|
+
* itself is never sent to Cloudflare, never written to a file, and lives for its hours only. A studio running
|
|
14
|
+
* locally (`npm run dev`, a loopback --url) uses the local D1.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
17
|
+
import { runner } from './cloudflare.mjs';
|
|
18
|
+
import { readStudio, siteUrl, writeStudio } from './studio.mjs';
|
|
19
|
+
|
|
20
|
+
const HOUR = 3600_000;
|
|
21
|
+
const RANGES = new Set(['1d', '7d', '30d', '90d']);
|
|
22
|
+
const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
23
|
+
|
|
24
|
+
const loopback = (url) => { try { return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(url).hostname); } catch { return false; } };
|
|
25
|
+
|
|
26
|
+
function siteOf(root, url) {
|
|
27
|
+
const studio = readStudio(root);
|
|
28
|
+
const site = String(url || siteUrl(root, studio) || '').replace(/\/+$/, '');
|
|
29
|
+
if (!/^https?:\/\//.test(site)) return { studio, site: null };
|
|
30
|
+
return { studio, site };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Put a key's hash into the studio's D1 (remote, or local for a loopback site). The key never leaves this machine. */
|
|
34
|
+
function mint(root, studio, kind, ttlMs, { local }) {
|
|
35
|
+
const key = `hsk_${randomBytes(24).toString('hex')}`;
|
|
36
|
+
const hash = createHash('sha256').update(key).digest('hex');
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
const w = runner(root, studio.cloudflare?.accountId && !local ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
|
|
39
|
+
const sql = `DELETE FROM stats_keys WHERE expires_at < ${now}; INSERT INTO stats_keys (hash, kind, expires_at) VALUES ('${hash}', '${kind}', ${now + ttlMs});`;
|
|
40
|
+
const res = w(['d1', 'execute', studio.cloudflare.d1, local ? '--local' : '--remote', '--command', sql]);
|
|
41
|
+
if (res.code !== 0) {
|
|
42
|
+
const why = /no such table: stats_keys/i.test(res.out)
|
|
43
|
+
? 'this studio\'s D1 has no stats tables yet: run `npm run deploy` (it applies migration 0002_studio_stats.sql), or `npm run dev` for a local site'
|
|
44
|
+
: /not logged in|login|authenticat/i.test(res.out) ? 'Wrangler is not signed in to this studio\'s Cloudflare: run `npx wrangler login` (the person approves once)'
|
|
45
|
+
: `could not write the key's hash to D1: ${res.out.trim().split('\n').slice(-3).join(' ')}`;
|
|
46
|
+
return { ok: false, why };
|
|
47
|
+
}
|
|
48
|
+
return { ok: true, key, hash, expiresAt: new Date(now + ttlMs).toISOString() };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function drop(root, studio, hash, { local }) {
|
|
52
|
+
const w = runner(root, studio.cloudflare?.accountId && !local ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
|
|
53
|
+
w(['d1', 'execute', studio.cloudflare.d1, local ? '--local' : '--remote', '--command', `DELETE FROM stats_keys WHERE hash = '${hash}';`]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function statsQuery({ range, game, song, video } = {}) {
|
|
57
|
+
const q = new URLSearchParams();
|
|
58
|
+
q.set('range', RANGES.has(range) ? range : '7d');
|
|
59
|
+
if (game && SLUG.test(game)) q.set('game', game);
|
|
60
|
+
else if (song && SLUG.test(song)) q.set('song', song);
|
|
61
|
+
else if (video && SLUG.test(video)) q.set('video', video);
|
|
62
|
+
return q;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The numbers, read with a ten-minute key that is dropped as soon as they are in. */
|
|
66
|
+
export async function statsShow(root, { url, range, game, song, video } = {}) {
|
|
67
|
+
const { studio, site } = siteOf(root, url);
|
|
68
|
+
if (!site) return { ok: false, command: 'stats', why: 'this studio has no live site yet: `npm run deploy` first, or give --url http://127.0.0.1:8787 for `npm run dev`' };
|
|
69
|
+
const local = loopback(site);
|
|
70
|
+
const k = mint(root, studio, 'read', 10 * 60_000, { local });
|
|
71
|
+
if (!k.ok) return { ok: false, command: 'stats', why: k.why };
|
|
72
|
+
try {
|
|
73
|
+
const res = await fetch(`${site}/api/stats?${statsQuery({ range, game, song, video })}`, { headers: { authorization: `Bearer ${k.key}` }, signal: AbortSignal.timeout(20_000) });
|
|
74
|
+
const body = await res.json().catch(() => ({}));
|
|
75
|
+
if (!res.ok || !body.ok) return { ok: false, command: 'stats', why: body.message ?? `${site}/api/stats answered ${res.status}` };
|
|
76
|
+
return { command: 'stats', ...body, ok: true };
|
|
77
|
+
} catch (error) {
|
|
78
|
+
return { ok: false, command: 'stats', why: `could not read ${site}/api/stats: ${error.message}` };
|
|
79
|
+
} finally { drop(root, studio, k.hash, { local }); }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A read key for `studio_stats` (the Homie MCP): 1 hour by default, 30 days at most. */
|
|
83
|
+
export function statsKey(root, { url, hours } = {}) {
|
|
84
|
+
const { studio, site } = siteOf(root, url);
|
|
85
|
+
if (!site) return { ok: false, command: 'stats key', why: 'this studio has no live site yet: `npm run deploy` first' };
|
|
86
|
+
const h = Math.max(1, Math.min(24 * 30, Math.floor(Number(hours) || 1)));
|
|
87
|
+
const k = mint(root, studio, 'read', h * HOUR, { local: loopback(site) });
|
|
88
|
+
if (!k.ok) return { ok: false, command: 'stats key', why: k.why };
|
|
89
|
+
return {
|
|
90
|
+
ok: true, command: 'stats key', site, key: k.key, expiresAt: k.expiresAt, hours: h,
|
|
91
|
+
use: `Call the Homie MCP tool studio_stats with { "site": "${site}", "key": "<this key>" } (and range, game, song or video). The key only reads this studio's numbers, ends at ${k.expiresAt}, and \`homie-studio stats revoke\` ends it sooner. Never paste it anywhere else.`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A one-time sign-in link (30 minutes) that opens the private stats page in the owner's own browser. */
|
|
96
|
+
export function statsLink(root, { url } = {}) {
|
|
97
|
+
const { studio, site } = siteOf(root, url);
|
|
98
|
+
if (!site) return { ok: false, command: 'stats link', why: 'this studio has no live site yet: `npm run deploy` first' };
|
|
99
|
+
const k = mint(root, studio, 'signin', 30 * 60_000, { local: loopback(site) });
|
|
100
|
+
if (!k.ok) return { ok: false, command: 'stats link', why: k.why };
|
|
101
|
+
return {
|
|
102
|
+
ok: true, command: 'stats link', site, link: `${site}/_studio/signin?k=${k.key}`, expiresAt: k.expiresAt,
|
|
103
|
+
use: 'Give this link to the studio\'s owner to open in their own browser: it works once, within 30 minutes, and keeps that browser signed in to the stats page for 30 days. It is theirs alone; do not post it anywhere.',
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function statsRevoke(root, { url } = {}) {
|
|
108
|
+
const { studio, site } = siteOf(root, url);
|
|
109
|
+
const local = site ? loopback(site) : false;
|
|
110
|
+
const w = runner(root, studio.cloudflare?.accountId && !local ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
|
|
111
|
+
const res = w(['d1', 'execute', studio.cloudflare.d1, local ? '--local' : '--remote', '--command', 'DELETE FROM stats_keys;']);
|
|
112
|
+
return res.code === 0 ? { ok: true, command: 'stats revoke', site, message: 'Every stats key and page session has ended.' } : { ok: false, command: 'stats revoke', why: res.out.trim().split('\n').slice(-3).join(' ') };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function statsShare(root, on) {
|
|
116
|
+
const studio = readStudio(root);
|
|
117
|
+
const share = on === 'on' || on === true || on === 'true';
|
|
118
|
+
writeStudio(root, { ...studio, stats: { ...(studio.stats ?? {}), share } });
|
|
119
|
+
return {
|
|
120
|
+
ok: true, command: 'stats share', share,
|
|
121
|
+
message: share
|
|
122
|
+
? 'studio.json stats.share is on. After `npm run deploy`, the site tells the homie.rocks directory two numbers for the whole studio: Play presses and rounds with people in them, over the last 7 days. Nothing else leaves the studio.'
|
|
123
|
+
: 'studio.json stats.share is off. After `npm run deploy`, the site tells the directory nothing.',
|
|
124
|
+
};
|
|
125
|
+
}
|
package/lib/studio.mjs
CHANGED
|
@@ -29,6 +29,48 @@ export function requireStudio(from) {
|
|
|
29
29
|
export function readStudio(root) { return JSON.parse(readFileSync(join(root, 'studio.json'), 'utf8')); }
|
|
30
30
|
export function writeStudio(root, studio) { writeFileSync(join(root, 'studio.json'), `${JSON.stringify(studio, null, 2)}\n`); }
|
|
31
31
|
|
|
32
|
+
/*
|
|
33
|
+
* WHERE THE SITE'S ADDRESS LIVES. studio.json is committed, and a workers.dev address carries the Cloudflare
|
|
34
|
+
* account's subdomain, which is often the person's own name. So studio.json keeps only a CUSTOM domain
|
|
35
|
+
* (`cloudflare.domain`, e.g. "night-owls.example"); the workers.dev address `deploy` gets back stays on this
|
|
36
|
+
* computer in .studio/local.json, which the studio's .gitignore leaves out.
|
|
37
|
+
*/
|
|
38
|
+
export const LOCAL_STATE = '.studio/local.json';
|
|
39
|
+
export const isWorkersDev = (url) => { try { return /\.workers\.dev$/i.test(new URL(url).hostname); } catch { return false; } };
|
|
40
|
+
|
|
41
|
+
/** `cloudflare.domain` as an origin: "example.com" or "https://example.com/" become "https://example.com"; else null. */
|
|
42
|
+
export function domainOrigin(value) {
|
|
43
|
+
const raw = String(value ?? '').trim();
|
|
44
|
+
if (!raw) return null;
|
|
45
|
+
try {
|
|
46
|
+
const u = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`);
|
|
47
|
+
if (u.protocol !== 'https:' || !u.hostname.includes('.') || isWorkersDev(u.origin)) return null;
|
|
48
|
+
return u.origin;
|
|
49
|
+
} catch { return null; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function readLocal(root) {
|
|
53
|
+
try { return JSON.parse(readFileSync(join(root, LOCAL_STATE), 'utf8')); } catch { return {}; }
|
|
54
|
+
}
|
|
55
|
+
export function writeLocal(root, patch) {
|
|
56
|
+
const path = join(root, LOCAL_STATE);
|
|
57
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
58
|
+
const next = { ...readLocal(root), ...patch };
|
|
59
|
+
writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
|
|
60
|
+
return next;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The studio site's public address: its custom domain (studio.json `cloudflare.domain`, or a non-workers.dev
|
|
65
|
+
* `cloudflare.url` an older studio wrote by hand), else the workers.dev address this computer's deploy got back,
|
|
66
|
+
* else an older studio.json's workers.dev `url` (until the next deploy moves it out).
|
|
67
|
+
*/
|
|
68
|
+
export function siteUrl(root, studio = readStudio(root)) {
|
|
69
|
+
const cf = studio.cloudflare ?? {};
|
|
70
|
+
const custom = domainOrigin(cf.domain) ?? (cf.url && !isWorkersDev(cf.url) ? domainOrigin(cf.url) : null);
|
|
71
|
+
return custom ?? readLocal(root).url ?? cf.url ?? null;
|
|
72
|
+
}
|
|
73
|
+
|
|
32
74
|
export function listGames(root) {
|
|
33
75
|
const dir = join(root, 'games');
|
|
34
76
|
if (!existsSync(dir)) return [];
|
package/netplay/NETPLAY.md
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
|
-
# Homie netplay contract, v1 (revision
|
|
2
|
-
|
|
3
|
-
Status: **v1, revision
|
|
4
|
-
2 added is either an optional field
|
|
5
|
-
|
|
1
|
+
# Homie netplay contract, v1 (revision 3)
|
|
2
|
+
|
|
3
|
+
Status: **v1, revision 3** (2026-09-30, `@homie-rocks/studio` 0.6.0). The wire version is
|
|
4
|
+
`v: 1`. Everything revision 2 and 3 added is either an optional field, a new message type,
|
|
5
|
+
or a change of pace inside the old caps, and both sides ignore types they do not know. A
|
|
6
|
+
change to the contract bumps `v` and keeps v1 working.
|
|
7
|
+
|
|
8
|
+
**What revision 3 added** (nothing on the wire changed; every v1 game plays on it):
|
|
9
|
+
- **Rooms of up to 32 seats**, sized by the game's netplay manifest (section 3).
|
|
10
|
+
- **One address may hold every seat plus four sockets** (section 6), so a party on one
|
|
11
|
+
Wi-Fi fills a 32-seat room with a TV beside it.
|
|
12
|
+
- **The checkpoint, round and roster caps double for 17–32 seats** (section 6): a
|
|
13
|
+
32-seat courier game's checkpoint measured 56 KB against the 64 KB cap.
|
|
14
|
+
- **Idle input keepalive** in the helper: an unchanged input frame goes out at most four
|
|
15
|
+
times a second (section 11).
|
|
16
|
+
- Measured cost and free-plan headroom of a 32-seat room (section 11).
|
|
6
17
|
|
|
7
18
|
**What revision 2 added:**
|
|
8
19
|
- Interpolation measures transit time.
|
|
@@ -95,8 +106,12 @@ code.
|
|
|
95
106
|
| `colour` | `seat % 12` | An index into a 12-colour palette. |
|
|
96
107
|
|
|
97
108
|
- **Every visitor gets a seat at once**, phone or desktop. There is no gathering.
|
|
98
|
-
- **Room size.** `maxPlayers` comes from the
|
|
99
|
-
|
|
109
|
+
- **Room size.** `maxPlayers` comes from the game's netplay manifest: game.json's
|
|
110
|
+
`netplay.maxPlayers`, or a `netplay.json` beside game.json or in the game's build
|
|
111
|
+
(`maxPlayers` or `players.max`), else game.json's `players.max`, else 8. **A room holds
|
|
112
|
+
at most 32** (`SEAT_MAX`). `homie-studio build` writes the number into the catalogue and
|
|
113
|
+
the site's Table takes it from there; the first visitor of an empty room may lower it
|
|
114
|
+
(`hello.max`), never raise it.
|
|
100
115
|
- **A dropped seat is held 60 s** for its token, which covers a reload or a network blip.
|
|
101
116
|
**A visitor who is present beats one who left:** when no seat is free, the relay
|
|
102
117
|
reclaims the seat that has been absent longest. Visitors who leave can never lock a
|
|
@@ -259,13 +274,15 @@ so `ev.origin` is `"null"`.
|
|
|
259
274
|
| `in` | < 256 B | 2 KB | 20 Hz, plus an immediate flush (≤ 60 Hz) on a press edge | 60 |
|
|
260
275
|
| `ev` | < 1 KB | 4 KB (512 B from a screen) | as needed | host 30, replica 10, screen 2 |
|
|
261
276
|
| `state` | < 2 KB per key | 8 KB per key; **64 keys and 64 KB per room** → `state-full` | only on change | 64 |
|
|
262
|
-
| `ckpt` | < 32 KB | 64 KB | 1 Hz | 4 |
|
|
263
|
-
| `round` / `roster` | < 2 KB / < 1 KB | 8 KB / 4 KB | on change | 4 / 8 |
|
|
277
|
+
| `ckpt` | < 32 KB | 64 KB (128 KB for 17–32 seats) | 1 Hz | 4 |
|
|
278
|
+
| `round` / `roster` | < 2 KB / < 1 KB | 8 KB / 4 KB (16 / 8 KB for 17–32 seats) | on change | 4 / 8 |
|
|
264
279
|
| `ping` | — | 256 B | every 2 s | 8 |
|
|
265
280
|
|
|
266
281
|
**Sockets.**
|
|
267
|
-
- At most `maxPlayers + 16` per room, and
|
|
268
|
-
|
|
282
|
+
- At most `maxPlayers + 16` per room, and per client address per room **12, or every seat
|
|
283
|
+
plus four when that is more** (`perAddress`: 12 for 8 seats, 20 for 16, 36 for 32). The
|
|
284
|
+
Worker passes `CF-Connecting-IP` as `conn.ip`. Measured before revision 3: 32 visitors on
|
|
285
|
+
one address got 12 seats and 20 `too-many` refusals.
|
|
269
286
|
- A client dropping more than 100 messages over the caps within 5 s is closed (`flood`).
|
|
270
287
|
|
|
271
288
|
**Keep it small.**
|
|
@@ -494,6 +511,31 @@ Measured, with Gem Rush's bump (owner movement, 30–50 ms one way):
|
|
|
494
511
|
writes/h ≈ $0.07/h, 9× everything else), or rewriting a whole log per event.
|
|
495
512
|
- Re-check Cloudflare's published prices before quoting.
|
|
496
513
|
|
|
514
|
+
**A 32-seat room (measured 2026-09-30, `@homie-rocks/studio` 0.6.0).** Every incoming
|
|
515
|
+
socket message is a Durable Object request (billed 20:1; outgoing messages are free), and
|
|
516
|
+
in a full room the replicas' inputs are nearly all of them:
|
|
517
|
+
|
|
518
|
+
| 32 seats, one host | Incoming msgs/s at the relay | Requests/hour (20:1) | Free plan (100,000 a day) |
|
|
519
|
+
|---|---|---|---|
|
|
520
|
+
| every replica moving, input 20 Hz | ≈ 630 | ≈ 114 k | ≈ 53 room-minutes a day |
|
|
521
|
+
| a game's own vendored helper, input 30 Hz | ≈ 860 | ≈ 155 k | ≈ 39 room-minutes a day |
|
|
522
|
+
| half the replicas standing still, 0.6.0 helper | ≈ 415 | ≈ 75 k | ≈ 80 room-minutes a day |
|
|
523
|
+
|
|
524
|
+
- Put another way, the free plan carries roughly **28 player-hours a day** at 20 Hz input
|
|
525
|
+
(each seated player costs about one request a second). A studio that plays more than
|
|
526
|
+
that a day needs Workers Paid ($5 a month), where a full 32-seat room-hour costs about
|
|
527
|
+
$0.02 in requests plus $0.006 in duration.
|
|
528
|
+
- **Idle keepalive (helper, 0.6.0):** an input frame identical to the last one sent (same
|
|
529
|
+
avatar or intent, same held keys, no press, same reset epoch) is re-sent at most every
|
|
530
|
+
250 ms. The host already holds that frame, so nothing it reads changes. A player standing
|
|
531
|
+
still costs 4 messages a second instead of 20. A game that vendors its own copy of the
|
|
532
|
+
helper gets this by updating that copy.
|
|
533
|
+
- Keep `inputHz` at 20 unless the game needs more: 30 Hz costs 1.5× the requests.
|
|
534
|
+
- Snapshots of a real 32-seat courier game (32 bodies plus the control table) were 1.1–1.7 KB at
|
|
535
|
+
20 Hz: about 32 KB/s down per replica, 2 KB/s up. No snapshot delta or compression was
|
|
536
|
+
needed; the 2 KB target and 16 KB cap hold at 32 seats. Duration (13,000 GB-s a day free)
|
|
537
|
+
covers one busy room all day.
|
|
538
|
+
|
|
497
539
|
## 12. Trust and safety
|
|
498
540
|
|
|
499
541
|
- **The host is a visitor's browser.** A modified host could fake scores. That is
|
|
@@ -569,7 +611,8 @@ npx --no-install homie-studio check my-game --url http://127.0.0.1:8787
|
|
|
569
611
|
candidate for v2, not in v1.
|
|
570
612
|
- **No lag compensation for hits.** A game can rewind a victim by the attacker's
|
|
571
613
|
`rtt/2 + delay` itself.
|
|
572
|
-
- **No snapshot deltas.** They are not needed at the measured sizes (Gem Rush: 364–427 B
|
|
614
|
+
- **No snapshot deltas.** They are not needed at the measured sizes (Gem Rush: 364–427 B;
|
|
615
|
+
a 32-body courier game's snapshot: 1.1–1.7 KB).
|
|
573
616
|
- **A reload shows a bot in the player's body for about 1–2 s**, and then the player
|
|
574
617
|
takes the same body back.
|
|
575
618
|
- **The host is a visitor's browser** (section 12).
|
package/netplay/netplay.ts
CHANGED
|
@@ -161,6 +161,8 @@ export interface NetStats {
|
|
|
161
161
|
snapHzOut: number;
|
|
162
162
|
inputHzIn: number;
|
|
163
163
|
inputHzOut: number;
|
|
164
|
+
/** Idle input frames not sent (identical to the last one, inside the keepalive interval). */
|
|
165
|
+
idleInputsSkipped?: number;
|
|
164
166
|
lastSnapBytes: number;
|
|
165
167
|
maxSnapBytes: number;
|
|
166
168
|
bytesInPerS: number;
|
|
@@ -507,6 +509,13 @@ const IN_SLOW_CAP_PER_S = 15;
|
|
|
507
509
|
const IN_SLOW_MS = 8000;
|
|
508
510
|
/** A socket with this much still unsent does not queue another input frame (the newest one waits instead). */
|
|
509
511
|
const IN_BACKLOG_BYTES = 2048;
|
|
512
|
+
/**
|
|
513
|
+
* An input frame identical to the last one sent (same avatar or intent, same held keys, no press, same reset epoch)
|
|
514
|
+
* goes out at most this often: a player standing still says so four times a second, not twenty. The host already
|
|
515
|
+
* holds that frame, so nothing it reads changes; every incoming socket message is a Durable Object request
|
|
516
|
+
* (billed 20:1), and in a 32-seat room the replicas' inputs are nearly all of them (NETPLAY.md §11).
|
|
517
|
+
*/
|
|
518
|
+
const IDLE_INPUT_MS = 250;
|
|
510
519
|
|
|
511
520
|
function heldList(held: Iterable<string> | Record<string, boolean> | undefined): string[] {
|
|
512
521
|
if (!held) return [];
|
|
@@ -635,9 +644,12 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
635
644
|
let prevHeld = new Set<string>();
|
|
636
645
|
let pendingPresses: Record<string, number> = {};
|
|
637
646
|
let inputTimer: ReturnType<typeof setTimeout> | null = null;
|
|
647
|
+
/** The last input frame sent, as text (idle frames repeat it at most every IDLE_INPUT_MS). */
|
|
648
|
+
let lastInputSig = '';
|
|
638
649
|
/** Wall times of the input frames sent in the last second, and until when the slow cap holds. */
|
|
639
650
|
const inSent: number[] = [];
|
|
640
651
|
let inSlowUntil = 0;
|
|
652
|
+
let idleSkipped = 0;
|
|
641
653
|
/** Why this client stopped for good (a FINAL_ERRORS refusal), or null while it plays or reconnects. */
|
|
642
654
|
let closedWhy: string | null = null;
|
|
643
655
|
/** Wall time the socket went down (0 while connected): how long a reconnect has taken. */
|
|
@@ -1071,12 +1083,17 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1071
1083
|
const budget = inputBudgetWait();
|
|
1072
1084
|
const backlog = (ws?.bufferedAmount ?? 0) > IN_BACKLOG_BYTES;
|
|
1073
1085
|
if (budget > 0 || backlog) { inputTimer = setTimeout(flushInput, backlog ? Math.max(budget, 30) : budget); return; }
|
|
1086
|
+
// Standing still: the host has this exact frame already. Say it again only as a keepalive.
|
|
1087
|
+
const pressed = Object.keys(pendingPresses).length > 0;
|
|
1088
|
+
let sig = '';
|
|
1089
|
+
try { sig = JSON.stringify([pendingA, pendingHeld, mine.rs]); } catch { sig = ''; }
|
|
1090
|
+
if (!pressed && sig && sig === lastInputSig && wall() - lastInputSentAt < IDLE_INPUT_MS) { idleSkipped += 1; return; }
|
|
1074
1091
|
inSent.push(wall());
|
|
1075
1092
|
inSeq += 1;
|
|
1076
1093
|
const msg: Record<string, unknown> = { t: 'in', q: inSeq, a: pendingA, h: pendingHeld, r: mine.rs };
|
|
1077
1094
|
if (Object.keys(pendingPresses).length) msg['p'] = pendingPresses;
|
|
1078
1095
|
if (raw(msg)) {
|
|
1079
|
-
inputOut.hit(wall()); pendingPresses = {};
|
|
1096
|
+
inputOut.hit(wall()); pendingPresses = {}; lastInputSig = sig;
|
|
1080
1097
|
sentHist.push({ q: inSeq, a: pendingA, h: pendingHeld, at: wall(), r: mine.rs });
|
|
1081
1098
|
if (sentHist.length > 64) sentHist.shift();
|
|
1082
1099
|
}
|
|
@@ -1095,7 +1112,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1095
1112
|
const t = wall();
|
|
1096
1113
|
return {
|
|
1097
1114
|
role, seat, host, connected, offline, rtt, offset: Math.round(offset),
|
|
1098
|
-
snapHzIn: snapIn.hz(t), snapHzOut: snapOut.hz(t), inputHzIn: inputIn.hz(t), inputHzOut: inputOut.hz(t),
|
|
1115
|
+
snapHzIn: snapIn.hz(t), snapHzOut: snapOut.hz(t), inputHzIn: inputIn.hz(t), inputHzOut: inputOut.hz(t), idleInputsSkipped: idleSkipped,
|
|
1099
1116
|
lastSnapBytes, maxSnapBytes, bytesInPerS: perSecond(bytesIn), bytesOutPerS: perSecond(bytesOut),
|
|
1100
1117
|
interpDelay: Math.round(interpDelay()), snapAgeP90: Math.round(ageP90), starvedPct: +starvedPct.toFixed(3), rejectedSnaps,
|
|
1101
1118
|
owned: api.owned, pending: sentHist.length, stateKeys: stateMap.size,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), song and video pages from the music/ and videos/ manifests, optional R2 storage for large media, deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|