@homie-rocks/studio 0.22.0 → 0.24.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/README.md +59 -0
  3. package/bin/homie-studio.mjs +68 -2
  4. package/chat/CHAT.md +101 -0
  5. package/chat/OWNERS.md +78 -0
  6. package/lib/build.mjs +20 -2
  7. package/lib/chat-cli.mjs +89 -0
  8. package/lib/cloudflare.mjs +5 -2
  9. package/lib/scaffold.mjs +59 -2
  10. package/lib/shop.mjs +270 -0
  11. package/lib/template-history.json +77 -0
  12. package/netplay/NETPLAY.md +194 -4
  13. package/netplay/netplay.ts +156 -3
  14. package/package.json +7 -1
  15. package/port/index.ts +3 -2
  16. package/port/view.ts +238 -0
  17. package/shop/SELLING.md +83 -0
  18. package/shop/SHOP.md +154 -0
  19. package/shop/shop.ts +130 -0
  20. package/site/SITE.md +22 -1
  21. package/starters/ember-vale/src/main.ts +22 -2
  22. package/starters/gem-rush/src/main.ts +21 -1
  23. package/starters/gem-rush-3d/src/main.ts +22 -1
  24. package/worker/account-page.mjs +14 -2
  25. package/worker/agents.mjs +3 -3
  26. package/worker/chat-page.mjs +556 -0
  27. package/worker/chat-store.mjs +161 -0
  28. package/worker/chat-words.mjs +46 -0
  29. package/worker/chat.mjs +419 -0
  30. package/worker/index.mjs +189 -7
  31. package/worker/office-page.mjs +120 -5
  32. package/worker/office.mjs +173 -6
  33. package/worker/pages.mjs +25 -8
  34. package/worker/players.mjs +8 -0
  35. package/worker/referrals.mjs +328 -0
  36. package/worker/room.mjs +291 -11
  37. package/worker/servers.mjs +5 -1
  38. package/worker/shop-page.mjs +532 -0
  39. package/worker/shop-rules.mjs +277 -0
  40. package/worker/shop-store.mjs +217 -0
  41. package/worker/shop.mjs +686 -0
  42. package/worker/site.mjs +4 -1
  43. package/worker/stats.mjs +4 -1
  44. package/worker/stripe.mjs +149 -0
  45. package/worker/version.mjs +1 -1
@@ -1,10 +1,26 @@
1
- # Homie netplay contract, v1 (revision 7)
1
+ # Homie netplay contract, v1 (revision 8)
2
2
 
3
- Status: **v1, revision 7** (2026-10-02, `@homie-rocks/studio` 0.17.0). The wire version is
4
- `v: 1`. Everything revisions 2 to 7 added is either an optional field, a new message type,
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
 
@@ -330,7 +349,9 @@ A socket that is kicked for silence is closed with code 4000, and the helper rec
330
349
  Record types:
331
350
 
