@homie-rocks/studio 0.54.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 +27 -0
- package/lib/background-queue.mjs +31 -0
- package/lib/build.mjs +4 -1
- package/lib/cloudflare.mjs +16 -1
- package/lib/read-replication.mjs +26 -0
- package/lib/scaffold.mjs +28 -10
- package/lib/shop.mjs +7 -0
- package/lib/static-assets.mjs +27 -0
- package/lib/template-history.json +42 -0
- package/lib/worker-build.mjs +13 -4
- package/lib/worker-config.mjs +3 -1
- package/package.json +1 -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 +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/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 +12 -68
- package/worker/hosted-registry.mjs +11 -0
- package/worker/hosted.mjs +3 -17
- package/worker/index.mjs +54 -2785
- 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 +16 -20
- package/worker/operational-schema.mjs +14 -0
- package/worker/page-policy.mjs +29 -0
- package/worker/pages.mjs +8 -28
- 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 +35 -10
- 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/service-run.mjs +1 -1
- package/worker/shop-links.mjs +1 -1
- package/worker/shop-page.mjs +1 -0
- package/worker/shop.mjs +27 -20
- 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
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/** Chat authorization, rate limits and history for a room. The relay owns its shared state. */
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
import { CHAT_LIMITS, CHAT_RATES, HELD_WORDS, allows, cleanText, floor, normalizeChat, windowMsOf } from './chat.mjs';
|
|
8
|
+
import { CONTROL_LIMITS, oneLine, handleFor, randomId } from './room-protocol.mjs';
|
|
9
|
+
export class RoomChat {
|
|
10
|
+
constructor(room) {this.room=room;}
|
|
11
|
+
chatRules() {
|
|
12
|
+
if (this.room.policy.chat) return this.room.policy.chat;
|
|
13
|
+
const sig = `${this.room.policy.kids}|${this.room.policy.speech}`;
|
|
14
|
+
if (!this.room.defaultChat || this.room.defaultChat.sig !== sig) this.room.defaultChat = { sig, rules: normalizeChat(null, { kids: this.room.policy.kids, speech: this.room.policy.speech }) };
|
|
15
|
+
return this.room.defaultChat.rules;
|
|
16
|
+
}
|
|
17
|
+
recentChat(now = this.room.now()) {
|
|
18
|
+
const keep = windowMsOf(this.room.chatRules());
|
|
19
|
+
return this.room.chatLog.filter((r) => now - r.at <= keep).slice(-CHAT_LIMITS.keep);
|
|
20
|
+
}
|
|
21
|
+
hydrate(recs) {
|
|
22
|
+
const have = new Set(this.room.chatLog.map((r) => r.id));
|
|
23
|
+
const old = (Array.isArray(recs) ? recs : []).filter((r) => r && typeof r.id === 'string' && !have.has(r.id));
|
|
24
|
+
this.room.chatLog = [...old, ...this.room.chatLog].sort((a, b) => a.at - b.at).slice(-CHAT_LIMITS.keep);
|
|
25
|
+
this.room.hydrated = true;
|
|
26
|
+
return old.length;
|
|
27
|
+
}
|
|
28
|
+
wireLine(r) {
|
|
29
|
+
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 } : {}) };
|
|
30
|
+
if (r.kind === 'react') return { t: 'react', ...base, react: r.react, glyph: r.glyph };
|
|
31
|
+
// A show-what-you-made card (0.29.0): a page that knows cards draws one; any other shows its words.
|
|
32
|
+
return { t: 'line', ...base, text: r.text, ...(r.say ? { say: r.say } : {}), ...(r.kind === 'card' && r.card ? { card: r.card } : {}) };
|
|
33
|
+
}
|
|
34
|
+
watchIdOf(conn) {
|
|
35
|
+
let id = this.room.watchIds.get(conn);
|
|
36
|
+
if (!id) { id = randomId(6); this.room.watchIds.set(conn, id); }
|
|
37
|
+
return id;
|
|
38
|
+
}
|
|
39
|
+
chatter(client, conn) {
|
|
40
|
+
let g = client;
|
|
41
|
+
if (!g && conn) {
|
|
42
|
+
const mine = this.room.live().filter((o) => !o.agent && ((conn.browser && o.browser === conn.browser) || (conn.player && o.player === conn.player)));
|
|
43
|
+
g = mine.find((o) => o.seat !== null) ?? mine.find((o) => o.watch) ?? mine[0] ?? null;
|
|
44
|
+
}
|
|
45
|
+
const via = String(g?.via ?? conn?.via ?? '');
|
|
46
|
+
const owner = via.split('~').includes('o');
|
|
47
|
+
const seat = g ? g.seat : null;
|
|
48
|
+
const handle = handleFor(g?.token || g?.browser || conn?.browser || `w-${this.room.watchIdOf(conn ?? g?.conn ?? {})}`);
|
|
49
|
+
// A watcher's name: its game's, else the account name the Worker vouched for (the Lounge), else a handle.
|
|
50
|
+
const name = seat !== null ? g.name : oneLine(g?.typed, CHAT_LIMITS.name) || oneLine(conn?.name, CHAT_LIMITS.name) || handle;
|
|
51
|
+
return {
|
|
52
|
+
key: g ? `c:${g.id}` : `w:${this.room.watchIdOf(conn)}`, client: g, seat, name, colour: seat !== null ? g.colour : null,
|
|
53
|
+
player: g?.player ?? conn?.player ?? null, acct: Boolean(g?.acct || conn?.acct), member: Boolean(g?.member || conn?.member) || owner,
|
|
54
|
+
// A moderator the owner named (the Lounge): only the Worker's word, shown on their lines.
|
|
55
|
+
mod: Boolean(conn?.mod) && !owner,
|
|
56
|
+
owner, browser: g?.browser ?? conn?.browser ?? null, token: g?.token ?? null, ip: g?.ip ?? conn?.ip ?? null,
|
|
57
|
+
watch: seat === null, hub: Boolean(conn?.hub) && !g, agent: Boolean(g?.agent), conn: conn ?? g?.conn ?? null,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
takeToken(map, key, rate, now) {
|
|
61
|
+
let b = map.get(key);
|
|
62
|
+
if (!b) { b = { tokens: rate.burst, at: now }; map.set(key, b); }
|
|
63
|
+
b.tokens = Math.min(rate.burst, b.tokens + (now - b.at) / rate.refillMs);
|
|
64
|
+
b.at = now;
|
|
65
|
+
if (b.tokens < 1) return false;
|
|
66
|
+
b.tokens -= 1;
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
onChat({ client = null, conn = null }, m) {
|
|
70
|
+
const now = this.room.now();
|
|
71
|
+
const rules = this.room.chatRules();
|
|
72
|
+
const who = this.room.chatter(client, conn);
|
|
73
|
+
const n = typeof m.n === 'string' && /^[A-Za-z0-9_-]{1,16}$/.test(m.n) ? m.n : null;
|
|
74
|
+
const kind = m.t === 'react' ? 'react' : typeof m.say === 'string' || typeof m.line === 'string' ? 'line' : 'text';
|
|
75
|
+
const stats = this.room.stats;
|
|
76
|
+
const reply = (why, extra = {}) => {
|
|
77
|
+
stats.chatHeld = stats.chatHeld ?? {};
|
|
78
|
+
stats.chatHeld[why] = (stats.chatHeld[why] ?? 0) + 1;
|
|
79
|
+
const out = JSON.stringify({ t: why === 'slow' ? 'slow' : 'held', why, message: HELD_WORDS[why] ?? HELD_WORDS.unknown, ...(n ? { n } : {}), ...extra });
|
|
80
|
+
if (who.conn) this.room.sendText(who.conn, out);
|
|
81
|
+
return { ok: false, why };
|
|
82
|
+
};
|
|
83
|
+
if (who.agent) return reply('ai_seat');
|
|
84
|
+
if (!allows(rules, kind)) return reply(rules.mode === 'off' ? 'off' : rules.mode === 'emoji' ? 'emoji' : 'lines');
|
|
85
|
+
if (who.hub && !rules.hub) return reply('hub');
|
|
86
|
+
if (who.watch && !rules.watchers && !who.owner) return reply('watchers');
|
|
87
|
+
const need = kind === 'text' ? rules.who : rules.react;
|
|
88
|
+
if (!who.owner && need === 'signed-in' && !who.acct) return reply(kind === 'text' ? 'sign-in' : 'sign-in-react');
|
|
89
|
+
if (!who.owner && need === 'members' && !who.member) return reply(kind === 'text' ? 'members' : 'members-react');
|
|
90
|
+
const mute = this.room.muteOf({ token: who.token, browser: who.browser, player: who.player });
|
|
91
|
+
if (mute) return reply('muted', { until: mute.until });
|
|
92
|
+
const b = this.room.chatBuckets.get(who.key) ?? { lastSayAt: -Infinity, lastText: '', lastTextAt: -Infinity };
|
|
93
|
+
this.room.chatBuckets.set(who.key, b);
|
|
94
|
+
if (!this.room.takeToken(this.room.chatBuckets, `${who.key}|${kind === 'react' ? 'react' : 'say'}`, kind === 'react' ? CHAT_RATES.react : CHAT_RATES.say, now)) return reply('slow');
|
|
95
|
+
if (who.ip && !this.room.takeToken(this.room.chatAddress, who.ip, CHAT_RATES.address, now)) return reply('slow');
|
|
96
|
+
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 });
|
|
97
|
+
let rec;
|
|
98
|
+
if (kind === 'react') {
|
|
99
|
+
const k = String(m.kind ?? m.react ?? '');
|
|
100
|
+
const r = rules.emoji.find((x) => x.k === k);
|
|
101
|
+
if (!r) return reply('unknown');
|
|
102
|
+
// A storm of reactions: the room fans out at most so many a second (the sender's own still floats for them).
|
|
103
|
+
while (this.room.chatReacts.length && this.room.chatReacts[0] <= now - 1000) this.room.chatReacts.shift();
|
|
104
|
+
if (this.room.chatReacts.length >= CHAT_RATES.roomReactsPerSecond) { stats.chatDrops = (stats.chatDrops ?? 0) + 1; return { ok: true, dropped: true }; }
|
|
105
|
+
this.room.chatReacts.push(now);
|
|
106
|
+
rec = { kind: 'react', react: r.k, glyph: r.e };
|
|
107
|
+
} else if (kind === 'line') {
|
|
108
|
+
const id = String(m.say ?? m.line ?? '');
|
|
109
|
+
const l = rules.lines.find((x) => x.id === id);
|
|
110
|
+
if (!l) return reply('unknown');
|
|
111
|
+
rec = { kind: 'line', say: l.id, text: l.text };
|
|
112
|
+
} else {
|
|
113
|
+
const text = cleanText(m.text, rules.max);
|
|
114
|
+
if (!text) return reply('empty');
|
|
115
|
+
const f = floor(text, rules);
|
|
116
|
+
if (!f.ok) return reply(f.why);
|
|
117
|
+
if (b.lastText === text.toLowerCase() && now - b.lastTextAt < CHAT_LIMITS.repeatMs) return reply('repeat');
|
|
118
|
+
b.lastText = text.toLowerCase();
|
|
119
|
+
b.lastTextAt = now;
|
|
120
|
+
rec = { kind: 'text', text };
|
|
121
|
+
}
|
|
122
|
+
if (kind !== 'react') b.lastSayAt = now;
|
|
123
|
+
Object.assign(rec, {
|
|
124
|
+
name: who.name, seat: who.seat, colour: who.colour, by: who.hub ? 'hub' : who.watch ? 'watcher' : 'player',
|
|
125
|
+
bubble: m.bubble !== false && rules.bubbles && who.seat !== null, acct: who.acct, owner: who.owner, ...(who.mod ? { mod: true } : {}),
|
|
126
|
+
// Only the office sees these: who sent it, so a mute or a kick from the line reaches them (never an address).
|
|
127
|
+
from: { client: who.client?.id ?? null, token: who.token, browser: who.browser, player: who.player },
|
|
128
|
+
});
|
|
129
|
+
if (kind === 'text' && rules.ai && typeof this.room.review === 'function' && !who.owner) {
|
|
130
|
+
if (this.room.chatPending >= CHAT_LIMITS.pending) return reply('busy');
|
|
131
|
+
this.room.chatPending += 1;
|
|
132
|
+
const t0 = now;
|
|
133
|
+
Promise.resolve()
|
|
134
|
+
.then(() => this.room.review(rec.text, { room: this.room.code, links: rules.links }))
|
|
135
|
+
.then((v) => v, (error) => ({ ok: true, by: 'error', error: String(error?.message ?? error).slice(0, 120) }))
|
|
136
|
+
.then((v) => {
|
|
137
|
+
this.room.chatPending = Math.max(0, this.room.chatPending - 1);
|
|
138
|
+
if (v && v.ok === false) {
|
|
139
|
+
stats.chatAiHeld = (stats.chatAiHeld ?? 0) + 1;
|
|
140
|
+
// What the review thought it was (insult, hate, sexual, grooming, harm, spam): counts for the office only.
|
|
141
|
+
stats.chatAiWhy = stats.chatAiWhy ?? {};
|
|
142
|
+
const why = String(v.why ?? 'held').slice(0, 24);
|
|
143
|
+
stats.chatAiWhy[why] = (stats.chatAiWhy[why] ?? 0) + 1;
|
|
144
|
+
reply('ai');
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (v?.by === 'error') stats.chatAiErrors = (stats.chatAiErrors ?? 0) + 1;
|
|
148
|
+
rec.review = { by: v?.by ?? 'ai', ms: this.room.now() - t0, ...(Number.isFinite(v?.p) ? { p: v.p } : {}) };
|
|
149
|
+
// A mute that came while it was being reviewed still holds it.
|
|
150
|
+
if (this.room.muteOf({ token: who.token, browser: who.browser, player: who.player })) { reply('muted'); return; }
|
|
151
|
+
this.room.publishChat(rec, who.conn, n);
|
|
152
|
+
});
|
|
153
|
+
return { ok: true, pending: true };
|
|
154
|
+
}
|
|
155
|
+
return this.room.publishChat(rec, who.conn, n);
|
|
156
|
+
}
|
|
157
|
+
publishChat(rec, senderConn = null, n = null) {
|
|
158
|
+
const now = this.room.now();
|
|
159
|
+
rec.id = randomId(6);
|
|
160
|
+
rec.at = now;
|
|
161
|
+
this.room.chatLog.push(rec);
|
|
162
|
+
const keep = windowMsOf(this.room.chatRules());
|
|
163
|
+
while (this.room.chatLog.length > CHAT_LIMITS.keep || (this.room.chatLog.length && now - this.room.chatLog[0].at > keep)) this.room.chatLog.shift();
|
|
164
|
+
const msg = this.room.wireLine(rec);
|
|
165
|
+
const text = JSON.stringify(msg);
|
|
166
|
+
const mine = n ? JSON.stringify({ ...msg, n }) : text;
|
|
167
|
+
const hub = this.room.chatRules().hub;
|
|
168
|
+
for (const o of this.room.live()) if (!this.room.lite(o)) this.room.sendText(o.conn, o.conn === senderConn ? mine : text);
|
|
169
|
+
for (const w of this.room.watchers) if (!w.hub || hub) this.room.sendText(w, w === senderConn ? mine : text);
|
|
170
|
+
if (rec.kind === 'react') this.room.stats.chatReacts = (this.room.stats.chatReacts ?? 0) + 1;
|
|
171
|
+
else this.room.stats.chatLines = (this.room.stats.chatLines ?? 0) + 1;
|
|
172
|
+
// Kept chat (0.29.0): a room whose rules keep history hands what was said to the Table (never a reaction).
|
|
173
|
+
if (rec.kind !== 'react' && this.room.chatRules().history > 0 && typeof this.room.onPublished === 'function') { try { this.room.onPublished(rec); } catch { /* kept next time */ } }
|
|
174
|
+
return { ok: true, id: rec.id };
|
|
175
|
+
}
|
|
176
|
+
unsay(ids) {
|
|
177
|
+
const gone = new Set(ids);
|
|
178
|
+
const before = this.room.chatLog.length;
|
|
179
|
+
this.room.chatLog = this.room.chatLog.filter((r) => !gone.has(r.id));
|
|
180
|
+
const text = JSON.stringify({ t: 'unline', ids: [...gone] });
|
|
181
|
+
for (const o of this.room.live()) if (!this.room.lite(o)) this.room.sendText(o.conn, text);
|
|
182
|
+
for (const w of this.room.watchers) this.room.sendText(w, text);
|
|
183
|
+
// Kept chat: what is taken down here is deleted where it was kept, older lines than the window too.
|
|
184
|
+
if (typeof this.room.onUnsaid === 'function' && gone.size) { try { this.room.onUnsaid([...gone]); } catch { /* best effort */ } }
|
|
185
|
+
return before - this.room.chatLog.length;
|
|
186
|
+
}
|
|
187
|
+
ownLine(r, conn) {
|
|
188
|
+
const f = r?.from ?? {};
|
|
189
|
+
if (!conn || r?.kind === 'studio') return false;
|
|
190
|
+
return Boolean((conn.player && f.player && f.player === conn.player) || (conn.browser && f.browser && f.browser === conn.browser && !f.player));
|
|
191
|
+
}
|
|
192
|
+
unsayOwn(conn, id) {
|
|
193
|
+
if (typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,16}$/.test(id)) return { ok: false, error: 'no-line' };
|
|
194
|
+
const r = this.room.chatLog.find((x) => x.id === id);
|
|
195
|
+
if (!r || r.kind === 'studio') return { ok: false, error: 'no-line' };
|
|
196
|
+
if (!this.room.ownLine(r, conn)) return { ok: false, error: 'not-yours' };
|
|
197
|
+
this.room.unsay([id]);
|
|
198
|
+
this.room.stats.chatOwnRemoved = (this.room.stats.chatOwnRemoved ?? 0) + 1;
|
|
199
|
+
return { ok: true, removed: 1 };
|
|
200
|
+
}
|
|
201
|
+
holdSender(line, minutes, now, { kick = false, message = '', purge = false } = {}) {
|
|
202
|
+
const f = line.from ?? {};
|
|
203
|
+
if (!f.token && !f.browser && !f.player) return { ok: false, error: 'no-player', message: 'that line has nobody to hold' };
|
|
204
|
+
const until = now + minutes * 60_000;
|
|
205
|
+
const hold = { token: f.token ?? null, browser: f.browser ?? null, player: f.player ?? null, name: line.name, seat: line.seat, until, at: now };
|
|
206
|
+
if (kick) this.room.bans = [...this.room.bans, { ...hold, ip: null, pass: null, message }].slice(-CONTROL_LIMITS.bans);
|
|
207
|
+
else this.room.mutes = [...this.room.mutes, hold].slice(-CONTROL_LIMITS.mutes);
|
|
208
|
+
this.room.tellBrowser(hold, kick ? { t: 'kicked', room: this.room.code, until, message } : { t: 'muted', room: this.room.code, until });
|
|
209
|
+
const removed = purge ? this.room.unsay(this.room.linesOf(f)) : 0;
|
|
210
|
+
this.room.tellWatchers();
|
|
211
|
+
return { ok: true, op: kick ? 'kick' : 'mute', name: line.name, seat: line.seat, until, sockets: 0, ...(removed ? { removed } : {}) };
|
|
212
|
+
}
|
|
213
|
+
linesOf(from) {
|
|
214
|
+
if (!from) return [];
|
|
215
|
+
return this.room.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);
|
|
216
|
+
}}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/** Owner controls, moderation and launch transitions for a room. The relay owns its shared state. */
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
import { skillPreset } from './agents.mjs';
|
|
7
|
+
|
|
8
|
+
import { CHAT_LIMITS, HELD_WORDS, floor } from './chat.mjs';
|
|
9
|
+
import { STALL, stallOf, versionOf, CONTROL_LIMITS, oneLine, randomId, keptRef } from './room-protocol.mjs';
|
|
10
|
+
export class RoomControls {
|
|
11
|
+
constructor(room) {this.room=room;}
|
|
12
|
+
findClient({ id = null, seat = null } = {}) {
|
|
13
|
+
if (typeof id === 'string' && id) { const c = this.room.clients.get(id); if (c && c.helloed) return c; }
|
|
14
|
+
if (Number.isInteger(seat)) return this.room.live().find((c) => c.seat === seat) ?? null;
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
banOf(c, token = c.token) {
|
|
18
|
+
const now = this.room.now();
|
|
19
|
+
const pass = c.agent?.pass ?? c.agentWord?.pass ?? null;
|
|
20
|
+
return this.room.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;
|
|
21
|
+
}
|
|
22
|
+
muteOf(c) {
|
|
23
|
+
const now = this.room.now();
|
|
24
|
+
return this.room.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;
|
|
25
|
+
}
|
|
26
|
+
liveAnnouncement() {
|
|
27
|
+
const a = this.room.announcement;
|
|
28
|
+
return a && a.until > this.room.now() ? a : null;
|
|
29
|
+
}
|
|
30
|
+
heldNotice(conn) {
|
|
31
|
+
const now = this.room.now();
|
|
32
|
+
if (this.room.closedUntil > now) return { t: 'closed', room: this.room.code, until: this.room.closedUntil, message: this.room.closedWhy || 'The studio closed this room.' };
|
|
33
|
+
const probe = { token: null, browser: conn.browser ?? null, player: conn.player ?? null, ip: null };
|
|
34
|
+
if (!probe.browser && !probe.player) return null;
|
|
35
|
+
const ban = this.room.banOf(probe, null);
|
|
36
|
+
if (ban) return { t: 'kicked', room: this.room.code, until: ban.until, message: ban.message };
|
|
37
|
+
const mute = this.room.muteOf(probe);
|
|
38
|
+
return mute ? { t: 'muted', room: this.room.code, until: mute.until } : null;
|
|
39
|
+
}
|
|
40
|
+
tellBrowser(who, msg) {
|
|
41
|
+
const text = JSON.stringify(msg);
|
|
42
|
+
for (const w of this.room.watchers) if ((who.browser && w.browser === who.browser) || (who.player && w.player === who.player)) this.room.sendText(w, text);
|
|
43
|
+
}
|
|
44
|
+
control(op, a = {}) {
|
|
45
|
+
const now = this.room.now();
|
|
46
|
+
const minutes = Math.max(1, Math.min(CONTROL_LIMITS.minutes, Math.floor(Number(a.minutes) || 10)));
|
|
47
|
+
this.room.bans = this.room.bans.filter((b) => b.until > now);
|
|
48
|
+
this.room.mutes = this.room.mutes.filter((m) => m.until > now);
|
|
49
|
+
switch (op) {
|
|
50
|
+
case 'kick': {
|
|
51
|
+
// From a chat line (section 19): its sender, in this room now or not (a watcher with no seat is held too). A line
|
|
52
|
+
// older than the window comes from the room's kept history (the Worker read it: its sender's account only).
|
|
53
|
+
const line = typeof a.line === 'string' ? this.room.chatLog.find((r) => r.id === a.line) ?? keptRef(a) : null;
|
|
54
|
+
const target = this.room.findClient(a) ?? (line?.from?.client ? this.room.clients.get(line.from.client) ?? null : null);
|
|
55
|
+
if (!target && line) return this.room.holdSender(line, minutes, now, { kick: true, message: oneLine(a.message, CONTROL_LIMITS.message) || 'The studio removed you from this room.', purge: a.purge === true });
|
|
56
|
+
if (!target) return { ok: false, error: 'no-player', message: 'nobody is in that seat now' };
|
|
57
|
+
const purged = a.purge === true ? this.room.unsay(this.room.linesOf({ client: target.id, token: target.token, browser: target.browser, player: target.player })) : 0;
|
|
58
|
+
const until = now + minutes * 60_000;
|
|
59
|
+
// The message is the studio's words; `until` says when (the shell says it in the player's own time).
|
|
60
|
+
const message = oneLine(a.message, CONTROL_LIMITS.message) || 'The studio removed you from this room.';
|
|
61
|
+
// An AI is held out by its pass (its seat becomes a brainless AI seat again); a person by token, browser, account.
|
|
62
|
+
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 };
|
|
63
|
+
this.room.bans = [...this.room.bans, ban].slice(-CONTROL_LIMITS.bans);
|
|
64
|
+
// Every socket of that player in this room: the seat itself, and its browser's or account's other tabs.
|
|
65
|
+
const out = this.room.live().filter((o) => o === target || (ban.browser && o.browser === ban.browser) || (ban.player && o.player === ban.player));
|
|
66
|
+
for (const o of out) {
|
|
67
|
+
const seat = o.seat;
|
|
68
|
+
const token = o.token;
|
|
69
|
+
this.room.error(o, 'kicked', message, { until });
|
|
70
|
+
this.room.onClose(o, 'kicked');
|
|
71
|
+
// The seat is free for somebody else at once, and its token resumes nothing.
|
|
72
|
+
const s = seat === null ? null : this.room.seats.get(seat);
|
|
73
|
+
if (s && s.token === token) { this.room.seats.delete(seat); this.room.seatsDirty = true; }
|
|
74
|
+
try { o.conn.close(1008, 'kicked'); } catch { /* gone */ }
|
|
75
|
+
}
|
|
76
|
+
this.room.stats.ownerKicks = (this.room.stats.ownerKicks ?? 0) + 1;
|
|
77
|
+
this.room.tellBrowser(ban, { t: 'kicked', room: this.room.code, until, message });
|
|
78
|
+
this.room.persist(now);
|
|
79
|
+
this.room.tellWatchers();
|
|
80
|
+
return { ok: true, op, name: ban.name, seat: ban.seat, sockets: out.length, until, ...(purged ? { removed: purged } : {}) };
|
|
81
|
+
}
|
|
82
|
+
case 'mute': {
|
|
83
|
+
const line = typeof a.line === 'string' ? this.room.chatLog.find((r) => r.id === a.line) ?? keptRef(a) : null;
|
|
84
|
+
const target = this.room.findClient(a) ?? (line?.from?.client ? this.room.clients.get(line.from.client) ?? null : null);
|
|
85
|
+
if (!target && line && a.off !== true) return this.room.holdSender(line, minutes, now, { kick: false, purge: a.purge === true });
|
|
86
|
+
if (!target) return { ok: false, error: 'no-player', message: 'nobody is in that seat now' };
|
|
87
|
+
const off = a.off === true;
|
|
88
|
+
const purged = !off && a.purge === true ? this.room.unsay(this.room.linesOf({ client: target.id, token: target.token, browser: target.browser, player: target.player })) : 0;
|
|
89
|
+
const same = (m) => (m.token && m.token === target.token) || (m.browser && m.browser === target.browser) || (m.player && m.player === target.player);
|
|
90
|
+
this.room.mutes = this.room.mutes.filter((m) => !same(m));
|
|
91
|
+
const until = off ? 0 : now + minutes * 60_000;
|
|
92
|
+
if (!off) this.room.mutes = [...this.room.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);
|
|
93
|
+
const note = { t: 'mute', id: target.id, seat: target.seat, until };
|
|
94
|
+
for (const o of this.room.live()) this.room.send(o, note);
|
|
95
|
+
this.room.tellBrowser(target, { t: 'muted', room: this.room.code, until });
|
|
96
|
+
this.room.tellWatchers();
|
|
97
|
+
return { ok: true, op, name: line?.name ?? target.name, seat: target.seat, until, ...(purged ? { removed: purged } : {}) };
|
|
98
|
+
}
|
|
99
|
+
case 'card': {
|
|
100
|
+
// Show what you made (0.29.0): a link card to a studio's game, which the Worker read from that studio's own
|
|
101
|
+
// manifest and checked (its words passed the floor and the review there). Its sender's name and account are the
|
|
102
|
+
// Worker's word; a mute holds a card as it holds a line.
|
|
103
|
+
const c = a.card && typeof a.card === 'object' ? a.card : null;
|
|
104
|
+
if (!c || typeof c.url !== 'string' || !/^https?:\/\//.test(c.url)) return { ok: false, error: 'card', message: 'no card' };
|
|
105
|
+
const who = { token: null, browser: typeof a.browser === 'string' ? a.browser : null, player: typeof a.player === 'string' ? a.player : null, ip: null };
|
|
106
|
+
if (a.owner !== true && this.room.banOf(who, null)) return { ok: false, error: 'kicked', message: 'The studio has held you out of this room for now.' };
|
|
107
|
+
if (a.owner !== true && this.room.muteOf(who)) return { ok: false, error: 'muted', message: HELD_WORDS.muted };
|
|
108
|
+
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) } : {}) };
|
|
109
|
+
const note = oneLine(a.note, CHAT_LIMITS.text);
|
|
110
|
+
const rules = this.room.chatRules();
|
|
111
|
+
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.' };
|
|
112
|
+
// The card's words (the person's note, the game's own title and pitch) pass the floor like any typed line.
|
|
113
|
+
const words = [note, card.title, card.pitch].filter(Boolean).join(' · ');
|
|
114
|
+
const f = floor(words, { ...rules, links: 'block' });
|
|
115
|
+
if (!f.ok && a.owner !== true) return { ok: false, error: 'held', why: f.why, message: HELD_WORDS[f.why] ?? HELD_WORDS.unknown };
|
|
116
|
+
const rec = {
|
|
117
|
+
kind: 'card', card, text: note || `Made: ${card.title}${card.studio ? ` by ${card.studio}` : ''}`,
|
|
118
|
+
name: oneLine(a.name, CHAT_LIMITS.name) || 'Someone', seat: null, colour: null, by: 'watcher', bubble: false,
|
|
119
|
+
acct: a.acct === true, owner: a.owner === true, ...(a.mod === true ? { mod: true } : {}),
|
|
120
|
+
from: { client: null, token: null, browser: who.browser, player: who.player },
|
|
121
|
+
};
|
|
122
|
+
// And the studio's review, as a typed line gets it (the owner's own card is never reviewed).
|
|
123
|
+
if (rules.ai && typeof this.room.review === 'function' && a.owner !== true) {
|
|
124
|
+
return Promise.resolve()
|
|
125
|
+
.then(() => this.room.review(words, { room: this.room.code, links: 'block' }))
|
|
126
|
+
.then((v) => v, () => ({ ok: true, by: 'error' }))
|
|
127
|
+
.then((v) => {
|
|
128
|
+
if (v && v.ok === false) { this.room.stats.chatAiHeld = (this.room.stats.chatAiHeld ?? 0) + 1; return { ok: false, error: 'held', why: 'ai', message: HELD_WORDS.ai }; }
|
|
129
|
+
rec.review = { by: v?.by ?? 'ai' };
|
|
130
|
+
if (this.room.muteOf(who)) return { ok: false, error: 'muted', message: HELD_WORDS.muted };
|
|
131
|
+
return { ...this.room.publishChat(rec), op };
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return { ...this.room.publishChat(rec), op };
|
|
135
|
+
}
|
|
136
|
+
case 'unsay': {
|
|
137
|
+
// Room chat (section 19): the owner takes a message down (or every message: `all`), on every screen at once.
|
|
138
|
+
const ids = a.all === true ? this.room.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);
|
|
139
|
+
if (!ids.length) return { ok: false, error: 'no-line', message: 'which message?' };
|
|
140
|
+
const removed = this.room.unsay(ids);
|
|
141
|
+
this.room.stats.chatRemoved = (this.room.stats.chatRemoved ?? 0) + removed;
|
|
142
|
+
return { ok: true, op, removed };
|
|
143
|
+
}
|
|
144
|
+
case 'announce': {
|
|
145
|
+
const text = oneLine(a.text, CONTROL_LIMITS.announce);
|
|
146
|
+
const prev = this.room.announcement;
|
|
147
|
+
if (!text) {
|
|
148
|
+
this.room.announcement = null;
|
|
149
|
+
if (prev) { const clear = JSON.stringify({ t: 'announce', id: prev.id, text: null }); for (const o of this.room.live()) this.room.sendText(o.conn, clear); for (const w of this.room.watchers) this.room.sendText(w, clear); }
|
|
150
|
+
this.room.tellWatchers();
|
|
151
|
+
return { ok: true, op, cleared: Boolean(prev) };
|
|
152
|
+
}
|
|
153
|
+
const seconds = Math.max(5, Math.min(CONTROL_LIMITS.announceSeconds, Math.floor(Number(a.seconds) || 30)));
|
|
154
|
+
this.room.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' };
|
|
155
|
+
const msg = JSON.stringify({ t: 'announce', ...this.room.announcement });
|
|
156
|
+
for (const o of this.room.live()) this.room.sendText(o.conn, msg);
|
|
157
|
+
for (const w of this.room.watchers) this.room.sendText(w, msg);
|
|
158
|
+
// The room's chat shows it too, as the studio's own line (section 19).
|
|
159
|
+
this.room.publishChat({ kind: 'studio', text, name: 'Studio', seat: null, colour: null, by: 'studio', bubble: false, acct: false, owner: true, from: null });
|
|
160
|
+
this.room.tellWatchers();
|
|
161
|
+
return { ok: true, op, id: this.room.announcement.id, people: this.room.live().length, until: this.room.announcement.until };
|
|
162
|
+
}
|
|
163
|
+
case 'close': {
|
|
164
|
+
if (a.reopen === true) { this.room.closedUntil = 0; this.room.closedWhy = ''; this.room.tellWatchers(); return { ok: true, op, reopened: true }; }
|
|
165
|
+
// `seconds` (10 s to a day) is a short close: everyone comes back through the game's door (a launch state change).
|
|
166
|
+
const seconds = Math.floor(Number(a.seconds));
|
|
167
|
+
const until = now + (seconds >= 10 ? Math.min(seconds, CONTROL_LIMITS.minutes * 60) * 1000 : minutes * 60_000);
|
|
168
|
+
const message = oneLine(a.message, CONTROL_LIMITS.message) || 'The studio closed this room. Thanks for playing!';
|
|
169
|
+
this.room.closedUntil = until;
|
|
170
|
+
this.room.closedWhy = message;
|
|
171
|
+
const people = this.room.live().length;
|
|
172
|
+
for (const c of [...this.room.clients.values()]) {
|
|
173
|
+
if (c.helloed) this.room.error(c, 'room-closed', message, { until });
|
|
174
|
+
this.room.clients.delete(c.id);
|
|
175
|
+
try { c.conn.close(1000, 'room-closed'); } catch { /* gone */ }
|
|
176
|
+
}
|
|
177
|
+
// Everything the room held goes, as when an empty room forgets (section 4).
|
|
178
|
+
this.room.hostId = null;
|
|
179
|
+
this.room.lastSnap = null; this.room.lastSnapText = null; this.room.lastCkpt = null; this.room.lastRound = null; this.room.lastRoster = null; this.room.rosterSent = null;
|
|
180
|
+
this.room.state.clear(); this.room.stateBytes = 0; this.room.seats.clear(); this.room.preferHost = null; this.room.openedAt = 0; this.room.askedMax = null;
|
|
181
|
+
this.room.emptySince = now; this.room.seatsDirty = false; this.room.persistDirty = false;
|
|
182
|
+
try { this.room.store?.clear?.(); } catch { /* best effort */ }
|
|
183
|
+
this.room.hosted.clear();
|
|
184
|
+
try { this.room.onForget?.(); } catch { /* the room is closed either way */ }
|
|
185
|
+
const msg = JSON.stringify({ t: 'closed', room: this.room.code, until, message });
|
|
186
|
+
for (const w of this.room.watchers) this.room.sendText(w, msg);
|
|
187
|
+
this.room.tellWatchers();
|
|
188
|
+
return { ok: true, op, people, until };
|
|
189
|
+
}
|
|
190
|
+
case 'regate': {
|
|
191
|
+
// A game's launch state changed (NETPLAY.md section 15): who may play is now narrower (`allow`: ticket kinds o,
|
|
192
|
+
// i, p), or anyone again (`allow` absent: a pending change is cancelled). The current round finishes first.
|
|
193
|
+
if (!Array.isArray(a.allow)) { const had = Boolean(this.room.regate); this.room.regate = null; this.room.tellWatchers(); return { ok: true, op, cancelled: had }; }
|
|
194
|
+
const allow = a.allow.filter((k) => ['o', 'i', 'p'].includes(k));
|
|
195
|
+
const leaving = this.room.live().filter((c) => !this.room.allowedAfter(c, allow)).length;
|
|
196
|
+
if (!leaving) { this.room.regate = null; this.room.tellWatchers(); return { ok: true, op, leaving: 0 }; }
|
|
197
|
+
const r = this.room.lastRound;
|
|
198
|
+
const playing = Boolean(r && r.phase === 'live' && Number(r.endsAt) > now);
|
|
199
|
+
const until = Math.min(now + 15 * 60_000, playing ? Number(r.endsAt) + 20_000 : now + 30_000);
|
|
200
|
+
this.room.regate = { allow, roundN: playing ? Number(r.n) : null, until, at: now, message: oneLine(a.message, CONTROL_LIMITS.message) || 'Thanks for playing!' };
|
|
201
|
+
const notice = oneLine(a.notice, CONTROL_LIMITS.announce);
|
|
202
|
+
if (notice) this.room.control('announce', { text: notice, seconds: Math.ceil((until - now) / 1000) + 5 });
|
|
203
|
+
this.room.tellWatchers();
|
|
204
|
+
return { ok: true, op, leaving, until, afterRound: playing ? Number(r.n) : null };
|
|
205
|
+
}
|
|
206
|
+
case 'seats': {
|
|
207
|
+
const n = Math.floor(Number(a.max));
|
|
208
|
+
if (!(n >= 1)) return { ok: false, error: 'max', message: 'seats is a number of at least 1' };
|
|
209
|
+
this.room.setSeats(n);
|
|
210
|
+
this.room.tellWatchers();
|
|
211
|
+
return { ok: true, op, max: this.room.maxPlayers };
|
|
212
|
+
}
|
|
213
|
+
case 'policy': {
|
|
214
|
+
// The owner changed the server (section 17): its new policy now, and AI out after the round if it is humans-only.
|
|
215
|
+
const r = this.room.setPolicy(a.pol, { force: true });
|
|
216
|
+
return r.ok ? { ...r, op } : { ...r, op };
|
|
217
|
+
}
|
|
218
|
+
case 'level': {
|
|
219
|
+
// The owner sets this room's dial (room_level): it applies at once, as a vote's result does.
|
|
220
|
+
const n = Math.floor(Number(a.level));
|
|
221
|
+
if (!(n >= 1 && n <= 5)) return { ok: false, error: 'level', message: 'level is 1 to 5' };
|
|
222
|
+
this.room.level = Math.min(this.room.policy.levelMax, n);
|
|
223
|
+
this.room.levelBy = 'owner';
|
|
224
|
+
this.room.seatsDirty = true;
|
|
225
|
+
this.room.tellPolicy();
|
|
226
|
+
this.room.tellWatchers();
|
|
227
|
+
return { ok: true, op, level: this.room.level, name: skillPreset(this.room.level, { kids: this.room.policy.kids }).name };
|
|
228
|
+
}
|
|
229
|
+
default:
|
|
230
|
+
return { ok: false, error: 'op', message: `no control called ${String(op).slice(0, 24)}` };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
allowedAfter(c, allow) {
|
|
234
|
+
// The owner's own agent passes count as the owner's (only the owner can mint one).
|
|
235
|
+
return String(c.via ?? '').split('~').some((part) => (part === 'o' && allow.includes('o')) || (part.startsWith('i-') && allow.includes('i')) || (part.startsWith('p-') && allow.includes('p'))
|
|
236
|
+
|| (part.startsWith('a-') && allow.includes('o') && c.agent?.by === 'studio'));
|
|
237
|
+
}
|
|
238
|
+
applyRegate(now = this.room.now()) {
|
|
239
|
+
const g = this.room.regate;
|
|
240
|
+
if (!g) return 0;
|
|
241
|
+
this.room.regate = null;
|
|
242
|
+
// The holder of the room's storage keeps the owner's state (the Table writes it on its next tick).
|
|
243
|
+
this.room.officeDirty = true;
|
|
244
|
+
const out = this.room.live().filter((c) => !this.room.allowedAfter(c, g.allow));
|
|
245
|
+
for (const c of out) {
|
|
246
|
+
const seat = c.seat;
|
|
247
|
+
const token = c.token;
|
|
248
|
+
this.room.error(c, 'room-closed', g.message);
|
|
249
|
+
this.room.onClose(c, 'regated');
|
|
250
|
+
const s = seat === null ? null : this.room.seats.get(seat);
|
|
251
|
+
if (s && s.token === token) { this.room.seats.delete(seat); this.room.seatsDirty = true; }
|
|
252
|
+
try { c.conn.close(1000, 'room-closed'); } catch { /* gone */ }
|
|
253
|
+
this.room.tellBrowser(c, { t: 'closed', room: this.room.code, message: g.message });
|
|
254
|
+
}
|
|
255
|
+
this.room.persist(now);
|
|
256
|
+
this.room.tellWatchers();
|
|
257
|
+
return out.length;
|
|
258
|
+
}
|
|
259
|
+
setStall(ms) {
|
|
260
|
+
this.room.stallMs = stallOf(ms) ?? STALL.ms;
|
|
261
|
+
return this.room.stallMs;
|
|
262
|
+
}
|
|
263
|
+
setCurrent(ver) {
|
|
264
|
+
this.room.currentVer = ver === undefined ? undefined : versionOf(ver);
|
|
265
|
+
}
|
|
266
|
+
forgetWorld(next) {
|
|
267
|
+
this.room.log({ ev: 'build-changed', room: this.room.code, from: this.room.gameVer ?? null, to: next ?? null });
|
|
268
|
+
this.room.lastSnap = null; this.room.lastSnapText = null; this.room.lastCkpt = null; this.room.lastRound = null; this.room.lastRoster = null; this.room.rosterSent = null;
|
|
269
|
+
this.room.state.clear(); this.room.stateBytes = 0; this.room.preferHost = null;
|
|
270
|
+
this.room.persistDirty = true;
|
|
271
|
+
}
|
|
272
|
+
setSeats(n, perIp = null) {
|
|
273
|
+
this.room.seatCap = Math.max(1, Math.floor(n));
|
|
274
|
+
this.room.maxPlayers = Math.max(1, Math.min(this.room.seatCap, (this.room.server || this.room.rules) ? this.room.seatCap : this.room.askedMax ?? this.room.seatCap));
|
|
275
|
+
if (Number.isFinite(perIp) && perIp >= 0) this.room.perIp = perIp;
|
|
276
|
+
}
|
|
277
|
+
officeSaved() {
|
|
278
|
+
const now = this.room.now();
|
|
279
|
+
return {
|
|
280
|
+
bans: this.room.bans.filter((b) => b.until > now), mutes: this.room.mutes.filter((m) => m.until > now),
|
|
281
|
+
announcement: this.room.liveAnnouncement(), closedUntil: this.room.closedUntil > now ? this.room.closedUntil : 0, closedWhy: this.room.closedWhy,
|
|
282
|
+
regate: this.room.regate, agentsOut: this.room.agentsOut,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
restoreOffice(o) {
|
|
286
|
+
if (!o || typeof o !== 'object') return;
|
|
287
|
+
const now = this.room.now();
|
|
288
|
+
this.room.bans = (Array.isArray(o.bans) ? o.bans : []).filter((b) => b && b.until > now).slice(-CONTROL_LIMITS.bans);
|
|
289
|
+
this.room.mutes = (Array.isArray(o.mutes) ? o.mutes : []).filter((m) => m && m.until > now).slice(-CONTROL_LIMITS.mutes);
|
|
290
|
+
this.room.announcement = o.announcement && o.announcement.until > now ? o.announcement : null;
|
|
291
|
+
this.room.closedUntil = Number(o.closedUntil) > now ? Number(o.closedUntil) : 0;
|
|
292
|
+
this.room.closedWhy = this.room.closedUntil ? String(o.closedWhy ?? '') : '';
|
|
293
|
+
// A launch change still waiting comes back; one long past is over (whoever it was for has gone).
|
|
294
|
+
this.room.regate = o.regate && Array.isArray(o.regate.allow) && Number(o.regate.until) > now - 120_000 ? o.regate : null;
|
|
295
|
+
this.room.agentsOut = o.agentsOut && Number(o.agentsOut.until) > now - 120_000 ? o.agentsOut : null;
|
|
296
|
+
}}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const ROOM_ALARM_MS = 10 * 60_000;
|
|
2
|
+
|
|
3
|
+
export const ROOM_PAUSE_MS = 60_000;
|
|
4
|
+
|
|
5
|
+
export function roomEndAt({ people, pausedAt, createdAt, restoredAt = null, savedAt = null, alarmAt = null }) {
|
|
6
|
+
// Only people keep play alive. With no durable alarm, the last save bounds an unrecorded departure.
|
|
7
|
+
const absence = pausedAt ?? (restoredAt !== null && alarmAt === null ? savedAt : null) ?? restoredAt ?? createdAt;
|
|
8
|
+
return people > 0 ? Infinity : absence + ROOM_PAUSE_MS;
|
|
9
|
+
}
|
|
10
|
+
|