@homie-rocks/studio 0.27.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.
@@ -0,0 +1,112 @@
1
+ /* The Tell Homie card: a note to the people who make Homie, exactly as it would go, with Send, Edit and Don't send.
2
+ * Nothing leaves this computer until the person presses Send here (or says yes in the chat). Edit drafts the note
3
+ * again from the person's own words, through the same redaction, so what the card shows is always what would go.
4
+ * The model is told what the person chose, once. Every text is set as text, never as HTML. */
5
+ (function () {
6
+ var C = Card;
7
+ var KINDS = [['stuck', 'Stuck'], ['confusing', 'Confusing'], ['idea', 'Idea'], ['praise', 'Praise'], ['bug', 'Bug']];
8
+ var LABEL = { stuck: 'Stuck', confusing: 'Confusing', idea: 'Idea', praise: 'Praise', bug: 'Bug' };
9
+ var ui = { editing: false, busy: false, why: null, kind: null, text: null, email: null };
10
+
11
+ function fieldsOf(sc) {
12
+ var n = sc.note || {};
13
+ return { kind: n.kind, text: n.text, step: n.step || undefined, email: n.email || undefined, offered: n.offered === true,
14
+ studioVersion: n.studioVersion || undefined, pluginVersion: n.pluginVersion || undefined, app: n.app || undefined,
15
+ // The studio the draft was made for (its pinned version is in the note): a send is checked against the same one.
16
+ studio: sc.studio || undefined };
17
+ }
18
+
19
+ function act(sc, args, after) {
20
+ if (ui.busy) return;
21
+ ui.busy = true; ui.why = null; C.render();
22
+ C.call('homie_feedback', args).then(function (res) {
23
+ ui.busy = false;
24
+ // Refused (not sent, a reworded note that cannot go, the app said no): the note stays on screen with why.
25
+ var d = res && res.structuredContent;
26
+ if (!d || d.kind !== 'feedback' || res.isError) { ui.why = C.textOf(res) || 'That did not work. Nothing was sent.'; C.render(); return; }
27
+ if (after) after(res);
28
+ C.take(res);
29
+ }).catch(function () { ui.busy = false; ui.why = 'The app did not answer. Nothing was sent.'; C.render(); });
30
+ }
31
+
32
+ function quote(text) {
33
+ var q = C.el('blockquote', 'quote');
34
+ String(text || '').split('\n').forEach(function (line, i) { if (i) q.appendChild(C.el('br')); q.appendChild(document.createTextNode(line)); });
35
+ return q;
36
+ }
37
+
38
+ function editor(card, sc) {
39
+ var n = sc.note || {};
40
+ if (ui.kind === null) { ui.kind = n.kind || 'idea'; ui.text = n.text || ''; ui.email = n.email || ''; }
41
+ var kinds = C.el('div', 'kinds'); kinds.setAttribute('role', 'radiogroup'); kinds.setAttribute('aria-label', 'What kind of note');
42
+ KINDS.forEach(function (k) {
43
+ var b = C.btn(k[1], 'small ' + (ui.kind === k[0] ? '' : 'ghost'), function () { ui.kind = k[0]; C.render(); });
44
+ b.setAttribute('role', 'radio'); b.setAttribute('aria-checked', ui.kind === k[0] ? 'true' : 'false');
45
+ kinds.appendChild(b);
46
+ });
47
+ card.appendChild(kinds);
48
+ var area = C.el('textarea'); area.value = ui.text; area.rows = 5; area.maxLength = 1500; area.setAttribute('aria-label', 'Your note, in your words');
49
+ area.oninput = function () { ui.text = area.value; };
50
+ card.appendChild(area);
51
+ var mail = C.el('input'); mail.type = 'email'; mail.placeholder = 'Your email, if you want a reply (optional)'; mail.value = ui.email; mail.setAttribute('aria-label', 'Reply address (optional)');
52
+ mail.oninput = function () { ui.email = mail.value; };
53
+ card.appendChild(mail);
54
+ var acts = C.el('div', 'actions');
55
+ acts.appendChild(C.btn(ui.busy ? 'Saving…' : 'Use this', '', function () {
56
+ var f = fieldsOf(sc);
57
+ act(sc, { action: 'draft', kind: ui.kind, text: ui.text, step: f.step, email: (ui.email || '').trim() || undefined, offered: f.offered, studioVersion: f.studioVersion, pluginVersion: f.pluginVersion, app: f.app, studio: f.studio, by: 'card' }, function (res) {
58
+ ui.editing = false; ui.kind = null;
59
+ var d = res && res.structuredContent;
60
+ if (d && d.state === 'draft') C.tell('edit:' + d.draft, 'The person reworded the note on the Tell Homie card. Nothing is sent yet; the card shows the new text (draft ' + d.draft + ') with Send. Do not send it yourself unless they say yes.');
61
+ });
62
+ }));
63
+ acts.appendChild(C.btn('Cancel', 'ghost', function () { ui.editing = false; ui.kind = null; C.render(); }));
64
+ card.appendChild(acts);
65
+ }
66
+
67
+ function render(sc) {
68
+ var card = C.el('main', 'card');
69
+ var state = sc.state;
70
+ var head = state === 'sent' ? C.pill('Sent', 'ok') : state === 'declined' ? C.pill('Not sent', '') : state === 'draft' ? C.pill('Not sent yet', 'warn') : C.pill('Not sent', 'bad');
71
+ C.add(card, C.top('Note', head));
72
+ card.appendChild(C.el('h1', '', state === 'sent' ? 'Sent to Homie. Thank you.' : state === 'declined' ? 'Not sent' : 'A note to the people who make Homie'));
73
+ var n = sc.note;
74
+ if (state === 'draft' && ui.editing) { editor(card, sc); }
75
+ else if (n) {
76
+ card.appendChild(C.el('p', 'sub', state === 'sent' ? 'This is what they got:' : state === 'declined' ? 'Nothing left this computer. This was the note:' : 'This is exactly what would go:'));
77
+ card.appendChild(C.add(C.el('div', 'chips'), C.pill(LABEL[n.kind] || n.kind, 'live')));
78
+ card.appendChild(quote(n.text));
79
+ if (sc.with) card.appendChild(C.el('p', 'fine', 'With it: ' + sc.with));
80
+ if (sc.taken && sc.taken.length) card.appendChild(C.el('p', 'fine', 'Homie took out ' + sc.taken.join(', ') + ' before showing it here.'));
81
+ }
82
+ if (ui.why) card.appendChild(C.el('p', 'note bad', ui.why));
83
+ if (state === 'draft' && !ui.editing) {
84
+ var acts = C.el('div', 'actions send');
85
+ var send = C.btn(ui.busy ? 'Sending…' : 'Send', '', function () {
86
+ act(sc, Object.assign({ action: 'send', draft: sc.draft, by: 'card' }, fieldsOf(sc)), function (res) {
87
+ var d = res && res.structuredContent;
88
+ if (d && d.state === 'sent') C.tell('sent:' + sc.draft, 'The person pressed Send on the Tell Homie card: the note went to Homie (reference ' + String(d.reference || '').slice(0, 8) + '). Thank them in one line, and do not offer another note in this session.');
89
+ });
90
+ });
91
+ send.disabled = ui.busy;
92
+ acts.appendChild(send);
93
+ var edit = C.btn('Edit', 'ghost', function () { ui.editing = true; ui.kind = null; C.render(); }); edit.disabled = ui.busy;
94
+ acts.appendChild(edit);
95
+ var no = C.btn('Don’t send', 'ghost', function () {
96
+ act(sc, { action: 'decline', draft: sc.draft, by: 'card' }, function () {
97
+ C.tell('declined:' + sc.draft, 'The person pressed Don’t send on the Tell Homie card. Nothing was sent. Do not offer to send a note again in this session.');
98
+ });
99
+ });
100
+ no.disabled = ui.busy;
101
+ acts.appendChild(no);
102
+ card.appendChild(acts);
103
+ }
104
+ if (state === 'sent' && sc.reference) card.appendChild(C.el('p', 'fine', 'Reference ' + String(sc.reference).slice(0, 8) + '. Notes are private: only the people who make Homie read them.'));
105
+ var foot = C.el('div', 'foot');
106
+ foot.appendChild(C.el('span', 'when', state === 'sent' ? 'Sent to ' + (sc.to || 'homie.rocks') : state === 'declined' ? 'Nothing was sent.' : 'Nothing is sent until you press Send. Goes to ' + (sc.to || 'homie.rocks') + ', privately.'));
107
+ card.appendChild(foot);
108
+ return card;
109
+ }
110
+
111
+ C.start({ kind: 'feedback', label: 'Note', render: render, poll: null });
112
+ })();
package/mcp/ui/setup.js CHANGED
@@ -60,6 +60,8 @@
60
60
  acts.appendChild(C.btn('Check again', 'ghost small', function () {
61
61
  C.call('setup_status', { fresh: true }).then(function (r) { C.take(r); }).catch(function () {});
62
62
  }));
