@homie-rocks/studio 0.10.0 → 0.12.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.
@@ -0,0 +1,287 @@
1
+ /**
2
+ * CLOUD SAVES, LIFETIME STATS AND MEMORIALS — per player, per game, in the studio's own D1 (saves/SAVES.md).
3
+ *
4
+ * saves key → JSON value, versioned (every write adds one), per player and game. A write may name the
5
+ * version it was based on (`base`): if another device wrote in between, it is refused with the
6
+ * current value (a conflict), so a stale offline copy never overwrites newer progress silently.
7
+ * player_stats numbers that only add up (or keep the highest or lowest) and survive a wipe: time played, gold
8
+ * earned, kills, best level. A hardcore death wipes the saves and keeps these.
9
+ * memorials the hall of the fallen: a character's name, the player's display name and a small summary,
10
+ * written when a hardcore character dies (optionally wiping that game's saves in the same step).
11
+ *
12
+ * LIMITS (SAVE_LIMITS): 64 KiB a value, 64 keys and 256 KiB a game per player in D1. With the studio's storage (R2,
13
+ * `homie-studio storage add`) a value up to 1 MiB goes to R2 instead (4 MiB a game per player), under a random
14
+ * object key that the public /media/ route refuses. Keys are 1 to 64 of A-Z a-z 0-9 _ . : -.
15
+ * A write is atomic per key, not across keys: keep a character in ONE key.
16
+ */
17
+ import { randomToken } from './webauthn.mjs';
18
+
19
+ export const SAVE_LIMITS = Object.freeze({
20
+ valueBytes: 64 * 1024, gameBytes: 256 * 1024, keys: 64, batch: 32,
21
+ blobBytes: 1024 * 1024, gameBlobBytes: 4 * 1024 * 1024,
22
+ stats: 64, memorialBytes: 2048, memorialsPerDay: 20, fallenList: 50,
23
+ });
24
+ export const SAVE_KEY = /^[A-Za-z0-9_.:-]{1,64}$/;
25
+ export const STAT_NAME = /^[A-Za-z0-9_.:-]{1,32}$/;
26
+ const BLOB = /^r2:(players\/[A-Za-z0-9_-]{16,64})$/;
27
+ const ENC = new TextEncoder();
28
+ const bytesOf = (text) => ENC.encode(text).length;
29
+
30
+ /** The R2 object a blob row points at, or null for a value kept in D1. */
31
+ const blobKey = (row) => (row && Number(row.blob) === 1 ? BLOB.exec(String(row.value))?.[1] ?? null : null);
32
+
33
+ async function valueOf(env, row) {
34
+ const key = blobKey(row);
35
+ if (!key) { try { return JSON.parse(row.value); } catch { return null; } }
36
+ if (!env.MEDIA) return null;
37
+ const obj = await env.MEDIA.get(key);
38
+ if (!obj) return null;
39
+ try { return JSON.parse(await obj.text()); } catch { return null; }
40
+ }
41
+
42
+ async function dropBlobs(env, keys) {
43
+ const list = keys.filter(Boolean);
44
+ if (!list.length || !env.MEDIA) return;
45
+ try { await env.MEDIA.delete(list); } catch { /* a missing object is already gone */ }
46
+ }
47
+
48
+ /** Every key of one player's game, with its version and size; `values` adds each value. */
49
+ export async function listSaves(env, player, game, { values = false } = {}) {
50
+ const rows = (await env.DB.prepare('SELECT key, value, bytes, version, updated_at, blob FROM saves WHERE player = ?1 AND game = ?2 ORDER BY key LIMIT 200').bind(player, game).all()).results ?? [];
51
+ const keys = [];
52
+ for (const r of rows) {
53
+ const k = { key: r.key, version: Number(r.version), bytes: Number(r.bytes), updatedAt: Number(r.updated_at) };
54
+ if (values) k.value = await valueOf(env, r);
55
+ keys.push(k);
56
+ }
57
+ return { keys, used: usage(rows) };
58
+ }
59
+
60
+ function usage(rows) {
61
+ let bytes = 0; let blobBytes = 0;
62
+ for (const r of rows) { if (Number(r.blob) === 1) blobBytes += Number(r.bytes); else bytes += Number(r.bytes); }
63
+ return { keys: rows.length, bytes, blobBytes };
64
+ }
65
+
66
+ export async function getSave(env, player, game, key) {
67
+ if (!SAVE_KEY.test(String(key))) return { ok: false, error: 'key', message: 'a save key is 1 to 64 letters, digits, _ . : or -' };
68
+ const row = await env.DB.prepare('SELECT value, bytes, version, updated_at, blob FROM saves WHERE player = ?1 AND game = ?2 AND key = ?3').bind(player, game, key).first();
69
+ if (!row) return { ok: true, key, value: null, version: 0, updatedAt: null };
70
+ return { ok: true, key, value: await valueOf(env, row), version: Number(row.version), updatedAt: Number(row.updated_at) };
71
+ }
72
+
73
+ /**
74
+ * Write and delete keys of one player's game. Each item: { key, value, base? } to set, { key, base? } to delete.
75
+ * `base` is the version the change was made from (0: the key did not exist); without it the write is
76
+ * unconditional. Returns one result per item: { key, ok: true, version } or { key, ok: false, error, current? }.
77
+ */
78
+ export async function writeSaves(env, player, game, { set = [], del = [] } = {}) {
79
+ const items = [...(Array.isArray(set) ? set : []).map((s) => ({ ...s, op: 'set' })), ...(Array.isArray(del) ? del : []).map((s) => ({ ...s, op: 'del' }))];
80
+ if (!items.length) return { ok: true, results: [] };
81
+ if (items.length > SAVE_LIMITS.batch) return { ok: false, error: 'batch', message: `at most ${SAVE_LIMITS.batch} keys in one write` };
82
+ const rows = (await env.DB.prepare('SELECT key, value, bytes, version, blob FROM saves WHERE player = ?1 AND game = ?2').bind(player, game).all()).results ?? [];
83
+ const now = new Map(rows.map((r) => [r.key, r]));
84
+ const results = [];
85
+ const at = Date.now();
86
+ for (const item of items) {
87
+ const key = String(item?.key ?? '');
88
+ if (!SAVE_KEY.test(key)) { results.push({ key, ok: false, error: 'key', message: 'a save key is 1 to 64 letters, digits, _ . : or -' }); continue; }
89
+ const base = item.base === undefined || item.base === null ? null : Math.floor(Number(item.base));
90
+ if (base !== null && !(base >= 0)) { results.push({ key, ok: false, error: 'base', message: 'base is the version the change was made from (0 when the key is new)' }); continue; }
91
+ const prev = now.get(key) ?? null;
92
+ if (base !== null && (prev ? Number(prev.version) : 0) !== base) {
93
+ results.push({ key, ok: false, error: 'conflict', message: 'another device saved this key since', current: prev ? { value: await valueOf(env, prev), version: Number(prev.version) } : { value: null, version: 0 } });
94
+ continue;
95
+ }
96
+ if (item.op === 'del') {
97
+ if (!prev) { results.push({ key, ok: true, version: 0 }); continue; }
98
+ const gone = await env.DB.prepare('DELETE FROM saves WHERE player = ?1 AND game = ?2 AND key = ?3 AND version = ?4 RETURNING version').bind(player, game, key, Number(prev.version)).first();
99
+ if (!gone) { results.push({ key, ok: false, error: 'conflict', message: 'another device saved this key since' }); continue; }
100
+ await dropBlobs(env, [blobKey(prev)]);
101
+ now.delete(key);
102
+ results.push({ key, ok: true, version: 0 });
103
+ continue;
104
+ }
105
+ if (item.value === undefined) { results.push({ key, ok: false, error: 'value', message: 'a save needs a value (null is a value; use del to remove a key)' }); continue; }
106
+ let text;
107
+ try { text = JSON.stringify(item.value); } catch { results.push({ key, ok: false, error: 'value', message: 'a save is JSON' }); continue; }
108
+ const size = bytesOf(text);
109
+ const others = [...now.values()].filter((r) => r.key !== key);
110
+ const used = usage(others);
111
+ if (!prev && others.length >= SAVE_LIMITS.keys) { results.push({ key, ok: false, error: 'too-many-keys', message: `a game keeps at most ${SAVE_LIMITS.keys} keys per player` }); continue; }
112
+ let stored = text; let blob = 0; let newObject = null;
113
+ if (size > SAVE_LIMITS.valueBytes) {
114
+ if (!env.MEDIA) { results.push({ key, ok: false, error: 'too-large', message: `a save is at most ${SAVE_LIMITS.valueBytes / 1024} KiB (this studio has no storage for larger ones)` }); continue; }
115
+ if (size > SAVE_LIMITS.blobBytes) { results.push({ key, ok: false, error: 'too-large', message: `a save is at most ${SAVE_LIMITS.blobBytes / 1024} KiB` }); continue; }
116
+ if (used.blobBytes + size > SAVE_LIMITS.gameBlobBytes) { results.push({ key, ok: false, error: 'full', message: `a game keeps at most ${SAVE_LIMITS.gameBlobBytes / 1048576} MiB of large saves per player` }); continue; }
117
+ newObject = `players/${randomToken(24)}`;
118
+ await env.MEDIA.put(newObject, text, { httpMetadata: { contentType: 'application/json' } });
119
+ stored = `r2:${newObject}`; blob = 1;
120
+ } else if (used.bytes + size > SAVE_LIMITS.gameBytes) {
121
+ results.push({ key, ok: false, error: 'full', message: `a game keeps at most ${SAVE_LIMITS.gameBytes / 1024} KiB of saves per player` });
122
+ continue;
123
+ }
124
+ let row;
125
+ if (base === null) {
126
+ row = await env.DB.prepare('INSERT INTO saves (player, game, key, value, bytes, version, updated_at, blob) VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6, ?7) ON CONFLICT(player, game, key) DO UPDATE SET value = excluded.value, bytes = excluded.bytes, version = saves.version + 1, updated_at = excluded.updated_at, blob = excluded.blob RETURNING version')
127
+ .bind(player, game, key, stored, size, at, blob).first();
128
+ } else if (base === 0) {
129
+ row = await env.DB.prepare('INSERT INTO saves (player, game, key, value, bytes, version, updated_at, blob) VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6, ?7) ON CONFLICT(player, game, key) DO NOTHING RETURNING version')
130
+ .bind(player, game, key, stored, size, at, blob).first();
131
+ } else {
132
+ row = await env.DB.prepare('UPDATE saves SET value = ?4, bytes = ?5, version = version + 1, updated_at = ?6, blob = ?7 WHERE player = ?1 AND game = ?2 AND key = ?3 AND version = ?8 RETURNING version')
133
+ .bind(player, game, key, stored, size, at, blob, base).first();
134
+ }
135
+ if (!row) {
136
+ await dropBlobs(env, [newObject]);
137
+ results.push({ key, ok: false, error: 'conflict', message: 'another device saved this key since' });
138
+ continue;
139
+ }
140
+ if (prev) await dropBlobs(env, [blobKey(prev)]);
141
+ now.set(key, { key, value: stored, bytes: size, version: Number(row.version), blob });
142
+ results.push({ key, ok: true, version: Number(row.version) });
143
+ }
144
+ return { ok: true, results, used: usage([...now.values()]) };
145
+ }
146
+
147
+ /** Every save of one player's game, except the `keep` keys: a hardcore death, or "new game". */
148
+ export function wipeStatements(env, player, game, keep = []) {
149
+ const kept = (Array.isArray(keep) ? keep : []).filter((k) => SAVE_KEY.test(String(k))).slice(0, SAVE_LIMITS.keys);
150
+ const marks = kept.map((_, i) => `?${i + 3}`).join(', ');
151
+ return env.DB.prepare(`DELETE FROM saves WHERE player = ?1 AND game = ?2${kept.length ? ` AND key NOT IN (${marks})` : ''} RETURNING key, value, blob`).bind(player, game, ...kept);
152
+ }
153
+
154
+ export async function wipeSaves(env, player, game, { keep = [] } = {}) {
155
+ const gone = (await wipeStatements(env, player, game, keep).all()).results ?? [];
156
+ await dropBlobs(env, gone.map(blobKey));
157
+ return { ok: true, wiped: gone.map((r) => r.key) };
158
+ }
159
+
160
+ /* ------------------------------------------------------------------ lifetime stats */
161
+
162
+ export async function readPlayerStats(env, player, game) {
163
+ const rows = (await env.DB.prepare('SELECT name, n FROM player_stats WHERE player = ?1 AND game = ?2 ORDER BY name LIMIT 100').bind(player, game).all()).results ?? [];
164
+ return Object.fromEntries(rows.map((r) => [r.name, Number(r.n)]));
165
+ }
166
+
167
+ /**
168
+ * Add to counters (`add`), keep the highest (`max`) or the lowest (`min`): { add: { kills: 1 }, max: { level: 7 } }.
169
+ * Numbers only, finite, |n| < 1e15. They never go back on their own: a wipe keeps them.
170
+ */
171
+ export async function bumpPlayerStats(env, player, game, { add = {}, max = {}, min = {} } = {}) {
172
+ const ops = [];
173
+ for (const [kind, obj] of [['add', add], ['max', max], ['min', min]]) {
174
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) continue;
175
+ for (const [name, raw] of Object.entries(obj)) {
176
+ const n = Number(raw);
177
+ if (!STAT_NAME.test(name)) return { ok: false, error: 'stat', message: `a stat name is 1 to 32 letters, digits, _ . : or - (got ${JSON.stringify(String(name).slice(0, 40))})` };
178
+ if (!Number.isFinite(n) || Math.abs(n) >= 1e15) return { ok: false, error: 'stat', message: `${name} is not a number a stat can hold` };
179
+ if (kind === 'add' && n === 0) continue;
180
+ ops.push({ kind, name, n });
181
+ }
182
+ }
183
+ if (ops.length > SAVE_LIMITS.stats) return { ok: false, error: 'stat', message: `at most ${SAVE_LIMITS.stats} stats at once` };
184
+ if (!ops.length) return { ok: true, stats: await readPlayerStats(env, player, game) };
185
+ const have = await readPlayerStats(env, player, game);
186
+ const fresh = new Set(ops.map((o) => o.name).filter((name) => !(name in have)));
187
+ if (Object.keys(have).length + fresh.size > SAVE_LIMITS.stats) return { ok: false, error: 'stat', message: `a game keeps at most ${SAVE_LIMITS.stats} stats per player` };
188
+ const at = Date.now();
189
+ const sql = {
190
+ add: 'INSERT INTO player_stats (player, game, name, n, updated_at) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(player, game, name) DO UPDATE SET n = player_stats.n + excluded.n, updated_at = excluded.updated_at',
191
+ max: 'INSERT INTO player_stats (player, game, name, n, updated_at) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(player, game, name) DO UPDATE SET n = max(player_stats.n, excluded.n), updated_at = excluded.updated_at',
192
+ min: 'INSERT INTO player_stats (player, game, name, n, updated_at) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(player, game, name) DO UPDATE SET n = min(player_stats.n, excluded.n), updated_at = excluded.updated_at',
193
+ };
194
+ await env.DB.batch(ops.map((o) => env.DB.prepare(sql[o.kind]).bind(player, game, o.name, o.n, at)));
195
+ return { ok: true, stats: await readPlayerStats(env, player, game) };
196
+ }
197
+
198
+ /* ------------------------------------------------------------------ the hall of the fallen */
199
+
200
+ /**
201
+ * A memorial: { character, summary, wipe, keep }. `character` follows the display-name rules (the caller cleans
202
+ * it); `summary` is a small JSON object (level, cause, gold, days survived…), 2 KiB at most. `wipe: true` deletes
203
+ * this game's saves (except `keep`) in the SAME transaction, so a death and its wipe can never half-happen.
204
+ */
205
+ export async function fall(env, player, playerName, game, { character, summary = {}, wipe = false, keep = [] }) {
206
+ if (summary === null || typeof summary !== 'object' || Array.isArray(summary)) return { ok: false, error: 'summary', message: 'a memorial summary is a JSON object' };
207
+ const text = JSON.stringify(summary);
208
+ if (bytesOf(text) > SAVE_LIMITS.memorialBytes) return { ok: false, error: 'too-large', message: `a memorial summary is at most ${SAVE_LIMITS.memorialBytes} bytes` };
209
+ const at = Date.now();
210
+ const today = await env.DB.prepare('SELECT COUNT(*) AS n FROM memorials WHERE player = ?1 AND game = ?2 AND at >= ?3').bind(player, game, at - 86400_000).first();
211
+ if (Number(today?.n) >= SAVE_LIMITS.memorialsPerDay) return { ok: false, error: 'rate', message: `at most ${SAVE_LIMITS.memorialsPerDay} memorials a day per player` };
212
+ const insert = env.DB.prepare('INSERT INTO memorials (player, game, character, player_name, summary, at) VALUES (?1, ?2, ?3, ?4, ?5, ?6) RETURNING id').bind(player, game, character, playerName, text, at);
213
+ const [made, wiped] = await env.DB.batch(wipe ? [insert, wipeStatements(env, player, game, keep)] : [insert]);
214
+ const gone = wiped?.results ?? [];
215
+ await dropBlobs(env, gone.map(blobKey));
216
+ return { ok: true, memorial: { id: Number(made?.results?.[0]?.id ?? 0), character, player: playerName, summary, at }, wiped: gone.map((r) => r.key) };
217
+ }
218
+
219
+ /** The newest memorials of a game (everyone's), or one player's own: { character, player, summary, at }. */
220
+ export async function fallenList(env, game, { limit = 20, player = null } = {}) {
221
+ const n = Math.max(1, Math.min(SAVE_LIMITS.fallenList, Math.floor(Number(limit)) || 20));
222
+ const rows = (await (player
223
+ ? env.DB.prepare('SELECT character, player_name, summary, at FROM memorials WHERE game = ?1 AND player = ?2 ORDER BY at DESC LIMIT ?3').bind(game, player, n)
224
+ : env.DB.prepare('SELECT character, player_name, summary, at FROM memorials WHERE game = ?1 ORDER BY at DESC LIMIT ?2').bind(game, n)).all()).results ?? [];
225
+ return rows.map((r) => {
226
+ let summary = {};
227
+ try { summary = JSON.parse(r.summary); } catch { summary = {}; }
228
+ return { character: r.character, player: r.player_name, summary, at: Number(r.at) };
229
+ });
230
+ }
231
+
232
+ /* ------------------------------------------------------------------ a player's whole data */
233
+
234
+ /** Everything this player has in every game, for their export (values included, large ones from R2). */
235
+ export async function playerData(env, player) {
236
+ const rows = (await env.DB.prepare('SELECT game, key, value, bytes, version, updated_at, blob FROM saves WHERE player = ?1 ORDER BY game, key').bind(player).all()).results ?? [];
237
+ const stats = (await env.DB.prepare('SELECT game, name, n, updated_at FROM player_stats WHERE player = ?1 ORDER BY game, name').bind(player).all()).results ?? [];
238
+ const mem = (await env.DB.prepare('SELECT game, character, player_name, summary, at FROM memorials WHERE player = ?1 ORDER BY at').bind(player).all()).results ?? [];
239
+ const games = {};
240
+ const of = (g) => (games[g] ??= { saves: {}, stats: {}, memorials: [] });
241
+ for (const r of rows) of(r.game).saves[r.key] = { value: await valueOf(env, r), version: Number(r.version), updatedAt: new Date(Number(r.updated_at)).toISOString() };
242
+ for (const s of stats) of(s.game).stats[s.name] = Number(s.n);
243
+ for (const m of mem) {
244
+ let summary = {};
245
+ try { summary = JSON.parse(m.summary); } catch { summary = {}; }
246
+ of(m.game).memorials.push({ character: m.character, as: m.player_name, summary, at: new Date(Number(m.at)).toISOString() });
247
+ }
248
+ return games;
249
+ }
250
+
251
+ /** Per game: keys, bytes and the last write, for the owner's view of a player (never the values). */
252
+ export async function savesSummary(env, player) {
253
+ const rows = (await env.DB.prepare('SELECT game, COUNT(*) AS keys, SUM(bytes) AS bytes, MAX(updated_at) AS at FROM saves WHERE player = ?1 GROUP BY game').bind(player).all()).results ?? [];
254
+ return rows.map((r) => ({ game: r.game, keys: Number(r.keys), bytes: Number(r.bytes) || 0, updatedAt: Number(r.at) || null }));
255
+ }
256
+
257
+ /** The statements that delete a player's saves, stats and memorials (and their large saves' objects, after). */
258
+ export async function dropPlayerData(env, player) {
259
+ const blobs = (await env.DB.prepare('SELECT value, blob FROM saves WHERE player = ?1 AND blob = 1').bind(player).all()).results ?? [];
260
+ return {
261
+ statements: [
262
+ env.DB.prepare('DELETE FROM saves WHERE player = ?1').bind(player),
263
+ env.DB.prepare('DELETE FROM player_stats WHERE player = ?1').bind(player),
264
+ env.DB.prepare('DELETE FROM memorials WHERE player = ?1').bind(player),
265
+ ],
266
+ after: () => dropBlobs(env, blobs.map(blobKey)),
267
+ };
268
+ }
269
+
270
+ /**
271
+ * A guest signs in to an account they already have: the account keeps what it has, and whatever only the guest
272
+ * had moves over (saves of keys the account lacks, stats it lacks, the guest's memorials). Statements for one
273
+ * batch, and the guest's large saves that the account already had (to drop after).
274
+ */
275
+ export async function adoptStatements(env, from, to) {
276
+ const clash = (await env.DB.prepare('SELECT g.value, g.blob FROM saves g JOIN saves a ON a.player = ?2 AND a.game = g.game AND a.key = g.key WHERE g.player = ?1 AND g.blob = 1').bind(from, to).all()).results ?? [];
277
+ return {
278
+ statements: [
279
+ env.DB.prepare('INSERT OR IGNORE INTO saves (player, game, key, value, bytes, version, updated_at, blob) SELECT ?2, game, key, value, bytes, version, updated_at, blob FROM saves WHERE player = ?1').bind(from, to),
280
+ env.DB.prepare('INSERT OR IGNORE INTO player_stats (player, game, name, n, updated_at) SELECT ?2, game, name, n, updated_at FROM player_stats WHERE player = ?1').bind(from, to),
281
+ env.DB.prepare('UPDATE memorials SET player = ?2 WHERE player = ?1').bind(from, to),
282
+ env.DB.prepare('DELETE FROM saves WHERE player = ?1').bind(from),
283
+ env.DB.prepare('DELETE FROM player_stats WHERE player = ?1').bind(from),
284
+ ],
285
+ after: () => dropBlobs(env, clash.map(blobKey)),
286
+ };
287
+ }
package/worker/site.mjs CHANGED
@@ -912,7 +912,7 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
912
912
  <a class="play" href="/${esc(g.id)}/play" data-play>${PLAY_ICON}<span>Play now — free</span></a>
