@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
@@ -1,29 +1,29 @@
1
- // `xcore publish`: a game to games.fun, at a link anyone can play (games.fun hosts it; the person needs no host of
1
+ // `xcore publish`: a game to xgames.fun, at a link anyone can play (xgames.fun hosts it; the person needs no host of
2
2
  // their own). A game brought from anywhere (`xcore adopt`) or made with the kit (@xgamesfun/kit, which passes its own
3
3
  // check): the command, the kit's MCP tool and its studio's Publish tab all come here.
4
4
  //
5
5
  // 1. signed in? ./client.mjs (xcore login)
6
6
  // 2. the files ./tree.mjs: refuses .env files and links; leaves out evidence/ and hidden files
7
- // 3. no secrets ./scan.mjs (games.fun runs the same scan again on its side)
7
+ // 3. no secrets ./scan.mjs (xgames.fun runs the same scan again on its side)
8
8
  // no reference names ./names.mjs: a file that names a game this one was made to look or play
9
9
  // like refuses it (spec.json goes up as a copy without them; the person's records keep them)
10
10
  // 4. its backend backend.json → origins (`xcore backend connect`): the only other sites the game may
11
- // talk to once published; games.fun's play host opens that game's CSP to exactly those
11
+ // talk to once published; xgames.fun's play host opens that game's CSP to exactly those
12
12
  // 5. its server code server/ (the gf-server-v1 contract, ../server/contract.mjs): bundled into one file and
13
13
  // checked (it builds, no code from text, no Node built-in a Worker lacks, no secrets, its
14
14
  // Durable Objects exported, its size); a problem refuses the publish. Sent only when
15
- // games.fun says it takes server code (GET /api/kit/me → server.contracts has gf-server-v1);
15
+ // xgames.fun says it takes server code (GET /api/kit/me → server.contracts has gf-server-v1);
16
16
  // otherwise the files go up alone, and the person is told. server/ itself is never a
17
17
  // public file.
18
18
  // 6. the checks ./gate.mjs: a check on these files; a fail publishes a private draft only. A brought game:
19
19
  // the page check (../check.mjs). A game the kit made: the kit's six gates, its `check`; one
20
20
  // without it is refused here, never checked by less
21
- // 7. upload only the files (and the server bundle) games.fun does not have yet
21
+ // 7. upload only the files (and the server bundle) xgames.fun does not have yet
22
22
  // 8. the version + link unlisted; an update is the next version at the same link
23
23
  // A brought game (gamesfun.json → page, ./page.mjs; `xcore adopt`) is built first when it has a build, and its `out`
24
24
  // folder is what goes up (with its server/ bundle, as for any game); the files publish adds live in its `static` folder.
25
25
  // Signed in by `xcore login`, or in a GitHub Actions job by the job's own OIDC token (./oidc.mjs; actions/publish).
26
- // The link is remembered in <game>/.gamesfun/publish.json (public facts only: which games.fun, the game's id, its link),
26
+ // The link is remembered in <game>/.gamesfun/publish.json (public facts only: which xgames.fun, the game's id, its link),
27
27
  // so the next publish of this folder updates the same game.
28
28
  import { existsSync } from 'node:fs';
29
29
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
@@ -40,16 +40,17 @@ import {
40
40
  } from './scan.mjs';
41
41
  import { findNames, namesMessage } from './names.mjs';
42
42
  import { gate } from './gate.mjs';
43
- import { call, ownOrigins, studioOrigin } from './client.mjs';
43
+ import { call, ownOrigins, signInState, studioOrigin } from './client.mjs';
44
44
  import { shown as shownDir } from '../games.mjs';
45
- import { publishCredential } from './oidc.mjs';
45
+ import { publishCredential, requireCredential } from './oidc.mjs';
46
46
  import { bundleServer } from '../server/bundle.mjs';
47
- import { publishField, studioAccepts } from '../server/contract.mjs';
47
+ import { CONFIG_FILE, migrationsChanged, publishField, studioAccepts, studioFeatures } from '../server/contract.mjs';
48
48
  import { economyFile, installPage, installServerCopy } from '../economy.mjs';
49
49
  import { rpcWords, serverRpc, solanaRpc } from '../solana-rpc.mjs';
50
50
  import { shadowedPath } from './hosting.mjs';
51
51
  import { buildFailure, pageRoots, runBuild } from './page.mjs';