332
351
  ```ts
333
- Peer = { id, seat: number|null, name, colour, device, want, role, muted?: true, watch?: true, agent?: AgentFacts }
352
+ Peer = { id, seat: number|null, name, colour, device, want, role, muted?: true, watch?: true, agent?: AgentFacts, badge?: string }
353
+ (badge: a word the studio's Worker verified from what the player's account owns, such as a supporter's
354
+ "Supporter"; a hello can never set it; never on a kids server, never for an AI. @homie-rocks/studio 0.24.0)
334
355
  RoundInfo = { n, phase: 'live'|'over', startedAt, endsAt, results?: RoundResult[] } // server ms
335
356
  RoundResult = { slot, seat: number|null, name, score, bot, place, agent?: true }
336
357
  Slot = { slot, seat: number|null, name, bot, agent?: { seat: number|null, role, hands } }
@@ -1154,3 +1175,172 @@ A beginner server's chat is quick lines only; on a kids server names are handles
1154
1175
  text ever reaches a brain. The owner's controls work on AI, and "AI talk off" (agents_brain
1155
1176
  `script`) silences every AI at once. Quiet AI hides every AI line on a player's own screen.
1156
1177
 
1178
+
1179
+ ---
1180
+
1181
+ ## 19. Room chat (revision 8)
1182
+
1183
+ Every room has a chat: reactions that float up every screen in it, the game's quick lines,
1184
+ and typed messages where the room's rules allow them. It is the Homie app's live-room chat and
1185
+ homie.rocks's room chat, in a studio's own game: the same five reactions in the same order
1186
+ (fire 🔥, clap 👏, laugh 😂, heart ❤️, wow 🤯), the same frames (`line` and `react`), the same
1187
+ slow word ("One line at a time."), the same float on the big screen.
1188
+
1189
+ **Where it runs.** In the room's own Table on the studio's Worker. A line goes up on the game's
1190
+ socket (`net.say`) or on the shell's watch socket (the play page's panel, the watch page, the big
1191
+ screen's page, a homie.rocks room page); the relay checks it, the floor reads typed text, the
1192
+ studio's review reads what the floor let through, and the relay fans it out to every socket and
1193
+ every watching shell. homie.rocks runs nothing: its room page opens the room's own watch socket.
1194
+
1195
+ ### The rules: `policy.chat`
1196
+
1197
+ | Field | Values (default) | |
1198
+ |---|---|---|
1199
+ | `mode` | `off`, `emoji`, `lines`, `text` (`text`) | What may be sent: each mode allows what the one before it does. |
1200
+ | `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). |
1201
+ | `react` | the same (`anyone`) | Who may send a reaction or a quick line. |
1202
+ | `slow` | 0–120 s (2) | Slow mode: between one person's lines (reactions have their own bucket). |
1203
+ | `max` | 20–280 characters (140) | A typed line. |
1204
+ | `links` | `block`, `allow` (`block`) | A link is never clickable either way. |
1205
+ | `swears` | `block`, `allow` (`block`) | Slurs, sexual words, threats and contact details are held whatever this says. |
1206
+ | `ai` | `true`, `false` (`true`) | The studio's review of typed text. |
1207
+ | `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. |
1208
+ | `emoji` | the five, then up to 3 of the game's own | `{ k, e }`: a kind and its glyph. |
1209
+ | `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. |
1210
+ | `capped` | `kids`, `server-lines`, `server-off` | Why the mode is lower than the game asks. |
1211
+
1212
+ The rules come in layers: game.json `"chat"` (the game's defaults, or `false` for none), then
1213
+ the owner's for the game, then the owner's for a server (worker/chat-store.mjs, D1 `chat_rules`),
1214
+ then the server's own caps: **a kids server, or a server whose speech is quick lines (every
1215
+ beginner server), keeps chat to emoji and quick lines; speech `off` turns it off.** The Worker
1216
+ composes them for every socket and the room applies the newest; an owner's change reaches every
1217
+ live room at once (a signed `policy` control). The studio's own word list (`block`, `allow`)
1218
+ stays in the room; clients get the rest in `welcome.policy.chat` and every `policy` frame.
1219
+
1220
+ ```json
1221
+ "chat": {
1222
+ "mode": "text", "who": "signed-in", "react": "anyone", "slow": 2, "max": 140,
1223
+ "emoji": { "gem": "💎" },
1224
+ "lines": { "gg": "Good game!", "gem": "Grab that gem!", "help": "Help me!" }
1225
+ }
1226
+ ```
1227
+
1228
+ ### Frames
1229
+
1230
+ | `t` | Direction | Fields | |
1231
+ |---|---|---|---|
1232
+ | `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. |
1233
+ | `react` | up | `kind`, `n?`, `bubble?: false` | One of the room's reactions. |
1234
+ | `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). |
1235
+ | `react` | down, everyone | `id`, `at`, `name`, `seat`, `colour`, `by`, `react`, `glyph`, `bubble?`, `n?` | |
1236
+ | `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. |
1237
+ | `unline` | down, everyone | `ids` | The owner took them down: hide them, and their bubbles. |
1238
+ | `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`. |
1239
+
1240
+ A line's sender is the socket's own client, or for a watch socket the client of the same browser
1241
+ (its room key) or account in the room: its seat, name and colour. A page with no client in the
1242
+ room (homie.rocks, a watch page before its game connects) is a watcher with a handle. **An AI never
1243
+ types in room chat** (section 18: it says only its game's lines, as `ev`). The relay's caps: a
1244
+ `say` frame at most 1.5 KB and 4 a second, a `react` 256 B and 10 a second; token buckets per
1245
+ sender (lines: 4 at once, then one every 2 s, as homie.rocks; reactions: 6, then one every
1246
+ 0.4 s), 30 at once per address (then one every 0.15 s), and at most 40 reactions a second fanned
1247
+ out per room (the float draws six a second anyway); the same words from one person within 30 s
1248
+ are a `repeat`.
1249
+
1250
+ ### The floor and the review
1251
+
1252
+ **The floor** (worker/chat.mjs `floor`) always runs, needs nothing, and costs nothing: a short
1253
+ built-in English list (slurs, sexual words, telling someone to hurt themselves, moving a player to
1254
+ another app or asking for pictures; worker/chat-words.mjs, `homie-studio chat words` prints it),
1255
+ emails and phone numbers, links, swears, and the studio's own words (`block`, `word*` inside words;
1256
+ `allow` lets one through). It reads through spacing ("f u c k"), repeats, leet and look-alike
1257
+ letters, and never across two words ("this hit").
1258
+
1259
+ **The review** (`reviewChat`) reads what the floor let through, on the studio's own Workers AI:
1260
+ Cloudflare's **Clef** decision model (`@cf/cloudflare/clef-flash`, launched 2026-10-01; var
1261
+ `HOMIE_CHAT_MODEL` changes it to `@cf/cloudflare/clef` or `@cf/meta/llama-guard-3-8b`). One
1262
+ typed question: is this message `ok`, an `insult`, `hate`, `sexual`, `grooming` (asks a player's
1263
+ age, where they live, for photos or to move apps), `harm` or `spam`? Clef answers with a
1264
+ probability for each; the message is held when `ok` is under 0.5, and the likeliest other answer
1265
+ is kept as why (in the office's counts, never shown to anyone as a label). The message waits for
1266
+ the answer (Clef-flash: about 40 ms median by Cloudflare's numbers, plus the round trip) and goes
1267
+ out only after it. **Emoji and quick lines are never reviewed** (they are the studio's own words)
1268
+ and reach every screen at once.
1269
+
1270
+ | Review | |
1271
+ |---|---|
1272
+ | 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`). |
1273
+ | 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. |
1274
+ | The owner | The studio's owner is never reviewed or held by slow mode (their lines are marked `owner`). |
1275
+
1276
+ ### The owner's tools
1277
+
1278
+ `unsay { id | ids | all }` takes lines down on every screen. `mute` and `kick` take `line` (a
1279
+ chat line's id) instead of a seat: the room holds that line's sender by token, browser and
1280
+ account, a watcher with no seat too; `purge: true` takes their lines down with it. The office
1281
+ (`/_studio/office`, `/_studio/api/chat…`) shows each live room's last minutes with Remove, Mute
1282
+ and Kick, each game's rules (and each server's) with what set them, the reports, and the review's
1283
+ day; the owner in their own game has Remove, Mute and Kick on every line in the chat sheet. An
1284
+ office key (the owner's AI) changes rules that tighten chat at once and only ASKS for a change
1285
+ that opens it up (a wider mode or audience, less slow, links or swears allowed, the review off,
1286
+ new lines or emoji).
1287
+
1288
+ **Reports.** A player taps a line, then Report, and picks a reason (mean, hate, sexual, unsafe,
1289
+ spam, other). The Worker files **the room's own copy** of that one line (its words, its sender's
1290
+ room name and account id if signed in, the room, when) for 30 days or until the owner dismisses
1291
+ it, once per line; never who reported it, never an address. 8 reports per browser per 10 minutes.
1292
+
1293
+ ### What the game does (the helper)
1294
+
1295
+ ```ts
1296
+ import { createBubbles, paintBubbles, BUBBLE_FONT } from '@homie-rocks/studio/port';
1297
+ const bubbles = createBubbles({ measure: (t) => { ctx.font = BUBBLE_FONT; return ctx.measureText(t).width; } });
1298
+ net.on('say', (s) => bubbles.say(s.seat, s.text, { id: s.id, kind: s.kind })); // over the speaker's character
1299
+ net.on('unchat', (e) => e.ids.forEach((id) => bubbles.remove(id))); // the studio took it down
1300
+ // each frame, after the names: anchors just over each speaking body's name label
1301
+ paintBubbles(ctx, bubbles.place(seats.map((s) => ({ key: s.seat, x: s.labelX, y: s.labelTop, self: s.mine }))));
1302
+ ```
1303
+
1304
+ | Call | Meaning |
1305
+ |---|---|
1306
+ | `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. |
1307
+ | `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. |
1308
+ | `net.on('unchat', { ids })`, `net.on('held', h)` | Lines taken down; a line of mine not sent (`why`, `message`). |
1309
+ | `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. |
1310
+ | `net.chatRules`, `net.chatShown` | The room's rules; whether this browser shows chat. |
1311
+ | `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`. |
1312
+ | `paintBubbles(ctx, list, { font, paper, ink, edge, radius })` | Draws them on a 2D canvas; a reaction alone is a bigger glyph. |
1313
+
1314
+ Gem Rush, Ember Vale and Gem Rush 3D draw bubbles over the speaker's name; Ember Vale keeps them
1315
+ clear of its guides' own bubbles. A game that draws none still has the panel, the float and the
1316
+ ticker.
1317
+
1318
+ ### What the page does (worker/chat-page.mjs)
1319
+
1320
+ - **Play:** a Chat pill in the room button's band (game.json `screen.share` already keeps that
1321
+ corner clear of the game's HUD), with a count of unread lines; its sheet: the room's last
1322
+ minutes, a row of reactions (2.5 rem circles that pop), the quick lines, typing where the rules
1323
+ allow it (and why not where they do not: "Sign in to type here. Emoji are open to everyone."),
1324
+ Report on a line, and two switches kept in this browser: **Show my messages over my character**
1325
+ and **Show chat on this screen** (off: no ticker, no float, no bubbles in this game). New lines
1326
+ show for a moment where the status chip sits (game.json `"screen": { "chat": "bottom-right" }`
1327
+ moves them; `false` keeps only the pill's count).
1328
+ - **Float:** every reaction rises up every screen in the room, as on the television: at most six
1329
+ a second and eighteen at once, 2.4 s each with a little sway. The sender's own floats at once.
1330
+ - **TV** (`/<game>/tv`): the room's lines in the corner the QR card does not use, no typing.
1331
+ - **Watch:** a Chat button in the band; its panel over the stage (a bottom sheet on a phone).
1332
+
1333
+ **Privacy.** Nothing a person says is stored: the room keeps the last 50 lines of the last 15
1334
+ minutes in its memory (never in Durable Object storage), and an empty room forgets them with
1335
+ everything else a minute after its last person leaves. The counts (lines, reactions, held by why,
1336
+ reviews and neurons) are daily numbers in the studio's own D1, never a message or a sender. A
1337
+ report keeps one message for 30 days. The review sends the message's words (and nothing about who
1338
+ said it) to the studio's own Workers AI; Cloudflare says it does not keep or train on them.
1339
+
1340
+ **Cost.** A line or a reaction is one incoming message on the room's object (billed 20:1); the
1341
+ fan-out is outgoing (free). A homie.rocks page or a watcher is one more socket. The review is
1342
+ about 2.3 neurons a typed line, inside the free allocation by default.
1343
+
1344
+ **Old games and old relays.** A game built before revision 8 has the panel, the float, the ticker
1345
+ and the TV corner; it draws no bubbles and its own UI cannot send. A revision-8 helper on an older
1346
+ relay never hears a `line` and has `chatRules` null.
@@ -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 = 7;
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:7';
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' }
@@ -199,6 +269,12 @@ export interface Peer {
199
269
  watch?: boolean;
200
270
  /** Revision 6: an AI in a seat (always named "<label> · AI"). */
201
271
  agent?: AgentFacts;
272
+ /**
273
+ * A badge the player's account owns on this studio (a supporter's, from the shop: @homie-rocks/studio 0.24.0), as
274
+ * a word to show beside their name. Only the studio's Worker sets it, from what the account bought; a hello cannot.
275
+ * Never on a kids server, never for an AI.
276
+ */
277
+ badge?: string;
202
278
  }
203
279
 
204
280
  /** One seat's entry in the body-control table: [seat, rs, own (1|0), ack]. */
@@ -411,6 +487,14 @@ export interface NetHandlers<S, A, C> {
411
487
  policy: (p: Policy) => void;
412
488
  /** Revision 6: the party's vote on the dial opened, moved or closed. */
413
489
  vote: (v: VoteState) => void;
490
+ /** Revision 8: a line or a reaction in the room's chat (anyone's, the studio's, mine). */
491
+ chat: (m: ChatLine) => void;
492
+ /** Revision 8: a message to draw over a seat's character (its sender wants it there and this browser shows chat). */
493
+ say: (s: SayBubble) => void;
494
+ /** Revision 8: the studio took messages down: hide them (and their bubbles). */
495
+ unchat: (e: { ids: string[] }) => void;
496
+ /** Revision 8: a message of mine was not sent (slow mode, sign in to type, a word, the studio's filter…). */
497
+ held: (h: ChatHeld) => void;
414
498
  }
415
499
 
416
500
  export interface Netplay<S = unknown, A = unknown, C = unknown> {
@@ -503,6 +587,17 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
503
587
  /** Resolves with the first role (welcome, or offline fallback). */
504
588
  readonly ready: Promise<RoleChange<S, C>>;
505
589
  on<K extends keyof NetHandlers<S, A, C>>(kind: K, fn: NetHandlers<S, A, C>[K]): () => void;
590
+ /**
591
+ * ROOM CHAT (revision 8, NETPLAY.md section 19). `chatRules`: the room's rules (null before the relay says them).
592
+ * `say(text)` types a line, `sayLine(id)` sends one of the room's quick lines, `react(kind)` an emoji (fire, clap,
593
+ * laugh, heart, wow, or the game's own): checked by the relay like the play page's own panel; false when not sent
594
+ * (offline, or the room's rules say no). `chatShown`: this browser shows chat (its player can turn it off).
595
+ */
596
+ readonly chatRules: ChatRules | null;
597
+ say(text: string, opts?: { bubble?: boolean }): boolean;
598
+ sayLine(id: string, opts?: { bubble?: boolean }): boolean;
599
+ react(kind: string, opts?: { bubble?: boolean }): boolean;
600
+ readonly chatShown: boolean;
506
601
  /** Server time in ms (relay clock). Offline: Date.now(). */
507
602
  now(): number;
508
603
  /** Host: true when the next `snapshot()` would be sent. Build the state only then. */
@@ -890,7 +985,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
890
985
  const handlers: { [K in keyof NetHandlers<S, A, C>]: Set<NetHandlers<S, A, C>[K]> } = {
891
986
  role: new Set(), join: new Set(), leave: new Set(), input: new Set(), event: new Set(),
892
987
  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(),
988
+ policy: new Set(), vote: new Set(), chat: new Set(), say: new Set(), unchat: new Set(), held: new Set(),
894
989
  };
895
990
  const emit = <K extends keyof NetHandlers<S, A, C>>(kind: K, arg: Parameters<NetHandlers<S, A, C>[K]>[0]): void => {
896
991
  for (const fn of [...handlers[kind]]) {
@@ -1018,6 +1113,10 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1018
1113
  const asAgent = cfg?.agent && typeof cfg.agent === 'object' ? { hands: cfg.agent.hands === 'host' ? 'host' : 'self', role: cfg.agent.role ?? 'party' } : null;
1019
1114
  let hushed = cfg?.hush === true;
1020
1115
  let aloneUntil = 0;
1116
+ // room chat (section 19): this browser shows chat (the play page's "Show chat"), and wants its own lines over its character.
1117
+ let chatShown = cfg?.chatOff !== true;
1118
+ let bubbleMine = cfg?.bubbleOff !== true;
1119
+ let chatSeq = 0;
1021
1120
 
1022
1121
  const wall = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now());
1023
1122
  const now = (): number => (offline ? Date.now() : Date.now() + offset);
@@ -1255,6 +1354,27 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1255
1354
  if (voteState) { emit('vote', voteState); post?.({ what: 'vote', vote: voteState }); }
1256
1355
  return;
1257
1356
  }
1357
+ case 'line':
1358
+ case 'react': {
1359
+ // Room chat (section 19): a line or a reaction, anyone's. One the sender wants over their character is a `say`.
1360
+ const line = readChatLine(m);
1361
+ if (!line) return;
1362
+ emit('chat', line);
1363
+ if (line.bubble && line.seat !== null && chatShown && line.kind !== 'studio') {
1364
+ 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' });
1365
+ }
1366
+ return;
1367
+ }
1368
+ case 'unline': {
1369
+ const ids = Array.isArray(m['ids']) ? (m['ids'] as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 64) : [];
1370
+ if (ids.length) emit('unchat', { ids });
1371
+ return;
1372
+ }
1373
+ case 'held':
1374
+ case 'slow': {
1375
+ emit('held', { why: String(m['why'] ?? (m['t'] === 'slow' ? 'slow' : 'held')), message: String(m['message'] ?? ''), ...(typeof m['until'] === 'number' ? { until: m['until'] } : {}) });
1376
+ return;
1377
+ }
1258
1378
  case 'state': if (typeof m['k'] === 'string') applyState(m['k'], m['d']); return;
1259
1379
  case 'pong': return onPong(m);
1260
1380
  case 'announce': {
@@ -1784,6 +1904,12 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1784
1904
  if (ev.source !== g.parent || !ev.data || typeof ev.data !== 'object') return;
1785
1905
  const d = ev.data as { t?: unknown; on?: unknown };
1786
1906
  if (d.t === 'homie-hush') hushed = d.on === true;
1907
+ // Room chat (section 19): the play page's "Show chat" and "Show my messages over my character".
1908
+ if (d.t === 'homie-chat') {
1909
+ const c = d as { show?: unknown; bubble?: unknown };
1910
+ if (typeof c.show === 'boolean') chatShown = c.show;
1911
+ if (typeof c.bubble === 'boolean') bubbleMine = c.bubble;
1912
+ }
1787
1913
  });
1788
1914
  } catch { /* not a browser */ }
1789
1915
 
@@ -1813,6 +1939,24 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1813
1939
  skill: { ...skillPreset(Number(sk.level) || 3, kids), ...sk },
1814
1940
  } as Policy;
1815
1941
  }
1942
+ function readChatLine(m: Record<string, unknown>): ChatLine | null {
1943
+ if (typeof m['id'] !== 'string') return null;
1944
+ const react = m['t'] === 'react';
1945
+ const by = m['by'] === 'watcher' || m['by'] === 'hub' || m['by'] === 'studio' ? m['by'] : 'player';
1946
+ return {
1947
+ id: m['id'], at: Number(m['at']) || now(), kind: react ? 'react' : by === 'studio' ? 'studio' : typeof m['say'] === 'string' ? 'line' : 'text',
1948
+ name: String(m['name'] ?? '').slice(0, 40), seat: typeof m['seat'] === 'number' ? m['seat'] : null, colour: typeof m['colour'] === 'number' ? m['colour'] : null, by,
1949
+ ...(typeof m['text'] === 'string' ? { text: m['text'] } : {}), ...(typeof m['say'] === 'string' ? { say: m['say'] } : {}),
1950
+ ...(react ? { react: String(m['react'] ?? ''), glyph: String(m['glyph'] ?? '') } : {}),
1951
+ ...(m['bubble'] === true ? { bubble: true } : {}), ...(m['acct'] === true ? { acct: true } : {}), ...(m['owner'] === true ? { owner: true } : {}),
1952
+ ...(typeof m['n'] === 'string' ? { mine: true } : {}),
1953
+ };
1954
+ }
1955
+ function chatSend(msg: Record<string, unknown>, bubble?: boolean): boolean {
1956
+ if (offline || asAgent) return false;
1957
+ chatSeq += 1;
1958
+ return raw({ ...msg, n: `g${chatSeq}`, ...((bubble ?? bubbleMine) ? {} : { bubble: false }) });
1959
+ }
1816
1960
  function readVote(m: Record<string, unknown>): VoteState | null {
1817
1961
  if (!Array.isArray(m['options']) || typeof m['id'] !== 'string') return null;
1818
1962
  return {
@@ -1894,6 +2038,15 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1894
2038
  },
1895
2039
  get hushed() { return hushed; },
1896
2040
  set hushed(on: boolean) { hushed = Boolean(on); },
2041
+ get chatRules() { return policy.chat ?? null; },
2042
+ get chatShown() { return chatShown; },
2043
+ say(text: string, o?: { bubble?: boolean }): boolean {
2044
+ const t = String(text ?? '').trim();
2045
+ if (!t) return false;
2046
+ return chatSend({ t: 'say', text: t.slice(0, 280) }, o?.bubble);
2047
+ },
2048
+ sayLine(id: string, o?: { bubble?: boolean }): boolean { return chatSend({ t: 'say', say: String(id ?? '').slice(0, 16) }, o?.bubble); },
2049
+ react(kind: string, o?: { bubble?: boolean }): boolean { return chatSend({ t: 'react', kind: String(kind ?? '').slice(0, 16) }, o?.bubble); },
1897
2050
  get asAgent() { return Boolean(asAgent); },
1898
2051
  ready,
1899
2052
  on(kind, fn) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.22.0",
3
+ "version": "0.24.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",
@@ -30,6 +30,10 @@
30
30
  "types": "./saves/saves.ts",
31
31
  "default": "./saves/saves.ts"
32
32
  },
33
+ "./shop": {
34
+ "types": "./shop/shop.ts",
35
+ "default": "./shop/shop.ts"
36
+ },
33
37
  "./agents": {
34
38
  "types": "./agents/agents.ts",
35
39
  "default": "./agents/agents.ts"
@@ -53,12 +57,14 @@
53
57
  "worker/",
54
58
  "mcp/",
55
59
  "saves/",
60
+ "shop/",
56
61
  "agents/",
57
62
  "lab/",
58
63
  "assets/",
59
64
  "starters/",
60
65
  "media/",
61
66
  "site/",
67
+ "chat/",
62
68
  "README.md",
63
69
  "CHANGELOG.md",
64
70
  "LICENSE",
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) and name labels that never pile up
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';