@homie-rocks/studio 0.28.0 → 0.29.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 +30 -1
- package/README.md +30 -1
- package/bin/homie-studio.mjs +24 -0
- package/chat/CHAT.md +5 -1
- package/chat/LOUNGE.md +114 -0
- package/chat/OWNERS.md +6 -2
- package/lib/build.mjs +5 -0
- package/lib/lounge-cli.mjs +70 -0
- package/lib/mcp-tools.mjs +6 -0
- package/lib/scaffold.mjs +13 -2
- package/lib/template-history.json +40 -0
- package/mcp/ui/setup.js +2 -0
- package/netplay/NETPLAY.md +11 -1
- package/package.json +1 -1
- package/site/SITE.md +13 -0
- package/worker/chat-store.mjs +2 -1
- package/worker/chat.mjs +13 -1
- package/worker/discover.mjs +5 -0
- package/worker/index.mjs +38 -3
- package/worker/lounge-page.mjs +623 -0
- package/worker/lounge-store.mjs +281 -0
- package/worker/lounge.mjs +547 -0
- package/worker/office-page.mjs +99 -0
- package/worker/office.mjs +19 -1
- package/worker/players.mjs +5 -0
- package/worker/room.mjs +116 -12
- package/worker/site.mjs +6 -1
- package/worker/version.mjs +1 -1
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE LOUNGE AND KEPT CHAT IN THE STUDIO'S OWN D1 (@homie-rocks/studio 0.29.0, chat/LOUNGE.md).
|
|
3
|
+
*
|
|
4
|
+
* chat_history what was said in a room whose owner turned `history` on (a number of days; off for every game by
|
|
5
|
+
* default, never on a kids server): typed lines, quick lines, show-what-you-made cards and the
|
|
6
|
+
* studio's own lines. Never a reaction, never an address, never a browser key. A signed-in person's
|
|
7
|
+
* account id is kept with their line, so they can take it down themselves and deleting their account
|
|
8
|
+
* takes every line of theirs with it. Forgotten after the room's `history` days.
|
|
9
|
+
* lounge_nights the play nights the owner sets in the office: a title, when (UTC), how long, a note and a game.
|
|
10
|
+
* lounge_mods the player accounts the owner trusts to keep the Lounge kind: Remove, Mute, Kick and slow mode.
|
|
11
|
+
*
|
|
12
|
+
* The room keeps the window in memory as always (worker/room.mjs); this is only the longer memory an owner asked for.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const LOUNGE_MIGRATION_FILE = '0009_studio_lounge.sql';
|
|
16
|
+
export const LOUNGE_MIGRATION = `-- The Lounge and kept chat (@homie-rocks/studio 0.29.0): what was said in a room whose owner turned history on (never a
|
|
17
|
+
-- reaction, an address or a browser key; a signed-in sender's account id so they can remove it), the play nights the
|
|
18
|
+
-- owner sets, and the moderators the owner names. Kept chat is forgotten after the room's history days.
|
|
19
|
+
CREATE TABLE IF NOT EXISTS chat_history (
|
|
20
|
+
game TEXT NOT NULL,
|
|
21
|
+
room TEXT NOT NULL,
|
|
22
|
+
id TEXT NOT NULL,
|
|
23
|
+
at INTEGER NOT NULL,
|
|
24
|
+
kind TEXT NOT NULL,
|
|
25
|
+
name TEXT NOT NULL,
|
|
26
|
+
src TEXT NOT NULL,
|
|
27
|
+
text TEXT,
|
|
28
|
+
say TEXT,
|
|
29
|
+
card TEXT,
|
|
30
|
+
player TEXT,
|
|
31
|
+
acct INTEGER NOT NULL DEFAULT 0,
|
|
32
|
+
owner INTEGER NOT NULL DEFAULT 0,
|
|
33
|
+
mod INTEGER NOT NULL DEFAULT 0,
|
|
34
|
+
PRIMARY KEY (game, room, id)
|
|
35
|
+
) WITHOUT ROWID;
|
|
36
|
+
CREATE INDEX IF NOT EXISTS chat_history_at ON chat_history (game, room, at);
|
|
37
|
+
CREATE INDEX IF NOT EXISTS chat_history_player ON chat_history (player);
|
|
38
|
+
CREATE TABLE IF NOT EXISTS lounge_nights (
|
|
39
|
+
id TEXT PRIMARY KEY,
|
|
40
|
+
title TEXT NOT NULL,
|
|
41
|
+
starts_at INTEGER NOT NULL,
|
|
42
|
+
minutes INTEGER NOT NULL,
|
|
43
|
+
note TEXT,
|
|
44
|
+
game TEXT,
|
|
45
|
+
created_at INTEGER NOT NULL
|
|
46
|
+
) WITHOUT ROWID;
|
|
47
|
+
CREATE INDEX IF NOT EXISTS lounge_nights_at ON lounge_nights (starts_at);
|
|
48
|
+
CREATE TABLE IF NOT EXISTS lounge_mods (
|
|
49
|
+
player TEXT PRIMARY KEY,
|
|
50
|
+
added_at INTEGER NOT NULL
|
|
51
|
+
) WITHOUT ROWID;
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
/** The Lounge's room: one Table for the whole studio, under a game id no game can have (game ids start a-z or 0-9). */
|
|
55
|
+
export const LOUNGE_GAME = '_lounge';
|
|
56
|
+
export const LOUNGE_ROOM = 'lounge';
|
|
57
|
+
export const LOUNGE_LIMITS = Object.freeze({ nights: 24, title: 80, note: 200, minMinutes: 15, maxMinutes: 720, mods: 50, page: 50, show: 3 });
|
|
58
|
+
const DAY = 86_400_000;
|
|
59
|
+
const ID = /^[A-Za-z0-9_-]{1,16}$/;
|
|
60
|
+
const PLAYER = /^[A-Za-z0-9_-]{1,64}$/;
|
|
61
|
+
const oneLine = (v, max) => [...String(v ?? '').normalize('NFKC').replace(/[\p{Cc}\p{Cf}\p{Co}\p{Cs}\u2028\u2029]+/gu, ' ').replace(/\s+/g, ' ').trim()].slice(0, max).join('').trim();
|
|
62
|
+
|
|
63
|
+
function randomHex(bytes) {
|
|
64
|
+
const raw = new Uint8Array(bytes);
|
|
65
|
+
crypto.getRandomValues(raw);
|
|
66
|
+
return [...raw].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* ------------------------------------------------------------------ kept chat */
|
|
70
|
+
|
|
71
|
+
/** Whether a message is the kind a room keeps: what people said and the studio's own lines, never a reaction. */
|
|
72
|
+
export const keptKind = (rec) => rec && ['text', 'line', 'card', 'studio'].includes(rec.kind);
|
|
73
|
+
|
|
74
|
+
/** One message as chat_history keeps it (a card's JSON is its link, title, studio, pitch and picture, nothing else). */
|
|
75
|
+
function rowOf(game, room, rec) {
|
|
76
|
+
const card = rec.card && typeof rec.card === 'object' ? JSON.stringify({ url: rec.card.url, title: rec.card.title, studio: rec.card.studio, pitch: rec.card.pitch ?? '', image: rec.card.image ?? null, game: rec.card.game ?? null }) : null;
|
|
77
|
+
return [game, room, rec.id, Math.floor(Number(rec.at) || Date.now()), rec.kind, oneLine(rec.name, 40) || 'Someone', rec.by ?? 'player',
|
|
78
|
+
rec.text === undefined || rec.text === null ? null : String(rec.text).slice(0, 600), rec.say ?? null, card,
|
|
79
|
+
rec.from?.player ?? rec.player ?? null, rec.acct ? 1 : 0, rec.owner ? 1 : 0, rec.mod ? 1 : 0];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Keep messages a room published (a batch of up to 20 at once). */
|
|
83
|
+
export async function keepLines(env, game, room, recs) {
|
|
84
|
+
const list = (recs ?? []).filter(keptKind).slice(0, 20);
|
|
85
|
+
if (!env?.DB || !list.length) return 0;
|
|
86
|
+
await env.DB.batch(list.map((rec) => env.DB.prepare(`INSERT INTO chat_history (game, room, id, at, kind, name, src, text, say, card, player, acct, owner, mod)
|
|
87
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14) ON CONFLICT(game, room, id) DO NOTHING`).bind(...rowOf(game, room, rec))));
|
|
88
|
+
return list.length;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Messages taken down (by the owner, a moderator, a mute or kick that took their lines, or their own sender). */
|
|
92
|
+
export async function forgetLines(env, game, room, ids) {
|
|
93
|
+
const list = (ids ?? []).filter((x) => typeof x === 'string' && ID.test(x)).slice(0, 64);
|
|
94
|
+
if (!env?.DB || !list.length) return 0;
|
|
95
|
+
const marks = list.map((_, i) => `?${i + 3}`).join(', ');
|
|
96
|
+
const r = await env.DB.prepare(`DELETE FROM chat_history WHERE game = ?1 AND room = ?2 AND id IN (${marks})`).bind(game, room, ...list).run();
|
|
97
|
+
return Number(r?.meta?.changes ?? 0);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A kept message as the room holds it again (its sender's account in `from`, so a mute or a kick from it still holds). */
|
|
101
|
+
export function recOf(r) {
|
|
102
|
+
let card = null;
|
|
103
|
+
try { card = r.card ? JSON.parse(r.card) : null; } catch { card = null; }
|
|
104
|
+
return {
|
|
105
|
+
id: r.id, at: Number(r.at), kind: r.kind, name: r.name, seat: null, colour: null, by: r.src,
|
|
106
|
+
...(r.text !== null && r.text !== undefined ? { text: r.text } : {}), ...(r.say ? { say: r.say } : {}), ...(card ? { card } : {}),
|
|
107
|
+
bubble: false, acct: Number(r.acct) === 1, owner: Number(r.owner) === 1, ...(Number(r.mod) === 1 ? { mod: true } : {}),
|
|
108
|
+
from: { client: null, token: null, browser: null, player: r.player ?? null }, kept: true,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* What a room kept: the newest `limit` of the last `days`, oldest first; `before` (a time) pages back. Expired rows are
|
|
114
|
+
* deleted on the way (the room's own days, so a shorter history forgets at once).
|
|
115
|
+
*/
|
|
116
|
+
export async function historyOf(env, game, room, { days, before = null, limit = LOUNGE_LIMITS.page, now = Date.now() } = {}) {
|
|
117
|
+
if (!env?.DB || !(days > 0)) return [];
|
|
118
|
+
const since = now - days * DAY;
|
|
119
|
+
await env.DB.prepare('DELETE FROM chat_history WHERE game = ?1 AND room = ?2 AND at < ?3').bind(game, room, since).run().catch(() => {});
|
|
120
|
+
const n = Math.max(1, Math.min(LOUNGE_LIMITS.page, Math.floor(Number(limit) || LOUNGE_LIMITS.page)));
|
|
121
|
+
const q = Number.isFinite(Number(before)) && before !== null
|
|
122
|
+
? env.DB.prepare('SELECT * FROM chat_history WHERE game = ?1 AND room = ?2 AND at >= ?3 AND at < ?4 ORDER BY at DESC LIMIT ?5').bind(game, room, since, Number(before), n)
|
|
123
|
+
: env.DB.prepare('SELECT * FROM chat_history WHERE game = ?1 AND room = ?2 AND at >= ?3 ORDER BY at DESC LIMIT ?4').bind(game, room, since, n);
|
|
124
|
+
const { results } = await q.all();
|
|
125
|
+
return (results ?? []).map(recOf).reverse();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** One kept message (a report about a line older than the room's window). */
|
|
129
|
+
export async function keptLine(env, game, room, id) {
|
|
130
|
+
if (!env?.DB || !ID.test(String(id ?? ''))) return null;
|
|
131
|
+
try { const r = await env.DB.prepare('SELECT * FROM chat_history WHERE game = ?1 AND room = ?2 AND id = ?3').bind(game, room, id).first(); return r ? recOf(r) : null; } catch { return null; }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The owner shortened a game's history (or turned it off: 0): what is older than that goes now, in every room of it. */
|
|
135
|
+
export async function trimHistory(env, game, days, now = Date.now()) {
|
|
136
|
+
if (!env?.DB) return 0;
|
|
137
|
+
const r = await env.DB.prepare('DELETE FROM chat_history WHERE game = ?1 AND at < ?2').bind(game, days > 0 ? now - days * DAY : now + 1).run();
|
|
138
|
+
return Number(r?.meta?.changes ?? 0);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A player deleted their account: every line they said in any room goes with it. */
|
|
142
|
+
export async function forgetPlayerHistory(env, player) {
|
|
143
|
+
if (!env?.DB || !PLAYER.test(String(player ?? ''))) return 0;
|
|
144
|
+
try { const r = await env.DB.prepare('DELETE FROM chat_history WHERE player = ?1').bind(player).run(); return Number(r?.meta?.changes ?? 0); } catch { return 0; }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The lines one player kept here (their account page's export). */
|
|
148
|
+
export async function playerHistory(env, player) {
|
|
149
|
+
if (!env?.DB || !PLAYER.test(String(player ?? ''))) return [];
|
|
150
|
+
try {
|
|
151
|
+
const { results } = await env.DB.prepare('SELECT game, room, id, at, kind, text, say, card FROM chat_history WHERE player = ?1 ORDER BY at DESC LIMIT 1000').bind(player).all();
|
|
152
|
+
return (results ?? []).map((r) => ({ game: r.game, room: r.room, id: r.id, at: Number(r.at), kind: r.kind, text: r.text ?? null, say: r.say ?? null, card: r.card ? JSON.parse(r.card) : null }));
|
|
153
|
+
} catch { return []; }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* ------------------------------------------------------------------ play nights */
|
|
157
|
+
|
|
158
|
+
/** A play night as the owner typed it: `{ ok, night }` or `{ ok: false, message }`. `at` is an ISO time or epoch ms. */
|
|
159
|
+
export function checkNight(body, { now = Date.now() } = {}) {
|
|
160
|
+
const bad = (message) => ({ ok: false, error: 'bad-request', message });
|
|
161
|
+
const b = body && typeof body === 'object' ? body : {};
|
|
162
|
+
const title = oneLine(b.title, LOUNGE_LIMITS.title);
|
|
163
|
+
if (!title) return bad('title is what the night is, in a few words ("Night Rush night")');
|
|
164
|
+
const at = typeof b.at === 'number' ? b.at : Date.parse(String(b.at ?? ''));
|
|
165
|
+
if (!Number.isFinite(at)) return bad('at is when it starts: an ISO time with its zone (2026-10-09T19:00:00-07:00) or UTC (…Z)');
|
|
166
|
+
if (at < now - 12 * 3600_000) return bad('that time has passed');
|
|
167
|
+
if (at > now + 366 * DAY) return bad('a play night within the next year');
|
|
168
|
+
const minutes = b.minutes === undefined || b.minutes === null ? 120 : Math.floor(Number(b.minutes));
|
|
169
|
+
if (!(minutes >= LOUNGE_LIMITS.minMinutes && minutes <= LOUNGE_LIMITS.maxMinutes)) return bad(`minutes is how long it lasts, ${LOUNGE_LIMITS.minMinutes} to ${LOUNGE_LIMITS.maxMinutes}`);
|
|
170
|
+
const note = oneLine(b.note, LOUNGE_LIMITS.note) || null;
|
|
171
|
+
const game = b.game === undefined || b.game === null || b.game === '' ? null : String(b.game);
|
|
172
|
+
if (game && !/^[a-z0-9][a-z0-9-]{0,39}$/.test(game) && !/^https:\/\/[^\s]{4,300}$/.test(game)) return bad('game is one of this studio\'s game ids, or the https link to a game of another studio');
|
|
173
|
+
return { ok: true, night: { title, at, minutes, note, game } };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function addNight(env, night) {
|
|
177
|
+
const { results } = await env.DB.prepare('SELECT COUNT(*) AS n FROM lounge_nights WHERE starts_at + minutes * 60000 >= ?1').bind(Date.now()).all();
|
|
178
|
+
if (Number(results?.[0]?.n ?? 0) >= LOUNGE_LIMITS.nights) return { ok: false, error: 'too-many', message: `At most ${LOUNGE_LIMITS.nights} play nights ahead at once.` };
|
|
179
|
+
const id = `pn_${randomHex(6)}`;
|
|
180
|
+
await env.DB.prepare('INSERT INTO lounge_nights (id, title, starts_at, minutes, note, game, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)')
|
|
181
|
+
.bind(id, night.title, night.at, night.minutes, night.note, night.game, Date.now()).run();
|
|
182
|
+
return { ok: true, id };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function removeNight(env, id) {
|
|
186
|
+
if (!/^pn_[a-f0-9]{12}$/.test(String(id ?? ''))) return { ok: false, error: 'bad-request', message: 'id is the play night\'s id' };
|
|
187
|
+
const r = await env.DB.prepare('DELETE FROM lounge_nights WHERE id = ?1').bind(id).run();
|
|
188
|
+
return { ok: Number(r?.meta?.changes ?? 0) > 0, removed: Number(r?.meta?.changes ?? 0), ...(Number(r?.meta?.changes ?? 0) ? {} : { error: 'no-night', message: 'No such play night.' }) };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The play nights still to come (or on now), soonest first; past ones are deleted on the way. */
|
|
192
|
+
export async function nightsOf(env, { now = Date.now(), limit = 6 } = {}) {
|
|
193
|
+
if (!env?.DB) return [];
|
|
194
|
+
try {
|
|
195
|
+
await env.DB.prepare('DELETE FROM lounge_nights WHERE starts_at + minutes * 60000 < ?1').bind(now - DAY).run().catch(() => {});
|
|
196
|
+
const { results } = await env.DB.prepare('SELECT * FROM lounge_nights WHERE starts_at + minutes * 60000 >= ?1 ORDER BY starts_at ASC LIMIT ?2').bind(now, Math.max(1, Math.min(LOUNGE_LIMITS.nights, limit))).all();
|
|
197
|
+
return (results ?? []).map((r) => ({ id: r.id, title: r.title, at: Number(r.starts_at), minutes: Number(r.minutes), note: r.note ?? null, game: r.game ?? null, on: Number(r.starts_at) <= now }));
|
|
198
|
+
} catch { return []; }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/* ------------------------------------------------------------------ moderators */
|
|
202
|
+
|
|
203
|
+
export async function modsOf(env) {
|
|
204
|
+
if (!env?.DB) return [];
|
|
205
|
+
try {
|
|
206
|
+
const { results } = await env.DB.prepare('SELECT m.player, m.added_at, p.name, p.guest FROM lounge_mods m LEFT JOIN players p ON p.id = m.player ORDER BY m.added_at ASC LIMIT ?1').bind(LOUNGE_LIMITS.mods).all();
|
|
207
|
+
return (results ?? []).map((r) => ({ player: r.player, name: r.name ?? null, account: r.name !== null && r.name !== undefined && Number(r.guest) === 0, addedAt: Number(r.added_at) }));
|
|
208
|
+
} catch { return []; }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export async function isMod(env, player) {
|
|
212
|
+
if (!env?.DB || !PLAYER.test(String(player ?? ''))) return false;
|
|
213
|
+
try { return Boolean(await env.DB.prepare('SELECT 1 AS y FROM lounge_mods WHERE player = ?1').bind(player).first()); } catch { return false; }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** A moderator must be a player account with a passkey (a guest cannot be trusted with other people's words). */
|
|
217
|
+
export async function addMod(env, player) {
|
|
218
|
+
if (!PLAYER.test(String(player ?? ''))) return { ok: false, error: 'bad-request', message: 'player is the player\'s id (the office lists them)' };
|
|
219
|
+
const p = await env.DB.prepare('SELECT id, name, guest FROM players WHERE id = ?1').bind(player).first().catch(() => null);
|
|
220
|
+
if (!p) return { ok: false, error: 'no-player', message: 'No player with that id here.' };
|
|
221
|
+
if (Number(p.guest) !== 0) return { ok: false, error: 'guest', message: `${p.name} is a guest: a moderator needs an account with a passkey.` };
|
|
222
|
+
const n = Number((await env.DB.prepare('SELECT COUNT(*) AS n FROM lounge_mods').first())?.n ?? 0);
|
|
223
|
+
if (n >= LOUNGE_LIMITS.mods) return { ok: false, error: 'too-many', message: `At most ${LOUNGE_LIMITS.mods} moderators.` };
|
|
224
|
+
await env.DB.prepare('INSERT OR IGNORE INTO lounge_mods (player, added_at) VALUES (?1, ?2)').bind(player, Date.now()).run();
|
|
225
|
+
return { ok: true, player, name: p.name };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function removeMod(env, player) {
|
|
229
|
+
if (!PLAYER.test(String(player ?? ''))) return { ok: false, error: 'bad-request', message: 'player is the player\'s id' };
|
|
230
|
+
const r = await env.DB.prepare('DELETE FROM lounge_mods WHERE player = ?1').bind(player).run();
|
|
231
|
+
return { ok: true, removed: Number(r?.meta?.changes ?? 0) };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/* ------------------------------------------------------------------ the Lounge's own rules (pure; the build reads them too) */
|
|
235
|
+
|
|
236
|
+
/** The Lounge's quick lines: short, kind, fine for every age (each passes the chat floor). */
|
|
237
|
+
export const LOUNGE_LINES = Object.freeze({ hi: 'Hi all!', welcome: 'Welcome in!', nice: 'Nice one!', making: 'What are you making?', in: 'Count me in!', gg: 'Good game!', soon: 'Back soon!', thanks: 'Thanks!' });
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The Lounge's chat before its owner changes anything (the office, `homie-studio lounge`): reactions and quick lines for
|
|
241
|
+
* anyone, typing for signed-in players (a passkey), a slower pace than a game's, links held (a card is how to show a
|
|
242
|
+
* game), every typed line reviewed, nothing kept past the room's few minutes until the owner turns history on, and
|
|
243
|
+
* homie.rocks may show it live (its visitors react; typing stays here).
|
|
244
|
+
*/
|
|
245
|
+
export const LOUNGE_CHAT = Object.freeze({ mode: 'text', who: 'signed-in', react: 'anyone', slow: 3, max: 280, links: 'block', swears: 'block', ai: true, bubbles: false, watchers: true, hub: true, history: 0, lines: LOUNGE_LINES });
|
|
246
|
+
export const LOUNGE_FEATURED = Object.freeze(['directory', 'studio', 'none']);
|
|
247
|
+
export const LOUNGE_DEFAULT_BLURB = 'Play nights, demos and what everybody is making. Come and say hi.';
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* studio.json "lounge" as the build keeps it: `true`, or `{ name, tab, blurb, kids, featured, chat }`; null when it is
|
|
251
|
+
* off or a game already has /lounge/. `kids` follows a studio made for children ("audience": "kids") unless it says.
|
|
252
|
+
*/
|
|
253
|
+
export function loungeConfig(studio, games = [], { audience = 'general' } = {}) {
|
|
254
|
+
const raw = studio?.lounge;
|
|
255
|
+
if (!raw) return null;
|
|
256
|
+
if ((games ?? []).some((g) => g.id === 'lounge')) return null;
|
|
257
|
+
const o = raw === true ? {} : raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
|
|
258
|
+
if (!o || o.on === false) return null;
|
|
259
|
+
const name = oneLine(o.name, 40) || 'The Lounge';
|
|
260
|
+
const tab = oneLine(o.tab, 16) || 'Lounge';
|
|
261
|
+
const blurb = oneLine(o.blurb, 200) || LOUNGE_DEFAULT_BLURB;
|
|
262
|
+
const kids = o.kids === undefined ? audience === 'kids' : o.kids === true;
|
|
263
|
+
const featured = LOUNGE_FEATURED.includes(o.featured) ? o.featured : 'directory';
|
|
264
|
+
const chat = o.chat && typeof o.chat === 'object' && !Array.isArray(o.chat) ? o.chat : null;
|
|
265
|
+
return { name, tab, blurb, kids, featured, ...(chat ? { chat } : {}) };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** studio.json "lounge" as the build checks it: the problems in words. */
|
|
269
|
+
export function loungeProblems(studio, games = [], chatProblems = () => []) {
|
|
270
|
+
const raw = studio?.lounge;
|
|
271
|
+
if (raw === undefined || raw === false || raw === null) return [];
|
|
272
|
+
const out = [];
|
|
273
|
+
if ((games ?? []).some((g) => g.id === 'lounge')) out.push('a game is called "lounge" and has the page /lounge/, so the Lounge is off: rename the game, or leave "lounge" out');
|
|
274
|
+
if (raw !== true && (typeof raw !== 'object' || Array.isArray(raw))) return [...out, '"lounge" is true, or { "name", "blurb", "kids", "featured", "chat" }'];
|
|
275
|
+
if (raw && typeof raw === 'object') {
|
|
276
|
+
for (const k of Object.keys(raw)) if (!['on', 'name', 'tab', 'blurb', 'kids', 'featured', 'chat'].includes(k)) out.push(`lounge.${k} is not a field (name, tab, blurb, kids, featured, chat)`);
|
|
277
|
+
if (raw.featured !== undefined && !LOUNGE_FEATURED.includes(raw.featured)) out.push(`lounge.featured is ${LOUNGE_FEATURED.join(', ')}`);
|
|
278
|
+
if (raw.chat !== undefined) for (const p of chatProblems(raw.chat)) out.push(p.replace(/^chat\./, 'lounge.chat.'));
|
|
279
|
+
}
|
|
280
|
+
return out.slice(0, 8);
|
|
281
|
+
}
|