@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.
Files changed (72) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +183 -84
  3. package/bin/xcore.mjs +13 -8
  4. package/commands/add.mjs +11 -8
  5. package/commands/adopt.mjs +9 -7
  6. package/commands/backend.mjs +2 -2
  7. package/commands/check.mjs +4 -4
  8. package/commands/db.mjs +218 -0
  9. package/commands/dev.mjs +8 -6
  10. package/commands/domain.mjs +8 -8
  11. package/commands/embed.mjs +3 -3
  12. package/commands/files.mjs +106 -0
  13. package/commands/login.mjs +9 -17
  14. package/commands/logout.mjs +15 -4
  15. package/commands/loot.mjs +2 -2
  16. package/commands/plan.mjs +22 -19
  17. package/commands/publish.mjs +54 -21
  18. package/commands/secret.mjs +6 -6
  19. package/docs/SERVER.md +218 -18
  20. package/lib/add.mjs +265 -40
  21. package/lib/browser.mjs +11 -2
  22. package/lib/check.mjs +10 -9
  23. package/lib/cli.mjs +24 -9
  24. package/lib/client/gamesfun.js +242 -122
  25. package/lib/client/godot/gamesfun.gd +17 -11
  26. package/lib/client/godot/gamesfun_peer.gd +2 -2
  27. package/lib/client/server/0001_init.sql +10 -0
  28. package/lib/client/server/database.js +271 -109
  29. package/lib/client/server/gf-db.js +55 -0
  30. package/lib/client/server/gf-docs.js +56 -0
  31. package/lib/client/server/rooms.js +16 -9
  32. package/lib/client/server/rpc.js +7 -7
  33. package/lib/client/server/stock.js +37 -0
  34. package/lib/client/server/vouchers.js +127 -46
  35. package/lib/client/unity/GamesFun.cs +15 -12
  36. package/lib/client/unity/GamesFun.jslib +1 -1
  37. package/lib/config-dir.mjs +1 -1
  38. package/lib/credentials.mjs +10 -5
  39. package/lib/db.mjs +355 -0
  40. package/lib/dev/d1.mjs +486 -0
  41. package/lib/dev/files.mjs +247 -0
  42. package/lib/dev/gf-local.js +69 -3
  43. package/lib/dev/index.mjs +68 -20
  44. package/lib/dev/limits.mjs +2 -2
  45. package/lib/dev/platform.mjs +111 -12
  46. package/lib/dev/server.mjs +54 -5
  47. package/lib/dev/tools.mjs +71 -18
  48. package/lib/files.mjs +208 -0
  49. package/lib/open.mjs +1 -1
  50. package/lib/play-policy.mjs +14 -5
  51. package/lib/publish/adopt.mjs +73 -33
  52. package/lib/publish/client.mjs +130 -20
  53. package/lib/publish/gate.mjs +4 -2
  54. package/lib/publish/hosting.mjs +5 -5
  55. package/lib/publish/index.mjs +162 -40
  56. package/lib/publish/oidc.mjs +26 -5
  57. package/lib/publish/page.mjs +1 -1
  58. package/lib/publish/plans.mjs +19 -12
  59. package/lib/publish/scan.mjs +78 -102
  60. package/lib/publish/secrets.mjs +4 -5
  61. package/lib/publish/site.mjs +9 -10
  62. package/lib/publish/tree.mjs +8 -8
  63. package/lib/secret-patterns.mjs +53 -16
  64. package/lib/secret-store.mjs +2 -2
  65. package/lib/server/bundle.mjs +113 -13
  66. package/lib/server/contract.mjs +440 -16
  67. package/lib/server/data-calls.mjs +136 -0
  68. package/lib/server/secrets.mjs +9 -7
  69. package/lib/solana-rpc.mjs +5 -5
  70. package/lib/state-folder.mjs +23 -0
  71. package/package.json +4 -4
  72. package/skills/bring-your-own-game/SKILL.md +10 -9
