@homie-rocks/studio 0.21.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 +68 -1
- package/README.md +92 -0
- package/assets/assets.ts +500 -0
- package/assets/render-page.ts +629 -0
- package/assets/safety.mjs +194 -0
- package/bin/homie-studio.mjs +81 -2
- package/chat/CHAT.md +101 -0
- package/chat/OWNERS.md +78 -0
- package/lib/art-cli.mjs +296 -0
- package/lib/art-tools.mjs +331 -0
- package/lib/asset-check.mjs +189 -0
- package/lib/asset-import.mjs +136 -0
- package/lib/asset-manifest.mjs +318 -0
- package/lib/build.mjs +32 -2
- package/lib/chat-cli.mjs +89 -0
- package/lib/cloudflare.mjs +5 -2
- package/lib/codex.mjs +43 -1
- package/lib/colour.mjs +160 -0
- package/lib/decisions.mjs +800 -0
- package/lib/directory.mjs +9 -1
- package/lib/library.mjs +169 -0
- package/lib/mcp-tools.mjs +22 -4
- package/lib/mcp.mjs +6 -3
- package/lib/optimise.mjs +380 -0
- package/lib/remix-assets.mjs +84 -0
- package/lib/render3d.mjs +81 -0
- package/lib/scaffold.mjs +39 -2
- package/lib/site.mjs +6 -1
- package/lib/studio.mjs +136 -9
- package/lib/style-board.mjs +227 -0
- package/lib/style-presets.mjs +121 -0
- package/lib/template-history.json +76 -0
- package/mcp/ui/card.css +15 -0
- package/mcp/ui/cast.js +62 -0
- package/mcp/ui/decision.js +61 -0
- package/mcp/ui/lineup.js +68 -0
- package/mcp/ui/rights.js +30 -0
- package/mcp/ui/style.js +154 -0
- package/netplay/NETPLAY.md +191 -3
- package/netplay/netplay.ts +150 -3
- package/package.json +15 -2
- 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/assets/RIGHTS.md +203 -0
- package/starters/gem-rush-3d/assets/manifest.json +2318 -0
- package/starters/gem-rush-3d/credits.json +31 -0
- package/starters/gem-rush-3d/game.json +11 -0
- package/starters/gem-rush-3d/index.html +20 -0
- package/starters/gem-rush-3d/lab.json +19 -0
- package/starters/gem-rush-3d/src/main.ts +2107 -0
- package/starters/gem-rush-3d/style.json +66 -0
- package/starters/gem-rush-3d/tunables.json +13 -0
- 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 +153 -8
- package/worker/office-page.mjs +115 -0
- package/worker/office.mjs +137 -6
- package/worker/pages.mjs +32 -9
- package/worker/qr.mjs +11 -0
- package/worker/room.mjs +288 -10
- package/worker/servers.mjs +5 -1
- package/worker/site.mjs +9 -4
- package/worker/stats.mjs +4 -1
- package/worker/version.mjs +1 -1
package/worker/room.mjs
CHANGED
|
@@ -34,14 +34,22 @@
|
|
|
34
34
|
* `agent:do` (a goal of the game's agents.json, at most one every 3 s). It says only `say:<lineId>` lines of that
|
|
35
35
|
* vocabulary, with arguments that fit, and only on a server whose AI may talk: the relay drops everything else an AI
|
|
36
36
|
* says (`stats.agentDrops`). The Table's own house agents (worker/agents.mjs) are peers like any other, on a loopback.
|
|
37
|
+
*
|
|
38
|
+
* ROOM CHAT (revision 8, NETPLAY.md section 19): anyone in the room (a player, a watcher, the big screen's page, a
|
|
39
|
+
* homie.rocks room page) says a line (`say`) or a reaction (`react`) on the game's socket or the shell's watch socket.
|
|
40
|
+
* The relay checks the room's chat rules (`policy.chat`: what may be sent, who may send it, slow mode), runs the floor
|
|
41
|
+
* on typed text (worker/chat.mjs), waits for the studio's review when there is one (the Table's Workers AI), and fans
|
|
42
|
+
* the line out to every socket and every watching shell. It keeps the last few minutes in memory for a page that just
|
|
43
|
+
* opened, and nothing anywhere else.
|
|
37
44
|
* =============================================================================
|
|
38
45
|
*/
|
|
39
46
|
import { aiName, skillPreset, stripAi } from './agents.mjs';
|
|
40
47
|
import { checkArgs, talks, vocabularyOf } from './brain.mjs';
|
|
48
|
+
import { CHAT_LIMITS, CHAT_RATES, HELD_WORDS, allows, cleanText, floor, normalizeChat, publicChat } from './chat.mjs';
|
|
41
49
|
|
|
42
50
|
export const NET_VERSION = 1;
|
|
43
51
|
/** The contract revision this relay speaks (NETPLAY.md): optional fields, frames and refusals; the wire stays `v: 1`. */
|
|
44
|
-
export const NET_REVISION =
|
|
52
|
+
export const NET_REVISION = 8;
|
|
45
53
|
const POLICY_KINDS = ['open', 'humans-only', 'hybrid', 'beginner'];
|
|
46
54
|
/**
|
|
47
55
|
* A room's policy before the Worker says anything (and the public server's with nothing set): today's behaviour.
|
|
@@ -77,6 +85,8 @@ export function normalizePolicy(p) {
|
|
|
77
85
|
bots: p.bots === 'off' ? 'off' : 'fill', level: Math.min(levelMax, int(p.level, 1, 5, 3)), levelMax,
|
|
78
86
|
speech: ['game', 'lines', 'off'].includes(p.speech) ? p.speech : 'game', kids,
|
|
79
87
|
brain: kind === 'humans-only' ? 'off' : ['off', 'script', 'workers-ai', 'owner-key'].includes(p.brain) ? p.brain : 'script',
|
|
88
|
+
// Revision 8: the room's chat rules (section 19), capped by the server's speech and kids.
|
|
89
|
+
...(p.chat !== undefined ? { chat: normalizeChat(p.chat === false ? { mode: 'off' } : p.chat, { kids, speech: ['game', 'lines', 'off'].includes(p.speech) ? p.speech : 'game' }) } : {}),
|
|
80
90
|
};
|
|
81
91
|
}
|
|
82
92
|
const policySig = (p) => JSON.stringify({ ...p, at: 0 });
|
|
@@ -91,6 +101,8 @@ const DEVICE_RANK = { desk: 0, tv: 1, phone: 2 };
|
|
|
91
101
|
/** Size caps (bytes of the JSON text as received), for a room of up to 16 seats. */
|
|
92
102
|
export const LIMITS = Object.freeze({
|
|
93
103
|
hello: 2048, snap: 16384, in: 2048, ev: 4096, ckpt: 65536, state: 8192, round: 8192, roster: 4096, ping: 256, other: 512,
|
|
104
|
+
// Room chat (revision 8): a typed line (280 characters, emoji count double), a reaction.
|
|
105
|
+
say: 1536, react: 256,
|
|
94
106
|
});
|
|
95
107
|
/**
|
|
96
108
|
* What grows with the seats (every body, every result, every slot): the checkpoint, round and roster caps double
|
|
@@ -100,7 +112,7 @@ export const LIMITS = Object.freeze({
|
|
|
100
112
|
export const GROWS = Object.freeze(['ckpt', 'round', 'roster']);
|
|
101
113
|
export const capOf = (t, seats) => (LIMITS[t] ?? LIMITS.other) * (GROWS.includes(t) && seats > 16 ? 2 : 1);
|
|
102
114
|
/** Rate caps (messages per rolling second, per client). Over the cap a message is dropped, counted and reported. */
|
|
103
|
-
export const RATES = Object.freeze({ snap: 30, in: 60, ev: 30, ckpt: 4, state: 64, round: 4, roster: 8, ping: 8, other: 8 });
|
|
115
|
+
export const RATES = Object.freeze({ snap: 30, in: 60, ev: 30, ckpt: 4, state: 64, round: 4, roster: 8, ping: 8, other: 8, say: 4, react: 10 });
|
|
104
116
|
/** `ev` per second by the sender's role: a screen is a spectator, and the host is somebody's phone. */
|
|
105
117
|
export const EV_RATES = Object.freeze({ host: 30, replica: 10, screen: 2 });
|
|
106
118
|
/** The keyed state channel: at most this many keys and bytes per room. */
|
|
@@ -276,6 +288,19 @@ export class NetRoom {
|
|
|
276
288
|
this.askedMax = null;
|
|
277
289
|
/** A launch change waiting for the round to finish: who may stay (ticket kinds), and when it applies at the latest. */
|
|
278
290
|
this.regate = null;
|
|
291
|
+
/**
|
|
292
|
+
* Room chat (revision 8, section 19): the last few minutes of lines and reactions (memory only, never stored), each
|
|
293
|
+
* sender's buckets, the studio's review of typed text (the Table sets `review`; null: the floor alone decides), and
|
|
294
|
+
* the messages waiting for it.
|
|
295
|
+
*/
|
|
296
|
+
this.chatLog = [];
|
|
297
|
+
this.chatBuckets = new Map();
|
|
298
|
+
this.chatAddress = new Map();
|
|
299
|
+
this.chatReacts = [];
|
|
300
|
+
this.chatPending = 0;
|
|
301
|
+
this.review = null;
|
|
302
|
+
this.watchIds = new WeakMap();
|
|
303
|
+
this.defaultChat = null;
|
|
279
304
|
}
|
|
280
305
|
|
|
281
306
|
/* ------------------------------------------------------------ sockets */
|
|
@@ -285,6 +310,9 @@ export class NetRoom {
|
|
|
285
310
|
const now = this.now();
|
|
286
311
|
const c = {
|
|
287
312
|
id: randomId(6), conn, ip: conn.ip ?? null, browser: conn.browser ?? null, player: conn.player ?? null, via: conn.via ?? null, helloed: false, seat: null, token: null, name: '', typed: '', colour: 0,
|
|
313
|
+
// Room chat (section 19): the Worker's word that this socket's account is a signed-in one (a passkey, not a guest),
|
|
314
|
+
// and that it belongs to this room's server.
|
|
315
|
+
acct: conn.acct === true, member: conn.member === true,
|
|
288
316
|
device: 'desk', want: 'play', canHost: true, hidden: false, waiting: false, joinedAt: now, lastSeen: now, lastSnapAt: 0,
|
|
289
317
|
rates: new Map(), drops: [], errAt: 0,
|
|
290
318
|
// A watcher (section 16): the Worker's word for a socket opened through a watch door, else the hello's own.
|
|
@@ -312,16 +340,21 @@ export class NetRoom {
|
|
|
312
340
|
// A shell that comes back to a room it was kicked from (or that is closed) hears so at once.
|
|
313
341
|
const held = this.heldNotice(conn);
|
|
314
342
|
if (held) this.sendText(conn, JSON.stringify(held));
|
|
343
|
+
// Room chat (section 19): the last few minutes, so a page that just opened has the conversation (never to a page the
|
|
344
|
+
// studio holds out of the room, or a room it closed).
|
|
345
|
+
if ((!held || held.t === 'muted') && (!conn.hub || this.chatRules().hub)) this.sendText(conn, JSON.stringify({ t: 'lines', lines: this.recentChat().map((r) => this.wireLine(r)) }));
|
|
315
346
|
const rate = [];
|
|
316
347
|
return {
|
|
317
348
|
onClose: () => this.watchers.delete(conn),
|
|
318
349
|
onMessage: (text) => {
|
|
319
350
|
const now = this.now();
|
|
320
351
|
while (rate.length && rate[0] <= now - 1000) rate.shift();
|
|
321
|
-
if (rate.length >=
|
|
352
|
+
if (rate.length >= 10 || String(text).length > LIMITS.say) return;
|
|
322
353
|
rate.push(now);
|
|
323
354
|
let m = null;
|
|
324
355
|
try { m = JSON.parse(String(text)); } catch { return; }
|
|
356
|
+
// Room chat from the shell (the play page, the big screen's page, the watch page, a homie.rocks room page).
|
|
357
|
+
if (m && (m.t === 'say' || m.t === 'react')) { this.onChat({ conn }, m); return; }
|
|
325
358
|
if (!m || m.t !== 'vote' || !conn.browser) return;
|
|
326
359
|
const c = this.live().find((o) => o.browser === conn.browser && o.seat !== null && !o.agent && !o.watch);
|
|
327
360
|
if (c) this.onVote(c, m, conn);
|
|
@@ -358,7 +391,7 @@ export class NetRoom {
|
|
|
358
391
|
levelNow() { return Math.max(1, Math.min(this.policy.levelMax, this.level ?? this.policy.level)); }
|
|
359
392
|
policyOut() {
|
|
360
393
|
const level = this.levelNow();
|
|
361
|
-
return { ...this.policy, skill: skillPreset(level, { kids: this.policy.kids }), ...(this.levelBy ? { by: this.levelBy } : {}) };
|
|
394
|
+
return { ...this.policy, chat: publicChat(this.chatRules()), skill: skillPreset(level, { kids: this.policy.kids }), ...(this.levelBy ? { by: this.levelBy } : {}) };
|
|
362
395
|
}
|
|
363
396
|
/** Send to every client in the room (a lite agent too: it hears join/leave, roster, round, policy and votes). */
|
|
364
397
|
broadcast(msg) {
|
|
@@ -550,6 +583,12 @@ export class NetRoom {
|
|
|
550
583
|
this.onVote(c, m);
|
|
551
584
|
return;
|
|
552
585
|
}
|
|
586
|
+
case 'say':
|
|
587
|
+
case 'react': {
|
|
588
|
+
// Room chat (section 19): the game's own chat UI (`net.say`, `net.react`), checked like the shell's.
|
|
589
|
+
this.onChat({ client: c }, m);
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
553
592
|
case 'ping': {
|
|
554
593
|
const wasHidden = c.hidden;
|
|
555
594
|
c.hidden = m.hid === true;
|
|
@@ -866,6 +905,216 @@ export class NetRoom {
|
|
|
866
905
|
for (const o of this.live()) if (o !== c && this.lite(o) && o.id !== this.hostId) this.send(o, out);
|
|
867
906
|
}
|
|
868
907
|
|
|
908
|
+
/* ------------------------------------------------------------ room chat (revision 8, section 19) */
|
|
909
|
+
|
|
910
|
+
/** The room's chat rules: the Worker's (in the policy), else the defaults for this server's speech and kids. */
|
|
911
|
+
chatRules() {
|
|
912
|
+
if (this.policy.chat) return this.policy.chat;
|
|
913
|
+
const sig = `${this.policy.kids}|${this.policy.speech}`;
|
|
914
|
+
if (!this.defaultChat || this.defaultChat.sig !== sig) this.defaultChat = { sig, rules: normalizeChat(null, { kids: this.policy.kids, speech: this.policy.speech }) };
|
|
915
|
+
return this.defaultChat.rules;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/** The window a page that just opened gets: the last `keep` messages of the last `keepMs`. */
|
|
919
|
+
recentChat(now = this.now()) {
|
|
920
|
+
return this.chatLog.filter((r) => now - r.at <= CHAT_LIMITS.keepMs).slice(-CHAT_LIMITS.keep);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/** A line or a reaction as every screen gets it (homie.rocks's room chat shapes: `line` and `react`). */
|
|
924
|
+
wireLine(r) {
|
|
925
|
+
const base = { id: r.id, at: r.at, name: r.name, seat: r.seat, colour: r.colour, by: r.by, ...(r.bubble ? { bubble: true } : {}), ...(r.acct ? { acct: true } : {}), ...(r.owner ? { owner: true } : {}) };
|
|
926
|
+
if (r.kind === 'react') return { t: 'react', ...base, react: r.react, glyph: r.glyph };
|
|
927
|
+
return { t: 'line', ...base, text: r.text, ...(r.say ? { say: r.say } : {}) };
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
watchIdOf(conn) {
|
|
931
|
+
let id = this.watchIds.get(conn);
|
|
932
|
+
if (!id) { id = randomId(6); this.watchIds.set(conn, id); }
|
|
933
|
+
return id;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Who is speaking: a game socket's own client, or for a shell's watch socket the client of the same browser (or
|
|
938
|
+
* account) in this room: its seat, name and colour. A page with no client here (a homie.rocks room page, a watch
|
|
939
|
+
* page before its game connects) is a watcher with a handle. Signed in, a member, the owner: the Worker's word.
|
|
940
|
+
*/
|
|
941
|
+
chatter(client, conn) {
|
|
942
|
+
let g = client;
|
|
943
|
+
if (!g && conn) {
|
|
944
|
+
const mine = this.live().filter((o) => !o.agent && ((conn.browser && o.browser === conn.browser) || (conn.player && o.player === conn.player)));
|
|
945
|
+
g = mine.find((o) => o.seat !== null) ?? mine.find((o) => o.watch) ?? mine[0] ?? null;
|
|
946
|
+
}
|
|
947
|
+
const via = String(g?.via ?? conn?.via ?? '');
|
|
948
|
+
const owner = via.split('~').includes('o');
|
|
949
|
+
const seat = g ? g.seat : null;
|
|
950
|
+
const handle = handleFor(g?.token || g?.browser || conn?.browser || `w-${this.watchIdOf(conn ?? g?.conn ?? {})}`);
|
|
951
|
+
const name = seat !== null ? g.name : oneLine(g?.typed, CHAT_LIMITS.name) || handle;
|
|
952
|
+
return {
|
|
953
|
+
key: g ? `c:${g.id}` : `w:${this.watchIdOf(conn)}`, client: g, seat, name, colour: seat !== null ? g.colour : null,
|
|
954
|
+
player: g?.player ?? conn?.player ?? null, acct: Boolean(g?.acct || conn?.acct), member: Boolean(g?.member || conn?.member) || owner,
|
|
955
|
+
owner, browser: g?.browser ?? conn?.browser ?? null, token: g?.token ?? null, ip: g?.ip ?? conn?.ip ?? null,
|
|
956
|
+
watch: seat === null, hub: Boolean(conn?.hub) && !g, agent: Boolean(g?.agent), conn: conn ?? g?.conn ?? null,
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/** A token bucket: true (and one token spent) when there is one. */
|
|
961
|
+
takeToken(map, key, rate, now) {
|
|
962
|
+
let b = map.get(key);
|
|
963
|
+
if (!b) { b = { tokens: rate.burst, at: now }; map.set(key, b); }
|
|
964
|
+
b.tokens = Math.min(rate.burst, b.tokens + (now - b.at) / rate.refillMs);
|
|
965
|
+
b.at = now;
|
|
966
|
+
if (b.tokens < 1) return false;
|
|
967
|
+
b.tokens -= 1;
|
|
968
|
+
return true;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* One message from anyone in the room: `{ t: 'say', text }` (typed), `{ t: 'say', say: '<line id>' }` (a quick line)
|
|
973
|
+
* or `{ t: 'react', kind }` (an emoji), with an optional `n` (the sender's own id for it) and `bubble: false` (not
|
|
974
|
+
* over my character). Refused with `held` (or homie.rocks's `slow`) to the sender only; else published to everyone,
|
|
975
|
+
* after the studio's review for typed text when there is one.
|
|
976
|
+
*/
|
|
977
|
+
onChat({ client = null, conn = null }, m) {
|
|
978
|
+
const now = this.now();
|
|
979
|
+
const rules = this.chatRules();
|
|
980
|
+
const who = this.chatter(client, conn);
|
|
981
|
+
const n = typeof m.n === 'string' && /^[A-Za-z0-9_-]{1,16}$/.test(m.n) ? m.n : null;
|
|
982
|
+
const kind = m.t === 'react' ? 'react' : typeof m.say === 'string' || typeof m.line === 'string' ? 'line' : 'text';
|
|
983
|
+
const stats = this.stats;
|
|
984
|
+
const reply = (why, extra = {}) => {
|
|
985
|
+
stats.chatHeld = stats.chatHeld ?? {};
|
|
986
|
+
stats.chatHeld[why] = (stats.chatHeld[why] ?? 0) + 1;
|
|
987
|
+
const out = JSON.stringify({ t: why === 'slow' ? 'slow' : 'held', why, message: HELD_WORDS[why] ?? HELD_WORDS.unknown, ...(n ? { n } : {}), ...extra });
|
|
988
|
+
if (who.conn) this.sendText(who.conn, out);
|
|
989
|
+
return { ok: false, why };
|
|
990
|
+
};
|
|
991
|
+
if (who.agent) return reply('ai_seat');
|
|
992
|
+
if (!allows(rules, kind)) return reply(rules.mode === 'off' ? 'off' : rules.mode === 'emoji' ? 'emoji' : 'lines');
|
|
993
|
+
if (who.hub && !rules.hub) return reply('hub');
|
|
994
|
+
if (who.watch && !rules.watchers && !who.owner) return reply('watchers');
|
|
995
|
+
const need = kind === 'text' ? rules.who : rules.react;
|
|
996
|
+
if (!who.owner && need === 'signed-in' && !who.acct) return reply(kind === 'text' ? 'sign-in' : 'sign-in-react');
|
|
997
|
+
if (!who.owner && need === 'members' && !who.member) return reply(kind === 'text' ? 'members' : 'members-react');
|
|
998
|
+
const mute = this.muteOf({ token: who.token, browser: who.browser, player: who.player });
|
|
999
|
+
if (mute) return reply('muted', { until: mute.until });
|
|
1000
|
+
const b = this.chatBuckets.get(who.key) ?? { lastSayAt: -Infinity, lastText: '', lastTextAt: -Infinity };
|
|
1001
|
+
this.chatBuckets.set(who.key, b);
|
|
1002
|
+
if (!this.takeToken(this.chatBuckets, `${who.key}|${kind === 'react' ? 'react' : 'say'}`, kind === 'react' ? CHAT_RATES.react : CHAT_RATES.say, now)) return reply('slow');
|
|
1003
|
+
if (who.ip && !this.takeToken(this.chatAddress, who.ip, CHAT_RATES.address, now)) return reply('slow');
|
|
1004
|
+
if (kind !== 'react' && rules.slow && !who.owner && now - b.lastSayAt < rules.slow * 1000) return reply('slow', { until: b.lastSayAt + rules.slow * 1000, slow: rules.slow });
|
|
1005
|
+
let rec;
|
|
1006
|
+
if (kind === 'react') {
|
|
1007
|
+
const k = String(m.kind ?? m.react ?? '');
|
|
1008
|
+
const r = rules.emoji.find((x) => x.k === k);
|
|
1009
|
+
if (!r) return reply('unknown');
|
|
1010
|
+
// A storm of reactions: the room fans out at most so many a second (the sender's own still floats for them).
|
|
1011
|
+
while (this.chatReacts.length && this.chatReacts[0] <= now - 1000) this.chatReacts.shift();
|
|
1012
|
+
if (this.chatReacts.length >= CHAT_RATES.roomReactsPerSecond) { stats.chatDrops = (stats.chatDrops ?? 0) + 1; return { ok: true, dropped: true }; }
|
|
1013
|
+
this.chatReacts.push(now);
|
|
1014
|
+
rec = { kind: 'react', react: r.k, glyph: r.e };
|
|
1015
|
+
} else if (kind === 'line') {
|
|
1016
|
+
const id = String(m.say ?? m.line ?? '');
|
|
1017
|
+
const l = rules.lines.find((x) => x.id === id);
|
|
1018
|
+
if (!l) return reply('unknown');
|
|
1019
|
+
rec = { kind: 'line', say: l.id, text: l.text };
|
|
1020
|
+
} else {
|
|
1021
|
+
const text = cleanText(m.text, rules.max);
|
|
1022
|
+
if (!text) return reply('empty');
|
|
1023
|
+
const f = floor(text, rules);
|
|
1024
|
+
if (!f.ok) return reply(f.why);
|
|
1025
|
+
if (b.lastText === text.toLowerCase() && now - b.lastTextAt < CHAT_LIMITS.repeatMs) return reply('repeat');
|
|
1026
|
+
b.lastText = text.toLowerCase();
|
|
1027
|
+
b.lastTextAt = now;
|
|
1028
|
+
rec = { kind: 'text', text };
|
|
1029
|
+
}
|
|
1030
|
+
if (kind !== 'react') b.lastSayAt = now;
|
|
1031
|
+
Object.assign(rec, {
|
|
1032
|
+
name: who.name, seat: who.seat, colour: who.colour, by: who.hub ? 'hub' : who.watch ? 'watcher' : 'player',
|
|
1033
|
+
bubble: m.bubble !== false && rules.bubbles && who.seat !== null, acct: who.acct, owner: who.owner,
|
|
1034
|
+
// Only the office sees these: who sent it, so a mute or a kick from the line reaches them (never an address).
|
|
1035
|
+
from: { client: who.client?.id ?? null, token: who.token, browser: who.browser, player: who.player },
|
|
1036
|
+
});
|
|
1037
|
+
if (kind === 'text' && rules.ai && typeof this.review === 'function' && !who.owner) {
|
|
1038
|
+
if (this.chatPending >= CHAT_LIMITS.pending) return reply('busy');
|
|
1039
|
+
this.chatPending += 1;
|
|
1040
|
+
const t0 = now;
|
|
1041
|
+
Promise.resolve()
|
|
1042
|
+
.then(() => this.review(rec.text, { room: this.code, links: rules.links }))
|
|
1043
|
+
.then((v) => v, (error) => ({ ok: true, by: 'error', error: String(error?.message ?? error).slice(0, 120) }))
|
|
1044
|
+
.then((v) => {
|
|
1045
|
+
this.chatPending = Math.max(0, this.chatPending - 1);
|
|
1046
|
+
if (v && v.ok === false) {
|
|
1047
|
+
stats.chatAiHeld = (stats.chatAiHeld ?? 0) + 1;
|
|
1048
|
+
// What the review thought it was (insult, hate, sexual, grooming, harm, spam): counts for the office only.
|
|
1049
|
+
stats.chatAiWhy = stats.chatAiWhy ?? {};
|
|
1050
|
+
const why = String(v.why ?? 'held').slice(0, 24);
|
|
1051
|
+
stats.chatAiWhy[why] = (stats.chatAiWhy[why] ?? 0) + 1;
|
|
1052
|
+
reply('ai');
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
if (v?.by === 'error') stats.chatAiErrors = (stats.chatAiErrors ?? 0) + 1;
|
|
1056
|
+
rec.review = { by: v?.by ?? 'ai', ms: this.now() - t0, ...(Number.isFinite(v?.p) ? { p: v.p } : {}) };
|
|
1057
|
+
// A mute that came while it was being reviewed still holds it.
|
|
1058
|
+
if (this.muteOf({ token: who.token, browser: who.browser, player: who.player })) { reply('muted'); return; }
|
|
1059
|
+
this.publishChat(rec, who.conn, n);
|
|
1060
|
+
});
|
|
1061
|
+
return { ok: true, pending: true };
|
|
1062
|
+
}
|
|
1063
|
+
return this.publishChat(rec, who.conn, n);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/** A message every socket and every watching shell gets now (the sender's own copy carries its `n`). */
|
|
1067
|
+
publishChat(rec, senderConn = null, n = null) {
|
|
1068
|
+
const now = this.now();
|
|
1069
|
+
rec.id = randomId(6);
|
|
1070
|
+
rec.at = now;
|
|
1071
|
+
this.chatLog.push(rec);
|
|
1072
|
+
while (this.chatLog.length > CHAT_LIMITS.keep || (this.chatLog.length && now - this.chatLog[0].at > CHAT_LIMITS.keepMs)) this.chatLog.shift();
|
|
1073
|
+
const msg = this.wireLine(rec);
|
|
1074
|
+
const text = JSON.stringify(msg);
|
|
1075
|
+
const mine = n ? JSON.stringify({ ...msg, n }) : text;
|
|
1076
|
+
const hub = this.chatRules().hub;
|
|
1077
|
+
for (const o of this.live()) if (!this.lite(o)) this.sendText(o.conn, o.conn === senderConn ? mine : text);
|
|
1078
|
+
for (const w of this.watchers) if (!w.hub || hub) this.sendText(w, w === senderConn ? mine : text);
|
|
1079
|
+
if (rec.kind === 'react') this.stats.chatReacts = (this.stats.chatReacts ?? 0) + 1;
|
|
1080
|
+
else this.stats.chatLines = (this.stats.chatLines ?? 0) + 1;
|
|
1081
|
+
return { ok: true, id: rec.id };
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/** Take messages down (the owner's `unsay`): from the window, and from every screen that shows them. */
|
|
1085
|
+
unsay(ids) {
|
|
1086
|
+
const gone = new Set(ids);
|
|
1087
|
+
const before = this.chatLog.length;
|
|
1088
|
+
this.chatLog = this.chatLog.filter((r) => !gone.has(r.id));
|
|
1089
|
+
const text = JSON.stringify({ t: 'unline', ids: [...gone] });
|
|
1090
|
+
for (const o of this.live()) if (!this.lite(o)) this.sendText(o.conn, text);
|
|
1091
|
+
for (const w of this.watchers) this.sendText(w, text);
|
|
1092
|
+
return before - this.chatLog.length;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/**
|
|
1096
|
+
* A mute or a kick for a chat line whose sender holds no socket in this room now (a watcher, a homie.rocks page): held
|
|
1097
|
+
* by that browser and account, as a kick holds a player's other tabs.
|
|
1098
|
+
*/
|
|
1099
|
+
holdSender(line, minutes, now, { kick = false, message = '', purge = false } = {}) {
|
|
1100
|
+
const f = line.from ?? {};
|
|
1101
|
+
if (!f.token && !f.browser && !f.player) return { ok: false, error: 'no-player', message: 'that line has nobody to hold' };
|
|
1102
|
+
const until = now + minutes * 60_000;
|
|
1103
|
+
const hold = { token: f.token ?? null, browser: f.browser ?? null, player: f.player ?? null, name: line.name, seat: line.seat, until, at: now };
|
|
1104
|
+
if (kick) this.bans = [...this.bans, { ...hold, ip: null, pass: null, message }].slice(-CONTROL_LIMITS.bans);
|
|
1105
|
+
else this.mutes = [...this.mutes, hold].slice(-CONTROL_LIMITS.mutes);
|
|
1106
|
+
this.tellBrowser(hold, kick ? { t: 'kicked', room: this.code, until, message } : { t: 'muted', room: this.code, until });
|
|
1107
|
+
const removed = purge ? this.unsay(this.linesOf(f)) : 0;
|
|
1108
|
+
this.tellWatchers();
|
|
1109
|
+
return { ok: true, op: kick ? 'kick' : 'mute', name: line.name, seat: line.seat, until, sockets: 0, ...(removed ? { removed } : {}) };
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/** The lines of one sender (a line's `from`), for a mute or a kick that also takes their messages down. */
|
|
1113
|
+
linesOf(from) {
|
|
1114
|
+
if (!from) return [];
|
|
1115
|
+
return this.chatLog.filter((r) => (from.client && r.from?.client === from.client) || (from.token && r.from?.token === from.token) || (from.browser && r.from?.browser === from.browser) || (from.player && r.from?.player === from.player)).map((r) => r.id);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
869
1118
|
/* ------------------------------------------------------------ watchers (section 16) */
|
|
870
1119
|
|
|
871
1120
|
/**
|
|
@@ -1008,6 +1257,10 @@ export class NetRoom {
|
|
|
1008
1257
|
}
|
|
1009
1258
|
this.reapSeats(now);
|
|
1010
1259
|
this.seatWaiting();
|
|
1260
|
+
// The chat window is minutes long; buckets of senders who went quiet are dropped.
|
|
1261
|
+
while (this.chatLog.length && now - this.chatLog[0].at > CHAT_LIMITS.keepMs) this.chatLog.shift();
|
|
1262
|
+
if (this.chatBuckets.size > 512) this.chatBuckets.clear();
|
|
1263
|
+
if (this.chatAddress.size > 512) this.chatAddress.clear();
|
|
1011
1264
|
if (!this.live().some((c) => !c.agent) && this.emptySince && now - this.emptySince > this.forgetMs) {
|
|
1012
1265
|
for (const c of this.live()) this.kick(c, 'agents-alone', 4001);
|
|
1013
1266
|
this.lastSnap = null; this.lastSnapText = null; this.lastCkpt = null; this.lastRound = null; this.lastRoster = null;
|
|
@@ -1015,6 +1268,8 @@ export class NetRoom {
|
|
|
1015
1268
|
this.emptySince = 0; this.openedAt = 0; this.askedMax = null;
|
|
1016
1269
|
// The party's dial and vote are the room's: a new party starts from the server's level.
|
|
1017
1270
|
this.level = null; this.levelBy = null; this.vote = null; this.agentsOut = null;
|
|
1271
|
+
// Room chat (section 19): an empty room forgets what was said, as it forgets everything else.
|
|
1272
|
+
this.chatLog = []; this.chatBuckets.clear(); this.chatAddress.clear(); this.chatReacts = [];
|
|
1018
1273
|
this.seatsDirty = false; this.persistDirty = false;
|
|
1019
1274
|
try { this.store?.clear?.(); } catch { /* best effort */ }
|
|
1020
1275
|
}
|
|
@@ -1333,13 +1588,17 @@ export class NetRoom {
|
|
|
1333
1588
|
this.mutes = this.mutes.filter((m) => m.until > now);
|
|
1334
1589
|
switch (op) {
|
|
1335
1590
|
case 'kick': {
|
|
1336
|
-
|
|
1591
|
+
// From a chat line (section 19): its sender, in this room now or not (a watcher with no seat is held too).
|
|
1592
|
+
const line = typeof a.line === 'string' ? this.chatLog.find((r) => r.id === a.line) : null;
|
|
1593
|
+
const target = this.findClient(a) ?? (line?.from?.client ? this.clients.get(line.from.client) ?? null : null);
|
|
1594
|
+
if (!target && line) return this.holdSender(line, minutes, now, { kick: true, message: oneLine(a.message, CONTROL_LIMITS.message) || 'The studio removed you from this room.', purge: a.purge === true });
|
|
1337
1595
|
if (!target) return { ok: false, error: 'no-player', message: 'nobody is in that seat now' };
|
|
1596
|
+
const purged = a.purge === true ? this.unsay(this.linesOf({ client: target.id, token: target.token, browser: target.browser, player: target.player })) : 0;
|
|
1338
1597
|
const until = now + minutes * 60_000;
|
|
1339
1598
|
// The message is the studio's words; `until` says when (the shell says it in the player's own time).
|
|
1340
1599
|
const message = oneLine(a.message, CONTROL_LIMITS.message) || 'The studio removed you from this room.';
|
|
1341
1600
|
// An AI is held out by its pass (its seat becomes a brainless AI seat again); a person by token, browser, account.
|
|
1342
|
-
const ban = { token: target.token, browser: target.browser, player: target.player, ip: a.address ? target.ip : null, pass: target.agent?.pass ?? null, name: target.name, seat: target.seat, until, at: now, message };
|
|
1601
|
+
const ban = { token: target.token, browser: target.browser, player: target.player, ip: a.address ? target.ip : null, pass: target.agent?.pass ?? null, name: line?.name ?? target.name, seat: target.seat, until, at: now, message };
|
|
1343
1602
|
this.bans = [...this.bans, ban].slice(-CONTROL_LIMITS.bans);
|
|
1344
1603
|
// Every socket of that player in this room: the seat itself, and its browser's or account's other tabs.
|
|
1345
1604
|
const out = this.live().filter((o) => o === target || (ban.browser && o.browser === ban.browser) || (ban.player && o.player === ban.player));
|
|
@@ -1357,21 +1616,32 @@ export class NetRoom {
|
|
|
1357
1616
|
this.tellBrowser(ban, { t: 'kicked', room: this.code, until, message });
|
|
1358
1617
|
this.persist(now);
|
|
1359
1618
|
this.tellWatchers();
|
|
1360
|
-
return { ok: true, op, name: ban.name, seat: ban.seat, sockets: out.length, until };
|
|
1619
|
+
return { ok: true, op, name: ban.name, seat: ban.seat, sockets: out.length, until, ...(purged ? { removed: purged } : {}) };
|
|
1361
1620
|
}
|
|
1362
1621
|
case 'mute': {
|
|
1363
|
-
const
|
|
1622
|
+
const line = typeof a.line === 'string' ? this.chatLog.find((r) => r.id === a.line) : null;
|
|
1623
|
+
const target = this.findClient(a) ?? (line?.from?.client ? this.clients.get(line.from.client) ?? null : null);
|
|
1624
|
+
if (!target && line && a.off !== true) return this.holdSender(line, minutes, now, { kick: false, purge: a.purge === true });
|
|
1364
1625
|
if (!target) return { ok: false, error: 'no-player', message: 'nobody is in that seat now' };
|
|
1365
1626
|
const off = a.off === true;
|
|
1627
|
+
const purged = !off && a.purge === true ? this.unsay(this.linesOf({ client: target.id, token: target.token, browser: target.browser, player: target.player })) : 0;
|
|
1366
1628
|
const same = (m) => (m.token && m.token === target.token) || (m.browser && m.browser === target.browser) || (m.player && m.player === target.player);
|
|
1367
1629
|
this.mutes = this.mutes.filter((m) => !same(m));
|
|
1368
1630
|
const until = off ? 0 : now + minutes * 60_000;
|
|
1369
|
-
if (!off) this.mutes = [...this.mutes, { token: target.token, browser: target.browser, player: target.player, name: target.name, seat: target.seat, until, at: now }].slice(-CONTROL_LIMITS.mutes);
|
|
1631
|
+
if (!off) this.mutes = [...this.mutes, { token: target.token, browser: target.browser, player: target.player, name: line?.name ?? target.name, seat: target.seat, until, at: now }].slice(-CONTROL_LIMITS.mutes);
|
|
1370
1632
|
const note = { t: 'mute', id: target.id, seat: target.seat, until };
|
|
1371
1633
|
for (const o of this.live()) this.send(o, note);
|
|
1372
1634
|
this.tellBrowser(target, { t: 'muted', room: this.code, until });
|
|
1373
1635
|
this.tellWatchers();
|
|
1374
|
-
return { ok: true, op, name: target.name, seat: target.seat, until };
|
|
1636
|
+
return { ok: true, op, name: line?.name ?? target.name, seat: target.seat, until, ...(purged ? { removed: purged } : {}) };
|
|
1637
|
+
}
|
|
1638
|
+
case 'unsay': {
|
|
1639
|
+
// Room chat (section 19): the owner takes a message down (or every message: `all`), on every screen at once.
|
|
1640
|
+
const ids = a.all === true ? this.chatLog.map((r) => r.id) : (Array.isArray(a.ids) ? a.ids : [a.id]).filter((x) => typeof x === 'string' && /^[A-Za-z0-9_-]{1,16}$/.test(x)).slice(0, 64);
|
|
1641
|
+
if (!ids.length) return { ok: false, error: 'no-line', message: 'which message?' };
|
|
1642
|
+
const removed = this.unsay(ids);
|
|
1643
|
+
this.stats.chatRemoved = (this.stats.chatRemoved ?? 0) + removed;
|
|
1644
|
+
return { ok: true, op, removed };
|
|
1375
1645
|
}
|
|
1376
1646
|
case 'announce': {
|
|
1377
1647
|
const text = oneLine(a.text, CONTROL_LIMITS.announce);
|
|
@@ -1387,6 +1657,8 @@ export class NetRoom {
|
|
|
1387
1657
|
const msg = JSON.stringify({ t: 'announce', ...this.announcement });
|
|
1388
1658
|
for (const o of this.live()) this.sendText(o.conn, msg);
|
|
1389
1659
|
for (const w of this.watchers) this.sendText(w, msg);
|
|
1660
|
+
// The room's chat shows it too, as the studio's own line (section 19).
|
|
1661
|
+
this.publishChat({ kind: 'studio', text, name: 'Studio', seat: null, colour: null, by: 'studio', bubble: false, acct: false, owner: true, from: null });
|
|
1390
1662
|
this.tellWatchers();
|
|
1391
1663
|
return { ok: true, op, id: this.announcement.id, people: this.live().length, until: this.announcement.until };
|
|
1392
1664
|
}
|
|
@@ -1541,6 +1813,12 @@ export class NetRoom {
|
|
|
1541
1813
|
}),
|
|
1542
1814
|
bans: this.bans.filter((b) => b.until > now).map((b) => ({ name: b.name, seat: b.seat, until: b.until, at: b.at, address: Boolean(b.ip), browser: browserTag(b.browser) })),
|
|
1543
1815
|
mutes: this.mutes.filter((m) => m.until > now).map((m) => ({ name: m.name, seat: m.seat, until: m.until })),
|
|
1816
|
+
// Room chat (section 19): the window as the owner sees it, with who sent each line (never an address).
|
|
1817
|
+
chat: {
|
|
1818
|
+
rules: this.chatRules(),
|
|
1819
|
+
pending: this.chatPending,
|
|
1820
|
+
lines: this.recentChat(now).map((r) => ({ ...this.wireLine(r), kind: r.kind, client: r.from?.client ?? null, player: r.from?.player ?? null, browser: browserTag(r.from?.browser), ...(r.review ? { review: r.review } : {}) })),
|
|
1821
|
+
},
|
|
1544
1822
|
},
|
|
1545
1823
|
};
|
|
1546
1824
|
}
|
package/worker/servers.mjs
CHANGED
|
@@ -158,6 +158,8 @@ export function serverOf(raw, { from = 'd1' } = {}) {
|
|
|
158
158
|
state: ['open', 'closed', 'archived'].includes(raw.state) ? raw.state : 'open',
|
|
159
159
|
createdAt: Number(pick('created_at', 'createdAt')) || 0,
|
|
160
160
|
updatedAt: Number(pick('updated_at', 'updatedAt')) || 0,
|
|
161
|
+
// A game.json seed's own chat rules (0.23.0): under the owner's for this server (worker/chat-store.mjs chatOf).
|
|
162
|
+
...(from === 'game.json' && raw.chat && typeof raw.chat === 'object' && !Array.isArray(raw.chat) ? { chat: raw.chat } : {}),
|
|
161
163
|
from,
|
|
162
164
|
};
|
|
163
165
|
}
|
|
@@ -206,7 +208,7 @@ export async function serversOf(env, meta, { fresh = false } = {}) {
|
|
|
206
208
|
* together are at most one fewer than the room's seats (at least one seat is a person's); a kids server caps the
|
|
207
209
|
* dial at 3 and keeps speech to quick lines. `server: null` is a named room (it takes the public server's rules).
|
|
208
210
|
*/
|
|
209
|
-
export function policyOf(server, { seats = 8, named = false } = {}) {
|
|
211
|
+
export function policyOf(server, { seats = 8, named = false, chat = null } = {}) {
|
|
210
212
|
const s = server ?? PUBLIC_SERVER;
|
|
211
213
|
const room = Math.max(1, Math.min(seats, s.seats ?? seats));
|
|
212
214
|
const guides = s.policy === 'beginner' ? Math.max(0, Math.min(s.guides, room - 1)) : 0;
|
|
@@ -219,6 +221,8 @@ export function policyOf(server, { seats = 8, named = false } = {}) {
|
|
|
219
221
|
level: Math.min(levelMax, s.level), levelMax,
|
|
220
222
|
speech: s.kids && s.speech === 'game' ? 'lines' : s.speech,
|
|
221
223
|
kids: Boolean(s.kids), brain: s.brain,
|
|
224
|
+
// Room chat (0.23.0, worker/chat-store.mjs chatOf): the room's chat rules; an owner's change makes the policy newer.
|
|
225
|
+
...(chat ? { chat, at: Math.max(Math.max(0, Number(s.updatedAt) || 0), Number(chat.at) || 0) } : {}),
|
|
222
226
|
};
|
|
223
227
|
}
|
|
224
228
|
|
package/worker/site.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* What a studio puts in site/ wins: a whole page (site/pages), a partial (site/partials), its tokens
|
|
10
10
|
* (site/theme.json) and CSS (site/theme.css). The build gathers them into games.json and site/dist/_site/.
|
|
11
11
|
*/
|
|
12
|
-
import { qrSvg } from './qr.mjs';
|
|
12
|
+
import { isLocalOrigin, qrSvg } from './qr.mjs';
|
|
13
13
|
import { STUDIO_VERSION_TAG } from './version.mjs';
|
|
14
14
|
import { licenseLabel, licenseOf, remixRow } from './license.mjs';
|
|
15
15
|
import { POLICY_WORDS, policyWords } from './servers.mjs';
|
|
@@ -690,7 +690,7 @@ export function watchOf(g) {
|
|
|
690
690
|
* A room as the live list draws it (the same fields /api/rooms and /<game>/live give the script). A server's room
|
|
691
691
|
* (0.16.0) names its server and policy, and how many AI are in it (always marked AI).
|
|
692
692
|
*/
|
|
693
|
-
export function roomView(g, r, max, srv = null) {
|
|
693
|
+
export function roomView(g, r, max, srv = null, { chat = false } = {}) {
|
|
694
694
|
const room = r.name ?? r.room;
|
|
695
695
|
const m = /^(?:pub|s-[a-z0-9-]+)-(\d+)$/.exec(String(room ?? ''));
|
|
696
696
|
const n = m ? Number(m[1]) : null;
|
|
@@ -701,6 +701,9 @@ export function roomView(g, r, max, srv = null) {
|
|
|
701
701
|
server: srv && srv.id !== 'public' ? { id: srv.id, name: srv.name, badge: policyWords(srv).badge } : null,
|
|
702
702
|
policy: srv?.policy ?? 'open',
|
|
703
703
|
ai: Number(r.ai ?? r.agents ?? 0) || 0,
|
|
704
|
+
// Room chat (0.23.0, NETPLAY.md section 19): its chat is on and homie.rocks's page for it may show it (and open the
|
|
705
|
+
// room's watch socket for its lines and emoji).
|
|
706
|
+
...(chat ? { chat: true } : {}),
|
|
704
707
|
};
|
|
705
708
|
}
|
|
706
709
|
|
|
@@ -1080,7 +1083,9 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
|
|
|
1080
1083
|
const host = origin.replace(/^https?:\/\//, '');
|
|
1081
1084
|
const playUrl = `${origin}/${g.id}/play`;
|
|
1082
1085
|
let qr = '';
|
|
1083
|
-
|
|
1086
|
+
// Local development: no code a phone cannot open; the landing says to deploy instead of naming this computer's address.
|
|
1087
|
+
const local = isLocalOrigin(origin);
|
|
1088
|
+
if (!local) try { qr = qrSvg(playUrl, { title: `Play ${g.name} on your phone` }); } catch { qr = ''; }
|
|
1084
1089
|
const tvOn = L.tv !== false;
|
|
1085
1090
|
const max = g.players?.max ?? 8;
|
|
1086
1091
|
const words = L.words ?? {};
|
|
@@ -1111,7 +1116,7 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
|
|
|
1111
1116
|
<p class="lead">Nothing to download and nothing to install: ${esc(g.name)} runs in the browser you already have, and everyone who presses Play meets in the same public room.</p>
|
|
1112
1117
|
<ol class="ways">
|
|
1113
1118
|
<li class="way"><span class="n">${icon('phone')}</span><div><h3>On your phone</h3><p>${esc(L.controls?.phone ?? 'Touch controls appear under your thumbs; the middle of the screen stays clear.')}</p>
|
|
1114
|
-
<div class="scan desk-only">${qr ? `<span class="qr" role="img" aria-label="A code for your phone’s camera: ${esc(host)}/${esc(g.id)}/play">${qr}</span>` : ''}<p
|
|
1119
|
+
<div class="scan desk-only">${qr ? `<span class="qr" role="img" aria-label="A code for your phone’s camera: ${esc(host)}/${esc(g.id)}/play">${qr}</span>` : ''}<p>${local ? 'Deploy to share: once the studio is online, a code here takes a phone straight to the game.' : `Point your phone’s camera at the code, or open <span class="addr">${esc(host)}/${esc(g.id)}/play</span>.`}</p></div>
|
|
1115
1120
|
<p class="phone-only" style="margin-top:8px"><strong>You are on it:</strong> press Play above.</p></div></li>
|
|
1116
1121
|
<li class="way"><span class="n">${icon('computer')}</span><div><h3>On your computer</h3><p>${esc(L.controls?.computer ?? 'Keys and mouse, in any modern browser; the game shows you which keys.')}</p></div></li>
|
|
1117
1122
|
${tvOn ? `<li class="way"><span class="n">${icon('tv')}</span><div><h3>On the TV</h3><ol><li>Open <span class="addr">${esc(host)}/${esc(g.id)}/tv</span> in the TV’s browser, or on a laptop plugged into it.</li><li>Scan the code it shows with your phone’s camera.</li><li>Play on your phone. Everyone on the couch joins the same way, up to ${esc(max)} ${esc(many)}, and the TV shows the whole room.</li></ol>${L.controls?.tv ? `<p style="margin-top:8px">${esc(L.controls.tv)}</p>` : ''}</div></li>` : ''}
|
package/worker/stats.mjs
CHANGED
|
@@ -59,7 +59,10 @@ CREATE TABLE IF NOT EXISTS stats_keys (
|
|
|
59
59
|
) WITHOUT ROWID;
|
|
60
60
|
`;
|
|
61
61
|
|
|
62
|
-
export const METRICS = Object.freeze(['visit', 'play', 'screen', 'watch', 'room', 'round', 'humans', 'peak', 'peak-room', 'song', 'video', 'agent-minutes', 'brain-calls', 'brain-neurons', 'brain-microdollars'
|
|
62
|
+
export const METRICS = Object.freeze(['visit', 'play', 'screen', 'watch', 'room', 'round', 'humans', 'peak', 'peak-room', 'song', 'video', 'agent-minutes', 'brain-calls', 'brain-neurons', 'brain-microdollars',
|
|
63
|
+
// Room chat (0.23.0): lines and reactions sent, messages held (by why: words, link, ai…), the review's calls and neurons.
|
|
64
|
+
// Counts only: never a message, never who.
|
|
65
|
+
'chat-lines', 'chat-reacts', 'chat-held', 'chat-reviews', 'chat-neurons']);
|
|
63
66
|
const MAX_METRICS = new Set(['peak', 'peak-room']);
|
|
64
67
|
const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
65
68
|
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.23.0';
|