@homie-rocks/studio 0.53.0 → 0.55.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 (90) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/bin/homie-studio.mjs +1 -1
  3. package/guides/app/SKILL.md +2 -2
  4. package/guides/shop/SKILL.md +6 -0
  5. package/lib/app-needs.mjs +8 -1
  6. package/lib/background-queue.mjs +31 -0
  7. package/lib/build.mjs +5 -2
  8. package/lib/check.mjs +10 -2
  9. package/lib/cloudflare.mjs +16 -1
  10. package/lib/dev.mjs +8 -1
  11. package/lib/read-replication.mjs +26 -0
  12. package/lib/scaffold.mjs +46 -9
  13. package/lib/shop-links.mjs +37 -19
  14. package/lib/shop-mcp.mjs +7 -1
  15. package/lib/shop.mjs +7 -0
  16. package/lib/static-assets.mjs +27 -0
  17. package/lib/template-history.json +84 -0
  18. package/lib/worker-build.mjs +13 -4
  19. package/lib/worker-config.mjs +3 -1
  20. package/package.json +1 -1
  21. package/rules/view.ts +1 -1
  22. package/shop/SHOP.md +13 -9
  23. package/worker/analytics.mjs +84 -0
  24. package/worker/app-frame.mjs +1 -1
  25. package/worker/app-graphics.mjs +1 -1
  26. package/worker/app-records.mjs +4 -2
  27. package/worker/app-shell.mjs +9 -4
  28. package/worker/app-streams.mjs +27 -10
  29. package/worker/app-tools.mjs +1 -0
  30. package/worker/asset-paths.mjs +2 -0
  31. package/worker/background-queue.mjs +30 -0
  32. package/worker/build-id.mjs +1 -1
  33. package/worker/built-metadata.mjs +2 -0
  34. package/worker/catalogue.mjs +18 -0
  35. package/worker/chat-page.mjs +1 -1
  36. package/worker/chat-store.mjs +5 -4
  37. package/worker/embed.mjs +1 -1
  38. package/worker/event-resources.mjs +29 -12
  39. package/worker/function-registry.mjs +4 -0
  40. package/worker/functions.mjs +7 -5
  41. package/worker/gate-routing.mjs +61 -0
  42. package/worker/gate.mjs +12 -68
  43. package/worker/hosted-registry.mjs +11 -0
  44. package/worker/hosted.mjs +3 -17
  45. package/worker/index.mjs +58 -2772
  46. package/worker/lazy-objects.mjs +40 -0
  47. package/worker/lobby-limits.mjs +2 -0
  48. package/worker/lobby.mjs +251 -0
  49. package/worker/lounge.mjs +3 -3
  50. package/worker/mcp-event-streams.mjs +3 -3
  51. package/worker/mcp-events.mjs +4 -4
  52. package/worker/mcp-store.mjs +12 -1
  53. package/worker/mcp.mjs +5 -1
  54. package/worker/office.mjs +105 -34
  55. package/worker/operational-schema.mjs +44 -0
  56. package/worker/page-policy.mjs +29 -0
  57. package/worker/pages.mjs +15 -29
  58. package/worker/parts.mjs +2 -1
  59. package/worker/payment-recovery.mjs +33 -19
  60. package/worker/player-registry.mjs +9 -0
  61. package/worker/players.mjs +8 -7
  62. package/worker/purchase-machine.mjs +5 -3
  63. package/worker/reconcile-guard.mjs +22 -0
  64. package/worker/request-db.mjs +78 -0
  65. package/worker/responses.mjs +18 -0
  66. package/worker/room-chat.mjs +216 -0
  67. package/worker/room-controls.mjs +296 -0
  68. package/worker/room-lifecycle.mjs +10 -0
  69. package/worker/room-protocol.mjs +197 -0
  70. package/worker/room.mjs +38 -731
  71. package/worker/rooms-cache.mjs +19 -0
  72. package/worker/routes.mjs +667 -0
  73. package/worker/runtime-support.mjs +445 -0
  74. package/worker/seats.mjs +1 -1
  75. package/worker/selling/index.mjs +1 -0
  76. package/worker/selling/runtime.mjs +7 -5
  77. package/worker/servers.mjs +5 -4
  78. package/worker/service-run.mjs +1 -1
  79. package/worker/shop-links.mjs +2 -2
  80. package/worker/shop-page.mjs +2 -0
  81. package/worker/shop-rules.mjs +3 -2
  82. package/worker/shop-store.mjs +4 -3
  83. package/worker/shop.mjs +89 -27
  84. package/worker/stats.mjs +7 -6
  85. package/worker/stream-objects.mjs +18 -0
  86. package/worker/stripe-intake.mjs +79 -0
  87. package/worker/stripe.mjs +3 -0
  88. package/worker/table.mjs +1212 -0
  89. package/worker/tool-registry.mjs +10 -0
  90. package/worker/version.mjs +1 -1
