@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.
- package/README.md +52 -16
- package/commands/dev.mjs +31 -10
- package/commands/login.mjs +22 -11
- package/commands/logout.mjs +2 -0
- package/docs/SERVER.md +90 -22
- package/lib/check.mjs +1 -0
- package/lib/cli.mjs +2 -0
- package/lib/client/gamesfun.js +4 -1
- package/lib/client/server/0001_init.sql +1 -1
- package/lib/client/server/database.js +2 -1
- package/lib/client/server/gf-db.js +6 -2
- package/lib/client/server/rooms.js +1 -0
- package/lib/dev/files.mjs +102 -5
- package/lib/dev/gf-local.js +4 -0
- package/lib/dev/index.mjs +32 -9
- package/lib/dev/limits.mjs +55 -24
- package/lib/dev/plans.mjs +101 -0
- package/lib/dev/platform.mjs +485 -136
- package/lib/login-reach.mjs +184 -0
- package/lib/publish/index.mjs +16 -7
- package/lib/publish/names.mjs +128 -1
- package/lib/publish/scan.mjs +25 -1
- package/lib/publish/tree.mjs +9 -1
- package/lib/review.mjs +216 -1
- package/lib/secret-patterns.mjs +36 -26
- package/lib/server/bundle.mjs +139 -28
- package/lib/server/contract.mjs +91 -11
- package/package.json +2 -2
- package/skills/bring-your-own-game/SKILL.md +13 -4
package/lib/dev/platform.mjs
CHANGED
|
@@ -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
|
|
11
|
-
//
|
|
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
|
-
|
|
33
|
-
|
|
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,
|
|
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 <=
|
|
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.
|
|
46
|
-
*
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
|
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]
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
249
|
-
|
|
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 {
|
|
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),
|
|
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 ? {
|
|
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
|
-
|
|
317
|
-
'files.
|
|
318
|
-
'files.
|
|
319
|
-
'files.
|
|
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
|
|
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 (!(
|
|
334
|
-
return no('too-many', `
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
|
633
|
+
return answer;
|
|
367
634
|
}
|
|
368
|
-
/**
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
|
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 ${
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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 >
|
|
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
|
-
|
|
861
|
+
value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
528
862
|
} catch {
|
|
529
|
-
throw
|
|
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.
|
|
561
|
-
if (rest.length === 2 && m === 'GET') return answer(res, await s.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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.
|
|
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
|
}
|