@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/lib/dev/files.mjs CHANGED
@@ -12,11 +12,84 @@ import { createReadStream, existsSync } from 'node:fs';
12
12
  import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
13
13
  import { createServer } from 'node:http';
14
14
  import path from 'node:path';
15
- import { FILES, filePathProblem, fileType, servedType } from '../server/contract.mjs';
15
+ import { FILES, filePathProblem, servedType } from '../server/contract.mjs';
16
16
  import { stateFolder } from '../state-folder.mjs';
17
17
 
18
18
  const no = (code, why) => ({ ok: false, code, why });
19
19
 
20
+ // ---- public files: the types a file may be public as, each known by its first bytes, as xgames.fun checks them ----
21
+ // A public file is media or data a page loads: an image, audio, video, a font, JSON or plain text, whose first bytes
22
+ // are plainly that type. Never a document a browser runs (HTML, SVG, XML, JavaScript, CSS, PDF) nor a download
23
+ // (programs, archives, application/octet-stream): those stay private, and the game's own server serves them.
24
+ const at = (b, offset, sig) => b.length >= offset + sig.length && sig.every((x, i) => b[offset + i] === x);
25
+ const word = (b, offset, text) =>
26
+ at(
27
+ b,
28
+ offset,
29
+ [...text].map(c => c.charCodeAt(0)),
30
+ );
31
+ /** The first byte that is not whitespace (after a UTF-8 byte order mark), or -1. */
32
+ function firstVisible(b) {
33
+ let i = at(b, 0, [0xef, 0xbb, 0xbf]) ? 3 : 0;
34
+ while (i < b.length && (b[i] === 0x20 || b[i] === 0x09 || b[i] === 0x0a || b[i] === 0x0d)) i++;
35
+ return i < b.length ? b[i] : -1;
36
+ }
37
+ const riff = (b, kind) => word(b, 0, 'RIFF') && word(b, 8, kind);
38
+ const ftyp = (b, brands = null) => word(b, 4, 'ftyp') && (!brands || brands.some(x => word(b, 8, x)));
39
+ const mpegAudio = b => word(b, 0, 'ID3') || (b[0] === 0xff && (b[1] & 0xe0) === 0xe0);
40
+ const ogg = b => word(b, 0, 'OggS'),
41
+ webm = b => at(b, 0, [0x1a, 0x45, 0xdf, 0xa3]),
42
+ wave = b => riff(b, 'WAVE');
43
+ export const PUBLIC_TYPES = Object.freeze({
44
+ 'image/png': b => at(b, 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
45
+ 'image/jpeg': b => at(b, 0, [0xff, 0xd8, 0xff]),
46
+ 'image/gif': b => word(b, 0, 'GIF87a') || word(b, 0, 'GIF89a'),
47
+ 'image/webp': b => riff(b, 'WEBP'),
48
+ 'image/avif': b => ftyp(b, ['avif', 'avis']),
49
+ 'audio/mpeg': mpegAudio,
50
+ 'audio/ogg': ogg,
51
+ 'video/ogg': ogg,
52
+ 'audio/wav': wave,
53
+ 'audio/wave': wave,
54
+ 'audio/x-wav': wave,
55
+ 'audio/webm': webm,
56
+ 'video/webm': webm,
57
+ 'audio/mp4': b => ftyp(b),
58
+ 'audio/x-m4a': b => ftyp(b),
59
+ 'video/mp4': b => ftyp(b),
60
+ 'audio/aac': b => b[0] === 0xff && (b[1] & 0xf6) === 0xf0,
61
+ 'audio/flac': b => word(b, 0, 'fLaC'),
62
+ 'font/woff': b => word(b, 0, 'wOFF'),
63
+ 'font/woff2': b => word(b, 0, 'wOF2'),
64
+ 'font/ttf': b => at(b, 0, [0, 1, 0, 0]) || word(b, 0, 'true'),
65
+ 'font/otf': b => word(b, 0, 'OTTO'),
66
+ 'font/collection': b => word(b, 0, 'ttcf'),
67
+ 'application/json': b => {
68
+ const c = firstVisible(b);
69
+ return c >= 0 && '{["-0123456789tfn'.includes(String.fromCharCode(c));
70
+ },
71
+ 'text/plain': b => !b.includes(0) && firstVisible(b) !== 0x3c,
72
+ });
73
+ /** How many leading bytes the public check reads. */
74
+ const SNIFF_BYTES = 512;
75
+ const PUBLIC_WORDS =
76
+ 'an image (PNG, JPEG, WebP, GIF, AVIF), audio (MP3, Ogg, WAV, WebM, MP4, AAC, FLAC), video (MP4, WebM, Ogg), a font (WOFF, WOFF2, TTF, OTF), JSON or plain text';
77
+ /** A type's essence: "Image/PNG; x=y" → "image/png". */
78
+ const essenceOf = type =>
79
+ String(type ?? '')
80
+ .split(';')[0]
81
+ .trim()
82
+ .toLowerCase();
83
+ const MEDIA_TYPE =
84
+ /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*(\s*;\s*[a-z0-9_-]+=("[^"\\]*"|[a-z0-9_.+-]+))*$/i;
85
+ /** The type a put names (lower case), application/octet-stream for none, or null when it is not a media type. */
86
+ const typeOf = type =>
87
+ type == null || type === ''
88
+ ? 'application/octet-stream'
89
+ : typeof type === 'string' && type.length <= 128 && MEDIA_TYPE.test(type)
90
+ ? type.trim().toLowerCase()
91
+ : null;
92
+
20
93
  /** One game's files. `origin`: where the public ones are served (a function: the files server's port comes later). */
21
94
  export function createFileStore({ dir, origin = () => null, cap = FILES.capBytes, fileBytes = FILES.fileBytes }) {
22
95
  const root = path.join(dir, '.gamesfun', 'dev', 'files');
@@ -56,7 +129,10 @@ export function createFileStore({ dir, origin = () => null, cap = FILES.capBytes
56
129
 
57
130
  return {
58
131
  root,
59
- /** body: a string, bytes (ArrayBuffer, a typed array, a Buffer). → {ok, path, bytes, url?} */
132
+ /**
133
+ * body: a string, bytes (ArrayBuffer, a typed array, a Buffer). `type`: a media type (none: application/octet-stream);
134
+ * `public`: served on the files origin, for the public types only (PUBLIC_TYPES). → {ok, path, bytes, url?}
135
+ */
60
136
  put(p, body, { type, public: isPublic = false } = {}) {
61
137
  return queued(async () => {
62
138
  const bad = filePathProblem(p);
@@ -70,6 +146,20 @@ export function createFileStore({ dir, origin = () => null, cap = FILES.capBytes
70
146
  ? Buffer.from(body.buffer, body.byteOffset, body.byteLength)
71
147
  : null;
72
148
  if (!bytes) return no('body', 'A file is a string, an ArrayBuffer or a stream of bytes.');
149
+ const t = typeOf(type);
150
+ if (t === null) return no('type', 'A file’s type is a media type, like image/png or application/json.');
151
+ // a public file is media or data the game's page loads, plainly so by its first bytes
152
+ const shown = isPublic === true;
153
+ if (shown && !PUBLIC_TYPES[essenceOf(t)])
154
+ return no(
155
+ 'type',
156
+ `A public file is ${PUBLIC_WORDS}; this one is ${essenceOf(t)}. Keep it private (public: false) and serve it from the game’s server.`,
157
+ );
158
+ if (shown && !PUBLIC_TYPES[essenceOf(t)](bytes.subarray(0, SNIFF_BYTES)))
159
+ return no(
160
+ 'type',
161
+ `This file’s first bytes are not ${essenceOf(t)}, so it can’t be public as that. A public file is ${PUBLIC_WORDS}.`,
162
+ );
73
163
  if (bytes.length > fileBytes)
74
164
  return no(
75
165
  'too-big',
@@ -88,8 +178,10 @@ export function createFileStore({ dir, origin = () => null, cap = FILES.capBytes
88
178
  await rename(`${file}.gf-tmp`, file);
89
179
  m[p] = {
90
180
  bytes: bytes.length,
91
- type: typeof type === 'string' && type ? type.slice(0, 200) : fileType(p),
92
- public: !!isPublic,
181
+ type: t,
182
+ public: shown,
183
+ // the type its first bytes showed: only a file so marked is served as itself
184
+ ...(shown ? { checked: essenceOf(t) } : {}),
93
185
  updatedAt: Date.now(),
94
186
  etag: createHash('sha256').update(bytes).digest('hex').slice(0, 32),
95
187
  };
@@ -206,7 +298,12 @@ export function serveFiles(store, { port = 0, host = '127.0.0.1' } = {}) {
206
298
  if (filePathProblem(p)) return send(404, { 'content-type': 'text/plain' }, 'No such file.');
207
299
  const f = await store.served(p);
208
300
  if (!f) return send(404, { 'content-type': 'text/plain' }, 'No such file.');
209
- const { type, inline } = servedType(f.meta.type);
301
+ // as itself only when its first bytes were checked as its type; anything else (a file kept before the check) is a
302
+ // download
303
+ const { type, inline } =
304
+ f.meta.checked && f.meta.checked === essenceOf(f.meta.type)
305
+ ? servedType(f.meta.type)
306
+ : { type: 'application/octet-stream', inline: false };
210
307
  const etag = `"${f.meta.etag}"`;
211
308
  const head = {
212
309
  'content-type': type,
@@ -67,6 +67,10 @@ class Scores extends RpcTarget {
67
67
  drop(board) {
68
68
  return call(this.#env, 'scores.drop', [board]);
69
69
  }
70
+ /** The game's boards (who made each: 'server' or 'page') and rules, as xgames.fun's GF.scores.list answers. */
71
+ list() {
72
+ return call(this.#env, 'scores.list', []);
73
+ }
70
74
  }
71
75
  class Loot extends RpcTarget {
72
76
  #env;
package/lib/dev/index.mjs CHANGED
@@ -3,7 +3,8 @@
3
3
  // /game/* the published files (gamesfun.json page.out), with the play host's headers (../check.mjs servePage)
4
4
  // /game/api/* the game's own server/ under workerd (./server.mjs), WebSockets too, sent on as /api/*; for a
5
5
  // game with a database and no server/, xcore's stock server, with a local D1 (./d1.mjs)
6
- // /platform/v1/* sign-in, saves and leaderboards (./platform.mjs), the store env.GF reads as well
6
+ // /platform/v1/* sign-in, saves and leaderboards (./platform.mjs), the store env.GF reads as well, held to the
7
+ // limits of the plan it imitates (./plans.mjs: Free unless `plan` says another)
7
8
  // /platform/v1/live the game's announcement, config and maintenance, from .gamesfun/live.json (./live.mjs); while
8
9
  // maintenance is on there, the holding page for its HTML and 503 'maintenance' for its api/
9
10
  // and platform calls, as xgames.fun's play edge does
@@ -25,6 +26,7 @@ import { PAGE_RPC, rpcWords, solanaRpc } from '../solana-rpc.mjs';
25
26
  import { handlePlatform, createPlatformStore } from './platform.mjs';
26
27
  import { createLiveRoutes, readLive, LIVE_FILE } from './live.mjs';
27
28
  import { createLimitWatch } from './limits.mjs';
29
+ import { devPlan, planLine } from './plans.mjs';
28
30
  import { serveFiles } from './files.mjs';
29
31
 
30
32
  const readJson = file => {
@@ -106,20 +108,27 @@ async function body(req, cap = 256 * 1024) {
106
108
  return Buffer.concat(chunks);
107
109
  }
108
110
 
109
- /**
110
- * Start the dev host for `dir`. Options: port (default 8787; 0 any), filesPort (default the next one; 0 any), noBuild,
111
- * log, env, fetchImpl (the RPC forward's).
112
- * → {url, origin, files (the public files' origin), rpc, server (the game server's status, or null), store, reload(),
113
- * close()}
114
- */
111
+ /** What xcore dev does not imitate, said once when it starts. */
112
+ export const NOT_IMITATED =
113
+ "platform: not imitated, only said in this log when a page passes them: one player's requests to the game's " +
114
+ "server a minute and its CPU a request, and one address's reads of /platform/v1; not imitated at all: the " +
115
+ "network's delays and the owner's moderation (bans, blocked words); a board's player ids are this machine's own";
116
+
115
117
  /** A Host header naming this machine: localhost, 127.0.0.1, [::1] or a name under .localhost, with any port. */
116
118
  export const isLocalHost = host =>
117
119
  /^(localhost|127\.0\.0\.1|\[::1\]|[a-z0-9-]+\.localhost)(:\d{1,5})?$/i.test(String(host ?? ''));
118
120
 
121
+ /**
122
+ * Start the dev host for `dir`. Options: port (default 8787; 0 any), filesPort (default the next one; 0 any), noBuild,
123
+ * plan (the plan imitated: 'free', the default, 'creator' or 'scale'), log, env, fetchImpl (the RPC forward's).
124
+ * → {url, origin, files (the public files' origin), rpc, plan, server (the game server's status, or null), store,
125
+ * reload(), close()}
126
+ */
119
127
  export async function startDev(
120
128
  dir,
121
129
  {
122
130
  port = 8787,
131
+ plan: planName = 'free',
123
132
  filesPort = port ? port + 1 : 0,
124
133
  noBuild = false,
125
134
  log = m => console.log(m),
@@ -128,6 +137,7 @@ export async function startDev(
128
137
  } = {},
129
138
  ) {
130
139
  const game = path.resolve(dir);
140
+ const plan = devPlan(planName);
131
141
  const roots = pageRoots(game);
132
142
  if (roots.build && !noBuild) {
133
143
  log(`build: ${roots.build}`);
@@ -142,7 +152,9 @@ export async function startDev(
142
152
  `no index.html in ${path.relative(game, roots.out) || '.'}: ${roots.build ? 'build the game first' : "export the game for the Web there first (or adopt it again with --out <the export's folder>)"}`,
143
153
  );
144
154
 
145
- const store = createPlatformStore({ dir: game, log: m => log(`platform: ${m}`) });
155
+ const store = createPlatformStore({ dir: game, plan, log: m => log(`platform: ${m}`) });
156
+ log(planLine(plan));
157
+ log(NOT_IMITATED);
146
158
  // the game's public files on an origin of their own, as <slug>.xgamesusercontent.com is to the published game:
147
159
  // another port is another origin (no sign-in, no calls to the game's api/), though the same site here (both are
148
160
  // 127.0.0.1); live, xgamesusercontent.com is another site as well
@@ -219,7 +231,7 @@ export async function startDev(
219
231
  res.end(text);
220
232
  }
221
233
 
222
- const limits = createLimitWatch({ log });
234
+ const limits = createLimitWatch({ log, plan });
223
235
  const apiAt = `/game${API_PREFIX}`; // the page's api/ beside it: /game/api/
224
236
  const live = createLiveRoutes({ dir: game, apiAt, say: problems => log(`live: ${problems.join('; ')}`) });
225
237
  const said = await readLive(game);
@@ -239,6 +251,16 @@ export async function startDev(
239
251
  }
240
252
  if (url.pathname === PAGE_RPC) return (await forwardRpc(req, res), true);
241
253
  if (await live.handle(req, res, url)) return true;
254
+ // the play edge holds one address's reads of /platform/v1 to a number a minute; a board read with no
255
+ // Authorization header may come from its cache, which is not counted
256
+ const p = url.pathname;
257
+ if (
258
+ (req.method === 'GET' || req.method === 'HEAD') &&
259
+ p.startsWith('/platform/v1/') &&
260
+ !p.startsWith('/platform/v1/dev/') &&
261
+ !(/^\/platform\/v1\/scores\/[^/]+$/.test(p) && !req.headers.authorization)
262
+ )
263
+ limits.platformRead(`${req.method} ${p}`);
242
264
  if (await handlePlatform(req, res, url, store)) return true;
243
265
  if (url.pathname.startsWith(apiAt) || url.pathname === apiAt.slice(0, -1)) {
244
266
  const rest = url.pathname.slice('/game'.length) + url.search;
@@ -354,6 +376,7 @@ export async function startDev(
354
376
  origin: site.origin,
355
377
  files: files.origin,
356
378
  rpc: rpcWords(rpc),
379
+ plan: plan.key,
357
380
  server: server ? server.status() : null,
358
381
  store,
359
382
  reload: () => server?.reload(),
@@ -1,26 +1,39 @@
1
- // What `xcore dev` says about xgames.fun's plan limits (README §5, `xcore plan`). The local server runs with no CPU or
2
- // request limits, so a page that polls its server ten times a second works here and is refused once published. This
3
- // counts the page's requests to its server and times each one, and says so in the dev log, once a minute at most for
4
- // the rate and once per route for the time. It never refuses anything itself.
1
+ // What `xcore dev` says about xgames.fun's limits that depend on the network, not on the game's store: one player's
2
+ // requests to the game's server a minute and the CPU each may take (by plan), and one address's reads of the page's
3
+ // /platform/v1 at the play edge. The local server runs with no CPU or request limits, and here every tab is the same
4
+ // address, so these are said in the dev log, never refused: once a minute at most for a rate, once per route for the
5
+ // time. The platform's own limits (saves, scores, reads and writes a minute) are refused as published: ./platform.mjs.
5
6
  //
6
- // The numbers are xgames.fun's plans; xgames.fun is the source and can change them.
7
- export const PLAN_LIMITS = Object.freeze({
8
- free: Object.freeze({ name: 'Free', perPlayer: 120, cpuMs: 50 }),
9
- creator: Object.freeze({ name: 'Creator', perPlayer: 600, cpuMs: 200 }),
10
- });
7
+ // The numbers are ./plans.mjs's, one table with the rest.
8
+ import { PLANS, PLAN_NAMES, RULES, devPlan } from './plans.mjs';
9
+
10
+ /** The plans' request numbers, as this watch reads them (kept for callers of an earlier version). */
11
+ export const PLAN_LIMITS = Object.freeze(
12
+ Object.fromEntries(
13
+ PLAN_NAMES.map(k => [
14
+ k,
15
+ Object.freeze({ name: PLANS[k].name, perPlayer: PLANS[k].rate.player, cpuMs: PLANS[k].cpuMs }),
16
+ ]),
17
+ ),
18
+ );
11
19
  const MINUTE = 60_000;
12
20
 
13
21
  /**
14
- * A watch over one dev session's server requests. `log(line)` takes what it has to say.
15
- * → {request(method, pathname) → done(), upgrade(pathname)}: request() before the server is called, done() when it
16
- * has answered (its headers).
22
+ * A watch over one dev session's server requests. `log(line)` takes what it has to say; `plan`: the plan imitated.
23
+ * → {request(method, pathname) → done(), upgrade(pathname), platformRead(what)}: request() before the server is
24
+ * called, done() when it has answered (its headers).
17
25
  */
18
- export function createLimitWatch({ log, now = () => Date.now() }) {
19
- const { free, creator } = PLAN_LIMITS;
26
+ export function createLimitWatch({ log, now = () => Date.now(), plan = 'free' }) {
27
+ const key = typeof plan === 'object' && plan?.key ? plan.key : devPlan(plan).key;
28
+ const here = PLAN_LIMITS[key];
29
+ const up = PLAN_LIMITS[PLAN_NAMES[PLAN_NAMES.indexOf(key) + 1]] ?? null;
20
30
  let start = now();
21
31
  let count = 0;
22
- let said = 0; // the highest plan already named this minute: 0 none, 1 Free, 2 Creator
32
+ let said = 0; // what is already said this minute: 0 nothing, 1 past this plan's, 2 past the next plan's too
23
33
  const slow = new Set();
34
+ let readStart = now(),
35
+ reads = 0,
36
+ readSaid = false;
24
37
 
25
38
  function count1(what) {
26
39
  const t = now();
@@ -30,21 +43,21 @@ export function createLimitWatch({ log, now = () => Date.now() }) {
30
43
  said = 0;
31
44
  }
32
45
  count++;
33
- if (count > creator.perPlayer && said < 2) {
46
+ if (up && count > up.perPlayer && said < 2) {
34
47
  said = 2;
35
48
  log(
36
49
  `limits: ${count} requests to the game's server within a minute (the last: ${what}). Published, one player's ` +
37
- `requests past ${creator.perPlayer} a minute are refused even on Creator (${free.perPlayer} on Free): its ` +
50
+ `requests past ${up.perPlayer} a minute are refused even on ${up.name} (${here.perPlayer} on ${here.name}): its ` +
38
51
  `server answers 429 with retry-after 60. Send frequent updates (positions, state) over a WebSocket ` +
39
52
  `(docs/SERVER.md, "A room over a WebSocket"); a socket is one request for its whole life.`,
40
53
  );
41
- } else if (count > free.perPlayer && said < 1) {
54
+ } else if (count > here.perPlayer && said < 1) {
42
55
  said = 1;
43
56
  log(
44
57
  `limits: ${count} requests to the game's server within a minute (the last: ${what}). Published on ` +
45
- `${free.name}, one player's requests past ${free.perPlayer} a minute are refused (429, retry-after 60); ` +
46
- `${creator.name} allows ${creator.perPlayer}. Poll less often, or send frequent updates over a WebSocket ` +
47
- `(docs/SERVER.md). Two tabs here count as one player.`,
58
+ `${here.name}, one player's requests past ${here.perPlayer} a minute are refused (429, retry-after 60)` +
59
+ `${up ? `; ${up.name} allows ${up.perPlayer}` : ''}. Poll less often, or send frequent updates over a ` +
60
+ `WebSocket (docs/SERVER.md). Two tabs here count as one player.`,
48
61
  );
49
62
  }
50
63
  }
@@ -56,11 +69,11 @@ export function createLimitWatch({ log, now = () => Date.now() }) {
56
69
  const t0 = now();
57
70
  return () => {
58
71
  const ms = now() - t0;
59
- if (ms <= free.cpuMs || slow.has(what)) return;
72
+ if (ms <= here.cpuMs || slow.has(what)) return;
60
73
  slow.add(what);
61
74
  log(
62
- `limits: ${what} took ${ms} ms here. Published, a request gets ${free.cpuMs} ms of CPU on ${free.name} ` +
63
- `(${creator.cpuMs} on ${creator.name}). Waiting on storage or fetch does not count, but if this is ` +
75
+ `limits: ${what} took ${ms} ms here. Published, a request gets ${here.cpuMs} ms of CPU on ${here.name}` +
76
+ `${up ? ` (${up.cpuMs} on ${up.name})` : ''}. Waiting on storage or fetch does not count, but if this is ` +
64
77
  `computing, it can pass a few times live and then answer 503 ("went over its limit"). Do less a ` +
65
78
  `request, or split the work.`,
66
79
  );
@@ -69,5 +82,23 @@ export function createLimitWatch({ log, now = () => Date.now() }) {
69
82
  upgrade(pathname) {
70
83
  count1(`WebSocket ${pathname}`);
71
84
  },
85
+ /** A read of /platform/v1 that the play edge would count against the address it came from. */
86
+ platformRead(what) {
87
+ const t = now(),
88
+ max = RULES.edge.platformReadsPerCallerMinute;
89
+ if (t - readStart >= MINUTE) {
90
+ readStart = t;
91
+ reads = 0;
92
+ readSaid = false;
93
+ }
94
+ if (++reads <= max || readSaid) return;
95
+ readSaid = true;
96
+ log(
97
+ `limits: ${reads} reads of /platform/v1 within a minute (the last: ${what}). Published, the play edge ` +
98
+ `refuses one address's reads past ${max} a minute (429 'rate'), on every plan; a board read with no ` +
99
+ `token may come from its 5 s cache and is not counted. Here every tab shares one address, so this is only ` +
100
+ `said: read less often (a board after a score, not on a timer).`,
101
+ );
102
+ },
72
103
  };
73
104
  }
@@ -0,0 +1,101 @@
1
+ // The numbers xgames.fun's platform holds a published game to, as `xcore dev` imitates them: one table, so a change
2
+ // on xgames.fun is one edit here. The source is xgames.fun's own plan table, the one its platform enforces (README
3
+ // "Plans and limits"; `xcore plan` shows a published game's own numbers). PLANS changes with the game's plan; RULES
4
+ // are the same on every plan. `xcore dev --plan <name>` picks the plan the dev host imitates (Free by default).
5
+ const deepFreeze = o => {
6
+ for (const v of Object.values(o)) if (v && typeof v === 'object') deepFreeze(v);
7
+ return Object.freeze(o);
8
+ };
9
+ const KB = 1024,
10
+ MB = 1024 * KB,
11
+ GB = 1024 * MB;
12
+
13
+ export const PLANS = deepFreeze({
14
+ free: {
15
+ name: 'Free',
16
+ cpuMs: 50,
17
+ rate: { player: 120 },
18
+ saves: { valueBytes: 64 * KB, keysPerPlayer: 16, gameBytes: 10 * MB, writesPerMinute: 600 },
19
+ scores: { boards: 8, rules: 16, entriesPerBoard: 10_000, submitsPerMinute: 600 },
20
+ reads: { perMinute: 1_200 },
21
+ files: { bytes: 100 * MB, fileBytes: 25 * MB, opsPerMinute: 120 },
22
+ },
23
+ creator: {
24
+ name: 'Creator',
25
+ cpuMs: 200,
26
+ rate: { player: 600 },
27
+ saves: { valueBytes: 64 * KB, keysPerPlayer: 16, gameBytes: GB, writesPerMinute: 12_000 },
28
+ scores: { boards: 16, rules: 32, entriesPerBoard: 1_000_000, submitsPerMinute: 6_000 },
29
+ reads: { perMinute: 12_000 },
30
+ files: { bytes: 10 * GB, fileBytes: 25 * MB, opsPerMinute: 1_200 },
31
+ },
32
+ scale: {
33
+ name: 'Scale',
34
+ cpuMs: 1_000,
35
+ rate: { player: 1_200 },
36
+ saves: { valueBytes: 64 * KB, keysPerPlayer: 16, gameBytes: 10 * GB, writesPerMinute: 60_000 },
37
+ scores: { boards: 16, rules: 32, entriesPerBoard: 1_000_000, submitsPerMinute: 30_000 },
38
+ reads: { perMinute: 60_000 },
39
+ files: { bytes: 100 * GB, fileBytes: 25 * MB, opsPerMinute: 6_000 },
40
+ },
41
+ });
42
+ export const PLAN_NAMES = Object.freeze(Object.keys(PLANS));
43
+ export const DEFAULT_PLAN = 'free';
44
+
45
+ export const RULES = deepFreeze({
46
+ saves: {
47
+ playerChars: 128,
48
+ // a page's writes may keep at most 1/128 of the game's save bytes for one player (never less than one save)
49
+ playerShareDivisor: 128,
50
+ // with the game's saves full, a player whose saves stay within 4 KB still writes, up to an eighth past the total
51
+ floorBytes: 4 * KB,
52
+ floorPoolDivisor: 8,
53
+ },
54
+ scores: { nameChars: 32, perPlayerEveryMs: 1_000, topMax: 100, offsetMax: 10_000, rankMax: 10_000 },
55
+ // the page's own calls (/platform/v1/*): the same on every plan
56
+ page: {
57
+ bodyBytes: 96 * KB,
58
+ saveWritesPerPlayerMinute: 60,
59
+ scoreSubmitsPerPlayerMinute: 30,
60
+ // once pages have spent the game's minute, a player with at most this many this minute still writes
61
+ saveWritesFloorPerPlayerMinute: 6,
62
+ scoreSubmitsFloorPerPlayerMinute: 3,
63
+ // boards a page makes: "high" only, two a player, and half the plan's boards for all pages together
64
+ boardsPerPlayer: 2,
65
+ boardsDivisor: 2,
66
+ // a board read with no good player token: the top 100 only, an identical read within 5 s from a cache, and a
67
+ // minute of its own, a tenth of the plan's reads
68
+ anonTopDepth: 100,
69
+ anonReadsDivisor: 10,
70
+ anonReadCacheMs: 5_000,
71
+ },
72
+ // the play edge's own limit on reads of /platform/v1 from one address (xcore dev only says it: see limits.mjs)
73
+ edge: { platformReadsPerCallerMinute: 120 },
74
+ // what GF.files.put holds in memory: a string or ArrayBuffer body, or a stream with no declared length
75
+ files: { unsizedBytes: MB },
76
+ });
77
+
78
+ /** The plan `xcore dev` imitates: 'free', 'creator' or 'scale' (any case). → {key, ...PLANS[key]}; throws on another. */
79
+ export function devPlan(name = DEFAULT_PLAN) {
80
+ const key = String(name ?? DEFAULT_PLAN)
81
+ .trim()
82
+ .toLowerCase();
83
+ if (!Object.hasOwn(PLANS, key)) throw new Error(`there is no plan "${name}": ${PLAN_NAMES.join(', ')}`);
84
+ return { key, ...PLANS[key] };
85
+ }
86
+
87
+ const count = n => n.toLocaleString('en-US');
88
+ /** A size in words: 10 MB, 1 GB, 80 KB. */
89
+ export const size = b =>
90
+ b >= GB ? `${count(b / GB)} GB` : b >= MB ? `${count(b / MB)} MB` : `${count(Math.round(b / KB))} KB`;
91
+ export { count };
92
+
93
+ /** The dev host's first line about the plan it imitates. */
94
+ export function planLine(plan) {
95
+ const others = PLAN_NAMES.filter(k => k !== plan.key).join('|');
96
+ return (
97
+ `platform: imitating the ${plan.name} plan (--plan ${others} for another): ${plan.scores.boards} boards, ` +
98
+ `${size(plan.saves.gameBytes)} of saves, ${count(plan.reads.perMinute)} reads and ` +
99
+ `${count(plan.saves.writesPerMinute)} saves a minute, ${size(plan.files.bytes)} of files`
100
+ );
101
+ }