@@ -0,0 +1,296 @@
1
+ /** Owner controls, moderation and launch transitions for a room. The relay owns its shared state. */
2
+
3
+
4
+
5
+
6
+ import { skillPreset } from './agents.mjs';
7
+
8
+ import { CHAT_LIMITS, HELD_WORDS, floor } from './chat.mjs';
9
+ import { STALL, stallOf, versionOf, CONTROL_LIMITS, oneLine, randomId, keptRef } from './room-protocol.mjs';
10
+ export class RoomControls {
11
+ constructor(room) {this.room=room;}
12
+ findClient({ id = null, seat = null } = {}) {
13
+ if (typeof id === 'string' && id) { const c = this.room.clients.get(id); if (c && c.helloed) return c; }
14
+ if (Number.isInteger(seat)) return this.room.live().find((c) => c.seat === seat) ?? null;
15
+ return null;
16
+ }
17
+ banOf(c, token = c.token) {
18
+ const now = this.room.now();
19
+ const pass = c.agent?.pass ?? c.agentWord?.pass ?? null;
20
+ return this.room.bans.find((b) => b.until > now && ((b.token && b.token === token) || (b.browser && b.browser === c.browser) || (b.player && b.player === c.player) || (b.ip && b.ip === c.ip) || (b.pass && b.pass === pass))) ?? null;
21
+ }
22
+ muteOf(c) {
23
+ const now = this.room.now();
24
+ return this.room.mutes.find((m) => m.until > now && ((m.token && m.token === c.token) || (m.browser && m.browser === c.browser) || (m.player && m.player === c.player))) ?? null;
25
+ }
26
+ liveAnnouncement() {
27
+ const a = this.room.announcement;
28
+ return a && a.until > this.room.now() ? a : null;
29
+ }
30
+ heldNotice(conn) {
31
+ const now = this.room.now();
32
+ if (this.room.closedUntil > now) return { t: 'closed', room: this.room.code, until: this.room.closedUntil, message: this.room.closedWhy || 'The studio closed this room.' };
33
+ const probe = { token: null, browser: conn.browser ?? null, player: conn.player ?? null, ip: null };
34
+ if (!probe.browser && !probe.player) return null;
35
+ const ban = this.room.banOf(probe, null);
36
+ if (ban) return { t: 'kicked', room: this.room.code, until: ban.until, message: ban.message };
37
+ const mute = this.room.muteOf(probe);
38
+ return mute ? { t: 'muted', room: this.room.code, until: mute.until } : null;
39
+ }
40
+ tellBrowser(who, msg) {
41
+ const text = JSON.stringify(msg);
42
+ for (const w of this.room.watchers) if ((who.browser && w.browser === who.browser) || (who.player && w.player === who.player)) this.room.sendText(w, text);
43
+ }
44
+ control(op, a = {}) {
45
+ const now = this.room.now();
46
+ const minutes = Math.max(1, Math.min(CONTROL_LIMITS.minutes, Math.floor(Number(a.minutes) || 10)));
47
+ this.room.bans = this.room.bans.filter((b) => b.until > now);
48
+ this.room.mutes = this.room.mutes.filter((m) => m.until > now);
49
+ switch (op) {
50
+ case 'kick': {
51
+ // From a chat line (section 19): its sender, in this room now or not (a watcher with no seat is held too). A line
52
+ // older than the window comes from the room's kept history (the Worker read it: its sender's account only).
53
+ const line = typeof a.line === 'string' ? this.room.chatLog.find((r) => r.id === a.line) ?? keptRef(a) : null;
54
+ const target = this.room.findClient(a) ?? (line?.from?.client ? this.room.clients.get(line.from.client) ?? null : null);
55
+ if (!target && line) return this.room.holdSender(line, minutes, now, { kick: true, message: oneLine(a.message, CONTROL_LIMITS.message) || 'The studio removed you from this room.', purge: a.purge === true });
56
+ if (!target) return { ok: false, error: 'no-player', message: 'nobody is in that seat now' };
57
+ const purged = a.purge === true ? this.room.unsay(this.room.linesOf({ client: target.id, token: target.token, browser: target.browser, player: target.player })) : 0;
58
+ const until = now + minutes * 60_000;
59
+ // The message is the studio's words; `until` says when (the shell says it in the player's own time).
60
+ const message = oneLine(a.message, CONTROL_LIMITS.message) || 'The studio removed you from this room.';
61
+ // An AI is held out by its pass (its seat becomes a brainless AI seat again); a person by token, browser, account.
62
+ const ban = { token: target.token, browser: target.browser, player: target.player, ip: a.address ? target.ip : null, pass: target.agent?.pass ?? null, name: line?.name ?? target.name, seat: target.seat, until, at: now, message };
63
+ this.room.bans = [...this.room.bans, ban].slice(-CONTROL_LIMITS.bans);
64
+ // Every socket of that player in this room: the seat itself, and its browser's or account's other tabs.
65
+ const out = this.room.live().filter((o) => o === target || (ban.browser && o.browser === ban.browser) || (ban.player && o.player === ban.player));
66
+ for (const o of out) {
67
+ const seat = o.seat;
68
+ const token = o.token;
69
+ this.room.error(o, 'kicked', message, { until });
70
+ this.room.onClose(o, 'kicked');
71
+ // The seat is free for somebody else at once, and its token resumes nothing.
72
+ const s = seat === null ? null : this.room.seats.get(seat);
73
+ if (s && s.token === token) { this.room.seats.delete(seat); this.room.seatsDirty = true; }
74
+ try { o.conn.close(1008, 'kicked'); } catch { /* gone */ }
75
+ }
76
+ this.room.stats.ownerKicks = (this.room.stats.ownerKicks ?? 0) + 1;
77
+ this.room.tellBrowser(ban, { t: 'kicked', room: this.room.code, until, message });
78
+ this.room.persist(now);
79
+ this.room.tellWatchers();
80
+ return { ok: true, op, name: ban.name, seat: ban.seat, sockets: out.length, until, ...(purged ? { removed: purged } : {}) };
81
+ }
82
+ case 'mute': {
83
+ const line = typeof a.line === 'string' ? this.room.chatLog.find((r) => r.id === a.line) ?? keptRef(a) : null;
84
+ const target = this.room.findClient(a) ?? (line?.from?.client ? this.room.clients.get(line.from.client) ?? null : null);
85
+ if (!target && line && a.off !== true) return this.room.holdSender(line, minutes, now, { kick: false, purge: a.purge === true });
86
+ if (!target) return { ok: false, error: 'no-player', message: 'nobody is in that seat now' };
87
+ const off = a.off === true;
88
+ const purged = !off && a.purge === true ? this.room.unsay(this.room.linesOf({ client: target.id, token: target.token, browser: target.browser, player: target.player })) : 0;
89
+ const same = (m) => (m.token && m.token === target.token) || (m.browser && m.browser === target.browser) || (m.player && m.player === target.player);
90
+ this.room.mutes = this.room.mutes.filter((m) => !same(m));
91
+ const until = off ? 0 : now + minutes * 60_000;
92
+ if (!off) this.room.mutes = [...this.room.mutes, { token: target.token, browser: target.browser, player: target.player, name: line?.name ?? target.name, seat: target.seat, until, at: now }].slice(-CONTROL_LIMITS.mutes);
93
+ const note = { t: 'mute', id: target.id, seat: target.seat, until };
94
+ for (const o of this.room.live()) this.room.send(o, note);
95
+ this.room.tellBrowser(target, { t: 'muted', room: this.room.code, until });
96
+ this.room.tellWatchers();
97
+ return { ok: true, op, name: line?.name ?? target.name, seat: target.seat, until, ...(purged ? { removed: purged } : {}) };
98
+ }
99
+ case 'card': {
100
+ // Show what you made (0.29.0): a link card to a studio's game, which the Worker read from that studio's own
101
+ // manifest and checked (its words passed the floor and the review there). Its sender's name and account are the
102
+ // Worker's word; a mute holds a card as it holds a line.
103
+ const c = a.card && typeof a.card === 'object' ? a.card : null;
104
+ if (!c || typeof c.url !== 'string' || !/^https?:\/\//.test(c.url)) return { ok: false, error: 'card', message: 'no card' };
105
+ const who = { token: null, browser: typeof a.browser === 'string' ? a.browser : null, player: typeof a.player === 'string' ? a.player : null, ip: null };
106
+ if (a.owner !== true && this.room.banOf(who, null)) return { ok: false, error: 'kicked', message: 'The studio has held you out of this room for now.' };
107
+ if (a.owner !== true && this.room.muteOf(who)) return { ok: false, error: 'muted', message: HELD_WORDS.muted };
108
+ const card = { url: c.url.slice(0, 300), title: oneLine(c.title, 80), studio: oneLine(c.studio, 60), pitch: oneLine(c.pitch, 160), image: typeof c.image === 'string' && /^https:\/\//.test(c.image) ? c.image.slice(0, 400) : null, ...(typeof c.game === 'string' ? { game: c.game.slice(0, 40) } : {}) };
109
+ const note = oneLine(a.note, CHAT_LIMITS.text);
110
+ const rules = this.room.chatRules();
111
+ if (rules.mode !== 'text' && a.owner !== true) return { ok: false, error: 'held', why: rules.mode === 'off' ? 'off' : 'lines', message: 'The Lounge isn\'t taking cards right now.' };
112
+ // The card's words (the person's note, the game's own title and pitch) pass the floor like any typed line.
113
+ const words = [note, card.title, card.pitch].filter(Boolean).join(' · ');
114
+ const f = floor(words, { ...rules, links: 'block' });
115
+ if (!f.ok && a.owner !== true) return { ok: false, error: 'held', why: f.why, message: HELD_WORDS[f.why] ?? HELD_WORDS.unknown };
116
+ const rec = {
117
+ kind: 'card', card, text: note || `Made: ${card.title}${card.studio ? ` by ${card.studio}` : ''}`,
118
+ name: oneLine(a.name, CHAT_LIMITS.name) || 'Someone', seat: null, colour: null, by: 'watcher', bubble: false,
119
+ acct: a.acct === true, owner: a.owner === true, ...(a.mod === true ? { mod: true } : {}),
120
+ from: { client: null, token: null, browser: who.browser, player: who.player },
121
+ };
122
+ // And the studio's review, as a typed line gets it (the owner's own card is never reviewed).
123
+ if (rules.ai && typeof this.room.review === 'function' && a.owner !== true) {
124
+ return Promise.resolve()
125
+ .then(() => this.room.review(words, { room: this.room.code, links: 'block' }))
126
+ .then((v) => v, () => ({ ok: true, by: 'error' }))
127
+ .then((v) => {
128
+ if (v && v.ok === false) { this.room.stats.chatAiHeld = (this.room.stats.chatAiHeld ?? 0) + 1; return { ok: false, error: 'held', why: 'ai', message: HELD_WORDS.ai }; }
129
+ rec.review = { by: v?.by ?? 'ai' };
130
+ if (this.room.muteOf(who)) return { ok: false, error: 'muted', message: HELD_WORDS.muted };
131
+ return { ...this.room.publishChat(rec), op };
132
+ });
133
+ }
134
+ return { ...this.room.publishChat(rec), op };
135
+ }
136
+ case 'unsay': {
137
+ // Room chat (section 19): the owner takes a message down (or every message: `all`), on every screen at once.
138
+ const ids = a.all === true ? this.room.chatLog.map((r) => r.id) : (Array.isArray(a.ids) ? a.ids : [a.id]).filter((x) => typeof x === 'string' && /^[A-Za-z0-9_-]{1,16}$/.test(x)).slice(0, 64);
139
+ if (!ids.length) return { ok: false, error: 'no-line', message: 'which message?' };
140
+ const removed = this.room.unsay(ids);
141
+ this.room.stats.chatRemoved = (this.room.stats.chatRemoved ?? 0) + removed;
142
+ return { ok: true, op, removed };
143
+ }
144
+ case 'announce': {
145
+ const text = oneLine(a.text, CONTROL_LIMITS.announce);
146
+ const prev = this.room.announcement;
147
+ if (!text) {
148
+ this.room.announcement = null;
149
+ if (prev) { const clear = JSON.stringify({ t: 'announce', id: prev.id, text: null }); for (const o of this.room.live()) this.room.sendText(o.conn, clear); for (const w of this.room.watchers) this.room.sendText(w, clear); }
150
+ this.room.tellWatchers();
151
+ return { ok: true, op, cleared: Boolean(prev) };
152
+ }
153
+ const seconds = Math.max(5, Math.min(CONTROL_LIMITS.announceSeconds, Math.floor(Number(a.seconds) || 30)));
154
+ this.room.announcement = { id: typeof a.id === 'string' && /^[A-Za-z0-9_-]{1,24}$/.test(a.id) ? a.id : randomId(6), text, at: now, until: now + seconds * 1000, from: 'studio' };
155
+ const msg = JSON.stringify({ t: 'announce', ...this.room.announcement });
156
+ for (const o of this.room.live()) this.room.sendText(o.conn, msg);
157
+ for (const w of this.room.watchers) this.room.sendText(w, msg);
158
+ // The room's chat shows it too, as the studio's own line (section 19).
159
+ this.room.publishChat({ kind: 'studio', text, name: 'Studio', seat: null, colour: null, by: 'studio', bubble: false, acct: false, owner: true, from: null });
160
+ this.room.tellWatchers();
161
+ return { ok: true, op, id: this.room.announcement.id, people: this.room.live().length, until: this.room.announcement.until };
162
+ }
163
+ case 'close': {
164
+ if (a.reopen === true) { this.room.closedUntil = 0; this.room.closedWhy = ''; this.room.tellWatchers(); return { ok: true, op, reopened: true }; }
165
+ // `seconds` (10 s to a day) is a short close: everyone comes back through the game's door (a launch state change).
166
+ const seconds = Math.floor(Number(a.seconds));
167
+ const until = now + (seconds >= 10 ? Math.min(seconds, CONTROL_LIMITS.minutes * 60) * 1000 : minutes * 60_000);
168
+ const message = oneLine(a.message, CONTROL_LIMITS.message) || 'The studio closed this room. Thanks for playing!';
169
+ this.room.closedUntil = until;
170
+ this.room.closedWhy = message;
171
+ const people = this.room.live().length;
172
+ for (const c of [...this.room.clients.values()]) {
173
+ if (c.helloed) this.room.error(c, 'room-closed', message, { until });
174
+ this.room.clients.delete(c.id);
175
+ try { c.conn.close(1000, 'room-closed'); } catch { /* gone */ }
176
+ }
177
+ // Everything the room held goes, as when an empty room forgets (section 4).
178
+ this.room.hostId = null;
179
+ this.room.lastSnap = null; this.room.lastSnapText = null; this.room.lastCkpt = null; this.room.lastRound = null; this.room.lastRoster = null; this.room.rosterSent = null;
180
+ this.room.state.clear(); this.room.stateBytes = 0; this.room.seats.clear(); this.room.preferHost = null; this.room.openedAt = 0; this.room.askedMax = null;
181
+ this.room.emptySince = now; this.room.seatsDirty = false; this.room.persistDirty = false;
182
+ try { this.room.store?.clear?.(); } catch { /* best effort */ }
183
+ this.room.hosted.clear();
184
+ try { this.room.onForget?.(); } catch { /* the room is closed either way */ }
185
+ const msg = JSON.stringify({ t: 'closed', room: this.room.code, until, message });
186
+ for (const w of this.room.watchers) this.room.sendText(w, msg);
187
+ this.room.tellWatchers();
188
+ return { ok: true, op, people, until };
189
+ }
190
+ case 'regate': {
191
+ // A game's launch state changed (NETPLAY.md section 15): who may play is now narrower (`allow`: ticket kinds o,
192
+ // i, p), or anyone again (`allow` absent: a pending change is cancelled). The current round finishes first.
193
+ if (!Array.isArray(a.allow)) { const had = Boolean(this.room.regate); this.room.regate = null; this.room.tellWatchers(); return { ok: true, op, cancelled: had }; }
194
+ const allow = a.allow.filter((k) => ['o', 'i', 'p'].includes(k));
195
+ const leaving = this.room.live().filter((c) => !this.room.allowedAfter(c, allow)).length;
196
+ if (!leaving) { this.room.regate = null; this.room.tellWatchers(); return { ok: true, op, leaving: 0 }; }
197
+ const r = this.room.lastRound;
198
+ const playing = Boolean(r && r.phase === 'live' && Number(r.endsAt) > now);
199
+ const until = Math.min(now + 15 * 60_000, playing ? Number(r.endsAt) + 20_000 : now + 30_000);
200
+ this.room.regate = { allow, roundN: playing ? Number(r.n) : null, until, at: now, message: oneLine(a.message, CONTROL_LIMITS.message) || 'Thanks for playing!' };
201
+ const notice = oneLine(a.notice, CONTROL_LIMITS.announce);
202
+ if (notice) this.room.control('announce', { text: notice, seconds: Math.ceil((until - now) / 1000) + 5 });
203
+ this.room.tellWatchers();
204
+ return { ok: true, op, leaving, until, afterRound: playing ? Number(r.n) : null };
205
+ }
206
+ case 'seats': {
207
+ const n = Math.floor(Number(a.max));
208
+ if (!(n >= 1)) return { ok: false, error: 'max', message: 'seats is a number of at least 1' };
209
+ this.room.setSeats(n);
210
+ this.room.tellWatchers();
211
+ return { ok: true, op, max: this.room.maxPlayers };
212
+ }
213
+ case 'policy': {
214
+ // The owner changed the server (section 17): its new policy now, and AI out after the round if it is humans-only.
215
+ const r = this.room.setPolicy(a.pol, { force: true });
216
+ return r.ok ? { ...r, op } : { ...r, op };
217
+ }
218
+ case 'level': {
219
+ // The owner sets this room's dial (room_level): it applies at once, as a vote's result does.
220
+ const n = Math.floor(Number(a.level));
221
+ if (!(n >= 1 && n <= 5)) return { ok: false, error: 'level', message: 'level is 1 to 5' };
222
+ this.room.level = Math.min(this.room.policy.levelMax, n);
223
+ this.room.levelBy = 'owner';
224
+ this.room.seatsDirty = true;
225
+ this.room.tellPolicy();
226
+ this.room.tellWatchers();
227
+ return { ok: true, op, level: this.room.level, name: skillPreset(this.room.level, { kids: this.room.policy.kids }).name };
228
+ }
229
+ default:
230
+ return { ok: false, error: 'op', message: `no control called ${String(op).slice(0, 24)}` };
231
+ }
232
+ }
233
+ allowedAfter(c, allow) {
234
+ // The owner's own agent passes count as the owner's (only the owner can mint one).
235
+ return String(c.via ?? '').split('~').some((part) => (part === 'o' && allow.includes('o')) || (part.startsWith('i-') && allow.includes('i')) || (part.startsWith('p-') && allow.includes('p'))
236
+ || (part.startsWith('a-') && allow.includes('o') && c.agent?.by === 'studio'));
237
+ }
238
+ applyRegate(now = this.room.now()) {
239
+ const g = this.room.regate;
240
+ if (!g) return 0;
241
+ this.room.regate = null;
242
+ // The holder of the room's storage keeps the owner's state (the Table writes it on its next tick).
243
+ this.room.officeDirty = true;
244
+ const out = this.room.live().filter((c) => !this.room.allowedAfter(c, g.allow));
245
+ for (const c of out) {
246
+ const seat = c.seat;
247
+ const token = c.token;
248
+ this.room.error(c, 'room-closed', g.message);
249
+ this.room.onClose(c, 'regated');
250
+ const s = seat === null ? null : this.room.seats.get(seat);
251
+ if (s && s.token === token) { this.room.seats.delete(seat); this.room.seatsDirty = true; }
252
+ try { c.conn.close(1000, 'room-closed'); } catch { /* gone */ }
253
+ this.room.tellBrowser(c, { t: 'closed', room: this.room.code, message: g.message });
254
+ }
255
+ this.room.persist(now);
256
+ this.room.tellWatchers();
257
+ return out.length;
258
+ }
259
+ setStall(ms) {
260
+ this.room.stallMs = stallOf(ms) ?? STALL.ms;
261
+ return this.room.stallMs;
262
+ }
263
+ setCurrent(ver) {
264
+ this.room.currentVer = ver === undefined ? undefined : versionOf(ver);
265
+ }
266
+ forgetWorld(next) {
267
+ this.room.log({ ev: 'build-changed', room: this.room.code, from: this.room.gameVer ?? null, to: next ?? null });
268
+ this.room.lastSnap = null; this.room.lastSnapText = null; this.room.lastCkpt = null; this.room.lastRound = null; this.room.lastRoster = null; this.room.rosterSent = null;
269
+ this.room.state.clear(); this.room.stateBytes = 0; this.room.preferHost = null;
270
+ this.room.persistDirty = true;
271
+ }
272
+ setSeats(n, perIp = null) {
273
+ this.room.seatCap = Math.max(1, Math.floor(n));
274
+ this.room.maxPlayers = Math.max(1, Math.min(this.room.seatCap, (this.room.server || this.room.rules) ? this.room.seatCap : this.room.askedMax ?? this.room.seatCap));
275
+ if (Number.isFinite(perIp) && perIp >= 0) this.room.perIp = perIp;
276
+ }
277
+ officeSaved() {
278
+ const now = this.room.now();
279
+ return {
280
+ bans: this.room.bans.filter((b) => b.until > now), mutes: this.room.mutes.filter((m) => m.until > now),
281
+ announcement: this.room.liveAnnouncement(), closedUntil: this.room.closedUntil > now ? this.room.closedUntil : 0, closedWhy: this.room.closedWhy,
282
+ regate: this.room.regate, agentsOut: this.room.agentsOut,
283
+ };
284
+ }
285
+ restoreOffice(o) {
286
+ if (!o || typeof o !== 'object') return;
287
+ const now = this.room.now();
288
+ this.room.bans = (Array.isArray(o.bans) ? o.bans : []).filter((b) => b && b.until > now).slice(-CONTROL_LIMITS.bans);
289
+ this.room.mutes = (Array.isArray(o.mutes) ? o.mutes : []).filter((m) => m && m.until > now).slice(-CONTROL_LIMITS.mutes);
290
+ this.room.announcement = o.announcement && o.announcement.until > now ? o.announcement : null;
291
+ this.room.closedUntil = Number(o.closedUntil) > now ? Number(o.closedUntil) : 0;
292
+ this.room.closedWhy = this.room.closedUntil ? String(o.closedWhy ?? '') : '';
293
+ // A launch change still waiting comes back; one long past is over (whoever it was for has gone).
294
+ this.room.regate = o.regate && Array.isArray(o.regate.allow) && Number(o.regate.until) > now - 120_000 ? o.regate : null;
295
+ this.room.agentsOut = o.agentsOut && Number(o.agentsOut.until) > now - 120_000 ? o.agentsOut : null;
296
+ }}
@@ -0,0 +1,10 @@
1
+ export const ROOM_ALARM_MS = 10 * 60_000;
2
+
3
+ export const ROOM_PAUSE_MS = 60_000;
4
+
5
+ export function roomEndAt({ people, pausedAt, createdAt, restoredAt = null, savedAt = null, alarmAt = null }) {
6
+ // Only people keep play alive. With no durable alarm, the last save bounds an unrecorded departure.
7
+ const absence = pausedAt ?? (restoredAt !== null && alarmAt === null ? savedAt : null) ?? restoredAt ?? createdAt;
8
+ return people > 0 ? Infinity : absence + ROOM_PAUSE_MS;
9
+ }
10
+
@@ -0,0 +1,197 @@
1
+
2
+
3
+ /** Shared wire constants and validation; importing these never loads the relay engine. */
4
+
5
+ export { LIMITS, GROWS, capOf } from './limits.mjs';
6
+
7
+
8
+ import { CHAT_LIMITS, normalizeChat } from './chat.mjs';
9
+
10
+ export const NET_VERSION = 1;
11
+ /** The contract revision this relay speaks (NETPLAY.md): optional fields, frames and refusals; the wire stays `v: 1`. */
12
+ export const NET_REVISION = 12;
13
+ /**
14
+ * Revision 9 (NETPLAY.md sections 22 and 23). STALL: how long a host may send no snapshot, while others are present,
15
+ * before the room is handed on: 1.5 s unless the game names its own (game.json `netplay.stallMs`), never under
16
+ * 1.5 s (a shorter one swaps hosts on a single slow frame) and never over 10 s (a frozen host must still be replaced
17
+ * while its players are watching a still picture).
18
+ */
19
+ export const STALL = Object.freeze({ ms: 1500, min: 1500, max: 10_000 });
20
+ /** A game's own stall time, held to STALL's bounds; null when it names none (or not a number). */
21
+ export function stallOf(v) {
22
+ const n = Math.floor(Number(v));
23
+ return Number.isFinite(n) && n > 0 ? Math.max(STALL.min, Math.min(STALL.max, n)) : null;
24
+ }
25
+ const VERSION_TEXT = /^[A-Za-z0-9._-]{1,32}$/;
26
+ /** A game revision (game.json `netplay.version`): 1 to 32 of A-Z a-z 0-9 . _ -, a number as its digits; else null. */
27
+ export function versionOf(v) {
28
+ const t = typeof v === 'number' && Number.isFinite(v) ? String(v) : typeof v === 'string' ? v.trim() : '';
29
+ return VERSION_TEXT.test(t) ? t : null;
30
+ }
31
+ const FEATURE_TEXT = /^[a-z0-9][a-z0-9-]{0,23}$/;
32
+ /** What a build says it can do (`hello.feat`): up to 8 short lowercase words. */
33
+ export function featuresOf(list) {
34
+ if (!Array.isArray(list)) return [];
35
+ const out = [];
36
+ for (const x of list) { const k = String(x ?? '').toLowerCase(); if (FEATURE_TEXT.test(k) && !out.includes(k)) out.push(k); if (out.length >= 8) break; }
37
+ return out;
38
+ }
39
+ /**
40
+ * A SOCKET THAT WENT AWAY IS NOT A FAILED ROOM. A browser that closes its tab, loses its network or is replaced by a
41
+ * new host ends its socket without a goodbye, and the runtime reports that as an error ("Network connection lost",
42
+ * "WebSocket peer disconnected"…). Those are departures: the room has already handled them (the seat is held, the
43
+ * host is replaced). Anything else thrown while the room was doing something IS a failure, and is logged with the
44
+ * room and what it was doing. `departure(error)` says which; the transport (the Table) asks it at its boundary.
45
+ */
46
+ const DEPARTURE = /network connection lost|connection (?:was )?(?:closed|reset|aborted|lost)|peer disconnected|websocket (?:is )?(?:closed|closing|not open|already closed)|socket (?:is )?closed|client disconnected|disconnected|broken pipe|econnreset|epipe|aborted|cancell?ed|the script will never generate a response|durable object reset/i;
47
+ export function departure(error) {
48
+ const text = typeof error === 'string' ? error : `${error?.name ?? ''} ${error?.message ?? ''} ${error?.code ?? ''}`;
49
+ return DEPARTURE.test(text);
50
+ }
51
+ /** An error as a log line carries it: its name and message, cut short. Never the frame it was thrown for. */
52
+ export function errorLine(error) {
53
+ return String(error?.stack && typeof error.stack === 'string' ? error.stack.split('\n').slice(0, 4).join(' | ') : error?.message ?? error).slice(0, 400);
54
+ }
55
+ const POLICY_KINDS = ['open', 'humans-only', 'hybrid', 'beginner'];
56
+ /**
57
+ * A room's policy before the Worker says anything (and the public server's with nothing set): today's behaviour.
58
+ * NETPLAY.md section 17: `server` null is a named room; `aiSeats + guides` seats are kept for AI (the top seats).
59
+ */
60
+ export const DEFAULT_POLICY = Object.freeze({ v: 1, at: 0, server: null, kind: 'open', aiSeats: 0, guides: 0, bots: 'fill', level: 3, levelMax: 5, speech: 'game', kids: false, brain: 'script' });
61
+ /** An agent's speech: at most one line every 4 s and 8 a minute (the relay drops the rest and counts them). */
62
+ export const AGENT_SPEECH = Object.freeze({ gapMs: 4000, perMinute: 8 });
63
+ /** The party's vote on the dial: open 15 s (or until every seated person voted); one opened by a player per 2 min. */
64
+ export const VOTE = Object.freeze({ ms: 15_000, cooldownMs: 120_000, autoMs: 30 * 60_000 });
65
+ /** Agents left with no person seated for this long are closed (`agents-alone`): an AI never keeps a room alive. */
66
+ export const AGENTS_ALONE_MS = 60_000;
67
+ const CAPS = ['skill', 'agents'];
68
+ /** Revision 7: what a game's host shows an AI (`agent:view`) and how often an AI may act (`agent:do`). */
69
+ export const AGENT_FRAMES = Object.freeze({ viewMs: 2000, viewBytes: 2048, doMs: 3000 });
70
+ const SAY = /^say:([a-z][a-z0-9_]{0,31})$/;
71
+ const d0 = (m) => (m.d && typeof m.d === 'object' && !Array.isArray(m.d) ? m.d : {});
72
+ const ASK = /^ask:([a-z][a-z0-9_]{0,31})$/;
73
+
74
+ /** A policy as the relay keeps it: every field checked (the Worker composed it, but a bad one must not break a room). */
75
+ export function normalizePolicy(p) {
76
+ if (!p || typeof p !== 'object') return null;
77
+ const int = (v, lo, hi, d) => { const n = Math.floor(Number(v)); return Number.isFinite(n) ? Math.max(lo, Math.min(hi, n)) : d; };
78
+ const kind = POLICY_KINDS.includes(p.kind) ? p.kind : 'open';
79
+ const kids = p.kids === true;
80
+ const levelMax = int(p.levelMax, 1, kids ? 3 : 5, kids ? 3 : 5);
81
+ const server = p.server && typeof p.server === 'object' && /^[a-z0-9][a-z0-9-]{0,19}$/.test(String(p.server.id ?? ''))
82
+ ? { id: String(p.server.id), name: String(p.server.name ?? p.server.id).replace(/[\u0000-\u001f\u007f]+/g, ' ').slice(0, 40) } : null;
83
+ const reserveOk = kind === 'hybrid' || kind === 'beginner';
84
+ return {
85
+ v: 1, at: Math.max(0, Number(p.at) || 0), server, kind,
86
+ aiSeats: reserveOk ? int(p.aiSeats, 0, 31, 0) : 0, guides: kind === 'beginner' ? int(p.guides, 0, 31, 0) : 0,
87
+ bots: p.bots === 'off' ? 'off' : 'fill', level: Math.min(levelMax, int(p.level, 1, 5, 3)), levelMax,
88
+ speech: ['game', 'lines', 'off'].includes(p.speech) ? p.speech : 'game', kids,
89
+ brain: kind === 'humans-only' ? 'off' : ['off', 'script', 'workers-ai', 'owner-key'].includes(p.brain) ? p.brain : 'script',
90
+ // Revision 8: the room's chat rules (section 19), capped by the server's speech and kids.
91
+ ...(p.chat !== undefined ? { chat: normalizeChat(p.chat === false ? { mode: 'off' } : p.chat, { kids, speech: ['game', 'lines', 'off'].includes(p.speech) ? p.speech : 'game' }) } : {}),
92
+ };
93
+ }
94
+ const policySig = (p) => JSON.stringify({ ...p, at: 0 });
95
+ /** The lower middle of the votes (DESIGN D8: a tie is settled kindly), or null with none. */
96
+ export function medianVote(list) {
97
+ const v = [...list].filter(Number.isFinite).sort((a, b) => a - b);
98
+ return v.length ? v[Math.floor((v.length - 1) / 2)] : null;
99
+ }
100
+ const utf8 = new TextEncoder();
101
+ // A message's size in bytes. Text that is all ASCII (nearly every frame a game sends) is as long as it is wide: it is
102
+ // not encoded to be measured, which at ten inputs a second from a thousand players was a twentieth of a room's second.
103
+ const ASCII = /^[\x00-\x7f]*$/;
104
+ const byteLength = text => ASCII.test(text) ? text.length : utf8.encode(text).length;
105
+ const PALETTE_SIZE = 12;
106
+ const DEVICE_RANK = { desk: 0, tv: 1, phone: 2 };
107
+
108
+ /** Rate caps (messages per rolling second, per client). Over the cap a message is dropped, counted and reported. */
109
+ export { RATES, EV_RATES, STATE_CAPS } from './limits.mjs';
110
+
111
+ /**
112
+ * THE OWNER'S CONTROLS (revision 4, NETPLAY.md section 15). The studio's Worker, acting for the signed-in owner,
113
+ * hands the room a control: kick a player out (and hold the door for some minutes), mute one, announce a line to
114
+ * everyone, close the room, or change its seats. The Table verifies the owner's signature before it calls
115
+ * `control()`; this file only applies one. What a player sees of them is new optional frames and two new refusals,
116
+ * so a v1 game that knows none of it keeps playing (its shell shows the notice and the banner).
117
+ */
118
+ export const CONTROL_LIMITS = Object.freeze({ minutes: 24 * 60, announce: 280, announceSeconds: 3600, bans: 200, mutes: 200, message: 200 });
119
+ /** What a muted player cannot say: an `ev` whose kind starts with one of these (a game's chat, quick lines, emotes). */
120
+ export const SPEECH = /^(?:say|chat|emote)/i;
121
+ /**
122
+ * The contract's 12 player colours: a peer's `colour` (its seat % 12) is an index into this list. The watch page's
123
+ * player strip draws them; a game that colours its players the same way matches it (Gem Rush does).
124
+ */
125
+ export const NET_PALETTE = Object.freeze(['#8fe36a', '#ffd166', '#ef6f6c', '#6cb4ee', '#c792ea', '#f4a261', '#2ec4b6', '#ff8fab', '#a7c957', '#e9c46a', '#90e0ef', '#f28482']);
126
+ /**
127
+ * What a game lets its watchers see (game.json "watch", passed by the Worker as `conn.watchPolicy`): `follow` (any
128
+ * player's view, the default), `overview` (the whole room only: a game with hidden hands or hidden roles), `off`.
129
+ */
130
+ export const WATCH_POLICIES = Object.freeze(['follow', 'overview', 'off']);
131
+ const oneLine = (v, max) => String(v ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
132
+ /** A short tag for a browser's room key: enough for the owner to see "the same browser", never the key itself. */
133
+ export function browserTag(key) {
134
+ if (!key) return null;
135
+ let h = 2166136261;
136
+ for (const ch of String(key)) h = Math.imul(h ^ ch.charCodeAt(0), 16777619) >>> 0;
137
+ return h.toString(36).padStart(6, '0').slice(-6);
138
+ }
139
+
140
+ /**
141
+ * A seat's handle when its player typed no name: two words, varied per seat and per room ("Velvet Comet"), never
142
+ * "Player 1" (a game that falls back to a list by seat number made every room's first player the same name).
143
+ * At most 16 characters, so a game that trims names at 18 keeps all of it.
144
+ */
145
+ export const HANDLE_WORDS = Object.freeze({
146
+ first: ['Neon', 'Velvet', 'Static', 'Amber', 'Cobalt', 'Lucky', 'Rapid', 'Silent', 'Solar', 'Lunar', 'Crimson', 'Golden',
147
+ 'Frosty', 'Wild', 'Quiet', 'Swift', 'Brave', 'Clever', 'Cosmic', 'Mellow', 'Rusty', 'Shiny', 'Sunny', 'Stormy', 'Turbo',
148
+ 'Pixel', 'Retro', 'Jolly', 'Nimble', 'Bold', 'Misty', 'Copper'],
149
+ second: ['Comet', 'Otter', 'Falcon', 'Fox', 'Panda', 'Rocket', 'Tiger', 'Raven', 'Pilot', 'Ranger', 'Rider', 'Moth', 'Koi',
150
+ 'Lynx', 'Heron', 'Badger', 'Yeti', 'Wolf', 'Bison', 'Gecko', 'Orca', 'Puma', 'Sparrow', 'Beacon', 'Meteor', 'Nova',
151
+ 'Ember', 'Drift', 'Spark', 'Echo', 'Kite', 'Mantis'],
152
+ });
153
+ /** The handle for a seat token (random per seat), skipping any handle another seat in the room already has. */
154
+ export function handleFor(token, taken = new Set()) {
155
+ let h = 2166136261;
156
+ for (const ch of String(token ?? '')) h = Math.imul(h ^ ch.charCodeAt(0), 16777619) >>> 0;
157
+ const { first, second } = HANDLE_WORDS;
158
+ const all = first.length * second.length;
159
+ for (let i = 0; i < all; i += 1) {
160
+ const n = (h + i * 7919) % all;
161
+ const name = `${first[n % first.length]} ${second[Math.floor(n / first.length) % second.length]}`;
162
+ if (!taken.has(name)) return name;
163
+ }
164
+ return `${first[h % first.length]} ${second[h % second.length]}`;
165
+ }
166
+
167
+ /** Random bytes as base64url, with no Node Buffer (runs in workerd too). */
168
+ function randomId(n) {
169
+ const raw = new Uint8Array(n);
170
+ crypto.getRandomValues(raw);
171
+ let s = '';
172
+ for (const b of raw) s += String.fromCharCode(b);
173
+ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
174
+ }
175
+
176
+ /** Press counts are summed by the host: clamp them so `{fire: 1e9}` is 8, not a billion loop turns. */
177
+ function clampPresses(p) {
178
+ if (!p || typeof p !== 'object' || Array.isArray(p)) return null;
179
+ const out = {};
180
+ let n = 0;
181
+ for (const [k, v] of Object.entries(p)) {
182
+ if (n >= 16) break;
183
+ const c = Math.max(0, Math.min(8, Math.floor(Number(v) || 0)));
184
+ if (c) { out[String(k).slice(0, 32)] = c; n += 1; }
185
+ }
186
+ return n ? out : null;
187
+ }
188
+
189
+ /** A kept line the Worker read from the room's history (its id, name and sender's account), for a mute or a kick. */
190
+ function keptRef(a) {
191
+ const k = a.kept && typeof a.kept === 'object' ? a.kept : null;
192
+ if (!k || k.id !== a.line || !k.from || typeof k.from.player !== 'string') return null;
193
+ return { id: k.id, name: oneLine(k.name, CHAT_LIMITS.name) || 'Someone', seat: null, from: { client: null, token: null, browser: null, player: k.from.player } };
194
+ }
195
+
196
+
197
+ export {VERSION_TEXT,FEATURE_TEXT,DEPARTURE,POLICY_KINDS,CAPS,SAY,d0,ASK,policySig,utf8,ASCII,byteLength,PALETTE_SIZE,DEVICE_RANK,oneLine,randomId,clampPresses,keptRef};