913
913
  ${liveLine(g, playing, { idle: `Bots hold every empty seat · one tap and you are in` })}
914
914
  </div>
915
- ${tvOn ? `<a class="also" href="#anywhere">${icon('tv')}<span>Or put it on the big screen</span></a>` : ''}${trailer ? `<a class="also" href="/videos/${esc(trailer.slug)}/">${icon('videos')}<span>Watch the trailer</span></a>` : ''}
915
+ ${tvOn ? `<a class="also" href="#anywhere">${icon('tv')}<span>Or put it on the big screen</span></a>` : ''}${trailer ? `<a class="also" href="/videos/${esc(trailer.slug)}/">${icon('videos')}<span>Watch the trailer</span></a>` : ''}${g.saves ? `<a class="also" href="/account/?next=${encodeURIComponent(`/${g.id}/play`)}" data-account>${icon('spark')}<span>Your progress follows you: sign in</span></a>` : ''}
916
916
  </div>
917
917
  </section>`;
918
918
 
@@ -8,6 +8,19 @@
8
8
  * never cached, never indexed.
9
9
  */
10
10
  import { OWNER_COOKIE, endSession, ownerAllowed, rangeOf, readStats, spendSignin, onlyOf } from './stats.mjs';
11
+ import { CODEX_SCRIPT } from './codex-script.mjs';
12
+
13
+ /** Where a sign-in may send the owner next: the stats, or one game's codex. */
14
+ const NEXT = /^\/_studio\/(?:stats|codex\/[a-z0-9][a-z0-9-]{0,39}\/)$/;
15
+ const nextOf = (url) => { const to = url.searchParams.get('to') ?? ''; return NEXT.test(to) ? to : '/_studio/stats'; };
16
+ let scriptHash = null;
17
+ async function codexScriptHash() {
18
+ if (!scriptHash) {
19
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(CODEX_SCRIPT));
20
+ scriptHash = `sha256-${btoa(String.fromCharCode(...new Uint8Array(digest)))}`;
21
+ }
22
+ return scriptHash;
23
+ }
11
24
 
12
25
  const esc = (value) => String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
13
26
  const fmt = (n) => Number(n || 0).toLocaleString('en-US');
@@ -93,6 +106,7 @@ function statsBody(cat, s, url) {
93
106
  ${tile(t.visits, 'visits')}${tile(t.plays, 'Play presses')}${tile(t.rooms, 'rooms opened')}${tile(t.rounds, 'rounds finished')}
94
107
  ${tile(t.peopleInRounds, 'people in finished rounds')}${tile(t.peakPlayers, 'most playing at once')}${tile(t.playingNow, 'playing right now', t.playingNow ? 'live' : '')}
95
108
  ${tile(t.songPlays, 'songs played')}${tile(t.videoViews, 'videos watched')}
109
+ ${s.players && (s.players.accounts || s.players.guests) ? `${tile(s.players.accounts, 'player accounts')}${tile(s.players.guests, 'guests with saves')}${tile(s.players.active7d, 'players this week')}` : ''}
96
110
  </section>
97
111
  <h2>Every day</h2><div class="bars" role="img" aria-label="visits and plays per day">${bars}</div>
98
112
  <h2>Games</h2><div class="scroll"><table class="wide"><thead><tr><th>Game</th><th>Visits</th><th>Plays</th><th>Rooms</th><th>Rounds</th><th>People in rounds</th><th>Peak</th><th>Peak in a room</th><th>Now</th></tr></thead><tbody>${gameRows || '<tr><td colspan="9" class="dim">No games.</td></tr>'}</tbody></table></div>
@@ -119,12 +133,12 @@ export async function ownerRoutes(request, env, url, { catalogueOf }) {
119
133
  // A GET spends nothing: a chat app's link preview must not use up the owner's one-time link.
120
134
  return page('Sign in · stats', `<h1>${esc(cat.studio?.name ?? 'Studio')} stats</h1>
