@xgamesfun/xcore 0.1.0 → 0.2.1
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/LICENSE +1 -1
- package/README.md +183 -84
- package/bin/xcore.mjs +13 -8
- package/commands/add.mjs +11 -8
- package/commands/adopt.mjs +9 -7
- package/commands/backend.mjs +2 -2
- package/commands/check.mjs +4 -4
- package/commands/db.mjs +218 -0
- package/commands/dev.mjs +8 -6
- package/commands/domain.mjs +8 -8
- package/commands/embed.mjs +3 -3
- package/commands/files.mjs +106 -0
- package/commands/login.mjs +9 -17
- package/commands/logout.mjs +15 -4
- package/commands/loot.mjs +2 -2
- package/commands/plan.mjs +22 -19
- package/commands/publish.mjs +54 -21
- package/commands/secret.mjs +6 -6
- package/docs/SERVER.md +218 -18
- package/lib/add.mjs +265 -40
- package/lib/browser.mjs +11 -2
- package/lib/check.mjs +10 -9
- package/lib/cli.mjs +24 -9
- package/lib/client/gamesfun.js +242 -122
- package/lib/client/godot/gamesfun.gd +17 -11
- package/lib/client/godot/gamesfun_peer.gd +2 -2
- package/lib/client/server/0001_init.sql +10 -0
- package/lib/client/server/database.js +271 -109
- package/lib/client/server/gf-db.js +55 -0
- package/lib/client/server/gf-docs.js +56 -0
- package/lib/client/server/rooms.js +16 -9
- package/lib/client/server/rpc.js +7 -7
- package/lib/client/server/stock.js +37 -0
- package/lib/client/server/vouchers.js +127 -46
- package/lib/client/unity/GamesFun.cs +15 -12
- package/lib/client/unity/GamesFun.jslib +1 -1
- package/lib/config-dir.mjs +1 -1
- package/lib/credentials.mjs +10 -5
- package/lib/db.mjs +355 -0
- package/lib/dev/d1.mjs +486 -0
- package/lib/dev/files.mjs +247 -0
- package/lib/dev/gf-local.js +69 -3
- package/lib/dev/index.mjs +68 -20
- package/lib/dev/limits.mjs +2 -2
- package/lib/dev/platform.mjs +111 -12
- package/lib/dev/server.mjs +54 -5
- package/lib/dev/tools.mjs +71 -18
- package/lib/files.mjs +208 -0
- package/lib/open.mjs +1 -1
- package/lib/play-policy.mjs +14 -5
- package/lib/publish/adopt.mjs +73 -33
- package/lib/publish/client.mjs +130 -20
- package/lib/publish/gate.mjs +4 -2
- package/lib/publish/hosting.mjs +5 -5
- package/lib/publish/index.mjs +162 -40
- package/lib/publish/oidc.mjs +26 -5
- package/lib/publish/page.mjs +1 -1
- package/lib/publish/plans.mjs +19 -12
- package/lib/publish/scan.mjs +78 -102
- package/lib/publish/secrets.mjs +4 -5
- package/lib/publish/site.mjs +9 -10
- package/lib/publish/tree.mjs +8 -8
- package/lib/secret-patterns.mjs +53 -16
- package/lib/secret-store.mjs +2 -2
- package/lib/server/bundle.mjs +113 -13
- package/lib/server/contract.mjs +440 -16
- package/lib/server/data-calls.mjs +136 -0
- package/lib/server/secrets.mjs +9 -7
- package/lib/solana-rpc.mjs +5 -5
- package/lib/state-folder.mjs +23 -0
- package/package.json +4 -4
- package/skills/bring-your-own-game/SKILL.md +10 -9
package/lib/add.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
// `xcore add signin|database`:
|
|
1
|
+
// `xcore add signin|database`: xgames.fun's player sign-in, saves and leaderboards, and a database of the game's own,
|
|
2
2
|
// put into a game made without the kit, in the shape its engine takes:
|
|
3
3
|
// - a web page: gamesfun.js beside the page (the folder its build copies as it is) and a <script> in index.html;
|
|
4
4
|
// - a Godot 4 project: addons/gamesfun/gamesfun.gd, autoloaded as GamesFun in project.godot;
|
|
5
5
|
// - a Unity project: Assets/Plugins/GamesFun/ (GamesFun.jslib and GamesFun.cs).
|
|
6
|
-
// The database is
|
|
7
|
-
//
|
|
6
|
+
// The database is the game's own D1 (gamesfun.json → database, docs/SERVER.md): a game with a server of its own reaches
|
|
7
|
+
// it from its code (server/gf-db.js), and a game with none gets xcore's stock server, which serves the page's gf.db.
|
|
8
|
+
// The older database, server/database.js (one Durable Object with SQLite storage), is kept for a game that has it.
|
|
8
9
|
import { createPrivateKey, createPublicKey, randomBytes } from 'node:crypto';
|
|
9
10
|
import { existsSync, readFileSync } from 'node:fs';
|
|
10
11
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
@@ -12,6 +13,14 @@ import path from 'node:path';
|
|
|
12
13
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
13
14
|
import { CMD } from './command.mjs';
|
|
14
15
|
import { CONFIG_FILE, pageRoots } from './publish/page.mjs';
|
|
16
|
+
import {
|
|
17
|
+
DATABASE_DEFAULTS,
|
|
18
|
+
DEFAULT_COLLECTIONS,
|
|
19
|
+
OLD_DEFAULT_RULES,
|
|
20
|
+
OWNER_SPACES,
|
|
21
|
+
readServerConfig,
|
|
22
|
+
} from './server/contract.mjs';
|
|
23
|
+
import { dataCallLine, ownerlessDataCalls } from './server/data-calls.mjs';
|
|
15
24
|
import { BACKEND_FILE } from './publish/scan.mjs';
|
|
16
25
|
import { serverRpc } from './solana-rpc.mjs';
|
|
17
26
|
import { isPageFolder, shown as shownPath } from './games.mjs';
|
|
@@ -195,7 +204,7 @@ export async function addSignin(dir) {
|
|
|
195
204
|
'A script that also runs without the autoload (headless tests, godot --script) cannot use the bare name GamesFun: there, get_node_or_null("/root/GamesFun"), and play on when it is null.',
|
|
196
205
|
'Export for the Web with Thread Support off, then adopt the project with that folder: ' +
|
|
197
206
|
`${CMD} adopt . --out <the export folder>.`,
|
|
198
|
-
'In the editor and in a desktop build, sign-in, saves, leaderboards and the database answer an error and the game plays on; on
|
|
207
|
+
'In the editor and in a desktop build, sign-in, saves, leaderboards and the database answer an error and the game plays on; on xgames.fun and under xcore check they work (a local player on this machine).',
|
|
199
208
|
],
|
|
200
209
|
};
|
|
201
210
|
}
|
|
@@ -211,7 +220,7 @@ export async function addSignin(dir) {
|
|
|
211
220
|
'When the browser keeps the sign-in window shut (a phone, a post on X), GamesFun.SignInCode gives {code, site}: show "Go to <site>/go and enter <code>".',
|
|
212
221
|
'Build for WebGL with Compression Format Disabled (or Decompression Fallback on) and threads off, then adopt the project with the build folder: ' +
|
|
213
222
|
`${CMD} adopt . --out <the build folder>.`,
|
|
214
|
-
'In the editor every call answers an error and the game plays on; in a WebGL build, on
|
|
223
|
+
'In the editor every call answers an error and the game plays on; in a WebGL build, on xgames.fun and under xcore check, it works (a local player on this machine).',
|
|
215
224
|
],
|
|
216
225
|
};
|
|
217
226
|
}
|
|
@@ -260,7 +269,7 @@ export async function addSignin(dir) {
|
|
|
260
269
|
: []),
|
|
261
270
|
'In the game: const gf = GamesFun; const player = await gf.signIn() (from a click), gf.onChange(player => …), await gf.saves.put("main", {...}), await gf.scores.submit("best", score), await gf.scores.top("best"); every call is listed at the top of gamesfun.js.',
|
|
262
271
|
'When the browser keeps the sign-in window shut (a phone, a post on X), gf.onCode(({code, site, window}) => …) says what to show: "Go to <site>/go and enter <code>". Show it plainly when window is false (the browser blocked the window). When a window opened, show it only as a quiet line, never an overlay: the player is still signing in there, and X’s login page hides from the game whether that window is still open.',
|
|
263
|
-
'On this machine (localhost, xcore check) a local player and this browser’s storage stand in; under xcore dev a local sign-in, the platform and the game’s server answer as
|
|
272
|
+
'On this machine (localhost, xcore check) a local player and this browser’s storage stand in; under xcore dev a local sign-in, the platform and the game’s server answer as xgames.fun will; once published, xgames.fun signs players in.',
|
|
264
273
|
],
|
|
265
274
|
};
|
|
266
275
|
}
|
|
@@ -273,6 +282,13 @@ const PARTS = {
|
|
|
273
282
|
handler: 'handleDatabase',
|
|
274
283
|
what: 'its database answers api/db/',
|
|
275
284
|
},
|
|
285
|
+
// the page's documents in the game's own D1 (gamesfun.json → database.collections): written by addDocs, not addPart
|
|
286
|
+
docs: {
|
|
287
|
+
file: 'gf-docs.js',
|
|
288
|
+
object: null,
|
|
289
|
+
handler: 'handleDocs',
|
|
290
|
+
what: "the page's gf.db answers api/db/ from the game's database, under gamesfun.json → database.collections",
|
|
291
|
+
},
|
|
276
292
|
rooms: {
|
|
277
293
|
file: 'rooms.js',
|
|
278
294
|
object: ROOM_OBJECT,
|
|
@@ -297,8 +313,10 @@ const OURS = 'Written by `xcore add';
|
|
|
297
313
|
/** server/index.js for the parts xcore wrote (only ever rewritten while it is xcore's own). */
|
|
298
314
|
export function serverIndex(parts) {
|
|
299
315
|
const list = Object.keys(PARTS).filter(k => parts.includes(k));
|
|
300
|
-
const commands = list.map(k => (
|
|
301
|
-
|
|
316
|
+
const commands = [...new Set(list.map(k => ({ rooms: 'multiplayer', docs: 'database' })[k] ?? k))].join(
|
|
317
|
+
'` and `xcore add ',
|
|
318
|
+
);
|
|
319
|
+
return `// The game's server on xgames.fun (docs/SERVER.md in @xgamesfun/xcore). ${OURS} ${commands}\`:
|
|
302
320
|
// ${list.map(k => PARTS[k].what).join('; ')}. To add routes of the game's own, delete the words "Written by" above
|
|
303
321
|
// first: xcore then leaves this file alone (and says which two lines to add for anything it adds later).
|
|
304
322
|
${list.map(k => `import { ${PARTS[k].handler} } from './${PARTS[k].file}';`).join('\n')}
|
|
@@ -323,22 +341,8 @@ ${list.map(k => ` const ${k} = await ${PARTS[k].handler}(request, env);\n
|
|
|
323
341
|
`;
|
|
324
342
|
}
|
|
325
343
|
|
|
326
|
-
/**
|
|
327
|
-
async function
|
|
328
|
-
const signin = await addSignin(dir);
|
|
329
|
-
const written = [...signin.written];
|
|
330
|
-
const spec = PARTS[part];
|
|
331
|
-
const server = path.join(dir, 'server');
|
|
332
|
-
// the settings at its top (ROOM, RULES, LIMITS, PAY) are the person's: kept as they set them
|
|
333
|
-
await put(path.join(server, spec.file), render(read(`server/${spec.file}`)), written, dir, { settings: true });
|
|
334
|
-
const index = ['index.js', 'index.mjs'].map(f => path.join(server, f)).find(f => existsSync(f));
|
|
335
|
-
const indexText = index ? await readFile(index, 'utf8') : null;
|
|
336
|
-
const ours = indexText === null || indexText.includes(OURS);
|
|
337
|
-
if (ours) {
|
|
338
|
-
const parts = Object.keys(PARTS).filter(k => k === part || existsSync(path.join(server, PARTS[k].file)));
|
|
339
|
-
await put(index ?? path.join(server, 'index.js'), serverIndex(parts), written, dir);
|
|
340
|
-
}
|
|
341
|
-
// gamesfun.json → server.objects: the part's Durable Object, beside any the server has
|
|
344
|
+
/** gamesfun.json, read and checked for one more part: valid JSON, and room for its Durable Object. Throws in words. */
|
|
345
|
+
async function serverConfig(dir, spec) {
|
|
342
346
|
const cfgFile = path.join(dir, CONFIG_FILE);
|
|
343
347
|
let cfg = {};
|
|
344
348
|
if (existsSync(cfgFile)) {
|
|
@@ -349,9 +353,30 @@ async function addPart(dir, part, { render = text => text } = {}) {
|
|
|
349
353
|
}
|
|
350
354
|
}
|
|
351
355
|
const objects = Array.isArray(cfg.server?.objects) ? cfg.server.objects : [];
|
|
356
|
+
if (spec.object && !objects.some(o => o?.binding === spec.object.binding) && objects.length >= 8)
|
|
357
|
+
throw new Error(`${CONFIG_FILE} already declares 8 Durable Objects, the most a game's server may have.`);
|
|
358
|
+
return { cfgFile, cfg, objects };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** One part of server code (the database, the rooms) into the game: its file, its object, its route. */
|
|
362
|
+
async function addPart(dir, part, { render = text => text } = {}) {
|
|
363
|
+
// everything that can refuse is asked before the first file is written, so a refusal leaves the game as it was
|
|
364
|
+
await serverConfig(dir, PARTS[part]);
|
|
365
|
+
// a game whose database the stock server served: from now on its own server answers api/*, and server/gf-docs.js
|
|
366
|
+
// goes on answering the page's gf.db there
|
|
367
|
+
const wasStock = !!(await readServerConfig(dir)).stock;
|
|
368
|
+
const signin = await addSignin(dir);
|
|
369
|
+
const written = [...signin.written];
|
|
370
|
+
const spec = PARTS[part];
|
|
371
|
+
const server = path.join(dir, 'server');
|
|
372
|
+
// the settings at its top (ROOM, RULES, LIMITS, PAY) are the person's: kept as they set them
|
|
373
|
+
await put(path.join(server, spec.file), render(read(`server/${spec.file}`)), written, dir, { settings: true });
|
|
374
|
+
const docs = await addDocs(dir, written, { wasStock });
|
|
375
|
+
const { index, indexText, ours } = await routeParts(dir, written, part);
|
|
376
|
+
// gamesfun.json → server.objects: the part's Durable Object, beside any the server has (read again: adding the
|
|
377
|
+
// sign-in may have written it)
|
|
378
|
+
const { cfgFile, cfg, objects } = await serverConfig(dir, spec);
|
|
352
379
|
if (spec.object && !objects.some(o => o?.binding === spec.object.binding)) {
|
|
353
|
-
if (objects.length >= 8)
|
|
354
|
-
throw new Error(`${CONFIG_FILE} already declares 8 Durable Objects, the most a game's server may have.`);
|
|
355
380
|
cfg.server = { ...(cfg.server ?? {}), objects: [...objects, spec.object] };
|
|
356
381
|
await writeFile(cfgFile, JSON.stringify(cfg, null, 2) + '\n');
|
|
357
382
|
written.push(`declared ${spec.object.class} (${spec.object.binding}) in ${CONFIG_FILE} → server.objects`);
|
|
@@ -362,11 +387,75 @@ async function addPart(dir, part, { render = text => text } = {}) {
|
|
|
362
387
|
: [
|
|
363
388
|
`server/${path.basename(index)} is your own: route ${{ rooms: 'the rooms', database: "the database's calls", rpc: 'api/rpc', vouchers: 'api/vouchers/' }[part]} in it.${spec.object ? ` Add \`export { ${spec.object.class} } from './${spec.file}';\` at the top, and` : ''} First thing in fetch: \`const r = await ${spec.handler}(request, env); if (r) return r;\` (with \`import { ${spec.handler} } from './${spec.file}';\`).`,
|
|
364
389
|
];
|
|
390
|
+
route.push(...docsRoute(docs, { ours, indexText, index, wasStock }));
|
|
365
391
|
return { signin, written, route };
|
|
366
392
|
}
|
|
367
393
|
|
|
394
|
+
/** database.js's documents code, between its marks: what the stock server and server/gf-docs.js share. */
|
|
395
|
+
export function docsSource() {
|
|
396
|
+
const db = read('server/database.js');
|
|
397
|
+
const start = db.indexOf("// ---- the documents' code");
|
|
398
|
+
const end = db.indexOf("// ---- end of the documents' code ----");
|
|
399
|
+
if (start < 0 || end < start) throw new Error("database.js has lost its documents' marks");
|
|
400
|
+
const core = db.slice(db.indexOf('\n', start) + 1, end).trim();
|
|
401
|
+
return read('server/gf-docs.js').replace('// __DOCS_CORE__', core);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The page's documents for a game with a server of its own: server/gf-docs.js, which answers api/db/ from the game's D1
|
|
406
|
+
* under gamesfun.json → database.collections, when the game has a database with collections. A game the stock server
|
|
407
|
+
* served (`wasStock`) keeps the rules it had: written out in gamesfun.json, so the helper reads the same ones.
|
|
408
|
+
* → true when the game has the helper
|
|
409
|
+
*/
|
|
410
|
+
async function addDocs(dir, written, { wasStock = false } = {}) {
|
|
411
|
+
const { cfgFile, cfg } = await serverConfig(dir, {});
|
|
412
|
+
const d = cfg.database;
|
|
413
|
+
if (!d || typeof d !== 'object' || Array.isArray(d)) return false;
|
|
414
|
+
if (d.collections === undefined) {
|
|
415
|
+
if (!wasStock) return false;
|
|
416
|
+
d.collections = structuredClone(DEFAULT_COLLECTIONS);
|
|
417
|
+
await writeFile(cfgFile, JSON.stringify(cfg, null, 2) + '\n');
|
|
418
|
+
written.push(
|
|
419
|
+
`wrote the stock server's rules into ${CONFIG_FILE} → database.collections, which server/gf-docs.js reads`,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
await put(path.join(dir, 'server', PARTS.docs.file), docsSource(), written, dir, { settings: true });
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
425
|
+
|
|
368
426
|
/**
|
|
369
|
-
*
|
|
427
|
+
* server/index.js routing every part xcore wrote into server/, rewritten only while it is xcore's own (with `part`, the
|
|
428
|
+
* one being added, counted in). → {index, indexText (before), ours}
|
|
429
|
+
*/
|
|
430
|
+
async function routeParts(dir, written, part = null) {
|
|
431
|
+
const server = path.join(dir, 'server');
|
|
432
|
+
const index = ['index.js', 'index.mjs'].map(f => path.join(server, f)).find(f => existsSync(f));
|
|
433
|
+
const indexText = index ? await readFile(index, 'utf8') : null;
|
|
434
|
+
const ours = indexText === null || indexText.includes(OURS);
|
|
435
|
+
if (ours) {
|
|
436
|
+
const parts = Object.keys(PARTS).filter(k => k === part || existsSync(path.join(server, PARTS[k].file)));
|
|
437
|
+
await put(index ?? path.join(server, 'index.js'), serverIndex(parts), written, dir);
|
|
438
|
+
}
|
|
439
|
+
return { index, indexText, ours };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** What to say about server/gf-docs.js: how to route it in a server/index.js of the person's own, and a stock game's move. */
|
|
443
|
+
function docsRoute(docs, { ours, indexText, index, wasStock }) {
|
|
444
|
+
if (!docs) return [];
|
|
445
|
+
const out = [];
|
|
446
|
+
if (!ours && !indexText.includes(PARTS.docs.handler))
|
|
447
|
+
out.push(
|
|
448
|
+
`server/${path.basename(index)} is your own: route the page's documents in it. First thing in fetch: \`const db = await handleDocs(request, env); if (db) return db;\` (with \`import { handleDocs } from './gf-docs.js';\`). Until it does, every gf.db call answers 404, and publish refuses the game.`,
|
|
449
|
+
);
|
|
450
|
+
if (wasStock)
|
|
451
|
+
out.push(
|
|
452
|
+
`This game's database was served by xcore's stock server (the page's gf.db, at api/db/). Now it has a server of its own, and server/gf-docs.js answers gf.db there from the same documents (table gf_docs), under the same rules (${CONFIG_FILE} → database.collections).`,
|
|
453
|
+
);
|
|
454
|
+
return out;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* The RPC's origin in backend.json → origins, which is all a game's server may call on xgames.fun: the folder's own
|
|
370
459
|
* XSOLANA_RPC_URL (.dev.vars, else .env), its origin only (the key is in its path or query). → the step to say, or null.
|
|
371
460
|
*/
|
|
372
461
|
async function allowRpc(dir, written) {
|
|
@@ -406,8 +495,8 @@ export async function addRpc(dir) {
|
|
|
406
495
|
...route,
|
|
407
496
|
`Get a devnet RPC URL of your own (Helius, Triton or QuickNode; their free plans fit a small game). Under ${CMD} dev, put XSOLANA_RPC_URL=… in the folder's .dev.vars (a hidden file publish never sends; keep it out of git).`,
|
|
408
497
|
...(origin ? [origin] : []),
|
|
409
|
-
`Publish (${CMD} publish), then give the URL to the game's server: \`${CMD} secret set XSOLANA_RPC_URL\` (it asks for it;
|
|
410
|
-
"Why:
|
|
498
|
+
`Publish (${CMD} publish), then give the URL to the game's server: \`${CMD} secret set XSOLANA_RPC_URL\` (it asks for it; xgames.fun keeps it on the server, never the page, and it reaches the next request with no publish). Until it is set, api/rpc says so and the page keeps using xgames.fun's shared relay, so nothing breaks.`,
|
|
499
|
+
"Why: xgames.fun's shared relay counts every call toward the game's plan ($10 a million calls, the economy's biggest cost a player); through the game's own RPC a call is one request to its server (about $0.30 a million) and the RPC's own plan.",
|
|
411
500
|
"Each call counts toward the game server's requests (on Free 120 a minute from one player and 600 a game): the economy's reads every few seconds fit; RPC.methods at the top of server/rpc.js is what it passes on.",
|
|
412
501
|
],
|
|
413
502
|
};
|
|
@@ -458,7 +547,7 @@ export async function seedAddress(seed) {
|
|
|
458
547
|
return encodeBase58(new Uint8Array(spki.subarray(-32)));
|
|
459
548
|
}
|
|
460
549
|
|
|
461
|
-
/** A new voucher key: {seed (hex), address}. The live key is made this way and sent to
|
|
550
|
+
/** A new voucher key: {seed (hex), address}. The live key is made this way and sent to xgames.fun, never kept here. */
|
|
462
551
|
export async function newVoucherKey() {
|
|
463
552
|
const seed = randomBytes(32).toString('hex');
|
|
464
553
|
return { seed, address: await seedAddress(seed) };
|
|
@@ -476,6 +565,13 @@ export async function addVouchers(dir) {
|
|
|
476
565
|
throw new Error(
|
|
477
566
|
`${dir} has no economy yet: a voucher is claimed from a grant in it. Make it first (${CMD} loot add ${shownPath(dir)}, then a grant in ${ECONOMY_FILE}), and run this again.`,
|
|
478
567
|
);
|
|
568
|
+
// a bad test key refuses here, before any file is written
|
|
569
|
+
const vars = path.join(dir, '.dev.vars');
|
|
570
|
+
const varsText = existsSync(vars) ? await readFile(vars, 'utf8') : '';
|
|
571
|
+
if (/^\s*VOUCHER_SEED\s*=/m.test(varsText) && !/^\s*VOUCHER_SEED\s*=\s*["']?[0-9a-fA-F]{64}["']?\s*$/m.test(varsText))
|
|
572
|
+
throw new Error(
|
|
573
|
+
`.dev.vars has a VOUCHER_SEED that is not a 32-byte seed in hex (64 characters): fix it or delete that line, and run this again.`,
|
|
574
|
+
);
|
|
479
575
|
let from = path.relative(path.join(dir, 'server'), economy).split(path.sep).join('/');
|
|
480
576
|
if (!from.startsWith('.')) from = `./${from}`;
|
|
481
577
|
const { signin, written, route } = await addPart(dir, 'vouchers', {
|
|
@@ -498,8 +594,8 @@ export async function addVouchers(dir) {
|
|
|
498
594
|
address,
|
|
499
595
|
steps: [
|
|
500
596
|
...route,
|
|
501
|
-
`Two keys sign vouchers: a test key for ${CMD} dev (its seed in .dev.vars; address ${address}) and the live key,
|
|
502
|
-
`Once published (${CMD} publish): ${CMD} secret set VOUCHER_SEED --new-key makes the live key
|
|
597
|
+
`Two keys sign vouchers: a test key for ${CMD} dev (its seed in .dev.vars; address ${address}) and the live key, which ${CMD} secret set VOUCHER_SEED --new-key makes and sends to xgames.fun only, never keeping it on this machine. A copy of this folder can sign only what the grant lets the test key sign.`,
|
|
598
|
+
`Once published (${CMD} publish): ${CMD} secret set VOUCHER_SEED --new-key makes the live key, sends it to the game's server on xgames.fun only, and prints its address. Put that address in the grant that pays results, in ${where}: "vouchers": ["<the live key's address>"] (up to three keys; the keeper's can stay beside it); then ${CMD} loot check, ${CMD} loot approve, ${CMD} publish.`,
|
|
503
599
|
named.length
|
|
504
600
|
? `${where} names the test key in ${named.map(k => `"${k}"`).join(', ')}: claims signed under ${CMD} dev pay on devnet. Take it out of the grant before the economy holds anything of value.`
|
|
505
601
|
: `To claim a voucher signed under ${CMD} dev, add the test key's address too (devnet only; take it out before the economy holds anything of value).`,
|
|
@@ -513,19 +609,148 @@ export async function addVouchers(dir) {
|
|
|
513
609
|
};
|
|
514
610
|
}
|
|
515
611
|
|
|
516
|
-
/**
|
|
612
|
+
/**
|
|
613
|
+
* The game's own database: a D1 database of its own (gamesfun.json → database; xgames.fun's STORAGE.md §8.2).
|
|
614
|
+
* - A game with a server of its own gets the block, an example first migration (server/migrations/0001_init.sql)
|
|
615
|
+
* and server/gf-db.js (prepared queries, and withPlayer: the signed-in player's id), for its own routes.
|
|
616
|
+
* - A game with no server gets the block with `collections`: xcore's stock server serves the page's gf.db from it.
|
|
617
|
+
* - A game that has server/database.js (the older way, a Durable Object) keeps it, brought up to date.
|
|
618
|
+
* → {engine, written, steps}
|
|
619
|
+
*/
|
|
517
620
|
export async function addDatabase(dir) {
|
|
621
|
+
if (existsSync(path.join(dir, 'server', 'database.js'))) return addDatabaseObject(dir);
|
|
622
|
+
// everything that can refuse is asked before the first file is written
|
|
623
|
+
const { cfgFile, cfg } = await serverConfig(dir, {});
|
|
624
|
+
const before = await readServerConfig(dir);
|
|
625
|
+
const own = before.has && !before.stock;
|
|
626
|
+
if (cfg.database !== undefined && before.database?.problems.length)
|
|
627
|
+
throw new Error(`${CONFIG_FILE} database: ${before.database.problems.join('; ')}; fix it first`);
|
|
628
|
+
if (cfg.database === undefined && (cfg.server?.objects ?? []).some(o => o?.binding === DATABASE_DEFAULTS.binding))
|
|
629
|
+
throw new Error(
|
|
630
|
+
`${CONFIG_FILE} has a Durable Object bound as ${DATABASE_DEFAULTS.binding}: add "database": {"binding": "GAME_DB"} (another name) to ${CONFIG_FILE} first, then run this again`,
|
|
631
|
+
);
|
|
632
|
+
const signin = await addSignin(dir);
|
|
633
|
+
const written = [...signin.written];
|
|
634
|
+
// gamesfun.json read again: adding the sign-in may have written it
|
|
635
|
+
const { cfg: now } = await serverConfig(dir, {});
|
|
636
|
+
if (now.database === undefined) {
|
|
637
|
+
now.database = own
|
|
638
|
+
? { engine: 'd1', binding: DATABASE_DEFAULTS.binding, migrations: DATABASE_DEFAULTS.migrations }
|
|
639
|
+
: { engine: 'd1', binding: DATABASE_DEFAULTS.binding, collections: structuredClone(DEFAULT_COLLECTIONS) };
|
|
640
|
+
await writeFile(cfgFile, JSON.stringify(now, null, 2) + '\n');
|
|
641
|
+
written.push(`declared the game's database in ${CONFIG_FILE} → database (env.${now.database.binding})`);
|
|
642
|
+
}
|
|
643
|
+
const database = (await readServerConfig(dir)).database;
|
|
644
|
+
const call = { godot: 'await GamesFun.db_add("levels", {...})', unity: 'await GamesFun.DbAdd("levels", json)' };
|
|
645
|
+
const later = [
|
|
646
|
+
`${CMD} dev runs the database on this machine (kept in .gamesfun/dev/d1/, never published), with the same migrations; ${CMD} db query "SELECT …" reads it, and ${CMD} db query … --live the one on xgames.fun.`,
|
|
647
|
+
`${CMD} publish makes the game's database on xgames.fun (one a game, kept between publishes) and applies the new migrations before the new version goes live. ${CMD} db export --live keeps a copy; ${CMD} db restore <time> --live goes back up to 30 days.`,
|
|
648
|
+
];
|
|
649
|
+
// a stock game may have migrations of its own too: their line endings are kept as they are
|
|
650
|
+
const migrationsFolder = path.join(dir, ...database.migrationsDir.split('/'));
|
|
651
|
+
if (!own && existsSync(migrationsFolder)) await migrationsAttributes(migrationsFolder, written, dir);
|
|
652
|
+
if (!own)
|
|
653
|
+
return {
|
|
654
|
+
engine: signin.engine,
|
|
655
|
+
written,
|
|
656
|
+
steps: [
|
|
657
|
+
...signin.steps.filter(x => !/^(In the game|From GDScript|From C#):/.test(x)),
|
|
658
|
+
`From the game: ${call[signin.engine] ?? 'await gf.db.add("levels", {...})'}, get, put, list, delete (documents in collections, any JSON up to 64 KB). This game has no server of its own, so xcore's stock server answers them from its database.`,
|
|
659
|
+
`Each player has a space of their own in every collection, and reads and writes only that. Change it per collection in ${CONFIG_FILE} → database.collections: {"read": "anyone"} lets everyone read one (another player's document by its owner: gf.db.get("levels", id, {owner})); {"write": "server"} is for what players must not set themselves, and needs a server of your own to write it.`,
|
|
660
|
+
'A list comes a page at a time, newest first: pass the answer\'s after back for the next one (gf.db.list("levels", {after})).',
|
|
661
|
+
...later,
|
|
662
|
+
],
|
|
663
|
+
};
|
|
664
|
+
// a server of its own: the example migration (only while there is none) and the helper for its routes
|
|
665
|
+
const folder = migrationsFolder;
|
|
666
|
+
if (!database.migrations.length)
|
|
667
|
+
await put(path.join(folder, '0001_init.sql'), read('server/0001_init.sql'), written, dir);
|
|
668
|
+
await migrationsAttributes(folder, written, dir);
|
|
669
|
+
await put(
|
|
670
|
+
path.join(dir, 'server', 'gf-db.js'),
|
|
671
|
+
read('server/gf-db.js').replace('__BINDING__', database.binding),
|
|
672
|
+
written,
|
|
673
|
+
dir,
|
|
674
|
+
);
|
|
675
|
+
// the page's documents too (gf.db), when the block has collections: server/gf-docs.js, routed in server/index.js
|
|
676
|
+
const docs = await addDocs(dir, written);
|
|
677
|
+
const route = docs ? docsRoute(docs, { ...(await routeParts(dir, written)), wasStock: false }) : [];
|
|
678
|
+
return {
|
|
679
|
+
engine: signin.engine,
|
|
680
|
+
written,
|
|
681
|
+
steps: [
|
|
682
|
+
...signin.steps.filter(x => !/^(In the game|From GDScript|From C#):/.test(x)),
|
|
683
|
+
...route,
|
|
684
|
+
...(docs
|
|
685
|
+
? [
|
|
686
|
+
`The page's documents (gf.db: ${call[signin.engine] ?? 'await gf.db.add("levels", {...})'}, get, put, list, delete) are server/gf-docs.js's, in the same database (table gf_docs), under ${CONFIG_FILE} → database.collections; this server's own code reaches them with docsOf(env).`,
|
|
687
|
+
]
|
|
688
|
+
: [
|
|
689
|
+
`The page's gf.db documents are not served here: to keep them in this database too, add "collections": {"*": {"read": "owner", "write": "owner"}} to ${CONFIG_FILE} → database and run ${CMD} add database again (it writes server/gf-docs.js and routes it).`,
|
|
690
|
+
]),
|
|
691
|
+
`This game has a server of its own: its code reads and writes the database. import { db, withPlayer } from './gf-db.js'; then in a route: const sub = await withPlayer(request, env) (the signed-in player's id, or null), and db(env).first / all / run / batch with ? for every value. env.${database.binding} is D1 itself.`,
|
|
692
|
+
`Its tables come from ${database.migrationsDir}/ (0001_init.sql is an example: change it now, before it is published). Every later change is a new file, 0002_<what>.sql: once a migration is published it never changes, and a changed one is refused.`,
|
|
693
|
+
'A rule that must hold however many requests come at once is one statement: UPDATE players SET runs = runs - 1 WHERE id = ? AND runs > 0 RETURNING runs; several that go together, db(env).batch([...]), all or none.',
|
|
694
|
+
...later,
|
|
695
|
+
],
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** A migration's bytes are its sha256: git must not change their line endings (core.autocrlf on Windows would). */
|
|
700
|
+
export const MIGRATIONS_GITATTRIBUTES =
|
|
701
|
+
"# a migration's bytes are its sha256 (xgames.fun refuses one that changed): no line-ending conversion\n*.sql -text\n";
|
|
702
|
+
async function migrationsAttributes(folder, written, dir) {
|
|
703
|
+
const file = path.join(folder, '.gitattributes');
|
|
704
|
+
if (!existsSync(file)) await put(file, MIGRATIONS_GITATTRIBUTES, written, dir);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* The older database (server/database.js: one Durable Object with SQLite storage, and the route to it), kept for a
|
|
709
|
+
* game that has it: brought up to date, and still working. A new game's database is its own D1 (addDatabase).
|
|
710
|
+
* → {engine, written, steps}
|
|
711
|
+
*/
|
|
712
|
+
export async function addDatabaseObject(dir) {
|
|
713
|
+
// what can refuse (gamesfun.json unreadable, its 8 Durable Objects taken) is asked before database.js is touched
|
|
714
|
+
await serverConfig(dir, PARTS.database);
|
|
715
|
+
// an untouched RULES from 0.1.0 is the old default, not the person's choice: it becomes the new one
|
|
716
|
+
const dbFile = path.join(dir, 'server', 'database.js');
|
|
717
|
+
let opened = false,
|
|
718
|
+
upgraded = false;
|
|
719
|
+
if (existsSync(dbFile)) {
|
|
720
|
+
const text = await readFile(dbFile, 'utf8');
|
|
721
|
+
// 0.1.0's database.js: one id a collection, whoever wrote it (no space of each player's own)
|
|
722
|
+
upgraded = !text.includes(OWNER_SPACES);
|
|
723
|
+
if (text.includes(OLD_DEFAULT_RULES)) {
|
|
724
|
+
await writeFile(dbFile, text.replace(OLD_DEFAULT_RULES, settingsBlock(read('server/database.js'), 'RULES').text));
|
|
725
|
+
opened = true;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
518
728
|
const { signin, written, route } = await addPart(dir, 'database');
|
|
519
729
|
const call = { godot: 'await GamesFun.db_add("levels", {...})', unity: 'await GamesFun.DbAdd("levels", json)' };
|
|
730
|
+
// the server's own 0.1.0 calls, which mean something else over the new database.js: each said with its change
|
|
731
|
+
const calls = upgraded ? await ownerlessDataCalls(dir) : [];
|
|
520
732
|
return {
|
|
521
733
|
engine: signin.engine,
|
|
522
734
|
written,
|
|
735
|
+
calls,
|
|
523
736
|
steps: [
|
|
524
737
|
...signin.steps.filter(x => !/^(In the game|From GDScript|From C#):/.test(x)),
|
|
525
738
|
...route,
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
739
|
+
...(calls.length
|
|
740
|
+
? [
|
|
741
|
+
`server/database.js was 0.1.0's, where a document was found by its id alone, whoever wrote it. Now every player has a space of their own, and dataOf(env) without an owner is the server's: change ${calls.length === 1 ? 'this call' : `these ${calls.length} calls`} before you publish (publish says them again until each names its owner):\n${calls.map(c => ` ${dataCallLine(c)}`).join('\n')}`,
|
|
742
|
+
]
|
|
743
|
+
: []),
|
|
744
|
+
'server/database.js is the older database (a Durable Object). It keeps working; a game\'s database is now a D1 of its own (gamesfun.json → database, docs/SERVER.md "A database of its own"), which xcore db can query, export and restore.',
|
|
745
|
+
...(opened
|
|
746
|
+
? [
|
|
747
|
+
"RULES in server/database.js was the old default, where anyone read every document: it is now read: 'owner' (each player reads only their own). To keep a collection public, name it: levels: { read: 'anyone', write: 'owner' }.",
|
|
748
|
+
]
|
|
749
|
+
: []),
|
|
750
|
+
`From the game: ${call[signin.engine] ?? 'await gf.db.add("levels", {...})'}, get, put, list, delete (documents in collections, any JSON up to 64 KB). Each player has a space of their own in every collection, and reads and writes only that: no player can take or see another's documents. Change that per collection in RULES at the top of server/database.js: read: "anyone" lets everyone read a collection (another player's document by its owner: gf.db.get("levels", id, {owner})), and write: "server" is for anything players must not set themselves (a prize, a verified score).`,
|
|
751
|
+
'A list comes a page at a time, newest first: pass the answer\'s after back for the next one (gf.db.list("levels", {after})).',
|
|
752
|
+
'Your server code reaches it past those rules with dataOf(env) (import { dataOf } from "./database.js"): its own documents (owner "server") by default, a player\'s with {owner: sub}.',
|
|
753
|
+
`On this machine the page keeps documents in the browser instead; ${CMD} publish puts the database on xgames.fun with the game (a Durable Object with SQLite storage, kept between publishes).`,
|
|
529
754
|
"Every call counts toward the game server's requests (on Free 120 a minute from one player and 600 shared by all its players; on Creator 600 and 6,000): read and write when something happens (a level saved, a profile changed), not every frame.",
|
|
530
755
|
],
|
|
531
756
|
};
|
|
@@ -537,19 +762,19 @@ export async function addMultiplayer(dir) {
|
|
|
537
762
|
const how = {
|
|
538
763
|
godot: [
|
|
539
764
|
"From GDScript, Godot's own multiplayer: `var peer = await GamesFun.join_room(code)` then `multiplayer.multiplayer_peer = peer` in place of an ENetMultiplayerPeer (null: GamesFun.room_error says why). The first player in is the server (id 1); RPCs, MultiplayerSpawner and MultiplayerSynchronizer work as over ENet, all reliable and ordered. In the editor and a desktop build the same call makes a local ENet game (Debug → Customize Run Instances to run two).",
|
|
540
|
-
'A code: GamesFun.room_from_link() (the room a shared link opened) or GamesFun.room_code() (a new one); share GamesFun.room_link(code), which opens the game in that room on
|
|
765
|
+
'A code: GamesFun.room_from_link() (the room a shared link opened) or GamesFun.room_code() (a new one); share GamesFun.room_link(code), which opens the game in that room on xgames.fun and in a post on X.',
|
|
541
766
|
'Godot\'s ENet does not run in a browser: replace ENetMultiplayerPeer.create_server/create_client with join_room, and keep the rest of the game\'s multiplayer code. A Host button: join_room(GamesFun.room_code(), {"host": true}); a Join button: join_room(code, {"host": false}), which fails as ENet does when no one is there. A variable typed ENetMultiplayerPeer becomes MultiplayerPeer.',
|
|
542
767
|
"The peer leaves its room when the game lets go of it (multiplayer.multiplayer_peer = null with no variable holding it) or on peer.close(). ROOM.maxPlayers in server/rooms.js is create_server's max_clients, for every room. Every packet is reliable and ordered, unreliable ones too; a frame's packets go to the room as one message.",
|
|
543
768
|
"When the host leaves, its clients see server_disconnected, as over ENet (Godot's server is never handed on).",
|
|
544
769
|
],
|
|
545
770
|
unity: [
|
|
546
771
|
"From C#: `var room = await GamesFun.JoinRoom(code)` (null: GamesFun.RoomError says why; JoinRoom(code, name, host: true) opens a new room only, host: false joins one only); room.Id, room.Host, room.IsHost; room.OnMessage, OnBytes, OnJoin, OnLeave, OnHost, OnClose; room.Send(json, to), room.SendBytes(bytes, to), room.Lock(), room.Leave(). The first player in is the host (Id 1); to: a player's id, 0 everyone else, -id everyone but one.",
|
|
547
|
-
'A code: await GamesFun.RoomFromLink() (the room a shared link opened) or GamesFun.RoomCode(); share await GamesFun.RoomLink(code), which opens the game in that room on
|
|
772
|
+
'A code: await GamesFun.RoomFromLink() (the room a shared link opened) or GamesFun.RoomCode(); share await GamesFun.RoomLink(code), which opens the game in that room on xgames.fun and in a post on X.',
|
|
548
773
|
"A netcode library's own transport (Netcode for GameObjects, Mirror) needs a server of its own: run game logic on the host (IsHost) and send state with room.Send / SendBytes instead.",
|
|
549
774
|
],
|
|
550
775
|
page: [
|
|
551
776
|
'In the game: `const { data: room, error } = await GamesFun.rooms.join(code)` (opts {name, host}: host true opens a new room only, false joins one only); room.id, room.host, room.isHost, room.peers; room.on("message" | "bytes" | "join" | "leave" | "host" | "close", …); room.send(data, to), room.sendBytes(bytes, to), room.lock(), room.leave(). The first player in is the host (id 1); to: a player\'s id, 0 everyone else, -id everyone but one.',
|
|
552
|
-
'A code: GamesFun.rooms.fromLink() (the room a shared link opened) or GamesFun.rooms.code(); share GamesFun.rooms.link(code), which opens the game in that room on
|
|
777
|
+
'A code: GamesFun.rooms.fromLink() (the room a shared link opened) or GamesFun.rooms.code(); share GamesFun.rooms.link(code), which opens the game in that room on xgames.fun and in a post on X.',
|
|
553
778
|
],
|
|
554
779
|
};
|
|
555
780
|
return {
|
|
@@ -560,7 +785,7 @@ export async function addMultiplayer(dir) {
|
|
|
560
785
|
...route,
|
|
561
786
|
...how[signin.engine],
|
|
562
787
|
"A room is a relay: the host's game decides what happens, and the room never reads a message. ROOM at the top of server/rooms.js sets the most players (16), whether only signed-in players may join, and the message limits.",
|
|
563
|
-
`On this machine, tabs of the same browser share a room (open the game twice); ${CMD} publish puts the rooms on
|
|
788
|
+
`On this machine, tabs of the same browser share a room (open the game twice); ${CMD} publish puts the rooms on xgames.fun with the game. A player's connection counts once toward the game server's requests (on Free 120 a minute from one player and 600 a game; on Creator 600 and 6,000), however many messages it carries.`,
|
|
564
789
|
],
|
|
565
790
|
};
|
|
566
791
|
}
|
package/lib/browser.mjs
CHANGED
|
@@ -111,8 +111,11 @@ export async function ensureBrowser({ log = console.error, install = true, env =
|
|
|
111
111
|
log(
|
|
112
112
|
"xcore: no Chrome or Edge found (set CHROME_PATH to use another). Downloading Playwright's Chromium once (about 170 MB) into its cache…",
|
|
113
113
|
);
|
|
114
|
-
|
|
115
|
-
const r = spawnSync(process.execPath, [
|
|
114
|
+
// its progress goes to stderr: stdout stays the command's own (the JSON of --json)
|
|
115
|
+
const r = spawnSync(process.execPath, [playwrightCli(), 'install', 'chromium'], {
|
|
116
|
+
stdio: ['ignore', 2, 2],
|
|
117
|
+
windowsHide: true,
|
|
118
|
+
});
|
|
116
119
|
const after = await playwrightChromium();
|
|
117
120
|
if (r.status !== 0 || !after.installed) {
|
|
118
121
|
throw new Error(
|
|
@@ -124,6 +127,12 @@ export async function ensureBrowser({ log = console.error, install = true, env =
|
|
|
124
127
|
return { path: after.path, kind: 'chromium', source: 'playwright (installed now)' };
|
|
125
128
|
}
|
|
126
129
|
|
|
130
|
+
/** playwright-core's own command line (its `install`). Found beside its package.json: the package's exports do not
|
|
131
|
+
* name cli.js, so it cannot be resolved by that name. → its path */
|
|
132
|
+
export function playwrightCli() {
|
|
133
|
+
return path.join(path.dirname(createRequire(import.meta.url).resolve('playwright-core/package.json')), 'cli.js');
|
|
134
|
+
}
|
|
135
|
+
|
|
127
136
|
/** A headless browser for the page check (HEADED=1 shows it). `chromium`: another Playwright BrowserType (the kit's). */
|
|
128
137
|
export async function launchBrowser({ chromium = null, headless = process.env.HEADED !== '1' } = {}) {
|
|
129
138
|
const type = chromium ?? (await import('playwright-core')).chromium;
|
package/lib/check.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The page check: a game made anywhere, checked as
|
|
1
|
+
// The page check: a game made anywhere, checked as xgames.fun hosts it. `xcore check` and publish run it for a brought
|
|
2
2
|
// game (gamesfun.json → page, ./publish/page.mjs: `xcore adopt`); the kit runs it for a copy of one of its starts
|
|
3
3
|
// that is a page of its own, adding that copy's own two checks (`start`, below).
|
|
4
4
|
//
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// 2 it fits the hosting: what publish would refuse (index.html at the root, .env files, links, secrets,
|
|
9
9
|
// backend.json, the host's own paths) and what breaks once hosted (limits, paths from the root, scripts, styles,
|
|
10
10
|
// fonts or frames from other sites): ./publish/adopt.mjs
|
|
11
|
-
// 3 the page starts: its published files served under a folder (as
|
|
11
|
+
// 3 the page starts: its published files served under a folder (as xgames.fun Studio's preview serves them) with the
|
|
12
12
|
// play host's page policy (./publish/hosting.mjs), in Chrome at a phone's size and a desktop's: no error thrown,
|
|
13
13
|
// nothing in the console's errors, none of its own files missing, and it shows something
|
|
14
14
|
// 4 it stays inside its origins: every request the page made is one the hosted page may make (its own files, the
|
|
@@ -38,7 +38,7 @@ const VIEWS = [
|
|
|
38
38
|
{ name: 'desktop', width: 1280, height: 800 },
|
|
39
39
|
];
|
|
40
40
|
const SETTLE_MS = Number(process.env.GF_PAGE_SETTLE_MS ?? 5000);
|
|
41
|
-
/** Where the page is served on the check's own origin: under a folder, as
|
|
41
|
+
/** Where the page is served on the check's own origin: under a folder, as xgames.fun Studio's preview and the kit's dev server serve it. */
|
|
42
42
|
export const MOUNT = '/game/';
|
|
43
43
|
/** Where the check's own server takes Chrome's policy-violation reports (outside MOUNT: never a file of the game). */
|
|
44
44
|
const REPORTS = '/__gf/csp-report';
|
|
@@ -55,17 +55,18 @@ const readJsonSync = file => {
|
|
|
55
55
|
* The published files as static files under MOUNT, with the play host's headers (its page policy for these backend
|
|
56
56
|
* origins, its permissions, nosniff, its types). → {url (the page's), origin, server (Node's), close}
|
|
57
57
|
* `xcore dev` (./dev/index.mjs) adds: `route(req, res, url)`, tried first (→ true when it answered: the game's server,
|
|
58
|
-
* the platform, the RPC); `html(text)`, applied to each .html file served; `port` (default: any free one)
|
|
58
|
+
* the platform, the RPC); `html(text)`, applied to each .html file served; `port` (default: any free one); `files`: the
|
|
59
|
+
* origin of the game's files (its files domain here), which the page may load pictures, sounds and fonts from.
|
|
59
60
|
*/
|
|
60
61
|
export function servePage(
|
|
61
62
|
dir,
|
|
62
|
-
{ backend = [], server = false, route = null, html = null, port = 0, overlay = null } = {},
|
|
63
|
+
{ backend = [], server = false, route = null, html = null, port = 0, overlay = null, files = [] } = {},
|
|
63
64
|
) {
|
|
64
65
|
const root = resolve(dir);
|
|
65
66
|
const over = overlay ? resolve(overlay) : null;
|
|
66
67
|
// the play host's policy, plus a report address on this check's own server: Chrome reports every violation there,
|
|
67
68
|
// a Web Worker's too, which no page event shows (a worker gets its policy from its own script's headers)
|
|
68
|
-
const base = pageHeaders(backend);
|
|
69
|
+
const base = pageHeaders(backend, null, files);
|
|
69
70
|
const headers = {
|
|
70
71
|
...base,
|
|
71
72
|
'content-security-policy': `${base['content-security-policy']}; report-uri ${REPORTS}`,
|
|
@@ -576,7 +577,7 @@ export async function pageVerify(
|
|
|
576
577
|
bad.length === 0,
|
|
577
578
|
bad.length
|
|
578
579
|
? bad.slice(0, 8).join('; ')
|
|
579
|
-
: `${pre.built.tree.files.length} files${pre.built.added?.length ? ` (with the ${pre.built.added.length} records
|
|
580
|
+
: `${pre.built.tree.files.length} files${pre.built.added?.length ? ` (with the ${pre.built.added.length} records xgames.fun keeps)` : ''}, ${(pre.built.bytes / 1048576).toFixed(1)} MB, nothing publish would refuse (${CMD} adopt lists what is worth a look)`,
|
|
580
581
|
);
|
|
581
582
|
} else check(2, 'it fits the hosting', null, 'not read: the build failed');
|
|
582
583
|
} else for (const c of await base.kept(game)) check(c.n, c.name, c.ok, c.detail);
|
|
@@ -584,7 +585,7 @@ export async function pageVerify(
|
|
|
584
585
|
if (!built || !existsSync(join(roots.out, 'index.html'))) {
|
|
585
586
|
const why = !built
|
|
586
587
|
? 'not run: the build failed'
|
|
587
|
-
: `no index.html in ${relative(game, roots.out).split(sep).join('/') || 'the game'}${roots.build ? ' (build it first, or
|
|
588
|
+
: `no index.html in ${relative(game, roots.out).split(sep).join('/') || 'the game'}${roots.build ? ' (build it first, or check it without --no-build)' : ''}`;
|
|
588
589
|
check(3, 'the page starts', built ? false : null, why);
|
|
589
590
|
check(4, 'it stays inside its origins', null, 'not run: the page did not start');
|
|
590
591
|
} else {
|
|
@@ -600,7 +601,7 @@ export async function pageVerify(
|
|
|
600
601
|
...v.errors.map(e => `${v.name}: ${e}`),
|
|
601
602
|
...v.missing.map(m =>
|
|
602
603
|
/^api\//.test(m) && !hasServer
|
|
603
|
-
? `${v.name}: missing ${m}: the page expects a server at api/, and
|
|
604
|
+
? `${v.name}: missing ${m}: the page expects a server at api/, and xgames.fun answers api/ only from the game's own server/ (docs/SERVER.md); otherwise call a backend of your own (${CMD} backend use custom)`
|
|
604
605
|
: /^(undefined|null|NaN) \(/.test(m)
|
|
605
606
|
? `${v.name}: missing ${m}: the page asked for a file named ${m.split(' ')[0]}, a path from a variable that was not set (a field missing from its data, or a load that failed before it)`
|
|
606
607
|
: `${v.name}: missing ${m}`,
|
package/lib/cli.mjs
CHANGED
|
@@ -17,18 +17,33 @@ export function parse(args, options, usage) {
|
|
|
17
17
|
|
|
18
18
|
export class UsageError extends Error {}
|
|
19
19
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Run a command body: prints errors plainly (no stack for expected ones) and maps them to exit codes. `args`: the
|
|
22
|
+
* command's arguments; with --json among them a failure is also said on stdout, as
|
|
23
|
+
* { ok: false, error, code?, state?, next? }, so a script or an agent reads why from the JSON it asked for (`code` is
|
|
24
|
+
* the refusal's word: 'usage', 'login', 'files', 'secrets', 'build'…; `next`, the command that moves it on).
|
|
25
|
+
*/
|
|
26
|
+
export async function guard(fn, args = []) {
|
|
27
|
+
const json = args.includes('--json');
|
|
22
28
|
try {
|
|
23
29
|
return (await fn()) ?? 0;
|
|
24
30
|
} catch (e) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
const usage = e instanceof UsageError;
|
|
32
|
+
console.error(usage ? e.message : `xcore: ${e.message}`);
|
|
33
|
+
if (!usage && process.env.GAMESFUN_DEBUG) console.error(e.stack);
|
|
34
|
+
if (json)
|
|
35
|
+
printJson({
|
|
36
|
+
ok: false,
|
|
37
|
+
error: usage ? e.message.split('\n')[0] : e.message,
|
|
38
|
+
...(usage
|
|
39
|
+
? { code: 'usage' }
|
|
40
|
+
: typeof e.code === 'string' && !/^E[A-Z]+$/.test(e.code)
|
|
41
|
+
? { code: e.code }
|
|
42
|
+
: {}),
|
|
43
|
+
...(typeof e.state === 'string' ? { state: e.state } : {}),
|
|
44
|
+
...(typeof e.next === 'string' ? { next: e.next } : {}),
|
|
45
|
+
});
|
|
46
|
+
return usage ? 2 : 1;
|
|
32
47
|
}
|
|
33
48
|
}
|
|
34
49
|
|