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