52
52
  import { CMD } from '../command.mjs';
53
+ import { stateFolder } from '../state-folder.mjs';
53
54
 
54
55
  export const linkFile = dir => path.join(dir, '.gamesfun', 'publish.json');
55
56
  export async function readLinks(dir) {
@@ -62,13 +63,14 @@ export async function readLinks(dir) {
62
63
  async function writeLink(dir, origin, record) {
63
64
  const all = await readLinks(dir);
64
65
  all[origin] = record;
66
+ await stateFolder(dir);
65
67
  await mkdir(path.dirname(linkFile(dir)), { recursive: true });
66
68
  await writeFile(linkFile(dir), JSON.stringify(all, null, 2) + '\n');
67
69
  }
68
70
 
69
71
  const mb = n => `${(n / 1048576).toFixed(1)} MB`;
70
72
  const PACKAGE = JSON.parse(await readFile(new URL('../../package.json', import.meta.url), 'utf8'));
71
- /** Who checked a game, in the report games.fun keeps with its version: this package, unless the kit says it was. */
73
+ /** Who checked a game, in the report xgames.fun keeps with its version: this package, unless the kit says it was. */
72
74
  export const LABEL = `${PACKAGE.name} ${PACKAGE.version}`;
73
75
  /** A game the kit made (its record, gf-three.json): its check is the kit's six gates, never this package's page check. */
74
76
  export const madeByKit = dir => existsSync(path.join(dir, 'gf-three.json'));
@@ -101,6 +103,41 @@ export class PublishRefusal extends Error {
101
103
  }
102
104
  }
103
105
 
106
+ /**
107
+ * xgames.fun's refusal of the game's database migrations (STORAGE.md §8.4, §8.10), as words the person can act on:
108
+ * 409 migration-changed, 409 migration-busy (another publish is applying them), 422 migration-failed (the SQL failed,
109
+ * or a file was left partly applied: its why says how to undo it), 502 database-unavailable (Cloudflare, not the SQL). Any other answer goes on as it came.
110
+ */
111
+ async function migrationWords(pending) {
112
+ try {
113
+ return await pending;
114
+ } catch (e) {
115
+ const a = e.answer ?? {};
116
+ const name = typeof a.name === 'string' ? a.name : 'a migration';
117
+ if (e.code === 'migration-changed')
118
+ throw new PublishRefusal(
119
+ `Nothing was published: ${name} changed after xgames.fun applied it to the game's database. A migration is never changed once applied: put ${name} back as it was, and make the change in a new migration after the last one.`,
120
+ { code: e.code, name, status: e.status },
121
+ );
122
+ if (e.code === 'database-unavailable')
123
+ throw new PublishRefusal(
124
+ "Nothing new went live: xgames.fun could not reach the game's database (Cloudflare did not answer, or was busy). Nothing is wrong with the migrations, and the version before keeps playing: publish again in a minute.",
125
+ { code: e.code, status: e.status },
126
+ );
127
+ if (e.code === 'migration-busy')
128
+ throw new PublishRefusal(
129
+ 'Nothing was published: another publish of this game is applying its database migrations on xgames.fun right now. Try again in a minute.',
130
+ { code: e.code, status: e.status },
131
+ );
132
+ if (e.code === 'migration-failed')
133
+ throw new PublishRefusal(
134
+ `Nothing new went live: ${name} failed on the game's database on xgames.fun${a.why ? ` (${String(a.why).replace(/\.$/, '')})` : ''}. The version before keeps playing. Fix ${name} (try it under ${CMD} dev, which applies the same migrations) and publish again.`,
135
+ { code: e.code, name, why: a.why ?? null, status: e.status },
136
+ );
137
+ throw e;
138
+ }
139
+ }
140
+
104
141
  /** Step 5, offline: the game's server code as it would go up. → null (no server/) or the bundle's facts. */
105
142
  export async function serverCheck(dir) {
106
143
  let b;
@@ -152,6 +189,14 @@ export function checkSummary(p) {
152
189
  compatibilityFlags: s.compatibilityFlags ?? [],
153
190
  files: (s.sources ?? []).map(f => f.path),
154
191
  warnings: s.warnings ?? [],
192
+ stock: !!s.stock,
193
+ database: s.database?.has
194
+ ? {
195
+ binding: s.database.binding,
196
+ migrations: s.database.migrations.map(m => m.path),
197
+ ...(s.stock ? { collections: s.database.collections } : {}),
198
+ }
199
+ : null,
155
200
  }
156
201
  : null,
157
202
  };
@@ -250,12 +295,9 @@ export async function publishGame(
250
295
  { code: 'kit' },
251
296
  );
252
297
  check ??= pageCheck;
253
- const cred = await publishCredential(origin, { env, fetchImpl });
254
- if (!cred)
255
- throw new PublishRefusal(
256
- `Not signed in to games.fun (${origin}). Sign in first: ${CMD} login (in GitHub Actions: permissions id-token: write; see the README's "Publish" section)`,
257
- { code: 'login' },
258
- );
298
+ const cred = await requireCredential(origin, { env, fetchImpl }).catch(e => {
299
+ throw new PublishRefusal(e.message, { code: 'login', state: e.state, next: e.next });
300
+ });
259
301
  if (cred.kind !== 'login') progress(`Signed in as ${cred.label} (GitHub's OIDC token; no key is stored anywhere).`);
260
302
  // every call signs with the credential's current token (a GitHub token is asked for again before it runs out)
261
303
  const api = async (route, opts = {}) => call(origin, route, { ...opts, ...(await cred.auth()), fetchImpl });
@@ -295,24 +337,54 @@ export async function publishGame(
295
337
  });
