@homie-rocks/studio 0.54.0 → 0.56.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 +66 -0
- package/README.md +2 -0
- package/bin/homie-studio.mjs +1 -1
- package/docs/custom-routes.md +72 -0
- package/guides/app/SKILL.md +7 -0
- package/guides/playtest/scripts/playtest.mjs +7 -5
- package/lib/background-queue.mjs +31 -0
- package/lib/build.mjs +4 -1
- package/lib/check.mjs +8 -1
- package/lib/cloudflare.mjs +65 -23
- package/lib/dev.mjs +6 -8
- package/lib/local-database-start.mjs +6 -0
- package/lib/mcp-tools.mjs +3 -3
- package/lib/port-check.mjs +3 -2
- package/lib/read-replication.mjs +26 -0
- package/lib/scaffold.mjs +30 -10
- package/lib/shop.mjs +7 -0
- package/lib/static-assets.mjs +27 -0
- package/lib/template-history.json +87 -0
- package/lib/worker-build.mjs +16 -4
- package/lib/worker-config.mjs +10 -1
- package/netplay/NETPLAY.md +5 -1
- package/netplay/netplay.ts +24 -9
- package/package.json +1 -1
- package/rules/core.ts +9 -0
- package/rules/host.ts +40 -11
- package/rules/view.ts +1 -0
- package/tools/TOOLS.md +6 -1
- package/worker/analytics.mjs +84 -0
- package/worker/app-frame.mjs +1 -1
- package/worker/app-graphics.mjs +1 -1
- package/worker/app-streams.mjs +3 -3
- package/worker/asset-paths.mjs +2 -0
- package/worker/background-queue.mjs +31 -0
- package/worker/build-id.mjs +1 -1
- package/worker/built-metadata.mjs +2 -0
- package/worker/catalogue.mjs +18 -0
- package/worker/chat-page.mjs +1 -1
- package/worker/custom-routes.mjs +16 -0
- package/worker/embed.mjs +1 -1
- package/worker/event-resources.mjs +23 -11
- package/worker/function-registry.mjs +4 -0
- package/worker/functions.mjs +7 -5
- package/worker/gate-routing.mjs +61 -0
- package/worker/gate.mjs +106 -91
- package/worker/hosted-registry.mjs +11 -0
- package/worker/hosted.mjs +3 -17
- package/worker/idle-chat.mjs +56 -0
- package/worker/index.mjs +56 -2785
- package/worker/lazy-objects.mjs +43 -0
- package/worker/lobby-limits.mjs +2 -0
- package/worker/lobby-peak.mjs +44 -0
- package/worker/lobby-shards.mjs +43 -0
- package/worker/lobby-store.mjs +35 -0
- package/worker/lobby-summary.mjs +28 -0
- package/worker/lobby.mjs +319 -0
- package/worker/lounge-store.mjs +2 -2
- package/worker/lounge.mjs +3 -3
- package/worker/match-router.mjs +74 -0
- package/worker/mcp-event-streams.mjs +3 -3
- package/worker/mcp-events.mjs +4 -4
- package/worker/mcp-room.mjs +2 -1
- package/worker/mcp-store.mjs +12 -1
- package/worker/mcp-tools.mjs +4 -2
- package/worker/mcp.mjs +5 -1
- package/worker/office.mjs +18 -21
- package/worker/operational-schema.mjs +16 -0
- package/worker/page-policy.mjs +29 -0
- package/worker/pages.mjs +9 -29
- package/worker/parts.mjs +2 -1
- package/worker/payment-recovery.mjs +33 -19
- package/worker/player-registry.mjs +9 -0
- package/worker/players.mjs +8 -7
- package/worker/purchase-machine.mjs +5 -3
- package/worker/reconcile-guard.mjs +22 -0
- package/worker/relay-channel.mjs +119 -0
- package/worker/request-db.mjs +36 -10
- package/worker/responses.mjs +18 -0
- package/worker/room-chat.mjs +217 -0
- package/worker/room-controls.mjs +296 -0
- package/worker/room-lifecycle.mjs +10 -0
- package/worker/room-protocol.mjs +197 -0
- package/worker/room-sessions.mjs +65 -0
- package/worker/room.mjs +54 -740
- package/worker/rooms-cache.mjs +19 -0
- package/worker/route-auth.mjs +22 -0
- package/worker/routes.mjs +670 -0
- package/worker/runtime-support.mjs +447 -0
- package/worker/seats.mjs +1 -1
- package/worker/selling/index.mjs +1 -0
- package/worker/selling/runtime.mjs +7 -5
- package/worker/service-run.mjs +1 -1
- package/worker/session-codec.mjs +24 -0
- package/worker/shop-links.mjs +1 -1
- package/worker/shop-page.mjs +1 -0
- package/worker/shop.mjs +27 -20
- package/worker/stats.mjs +9 -7
- package/worker/stream-objects.mjs +18 -0
- package/worker/stripe-intake.mjs +79 -0
- package/worker/stripe.mjs +3 -0
- package/worker/table.mjs +1448 -0
- package/worker/tool-registry.mjs +10 -0
- package/worker/version.mjs +1 -1
- package/worker/watch-sockets.mjs +75 -0
package/worker/room.mjs
CHANGED
|
@@ -1,247 +1,14 @@
|
|
|
1
|
+
import {RoomControls} from './room-controls.mjs';
|
|
2
|
+
import {RoomChat} from './room-chat.mjs';
|
|
3
|
+
/** Stateful netplay relay; shared wire validation lives in room-protocol.mjs. */
|
|
1
4
|
import { STUDIO_BUILD } from './build-id.mjs';
|
|
2
|
-
import {
|
|
3
|
-
/*
|
|
4
|
-
* room.mjs — the netplay v1 relay semantics, with no transport in it.
|
|
5
|
-
* =============================================================================
|
|
6
|
-
*
|
|
7
|
-
* This is the reference implementation of what NETPLAY.md asks of the shell's
|
|
8
|
-
* relay. `worker/index.mjs` runs it unchanged inside the site's Table Durable
|
|
9
|
-
* Object, under `wrangler dev` locally and on Cloudflare once deployed. Every socket is a
|
|
10
|
-
* `conn` with `send(text)`, `close(code, reason)`, an optional `buffered()` and
|
|
11
|
-
* an optional `ip`; time comes from `now()`. No Node built-ins: it runs in
|
|
12
|
-
* workerd as it is.
|
|
13
|
-
*
|
|
14
|
-
* WHAT IT HOLDS, AND WHERE:
|
|
15
|
-
* memory the latest snapshot, checkpoint, round, roster and keyed state.
|
|
16
|
-
* Nothing is written per frame (NETPLAY.md, cost note).
|
|
17
|
-
* storage (optional `store`) the seat map, written only when a seat is
|
|
18
|
-
* allocated or the host changes, plus the latest checkpoint/round/
|
|
19
|
-
* roster/state at most every 30 s. That is what lets a Worker deploy
|
|
20
|
-
* (which evicts every Durable Object) keep seats, bodies and round.
|
|
21
|
-
*
|
|
22
|
-
* WATCHERS (revision 5, NETPLAY.md section 16): a screen that came to watch
|
|
23
|
-
* (`hello.watch`, or a socket the Worker opened through a game's watch door,
|
|
24
|
-
* `conn.watch`) never takes a seat, hosts only when no player can, and is told
|
|
25
|
-
* whether it may follow one player's view (`welcome.watch`, then `watch` frames).
|
|
26
|
-
*
|
|
27
|
-
* SERVERS AND AGENT SEATS (revision 6, NETPLAY.md section 17): the Worker hands
|
|
28
|
-
* every socket the room's policy (`setPolicy`) and, for an AI with a verified
|
|
29
|
-
* pass, its facts (`conn.agent`). The relay keeps the seats a hybrid server
|
|
30
|
-
* reserves for AI, names every agent "<label> · AI", rewrites the host's roster
|
|
31
|
-
* and results so they cannot hide an AI, filters speech, runs the party's vote on
|
|
32
|
-
* the skill dial, and closes agents that are left alone with no person seated.
|
|
33
|
-
*
|
|
34
|
-
* AGENT HANDS AND BRAINS (revision 7, NETPLAY.md section 18): an AI with no game client of its own (hands `host`)
|
|
35
|
-
* sees the game through `agent:view` (the host's, to its seat, at most one every 2 s, under 2 KB) and moves through
|
|
36
|
-
* `agent:do` (a goal of the game's agents.json, at most one every 3 s). It says only `say:<lineId>` lines of that
|
|
37
|
-
* vocabulary, with arguments that fit, and only on a server whose AI may talk: the relay drops everything else an AI
|
|
38
|
-
* says (`stats.agentDrops`). The Table's own house agents (worker/agents.mjs) are peers like any other, on a loopback.
|
|
39
|
-
*
|
|
40
|
-
* ROOM CHAT (revision 8, NETPLAY.md section 19): anyone in the room (a player, a watcher, the big screen's page, a
|
|
41
|
-
* homie.rocks room page) says a line (`say`) or a reaction (`react`) on the game's socket or the shell's watch socket.
|
|
42
|
-
* The relay checks the room's chat rules (`policy.chat`: what may be sent, who may send it, slow mode), runs the floor
|
|
43
|
-
* on typed text (worker/chat.mjs), waits for the studio's review when there is one (the Table's Workers AI), and fans
|
|
44
|
-
* the line out to every socket and every watching shell. It keeps the last few minutes in memory for a page that just
|
|
45
|
-
* opened, and nothing anywhere else.
|
|
46
|
-
*
|
|
47
|
-
* THE SERVER AS HOST (revision 10, NETPLAY.md section 29): a game whose rules run on the server has no browser host.
|
|
48
|
-
* The Table hands the room a host runtime (`setServerHost`, rules/host.ts). It is not a client: it is not in
|
|
49
|
-
* `clients`, it has no socket, it is never elected away, and stall detection, yield and the caps written for a host
|
|
50
|
-
* that is somebody's phone (`LIMITS`, `RATES`) do not apply to it. The relay calls it with parsed frames (`in`, `ev`,
|
|
51
|
-
* who came and went) and fans out each frame it hands back (`hostFrame`). Everything else here is as it was.
|
|
52
|
-
* =============================================================================
|
|
53
|
-
*/
|
|
5
|
+
import { snapshotEncoder, scheduledView } from '../rules/interest.mjs';
|
|
54
6
|
import { LIMITS, capOf, RATES, EV_RATES, STATE_CAPS, rulesRates, RULES_FRAMES, takeRulesToken } from './limits.mjs';
|
|
55
|
-
export { LIMITS, GROWS, capOf } from './limits.mjs';
|
|
56
7
|
import { aiName, skillPreset, stripAi } from './agents.mjs';
|
|
57
8
|
import { DECIDE, checkArgs, checkDecide, talks, vocabularyOf } from './brain.mjs';
|
|
58
|
-
import {
|
|
59
|
-
|
|
60
|
-
export
|
|
61
|
-
/** The contract revision this relay speaks (NETPLAY.md): optional fields, frames and refusals; the wire stays `v: 1`. */
|
|
62
|
-
export const NET_REVISION = 12;
|
|
63
|
-
/**
|
|
64
|
-
* Revision 9 (NETPLAY.md sections 22 and 23). STALL: how long a host may send no snapshot, while others are present,
|
|
65
|
-
* before the room is handed on: 1.5 s unless the game names its own (game.json `netplay.stallMs`), never under
|
|
66
|
-
* 1.5 s (a shorter one swaps hosts on a single slow frame) and never over 10 s (a frozen host must still be replaced
|
|
67
|
-
* while its players are watching a still picture).
|
|
68
|
-
*/
|
|
69
|
-
export const STALL = Object.freeze({ ms: 1500, min: 1500, max: 10_000 });
|
|
70
|
-
/** A game's own stall time, held to STALL's bounds; null when it names none (or not a number). */
|
|
71
|
-
export function stallOf(v) {
|
|
72
|
-
const n = Math.floor(Number(v));
|
|
73
|
-
return Number.isFinite(n) && n > 0 ? Math.max(STALL.min, Math.min(STALL.max, n)) : null;
|
|
74
|
-
}
|
|
75
|
-
const VERSION_TEXT = /^[A-Za-z0-9._-]{1,32}$/;
|
|
76
|
-
/** A game revision (game.json `netplay.version`): 1 to 32 of A-Z a-z 0-9 . _ -, a number as its digits; else null. */
|
|
77
|
-
export function versionOf(v) {
|
|
78
|
-
const t = typeof v === 'number' && Number.isFinite(v) ? String(v) : typeof v === 'string' ? v.trim() : '';
|
|
79
|
-
return VERSION_TEXT.test(t) ? t : null;
|
|
80
|
-
}
|
|
81
|
-
const FEATURE_TEXT = /^[a-z0-9][a-z0-9-]{0,23}$/;
|
|
82
|
-
/** What a build says it can do (`hello.feat`): up to 8 short lowercase words. */
|
|
83
|
-
export function featuresOf(list) {
|
|
84
|
-
if (!Array.isArray(list)) return [];
|
|
85
|
-
const out = [];
|
|
86
|
-
for (const x of list) { const k = String(x ?? '').toLowerCase(); if (FEATURE_TEXT.test(k) && !out.includes(k)) out.push(k); if (out.length >= 8) break; }
|
|
87
|
-
return out;
|
|
88
|
-
}
|
|
89
|
-
/**
|
|
90
|
-
* A SOCKET THAT WENT AWAY IS NOT A FAILED ROOM. A browser that closes its tab, loses its network or is replaced by a
|
|
91
|
-
* new host ends its socket without a goodbye, and the runtime reports that as an error ("Network connection lost",
|
|
92
|
-
* "WebSocket peer disconnected"…). Those are departures: the room has already handled them (the seat is held, the
|
|
93
|
-
* host is replaced). Anything else thrown while the room was doing something IS a failure, and is logged with the
|
|
94
|
-
* room and what it was doing. `departure(error)` says which; the transport (the Table) asks it at its boundary.
|
|
95
|
-
*/
|
|
96
|
-
const DEPARTURE = /network connection lost|connection (?:was )?(?:closed|reset|aborted|lost)|peer disconnected|websocket (?:is )?(?:closed|closing|not open|already closed)|socket (?:is )?closed|client disconnected|disconnected|broken pipe|econnreset|epipe|aborted|cancell?ed|the script will never generate a response|durable object reset/i;
|
|
97
|
-
export function departure(error) {
|
|
98
|
-
const text = typeof error === 'string' ? error : `${error?.name ?? ''} ${error?.message ?? ''} ${error?.code ?? ''}`;
|
|
99
|
-
return DEPARTURE.test(text);
|
|
100
|
-
}
|
|
101
|
-
/** An error as a log line carries it: its name and message, cut short. Never the frame it was thrown for. */
|
|
102
|
-
export function errorLine(error) {
|
|
103
|
-
return String(error?.stack && typeof error.stack === 'string' ? error.stack.split('\n').slice(0, 4).join(' | ') : error?.message ?? error).slice(0, 400);
|
|
104
|
-
}
|
|
105
|
-
const POLICY_KINDS = ['open', 'humans-only', 'hybrid', 'beginner'];
|
|
106
|
-
/**
|
|
107
|
-
* A room's policy before the Worker says anything (and the public server's with nothing set): today's behaviour.
|
|
108
|
-
* NETPLAY.md section 17: `server` null is a named room; `aiSeats + guides` seats are kept for AI (the top seats).
|
|
109
|
-
*/
|
|
110
|
-
export const DEFAULT_POLICY = Object.freeze({ v: 1, at: 0, server: null, kind: 'open', aiSeats: 0, guides: 0, bots: 'fill', level: 3, levelMax: 5, speech: 'game', kids: false, brain: 'script' });
|
|
111
|
-
/** An agent's speech: at most one line every 4 s and 8 a minute (the relay drops the rest and counts them). */
|
|
112
|
-
export const AGENT_SPEECH = Object.freeze({ gapMs: 4000, perMinute: 8 });
|
|
113
|
-
/** The party's vote on the dial: open 15 s (or until every seated person voted); one opened by a player per 2 min. */
|
|
114
|
-
export const VOTE = Object.freeze({ ms: 15_000, cooldownMs: 120_000, autoMs: 30 * 60_000 });
|
|
115
|
-
/** Agents left with no person seated for this long are closed (`agents-alone`): an AI never keeps a room alive. */
|
|
116
|
-
export const AGENTS_ALONE_MS = 60_000;
|
|
117
|
-
const CAPS = ['skill', 'agents'];
|
|
118
|
-
/** Revision 7: what a game's host shows an AI (`agent:view`) and how often an AI may act (`agent:do`). */
|
|
119
|
-
export const AGENT_FRAMES = Object.freeze({ viewMs: 2000, viewBytes: 2048, doMs: 3000 });
|
|
120
|
-
const SAY = /^say:([a-z][a-z0-9_]{0,31})$/;
|
|
121
|
-
const d0 = (m) => (m.d && typeof m.d === 'object' && !Array.isArray(m.d) ? m.d : {});
|
|
122
|
-
const ASK = /^ask:([a-z][a-z0-9_]{0,31})$/;
|
|
123
|
-
|
|
124
|
-
/** A policy as the relay keeps it: every field checked (the Worker composed it, but a bad one must not break a room). */
|
|
125
|
-
export function normalizePolicy(p) {
|
|
126
|
-
if (!p || typeof p !== 'object') return null;
|
|
127
|
-
const int = (v, lo, hi, d) => { const n = Math.floor(Number(v)); return Number.isFinite(n) ? Math.max(lo, Math.min(hi, n)) : d; };
|
|
128
|
-
const kind = POLICY_KINDS.includes(p.kind) ? p.kind : 'open';
|
|
129
|
-
const kids = p.kids === true;
|
|
130
|
-
const levelMax = int(p.levelMax, 1, kids ? 3 : 5, kids ? 3 : 5);
|
|
131
|
-
const server = p.server && typeof p.server === 'object' && /^[a-z0-9][a-z0-9-]{0,19}$/.test(String(p.server.id ?? ''))
|
|
132
|
-
? { id: String(p.server.id), name: String(p.server.name ?? p.server.id).replace(/[\u0000-\u001f\u007f]+/g, ' ').slice(0, 40) } : null;
|
|
133
|
-
const reserveOk = kind === 'hybrid' || kind === 'beginner';
|
|
134
|
-
return {
|
|
135
|
-
v: 1, at: Math.max(0, Number(p.at) || 0), server, kind,
|
|
136
|
-
aiSeats: reserveOk ? int(p.aiSeats, 0, 31, 0) : 0, guides: kind === 'beginner' ? int(p.guides, 0, 31, 0) : 0,
|
|
137
|
-
bots: p.bots === 'off' ? 'off' : 'fill', level: Math.min(levelMax, int(p.level, 1, 5, 3)), levelMax,
|
|
138
|
-
speech: ['game', 'lines', 'off'].includes(p.speech) ? p.speech : 'game', kids,
|
|
139
|
-
brain: kind === 'humans-only' ? 'off' : ['off', 'script', 'workers-ai', 'owner-key'].includes(p.brain) ? p.brain : 'script',
|
|
140
|
-
// Revision 8: the room's chat rules (section 19), capped by the server's speech and kids.
|
|
141
|
-
...(p.chat !== undefined ? { chat: normalizeChat(p.chat === false ? { mode: 'off' } : p.chat, { kids, speech: ['game', 'lines', 'off'].includes(p.speech) ? p.speech : 'game' }) } : {}),
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
const policySig = (p) => JSON.stringify({ ...p, at: 0 });
|
|
145
|
-
/** The lower middle of the votes (DESIGN D8: a tie is settled kindly), or null with none. */
|
|
146
|
-
export function medianVote(list) {
|
|
147
|
-
const v = [...list].filter(Number.isFinite).sort((a, b) => a - b);
|
|
148
|
-
return v.length ? v[Math.floor((v.length - 1) / 2)] : null;
|
|
149
|
-
}
|
|
150
|
-
const utf8 = new TextEncoder();
|
|
151
|
-
// A message's size in bytes. Text that is all ASCII (nearly every frame a game sends) is as long as it is wide: it is
|
|
152
|
-
// not encoded to be measured, which at ten inputs a second from a thousand players was a twentieth of a room's second.
|
|
153
|
-
const ASCII = /^[\x00-\x7f]*$/;
|
|
154
|
-
const byteLength = text => ASCII.test(text) ? text.length : utf8.encode(text).length;
|
|
155
|
-
const PALETTE_SIZE = 12;
|
|
156
|
-
const DEVICE_RANK = { desk: 0, tv: 1, phone: 2 };
|
|
157
|
-
|
|
158
|
-
/** Rate caps (messages per rolling second, per client). Over the cap a message is dropped, counted and reported. */
|
|
159
|
-
export { RATES, EV_RATES, STATE_CAPS } from './limits.mjs';
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* THE OWNER'S CONTROLS (revision 4, NETPLAY.md section 15). The studio's Worker, acting for the signed-in owner,
|
|
163
|
-
* hands the room a control: kick a player out (and hold the door for some minutes), mute one, announce a line to
|
|
164
|
-
* everyone, close the room, or change its seats. The Table verifies the owner's signature before it calls
|
|
165
|
-
* `control()`; this file only applies one. What a player sees of them is new optional frames and two new refusals,
|
|
166
|
-
* so a v1 game that knows none of it keeps playing (its shell shows the notice and the banner).
|
|
167
|
-
*/
|
|
168
|
-
export const CONTROL_LIMITS = Object.freeze({ minutes: 24 * 60, announce: 280, announceSeconds: 3600, bans: 200, mutes: 200, message: 200 });
|
|
169
|
-
/** What a muted player cannot say: an `ev` whose kind starts with one of these (a game's chat, quick lines, emotes). */
|
|
170
|
-
export const SPEECH = /^(?:say|chat|emote)/i;
|
|
171
|
-
/**
|
|
172
|
-
* The contract's 12 player colours: a peer's `colour` (its seat % 12) is an index into this list. The watch page's
|
|
173
|
-
* player strip draws them; a game that colours its players the same way matches it (Gem Rush does).
|
|
174
|
-
*/
|
|
175
|
-
export const NET_PALETTE = Object.freeze(['#8fe36a', '#ffd166', '#ef6f6c', '#6cb4ee', '#c792ea', '#f4a261', '#2ec4b6', '#ff8fab', '#a7c957', '#e9c46a', '#90e0ef', '#f28482']);
|
|
176
|
-
/**
|
|
177
|
-
* What a game lets its watchers see (game.json "watch", passed by the Worker as `conn.watchPolicy`): `follow` (any
|
|
178
|
-
* player's view, the default), `overview` (the whole room only: a game with hidden hands or hidden roles), `off`.
|
|
179
|
-
*/
|
|
180
|
-
export const WATCH_POLICIES = Object.freeze(['follow', 'overview', 'off']);
|
|
181
|
-
const oneLine = (v, max) => String(v ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
182
|
-
/** A short tag for a browser's room key: enough for the owner to see "the same browser", never the key itself. */
|
|
183
|
-
export function browserTag(key) {
|
|
184
|
-
if (!key) return null;
|
|
185
|
-
let h = 2166136261;
|
|
186
|
-
for (const ch of String(key)) h = Math.imul(h ^ ch.charCodeAt(0), 16777619) >>> 0;
|
|
187
|
-
return h.toString(36).padStart(6, '0').slice(-6);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* A seat's handle when its player typed no name: two words, varied per seat and per room ("Velvet Comet"), never
|
|
192
|
-
* "Player 1" (a game that falls back to a list by seat number made every room's first player the same name).
|
|
193
|
-
* At most 16 characters, so a game that trims names at 18 keeps all of it.
|
|
194
|
-
*/
|
|
195
|
-
export const HANDLE_WORDS = Object.freeze({
|
|
196
|
-
first: ['Neon', 'Velvet', 'Static', 'Amber', 'Cobalt', 'Lucky', 'Rapid', 'Silent', 'Solar', 'Lunar', 'Crimson', 'Golden',
|
|
197
|
-
'Frosty', 'Wild', 'Quiet', 'Swift', 'Brave', 'Clever', 'Cosmic', 'Mellow', 'Rusty', 'Shiny', 'Sunny', 'Stormy', 'Turbo',
|
|
198
|
-
'Pixel', 'Retro', 'Jolly', 'Nimble', 'Bold', 'Misty', 'Copper'],
|
|
199
|
-
second: ['Comet', 'Otter', 'Falcon', 'Fox', 'Panda', 'Rocket', 'Tiger', 'Raven', 'Pilot', 'Ranger', 'Rider', 'Moth', 'Koi',
|
|
200
|
-
'Lynx', 'Heron', 'Badger', 'Yeti', 'Wolf', 'Bison', 'Gecko', 'Orca', 'Puma', 'Sparrow', 'Beacon', 'Meteor', 'Nova',
|
|
201
|
-
'Ember', 'Drift', 'Spark', 'Echo', 'Kite', 'Mantis'],
|
|
202
|
-
});
|
|
203
|
-
/** The handle for a seat token (random per seat), skipping any handle another seat in the room already has. */
|
|
204
|
-
export function handleFor(token, taken = new Set()) {
|
|
205
|
-
let h = 2166136261;
|
|
206
|
-
for (const ch of String(token ?? '')) h = Math.imul(h ^ ch.charCodeAt(0), 16777619) >>> 0;
|
|
207
|
-
const { first, second } = HANDLE_WORDS;
|
|
208
|
-
const all = first.length * second.length;
|
|
209
|
-
for (let i = 0; i < all; i += 1) {
|
|
210
|
-
const n = (h + i * 7919) % all;
|
|
211
|
-
const name = `${first[n % first.length]} ${second[Math.floor(n / first.length) % second.length]}`;
|
|
212
|
-
if (!taken.has(name)) return name;
|
|
213
|
-
}
|
|
214
|
-
return `${first[h % first.length]} ${second[h % second.length]}`;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/** Random bytes as base64url, with no Node Buffer (runs in workerd too). */
|
|
218
|
-
function randomId(n) {
|
|
219
|
-
const raw = new Uint8Array(n);
|
|
220
|
-
crypto.getRandomValues(raw);
|
|
221
|
-
let s = '';
|
|
222
|
-
for (const b of raw) s += String.fromCharCode(b);
|
|
223
|
-
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/** Press counts are summed by the host: clamp them so `{fire: 1e9}` is 8, not a billion loop turns. */
|
|
227
|
-
function clampPresses(p) {
|
|
228
|
-
if (!p || typeof p !== 'object' || Array.isArray(p)) return null;
|
|
229
|
-
const out = {};
|
|
230
|
-
let n = 0;
|
|
231
|
-
for (const [k, v] of Object.entries(p)) {
|
|
232
|
-
if (n >= 16) break;
|
|
233
|
-
const c = Math.max(0, Math.min(8, Math.floor(Number(v) || 0)));
|
|
234
|
-
if (c) { out[String(k).slice(0, 32)] = c; n += 1; }
|
|
235
|
-
}
|
|
236
|
-
return n ? out : null;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/** A kept line the Worker read from the room's history (its id, name and sender's account), for a mute or a kick. */
|
|
240
|
-
function keptRef(a) {
|
|
241
|
-
const k = a.kept && typeof a.kept === 'object' ? a.kept : null;
|
|
242
|
-
if (!k || k.id !== a.line || !k.from || typeof k.from.player !== 'string') return null;
|
|
243
|
-
return { id: k.id, name: oneLine(k.name, CHAT_LIMITS.name) || 'Someone', seat: null, from: { client: null, token: null, browser: null, player: k.from.player } };
|
|
244
|
-
}
|
|
9
|
+
import { publicChat, windowMsOf } from './chat.mjs';
|
|
10
|
+
import { NET_VERSION, NET_REVISION, versionOf, featuresOf, errorLine, DEFAULT_POLICY, AGENT_SPEECH, VOTE, AGENTS_ALONE_MS, CAPS, AGENT_FRAMES, SAY, d0, ASK, normalizePolicy, policySig, medianVote, byteLength, PALETTE_SIZE, DEVICE_RANK, SPEECH, WATCH_POLICIES, oneLine, browserTag, handleFor, randomId, clampPresses } from './room-protocol.mjs';
|
|
11
|
+
export * from './room-protocol.mjs';
|
|
245
12
|
|
|
246
13
|
export class NetRoom {
|
|
247
14
|
/**
|
|
@@ -266,6 +33,8 @@ export class NetRoom {
|
|
|
266
33
|
holdMs = 60_000, forgetMs = 60_000, persistMs = 30_000, store = null, now = Date.now, log = () => {},
|
|
267
34
|
rates = null, agentsAloneMs = AGENTS_ALONE_MS, voteMs = VOTE.ms, voteCooldownMs = VOTE.cooldownMs, autoVoteMs = VOTE.autoMs,
|
|
268
35
|
}) {
|
|
36
|
+
this.chatOps = new RoomChat(this);
|
|
37
|
+
this.controls = new RoomControls(this);
|
|
269
38
|
this.rates = rates;
|
|
270
39
|
this.agentsAloneMs = agentsAloneMs;
|
|
271
40
|
this.voteMs = voteMs;
|
|
@@ -284,6 +53,10 @@ export class NetRoom {
|
|
|
284
53
|
this.agentsOut = null;
|
|
285
54
|
/** Revision 7: the game's agents.json (the Table reads it once), the only words an AI here may say. */
|
|
286
55
|
this.vocab = null;
|
|
56
|
+
this.rawVocab = null;
|
|
57
|
+
this.officeDirty = false;
|
|
58
|
+
this.endedMatch = null;
|
|
59
|
+
this.snapAt = new Map();
|
|
287
60
|
this.code = code;
|
|
288
61
|
this.rules = rules === true;
|
|
289
62
|
this.tickHz = tickHz;
|
|
@@ -632,7 +405,7 @@ export class NetRoom {
|
|
|
632
405
|
/* ------------------------------------------------------------ sockets */
|
|
633
406
|
|
|
634
407
|
/** A new relay socket. Returns the two callbacks the transport must call. */
|
|
635
|
-
attach(conn) {
|
|
408
|
+
attach(conn, resume = null) {
|
|
636
409
|
const now = this.now();
|
|
637
410
|
const c = {
|
|
638
411
|
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,
|
|
@@ -653,6 +426,7 @@ export class NetRoom {
|
|
|
653
426
|
// address, so a helper from before revisions carries it too) when the transport gives one, else the hello's.
|
|
654
427
|
verWord: 'ver' in conn ? versionOf(conn.ver) : undefined, ver: null, feat: [], staleTold: false,
|
|
655
428
|
};
|
|
429
|
+
if(resume)Object.assign(c,resume,{conn});
|
|
656
430
|
this.clients.set(c.id, c);
|
|
657
431
|
return {
|
|
658
432
|
id: c.id,
|
|
@@ -682,18 +456,19 @@ export class NetRoom {
|
|
|
682
456
|
* The shell's watch socket: the room's facts, and (revision 6) the party's vote from the play page's card. A vote
|
|
683
457
|
* from it counts as the seat of the client in this room with the same browser key (`conn.browser`).
|
|
684
458
|
*/
|
|
685
|
-
watch(conn) {
|
|
459
|
+
watch(conn, restored = null) {
|
|
686
460
|
this.watchers.add(conn);
|
|
687
|
-
this.sendText(conn, JSON.stringify(this.facts()));
|
|
461
|
+
if (!restored) this.sendText(conn, JSON.stringify(this.facts()));
|
|
688
462
|
// A shell that comes back to a room it was kicked from (or that is closed) hears so at once.
|
|
689
463
|
const held = this.heldNotice(conn);
|
|
690
|
-
if (held) this.sendText(conn, JSON.stringify(held));
|
|
464
|
+
if (!restored && held) this.sendText(conn, JSON.stringify(held));
|
|
691
465
|
// Room chat (section 19): the last few minutes, so a page that just opened has the conversation (never to a page the
|
|
692
466
|
// studio holds out of the room, or a room it closed).
|
|
693
467
|
// A line of this browser's or this account's own says so (`mine`), on this page's copy only: it may take it down.
|
|
694
|
-
if ((!held || held.t === 'muted') && (!conn.hub || this.chatRules().hub)) this.sendText(conn, JSON.stringify({ t: 'lines', lines: this.recentChat().map((r) => (this.ownLine(r, conn) ? { ...this.wireLine(r), mine: true } : this.wireLine(r))) }));
|
|
695
|
-
const rate = [];
|
|
468
|
+
if (!restored && (!held || held.t === 'muted') && (!conn.hub || this.chatRules().hub)) this.sendText(conn, JSON.stringify({ t: 'lines', lines: this.recentChat().map((r) => (this.ownLine(r, conn) ? { ...this.wireLine(r), mine: true } : this.wireLine(r))) }));
|
|
469
|
+
const rate = restored?.rate ?? [];
|
|
696
470
|
return {
|
|
471
|
+
state: {rate},
|
|
697
472
|
onClose: () => this.watchers.delete(conn),
|
|
698
473
|
onMessage: (text) => {
|
|
699
474
|
const now = this.now();
|
|
@@ -1205,7 +980,7 @@ export class NetRoom {
|
|
|
1205
980
|
const behind = !lite && this.currentVer !== undefined && c.ver !== this.currentVer;
|
|
1206
981
|
if (behind) c.staleTold = true;
|
|
1207
982
|
this.send(c, {
|
|
1208
|
-
t: 'welcome', v: NET_VERSION, rev: NET_REVISION, id: c.id, room: this.code, seat: c.seat, token: c.token, name: c.name, colour: c.colour,
|
|
983
|
+
t: 'welcome', v: NET_VERSION, rev: NET_REVISION, ...(c.conn.idlePing ? {idlePing:true} : {}), id: c.id, room: this.code, seat: c.seat, token: c.token, name: c.name, colour: c.colour,
|
|
1209
984
|
...(this.gameVer ? { ver: this.gameVer } : {}), ...(behind ? { stale: { ver: this.currentVer } } : {}), stall: this.stallMs,
|
|
1210
985
|
role, why, host: this.hostRef(), peers: this.peers(), ...(this.rules ? { held: this.heldPeers() } : {}), st: now, max: this.maxPlayers,
|
|
1211
986
|
round: this.lastRound, roster: this.lastRoster, snap: lite ? null : this.playerSnapshot(c, this.lastSnap, true), state: lite ? {} : this.stateObject(),
|
|
@@ -1399,82 +1174,31 @@ export class NetRoom {
|
|
|
1399
1174
|
/* ------------------------------------------------------------ room chat (revision 8, section 19) */
|
|
1400
1175
|
|
|
1401
1176
|
/** The room's chat rules: the Worker's (in the policy), else the defaults for this server's speech and kids. */
|
|
1402
|
-
chatRules() {
|
|
1403
|
-
if (this.policy.chat) return this.policy.chat;
|
|
1404
|
-
const sig = `${this.policy.kids}|${this.policy.speech}`;
|
|
1405
|
-
if (!this.defaultChat || this.defaultChat.sig !== sig) this.defaultChat = { sig, rules: normalizeChat(null, { kids: this.policy.kids, speech: this.policy.speech }) };
|
|
1406
|
-
return this.defaultChat.rules;
|
|
1407
|
-
}
|
|
1177
|
+
chatRules(...args) {return this.chatOps.chatRules(...args);}
|
|
1408
1178
|
|
|
1409
1179
|
/** The window a page that just opened gets: the last `keep` messages of the last `keepMs`. */
|
|
1410
|
-
recentChat(
|
|
1411
|
-
const keep = windowMsOf(this.chatRules());
|
|
1412
|
-
return this.chatLog.filter((r) => now - r.at <= keep).slice(-CHAT_LIMITS.keep);
|
|
1413
|
-
}
|
|
1180
|
+
recentChat(...args) {return this.chatOps.recentChat(...args);}
|
|
1414
1181
|
|
|
1415
1182
|
/**
|
|
1416
1183
|
* The lines a room kept (its `history`, read back by the Table): into the window under what was said since, oldest
|
|
1417
1184
|
* first, never twice. A line taken down meanwhile is not in what the Table read.
|
|
1418
1185
|
*/
|
|
1419
|
-
hydrate(
|
|
1420
|
-
const have = new Set(this.chatLog.map((r) => r.id));
|
|
1421
|
-
const old = (Array.isArray(recs) ? recs : []).filter((r) => r && typeof r.id === 'string' && !have.has(r.id));
|
|
1422
|
-
this.chatLog = [...old, ...this.chatLog].sort((a, b) => a.at - b.at).slice(-CHAT_LIMITS.keep);
|
|
1423
|
-
this.hydrated = true;
|
|
1424
|
-
return old.length;
|
|
1425
|
-
}
|
|
1186
|
+
hydrate(...args) {return this.chatOps.hydrate(...args);}
|
|
1426
1187
|
|
|
1427
1188
|
/** A line or a reaction as every screen gets it (homie.rocks's room chat shapes: `line` and `react`). */
|
|
1428
|
-
wireLine(
|
|
1429
|
-
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 } : {}), ...(r.mod ? { mod: true } : {}), ...(r.kept ? { kept: true } : {}) };
|
|
1430
|
-
if (r.kind === 'react') return { t: 'react', ...base, react: r.react, glyph: r.glyph };
|
|
1431
|
-
// A show-what-you-made card (0.29.0): a page that knows cards draws one; any other shows its words.
|
|
1432
|
-
return { t: 'line', ...base, text: r.text, ...(r.say ? { say: r.say } : {}), ...(r.kind === 'card' && r.card ? { card: r.card } : {}) };
|
|
1433
|
-
}
|
|
1189
|
+
wireLine(...args) {return this.chatOps.wireLine(...args);}
|
|
1434
1190
|
|
|
1435
|
-
watchIdOf(
|
|
1436
|
-
let id = this.watchIds.get(conn);
|
|
1437
|
-
if (!id) { id = randomId(6); this.watchIds.set(conn, id); }
|
|
1438
|
-
return id;
|
|
1439
|
-
}
|
|
1191
|
+
watchIdOf(...args) {return this.chatOps.watchIdOf(...args);}
|
|
1440
1192
|
|
|
1441
1193
|
/**
|
|
1442
1194
|
* Who is speaking: a game socket's own client, or for a shell's watch socket the client of the same browser (or
|
|
1443
1195
|
* account) in this room: its seat, name and colour. A page with no client here (a homie.rocks room page, a watch
|
|
1444
1196
|
* page before its game connects) is a watcher with a handle. Signed in, a member, the owner: the Worker's word.
|
|
1445
1197
|
*/
|
|
1446
|
-
chatter(
|
|
1447
|
-
let g = client;
|
|
1448
|
-
if (!g && conn) {
|
|
1449
|
-
const mine = this.live().filter((o) => !o.agent && ((conn.browser && o.browser === conn.browser) || (conn.player && o.player === conn.player)));
|
|
1450
|
-
g = mine.find((o) => o.seat !== null) ?? mine.find((o) => o.watch) ?? mine[0] ?? null;
|
|
1451
|
-
}
|
|
1452
|
-
const via = String(g?.via ?? conn?.via ?? '');
|
|
1453
|
-
const owner = via.split('~').includes('o');
|
|
1454
|
-
const seat = g ? g.seat : null;
|
|
1455
|
-
const handle = handleFor(g?.token || g?.browser || conn?.browser || `w-${this.watchIdOf(conn ?? g?.conn ?? {})}`);
|
|
1456
|
-
// A watcher's name: its game's, else the account name the Worker vouched for (the Lounge), else a handle.
|
|
1457
|
-
const name = seat !== null ? g.name : oneLine(g?.typed, CHAT_LIMITS.name) || oneLine(conn?.name, CHAT_LIMITS.name) || handle;
|
|
1458
|
-
return {
|
|
1459
|
-
key: g ? `c:${g.id}` : `w:${this.watchIdOf(conn)}`, client: g, seat, name, colour: seat !== null ? g.colour : null,
|
|
1460
|
-
player: g?.player ?? conn?.player ?? null, acct: Boolean(g?.acct || conn?.acct), member: Boolean(g?.member || conn?.member) || owner,
|
|
1461
|
-
// A moderator the owner named (the Lounge): only the Worker's word, shown on their lines.
|
|
1462
|
-
mod: Boolean(conn?.mod) && !owner,
|
|
1463
|
-
owner, browser: g?.browser ?? conn?.browser ?? null, token: g?.token ?? null, ip: g?.ip ?? conn?.ip ?? null,
|
|
1464
|
-
watch: seat === null, hub: Boolean(conn?.hub) && !g, agent: Boolean(g?.agent), conn: conn ?? g?.conn ?? null,
|
|
1465
|
-
};
|
|
1466
|
-
}
|
|
1198
|
+
chatter(...args) {return this.chatOps.chatter(...args);}
|
|
1467
1199
|
|
|
1468
1200
|
/** A token bucket: true (and one token spent) when there is one. */
|
|
1469
|
-
takeToken(
|
|
1470
|
-
let b = map.get(key);
|
|
1471
|
-
if (!b) { b = { tokens: rate.burst, at: now }; map.set(key, b); }
|
|
1472
|
-
b.tokens = Math.min(rate.burst, b.tokens + (now - b.at) / rate.refillMs);
|
|
1473
|
-
b.at = now;
|
|
1474
|
-
if (b.tokens < 1) return false;
|
|
1475
|
-
b.tokens -= 1;
|
|
1476
|
-
return true;
|
|
1477
|
-
}
|
|
1201
|
+
takeToken(...args) {return this.chatOps.takeToken(...args);}
|
|
1478
1202
|
|
|
1479
1203
|
/**
|
|
1480
1204
|
* One message from anyone in the room: `{ t: 'say', text }` (typed), `{ t: 'say', say: '<line id>' }` (a quick line)
|
|
@@ -1482,172 +1206,31 @@ export class NetRoom {
|
|
|
1482
1206
|
* over my character). Refused with `held` (or homie.rocks's `slow`) to the sender only; else published to everyone,
|
|
1483
1207
|
* after the studio's review for typed text when there is one.
|
|
1484
1208
|
*/
|
|
1485
|
-
onChat({
|
|
1486
|
-
const now = this.now();
|
|
1487
|
-
const rules = this.chatRules();
|
|
1488
|
-
const who = this.chatter(client, conn);
|
|
1489
|
-
const n = typeof m.n === 'string' && /^[A-Za-z0-9_-]{1,16}$/.test(m.n) ? m.n : null;
|
|
1490
|
-
const kind = m.t === 'react' ? 'react' : typeof m.say === 'string' || typeof m.line === 'string' ? 'line' : 'text';
|
|
1491
|
-
const stats = this.stats;
|
|
1492
|
-
const reply = (why, extra = {}) => {
|
|
1493
|
-
stats.chatHeld = stats.chatHeld ?? {};
|
|
1494
|
-
stats.chatHeld[why] = (stats.chatHeld[why] ?? 0) + 1;
|
|
1495
|
-
const out = JSON.stringify({ t: why === 'slow' ? 'slow' : 'held', why, message: HELD_WORDS[why] ?? HELD_WORDS.unknown, ...(n ? { n } : {}), ...extra });
|
|
1496
|
-
if (who.conn) this.sendText(who.conn, out);
|
|
1497
|
-
return { ok: false, why };
|
|
1498
|
-
};
|
|
1499
|
-
if (who.agent) return reply('ai_seat');
|
|
1500
|
-
if (!allows(rules, kind)) return reply(rules.mode === 'off' ? 'off' : rules.mode === 'emoji' ? 'emoji' : 'lines');
|
|
1501
|
-
if (who.hub && !rules.hub) return reply('hub');
|
|
1502
|
-
if (who.watch && !rules.watchers && !who.owner) return reply('watchers');
|
|
1503
|
-
const need = kind === 'text' ? rules.who : rules.react;
|
|
1504
|
-
if (!who.owner && need === 'signed-in' && !who.acct) return reply(kind === 'text' ? 'sign-in' : 'sign-in-react');
|
|
1505
|
-
if (!who.owner && need === 'members' && !who.member) return reply(kind === 'text' ? 'members' : 'members-react');
|
|
1506
|
-
const mute = this.muteOf({ token: who.token, browser: who.browser, player: who.player });
|
|
1507
|
-
if (mute) return reply('muted', { until: mute.until });
|
|
1508
|
-
const b = this.chatBuckets.get(who.key) ?? { lastSayAt: -Infinity, lastText: '', lastTextAt: -Infinity };
|
|
1509
|
-
this.chatBuckets.set(who.key, b);
|
|
1510
|
-
if (!this.takeToken(this.chatBuckets, `${who.key}|${kind === 'react' ? 'react' : 'say'}`, kind === 'react' ? CHAT_RATES.react : CHAT_RATES.say, now)) return reply('slow');
|
|
1511
|
-
if (who.ip && !this.takeToken(this.chatAddress, who.ip, CHAT_RATES.address, now)) return reply('slow');
|
|
1512
|
-
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 });
|
|
1513
|
-
let rec;
|
|
1514
|
-
if (kind === 'react') {
|
|
1515
|
-
const k = String(m.kind ?? m.react ?? '');
|
|
1516
|
-
const r = rules.emoji.find((x) => x.k === k);
|
|
1517
|
-
if (!r) return reply('unknown');
|
|
1518
|
-
// A storm of reactions: the room fans out at most so many a second (the sender's own still floats for them).
|
|
1519
|
-
while (this.chatReacts.length && this.chatReacts[0] <= now - 1000) this.chatReacts.shift();
|
|
1520
|
-
if (this.chatReacts.length >= CHAT_RATES.roomReactsPerSecond) { stats.chatDrops = (stats.chatDrops ?? 0) + 1; return { ok: true, dropped: true }; }
|
|
1521
|
-
this.chatReacts.push(now);
|
|
1522
|
-
rec = { kind: 'react', react: r.k, glyph: r.e };
|
|
1523
|
-
} else if (kind === 'line') {
|
|
1524
|
-
const id = String(m.say ?? m.line ?? '');
|
|
1525
|
-
const l = rules.lines.find((x) => x.id === id);
|
|
1526
|
-
if (!l) return reply('unknown');
|
|
1527
|
-
rec = { kind: 'line', say: l.id, text: l.text };
|
|
1528
|
-
} else {
|
|
1529
|
-
const text = cleanText(m.text, rules.max);
|
|
1530
|
-
if (!text) return reply('empty');
|
|
1531
|
-
const f = floor(text, rules);
|
|
1532
|
-
if (!f.ok) return reply(f.why);
|
|
1533
|
-
if (b.lastText === text.toLowerCase() && now - b.lastTextAt < CHAT_LIMITS.repeatMs) return reply('repeat');
|
|
1534
|
-
b.lastText = text.toLowerCase();
|
|
1535
|
-
b.lastTextAt = now;
|
|
1536
|
-
rec = { kind: 'text', text };
|
|
1537
|
-
}
|
|
1538
|
-
if (kind !== 'react') b.lastSayAt = now;
|
|
1539
|
-
Object.assign(rec, {
|
|
1540
|
-
name: who.name, seat: who.seat, colour: who.colour, by: who.hub ? 'hub' : who.watch ? 'watcher' : 'player',
|
|
1541
|
-
bubble: m.bubble !== false && rules.bubbles && who.seat !== null, acct: who.acct, owner: who.owner, ...(who.mod ? { mod: true } : {}),
|
|
1542
|
-
// Only the office sees these: who sent it, so a mute or a kick from the line reaches them (never an address).
|
|
1543
|
-
from: { client: who.client?.id ?? null, token: who.token, browser: who.browser, player: who.player },
|
|
1544
|
-
});
|
|
1545
|
-
if (kind === 'text' && rules.ai && typeof this.review === 'function' && !who.owner) {
|
|
1546
|
-
if (this.chatPending >= CHAT_LIMITS.pending) return reply('busy');
|
|
1547
|
-
this.chatPending += 1;
|
|
1548
|
-
const t0 = now;
|
|
1549
|
-
Promise.resolve()
|
|
1550
|
-
.then(() => this.review(rec.text, { room: this.code, links: rules.links }))
|
|
1551
|
-
.then((v) => v, (error) => ({ ok: true, by: 'error', error: String(error?.message ?? error).slice(0, 120) }))
|
|
1552
|
-
.then((v) => {
|
|
1553
|
-
this.chatPending = Math.max(0, this.chatPending - 1);
|
|
1554
|
-
if (v && v.ok === false) {
|
|
1555
|
-
stats.chatAiHeld = (stats.chatAiHeld ?? 0) + 1;
|
|
1556
|
-
// What the review thought it was (insult, hate, sexual, grooming, harm, spam): counts for the office only.
|
|
1557
|
-
stats.chatAiWhy = stats.chatAiWhy ?? {};
|
|
1558
|
-
const why = String(v.why ?? 'held').slice(0, 24);
|
|
1559
|
-
stats.chatAiWhy[why] = (stats.chatAiWhy[why] ?? 0) + 1;
|
|
1560
|
-
reply('ai');
|
|
1561
|
-
return;
|
|
1562
|
-
}
|
|
1563
|
-
if (v?.by === 'error') stats.chatAiErrors = (stats.chatAiErrors ?? 0) + 1;
|
|
1564
|
-
rec.review = { by: v?.by ?? 'ai', ms: this.now() - t0, ...(Number.isFinite(v?.p) ? { p: v.p } : {}) };
|
|
1565
|
-
// A mute that came while it was being reviewed still holds it.
|
|
1566
|
-
if (this.muteOf({ token: who.token, browser: who.browser, player: who.player })) { reply('muted'); return; }
|
|
1567
|
-
this.publishChat(rec, who.conn, n);
|
|
1568
|
-
});
|
|
1569
|
-
return { ok: true, pending: true };
|
|
1570
|
-
}
|
|
1571
|
-
return this.publishChat(rec, who.conn, n);
|
|
1572
|
-
}
|
|
1209
|
+
onChat(...args) {return this.chatOps.onChat(...args);}
|
|
1573
1210
|
|
|
1574
1211
|
/** A message every socket and every watching shell gets now (the sender's own copy carries its `n`). */
|
|
1575
|
-
publishChat(
|
|
1576
|
-
const now = this.now();
|
|
1577
|
-
rec.id = randomId(6);
|
|
1578
|
-
rec.at = now;
|
|
1579
|
-
this.chatLog.push(rec);
|
|
1580
|
-
const keep = windowMsOf(this.chatRules());
|
|
1581
|
-
while (this.chatLog.length > CHAT_LIMITS.keep || (this.chatLog.length && now - this.chatLog[0].at > keep)) this.chatLog.shift();
|
|
1582
|
-
const msg = this.wireLine(rec);
|
|
1583
|
-
const text = JSON.stringify(msg);
|
|
1584
|
-
const mine = n ? JSON.stringify({ ...msg, n }) : text;
|
|
1585
|
-
const hub = this.chatRules().hub;
|
|
1586
|
-
for (const o of this.live()) if (!this.lite(o)) this.sendText(o.conn, o.conn === senderConn ? mine : text);
|
|
1587
|
-
for (const w of this.watchers) if (!w.hub || hub) this.sendText(w, w === senderConn ? mine : text);
|
|
1588
|
-
if (rec.kind === 'react') this.stats.chatReacts = (this.stats.chatReacts ?? 0) + 1;
|
|
1589
|
-
else this.stats.chatLines = (this.stats.chatLines ?? 0) + 1;
|
|
1590
|
-
// Kept chat (0.29.0): a room whose rules keep history hands what was said to the Table (never a reaction).
|
|
1591
|
-
if (rec.kind !== 'react' && this.chatRules().history > 0 && typeof this.onPublished === 'function') { try { this.onPublished(rec); } catch { /* kept next time */ } }
|
|
1592
|
-
return { ok: true, id: rec.id };
|
|
1593
|
-
}
|
|
1212
|
+
publishChat(...args) {return this.chatOps.publishChat(...args);}
|
|
1594
1213
|
|
|
1595
1214
|
/** Take messages down (the owner's `unsay`): from the window, and from every screen that shows them. */
|
|
1596
|
-
unsay(
|
|
1597
|
-
const gone = new Set(ids);
|
|
1598
|
-
const before = this.chatLog.length;
|
|
1599
|
-
this.chatLog = this.chatLog.filter((r) => !gone.has(r.id));
|
|
1600
|
-
const text = JSON.stringify({ t: 'unline', ids: [...gone] });
|
|
1601
|
-
for (const o of this.live()) if (!this.lite(o)) this.sendText(o.conn, text);
|
|
1602
|
-
for (const w of this.watchers) this.sendText(w, text);
|
|
1603
|
-
// Kept chat: what is taken down here is deleted where it was kept, older lines than the window too.
|
|
1604
|
-
if (typeof this.onUnsaid === 'function' && gone.size) { try { this.onUnsaid([...gone]); } catch { /* best effort */ } }
|
|
1605
|
-
return before - this.chatLog.length;
|
|
1606
|
-
}
|
|
1215
|
+
unsay(...args) {return this.chatOps.unsay(...args);}
|
|
1607
1216
|
|
|
1608
1217
|
/**
|
|
1609
1218
|
* A person takes their own message down (0.29.0): a watching shell of the browser or the signed-in account that sent
|
|
1610
1219
|
* it. Kept lines carry only the account, so a guest's own line comes down only while it is in this window.
|
|
1611
1220
|
*/
|
|
1612
1221
|
/** Whether a line is this socket's own: its account's, or (a guest's) its browser's. */
|
|
1613
|
-
ownLine(
|
|
1614
|
-
const f = r?.from ?? {};
|
|
1615
|
-
if (!conn || r?.kind === 'studio') return false;
|
|
1616
|
-
return Boolean((conn.player && f.player && f.player === conn.player) || (conn.browser && f.browser && f.browser === conn.browser && !f.player));
|
|
1617
|
-
}
|
|
1222
|
+
ownLine(...args) {return this.chatOps.ownLine(...args);}
|
|
1618
1223
|
|
|
1619
|
-
unsayOwn(
|
|
1620
|
-
if (typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,16}$/.test(id)) return { ok: false, error: 'no-line' };
|
|
1621
|
-
const r = this.chatLog.find((x) => x.id === id);
|
|
1622
|
-
if (!r || r.kind === 'studio') return { ok: false, error: 'no-line' };
|
|
1623
|
-
if (!this.ownLine(r, conn)) return { ok: false, error: 'not-yours' };
|
|
1624
|
-
this.unsay([id]);
|
|
1625
|
-
this.stats.chatOwnRemoved = (this.stats.chatOwnRemoved ?? 0) + 1;
|
|
1626
|
-
return { ok: true, removed: 1 };
|
|
1627
|
-
}
|
|
1224
|
+
unsayOwn(...args) {return this.chatOps.unsayOwn(...args);}
|
|
1628
1225
|
|
|
1629
1226
|
/**
|
|
1630
1227
|
* 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
|
|
1631
1228
|
* by that browser and account, as a kick holds a player's other tabs.
|
|
1632
1229
|
*/
|
|
1633
|
-
holdSender(
|
|
1634
|
-
const f = line.from ?? {};
|
|
1635
|
-
if (!f.token && !f.browser && !f.player) return { ok: false, error: 'no-player', message: 'that line has nobody to hold' };
|
|
1636
|
-
const until = now + minutes * 60_000;
|
|
1637
|
-
const hold = { token: f.token ?? null, browser: f.browser ?? null, player: f.player ?? null, name: line.name, seat: line.seat, until, at: now };
|
|
1638
|
-
if (kick) this.bans = [...this.bans, { ...hold, ip: null, pass: null, message }].slice(-CONTROL_LIMITS.bans);
|
|
1639
|
-
else this.mutes = [...this.mutes, hold].slice(-CONTROL_LIMITS.mutes);
|
|
1640
|
-
this.tellBrowser(hold, kick ? { t: 'kicked', room: this.code, until, message } : { t: 'muted', room: this.code, until });
|
|
1641
|
-
const removed = purge ? this.unsay(this.linesOf(f)) : 0;
|
|
1642
|
-
this.tellWatchers();
|
|
1643
|
-
return { ok: true, op: kick ? 'kick' : 'mute', name: line.name, seat: line.seat, until, sockets: 0, ...(removed ? { removed } : {}) };
|
|
1644
|
-
}
|
|
1230
|
+
holdSender(...args) {return this.chatOps.holdSender(...args);}
|
|
1645
1231
|
|
|
1646
1232
|
/** The lines of one sender (a line's `from`), for a mute or a kick that also takes their messages down. */
|
|
1647
|
-
linesOf(
|
|
1648
|
-
if (!from) return [];
|
|
1649
|
-
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);
|
|
1650
|
-
}
|
|
1233
|
+
linesOf(...args) {return this.chatOps.linesOf(...args);}
|
|
1651
1234
|
|
|
1652
1235
|
/* ------------------------------------------------------------ watchers (section 16) */
|
|
1653
1236
|
|
|
@@ -1768,18 +1351,19 @@ export class NetRoom {
|
|
|
1768
1351
|
}
|
|
1769
1352
|
|
|
1770
1353
|
/** Call every ~250 ms (setInterval locally, an alarm or the socket traffic on a Durable Object). */
|
|
1771
|
-
tick() {
|
|
1354
|
+
tick(idle = false) {
|
|
1772
1355
|
const now = this.now();
|
|
1773
1356
|
// Sockets that never said hello, or went silent (a slept phone, a half-open link): close them, and a seat
|
|
1774
1357
|
// becomes a bot instead of a frozen body.
|
|
1775
1358
|
for (const c of [...this.clients.values()]) {
|
|
1359
|
+
c.lastSeen=Math.max(c.lastSeen,c.conn.lastPing?.()??0);
|
|
1776
1360
|
if (!c.helloed) { if (now - c.joinedAt > 5000) { this.clients.delete(c.id); try { c.conn.close(4000, 'no-hello'); } catch { /* gone */ } } continue; }
|
|
1777
1361
|
// A loopback (the Table's own house agent) cannot be half-open: it is never closed for silence.
|
|
1778
1362
|
if (now - c.lastSeen > this.idleMs && !c.conn.loopback) this.kick(c, 'silent', 4000);
|
|
1779
1363
|
}
|
|
1780
1364
|
// A host that stopped sending snapshots while others are present.
|
|
1781
1365
|
const h = this.host();
|
|
1782
|
-
if (h && this.live().length > 1) {
|
|
1366
|
+
if (!idle && h && this.live().length > 1) {
|
|
1783
1367
|
const since = Math.max(h.lastSnapAt, this.hostSince, this.lastJoinAt);
|
|
1784
1368
|
if (now - since > this.stallMs) {
|
|
1785
1369
|
const next = this.elect(h.id, 'host-stalled');
|
|
@@ -2124,325 +1708,55 @@ export class NetRoom {
|
|
|
2124
1708
|
/* ------------------------------------------------------------ the owner's controls (section 15) */
|
|
2125
1709
|
|
|
2126
1710
|
/** A live client by socket id, else the present client in a seat. */
|
|
2127
|
-
findClient({
|
|
2128
|
-
if (typeof id === 'string' && id) { const c = this.clients.get(id); if (c && c.helloed) return c; }
|
|
2129
|
-
if (Number.isInteger(seat)) return this.live().find((c) => c.seat === seat) ?? null;
|
|
2130
|
-
return null;
|
|
2131
|
-
}
|
|
1711
|
+
findClient(...args) {return this.controls.findClient(...args);}
|
|
2132
1712
|
|
|
2133
1713
|
/** The hold that keeps this client out, if any: by its seat token, its browser's room key, its account, or (only when the owner asked) its address. */
|
|
2134
|
-
banOf(
|
|
2135
|
-
const now = this.now();
|
|
2136
|
-
const pass = c.agent?.pass ?? c.agentWord?.pass ?? null;
|
|
2137
|
-
return this.bans.find((b) => b.until > now && ((b.token && b.token === token) || (b.browser && b.browser === c.browser) || (b.player && b.player === c.player) || (b.ip && b.ip === c.ip) || (b.pass && b.pass === pass))) ?? null;
|
|
2138
|
-
}
|
|
1714
|
+
banOf(...args) {return this.controls.banOf(...args);}
|
|
2139
1715
|
|
|
2140
|
-
muteOf(
|
|
2141
|
-
const now = this.now();
|
|
2142
|
-
return this.mutes.find((m) => m.until > now && ((m.token && m.token === c.token) || (m.browser && m.browser === c.browser) || (m.player && m.player === c.player))) ?? null;
|
|
2143
|
-
}
|
|
1716
|
+
muteOf(...args) {return this.controls.muteOf(...args);}
|
|
2144
1717
|
|
|
2145
|
-
liveAnnouncement() {
|
|
2146
|
-
const a = this.announcement;
|
|
2147
|
-
return a && a.until > this.now() ? a : null;
|
|
2148
|
-
}
|
|
1718
|
+
liveAnnouncement(...args) {return this.controls.liveAnnouncement(...args);}
|
|
2149
1719
|
|
|
2150
1720
|
/** What a watching shell is told on arrival: the room is closed, or its browser is held out or muted. */
|
|
2151
|
-
heldNotice(
|
|
2152
|
-
const now = this.now();
|
|
2153
|
-
if (this.closedUntil > now) return { t: 'closed', room: this.code, until: this.closedUntil, message: this.closedWhy || 'The studio closed this room.' };
|
|
2154
|
-
const probe = { token: null, browser: conn.browser ?? null, player: conn.player ?? null, ip: null };
|
|
2155
|
-
if (!probe.browser && !probe.player) return null;
|
|
2156
|
-
const ban = this.banOf(probe, null);
|
|
2157
|
-
if (ban) return { t: 'kicked', room: this.code, until: ban.until, message: ban.message };
|
|
2158
|
-
const mute = this.muteOf(probe);
|
|
2159
|
-
return mute ? { t: 'muted', room: this.code, until: mute.until } : null;
|
|
2160
|
-
}
|
|
1721
|
+
heldNotice(...args) {return this.controls.heldNotice(...args);}
|
|
2161
1722
|
|
|
2162
1723
|
/** Tell the watching shells of one browser (or account) something meant for that player alone. */
|
|
2163
|
-
tellBrowser(
|
|
2164
|
-
const text = JSON.stringify(msg);
|
|
2165
|
-
for (const w of this.watchers) if ((who.browser && w.browser === who.browser) || (who.player && w.player === who.player)) this.sendText(w, text);
|
|
2166
|
-
}
|
|
1724
|
+
tellBrowser(...args) {return this.controls.tellBrowser(...args);}
|
|
2167
1725
|
|
|
2168
1726
|
/**
|
|
2169
1727
|
* One control, already verified by the caller (the Table checks the owner's signature). Returns what happened:
|
|
2170
1728
|
* `{ ok: true, ... }`, or `{ ok: false, error }` when there is nobody to act on.
|
|
2171
1729
|
*/
|
|
2172
|
-
control(
|
|
2173
|
-
const now = this.now();
|
|
2174
|
-
const minutes = Math.max(1, Math.min(CONTROL_LIMITS.minutes, Math.floor(Number(a.minutes) || 10)));
|
|
2175
|
-
this.bans = this.bans.filter((b) => b.until > now);
|
|
2176
|
-
this.mutes = this.mutes.filter((m) => m.until > now);
|
|
2177
|
-
switch (op) {
|
|
2178
|
-
case 'kick': {
|
|
2179
|
-
// From a chat line (section 19): its sender, in this room now or not (a watcher with no seat is held too). A line
|
|
2180
|
-
// older than the window comes from the room's kept history (the Worker read it: its sender's account only).
|
|
2181
|
-
const line = typeof a.line === 'string' ? this.chatLog.find((r) => r.id === a.line) ?? keptRef(a) : null;
|
|
2182
|
-
const target = this.findClient(a) ?? (line?.from?.client ? this.clients.get(line.from.client) ?? null : null);
|
|
2183
|
-
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 });
|
|
2184
|
-
if (!target) return { ok: false, error: 'no-player', message: 'nobody is in that seat now' };
|
|
2185
|
-
const purged = a.purge === true ? this.unsay(this.linesOf({ client: target.id, token: target.token, browser: target.browser, player: target.player })) : 0;
|
|
2186
|
-
const until = now + minutes * 60_000;
|
|
2187
|
-
// The message is the studio's words; `until` says when (the shell says it in the player's own time).
|
|
2188
|
-
const message = oneLine(a.message, CONTROL_LIMITS.message) || 'The studio removed you from this room.';
|
|
2189
|
-
// An AI is held out by its pass (its seat becomes a brainless AI seat again); a person by token, browser, account.
|
|
2190
|
-
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 };
|
|
2191
|
-
this.bans = [...this.bans, ban].slice(-CONTROL_LIMITS.bans);
|
|
2192
|
-
// Every socket of that player in this room: the seat itself, and its browser's or account's other tabs.
|
|
2193
|
-
const out = this.live().filter((o) => o === target || (ban.browser && o.browser === ban.browser) || (ban.player && o.player === ban.player));
|
|
2194
|
-
for (const o of out) {
|
|
2195
|
-
const seat = o.seat;
|
|
2196
|
-
const token = o.token;
|
|
2197
|
-
this.error(o, 'kicked', message, { until });
|
|
2198
|
-
this.onClose(o, 'kicked');
|
|
2199
|
-
// The seat is free for somebody else at once, and its token resumes nothing.
|
|
2200
|
-
const s = seat === null ? null : this.seats.get(seat);
|
|
2201
|
-
if (s && s.token === token) { this.seats.delete(seat); this.seatsDirty = true; }
|
|
2202
|
-
try { o.conn.close(1008, 'kicked'); } catch { /* gone */ }
|
|
2203
|
-
}
|
|
2204
|
-
this.stats.ownerKicks = (this.stats.ownerKicks ?? 0) + 1;
|
|
2205
|
-
this.tellBrowser(ban, { t: 'kicked', room: this.code, until, message });
|
|
2206
|
-
this.persist(now);
|
|
2207
|
-
this.tellWatchers();
|
|
2208
|
-
return { ok: true, op, name: ban.name, seat: ban.seat, sockets: out.length, until, ...(purged ? { removed: purged } : {}) };
|
|
2209
|
-
}
|
|
2210
|
-
case 'mute': {
|
|
2211
|
-
const line = typeof a.line === 'string' ? this.chatLog.find((r) => r.id === a.line) ?? keptRef(a) : null;
|
|
2212
|
-
const target = this.findClient(a) ?? (line?.from?.client ? this.clients.get(line.from.client) ?? null : null);
|
|
2213
|
-
if (!target && line && a.off !== true) return this.holdSender(line, minutes, now, { kick: false, purge: a.purge === true });
|
|
2214
|
-
if (!target) return { ok: false, error: 'no-player', message: 'nobody is in that seat now' };
|
|
2215
|
-
const off = a.off === true;
|
|
2216
|
-
const purged = !off && a.purge === true ? this.unsay(this.linesOf({ client: target.id, token: target.token, browser: target.browser, player: target.player })) : 0;
|
|
2217
|
-
const same = (m) => (m.token && m.token === target.token) || (m.browser && m.browser === target.browser) || (m.player && m.player === target.player);
|
|
2218
|
-
this.mutes = this.mutes.filter((m) => !same(m));
|
|
2219
|
-
const until = off ? 0 : now + minutes * 60_000;
|
|
2220
|
-
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);
|
|
2221
|
-
const note = { t: 'mute', id: target.id, seat: target.seat, until };
|
|
2222
|
-
for (const o of this.live()) this.send(o, note);
|
|
2223
|
-
this.tellBrowser(target, { t: 'muted', room: this.code, until });
|
|
2224
|
-
this.tellWatchers();
|
|
2225
|
-
return { ok: true, op, name: line?.name ?? target.name, seat: target.seat, until, ...(purged ? { removed: purged } : {}) };
|
|
2226
|
-
}
|
|
2227
|
-
case 'card': {
|
|
2228
|
-
// Show what you made (0.29.0): a link card to a studio's game, which the Worker read from that studio's own
|
|
2229
|
-
// manifest and checked (its words passed the floor and the review there). Its sender's name and account are the
|
|
2230
|
-
// Worker's word; a mute holds a card as it holds a line.
|
|
2231
|
-
const c = a.card && typeof a.card === 'object' ? a.card : null;
|
|
2232
|
-
if (!c || typeof c.url !== 'string' || !/^https?:\/\//.test(c.url)) return { ok: false, error: 'card', message: 'no card' };
|
|
2233
|
-
const who = { token: null, browser: typeof a.browser === 'string' ? a.browser : null, player: typeof a.player === 'string' ? a.player : null, ip: null };
|
|
2234
|
-
if (a.owner !== true && this.banOf(who, null)) return { ok: false, error: 'kicked', message: 'The studio has held you out of this room for now.' };
|
|
2235
|
-
if (a.owner !== true && this.muteOf(who)) return { ok: false, error: 'muted', message: HELD_WORDS.muted };
|
|
2236
|
-
const card = { url: c.url.slice(0, 300), title: oneLine(c.title, 80), studio: oneLine(c.studio, 60), pitch: oneLine(c.pitch, 160), image: typeof c.image === 'string' && /^https:\/\//.test(c.image) ? c.image.slice(0, 400) : null, ...(typeof c.game === 'string' ? { game: c.game.slice(0, 40) } : {}) };
|
|
2237
|
-
const note = oneLine(a.note, CHAT_LIMITS.text);
|
|
2238
|
-
const rules = this.chatRules();
|
|
2239
|
-
if (rules.mode !== 'text' && a.owner !== true) return { ok: false, error: 'held', why: rules.mode === 'off' ? 'off' : 'lines', message: 'The Lounge isn\'t taking cards right now.' };
|
|
2240
|
-
// The card's words (the person's note, the game's own title and pitch) pass the floor like any typed line.
|
|
2241
|
-
const words = [note, card.title, card.pitch].filter(Boolean).join(' · ');
|
|
2242
|
-
const f = floor(words, { ...rules, links: 'block' });
|
|
2243
|
-
if (!f.ok && a.owner !== true) return { ok: false, error: 'held', why: f.why, message: HELD_WORDS[f.why] ?? HELD_WORDS.unknown };
|
|
2244
|
-
const rec = {
|
|
2245
|
-
kind: 'card', card, text: note || `Made: ${card.title}${card.studio ? ` by ${card.studio}` : ''}`,
|
|
2246
|
-
name: oneLine(a.name, CHAT_LIMITS.name) || 'Someone', seat: null, colour: null, by: 'watcher', bubble: false,
|
|
2247
|
-
acct: a.acct === true, owner: a.owner === true, ...(a.mod === true ? { mod: true } : {}),
|
|
2248
|
-
from: { client: null, token: null, browser: who.browser, player: who.player },
|
|
2249
|
-
};
|
|
2250
|
-
// And the studio's review, as a typed line gets it (the owner's own card is never reviewed).
|
|
2251
|
-
if (rules.ai && typeof this.review === 'function' && a.owner !== true) {
|
|
2252
|
-
return Promise.resolve()
|
|
2253
|
-
.then(() => this.review(words, { room: this.code, links: 'block' }))
|
|
2254
|
-
.then((v) => v, () => ({ ok: true, by: 'error' }))
|
|
2255
|
-
.then((v) => {
|
|
2256
|
-
if (v && v.ok === false) { this.stats.chatAiHeld = (this.stats.chatAiHeld ?? 0) + 1; return { ok: false, error: 'held', why: 'ai', message: HELD_WORDS.ai }; }
|
|
2257
|
-
rec.review = { by: v?.by ?? 'ai' };
|
|
2258
|
-
if (this.muteOf(who)) return { ok: false, error: 'muted', message: HELD_WORDS.muted };
|
|
2259
|
-
return { ...this.publishChat(rec), op };
|
|
2260
|
-
});
|
|
2261
|
-
}
|
|
2262
|
-
return { ...this.publishChat(rec), op };
|
|
2263
|
-
}
|
|
2264
|
-
case 'unsay': {
|
|
2265
|
-
// Room chat (section 19): the owner takes a message down (or every message: `all`), on every screen at once.
|
|
2266
|
-
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);
|
|
2267
|
-
if (!ids.length) return { ok: false, error: 'no-line', message: 'which message?' };
|
|
2268
|
-
const removed = this.unsay(ids);
|
|
2269
|
-
this.stats.chatRemoved = (this.stats.chatRemoved ?? 0) + removed;
|
|
2270
|
-
return { ok: true, op, removed };
|
|
2271
|
-
}
|
|
2272
|
-
case 'announce': {
|
|
2273
|
-
const text = oneLine(a.text, CONTROL_LIMITS.announce);
|
|
2274
|
-
const prev = this.announcement;
|
|
2275
|
-
if (!text) {
|
|
2276
|
-
this.announcement = null;
|
|
2277
|
-
if (prev) { const clear = JSON.stringify({ t: 'announce', id: prev.id, text: null }); for (const o of this.live()) this.sendText(o.conn, clear); for (const w of this.watchers) this.sendText(w, clear); }
|
|
2278
|
-
this.tellWatchers();
|
|
2279
|
-
return { ok: true, op, cleared: Boolean(prev) };
|
|
2280
|
-
}
|
|
2281
|
-
const seconds = Math.max(5, Math.min(CONTROL_LIMITS.announceSeconds, Math.floor(Number(a.seconds) || 30)));
|
|
2282
|
-
this.announcement = { id: typeof a.id === 'string' && /^[A-Za-z0-9_-]{1,24}$/.test(a.id) ? a.id : randomId(6), text, at: now, until: now + seconds * 1000, from: 'studio' };
|
|
2283
|
-
const msg = JSON.stringify({ t: 'announce', ...this.announcement });
|
|
2284
|
-
for (const o of this.live()) this.sendText(o.conn, msg);
|
|
2285
|
-
for (const w of this.watchers) this.sendText(w, msg);
|
|
2286
|
-
// The room's chat shows it too, as the studio's own line (section 19).
|
|
2287
|
-
this.publishChat({ kind: 'studio', text, name: 'Studio', seat: null, colour: null, by: 'studio', bubble: false, acct: false, owner: true, from: null });
|
|
2288
|
-
this.tellWatchers();
|
|
2289
|
-
return { ok: true, op, id: this.announcement.id, people: this.live().length, until: this.announcement.until };
|
|
2290
|
-
}
|
|
2291
|
-
case 'close': {
|
|
2292
|
-
if (a.reopen === true) { this.closedUntil = 0; this.closedWhy = ''; this.tellWatchers(); return { ok: true, op, reopened: true }; }
|
|
2293
|
-
// `seconds` (10 s to a day) is a short close: everyone comes back through the game's door (a launch state change).
|
|
2294
|
-
const seconds = Math.floor(Number(a.seconds));
|
|
2295
|
-
const until = now + (seconds >= 10 ? Math.min(seconds, CONTROL_LIMITS.minutes * 60) * 1000 : minutes * 60_000);
|
|
2296
|
-
const message = oneLine(a.message, CONTROL_LIMITS.message) || 'The studio closed this room. Thanks for playing!';
|
|
2297
|
-
this.closedUntil = until;
|
|
2298
|
-
this.closedWhy = message;
|
|
2299
|
-
const people = this.live().length;
|
|
2300
|
-
for (const c of [...this.clients.values()]) {
|
|
2301
|
-
if (c.helloed) this.error(c, 'room-closed', message, { until });
|
|
2302
|
-
this.clients.delete(c.id);
|
|
2303
|
-
try { c.conn.close(1000, 'room-closed'); } catch { /* gone */ }
|
|
2304
|
-
}
|
|
2305
|
-
// Everything the room held goes, as when an empty room forgets (section 4).
|
|
2306
|
-
this.hostId = null;
|
|
2307
|
-
this.lastSnap = null; this.lastSnapText = null; this.lastCkpt = null; this.lastRound = null; this.lastRoster = null; this.rosterSent = null;
|
|
2308
|
-
this.state.clear(); this.stateBytes = 0; this.seats.clear(); this.preferHost = null; this.openedAt = 0; this.askedMax = null;
|
|
2309
|
-
this.emptySince = now; this.seatsDirty = false; this.persistDirty = false;
|
|
2310
|
-
try { this.store?.clear?.(); } catch { /* best effort */ }
|
|
2311
|
-
this.hosted.clear();
|
|
2312
|
-
try { this.onForget?.(); } catch { /* the room is closed either way */ }
|
|
2313
|
-
const msg = JSON.stringify({ t: 'closed', room: this.code, until, message });
|
|
2314
|
-
for (const w of this.watchers) this.sendText(w, msg);
|
|
2315
|
-
this.tellWatchers();
|
|
2316
|
-
return { ok: true, op, people, until };
|
|
2317
|
-
}
|
|
2318
|
-
case 'regate': {
|
|
2319
|
-
// A game's launch state changed (NETPLAY.md section 15): who may play is now narrower (`allow`: ticket kinds o,
|
|
2320
|
-
// i, p), or anyone again (`allow` absent: a pending change is cancelled). The current round finishes first.
|
|
2321
|
-
if (!Array.isArray(a.allow)) { const had = Boolean(this.regate); this.regate = null; this.tellWatchers(); return { ok: true, op, cancelled: had }; }
|
|
2322
|
-
const allow = a.allow.filter((k) => ['o', 'i', 'p'].includes(k));
|
|
2323
|
-
const leaving = this.live().filter((c) => !this.allowedAfter(c, allow)).length;
|
|
2324
|
-
if (!leaving) { this.regate = null; this.tellWatchers(); return { ok: true, op, leaving: 0 }; }
|
|
2325
|
-
const r = this.lastRound;
|
|
2326
|
-
const playing = Boolean(r && r.phase === 'live' && Number(r.endsAt) > now);
|
|
2327
|
-
const until = Math.min(now + 15 * 60_000, playing ? Number(r.endsAt) + 20_000 : now + 30_000);
|
|
2328
|
-
this.regate = { allow, roundN: playing ? Number(r.n) : null, until, at: now, message: oneLine(a.message, CONTROL_LIMITS.message) || 'Thanks for playing!' };
|
|
2329
|
-
const notice = oneLine(a.notice, CONTROL_LIMITS.announce);
|
|
2330
|
-
if (notice) this.control('announce', { text: notice, seconds: Math.ceil((until - now) / 1000) + 5 });
|
|
2331
|
-
this.tellWatchers();
|
|
2332
|
-
return { ok: true, op, leaving, until, afterRound: playing ? Number(r.n) : null };
|
|
2333
|
-
}
|
|
2334
|
-
case 'seats': {
|
|
2335
|
-
const n = Math.floor(Number(a.max));
|
|
2336
|
-
if (!(n >= 1)) return { ok: false, error: 'max', message: 'seats is a number of at least 1' };
|
|
2337
|
-
this.setSeats(n);
|
|
2338
|
-
this.tellWatchers();
|
|
2339
|
-
return { ok: true, op, max: this.maxPlayers };
|
|
2340
|
-
}
|
|
2341
|
-
case 'policy': {
|
|
2342
|
-
// The owner changed the server (section 17): its new policy now, and AI out after the round if it is humans-only.
|
|
2343
|
-
const r = this.setPolicy(a.pol, { force: true });
|
|
2344
|
-
return r.ok ? { ...r, op } : { ...r, op };
|
|
2345
|
-
}
|
|
2346
|
-
case 'level': {
|
|
2347
|
-
// The owner sets this room's dial (room_level): it applies at once, as a vote's result does.
|
|
2348
|
-
const n = Math.floor(Number(a.level));
|
|
2349
|
-
if (!(n >= 1 && n <= 5)) return { ok: false, error: 'level', message: 'level is 1 to 5' };
|
|
2350
|
-
this.level = Math.min(this.policy.levelMax, n);
|
|
2351
|
-
this.levelBy = 'owner';
|
|
2352
|
-
this.seatsDirty = true;
|
|
2353
|
-
this.tellPolicy();
|
|
2354
|
-
this.tellWatchers();
|
|
2355
|
-
return { ok: true, op, level: this.level, name: skillPreset(this.level, { kids: this.policy.kids }).name };
|
|
2356
|
-
}
|
|
2357
|
-
default:
|
|
2358
|
-
return { ok: false, error: 'op', message: `no control called ${String(op).slice(0, 24)}` };
|
|
2359
|
-
}
|
|
2360
|
-
}
|
|
1730
|
+
control(...args) {return this.controls.control(...args);}
|
|
2361
1731
|
|
|
2362
1732
|
/** Whether a client may stay once the room re-gates: one of its ticket's holders (o, i-…, p-…) is of an allowed kind. */
|
|
2363
|
-
allowedAfter(
|
|
2364
|
-
// The owner's own agent passes count as the owner's (only the owner can mint one).
|
|
2365
|
-
return String(c.via ?? '').split('~').some((part) => (part === 'o' && allow.includes('o')) || (part.startsWith('i-') && allow.includes('i')) || (part.startsWith('p-') && allow.includes('p'))
|
|
2366
|
-
|| (part.startsWith('a-') && allow.includes('o') && c.agent?.by === 'studio'));
|
|
2367
|
-
}
|
|
1733
|
+
allowedAfter(...args) {return this.controls.allowedAfter(...args);}
|
|
2368
1734
|
|
|
2369
1735
|
/** The launch change, now: everyone it leaves out is sent out of the room with the studio's words; the rest play on. */
|
|
2370
|
-
applyRegate(
|
|
2371
|
-
const g = this.regate;
|
|
2372
|
-
if (!g) return 0;
|
|
2373
|
-
this.regate = null;
|
|
2374
|
-
// The holder of the room's storage keeps the owner's state (the Table writes it on its next tick).
|
|
2375
|
-
this.officeDirty = true;
|
|
2376
|
-
const out = this.live().filter((c) => !this.allowedAfter(c, g.allow));
|
|
2377
|
-
for (const c of out) {
|
|
2378
|
-
const seat = c.seat;
|
|
2379
|
-
const token = c.token;
|
|
2380
|
-
this.error(c, 'room-closed', g.message);
|
|
2381
|
-
this.onClose(c, 'regated');
|
|
2382
|
-
const s = seat === null ? null : this.seats.get(seat);
|
|
2383
|
-
if (s && s.token === token) { this.seats.delete(seat); this.seatsDirty = true; }
|
|
2384
|
-
try { c.conn.close(1000, 'room-closed'); } catch { /* gone */ }
|
|
2385
|
-
this.tellBrowser(c, { t: 'closed', room: this.code, message: g.message });
|
|
2386
|
-
}
|
|
2387
|
-
this.persist(now);
|
|
2388
|
-
this.tellWatchers();
|
|
2389
|
-
return out.length;
|
|
2390
|
-
}
|
|
1736
|
+
applyRegate(...args) {return this.controls.applyRegate(...args);}
|
|
2391
1737
|
|
|
2392
1738
|
/**
|
|
2393
1739
|
* The room's seats, changed while it runs (the owner's "max players per room"). Players already seated above the
|
|
2394
1740
|
* new number keep their seat until they leave; nobody new is seated there. Never above the room's own cap.
|
|
2395
1741
|
*/
|
|
2396
1742
|
/** The game's own stall time (game.json `netplay.stallMs`), held to STALL's bounds; anything else: the default. */
|
|
2397
|
-
setStall(
|
|
2398
|
-
this.stallMs = stallOf(ms) ?? STALL.ms;
|
|
2399
|
-
return this.stallMs;
|
|
2400
|
-
}
|
|
1743
|
+
setStall(...args) {return this.controls.setStall(...args);}
|
|
2401
1744
|
|
|
2402
1745
|
/** The build that is live now (the Worker's word, from the catalogue): a string, or null when the game names none. */
|
|
2403
|
-
setCurrent(
|
|
2404
|
-
this.currentVer = ver === undefined ? undefined : versionOf(ver);
|
|
2405
|
-
}
|
|
1746
|
+
setCurrent(...args) {return this.controls.setCurrent(...args);}
|
|
2406
1747
|
|
|
2407
1748
|
/**
|
|
2408
1749
|
* The room changes build (section 23): everything the old build's rules wrote is dropped, so the new build's first
|
|
2409
1750
|
* host starts a fresh round. Seats keep their tokens and names (a player who reloads comes back to their seat).
|
|
2410
1751
|
*/
|
|
2411
|
-
forgetWorld(
|
|
2412
|
-
this.log({ ev: 'build-changed', room: this.code, from: this.gameVer ?? null, to: next ?? null });
|
|
2413
|
-
this.lastSnap = null; this.lastSnapText = null; this.lastCkpt = null; this.lastRound = null; this.lastRoster = null; this.rosterSent = null;
|
|
2414
|
-
this.state.clear(); this.stateBytes = 0; this.preferHost = null;
|
|
2415
|
-
this.persistDirty = true;
|
|
2416
|
-
}
|
|
1752
|
+
forgetWorld(...args) {return this.controls.forgetWorld(...args);}
|
|
2417
1753
|
|
|
2418
|
-
setSeats(
|
|
2419
|
-
this.seatCap = Math.max(1, Math.floor(n));
|
|
2420
|
-
this.maxPlayers = Math.max(1, Math.min(this.seatCap, (this.server || this.rules) ? this.seatCap : this.askedMax ?? this.seatCap));
|
|
2421
|
-
if (Number.isFinite(perIp) && perIp >= 0) this.perIp = perIp;
|
|
2422
|
-
}
|
|
1754
|
+
setSeats(...args) {return this.controls.setSeats(...args);}
|
|
2423
1755
|
|
|
2424
1756
|
/** The owner's holds, the banner and a closed door: kept apart from the room's play (they outlive an empty room). */
|
|
2425
|
-
officeSaved() {
|
|
2426
|
-
const now = this.now();
|
|
2427
|
-
return {
|
|
2428
|
-
bans: this.bans.filter((b) => b.until > now), mutes: this.mutes.filter((m) => m.until > now),
|
|
2429
|
-
announcement: this.liveAnnouncement(), closedUntil: this.closedUntil > now ? this.closedUntil : 0, closedWhy: this.closedWhy,
|
|
2430
|
-
regate: this.regate, agentsOut: this.agentsOut,
|
|
2431
|
-
};
|
|
2432
|
-
}
|
|
1757
|
+
officeSaved(...args) {return this.controls.officeSaved(...args);}
|
|
2433
1758
|
|
|
2434
|
-
restoreOffice(
|
|
2435
|
-
if (!o || typeof o !== 'object') return;
|
|
2436
|
-
const now = this.now();
|
|
2437
|
-
this.bans = (Array.isArray(o.bans) ? o.bans : []).filter((b) => b && b.until > now).slice(-CONTROL_LIMITS.bans);
|
|
2438
|
-
this.mutes = (Array.isArray(o.mutes) ? o.mutes : []).filter((m) => m && m.until > now).slice(-CONTROL_LIMITS.mutes);
|
|
2439
|
-
this.announcement = o.announcement && o.announcement.until > now ? o.announcement : null;
|
|
2440
|
-
this.closedUntil = Number(o.closedUntil) > now ? Number(o.closedUntil) : 0;
|
|
2441
|
-
this.closedWhy = this.closedUntil ? String(o.closedWhy ?? '') : '';
|
|
2442
|
-
// A launch change still waiting comes back; one long past is over (whoever it was for has gone).
|
|
2443
|
-
this.regate = o.regate && Array.isArray(o.regate.allow) && Number(o.regate.until) > now - 120_000 ? o.regate : null;
|
|
2444
|
-
this.agentsOut = o.agentsOut && Number(o.agentsOut.until) > now - 120_000 ? o.agentsOut : null;
|
|
2445
|
-
}
|
|
1759
|
+
restoreOffice(...args) {return this.controls.restoreOffice(...args);}
|
|
2446
1760
|
|
|
2447
1761
|
/** The room as its owner sees it: the facts, plus who each player is to the studio (never an address). */
|
|
2448
1762
|
officeFacts() {
|