@xgamesfun/xcore 0.2.1 → 0.4.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.
@@ -1,49 +1,71 @@
1
1
  // The platform's services on this machine, for `xcore dev` (gf-server-v1, ../server/contract.mjs): one game's saves and
2
2
  // scores, a local sign-in, and loot reports. Both sides of the dev host use this one store, so what the page saves
3
3
  // through gamesfun.js the game's server reads through env.GF, as it would hosted:
4
- // - the game's server, through ./gf-local.js's HOST binding (handleHost),
5
- // - the page, through the dev host's /platform/v1/* routes (handlePlatform, below).
4
+ // - the game's server, through ./gf-local.js's HOST binding (handleHost; `run`),
5
+ // - the page, through the dev host's /platform/v1/* routes (handlePlatform, below; `page`).
6
6
  // Kept in <game>/.gamesfun/dev-platform.json (a hidden folder: never published), written a moment after each change.
7
7
  // The game's project files (env.GF.files) are ./files.mjs's, in <game>/.gamesfun/dev/files/.
8
8
  //
9
9
  // Every answer has xgames.fun's shape: {ok: true, …} or {ok: false, code, why}. A refusal is an answer, never a throw,
10
- // so a game's server reads it the same here as hosted. The limits are the hosted ones (PLATFORM_LIMITS), so a game
11
- // that stays inside them here stays inside them there; the per-minute rate limits are left out on this machine.
10
+ // so a game's server reads it the same here as hosted. The limits are the platform's on the plan this dev host
11
+ // imitates (./plans.mjs: Free unless `xcore dev --plan`), and so are the rules a page is held to and the server is not:
12
+ // the boards a page makes ("high" only, two a player, half the plan's), a player's share of the game's saves, each
13
+ // player's writes a minute, and a board read with no token (the top 100, a 5 s cache, a minute of its own). The
14
+ // counts a minute are kept in memory, as the platform keeps them; they start again when xcore dev does.
12
15
  //
13
16
  // Local sign-in. No xgames.fun signs a player in on this machine, so the dev host hands out its own tokens
14
17
  // (POST /platform/v1/dev/token): `gfdev1.<payload>.<HMAC-SHA256>`, signed with a key made when it starts and never
15
18
  // written anywhere, for this game only, lasting an hour. verifyPlayer accepts exactly those; an xgames.fun token (EdDSA,
16
19
  // from the play host) is refused here with words that say so.
17
- import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
20
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
18
21
  import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
19
22
  import path from 'node:path';
20
- import {
21
- PLATFORM_LIMITS as L,
22
- PLATFORM_PREFIX,
23
- SAVE_KEY,
24
- SERVER_FORMAT,
25
- checkLootResult,
26
- FILES,
27
- } from '../server/contract.mjs';
23
+ import { PLATFORM_PREFIX, SAVE_KEY, SERVER_FORMAT, checkLootResult } from '../server/contract.mjs';
28
24
  import { createFileStore } from './files.mjs';
25
+ import { RULES, count, devPlan, size } from './plans.mjs';
29
26
  import { stateFolder } from '../state-folder.mjs';
30
27
 
31
28
  const TOKEN_MS = 60 * 60 * 1000;
32
- /** How much of a stream with no declared length GF.files.put reads (xgames.fun's UNSIZED_MAX); a longer one needs it. */
33
- const UNSIZED_MAX = 1024 * 1024;
29
+ const MINUTE = 60_000;
30
+ /** How much of a body GF.files.put holds in memory: a stream with no declared length, a string or an ArrayBuffer. */
31
+ const UNSIZED_MAX = RULES.files.unsizedBytes;
32
+ const mb = b => `${(b / 1048576).toFixed(b < 10 * 1048576 ? 1 : 0)} MB`;
34
33
  // eslint-disable-next-line no-control-regex -- it exists to drop control characters
35
34
  const UNSAFE = /[\u0000-\u001f<>]/g;
36
35
  const NAME = s =>
37
36
  String(s ?? '')
38
37
  .replace(UNSAFE, '')
39
38
  .trim()
40
- .slice(0, L.scores.nameChars);
39
+ .slice(0, RULES.scores.nameChars);
41
40
  const no = (code, why) => ({ ok: false, code, why });
42
- const okPlayer = p => typeof p === 'string' && p.length >= 1 && p.length <= L.saves.playerChars;
41
+ const okPlayer = p => typeof p === 'string' && p.length >= 1 && p.length <= RULES.saves.playerChars;
42
+ /** A name's matching form: NFKC, invisible format characters gone, lower case (how the platform reads "@handle"). */
43
+ const fold = s =>
44
+ String(s)
45
+ .normalize('NFKC')
46
+ .replace(/\p{Cf}/gu, '')
47
+ .toLowerCase();
48
+ const B32 = 'abcdefghijklmnopqrstuvwxyz234567';
49
+ const base32 = bytes => {
50
+ let out = '',
51
+ bits = 0,
52
+ value = 0;
53
+ for (const byte of bytes) {
54
+ value = ((value << 8) | byte) & 0xffff;
55
+ bits += 8;
56
+ while (bits >= 5) {
57
+ out += B32[(value >>> (bits - 5)) & 31];
58
+ bits -= 5;
59
+ }
60
+ }
61
+ return out;
62
+ };
43
63
 
44
64
  /**
45
- * One game's platform store. → {slug, gameId, run(op, args), handleHost(request), devToken({name, id}),
46
- * verifyPlayer(token), flush(), file, files (./files.mjs), setFilesOrigin(origin)}
65
+ * One game's platform store. `plan`: the plan imitated ('free', 'creator', 'scale', or ./plans.mjs devPlan's answer).
66
+ * → {slug, gameId, plan, run(op, args) (env.GF's), page(op, args, player, {anon}) (the page's), pageSubmit,
67
+ * playerId(sub), handleHost(request), devToken({name, id}), verifyPlayer(token), readToken, flush(), file, files
68
+ * (./files.mjs), setFilesOrigin(origin)}
47
69
  */