121
135
  <p class="dim">Open this studio's private stats in this browser. The link works once.</p>
122
- <form method="post" action="/_studio/signin?k=${esc(encodeURIComponent(key))}"><button type="submit">Open the stats</button></form>`);
136
+ <form method="post" action="/_studio/signin?k=${esc(encodeURIComponent(key))}${nextOf(url) !== '/_studio/stats' ? `&amp;to=${esc(encodeURIComponent(nextOf(url)))}` : ''}"><button type="submit">${nextOf(url).startsWith('/_studio/codex/') ? 'Open the codex' : 'Open the stats'}</button></form>`);
123
137
  }
124
138
  if (request.method !== 'POST' || !sameOrigin(request, url)) return page('Not allowed', '<h1>Not allowed</h1>', 403);
125
139
  const session = await spendSignin(env, key);
126
140
  if (!session) return page('Link used or expired', `<h1>That link has been used or has expired</h1><p class="note">A sign-in link works once, for 30 minutes. Ask your AI for a new one: <code>npx --no-install homie-studio stats link</code>.</p>`, 403);
127
- return new Response(null, { status: 303, headers: { location: '/_studio/stats', 'cache-control': 'no-store', 'set-cookie': `${OWNER_COOKIE}=${session}; Path=/_studio/; HttpOnly; SameSite=Lax; Max-Age=${30 * 24 * 3600}${secure}` } });
141
+ return new Response(null, { status: 303, headers: { location: nextOf(url), 'cache-control': 'no-store', 'set-cookie': `${OWNER_COOKIE}=${session}; Path=/_studio/; HttpOnly; SameSite=Lax; Max-Age=${30 * 24 * 3600}${secure}` } });
128
142
  }
