@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/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` (
|
|
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 } = {},
|
package/lib/play-policy.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The play origin's contract: how
|
|
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({
|
|
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
|
-
|
|
150
|
-
const
|
|
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'",
|
package/lib/publish/adopt.mjs
CHANGED
|
@@ -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
|
|
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):
|
|
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:
|
|
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 (
|
|
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
|
|
306
|
-
* Support off (
|
|
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
|
|
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
|
|
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
|
|
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: "
|
|
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
|
|
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: `
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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:
|
|
1241
|
-
fix: `Either move what it does into server/, which
|
|
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:
|
|
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
|
]
|