63
+ // Homie's own updates by email: the person signs up on homie.rocks (double opt-in); the card asks for nothing.
64
+ if (sc.updates) acts.appendChild(C.btn('Get Homie updates', 'ghost small', function () { C.open(sc.updates); }));
63
65
  foot.appendChild(acts);
64
66
  card.appendChild(foot);
65
67
  if (install && install.state === 'done') C.tell('install-' + install.job, (cur ? cur.name : 'The studio') + '\'s toolkit finished installing; the studio is ready to build.');
@@ -1230,6 +1230,7 @@ every watching shell. homie.rocks runs nothing: its room page opens the room's o
1230
1230
  | `swears` | `block`, `allow` (`block`) | Slurs, sexual words, threats and contact details are held whatever this says. |
1231
1231
  | `ai` | `true`, `false` (`true`) | The studio's review of typed text. |
1232
1232
  | `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. |
1233
+ | `history` | 0–90 days (0) | 0.29.0: keep what is said (never a reaction) in the studio's D1 for that many days, and give it back as the window when a page opens; 0 keeps nothing past the window. A kids server, a capped room or chat `off` keeps 0. |
1233
1234
  | `emoji` | the five, then up to 3 of the game's own | `{ k, e }`: a kind and its glyph. |
1234
1235
  | `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. |
