@xgamesfun/xcore 0.3.0 → 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 = '' } = {}) {
@@ -150,53 +224,208 @@ export function createPlatformStore({
150
224
  if (c.aud !== `game:${slug}`) return no('game', 'The token is for another game.');
151
225
  return { ok: true, sub: c.sub, wallet: null, x: null, lvl: c.lvl | 0, exp: c.exp, dev: true, name: c.name };
152
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);
153
232
 
154
233
  const ranked = b =>
155
234
  [...b.rows].sort((x, y) => (b.order === 'low' ? x.score - y.score : y.score - x.score) || x.at - y.at);
156
235
  const rankOf = (b, score) => b.rows.filter(r => (b.order === 'low' ? r.score < score : r.score > score)).length + 1;
157
- 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.`);
158
244
  const badKey = () => no('key', 'A save key is 1–32 letters, digits, ".", "_" or "-".');
159
245
  const badBoard = () => no('board', 'A board name is 1–32 letters, digits, ".", "_" or "-" (or a pattern: daily-*).');
160
246
  // a rule names one board, or every board whose name starts with a prefix: `daily-*` (and `*`, every board)
161
247
  const PATTERN = /^[A-Za-z0-9_.-]{0,31}\*$/;
162
248
  const WRITERS = ['any', 'server'];
163
249
  const badWriter = () => no('writer', 'A board’s writer is "server" (only the game’s server submits) or "any".');
164
- 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 = {
165
417
  async 'saves.get'(player, k = 'main') {
166
418
  const d = await load();
167
419
  if (!okPlayer(player)) return badPlayer();
168
420
  if (!SAVE_KEY.test(String(k))) return badKey();
169
- const s = d.saves[player]?.[k];
421
+ const s = Object.hasOwn(d.saves[player] ?? {}, k) ? d.saves[player][k] : null;
170
422
  return { ok: true, value: s ? s.value : null, updatedAt: s?.updated ?? null };
171
423
  },
172
- async 'saves.put'(player, k, value) {
173
- const d = await load();
174
- if (!okPlayer(player)) return badPlayer();
175
- if (!SAVE_KEY.test(String(k))) return badKey();
176
- let text;
177
- try {
178
- text = JSON.stringify(value);
179
- } catch {
180
- text = undefined;
181
- }
182
- if (text === undefined) return no('value', 'A save is a JSON value.');
183
- const bytes = Buffer.byteLength(text);
184
- if (bytes > L.saves.valueBytes)
185
- return no(
186
- 'too-big',
187
- `A save may be ${L.saves.valueBytes / 1024} KB; this one is ${Math.ceil(bytes / 1024)} KB.`,
188
- );
189
- const mine = (d.saves[player] ??= {});
190
- if (!mine[k] && Object.keys(mine).length >= L.saves.keysPerPlayer)
191
- return no('too-many', `A player may have ${L.saves.keysPerPlayer} saves.`);
192
- mine[k] = { value: JSON.parse(text), bytes, updated: now() };
193
- persist();
194
- return { ok: true, bytes };
195
- },
424
+ 'saves.put': (player, k, value) => putSave(player, k, value, null),
196
425
  async 'saves.delete'(player, k) {
197
426
  const d = await load();
198
427
  if (!okPlayer(player) || !SAVE_KEY.test(String(k))) return no('key', 'No such save.');
199
- const had = !!d.saves[player]?.[k];
428
+ const had = Object.hasOwn(d.saves[player] ?? {}, k);
200
429
  if (had) {
201
430
  delete d.saves[player][k];
202
431
  persist();
@@ -211,32 +440,12 @@ export function createPlatformStore({
211
440
  .map(([k, s]) => ({ key: k, bytes: s.bytes, updatedAt: s.updated }));
212
441
  return { ok: true, keys };
213
442
  },
214
- // `writer` ('server' | 'any') is the game's server's word only: the page's route never passes it (pageSubmit).
215
- async 'scores.submit'(board, player, score, { name = '', order = 'high', writer } = {}) {
216
- const d = await load();
217
- if (!SAVE_KEY.test(String(board))) return badBoard();
218
- if (!okPlayer(player)) return badPlayer();
219
- if (typeof score !== 'number' || !Number.isFinite(score)) return no('score', 'A score is a finite number.');
220
- if (typeof name !== 'string' || name.length > L.scores.nameChars)
221
- return no('name', `A name is at most ${L.scores.nameChars} characters.`);
222
- const b = ensureBoard(d, board, order, writer);
223
- if (b.ok === false) return b;
224
- let row = b.rows.find(r => r.player === player);
225
- if (row && now() - row.tried < L.scores.perPlayerEveryMs) return no('rate', 'One score a second a player.');
226
- const t = now(),
227
- improved = !row || (b.order === 'low' ? score < row.score : score > row.score);
228
- if (!row) b.rows.push((row = { player, name: NAME(name), score, at: t, tried: t }));
229
- else {
230
- row.tried = t;
231
- if (improved) Object.assign(row, { score, at: t, name: NAME(name) || row.name });
232
- }
233
- persist();
234
- return { ok: true, improved, best: row.score, rank: rankOf(b, row.score) };
235
- },
443
+ 'scores.submit': (board, player, score, opts) => submitScore(board, player, score, opts ?? {}, null),
236
444
  /** The board's settings, made now when new: {order?, writer?: 'server' | 'any'} (xgames.fun's GF.scores.setup).
237
445
  * `board` may be a pattern (`daily-*`, `*`): then only the rule is kept, for those boards now and to come. */
238
- async 'scores.setup'(board, { order = 'high', writer } = {}) {
446
+ async 'scores.setup'(board, { order: asked, writer } = {}) {
239
447
  const d = await load();
448
+ const order = asked ?? undefined; // null asks nothing, as no order does
240
449
  if (typeof board === 'string' && PATTERN.test(board)) {
241
450
  if (!WRITERS.includes(writer)) return badWriter();
242
451
  const removed = d.rules[board] === writer ? 0 : setRule(d, board, writer);
@@ -245,10 +454,43 @@ export function createPlatformStore({
245
454
  return { ok: true, board, writer, removed };
246
455
  }
247
456
  if (!SAVE_KEY.test(String(board))) return badBoard();
248
- const b = ensureBoard(d, board, order, writer);
249
- 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;
250
463
  persist();
251
- 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() } };
252
494
  },
253
495
  /** The board, its entries and its own rule, gone: its place among the boards is free again (GF.scores.drop). */
254
496
  async 'scores.drop'(board) {
@@ -264,10 +506,12 @@ export function createPlatformStore({
264
506
  async 'scores.top'(board, { limit = 10, offset = 0 } = {}) {
265
507
  const d = await load();
266
508
  if (!SAVE_KEY.test(String(board))) return badBoard();
267
- 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),
268
510
  skip = Math.max(0, offset | 0);
269
511
  const b = d.boards[board];
270
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: [] };
271
515
  const entries = ranked(b)
272
516
  .slice(skip, skip + n)
273
517
  .map(r => ({ rank: rankOf(b, r.score), player: r.player, name: r.name, score: r.score, at: r.at }));
@@ -278,7 +522,7 @@ export function createPlatformStore({
278
522
  if (!SAVE_KEY.test(String(board)) || !okPlayer(player)) return no('board', 'No such board or player.');
279
523
  const b = d.boards[board],
280
524
  r = b?.rows.find(x => x.player === player);
281
- 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 };
282
526
  },
283
527
  async 'scores.remove'(board, player) {
284
528
  const d = await load();
@@ -291,6 +535,13 @@ export function createPlatformStore({
291
535
  }
292
536
  return { ok: true, removed: had };
293
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'),
294
545
  async 'loot.report'(result) {
295
546
  const d = await load();
296
547
  const bad = checkLootResult(result, now());
@@ -313,15 +564,16 @@ export function createPlatformStore({
313
564
  async info() {
314
565
  return { ok: true, contract: SERVER_FORMAT, gameId, slug, local: true };
315
566
  },
316
- 'files.head': p => files.head(p),
317
- 'files.list': opts => files.list(opts ?? {}),
318
- 'files.delete': p => files.delete(p),
319
- '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()) },
320
572
  };
321
573
 
322
574
  /** The board's writer: its own rule, else the longest pattern (`daily-*`, `*`) its name starts with, else 'any'. */
323
575
  function writerOf(d, board) {
324
- if (d.rules[board]) return d.rules[board];
576
+ if (Object.hasOwn(d.rules, board)) return d.rules[board];
325
577
  const p = Object.keys(d.rules)
326
578
  .filter(r => r.endsWith('*') && board.startsWith(r.slice(0, -1)))
327
579
  .sort((x, y) => y.length - x.length)[0];
@@ -330,8 +582,8 @@ export function createPlatformStore({
330
582
  /** Sets the rule for a board or a pattern. A board that becomes server-only starts empty: what was written while
331
583
  * anyone could write it is no one's checked word. → the number of entries removed, or a refusal. */
332
584
  function setRule(d, name, writer) {
333
- if (!(name in d.rules) && Object.keys(d.rules).length >= L.scores.rules)
334
- 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.`);
335
587
  const hit = name.endsWith('*')
336
588
  ? Object.keys(d.boards).filter(b => b.startsWith(name.slice(0, -1)))
337
589
  : d.boards[name]
@@ -347,31 +599,86 @@ export function createPlatformStore({
347
599
  }
348
600
  return removed;
349
601
  }
350
- /** Makes the board (when new) with `order`; `writer`, when given, becomes its rule. → the board (with `removed`) or
351
- * a refusal. */
352
- function ensureBoard(d, board, order, writer) {
353
- if (writer !== undefined && !WRITERS.includes(writer)) return badWriter();
354
- let b = d.boards[board];
355
- if (!b) {
356
- if (!['high', 'low'].includes(order)) return no('order', 'A board’s order is "high" (higher wins) or "low".');
357
- if (Object.keys(d.boards).length >= L.scores.boards)
358
- return no('too-many', `A game may have ${L.scores.boards} boards (GF.scores.drop frees one).`);
359
- b = d.boards[board] = { order, rows: [] };
360
- }
361
- let removed = 0;
362
- if (writer !== undefined && writer !== writerOf(d, board)) {
363
- removed = setRule(d, board, writer);
364
- 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 });
365
632
  }
