@homie-rocks/studio 0.14.4 → 0.16.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/README.md +43 -6
- package/bin/homie-studio.mjs +59 -3
- package/lib/build.mjs +48 -0
- package/lib/office.mjs +7 -6
- package/lib/scaffold.mjs +50 -3
- package/lib/servers.mjs +133 -0
- package/lib/template-history.json +55 -0
- package/netplay/NETPLAY.md +317 -21
- package/netplay/netplay.ts +649 -33
- package/package.json +1 -1
- package/port/bots.ts +25 -2
- package/port/hud.ts +10 -7
- package/port/index.ts +5 -2
- package/port/room.ts +48 -10
- package/port/skill.ts +28 -0
- package/port/touch.ts +4 -1
- package/site/SITE.md +71 -0
- package/starters/ember-vale/src/main.ts +58 -12
- package/starters/gem-rush/src/main.ts +128 -33
- package/worker/account-page.mjs +26 -0
- package/worker/agents.mjs +238 -0
- package/worker/index.mjs +353 -68
- package/worker/office-page.mjs +178 -10
- package/worker/office.mjs +294 -25
- package/worker/pages.mjs +622 -18
- package/worker/players.mjs +11 -3
- package/worker/room.mjs +547 -41
- package/worker/saves.mjs +4 -0
- package/worker/servers.mjs +445 -0
- package/worker/site.mjs +166 -10
- package/worker/stats.mjs +9 -3
- package/worker/version.mjs +1 -1
package/worker/saves.mjs
CHANGED
|
@@ -245,6 +245,10 @@ export async function playerData(env, player) {
|
|
|
245
245
|
try { summary = JSON.parse(m.summary); } catch { summary = {}; }
|
|
246
246
|
of(m.game).memorials.push({ character: m.character, as: m.player_name, summary, at: new Date(Number(m.at)).toISOString() });
|
|
247
247
|
}
|
|
248
|
+
// The servers this player belongs to (0.16.0; a studio before migration 0006 has none).
|
|
249
|
+
let servers = [];
|
|
250
|
+
try { servers = (await env.DB.prepare('SELECT game, server, role, home, joined_at, seen_at FROM server_members WHERE player = ?1 ORDER BY game, server').bind(player).all()).results ?? []; } catch { servers = []; }
|
|
251
|
+
for (const m of servers) (of(m.game).servers ??= []).push({ server: m.server, role: m.role, home: Number(m.home) === 1, joinedAt: new Date(Number(m.joined_at)).toISOString(), seenAt: new Date(Number(m.seen_at)).toISOString() });
|
|
248
252
|
return games;
|
|
249
253
|
}
|
|
250
254
|
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SERVERS (@homie-rocks/studio 0.16.0; NETPLAY.md section 17): a named, lasting pool of rooms for one game, with its
|
|
3
|
+
* own policy, its own door and its own Lobby pool. Strangers are matched only inside one server, never across.
|
|
4
|
+
*
|
|
5
|
+
* public every game's implicit server: its `pub-N` rooms, so every old link and room keeps working. With no
|
|
6
|
+
* D1 row it is open, bots fill, Fair, anyone may come in.
|
|
7
|
+
* s-<id>-<n> a server's rooms. A server id is 2 to 20 lowercase letters, digits and hyphens, unique in its game.
|
|
8
|
+
*
|
|
9
|
+
* POLICIES: `open` (anyone; an AI with a pass may sit, always marked AI), `humans-only` (no AI of any kind; the game's
|
|
10
|
+
* own bots are off unless the owner turns on practice bots), `hybrid` (N seats in every room are AI companions),
|
|
11
|
+
* `beginner` (new players only, AI guides, quick lines only, optionally `kids`).
|
|
12
|
+
*
|
|
13
|
+
* DOORS: `open`, `accounts` (a passkey account), `invite` (an invite to this server), and the beginner check (an
|
|
14
|
+
* account younger than `beginner_days`, below `beginner_level`; a mentor gets in). The door decides who comes in;
|
|
15
|
+
* membership (server_members) is who belongs: a home server per game, and a member badge. Nobody is a member by
|
|
16
|
+
* being let in: a signed-in player who plays here becomes one (one write per server per 10 minutes).
|
|
17
|
+
*
|
|
18
|
+
* Everything is in the studio's own D1 (migration 0006); homie.rocks stores none of it. A game.json `"servers"` list
|
|
19
|
+
* seeds servers the way `"launch"` seeds a launch state: a D1 row with the same id wins.
|
|
20
|
+
*/
|
|
21
|
+
import { SKILLS } from './agents.mjs';
|
|
22
|
+
|
|
23
|
+
export const SERVERS_MIGRATION_FILE = '0006_studio_servers.sql';
|
|
24
|
+
export const SERVERS_MIGRATION = `-- Servers and agent seats (@homie-rocks/studio 0.16.0): named, lasting room pools per game, their policies,
|
|
25
|
+
-- who belongs to them, and the passes that let an AI sit in a seat. Nothing here is about a person beyond a
|
|
26
|
+
-- player id the studio already has; no address, no age.
|
|
27
|
+
CREATE TABLE IF NOT EXISTS servers (
|
|
28
|
+
game TEXT NOT NULL,
|
|
29
|
+
id TEXT NOT NULL,
|
|
30
|
+
name TEXT NOT NULL,
|
|
31
|
+
blurb TEXT,
|
|
32
|
+
policy TEXT NOT NULL,
|
|
33
|
+
ai_seats INTEGER NOT NULL DEFAULT 0,
|
|
34
|
+
guides INTEGER NOT NULL DEFAULT 0,
|
|
35
|
+
bots TEXT NOT NULL DEFAULT 'fill',
|
|
36
|
+
level INTEGER NOT NULL DEFAULT 3,
|
|
37
|
+
level_max INTEGER NOT NULL DEFAULT 5,
|
|
38
|
+
speech TEXT NOT NULL DEFAULT 'game',
|
|
39
|
+
door TEXT NOT NULL DEFAULT 'open',
|
|
40
|
+
kids INTEGER NOT NULL DEFAULT 0,
|
|
41
|
+
beginner_days INTEGER,
|
|
42
|
+
beginner_level INTEGER,
|
|
43
|
+
rooms_max INTEGER NOT NULL DEFAULT 4,
|
|
44
|
+
seats INTEGER,
|
|
45
|
+
brain TEXT NOT NULL DEFAULT 'script',
|
|
46
|
+
listed INTEGER NOT NULL DEFAULT 1,
|
|
47
|
+
state TEXT NOT NULL DEFAULT 'open',
|
|
48
|
+
created_at INTEGER NOT NULL,
|
|
49
|
+
updated_at INTEGER NOT NULL,
|
|
50
|
+
PRIMARY KEY (game, id)
|
|
51
|
+
) WITHOUT ROWID;
|
|
52
|
+
CREATE TABLE IF NOT EXISTS server_members (
|
|
53
|
+
game TEXT NOT NULL,
|
|
54
|
+
server TEXT NOT NULL,
|
|
55
|
+
player TEXT NOT NULL,
|
|
56
|
+
role TEXT NOT NULL DEFAULT 'member',
|
|
57
|
+
home INTEGER NOT NULL DEFAULT 0,
|
|
58
|
+
joined_at INTEGER NOT NULL,
|
|
59
|
+
seen_at INTEGER NOT NULL,
|
|
60
|
+
PRIMARY KEY (game, server, player)
|
|
61
|
+
) WITHOUT ROWID;
|
|
62
|
+
CREATE INDEX IF NOT EXISTS server_members_player ON server_members (player);
|
|
63
|
+
-- An agent pass: one AI's way into a seat. Only the SHA-256 of its secret.
|
|
64
|
+
CREATE TABLE IF NOT EXISTS agent_passes (
|
|
65
|
+
id TEXT PRIMARY KEY,
|
|
66
|
+
hash TEXT NOT NULL UNIQUE,
|
|
67
|
+
label TEXT NOT NULL,
|
|
68
|
+
kind TEXT NOT NULL,
|
|
69
|
+
role TEXT NOT NULL DEFAULT 'party',
|
|
70
|
+
hands TEXT NOT NULL DEFAULT 'self',
|
|
71
|
+
game TEXT,
|
|
72
|
+
server TEXT,
|
|
73
|
+
issuer TEXT NOT NULL DEFAULT 'owner',
|
|
74
|
+
expires_at INTEGER,
|
|
75
|
+
revoked INTEGER NOT NULL DEFAULT 0,
|
|
76
|
+
created_at INTEGER NOT NULL
|
|
77
|
+
) WITHOUT ROWID;
|
|
78
|
+
ALTER TABLE office_invites ADD COLUMN server TEXT;
|
|
79
|
+
`;
|
|
80
|
+
|
|
81
|
+
export const POLICIES = Object.freeze(['open', 'humans-only', 'hybrid', 'beginner']);
|
|
82
|
+
export const DOORS = Object.freeze(['open', 'accounts', 'invite']);
|
|
83
|
+
export const SPEECHES = Object.freeze(['game', 'lines', 'off']);
|
|
84
|
+
export const BRAINS = Object.freeze(['off', 'script', 'workers-ai', 'owner-key']);
|
|
85
|
+
export const MEMBER_ROLES = Object.freeze(['member', 'mentor', 'mod']);
|
|
86
|
+
export const SERVER_ID = /^[a-z0-9][a-z0-9-]{1,19}$/;
|
|
87
|
+
/** At most 12 servers a game and 16 rooms a server (the Lobby keeps every pool in memory). */
|
|
88
|
+
export const SERVER_LIMITS = Object.freeze({ perGame: 12, roomsMax: 16, name: 40, blurb: 160, beginnerDays: 30 });
|
|
89
|
+
const DAY = 86_400_000;
|
|
90
|
+
|
|
91
|
+
/** The badge and the one line each policy shows on a card, the server page, the play shell and the office. */
|
|
92
|
+
export const POLICY_WORDS = Object.freeze({
|
|
93
|
+
open: { badge: 'Open', line: 'Open: anyone can play. AI players are always marked AI.' },
|
|
94
|
+
'humans-only': { badge: 'Humans only', line: 'Humans only: every player here is a person. AI can\'t join.' },
|
|
95
|
+
hybrid: { badge: 'Hybrid', line: 'Hybrid: {n} seats in every room are AI companions, always marked AI. Your party sets their level.' },
|
|
96
|
+
beginner: { badge: 'Beginner', line: 'Beginner: for new players. AI guides help you learn. Chat is quick lines only.' },
|
|
97
|
+
});
|
|
98
|
+
export const KIDS_LINE = 'Players have handles, not names.';
|
|
99
|
+
|
|
100
|
+
/** The badge ("Hybrid · 2") and the line for a server, as its card and page say it. */
|
|
101
|
+
export function policyWords(s) {
|
|
102
|
+
const w = POLICY_WORDS[s?.policy] ?? POLICY_WORDS.open;
|
|
103
|
+
const n = s?.policy === 'hybrid' ? s.aiSeats : 0;
|
|
104
|
+
return {
|
|
105
|
+
badge: s?.policy === 'hybrid' ? `${w.badge} · ${n}` : w.badge,
|
|
106
|
+
line: `${w.line.replace('{n}', String(n))}${s?.kids ? ` ${KIDS_LINE}` : ''}`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* ------------------------------------------------------------------ rooms and servers */
|
|
111
|
+
|
|
112
|
+
/** The server a room belongs to: `pub-N` and any other name are `public`; `s-<id>-<n>` is `<id>`. */
|
|
113
|
+
export function roomServer(code) {
|
|
114
|
+
const m = /^s-([a-z0-9][a-z0-9-]{1,19})-(\d{1,9})$/.exec(String(code ?? ''));
|
|
115
|
+
return m ? m[1] : 'public';
|
|
116
|
+
}
|
|
117
|
+
/** A pool's room: `pub-<n>` for the public server, `s-<id>-<n>` for any other. */
|
|
118
|
+
export const roomCode = (server, n) => (!server || server === 'public' ? `pub-${n}` : `s-${server}-${n}`);
|
|
119
|
+
/** A room the Lobby makes and matches strangers in (a named `?room=` is never pooled). */
|
|
120
|
+
export const pooledRoom = (code) => /^pub-\d{1,9}$/.test(String(code ?? '')) || /^s-[a-z0-9][a-z0-9-]{1,19}-\d{1,9}$/.test(String(code ?? ''));
|
|
121
|
+
|
|
122
|
+
const int = (v, lo, hi, dflt) => { const n = Math.floor(Number(v)); return Number.isFinite(n) ? Math.max(lo, Math.min(hi, n)) : dflt; };
|
|
123
|
+
const oneLine = (v, max) => String(v ?? '').replace(/[\p{Cc}\p{Cf}]+/gu, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* A server as everything else reads it, from a D1 row (snake_case) or a game.json seed (camelCase), with each
|
|
127
|
+
* policy's defaults: humans-only has no AI and its bots off; hybrid keeps 2 AI seats; beginner has 2 guides, quick
|
|
128
|
+
* lines only and a gentle dial.
|
|
129
|
+
*/
|
|
130
|
+
export function serverOf(raw, { from = 'd1' } = {}) {
|
|
131
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
132
|
+
const pick = (snake, camel) => (raw[snake] !== undefined && raw[snake] !== null ? raw[snake] : raw[camel]);
|
|
133
|
+
const id = String(raw.id ?? '');
|
|
134
|
+
if (id !== 'public' && !SERVER_ID.test(id)) return null;
|
|
135
|
+
const policy = POLICIES.includes(raw.policy) ? raw.policy : 'open';
|
|
136
|
+
const human = policy === 'humans-only';
|
|
137
|
+
const kids = policy === 'beginner' && (pick('kids', 'kids') === true || Number(pick('kids', 'kids')) === 1);
|
|
138
|
+
const levelMax = int(pick('level_max', 'levelMax'), 1, kids ? 3 : 5, kids ? 3 : 5);
|
|
139
|
+
return {
|
|
140
|
+
id,
|
|
141
|
+
name: oneLine(raw.name, SERVER_LIMITS.name) || (id === 'public' ? 'Quick play' : id),
|
|
142
|
+
blurb: oneLine(raw.blurb, SERVER_LIMITS.blurb),
|
|
143
|
+
policy,
|
|
144
|
+
aiSeats: human || policy === 'open' ? 0 : int(pick('ai_seats', 'aiSeats'), 0, 31, policy === 'hybrid' ? 2 : 0),
|
|
145
|
+
guides: policy === 'beginner' ? int(pick('guides', 'guides'), 0, 31, 2) : 0,
|
|
146
|
+
bots: (pick('bots', 'bots') ?? (human ? 'off' : 'fill')) === 'off' ? 'off' : 'fill',
|
|
147
|
+
level: Math.min(levelMax, int(pick('level', 'level'), 1, 5, policy === 'beginner' ? 2 : 3)),
|
|
148
|
+
levelMax,
|
|
149
|
+
speech: policy === 'beginner' ? (pick('speech', 'speech') === 'off' ? 'off' : 'lines') : SPEECHES.includes(pick('speech', 'speech')) ? pick('speech', 'speech') : 'game',
|
|
150
|
+
door: DOORS.includes(pick('door', 'door')) ? pick('door', 'door') : 'open',
|
|
151
|
+
kids,
|
|
152
|
+
beginnerDays: policy === 'beginner' ? int(pick('beginner_days', 'beginnerDays'), 1, 3650, SERVER_LIMITS.beginnerDays) : null,
|
|
153
|
+
beginnerLevel: policy === 'beginner' && pick('beginner_level', 'beginnerLevel') !== undefined && pick('beginner_level', 'beginnerLevel') !== null ? int(pick('beginner_level', 'beginnerLevel'), 1, 1_000_000, null) : null,
|
|
154
|
+
roomsMax: int(pick('rooms_max', 'roomsMax'), 1, SERVER_LIMITS.roomsMax, id === 'public' ? SERVER_LIMITS.roomsMax : 4),
|
|
155
|
+
seats: pick('seats', 'seats') === undefined || pick('seats', 'seats') === null ? null : int(pick('seats', 'seats'), 1, 32, null),
|
|
156
|
+
brain: human ? 'off' : BRAINS.includes(pick('brain', 'brain')) ? pick('brain', 'brain') : 'script',
|
|
157
|
+
listed: pick('listed', 'listed') === undefined || pick('listed', 'listed') === null ? true : pick('listed', 'listed') === true || Number(pick('listed', 'listed')) === 1,
|
|
158
|
+
state: ['open', 'closed', 'archived'].includes(raw.state) ? raw.state : 'open',
|
|
159
|
+
createdAt: Number(pick('created_at', 'createdAt')) || 0,
|
|
160
|
+
updatedAt: Number(pick('updated_at', 'updatedAt')) || 0,
|
|
161
|
+
from,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The public server with nothing set: today's behaviour. */
|
|
166
|
+
export const PUBLIC_SERVER = Object.freeze(serverOf({ id: 'public', name: 'Quick play', policy: 'open' }, { from: 'default' }));
|
|
167
|
+
|
|
168
|
+
const serverCache = new WeakMap();
|
|
169
|
+
/** Every server row of every game (D1), read at most every 5 s per Worker instance; an empty map before migration 0006. */
|
|
170
|
+
async function rowsOf(env, { fresh = false } = {}) {
|
|
171
|
+
if (!env?.DB || env.HOMIE_PREVIEW === '1') return new Map();
|
|
172
|
+
const hit = serverCache.get(env.DB);
|
|
173
|
+
if (!fresh && hit && Date.now() - hit.at < 5000) return hit.map;
|
|
174
|
+
let map = new Map();
|
|
175
|
+
try {
|
|
176
|
+
const { results } = await env.DB.prepare('SELECT * FROM servers LIMIT 2000').all();
|
|
177
|
+
for (const r of results ?? []) {
|
|
178
|
+
const list = map.get(r.game) ?? [];
|
|
179
|
+
list.push(r);
|
|
180
|
+
map.set(r.game, list);
|
|
181
|
+
}
|
|
182
|
+
} catch { map = new Map(); }
|
|
183
|
+
serverCache.set(env.DB, { at: Date.now(), map });
|
|
184
|
+
return map;
|
|
185
|
+
}
|
|
186
|
+
export const forgetServers = (env) => { if (env?.DB) serverCache.delete(env.DB); };
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* A game's servers: `public` first, then the rest by when they were made. The game.json seeds (the catalogue's
|
|
190
|
+
* `servers`), with a D1 row of the same id winning. Closed and archived servers are in it (their state says so).
|
|
191
|
+
*/
|
|
192
|
+
export async function serversOf(env, meta, { fresh = false } = {}) {
|
|
193
|
+
const rows = (await rowsOf(env, { fresh })).get(meta?.id) ?? [];
|
|
194
|
+
const byId = new Map();
|
|
195
|
+
byId.set('public', PUBLIC_SERVER);
|
|
196
|
+
for (const seed of Array.isArray(meta?.servers) ? meta.servers.slice(0, SERVER_LIMITS.perGame) : []) {
|
|
197
|
+
const s = serverOf({ ...seed, updatedAt: 1 }, { from: 'game.json' });
|
|
198
|
+
if (s) byId.set(s.id, s);
|
|
199
|
+
}
|
|
200
|
+
for (const r of rows) { const s = serverOf(r, { from: 'd1' }); if (s) byId.set(s.id, s); }
|
|
201
|
+
return [...byId.values()].sort((a, b) => (a.id === 'public' ? -1 : b.id === 'public' ? 1 : a.createdAt - b.createdAt || a.id.localeCompare(b.id)));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* A room's policy (the Worker composes it; the room's Table applies it, NETPLAY.md section 17). AI seats and guides
|
|
206
|
+
* together are at most one fewer than the room's seats (at least one seat is a person's); a kids server caps the
|
|
207
|
+
* dial at 3 and keeps speech to quick lines. `server: null` is a named room (it takes the public server's rules).
|
|
208
|
+
*/
|
|
209
|
+
export function policyOf(server, { seats = 8, named = false } = {}) {
|
|
210
|
+
const s = server ?? PUBLIC_SERVER;
|
|
211
|
+
const room = Math.max(1, Math.min(seats, s.seats ?? seats));
|
|
212
|
+
const guides = s.policy === 'beginner' ? Math.max(0, Math.min(s.guides, room - 1)) : 0;
|
|
213
|
+
const aiSeats = s.policy === 'hybrid' || s.policy === 'beginner' ? Math.max(0, Math.min(s.aiSeats, room - 1 - guides)) : 0;
|
|
214
|
+
const levelMax = s.kids ? Math.min(3, s.levelMax) : s.levelMax;
|
|
215
|
+
return {
|
|
216
|
+
v: 1, at: Math.max(0, Number(s.updatedAt) || 0),
|
|
217
|
+
server: named ? null : { id: s.id, name: s.name },
|
|
218
|
+
kind: s.policy, aiSeats, guides, bots: s.bots,
|
|
219
|
+
level: Math.min(levelMax, s.level), levelMax,
|
|
220
|
+
speech: s.kids && s.speech === 'game' ? 'lines' : s.speech,
|
|
221
|
+
kids: Boolean(s.kids), brain: s.brain,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export const levelName = (n) => SKILLS[Math.max(1, Math.min(5, Math.round(Number(n)) || 3)) - 1].name;
|
|
226
|
+
|
|
227
|
+
/* ------------------------------------------------------------------ the door */
|
|
228
|
+
|
|
229
|
+
/** The cookie a server's invite leaves in a browser (path /<game>/), like a game's invite pass. */
|
|
230
|
+
export const serverPassCookie = (game, server) => `studio_pass_${game}.${server}`;
|
|
231
|
+
|
|
232
|
+
async function sha256(text) {
|
|
233
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
|
|
234
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** This browser's pass to a server's invite door (its invite still open), or null. */
|
|
238
|
+
export async function serverPassOf(request, env, game, server, cookieValues) {
|
|
239
|
+
if (!env?.DB) return null;
|
|
240
|
+
for (const value of cookieValues(request, serverPassCookie(game, server)).filter((v) => /^[a-f0-9]{48}$/.test(v)).slice(0, 2)) {
|
|
241
|
+
try {
|
|
242
|
+
const row = await env.DB.prepare('SELECT p.invite, p.expires_at, i.revoked, i.server FROM office_passes p JOIN office_invites i ON i.id = p.invite WHERE p.hash = ?1 AND p.game = ?2')
|
|
243
|
+
.bind(await sha256(value), game).first();
|
|
244
|
+
if (row && !row.revoked && row.server === server && Number(row.expires_at) > Date.now()) return { invite: row.invite };
|
|
245
|
+
} catch { return null; }
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** A member's row, or null. */
|
|
251
|
+
export async function memberOf(env, game, server, player) {
|
|
252
|
+
if (!env?.DB || !player) return null;
|
|
253
|
+
try { return await env.DB.prepare('SELECT role, home, joined_at, seen_at FROM server_members WHERE game = ?1 AND server = ?2 AND player = ?3').bind(game, server, player).first(); } catch { return null; }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Whether these holders may come in through a server's door: `{ ok, mentor?, owner? }`, or `{ ok: false, why, … }`
|
|
258
|
+
* with `why` one of closed, account, invite, veteran. `holders` are a ticket's (o, i-…, p-…, a-…) or a page's own.
|
|
259
|
+
* The owner always comes in. An agent's own checks are the pass's (worker/agents.mjs passRefusal), not these.
|
|
260
|
+
* `watching`: a watcher is held to an account or invite door, never to the beginner check (it takes no seat).
|
|
261
|
+
*/
|
|
262
|
+
export async function serverAccess(env, { game, server, holders = [], watching = false }) {
|
|
263
|
+
const parts = holders.filter(Boolean);
|
|
264
|
+
if (parts.includes('o')) return { ok: true, owner: true };
|
|
265
|
+
if (!server || server.state !== 'open') return { ok: false, why: 'closed' };
|
|
266
|
+
const pid = parts.find((p) => p.startsWith('p-'))?.slice(2) ?? null;
|
|
267
|
+
const beginner = server.policy === 'beginner' && !watching;
|
|
268
|
+
if (server.door === 'accounts' || (beginner && pid)) {
|
|
269
|
+
let player = null;
|
|
270
|
+
try { player = pid && env?.DB ? await env.DB.prepare('SELECT id, guest, created_at FROM players WHERE id = ?1').bind(pid).first() : null; } catch { player = null; }
|
|
271
|
+
if (server.door === 'accounts' && (!player || Number(player.guest) === 1)) return { ok: false, why: 'account' };
|
|
272
|
+
if (beginner && player) {
|
|
273
|
+
const member = await memberOf(env, game, server.id, pid);
|
|
274
|
+
const mentor = member && (member.role === 'mentor' || member.role === 'mod');
|
|
275
|
+
const days = Math.floor((Date.now() - Number(player.created_at)) / DAY);
|
|
276
|
+
if (!mentor && days >= (server.beginnerDays ?? SERVER_LIMITS.beginnerDays)) return { ok: false, why: 'veteran', days };
|
|
277
|
+
if (!mentor && server.beginnerLevel) {
|
|
278
|
+
let level = 0;
|
|
279
|
+
try { level = Number((await env.DB.prepare("SELECT n FROM player_stats WHERE player = ?1 AND game = ?2 AND name = 'level'").bind(pid, game).first())?.n) || 0; } catch { level = 0; }
|
|
280
|
+
if (level >= server.beginnerLevel) return { ok: false, why: 'veteran', level };
|
|
281
|
+
}
|
|
282
|
+
if (mentor) return { ok: true, mentor: true };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (server.door === 'invite') {
|
|
286
|
+
const invite = parts.find((p) => p.startsWith('i-'))?.slice(2) ?? null;
|
|
287
|
+
let row = null;
|
|
288
|
+
try { row = invite && env?.DB ? await env.DB.prepare('SELECT revoked, server FROM office_invites WHERE id = ?1 AND game = ?2').bind(invite, game).first() : null; } catch { row = null; }
|
|
289
|
+
if (!row || row.revoked || row.server !== server.id) return { ok: false, why: 'invite' };
|
|
290
|
+
}
|
|
291
|
+
return { ok: true };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/* ------------------------------------------------------------------ membership */
|
|
295
|
+
|
|
296
|
+
const seen = new Map();
|
|
297
|
+
/** A signed-in player played here: a member from now (or seen again). One write per (server, player) per 10 minutes. */
|
|
298
|
+
export async function noteMember(env, game, server, player) {
|
|
299
|
+
if (!env?.DB || !player || !SERVER_ID.test(String(server)) && server !== 'public') return false;
|
|
300
|
+
const key = `${game}/${server}/${player}`;
|
|
301
|
+
const now = Date.now();
|
|
302
|
+
if (now - (seen.get(key) ?? 0) < 10 * 60_000) return false;
|
|
303
|
+
seen.set(key, now);
|
|
304
|
+
if (seen.size > 5000) for (const [k, at] of seen) if (now - at > 10 * 60_000) seen.delete(k);
|
|
305
|
+
try {
|
|
306
|
+
await env.DB.prepare(`INSERT INTO server_members (game, server, player, role, home, joined_at, seen_at) VALUES (?1, ?2, ?3, 'member', 0, ?4, ?4)
|
|
307
|
+
ON CONFLICT(game, server, player) DO UPDATE SET seen_at = excluded.seen_at`).bind(game, server, player, now).run();
|
|
308
|
+
return true;
|
|
309
|
+
} catch { return false; }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** How many belong to each server of a game: { <server>: n }. */
|
|
313
|
+
export async function memberCounts(env, game) {
|
|
314
|
+
try {
|
|
315
|
+
const { results } = await env.DB.prepare('SELECT server, COUNT(*) AS n FROM server_members WHERE game = ?1 GROUP BY server').bind(game).all();
|
|
316
|
+
return Object.fromEntries((results ?? []).map((r) => [r.server, Number(r.n) || 0]));
|
|
317
|
+
} catch { return {}; }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** A player's servers of every game: [{ game, server, role, home, joinedAt, seenAt }]. */
|
|
321
|
+
export async function membershipsOf(env, player) {
|
|
322
|
+
try {
|
|
323
|
+
const { results } = await env.DB.prepare('SELECT game, server, role, home, joined_at, seen_at FROM server_members WHERE player = ?1 ORDER BY home DESC, seen_at DESC LIMIT 100').bind(player).all();
|
|
324
|
+
return (results ?? []).map((r) => ({ game: r.game, server: r.server, role: r.role, home: Number(r.home) === 1, joinedAt: Number(r.joined_at), seenAt: Number(r.seen_at) }));
|
|
325
|
+
} catch { return []; }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** A player's home server in a game, or null. */
|
|
329
|
+
export async function homeOf(env, game, player) {
|
|
330
|
+
if (!env?.DB || !player) return null;
|
|
331
|
+
try { return (await env.DB.prepare('SELECT server FROM server_members WHERE game = ?1 AND player = ?2 AND home = 1').bind(game, player).first())?.server ?? null; } catch { return null; }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Join (and optionally make home), or leave: the player's own choice (POST /<game>/s/<id>/home). */
|
|
335
|
+
export async function setMembership(env, game, server, player, { join = true, home = false } = {}) {
|
|
336
|
+
const now = Date.now();
|
|
337
|
+
if (!join) {
|
|
338
|
+
await env.DB.prepare("DELETE FROM server_members WHERE game = ?1 AND server = ?2 AND player = ?3 AND role = 'member'").bind(game, server, player).run();
|
|
339
|
+
await env.DB.prepare('UPDATE server_members SET home = 0 WHERE game = ?1 AND server = ?2 AND player = ?3').bind(game, server, player).run();
|
|
340
|
+
return { ok: true, member: false, home: false };
|
|
341
|
+
}
|
|
342
|
+
const writes = [];
|
|
343
|
+
if (home) writes.push(env.DB.prepare('UPDATE server_members SET home = 0 WHERE game = ?1 AND player = ?2').bind(game, player));
|
|
344
|
+
writes.push(env.DB.prepare(`INSERT INTO server_members (game, server, player, role, home, joined_at, seen_at) VALUES (?1, ?2, ?3, 'member', ?4, ?5, ?5)
|
|
345
|
+
ON CONFLICT(game, server, player) DO UPDATE SET home = CASE WHEN ?4 = 1 THEN 1 ELSE home END, seen_at = excluded.seen_at`).bind(game, server, player, home ? 1 : 0, now));
|
|
346
|
+
await env.DB.batch(writes);
|
|
347
|
+
return { ok: true, member: true, home };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/* ------------------------------------------------------------------ the owner's changes */
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* A new server or a change, checked: `{ ok, fields }` (camelCase, only what was given), or `{ ok: false, message }`.
|
|
354
|
+
* `create` needs a name and a policy (the id comes from the name unless given).
|
|
355
|
+
*/
|
|
356
|
+
export function checkServer(body, { create = false } = {}) {
|
|
357
|
+
const bad = (message) => ({ ok: false, error: 'bad-request', message });
|
|
358
|
+
const f = {};
|
|
359
|
+
if (create || body.name !== undefined) {
|
|
360
|
+
const name = oneLine(body.name, SERVER_LIMITS.name);
|
|
361
|
+
if (!name || !/[\p{L}\p{N}]/u.test(name)) return bad('name is the server\'s name (1 to 40 characters)');
|
|
362
|
+
f.name = name;
|
|
363
|
+
}
|
|
364
|
+
if (create) {
|
|
365
|
+
const id = body.id !== undefined ? String(body.id) : String(f.name).toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 20).replace(/-+$/, '');
|
|
366
|
+
if (!SERVER_ID.test(id) || id === 'public') return bad('id is 2 to 20 lowercase letters, digits and hyphens (and not "public")');
|
|
367
|
+
f.id = id;
|
|
368
|
+
}
|
|
369
|
+
if (create || body.policy !== undefined) {
|
|
370
|
+
if (!POLICIES.includes(body.policy)) return bad('policy is open, humans-only, hybrid or beginner');
|
|
371
|
+
f.policy = body.policy;
|
|
372
|
+
}
|
|
373
|
+
if (body.blurb !== undefined) f.blurb = oneLine(body.blurb, SERVER_LIMITS.blurb);
|
|
374
|
+
const num = (k, lo, hi) => { if (body[k] === undefined) return true; const n = Math.floor(Number(body[k])); if (!Number.isFinite(n) || n < lo || n > hi) return false; f[k] = n; return true; };
|
|
375
|
+
if (!num('aiSeats', 0, 31)) return bad('aiSeats is 0 to 31 (and at most the room\'s seats less one)');
|
|
376
|
+
if (!num('guides', 0, 31)) return bad('guides is 0 to 31');
|
|
377
|
+
if (!num('level', 1, 5)) return bad('level is 1 to 5 (Rookie, Steady, Fair, Strong, Maxed)');
|
|
378
|
+
if (!num('levelMax', 1, 5)) return bad('levelMax is 1 to 5');
|
|
379
|
+
if (!num('rooms', 1, SERVER_LIMITS.roomsMax)) return bad(`rooms is 1 to ${SERVER_LIMITS.roomsMax}`);
|
|
380
|
+
if (!num('beginnerDays', 1, 3650)) return bad('beginnerDays is 1 to 3650');
|
|
381
|
+
if (body.beginnerLevel === null) f.beginnerLevel = null; else if (!num('beginnerLevel', 1, 1_000_000)) return bad('beginnerLevel is a number, or null for none');
|
|
382
|
+
if (body.seats === null) f.seats = null; else if (!num('seats', 1, 32)) return bad('seats is 1 to 32, or null for the game\'s own');
|
|
383
|
+
if (body.kids !== undefined) f.kids = body.kids === true || body.kids === 'on';
|
|
384
|
+
if (body.listed !== undefined) f.listed = !(body.listed === false || body.listed === 'off');
|
|
385
|
+
if (body.bots !== undefined) { if (!['fill', 'off'].includes(body.bots)) return bad('bots is fill or off'); f.bots = body.bots; }
|
|
386
|
+
if (body.speech !== undefined) { if (!SPEECHES.includes(body.speech)) return bad('speech is game, lines or off'); f.speech = body.speech; }
|
|
387
|
+
if (body.door !== undefined) { if (!DOORS.includes(body.door)) return bad('door is open, accounts or invite'); f.door = body.door; }
|
|
388
|
+
if (body.brain !== undefined) { if (!BRAINS.includes(body.brain)) return bad('brain is off, script, workers-ai or owner-key'); f.brain = body.brain; }
|
|
389
|
+
if (f.levelMax !== undefined && f.level !== undefined && f.level > f.levelMax) f.level = f.levelMax;
|
|
390
|
+
return { ok: true, fields: f };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const DOOR_RANK = { open: 0, accounts: 1, invite: 2 };
|
|
394
|
+
/**
|
|
395
|
+
* Whether a change takes something away from people (DESIGN D13: the AI only ASKS for these): a stricter door, any
|
|
396
|
+
* policy change to humans-only (agents leave after the round), closing it, a beginner check made stricter, or fewer
|
|
397
|
+
* rooms or seats. Everything else (a new name, more AI seats, a wider door) happens at once.
|
|
398
|
+
*/
|
|
399
|
+
export function narrows(before, f) {
|
|
400
|
+
if (!before) return false;
|
|
401
|
+
if (f.door !== undefined && DOOR_RANK[f.door] > DOOR_RANK[before.door]) return true;
|
|
402
|
+
if (f.policy !== undefined && f.policy !== before.policy && (f.policy === 'humans-only' || f.policy === 'beginner')) return true;
|
|
403
|
+
if (f.beginnerDays !== undefined && before.beginnerDays && f.beginnerDays < before.beginnerDays) return true;
|
|
404
|
+
if (f.beginnerLevel !== undefined && f.beginnerLevel !== null && (!before.beginnerLevel || f.beginnerLevel < before.beginnerLevel)) return true;
|
|
405
|
+
if (f.rooms !== undefined && f.rooms < before.roomsMax) return true;
|
|
406
|
+
if (f.seats !== undefined && f.seats !== null && (before.seats === null || f.seats < before.seats)) return true;
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** The D1 row for a server (a new one, or the old one with the change), ready for INSERT OR REPLACE. */
|
|
411
|
+
export function rowFor(game, before, f, now = Date.now()) {
|
|
412
|
+
const b = before ?? serverOf({ id: f.id, name: f.name, policy: f.policy }, { from: 'd1' });
|
|
413
|
+
const next = serverOf({
|
|
414
|
+
id: b.id, name: f.name ?? b.name, blurb: f.blurb ?? b.blurb, policy: f.policy ?? b.policy,
|
|
415
|
+
aiSeats: f.aiSeats ?? (f.policy && f.policy !== b.policy ? undefined : b.aiSeats),
|
|
416
|
+
guides: f.guides ?? (f.policy && f.policy !== b.policy ? undefined : b.guides),
|
|
417
|
+
bots: f.bots ?? (f.policy && f.policy !== b.policy ? undefined : b.bots),
|
|
418
|
+
level: f.level ?? b.level, levelMax: f.levelMax ?? b.levelMax,
|
|
419
|
+
speech: f.speech ?? (f.policy && f.policy !== b.policy ? undefined : b.speech),
|
|
420
|
+
door: f.door ?? b.door, kids: f.kids ?? b.kids,
|
|
421
|
+
beginnerDays: f.beginnerDays ?? b.beginnerDays, beginnerLevel: f.beginnerLevel !== undefined ? f.beginnerLevel : b.beginnerLevel,
|
|
422
|
+
roomsMax: f.rooms ?? b.roomsMax, seats: f.seats !== undefined ? f.seats : b.seats,
|
|
423
|
+
brain: f.brain ?? (f.policy && f.policy !== b.policy ? undefined : b.brain), listed: f.listed ?? b.listed,
|
|
424
|
+
state: f.state ?? b.state, createdAt: b.createdAt || now, updatedAt: Math.max(now, (b.updatedAt || 0) + 1),
|
|
425
|
+
}, { from: 'd1' });
|
|
426
|
+
return next;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export async function writeServer(env, game, s) {
|
|
430
|
+
await env.DB.prepare(`INSERT OR REPLACE INTO servers (game, id, name, blurb, policy, ai_seats, guides, bots, level, level_max, speech, door, kids, beginner_days, beginner_level, rooms_max, seats, brain, listed, state, created_at, updated_at)
|
|
431
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22)`)
|
|
432
|
+
.bind(game, s.id, s.name, s.blurb || null, s.policy, s.aiSeats, s.guides, s.bots, s.level, s.levelMax, s.speech, s.door, s.kids ? 1 : 0, s.beginnerDays, s.beginnerLevel, s.roomsMax, s.seats, s.brain, s.listed ? 1 : 0, s.state, s.createdAt, s.updatedAt).run();
|
|
433
|
+
forgetServers(env);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** A server as the office, the API and the pages show it. */
|
|
437
|
+
export function serverView(s, { origin = '', game = '' } = {}) {
|
|
438
|
+
const w = policyWords(s);
|
|
439
|
+
return {
|
|
440
|
+
id: s.id, name: s.name, blurb: s.blurb, policy: s.policy, badge: w.badge, line: w.line, aiSeats: s.aiSeats, guides: s.guides, bots: s.bots,
|
|
441
|
+
level: s.level, levelName: levelName(s.level), levelMax: s.levelMax, speech: s.speech, door: s.door, kids: s.kids,
|
|
442
|
+
beginnerDays: s.beginnerDays, beginnerLevel: s.beginnerLevel, rooms: s.roomsMax, seats: s.seats, brain: s.brain, listed: s.listed, state: s.state, from: s.from,
|
|
443
|
+
page: `${origin}/${game}/${s.id === 'public' ? '' : `s/${s.id}/`}`, play: `${origin}/${game}/${s.id === 'public' ? 'play' : `s/${s.id}/play`}`,
|
|
444
|
+
};
|
|
445
|
+
}
|