@homie-rocks/studio 0.28.0 → 0.29.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 +30 -1
- package/README.md +30 -1
- package/bin/homie-studio.mjs +24 -0
- package/chat/CHAT.md +5 -1
- package/chat/LOUNGE.md +114 -0
- package/chat/OWNERS.md +6 -2
- package/lib/build.mjs +5 -0
- package/lib/lounge-cli.mjs +70 -0
- package/lib/mcp-tools.mjs +6 -0
- package/lib/scaffold.mjs +13 -2
- package/lib/template-history.json +40 -0
- package/mcp/ui/setup.js +2 -0
- package/netplay/NETPLAY.md +11 -1
- package/package.json +1 -1
- package/site/SITE.md +13 -0
- package/worker/chat-store.mjs +2 -1
- package/worker/chat.mjs +13 -1
- package/worker/discover.mjs +5 -0
- package/worker/index.mjs +38 -3
- package/worker/lounge-page.mjs +623 -0
- package/worker/lounge-store.mjs +281 -0
- package/worker/lounge.mjs +547 -0
- package/worker/office-page.mjs +99 -0
- package/worker/office.mjs +19 -1
- package/worker/players.mjs +5 -0
- package/worker/room.mjs +116 -12
- package/worker/site.mjs +6 -1
- package/worker/version.mjs +1 -1
package/worker/chat-store.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ROOM CHAT IN THE STUDIO'S OWN D1 (@homie-rocks/studio 0.23.0, NETPLAY.md section 19): the owner's chat rules per game
|
|
3
3
|
* and per server, and the reports players make. Never the chat itself: what people say lives a few minutes in the
|
|
4
|
-
* room's memory and nowhere else (worker/room.mjs)
|
|
4
|
+
* room's memory and nowhere else (worker/room.mjs), unless an owner turns a room's `history` on (0.29.0: then
|
|
5
|
+
* worker/lounge-store.mjs keeps it for that many days). A report keeps the one message it is about, for 30 days.
|
|
5
6
|
*
|
|
6
7
|
* chat_rules (game, server, rules JSON, updated_at): server '' is the whole game; a server's row wins over it
|
|
7
8
|
* chat_reports one reported message: its words, its sender's room name and (when signed in) account id, the room,
|
package/worker/chat.mjs
CHANGED
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
*
|
|
21
21
|
* Emoji and quick lines are the studio's own words: they never need a review, so they reach every screen at once.
|
|
22
22
|
* NOTHING is stored here: the relay keeps a short window in memory (section 19), and a report keeps only the message.
|
|
23
|
+
* A room keeps its words longer only when its owner turns `history` on (a number of days, 0.29.0; off for every game
|
|
24
|
+
* by default, never on a kids server): then the Table writes what was said, never a reaction, to the studio's own D1
|
|
25
|
+
* (worker/lounge-store.mjs) and forgets it after that many days.
|
|
23
26
|
*/
|
|
24
27
|
import { WORDS_B64 } from './chat-words.mjs';
|
|
25
28
|
import { clefRun, localAiOf } from './brain.mjs';
|
|
@@ -55,6 +58,8 @@ export const CHAT_LIMITS = Object.freeze({
|
|
|
55
58
|
/** A typed message: `max` is the room's, at most this (homie.rocks's LINE_MAX). */
|
|
56
59
|
text: 280, minText: 20, name: 24,
|
|
57
60
|
lineText: 60, lines: 12, extraEmoji: 3, words: 64, word: 32, slowMax: 120,
|
|
61
|
+
/** `history`: the most days a room may keep what was said (0, the default, keeps nothing past the window). */
|
|
62
|
+
historyMax: 90,
|
|
58
63
|
/** The room's window: the last `keep` messages from the last `keepMs` (memory only), for a page that just opened. */
|
|
59
64
|
keep: 50, keepMs: 15 * 60_000,
|
|
60
65
|
/** The same words from the same person within this long are not sent again. */
|
|
@@ -77,9 +82,12 @@ export const CHAT_RATES = Object.freeze({
|
|
|
77
82
|
/** A game with no "chat" in its game.json: emoji and quick lines for everyone, typing for signed-in players (reviewed). */
|
|
78
83
|
export const CHAT_DEFAULTS = Object.freeze({
|
|
79
84
|
mode: 'text', who: 'signed-in', react: 'anyone', slow: 2, max: 140, links: 'block', swears: 'block', ai: true,
|
|
80
|
-
bubbles: true, watchers: true, hub: true,
|
|
85
|
+
bubbles: true, watchers: true, hub: true, history: 0,
|
|
81
86
|
});
|
|
82
87
|
|
|
88
|
+
/** How long a room with these rules keeps what was said: its `history` days, else the window's few minutes. */
|
|
89
|
+
export const windowMsOf = (rules) => (rules?.history > 0 ? rules.history * 86_400_000 : CHAT_LIMITS.keepMs);
|
|
90
|
+
|
|
83
91
|
const ID = /^[a-z][a-z0-9_]{0,15}$/;
|
|
84
92
|
const PICTO = /\p{Extended_Pictographic}/u;
|
|
85
93
|
const oneLine = (v, max) => [...String(v ?? '').normalize('NFKC').replace(/[\p{Cc}\p{Cf}\p{Co}\p{Cs}\u2028\u2029]+/gu, ' ').replace(/\s+/g, ' ').trim()].slice(0, max).join('').trim();
|
|
@@ -151,6 +159,8 @@ export function normalizeChat(raw, { kids = false, speech = 'game' } = {}) {
|
|
|
151
159
|
links: r.links === 'allow' ? 'allow' : 'block',
|
|
152
160
|
swears: r.swears === 'allow' ? 'allow' : 'block',
|
|
153
161
|
ai: bool('ai'), bubbles: bool('bubbles'), watchers: bool('watchers'), hub: bool('hub'),
|
|
162
|
+
// Kept words (0.29.0): only when the owner asks, never on a kids server or a capped one, never with chat off.
|
|
163
|
+
history: kids || capped || mode === 'off' ? 0 : int(r.history, 0, CHAT_LIMITS.historyMax, CHAT_DEFAULTS.history),
|
|
154
164
|
emoji: [...REACTIONS.map((x) => ({ ...x })), ...extraEmoji(r.emoji)],
|
|
155
165
|
lines: quickLines(r.lines),
|
|
156
166
|
block: wordList(r.block), allow: wordList(r.allow),
|
|
@@ -191,6 +201,7 @@ export function checkChatRules(body) {
|
|
|
191
201
|
for (const k of ['who', 'react']) if (b[k] !== undefined) { if (!CHAT_WHO.includes(b[k])) return bad(`${k} is anyone, signed-in or members`); f[k] = b[k]; }
|
|
192
202
|
if (b.slow !== undefined) { const n = Math.floor(Number(b.slow)); if (!Number.isFinite(n) || n < 0 || n > CHAT_LIMITS.slowMax) return bad(`slow is 0 to ${CHAT_LIMITS.slowMax} seconds`); f.slow = n; }
|
|
193
203
|
if (b.max !== undefined) { const n = Math.floor(Number(b.max)); if (!Number.isFinite(n) || n < CHAT_LIMITS.minText || n > CHAT_LIMITS.text) return bad(`max is ${CHAT_LIMITS.minText} to ${CHAT_LIMITS.text} characters`); f.max = n; }
|
|
204
|
+
if (b.history !== undefined) { const n = Math.floor(Number(b.history)); if (!Number.isFinite(n) || n < 0 || n > CHAT_LIMITS.historyMax) return bad(`history is 0 (keep nothing) to ${CHAT_LIMITS.historyMax} days`); f.history = n; }
|
|
194
205
|
if (b.links !== undefined) { if (!['block', 'allow'].includes(b.links)) return bad('links is block or allow'); f.links = b.links; }
|
|
195
206
|
if (b.swears !== undefined) { if (!['block', 'allow'].includes(b.swears)) return bad('swears is block or allow'); f.swears = b.swears; }
|
|
196
207
|
for (const k of ['ai', 'bubbles', 'watchers', 'hub']) if (b[k] !== undefined) f[k] = b[k] === true || b[k] === 'on' || b[k] === 'true';
|
|
@@ -208,6 +219,7 @@ export function chatProblems(raw) {
|
|
|
208
219
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return ['"chat" is an object (or false for no chat)'];
|
|
209
220
|
const out = [];
|
|
210
221
|
if (raw.mode !== undefined && !CHAT_MODES.includes(raw.mode)) out.push('chat.mode is off, emoji, lines or text');
|
|
222
|
+
if (raw.history !== undefined && !(Number.isInteger(raw.history) && raw.history >= 0 && raw.history <= CHAT_LIMITS.historyMax)) out.push(`chat.history is a number of days, 0 to ${CHAT_LIMITS.historyMax} (0 keeps nothing past the room's few minutes)`);
|
|
211
223
|
for (const k of ['who', 'react']) if (raw[k] !== undefined && !CHAT_WHO.includes(raw[k])) out.push(`chat.${k} is anyone, signed-in or members`);
|
|
212
224
|
// Where chat sits on the screen is the page's (game.json "screen": { "chat" }), never the room's rules.
|
|
213
225
|
for (const k of ['corner', 'place', 'at']) if (raw[k] !== undefined && (k !== 'at' || typeof raw.at === 'string')) out.push(`chat.${k}: where chat sits on the screen goes in "screen": { "chat": { "at": … } }`);
|
package/worker/discover.mjs
CHANGED
|
@@ -67,6 +67,8 @@ export function robotsTxt(cat, origin, { preview = false } = {}) {
|
|
|
67
67
|
'Disallow: /shop/thanks',
|
|
68
68
|
'Disallow: /shop/parent/',
|
|
69
69
|
'Disallow: /__homie',
|
|
70
|
+
// The Lounge's socket and APIs (0.29.0); its page may be crawled.
|
|
71
|
+
...(cat.studio?.lounge ? ['Disallow: /lounge/api/', 'Disallow: /lounge/__'] : []),
|
|
70
72
|
...games.flatMap((id) => [`Disallow: /${id}/play`, `Disallow: /${id}/tv`, `Disallow: /${id}/watch`, `Disallow: /${id}/live`, `Disallow: /${id}/invite`, `Disallow: /${id}/api/`, `Disallow: /${id}/__`, `Disallow: /${id}/s/*/play`, `Disallow: /${id}/s/*/tv`, `Disallow: /${id}/s/*/home`]),
|
|
71
73
|
'',
|
|
72
74
|
`Sitemap: ${origin}/sitemap.xml`,
|
|
@@ -96,6 +98,8 @@ export function sitemapEntries(cat, { all = null } = {}) {
|
|
|
96
98
|
if (songs.length) { add('/music/', newest(songs.map(mediaDate))); for (const e of songs) add(`/music/${e.slug}/`, mediaDate(e)); }
|
|
97
99
|
if (videos.length) { add('/videos/', newest(videos.map(mediaDate))); for (const e of videos) add(`/videos/${e.slug}/`, mediaDate(e)); }
|
|
98
100
|
if (posts.length) { add('/posts/', newest(posts.map(postDate))); for (const p of posts) add(`/posts/${p.slug}/`, postDate(p)); }
|
|
101
|
+
// The Lounge (0.29.0), when studio.json turns it on: its page (what is said there is not in the page).
|
|
102
|
+
if (cat.studio?.lounge) add('/lounge/');
|
|
99
103
|
for (const p of ownPages(cat, all)) add(p);
|
|
100
104
|
return out;
|
|
101
105
|
}
|
|
@@ -210,6 +214,7 @@ export function llmsTxt(cat, origin, { remixable = () => false, directory = 'htt
|
|
|
210
214
|
}
|
|
211
215
|
out.push('', '## Optional', '');
|
|
212
216
|
if (games.length) out.push(`- [Rooms](${origin}/rooms/): every public room playing now (${origin}/api/rooms is the same as JSON)`);
|
|
217
|
+
if (cat.studio?.lounge) out.push(`- [${label(cat.studio.lounge.name ?? 'The Lounge')}](${origin}/lounge/): the studio's community room: play nights, live rooms and chat (${origin}/lounge/api/now is its play nights and rules as JSON; what people say there is not published)`);
|
|
213
218
|
out.push(`- [Studio manifest](${origin}/.well-known/homie-studio.json): the games, songs, videos and posts as JSON`);
|
|
214
219
|
out.push(`- [Sitemap](${origin}/sitemap.xml): every public page`);
|
|
215
220
|
if (!full) out.push(`- [Everything, in full](${origin}/llms-full.txt): each game's whole description, how to play and credits, and every post's text`);
|
package/worker/index.mjs
CHANGED
|
@@ -92,6 +92,8 @@ import { BRAIN_BUDGET, HouseAgents, agentFacts, aiName, decodeFacts, encodeFacts
|
|
|
92
92
|
import { CLEF, DECIDE, clefRun, costOf, localAiOf, picksOf, talks } from './brain.mjs';
|
|
93
93
|
import { REACTIONS, reviewChat } from './chat.mjs';
|
|
94
94
|
import { REPORT_REASONS, chatDay, chatOf, chatRowsOf, fileReport } from './chat-store.mjs';
|
|
95
|
+
import { forgetLines, historyOf, keepLines, keptLine } from './lounge-store.mjs';
|
|
96
|
+
import { isLoungePath, loungeRoutes } from './lounge.mjs';
|
|
95
97
|
import { doorPage, serverPage, serversPage } from './site.mjs';
|
|
96
98
|
import { isLocalOrigin, qrSvg } from './qr.mjs';
|
|
97
99
|
import { SEAT_MAX, perAddress, seatsOf } from './seats.mjs';
|
|
@@ -601,6 +603,11 @@ async function route(request, env, ctx) {
|
|
|
601
603
|
if (shopped) return shopped;
|
|
602
604
|
}
|
|
603
605
|
if (path.startsWith('/api/player/') || path === '/account' || path === '/account/' || path === '/_homie/account.js') return playerRoutes(request, env, ctx, url, { catalogueOf: getCat });
|
|
606
|
+
// The Lounge (0.29.0, chat/LOUNGE.md): the studio's own community room, when studio.json turns it on.
|
|
607
|
+
if (isLoungePath(path)) {
|
|
608
|
+
const lounged = await loungeRoutes(request, env, ctx, url, { catalogueOf: getCat, roomsOf });
|
|
609
|
+
if (lounged) return lounged;
|
|
610
|
+
}
|
|
604
611
|
if (path === '/_homie/site.js') return new Response(SITE_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': url.searchParams.get('v') === STUDIO_VERSION_TAG ? 'public, max-age=31536000, immutable' : 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
|
|
605
612
|
if (path === '/api/stats/beat' && request.method === 'POST') return mediaBeat(request, env, ctx, url);
|
|
606
613
|
if (path === '/api/stats') {
|
|
@@ -658,6 +665,8 @@ async function route(request, env, ctx) {
|
|
|
658
665
|
// The studio's posts, for the hub (the full text is in /posts/feed.json).
|
|
659
666
|
posts: (cat.posts ?? []).slice(0, 20).map((p) => ({ slug: p.slug, title: p.title, date: p.date, summary: p.summary, page: `${url.origin}/posts/${p.slug}/`, image: p.image ? (p.image.startsWith('/') ? `${url.origin}${p.image}` : p.image) : null, links: p.links ?? {} })),
|
|
660
667
|
...(shareRooms ? { rooms: `${url.origin}/api/rooms` } : {}),
|
|
668
|
+
// The studio's Lounge (0.29.0), for a directory that shows lounges: its page and its public facts.
|
|
669
|
+
...(cat.studio?.lounge ? { lounge: { name: cat.studio.lounge.name, page: `${url.origin}/lounge/`, now: `${url.origin}/lounge/api/now` } } : {}),
|
|
661
670
|
// Shared only when studio.json says `stats.share`: two numbers for the whole studio, for the hub.
|
|
662
671
|
...(played ? { played } : {}),
|
|
663
672
|
// The shop (0.24.0): whether it sells and its till, never a key or a sale; and referrals: this studio takes
|
|
@@ -1200,11 +1209,30 @@ export class Table {
|
|
|
1200
1209
|
this.vocabRead = this.readVocab(game).catch(() => false);
|
|
1201
1210
|
// Room chat (section 19): typed text the floor let through waits for the studio's own Workers AI.
|
|
1202
1211
|
this.room.review = (text, opts) => this.reviewChat(text, opts);
|
|
1212
|
+
// Kept chat (0.29.0): a room whose rules keep `history` writes what was said to D1 and deletes what is taken down,
|
|
1213
|
+
// one after another (a line removed the moment it went out is never written after its delete).
|
|
1214
|
+
this.room.onPublished = (rec) => this.keptQueue(() => keepLines(this.env, this.game, this.code, [rec]));
|
|
1215
|
+
this.room.onUnsaid = (ids) => this.keptQueue(() => forgetLines(this.env, this.game, this.code, ids));
|
|
1203
1216
|
// A game's own decisions (section 20): the host's typed questions, for a game whose game.json opts in.
|
|
1204
1217
|
this.room.decider = (state, questions, opts) => this.decide(state, questions, opts);
|
|
1205
1218
|
return this.room;
|
|
1206
1219
|
}
|
|
1207
1220
|
|
|
1221
|
+
/** Kept chat's writes, in order, each finished before the next (D1 calls from one room never pass each other). */
|
|
1222
|
+
keptQueue(fn) {
|
|
1223
|
+
if (!this.env.DB) return;
|
|
1224
|
+
this.kept = (this.kept ?? Promise.resolve()).then(fn).catch((error) => { try { console.log(JSON.stringify({ ev: 'chat-history-failed', room: this.code, error: String(error?.message ?? error).slice(0, 200) })); } catch { /* no console */ } });
|
|
1225
|
+
this.ctx.waitUntil(this.kept);
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/** A room that keeps history puts what it kept back in its window before its first page opens (once per opening). */
|
|
1229
|
+
async hydrateHistory(room) {
|
|
1230
|
+
const days = room.chatRules().history;
|
|
1231
|
+
if (!(days > 0) || room.hydrated || !this.env.DB) return;
|
|
1232
|
+
room.hydrated = true;
|
|
1233
|
+
try { await this.kept; room.hydrate(await historyOf(this.env, this.game, this.code, { days })); } catch { /* the window alone (before migration 0009) */ }
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1208
1236
|
/** Whether this game asked for decisions (game.json "decide": true, in the built catalogue), read once a minute. */
|
|
1209
1237
|
async decideOn() {
|
|
1210
1238
|
if (this.decideRead && Date.now() - this.decideRead.at < 60_000) return this.decideRead.on;
|
|
@@ -1411,14 +1439,16 @@ export class Table {
|
|
|
1411
1439
|
// A chat line as the room keeps it (section 19), for a report: the Worker files the room's own copy, never a reporter's.
|
|
1412
1440
|
if (url.pathname === '/__chat') {
|
|
1413
1441
|
const id = url.searchParams.get('id');
|
|
1414
|
-
|
|
1415
|
-
|
|
1442
|
+
// A line older than the window is in the room's kept history, when it keeps one (0.29.0).
|
|
1443
|
+
const r = room.chatLog.find((x) => x.id === id) ?? (room.chatRules().history > 0 ? await keptLine(this.env, this.game, this.code, id) : null);
|
|
1444
|
+
return json({ ok: Boolean(r), line: r ? { id: r.id, kind: r.kind, text: r.text ?? null, glyph: r.glyph ?? null, name: r.name, seat: r.seat, at: r.at, by: r.by, player: r.from?.player ?? null, owner: Boolean(r.owner), mod: Boolean(r.mod) } : null });
|
|
1416
1445
|
}
|
|
1417
1446
|
if (url.pathname === '/__office') {
|
|
1418
1447
|
const ctl = await request.json().catch(() => null);
|
|
1419
1448
|
const why = await verifyControl(this.env, ctl, { game: this.game, room: this.code }, this.seen);
|
|
1420
1449
|
if (why) return json({ ok: false, error: 'refused', why }, 403);
|
|
1421
|
-
|
|
1450
|
+
// A Lounge card waits for the studio's review (0.29.0); every other control answers at once.
|
|
1451
|
+
const res = await room.control(ctl.op, ctl.args && typeof ctl.args === 'object' ? ctl.args : {});
|
|
1422
1452
|
// AI talk turned off (a policy) or a guide kicked: the house guides follow at once.
|
|
1423
1453
|
if (this.house) this.house.sync();
|
|
1424
1454
|
await this.ctx.storage.put('office', room.officeSaved()).catch(() => {});
|
|
@@ -1436,6 +1466,8 @@ export class Table {
|
|
|
1436
1466
|
if (pol) room.setPolicy(pol);
|
|
1437
1467
|
if (this.house) this.house.sync();
|
|
1438
1468
|
const agent = decodeFacts(url.searchParams.get('ag'));
|
|
1469
|
+
// Kept chat (0.29.0): the room's history is back in its window before this page hears it.
|
|
1470
|
+
await this.hydrateHistory(room);
|
|
1439
1471
|
const [client, server] = Object.values(new WebSocketPair());
|
|
1440
1472
|
server.accept();
|
|
1441
1473
|
const via = url.searchParams.get('via');
|
|
@@ -1459,6 +1491,9 @@ export class Table {
|
|
|
1459
1491
|
acct: url.searchParams.get('acct') === '1',
|
|
1460
1492
|
member: url.searchParams.get('mem') === '1',
|
|
1461
1493
|
hub: url.pathname === '/__watch' && url.searchParams.get('hub') === '1',
|
|
1494
|
+
// The Lounge (0.29.0): the signed-in account's name and a moderator's mark, the Worker's word on a watch socket.
|
|
1495
|
+
...(url.pathname === '/__watch' && url.searchParams.get('nm') ? { name: String(url.searchParams.get('nm')).slice(0, 40) } : {}),
|
|
1496
|
+
...(url.pathname === '/__watch' && url.searchParams.get('mod') === '1' ? { mod: true } : {}),
|
|
1462
1497
|
send: (text) => { try { server.send(text); } catch { /* closed */ } },
|
|
1463
1498
|
close: (c, r) => { try { server.close(c, r); } catch { /* closed */ } },
|
|
1464
1499
|
buffered: () => 0,
|