366
- return { ...b, removed };
633
+ return answer;
367
634
  }
368
- /** The page's submit (/platform/v1/scores/<board>): refused on a board only the server writes; never sets a rule. */
369
- async function pageSubmit(board, player, score, { name, order } = {}) {
370
- const d = await load();
371
- if (writerOf(d, board) === 'server')
372
- return no('server-only', 'This board takes scores from the game’s server only.');
373
- 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
+ }
374
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
+ );
375
682
 
376
683
  /** One call by name with positional args, as env.GF takes them. → the answer ({ok, …}); never throws. */
377
684
  async function run(op, args = []) {
@@ -389,19 +696,26 @@ export function createPlatformStore({
389
696
  // a file's bytes travel as they are: the put's body, the get's answer (its facts in x-gf-answer)
390
697
  if (op === 'files.put') {
391
698
  const q = url.searchParams;
392
- // 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)
393
706
  const stream = q.get('stream') === '1';
394
707
  const length = q.has('length') ? Number(q.get('length')) : null;
395
- if (stream && length !== null && length > FILES.fileBytes)
396
- return Response.json({ ok: false, code: 'too-big', why: `A file may be ${FILES.fileBytes / 1048576} MB.` });
397
- 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;
398
712
  const body = await readCapped(request, cap);
399
713
  if (!body) {
400
714
  if (stream && length === null)
401
715
  return Response.json({
402
716
  ok: false,
403
717
  code: 'body',
404
- 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.)`,
405
719
  });
406
720
  if (stream)
407
721
  return Response.json({
@@ -409,15 +723,22 @@ export function createPlatformStore({
409
723
  code: 'body',
410
724
  why: `The stream gave more bytes than its length (${length}), so nothing was stored.`,
411
725
  });
412
- 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)}.` });
413
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
+ });
414
734
  if (stream && length !== null && body.length !== length)
415
735
  return Response.json({
416
736
  ok: false,
417
737
  code: 'body',
418
738
  why: `The stream gave fewer bytes than its length (${length}), so nothing was stored.`,
419
739
  });
420
- 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' };
421
742
  return Response.json(await files.put(q.get('path') ?? '', body, opts));
422
743
  }
423
744
  if (op === 'files.get') {
@@ -427,6 +748,8 @@ export function createPlatformStore({
427
748
  } catch {
428
749
  /* no body */
429
750
  }
751
+ const spent = filesOp();
752
+ if (spent) return Response.json(spent);
430
753
  const a = await files.get(String(args?.[0] ?? ''));
431
754
  if (!a.ok) return Response.json(a);
432
755
  const { body, ...facts } = a;
@@ -449,8 +772,11 @@ export function createPlatformStore({
449
772
  return {
450
773
  slug,
451
774
  gameId,
775
+ plan: P,
452
776
  run,
777
+ page,
453
778
  pageSubmit,
779
+ playerId,
454
780
  handleHost,
455
781
  devToken,
456
782
  verifyPlayer,
@@ -489,8 +815,11 @@ async function readCapped(request, cap, drain = 64 * 1024 * 1024) {
489
815
  // DELETE /platform/v1/saves/<key> bearer → {ok, deleted}
490
816
  // POST /platform/v1/scores/<board> bearer {score, name?, order?} → {ok, improved, best, rank}
491
817
  // (403 server-only: a board the game's server set writer 'server')
492
- // 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)
493
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).
494
823
  const STATUS = {
495
824
  'server-only': 403,
496
825
  player: 401,
@@ -514,20 +843,26 @@ const json = (res, status, body) => {
514
843
  return true;
515
844
  };
516
845
  const answer = (res, a) => json(res, a?.ok ? 200 : (STATUS[a?.code] ?? 400), a);
517
- 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 } = {}) {
518
849
  let size = 0;
519
850
  const chunks = [];
520
851
  for await (const c of req) {
521
852
  size += c.length;
522
- 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.`);
523
854
  chunks.push(c);
524
855
  }
525
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;
526
860
  try {
527
- return JSON.parse(Buffer.concat(chunks).toString('utf8'));
861
+ value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
528
862
  } catch {
529
- throw Object.assign(new Error('The body is not JSON.'), { status: 400 });
863
+ throw refusal(400, 'failed', 'The body is not JSON.');
530
864
  }
865
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
531
866
  }
532
867
 
533
868
  /** → true when the request was a platform route (answered), else false. */
@@ -549,7 +884,7 @@ export async function handlePlatform(req, res, url, s) {
549
884
  try {
550
885
  const m = req.method;
551
886
  if (rest[0] === 'dev' && rest[1] === 'token' && rest.length === 2 && m === 'POST') {
552
- const b = await readBody(req);
887
+ const b = await readBody(req, { strict: false });
553
888
  return json(res, 200, s.devToken({ name: b.name, id: b.id }));
554
889
  }
555
890
  if (rest[0] === 'me' && rest.length === 1 && m === 'GET') return answer(res, who());
@@ -557,38 +892,52 @@ export async function handlePlatform(req, res, url, s) {
557
892
  const w = who();
558
893
  if (!w.ok) return answer(res, w);
559
894
  const id = w.player.id;
560
- if (rest.length === 1 && m === 'GET') return answer(res, await s.run('saves.list', [id]));
561
- 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));
562
897
  if (rest.length === 2 && m === 'PUT') {
563
898
  const b = await readBody(req);
564
- 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));
565
900
  }
566
- 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));
567
902
  }
568
903
  if (rest[0] === 'scores' && rest.length === 2 && m === 'GET') {
569
- const q = {
570
- limit: Number(url.searchParams.get('limit') ?? 10),
571
- offset: Number(url.searchParams.get('offset') ?? 0),
572
- };
573
- 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 });
574
917
  }
575
918
  if (rest[0] === 'scores' && rest.length === 2 && m === 'POST') {
576
919
  const w = who();
577
920
  if (!w.ok) return answer(res, w);
578
921
  const b = await readBody(req);
579
- const opts = {
580
- name: typeof b.name === 'string' ? b.name : w.player.name,
581
- ...(b.order ? { order: b.order } : {}),
582
- };
583
- 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
+ );
584
933
  }
585
934
  if (rest[0] === 'scores' && rest.length === 3 && rest[2] === 'me' && m === 'GET') {
586
935
  const w = who();
587
936
  if (!w.ok) return answer(res, w);
588
- 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));
589
938
  }
590
939
  return json(res, 404, { ok: false, code: 'unknown', why: `No platform route ${m} ${url.pathname}.` });
591
940
  } catch (e) {
592
- 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 });
593
942
  }
594
943
  }