@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/office.mjs
CHANGED
|
@@ -42,6 +42,13 @@
|
|
|
42
42
|
* passes (issued and revoked at once; the secret is shown once), a room's dial (room_level), and whether AI guides
|
|
43
43
|
* may talk (the first time is an ASK: the owner's consent). A server change reaches its live rooms as a signed
|
|
44
44
|
* `policy` control; AI leave after the round when a server becomes humans-only.
|
|
45
|
+
*
|
|
46
|
+
* ROOM CHAT (0.23.0, worker/chat.mjs and worker/chat-store.mjs; NETPLAY.md section 19): each game's chat rules (its
|
|
47
|
+
* game.json defaults, the owner's for the game, the owner's for each server), the last minutes of every live room's
|
|
48
|
+
* chat with Remove (a signed `unsay`), and Mute or Kick from a line (its sender, with their lines taken down), the
|
|
49
|
+
* reports players made (each the one message it is about) with Dismiss, and the review's day (Workers AI neurons).
|
|
50
|
+
* From an office key, a change that opens chat up (more allowed, fewer checks) is an ASK; one that tightens it, or
|
|
51
|
+
* taking a message down, happens at once.
|
|
45
52
|
*/
|
|
46
53
|
import { SEAT_MAX, seatsOf } from './seats.mjs';
|
|
47
54
|
import { PUBLIC_SERVER, SERVER_LIMITS, checkServer, levelName, narrows, policyOf, roomServer, rowFor, serverView, serversOf, serverPassCookie, writeServer } from './servers.mjs';
|
|
@@ -51,6 +58,8 @@ import { OWNER_COOKIE, cookieValues, ownerAllowed, ownerSession, today } from '.
|
|
|
51
58
|
import { esc, layout, notFoundPage } from './site.mjs';
|
|
52
59
|
import { confirmPage, lockedPage, officePage } from './office-page.mjs';
|
|
53
60
|
import { licenseOf } from './license.mjs';
|
|
61
|
+
import { CHAT_MODES, CHAT_WHO, checkChatRules, publicChat } from './chat.mjs';
|
|
62
|
+
import { chatDay, chatOf, chatRowsOf, clearChatRules, dismissReport, reportsOf, writeChatRules } from './chat-store.mjs';
|
|
54
63
|
|
|
55
64
|
export { OFFICE_MIGRATION, OFFICE_MIGRATION_FILE } from './office-schema.mjs';
|
|
56
65
|
|
|
@@ -456,6 +465,8 @@ async function roomRow(env, meta, room, max) {
|
|
|
456
465
|
host: f.host ?? null, announce: f.announce ?? null,
|
|
457
466
|
slots: slots.slice(0, 64).map((s) => ({ slot: s.slot, seat: s.seat ?? null, name: oneLine(s.name, 40), bot: Boolean(s.bot), ...(s.agent ? { agent: { role: s.agent.role ?? 'party', seat: s.agent.seat ?? null } } : {}) })),
|
|
458
467
|
clients, bans: office.bans ?? [], mutes: office.mutes ?? [],
|
|
468
|
+
// Room chat (0.23.0): the room's last minutes, with who sent each line (a client id and account; never an address).
|
|
469
|
+
chat: office.chat ? { mode: office.chat.rules?.mode ?? null, pending: office.chat.pending ?? 0, lines: (office.chat.lines ?? []).slice(-50).map((l) => ({ id: l.id, at: l.at, t: l.t, kind: l.kind, name: oneLine(l.name, 40), seat: l.seat ?? null, by: l.by, text: l.text ?? null, glyph: l.glyph ?? null, react: l.react ?? null, say: l.say ?? null, acct: Boolean(l.acct), owner: Boolean(l.owner), client: l.client ?? null, player: l.player ?? null, browser: l.browser ?? null, ...(l.review ? { review: l.review } : {}) })) } : null,
|
|
459
470
|
// The house guides' brains (0.17.0): which brain, why not when it is not, and the last decisions (seats and ids).
|
|
460
471
|
...(f.brains && typeof f.brains === 'object' ? { brains: {
|
|
461
472
|
brain: String(f.brains.brain ?? ''), why: f.brains.why ? oneLine(f.brains.why, 120) : null, calls: Number(f.brains.calls) || 0,
|
|
@@ -517,14 +528,23 @@ export async function officeView(env, cat, origin) {
|
|
|
517
528
|
const row = settings.get(g.id);
|
|
518
529
|
const max = seatsFor(g, settings);
|
|
519
530
|
const rooms = (await Promise.all((await liveRooms(env, g.id)).map((r) => roomRow(env, g, r.room, max)))).filter(Boolean)
|
|
520
|
-
.filter((r) => r.players + r.screens > 0 || r.closedUntil || r.bans.length);
|
|
531
|
+
.filter((r) => r.players + r.screens > 0 || r.closedUntil || r.bans.length || (r.chat && r.chat.lines.length));
|
|
521
532
|
rooms.sort((a, b) => b.players - a.players || a.room.localeCompare(b.room));
|
|
522
533
|
const servers = await serversOf(env, g, { fresh: true });
|
|
523
534
|
const byServer = {};
|
|
524
535
|
for (const r of rooms) { const b = (byServer[r.server] ??= { rooms: 0, players: 0, ai: 0 }); b.rooms += 1; b.players += r.players; b.ai += r.ai; }
|
|
525
536
|
const members = await memberCountsOf(env, g.id);
|
|
537
|
+
const chatRows = await chatRowsOf(env, { fresh: true });
|
|
526
538
|
return {
|
|
527
539
|
id: g.id, name: g.name, launch, launchFrom: row?.launch ? 'office' : g.launch ? 'game.json' : 'default',
|
|
540
|
+
// Room chat (0.23.0): the game's rules as Quick play has them, where each came from, and each server's own.
|
|
541
|
+
chat: {
|
|
542
|
+
rules: publicChat(chatOf(g, servers.find((x) => x.id === 'public') ?? null, chatRows)),
|
|
543
|
+
from: chatRows.get(`${g.id}/`) ? 'office' : g.chat !== undefined ? 'game.json' : 'default',
|
|
544
|
+
office: chatRows.get(`${g.id}/`) ?? null,
|
|
545
|
+
servers: servers.filter((x) => x.id !== 'public' || chatRows.get(`${g.id}/public`)).map((sv) => ({ id: sv.id, name: sv.name, rules: publicChat(chatOf(g, sv, chatRows)), office: chatRows.get(`${g.id}/${sv.id}`) ?? null })),
|
|
546
|
+
reports: await reportsOf(env, { game: g.id }),
|
|
547
|
+
},
|
|
528
548
|
// Servers (0.16.0): each with its policy and what is on it now; its build reads the dial only from netplay rev 6.
|
|
529
549
|
servers: servers.map((sv) => ({ ...serverView(sv, { origin, game: g.id }), live: byServer[sv.id] ?? { rooms: 0, players: 0, ai: 0 }, members: members[sv.id] ?? 0 })),
|
|
530
550
|
build: { netplayRev: Number.isInteger(g.netplayRev) ? g.netplayRev : null, predates: !(Number(g.netplayRev) >= 6), caps: g.caps ?? null, guides: Number(g.netplayRev) >= 7 },
|
|
@@ -545,6 +565,7 @@ export async function officeView(env, cat, origin) {
|
|
|
545
565
|
fillSpot: { available: Boolean(fillSpot()), note: 'Let a fill-a-spot service seat AI (coming later)' },
|
|
546
566
|
agentsTalk: await talkConsented(env),
|
|
547
567
|
brain: await brainDay(env),
|
|
568
|
+
chat: await chatDay(env, today()),
|
|
548
569
|
};
|
|
549
570
|
}
|
|
550
571
|
async function memberCountsOf(env, game) {
|
|
@@ -579,8 +600,10 @@ function checkAction(cat, op, body) {
|
|
|
579
600
|
if (!room) return bad('room is the room\'s code (pub-3, or a named room)');
|
|
580
601
|
const id = typeof body.id === 'string' && /^[A-Za-z0-9_-]{1,16}$/.test(body.id) ? body.id : null;
|
|
581
602
|
const seat = Number.isInteger(body.seat) && body.seat >= 0 && body.seat < SEAT_MAX ? body.seat : null;
|
|
582
|
-
|
|
583
|
-
|
|
603
|
+
// From a chat line (0.23.0): its sender, whoever they are now (a watcher too); `purge` takes their lines down.
|
|
604
|
+
const line = typeof body.line === 'string' && /^[A-Za-z0-9_-]{1,16}$/.test(body.line) ? body.line : null;
|
|
605
|
+
if (id === null && seat === null && line === null) return bad('name the player: id (from the office), seat, or line (a chat message of theirs)');
|
|
606
|
+
return { ok: true, action: { op, game: meta.id, room, id, seat, ...(line ? { line, purge: body.purge === true } : {}), minutes, ...(op === 'kick' ? { address: body.address === true, message: oneLine(body.message, 200) || null } : { off: body.off === true }), name: oneLine(body.name, 40) || null } };
|
|
584
607
|
}
|
|
585
608
|
case 'close': {
|
|
586
609
|
if (!meta) return bad('game is one of this studio\'s game ids');
|
|
@@ -659,11 +682,56 @@ function checkAction(cat, op, body) {
|
|
|
659
682
|
if (budget !== undefined && (!['workers-ai', 'owner-key'].includes(body.mode) || !(budget >= 0) || budget > (body.mode === 'owner-key' ? 100 : 1e7))) return bad('budget goes with workers-ai (neurons a day; the free allocation is 10,000 an account) or owner-key (dollars a day, at most 100)');
|
|
660
683
|
return { ok: true, action: { op, game: meta.id, server: body.server, mode: body.mode, ...(budget !== undefined ? { budget } : {}) } };
|
|
661
684
|
}
|
|
685
|
+
case 'chat-rules': {
|
|
686
|
+
if (!meta) return bad('game is one of this studio\'s game ids');
|
|
687
|
+
const server = body.server === undefined || body.server === null || body.server === '' ? '' : String(body.server);
|
|
688
|
+
if (server && !(server === 'public' || /^[a-z0-9][a-z0-9-]{1,19}$/.test(server))) return bad('server is a server\'s id, or leave it out for the whole game');
|
|
689
|
+
if (body.reset === true) return { ok: true, action: { op, game: meta.id, server, reset: true } };
|
|
690
|
+
const { game: _g, server: _s, ...rest } = body;
|
|
691
|
+
const c = checkChatRules(rest);
|
|
692
|
+
if (!c.ok) return c;
|
|
693
|
+
if (!Object.keys(c.fields).length) return bad(`say what to change: mode (${CHAT_MODES.join(', ')}), who / react (${CHAT_WHO.join(', ')}), slow, max, links, swears, ai, bubbles, watchers, hub, block, allow, lines, emoji; or reset: true`);
|
|
694
|
+
return { ok: true, action: { op, game: meta.id, server, fields: c.fields } };
|
|
695
|
+
}
|
|
696
|
+
case 'chat-remove': {
|
|
697
|
+
if (!meta) return bad('game is one of this studio\'s game ids');
|
|
698
|
+
if (!room) return bad('room is the room\'s code');
|
|
699
|
+
const ids = (Array.isArray(body.ids) ? body.ids : body.id !== undefined ? [body.id] : []).filter((x) => typeof x === 'string' && /^[A-Za-z0-9_-]{1,16}$/.test(x)).slice(0, 64);
|
|
700
|
+
if (!ids.length && body.all !== true) return bad('id is the message\'s id (from the office), or all: true');
|
|
701
|
+
return { ok: true, action: { op, game: meta.id, room, ...(body.all === true ? { all: true } : { ids }) } };
|
|
702
|
+
}
|
|
703
|
+
case 'chat-report': {
|
|
704
|
+
if (!/^cr_[a-f0-9]{16}$/.test(String(body.id ?? ''))) return bad('id is the report\'s id');
|
|
705
|
+
return { ok: true, action: { op, id: body.id } };
|
|
706
|
+
}
|
|
707
|
+
case 'chat-budget': {
|
|
708
|
+
const n = Math.floor(Number(body.neurons));
|
|
709
|
+
if (!(n >= 0 && n <= 1e7)) return bad('neurons is the review\'s day for the whole studio (0 to 10,000,000; Workers AI gives an account 10,000 a day free)');
|
|
710
|
+
return { ok: true, action: { op, neurons: n } };
|
|
711
|
+
}
|
|
662
712
|
default:
|
|
663
713
|
return bad('unknown control');
|
|
664
714
|
}
|
|
665
715
|
}
|
|
666
716
|
|
|
717
|
+
/** Whether a chat change opens chat up (an office key only ASKS for these): more allowed, fewer checks, wider doors. */
|
|
718
|
+
export function chatOpensUp(before, f) {
|
|
719
|
+
const rank = { off: 0, emoji: 1, lines: 2, text: 3 };
|
|
720
|
+
const who = { members: 0, 'signed-in': 1, anyone: 2 };
|
|
721
|
+
if (f.mode !== undefined && rank[f.mode] > rank[before.mode]) return true;
|
|
722
|
+
for (const k of ['who', 'react']) if (f[k] !== undefined && who[f[k]] > who[before[k]]) return true;
|
|
723
|
+
if (f.slow !== undefined && f.slow < before.slow) return true;
|
|
724
|
+
if (f.max !== undefined && f.max > before.max) return true;
|
|
725
|
+
if (f.links === 'allow' && before.links !== 'allow') return true;
|
|
726
|
+
if (f.swears === 'allow' && before.swears !== 'allow') return true;
|
|
727
|
+
for (const k of ['watchers', 'hub']) if (f[k] === true && !before[k]) return true;
|
|
728
|
+
if (f.ai === false && before.ai) return true;
|
|
729
|
+
if (f.allow !== undefined && f.allow.some((w) => !(before.allow ?? []).includes(w))) return true;
|
|
730
|
+
if (f.block !== undefined && (before.block ?? []).some((w) => !f.block.includes(w))) return true;
|
|
731
|
+
if (f.lines !== undefined || f.emoji !== undefined) return true;
|
|
732
|
+
return false;
|
|
733
|
+
}
|
|
734
|
+
|
|
667
735
|
/** The action in plain words, for the owner's confirm page and the AI's card. */
|
|
668
736
|
export function describe(cat, a) {
|
|
669
737
|
const g = (cat.games ?? []).find((x) => x.id === a.game);
|
|
@@ -705,6 +773,26 @@ export function describe(cat, a) {
|
|
|
705
773
|
case 'agents-brain': return ['workers-ai', 'owner-key'].includes(a.mode)
|
|
706
774
|
? `Let the AI guides on ${gname}'s server ${a.server} talk: they speak only the lines the game's own agents.json gives them (never free text), at most one line every 8 seconds, never about a person, and a player can quiet them. Their brain runs on ${a.mode === 'workers-ai' ? `this studio's own Workers AI (free allowance${a.budget !== undefined ? `; at most ${Math.round(a.budget).toLocaleString('en-US')} neurons a day` : ''})` : `your own AI provider key (claude-haiku-4-5, your money${a.budget !== undefined ? `, at most $${Number(a.budget).toFixed(2)} a day` : ', capped daily'})`}.`
|
|
707
775
|
: `Set the AI guides' brain on ${gname}'s server ${a.server} to ${a.mode}.`;
|
|
776
|
+
case 'chat-rules': {
|
|
777
|
+
const where = a.server ? `${gname}'s server ${a.server}` : gname;
|
|
778
|
+
if (a.reset) return `Give ${where} its own chat rules back (the game's game.json, or Homie's defaults).`;
|
|
779
|
+
const f = a.fields;
|
|
780
|
+
const bits = [];
|
|
781
|
+
if (f.mode) bits.push({ off: 'turn chat off', emoji: 'keep chat to emoji', lines: 'keep chat to emoji and quick lines', text: 'let players type' }[f.mode]);
|
|
782
|
+
if (f.who) bits.push(`typing for ${f.who === 'anyone' ? 'anyone, guests too' : f.who === 'signed-in' ? 'signed-in players (a passkey)' : 'members only'}`);
|
|
783
|
+
if (f.react) bits.push(`emoji and quick lines for ${f.react === 'anyone' ? 'anyone' : f.react === 'signed-in' ? 'signed-in players' : 'members only'}`);
|
|
784
|
+
if (f.slow !== undefined) bits.push(f.slow ? `slow mode: one message every ${f.slow} s` : 'no slow mode');
|
|
785
|
+
if (f.max !== undefined) bits.push(`messages up to ${f.max} characters`);
|
|
786
|
+
if (f.links) bits.push(f.links === 'allow' ? 'allow links' : 'hold links');
|
|
787
|
+
if (f.swears) bits.push(f.swears === 'allow' ? 'allow swearing' : 'hold swearing');
|
|
788
|
+
if (f.ai !== undefined) bits.push(f.ai ? 'review typed messages with this studio\'s Workers AI' : 'stop the AI review (the word list still runs)');
|
|
789
|
+
const rest = Object.keys(f).filter((k) => !['mode', 'who', 'react', 'slow', 'max', 'links', 'swears', 'ai'].includes(k));
|
|
790
|
+
if (rest.length) bits.push(`change its ${rest.join(', ')}`);
|
|
791
|
+
return `Room chat in ${where}: ${bits.join('; ')}.`;
|
|
792
|
+
}
|
|
793
|
+
case 'chat-remove': return a.all ? `Take every message down in ${where}.` : `Take ${a.ids.length === 1 ? 'a message' : `${a.ids.length} messages`} down in ${where}.`;
|
|
794
|
+
case 'chat-report': return 'Dismiss a chat report (it is deleted).';
|
|
795
|
+
case 'chat-budget': return `Let room chat's review use up to ${a.neurons.toLocaleString('en-US')} Workers AI neurons a day for the whole studio (about ${a.neurons.toLocaleString('en-US')} typed messages; the free allocation is 10,000 an account, shared with the AI guides; past it on Workers Paid, $0.011 per 1,000).`;
|
|
708
796
|
default: return 'A control.';
|
|
709
797
|
}
|
|
710
798
|
}
|
|
@@ -719,6 +807,15 @@ export async function needsAsk(env, cat, a) {
|
|
|
719
807
|
const before = meta ? (await serversOf(env, meta, { fresh: true })).find((x) => x.id === a.server) : null;
|
|
720
808
|
return narrows(before, a.fields);
|
|
721
809
|
}
|
|
810
|
+
// A bigger review budget can cost money on Workers Paid: the owner says yes. A smaller one happens at once.
|
|
811
|
+
if (a.op === 'chat-budget') return a.neurons > (await chatDay(env, today())).budget.neurons;
|
|
812
|
+
if (a.op === 'chat-rules') {
|
|
813
|
+
if (a.reset) return true;
|
|
814
|
+
const meta = (cat.games ?? []).find((g) => g.id === a.game);
|
|
815
|
+
const servers = meta ? await serversOf(env, meta, { fresh: true }) : [];
|
|
816
|
+
const before = chatOf(meta, servers.find((x) => x.id === (a.server || 'public')) ?? null, await chatRowsOf(env, { fresh: true }));
|
|
817
|
+
return chatOpensUp(before, a.fields);
|
|
818
|
+
}
|
|
722
819
|
if (a.op === 'agents-brain') {
|
|
723
820
|
if (['workers-ai', 'owner-key'].includes(a.mode) && !(await talkConsented(env))) return true;
|
|
724
821
|
// Raising the cap on the owner's own money is the owner's to confirm; Workers AI's free allocation is not money.
|
|
@@ -735,7 +832,7 @@ async function serverRooms(env, game, server) {
|
|
|
735
832
|
|
|
736
833
|
/** A server's policy reaches every live room of it now (a signed `policy` control; section 17). */
|
|
737
834
|
async function pushPolicy(env, meta, settings, server) {
|
|
738
|
-
const pol = policyOf(server, { seats: seatsFor(meta, settings) });
|
|
835
|
+
const pol = policyOf(server, { seats: seatsFor(meta, settings), chat: chatOf(meta, server, await chatRowsOf(env, { fresh: true })) });
|
|
739
836
|
const rooms = await serverRooms(env, meta.id, server.id);
|
|
740
837
|
const res = await Promise.all(rooms.map((r) => roomControl(env, meta, settings, r.room, 'policy', { pol })));
|
|
741
838
|
return { rooms: rooms.length, leaving: res.reduce((n, r) => n + (r.leaving ?? 0), 0) };
|
|
@@ -748,9 +845,10 @@ export async function perform(env, cat, a) {
|
|
|
748
845
|
switch (a.op) {
|
|
749
846
|
case 'kick':
|
|
750
847
|
case 'mute': {
|
|
848
|
+
const line = a.line ? { line: a.line, purge: a.purge === true } : {};
|
|
751
849
|
const r = await roomControl(env, meta, settings, a.room, a.op, a.op === 'kick'
|
|
752
|
-
? { id: a.id, seat: a.seat, minutes: a.minutes, address: a.address, ...(a.message ? { message: a.message } : {}) }
|
|
753
|
-
: { id: a.id, seat: a.seat, minutes: a.minutes, off: a.off });
|
|
850
|
+
? { id: a.id, seat: a.seat, minutes: a.minutes, address: a.address, ...line, ...(a.message ? { message: a.message } : {}) }
|
|
851
|
+
: { id: a.id, seat: a.seat, minutes: a.minutes, off: a.off, ...line });
|
|
754
852
|
return r;
|
|
755
853
|
}
|
|
756
854
|
case 'close': return roomControl(env, meta, settings, a.room, 'close', a.reopen ? { reopen: true } : { minutes: a.minutes, ...(a.message ? { message: a.message } : {}) });
|
|
@@ -868,6 +966,32 @@ export async function perform(env, cat, a) {
|
|
|
868
966
|
} catch (error) { return { ok: false, error: 'not-migrated', message: `Agent passes need migration 0006_studio_servers.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }; }
|
|
869
967
|
}
|
|
870
968
|
case 'room-level': return roomControl(env, meta, settings, a.room, 'level', { level: a.level });
|
|
969
|
+
case 'chat-rules': {
|
|
970
|
+
if (!env?.DB) return { ok: false, error: 'no-db', message: 'This studio has no D1 for its office.' };
|
|
971
|
+
try {
|
|
972
|
+
if (a.reset) await clearChatRules(env, meta.id, a.server);
|
|
973
|
+
else await writeChatRules(env, meta.id, a.server, a.fields);
|
|
974
|
+
} catch (error) { return { ok: false, error: 'not-migrated', message: `Chat rules need migration 0007_studio_chat.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }; }
|
|
975
|
+
// Every live room the change reaches hears its new rules now (a signed policy control, as a server change does).
|
|
976
|
+
const servers = await serversOf(env, meta, { fresh: true });
|
|
977
|
+
const reach = a.server ? servers.filter((x) => x.id === a.server) : servers;
|
|
978
|
+
let rooms = 0;
|
|
979
|
+
for (const sv of reach) rooms += (await pushPolicy(env, meta, settings, sv)).rooms;
|
|
980
|
+
const rows = await chatRowsOf(env, { fresh: true });
|
|
981
|
+
const rules = publicChat(chatOf(meta, servers.find((x) => x.id === (a.server || 'public')) ?? PUBLIC_SERVER, rows));
|
|
982
|
+
return { ok: true, op: a.op, game: meta.id, server: a.server || null, rules, rooms };
|
|
983
|
+
}
|
|
984
|
+
case 'chat-remove': {
|
|
985
|
+
const r = await roomControl(env, meta, settings, a.room, 'unsay', a.all ? { all: true } : { ids: a.ids });
|
|
986
|
+
return r;
|
|
987
|
+
}
|
|
988
|
+
case 'chat-report': {
|
|
989
|
+
try { return { op: a.op, ...(await dismissReport(env, a.id)) }; } catch (error) { return { ok: false, error: 'not-migrated', message: String(error?.message ?? error).slice(0, 120) }; }
|
|
990
|
+
}
|
|
991
|
+
case 'chat-budget': {
|
|
992
|
+
try { await env.DB.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('chat_budget', ?1)").bind(JSON.stringify({ neurons: a.neurons })).run(); } catch (error) { return { ok: false, error: 'no-db', message: String(error?.message ?? error).slice(0, 120) }; }
|
|
993
|
+
return { ok: true, op: a.op, budget: { neurons: a.neurons }, note: `Room chat's review may use ${a.neurons.toLocaleString('en-US')} neurons a day from now on (rooms read it again within a minute).` };
|
|
994
|
+
}
|
|
871
995
|
case 'agents-brain': {
|
|
872
996
|
const before = (await serversOf(env, meta, { fresh: true })).find((x) => x.id === a.server);
|
|
873
997
|
if (!before) return { ok: false, error: 'no-server', message: `${meta.name} has no server called ${a.server}.` };
|
|
@@ -1025,7 +1149,14 @@ async function api(request, env, url, cat) {
|
|
|
1025
1149
|
return json({ ok: true, game, server, members });
|
|
1026
1150
|
} catch (error) { return json({ ok: false, error: 'not-migrated', message: `Servers need migration 0006_studio_servers.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }, 503); }
|
|
1027
1151
|
}
|
|
1152
|
+
// Room chat (0.23.0): every game's rules, every live room's last minutes, the reports, the review's day.
|
|
1153
|
+
if (path === '/_studio/api/chat' && request.method === 'GET') {
|
|
1154
|
+
const metas = (cat.games ?? []).filter((g) => !url.searchParams.get('game') || g.id === url.searchParams.get('game'));
|
|
1155
|
+
const view = await officeView(env, { ...cat, games: metas }, url.origin);
|
|
1156
|
+
return json({ ok: true, day: view.chat, games: view.games.map((g) => ({ id: g.id, name: g.name, chat: g.chat, rooms: g.rooms.map((r) => ({ room: r.room, label: r.label, players: r.players, chat: r.chat })) })) });
|
|
1157
|
+
}
|
|
1028
1158
|
const OPS = {
|
|
1159
|
+
'/_studio/api/chat/rules': 'chat-rules', '/_studio/api/chat/remove': 'chat-remove', '/_studio/api/chat/report': 'chat-report', '/_studio/api/chat/budget': 'chat-budget',
|
|
1029
1160
|
'/_studio/api/kick': 'kick', '/_studio/api/mute': 'mute', '/_studio/api/close': 'close', '/_studio/api/announce': 'announce', '/_studio/api/game': 'game',
|
|
1030
1161
|
'/_studio/api/servers': 'server-create', '/_studio/api/servers/set': 'server-set', '/_studio/api/servers/close': 'server-close', '/_studio/api/servers/member': 'member',
|
|
1031
1162
|
'/_studio/api/agents/pass': 'pass', '/_studio/api/room-level': 'room-level', '/_studio/api/agents/brain': 'agents-brain',
|
package/worker/pages.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { NET_PALETTE } from './room.mjs';
|
|
|
7
7
|
import { SKILLS } from './agents.mjs';
|
|
8
8
|
import { KIDS_LINE, POLICY_WORDS } from './servers.mjs';
|
|
9
9
|
import { SAVES_SHELL_CSS, SAVES_SHELL_JS } from './saves-shell.mjs';
|
|
10
|
+
import { CHAT_CSS, CHAT_JS, CHAT_OWNER_JS, chatBoot } from './chat-page.mjs';
|
|
10
11
|
|
|
11
12
|
export { homePage, mediaIndexPage, notFoundPage, songPage, videoPage } from './site.mjs';
|
|
12
13
|
|
|
@@ -84,7 +85,7 @@ export function sharePlaces(value) {
|
|
|
84
85
|
* the edge (game.json `screen.share`, per device: sharePlaces) opens Invite, Big screen and the room code; nothing
|
|
85
86
|
* covers the middle of the screen or a thumb.
|
|
86
87
|
*/
|
|
87
|
-
export function playPage(cat, g, { screen = false, joinUrl = null, qr = null, local = false, room = null, ticket = null, owner = false, launch = 'public', server = null } = {}) {
|
|
88
|
+
export function playPage(cat, g, { screen = false, joinUrl = null, qr = null, local = false, room = null, ticket = null, owner = false, launch = 'public', server = null, acct = false, member = false } = {}) {
|
|
88
89
|
const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
|
|
89
90
|
const corner = (name, fallback) => (['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(g.screen?.[name]) ? g.screen[name] : fallback);
|
|
90
91
|
const places = sharePlaces(g.screen?.share);
|
|
@@ -159,7 +160,7 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
|
|
|
159
160
|
.notice .acts a { display: inline-flex; align-items: center; min-height: 44px; padding: 0 16px; border-radius: 12px; text-decoration: none; font-weight: 700; color: #eef1f8; border: 1px solid rgba(255,255,255,.18); }
|
|
160
161
|
.notice .acts a.primary { background: var(--hot); color: #0b0b10; border-color: transparent; }
|
|
161
162
|
[hidden] { display: none !important; }
|
|
162
|
-
${SERVER_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${pillUi}`;
|
|
163
|
+
${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${pillUi}`;
|
|
163
164
|
// The room button's place on each device (sharePlaces); the shell moves it to this browser's once it knows the device.
|
|
164
165
|
// A ticket (a game that is not public) and the owner's overlay ride along only for the browser they are for.
|
|
165
166
|
// The server this page plays on (0.16.0): its pool (the Lobby), its badge and line for the chip, its ceiling.
|
|
@@ -202,8 +203,10 @@ ${SERVER_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${pillUi}`;
|
|
|
202
203
|
<div class="card" data-screen hidden></div>
|
|
203
204
|
${joinCard}${share}
|
|
204
205
|
<script>window.__HOMIE_PLAY=${JSON.stringify(boot).replace(/</g, '\\u003c')};</script>
|
|
206
|
+
<script>window.__HOMIE_CHAT=${JSON.stringify(chatBoot(g, { surface: screen ? 'tv' : 'play', owner, acct, member })).replace(/</g, '\\u003c')};</script>
|
|
207
|
+
<script>${CHAT_JS}</script>
|
|
205
208
|
<script>${SHELL_JS}</script>${g.saves && !screen ? `
|
|
206
|
-
<script>${SAVES_SHELL_JS}</script>` : ''}${owner ? `<style>${OWNER_CSS}</style><script>${OWNER_JS}</script>` : ''}`, css, frameAncestors(cat));
|
|
209
|
+
<script>${SAVES_SHELL_JS}</script>` : ''}${owner ? `<style>${OWNER_CSS}</style><script>${OWNER_JS}</script><script>${CHAT_OWNER_JS}</script>` : ''}`, css, frameAncestors(cat));
|
|
207
210
|
}
|
|
208
211
|
|
|
209
212
|
/*
|
|
@@ -399,6 +402,8 @@ const SHELL_JS = String.raw`(function () {
|
|
|
399
402
|
if (params.get('debug') === '1') q.set('debug', '1');
|
|
400
403
|
// Quiet AI (this browser hides AI speech): the helper starts hushed, and hears a change by message.
|
|
401
404
|
if (state.quietAi) q.set('hush', '1');
|
|
405
|
+
// Room chat (section 19): this browser's "Show chat" and "Show my messages over my character", for the game's bubbles.
|
|
406
|
+
try { if (localStorage.getItem('homie-chat-show') === '0') q.set('chat', '0'); if (localStorage.getItem('homie-chat-bubble') === '0') q.set('bub', '0'); } catch (e) {}
|
|
402
407
|
// The frame cannot read this page's address (it is an opaque origin): hand it the game's own switches.
|
|
403
408
|
['touchdebug', 'cam', 'view'].forEach(function (k) { var v = params.get(k); if (v && /^[A-Za-z0-9_-]{1,16}$/.test(v)) q.set(k, v); });
|
|
404
409
|
frame.src = '/' + boot.game + '/__game/?' + q.toString();
|
|
@@ -409,7 +414,7 @@ const SHELL_JS = String.raw`(function () {
|
|
|
409
414
|
var m = ev.data;
|
|
410
415
|
if (!m || typeof m !== 'object' || m.t !== 'homie-net') return;
|
|
411
416
|
if (m.what === 'attached') state.attached = true;
|
|
412
|
-
if (m.what === 'token' && typeof m.token === 'string') { state.seat = m.seat; try { sessionStorage.setItem(KEY, m.token); } catch (e) {} }
|
|
417
|
+
if (m.what === 'token' && typeof m.token === 'string') { state.seat = m.seat; try { sessionStorage.setItem(KEY, m.token); } catch (e) {} if (window.__homieChat) window.__homieChat.seat(); }
|
|
413
418
|
if (m.what === 'stats') state.stats = m.stats;
|
|
414
419
|
if (m.what === 'round') onRound(m.round);
|
|
415
420
|
if (m.what === 'roster') state.roster = m.slots;
|
|
@@ -455,6 +460,8 @@ const SHELL_JS = String.raw`(function () {
|
|
|
455
460
|
try { ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/' + boot.game + '/__watch?room=' + encodeURIComponent(room) + extra); }
|
|
456
461
|
catch (e) { setTimeout(function () { watch(room); }, 2000); return; }
|
|
457
462
|
state.watchSocket = ws;
|
|
463
|
+
// Room chat (section 19) speaks on this socket: lines, reactions and what the room keeps of the last minutes.
|
|
464
|
+
if (window.__homieChat) window.__homieChat.socket(ws, room);
|
|
458
465
|
ws.onmessage = function (ev) {
|
|
459
466
|
var m; try { m = JSON.parse(ev.data); } catch (e) { return; }
|
|
460
467
|
if (!m || typeof m !== 'object') return;
|
|
@@ -463,7 +470,9 @@ const SHELL_JS = String.raw`(function () {
|
|
|
463
470
|
if (m.t === 'announce') { banner(m); return; }
|
|
464
471
|
if (m.t === 'muted') { mutedNote(m); return; }
|
|
465
472
|
if (m.t === 'vote') { onVote(m); return; }
|
|
473
|
+
if (window.__homieChat && window.__homieChat.receive(m)) return;
|
|
466
474
|
if (m.t !== 'net') return;
|
|
475
|
+
if (window.__homieChat) window.__homieChat.facts(m);
|
|
467
476
|
state.facts = m;
|
|
468
477
|
if (typeof m.st === 'number') state.offset = m.st - Date.now();
|
|
469
478
|
if (m.announce && m.announce.text) banner(m.announce, true);
|
|
@@ -834,11 +843,11 @@ const OWNER_JS = String.raw`(function () {
|
|
|
834
843
|
* watchers see the whole room only. A private or invite-only game is watched only by whoever may play it (the door
|
|
835
844
|
* is the play door's). Every name a player typed is set with textContent.
|
|
836
845
|
*/
|
|
837
|
-
export function watchPage(cat, g, { room = null, ticket = null, policy = 'follow' } = {}) {
|
|
846
|
+
export function watchPage(cat, g, { room = null, ticket = null, policy = 'follow', owner = false, acct = false, member = false } = {}) {
|
|
838
847
|
const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
|
|
839
848
|
const hot = /^#[0-9a-f]{3,8}$/i.test(accent) ? accent : '#ffcf5a';
|
|
840
849
|
const boot = { game: g.id, name: g.name, policy, palette: NET_PALETTE, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}) };
|
|
841
|
-
const css = `:root{--hot:${hot}}${WATCH_CSS}`;
|
|
850
|
+
const css = `:root{--hot:${hot}}${WATCH_CSS}${CHAT_CSS}`;
|
|
842
851
|
return layoutless(`Watch ${g.name}`, `
|
|
843
852
|
<header class="wtop" data-top>
|
|
844
853
|
<span class="live" data-live><i aria-hidden="true"></i><b>Live</b></span>
|
|
@@ -855,6 +864,8 @@ export function watchPage(cat, g, { room = null, ticket = null, policy = 'follow
|
|
|
855
864
|
</footer>
|
|
856
865
|
<div class="wnote" data-note hidden></div>
|
|
857
866
|
<script>window.__HOMIE_WATCH=${JSON.stringify(boot).replace(/</g, '\\u003c')};</script>
|
|
867
|
+
<script>window.__HOMIE_CHAT=${JSON.stringify(chatBoot(g, { surface: 'watch', owner, acct, member })).replace(/</g, '\\u003c')};</script>
|
|
868
|
+
<script>${CHAT_JS}</script>${owner ? `<script>${CHAT_OWNER_JS}</script>` : ''}
|
|
858
869
|
<script>${WATCH_JS}</script>`, css, frameAncestors(cat));
|
|
859
870
|
}
|
|
860
871
|
|
|
@@ -1055,12 +1066,16 @@ const WATCH_JS = String.raw`(function () {
|
|
|
1055
1066
|
try { ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/' + boot.game + '/__watch?room=' + encodeURIComponent(room) + extra); }
|
|
1056
1067
|
catch (e) { setTimeout(function () { watch(room); }, 2000); return; }
|
|
1057
1068
|
state.socket = ws;
|
|
1069
|
+
// Room chat (section 19): a watcher reads the room's chat and sends what the room's rules let a watcher send.
|
|
1070
|
+
if (window.__homieChat) window.__homieChat.socket(ws, room);
|
|
1058
1071
|
ws.onmessage = function (ev) {
|
|
1059
1072
|
var m; try { m = JSON.parse(ev.data); } catch (e) { return; }
|
|
1060
1073
|
if (!m || typeof m !== 'object') return;
|
|
1061
1074
|
if (m.t === 'kicked') { shut('kicked', m); return; }
|
|
1062
1075
|
if (m.t === 'closed') { shut('closed', m); return; }
|
|
1076
|
+
if (window.__homieChat && window.__homieChat.receive(m)) return;
|
|
1063
1077
|
if (m.t !== 'net') return;
|
|
1078
|
+
if (window.__homieChat) window.__homieChat.facts(m);
|
|
1064
1079
|
state.facts = m;
|
|
1065
1080
|
if (typeof m.st === 'number') state.offset = m.st - Date.now();
|
|
1066
1081
|
if (m.round) state.round = m.round;
|