129
143
  if (path === '/_studio/signout') {
130
144
  if (request.method !== 'POST' || !sameOrigin(request, url)) return page('Not allowed', '<h1>Not allowed</h1>', 403);
@@ -140,5 +154,24 @@ export async function ownerRoutes(request, env, url, { catalogueOf }) {
140
154
  }
141
155
  return page(`Stats · ${cat.studio?.name ?? 'Studio'}`, statsBody(cat, stats, url));
142
156
  }
157
+ // A game's Game Codex (lib/codex.mjs, built into site/dist/_studio/codex/<id>/): the owner's only, never listed.
158
+ const codex = /^\/_studio\/codex\/([a-z0-9][a-z0-9-]{0,39})(\/|\/index\.html)?$/.exec(path);
159
+ if (codex) {
160
+ if (request.method !== 'GET' && request.method !== 'HEAD') return page('Not allowed', '<h1>Not allowed</h1>', 405);
161
+ if (!codex[2]) return new Response(null, { status: 301, headers: { location: `/_studio/codex/${codex[1]}/`, 'cache-control': 'no-store' } });
162
+ if ((await ownerAllowed(request, env, { kinds: ['session'] })) !== 'session') {
163
+ return page('Codex · private', `<h1>${esc(cat.studio?.name ?? 'Studio')}: Game Codex</h1>
164
+ <p class="dim">A game's codex is for the studio's owner only.</p>
165
+ <p class="note">To open it, the owner asks their AI in the studio's folder to run <code>npx --no-install homie-studio codex link ${esc(codex[1])}</code>. That uses the studio's own Cloudflare login, and gives a one-time link for this browser.</p>`, 401);
166
+ }
167
+ const res = env.ASSETS ? await env.ASSETS.fetch(new Request(`${url.origin}/_studio/codex/${codex[1]}/index.html`)) : null;
168
+ if (!res || !res.ok) return page('No codex yet', `<h1>No codex for this game yet</h1><p class="note">Its plan is <code>games/${esc(codex[1])}/CODEX.md</code> in the studio; the next <code>npm run deploy</code> puts it here.</p>`, 404);
169
+ return new Response(request.method === 'HEAD' ? null : await res.text(), {
170
+ headers: {
171
+ ...PRIVATE_HEADERS,
172
+ 'content-security-policy': `default-src 'none'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com data:; img-src data:; script-src '${await codexScriptHash()}'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'`,
173
+ },
174
+ });
175
+ }
143
176
  return page('Not found', '<h1>Not found</h1>', 404);