1235
1236
  | `capped` | `kids`, `server-lines`, `server-off` | Why the mode is lower than the game asks. |
@@ -1261,6 +1262,12 @@ stays in the room; clients get the rest in `welcome.policy.chat` and every `poli
1261
1262
  | `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. |
1262
1263
  | `unline` | down, everyone | `ids` | The owner took them down: hide them, and their bubbles. |
1263
1264
  | `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`. |
1265
+ | `unsay` | up, a watching shell | `id` | 0.29.0: take my own line down (a line of this browser, or of this signed-in account). Everyone gets `unline`. |
1266
+
1267
+ 0.29.0 adds to `line`: `card` (`{ url, title, studio, pitch, image }`, a "show what you made" card the Worker read
1268
+ from a Homie studio's manifest; `text` stays a plain fallback such as "Made: <title> by <studio>"), `mod` (a moderator
1269
+ the owner named), `kept` (it came from the room's history) and, on a page's own copy of the window only, `mine` (this
1270
+ browser's or account's own line). A page that knows none of these shows the line's `text`.
1264
1271
 
1265
1272
  A line's sender is the socket's own client, or for a watch socket the client of the same browser
1266
1273
  (its room key) or account in the room: its seat, name and colour. A page with no client in the
@@ -1361,7 +1368,10 @@ ticker.
1361
1368
 
1362
1369
  **Privacy.** Nothing a person says is stored: the room keeps the last 50 lines of the last 15
1363
1370
  minutes in its memory (never in Durable Object storage), and an empty room forgets them with
1364
- everything else a minute after its last person leaves. The counts (lines, reactions, held by why,
1371
+ everything else a minute after its last person leaves. Only where the owner turns `history` on (0.29.0; off by
1372
+ default for every game and the Lounge, never on a kids server) does the Table keep what was said in D1
1373
+ (`chat_history`: the words, the name shown, when, a signed-in sender's account id; never a reaction, an address or a
1374
+ browser key) for that many days; what is taken down there is deleted there, and a deleted account takes its lines. The counts (lines, reactions, held by why,
1365
1375
  reviews and neurons) are daily numbers in the studio's own D1, never a message or a sender. A
1366
1376
  report keeps one message for 30 days. The review sends the message's words (and nothing about who
1367
1377
  said it) to the studio's own Workers AI; Cloudflare says it does not keep or train on them.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.27.0",
3
+ "version": "0.29.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",
package/site/SITE.md CHANGED
@@ -13,6 +13,7 @@ and every game gets a landing page. Anything the studio puts in `site/` wins.
13
13
  | `/music/`, `/videos/` | Songs and videos (`media/MEDIA.md`) | the studio has one published |
14
14
  | `/rooms/` | Every public room playing now across the studio's games, each with Join (`/api/rooms` is the same as JSON, cacheable for 15 s) | the studio has a game |
15
15
  | `/posts/` | The studio's posts, newest first, with `/posts/feed.xml` (Atom) and `/posts/feed.json` (JSON Feed) | the studio has a post |
16
+ | `/lounge/` | The Lounge (0.29.0): the studio's community room, with play nights, live rooms and "show what you made" (`chat/LOUNGE.md`) | studio.json has `"lounge"` |
16
17
 
17
18
  A section with nothing in it has no tab, and its address answers 404. Every page ends with a
18
19
  "Made with Homie" link to https://homie.rocks/studio/. studio.json may add `"tagline"` (one line, on Home and in
@@ -35,6 +36,18 @@ the hub offers Watch beside Join. From 0.23.0 a room whose chat is on and whose
35
36
  socket from the visitor's browser and shows its lines and reactions live (NETPLAY.md section 19). A visitor there
36
37
  sends what a watcher may send (reactions and quick lines when the room's `react` is `anyone`), never types.
37
38
 
39
+ ## The Lounge: `/lounge/` (0.29.0)
40
+
41
+ With studio.json `"lounge": true` (or `{ "name", "tab", "blurb", "featured", "kids", "chat" }`), `/lounge/` is the
42
+ studio's community room (worker/lounge.mjs, lounge-page.mjs; chat/LOUNGE.md): room chat in a room of its own, in the
43
+ studio's look, one column on a phone ("Chat" and "What's on") and the talk beside a sidebar on a computer. Its script
44
+ is `/_homie/lounge.js`; its socket is `/lounge/__watch` (a page of this site carries its signed-in account; a page of
45
+ another site is a watcher that reacts, never types). `/lounge/api/now` is its public facts as JSON (rules, play
46
+ nights, live rooms; CORS open, cacheable for 30 s), and the manifest names it (`lounge: { name, page, now }`).
47
+ `/lounge/api/history`, `show`, `report`, `delete` and `mod` are this site's pages' only. The owner's tools are
48
+ `/_studio/api/lounge` and `/_studio/api/lounge/rules`, `night`, `mod`, `remove` and `hold`. A game called `lounge`
49
+ keeps its page, and the build says the Lounge is off.
50
+
38
51
  ## Room chat on the play, watch and TV pages
39
52
 
40
53
  Every play page, big screen and watch page carries room chat (worker/chat-page.mjs; chat/CHAT.md): the Chat pill
@@ -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). A report keeps the one message it is about, for 30 days.
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": … } }`);
@@ -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
- const r = room.chatLog.find((x) => x.id === id);
1415
- 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 } : null });
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
- const res = room.control(ctl.op, ctl.args && typeof ctl.args === 'object' ? ctl.args : {});
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,