@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/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,12 +310,17 @@ 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.
|
|
291
319
|
watch: conn.watch === true, policy: WATCH_POLICIES.includes(conn.watchPolicy) ? conn.watchPolicy : 'follow', follow: null, followWhy: null,
|
|
292
320
|
// An agent (section 17): only the Worker's word, from a verified pass, makes one; its hello cannot.
|
|
293
321
|
agentWord: conn.agent && typeof conn.agent === 'object' ? conn.agent : null, agent: null, caps: new Set(), speechAt: [],
|
|
322
|
+
// A badge the studio's Worker verified (the shop: a supporter's); a hello cannot set one.
|
|
323
|
+
badge: typeof conn.badge === 'string' && conn.badge.length <= 16 ? conn.badge : null,
|
|
294
324
|
// Revision 7: the latest view the host showed this AI (what its arguments are checked against), and its pace.
|
|
295
325
|
lastView: null, viewAt: 0, doAt: 0,
|
|
296
326
|
};
|
|
@@ -312,16 +342,21 @@ export class NetRoom {
|
|
|
312
342
|
// A shell that comes back to a room it was kicked from (or that is closed) hears so at once.
|
|
313
343
|
const held = this.heldNotice(conn);
|
|
314
344
|
if (held) this.sendText(conn, JSON.stringify(held));
|
|
345
|
+
// Room chat (section 19): the last few minutes, so a page that just opened has the conversation (never to a page the
|
|
346
|
+
// studio holds out of the room, or a room it closed).
|
|
347
|
+
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
348
|
const rate = [];
|
|
316
349
|
return {
|
|
317
350
|
onClose: () => this.watchers.delete(conn),
|
|
318
351
|
onMessage: (text) => {
|
|
319
352
|
const now = this.now();
|
|
320
353
|
while (rate.length && rate[0] <= now - 1000) rate.shift();
|
|
321
|
-
if (rate.length >=
|
|
354
|
+
if (rate.length >= 10 || String(text).length > LIMITS.say) return;
|
|
322
355
|
rate.push(now);
|
|
323
356
|
let m = null;
|
|
324
357
|
try { m = JSON.parse(String(text)); } catch { return; }
|
|
358
|
+
// Room chat from the shell (the play page, the big screen's page, the watch page, a homie.rocks room page).
|
|
359
|
+
if (m && (m.t === 'say' || m.t === 'react')) { this.onChat({ conn }, m); return; }
|
|
325
360
|
if (!m || m.t !== 'vote' || !conn.browser) return;
|
|
326
361
|
const c = this.live().find((o) => o.browser === conn.browser && o.seat !== null && !o.agent && !o.watch);
|
|
327
362
|
if (c) this.onVote(c, m, conn);
|
|
@@ -342,7 +377,7 @@ export class NetRoom {
|
|
|
342
377
|
host() { return this.hostId ? this.clients.get(this.hostId) ?? null : null; }
|
|
343
378
|
hostRef() { const h = this.host(); return h ? { id: h.id, seat: h.seat } : null; }
|
|
344
379
|
roleOf(c) { return c.id === this.hostId ? 'host' : c.seat === null ? 'screen' : 'replica'; }
|
|
345
|
-
peer(c) { return { id: c.id, seat: c.seat, name: c.name, colour: c.colour, device: c.device, want: c.want, role: this.roleOf(c), ...(c.watch ? { watch: true } : {}), ...(c.agent ? { agent: { ...c.agent } } : {}), ...(this.muteOf(c) ? { muted: true } : {}) }; }
|
|
380
|
+
peer(c) { return { id: c.id, seat: c.seat, name: c.name, colour: c.colour, device: c.device, want: c.want, role: this.roleOf(c), ...(c.watch ? { watch: true } : {}), ...(c.agent ? { agent: { ...c.agent } } : {}), ...(this.muteOf(c) ? { muted: true } : {}), ...(c.badge && !c.agent ? { badge: c.badge } : {}) }; }
|
|
346
381
|
/** A hands-`host` agent (section 17): no game client of its own, so no snapshots, checkpoints or state. */
|
|
347
382
|
lite(c) { return Boolean(c.agent && c.agent.hands === 'host'); }
|
|
348
383
|
seatedHumans() { return this.live().filter((c) => c.seat !== null && !c.agent); }
|
|
@@ -358,7 +393,7 @@ export class NetRoom {
|
|
|
358
393
|
levelNow() { return Math.max(1, Math.min(this.policy.levelMax, this.level ?? this.policy.level)); }
|
|
359
394
|
policyOut() {
|
|
360
395
|
const level = this.levelNow();
|
|
361
|
-
return { ...this.policy, skill: skillPreset(level, { kids: this.policy.kids }), ...(this.levelBy ? { by: this.levelBy } : {}) };
|
|
396
|
+
return { ...this.policy, chat: publicChat(this.chatRules()), skill: skillPreset(level, { kids: this.policy.kids }), ...(this.levelBy ? { by: this.levelBy } : {}) };
|
|
362
397
|
}
|
|
363
398
|
/** Send to every client in the room (a lite agent too: it hears join/leave, roster, round, policy and votes). */
|
|
364
399
|
broadcast(msg) {
|
|
@@ -550,6 +585,12 @@ export class NetRoom {
|
|
|
550
585
|
this.onVote(c, m);
|
|
551
586
|
return;
|
|
552
587
|
}
|
|
588
|
+
case 'say':
|
|
589
|
+
case 'react': {
|
|
590
|
+
// Room chat (section 19): the game's own chat UI (`net.say`, `net.react`), checked like the shell's.
|
|
591
|
+
this.onChat({ client: c }, m);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
553
594
|
case 'ping': {
|
|
554
595
|
const wasHidden = c.hidden;
|
|
555
596
|
c.hidden = m.hid === true;
|
|
@@ -866,6 +907,216 @@ export class NetRoom {
|
|
|
866
907
|
for (const o of this.live()) if (o !== c && this.lite(o) && o.id !== this.hostId) this.send(o, out);
|
|
867
908
|
}
|
|
868
909
|
|
|
910
|
+
/* ------------------------------------------------------------ room chat (revision 8, section 19) */
|
|
911
|
+
|
|
912
|
+
/** The room's chat rules: the Worker's (in the policy), else the defaults for this server's speech and kids. */
|
|
913
|
+
chatRules() {
|
|
914
|
+
if (this.policy.chat) return this.policy.chat;
|
|
915
|
+
const sig = `${this.policy.kids}|${this.policy.speech}`;
|
|
916
|
+
if (!this.defaultChat || this.defaultChat.sig !== sig) this.defaultChat = { sig, rules: normalizeChat(null, { kids: this.policy.kids, speech: this.policy.speech }) };
|
|
917
|
+
return this.defaultChat.rules;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/** The window a page that just opened gets: the last `keep` messages of the last `keepMs`. */
|
|
921
|
+
recentChat(now = this.now()) {
|
|
922
|
+
return this.chatLog.filter((r) => now - r.at <= CHAT_LIMITS.keepMs).slice(-CHAT_LIMITS.keep);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/** A line or a reaction as every screen gets it (homie.rocks's room chat shapes: `line` and `react`). */
|
|
926
|
+
wireLine(r) {
|
|
927
|
+
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 } : {}) };
|
|
928
|
+
if (r.kind === 'react') return { t: 'react', ...base, react: r.react, glyph: r.glyph };
|
|
929
|
+
return { t: 'line', ...base, text: r.text, ...(r.say ? { say: r.say } : {}) };
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
watchIdOf(conn) {
|
|
933
|
+
let id = this.watchIds.get(conn);
|
|
934
|
+
if (!id) { id = randomId(6); this.watchIds.set(conn, id); }
|
|
935
|
+
return id;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* Who is speaking: a game socket's own client, or for a shell's watch socket the client of the same browser (or
|
|
940
|
+
* account) in this room: its seat, name and colour. A page with no client here (a homie.rocks room page, a watch
|
|
941
|
+
* page before its game connects) is a watcher with a handle. Signed in, a member, the owner: the Worker's word.
|
|
942
|
+
*/
|
|
943
|
+
chatter(client, conn) {
|
|
944
|
+
let g = client;
|
|
945
|
+
if (!g && conn) {
|
|
946
|
+
const mine = this.live().filter((o) => !o.agent && ((conn.browser && o.browser === conn.browser) || (conn.player && o.player === conn.player)));
|
|
947
|
+
g = mine.find((o) => o.seat !== null) ?? mine.find((o) => o.watch) ?? mine[0] ?? null;
|
|
948
|
+
}
|
|
949
|
+
const via = String(g?.via ?? conn?.via ?? '');
|
|
950
|
+
const owner = via.split('~').includes('o');
|
|
951
|
+
const seat = g ? g.seat : null;
|
|
952
|
+
const handle = handleFor(g?.token || g?.browser || conn?.browser || `w-${this.watchIdOf(conn ?? g?.conn ?? {})}`);
|
|
953
|
+
const name = seat !== null ? g.name : oneLine(g?.typed, CHAT_LIMITS.name) || handle;
|
|
954
|
+
return {
|
|
955
|
+
key: g ? `c:${g.id}` : `w:${this.watchIdOf(conn)}`, client: g, seat, name, colour: seat !== null ? g.colour : null,
|
|
956
|
+
player: g?.player ?? conn?.player ?? null, acct: Boolean(g?.acct || conn?.acct), member: Boolean(g?.member || conn?.member) || owner,
|
|
957
|
+
owner, browser: g?.browser ?? conn?.browser ?? null, token: g?.token ?? null, ip: g?.ip ?? conn?.ip ?? null,
|
|
958
|
+
watch: seat === null, hub: Boolean(conn?.hub) && !g, agent: Boolean(g?.agent), conn: conn ?? g?.conn ?? null,
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/** A token bucket: true (and one token spent) when there is one. */
|
|
963
|
+
takeToken(map, key, rate, now) {
|
|
964
|
+
let b = map.get(key);
|
|
965
|
+
if (!b) { b = { tokens: rate.burst, at: now }; map.set(key, b); }
|
|
966
|
+
b.tokens = Math.min(rate.burst, b.tokens + (now - b.at) / rate.refillMs);
|
|
967
|
+
b.at = now;
|
|
968
|
+
if (b.tokens < 1) return false;
|
|
969
|
+
b.tokens -= 1;
|
|
970
|
+
return true;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* One message from anyone in the room: `{ t: 'say', text }` (typed), `{ t: 'say', say: '<line id>' }` (a quick line)
|
|
975
|
+
* or `{ t: 'react', kind }` (an emoji), with an optional `n` (the sender's own id for it) and `bubble: false` (not
|
|
976
|
+
* over my character). Refused with `held` (or homie.rocks's `slow`) to the sender only; else published to everyone,
|
|
977
|
+
* after the studio's review for typed text when there is one.
|
|
978
|
+
*/
|
|
979
|
+
onChat({ client = null, conn = null }, m) {
|
|
980
|
+
const now = this.now();
|
|
981
|
+
const rules = this.chatRules();
|
|
982
|
+
const who = this.chatter(client, conn);
|
|
983
|
+
const n = typeof m.n === 'string' && /^[A-Za-z0-9_-]{1,16}$/.test(m.n) ? m.n : null;
|
|
984
|
+
const kind = m.t === 'react' ? 'react' : typeof m.say === 'string' || typeof m.line === 'string' ? 'line' : 'text';
|
|
985
|
+
const stats = this.stats;
|
|
986
|
+
const reply = (why, extra = {}) => {
|
|
987
|
+
stats.chatHeld = stats.chatHeld ?? {};
|
|
988
|
+
stats.chatHeld[why] = (stats.chatHeld[why] ?? 0) + 1;
|
|
989
|
+
const out = JSON.stringify({ t: why === 'slow' ? 'slow' : 'held', why, message: HELD_WORDS[why] ?? HELD_WORDS.unknown, ...(n ? { n } : {}), ...extra });
|
|
990
|
+
if (who.conn) this.sendText(who.conn, out);
|
|
991
|
+
return { ok: false, why };
|
|
992
|
+
};
|
|
993
|
+
if (who.agent) return reply('ai_seat');
|
|
994
|
+
if (!allows(rules, kind)) return reply(rules.mode === 'off' ? 'off' : rules.mode === 'emoji' ? 'emoji' : 'lines');
|
|
995
|
+
if (who.hub && !rules.hub) return reply('hub');
|
|
996
|
+
if (who.watch && !rules.watchers && !who.owner) return reply('watchers');
|
|
997
|
+
const need = kind === 'text' ? rules.who : rules.react;
|
|
998
|
+
if (!who.owner && need === 'signed-in' && !who.acct) return reply(kind === 'text' ? 'sign-in' : 'sign-in-react');
|
|
999
|
+
if (!who.owner && need === 'members' && !who.member) return reply(kind === 'text' ? 'members' : 'members-react');
|
|
1000
|
+
const mute = this.muteOf({ token: who.token, browser: who.browser, player: who.player });
|
|
1001
|
+
if (mute) return reply('muted', { until: mute.until });
|
|
1002
|
+
const b = this.chatBuckets.get(who.key) ?? { lastSayAt: -Infinity, lastText: '', lastTextAt: -Infinity };
|
|
1003
|
+
this.chatBuckets.set(who.key, b);
|
|
1004
|
+
if (!this.takeToken(this.chatBuckets, `${who.key}|${kind === 'react' ? 'react' : 'say'}`, kind === 'react' ? CHAT_RATES.react : CHAT_RATES.say, now)) return reply('slow');
|
|
1005
|
+
if (who.ip && !this.takeToken(this.chatAddress, who.ip, CHAT_RATES.address, now)) return reply('slow');
|
|
1006
|
+
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 });
|
|
1007
|
+
let rec;
|
|
1008
|
+
if (kind === 'react') {
|
|
1009
|
+
const k = String(m.kind ?? m.react ?? '');
|
|
1010
|
+
const r = rules.emoji.find((x) => x.k === k);
|
|
1011
|
+
if (!r) return reply('unknown');
|
|
1012
|
+
// A storm of reactions: the room fans out at most so many a second (the sender's own still floats for them).
|
|
1013
|
+
while (this.chatReacts.length && this.chatReacts[0] <= now - 1000) this.chatReacts.shift();
|
|
1014
|
+
if (this.chatReacts.length >= CHAT_RATES.roomReactsPerSecond) { stats.chatDrops = (stats.chatDrops ?? 0) + 1; return { ok: true, dropped: true }; }
|
|
1015
|
+
this.chatReacts.push(now);
|
|
1016
|
+
rec = { kind: 'react', react: r.k, glyph: r.e };
|
|
1017
|
+
} else if (kind === 'line') {
|
|
1018
|
+
const id = String(m.say ?? m.line ?? '');
|
|
1019
|
+
const l = rules.lines.find((x) => x.id === id);
|
|
1020
|
+
if (!l) return reply('unknown');
|
|
1021
|
+
rec = { kind: 'line', say: l.id, text: l.text };
|
|
1022
|
+
} else {
|
|
1023
|
+
const text = cleanText(m.text, rules.max);
|
|
1024
|
+
if (!text) return reply('empty');
|
|
1025
|
+
const f = floor(text, rules);
|
|
1026
|
+
if (!f.ok) return reply(f.why);
|
|
1027
|
+
if (b.lastText === text.toLowerCase() && now - b.lastTextAt < CHAT_LIMITS.repeatMs) return reply('repeat');
|
|
1028
|
+
b.lastText = text.toLowerCase();
|
|
1029
|
+
b.lastTextAt = now;
|
|
1030
|
+
rec = { kind: 'text', text };
|
|
1031
|
+
}
|
|
1032
|
+
if (kind !== 'react') b.lastSayAt = now;
|
|
1033
|
+
Object.assign(rec, {
|
|
1034
|
+
name: who.name, seat: who.seat, colour: who.colour, by: who.hub ? 'hub' : who.watch ? 'watcher' : 'player',
|
|
1035
|
+
bubble: m.bubble !== false && rules.bubbles && who.seat !== null, acct: who.acct, owner: who.owner,
|
|
1036
|
+
// Only the office sees these: who sent it, so a mute or a kick from the line reaches them (never an address).
|
|
1037
|
+
from: { client: who.client?.id ?? null, token: who.token, browser: who.browser, player: who.player },
|
|
1038
|
+
});
|
|
1039
|
+
if (kind === 'text' && rules.ai && typeof this.review === 'function' && !who.owner) {
|
|
1040
|
+
if (this.chatPending >= CHAT_LIMITS.pending) return reply('busy');
|
|
1041
|
+
this.chatPending += 1;
|
|
1042
|
+
const t0 = now;
|
|
1043
|
+
Promise.resolve()
|
|
1044
|
+
.then(() => this.review(rec.text, { room: this.code, links: rules.links }))
|
|
1045
|
+
.then((v) => v, (error) => ({ ok: true, by: 'error', error: String(error?.message ?? error).slice(0, 120) }))
|
|
1046
|
+
.then((v) => {
|
|
1047
|
+
this.chatPending = Math.max(0, this.chatPending - 1);
|
|
1048
|
+
if (v && v.ok === false) {
|
|
1049
|
+
stats.chatAiHeld = (stats.chatAiHeld ?? 0) + 1;
|
|
1050
|
+
// What the review thought it was (insult, hate, sexual, grooming, harm, spam): counts for the office only.
|
|
1051
|
+
stats.chatAiWhy = stats.chatAiWhy ?? {};
|
|
1052
|
+
const why = String(v.why ?? 'held').slice(0, 24);
|
|
1053
|
+
stats.chatAiWhy[why] = (stats.chatAiWhy[why] ?? 0) + 1;
|
|
1054
|
+
reply('ai');
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
if (v?.by === 'error') stats.chatAiErrors = (stats.chatAiErrors ?? 0) + 1;
|
|
1058
|
+
rec.review = { by: v?.by ?? 'ai', ms: this.now() - t0, ...(Number.isFinite(v?.p) ? { p: v.p } : {}) };
|
|
1059
|
+
// A mute that came while it was being reviewed still holds it.
|
|
1060
|
+
if (this.muteOf({ token: who.token, browser: who.browser, player: who.player })) { reply('muted'); return; }
|
|
1061
|
+
this.publishChat(rec, who.conn, n);
|
|
1062
|
+
});
|
|
1063
|
+
return { ok: true, pending: true };
|
|
1064
|
+
}
|
|
1065
|
+
return this.publishChat(rec, who.conn, n);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/** A message every socket and every watching shell gets now (the sender's own copy carries its `n`). */
|
|
1069
|
+
publishChat(rec, senderConn = null, n = null) {
|
|
1070
|
+
const now = this.now();
|
|
1071
|
+
rec.id = randomId(6);
|
|
1072
|
+
rec.at = now;
|
|
1073
|
+
this.chatLog.push(rec);
|
|
1074
|
+
while (this.chatLog.length > CHAT_LIMITS.keep || (this.chatLog.length && now - this.chatLog[0].at > CHAT_LIMITS.keepMs)) this.chatLog.shift();
|
|
1075
|
+
const msg = this.wireLine(rec);
|
|
1076
|
+
const text = JSON.stringify(msg);
|
|
1077
|
+
const mine = n ? JSON.stringify({ ...msg, n }) : text;
|
|
1078
|
+
const hub = this.chatRules().hub;
|
|
1079
|
+
for (const o of this.live()) if (!this.lite(o)) this.sendText(o.conn, o.conn === senderConn ? mine : text);
|
|
1080
|
+
for (const w of this.watchers) if (!w.hub || hub) this.sendText(w, w === senderConn ? mine : text);
|
|
1081
|
+
if (rec.kind === 'react') this.stats.chatReacts = (this.stats.chatReacts ?? 0) + 1;
|
|
1082
|
+
else this.stats.chatLines = (this.stats.chatLines ?? 0) + 1;
|
|
1083
|
+
return { ok: true, id: rec.id };
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/** Take messages down (the owner's `unsay`): from the window, and from every screen that shows them. */
|
|
1087
|
+
unsay(ids) {
|
|
1088
|
+
const gone = new Set(ids);
|
|
1089
|
+
const before = this.chatLog.length;
|
|
1090
|
+
this.chatLog = this.chatLog.filter((r) => !gone.has(r.id));
|
|
1091
|
+
const text = JSON.stringify({ t: 'unline', ids: [...gone] });
|
|
1092
|
+
for (const o of this.live()) if (!this.lite(o)) this.sendText(o.conn, text);
|
|
1093
|
+
for (const w of this.watchers) this.sendText(w, text);
|
|
1094
|
+
return before - this.chatLog.length;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* 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
|
|
1099
|
+
* by that browser and account, as a kick holds a player's other tabs.
|
|
1100
|
+
*/
|
|
1101
|
+
holdSender(line, minutes, now, { kick = false, message = '', purge = false } = {}) {
|
|
1102
|
+
const f = line.from ?? {};
|
|
1103
|
+
if (!f.token && !f.browser && !f.player) return { ok: false, error: 'no-player', message: 'that line has nobody to hold' };
|
|
1104
|
+
const until = now + minutes * 60_000;
|
|
1105
|
+
const hold = { token: f.token ?? null, browser: f.browser ?? null, player: f.player ?? null, name: line.name, seat: line.seat, until, at: now };
|
|
1106
|
+
if (kick) this.bans = [...this.bans, { ...hold, ip: null, pass: null, message }].slice(-CONTROL_LIMITS.bans);
|
|
1107
|
+
else this.mutes = [...this.mutes, hold].slice(-CONTROL_LIMITS.mutes);
|
|
1108
|
+
this.tellBrowser(hold, kick ? { t: 'kicked', room: this.code, until, message } : { t: 'muted', room: this.code, until });
|
|
1109
|
+
const removed = purge ? this.unsay(this.linesOf(f)) : 0;
|
|
1110
|
+
this.tellWatchers();
|
|
1111
|
+
return { ok: true, op: kick ? 'kick' : 'mute', name: line.name, seat: line.seat, until, sockets: 0, ...(removed ? { removed } : {}) };
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/** The lines of one sender (a line's `from`), for a mute or a kick that also takes their messages down. */
|
|
1115
|
+
linesOf(from) {
|
|
1116
|
+
if (!from) return [];
|
|
1117
|
+
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);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
869
1120
|
/* ------------------------------------------------------------ watchers (section 16) */
|
|
870
1121
|
|
|
871
1122
|
/**
|
|
@@ -1008,6 +1259,10 @@ export class NetRoom {
|
|
|
1008
1259
|
}
|
|
1009
1260
|
this.reapSeats(now);
|
|
1010
1261
|
this.seatWaiting();
|
|
1262
|
+
// The chat window is minutes long; buckets of senders who went quiet are dropped.
|
|
1263
|
+
while (this.chatLog.length && now - this.chatLog[0].at > CHAT_LIMITS.keepMs) this.chatLog.shift();
|
|
1264
|
+
if (this.chatBuckets.size > 512) this.chatBuckets.clear();
|
|
1265
|
+
if (this.chatAddress.size > 512) this.chatAddress.clear();
|
|
1011
1266
|
if (!this.live().some((c) => !c.agent) && this.emptySince && now - this.emptySince > this.forgetMs) {
|
|
1012
1267
|
for (const c of this.live()) this.kick(c, 'agents-alone', 4001);
|
|
1013
1268
|
this.lastSnap = null; this.lastSnapText = null; this.lastCkpt = null; this.lastRound = null; this.lastRoster = null;
|
|
@@ -1015,6 +1270,8 @@ export class NetRoom {
|
|
|
1015
1270
|
this.emptySince = 0; this.openedAt = 0; this.askedMax = null;
|
|
1016
1271
|
// The party's dial and vote are the room's: a new party starts from the server's level.
|
|
1017
1272
|
this.level = null; this.levelBy = null; this.vote = null; this.agentsOut = null;
|
|
1273
|
+
// Room chat (section 19): an empty room forgets what was said, as it forgets everything else.
|
|
1274
|
+
this.chatLog = []; this.chatBuckets.clear(); this.chatAddress.clear(); this.chatReacts = [];
|
|
1018
1275
|
this.seatsDirty = false; this.persistDirty = false;
|
|
1019
1276
|
try { this.store?.clear?.(); } catch { /* best effort */ }
|
|
1020
1277
|
}
|
|
@@ -1333,13 +1590,17 @@ export class NetRoom {
|
|
|
1333
1590
|
this.mutes = this.mutes.filter((m) => m.until > now);
|
|
1334
1591
|
switch (op) {
|
|
1335
1592
|
case 'kick': {
|
|
1336
|
-
|
|
1593
|
+
// From a chat line (section 19): its sender, in this room now or not (a watcher with no seat is held too).
|
|
1594
|
+
const line = typeof a.line === 'string' ? this.chatLog.find((r) => r.id === a.line) : null;
|
|
1595
|
+
const target = this.findClient(a) ?? (line?.from?.client ? this.clients.get(line.from.client) ?? null : null);
|
|
1596
|
+
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
1597
|
if (!target) return { ok: false, error: 'no-player', message: 'nobody is in that seat now' };
|
|
1598
|
+
const purged = a.purge === true ? this.unsay(this.linesOf({ client: target.id, token: target.token, browser: target.browser, player: target.player })) : 0;
|
|
1338
1599
|
const until = now + minutes * 60_000;
|
|
1339
1600
|
// The message is the studio's words; `until` says when (the shell says it in the player's own time).
|
|
1340
1601
|
const message = oneLine(a.message, CONTROL_LIMITS.message) || 'The studio removed you from this room.';
|
|
1341
1602
|
// 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 };
|
|
1603
|
+
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
1604
|
this.bans = [...this.bans, ban].slice(-CONTROL_LIMITS.bans);
|
|
1344
1605
|
// Every socket of that player in this room: the seat itself, and its browser's or account's other tabs.
|
|
1345
1606
|
const out = this.live().filter((o) => o === target || (ban.browser && o.browser === ban.browser) || (ban.player && o.player === ban.player));
|
|
@@ -1357,21 +1618,32 @@ export class NetRoom {
|
|
|
1357
1618
|
this.tellBrowser(ban, { t: 'kicked', room: this.code, until, message });
|
|
1358
1619
|
this.persist(now);
|
|
1359
1620
|
this.tellWatchers();
|
|
1360
|
-
return { ok: true, op, name: ban.name, seat: ban.seat, sockets: out.length, until };
|
|
1621
|
+
return { ok: true, op, name: ban.name, seat: ban.seat, sockets: out.length, until, ...(purged ? { removed: purged } : {}) };
|
|
1361
1622
|
}
|
|
1362
1623
|
case 'mute': {
|
|
1363
|
-
const
|
|
1624
|
+
const line = typeof a.line === 'string' ? this.chatLog.find((r) => r.id === a.line) : null;
|
|
1625
|
+
const target = this.findClient(a) ?? (line?.from?.client ? this.clients.get(line.from.client) ?? null : null);
|
|
1626
|
+
if (!target && line && a.off !== true) return this.holdSender(line, minutes, now, { kick: false, purge: a.purge === true });
|
|
1364
1627
|
if (!target) return { ok: false, error: 'no-player', message: 'nobody is in that seat now' };
|
|
1365
1628
|
const off = a.off === true;
|
|
1629
|
+
const purged = !off && a.purge === true ? this.unsay(this.linesOf({ client: target.id, token: target.token, browser: target.browser, player: target.player })) : 0;
|
|
1366
1630
|
const same = (m) => (m.token && m.token === target.token) || (m.browser && m.browser === target.browser) || (m.player && m.player === target.player);
|
|
1367
1631
|
this.mutes = this.mutes.filter((m) => !same(m));
|
|
1368
1632
|
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);
|
|
1633
|
+
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
1634
|
const note = { t: 'mute', id: target.id, seat: target.seat, until };
|
|
1371
1635
|
for (const o of this.live()) this.send(o, note);
|
|
1372
1636
|
this.tellBrowser(target, { t: 'muted', room: this.code, until });
|
|
1373
1637
|
this.tellWatchers();
|
|
1374
|
-
return { ok: true, op, name: target.name, seat: target.seat, until };
|
|
1638
|
+
return { ok: true, op, name: line?.name ?? target.name, seat: target.seat, until, ...(purged ? { removed: purged } : {}) };
|
|
1639
|
+
}
|
|
1640
|
+
case 'unsay': {
|
|
1641
|
+
// Room chat (section 19): the owner takes a message down (or every message: `all`), on every screen at once.
|
|
1642
|
+
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);
|
|
1643
|
+
if (!ids.length) return { ok: false, error: 'no-line', message: 'which message?' };
|
|
1644
|
+
const removed = this.unsay(ids);
|
|
1645
|
+
this.stats.chatRemoved = (this.stats.chatRemoved ?? 0) + removed;
|
|
1646
|
+
return { ok: true, op, removed };
|
|
1375
1647
|
}
|
|
1376
1648
|
case 'announce': {
|
|
1377
1649
|
const text = oneLine(a.text, CONTROL_LIMITS.announce);
|
|
@@ -1387,6 +1659,8 @@ export class NetRoom {
|
|
|
1387
1659
|
const msg = JSON.stringify({ t: 'announce', ...this.announcement });
|
|
1388
1660
|
for (const o of this.live()) this.sendText(o.conn, msg);
|
|
1389
1661
|
for (const w of this.watchers) this.sendText(w, msg);
|
|
1662
|
+
// The room's chat shows it too, as the studio's own line (section 19).
|
|
1663
|
+
this.publishChat({ kind: 'studio', text, name: 'Studio', seat: null, colour: null, by: 'studio', bubble: false, acct: false, owner: true, from: null });
|
|
1390
1664
|
this.tellWatchers();
|
|
1391
1665
|
return { ok: true, op, id: this.announcement.id, people: this.live().length, until: this.announcement.until };
|
|
1392
1666
|
}
|
|
@@ -1541,6 +1815,12 @@ export class NetRoom {
|
|
|
1541
1815
|
}),
|
|
1542
1816
|
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
1817
|
mutes: this.mutes.filter((m) => m.until > now).map((m) => ({ name: m.name, seat: m.seat, until: m.until })),
|
|
1818
|
+
// Room chat (section 19): the window as the owner sees it, with who sent each line (never an address).
|
|
1819
|
+
chat: {
|
|
1820
|
+
rules: this.chatRules(),
|
|
1821
|
+
pending: this.chatPending,
|
|
1822
|
+
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 } : {}) })),
|
|
1823
|
+
},
|
|
1544
1824
|
},
|
|
1545
1825
|
};
|
|
1546
1826
|
}
|
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
|
|