package/lib/files.mjs ADDED
@@ -0,0 +1,208 @@
1
+ // `xcore files ls|put|get|rm|usage`: the game's project files (xgames.fun's STORAGE.md §2): files the developer or
2
+ // the game's server writes, never players (replays, generated levels, content packs). On this machine by default (the
3
+ // ones `xcore dev` gives env.GF.files, in .gamesfun/dev/files/: ./dev/files.mjs); with `live: true` the game's files on
4
+ // xgames.fun, through the owner's routes (§8.8; each call is kept in the game's audit log there):
5
+ // GET /api/kit/games/<id>/files?prefix=&after= the list
6
+ // PUT /api/kit/games/<id>/files/<path> the raw bytes, content-type, x-gf-public: 1 for a public one
7
+ // GET /api/kit/games/<id>/files/<path> the bytes
8
+ // DELETE /api/kit/games/<id>/files/<path>
9
+ // GET /api/kit/games/<id>/files-usage the bytes against the cap
10
+ // A public file is served at https://<slug>.xgamesusercontent.com/<path>, another site to the browser (here, at the
11
+ // files origin `xcore dev` prints: another origin, though the same site). Paths are checked here as xgames.fun checks
12
+ // them (../server/contract.mjs FILES).
13
+ import { createReadStream } from 'node:fs';
14
+ import { stat } from 'node:fs/promises';
15
+ import path from 'node:path';
16
+ import { Readable } from 'node:stream';
17
+ import { call, studioOrigin } from './publish/client.mjs';
18
+ import { OIDC_KIND, requireCredential } from './publish/oidc.mjs';
19
+ import { notFromCi } from './db.mjs';
20
+ import { readLinks } from './publish/index.mjs';
21
+ import { FILES, STUDIO_BODY_BYTES, fileType, filePathProblem, studioFeatures } from './server/contract.mjs';
22
+ import { createFileStore } from './dev/files.mjs';
23
+ import { CMD } from './command.mjs';
24
+
25
+ const refuse = (why, code = 'files') => Object.assign(new Error(why), { code });
26
+ const encodePath = p => p.split('/').map(encodeURIComponent).join('/');
27
+
28
+ /** The game on xgames.fun, how to sign the call, and its files' domain; refused when xgames.fun has no files yet. */
29
+ async function target(dir, { origin, env, fetchImpl }) {
30
+ const known = (await readLinks(dir))[origin];
31
+ if (!known?.projectId)
32
+ throw refuse(`this folder's game is not on ${origin} yet: publish it first (${CMD} publish), then put its files`);
33
+ const cred = await requireCredential(origin, { env, fetchImpl });
34
+ const auth = await cred.auth();
35
+ const me = await call(origin, '/api/kit/me', { ...auth, fetchImpl });
36
+ if (!studioFeatures(me).has('files'))
37
+ throw refuse(
38
+ 'this xgames.fun does not keep project files yet (GET /api/kit/me does not list "files"): on this machine, leave out --live',
39
+ 'feature',
40
+ );
41
+ return {
42
+ id: known.projectId,
43
+ auth,
44
+ ci: cred.kind === OIDC_KIND,
45
+ domain: me?.files?.domain ?? FILES.domain,
46
+ slug: known.slug ?? null,
47
+ };
48
+ }
49
+ const base = (origin, id) => `${origin}/api/kit/games/${encodeURIComponent(id)}`;
50
+
51
+ /** A call whose body or answer is a file's bytes (call() is JSON's). → the Response, refused in words when not ok */
52
+ async function raw(url, { method, auth, body, headers = {}, fetchImpl, ci = false }) {
53
+ let res;
54
+ try {
55
+ res = await fetchImpl(url, {
56
+ method,
57
+ headers: { ...headers, ...(auth.headers ?? {}), authorization: `Bearer ${auth.token}` },
58
+ ...(body ? { body, duplex: 'half' } : {}),
59
+ });
60
+ } catch (e) {
61
+ throw Object.assign(new Error(`Could not reach xgames.fun (${e.cause?.code ?? e.message}).`), { status: 0 });
62
+ }
63
+ if (res.ok) return res;
64
+ let a = null;
65
+ try {
66
+ a = await res.json();
67
+ } catch {}
68
+ // a GitHub Actions run may list and download, not change the files
69
+ if (res.status === 403 && ci && (method === 'PUT' || method === 'DELETE'))
70
+ throw Object.assign(new Error(notFromCi(method === 'PUT' ? 'put files' : 'delete files')), {
71
+ status: 403,
72
+ code: 'ci',
73
+ });
74
+ if (res.status === 413 && !a?.code)
75
+ throw Object.assign(new Error(tooBigForStudio()), { status: 413, code: 'too-big' });
76
+ throw Object.assign(new Error(a?.why ?? a?.error ?? `xgames.fun answered ${res.status}.`), {
77
+ status: res.status,
78
+ ...(a?.code ? { code: a.code } : {}),
79
+ });
80
+ }
81
+
82
+ const mb = n => `${(n / 1_000_000).toFixed(1)} MB`;
83
+ /** Why a file is too big to go through xgames.fun's Studio (its host's 4.5 MB limit on a request), and what to do. */
84
+ const tooBigForStudio = (size = null) =>
85
+ `${size === null ? 'xgames.fun answered 413: the file was too big for it to take.' : `This file is ${mb(size)}.`} A file put from this machine goes through xgames.fun's Studio, whose host takes at most ${mb(STUDIO_BODY_BYTES)} a request. A bigger one (up to ${FILES.fileBytes / 1048576} MB) goes up from the game's server: env.GF.files.put(path, body) (docs/SERVER.md, "Project files").`;
86
+
87
+ const local = dir => createFileStore({ dir });
88
+ const answer = a => {
89
+ if (a.ok === false) throw refuse(a.why, a.code);
90
+ return a;
91
+ };
92
+
93
+ /** → {ok, live, items: [{path, bytes, type, public, updatedAt}], after} */
94
+ export async function filesList(
95
+ dir,
96
+ { live = false, prefix = '', after = '', origin = studioOrigin(), env = process.env, fetchImpl = fetch } = {},
97
+ ) {
98
+ if (prefix && !prefix.endsWith('/')) prefix += '/';
99
+ if (!live) return { live: false, ...answer(await local(dir).list({ prefix, after, limit: FILES.listMax })) };
100
+ const t = await target(dir, { origin, env, fetchImpl });
101
+ const q = new URLSearchParams({ ...(prefix ? { prefix } : {}), ...(after ? { after } : {}) });
102
+ const route = `/api/kit/games/${encodeURIComponent(t.id)}/files${q.size ? `?${q}` : ''}`;
103
+ return { live: true, ...(await call(origin, route, { ...t.auth, fetchImpl })) };
104
+ }
105
+
106
+ /** A file from this machine to `to` (default: its own name). → {ok, live, path, bytes, url?} */
107
+ export async function filesPut(
108
+ dir,
109
+ file,
110
+ to,
111
+ { live = false, isPublic = false, type = null, origin = studioOrigin(), env = process.env, fetchImpl = fetch } = {},
112
+ ) {
113
+ const p = to ?? path.basename(file);
114
+ const bad = filePathProblem(p);
115
+ if (bad) throw refuse(bad, 'path');
116
+ let size;
117
+ try {
118
+ size = (await stat(file)).size;
119
+ } catch {
120
+ throw refuse(`${file} is not there`);
121
+ }
122
+ if (size > FILES.fileBytes)
123
+ throw refuse(
124
+ `${file} is ${(size / 1048576).toFixed(1)} MB; a file may be ${FILES.fileBytes / 1048576} MB`,
125
+ 'too-big',
126
+ );
127
+ const kind = type ?? fileType(p);
128
+ if (live && size > STUDIO_BODY_BYTES) throw refuse(tooBigForStudio(size), 'too-big');
129
+ if (!live) {
130
+ const { readFile } = await import('node:fs/promises');
131
+ return { live: false, ...answer(await local(dir).put(p, await readFile(file), { type: kind, public: isPublic })) };
132
+ }
133
+ const t = await target(dir, { origin, env, fetchImpl });
134
+ const res = await raw(`${base(origin, t.id)}/files/${encodePath(p)}`, {
135
+ method: 'PUT',
136
+ auth: t.auth,
137
+ body: Readable.toWeb(createReadStream(file)),
138
+ // Studio's owner route reads the length from content-length, and passes it on to the platform itself (as
139
+ // content-length and x-gf-length), which streams the body to R2 held to exactly that many bytes
140
+ headers: {
141
+ 'content-type': kind,
142
+ 'content-length': String(size),
143
+ ...(isPublic ? { 'x-gf-public': '1' } : {}),
144
+ },
145
+ fetchImpl,
146
+ ci: t.ci,
147
+ });
148
+ const a = await res.json().catch(() => ({}));
149
+ const url = a.url ?? (isPublic && t.slug ? `https://${t.slug}.${t.domain}/${p}` : null);
150
+ return { ok: true, live: true, path: p, bytes: a.bytes ?? size, ...(url ? { url } : {}) };
151
+ }
152
+
153
+ /** A file's bytes. → {ok, live, path, body (a Buffer), type, bytes, public} */
154
+ export async function filesGet(
155
+ dir,
156
+ p,
157
+ { live = false, origin = studioOrigin(), env = process.env, fetchImpl = fetch } = {},
158
+ ) {
159
+ const bad = filePathProblem(p);
160
+ if (bad) throw refuse(bad, 'path');
161
+ if (!live) return { live: false, path: p, ...answer(await local(dir).get(p)) };
162
+ const t = await target(dir, { origin, env, fetchImpl });
163
+ const res = await raw(`${base(origin, t.id)}/files/${encodePath(p)}`, { method: 'GET', auth: t.auth, fetchImpl });
164
+ const body = Buffer.from(await res.arrayBuffer());
165
+ return {
166
+ ok: true,
167
+ live: true,
168
+ path: p,
169
+ body,
170
+ // xgames.fun sends every download as an attachment (octet-stream) and the file's own type in x-gf-type
171
+ type: res.headers.get('x-gf-type') ?? res.headers.get('content-type') ?? 'application/octet-stream',
172
+ bytes: body.length,
173
+ public: res.headers.get('x-gf-public') === '1',
174
+ };
175
+ }
176
+
177
+ /** → {ok, live, deleted} */
178
+ export async function filesRemove(
179
+ dir,
180
+ p,
181
+ { live = false, origin = studioOrigin(), env = process.env, fetchImpl = fetch } = {},
182
+ ) {
183
+ const bad = filePathProblem(p);
184
+ if (bad) throw refuse(bad, 'path');
185
+ if (!live) return { live: false, ...answer(await local(dir).delete(p)) };
186
+ const t = await target(dir, { origin, env, fetchImpl });
187
+ const res = await raw(`${base(origin, t.id)}/files/${encodePath(p)}`, {
188
+ method: 'DELETE',
189
+ auth: t.auth,
190
+ fetchImpl,
191
+ ci: t.ci,
192
+ });
193
+ const a = await res.json().catch(() => ({}));
194
+ return { ok: true, live: true, deleted: a.deleted ?? true };
195
+ }
196
+
197
+ /** → {ok, live, bytes, files, cap} */
198
+ export async function filesUsage(
199
+ dir,
200
+ { live = false, origin = studioOrigin(), env = process.env, fetchImpl = fetch } = {},
201
+ ) {
202
+ if (!live) return { live: false, ...answer(await local(dir).usage()) };
203
+ const t = await target(dir, { origin, env, fetchImpl });
204
+ return {
205
+ live: true,
206
+ ...(await call(origin, `/api/kit/games/${encodeURIComponent(t.id)}/files-usage`, { ...t.auth, fetchImpl })),
207
+ };
208
+ }
package/lib/open.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  import { spawn } from 'node:child_process';
4
4
  import { findChrome } from './browser.mjs';
