@homie-rocks/studio 0.22.0 → 0.23.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 +37 -0
- package/bin/homie-studio.mjs +34 -1
- package/chat/CHAT.md +101 -0
- package/chat/OWNERS.md +78 -0
- package/lib/build.mjs +7 -0
- package/lib/chat-cli.mjs +89 -0
- package/lib/cloudflare.mjs +5 -2
- package/lib/scaffold.mjs +13 -2
- package/lib/template-history.json +38 -0
- package/netplay/NETPLAY.md +191 -3
- package/netplay/netplay.ts +150 -3
- package/package.json +2 -1
- package/port/index.ts +3 -2
- package/port/view.ts +238 -0
- package/site/SITE.md +11 -1
- package/starters/ember-vale/src/main.ts +22 -2
- package/starters/gem-rush/src/main.ts +21 -1
- package/starters/gem-rush-3d/src/main.ts +22 -1
- package/worker/agents.mjs +3 -3
- package/worker/chat-page.mjs +556 -0
- package/worker/chat-store.mjs +161 -0
- package/worker/chat-words.mjs +46 -0
- package/worker/chat.mjs +419 -0
- package/worker/index.mjs +148 -5
- package/worker/office-page.mjs +115 -0
- package/worker/office.mjs +137 -6
- package/worker/pages.mjs +21 -6
- package/worker/room.mjs +288 -10
- package/worker/servers.mjs +5 -1
- package/worker/site.mjs +4 -1
- package/worker/stats.mjs +4 -1
- package/worker/version.mjs +1 -1
package/worker/index.mjs
CHANGED
|
@@ -51,6 +51,9 @@
|
|
|
51
51
|
* /account/ a player's account: a passkey, a name, their data (worker/players.mjs, saves/SAVES.md)
|
|
52
52
|
* /api/player/... sign in and up, saves, lifetime stats and memorials (the game's saves bridge, via the
|
|
53
53
|
* play shell), export and delete
|
|
54
|
+
* /<game>/api/chat/report a player reports one chat line (POST, this site's pages): the room's own copy of it is kept
|
|
55
|
+
* for the owner, 30 days (worker/chat-store.mjs); room chat itself rides the sockets above
|
|
56
|
+
* (NETPLAY.md section 19) and is never stored
|
|
54
57
|
*
|
|
55
58
|
* A page in the studio's site/pages wins over the generated one at the same address. Every HTML answer is
|
|
56
59
|
* `no-transform` (an edge in front of a custom domain injects nothing) and is never framed by another site.
|
|
@@ -80,6 +83,8 @@ import {
|
|
|
80
83
|
} from './servers.mjs';
|
|
81
84
|
import { BRAIN_BUDGET, HouseAgents, agentFacts, aiName, decodeFacts, encodeFacts, passById, passRefusal, sitRoute } from './agents.mjs';
|
|
82
85
|
import { talks } from './brain.mjs';
|
|
86
|
+
import { REACTIONS, reviewChat } from './chat.mjs';
|
|
87
|
+
import { REPORT_REASONS, chatDay, chatOf, chatRowsOf, fileReport } from './chat-store.mjs';
|
|
83
88
|
import { doorPage, serverPage, serversPage } from './site.mjs';
|
|
84
89
|
import { isLocalOrigin, qrSvg } from './qr.mjs';
|
|
85
90
|
import { SEAT_MAX, perAddress, seatsOf } from './seats.mjs';
|
|
@@ -232,6 +237,8 @@ function named(cat, env) {
|
|
|
232
237
|
async function roomsOf(env, games, { servers = null } = {}) {
|
|
233
238
|
const rooms = [];
|
|
234
239
|
const live = {};
|
|
240
|
+
// Room chat (section 19): whether each room's chat is on and may show on homie.rocks's page for it.
|
|
241
|
+
const chatRows = await chatRowsOf(env);
|
|
235
242
|
await Promise.all(games.map(async (g) => {
|
|
236
243
|
let list = [];
|
|
237
244
|
try { list = (await (await env.LOBBY.get(env.LOBBY.idFromName(g.id)).fetch('https://lobby/rooms')).json()).rooms ?? []; } catch { list = []; }
|
|
@@ -242,7 +249,8 @@ async function roomsOf(env, games, { servers = null } = {}) {
|
|
|
242
249
|
const sid = r.server ?? roomServer(r.name);
|
|
243
250
|
const srv = sid === 'public' ? (known.find((x) => x.id === 'public') ?? PUBLIC_SERVER) : known.find((x) => x.id === sid);
|
|
244
251
|
if (!srv || (sid !== 'public' && (!srv.listed || srv.state !== 'open' || srv.door !== 'open'))) continue;
|
|
245
|
-
|
|
252
|
+
const chat = chatOf(g, srv, chatRows);
|
|
253
|
+
rooms.push(roomView(g, r, seatsOf(g), srv, { chat: chat.mode !== 'off' && chat.hub }));
|
|
246
254
|
}
|
|
247
255
|
}));
|
|
248
256
|
rooms.sort((a, b) => b.players - a.players || a.game.localeCompare(b.game) || String(a.room).localeCompare(String(b.room)));
|
|
@@ -381,6 +389,30 @@ function mediaRow(e, origin, kind, cat) {
|
|
|
381
389
|
* wrapper Worker of its own for this. A preflight (a POST's content-type) is answered too.
|
|
382
390
|
*/
|
|
383
391
|
const KNOCK_CORS = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET, POST', 'access-control-allow-headers': 'content-type', 'access-control-max-age': '86400' };
|
|
392
|
+
/**
|
|
393
|
+
* Who a browser is to a room's chat (section 19): signed in with a passkey (an account, not a guest), and a member of the
|
|
394
|
+
* server (on the public server, any signed-in account). One D1 read for a signed-in player, none for a guest.
|
|
395
|
+
*/
|
|
396
|
+
async function chatWho(env, game, srv, holder) {
|
|
397
|
+
const pid = holders(holder).find((x) => x.startsWith('p-'))?.slice(2) ?? null;
|
|
398
|
+
if (!pid || !env.DB) return { acct: false, member: false };
|
|
399
|
+
let acct = false;
|
|
400
|
+
try { acct = Number((await env.DB.prepare('SELECT guest FROM players WHERE id = ?1').bind(pid).first())?.guest) === 0; } catch { acct = false; }
|
|
401
|
+
const member = acct && (!srv || srv.id === 'public' || Boolean(await memberOf(env, game, srv.id, pid)));
|
|
402
|
+
return { acct, member };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Reports a browser (and its address) may make: 8 in 10 minutes, counted in this Worker's memory and forgotten. */
|
|
406
|
+
const reportsMade = new Map();
|
|
407
|
+
function reportAllowed(key, now = Date.now()) {
|
|
408
|
+
const list = (reportsMade.get(key) ?? []).filter((at) => now - at < 10 * 60_000);
|
|
409
|
+
if (list.length >= 8) { reportsMade.set(key, list); return false; }
|
|
410
|
+
list.push(now);
|
|
411
|
+
reportsMade.set(key, list);
|
|
412
|
+
if (reportsMade.size > 5000) for (const [k, v] of reportsMade) if (!v.some((at) => now - at < 10 * 60_000)) reportsMade.delete(k);
|
|
413
|
+
return true;
|
|
414
|
+
}
|
|
415
|
+
|
|
384
416
|
function notAHomie(request) {
|
|
385
417
|
if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: { ...KNOCK_CORS, 'cache-control': 'no-store' } });
|
|
386
418
|
return json({ ok: false, error: 'not-a-homie', message: 'this is a studio site, not a Homie box; a game here plays on its own' }, 200, KNOCK_CORS);
|
|
@@ -419,6 +451,9 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
|
|
|
419
451
|
...(agent ? { agent: { hands: agent.hands, role: agent.role } } : {}),
|
|
420
452
|
// The play page's "Quiet AI": this browser hides AI speech (the helper's `net.hushed`).
|
|
421
453
|
...(url.searchParams.get('hush') === '1' ? { hush: true } : {}),
|
|
454
|
+
// Room chat (section 19): this browser hides chat ("Show chat" off), or its player's own lines stay off their character.
|
|
455
|
+
...(url.searchParams.get('chat') === '0' ? { chatOff: true } : {}),
|
|
456
|
+
...(url.searchParams.get('bub') === '0' ? { bubbleOff: true } : {}),
|
|
422
457
|
...(device ? { device } : {}),
|
|
423
458
|
want,
|
|
424
459
|
...(watching ? { watch: true, follow: /^\d+$/.test(follow) ? Number(follow) : follow, watchPolicy: policy } : {}),
|
|
@@ -854,7 +889,7 @@ async function route(request, env, ctx) {
|
|
|
854
889
|
}
|
|
855
890
|
await countVisit(request, env, ctx, game, 'play');
|
|
856
891
|
shareDaily(cat, url, ctx);
|
|
857
|
-
return playPage(cat, meta, { ticket, owner: d.owner, launch, server });
|
|
892
|
+
return playPage(cat, meta, { ticket, owner: d.owner, launch, server, ...(await chatWho(env, game, srv, d.acct)) });
|
|
858
893
|
}
|
|
859
894
|
if (sub === 'watch') {
|
|
860
895
|
// The same door as Play: a game that is private or an invite-only beta is watched only by whoever may play it.
|
|
@@ -871,7 +906,7 @@ async function route(request, env, ctx) {
|
|
|
871
906
|
const ticket = d.holder ? await ticketFor(env, game, d.holder) : null;
|
|
872
907
|
await countVisit(request, env, ctx, game, 'watch');
|
|
873
908
|
shareDaily(cat, url, ctx);
|
|
874
|
-
return watchPage(cat, meta, { room: asked, ticket, policy });
|
|
909
|
+
return watchPage(cat, meta, { room: asked, ticket, policy, owner: d.owner, ...(await chatWho(env, game, srv, d.acct)) });
|
|
875
910
|
}
|
|
876
911
|
if (sub === 'api/watch') {
|
|
877
912
|
if (!door.ok || watchOf(meta) === 'off') return json({ ok: false, error: 'not-found' }, 404);
|
|
@@ -912,6 +947,27 @@ async function route(request, env, ctx) {
|
|
|
912
947
|
},
|
|
913
948
|
});
|
|
914
949
|
}
|
|
950
|
+
if (sub === 'api/chat/report') {
|
|
951
|
+
// A player reports one chat line (section 19): the room's own copy of it is what is kept, never the reporter's words.
|
|
952
|
+
if (request.method !== 'POST' || !sameOrigin(request, url)) return json({ ok: false, error: 'origin', message: 'a report comes from this site\'s own pages' }, 403);
|
|
953
|
+
if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
|
|
954
|
+
let body = {};
|
|
955
|
+
try { body = JSON.parse((await request.text()).slice(0, 2048) || '{}'); } catch { return json({ ok: false, error: 'json' }, 400); }
|
|
956
|
+
const room = String(body.room ?? '');
|
|
957
|
+
const id = String(body.id ?? '');
|
|
958
|
+
if (!ROOM_ID.test(room) || !/^[A-Za-z0-9_-]{1,16}$/.test(id)) return json({ ok: false, error: 'bad-request', message: 'room and id name the message' }, 400);
|
|
959
|
+
const reason = REPORT_REASONS.includes(body.reason) ? body.reason : 'other';
|
|
960
|
+
const key = `${BROWSER_KEY.test(String(body.b ?? '')) ? body.b : ''}|${request.headers.get('cf-connecting-ip') ?? ''}`;
|
|
961
|
+
if (!reportAllowed(key)) return json({ ok: false, error: 'rate', message: 'That is a lot of reports: the studio has them. Try again in a few minutes.' }, 429);
|
|
962
|
+
let line = null;
|
|
963
|
+
try { line = (await (await env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`)).fetch(`https://table/__chat?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}&op=line&id=${encodeURIComponent(id)}`)).json()).line ?? null; } catch { line = null; }
|
|
964
|
+
if (!line) return json({ ok: false, error: 'gone', message: 'That message is no longer in the room.' }, 404);
|
|
965
|
+
if (line.by === 'studio') return json({ ok: false, error: 'studio', message: 'That is the studio\'s own line.' }, 400);
|
|
966
|
+
try { await fileReport(env, { game, room, line, reason }); } catch (error) {
|
|
967
|
+
return json({ ok: false, error: 'not-migrated', message: `Reports need migration 0007_studio_chat.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }, 503);
|
|
968
|
+
}
|
|
969
|
+
return json({ ok: true, message: 'Thanks for telling the studio. They will look at it.' });
|
|
970
|
+
}
|
|
915
971
|
if (sub === '__net' || sub === '__watch') {
|
|
916
972
|
if (request.headers.get('upgrade') !== 'websocket') return new Response('websocket only', { status: 426 });
|
|
917
973
|
const room = url.searchParams.get('room') || 'main';
|
|
@@ -942,8 +998,15 @@ async function route(request, env, ctx) {
|
|
|
942
998
|
const access = await serverAccess(env, { game, server: srv, holders: parts, watching: w || sub === '__watch' });
|
|
943
999
|
if (!access.ok) return new Response('this server is not open to you', { status: 403 });
|
|
944
1000
|
}
|
|
1001
|
+
// Room chat (section 19): the room's rules, and who this socket is to them: an account with a passkey (not a
|
|
1002
|
+
// guest), a member of this server, or another site's page watching from elsewhere (homie.rocks's room page).
|
|
1003
|
+
const chat = chatOf(meta, srv, await chatRowsOf(env));
|
|
1004
|
+
const { acct, member } = await chatWho(env, game, srv, who);
|
|
1005
|
+
const origin = request.headers.get('origin');
|
|
1006
|
+
const hub = sub === '__watch' && Boolean(origin) && origin !== 'null' && origin !== url.origin;
|
|
1007
|
+
const lean = { ...chat, emoji: chat.emoji.filter((e) => !REACTIONS.some((r) => r.k === e.k)) };
|
|
945
1008
|
const stub = env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
|
|
946
|
-
const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}${sub === '__net' ? `&wp=${policy}${w ? '&w=1' : ''}` : ''}&pol=${encodeFacts(pol)}${ag ? `&ag=${encodeFacts(ag)}` : ''}`;
|
|
1009
|
+
const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}${sub === '__net' ? `&wp=${policy}${w ? '&w=1' : ''}` : ''}&pol=${encodeFacts(pol)}&chat=${encodeFacts(lean)}${acct ? '&acct=1' : ''}${member ? '&mem=1' : ''}${hub ? '&hub=1' : ''}${ag ? `&ag=${encodeFacts(ag)}` : ''}`;
|
|
947
1010
|
return stub.fetch(new Request(target, request));
|
|
948
1011
|
}
|
|
949
1012
|
// The same knock, relative to the game's own page: the same answer (see notAHomie).
|
|
@@ -1031,6 +1094,12 @@ export class Table {
|
|
|
1031
1094
|
this.brainUsed = { neurons: 0, micros: 0 };
|
|
1032
1095
|
this.brainPending = { calls: {}, neurons: 0, micros: 0 };
|
|
1033
1096
|
this.brainReadAt = 0;
|
|
1097
|
+
/** Room chat's review (section 19): the studio's day (budget and use, read at most once a minute) and what is not counted yet. */
|
|
1098
|
+
this.chatCap = null;
|
|
1099
|
+
this.chatUsed = 0;
|
|
1100
|
+
this.chatReadAt = 0;
|
|
1101
|
+
this.chatPend = { neurons: 0, ok: 0, held: 0, error: 0 };
|
|
1102
|
+
this.chatSent = { chatLines: 0, chatReacts: 0, held: {} };
|
|
1034
1103
|
ctx.blockConcurrencyWhile(async () => {
|
|
1035
1104
|
this.saved = (await ctx.storage.get('net')) ?? null;
|
|
1036
1105
|
this.recorded = (await ctx.storage.get('recorded')) ?? 0;
|
|
@@ -1055,9 +1124,63 @@ export class Table {
|
|
|
1055
1124
|
if (this.saved) this.room.restore(this.saved);
|
|
1056
1125
|
if (this.officeSaved) this.room.restoreOffice(this.officeSaved);
|
|
1057
1126
|
this.vocabRead = this.readVocab(game).catch(() => false);
|
|
1127
|
+
// Room chat (section 19): typed text the floor let through waits for the studio's own Workers AI.
|
|
1128
|
+
this.room.review = (text, opts) => this.reviewChat(text, opts);
|
|
1058
1129
|
return this.room;
|
|
1059
1130
|
}
|
|
1060
1131
|
|
|
1132
|
+
/**
|
|
1133
|
+
* One typed chat message, reviewed by the studio's decision model (worker/chat.mjs reviewChat) within the day's
|
|
1134
|
+
* budget for the whole studio. No binding (`homie-studio dev` without --remote-ai), no budget left, or an error: the
|
|
1135
|
+
* floor alone has decided (`by` says which), and the message goes out.
|
|
1136
|
+
*/
|
|
1137
|
+
async reviewChat(text, { links = 'block' } = {}) {
|
|
1138
|
+
if (!this.env.AI) return { ok: true, by: 'none' };
|
|
1139
|
+
if (this.chatCap === null || Date.now() - this.chatReadAt > 60_000) await this.readChatDay().catch(() => {});
|
|
1140
|
+
if (this.chatUsed >= (this.chatCap ?? 0)) return { ok: true, by: 'budget' };
|
|
1141
|
+
try {
|
|
1142
|
+
const v = await reviewChat(this.env, text, { links });
|
|
1143
|
+
this.chatUsed += v.neurons;
|
|
1144
|
+
this.chatPend.neurons += v.neurons;
|
|
1145
|
+
this.chatPend[v.ok ? 'ok' : 'held'] += 1;
|
|
1146
|
+
return { ok: v.ok, by: 'ai', why: v.why, ms: v.ms, p: v.p };
|
|
1147
|
+
} catch (error) {
|
|
1148
|
+
this.chatPend.error += 1;
|
|
1149
|
+
try { console.log(JSON.stringify({ ev: 'chat-review-failed', room: this.code, error: String(error?.message ?? error).slice(0, 200) })); } catch { /* no console */ }
|
|
1150
|
+
return { ok: true, by: 'error' };
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
async readChatDay() {
|
|
1155
|
+
const day = await chatDay(this.env, today());
|
|
1156
|
+
this.chatCap = day.budget.neurons;
|
|
1157
|
+
this.chatUsed = day.used.neurons + this.chatPend.neurons;
|
|
1158
|
+
this.chatReadAt = Date.now();
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/** Chat's counts into D1 (stats_daily): lines, reactions, held by why, reviews and neurons. Never a message or a sender. */
|
|
1162
|
+
async flushChat() {
|
|
1163
|
+
const s = this.room?.stats;
|
|
1164
|
+
if (!s || !this.env.DB) return;
|
|
1165
|
+
const game = this.game ?? '';
|
|
1166
|
+
const rows = [];
|
|
1167
|
+
for (const [k, metric] of [['chatLines', 'chat-lines'], ['chatReacts', 'chat-reacts']]) {
|
|
1168
|
+
const d = (s[k] ?? 0) - (this.chatSent[k] ?? 0);
|
|
1169
|
+
if (d > 0) { rows.push(counter(this.env, { metric, subject: game, n: d })); this.chatSent[k] = s[k]; }
|
|
1170
|
+
}
|
|
1171
|
+
for (const [why, n] of Object.entries(s.chatHeld ?? {})) {
|
|
1172
|
+
const d = n - (this.chatSent.held[why] ?? 0);
|
|
1173
|
+
if (d > 0) { rows.push(counter(this.env, { metric: 'chat-held', subject: game, source: why, n: d })); this.chatSent.held[why] = n; }
|
|
1174
|
+
}
|
|
1175
|
+
const p = this.chatPend;
|
|
1176
|
+
for (const k of ['ok', 'held', 'error']) if (p[k]) rows.push(counter(this.env, { metric: 'chat-reviews', subject: game, source: k, n: p[k] }));
|
|
1177
|
+
const neurons = Math.floor(p.neurons);
|
|
1178
|
+
if (neurons) rows.push(counter(this.env, { metric: 'chat-neurons', subject: game, n: neurons }));
|
|
1179
|
+
this.chatPend = { neurons: p.neurons - neurons, ok: 0, held: 0, error: 0 };
|
|
1180
|
+
const list = rows.filter(Boolean);
|
|
1181
|
+
if (list.length) await this.env.DB.batch(list).catch(() => {});
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1061
1184
|
/** The game's agents.json from the build (the only words an AI in this room may say), once. */
|
|
1062
1185
|
async readVocab(game) {
|
|
1063
1186
|
if (!this.env.ASSETS || !/^[a-z0-9][a-z0-9-]{0,39}$/.test(String(game ?? ''))) return false;
|
|
@@ -1156,6 +1279,12 @@ export class Table {
|
|
|
1156
1279
|
// The back office (worker/office.mjs), from the studio's Worker only: the room as its owner sees it, and the
|
|
1157
1280
|
// owner's signed controls, which this room verifies before it applies one (NETPLAY.md section 15).
|
|
1158
1281
|
if (url.pathname === '/__facts') return json({ ...room.officeFacts(), ...(this.house ? { brains: this.house.facts() } : {}) });
|
|
1282
|
+
// 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.
|
|
1283
|
+
if (url.pathname === '/__chat') {
|
|
1284
|
+
const id = url.searchParams.get('id');
|
|
1285
|
+
const r = room.chatLog.find((x) => x.id === id);
|
|
1286
|
+
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 });
|
|
1287
|
+
}
|
|
1159
1288
|
if (url.pathname === '/__office') {
|
|
1160
1289
|
const ctl = await request.json().catch(() => null);
|
|
1161
1290
|
const why = await verifyControl(this.env, ctl, { game: this.game, room: this.code }, this.seen);
|
|
@@ -1172,6 +1301,9 @@ export class Table {
|
|
|
1172
1301
|
if (room.seatCap !== max) room.setSeats(max, perAddress(max));
|
|
1173
1302
|
// The room's policy (section 17), composed by the Worker for every socket: a newer one than the room's applies.
|
|
1174
1303
|
const pol = decodeFacts(url.searchParams.get('pol'));
|
|
1304
|
+
// The room's chat rules ride beside it (they may be longer than a policy): section 19.
|
|
1305
|
+
const chatRules = decodeFacts(url.searchParams.get('chat'), 16384);
|
|
1306
|
+
if (pol && chatRules && typeof chatRules === 'object') pol.chat = chatRules;
|
|
1175
1307
|
if (pol) room.setPolicy(pol);
|
|
1176
1308
|
if (this.house) this.house.sync();
|
|
1177
1309
|
const agent = decodeFacts(url.searchParams.get('ag'));
|
|
@@ -1191,11 +1323,20 @@ export class Table {
|
|
|
1191
1323
|
watchPolicy: WATCH_POLICIES.includes(url.searchParams.get('wp')) ? url.searchParams.get('wp') : 'follow',
|
|
1192
1324
|
// House QA and `homie-studio check` mark their browsers; their rooms, rounds and peaks are not the studio's numbers.
|
|
1193
1325
|
qa: isQa(request),
|
|
1326
|
+
// Room chat (section 19), the Worker's word: a signed-in account (a passkey), a member of this server, a page of
|
|
1327
|
+
// another site (homie.rocks's room page) watching from elsewhere.
|
|
1328
|
+
acct: url.searchParams.get('acct') === '1',
|
|
1329
|
+
member: url.searchParams.get('mem') === '1',
|
|
1330
|
+
hub: url.pathname === '/__watch' && url.searchParams.get('hub') === '1',
|
|
1194
1331
|
send: (text) => { try { server.send(text); } catch { /* closed */ } },
|
|
1195
1332
|
close: (c, r) => { try { server.close(c, r); } catch { /* closed */ } },
|
|
1196
1333
|
buffered: () => 0,
|
|
1197
1334
|
};
|
|
1198
1335
|
if (url.pathname === '/__watch') {
|
|
1336
|
+
// Watching shells are cheap (outgoing is free) but not free: a room holds at most 256, and 24 from one address
|
|
1337
|
+
// (room chat, 0.23.0: another site's page can open one too).
|
|
1338
|
+
const same = conn.ip ? [...room.watchers].filter((x) => x.ip === conn.ip).length : 0;
|
|
1339
|
+
if (room.watchers.size >= 256 || same >= 24) { try { server.close(1013, 'too many watching'); } catch { /* gone */ } return new Response(null, { status: 101, webSocket: client }); }
|
|
1199
1340
|
const w = room.watch(conn);
|
|
1200
1341
|
// The play page's vote card speaks on this socket (section 17); nothing else it says is read.
|
|
1201
1342
|
server.addEventListener('message', (e) => { if (typeof e.data === 'string') w.onMessage(e.data); });
|
|
@@ -1247,8 +1388,10 @@ export class Table {
|
|
|
1247
1388
|
if (room.officeDirty) { room.officeDirty = false; this.ctx.storage.put('office', room.officeSaved()).catch(() => {}); }
|
|
1248
1389
|
n += 1;
|
|
1249
1390
|
if (n % 4 === 0) { room.tellWatchers(); this.report(); this.recordRound(); this.syncHouse(); }
|
|
1391
|
+
// Room chat's counts, once a minute (section 19).
|
|
1392
|
+
if (n % 240 === 0) this.ctx.waitUntil(this.flushChat().catch(() => {}));
|
|
1250
1393
|
if (room.seats.size === 0 && room.clients.size === 0) this.openCounted = false;
|
|
1251
|
-
if (room.clients.size === 0 && room.watchers.size === 0) { clearInterval(this.timer); this.timer = null; this.report(); if (this.house) { this.ctx.waitUntil(this.flushBrain(true).catch(() => {})); this.house = null; } }
|
|
1394
|
+
if (room.clients.size === 0 && room.watchers.size === 0) { clearInterval(this.timer); this.timer = null; this.report(); this.ctx.waitUntil(this.flushChat().catch(() => {})); if (this.house) { this.ctx.waitUntil(this.flushBrain(true).catch(() => {})); this.house = null; } }
|
|
1252
1395
|
}, 250);
|
|
1253
1396
|
}
|
|
1254
1397
|
|
package/worker/office-page.mjs
CHANGED
|
@@ -105,6 +105,16 @@ button.small { min-height: 30px; padding: 4px 10px; font-size: 13px; border-radi
|
|
|
105
105
|
.warnbox { margin: 10px 16px; padding: 10px 12px; border-radius: 12px; border: 1px solid rgba(255,179,92,.45); color: var(--warn); font-size: 14px; }
|
|
106
106
|
.secret { font: 600 13px/1.4 ui-monospace, Menlo, monospace; word-break: break-all; padding: 10px; border-radius: 10px; background: var(--bg); border: 1px solid var(--warn); }
|
|
107
107
|
.chip.ai { color: #ffe7a8; border-color: rgba(255,207,90,.6); }
|
|
108
|
+
/* Room chat (0.23.0): a room's last minutes, and each game's rules and reports. */
|
|
109
|
+
.chatlog { margin-top: 10px; padding: 10px 12px; border-radius: 12px; background: var(--bg); border: 1px solid var(--line); }
|
|
110
|
+
.chatlog h4 { margin: 0 0 6px; font-size: 12px; letter-spacing: .09em; text-transform: uppercase; color: var(--dim); }
|
|
111
|
+
.chatlog .cl { display: grid; grid-template-columns: minmax(80px, auto) minmax(0, 1fr) auto auto; gap: 4px 10px; align-items: center; padding: 6px 0; border-top: 1px dashed var(--line); font-size: 14px; }
|
|
112
|
+
.chatlog .cl:first-of-type { border-top: 0; }
|
|
113
|
+
.chatlog .cl .txt { overflow-wrap: anywhere; }
|
|
114
|
+
.chatlog .cl.react .txt { font-size: 18px; }
|
|
115
|
+
.chatlog .cl.studio b { color: var(--accent); }
|
|
116
|
+
.chatbox .inv span { overflow-wrap: anywhere; }
|
|
117
|
+
@media (max-width: 720px) { .chatlog .cl { grid-template-columns: 1fr; } }
|
|
108
118
|
/* The New server form: one panel, grouped, every control labelled, help under it in the office's quiet grey. */
|
|
109
119
|
.newsrv { display: grid; gap: 16px; max-width: 820px; margin-top: 10px; padding: 16px; border-radius: 14px; background: var(--panel2); border: 1px solid var(--line); }
|
|
110
120
|
.newsrv [hidden] { display: none !important; }
|
|
@@ -588,6 +598,103 @@ export const OFFICE_SCRIPT = String.raw`(function () {
|
|
|
588
598
|
return box;
|
|
589
599
|
}
|
|
590
600
|
|
|
601
|
+
/* ---------------------------------------------------------------- room chat (0.23.0, NETPLAY.md section 19) */
|
|
602
|
+
var CHAT_MODES = [['off', 'Off'], ['emoji', 'Emoji'], ['lines', 'Emoji + quick lines'], ['text', 'Typing too']];
|
|
603
|
+
var CHAT_WHO = [['anyone', 'Anyone'], ['signed-in', 'Signed in'], ['members', 'Members']];
|
|
604
|
+
var CHAT_MODE_HELP = { off: 'No chat at all in this game\'s rooms.', emoji: 'Players and watchers send the five reactions (and the game\'s own); they float up every screen.', lines: 'Reactions and the game\'s own quick lines ("Good game!"): nothing anyone types. What a kids or beginner server always keeps to.', text: 'Reactions, quick lines and typed messages. Every typed message passes the word list first, and this studio\'s Workers AI when the review is on.' };
|
|
605
|
+
function ago(ms) { var s = Math.max(0, Math.round(ms / 1000)); return s < 60 ? s + ' s ago' : Math.round(s / 60) + ' min ago'; }
|
|
606
|
+
/** The game's chat rules (for the whole game, or one server), the review's day, and the reports players made. */
|
|
607
|
+
function chatBox(g) {
|
|
608
|
+
var c = g.chat || {};
|
|
609
|
+
var box = el('div', 'servers chatbox');
|
|
610
|
+
var key = g.id + '/chat';
|
|
611
|
+
var d = S.drafts[key] || (S.drafts[key] = { server: '' });
|
|
612
|
+
var target = d.server ? ((c.servers || []).filter(function (x) { return x.id === d.server; })[0] || {}) : null;
|
|
613
|
+
var r = Object.assign({}, (target && target.rules) || c.rules || {}, d.edit || {});
|
|
614
|
+
var head = el('h3', '', 'Room chat');
|
|
615
|
+
var summary = el('div', 'line dim');
|
|
616
|
+
var from = c.from === 'office' ? 'your rules' : c.from === 'game.json' ? 'the game\'s game.json' : 'Homie\'s defaults';
|
|
617
|
+
summary.textContent = (c.rules ? ({ off: 'Off', emoji: 'Emoji only', lines: 'Emoji and quick lines', text: 'Typing allowed' }[c.rules.mode] || c.rules.mode) : '–') + (c.rules && c.rules.mode === 'text' ? ' · typing: ' + c.rules.who + (c.rules.ai ? ' · AI review on' : ' · word list only') : '') + ' · from ' + from + '.';
|
|
618
|
+
add(box, head, summary);
|
|
619
|
+
if (!S.open[key]) {
|
|
620
|
+
add(box, add(el('div', 'line'), btn('Change chat rules', 'ghost small', function () { S.open[key] = true; render(); })));
|
|
621
|
+
} else {
|
|
622
|
+
var form = el('form', 'newsrv');
|
|
623
|
+
var scope = sel([['', 'The whole game']].concat((g.servers || []).map(function (x) { return [x.id, 'Server: ' + x.name]; })), d.server, 'Rules for');
|
|
624
|
+
scope.onchange = function () { d.server = scope.value; d.edit = {}; render(); };
|
|
625
|
+
var set = function (k, v) { d.edit = d.edit || {}; d.edit[k] = v; };
|
|
626
|
+
var mode = segment(g.id + 'cmode', CHAT_MODES, r.mode, 'What chat allows', function (v) { set('mode', v); help.textContent = CHAT_MODE_HELP[v]; });
|
|
627
|
+
var help = el('p', 'help', CHAT_MODE_HELP[r.mode] || '');
|
|
628
|
+
var who = segment(g.id + 'cwho', CHAT_WHO, r.who, 'Who may type', function (v) { set('who', v); });
|
|
629
|
+
var react = segment(g.id + 'creact', CHAT_WHO, r.react, 'Who may react', function (v) { set('react', v); });
|
|
630
|
+
var slow = num(r.slow === undefined ? 2 : r.slow, 0, 120, 'Slow mode, seconds'); slow.onchange = function () { set('slow', Number(slow.value)); };
|
|
631
|
+
var max = num(r.max || 140, 20, 280, 'Longest message'); max.onchange = function () { set('max', Number(max.value)); };
|
|
632
|
+
var check = function (k, label, on) { var x = el('input'); x.type = 'checkbox'; x.checked = Boolean(on); x.onchange = function () { set(k, x.checked); }; return add(el('label', 'check'), x, el('span', '', label)); };
|
|
633
|
+
var words = el('input'); words.type = 'text'; words.placeholder = 'Words to hold, comma separated (word* holds it inside words too)'; words.value = (d.edit && d.edit.block !== undefined ? d.edit.block : (target ? (target.office || {}).block : (c.office || {}).block) || []).join(', ');
|
|
634
|
+
words.onchange = function () { set('block', words.value.split(',').map(function (w) { return w.trim(); }).filter(Boolean)); };
|
|
635
|
+
add(form,
|
|
636
|
+
row('Rules for', null, scope, el('p', 'help', 'A server\'s rules sit over the game\'s; a kids or beginner server always keeps chat to emoji and quick lines.')),
|
|
637
|
+
row('Chat', null, mode.box, help),
|
|
638
|
+
row('Who may type', null, who.box, el('p', 'help', 'Signed in: a player account with a passkey (no password, no email). Members: players who belong to the room\'s server.')),
|
|
639
|
+
row('Emoji and lines', null, react.box, el('p', 'help', 'Who may send reactions and quick lines. Anyone keeps the room lively for guests.')),
|
|
640
|
+
row('Slow mode', null, add(el('div', 'line'), slow, el('span', 'dim', 'seconds between one person\'s messages')), null),
|
|
641
|
+
row('Longest message', null, add(el('div', 'line'), max, el('span', 'dim', 'characters')), null),
|
|
642
|
+
row('Checks', null, add(el('div', 'ctl'),
|
|
643
|
+
check('ai', 'Review typed messages with this studio\'s Workers AI (Cloudflare\'s Clef decision model)', r.ai !== false),
|
|
644
|
+
check('links', 'Allow links (they are never clickable)', r.links === 'allow'),
|
|
645
|
+
check('swears', 'Allow swearing (slurs, sexual words and threats are always held)', r.swears === 'allow')), el('p', 'help', 'The word list always runs, and needs nothing. The review adds what a list misses (bullying, a stranger asking a kid where they live), within a day\'s budget of Cloudflare\'s free allocation; past it, the word list alone decides.')),
|
|
646
|
+
row('Your words', null, words, null),
|
|
647
|
+
row('Shown', null, add(el('div', 'ctl'),
|
|
648
|
+
check('bubbles', 'Over the speaker\'s character, in games that draw it', r.bubbles !== false),
|
|
649
|
+
check('watchers', 'Watchers may send too (not just read)', r.watchers !== false),
|
|
650
|
+
check('hub', 'On homie.rocks\'s page for the room', r.hub !== false)), null));
|
|
651
|
+
var acts = el('div', 'acts');
|
|
652
|
+
add(acts,
|
|
653
|
+
btn('Save', '', function () {
|
|
654
|
+
var body = Object.assign({ game: g.id }, d.server ? { server: d.server } : {}, d.edit || {});
|
|
655
|
+
if (Object.keys(d.edit || {}).length === 0) { toast('Nothing changed.'); return; }
|
|
656
|
+
act('/_studio/api/chat/rules', body, 'Chat rules saved: every live room has them now.').then(function (res) { if (res && res.ok) { d.edit = {}; S.open[key] = false; } });
|
|
657
|
+
}),
|
|
658
|
+
btn('Back to the game\'s own', 'ghost', function () { act('/_studio/api/chat/rules', Object.assign({ game: g.id, reset: true }, d.server ? { server: d.server } : {}), 'Back to the game\'s own chat rules.').then(function () { d.edit = {}; }); }),
|
|
659
|
+
btn('Close', 'ghost', function () { S.open[key] = false; d.edit = {}; render(); }));
|
|
660
|
+
form.appendChild(acts);
|
|
661
|
+
form.onsubmit = function (e) { e.preventDefault(); };
|
|
662
|
+
box.appendChild(form);
|
|
663
|
+
}
|
|
664
|
+
// What players reported: one message each, kept 30 days or until you dismiss it.
|
|
665
|
+
var reps = c.reports || [];
|
|
666
|
+
if (reps.length) {
|
|
667
|
+
add(box, el('h3', '', 'Reports (' + reps.length + ')'));
|
|
668
|
+
reps.forEach(function (p) {
|
|
669
|
+
var rowEl = el('div', 'inv');
|
|
670
|
+
add(rowEl, el('b', '', p.name || 'Someone'), el('span', '', '"' + p.text + '"'), el('span', 'chip muted', p.reasonText || p.reason), el('span', 'faint', p.room + ' · ' + ago(Date.now() - (S.skew || 0) - p.at)), p.player ? el('span', 'faint', 'account ' + p.player.slice(0, 10) + '…') : null,
|
|
671
|
+
btn('Dismiss', 'ghost small', function () { act('/_studio/api/chat/report', { id: p.id }, 'Report dismissed (deleted).'); }));
|
|
672
|
+
box.appendChild(rowEl);
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
return box;
|
|
676
|
+
}
|
|
677
|
+
/** A room's last minutes of chat, with Remove, Mute and Kick from a line. */
|
|
678
|
+
function roomChat(g, r, now) {
|
|
679
|
+
var ch = r.chat;
|
|
680
|
+
if (!ch || !(ch.lines || []).length) return null;
|
|
681
|
+
var box = el('div', 'chatlog');
|
|
682
|
+
add(box, el('h4', '', 'Chat · last ' + ch.lines.length + (ch.lines.length === 1 ? ' message' : ' messages')));
|
|
683
|
+
ch.lines.slice().reverse().slice(0, 20).forEach(function (l) {
|
|
684
|
+
var rowEl = el('div', 'cl' + (l.kind === 'react' ? ' react' : '') + (l.by === 'studio' ? ' studio' : ''));
|
|
685
|
+
add(rowEl, el('b', '', l.by === 'studio' ? 'Studio' : l.name), el('span', 'txt', l.kind === 'react' ? (l.glyph || '') : (l.text || '')),
|
|
686
|
+
el('span', 'faint', ago(now - l.at) + (l.by === 'watcher' ? ' · watching' : l.by === 'hub' ? ' · on homie.rocks' : l.seat !== null && l.seat !== undefined ? ' · seat ' + (l.seat + 1) : '') + (l.review && l.review.by === 'ai' ? ' · reviewed' : '')));
|
|
687
|
+
if (l.by !== 'studio') {
|
|
688
|
+
add(rowEl, add(el('span', 'pacts'),
|
|
689
|
+
btn('Remove', 'ghost small', function () { act('/_studio/api/chat/remove', { game: g.id, room: r.room, id: l.id }, 'Removed from every screen.'); }),
|
|
690
|
+
btn('Mute', 'warn small', function () { act('/_studio/api/mute', { game: g.id, room: r.room, line: l.id, minutes: hold(), purge: true }, (l.name || 'They') + ' is muted for ' + hold() + ' min; their messages are down.'); }),
|
|
691
|
+
armed('Kick', 'bad', function () { act('/_studio/api/kick', { game: g.id, room: r.room, line: l.id, minutes: hold(), purge: true }, (l.name || 'They') + ' was removed from ' + r.label + ' for ' + hold() + ' min.'); })));
|
|
692
|
+
}
|
|
693
|
+
box.appendChild(rowEl);
|
|
694
|
+
});
|
|
695
|
+
return box;
|
|
696
|
+
}
|
|
697
|
+
|
|
591
698
|
function person(g, r, c, now) {
|
|
592
699
|
var row = el('div', 'person');
|
|
593
700
|
var who = el('div', 'who');
|
|
@@ -649,6 +756,8 @@ export const OFFICE_SCRIPT = String.raw`(function () {
|
|
|
649
756
|
people.appendChild(el('div', 'held', 'Guides\' brain: ' + (r.brains.brain || 'script') + (r.brains.why ? ' · ' + r.brains.why : '') + ' · ' + r.brains.calls + ' calls' + (last.length ? ' · ' + last.join('; ') : '')));
|
|
650
757
|
}
|
|
651
758
|
(r.bans || []).forEach(function (b) { people.appendChild(el('div', 'held', 'Kicked: ' + (b.name || 'someone') + ', may come back in ' + dur(b.until - now) + (b.address ? ' (their network too)' : ''))); });
|
|
759
|
+
var said = roomChat(g, r, now);
|
|
760
|
+
if (said) people.appendChild(said);
|
|
652
761
|
box.appendChild(people);
|
|
653
762
|
}
|
|
654
763
|
return box;
|
|
@@ -662,6 +771,10 @@ export const OFFICE_SCRIPT = String.raw`(function () {
|
|
|
662
771
|
document.getElementById('livedot').className = 'dot' + (d.playing ? ' on' : '');
|
|
663
772
|
var at = document.activeElement;
|
|
664
773
|
var focus = at && root.contains(at) && (/INPUT|SELECT/.test(at.tagName) || Boolean(at.closest && at.closest('.newsrv')));
|
|
774
|
+
// The day's chat review across the studio: what it used of its budget, and what it held.
|
|
775
|
+
var cd = d.chat;
|
|
776
|
+
var chatEl = document.getElementById('chatday');
|
|
777
|
+
if (chatEl && cd) chatEl.textContent = 'Chat review today: ' + (cd.ai ? Math.round(cd.used.neurons).toLocaleString() + ' of ' + Math.round(cd.budget.neurons).toLocaleString() + ' neurons, ' + cd.used.reviews + ' reviewed, ' + cd.used.held + ' held' + (cd.used.errors ? ', ' + cd.used.errors + ' unanswered (the word list decided)' : '') : 'off (no Workers AI binding on this Worker yet: deploy once more; the word list decides meanwhile)') + '.';
|
|
665
778
|
// Never redraw under a typing owner (or one working in the New server form), a control waiting for its second
|
|
666
779
|
// tap, or a pass secret shown once.
|
|
667
780
|
if (focus || root.querySelector('[data-armed="1"]') || root.querySelector('.secret')) return;
|
|
@@ -676,6 +789,7 @@ export const OFFICE_SCRIPT = String.raw`(function () {
|
|
|
676
789
|
// A build from before servers (netplay rev 5 or older): what it cannot do yet, and the fix.
|
|
677
790
|
if (g.build && g.build.predates && (g.servers || []).some(function (x) { return x.id !== 'public' && (x.aiSeats || x.guides); })) card.appendChild(el('div', 'warnbox', 'This build predates servers (netplay rev ' + (g.build.netplayRev || '5 or older') + '): reserved AI seats stay empty and its bots don\'t read the dial. Rebuild with @homie-rocks/studio 0.16.'));
|
|
678
791
|
card.appendChild(serversBox(g));
|
|
792
|
+
card.appendChild(chatBox(g));
|
|
679
793
|
card.appendChild(passes(g));
|
|
680
794
|
if (g.launch === 'invite' || (g.invites && g.invites.length)) card.appendChild(invites(g));
|
|
681
795
|
var rooms = el('div', 'rooms');
|
|
@@ -734,6 +848,7 @@ export async function officePage(cat, headers = {}) {
|
|
|
734
848
|
<select id="announce-scope" aria-label="Who sees it"><option value="">every room of every game</option>${options}</select><button type="submit">Send</button>
|
|
735
849
|
<label for="hold" style="margin-left:auto">Kick, mute and close for</label><select id="hold"><option value="5">5 min</option><option value="10" selected>10 min</option><option value="30">30 min</option><option value="60">1 hour</option><option value="1440">a day</option></select></form>
|
|
736
850
|
<p class="dim" id="status" role="status"></p>
|
|
851
|
+
<p class="dim" id="chatday"></p>
|
|
737
852
|
<div id="games" aria-live="polite"><p class="empty">Reading the rooms…</p></div>
|
|
738
853
|
<div class="toast" id="toast" role="status" hidden></div>
|
|
739
854
|
<p class="note">Servers: each game's named room pools. Open: anyone, AI with a pass marked AI. Humans only: no AI at all. Hybrid: some seats in every room are AI companions; the party votes their level. Beginner: new players, AI guides, quick lines only. Changing a server reaches its live rooms at once; AI leave after the round when a server becomes humans-only.</p>
|