@homie-rocks/studio 0.12.0 → 0.13.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 +86 -7
- package/bin/homie-studio.mjs +59 -3
- package/lib/build.mjs +2 -0
- package/lib/cloudflare.mjs +18 -5
- package/lib/directory.mjs +5 -4
- package/lib/doctor.mjs +11 -5
- package/lib/net.mjs +137 -0
- package/lib/office.mjs +172 -0
- package/lib/progress.mjs +39 -44
- package/lib/repo.mjs +51 -0
- package/lib/scaffold.mjs +42 -1
- package/lib/setup.mjs +15 -23
- package/lib/stats.mjs +1 -1
- package/lib/template-history.json +49 -0
- package/lib/upgrade.mjs +5 -2
- package/netplay/NETPLAY.md +101 -15
- package/netplay/netplay.ts +49 -3
- package/package.json +1 -1
- package/worker/index.mjs +197 -40
- package/worker/office-page.mjs +366 -0
- package/worker/office-schema.mjs +39 -0
- package/worker/office.mjs +762 -0
- package/worker/pages.mjs +257 -10
- package/worker/room.mjs +301 -8
- package/worker/stats-page.mjs +16 -7
- package/worker/stats.mjs +39 -12
- package/worker/version.mjs +1 -1
package/netplay/NETPLAY.md
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
|
-
# Homie netplay contract, v1 (revision
|
|
2
|
-
|
|
3
|
-
Status: **v1, revision
|
|
4
|
-
`v: 1`. Everything
|
|
5
|
-
or a change of pace inside the old caps, and both sides ignore types they do
|
|
6
|
-
change to the contract bumps `v` and keeps v1 working.
|
|
1
|
+
# Homie netplay contract, v1 (revision 4)
|
|
2
|
+
|
|
3
|
+
Status: **v1, revision 4** (2026-10-01, `@homie-rocks/studio` 0.13.0). The wire version is
|
|
4
|
+
`v: 1`. Everything revisions 2 to 4 added is either an optional field, a new message type,
|
|
5
|
+
a new refusal, or a change of pace inside the old caps, and both sides ignore types they do
|
|
6
|
+
not know. A change to the contract bumps `v` and keeps v1 working.
|
|
7
|
+
|
|
8
|
+
**What revision 4 added** (a v1 game that knows none of it keeps playing; its shell shows
|
|
9
|
+
the notices and the banner):
|
|
10
|
+
- **The owner's controls** (section 15): the studio's owner kicks a player out of a room
|
|
11
|
+
for some minutes, mutes one, announces a line to everyone, closes a room, or changes its
|
|
12
|
+
seats. Each is a control signed by the studio's Worker that the room verifies before it
|
|
13
|
+
acts; no client can send one.
|
|
14
|
+
- New relay → client frames `announce` and `mute`, a `welcome.announce`, `peer.muted`, and
|
|
15
|
+
two final refusals: `kicked` and `room-closed` (with `until`).
|
|
16
|
+
- The watch socket's `kicked`, `muted`, `announce` and `closed` frames, for the shell.
|
|
7
17
|
|
|
8
18
|
**What revision 3 added** (nothing on the wire changed; every v1 game plays on it):
|
|
9
19
|
- **Rooms of up to 32 seats**, sized by the game's netplay manifest (section 3).
|
|
@@ -217,7 +227,7 @@ browser (an `ev` to its seat); only that browser changes and saves the player's
|
|
|
217
227
|
| `hello` | everyone, once | `v: 1`, `token?`, `name?`, `device`, `want: 'play'\|'screen'`, `canHost`, `game?`, `max?` | Seats the client (or queues it), picks a role, replies `welcome`, and sends `join` to the others. Refuses with `version`, `room-full` or `too-many`. |
|
|
218
228
|
| `snap` | host | `k`, `st`, `d`, `c?` (control table) | Clamps `st` to [now − 2 s, now + 250 ms] (a snapshot stamped in the future would freeze replicas). Stores the snapshot and fans it out with `from`. Skips a socket with more than 256 KB buffered. |
|
|
219
229
|
| `in` | seated non-host | `q`, `a`, `h`, `p?`, `r` (the reset epoch the sender has adopted) | Forwards to the host only, stamped `from: <seat>`. Presses are clamped to integers 0–8, at most 16 keys. |
|
|
220
|
-
| `ev` | anyone | `k`, `d`, `to?` | **From the host:** to everyone else, to one seat (`to`: number) or to one peer (`to`: id string, such as a screen). **From anyone else:** to the host, with `from` and the sender's `id`. **Best-effort:** a drop over a cap is reported with `error rate`. |
|
|
230
|
+
| `ev` | anyone | `k`, `d`, `to?` | **From a muted player** (section 15): a speech kind (`say…`, `chat…`, `emote…`) is dropped. **From the host:** to everyone else, to one seat (`to`: number) or to one peer (`to`: id string, such as a screen). **From anyone else:** to the host, with `from` and the sender's `id`. **Best-effort:** a drop over a cap is reported with `error rate`. |
|
|
221
231
|
| `state` | host | `k` (≤ 64 characters), `d` (`null` deletes) | Stores the key in memory, forwards it to all others, and includes the whole map in every `welcome` and in `role` to a new host. |
|
|
222
232
|
| `ckpt` | host | `k`, `st`, `d`, `c?` | Stores it in memory. It is saved to storage at most every 30 s. Not forwarded. |
|
|
223
233
|
| `round` | host | `round` | Stores it, forwards it, and tells the shell's watchers. |
|
|
@@ -240,33 +250,45 @@ browser (an `ev` to its seat); only that browser changes and saves the player's
|
|
|
240
250
|
| `state` | everyone but the host | `k`, `d` |
|
|
241
251
|
| `round` / `roster` | everyone but the host | `round` / `slots` |
|
|
242
252
|
| `pong` | the pinger | `c`, `st` |
|
|
243
|
-
| `error` | the offender | `code`, `message`, `of?` |
|
|
253
|
+
| `error` | the offender | `code`, `message`, `of?`, `until?` |
|
|
254
|
+
| `announce` | everyone (and in `welcome.announce`) | `id`, `text` (`null`: taken down), `at`, `until`, `from: 'studio'` (section 15) |
|
|
255
|
+
| `mute` | everyone | `id`, `seat`, `until` (`0`: unmuted) (section 15) |
|
|
244
256
|
|
|
245
257
|
**Error codes.**
|
|
246
258
|
- Final (the helper stops reconnecting): `version`, `replaced`, `room-full`,
|
|
247
|
-
`too-many`, `
|
|
248
|
-
|
|
259
|
+
`too-many`, `kicked`, `room-closed` (the last two with `until`, server ms; section 15).
|
|
260
|
+
The helper before revision 4 does not know `kicked` and `room-closed` and keeps knocking;
|
|
261
|
+
every knock is refused at `hello`, and the shell stops the frame (section 15).
|
|
262
|
+
- Reported only: `too-large`, `rate`, `state-full`. (`flood` closes the socket; the helper
|
|
263
|
+
comes back slowly.)
|
|
249
264
|
|
|
250
265
|
A socket that is kicked for silence is closed with code 4000, and the helper reconnects.
|
|
251
266
|
|
|
252
267
|
Record types:
|
|
253
268
|
|
|
254
269
|
```ts
|
|
255
|
-
Peer = { id, seat: number|null, name, colour, device, want, role }
|
|
270
|
+
Peer = { id, seat: number|null, name, colour, device, want, role, muted?: true }
|
|
256
271
|
RoundInfo = { n, phase: 'live'|'over', startedAt, endsAt, results?: RoundResult[] } // server ms
|
|
257
272
|
RoundResult = { slot, seat: number|null, name, score, bot, place }
|
|
258
273
|
Slot = { slot, seat: number|null, name, bot }
|
|
259
274
|
ControlWire = [seat, rs, own (1|0), ack] // one per present seat, in snap.c and ckpt.c
|
|
260
275
|
```
|
|
261
276
|
|
|
262
|
-
**The shell's watch socket** (`/<game>/__watch?room=`) gets
|
|
263
|
-
`{ t:'net', room, host, clients, round, roster, snapHz, counts: {players, screens, waiting, humans, bots, maxPlayers}, memory: {snapBytes, ckptBytes, ckptAgeMs, stateKeys, stateBytes}, stats }`
|
|
264
|
-
about once a second and on every change
|
|
277
|
+
**The shell's watch socket** (`/<game>/__watch?room=&b=`) gets
|
|
278
|
+
`{ t:'net', room, host, clients, round, roster, snapHz, openedAt, announce, closedUntil?, counts: {players, screens, waiting, humans, bots, maxPlayers}, memory: {snapBytes, ckptBytes, ckptAgeMs, stateKeys, stateBytes}, stats }`
|
|
279
|
+
about once a second and on every change, and the owner's controls as they happen
|
|
280
|
+
(section 15): `announce` (the same frame the game gets), `closed { until, message }`, and,
|
|
281
|
+
only for the browser they are about (its room key `b`), `kicked { until, message }` and
|
|
282
|
+
`muted { until }`. A watch socket that opens on a closed room, or from a browser that is
|
|
283
|
+
held out of it, hears so at once.
|
|
265
284
|
|
|
266
285
|
**Game frame → parent page** (`postMessage { t: 'homie-net', what, ... }`). The values of
|
|
267
286
|
`what` are:
|
|
268
287
|
- `attached`, `token`, `role`, `stats` (2 Hz), `round`, `roster`;
|
|
269
|
-
- `closed` (with `why`: `replaced`, `version`, `room-full`, `too-many` or
|
|
288
|
+
- `closed` (with `why`: `replaced`, `version`, `room-full`, `too-many`, `kicked` or
|
|
289
|
+
`room-closed`, and `until` and `message` for the last two);
|
|
290
|
+
- `pick` (with `seat`): the game's `net.pickPlayer(seat)`, a player clicked. Only the studio
|
|
291
|
+
owner's page does anything with it (section 15).
|
|
270
292
|
|
|
271
293
|
The shell must check `ev.source === frame.contentWindow`. The frame is an opaque origin,
|
|
272
294
|
so `ev.origin` is `"null"`.
|
|
@@ -558,6 +580,9 @@ in a full room the replicas' inputs are nearly all of them:
|
|
|
558
580
|
`desk` cannot jump the queue.
|
|
559
581
|
- **Names and guest text are data.** Render them with `textContent`, never as markup,
|
|
560
582
|
and never read them as instructions.
|
|
583
|
+
- **Only the studio's owner moderates a room** (section 15): a kick, a mute, an
|
|
584
|
+
announcement or a closed room is a control the studio's Worker signs and the room
|
|
585
|
+
verifies; no visitor's socket can send one.
|
|
561
586
|
|
|
562
587
|
## 13. Using it
|
|
563
588
|
|
|
@@ -621,3 +646,64 @@ npx --no-install homie-studio check my-game --url http://127.0.0.1:8787
|
|
|
621
646
|
- **A reload shows a bot in the player's body for about 1–2 s**, and then the player
|
|
622
647
|
takes the same body back.
|
|
623
648
|
- **The host is a visitor's browser** (section 12).
|
|
649
|
+
|
|
650
|
+
## 15. The owner's controls (revision 4)
|
|
651
|
+
|
|
652
|
+
The studio's owner runs their live rooms from the studio's back office (`/_studio/office`,
|
|
653
|
+
the owner's overlay in their own game, `homie-studio office`, and the Homie MCP's owner
|
|
654
|
+
tools). The room's relay enforces five controls:
|
|
655
|
+
|
|
656
|
+
| `op` | Arguments | What the relay does |
|
|
657
|
+
|---|---|---|
|
|
658
|
+
| `kick` | `id` or `seat`, `minutes` (1–1440, default 10), `address?`, `message?` | Sends that player `error kicked { until, message }` and closes the socket (and the same browser's or account's other sockets in the room); everyone else gets `leave { why: 'kicked' }`, so the host turns the body back into a bot. The seat is freed at once. Until `until`, a `hello` from that seat token, that browser's room key, that player account, or (only with `address: true`) that network address is refused with `kicked`. |
|
|
659
|
+
| `mute` | `id` or `seat`, `minutes`, `off?` | Everyone gets `mute { id, seat, until }` (`until: 0` with `off`), and the peer carries `muted: true`. Until then the relay drops that player's `ev` whose `k` starts with `say`, `chat` or `emote` (the speech kinds). A muted host's own speech is not dropped, because the host also relays everyone's; a game hides it with `net.isMuted(seat)`. The mute follows the seat token through a reload. |
|
|
660
|
+
| `announce` | `text` (one line, at most 280 characters; empty takes it down), `seconds` (5–3600, default 30) | Everyone and every watching shell gets `announce { id, text, at, until, from: 'studio' }`; a joiner gets it in `welcome.announce` until it ends. |
|
|
661
|
+
| `close` | `minutes`, or `seconds` (10 s up to a day), `message?`, or `reopen: true` | Every socket gets `error room-closed { until, message }` and is closed; the room forgets its play as an empty room does (section 4); watchers get `closed`. Until `until` every `hello` is refused with `room-closed`, and the Lobby sends nobody there. `reopen` opens it at once. |
|
|
662
|
+
| `seats` | `max` | The room's seats while it runs (the owner's "players per room"). Players already seated above it keep their seat; nobody new is seated there. Never above the room's own cap (the game's manifest). |
|
|
663
|
+
| `regate` | `allow` (holder kinds: `o` the owner, `i` an invite, `p` a player account), `notice?`, `message?`; no `allow` calls a waiting one off | A game's launch state narrowed (private, or an invite-only beta). The current round finishes first: the `notice` goes up as an announcement, and 5 s after the host's `round` with `phase: 'over'` (at the latest 20 s after its `endsAt`, or 30 s when no round is running; never later than 15 minutes) every socket whose ticket names no allowed holder gets `error room-closed { message }` (no `until`: they come back through the game's door if they have access) and is closed; its seat is freed and the others play on. A room with nobody to send out does nothing. |
|
|
664
|
+
|
|
665
|
+
**Signed by the studio, checked by the room.** A control reaches the relay only from the
|
|
666
|
+
studio's own Worker, as
|
|
667
|
+
`{ t: 'ctl', v: 1, op, game, room, args, at, exp, n, sig }`, where `sig` is an HMAC-SHA256
|
|
668
|
+
with the studio's office secret (random, in the studio's own D1) over every other field
|
|
669
|
+
(keys sorted). The room's Table refuses a control that is for another game or room, is past
|
|
670
|
+
`exp` (a minute after `at`), repeats an `n` it has seen, or does not verify; then
|
|
671
|
+
`room.mjs`'s `control(op, args)` applies it. The Worker signs only for the owner: their
|
|
672
|
+
signed-in browser (an HttpOnly session no page or game can read), or an office key from the
|
|
673
|
+
studio's own Cloudflare login, with which the owner's AI may announce at once but only asks
|
|
674
|
+
for a kick, a mute, a close or a launch change, which the owner confirms with one tap. No
|
|
675
|
+
client socket can send a control: a `ctl` frame on a game's socket is ignored.
|
|
676
|
+
|
|
677
|
+
**Who a socket is.** The Worker passes each socket's verified ticket holders to the room (`o`, `i-<invite>`,
|
|
678
|
+
`p-<player>`, or an invite and an account together, `i-…~p-…`): what `regate` keeps, and the account a kick holds.
|
|
679
|
+
|
|
680
|
+
**Who a kick holds out.** A seat token resumes a seat; a browser's **room key** (`b`) is a
|
|
681
|
+
random value the play page keeps in the site's own storage and passes in the frame's and
|
|
682
|
+
the watch socket's addresses, so a new tab of the same browser is held too. It says nothing
|
|
683
|
+
about who the player is, and the relay forgets it with the hold. Player accounts, where a
|
|
684
|
+
studio has them, add the account. A network address is held only when the owner asks
|
|
685
|
+
(a household or a carrier can share one).
|
|
686
|
+
|
|
687
|
+
**Holds outlive the room.** Kicks, mutes, the announcement and a closed door are kept apart
|
|
688
|
+
from the room's play (the Table's `office` storage key): an empty room forgetting its play
|
|
689
|
+
(section 4), a deploy and an eviction keep them until they end.
|
|
690
|
+
|
|
691
|
+
**Speech, for game makers.** Send chat, quick lines and emotes as `ev` whose kind starts with `say`, `chat` or
|
|
692
|
+
`emote` (`chat`, `say:gg`, `emote:wave`): a muted player's are then dropped by the relay with nothing more to do. Any
|
|
693
|
+
other way a game lets players talk (a typed sign, a name tag) should check `net.isMuted(seat)`.
|
|
694
|
+
|
|
695
|
+
**The game's side** (the helper, 0.13.0): `on('announce', a)` and `net.announcement` (draw
|
|
696
|
+
it your own way, or leave it to the shell's banner); `on('mute', m)`, `peer.muted` and
|
|
697
|
+
`net.isMuted(seat)` (hide a muted player's chat); `kicked` and `room-closed` are final, and
|
|
698
|
+
`closedWhy` says which; `net.pickPlayer(seat)` when a player's body or name is clicked
|
|
699
|
+
(the owner's page opens that player's card with Mute and Kick).
|
|
700
|
+
|
|
701
|
+
**The shell's side.** On `kicked` or `closed` (from its watch socket, or the frame's
|
|
702
|
+
`closed`) the play page stops the game frame (so a helper from before revision 4 stops
|
|
703
|
+
knocking), says what happened and when the player can come back, and offers another room
|
|
704
|
+
(the Lobby's `/api/lobby?not=<room>` never answers that room). It shows `announce` as one
|
|
705
|
+
line across the top until it ends, and tells a muted player so.
|
|
706
|
+
|
|
707
|
+
**Launch states** are the site's, not the relay's: a game that is private or an invite-only
|
|
708
|
+
beta gives each browser it lets in a signed ticket (`t`) for its frame and sockets, and the
|
|
709
|
+
Worker refuses a socket without one before it reaches the room.
|
package/netplay/netplay.ts
CHANGED
|
@@ -65,6 +65,8 @@ export interface Peer {
|
|
|
65
65
|
device: Device;
|
|
66
66
|
want: Want;
|
|
67
67
|
role: Role;
|
|
68
|
+
/** Muted by the studio's owner (section 15): hide this player's chat and emotes. */
|
|
69
|
+
muted?: boolean;
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
/** One seat's entry in the body-control table: [seat, rs, own (1|0), ack]. */
|
|
@@ -93,6 +95,14 @@ export interface NetEvent<D = unknown> {
|
|
|
93
95
|
id?: string;
|
|
94
96
|
}
|
|
95
97
|
|
|
98
|
+
/**
|
|
99
|
+
* The studio's announcement (NETPLAY.md section 15): one line from the studio's owner to everyone in the room, until
|
|
100
|
+
* `until` (server ms). The shell shows it as a banner; a game may show it its own way. `text: null` takes it down.
|
|
101
|
+
*/
|
|
102
|
+
export interface Announcement { id: string; text: string | null; at?: number; until?: number; from?: string }
|
|
103
|
+
/** A player muted (or unmuted, `until: 0`) by the studio's owner: their chat and emotes (`ev` kinds say, chat, emote) reach nobody. */
|
|
104
|
+
export interface Mute { id: string; seat: number | null; until: number }
|
|
105
|
+
|
|
96
106
|
/** A seat's body control as the host keeps it. */
|
|
97
107
|
export interface Control {
|
|
98
108
|
seat: number;
|
|
@@ -246,6 +256,10 @@ export interface NetHandlers<S, A, C> {
|
|
|
246
256
|
round: (r: RoundInfo) => void;
|
|
247
257
|
roster: (slots: Slot[]) => void;
|
|
248
258
|
status: (connected: boolean) => void;
|
|
259
|
+
/** The studio's owner announced something to this room (or took it down: `text: null`). */
|
|
260
|
+
announce: (a: Announcement) => void;
|
|
261
|
+
/** The studio's owner muted or unmuted a player in this room. */
|
|
262
|
+
mute: (m: Mute) => void;
|
|
249
263
|
}
|
|
250
264
|
|
|
251
265
|
export interface Netplay<S = unknown, A = unknown, C = unknown> {
|
|
@@ -277,6 +291,15 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
|
|
|
277
291
|
readonly peers: ReadonlyMap<string, Peer>;
|
|
278
292
|
readonly roundInfo: RoundInfo | null;
|
|
279
293
|
readonly slots: Slot[] | null;
|
|
294
|
+
/** The studio's announcement now showing in this room, or null. */
|
|
295
|
+
readonly announcement: Announcement | null;
|
|
296
|
+
/** Whether the studio's owner has muted this seat (its chat and emotes reach nobody; a game hides them too). */
|
|
297
|
+
isMuted(seat: number | null): boolean;
|
|
298
|
+
/**
|
|
299
|
+
* A player was clicked (their body, their name): tell the shell. Only the studio's owner's page does anything with
|
|
300
|
+
* it (opens that player's card with Mute and Kick); for everyone else it is nothing.
|
|
301
|
+
*/
|
|
302
|
+
pickPlayer(seat: number | null): void;
|
|
280
303
|
/** Resolves with the first role (welcome, or offline fallback). */
|
|
281
304
|
readonly ready: Promise<RoleChange<S, C>>;
|
|
282
305
|
on<K extends keyof NetHandlers<S, A, C>>(kind: K, fn: NetHandlers<S, A, C>[K]): () => void;
|
|
@@ -495,7 +518,7 @@ const LADDER = [250, 500, 1000, 2000, 4000];
|
|
|
495
518
|
* second of frames at once), and a kick that ended the page's play for good left a phone frozen with no word.
|
|
496
519
|
* After one it comes back (its seat token keeps its body), sending at the slow rate below.
|
|
497
520
|
*/
|
|
498
|
-
const FINAL_ERRORS = new Set(['replaced', 'version', 'room-full', 'too-many']);
|
|
521
|
+
const FINAL_ERRORS = new Set(['replaced', 'version', 'room-full', 'too-many', 'kicked', 'room-closed']);
|
|
499
522
|
/**
|
|
500
523
|
* Input pacing (NETPLAY.md §5): at most this many `in` frames in any rolling second from this browser, whatever
|
|
501
524
|
* the frame rate or the press rate: two thirds of the relay's cap of 60, so frames that reach it bunched (a
|
|
@@ -565,7 +588,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
565
588
|
|
|
566
589
|
const handlers: { [K in keyof NetHandlers<S, A, C>]: Set<NetHandlers<S, A, C>[K]> } = {
|
|
567
590
|
role: new Set(), join: new Set(), leave: new Set(), input: new Set(), event: new Set(),
|
|
568
|
-
snapshot: new Set(), control: new Set(), state: new Set(), round: new Set(), roster: new Set(), status: new Set(),
|
|
591
|
+
snapshot: new Set(), control: new Set(), state: new Set(), round: new Set(), roster: new Set(), status: new Set(), announce: new Set(), mute: new Set(),
|
|
569
592
|
};
|
|
570
593
|
const emit = <K extends keyof NetHandlers<S, A, C>>(kind: K, arg: Parameters<NetHandlers<S, A, C>[K]>[0]): void => {
|
|
571
594
|
for (const fn of [...handlers[kind]]) {
|
|
@@ -652,6 +675,9 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
652
675
|
let idleSkipped = 0;
|
|
653
676
|
/** Why this client stopped for good (a FINAL_ERRORS refusal), or null while it plays or reconnects. */
|
|
654
677
|
let closedWhy: string | null = null;
|
|
678
|
+
/** The studio's announcement now showing, and the seats the studio muted (until when, server ms). */
|
|
679
|
+
let announcement: Announcement | null = null;
|
|
680
|
+
const muted = new Map<number, number>();
|
|
655
681
|
/** Wall time the socket went down (0 while connected): how long a reconnect has taken. */
|
|
656
682
|
let downSince = 0;
|
|
657
683
|
const sentHist: { q: number; a: A; h: string[]; at: number; r: number }[] = [];
|
|
@@ -784,6 +810,21 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
784
810
|
case 'ev': emit('event', { k: String(m['k'] ?? ''), d: m['d'], from: typeof m['from'] === 'number' ? m['from'] : null, ...(typeof m['id'] === 'string' ? { id: m['id'] } : {}) }); return;
|
|
785
811
|
case 'state': if (typeof m['k'] === 'string') applyState(m['k'], m['d']); return;
|
|
786
812
|
case 'pong': return onPong(m);
|
|
813
|
+
case 'announce': {
|
|
814
|
+
if (typeof m['id'] !== 'string') return;
|
|
815
|
+
const a: Announcement = { id: m['id'], text: typeof m['text'] === 'string' ? m['text'] : null, ...(typeof m['at'] === 'number' ? { at: m['at'] } : {}), ...(typeof m['until'] === 'number' ? { until: m['until'] } : {}), from: 'studio' };
|
|
816
|
+
announcement = a.text ? a : null;
|
|
817
|
+
emit('announce', a);
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
case 'mute': {
|
|
821
|
+
const mute: Mute = { id: String(m['id'] ?? ''), seat: typeof m['seat'] === 'number' ? m['seat'] : null, until: Number(m['until']) || 0 };
|
|
822
|
+
if (mute.seat !== null) { if (mute.until > now()) muted.set(mute.seat, mute.until); else muted.delete(mute.seat); }
|
|
823
|
+
const p = peers.get(mute.id);
|
|
824
|
+
if (p) peers.set(mute.id, { ...p, ...(mute.until > now() ? { muted: true } : { muted: undefined }) });
|
|
825
|
+
emit('mute', mute);
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
787
828
|
case 'join': {
|
|
788
829
|
const p = m['peer'] as Peer | undefined;
|
|
789
830
|
if (p && typeof p.id === 'string') { peers.set(p.id, p); if (role === 'host' && p.seat !== null) ctlOf(p.seat); emit('join', p); }
|
|
@@ -855,7 +896,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
855
896
|
if (FINAL_ERRORS.has(code)) {
|
|
856
897
|
closed = true;
|
|
857
898
|
closedWhy = code;
|
|
858
|
-
post?.({ what: 'closed', why: code });
|
|
899
|
+
post?.({ what: 'closed', why: code, ...(typeof m['until'] === 'number' ? { until: m['until'] } : {}), ...(typeof m['message'] === 'string' ? { message: String(m['message']).slice(0, 200) } : {}) });
|
|
859
900
|
}
|
|
860
901
|
return;
|
|
861
902
|
}
|
|
@@ -887,6 +928,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
887
928
|
if (typeof m['st'] === 'number' && rtt === null) offset = (m['st'] as number) - Date.now();
|
|
888
929
|
const ckpt = (m['ckpt'] as Checkpoint<C> | null) ?? null;
|
|
889
930
|
let snap = (m['snap'] as Snapshot<S> | null) ?? null;
|
|
931
|
+
const a = m['announce'] as Announcement | undefined;
|
|
932
|
+
if (a && typeof a === 'object' && typeof a.id === 'string' && typeof a.text === 'string' && (!announcement || announcement.id !== a.id)) { announcement = { ...a, from: 'studio' }; queueMicrotask(() => emit('announce', announcement as Announcement)); }
|
|
890
933
|
if (!continuing) {
|
|
891
934
|
roundInfo = (m['round'] as RoundInfo | null) ?? roundInfo;
|
|
892
935
|
slots = (m['roster'] as Slot[] | null) ?? slots;
|
|
@@ -1237,6 +1280,9 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1237
1280
|
get peers() { return peers; },
|
|
1238
1281
|
get roundInfo() { return roundInfo; },
|
|
1239
1282
|
get slots() { return slots; },
|
|
1283
|
+
get announcement() { return announcement && (announcement.until === undefined || announcement.until > now()) ? announcement : null; },
|
|
1284
|
+
isMuted(s: number | null): boolean { return s !== null && (muted.get(s) ?? 0) > now(); },
|
|
1285
|
+
pickPlayer(s: number | null): void { post?.({ what: 'pick', seat: typeof s === 'number' ? s : null }); },
|
|
1240
1286
|
ready,
|
|
1241
1287
|
on(kind, fn) {
|
|
1242
1288
|
(handlers[kind] as Set<unknown>).add(fn);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, optional R2 storage for large media, deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|