5
5
 
6
- // `web: true` (games.fun's own sign-in page, `xcore login`) also allows an https address; nothing else ever does.
6
+ // `web: true` (xgames.fun's own sign-in page, `xcore login`) also allows an https address; nothing else ever does.
7
7
  export function openCommand(
8
8
  url,
9
9
  { platform = process.platform, chrome = findChrome()?.path ?? null, web = false } = {},
@@ -1,4 +1,4 @@
1
- // The play origin's contract: how games.fun stores a published game's files (a file tree), which types it serves them
1
+ // The play origin's contract: how xgames.fun stores a published game's files (a file tree), which types it serves them
2
2
  // with, and the security headers every page gets. Pure module (no Node or DOM imports), shared by the page check
3
3
  // (./publish/hosting.mjs) and the kit.
4
4
  //
@@ -144,10 +144,19 @@ export function connectOrigin(value) {
144
144
  * backendOrigins): added to connect-src, and the https ones to img-src and media-src (pictures and sounds from the
145
145
  * game's own storage). Per game: each tree's files carry only that tree's origins.
146
146
  */
147
- export function playHeaders({ studioOrigin, isolated = false, mime, embedders = [], connect = [], backend = [] }) {
147
+ export function playHeaders({
148
+ studioOrigin,
149
+ isolated = false,
150
+ mime,
151
+ embedders = [],
152
+ connect = [],
153
+ backend = [],
154
+ files = [],
155
+ }) {
148
156
  const own = backend.map(connectOrigin);
149
- const extraConnect = [...new Set([...connect.map(connectOrigin), ...own])];
150
- const media = own.filter(o => o.startsWith('https:'));
157
+ // the game's files domain (<slug>.xgamesusercontent.com; STORAGE.md §8.7): pictures, sounds, fonts and fetches
158
+ const extraConnect = [...new Set([...connect.map(connectOrigin), ...own, ...files])];
159
+ const media = [...own.filter(o => o.startsWith('https:')), ...files];
151
160
  const csp = [
152
161
  "default-src 'self'",
153
162
  "script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' blob:",
@@ -155,7 +164,7 @@ export function playHeaders({ studioOrigin, isolated = false, mime, embedders =
155
164
  ["connect-src 'self' blob: data:", ...extraConnect].join(' '),
156
165
  ["img-src 'self' data: blob:", ...media].join(' '),
157
166
  ["media-src 'self' data: blob:", ...media].join(' '),
158
- "font-src 'self' data:",
167
+ ["font-src 'self' data:", ...files].join(' '),
159
168
  "style-src 'self' 'unsafe-inline'",
160
169
  "object-src 'none'",
161
170
  "base-uri 'none'",
@@ -1,7 +1,7 @@
1
1
  // `xcore adopt <folder>`: bring a web game made anywhere (this package's README). It reads the folder's
2
2
  // shape (plain static files, or a project whose build makes them: Vite, Next.js static export, Astro, SvelteKit's static
3
3
  // adapter, Nuxt generate, Create React App, Parcel, Gatsby, or any `build` script), builds it, writes its record
4
- // (gamesfun.json → page: lib/publish/page.mjs) and reports everything that does not fit games.fun's hosting
4
+ // (gamesfun.json → page: lib/publish/page.mjs) and reports everything that does not fit xgames.fun's hosting
5
5
  // (lib/publish/hosting.mjs), each with its fix:
6
6
  //
7
7
  // refused it cannot be hosted as it is: no static index.html (server-side rendering, a Node server as the
@@ -245,7 +245,7 @@ function readdirSafe(d) {
245
245
  }
246
246
  }
247
247
 
248
- /** A Unity or Godot project (its sources): games.fun hosts its web export, so that folder is the page. → a shape, or
248
+ /** A Unity or Godot project (its sources): xgames.fun hosts its web export, so that folder is the page. → a shape, or
249
249
  * null when the folder is no engine's project */
250
250
  function engineProject(dir) {
251
251
  const unity =
@@ -283,7 +283,7 @@ function engineProject(dir) {
283
283
  out: null,
284
284
  static: null,
285
285
  engine: name,
286
- why: `a ${name} project with no web export in it: games.fun hosts the export (index.html and what it loads), not the project`,
286
+ why: `a ${name} project with no web export in it: xgames.fun hosts the export (index.html and what it loads), not the project`,
287
287
  fix: unity
288
288
  ? 'In Unity: File → Build Settings → WebGL, Player Settings → Publishing Settings: Compression Format Disabled (or Decompression Fallback on), then Build into a folder such as Builds/WebGL, and adopt again (or point adopt at it: --out Builds/WebGL)'
289
289
  : 'In Godot: Project → Export → Add… → Web, Thread Support off, export to build/web/index.html, and adopt again (or point adopt at it: --out build/web)',
@@ -295,15 +295,15 @@ function godotExportFix(preset) {
295
295
  const exportIt = `Export it: godot --headless --path . --export-release "${preset.name}" ${preset.path} (or Project → Export → ${preset.name} in the editor), then ${CMD} adopt again`;
296
296
  return preset.wrote
297
297
  ? `xcore wrote a Web export preset in export_presets.cfg (Thread Support off, to build/web/index.html, leaving xcore's folders out of the game's package; build/ has a .gdignore). ${exportIt}`
298
- : `Its Web export preset is there, with nothing exported yet. ${exportIt}${preset.threads ? '. It has Thread Support on: turn it off first (a games.fun page is not cross-origin isolated)' : ''}`;
298
+ : `Its Web export preset is there, with nothing exported yet. ${exportIt}${preset.threads ? '. It has Thread Support on: turn it off first (an xgames.fun page is not cross-origin isolated)' : ''}`;
299
299
  }
300
300
 
301
301
  /** What a Godot project's Web preset leaves out of the game's package: xcore's and the tools' folders. */
302
302
  export const GODOT_EXCLUDE = 'build/*, evidence/*, gamesfun/*, server/*, node_modules/*, .work/*';
303
303
 
304
304
  /**
305
- * A Web export preset for games.fun in a Godot project that has none (export_presets.cfg made or added to): Thread
306
- * Support off (a games.fun page is not cross-origin isolated), exported to build/web/index.html, with xcore's folders
305
+ * A Web export preset for xgames.fun in a Godot project that has none (export_presets.cfg made or added to): Thread
306
+ * Support off (an xgames.fun page is not cross-origin isolated), exported to build/web/index.html, with xcore's folders
307
307
  * left out of the package, and a .gdignore in build/ so the editor never imports the export's own files.
308
308
  * → {wrote, name, path (its export path from the project), threads}; with a Web preset already, that one, unchanged
309
309
  */
@@ -734,7 +734,7 @@ export async function scanPage(out, { backend = [], limits = DEFAULT_LIMITS, pre
734
734
  const fromRoot = (what, own) => {
735
735
  if (fix.some(x => x.what === what)) return; // the same path twice on a line of a bundle
736
736
  if (rootPaths++ >= 3) return;
737
- const long = `Use relative paths: the game is served under a folder in games.fun Studio's preview and in the kit's dev server, so /x breaks there. ${ROOT_PATH_FIX}`;
737
+ const long = `Use relative paths: the game is served under a folder in xgames.fun Studio's preview and in the kit's dev server, so /x breaks there. ${ROOT_PATH_FIX}`;
738
738
  fix.push({ what, fix: own ?? (fix.some(x => x.fix === long) ? 'The same fix: relative paths' : long) });
739
739
  };
740
740
  // a page written as name.html (SvelteKit's default, some others): the host serves /name.html and /name/ (from
@@ -791,7 +791,7 @@ export async function scanPage(out, { backend = [], limits = DEFAULT_LIMITS, pre
791
791
  for (const u of urls.filter(u => typeof u === 'string' && /^\/(?!\/)/.test(u)))
792
792
  fromRoot(
793
793
  `${at(f.rel)}:${lineAt(text, m.index)} has an import map entry ${u} from the root`,
794
- `Use a relative path (./${u.slice(1)}): the game is served under a folder in games.fun Studio's preview and in the kit's dev server`,
794
+ `Use a relative path (./${u.slice(1)}): the game is served under a folder in xgames.fun Studio's preview and in the kit's dev server`,
795
795
  );
796
796
  for (const h of hosts) loadedFrom.add(h);
797
797
  if (hosts.length)
@@ -849,7 +849,7 @@ export async function scanPage(out, { backend = [], limits = DEFAULT_LIMITS, pre
849
849
  for (const m of text.matchAll(/url\(\s*["']?(\/(?!\/)[^"')]+)/gi))
850
850
  fromRoot(
851
851
  `${at(f.rel)}:${lineAt(text, m.index)} uses ${m[1]} from the root, in a style`,
852
- `Use a relative path (url(./${m[1].slice(1)})): the game is served under a folder in games.fun Studio's preview and in the kit's dev server`,
852
+ `Use a relative path (url(./${m[1].slice(1)})): the game is served under a folder in xgames.fun Studio's preview and in the kit's dev server`,
853
853
  );
854
854
  for (const m of text.matchAll(/\b(?:src|href|poster)\s*=\s*["'](\/(?!\/)[^"']*)["']/gi)) {
855
855
  const tag = text.slice(text.lastIndexOf('<', m.index), text.indexOf('>', m.index) + 1);
@@ -870,7 +870,7 @@ export async function scanPage(out, { backend = [], limits = DEFAULT_LIMITS, pre
870
870
  if (/^\/socket\.io\//.test(m[1])) {
871
871
  fix.push({
872
872
  what: `${at(f.rel)}:${lineAt(text, m.index)} loads ${m[1]}, which your Socket.IO server serves`,
873
- fix: "games.fun serves only the game's files, not your server's: copy the client (node_modules/socket.io-client/dist/socket.io.min.js) into the game and load the copy, then connect with io('https://<your server>') instead of io(), and declare that server: backend use custom --url https://<your server>",
873
+ fix: "xgames.fun serves only the game's files, not your server's: copy the client (node_modules/socket.io-client/dist/socket.io.min.js) into the game and load the copy, then connect with io('https://<your server>') instead of io(), and declare that server: backend use custom --url https://<your server>",
874
874
  });
875
875
  continue;
876
876
  }
@@ -925,7 +925,7 @@ export async function scanPage(out, { backend = [], limits = DEFAULT_LIMITS, pre
925
925
  ))
926
926
  fromRoot(
927
927
  `${at(f.rel)}:${lineAt(text, m.index)} loads ${m[1]} from the root, in a script`,
928
- `Use a relative path (./${m[1].slice(1)}): the game is served under a folder in games.fun Studio's preview and in the kit's dev server`,
928
+ `Use a relative path (./${m[1].slice(1)}): the game is served under a folder in xgames.fun Studio's preview and in the kit's dev server`,
929
929
  );
930
930
  }
931
931
  if (/\.(m?js|html?)$/i.test(f.rel))
@@ -985,7 +985,7 @@ export async function scanPage(out, { backend = [], limits = DEFAULT_LIMITS, pre
985
985
  if (callsApi.length)
986
986
  fix.push({
987
987
  what: `the page calls api/ on its own origin (${callsApi.slice(0, 3).join(', ')}), and the game has no server/`,
988
- fix: `games.fun answers a game's api/ only from its own server/ (a Worker, docs/SERVER.md). Either move that logic into server/, run the server on a host of your own and call it there (${CMD} backend use custom), or run the logic in the page (WebAssembly runs: Pyodide for Python, for example). Ask the person before a change of that size`,
988
+ fix: `xgames.fun answers a game's api/ only from its own server/ (a Worker, docs/SERVER.md). Either move that logic into server/, run the server on a host of your own and call it there (${CMD} backend use custom), or run the logic in the page (WebAssembly runs: Pyodide for Python, for example). Ask the person before a change of that size`,
989
989
  });
990
990
  const threads = devices.get('SharedArrayBuffer (threads)');
991
991
  devices.delete('SharedArrayBuffer (threads)');
@@ -993,13 +993,13 @@ export async function scanPage(out, { backend = [], limits = DEFAULT_LIMITS, pre
993
993
  const godotThreads = /\bGODOT_THREADS_ENABLED\s*=\s*true\b/.test(entry);
994
994
  if (godotThreads)
995
995
  fix.push({
996
- what: `${at('index.html')} is a Godot export built with Thread Support: it needs a cross-origin isolated page, which games.fun does not give a brought game, so it stops at "missing features: Cross Origin Isolation, SharedArrayBuffer"`,
996
+ what: `${at('index.html')} is a Godot export built with Thread Support: it needs a cross-origin isolated page, which xgames.fun does not give a brought game, so it stops at "missing features: Cross Origin Isolation, SharedArrayBuffer"`,
997
997
  fix: 'In Godot (4.3 or later), Project → Export → Web: turn Thread Support off and export again (Godot 4.0 to 4.2 always export with threads: upgrade the project)',
998
998
  });
999
999
  else if (threads)
1000
1000
  warn.push({
1001
- what: `${threads} uses SharedArrayBuffer or Atomics.wait: a games.fun page is not cross-origin isolated, so SharedArrayBuffer is undefined there`,
1002
- fix: 'Keep a path without it (the game should still start), or export single-threaded: an engine built with threads (Emscripten pthreads, Godot or Unity with thread support) does not start on games.fun',
1001
+ what: `${threads} uses SharedArrayBuffer or Atomics.wait: an xgames.fun page is not cross-origin isolated, so SharedArrayBuffer is undefined there`,
1002
+ fix: 'Keep a path without it (the game should still start), or export single-threaded: an engine built with threads (Emscripten pthreads, Godot or Unity with thread support) does not start on xgames.fun',
1003
1003
  });
1004
1004
  if (devices.size)
1005
1005
  warn.push({
@@ -1087,6 +1087,22 @@ export async function adopt(
1087
1087
  out = saved.out;
1088
1088
  stat = saved.build || saved.static !== saved.out ? (saved.static ?? null) : null;
1089
1089
  report.fromRecord = true;
1090
+ } else if (saved) {
1091
+ // a flag changes its own part of the record; the rest stays as recorded (a custom build kept when only --static
1092
+ // is given; the folder loot add chose kept when only --out is). A new --build makes its own output folder.
1093
+ report.kept = [];
1094
+ if (!build && saved.build) {
1095
+ build = saved.build;
1096
+ report.kept.push(`build (${saved.build})`);
1097
+ }
1098
+ if (!out && saved.out && !(build && build !== saved.build)) {
1099
+ out = saved.out;
1100
+ report.kept.push(`out (${saved.out})`);
1101
+ }
1102
+ if (!stat && saved.static && (saved.build || saved.static !== saved.out)) {
1103
+ stat = saved.static;
1104
+ report.kept.push(`static (${saved.static})`);
1105
+ }
1090
1106
  }
1091
1107
  const shape = detectShape(root);
1092
1108
  report.shape = {
@@ -1097,7 +1113,7 @@ export async function adopt(
1097
1113
  };
1098
1114
  const given = build || out;
1099
1115
  if (!given && (shape.kind === 'server' || shape.kind === 'none')) {
1100
- // a Godot project with no Web export yet: its preset written for games.fun (single-threaded, into build/web/)
1116
+ // a Godot project with no Web export yet: its preset written for xgames.fun (single-threaded, into build/web/)
1101
1117
  const preset = shape.engine === 'Godot' ? await godotWebPreset(root) : null;
1102
1118
  if (preset?.wrote) report.made.push('export_presets.cfg (a Web preset)', 'build/.gdignore');
1103
1119
  report.refused.push({ what: shape.why, fix: preset ? godotExportFix(preset) : shape.fix });
@@ -1116,7 +1132,7 @@ export async function adopt(
1116
1132
  )
1117
1133
  report.fix.push({
1118
1134
  what: `vite.config sets base: '${shape.base}'`,
1119
- fix: "Use base: './': the game is served under a folder in games.fun Studio's preview and in the kit's dev server, so paths from the root break there",
1135
+ fix: "Use base: './': the game is served under a folder in xgames.fun Studio's preview and in the kit's dev server, so paths from the root break there",
1120
1136
  });
1121
1137
 
1122
1138
  if (page.build && !noBuild) {
@@ -1214,11 +1230,11 @@ export async function adopt(
1214
1230
  ].find(([, files]) => files.some(f => existsSync(path.join(root, f))));
1215
1231
  const apiFix = report.fix.find(f => /calls api\/ on its own origin/.test(f.what));
1216
1232
  if (apiFix && program)
1217
- apiFix.what += `; the folder has ${program[0]} (${program[1].find(f => existsSync(path.join(root, f)))}), which games.fun does not run`;
1233
+ apiFix.what += `; the folder has ${program[0]} (${program[1].find(f => existsSync(path.join(root, f)))}), which xgames.fun does not run`;
1218
1234
  const router = pathRouter(root, report.page.out);
1219
1235
  if (router)
1220
1236
  report.warn.push({
1221
- what: `${router.where} routes by path (${router.how}): a deep link or a reload of a route (/level/2) answers "not found" once hosted, and under a folder (games.fun Studio's preview, the kit's dev server) no route matches`,
1237
+ what: `${router.where} routes by path (${router.how}): a deep link or a reload of a route (/level/2) answers "not found" once hosted, and under a folder (xgames.fun Studio's preview, the kit's dev server) no route matches`,
1222
1238
  fix:
1223
1239
  router.fix ??
1224
1240
  'The host serves files only, with no fallback to index.html. Use hash routing: Vue createWebHashHistory(); Nuxt router: { options: { hashMode: true } } in nuxt.config; React Router createHashRouter or <HashRouter>; Angular withHashLocation() in provideRouter (or useHash: true). Links people already shared keep their old paths',
@@ -1233,12 +1249,12 @@ export async function adopt(
1233
1249
  report.warn.push(
1234
1250
  hasServer
1235
1251
  ? {
1236
- what: `its Node server (${named}) is not published; its server/ is, and games.fun runs that at /api/*`,
1252
+ what: `its Node server (${named}) is not published; its server/ is, and xgames.fun runs that at /api/*`,
1237
1253
  fix: `Fine when server/ took over its work (the page talks to api/ on its own origin, not to the Node server): run both with ${CMD} dev to see. If the page still needs the Node server, keep it on your own host and declare it: ${CMD} backend use custom --url https://<your server>`,
1238
1254
  }
1239
1255
  : {
1240
- what: `its server (${named}) is not published: games.fun hosts ${report.page.out}/ only`,
1241
- fix: `Either move what it does into server/, which games.fun runs for the game (docs/SERVER.md: a Worker at /api/*, Durable Objects for rooms; ${CMD} add multiplayer starts rooms, ${CMD} add database a database; ${CMD} dev runs it here), or keep it on your own host and declare it: ${CMD} backend use custom --url https://<your server> (its wss:// origin comes with it), with the page connecting to that address instead of its own origin (io(), fetch('/api/…')); the server answers CORS for https://<slug>-play.xgames.fun`,
1256
+ what: `its server (${named}) is not published: xgames.fun hosts ${report.page.out}/ only`,
1257
+ fix: `Either move what it does into server/, which xgames.fun runs for the game (docs/SERVER.md: a Worker at /api/*, Durable Objects for rooms; ${CMD} add multiplayer starts rooms, ${CMD} add database a database; ${CMD} dev runs it here), or keep it on your own host and declare it: ${CMD} backend use custom --url https://<your server> (its wss:// origin comes with it), with the page connecting to that address instead of its own origin (io(), fetch('/api/…')); the server answers CORS for https://<slug>-play.xgames.fun`,
1242
1258
  },
1243
1259
  );
1244
1260
  }
@@ -1287,6 +1303,7 @@ export function reportLines(r, { shown = d => d } = {}) {
1287
1303
  ` published folder: ${r.page.out}${r.page.build ? `, made by: ${r.page.build}` : ''}${r.page.static && r.page.static !== r.page.out ? `; xcore adds its files to: ${r.page.static}` : ''}; ${r.files} files, ${mb(r.bytes)}`,
1288
1304
  );
1289
1305
  if (r.fromRecord) lines.push(' as gamesfun.json records it (give --build, --out or --static to change it)');
1306
+ if (r.kept?.length) lines.push(` kept from gamesfun.json: ${r.kept.join(', ')}`);
1290
1307
  if (r.wrote) lines.push(' wrote gamesfun.json (page: how it builds and what is published; never published itself)');
1291
1308
  const list = (title, items) => {
1292
1309
  if (!items.length) return;
@@ -1305,19 +1322,42 @@ export function reportLines(r, { shown = d => d } = {}) {
1305
1322
  list('Fix before publishing', r.fix);
1306
1323
  list('Worth a look', r.warn);
1307
1324
  if (r.origins.declared.length) lines.push('', `backend.json lets the page reach: ${r.origins.declared.join(', ')}`);
1308
- lines.push(
1309
- '',
1310
- r.refused.length
1311
- ? r.made?.length
1312
- ? `Wrote ${r.made.join(' and ')}; no record yet (gamesfun.json). Do the above and adopt again.`
1313
- : 'Nothing was written. Fix the above and adopt again.'
1314
- : r.fix.length
1315
- ? `Next: fix the list above, then ${CMD} check (the page in Chrome, as the host serves it).`
1316
- : `Next: ${CMD} check (the page in Chrome, as the host serves it); when you want it live, ${CMD} login and ${CMD} publish.`,
1317
- );
1325
+ lines.push('', adoptNext(r, { shown }).words);
1318
1326
  return lines;
1319
1327
  }
1320
1328
 
1329
+ /**
1330
+ * Where an adopt leaves the game, and what to do next (the report's last line, and `state` and `next` in --json):
1331
+ * refused (nothing recorded; `made` lists any file written to help, such as a Godot Web preset) → fix, adopt again
1332
+ * fix (recorded, with problems that stop a publish) → fix, then check
1333
+ * ready (recorded; only "worth a look" left, if anything) → check, then publish
1334
+ * → { state, words, next (the command to run, or null while something must be fixed by hand first) }
1335
+ */
1336
+ export function adoptNext(r, { shown = d => d } = {}) {
1337
+ const name = shown(r.dir);
1338
+ // the folder for the next commands, when it is not the one they run in
1339
+ const at = name === '.' ? '' : ` ${/[\s"'$`\\]/.test(name) ? JSON.stringify(name) : name}`;
1340
+ if (r.refused.length)
1341
+ return {
1342
+ state: 'refused',
1343
+ words: r.made?.length
1344
+ ? `Wrote ${r.made.join(' and ')}; no record yet (gamesfun.json). Do the above and adopt again.`
1345
+ : 'Nothing was written. Fix the above and adopt again.',
1346
+ next: null,
1347
+ };
1348
+ if (r.fix.length)
1349
+ return {
1350
+ state: 'fix',
1351
+ words: `Next: fix the list above, then ${CMD} check${at} (the page in Chrome, as the host serves it).`,
1352
+ next: null,
1353
+ };
1354
+ return {
1355
+ state: 'ready',
1356
+ words: `Next: ${CMD} check${at} (the page in Chrome, as the host serves it); when you want it live, ${CMD} login and ${CMD} publish${at}.`,
1357
+ next: `${CMD} check${at}`,
1358
+ };
1359
+ }
1360
+
1321
1361
  /**
1322
1362
  * `xcore loot add`: where a game's economy lives and its page client (@xgamesfun/solana's, as ../economy.mjs
1323
1363
  * installPage copies it). A brought game keeps both in the folder its build copies as it is (gamesfun.json page.static),
@@ -1367,7 +1407,7 @@ export async function addLoot(dir, { env = process.env } = {}) {
1367
1407
  ...laid,
1368
1408
  `The page client is in ${where}gf/loot/ (a copy: never edit it; publish keeps it current).`,
1369
1409
  `Load it from the page at run time with a page-relative URL: const { solanaApp, walletStandardSigner } = await import(new URL('gf/loot/play/index.js', document.baseURI).href) (a bundler leaves a URL import alone; Vite: add /* @vite-ignore */), and { solanaWallets, connectWallet, PAGE_RPC } from gf/loot/wallets.js; read the economy with fetch('${name}').`,
1370
- "Give solanaApp rpcUrl: PAGE_RPC (the page's own /loot/v1/rpc: games.fun's relay once published, xcore dev's forward on this machine); a browser with no wallet is told so, with the game still playable.",
1410
+ "Give solanaApp rpcUrl: PAGE_RPC (the page's own /loot/v1/rpc: xgames.fun's relay once published, xcore dev's forward on this machine); a browser with no wallet is told so, with the game still playable.",
1371
1411
  `From Godot, Unity or a page with gamesfun.js (${CMD} add signin): GamesFun.loot.ready(), .connect() and .call('shop'), .call('buy', key, {listing}) … do the same with JSON in and out; play it all with ${CMD} dev.`,
1372
1412
  `Going live is the person's own step, in their own wallet: ${CMD} loot approve (in the folder with ${name}).`,
1373
1413
  ]