@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.
- package/README.md +86 -5
- package/bin/homie-studio.mjs +91 -4
- package/bin/statusline.mjs +41 -0
- package/lib/build.mjs +11 -3
- package/lib/cloudflare.mjs +11 -7
- package/lib/codex.mjs +665 -0
- package/lib/doctor.mjs +279 -0
- package/lib/feed-summary.mjs +88 -0
- package/lib/players.mjs +38 -0
- package/lib/progress.mjs +11 -3
- package/lib/scaffold.mjs +51 -1
- package/lib/site.mjs +2 -2
- package/lib/stats.mjs +3 -3
- package/lib/statusline.mjs +155 -0
- package/lib/studio.mjs +1 -1
- package/lib/template-history.json +48 -0
- package/netplay/NETPLAY.md +5 -0
- package/package.json +6 -1
- package/port/index.ts +2 -0
- package/port/keys.ts +3 -0
- package/saves/SAVES.md +236 -0
- package/saves/saves.ts +326 -0
- package/site/SITE.md +9 -0
- package/starters/ember-vale/game.json +20 -0
- package/starters/ember-vale/index.html +46 -0
- package/starters/ember-vale/src/main.ts +429 -0
- package/worker/account-page.mjs +350 -0
- package/worker/codex-script.mjs +6 -0
- package/worker/index.mjs +11 -1
- package/worker/pages.mjs +10 -3
- package/worker/players.mjs +817 -0
- package/worker/saves-shell.mjs +388 -0
- package/worker/saves.mjs +287 -0
- package/worker/site.mjs +1 -1
- package/worker/stats-page.mjs +35 -2
- package/worker/stats.mjs +16 -1
- package/worker/version.mjs +1 -1
- package/worker/webauthn.mjs +259 -0
package/worker/saves.mjs
ADDED
|
@@ -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
|
|
package/worker/stats-page.mjs
CHANGED
|
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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"
|
|
136
|
+
<form method="post" action="/_studio/signin?k=${esc(encodeURIComponent(key))}${nextOf(url) !== '/_studio/stats' ? `&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:
|
|
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)])),
|
package/worker/version.mjs
CHANGED
|
@@ -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.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.12.0';
|