@homie-rocks/studio 0.53.0 → 0.55.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 +57 -0
- package/bin/homie-studio.mjs +1 -1
- package/guides/app/SKILL.md +2 -2
- package/guides/shop/SKILL.md +6 -0
- package/lib/app-needs.mjs +8 -1
- package/lib/background-queue.mjs +31 -0
- package/lib/build.mjs +5 -2
- package/lib/check.mjs +10 -2
- package/lib/cloudflare.mjs +16 -1
- package/lib/dev.mjs +8 -1
- package/lib/read-replication.mjs +26 -0
- package/lib/scaffold.mjs +46 -9
- package/lib/shop-links.mjs +37 -19
- package/lib/shop-mcp.mjs +7 -1
- package/lib/shop.mjs +7 -0
- package/lib/static-assets.mjs +27 -0
- package/lib/template-history.json +84 -0
- package/lib/worker-build.mjs +13 -4
- package/lib/worker-config.mjs +3 -1
- package/package.json +1 -1
- package/rules/view.ts +1 -1
- package/shop/SHOP.md +13 -9
- package/worker/analytics.mjs +84 -0
- package/worker/app-frame.mjs +1 -1
- package/worker/app-graphics.mjs +1 -1
- package/worker/app-records.mjs +4 -2
- package/worker/app-shell.mjs +9 -4
- package/worker/app-streams.mjs +27 -10
- package/worker/app-tools.mjs +1 -0
- package/worker/asset-paths.mjs +2 -0
- package/worker/background-queue.mjs +30 -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/chat-store.mjs +5 -4
- package/worker/embed.mjs +1 -1
- package/worker/event-resources.mjs +29 -12
- package/worker/function-registry.mjs +4 -0
- package/worker/functions.mjs +7 -5
- package/worker/gate-routing.mjs +61 -0
- package/worker/gate.mjs +12 -68
- package/worker/hosted-registry.mjs +11 -0
- package/worker/hosted.mjs +3 -17
- package/worker/index.mjs +58 -2772
- package/worker/lazy-objects.mjs +40 -0
- package/worker/lobby-limits.mjs +2 -0
- package/worker/lobby.mjs +251 -0
- package/worker/lounge.mjs +3 -3
- package/worker/mcp-event-streams.mjs +3 -3
- package/worker/mcp-events.mjs +4 -4
- package/worker/mcp-store.mjs +12 -1
- package/worker/mcp.mjs +5 -1
- package/worker/office.mjs +105 -34
- package/worker/operational-schema.mjs +44 -0
- package/worker/page-policy.mjs +29 -0
- package/worker/pages.mjs +15 -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/request-db.mjs +78 -0
- package/worker/responses.mjs +18 -0
- package/worker/room-chat.mjs +216 -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.mjs +38 -731
- package/worker/rooms-cache.mjs +19 -0
- package/worker/routes.mjs +667 -0
- package/worker/runtime-support.mjs +445 -0
- package/worker/seats.mjs +1 -1
- package/worker/selling/index.mjs +1 -0
- package/worker/selling/runtime.mjs +7 -5
- package/worker/servers.mjs +5 -4
- package/worker/service-run.mjs +1 -1
- package/worker/shop-links.mjs +2 -2
- package/worker/shop-page.mjs +2 -0
- package/worker/shop-rules.mjs +3 -2
- package/worker/shop-store.mjs +4 -3
- package/worker/shop.mjs +89 -27
- package/worker/stats.mjs +7 -6
- package/worker/stream-objects.mjs +18 -0
- package/worker/stripe-intake.mjs +79 -0
- package/worker/stripe.mjs +3 -0
- package/worker/table.mjs +1212 -0
- package/worker/tool-registry.mjs +10 -0
- package/worker/version.mjs +1 -1
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;
|
|
@@ -1399,82 +1168,31 @@ export class NetRoom {
|
|
|
1399
1168
|
/* ------------------------------------------------------------ room chat (revision 8, section 19) */
|
|
1400
1169
|
|
|
1401
1170
|
/** 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
|
-
}
|
|
1171
|
+
chatRules(...args) {return this.chatOps.chatRules(...args);}
|
|
1408
1172
|
|
|
1409
1173
|
/** 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
|
-
}
|
|
1174
|
+
recentChat(...args) {return this.chatOps.recentChat(...args);}
|
|
1414
1175
|
|
|
1415
1176
|
/**
|
|
1416
1177
|
* The lines a room kept (its `history`, read back by the Table): into the window under what was said since, oldest
|
|
1417
1178
|
* first, never twice. A line taken down meanwhile is not in what the Table read.
|
|
1418
1179
|
*/
|
|
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
|
-
}
|
|
1180
|
+
hydrate(...args) {return this.chatOps.hydrate(...args);}
|
|
1426
1181
|
|
|
1427
1182
|
/** 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
|
-
}
|
|
1183
|
+
wireLine(...args) {return this.chatOps.wireLine(...args);}
|
|
1434
1184
|
|
|
1435
|
-
watchIdOf(
|
|
1436
|
-
let id = this.watchIds.get(conn);
|
|
1437
|
-
if (!id) { id = randomId(6); this.watchIds.set(conn, id); }
|
|
1438
|
-
return id;
|
|
1439
|
-
}
|
|
1185
|
+
watchIdOf(...args) {return this.chatOps.watchIdOf(...args);}
|
|
1440
1186
|
|
|
1441
1187
|
/**
|
|
1442
1188
|
* Who is speaking: a game socket's own client, or for a shell's watch socket the client of the same browser (or
|
|
1443
1189
|
* account) in this room: its seat, name and colour. A page with no client here (a homie.rocks room page, a watch
|
|
1444
1190
|
* page before its game connects) is a watcher with a handle. Signed in, a member, the owner: the Worker's word.
|
|
1445
1191
|
*/
|
|
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
|
-
}
|
|
1192
|
+
chatter(...args) {return this.chatOps.chatter(...args);}
|
|
1467
1193
|
|
|
1468
1194
|
/** 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
|
-
}
|
|
1195
|
+
takeToken(...args) {return this.chatOps.takeToken(...args);}
|
|
1478
1196
|
|
|
1479
1197
|
/**
|
|
1480
1198
|
* One message from anyone in the room: `{ t: 'say', text }` (typed), `{ t: 'say', say: '<line id>' }` (a quick line)
|
|
@@ -1482,172 +1200,31 @@ export class NetRoom {
|
|
|
1482
1200
|
* over my character). Refused with `held` (or homie.rocks's `slow`) to the sender only; else published to everyone,
|
|
1483
1201
|
* after the studio's review for typed text when there is one.
|
|
1484
1202
|
*/
|
|
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
|
-
}
|
|
1203
|
+
onChat(...args) {return this.chatOps.onChat(...args);}
|
|
1573
1204
|
|
|
1574
1205
|
/** 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
|
-
}
|
|
1206
|
+
publishChat(...args) {return this.chatOps.publishChat(...args);}
|
|
1594
1207
|
|
|
1595
1208
|
/** 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
|
-
}
|
|
1209
|
+
unsay(...args) {return this.chatOps.unsay(...args);}
|
|
1607
1210
|
|
|
1608
1211
|
/**
|
|
1609
1212
|
* A person takes their own message down (0.29.0): a watching shell of the browser or the signed-in account that sent
|
|
1610
1213
|
* it. Kept lines carry only the account, so a guest's own line comes down only while it is in this window.
|
|
1611
1214
|
*/
|
|
1612
1215
|
/** 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
|
-
}
|
|
1216
|
+
ownLine(...args) {return this.chatOps.ownLine(...args);}
|
|
1618
1217
|
|
|
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
|
-
}
|
|
1218
|
+
unsayOwn(...args) {return this.chatOps.unsayOwn(...args);}
|
|
1628
1219
|
|
|
1629
1220
|
/**
|
|
1630
1221
|
* 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
1222
|
* by that browser and account, as a kick holds a player's other tabs.
|
|
1632
1223
|
*/
|
|
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
|
-
}
|
|
1224
|
+
holdSender(...args) {return this.chatOps.holdSender(...args);}
|
|
1645
1225
|
|
|
1646
1226
|
/** 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
|
-
}
|
|
1227
|
+
linesOf(...args) {return this.chatOps.linesOf(...args);}
|
|
1651
1228
|
|
|
1652
1229
|
/* ------------------------------------------------------------ watchers (section 16) */
|
|
1653
1230
|
|
|
@@ -2124,325 +1701,55 @@ export class NetRoom {
|
|
|
2124
1701
|
/* ------------------------------------------------------------ the owner's controls (section 15) */
|
|
2125
1702
|
|
|
2126
1703
|
/** 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
|
-
}
|
|
1704
|
+
findClient(...args) {return this.controls.findClient(...args);}
|
|
2132
1705
|
|
|
2133
1706
|
/** 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
|
-
}
|
|
1707
|
+
banOf(...args) {return this.controls.banOf(...args);}
|
|
2139
1708
|
|
|
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
|
-
}
|
|
1709
|
+
muteOf(...args) {return this.controls.muteOf(...args);}
|
|
2144
1710
|
|
|
2145
|
-
liveAnnouncement() {
|
|
2146
|
-
const a = this.announcement;
|
|
2147
|
-
return a && a.until > this.now() ? a : null;
|
|
2148
|
-
}
|
|
1711
|
+
liveAnnouncement(...args) {return this.controls.liveAnnouncement(...args);}
|
|
2149
1712
|
|
|
2150
1713
|
/** 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
|
-
}
|
|
1714
|
+
heldNotice(...args) {return this.controls.heldNotice(...args);}
|
|
2161
1715
|
|
|
2162
1716
|
/** 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
|
-
}
|
|
1717
|
+
tellBrowser(...args) {return this.controls.tellBrowser(...args);}
|
|
2167
1718
|
|
|
2168
1719
|
/**
|
|
2169
1720
|
* One control, already verified by the caller (the Table checks the owner's signature). Returns what happened:
|
|
2170
1721
|
* `{ ok: true, ... }`, or `{ ok: false, error }` when there is nobody to act on.
|
|
2171
1722
|
*/
|
|
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
|
-
}
|
|
1723
|
+
control(...args) {return this.controls.control(...args);}
|
|
2361
1724
|
|
|
2362
1725
|
/** 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
|
-
}
|
|
1726
|
+
allowedAfter(...args) {return this.controls.allowedAfter(...args);}
|
|
2368
1727
|
|
|
2369
1728
|
/** 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
|
-
}
|
|
1729
|
+
applyRegate(...args) {return this.controls.applyRegate(...args);}
|
|
2391
1730
|
|
|
2392
1731
|
/**
|
|
2393
1732
|
* The room's seats, changed while it runs (the owner's "max players per room"). Players already seated above the
|
|
2394
1733
|
* new number keep their seat until they leave; nobody new is seated there. Never above the room's own cap.
|
|
2395
1734
|
*/
|
|
2396
1735
|
/** 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
|
-
}
|
|
1736
|
+
setStall(...args) {return this.controls.setStall(...args);}
|
|
2401
1737
|
|
|
2402
1738
|
/** 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
|
-
}
|
|
1739
|
+
setCurrent(...args) {return this.controls.setCurrent(...args);}
|
|
2406
1740
|
|
|
2407
1741
|
/**
|
|
2408
1742
|
* The room changes build (section 23): everything the old build's rules wrote is dropped, so the new build's first
|
|
2409
1743
|
* host starts a fresh round. Seats keep their tokens and names (a player who reloads comes back to their seat).
|
|
2410
1744
|
*/
|
|
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
|
-
}
|
|
1745
|
+
forgetWorld(...args) {return this.controls.forgetWorld(...args);}
|
|
2417
1746
|
|
|
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
|
-
}
|
|
1747
|
+
setSeats(...args) {return this.controls.setSeats(...args);}
|
|
2423
1748
|
|
|
2424
1749
|
/** 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
|
-
}
|
|
1750
|
+
officeSaved(...args) {return this.controls.officeSaved(...args);}
|
|
2433
1751
|
|
|
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
|
-
}
|
|
1752
|
+
restoreOffice(...args) {return this.controls.restoreOffice(...args);}
|
|
2446
1753
|
|
|
2447
1754
|
/** The room as its owner sees it: the facts, plus who each player is to the studio (never an address). */
|
|
2448
1755
|
officeFacts() {
|