296
338
  if (pre.secrets) throw new PublishRefusal(pre.secrets, { code: 'secrets', hits: pre.hits });
297
339
  if (pre.names) throw new PublishRefusal(pre.names, { code: 'names', hits: pre.named });
340
+ // the page's documents have rules (database.collections) and nobody to answer them: every gf.db call would 404
341
+ if (pre.server?.docsUnrouted)
342
+ throw new PublishRefusal(
343
+ `Nothing was published: ${CONFIG_FILE} → database.collections sets the rules of the page's documents (gf.db, at api/db/), and this game's own server does not route api/db/, so every gf.db call would answer 404. ${CMD} add database writes server/gf-docs.js; route it first thing in your server's fetch: const db = await handleDocs(request, env); if (db) return db; (import { handleDocs } from './gf-docs.js'). Or take "collections" out of ${CONFIG_FILE} if the page does not use gf.db.`,
344
+ { code: 'db-route' },
345
+ );
298
346
  const takes = pre.server?.has ? studioAccepts(me) : null;
347
+ const database = pre.server?.database?.has ? pre.server.database : null;
348
+ if (database) {
349
+ // a database needs xgames.fun to make one (STORAGE.md §8.1): the game is not published without it
350
+ if (!takes.ok)
351
+ throw new PublishRefusal(
352
+ `Nothing was published: this game has a database (${CONFIG_FILE} → database), which runs on the game's server, and ${takes.why}. Publish it again once it does.`,
353
+ { code: 'feature', feature: 'server' },
354
+ );
355
+ if (!studioFeatures(me).has('d1'))
356
+ throw new PublishRefusal(
357
+ `Nothing was published: this game has a database (${CONFIG_FILE} → database), and this xgames.fun does not make databases for games yet (GET /api/kit/me does not list "d1"). Publish it again once it does, or take "database" out of ${CONFIG_FILE} to publish without one.`,
358
+ { code: 'feature', feature: 'd1' },
359
+ );
360
+ // append only: what this folder put on this xgames.fun before still comes first, byte for byte
361
+ const before = newGame ? [] : ((await readLinks(dir))[origin]?.migrations ?? []);
362
+ const changed = migrationsChanged(database.migrations, before);
363
+ if (changed)
364
+ throw new PublishRefusal(`Nothing was published: ${changed.why}.`, { code: changed.code, name: changed.name });
365
+ }
366
+ if (takes?.ok && pre.server.usesFiles && !studioFeatures(me).has('files'))
367
+ throw new PublishRefusal(
368
+ `Nothing was published: this game's server uses its project files (env.GF.files), and this xgames.fun does not keep project files yet (GET /api/kit/me does not list "files"). Publish it again once it does.`,
369
+ { code: 'feature', feature: 'files' },
370
+ );
299
371
  if (takes && !takes.ok)
300
372
  progress(
301
- `This game has server code (server/), and ${takes.why}: only its files are published now, without its server. Its /api/ calls will not answer until games.fun takes server code; publish again then.`,
373
+ `This game has server code (server/), and ${takes.why}: only its files are published now, without its server. Its /api/ calls will not answer until xgames.fun takes server code; publish again then.`,
302
374
  );