48
70
  export function createPlatformStore({
49
71
  dir,
@@ -51,12 +73,14 @@ export function createPlatformStore({
51
73
  log = () => {},
52
74
  now = () => Date.now(),
53
75
  file = path.join(dir, '.gamesfun', 'dev-platform.json'),
76
+ plan = 'free',
54
77
  } = {}) {
78
+ const P = typeof plan === 'object' && plan?.key ? plan : devPlan(plan);
55
79
  const key = randomBytes(32);
56
80
  const gameId = `dev-${slug}`.slice(0, 60);
57
81
  // the public files' origin: the dev host's files server, once it listens
58
82
  let filesOrigin = null;
59
- const files = createFileStore({ dir, origin: () => filesOrigin });
83
+ const files = createFileStore({ dir, origin: () => filesOrigin, cap: P.files.bytes, fileBytes: P.files.fileBytes });
60
84
  let data = null,
61
85
  loading = null,
62
86
  timer = null,
@@ -78,12 +102,16 @@ export function createPlatformStore({
78
102
  data.saves ??= {};
79
103
  data.boards ??= {};
80
104
  data.rules ??= {};
81
- // an older file kept a board's rule on the board
82
- for (const [name, b] of Object.entries(data.boards))
105
+ for (const [name, b] of Object.entries(data.boards)) {
106
+ // an older file kept a board's rule on the board
83
107
  if (b.writer) {
84
108
  data.rules[name] ??= b.writer;
85
109
  delete b.writer;
86
110
  }
111
+ // a board from before boards had a maker is the server's, as the platform counts boards made before then
112
+ b.maker ??= null;
113
+ b.created ??= now();
114
+ }
87
115
  data.reports ??= [];
88
116
  return data;
89
117
  };
@@ -100,6 +128,52 @@ export function createPlatformStore({
100
128
  }, 150);
101
129
  };
102
130
 
131
+ // ---- the counts a minute, in memory as the platform keeps them ----
132
+ const windows = new Map();
133
+ let pruned = -Infinity;
134
+ /** One from window `k`: true while under `limit` in its period. */
135
+ function spend(k, limit, ms = MINUTE) {
136
+ const t = now();
137
+ if (t - pruned >= MINUTE) {
138
+ for (const [w, v] of windows) if (t - v.start >= v.ms) windows.delete(w);
139
+ pruned = t;
140
+ }
141
+ const w = windows.get(k);
142
+ if (w && t - w.start < ms) {
143
+ if (w.n >= limit) return false;
144
+ w.n++;
145
+ return true;
146
+ }
147
+ windows.set(k, { start: t, n: 1, ms });
148
+ return true;
149
+ }
150
+ const used = k => {
151
+ const w = windows.get(k);
152
+ return w && now() - w.start < w.ms ? w.n : 0;
153
+ };
154
+ /**
155
+ * One write ('saves' or 'scores') from the game's minute (`max`, the plan's). The game's server spends `kind`; pages
156
+ * spend `page-<kind>`, the same number counted apart; once that is spent, a player who has made at most `page.floor`
157
+ * this minute still writes, from a second count.
158
+ */
159
+ function spendWrite(kind, max, page = null) {
160
+ if (!page) return spend(kind, max);
161
+ if (spend(`page-${kind}`, max)) return true;
162
+ return used(page.mine) <= page.floor && spend(`page-${kind}-floor`, max);
163
+ }
164
+ /** The game's reads a minute (env.GF's 'reads', the pages' 'page-reads'): null, or the refusal. */
165
+ const readRefused = w =>
166
+ spend(w, P.reads.perMinute)
167
+ ? null
168
+ : no('rate', `Too many reads this minute (this game’s plan, ${P.name}, allows ${count(P.reads.perMinute)}).`);
169
+ const filesOp = () =>
170
+ spend('files-ops', P.files.opsPerMinute)
171
+ ? null
172
+ : no(
173
+ 'rate',
174
+ `Too many file calls this minute (this game’s plan, ${P.name}, allows ${count(P.files.opsPerMinute)}).`,
175
+ );
176
+
103
177
  const sign = body => createHmac('sha256', key).update(body).digest('base64url');
104
178
  /** A local sign-in for a new or named player of this game. → {token, player, expires} */
105
179
  function devToken({ name = '', id = '' } = {}) {
@@ -116,10 +190,13 @@ export function createPlatformStore({
116
190
  expires: exp * 1000,
117
191
  };
118
192
  }
119
- /** env.GF.verifyPlayer, on this machine: xgames.fun's answer shape (no name: hosted it has none), plus `dev: true`. */
193
+ /**
194
+ * env.GF.verifyPlayer, on this machine: xgames.fun's answer shape (no name: hosted it has none), plus `dev: true`.
195
+ * `banned` is always null here: xcore dev has no owner's bans (hosted, it is the creator's ban on any of the token's ids).
196
+ */
120
197
  function verifyPlayer(token) {
121
198
  const { name: _name, ...v } = readToken(token);
122
- return v;
199
+ return v.ok ? { ...v, banned: null } : v;
123
200
  }
124
201
  /** The local sign-in token's claims as verifyPlayer answers them, plus the local player's `name` (the page's /me). */
125
202
  function readToken(token) {
@@ -147,53 +224,208 @@ export function createPlatformStore({
147
224
  if (c.aud !== `game:${slug}`) return no('game', 'The token is for another game.');
148
225
  return { ok: true, sub: c.sub, wallet: null, x: null, lvl: c.lvl | 0, exp: c.exp, dev: true, name: c.name };
149
226
  }
227
+ // A board read from a page shows each player as an id of this game's own (16 base32 characters of an HMAC of the
228
+ // player's id), never the id itself, as the platform does. Here the key is this game's dev key, so the ids are not
229
+ // the ones the published game shows; they stay the same from one run of xcore dev to the next.
230
+ const pidKey = createHash('sha256').update(`xcore dev board player id|${gameId}`).digest();
231
+ const playerId = sub => base32(createHmac('sha256', pidKey).update(String(sub)).digest()).slice(0, 16);
150
232
 
151
233
  const ranked = b =>
152
234
  [...b.rows].sort((x, y) => (b.order === 'low' ? x.score - y.score : y.score - x.score) || x.at - y.at);
153
235
  const rankOf = (b, score) => b.rows.filter(r => (b.order === 'low' ? r.score < score : r.score > score)).length + 1;
154
- const badPlayer = () => no('player', `A player id is 1–${L.saves.playerChars} characters.`);
236
+ /** One player's rank, counted to RULES.scores.rankMax: past it, rank rankMax + 1 with rankAtLeast. */
237
+ const placeOf = (b, score) => {
238
+ const better = rankOf(b, score) - 1,
239
+ max = RULES.scores.rankMax;
240
+ return better >= max ? { rank: max + 1, rankAtLeast: true } : { rank: better + 1 };
241
+ };
242
+ const savesBytes = mine => Object.values(mine ?? {}).reduce((n, s) => n + (s.bytes ?? 0), 0);
243
+ const badPlayer = () => no('player', `A player id is 1–${RULES.saves.playerChars} characters.`);
155
244
  const badKey = () => no('key', 'A save key is 1–32 letters, digits, ".", "_" or "-".');
156
245
  const badBoard = () => no('board', 'A board name is 1–32 letters, digits, ".", "_" or "-" (or a pattern: daily-*).');
157
246
  // a rule names one board, or every board whose name starts with a prefix: `daily-*` (and `*`, every board)
158
247
  const PATTERN = /^[A-Za-z0-9_.-]{0,31}\*$/;
159
248
  const WRITERS = ['any', 'server'];
160
249
  const badWriter = () => no('writer', 'A board’s writer is "server" (only the game’s server submits) or "any".');
161
- const ops = {
250
+ const badOrder = () => no('order', 'A board’s order is "high" (higher wins) or "low".');
251
+ /** The boards all pages together may make: the plan's over RULES.page.boardsDivisor. */
252
+ const pageBoards = () => Math.floor(P.scores.boards / RULES.page.boardsDivisor);
253
+
254
+ /**
255
+ * One save written. `by`: the player whose page writes it, else null (the game's server). A page's write is held to
256
+ * the player's share of the game's save bytes (1/128, never less than one save). With the game's saves at their
257
+ * total, a player whose saves stay within RULES.saves.floorBytes still writes, up to an eighth past the total. A
258
+ * write that does not grow the saves is never refused for space.
259
+ */
260
+ async function putSave(player, k, value, by = null) {
261
+ const d = await load();
262
+ if (!okPlayer(player)) return badPlayer();
263
+ if (!SAVE_KEY.test(String(k))) return badKey();
264
+ let text;
265
+ try {
266
+ text = JSON.stringify(value);
267
+ } catch {
268
+ text = undefined;
269
+ }
270
+ if (text === undefined) return no('value', 'A save is a JSON value.');
271
+ const S = P.saves;
272
+ const bytes = Buffer.byteLength(text);
273
+ if (bytes > S.valueBytes)
274
+ return no('too-big', `A save may be ${S.valueBytes / 1024} KB; this one is ${Math.ceil(bytes / 1024)} KB.`);
275
+ const mine = d.saves[player] ?? {},
276
+ old = Object.hasOwn(mine, k) ? mine[k] : null;
277
+ if (!old && Object.keys(mine).length >= S.keysPerPlayer)
278
+ return no('too-many', `A player may have ${S.keysPerPlayer} saves.`);
279
+ const grows = bytes > (old?.bytes ?? 0);
280
+ const theirs = savesBytes(mine) - (old?.bytes ?? 0) + bytes;
281
+ if (by !== null && grows) {
282
+ const cap = Math.max(S.valueBytes, Math.floor(S.gameBytes / RULES.saves.playerShareDivisor));
283
+ if (theirs > cap)
284
+ return no(
285
+ 'full',
286
+ `This player’s saves are full: a player may keep ${size(cap)} of this game’s saves (its plan, ${P.name}, allows ${size(S.gameBytes)} in all).`,
287
+ );
288
+ }
289
+ const total = Object.values(d.saves).reduce((n, m) => n + savesBytes(m), 0) - (old?.bytes ?? 0) + bytes;
290
+ if (
291
+ grows &&
292
+ total > S.gameBytes &&
293
+ (theirs > RULES.saves.floorBytes || total > S.gameBytes + Math.floor(S.gameBytes / RULES.saves.floorPoolDivisor))
294
+ )
295
+ return no(
296
+ 'full',
297
+ `This game’s saves are full (its plan, ${P.name}, allows ${size(S.gameBytes)}; a player may still keep ${size(RULES.saves.floorBytes)}).`,
298
+ );
299
+ const page = by === null ? null : { mine: `p:ps:${by}`, floor: RULES.page.saveWritesFloorPerPlayerMinute };
300
+ if (!spendWrite('saves', S.writesPerMinute, page))
301
+ return no(
302
+ 'rate',
303
+ `Too many saves this minute (this game’s plan, ${P.name}, allows ${count(S.writesPerMinute)}).`,
304
+ );
305
+ (d.saves[player] ??= {})[k] = { value: JSON.parse(text), bytes, updated: now() };
306
+ persist();
307
+ return { ok: true, bytes };
308
+ }
309
+
310
+ /**
311
+ * Makes the board (when new) with `order` (undefined: "high"); `writer`, when given, becomes its rule. `maker`: the
312
+ * player whose page post makes it, else null (the game's server). A page makes only "high" boards, each player
313
+ * RULES.page.boardsPerPlayer of them, all pages together half the plan's; its order never changes a board that is
314
+ * there. The server naming the other order remakes a board a page made (its entries go: `remade`); one the server
315
+ * made keeps its order (`declare`, setup's: 'order-differs'; a submit's: `differs`).
316
+ * → {b, removed, remade, differs} or a refusal
317
+ */
318
+ function ensureBoard(d, board, order, writer, maker = null, declare = false) {
319
+ if (writer !== undefined && !WRITERS.includes(writer)) return badWriter();
320
+ let b = d.boards[board],
321
+ removed = 0,
322
+ remade = false,
323
+ differs = false;
324
+ if (!b) {
325
+ const ord = order === undefined ? 'high' : order;
326
+ if (!['high', 'low'].includes(ord)) return badOrder();
327
+ const all = Object.values(d.boards);
328
+ if (maker !== null && ord !== 'high')
329
+ return no(
330
+ 'order',
331
+ 'A page makes "high" boards only (higher wins): a "low" board is made by the game’s server (GF.scores.setup) or its owner.',
332
+ );
333
+ if (maker !== null && all.filter(x => x.maker === maker).length >= RULES.page.boardsPerPlayer)
334
+ return no(
335
+ 'too-many',
336
+ `One player may make ${RULES.page.boardsPerPlayer} of this game’s boards from its page; the game’s server (GF.scores.setup) or its owner makes more.`,
337
+ );
338
+ if (maker !== null && all.filter(x => x.maker != null).length >= pageBoards())
339
+ return no(
340
+ 'too-many',
341
+ `This game’s pages may make ${pageBoards()} of its ${P.scores.boards} boards (its plan, ${P.name}); the rest are its server’s (GF.scores.setup) or its owner’s.`,
342
+ );
343
+ if (all.length >= P.scores.boards)
344
+ return no(
345
+ 'too-many',
346
+ `This game’s plan (${P.name}) allows ${P.scores.boards} boards (GF.scores.drop frees one).`,
347
+ );
348
+ b = d.boards[board] = { order: ord, rows: [], maker, created: now() };
349
+ } else if (maker === null && (order === 'high' || order === 'low') && order !== b.order) {
350
+ if (b.maker == null && declare)
351
+ return no(
352
+ 'order-differs',
353
+ `This board ranks "${b.order}" (${b.order === 'low' ? 'lower' : 'higher'} wins), not "${order}": GF.scores.drop it, then set it up again.`,
354
+ );
355
+ if (b.maker == null) differs = true;
356
+ else {
357
+ removed = b.rows.length;
358
+ Object.assign(b, { rows: [], order, maker: null, created: now() });
359
+ remade = true;
360
+ }
361
+ }
362
+ if (writer !== undefined && writer !== writerOf(d, board)) {
363
+ const r = setRule(d, board, writer);
364
+ if (typeof r !== 'number') return r;
365
+ removed += r;
366
+ }
367
+ return { b, removed, remade, differs };
368
+ }
369
+
370
+ // `writer` ('server' | 'any') is the game's server's word only: the page's route never passes it (page).
371
+ // `order`: none makes a new board "high" and asks nothing of one that is there.
372
+ async function submitScore(board, player, score, { name = '', order, writer } = {}, maker = null) {
373
+ const d = await load();
374
+ if (!SAVE_KEY.test(String(board))) return badBoard();
375
+ if (!okPlayer(player)) return badPlayer();
376
+ if (typeof score !== 'number' || !Number.isFinite(score)) return no('score', 'A score is a finite number.');
377
+ if (typeof name !== 'string' || name.length > RULES.scores.nameChars)
378
+ return no('name', `A name is at most ${RULES.scores.nameChars} characters.`);
379
+ const e = ensureBoard(d, board, order, writer, maker);
380
+ if (e.ok === false) return e;
381
+ const { b } = e;
382
+ if (e.removed || e.remade) persist();
383
+ let row = b.rows.find(r => r.player === player);
384
+ if (row && now() - row.tried < RULES.scores.perPlayerEveryMs) return no('rate', 'One score a second a player.');
385
+ const pageWrite =
386
+ maker === null ? null : { mine: `p:pc:${maker}`, floor: RULES.page.scoreSubmitsFloorPerPlayerMinute };
387
+ if (!spendWrite('scores', P.scores.submitsPerMinute, pageWrite))
388
+ return no(
389
+ 'rate',
390
+ `Too many scores this minute (this game’s plan, ${P.name}, allows ${count(P.scores.submitsPerMinute)}).`,
391
+ );
392
+ if (!row && b.rows.length >= P.scores.entriesPerBoard)
393
+ return no(
394
+ 'full',
395
+ `This board is full (this game’s plan, ${P.name}, allows ${count(P.scores.entriesPerBoard)} entries a board).`,
396
+ );
397
+ const t = now(),
398
+ improved = !row || (b.order === 'low' ? score < row.score : score > row.score);
399
+ if (!row) b.rows.push((row = { player, name: NAME(name), score, at: t, tried: t }));
400
+ else {
401
+ row.tried = t;
402
+ if (improved) Object.assign(row, { score, at: t, name: NAME(name) || row.name });
403
+ }
404
+ persist();
405
+ return {
406
+ ok: true,
407
+ improved,
408
+ best: row.score,
409
+ ...placeOf(b, row.score),
410
+ ...(e.remade ? { remade: true, removed: e.removed } : {}),
411
+ ...(e.differs ? { orderDiffers: true, order: b.order } : {}),
412
+ };
413
+ }
414
+
415
+ // the calls as the store answers them, with no count a minute (env.GF's and the page's count their own)
416
+ const raw = {
162
417
  async 'saves.get'(player, k = 'main') {
163
418
  const d = await load();
164
419
  if (!okPlayer(player)) return badPlayer();
165
420
  if (!SAVE_KEY.test(String(k))) return badKey();
166
- const s = d.saves[player]?.[k];
421
+ const s = Object.hasOwn(d.saves[player] ?? {}, k) ? d.saves[player][k] : null;
167
422
  return { ok: true, value: s ? s.value : null, updatedAt: s?.updated ?? null };
168
423
  },
169
- async 'saves.put'(player, k, value) {
170
- const d = await load();
171
- if (!okPlayer(player)) return badPlayer();
172
- if (!SAVE_KEY.test(String(k))) return badKey();
173
- let text;
174
- try {
175
- text = JSON.stringify(value);
176
- } catch {
177
- text = undefined;
178
- }
179
- if (text === undefined) return no('value', 'A save is a JSON value.');
180
- const bytes = Buffer.byteLength(text);
181
- if (bytes > L.saves.valueBytes)
182
- return no(
183
- 'too-big',
184
- `A save may be ${L.saves.valueBytes / 1024} KB; this one is ${Math.ceil(bytes / 1024)} KB.`,
185
- );
186
- const mine = (d.saves[player] ??= {});
187
- if (!mine[k] && Object.keys(mine).length >= L.saves.keysPerPlayer)
188
- return no('too-many', `A player may have ${L.saves.keysPerPlayer} saves.`);
189
- mine[k] = { value: JSON.parse(text), bytes, updated: now() };
190
- persist();
191
- return { ok: true, bytes };
192
- },
424
+ 'saves.put': (player, k, value) => putSave(player, k, value, null),
193
425
  async 'saves.delete'(player, k) {
194
426
  const d = await load();
195
427
  if (!okPlayer(player) || !SAVE_KEY.test(String(k))) return no('key', 'No such save.');
196
- const had = !!d.saves[player]?.[k];
428
+ const had = Object.hasOwn(d.saves[player] ?? {}, k);
197
429
  if (had) {
198
430
  delete d.saves[player][k];
199
431
  persist();
@@ -208,32 +440,12 @@ export function createPlatformStore({
208
440
  .map(([k, s]) => ({ key: k, bytes: s.bytes, updatedAt: s.updated }));
209
441
  return { ok: true, keys };
210
442
  },
211
- // `writer` ('server' | 'any') is the game's server's word only: the page's route never passes it (pageSubmit).
212
- async 'scores.submit'(board, player, score, { name = '', order = 'high', writer } = {}) {
213
- const d = await load();
214
- if (!SAVE_KEY.test(String(board))) return badBoard();
215
- if (!okPlayer(player)) return badPlayer();
216
- if (typeof score !== 'number' || !Number.isFinite(score)) return no('score', 'A score is a finite number.');
217
- if (typeof name !== 'string' || name.length > L.scores.nameChars)
218
- return no('name', `A name is at most ${L.scores.nameChars} characters.`);
219
- const b = ensureBoard(d, board, order, writer);
220
- if (b.ok === false) return b;
221
- let row = b.rows.find(r => r.player === player);
222
- if (row && now() - row.tried < L.scores.perPlayerEveryMs) return no('rate', 'One score a second a player.');
223
- const t = now(),
224
- improved = !row || (b.order === 'low' ? score < row.score : score > row.score);
225
- if (!row) b.rows.push((row = { player, name: NAME(name), score, at: t, tried: t }));
226
- else {
227
- row.tried = t;
228
- if (improved) Object.assign(row, { score, at: t, name: NAME(name) || row.name });
229
- }
230
- persist();
231
- return { ok: true, improved, best: row.score, rank: rankOf(b, row.score) };
232
- },
443
+ 'scores.submit': (board, player, score, opts) => submitScore(board, player, score, opts ?? {}, null),
233
444
  /** The board's settings, made now when new: {order?, writer?: 'server' | 'any'} (xgames.fun's GF.scores.setup).
234
445
  * `board` may be a pattern (`daily-*`, `*`): then only the rule is kept, for those boards now and to come. */
235
- async 'scores.setup'(board, { order = 'high', writer } = {}) {
446
+ async 'scores.setup'(board, { order: asked, writer } = {}) {
236
447
  const d = await load();
448
+ const order = asked ?? undefined; // null asks nothing, as no order does
237
449
  if (typeof board === 'string' && PATTERN.test(board)) {
238
450
  if (!WRITERS.includes(writer)) return badWriter();
239
451
  const removed = d.rules[board] === writer ? 0 : setRule(d, board, writer);
@@ -242,10 +454,43 @@ export function createPlatformStore({
242
454
  return { ok: true, board, writer, removed };
243
455
  }
244
456
  if (!SAVE_KEY.test(String(board))) return badBoard();
245
- const b = ensureBoard(d, board, order, writer);
246
- if (b.ok === false) return b;
457
+ if (order !== undefined && !['high', 'low'].includes(order)) return badOrder();
458
+ const was = d.boards[board];
459
+ const e = ensureBoard(d, board, order, writer, null, true);
460
+ if (e.ok === false) return e;
461
+ // a board the server sets up is no player's to count among the boards they made
462
+ if (was) was.maker = null;
247
463
  persist();
248
- return { ok: true, board, order: b.order, writer: writerOf(d, board), removed: b.removed };
464
+ return {
465
+ ok: true,
466
+ board,
467
+ order: e.b.order,
468
+ writer: writerOf(d, board),
469
+ removed: e.removed,
470
+ ...(e.remade ? { remade: true } : {}),
471
+ };
472
+ },
473
+ /** The game's boards and rules, so its server can find the boards pages made (GF.scores.list). */
474
+ async 'scores.list'() {
475
+ const d = await load();
476
+ const boards = Object.keys(d.boards)
477
+ .sort()
478
+ .map(name => {
479
+ const b = d.boards[name];
480
+ return {
481
+ board: name,
482
+ order: b.order,
483
+ made: b.maker == null ? 'server' : 'page',
484
+ ...(b.maker == null ? {} : { player: b.maker }),
485
+ writer: writerOf(d, name),
486
+ entries: b.rows.length,
487
+ created: b.created,
488
+ };
489
+ });
490
+ const rules = Object.keys(d.rules)
491
+ .sort()
492
+ .map(name => ({ board: name, writer: d.rules[name] }));
493
+ return { ok: true, boards, rules, limit: { boards: P.scores.boards, page: pageBoards() } };
249
494
  },
250
495
  /** The board, its entries and its own rule, gone: its place among the boards is free again (GF.scores.drop). */
251
496
  async 'scores.drop'(board) {
@@ -261,10 +506,12 @@ export function createPlatformStore({
261
506
  async 'scores.top'(board, { limit = 10, offset = 0 } = {}) {
262
507
  const d = await load();
263
508
  if (!SAVE_KEY.test(String(board))) return badBoard();
264
- const n = Math.min(Math.max(1, limit | 0), L.scores.topMax),
509
+ const n = Math.min(Math.max(1, limit | 0), RULES.scores.topMax),
265
510
  skip = Math.max(0, offset | 0);
266
511
  const b = d.boards[board];
267
512
  if (!b) return { ok: true, order: null, writer: writerOf(d, board), entries: [] };
513
+ // read no deeper than the platform does: past it, no entries (a player's own rank is still theirs: best)
514
+ if (skip > RULES.scores.offsetMax) return { ok: true, order: b.order, writer: writerOf(d, board), entries: [] };
268
515
  const entries = ranked(b)
269
516
  .slice(skip, skip + n)
270
517
  .map(r => ({ rank: rankOf(b, r.score), player: r.player, name: r.name, score: r.score, at: r.at }));
@@ -275,7 +522,7 @@ export function createPlatformStore({
275
522
  if (!SAVE_KEY.test(String(board)) || !okPlayer(player)) return no('board', 'No such board or player.');
276
523
  const b = d.boards[board],
277
524
  r = b?.rows.find(x => x.player === player);
278
- return { ok: true, entry: r ? { rank: rankOf(b, r.score), score: r.score, name: r.name, at: r.at } : null };
525
+ return { ok: true, entry: r ? { ...placeOf(b, r.score), score: r.score, name: r.name, at: r.at } : null };
279
526
  },
280
527
  async 'scores.remove'(board, player) {
281
528
  const d = await load();
@@ -288,6 +535,13 @@ export function createPlatformStore({
288
535
  }
289
536
  return { ok: true, removed: had };
290
537
  },
538
+ };
539
+ // env.GF's reads count against the game's server's reads a minute
540
+ const READS = ['saves.get', 'saves.list', 'scores.top', 'scores.best', 'scores.list'];
541
+ const counted = window => Object.fromEntries(READS.map(op => [op, (...a) => readRefused(window) ?? raw[op](...a)]));
542
+ const ops = {
543
+ ...raw,
544
+ ...counted('reads'),
291
545
  async 'loot.report'(result) {
292
546
  const d = await load();
293
547
  const bad = checkLootResult(result, now());
@@ -310,15 +564,16 @@ export function createPlatformStore({
310
564
  async info() {
311
565
  return { ok: true, contract: SERVER_FORMAT, gameId, slug, local: true };
312
566
  },
313
- 'files.head': p => files.head(p),
314
- 'files.list': opts => files.list(opts ?? {}),
315
- 'files.delete': p => files.delete(p),
316
- 'files.usage': () => files.usage(),
567
+ // every files call spends one of the game's file calls a minute
568
+ 'files.head': p => filesOp() ?? files.head(p),
569
+ 'files.list': opts => filesOp() ?? files.list(opts ?? {}),
570
+ 'files.delete': p => filesOp() ?? files.delete(p),
571
+ 'files.usage': async () => filesOp() ?? { ...(await files.usage()) },
317
572
  };
318
573
 
319
574
  /** The board's writer: its own rule, else the longest pattern (`daily-*`, `*`) its name starts with, else 'any'. */
320
575
  function writerOf(d, board) {
321
- if (d.rules[board]) return d.rules[board];
576
+ if (Object.hasOwn(d.rules, board)) return d.rules[board];
322
577
  const p = Object.keys(d.rules)
323
578
  .filter(r => r.endsWith('*') && board.startsWith(r.slice(0, -1)))
324
579
  .sort((x, y) => y.length - x.length)[0];
@@ -327,8 +582,8 @@ export function createPlatformStore({
327
582
  /** Sets the rule for a board or a pattern. A board that becomes server-only starts empty: what was written while
328
583
  * anyone could write it is no one's checked word. → the number of entries removed, or a refusal. */
329
584
  function setRule(d, name, writer) {
330
- if (!(name in d.rules) && Object.keys(d.rules).length >= L.scores.rules)
331
- return no('too-many', `A game may have ${L.scores.rules} board rules.`);
585
+ if (!Object.hasOwn(d.rules, name) && Object.keys(d.rules).length >= P.scores.rules)
586
+ return no('too-many', `This game’s plan (${P.name}) allows ${P.scores.rules} board rules.`);
332
587
  const hit = name.endsWith('*')
333
588
  ? Object.keys(d.boards).filter(b => b.startsWith(name.slice(0, -1)))
334
589
  : d.boards[name]
@@ -344,31 +599,86 @@ export function createPlatformStore({
344
599
  }
345
600
  return removed;
346
601
  }
347
- /** Makes the board (when new) with `order`; `writer`, when given, becomes its rule. → the board (with `removed`) or
348
- * a refusal. */
349
- function ensureBoard(d, board, order, writer) {
350
- if (writer !== undefined && !WRITERS.includes(writer)) return badWriter();
351
- let b = d.boards[board];
352
- if (!b) {
353
- if (!['high', 'low'].includes(order)) return no('order', 'A board’s order is "high" (higher wins) or "low".');
354
- if (Object.keys(d.boards).length >= L.scores.boards)
355
- return no('too-many', `A game may have ${L.scores.boards} boards (GF.scores.drop frees one).`);
356
- b = d.boards[board] = { order, rows: [] };
357
- }
358
- let removed = 0;
359
- if (writer !== undefined && writer !== writerOf(d, board)) {
360
- removed = setRule(d, board, writer);
361
- if (typeof removed !== 'number') return removed;
602
+
603
+ // ---- the page's own calls (/platform/v1/*): the same store, and each player's share of the game's writes ----
604
+ const PAGE_WRITES = {
605
+ 'saves.put': ['ps', RULES.page.saveWritesPerPlayerMinute, 'saves'],
606
+ 'saves.delete': ['ps', RULES.page.saveWritesPerPlayerMinute, 'saves'],
607
+ 'scores.submit': ['pc', RULES.page.scoreSubmitsPerPlayerMinute, 'scores'],
608
+ };
609
+ const pageReads = counted('page-reads');
610
+ // a board read with no good player token: the top RULES.page.anonTopDepth, the same read again within
611
+ // anonReadCacheMs from memory, and the rest held to the plan's reads over anonReadsDivisor a minute
612
+ const anonTops = new Map();
613
+ async function anonTop(board, opts) {
614
+ const n = Math.min(Math.max(1, Number(opts?.limit ?? 10) | 0), RULES.scores.topMax),
615
+ skip = Math.max(0, Number(opts?.offset ?? 0) | 0);
616
+ if (skip + n > RULES.page.anonTopDepth)
617
+ return no('player', `Sign in to read past the top ${RULES.page.anonTopDepth} of a board.`);
618
+ const k = `${board}\u0000${n}\u0000${skip}`,
619
+ t = now(),
620
+ hit = anonTops.get(k);
621
+ if (hit && t - hit.at < RULES.page.anonReadCacheMs) return hit.answer;
622
+ const max = Math.max(1, Math.floor(P.reads.perMinute / RULES.page.anonReadsDivisor));
623
+ if (!spend('page-anon-reads', max))
624
+ return no(
625
+ 'rate',
626
+ `Too many reads this minute from visitors who are not signed in (this game’s plan, ${P.name}, allows ${count(max)}). Sign in, or try again shortly.`,
627
+ );
628
+ const answer = await pageReads['scores.top'](board, { limit: n, offset: skip });
629
+ if (answer?.ok) {
630
+ if (anonTops.size >= 256) anonTops.clear();
631
+ anonTops.set(k, { at: t, answer });
362
632
  }
363
- return { ...b, removed };
633
+ return answer;
364
634
  }
365
- /** The page's submit (/platform/v1/scores/<board>): refused on a board only the server writes; never sets a rule. */
366
- async function pageSubmit(board, player, score, { name, order } = {}) {
367
- const d = await load();
368
- if (writerOf(d, board) === 'server')
369
- return no('server-only', 'This board takes scores from the game’s server only.');
370
- return run('scores.submit', [board, player, score, { name, ...(order ? { order } : {}) }]);
635
+ /**
636
+ * One page call for `player` (the token's sub): op with env.GF's positional args. `anon`: a board read with no good
637
+ * player token. → the answer; never throws.
638
+ */
639
+ async function page(op, args = [], player = '', { anon = false } = {}) {
640
+ try {
641
+ if (!Object.hasOwn(PAGE_WRITES, op) && !Object.hasOwn(pageReads, op))
642
+ return no('unknown', `No ${op} from a page.`);
643
+ if (op === 'scores.list') return no('unknown', `No ${op} from a page.`);
644
+ const a = Array.isArray(args) ? args.slice(0, 4) : [];
645
+ if (op === 'scores.submit') {
646
+ const d = await load();
647
+ // a board the game's server owns takes no score from a page; and a page never sets a rule
648
+ if (typeof a[0] === 'string' && writerOf(d, a[0]) === 'server')
649
+ return no('server-only', 'This board takes scores from the game’s server only.');
650
+ if (a[3] && typeof a[3] === 'object') {
651
+ const { writer: _w, hidden: _h, ...opts } = a[3];
652
+ a[3] = opts;
653
+ }
654
+ }
655
+ if (op === 'scores.top' && a[1] && typeof a[1] === 'object') {
656
+ const { hidden: _h, ...opts } = a[1];
657
+ a[1] = opts;
658
+ }
659
+ if (op === 'scores.top' && anon)
660
+ return typeof a[0] === 'string' && SAVE_KEY.test(a[0]) ? await anonTop(a[0], a[1] ?? {}) : badBoard();
661
+ const w = PAGE_WRITES[op];
662
+ if (w) {
663
+ if (!okPlayer(player)) return badPlayer();
664
+ if (!spend(`p:${w[0]}:${player}`, w[1]))
665
+ return no('rate', `Too many ${w[2]} this minute for one player (${w[1]}).`);
666
+ }
667
+ if (op === 'scores.submit') return await submitScore(a[0], a[1], a[2], a[3] ?? {}, player);
668
+ if (op === 'saves.put') return await putSave(a[0], a[1], a[2], player);
669
+ if (op === 'saves.delete') return await raw['saves.delete'](...a);
670
+ return await pageReads[op](...a);
671
+ } catch (e) {
672
+ return no('failed', String(e?.message ?? e).slice(0, 300));
673
+ }
371
674
  }
675
+ /** The page's submit (/platform/v1/scores/<board>), for `player`. */
676
+ const pageSubmit = (board, player, score, { name, order, writer } = {}) =>
677
+ page(
678
+ 'scores.submit',
679
+ [board, player, score, { name, ...(order ? { order } : {}), ...(writer ? { writer } : {}) }],
680
+ player,
681
+ );
372
682
 
373
683
  /** One call by name with positional args, as env.GF takes them. → the answer ({ok, …}); never throws. */
374
684
  async function run(op, args = []) {
@@ -386,19 +696,26 @@ export function createPlatformStore({
386
696
  // a file's bytes travel as they are: the put's body, the get's answer (its facts in x-gf-answer)
387
697
  if (op === 'files.put') {
388
698
  const q = url.searchParams;
389
- // a stream: held to exactly its declared length, or read up to 1 MB when it has none (xgames.fun's put does so)
699
+ const spent = filesOp();
700
+ if (spent) {
701
+ await request.body?.cancel().catch(() => {});
702
+ return Response.json(spent);
703
+ }
704
+ // a stream: held to exactly its declared length, or read up to 1 MB when it has none; a string or an
705
+ // ArrayBuffer is in memory already: at most 1 MB (xgames.fun's put does so)
390
706
  const stream = q.get('stream') === '1';
391
707
  const length = q.has('length') ? Number(q.get('length')) : null;
392
- if (stream && length !== null && length > FILES.fileBytes)
393
- return Response.json({ ok: false, code: 'too-big', why: `A file may be ${FILES.fileBytes / 1048576} MB.` });
394
- const cap = !stream ? FILES.fileBytes : length !== null ? length : UNSIZED_MAX;
708
+ const max = P.files.fileBytes;
709
+ if (stream && length !== null && length > max)
710
+ return Response.json({ ok: false, code: 'too-big', why: `A file may be ${mb(max)}.` });
711
+ const cap = !stream ? max : length !== null ? length : UNSIZED_MAX;
395
712
  const body = await readCapped(request, cap);
396
713
  if (!body) {
397
714
  if (stream && length === null)
398
715
  return Response.json({
399
716
  ok: false,
400
717
  code: 'body',
401
- why: `A stream over ${UNSIZED_MAX / 1048576} MB needs its length: put(path, stream, {length}). (A string or an ArrayBuffer needs none.)`,
718
+ why: `A stream over ${mb(UNSIZED_MAX)} needs its length: put(path, stream, {length}). (A string or an ArrayBuffer needs none.)`,
402
719
  });
403
720
  if (stream)
404
721
  return Response.json({
@@ -406,15 +723,22 @@ export function createPlatformStore({
406
723
  code: 'body',
407
724
  why: `The stream gave more bytes than its length (${length}), so nothing was stored.`,
408
725
  });
409
- return Response.json({ ok: false, code: 'too-big', why: `A file may be ${FILES.fileBytes / 1048576} MB.` });
726
+ return Response.json({ ok: false, code: 'too-big', why: `A file may be ${mb(max)}.` });
410
727
  }
728
+ if (!stream && body.length > UNSIZED_MAX)
729
+ return Response.json({
730
+ ok: false,
731
+ code: 'body',
732
+ why: `A string or ArrayBuffer body is at most ${mb(UNSIZED_MAX)}; send a larger file as a byte stream with its length: put(path, stream, {length}).`,
733
+ });
411
734
  if (stream && length !== null && body.length !== length)
412
735
  return Response.json({
413
736
  ok: false,
414
737
  code: 'body',
415
738
  why: `The stream gave fewer bytes than its length (${length}), so nothing was stored.`,
416
739
  });
417
- const opts = { public: q.get('public') === '1', ...(q.get('type') ? { type: q.get('type') } : {}) };
740
+ // no type is application/octet-stream, as the platform stores it (never a public type)
741
+ const opts = { public: q.get('public') === '1', type: q.get('type') || 'application/octet-stream' };
418
742
  return Response.json(await files.put(q.get('path') ?? '', body, opts));
419
743
  }
420
744
  if (op === 'files.get') {
@@ -424,6 +748,8 @@ export function createPlatformStore({
424
748
  } catch {
425
749
  /* no body */
426
750
  }
751
+ const spent = filesOp();
752
+ if (spent) return Response.json(spent);
427
753
  const a = await files.get(String(args?.[0] ?? ''));
428
754
  if (!a.ok) return Response.json(a);
429
755
  const { body, ...facts } = a;
@@ -446,8 +772,11 @@ export function createPlatformStore({
446
772
  return {
447
773
  slug,
448
774
  gameId,
775
+ plan: P,
449
776
  run,
777
+ page,
450
778
  pageSubmit,
779
+ playerId,
451
780
  handleHost,
452
781
  devToken,
453
782
  verifyPlayer,
@@ -486,8 +815,11 @@ async function readCapped(request, cap, drain = 64 * 1024 * 1024) {
486
815
  // DELETE /platform/v1/saves/<key> bearer → {ok, deleted}
487
816
  // POST /platform/v1/scores/<board> bearer {score, name?, order?} → {ok, improved, best, rank}
488
817
  // (403 server-only: a board the game's server set writer 'server')
489
- // GET /platform/v1/scores/<board>?limit=&offset= → {ok, order, writer, entries}
818
+ // GET /platform/v1/scores/<board>?limit=&offset= bearer? → {ok, order, writer, entries}: each entry's player
819
+ // an id of this game's own, `me: true` on the caller's; with no good token,
820
+ // the top 100 only (401 past it)
490
821
  // GET /platform/v1/scores/<board>/me bearer → {ok, entry}
822
+ // Bodies are JSON (content-type: application/json, else 415), at most 96 KB (413).
491
823
  const STATUS = {
492
824
  'server-only': 403,
493
825
  player: 401,
@@ -511,20 +843,26 @@ const json = (res, status, body) => {
511
843
  return true;
512
844
  };
513
845
  const answer = (res, a) => json(res, a?.ok ? 200 : (STATUS[a?.code] ?? 400), a);
514
- async function readBody(req, cap = 96 * 1024) {
846
+ const refusal = (status, code, why) => Object.assign(new Error(why), { status, code });
847
+ /** A page's JSON body, as the platform reads one: at most 96 KB, JSON only; an empty one is {}, a non-object {}. */
848
+ async function readBody(req, { strict = true } = {}) {
515
849
  let size = 0;
516
850
  const chunks = [];
517
851
  for await (const c of req) {
518
852
  size += c.length;
519
- if (size > cap) throw Object.assign(new Error('The body is too big.'), { status: 413 });
853
+ if (size > RULES.page.bodyBytes) throw refusal(413, 'too-big', `A body may be ${RULES.page.bodyBytes / 1024} KB.`);
520
854
  chunks.push(c);
521
855
  }
522
856
  if (!size) return {};
857
+ if (strict && !/^application\/json\s*(;|$)/i.test(String(req.headers['content-type'] ?? '')))
858
+ throw refusal(415, 'failed', 'The body must be JSON (content-type: application/json).');
859
+ let value;
523
860
  try {
524
- return JSON.parse(Buffer.concat(chunks).toString('utf8'));
861
+ value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
525
862
  } catch {
526
- throw Object.assign(new Error('The body is not JSON.'), { status: 400 });
863
+ throw refusal(400, 'failed', 'The body is not JSON.');
527
864
  }
865
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
528
866
  }
529
867
 
530
868
  /** → true when the request was a platform route (answered), else false. */
@@ -546,7 +884,7 @@ export async function handlePlatform(req, res, url, s) {
546
884
  try {
547
885
  const m = req.method;
548
886
  if (rest[0] === 'dev' && rest[1] === 'token' && rest.length === 2 && m === 'POST') {
549
- const b = await readBody(req);
887
+ const b = await readBody(req, { strict: false });
550
888
  return json(res, 200, s.devToken({ name: b.name, id: b.id }));
551
889
  }
552
890
  if (rest[0] === 'me' && rest.length === 1 && m === 'GET') return answer(res, who());
@@ -554,38 +892,52 @@ export async function handlePlatform(req, res, url, s) {
554
892
  const w = who();
555
893
  if (!w.ok) return answer(res, w);
556
894
  const id = w.player.id;
557
- if (rest.length === 1 && m === 'GET') return answer(res, await s.run('saves.list', [id]));
558
- if (rest.length === 2 && m === 'GET') return answer(res, await s.run('saves.get', [id, rest[1]]));
895
+ if (rest.length === 1 && m === 'GET') return answer(res, await s.page('saves.list', [id], id));
896
+ if (rest.length === 2 && m === 'GET') return answer(res, await s.page('saves.get', [id, rest[1]], id));
559
897
  if (rest.length === 2 && m === 'PUT') {
560
898
  const b = await readBody(req);
561
- return answer(res, await s.run('saves.put', [id, rest[1], b.value]));
899
+ return answer(res, await s.page('saves.put', [id, rest[1], b.value], id));
562
900
  }
563
- if (rest.length === 2 && m === 'DELETE') return answer(res, await s.run('saves.delete', [id, rest[1]]));
901
+ if (rest.length === 2 && m === 'DELETE') return answer(res, await s.page('saves.delete', [id, rest[1]], id));
564
902
  }
565
903
  if (rest[0] === 'scores' && rest.length === 2 && m === 'GET') {
566
- const q = {
567
- limit: Number(url.searchParams.get('limit') ?? 10),
568
- offset: Number(url.searchParams.get('offset') ?? 0),
569
- };
570
- return answer(res, await s.run('scores.top', [rest[1], q]));
904
+ // no token needed; a good one marks the caller's own entry `me: true`, a bad one marks nothing
905
+ const v = bearer ? s.readToken(bearer) : null;
906
+ const mine = v?.ok ? [v.sub, v.wallet].filter(i => typeof i === 'string' && i) : [];
907
+ const limit = Number(url.searchParams.get('limit') ?? 10),
908
+ offset = Number(url.searchParams.get('offset') ?? 0);
909
+ const top = await s.page('scores.top', [rest[1], { limit, offset }], '', { anon: !mine.length });
910
+ if (!top?.ok || !Array.isArray(top.entries)) return answer(res, top);
911
+ const entries = top.entries.map(e => ({
912
+ ...e,
913
+ player: s.playerId(e.player),
914
+ ...(mine.includes(e.player) ? { me: true } : {}),
915
+ }));
916
+ return answer(res, { ...top, entries });
571
917
  }
572
918
  if (rest[0] === 'scores' && rest.length === 2 && m === 'POST') {
573
919
  const w = who();
574
920
  if (!w.ok) return answer(res, w);
575
921
  const b = await readBody(req);
576
- const opts = {
577
- name: typeof b.name === 'string' ? b.name : w.player.name,
578
- ...(b.order ? { order: b.order } : {}),
579
- };
580
- return answer(res, await s.pageSubmit(rest[1], w.player.id, b.score, opts));
922
+ // a name that reads as an X handle stands only when it is the player's own (none here): else the player's name
923
+ const sent = typeof b.name === 'string' ? b.name : null;
924
+ const own = w.player.x?.handle ? `@${w.player.x.handle}` : '';
925
+ const name =
926
+ sent === null || (fold(NAME(sent)).startsWith('@') && !(own && fold(NAME(sent)) === fold(own)))
927
+ ? w.player.name
928
+ : sent;
929
+ return answer(
930
+ res,
931
+ await s.pageSubmit(rest[1], w.player.id, b.score, { name, ...(b.order ? { order: b.order } : {}) }),
932
+ );
581
933
  }
582
934
  if (rest[0] === 'scores' && rest.length === 3 && rest[2] === 'me' && m === 'GET') {
583
935
  const w = who();
584
936
  if (!w.ok) return answer(res, w);
585
- return answer(res, await s.run('scores.best', [rest[1], w.player.id]));
937
+ return answer(res, await s.page('scores.best', [rest[1], w.player.id], w.player.id));
586
938
  }
587
939
  return json(res, 404, { ok: false, code: 'unknown', why: `No platform route ${m} ${url.pathname}.` });
588
940
  } catch (e) {
589
- return json(res, e.status ?? 500, { ok: false, code: 'failed', why: e.message });
941
+ return json(res, e.status ?? 500, { ok: false, code: e.code ?? 'failed', why: e.message });
590
942
  }
591
943
  }