@homie-rocks/studio 0.21.0 → 0.23.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/CHANGELOG.md +68 -1
- package/README.md +92 -0
- package/assets/assets.ts +500 -0
- package/assets/render-page.ts +629 -0
- package/assets/safety.mjs +194 -0
- package/bin/homie-studio.mjs +81 -2
- package/chat/CHAT.md +101 -0
- package/chat/OWNERS.md +78 -0
- package/lib/art-cli.mjs +296 -0
- package/lib/art-tools.mjs +331 -0
- package/lib/asset-check.mjs +189 -0
- package/lib/asset-import.mjs +136 -0
- package/lib/asset-manifest.mjs +318 -0
- package/lib/build.mjs +32 -2
- package/lib/chat-cli.mjs +89 -0
- package/lib/cloudflare.mjs +5 -2
- package/lib/codex.mjs +43 -1
- package/lib/colour.mjs +160 -0
- package/lib/decisions.mjs +800 -0
- package/lib/directory.mjs +9 -1
- package/lib/library.mjs +169 -0
- package/lib/mcp-tools.mjs +22 -4
- package/lib/mcp.mjs +6 -3
- package/lib/optimise.mjs +380 -0
- package/lib/remix-assets.mjs +84 -0
- package/lib/render3d.mjs +81 -0
- package/lib/scaffold.mjs +39 -2
- package/lib/site.mjs +6 -1
- package/lib/studio.mjs +136 -9
- package/lib/style-board.mjs +227 -0
- package/lib/style-presets.mjs +121 -0
- package/lib/template-history.json +76 -0
- package/mcp/ui/card.css +15 -0
- package/mcp/ui/cast.js +62 -0
- package/mcp/ui/decision.js +61 -0
- package/mcp/ui/lineup.js +68 -0
- package/mcp/ui/rights.js +30 -0
- package/mcp/ui/style.js +154 -0
- package/netplay/NETPLAY.md +191 -3
- package/netplay/netplay.ts +150 -3
- package/package.json +15 -2
- package/port/index.ts +3 -2
- package/port/view.ts +238 -0
- package/site/SITE.md +11 -1
- package/starters/ember-vale/src/main.ts +22 -2
- package/starters/gem-rush/src/main.ts +21 -1
- package/starters/gem-rush-3d/assets/RIGHTS.md +203 -0
- package/starters/gem-rush-3d/assets/manifest.json +2318 -0
- package/starters/gem-rush-3d/credits.json +31 -0
- package/starters/gem-rush-3d/game.json +11 -0
- package/starters/gem-rush-3d/index.html +20 -0
- package/starters/gem-rush-3d/lab.json +19 -0
- package/starters/gem-rush-3d/src/main.ts +2107 -0
- package/starters/gem-rush-3d/style.json +66 -0
- package/starters/gem-rush-3d/tunables.json +13 -0
- package/worker/agents.mjs +3 -3
- package/worker/chat-page.mjs +556 -0
- package/worker/chat-store.mjs +161 -0
- package/worker/chat-words.mjs +46 -0
- package/worker/chat.mjs +419 -0
- package/worker/index.mjs +153 -8
- package/worker/office-page.mjs +115 -0
- package/worker/office.mjs +137 -6
- package/worker/pages.mjs +32 -9
- package/worker/qr.mjs +11 -0
- package/worker/room.mjs +288 -10
- package/worker/servers.mjs +5 -1
- package/worker/site.mjs +9 -4
- package/worker/stats.mjs +4 -1
- package/worker/version.mjs +1 -1
package/netplay/NETPLAY.md
CHANGED
|
@@ -1,10 +1,26 @@
|
|
|
1
|
-
# Homie netplay contract, v1 (revision
|
|
1
|
+
# Homie netplay contract, v1 (revision 8)
|
|
2
2
|
|
|
3
|
-
Status: **v1, revision
|
|
4
|
-
`v: 1`. Everything revisions 2 to
|
|
3
|
+
Status: **v1, revision 8** (2026-10-03, `@homie-rocks/studio` 0.23.0). The wire version is
|
|
4
|
+
`v: 1`. Everything revisions 2 to 8 added is either an optional field, a new message type,
|
|
5
5
|
a new refusal, or a change of pace inside the old caps, and both sides ignore types they do
|
|
6
6
|
not know. A change to the contract bumps `v` and keeps v1 working.
|
|
7
7
|
|
|
8
|
+
**What revision 8 added** (a game that knows none of it has room chat anyway: the play page's
|
|
9
|
+
panel, the float and the big screen's corner need no game code):
|
|
10
|
+
- **Room chat** (section 19): anyone in a room (a player, a watcher, the big screen's page, a
|
|
11
|
+
homie.rocks room page) says a line (`say`) or sends a reaction (`react`), on the game's socket
|
|
12
|
+
or the shell's watch socket. The relay checks the room's chat rules (`policy.chat`), runs the
|
|
13
|
+
floor on typed text, waits for the studio's own review (Cloudflare's Clef decision model on its
|
|
14
|
+
Workers AI) and fans the line out to every socket and every watching shell, in homie.rocks's
|
|
15
|
+
room-chat shapes (`line`, `react`). It keeps the last few minutes in memory and nothing else.
|
|
16
|
+
- New frames `say` and `react` (client → relay), `line`, `react`, `lines`, `unline` and `held`
|
|
17
|
+
(relay → client; `slow` as homie.rocks says it); `policy.chat`; the owner's `unsay` control, and
|
|
18
|
+
`line` and `purge` on `mute` and `kick`.
|
|
19
|
+
- The helper: `net.on('chat' | 'say' | 'unchat' | 'held')`, `net.say(text)`, `net.sayLine(id)`,
|
|
20
|
+
`net.react(kind)`, `net.chatRules`, `net.chatShown`; `NETPLAY_REVISION` 8 and the build mark
|
|
21
|
+
`homie-netplay-rev:8`. The port kit: `createBubbles` and `paintBubbles` (speech bubbles over
|
|
22
|
+
characters, beside `createLabels`).
|
|
23
|
+
|
|
8
24
|
**What revision 7 added** (a game that knows none of it plays on every server; its AI guide
|
|
9
25
|
seats are the game's own bots, silent):
|
|
10
26
|
- **Agent hands and brains** (section 18): an AI with no game client of its own (hands
|
|
@@ -97,6 +113,9 @@ the notices and the banner):
|
|
|
97
113
|
| `agents/agents.ts` | `useAgents`, the AI guides' host side and every browser's asks and lines (section 18), imported as `@homie-rocks/studio/agents`. |
|
|
98
114
|
| `worker/brain.mjs` | The vocabulary, prompts, `parseDecision`, the scripted floor and the providers (section 18). |
|
|
99
115
|
| `starters/ember-vale/` | Ember Vale: saves (a hero that lasts), and the AI guides' reference (`agents.json`, section 18). |
|
|
116
|
+
| `worker/chat.mjs` | Room chat's rules, the floor every typed message passes, and the studio's review on Clef (section 19). |
|
|
117
|
+
| `worker/chat-page.mjs` | The chat panel, the float and the big screen's corner: one component for the play, watch and TV pages. |
|
|
118
|
+
| `chat/CHAT.md`, `chat/OWNERS.md` | Room chat for game makers, and a plain note for studio owners on chat and children's data. |
|
|
100
119
|
|
|
101
120
|
---
|
|
102
121
|
|
|
@@ -1154,3 +1173,172 @@ A beginner server's chat is quick lines only; on a kids server names are handles
|
|
|
1154
1173
|
text ever reaches a brain. The owner's controls work on AI, and "AI talk off" (agents_brain
|
|
1155
1174
|
`script`) silences every AI at once. Quiet AI hides every AI line on a player's own screen.
|
|
1156
1175
|
|
|
1176
|
+
|
|
1177
|
+
---
|
|
1178
|
+
|
|
1179
|
+
## 19. Room chat (revision 8)
|
|
1180
|
+
|
|
1181
|
+
Every room has a chat: reactions that float up every screen in it, the game's quick lines,
|
|
1182
|
+
and typed messages where the room's rules allow them. It is the Homie app's live-room chat and
|
|
1183
|
+
homie.rocks's room chat, in a studio's own game: the same five reactions in the same order
|
|
1184
|
+
(fire 🔥, clap 👏, laugh 😂, heart ❤️, wow 🤯), the same frames (`line` and `react`), the same
|
|
1185
|
+
slow word ("One line at a time."), the same float on the big screen.
|
|
1186
|
+
|
|
1187
|
+
**Where it runs.** In the room's own Table on the studio's Worker. A line goes up on the game's
|
|
1188
|
+
socket (`net.say`) or on the shell's watch socket (the play page's panel, the watch page, the big
|
|
1189
|
+
screen's page, a homie.rocks room page); the relay checks it, the floor reads typed text, the
|
|
1190
|
+
studio's review reads what the floor let through, and the relay fans it out to every socket and
|
|
1191
|
+
every watching shell. homie.rocks runs nothing: its room page opens the room's own watch socket.
|
|
1192
|
+
|
|
1193
|
+
### The rules: `policy.chat`
|
|
1194
|
+
|
|
1195
|
+
| Field | Values (default) | |
|
|
1196
|
+
|---|---|---|
|
|
1197
|
+
| `mode` | `off`, `emoji`, `lines`, `text` (`text`) | What may be sent: each mode allows what the one before it does. |
|
|
1198
|
+
| `who` | `anyone`, `signed-in`, `members` (`signed-in`) | Who may type. Signed in: a player account with a passkey (saves/SAVES.md), never a guest. Members: players who belong to the room's server (on Quick play: any signed-in player). |
|
|
1199
|
+
| `react` | the same (`anyone`) | Who may send a reaction or a quick line. |
|
|
1200
|
+
| `slow` | 0–120 s (2) | Slow mode: between one person's lines (reactions have their own bucket). |
|
|
1201
|
+
| `max` | 20–280 characters (140) | A typed line. |
|
|
1202
|
+
| `links` | `block`, `allow` (`block`) | A link is never clickable either way. |
|
|
1203
|
+
| `swears` | `block`, `allow` (`block`) | Slurs, sexual words, threats and contact details are held whatever this says. |
|
|
1204
|
+
| `ai` | `true`, `false` (`true`) | The studio's review of typed text. |
|
|
1205
|
+
| `bubbles`, `watchers`, `hub` | `true` | A line may show over its sender's character; watchers may send (not only read); homie.rocks's page for the room may show it. |
|
|
1206
|
+
| `emoji` | the five, then up to 3 of the game's own | `{ k, e }`: a kind and its glyph. |
|
|
1207
|
+
| `lines` | 8 default lines, or up to 12 of the game's own | `{ id, text }`: the game's words; each passes the floor at build. |
|
|
1208
|
+
| `capped` | `kids`, `server-lines`, `server-off` | Why the mode is lower than the game asks. |
|
|
1209
|
+
|
|
1210
|
+
The rules come in layers: game.json `"chat"` (the game's defaults, or `false` for none), then
|
|
1211
|
+
the owner's for the game, then the owner's for a server (worker/chat-store.mjs, D1 `chat_rules`),
|
|
1212
|
+
then the server's own caps: **a kids server, or a server whose speech is quick lines (every
|
|
1213
|
+
beginner server), keeps chat to emoji and quick lines; speech `off` turns it off.** The Worker
|
|
1214
|
+
composes them for every socket and the room applies the newest; an owner's change reaches every
|
|
1215
|
+
live room at once (a signed `policy` control). The studio's own word list (`block`, `allow`)
|
|
1216
|
+
stays in the room; clients get the rest in `welcome.policy.chat` and every `policy` frame.
|
|
1217
|
+
|
|
1218
|
+
```json
|
|
1219
|
+
"chat": {
|
|
1220
|
+
"mode": "text", "who": "signed-in", "react": "anyone", "slow": 2, "max": 140,
|
|
1221
|
+
"emoji": { "gem": "💎" },
|
|
1222
|
+
"lines": { "gg": "Good game!", "gem": "Grab that gem!", "help": "Help me!" }
|
|
1223
|
+
}
|
|
1224
|
+
```
|
|
1225
|
+
|
|
1226
|
+
### Frames
|
|
1227
|
+
|
|
1228
|
+
| `t` | Direction | Fields | |
|
|
1229
|
+
|---|---|---|---|
|
|
1230
|
+
| `say` | up | `text` or `say` (a quick line's id), `n?`, `bubble?: false` | A typed line or a quick line. `n`: the sender's own id for it (1–16 of `A-Za-z0-9_-`), back on its own copy only. `bubble: false`: not over my character. |
|
|
1231
|
+
| `react` | up | `kind`, `n?`, `bubble?: false` | One of the room's reactions. |
|
|
1232
|
+
| `line` | down, everyone | `id`, `at`, `name`, `seat`, `colour`, `by`, `text`, `say?`, `bubble?`, `acct?`, `owner?`, `n?` | `by`: `player`, `watcher`, `hub` (a page of another site), `studio` (an announcement, which is a line too). |
|
|
1233
|
+
| `react` | down, everyone | `id`, `at`, `name`, `seat`, `colour`, `by`, `react`, `glyph`, `bubble?`, `n?` | |
|
|
1234
|
+
| `lines` | down, a watching shell as it opens | `lines` | The window: the last 50 lines of the last 15 minutes. Never to a page the room holds out. |
|
|
1235
|
+
| `unline` | down, everyone | `ids` | The owner took them down: hide them, and their bubbles. |
|
|
1236
|
+
| `held` / `slow` | down, the sender | `why`, `message`, `n?`, `until?` | Not sent. `why`: `off`, `emoji`, `lines`, `sign-in`, `sign-in-react`, `members`, `members-react`, `watchers`, `hub`, `muted`, `slow`, `repeat`, `words`, `harm`, `contact`, `link`, `swears`, `ai`, `busy`, `unknown`, `empty`, `ai_seat`. |
|
|
1237
|
+
|
|
1238
|
+
A line's sender is the socket's own client, or for a watch socket the client of the same browser
|
|
1239
|
+
(its room key) or account in the room: its seat, name and colour. A page with no client in the
|
|
1240
|
+
room (homie.rocks, a watch page before its game connects) is a watcher with a handle. **An AI never
|
|
1241
|
+
types in room chat** (section 18: it says only its game's lines, as `ev`). The relay's caps: a
|
|
1242
|
+
`say` frame at most 1.5 KB and 4 a second, a `react` 256 B and 10 a second; token buckets per
|
|
1243
|
+
sender (lines: 4 at once, then one every 2 s, as homie.rocks; reactions: 6, then one every
|
|
1244
|
+
0.4 s), 30 at once per address (then one every 0.15 s), and at most 40 reactions a second fanned
|
|
1245
|
+
out per room (the float draws six a second anyway); the same words from one person within 30 s
|
|
1246
|
+
are a `repeat`.
|
|
1247
|
+
|
|
1248
|
+
### The floor and the review
|
|
1249
|
+
|
|
1250
|
+
**The floor** (worker/chat.mjs `floor`) always runs, needs nothing, and costs nothing: a short
|
|
1251
|
+
built-in English list (slurs, sexual words, telling someone to hurt themselves, moving a player to
|
|
1252
|
+
another app or asking for pictures; worker/chat-words.mjs, `homie-studio chat words` prints it),
|
|
1253
|
+
emails and phone numbers, links, swears, and the studio's own words (`block`, `word*` inside words;
|
|
1254
|
+
`allow` lets one through). It reads through spacing ("f u c k"), repeats, leet and look-alike
|
|
1255
|
+
letters, and never across two words ("this hit").
|
|
1256
|
+
|
|
1257
|
+
**The review** (`reviewChat`) reads what the floor let through, on the studio's own Workers AI:
|
|
1258
|
+
Cloudflare's **Clef** decision model (`@cf/cloudflare/clef-flash`, launched 2026-10-01; var
|
|
1259
|
+
`HOMIE_CHAT_MODEL` changes it to `@cf/cloudflare/clef` or `@cf/meta/llama-guard-3-8b`). One
|
|
1260
|
+
typed question: is this message `ok`, an `insult`, `hate`, `sexual`, `grooming` (asks a player's
|
|
1261
|
+
age, where they live, for photos or to move apps), `harm` or `spam`? Clef answers with a
|
|
1262
|
+
probability for each; the message is held when `ok` is under 0.5, and the likeliest other answer
|
|
1263
|
+
is kept as why (in the office's counts, never shown to anyone as a label). The message waits for
|
|
1264
|
+
the answer (Clef-flash: about 40 ms median by Cloudflare's numbers, plus the round trip) and goes
|
|
1265
|
+
out only after it. **Emoji and quick lines are never reviewed** (they are the studio's own words)
|
|
1266
|
+
and reach every screen at once.
|
|
1267
|
+
|
|
1268
|
+
| Review | |
|
|
1269
|
+
|---|---|
|
|
1270
|
+
| Budget | A day, for the whole studio (meta `chat_budget`): 2,000 neurons by default. With the AI guides' 8,000 that is the 10,000 Workers AI gives an account free a day. A short message measured about 2.3 neurons (clef-flash: $0.09 per million input tokens, no output charge; the question and its answers are most of the input), so about 850 reviewed messages a day. Counted in `stats_daily` (`chat-reviews`, `chat-neurons`). |
|
|
1271
|
+
| No review | No `AI` binding (`homie-studio dev` without `--remote-ai`), the day's budget used, the model failing or slower than 1.5 s, more than 8 messages waiting: the floor alone decides, the message goes out, and the office counts it. |
|
|
1272
|
+
| The owner | The studio's owner is never reviewed or held by slow mode (their lines are marked `owner`). |
|
|
1273
|
+
|
|
1274
|
+
### The owner's tools
|
|
1275
|
+
|
|
1276
|
+
`unsay { id | ids | all }` takes lines down on every screen. `mute` and `kick` take `line` (a
|
|
1277
|
+
chat line's id) instead of a seat: the room holds that line's sender by token, browser and
|
|
1278
|
+
account, a watcher with no seat too; `purge: true` takes their lines down with it. The office
|
|
1279
|
+
(`/_studio/office`, `/_studio/api/chat…`) shows each live room's last minutes with Remove, Mute
|
|
1280
|
+
and Kick, each game's rules (and each server's) with what set them, the reports, and the review's
|
|
1281
|
+
day; the owner in their own game has Remove, Mute and Kick on every line in the chat sheet. An
|
|
1282
|
+
office key (the owner's AI) changes rules that tighten chat at once and only ASKS for a change
|
|
1283
|
+
that opens it up (a wider mode or audience, less slow, links or swears allowed, the review off,
|
|
1284
|
+
new lines or emoji).
|
|
1285
|
+
|
|
1286
|
+
**Reports.** A player taps a line, then Report, and picks a reason (mean, hate, sexual, unsafe,
|
|
1287
|
+
spam, other). The Worker files **the room's own copy** of that one line (its words, its sender's
|
|
1288
|
+
room name and account id if signed in, the room, when) for 30 days or until the owner dismisses
|
|
1289
|
+
it, once per line; never who reported it, never an address. 8 reports per browser per 10 minutes.
|
|
1290
|
+
|
|
1291
|
+
### What the game does (the helper)
|
|
1292
|
+
|
|
1293
|
+
```ts
|
|
1294
|
+
import { createBubbles, paintBubbles, BUBBLE_FONT } from '@homie-rocks/studio/port';
|
|
1295
|
+
const bubbles = createBubbles({ measure: (t) => { ctx.font = BUBBLE_FONT; return ctx.measureText(t).width; } });
|
|
1296
|
+
net.on('say', (s) => bubbles.say(s.seat, s.text, { id: s.id, kind: s.kind })); // over the speaker's character
|
|
1297
|
+
net.on('unchat', (e) => e.ids.forEach((id) => bubbles.remove(id))); // the studio took it down
|
|
1298
|
+
// each frame, after the names: anchors just over each speaking body's name label
|
|
1299
|
+
paintBubbles(ctx, bubbles.place(seats.map((s) => ({ key: s.seat, x: s.labelX, y: s.labelTop, self: s.mine }))));
|
|
1300
|
+
```
|
|
1301
|
+
|
|
1302
|
+
| Call | Meaning |
|
|
1303
|
+
|---|---|
|
|
1304
|
+
| `net.on('chat', m)` | Every line and reaction: `{ id, at, kind: 'text'\|'line'\|'react'\|'studio', name, seat, colour, by, text?, say?, react?, glyph?, bubble?, acct?, owner?, mine? }`. A game with its own chat log draws these. |
|
|
1305
|
+
| `net.on('say', s)` | One to draw over a character: `{ id, seat, name, text, glyph?, kind }`. Only when its sender wants it there (`bubble`), the room's `bubbles` rule allows it, the sender holds a seat, and this browser shows chat. |
|
|
1306
|
+
| `net.on('unchat', { ids })`, `net.on('held', h)` | Lines taken down; a line of mine not sent (`why`, `message`). |
|
|
1307
|
+
| `net.say(text)`, `net.sayLine(id)`, `net.react(kind)` | The game's own chat UI (a keyboard key, a wheel of quick lines): checked by the relay like the panel's. False when not sent. |
|
|
1308
|
+
| `net.chatRules`, `net.chatShown` | The room's rules; whether this browser shows chat. |
|
|
1309
|
+
| `createBubbles({ measure, lineHeight?, maxWidth?, maxLines?, ms?, avoid?, screen? })` | `say(key, text, { id, kind })`, `remove(id)`, `clear(key?)`, `place(anchors, dt)` → `BubbleOut[]` (the player's own first, none covering another, wrapped to 3 lines, popping in and fading out), `boxes()` (for `createLabels`' `avoid`), `size`. |
|
|
1310
|
+
| `paintBubbles(ctx, list, { font, paper, ink, edge, radius })` | Draws them on a 2D canvas; a reaction alone is a bigger glyph. |
|
|
1311
|
+
|
|
1312
|
+
Gem Rush, Ember Vale and Gem Rush 3D draw bubbles over the speaker's name; Ember Vale keeps them
|
|
1313
|
+
clear of its guides' own bubbles. A game that draws none still has the panel, the float and the
|
|
1314
|
+
ticker.
|
|
1315
|
+
|
|
1316
|
+
### What the page does (worker/chat-page.mjs)
|
|
1317
|
+
|
|
1318
|
+
- **Play:** a Chat pill in the room button's band (game.json `screen.share` already keeps that
|
|
1319
|
+
corner clear of the game's HUD), with a count of unread lines; its sheet: the room's last
|
|
1320
|
+
minutes, a row of reactions (2.5 rem circles that pop), the quick lines, typing where the rules
|
|
1321
|
+
allow it (and why not where they do not: "Sign in to type here. Emoji are open to everyone."),
|
|
1322
|
+
Report on a line, and two switches kept in this browser: **Show my messages over my character**
|
|
1323
|
+
and **Show chat on this screen** (off: no ticker, no float, no bubbles in this game). New lines
|
|
1324
|
+
show for a moment where the status chip sits (game.json `"screen": { "chat": "bottom-right" }`
|
|
1325
|
+
moves them; `false` keeps only the pill's count).
|
|
1326
|
+
- **Float:** every reaction rises up every screen in the room, as on the television: at most six
|
|
1327
|
+
a second and eighteen at once, 2.4 s each with a little sway. The sender's own floats at once.
|
|
1328
|
+
- **TV** (`/<game>/tv`): the room's lines in the corner the QR card does not use, no typing.
|
|
1329
|
+
- **Watch:** a Chat button in the band; its panel over the stage (a bottom sheet on a phone).
|
|
1330
|
+
|
|
1331
|
+
**Privacy.** Nothing a person says is stored: the room keeps the last 50 lines of the last 15
|
|
1332
|
+
minutes in its memory (never in Durable Object storage), and an empty room forgets them with
|
|
1333
|
+
everything else a minute after its last person leaves. The counts (lines, reactions, held by why,
|
|
1334
|
+
reviews and neurons) are daily numbers in the studio's own D1, never a message or a sender. A
|
|
1335
|
+
report keeps one message for 30 days. The review sends the message's words (and nothing about who
|
|
1336
|
+
said it) to the studio's own Workers AI; Cloudflare says it does not keep or train on them.
|
|
1337
|
+
|
|
1338
|
+
**Cost.** A line or a reaction is one incoming message on the room's object (billed 20:1); the
|
|
1339
|
+
fan-out is outgoing (free). A homie.rocks page or a watcher is one more socket. The review is
|
|
1340
|
+
about 2.3 neurons a typed line, inside the free allocation by default.
|
|
1341
|
+
|
|
1342
|
+
**Old games and old relays.** A game built before revision 8 has the panel, the float, the ticker
|
|
1343
|
+
and the TV corner; it draws no bubbles and its own UI cannot send. A revision-8 helper on an older
|
|
1344
|
+
relay never hears a `line` and has `chatRules` null.
|
package/netplay/netplay.ts
CHANGED
|
@@ -44,6 +44,12 @@
|
|
|
44
44
|
* sends its seat and moves through `agent:do` goals; it speaks only the
|
|
45
45
|
* game's `agents.json` lines (`say:<lineId>`). `@homie-rocks/studio/agents`
|
|
46
46
|
* (`useAgents`) is the host's side of it; this file only carries the frames.
|
|
47
|
+
* - Room chat (revision 8, NETPLAY.md section 19): everyone's lines and
|
|
48
|
+
* reactions (`on('chat')`), the ones to draw over a character
|
|
49
|
+
* (`on('say')`: a speech bubble, `createBubbles` in the port kit draws
|
|
50
|
+
* them), messages taken down (`on('unchat')`), and the game's own chat UI
|
|
51
|
+
* (`net.say(text)`, `net.sayLine(id)`, `net.react(kind)`); the room's rules
|
|
52
|
+
* are `net.chatRules`. The play page's chat panel needs none of it.
|
|
47
53
|
*
|
|
48
54
|
* WHAT IT DOES NOT DO: rendering, physics, input devices, bots. `Roster` below
|
|
49
55
|
* is the bot-yield bookkeeping a host needs; the bots themselves are the game's.
|
|
@@ -52,9 +58,9 @@
|
|
|
52
58
|
|
|
53
59
|
export const NETPLAY_VERSION = 1;
|
|
54
60
|
/** The contract revision this helper speaks (NETPLAY.md): its hello says so (`rev`), and so does every build of it. */
|
|
55
|
-
export const NETPLAY_REVISION =
|
|
61
|
+
export const NETPLAY_REVISION = 8;
|
|
56
62
|
/** In every bundle that includes the helper: `homie-studio build` reads it to tell the office which revision a build speaks. */
|
|
57
|
-
export const NETPLAY_MARK = 'homie-netplay-rev:
|
|
63
|
+
export const NETPLAY_MARK = 'homie-netplay-rev:8';
|
|
58
64
|
|
|
59
65
|
export type Role = 'host' | 'replica' | 'screen';
|
|
60
66
|
export type Device = 'phone' | 'desk' | 'tv';
|
|
@@ -100,8 +106,70 @@ export interface NetConfig {
|
|
|
100
106
|
agent?: { hands: 'self' | 'host'; role: AgentRole };
|
|
101
107
|
/** Revision 6: the play page's "Quiet AI" is on: AI speech is not shown on this browser. */
|
|
102
108
|
hush?: boolean;
|
|
109
|
+
/** Revision 8: this browser hides room chat (the play page's "Show chat" is off): no `say` bubbles either. */
|
|
110
|
+
chatOff?: boolean;
|
|
111
|
+
/** Revision 8: this player does not want their own messages over their character (the play page's toggle). */
|
|
112
|
+
bubbleOff?: boolean;
|
|
103
113
|
}
|
|
104
114
|
|
|
115
|
+
/* ------------------------------------------------- room chat (revision 8, section 19) */
|
|
116
|
+
|
|
117
|
+
export type ChatMode = 'off' | 'emoji' | 'lines' | 'text';
|
|
118
|
+
export type ChatWho = 'anyone' | 'signed-in' | 'members';
|
|
119
|
+
/** A room's chat rules (the policy's `chat`): what may be sent, who may send it, and the room's reactions and lines. */
|
|
120
|
+
export interface ChatRules {
|
|
121
|
+
mode: ChatMode;
|
|
122
|
+
/** Who may type (`text`), and who may send a reaction or a quick line. */
|
|
123
|
+
who: ChatWho;
|
|
124
|
+
react: ChatWho;
|
|
125
|
+
/** Slow mode: seconds between one person's messages (0: off). */
|
|
126
|
+
slow: number;
|
|
127
|
+
/** A typed message's length. */
|
|
128
|
+
max: number;
|
|
129
|
+
links: 'block' | 'allow';
|
|
130
|
+
swears: 'block' | 'allow';
|
|
131
|
+
/** The studio's Workers AI reviews typed messages (the word list always runs). */
|
|
132
|
+
ai: boolean;
|
|
133
|
+
/** A message may show over its sender's character. */
|
|
134
|
+
bubbles: boolean;
|
|
135
|
+
watchers: boolean;
|
|
136
|
+
hub: boolean;
|
|
137
|
+
/** The room's reactions: homie.rocks's five (fire, clap, laugh, heart, wow) and up to three of the game's own. */
|
|
138
|
+
emoji: { k: string; e: string }[];
|
|
139
|
+
/** The room's quick lines: the game's own words (game.json "chat": { "lines" }). */
|
|
140
|
+
lines: { id: string; text: string }[];
|
|
141
|
+
kids?: boolean;
|
|
142
|
+
/** Why the mode is lower than the game asks: `kids`, `server-lines` or `server-off`. */
|
|
143
|
+
capped?: string;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* One message in the room (`on('chat')`): a typed line, a quick line (`say`: its id), a reaction (`react`, `glyph`), or
|
|
147
|
+
* the studio's own line (an announcement). `seat` is the sender's (null: a watcher, a homie.rocks page, the studio);
|
|
148
|
+
* `bubble`: the sender wants it over their character. Names and text are data: draw them as text, never as markup.
|
|
149
|
+
*/
|
|
150
|
+
export interface ChatLine {
|
|
151
|
+
id: string;
|
|
152
|
+
at: number;
|
|
153
|
+
kind: 'text' | 'line' | 'react' | 'studio';
|
|
154
|
+
name: string;
|
|
155
|
+
seat: number | null;
|
|
156
|
+
colour: number | null;
|
|
157
|
+
by: 'player' | 'watcher' | 'hub' | 'studio';
|
|
158
|
+
text?: string;
|
|
159
|
+
say?: string;
|
|
160
|
+
react?: string;
|
|
161
|
+
glyph?: string;
|
|
162
|
+
bubble?: boolean;
|
|
163
|
+
acct?: boolean;
|
|
164
|
+
owner?: boolean;
|
|
165
|
+
/** This browser sent it. */
|
|
166
|
+
mine?: boolean;
|
|
167
|
+
}
|
|
168
|
+
/** A message to draw over a character (`on('say')`): the seat's, with its words or its emoji. */
|
|
169
|
+
export interface SayBubble { id: string; seat: number; name: string; text: string; glyph?: string; kind: 'text' | 'line' | 'react' }
|
|
170
|
+
/** A message that was not sent (to this browser only): `why` is a word (slow, sign-in, words, link, ai…), `message` says it. */
|
|
171
|
+
export interface ChatHeld { why: string; message: string; until?: number }
|
|
172
|
+
|
|
105
173
|
/* ------------------------------------------------- servers and agent seats (revision 6, section 17) */
|
|
106
174
|
|
|
107
175
|
export type PolicyKind = 'open' | 'humans-only' | 'hybrid' | 'beginner';
|
|
@@ -130,6 +198,8 @@ export interface Policy {
|
|
|
130
198
|
brain: string;
|
|
131
199
|
skill: Skill;
|
|
132
200
|
by?: 'vote' | 'owner';
|
|
201
|
+
/** Revision 8: the room's chat rules (section 19). */
|
|
202
|
+
chat?: ChatRules;
|
|
133
203
|
}
|
|
134
204
|
/** What a room says about an AI in a seat (`peer.agent`). */
|
|
135
205
|
export interface AgentFacts { pass: string; role: AgentRole; hands: 'self' | 'host'; by: 'studio' | 'guest' | 'service' }
|
|
@@ -411,6 +481,14 @@ export interface NetHandlers<S, A, C> {
|
|
|
411
481
|
policy: (p: Policy) => void;
|
|
412
482
|
/** Revision 6: the party's vote on the dial opened, moved or closed. */
|
|
413
483
|
vote: (v: VoteState) => void;
|
|
484
|
+
/** Revision 8: a line or a reaction in the room's chat (anyone's, the studio's, mine). */
|
|
485
|
+
chat: (m: ChatLine) => void;
|
|
486
|
+
/** Revision 8: a message to draw over a seat's character (its sender wants it there and this browser shows chat). */
|
|
487
|
+
say: (s: SayBubble) => void;
|
|
488
|
+
/** Revision 8: the studio took messages down: hide them (and their bubbles). */
|
|
489
|
+
unchat: (e: { ids: string[] }) => void;
|
|
490
|
+
/** Revision 8: a message of mine was not sent (slow mode, sign in to type, a word, the studio's filter…). */
|
|
491
|
+
held: (h: ChatHeld) => void;
|
|
414
492
|
}
|
|
415
493
|
|
|
416
494
|
export interface Netplay<S = unknown, A = unknown, C = unknown> {
|
|
@@ -503,6 +581,17 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
|
|
|
503
581
|
/** Resolves with the first role (welcome, or offline fallback). */
|
|
504
582
|
readonly ready: Promise<RoleChange<S, C>>;
|
|
505
583
|
on<K extends keyof NetHandlers<S, A, C>>(kind: K, fn: NetHandlers<S, A, C>[K]): () => void;
|
|
584
|
+
/**
|
|
585
|
+
* ROOM CHAT (revision 8, NETPLAY.md section 19). `chatRules`: the room's rules (null before the relay says them).
|
|
586
|
+
* `say(text)` types a line, `sayLine(id)` sends one of the room's quick lines, `react(kind)` an emoji (fire, clap,
|
|
587
|
+
* laugh, heart, wow, or the game's own): checked by the relay like the play page's own panel; false when not sent
|
|
588
|
+
* (offline, or the room's rules say no). `chatShown`: this browser shows chat (its player can turn it off).
|
|
589
|
+
*/
|
|
590
|
+
readonly chatRules: ChatRules | null;
|
|
591
|
+
say(text: string, opts?: { bubble?: boolean }): boolean;
|
|
592
|
+
sayLine(id: string, opts?: { bubble?: boolean }): boolean;
|
|
593
|
+
react(kind: string, opts?: { bubble?: boolean }): boolean;
|
|
594
|
+
readonly chatShown: boolean;
|
|
506
595
|
/** Server time in ms (relay clock). Offline: Date.now(). */
|
|
507
596
|
now(): number;
|
|
508
597
|
/** Host: true when the next `snapshot()` would be sent. Build the state only then. */
|
|
@@ -890,7 +979,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
890
979
|
const handlers: { [K in keyof NetHandlers<S, A, C>]: Set<NetHandlers<S, A, C>[K]> } = {
|
|
891
980
|
role: new Set(), join: new Set(), leave: new Set(), input: new Set(), event: new Set(),
|
|
892
981
|
snapshot: new Set(), control: new Set(), state: new Set(), round: new Set(), roster: new Set(), status: new Set(), announce: new Set(), mute: new Set(), view: new Set(),
|
|
893
|
-
policy: new Set(), vote: new Set(),
|
|
982
|
+
policy: new Set(), vote: new Set(), chat: new Set(), say: new Set(), unchat: new Set(), held: new Set(),
|
|
894
983
|
};
|
|
895
984
|
const emit = <K extends keyof NetHandlers<S, A, C>>(kind: K, arg: Parameters<NetHandlers<S, A, C>[K]>[0]): void => {
|
|
896
985
|
for (const fn of [...handlers[kind]]) {
|
|
@@ -1018,6 +1107,10 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1018
1107
|
const asAgent = cfg?.agent && typeof cfg.agent === 'object' ? { hands: cfg.agent.hands === 'host' ? 'host' : 'self', role: cfg.agent.role ?? 'party' } : null;
|
|
1019
1108
|
let hushed = cfg?.hush === true;
|
|
1020
1109
|
let aloneUntil = 0;
|
|
1110
|
+
// room chat (section 19): this browser shows chat (the play page's "Show chat"), and wants its own lines over its character.
|
|
1111
|
+
let chatShown = cfg?.chatOff !== true;
|
|
1112
|
+
let bubbleMine = cfg?.bubbleOff !== true;
|
|
1113
|
+
let chatSeq = 0;
|
|
1021
1114
|
|
|
1022
1115
|
const wall = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
1023
1116
|
const now = (): number => (offline ? Date.now() : Date.now() + offset);
|
|
@@ -1255,6 +1348,27 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1255
1348
|
if (voteState) { emit('vote', voteState); post?.({ what: 'vote', vote: voteState }); }
|
|
1256
1349
|
return;
|
|
1257
1350
|
}
|
|
1351
|
+
case 'line':
|
|
1352
|
+
case 'react': {
|
|
1353
|
+
// Room chat (section 19): a line or a reaction, anyone's. One the sender wants over their character is a `say`.
|
|
1354
|
+
const line = readChatLine(m);
|
|
1355
|
+
if (!line) return;
|
|
1356
|
+
emit('chat', line);
|
|
1357
|
+
if (line.bubble && line.seat !== null && chatShown && line.kind !== 'studio') {
|
|
1358
|
+
emit('say', { id: line.id, seat: line.seat, name: line.name, text: line.kind === 'react' ? (line.glyph ?? '') : (line.text ?? ''), ...(line.glyph ? { glyph: line.glyph } : {}), kind: line.kind === 'react' ? 'react' : line.kind === 'line' ? 'line' : 'text' });
|
|
1359
|
+
}
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
case 'unline': {
|
|
1363
|
+
const ids = Array.isArray(m['ids']) ? (m['ids'] as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 64) : [];
|
|
1364
|
+
if (ids.length) emit('unchat', { ids });
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
case 'held':
|
|
1368
|
+
case 'slow': {
|
|
1369
|
+
emit('held', { why: String(m['why'] ?? (m['t'] === 'slow' ? 'slow' : 'held')), message: String(m['message'] ?? ''), ...(typeof m['until'] === 'number' ? { until: m['until'] } : {}) });
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1258
1372
|
case 'state': if (typeof m['k'] === 'string') applyState(m['k'], m['d']); return;
|
|
1259
1373
|
case 'pong': return onPong(m);
|
|
1260
1374
|
case 'announce': {
|
|
@@ -1784,6 +1898,12 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1784
1898
|
if (ev.source !== g.parent || !ev.data || typeof ev.data !== 'object') return;
|
|
1785
1899
|
const d = ev.data as { t?: unknown; on?: unknown };
|
|
1786
1900
|
if (d.t === 'homie-hush') hushed = d.on === true;
|
|
1901
|
+
// Room chat (section 19): the play page's "Show chat" and "Show my messages over my character".
|
|
1902
|
+
if (d.t === 'homie-chat') {
|
|
1903
|
+
const c = d as { show?: unknown; bubble?: unknown };
|
|
1904
|
+
if (typeof c.show === 'boolean') chatShown = c.show;
|
|
1905
|
+
if (typeof c.bubble === 'boolean') bubbleMine = c.bubble;
|
|
1906
|
+
}
|
|
1787
1907
|
});
|
|
1788
1908
|
} catch { /* not a browser */ }
|
|
1789
1909
|
|
|
@@ -1813,6 +1933,24 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1813
1933
|
skill: { ...skillPreset(Number(sk.level) || 3, kids), ...sk },
|
|
1814
1934
|
} as Policy;
|
|
1815
1935
|
}
|
|
1936
|
+
function readChatLine(m: Record<string, unknown>): ChatLine | null {
|
|
1937
|
+
if (typeof m['id'] !== 'string') return null;
|
|
1938
|
+
const react = m['t'] === 'react';
|
|
1939
|
+
const by = m['by'] === 'watcher' || m['by'] === 'hub' || m['by'] === 'studio' ? m['by'] : 'player';
|
|
1940
|
+
return {
|
|
1941
|
+
id: m['id'], at: Number(m['at']) || now(), kind: react ? 'react' : by === 'studio' ? 'studio' : typeof m['say'] === 'string' ? 'line' : 'text',
|
|
1942
|
+
name: String(m['name'] ?? '').slice(0, 40), seat: typeof m['seat'] === 'number' ? m['seat'] : null, colour: typeof m['colour'] === 'number' ? m['colour'] : null, by,
|
|
1943
|
+
...(typeof m['text'] === 'string' ? { text: m['text'] } : {}), ...(typeof m['say'] === 'string' ? { say: m['say'] } : {}),
|
|
1944
|
+
...(react ? { react: String(m['react'] ?? ''), glyph: String(m['glyph'] ?? '') } : {}),
|
|
1945
|
+
...(m['bubble'] === true ? { bubble: true } : {}), ...(m['acct'] === true ? { acct: true } : {}), ...(m['owner'] === true ? { owner: true } : {}),
|
|
1946
|
+
...(typeof m['n'] === 'string' ? { mine: true } : {}),
|
|
1947
|
+
};
|
|
1948
|
+
}
|
|
1949
|
+
function chatSend(msg: Record<string, unknown>, bubble?: boolean): boolean {
|
|
1950
|
+
if (offline || asAgent) return false;
|
|
1951
|
+
chatSeq += 1;
|
|
1952
|
+
return raw({ ...msg, n: `g${chatSeq}`, ...((bubble ?? bubbleMine) ? {} : { bubble: false }) });
|
|
1953
|
+
}
|
|
1816
1954
|
function readVote(m: Record<string, unknown>): VoteState | null {
|
|
1817
1955
|
if (!Array.isArray(m['options']) || typeof m['id'] !== 'string') return null;
|
|
1818
1956
|
return {
|
|
@@ -1894,6 +2032,15 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1894
2032
|
},
|
|
1895
2033
|
get hushed() { return hushed; },
|
|
1896
2034
|
set hushed(on: boolean) { hushed = Boolean(on); },
|
|
2035
|
+
get chatRules() { return policy.chat ?? null; },
|
|
2036
|
+
get chatShown() { return chatShown; },
|
|
2037
|
+
say(text: string, o?: { bubble?: boolean }): boolean {
|
|
2038
|
+
const t = String(text ?? '').trim();
|
|
2039
|
+
if (!t) return false;
|
|
2040
|
+
return chatSend({ t: 'say', text: t.slice(0, 280) }, o?.bubble);
|
|
2041
|
+
},
|
|
2042
|
+
sayLine(id: string, o?: { bubble?: boolean }): boolean { return chatSend({ t: 'say', say: String(id ?? '').slice(0, 16) }, o?.bubble); },
|
|
2043
|
+
react(kind: string, o?: { bubble?: boolean }): boolean { return chatSend({ t: 'react', kind: String(kind ?? '').slice(0, 16) }, o?.bubble); },
|
|
1897
2044
|
get asAgent() { return Boolean(asAgent); },
|
|
1898
2045
|
ready,
|
|
1899
2046
|
on(kind, fn) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.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, 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",
|
|
@@ -38,6 +38,10 @@
|
|
|
38
38
|
"types": "./lab/lab.ts",
|
|
39
39
|
"default": "./lab/lab.ts"
|
|
40
40
|
},
|
|
41
|
+
"./assets": {
|
|
42
|
+
"types": "./assets/assets.ts",
|
|
43
|
+
"default": "./assets/assets.ts"
|
|
44
|
+
},
|
|
41
45
|
"./worker": "./worker/index.mjs",
|
|
42
46
|
"./package.json": "./package.json"
|
|
43
47
|
},
|
|
@@ -51,9 +55,11 @@
|
|
|
51
55
|
"saves/",
|
|
52
56
|
"agents/",
|
|
53
57
|
"lab/",
|
|
58
|
+
"assets/",
|
|
54
59
|
"starters/",
|
|
55
60
|
"media/",
|
|
56
61
|
"site/",
|
|
62
|
+
"chat/",
|
|
57
63
|
"README.md",
|
|
58
64
|
"CHANGELOG.md",
|
|
59
65
|
"LICENSE",
|
|
@@ -64,8 +70,15 @@
|
|
|
64
70
|
},
|
|
65
71
|
"dependencies": {
|
|
66
72
|
"@anthropic-ai/sdk": "0.131.0",
|
|
73
|
+
"@gltf-transform/core": "4.5.1",
|
|
74
|
+
"@gltf-transform/extensions": "4.5.1",
|
|
75
|
+
"@gltf-transform/functions": "4.5.1",
|
|
67
76
|
"esbuild": "0.28.1",
|
|
68
|
-
"
|
|
77
|
+
"gltf-validator": "2.0.0-dev.3.10",
|
|
78
|
+
"meshoptimizer": "1.3.0",
|
|
79
|
+
"puppeteer-core": "25.8.0",
|
|
80
|
+
"sharp": "0.35.5",
|
|
81
|
+
"three": "0.185.1"
|
|
69
82
|
},
|
|
70
83
|
"scripts": {
|
|
71
84
|
"test": "node --test test/*.test.mjs"
|
package/port/index.ts
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
* keys on computers, a floating stick and small buttons on phones
|
|
9
9
|
* groundBasis / screenToGround / PlayerYaw
|
|
10
10
|
* the camera rules: input on screen axes, a yaw only the player turns
|
|
11
|
-
* fitView / easeView / toScreen / createLabels
|
|
11
|
+
* fitView / easeView / toScreen / createLabels / createBubbles
|
|
12
12
|
* a flat world on every screen (the whole of it, or filling an upright phone and following the
|
|
13
|
-
* player)
|
|
13
|
+
* player), name labels that never pile up, and speech bubbles over characters (room chat)
|
|
14
14
|
* exposePort what `homie-studio port check` reads (owner tests)
|
|
15
15
|
* createHud a minimal clock / scores / results overlay
|
|
16
16
|
* BotBrain, seek, nearest, rubberBand
|
|
@@ -31,6 +31,7 @@ export { createTouchControls, type TouchControls, type TouchOptions, type TouchB
|
|
|
31
31
|
export { createControls, type Controls, type ControlsOptions } from './controls';
|
|
32
32
|
export { groundBasis, screenToGround, PlayerYaw, type GroundBasis, type CameraLike } from './camera';
|
|
33
33
|
export { fitView, easeView, toScreen, createLabels, type Fit, type FitOptions, type LabelIn, type LabelOut, type LabelOptions, type LabelBox } from './view';
|
|
34
|
+
export { createBubbles, paintBubbles, wrapText, BUBBLE_FONT, type Bubbles, type BubbleOptions, type BubbleIn, type BubbleOut, type BubbleStyle } from './view';
|
|
34
35
|
export { exposePort, type PortProbeOptions, type View } from './probe';
|
|
35
36
|
export { createRoom, type Room, type RoomOptions, type BodyBase, type RoomSnap, type RoomCkpt } from './room';
|
|
36
37
|
export { createHud, type HudOptions } from './hud';
|