@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.
- package/CHANGELOG.md +59 -1
- package/README.md +40 -2
- 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/feedback.mjs +181 -0
- package/lib/lounge-cli.mjs +70 -0
- package/lib/mcp-tools.mjs +140 -1
- package/lib/mcp.mjs +6 -2
- package/lib/redact.mjs +99 -0
- package/lib/scaffold.mjs +25 -2
- package/lib/template-history.json +80 -0
- package/mcp/ui/card.css +8 -0
- package/mcp/ui/feedback.js +112 -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
|
@@ -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.');
|
package/netplay/NETPLAY.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
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,
|