303
375
  if (takes?.ok && pre.server.bytes > takes.maxBytes)
304
- throw new PublishRefusal(`The server bundle is ${mb(pre.server.bytes)}; games.fun takes ${mb(takes.maxBytes)}.`, {
376
+ throw new PublishRefusal(`The server bundle is ${mb(pre.server.bytes)}; xgames.fun takes ${mb(takes.maxBytes)}.`, {
305
377
  code: 'size',
306
378
  });
307
379
  for (const w of pre.server?.warnings ?? []) progress(`Server code: ${w}`);
308
380
  // a page's Solana calls go to the game's own RPC when its server has one (xcore add rpc: server/rpc.js at api/rpc,
309
- // the URL a secret of the server), else to /loot/v1/rpc on the play host, games.fun's shared relay, which counts
381
+ // the URL a secret of the server), else to /loot/v1/rpc on the play host, xgames.fun's shared relay, which counts
310
382
  // every call toward the game's plan
311
383
  if (economyFile(roots.out) || (roots.overlay && economyFile(roots.overlay))) {
312
384
  const ownRpc = pre.server?.has && existsSync(path.join(dir, 'server', 'rpc.js'));
313
385
  if (ownRpc) {
314
386
  progress(
315
- `Its economy's Solana calls go to the game's own RPC (server/rpc.js, api/rpc) once its server has XSOLANA_RPC_URL: ${CMD} secret set XSOLANA_RPC_URL (once; ${CMD} secret list shows whether it is set). Until then they use games.fun's shared relay.`,
387
+ `Its economy's Solana calls go to the game's own RPC (server/rpc.js, api/rpc) once its server has XSOLANA_RPC_URL: ${CMD} secret set XSOLANA_RPC_URL (once; ${CMD} secret list shows whether it is set). Until then they use xgames.fun's shared relay.`,
316
388
  );
317
389
  // the server may call only backend.json's origins: the RPC's must be one
318
390
  const local = serverRpc(dir);
@@ -327,7 +399,7 @@ export async function publishGame(
327
399
  } else {
328
400
  const rpc = solanaRpc(env, rpcFile ? { file: rpcFile } : undefined);
329
401
  progress(
330
- `Its economy's Solana calls go through games.fun's shared relay once published, and each counts toward the game's plan ($10 a million; on Free's $1 a month, ~140 player-hours of an economy that reads every 5 s). Bring the game's own devnet RPC: ${CMD} add rpc${rpc.shared ? '' : ` (${rpcWords(rpc)} is used on this machine only)`}.`,
402
+ `Its economy's Solana calls go through xgames.fun's shared relay once published, and each counts toward the game's plan ($10 a million; on Free's $1 a month, ~140 player-hours of an economy that reads every 5 s). Bring the game's own devnet RPC: ${CMD} add rpc${rpc.shared ? '' : ` (${rpcWords(rpc)} is used on this machine only)`}.`,
331
403
  );
332
404
  }
333
405
  }
@@ -394,7 +466,7 @@ export async function publishGame(
394
466
  );
395
467
  if (!report.pass && !draft)
396
468
  progress(
397
- `Its checks did not pass (${report.failed.map(c => `${c.n} ${c.name}`).join('; ') || 'the check did not run'}), so it goes up as a private draft: a version in your games.fun Studio, with no public link.`,
469
+ `Its checks did not pass (${report.failed.map(c => `${c.n} ${c.name}`).join('; ') || 'the check did not run'}), so it goes up as a private draft: a version in your xgames.fun Studio, with no public link.`,
398
470
  );
399
471
 
400
472
  const links = await readLinks(dir),
@@ -408,21 +480,30 @@ export async function publishGame(
408
480
  if (server) {
409
481
  built.sources.set(server.sha256, { data: Buffer.from(server.code) });
410
482
  for (const w of server.wasm ?? []) built.sources.set(w.sha256, { data: Buffer.from(w.bytes) }); // its wasm modules, the same way
483
+ for (const m of database?.migrations ?? []) built.sources.set(m.sha256, { data: m.data }); // and its migrations
411
484
  progress(
412
- `With its server code: ${server.sources.length} file${server.sources.length === 1 ? '' : 's'} of server/ as one bundle (${(server.bytes / 1024).toFixed(1)} KB)${server.objects.length ? `, Durable Objects ${server.objects.map(o => o.class).join(', ')}` : ''}.`,
485
+ server.stock
486
+ ? `With xcore's stock server (${(server.bytes / 1024).toFixed(1)} KB), which serves the page's api/db/ from the game's database.`
487
+ : `With its server code: ${server.sources.length} file${server.sources.length === 1 ? '' : 's'} of server/ as one bundle (${(server.bytes / 1024).toFixed(1)} KB)${server.objects.length ? `, Durable Objects ${server.objects.map(o => o.class).join(', ')}` : ''}.`,
413
488
  );
489
+ if (database)
490
+ progress(
491
+ `With its database (env.${database.binding}): ${database.migrations.length ? `${database.migrations.length} migration${database.migrations.length === 1 ? '' : 's'} (${database.migrations.map(m => m.name).join(', ')}); xgames.fun applies the new ones before the new version goes live` : 'no migrations'}.`,
492
+ );
414
493
  }
415
- const begun = await api('/api/kit/publish', {
416
- method: 'POST',
417
- body: {
418
- title: name,
419
- ...(known?.projectId ? { projectId: known.projectId } : {}),
420
- tree: built.tree,
421
- verify: report,
422
- draft: draft === true,
423
- ...(server ? { server: publishField(server) } : {}),
424
- },
425
- });
494
+ const begun = await migrationWords(
495
+ api('/api/kit/publish', {
496
+ method: 'POST',
497
+ body: {
498
+ title: name,
499
+ ...(known?.projectId ? { projectId: known.projectId } : {}),
500
+ tree: built.tree,
501
+ verify: report,
502
+ draft: draft === true,
503
+ ...(server ? { server: publishField(server) } : {}),
504
+ },
505
+ }),
506
+ );
426
507
 
427
508
  // the game exists from here on: remember it, so publishing again (after a failure below) updates this game, never
428
509
  // making another
@@ -464,19 +545,20 @@ export async function publishGame(
464
545
  }
465
546
  };
466
547
  progress(
467
- `Uploading ${begun.missing.length} new file${begun.missing.length === 1 ? '' : 's'} (${mb(begun.newBytes)}); ${alreadyThere} games.fun already has.`,
548
+ `Uploading ${begun.missing.length} new file${begun.missing.length === 1 ? '' : 's'} (${mb(begun.newBytes)}); ${alreadyThere} xgames.fun already has.`,
468
549
  );
469
550
  await Promise.all(Array.from({ length: Math.min(4, begun.missing.length) }, worker));
470
551
 
471
552
  let done;
472
553
  try {
473
- done = await api(`/api/kit/publish/${begun.id}`, { method: 'POST', body: { action: 'complete' } });
554
+ done = await migrationWords(api(`/api/kit/publish/${begun.id}`, { method: 'POST', body: { action: 'complete' } }));
474
555
  } catch (e) {
475
- // games.fun can finish the publish after it stops answering (a large game's last step outlasts the request)
556
+ if (e instanceof PublishRefusal) throw e;
557
+ // xgames.fun can finish the publish after it stops answering (a large game's last step outlasts the request)
476
558
  if ([502, 503, 504].includes(e.status))
477
559
  throw Object.assign(
478
560
  new Error(
479
- `games.fun did not answer in time (${e.status}) while it finished the publish, which may have gone through anyway. Run \`${CMD} publish ${shownDir(dir)} --status\` before publishing again; publishing again updates this same game, it does not make a new one.`,
561
+ `xgames.fun did not answer in time (${e.status}) while it finished the publish, which may have gone through anyway. Run \`${CMD} publish ${shownDir(dir)} --status\` before publishing again; publishing again updates this same game, it does not make a new one.`,
480
562
  ),
481
563
  { status: e.status, code: 'timeout' },
482
564
  );
@@ -507,6 +589,8 @@ export async function publishGame(
507
589
  bytes: server.bytes,
508
590
  sha256: server.sha256,
509
591
  objects: server.objects.map(o => o.class),
592
+ ...(server.stock ? { stock: true } : {}),
593
+ ...(database ? { database: { binding: database.binding, migrations: database.migrations.length } } : {}),
510
594
  ...(done.server ? { games: done.server } : {}),
511
595
  }
512
596
  : { sent: false, why: takes?.why ?? 'not sent' }
@@ -521,16 +605,46 @@ export async function publishGame(
521
605
  gameUrl: result.gameUrl ?? known?.gameUrl ?? null,
522
606
  lastVersion: done.version?.number ?? null,
523
607
  draft: done.draft,
608
+ // the migrations xgames.fun applied (a version that went up live has had them applied): the next publish
609
+ // checks they come first, unchanged
610
+ ...(database && server
611
+ ? done.draft
612
+ ? known?.migrations
613
+ ? { migrations: known.migrations }
614
+ : {}
615
+ : { migrations: database.migrations.map(m => ({ name: m.name, sha256: m.sha256 })) }
616
+ : known?.migrations
617
+ ? { migrations: known.migrations }
618
+ : {}),
524
619
  at: new Date().toISOString(),
525
620
  });
526
621
  return result;
527
622
  }
528
623
 
529
- /** The game's remembered link and what games.fun says about it now (`xcore publish --status`). */
624
+ /**
625
+ * Where a folder's game stands on one xgames.fun, from its remembered link (.gamesfun/publish.json):
626
+ * never --publish begins--> started --a version goes up--> public | draft
627
+ * public --a version whose checks failed--> draft-over-public (the earlier version stays live) --publish--> public
628
+ * `started`: xgames.fun made the game but no version finished going up (an interrupted first publish); publishing
629
+ * again goes on with the same game.
630
+ */
631
+ export function linkState(known) {
632
+ if (!known?.projectId) return 'never';
633
+ if (known.lastVersion == null) return 'started';
634
+ if (known.draft) return known.url ? 'draft-over-public' : 'draft';
635
+ return 'public';
636
+ }
637
+
638
+ /** The game's remembered link and what xgames.fun says about it now (`xcore publish --status`). */
530
639
  export async function publishStatus(dir, { origin = studioOrigin(), env = process.env, fetchImpl = fetch } = {}) {
531
640
  const links = await readLinks(dir),
532
641
  known = links[origin] ?? null;
533
- const cred = await publishCredential(origin, { env, fetchImpl }).catch(() => null);
642
+ let oidcError = null;
643
+ const cred = await publishCredential(origin, { env, fetchImpl }).catch(e => {
644
+ oidcError = e.message;
645
+ return null;
646
+ });
647
+ const { token: _token, words: _words, ...signIn } = await signInState(origin, { env });
534
648
  let live = null;
535
649
  if (known?.projectId && cred) {
536
650
  try {
@@ -546,6 +660,14 @@ export async function publishStatus(dir, { origin = studioOrigin(), env = proces
546
660
  ? { email: cred.email, expiresAt: cred.expiresAt, store: cred.store }
547
661
  : { email: null, via: cred.kind, as: cred.label }
548
662
  : null,
663
+ // the saved sign-in's state ('signed-in' | 'signed-out' | 'expired' | 'unreadable') and what to run next;
664
+ // `oidcError`: a GitHub Actions job whose OIDC token could not be had
665
+ signIn: {
666
+ ...signIn,
667
+ ...(cred && cred.kind !== 'login' ? { via: cred.kind } : {}),
668
+ ...(oidcError ? { oidcError } : {}),
669
+ },
670
+ link: linkState(known),
549
671
  known,
550
672
  live,
551
673
  };
@@ -3,14 +3,15 @@
3
3
  // A workflow with `permissions: id-token: write` gets two variables from GitHub: ACTIONS_ID_TOKEN_REQUEST_URL and
4
4
  // ACTIONS_ID_TOKEN_REQUEST_TOKEN. With them the kit asks GitHub for a short-lived JWT (issuer
5
5
  // token.actions.githubusercontent.com, audience https://xgames.fun, the repository and ref as claims) and sends it to
6
- // games.fun's kit publish routes as `Authorization: Bearer <JWT>` with `x-gamesfun-credential: github-oidc`. games.fun
6
+ // xgames.fun's kit publish routes as `Authorization: Bearer <JWT>` with `x-gamesfun-credential: github-oidc`. xgames.fun
7
7
  // checks the issuer, the audience and the repository + branch against the link its owner made once in Studio ("this
8
8
  // repo and branch publish this game"), so nothing secret is stored in the repository or in GitHub.
9
9
  //
10
- // Used only when no `xcore login` is saved for that games.fun: a person's own sign-in always wins on their machine.
10
+ // Used only when no `xcore login` is saved for that xgames.fun: a person's own sign-in always wins on their machine.
11
11
  // The token lasts a few minutes, so it is asked for again before each call once it is four minutes old (a publish
12
- // with verify runs longer than one token lives). GAMESFUN_OIDC_AUDIENCE changes the audience (a games.fun of your own).
13
- import { savedLogin } from './client.mjs';
12
+ // with verify runs longer than one token lives). GAMESFUN_OIDC_AUDIENCE changes the audience (an xgames.fun of your own).
13
+ import { signInState } from './client.mjs';
14
+ import { CMD } from '../command.mjs';
14
15
 
15
16
  export const OIDC_AUDIENCE = 'https://xgames.fun';
16
17
  export const OIDC_HEADER = 'x-gamesfun-credential';
@@ -71,7 +72,10 @@ export function claimsOf(jwt) {
71
72
  * { kind: 'login' | 'github-oidc', label, email, auth(): Promise<{ token, headers }> }.
72
73
  */
73
74
  export async function publishCredential(origin, { env = process.env, fetchImpl = fetch } = {}) {
74
- const login = await savedLogin(origin, { env });
75
+ // only a sign-in that is still good wins: an expired or unreadable one gives way to the job's OIDC token
76
+ const st = await signInState(origin, { env });
77
+ const login =
78
+ st.state === 'signed-in' ? { token: st.token, email: st.email, expiresAt: st.expiresAt, store: st.store } : null;
75
79
  if (login)
76
80
  return {
77
81
  kind: 'login',
@@ -106,3 +110,20 @@ export async function publishCredential(origin, { env = process.env, fetchImpl =
106
110
  auth,
107
111
  };
108
112
  }
113
+
114
+ /**
115
+ * The credential, or an Error saying why there is none, in the sign-in state's own words ("ended on …", "cannot be
116
+ * read in this session"), with the command that fixes it. `.code` is 'login', `.state` the sign-in state.
117
+ */
118
+ export async function requireCredential(origin, { env = process.env, fetchImpl = fetch } = {}) {
119
+ const cred = await publishCredential(origin, { env, fetchImpl });
120
+ if (cred) return cred;
121
+ const st = await signInState(origin, { env });
122
+ const state = st.state === 'signed-in' ? 'signed-out' : st.state;
123
+ throw Object.assign(
124
+ new Error(
125
+ `${st.state === 'signed-out' ? `Not signed in to xgames.fun (${origin}).` : st.words} Run: ${CMD} login (in GitHub Actions: permissions id-token: write; see the README's "Publish" section)`,
126
+ ),
127
+ { code: 'login', state, next: `${CMD} login` },
128
+ );
129
+ }
@@ -10,7 +10,7 @@
10
10
  // output; index.html at its root is the game's entry
11
11
  // static the folder the build copies into `out` as it is (Vite, Next.js, Astro: public/), where the kit puts the
12
12
  // files it adds (backend.json, xsolana.json, gf/loot/); "." for plain files; omitted when the build has none
13
- // title optional: the game's name on games.fun (else index.html's <title>, else package.json's name)
13
+ // title optional: the game's name on xgames.fun (else index.html's <title>, else package.json's name)
14
14
  //
15
15
  // Only these four keys; every path is relative, with forward slashes, and stays inside the game's folder.
16
16
  import { execFile, spawn } from 'node:child_process';
@@ -1,19 +1,20 @@
1
- // `xcore plan`'s calls to games.fun. A plan is a game's backend tier: Free by default (strict), or
1
+ // `xcore plan`'s calls to xgames.fun. A plan is a game's backend tier: Free by default (strict), or
2
2
  // Creator when its owner puts it in one of their paid game slots (priced per game slot a month; a slot moves between
3
- // games at any time without changing the bill), or Scale, which games.fun's operator grants.
4
- // GET /api/kit/plan → {ok, slots, used, billing, capCents, monthCents, includedCents, games}
3
+ // games at any time without changing the bill), or Scale, which xgames.fun's operator grants.
4
+ // Every game sits in a slot: 2 free ones for everyone (their games on Free), any more are paid (their games on Creator).
5
+ // GET /api/kit/plan → {ok, slots, used, billing, capCents, monthCents, includedCents, games,
6
+ // gameSlots?: {free: {slots, used}, paid: {slots, used}, granted, open}}
7
+ // (slots/used: the paid slots; gameSlots: from xgames.fun 2026-11-08)
5
8
  // GET /api/kit/games/<gameId>/plan → {ok, game, tier, capped, comp, limits, monthCents, includedCents, slots, used}
6
9
  // POST /api/kit/games/<gameId>/plan {"active": bool} → the same, and changed
7
10
  // The game is the folder's own (its .gamesfun/publish.json for this Studio); the credential is publish's.
8
11
  import { readLinks } from './index.mjs';
9
12
  import { call, studioOrigin } from './client.mjs';
10
- import { publishCredential } from './oidc.mjs';
13
+ import { requireCredential } from './oidc.mjs';
11
14
  import { CMD } from '../command.mjs';
12
15
 
13
16
  async function signedIn(origin, { env, fetchImpl }) {
14
- const cred = await publishCredential(origin, { env, fetchImpl });
15
- if (!cred) throw new Error(`not signed in to ${origin}: run ${CMD} login first`);
16
- return cred.auth();
17
+ return (await requireCredential(origin, { env, fetchImpl })).auth();
17
18
  }
18
19
  async function gameId(dir, origin) {
19
20
  const known = (await readLinks(dir))[origin];
@@ -53,7 +54,7 @@ export function describeGame(r) {
53
54
  const lines = [];
54
55
  const name = r.game?.title || r.game?.slug || 'This game';
55
56
  lines.push(
56
- `${name}: ${tierName(r.tier)}${r.comp ? ' (granted by games.fun)' : ''}${r.capped ? ', capped for the rest of the month' : ''}`,
57
+ `${name}: ${tierName(r.tier)}${r.comp ? ' (granted by xgames.fun)' : ''}${r.capped ? ', capped for the rest of the month' : ''}`,
57
58
  );
58
59
  const included = r.includedCents == null ? '' : ` of ${dollars(r.includedCents)} included`;
59
60
  lines.push(` this month: ${dollars(r.monthCents)}${included}`);
@@ -71,7 +72,7 @@ export function describeGame(r) {
71
72
  lines.push(
72
73
  ' capped: its server answers slowly (60 requests a minute) until the month ends, or until it gets more room',
73
74
  );
74
- if (r.slots !== undefined) lines.push(` game slots: ${r.used ?? 0} of ${r.slots} in use`);
75
+ if (r.slots !== undefined) lines.push(` paid slots: ${r.used ?? 0} of ${r.slots} in use`);
75
76
  return lines.join('\n');
76
77
  }
77
78
 
@@ -80,12 +81,18 @@ export function describeAccount(r) {
80
81
  const open = r.payments?.on || r.billing;
81
82
  const until = r.periodEnd ? `, paid through ${new Date(r.periodEnd).toISOString().slice(0, 10)}` : '';
82
83
  const how = r.payments?.on
83
- ? ` (buy or renew on games.fun: Studio → Plans, in ${(r.payments.currencies ?? ['SOL', 'USDC']).join(' or ')}${r.payments.cluster === 'devnet' ? ', devnet test payments' : ''})`
84
+ ? ` (buy or renew on xgames.fun: your console → Plans, in ${(r.payments.currencies ?? ['SOL', 'USDC']).join(' or ')}${r.payments.cluster === 'devnet' ? ', devnet test payments' : ''})`
84
85
  : open
85
86
  ? ''
86
- : ' (buying slots is not open on this games.fun yet)';
87
+ : ' (buying slots is not open on this xgames.fun yet)';
88
+ const s = r.gameSlots;
87
89
  const lines = [
88
- `Game slots: ${r.used ?? 0} of ${r.slots ?? 0} in use${until}${how}`,
90
+ ...(s
91
+ ? [
92
+ `Free slots: ${s.free.used} of ${s.free.slots} in use (their games run on Free)${s.open === 0 ? '; a new game needs one: archive a game or move one to a paid slot' : ''}`,
93
+ ]
94
+ : []),
95
+ `${s ? 'Paid slots' : 'Game slots'}: ${r.used ?? 0} of ${r.slots ?? 0} in use${s ? ' (their games run on Creator)' : ''}${until}${how}`,
89
96
  `This month: ${dollars(r.monthCents)}${r.includedCents == null ? '' : ` of ${dollars(r.includedCents)} included`}${r.creditCents ? `; usage credit ${dollars(r.creditCents)}` : ''}${r.capCents ? `; spending cap ${dollars(r.capCents)}` : ''}`,
90
97
  ];
91
98
  for (const g of r.games ?? [])