144
177
  }
package/worker/stats.mjs CHANGED
@@ -200,6 +200,19 @@ export function rangeOf(params, now = Date.now()) {
200
200
  return { from: dayOffset(end, -(days - 1)), to: end, days };
201
201
  }
202
202
 
203
+ /**
204
+ * Player accounts (0.12.0, worker/players.mjs): how many, never who. Null before migration 0004 or with no D1.
205
+ * `accounts` have a passkey; `guests` saved something without one; `active7d` played in the last 7 days.
206
+ */
207
+ export async function playersNow(env) {
208
+ if (!env?.DB) return null;
209
+ const now = Date.now();
210
+ try {
211
+ const r = await env.DB.prepare('SELECT SUM(guest = 0) AS accounts, SUM(guest = 1) AS guests, SUM(guest = 0 AND created_at >= ?1) AS new7d, SUM(seen_at >= ?1) AS active7d FROM players').bind(now - 7 * 86400_000).first();
212
+ return { accounts: Number(r?.accounts) || 0, guests: Number(r?.guests) || 0, newAccounts7d: Number(r?.new7d) || 0, active7d: Number(r?.active7d) || 0 };
213
+ } catch { return null; }
214
+ }
215
+
203
216
  /** The Lobby's count of people in every room of each game, right now. */
