@homie-rocks/studio 0.22.0 → 0.24.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 +61 -1
- package/README.md +59 -0
- package/bin/homie-studio.mjs +68 -2
- package/chat/CHAT.md +101 -0
- package/chat/OWNERS.md +78 -0
- package/lib/build.mjs +20 -2
- package/lib/chat-cli.mjs +89 -0
- package/lib/cloudflare.mjs +5 -2
- package/lib/scaffold.mjs +59 -2
- package/lib/shop.mjs +270 -0
- package/lib/template-history.json +77 -0
- package/netplay/NETPLAY.md +194 -4
- package/netplay/netplay.ts +156 -3
- package/package.json +7 -1
- package/port/index.ts +3 -2
- package/port/view.ts +238 -0
- package/shop/SELLING.md +83 -0
- package/shop/SHOP.md +154 -0
- package/shop/shop.ts +130 -0
- package/site/SITE.md +22 -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/account-page.mjs +14 -2
- 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 +189 -7
- package/worker/office-page.mjs +120 -5
- package/worker/office.mjs +173 -6
- package/worker/pages.mjs +25 -8
- package/worker/players.mjs +8 -0
- package/worker/referrals.mjs +328 -0
- package/worker/room.mjs +291 -11
- package/worker/servers.mjs +5 -1
- package/worker/shop-page.mjs +532 -0
- package/worker/shop-rules.mjs +277 -0
- package/worker/shop-store.mjs +217 -0
- package/worker/shop.mjs +686 -0
- package/worker/site.mjs +4 -1
- package/worker/stats.mjs +4 -1
- package/worker/stripe.mjs +149 -0
- package/worker/version.mjs +1 -1
package/worker/index.mjs
CHANGED
|
@@ -51,6 +51,13 @@
|
|
|
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
|
|
57
|
+
* /shop/, /api/shop/... the studio's shop (worker/shop.mjs, shop/SHOP.md): its items in real money, the studio's
|
|
58
|
+
* OWN Stripe Checkout, the signed webhook, what a player owns (/api/player/owns), refunds;
|
|
59
|
+
* closed until the owner's restricted key is in; never on a kids server or a television
|
|
60
|
+
* /api/referrals/statement another studio's signed referral statement to this one (worker/referrals.mjs)
|
|
54
61
|
*
|
|
55
62
|
* A page in the studio's site/pages wins over the generated one at the same address. Every HTML answer is
|
|
56
63
|
* `no-transform` (an edge in front of a custom domain injects nothing) and is never framed by another site.
|
|
@@ -80,6 +87,8 @@ import {
|
|
|
80
87
|
} from './servers.mjs';
|
|
81
88
|
import { BRAIN_BUDGET, HouseAgents, agentFacts, aiName, decodeFacts, encodeFacts, passById, passRefusal, sitRoute } from './agents.mjs';
|
|
82
89
|
import { talks } from './brain.mjs';
|
|
90
|
+
import { REACTIONS, reviewChat } from './chat.mjs';
|
|
91
|
+
import { REPORT_REASONS, chatDay, chatOf, chatRowsOf, fileReport } from './chat-store.mjs';
|
|
83
92
|
import { doorPage, serverPage, serversPage } from './site.mjs';
|
|
84
93
|
import { isLocalOrigin, qrSvg } from './qr.mjs';
|
|
85
94
|
import { SEAT_MAX, perAddress, seatsOf } from './seats.mjs';
|
|
@@ -99,6 +108,8 @@ import {
|
|
|
99
108
|
// every device, and the owner's own account (`homie-studio players owner`) counts as the owner.
|
|
100
109
|
usePlayers(playerAccounts);
|
|
101
110
|
import { STUDIO_VERSION_TAG } from './version.mjs';
|
|
111
|
+
import { roomBadge, shellShop, shopOf, shopRoutes } from './shop.mjs';
|
|
112
|
+
import { arrivalCookie, manifestReferrals } from './referrals.mjs';
|
|
102
113
|
import { licenseOf, remixAllowed, remixRow } from './license.mjs';
|
|
103
114
|
|
|
104
115
|
export { SEAT_MAX } from './seats.mjs';
|
|
@@ -232,6 +243,8 @@ function named(cat, env) {
|
|
|
232
243
|
async function roomsOf(env, games, { servers = null } = {}) {
|
|
233
244
|
const rooms = [];
|
|
234
245
|
const live = {};
|
|
246
|
+
// Room chat (section 19): whether each room's chat is on and may show on homie.rocks's page for it.
|
|
247
|
+
const chatRows = await chatRowsOf(env);
|
|
235
248
|
await Promise.all(games.map(async (g) => {
|
|
236
249
|
let list = [];
|
|
237
250
|
try { list = (await (await env.LOBBY.get(env.LOBBY.idFromName(g.id)).fetch('https://lobby/rooms')).json()).rooms ?? []; } catch { list = []; }
|
|
@@ -242,7 +255,8 @@ async function roomsOf(env, games, { servers = null } = {}) {
|
|
|
242
255
|
const sid = r.server ?? roomServer(r.name);
|
|
243
256
|
const srv = sid === 'public' ? (known.find((x) => x.id === 'public') ?? PUBLIC_SERVER) : known.find((x) => x.id === sid);
|
|
244
257
|
if (!srv || (sid !== 'public' && (!srv.listed || srv.state !== 'open' || srv.door !== 'open'))) continue;
|
|
245
|
-
|
|
258
|
+
const chat = chatOf(g, srv, chatRows);
|
|
259
|
+
rooms.push(roomView(g, r, seatsOf(g), srv, { chat: chat.mode !== 'off' && chat.hub }));
|
|
246
260
|
}
|
|
247
261
|
}));
|
|
248
262
|
rooms.sort((a, b) => b.players - a.players || a.game.localeCompare(b.game) || String(a.room).localeCompare(String(b.room)));
|
|
@@ -381,6 +395,30 @@ function mediaRow(e, origin, kind, cat) {
|
|
|
381
395
|
* wrapper Worker of its own for this. A preflight (a POST's content-type) is answered too.
|
|
382
396
|
*/
|
|
383
397
|
const KNOCK_CORS = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET, POST', 'access-control-allow-headers': 'content-type', 'access-control-max-age': '86400' };
|
|
398
|
+
/**
|
|
399
|
+
* 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
|
|
400
|
+
* server (on the public server, any signed-in account). One D1 read for a signed-in player, none for a guest.
|
|
401
|
+
*/
|
|
402
|
+
async function chatWho(env, game, srv, holder) {
|
|
403
|
+
const pid = holders(holder).find((x) => x.startsWith('p-'))?.slice(2) ?? null;
|
|
404
|
+
if (!pid || !env.DB) return { acct: false, member: false };
|
|
405
|
+
let acct = false;
|
|
406
|
+
try { acct = Number((await env.DB.prepare('SELECT guest FROM players WHERE id = ?1').bind(pid).first())?.guest) === 0; } catch { acct = false; }
|
|
407
|
+
const member = acct && (!srv || srv.id === 'public' || Boolean(await memberOf(env, game, srv.id, pid)));
|
|
408
|
+
return { acct, member };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Reports a browser (and its address) may make: 8 in 10 minutes, counted in this Worker's memory and forgotten. */
|
|
412
|
+
const reportsMade = new Map();
|
|
413
|
+
function reportAllowed(key, now = Date.now()) {
|
|
414
|
+
const list = (reportsMade.get(key) ?? []).filter((at) => now - at < 10 * 60_000);
|
|
415
|
+
if (list.length >= 8) { reportsMade.set(key, list); return false; }
|
|
416
|
+
list.push(now);
|
|
417
|
+
reportsMade.set(key, list);
|
|
418
|
+
if (reportsMade.size > 5000) for (const [k, v] of reportsMade) if (!v.some((at) => now - at < 10 * 60_000)) reportsMade.delete(k);
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
|
|
384
422
|
function notAHomie(request) {
|
|
385
423
|
if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: { ...KNOCK_CORS, 'cache-control': 'no-store' } });
|
|
386
424
|
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 +457,9 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
|
|
|
419
457
|
...(agent ? { agent: { hands: agent.hands, role: agent.role } } : {}),
|
|
420
458
|
// The play page's "Quiet AI": this browser hides AI speech (the helper's `net.hushed`).
|
|
421
459
|
...(url.searchParams.get('hush') === '1' ? { hush: true } : {}),
|
|
460
|
+
// Room chat (section 19): this browser hides chat ("Show chat" off), or its player's own lines stay off their character.
|
|
461
|
+
...(url.searchParams.get('chat') === '0' ? { chatOff: true } : {}),
|
|
462
|
+
...(url.searchParams.get('bub') === '0' ? { bubbleOff: true } : {}),
|
|
422
463
|
...(device ? { device } : {}),
|
|
423
464
|
want,
|
|
424
465
|
...(watching ? { watch: true, follow: /^\d+$/.test(follow) ? Number(follow) : follow, watchPolicy: policy } : {}),
|
|
@@ -426,6 +467,9 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
|
|
|
426
467
|
...(meta?.movement ? { movement: meta.movement } : {}),
|
|
427
468
|
// game.json "saves": the play shell around this frame answers @homie-rocks/studio/saves (saves/SAVES.md).
|
|
428
469
|
...(meta?.saves && want === 'play' ? { saves: true } : {}),
|
|
470
|
+
// The studio sells something in this game (shop/SHOP.md): the play shell answers @homie-rocks/studio/shop. A
|
|
471
|
+
// watcher has no shop; the shell itself says "kids" on a kids server and shows only a code on a television.
|
|
472
|
+
...(!agent && !watching && shellShop(cat, game) ? { shop: true } : {}),
|
|
429
473
|
};
|
|
430
474
|
let html = await res.text();
|
|
431
475
|
const head = `<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
|
|
@@ -504,7 +548,18 @@ function finish(res, path) {
|
|
|
504
548
|
|
|
505
549
|
export default {
|
|
506
550
|
async fetch(request, env, ctx) {
|
|
507
|
-
|
|
551
|
+
const url = new URL(request.url);
|
|
552
|
+
const res = finish(await route(request, env, ctx), url.pathname);
|
|
553
|
+
// A referral's arrival (worker/referrals.mjs): only a person's page load with ?via= of another site, on a studio
|
|
554
|
+
// that pays referrals. The page is answered first; a cookie is added to it only then.
|
|
555
|
+
if (url.searchParams.has('via') && res && res.status === 200 && /text\/html/i.test(res.headers.get('content-type') ?? '')) {
|
|
556
|
+
try {
|
|
557
|
+
const cat = await catalogue(env, url.origin);
|
|
558
|
+
const cookie = await arrivalCookie(request, env, url, shopOf(cat));
|
|
559
|
+
if (cookie) { const out = new Response(res.body, res); out.headers.append('set-cookie', cookie); return out; }
|
|
560
|
+
} catch { /* an arrival is never worth a failed page */ }
|
|
561
|
+
}
|
|
562
|
+
return res;
|
|
508
563
|
},
|
|
509
564
|
};
|
|
510
565
|
|
|
@@ -527,6 +582,11 @@ async function route(request, env, ctx) {
|
|
|
527
582
|
}
|
|
528
583
|
if (path.startsWith('/_studio/')) return (await officeRoutes(request, env, url, { catalogueOf: getAll })) ?? ownerRoutes(request, env, url, { catalogueOf: getAll });
|
|
529
584
|
if (path === '/__homie' || path.startsWith('/__homie/')) return notAHomie(request);
|
|
585
|
+
// The shop (0.24.0) before player accounts: /api/player/owns is the shop's.
|
|
586
|
+
{
|
|
587
|
+
const shopped = await shopRoutes(request, env, ctx, url, { catalogueOf: getAll });
|
|
588
|
+
if (shopped) return shopped;
|
|
589
|
+
}
|
|
530
590
|
if (path.startsWith('/api/player/') || path === '/account' || path === '/account/' || path === '/_homie/account.js') return playerRoutes(request, env, ctx, url, { catalogueOf: getCat });
|
|
531
591
|
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' } });
|
|
532
592
|
if (path === '/api/stats/beat' && request.method === 'POST') return mediaBeat(request, env, ctx, url);
|
|
@@ -587,6 +647,10 @@ async function route(request, env, ctx) {
|
|
|
587
647
|
...(shareRooms ? { rooms: `${url.origin}/api/rooms` } : {}),
|
|
588
648
|
// Shared only when studio.json says `stats.share`: two numbers for the whole studio, for the hub.
|
|
589
649
|
...(played ? { played } : {}),
|
|
650
|
+
// The shop (0.24.0): whether it sells and its till, never a key or a sale; and referrals: this studio takes
|
|
651
|
+
// signed statements as a referrer, with the public half of its statement key and, if it sells, its terms.
|
|
652
|
+
...(shopOf(cat)?.open ? { shop: { open: true, till: shopOf(cat).till, currency: shopOf(cat).currency, page: `${url.origin}/shop/` } } : {}),
|
|
653
|
+
...(await manifestReferrals(env, url.origin, shopOf(cat), cat.studio).then((r) => (r ? { referrals: r } : {})).catch(() => ({}))),
|
|
590
654
|
}, 200, { 'access-control-allow-origin': '*' });
|
|
591
655
|
}
|
|
592
656
|
if (path === '/api/games') {
|
|
@@ -850,11 +914,15 @@ async function route(request, env, ctx) {
|
|
|
850
914
|
const local = isLocalOrigin(url.origin);
|
|
851
915
|
let qr = null;
|
|
852
916
|
if (!local) try { qr = qrSvg(joinUrl, { title: `Join ${meta.name ?? game}` }); } catch { /* too long for a QR: the address shows as text */ }
|
|
853
|
-
|
|
917
|
+
// The shop on a television is a code to buy on a phone (the TV never sells; never on a kids server).
|
|
918
|
+
const sh = shellShop(cat, game, { kids: pol.kids });
|
|
919
|
+
let shopQr = null;
|
|
920
|
+
if (sh && !local) try { shopQr = qrSvg(`${url.origin}/shop/?game=${encodeURIComponent(game)}`, { title: `Shop: ${meta.name ?? game}` }); } catch { shopQr = null; }
|
|
921
|
+
return playPage(cat, meta, { screen: true, joinUrl, qr, local, room, ticket, owner: d.owner, launch, server, shop: sh ? { ...sh, qr: shopQr, url: `${url.origin}/shop/?game=${game}` } : null });
|
|
854
922
|
}
|
|
855
923
|
await countVisit(request, env, ctx, game, 'play');
|
|
856
924
|
shareDaily(cat, url, ctx);
|
|
857
|
-
return playPage(cat, meta, { ticket, owner: d.owner, launch, server });
|
|
925
|
+
return playPage(cat, meta, { ticket, owner: d.owner, launch, server, ...(await chatWho(env, game, srv, d.acct)), shop: shellShop(cat, game, { kids: pol.kids }) });
|
|
858
926
|
}
|
|
859
927
|
if (sub === 'watch') {
|
|
860
928
|
// 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 +939,7 @@ async function route(request, env, ctx) {
|
|
|
871
939
|
const ticket = d.holder ? await ticketFor(env, game, d.holder) : null;
|
|
872
940
|
await countVisit(request, env, ctx, game, 'watch');
|
|
873
941
|
shareDaily(cat, url, ctx);
|
|
874
|
-
return watchPage(cat, meta, { room: asked, ticket, policy });
|
|
942
|
+
return watchPage(cat, meta, { room: asked, ticket, policy, owner: d.owner, ...(await chatWho(env, game, srv, d.acct)) });
|
|
875
943
|
}
|
|
876
944
|
if (sub === 'api/watch') {
|
|
877
945
|
if (!door.ok || watchOf(meta) === 'off') return json({ ok: false, error: 'not-found' }, 404);
|
|
@@ -912,6 +980,27 @@ async function route(request, env, ctx) {
|
|
|
912
980
|
},
|
|
913
981
|
});
|
|
914
982
|
}
|
|
983
|
+
if (sub === 'api/chat/report') {
|
|
984
|
+
// A player reports one chat line (section 19): the room's own copy of it is what is kept, never the reporter's words.
|
|
985
|
+
if (request.method !== 'POST' || !sameOrigin(request, url)) return json({ ok: false, error: 'origin', message: 'a report comes from this site\'s own pages' }, 403);
|
|
986
|
+
if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
|
|
987
|
+
let body = {};
|
|
988
|
+
try { body = JSON.parse((await request.text()).slice(0, 2048) || '{}'); } catch { return json({ ok: false, error: 'json' }, 400); }
|
|
989
|
+
const room = String(body.room ?? '');
|
|
990
|
+
const id = String(body.id ?? '');
|
|
991
|
+
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);
|
|
992
|
+
const reason = REPORT_REASONS.includes(body.reason) ? body.reason : 'other';
|
|
993
|
+
const key = `${BROWSER_KEY.test(String(body.b ?? '')) ? body.b : ''}|${request.headers.get('cf-connecting-ip') ?? ''}`;
|
|
994
|
+
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);
|
|
995
|
+
let line = null;
|
|
996
|
+
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; }
|
|
997
|
+
if (!line) return json({ ok: false, error: 'gone', message: 'That message is no longer in the room.' }, 404);
|
|
998
|
+
if (line.by === 'studio') return json({ ok: false, error: 'studio', message: 'That is the studio\'s own line.' }, 400);
|
|
999
|
+
try { await fileReport(env, { game, room, line, reason }); } catch (error) {
|
|
1000
|
+
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);
|
|
1001
|
+
}
|
|
1002
|
+
return json({ ok: true, message: 'Thanks for telling the studio. They will look at it.' });
|
|
1003
|
+
}
|
|
915
1004
|
if (sub === '__net' || sub === '__watch') {
|
|
916
1005
|
if (request.headers.get('upgrade') !== 'websocket') return new Response('websocket only', { status: 426 });
|
|
917
1006
|
const room = url.searchParams.get('room') || 'main';
|
|
@@ -942,8 +1031,19 @@ async function route(request, env, ctx) {
|
|
|
942
1031
|
const access = await serverAccess(env, { game, server: srv, holders: parts, watching: w || sub === '__watch' });
|
|
943
1032
|
if (!access.ok) return new Response('this server is not open to you', { status: 403 });
|
|
944
1033
|
}
|
|
1034
|
+
// Room chat (section 19): the room's rules, and who this socket is to them: an account with a passkey (not a
|
|
1035
|
+
// guest), a member of this server, or another site's page watching from elsewhere (homie.rocks's room page).
|
|
1036
|
+
const chat = chatOf(meta, srv, await chatRowsOf(env));
|
|
1037
|
+
const { acct, member } = await chatWho(env, game, srv, who);
|
|
1038
|
+
const origin = request.headers.get('origin');
|
|
1039
|
+
const hub = sub === '__watch' && Boolean(origin) && origin !== 'null' && origin !== url.origin;
|
|
1040
|
+
const lean = { ...chat, emoji: chat.emoji.filter((e) => !REACTIONS.some((r) => r.k === e.k)) };
|
|
1041
|
+
// A supporter's badge (the shop, 0.24.0): what the player's account owns, looked up here, so a hello can never claim
|
|
1042
|
+
// one; none on a kids server, none for an AI or a watcher.
|
|
1043
|
+
const pid = !ag && !w && sub === '__net' ? parts.find((x) => x.startsWith('p-'))?.slice(2) : null;
|
|
1044
|
+
const badge = pid ? await roomBadge(env, cat, pid, { kids: pol.kids }) : null;
|
|
945
1045
|
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)}` : ''}`;
|
|
1046
|
+
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)}` : ''}${badge ? `&bd=${encodeURIComponent(badge)}` : ''}`;
|
|
947
1047
|
return stub.fetch(new Request(target, request));
|
|
948
1048
|
}
|
|
949
1049
|
// The same knock, relative to the game's own page: the same answer (see notAHomie).
|
|
@@ -1031,6 +1131,12 @@ export class Table {
|
|
|
1031
1131
|
this.brainUsed = { neurons: 0, micros: 0 };
|
|
1032
1132
|
this.brainPending = { calls: {}, neurons: 0, micros: 0 };
|
|
1033
1133
|
this.brainReadAt = 0;
|
|
1134
|
+
/** 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. */
|
|
1135
|
+
this.chatCap = null;
|
|
1136
|
+
this.chatUsed = 0;
|
|
1137
|
+
this.chatReadAt = 0;
|
|
1138
|
+
this.chatPend = { neurons: 0, ok: 0, held: 0, error: 0 };
|
|
1139
|
+
this.chatSent = { chatLines: 0, chatReacts: 0, held: {} };
|
|
1034
1140
|
ctx.blockConcurrencyWhile(async () => {
|
|
1035
1141
|
this.saved = (await ctx.storage.get('net')) ?? null;
|
|
1036
1142
|
this.recorded = (await ctx.storage.get('recorded')) ?? 0;
|
|
@@ -1055,9 +1161,63 @@ export class Table {
|
|
|
1055
1161
|
if (this.saved) this.room.restore(this.saved);
|
|
1056
1162
|
if (this.officeSaved) this.room.restoreOffice(this.officeSaved);
|
|
1057
1163
|
this.vocabRead = this.readVocab(game).catch(() => false);
|
|
1164
|
+
// Room chat (section 19): typed text the floor let through waits for the studio's own Workers AI.
|
|
1165
|
+
this.room.review = (text, opts) => this.reviewChat(text, opts);
|
|
1058
1166
|
return this.room;
|
|
1059
1167
|
}
|
|
1060
1168
|
|
|
1169
|
+
/**
|
|
1170
|
+
* One typed chat message, reviewed by the studio's decision model (worker/chat.mjs reviewChat) within the day's
|
|
1171
|
+
* budget for the whole studio. No binding (`homie-studio dev` without --remote-ai), no budget left, or an error: the
|
|
1172
|
+
* floor alone has decided (`by` says which), and the message goes out.
|
|
1173
|
+
*/
|
|
1174
|
+
async reviewChat(text, { links = 'block' } = {}) {
|
|
1175
|
+
if (!this.env.AI) return { ok: true, by: 'none' };
|
|
1176
|
+
if (this.chatCap === null || Date.now() - this.chatReadAt > 60_000) await this.readChatDay().catch(() => {});
|
|
1177
|
+
if (this.chatUsed >= (this.chatCap ?? 0)) return { ok: true, by: 'budget' };
|
|
1178
|
+
try {
|
|
1179
|
+
const v = await reviewChat(this.env, text, { links });
|
|
1180
|
+
this.chatUsed += v.neurons;
|
|
1181
|
+
this.chatPend.neurons += v.neurons;
|
|
1182
|
+
this.chatPend[v.ok ? 'ok' : 'held'] += 1;
|
|
1183
|
+
return { ok: v.ok, by: 'ai', why: v.why, ms: v.ms, p: v.p };
|
|
1184
|
+
} catch (error) {
|
|
1185
|
+
this.chatPend.error += 1;
|
|
1186
|
+
try { console.log(JSON.stringify({ ev: 'chat-review-failed', room: this.code, error: String(error?.message ?? error).slice(0, 200) })); } catch { /* no console */ }
|
|
1187
|
+
return { ok: true, by: 'error' };
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
async readChatDay() {
|
|
1192
|
+
const day = await chatDay(this.env, today());
|
|
1193
|
+
this.chatCap = day.budget.neurons;
|
|
1194
|
+
this.chatUsed = day.used.neurons + this.chatPend.neurons;
|
|
1195
|
+
this.chatReadAt = Date.now();
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/** Chat's counts into D1 (stats_daily): lines, reactions, held by why, reviews and neurons. Never a message or a sender. */
|
|
1199
|
+
async flushChat() {
|
|
1200
|
+
const s = this.room?.stats;
|
|
1201
|
+
if (!s || !this.env.DB) return;
|
|
1202
|
+
const game = this.game ?? '';
|
|
1203
|
+
const rows = [];
|
|
1204
|
+
for (const [k, metric] of [['chatLines', 'chat-lines'], ['chatReacts', 'chat-reacts']]) {
|
|
1205
|
+
const d = (s[k] ?? 0) - (this.chatSent[k] ?? 0);
|
|
1206
|
+
if (d > 0) { rows.push(counter(this.env, { metric, subject: game, n: d })); this.chatSent[k] = s[k]; }
|
|
1207
|
+
}
|
|
1208
|
+
for (const [why, n] of Object.entries(s.chatHeld ?? {})) {
|
|
1209
|
+
const d = n - (this.chatSent.held[why] ?? 0);
|
|
1210
|
+
if (d > 0) { rows.push(counter(this.env, { metric: 'chat-held', subject: game, source: why, n: d })); this.chatSent.held[why] = n; }
|
|
1211
|
+
}
|
|
1212
|
+
const p = this.chatPend;
|
|
1213
|
+
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] }));
|
|
1214
|
+
const neurons = Math.floor(p.neurons);
|
|
1215
|
+
if (neurons) rows.push(counter(this.env, { metric: 'chat-neurons', subject: game, n: neurons }));
|
|
1216
|
+
this.chatPend = { neurons: p.neurons - neurons, ok: 0, held: 0, error: 0 };
|
|
1217
|
+
const list = rows.filter(Boolean);
|
|
1218
|
+
if (list.length) await this.env.DB.batch(list).catch(() => {});
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1061
1221
|
/** The game's agents.json from the build (the only words an AI in this room may say), once. */
|
|
1062
1222
|
async readVocab(game) {
|
|
1063
1223
|
if (!this.env.ASSETS || !/^[a-z0-9][a-z0-9-]{0,39}$/.test(String(game ?? ''))) return false;
|
|
@@ -1156,6 +1316,12 @@ export class Table {
|
|
|
1156
1316
|
// The back office (worker/office.mjs), from the studio's Worker only: the room as its owner sees it, and the
|
|
1157
1317
|
// owner's signed controls, which this room verifies before it applies one (NETPLAY.md section 15).
|
|
1158
1318
|
if (url.pathname === '/__facts') return json({ ...room.officeFacts(), ...(this.house ? { brains: this.house.facts() } : {}) });
|
|
1319
|
+
// 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.
|
|
1320
|
+
if (url.pathname === '/__chat') {
|
|
1321
|
+
const id = url.searchParams.get('id');
|
|
1322
|
+
const r = room.chatLog.find((x) => x.id === id);
|
|
1323
|
+
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 });
|
|
1324
|
+
}
|
|
1159
1325
|
if (url.pathname === '/__office') {
|
|
1160
1326
|
const ctl = await request.json().catch(() => null);
|
|
1161
1327
|
const why = await verifyControl(this.env, ctl, { game: this.game, room: this.code }, this.seen);
|
|
@@ -1172,6 +1338,9 @@ export class Table {
|
|
|
1172
1338
|
if (room.seatCap !== max) room.setSeats(max, perAddress(max));
|
|
1173
1339
|
// The room's policy (section 17), composed by the Worker for every socket: a newer one than the room's applies.
|
|
1174
1340
|
const pol = decodeFacts(url.searchParams.get('pol'));
|
|
1341
|
+
// The room's chat rules ride beside it (they may be longer than a policy): section 19.
|
|
1342
|
+
const chatRules = decodeFacts(url.searchParams.get('chat'), 16384);
|
|
1343
|
+
if (pol && chatRules && typeof chatRules === 'object') pol.chat = chatRules;
|
|
1175
1344
|
if (pol) room.setPolicy(pol);
|
|
1176
1345
|
if (this.house) this.house.sync();
|
|
1177
1346
|
const agent = decodeFacts(url.searchParams.get('ag'));
|
|
@@ -1188,14 +1357,25 @@ export class Table {
|
|
|
1188
1357
|
watch: url.searchParams.get('w') === '1',
|
|
1189
1358
|
// An AI with a pass the Worker verified (section 17): its pass, role, hands and name. Only the Worker sets it.
|
|
1190
1359
|
...(agent && typeof agent === 'object' && url.pathname === '/__net' ? { agent } : {}),
|
|
1360
|
+
// A badge the player's account owns (the shop): the Worker looked it up; a hello cannot set one.
|
|
1361
|
+
...(url.pathname === '/__net' && /^[\p{L}\p{N} .'&+-]{1,16}$/u.test(url.searchParams.get('bd') ?? '') ? { badge: url.searchParams.get('bd') } : {}),
|
|
1191
1362
|
watchPolicy: WATCH_POLICIES.includes(url.searchParams.get('wp')) ? url.searchParams.get('wp') : 'follow',
|
|
1192
1363
|
// House QA and `homie-studio check` mark their browsers; their rooms, rounds and peaks are not the studio's numbers.
|
|
1193
1364
|
qa: isQa(request),
|
|
1365
|
+
// Room chat (section 19), the Worker's word: a signed-in account (a passkey), a member of this server, a page of
|
|
1366
|
+
// another site (homie.rocks's room page) watching from elsewhere.
|
|
1367
|
+
acct: url.searchParams.get('acct') === '1',
|
|
1368
|
+
member: url.searchParams.get('mem') === '1',
|
|
1369
|
+
hub: url.pathname === '/__watch' && url.searchParams.get('hub') === '1',
|
|
1194
1370
|
send: (text) => { try { server.send(text); } catch { /* closed */ } },
|
|
1195
1371
|
close: (c, r) => { try { server.close(c, r); } catch { /* closed */ } },
|
|
1196
1372
|
buffered: () => 0,
|
|
1197
1373
|
};
|
|
1198
1374
|
if (url.pathname === '/__watch') {
|
|
1375
|
+
// Watching shells are cheap (outgoing is free) but not free: a room holds at most 256, and 24 from one address
|
|
1376
|
+
// (room chat, 0.23.0: another site's page can open one too).
|
|
1377
|
+
const same = conn.ip ? [...room.watchers].filter((x) => x.ip === conn.ip).length : 0;
|
|
1378
|
+
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
1379
|
const w = room.watch(conn);
|
|
1200
1380
|
// The play page's vote card speaks on this socket (section 17); nothing else it says is read.
|
|
1201
1381
|
server.addEventListener('message', (e) => { if (typeof e.data === 'string') w.onMessage(e.data); });
|
|
@@ -1247,8 +1427,10 @@ export class Table {
|
|
|
1247
1427
|
if (room.officeDirty) { room.officeDirty = false; this.ctx.storage.put('office', room.officeSaved()).catch(() => {}); }
|
|
1248
1428
|
n += 1;
|
|
1249
1429
|
if (n % 4 === 0) { room.tellWatchers(); this.report(); this.recordRound(); this.syncHouse(); }
|
|
1430
|
+
// Room chat's counts, once a minute (section 19).
|
|
1431
|
+
if (n % 240 === 0) this.ctx.waitUntil(this.flushChat().catch(() => {}));
|
|
1250
1432
|
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; } }
|
|
1433
|
+
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
1434
|
}, 250);
|
|
1253
1435
|
}
|
|
1254
1436
|
|
package/worker/office-page.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { SKILLS } from './agents.mjs';
|
|
|
10
10
|
import { KIDS_LINE, POLICY_WORDS, SERVER_LIMITS } from './servers.mjs';
|
|
11
11
|
import { esc } from './site.mjs';
|
|
12
12
|
|
|
13
|
-
const PRIVATE = {
|
|
13
|
+
export const PRIVATE = {
|
|
14
14
|
'content-type': 'text/html; charset=utf-8',
|
|
15
15
|
'cache-control': 'no-store, private',
|
|
16
16
|
'x-robots-tag': 'noindex, nofollow',
|
|
@@ -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');
|
|
@@ -718,7 +832,7 @@ async function officeScriptHash() {
|
|
|
718
832
|
return scriptHash;
|
|
719
833
|
}
|
|
720
834
|
|
|
721
|
-
function shell(cat, title, body, { status = 200, script = null, extra = {} } = {}) {
|
|
835
|
+
export function shell(cat, title, body, { status = 200, script = null, extra = {} } = {}) {
|
|
722
836
|
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
723
837
|
<meta name="robots" content="noindex, nofollow"><title>${esc(title)}</title><style>:root{--accent:${accentOf(cat)}}${CSS}</style></head><body><div class="wrap">${body}</div>${script ? `<script>${script}</script>` : ''}</body></html>`;
|
|
724
838
|
}
|
|
@@ -728,12 +842,13 @@ export async function officePage(cat, headers = {}) {
|
|
|
728
842
|
const name = cat.studio?.name ?? 'Studio';
|
|
729
843
|
const options = (cat.games ?? []).map((g) => `<option value="${esc(g.id)}">every room of ${esc(g.name)}</option>`).join('');
|
|
730
844
|
const body = `<header class="top"><h1>${esc(name)} <small>Office</small></h1>
|
|
731
|
-
<div class="links"><span class="live"><i class="dot" id="livedot"></i><span id="live">…</span></span><a href="/_studio/stats">Stats</a>
|
|
845
|
+
<div class="links"><span class="live"><i class="dot" id="livedot"></i><span id="live">…</span></span><a href="/_studio/stats">Stats</a>${cat.shop ? '<a href="/_studio/office/shop">Shop</a>' : ''}
|
|
732
846
|
<form method="post" action="/_studio/signout" style="display:inline"><button class="ghost small" type="submit">Sign out</button></form></div></header>
|
|
733
847
|
<form class="bar" id="announce-all"><label for="announce-text">Announce</label><input type="text" id="announce-text" maxlength="280" placeholder="A line every player sees, in the game" autocomplete="off">
|
|
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>
|
|
@@ -756,7 +871,7 @@ export function lockedPage(cat, { what = 'office', ask = null } = {}) {
|
|
|
756
871
|
return new Response(shell(cat, `${what === 'confirm' ? 'Confirm' : 'Office'} · private`, body), { status: 401, headers: { ...PRIVATE, 'content-security-policy': NO_SCRIPT_CSP } });
|
|
757
872
|
}
|
|
758
873
|
|
|
759
|
-
const VERB = { kick: 'Kick', mute: 'Mute', close: 'Close the room', announce: 'Announce', game: 'Change it', 'server-set': 'Change it', 'server-close': 'Close it', member: 'Remove', 'agents-brain': 'Let them talk' };
|
|
874
|
+
const VERB = { refund: 'Refund it', 'shop-settle': 'Mark it paid', kick: 'Kick', mute: 'Mute', close: 'Close the room', announce: 'Announce', game: 'Change it', 'server-set': 'Change it', 'server-close': 'Close it', member: 'Remove', 'agents-brain': 'Let them talk' };
|
|
760
875
|
|
|
761
876
|
/** The owner's one tap for what their AI asked, and what came of it. */
|
|
762
877
|
export function confirmPage(cat, ask, { missing = false } = {}) {
|
|
@@ -768,7 +883,7 @@ export function confirmPage(cat, ask, { missing = false } = {}) {
|
|
|
768
883
|
const verb = ask.action?.op === 'mute' && ask.action.off ? 'Unmute' : ask.action?.op === 'close' && ask.action.reopen ? 'Open it again' : VERB[ask.action?.op] ?? 'Yes';
|
|
769
884
|
body = `<h1>${esc(name)} <small>Your AI asks</small></h1>
|
|
770
885
|
<p style="font-size:19px;line-height:1.45;max-width:46ch">${esc(ask.what)}</p>
|
|
771
|
-
<form method="post" class="bar" style="background:transparent;border:0;padding:0"><button type="submit" name="do" value="yes" class="${['kick', 'close', 'server-close', 'member'].includes(ask.action?.op) && !ask.action?.reopen ? 'armed' : ''}">${esc(verb)}</button><button type="submit" name="do" value="no" class="ghost">No</button></form>
|
|
886
|
+
<form method="post" class="bar" style="background:transparent;border:0;padding:0"><button type="submit" name="do" value="yes" class="${['kick', 'close', 'server-close', 'member', 'refund'].includes(ask.action?.op) && !ask.action?.reopen ? 'armed' : ''}">${esc(verb)}</button><button type="submit" name="do" value="no" class="ghost">No</button></form>
|
|
772
887
|
<p class="note">Only you can confirm this: your AI asked through the studio's office key, which cannot say yes. It lasts until ${esc(new Date(ask.expiresAt).toISOString().slice(11, 16))} UTC.</p>`;
|
|
773
888
|
} else {
|
|
774
889
|
const said = { done: 'Done.', failed: 'That did not work.', cancelled: 'Not done: you said no.', expired: 'This ask ran out (15 minutes). Your AI can ask again.', working: 'Working on it…' }[ask.state] ?? ask.state;
|