204
217
  async function playingNow(env, games) {
205
218
  const out = {};
@@ -243,7 +256,7 @@ export async function readStats(env, cat, { range, only = null, studios = new Se
243
256
  const scoped = rows.filter(inScope);
244
257
  const sum = (pred) => scoped.reduce((n, r) => n + (pred(r) ? Number(r.n) : 0), 0);
245
258
  const maxOf = (pred) => scoped.reduce((n, r) => Math.max(n, pred(r) ? Number(r.n) : 0), 0);
246
- const now = await playingNow(env, games);
259
+ const [now, people] = await Promise.all([playingNow(env, games), only ? null : playersNow(env)]);
247
260
  const perGame = games.map((g) => {
248
261
  const of = (m, extra = () => true) => sum((r) => r.metric === m && r.subject === g.id && extra(r));
249
262
  return {
@@ -282,6 +295,8 @@ export async function readStats(env, cat, { range, only = null, studios = new Se
282
295
  peakPlayers: Math.max(0, ...perGame.map((g) => g.peakPlayers)), peakInOneRoom: Math.max(0, ...perGame.map((g) => g.peakInOneRoom)),
283
296
  playingNow: perGame.reduce((n, g) => n + g.playingNow, 0),
284
297
  },
298
+ // Player accounts (saves/SAVES.md): counts only, never a name, a passkey or an email.
299
+ ...(people ? { players: people } : {}),
285
300
  crossings: { fromHub: byKind('hub'), fromStudios: byKind('studio'), fromSearch: byKind('search'), fromWeb: byKind('web'), fromLinks: byKind('link') },
286
301
  // The studio's own pages: its home, and the /music/ and /videos/ lists (a catalogue's road).
287
302
  pages: Object.fromEntries(['home', 'games', 'rooms', 'posts', 'music', 'videos'].map((p) => [p, sum((r) => r.metric === 'visit' && r.subject === p)])),
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.10.0';
2
+ export const STUDIO_VERSION_TAG = '0.12.0';