@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
@@ -0,0 +1,136 @@
1
+ // The server's own calls to the older database (server/database.js: dataOf(env)) that 0.2.0 reads differently from
2
+ // 0.1.0. In 0.1.0 a document was found by its collection and id alone, whoever wrote it; now every player has a space
3
+ // of their own and dataOf(env) without an owner is the server's: a get by id alone answers 404 for a player's
4
+ // document, and a put, add or delete writes the server's own copy, which the player never sees. A list without an
5
+ // owner is everyone's, as it was; its `offset` is refused. Read where `xcore add database` brings a 0.1.0
6
+ // database.js up to date, and at publish, so each call is said with the change it needs (CHANGELOG / README
7
+ // "Upgrading from 0.1.0").
8
+ import { readdir, readFile } from 'node:fs/promises';
9
+ import path from 'node:path';
10
+
11
+ /** Comments and strings blanked (same length), so a word in them is not code. */
12
+ export function codeOnly(src) {
13
+ // a string keeps its quotes (so `import('./x.js')` still reads as a literal path), its text becomes spaces
14
+ return src.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*|'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"/g, m =>
15
+ m[0] === '/' ? m.replace(/[^\n]/g, ' ') : m[0] + ' '.repeat(m.length - 2) + m[0],
16
+ );
17
+ }
18
+
19
+ /** Each call's options: the argument after collection, id and value (get(c, id, opts), list(c, opts), …). */
20
+ const OPTS_AT = { get: 2, put: 3, add: 2, delete: 2, list: 1 };
21
+ const CALL = '(get|put|add|list|delete)';
22
+ const lineAt = (text, i) => text.slice(0, i).split('\n').length;
23
+
24
+ /** The arguments of the call whose ( is at `open` in `code`, as [start, end) spans; null if it never closes. */
25
+ function argsAt(code, open) {
26
+ const spans = [];
27
+ let depth = 0,
28
+ start = open + 1;
29
+ for (let i = open; i < code.length; i++) {
30
+ const c = code[i];
31
+ if (c === '(' || c === '[' || c === '{') depth++;
32
+ else if (c === ')' || c === ']' || c === '}') {
33
+ if (--depth === 0) {
34
+ if (code.slice(start, i).trim()) spans.push([start, i]);
35
+ return { spans, end: i + 1 };
36
+ }
37
+ } else if (c === ',' && depth === 1) {
38
+ spans.push([start, i]);
39
+ start = i + 1;
40
+ }
41
+ }
42
+ return null;
43
+ }
44
+
45
+ /** What one call needs, or null when it says whose space it means (or its options are not written out here). */
46
+ function needs(op, opts) {
47
+ if (op === 'list') {
48
+ if (opts && opts.startsWith('{') && /\boffset\b/.test(opts) && !/\boffset\s*:\s*0\s*[,}]/.test(opts))
49
+ return "offset is refused now: page with the last answer's after (.list(c, {after})), which the answer gives with more";
50
+ return null;
51
+ }
52
+ if (opts !== null && (!opts.startsWith('{') || /\bowner\b|\.\.\./.test(opts))) return null;
53
+ const mean = {
54
+ get: 'reads the server\'s own space (owner "server"), so a document a player wrote answers 404',
55
+ put: "writes the server's own copy, which the player never sees",
56
+ add: "adds to the server's own space, not a player's",
57
+ delete: "deletes the server's own copy; a player's document stays",
58
+ }[op];
59
+ return `without an owner it ${mean}. For a player's document pass {owner: sub} (their id, from env.GF.verifyPlayer); for the server's own, {owner: 'server'}`;
60
+ }
61
+
62
+ /**
63
+ * The calls to dataOf(env) in one file's text that need a change: dataOf(env).get(…) and a name it was kept under
64
+ * (const data = dataOf(env); data.get(…)). → [{file, line, op, fix}]
65
+ */
66
+ export function dataCallsIn(file, text) {
67
+ const code = codeOnly(text);
68
+ const found = [];
69
+ const look = (re, from) => {
70
+ for (const m of code.matchAll(re)) {
71
+ const open = m.index + m[0].length - 1;
72
+ const args = argsAt(code, open);
73
+ if (!args) continue;
74
+ const op = m[from];
75
+ const at = OPTS_AT[op];
76
+ const opts = args.spans[at] ? text.slice(...args.spans[at]).trim() : null;
77
+ const fix = needs(op, opts);
78
+ if (fix) found.push({ file, line: lineAt(code, m.index), op, fix });
79
+ }
80
+ };
81
+ // dataOf(env).get(…), dataOf(env)?.put(…)
82
+ for (const m of code.matchAll(/(?<![\w$.])dataOf\s*\(/g)) {
83
+ const args = argsAt(code, m.index + m[0].length - 1);
84
+ if (!args) continue;
85
+ const rest = new RegExp(String.raw`^\s*\??\.\s*${CALL}\s*\(`).exec(code.slice(args.end));
86
+ if (!rest) continue;
87
+ const at = args.end + rest[0].length - 1;
88
+ const call = argsAt(code, at);
89
+ if (!call) continue;
90
+ const opts = call.spans[OPTS_AT[rest[1]]] ? text.slice(...call.spans[OPTS_AT[rest[1]]]).trim() : null;
91
+ const fix = needs(rest[1], opts);
92
+ if (fix) found.push({ file, line: lineAt(code, m.index), op: rest[1], fix });
93
+ }
94
+ // const data = dataOf(env); … data.get(…)
95
+ for (const m of code.matchAll(/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:await\s+)?dataOf\s*\(/g))
96
+ look(new RegExp(String.raw`(?<![\w$.])${m[1].replace(/\$/g, '\\$')}\s*\??\.\s*${CALL}\s*\(`, 'g'), 1);
97
+ return found.sort((a, b) => a.line - b.line);
98
+ }
99
+
100
+ const SOURCE = /\.(m?js|cjs|ts|mts|jsx|tsx)$/;
101
+ /** xcore's own files, and code of others: not the game's calls. */
102
+ const NOT_OURS = rel =>
103
+ rel === 'server/database.js' || rel.startsWith('server/gf-loot/') || rel.split('/').includes('node_modules');
104
+
105
+ /**
106
+ * The calls in the game's server code that need a change: `files`, the paths (relative to `dir`, with /) to read, or
107
+ * every source file in server/. → [{file, line, op, fix}]
108
+ */
109
+ export async function ownerlessDataCalls(dir, files = null) {
110
+ if (!files) {
111
+ files = [];
112
+ const walk = async rel => {
113
+ let entries;
114
+ try {
115
+ entries = await readdir(path.join(dir, ...rel.split('/')), { withFileTypes: true });
116
+ } catch {
117
+ return;
118
+ }
119
+ for (const e of entries) {
120
+ const r = `${rel}/${e.name}`;
121
+ if (e.isDirectory()) await walk(r);
122
+ else files.push(r);
123
+ }
124
+ };
125
+ await walk('server');
126
+ }
127
+ const out = [];
128
+ for (const rel of files.filter(f => SOURCE.test(f) && !NOT_OURS(f))) {
129
+ const text = await readFile(path.join(dir, ...rel.split('/')), 'utf8').catch(() => null);
130
+ if (text?.includes('dataOf')) out.push(...dataCallsIn(rel, text));
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /** One call, as a line a person reads. */
136
+ export const dataCallLine = c => `${c.file}:${c.line} dataOf(env).${c.op}(…): ${c.fix}`;
@@ -1,19 +1,21 @@
1
- // A game server's own secrets (gf-server-v1, games.fun's hosting contract): `xcore secret set|list|delete`.
2
- // The owner sets a name and a value; games.fun hands the value to Cloudflare as a secret on the game's server script
1
+ // A game server's own secrets (gf-server-v1, xgames.fun's hosting contract): `xcore secret set|list|delete`.
2
+ // The owner sets a name and a value; xgames.fun hands the value to Cloudflare as a secret on the game's server script
3
3
  // and never stores, logs or answers it; the server reads env.<NAME> from the next request. Never the page: a page's
4
4
  // files are public. Locally, `xcore dev` gives the server the same names from the game folder's .dev.vars.
5
- // This file is the rules (shared with xcore dev's .dev.vars); the calls to games.fun are ../publish/secrets.mjs.
5
+ // This file is the rules (shared with xcore dev's .dev.vars); the calls to xgames.fun are ../publish/secrets.mjs.
6
6
 
7
- /** games.fun's limits for a game's secrets. */
7
+ /** xgames.fun's limits for a game's secrets. */
8
8
  export const SECRET_LIMITS = Object.freeze({ perGame: 32, valueBytes: 5120, nameChars: 64 });
9
9
  const NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
10
10
 
11
- /** The name's problem in plain words, or null (games.fun's rule). `taken`: the server's room bindings. */
11
+ /** The name's problem in plain words, or null (xgames.fun's rule). `taken`: the server's bindings (rooms, database). */
12
12
  export function secretNameProblem(name, taken = []) {
13
13
  if (typeof name !== 'string' || !NAME.test(name))
14
14
  return 'a secret’s name is 1–64 of A–Z, 0–9 and _, starting with a letter (for example VOUCHER_KEY)';
15
- if (name === 'GF' || name.startsWith('GF_')) return `${name} is games.fun’s own (env.GF and GF_*): pick another name`;
16
- if (taken.includes(name)) return `${name} is already one of the server's rooms (a Durable Object binding)`;
15
+ if (name === 'GF' || name.startsWith('GF_'))
16
+ return `${name} is xgames.fun’s own (env.GF and GF_*): pick another name`;
17
+ if (taken.includes(name))
18
+ return `${name} is already one of the server's bindings (its rooms' Durable Objects, or its database)`;
17
19
  return null;
18
20
  }
19
21
 
@@ -1,6 +1,6 @@
1
1
  // The Solana RPC a game's economy talks to (devnet only, like loot). One setting, XSOLANA_RPC_URL (@xgamesfun/solana's
2
2
  // own name for it; LOOT_RPC_URL, its older name, is read too), from the environment or the workspace's .env: unset, it
3
- // is games.fun's shared relay (the play host's /loot/v1/rpc, which holds games.fun's own RPC key on its side); set,
3
+ // is xgames.fun's shared relay (the play host's /loot/v1/rpc, which holds xgames.fun's own RPC key on its side); set,
4
4
  // it is the person's own devnet RPC (Helius, Triton, QuickNode, a local validator). The shared one is the default
5
5
  // for now; a published game brings its own with `xcore add rpc` (server/rpc.js at api/rpc, the URL a secret of its
6
6
  // server), which the page client asks first.
@@ -12,7 +12,7 @@ import { existsSync, readFileSync } from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import { parseEnv } from 'node:util';
14
14
 
15
- /** games.fun's shared devnet relay (the play host's /loot/v1/rpc). */
15
+ /** xgames.fun's shared devnet relay (the play host's /loot/v1/rpc). */
16
16
  export const SHARED_RPC = 'https://play.xgames.fun/loot/v1/rpc';
17
17
  /** The path a game's page calls, on whatever host serves it. */
18
18
  export const PAGE_RPC = '/loot/v1/rpc';
@@ -34,7 +34,7 @@ function setting(env, file) {
34
34
 
35
35
  /**
36
36
  * The RPC in use: {url, shared, problem}. `file`: the .env it reads (the folder the command runs in, or GAMESFUN_WORK; the
37
- * kit passes its workspace's). `shared` is games.fun's relay (nothing set). A setting that is mainnet, not a
37
+ * kit passes its workspace's). `shared` is xgames.fun's relay (nothing set). A setting that is mainnet, not a
38
38
  * URL, or plain http to anything but this machine is not used: `problem` says why and `url` is the shared relay.
39
39
  */
40
40
  export function solanaRpc(env = process.env, { file = path.join(env.GAMESFUN_WORK || process.cwd(), '.env') } = {}) {
@@ -43,7 +43,7 @@ export function solanaRpc(env = process.env, { file = path.join(env.GAMESFUN_WOR
43
43
  const refuse = why => ({
44
44
  url: SHARED_RPC,
45
45
  shared: true,
46
- problem: `XSOLANA_RPC_URL ${why}; using games.fun's shared devnet RPC instead`,
46
+ problem: `XSOLANA_RPC_URL ${why}; using xgames.fun's shared devnet RPC instead`,
47
47
  });
48
48
  let url;
49
49
  try {
@@ -60,7 +60,7 @@ export function solanaRpc(env = process.env, { file = path.join(env.GAMESFUN_WOR
60
60
  /** The RPC in words, never its URL when it is the person's own (it usually carries their key). */
61
61
  export function rpcWords(rpc) {
62
62
  if (rpc.shared)
63
- return `games.fun's shared devnet RPC (${SHARED_RPC}; set XSOLANA_RPC_URL in .env to use your own)${rpc.problem ? `: ${rpc.problem}` : ''}`;
63
+ return `xgames.fun's shared devnet RPC (${SHARED_RPC}; set XSOLANA_RPC_URL in .env to use your own)${rpc.problem ? `: ${rpc.problem}` : ''}`;
64
64
  let host = 'your own';
65
65
  try {
66
66
  host = new URL(rpc.url).host;
@@ -0,0 +1,23 @@
1
+ // <game>/.gamesfun/: xcore's own files for one game (the publish link, the local database and files, the dev
2
+ // platform's saves, exports of the players' data). Hidden, so never published; and kept out of git by a .gitignore of
3
+ // its own (`*`), written whenever xcore writes into the folder and finds none, so an export of players' data is not
4
+ // committed by a `git add .`.
5
+ import { existsSync } from 'node:fs';
6
+ import { mkdir, writeFile } from 'node:fs/promises';
7
+ import path from 'node:path';
8
+
9
+ export const STATE_FOLDER = '.gamesfun';
10
+ export const STATE_GITIGNORE = `# xcore's own files for this game: the publish link, the local database and files, exports of your players' data.
11
+ # Never published, and kept out of git. xcore writes this file again whenever it is missing, so deleting it does not
12
+ # let anything in: change it instead (it is left as you write it). A line such as !publish.json commits that file.
13
+ *
14
+ `;
15
+
16
+ /** <dir>/.gamesfun, made with its .gitignore when either is missing. → the folder */
17
+ export async function stateFolder(dir) {
18
+ const folder = path.join(dir, STATE_FOLDER);
19
+ await mkdir(folder, { recursive: true });
20
+ const ignore = path.join(folder, '.gitignore');
21
+ if (!existsSync(ignore)) await writeFile(ignore, STATE_GITIGNORE, { flag: 'wx' }).catch(() => {});
22
+ return folder;
23
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xgamesfun/xcore",
3
- "version": "0.1.0",
4
- "description": "games.fun from the command line, the one place to start: sign in, bring a web game you already made, check it as games.fun hosts it, give it a backend and a Solana economy on loot (@xgamesfun/solana), and publish it at a link anyone can play. Making a game from an idea is the kit's (@xgamesfun/kit), which it hands those commands to.",
3
+ "version": "0.2.1",
4
+ "description": "xgames.fun from the command line: bring a web game you already made, check it as xgames.fun hosts it, give it a backend and a Solana economy, and publish it at a link anyone can play.",
5
5
  "keywords": [
6
6
  "game",
7
7
  "browser-game",
@@ -9,7 +9,7 @@
9
9
  "hosting",
10
10
  "web3",
11
11
  "solana",
12
- "games.fun"
12
+ "xgames.fun"
13
13
  ],
14
14
  "homepage": "https://github.com/XGamesFun/xcore#readme",
15
15
  "repository": {
@@ -19,7 +19,7 @@
19
19
  "bugs": {
20
20
  "url": "https://github.com/XGamesFun/xcore/issues"
21
21
  },
22
- "author": "games.fun",
22
+ "author": "xgames.fun",
23
23
  "license": "MIT",
24
24
  "type": "module",
25
25
  "bin": {
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: bring-your-own-game
3
- description: Help someone bring a web game they already made without this kit (plain HTML/JS, or a Vite, Next.js static export, Astro, SvelteKit, Nuxt, Create React App or Parcel project) to games.fun - adopt it, fix what does not fit the hosting (server-side rendering, paths from the root, CDN scripts and fonts, undeclared backend origins, secrets, size limits), check it with the page check, connect their own backend or a server/ Worker, add a loot economy, and publish it. Use when the person has an existing game folder or repository and wants web3, games.fun hosting, or the kit's checks for it; never rebuild such a game through the creation loop.
3
+ description: Help someone bring a web game they already made without this kit (plain HTML/JS, or a Vite, Next.js static export, Astro, SvelteKit, Nuxt, Create React App or Parcel project) to xgames.fun - adopt it, fix what does not fit the hosting (server-side rendering, paths from the root, CDN scripts and fonts, undeclared backend origins, secrets, size limits), check it with the page check, connect their own backend or a server/ Worker, add a loot economy, and publish it. Use when the person has an existing game folder or repository and wants web3, xgames.fun hosting, or the kit's checks for it; never rebuild such a game through the creation loop.
4
4
  ---
5
5
 
6
6
  # Bring your own game
7
7
 
8
- The person already has a game. Your job is to make it fit games.fun's hosting with the smallest changes to _their_
8
+ The person already has a game. Your job is to make it fit xgames.fun's hosting with the smallest changes to _their_
9
9
  files, never to remake it. The whole story, with every rule of the hosting, is xcore's README
10
10
  (github.com/XGamesFun/xcore; on npm, @xgamesfun/xcore's page); read its "What the hosting takes" table once. Everything
11
11
  here is `@xgamesfun/xcore` (the `xcore` command: `npx -y @xgamesfun/xcore …`, or `xcore …` once installed with `npm i -g
@@ -67,7 +67,7 @@ Four checks: it builds, it fits the hosting, the page starts (phone and desktop,
67
67
  console errors, nothing missing, nothing from the root, something on screen), it stays inside its origins. Open the two
68
68
  screenshots in `evidence/verify-N/` and look at them. A failing check 4 names the origin and why: declare it in
69
69
  `backend.json` if the game really calls it, or remove the call. The page check does not say the game plays well:
70
- that is the person's own game; ask them what to try, and try it with `xcore dev <folder>` (the game as games.fun
70
+ that is the person's own game; ask them what to try, and try it with `xcore dev <folder>` (the game as xgames.fun
71
71
  hosts it, with its server, sign-in, saves, rooms and economy RPC) or their own.
72
72
 
73
73
  ## 4. Players, a database, a backend (only when the game has or needs them)
@@ -96,7 +96,7 @@ await GamesFun.join_room(code)` and `multiplayer.multiplayer_peer = peer`; keep
96
96
  answer CORS for the game's origin. Never put their secret keys in the game.
97
97
  - **They want one**: Supabase or Railway, connected by the game-making kit (`@xgamesfun/kit`: `xcore backend connect
98
98
  supabase|railway` with the kit installed). Without the kit, they set one up themselves and you declare it as above.
99
- - **Server logic games.fun runs for them**: `server/index.js` and `gamesfun.json` → `server`
99
+ - **Server logic xgames.fun runs for them**: `server/index.js` and `gamesfun.json` → `server`
100
100
  ([docs/SERVER.md](../../docs/SERVER.md)): a Worker on `/api/*`, Durable Objects for rooms, `env.GF` for sign-in, saves
101
101
  and scores. Their page calls it with a relative address (`fetch('api/scores')`): `/api/*` at the published origin,
102
102
  `/game/api/*` under `xcore dev`, which runs it under workerd with a local `env.GF`. A Node-only server stays on
@@ -126,14 +126,14 @@ Unity). A browser with no wallet is told so, with the game still playable. `xcor
126
126
  SOL: an economy approved to try it is one the person can approve again later). Going live is the person's own
127
127
  `xcore loot approve`, in their wallet: never yours.
128
128
 
129
- Paying a result has two signers (docs/SERVER.md, "Paying a result", has the rule): games.fun's keeper, when the
129
+ Paying a result has two signers (docs/SERVER.md, "Paying a result", has the rule): xgames.fun's keeper, when the
130
130
  server already decides who won and the prize is fixed by place (the server calls `env.GF.loot.report`; the owner
131
131
  certifies the host key in Studio once, and nobody keeps a key), or the server's own key, when the amount depends on
132
132
  the result, when the server replays or bounds runs, for entry fees or a prize pool, or past 120 results an hour. For
133
133
  the own key, run `xcore add vouchers <folder>`: it writes `server/vouchers.js` and a test key for `xcore dev` (its
134
134
  seed in `.dev.vars`, kept out of git). Then, in order: write `PAY.check` with the person (what their server checks
135
135
  before it pays; nothing is paid until it is written) and count payouts in `PAY.paid`; they publish; they run `xcore
136
- secret set VOUCHER_SEED --new-key` (the live key, made on games.fun and never on their machine; it prints the address);
136
+ secret set VOUCHER_SEED --new-key` (the live key: made here, sent to xgames.fun only and never kept on their machine; it prints the address);
137
137
  that address goes in the grant's `vouchers` (`xcore loot check`); they approve and publish again. Never read the seed
138
138
  out of `.dev.vars` or repeat it.
139
139
 
@@ -165,9 +165,10 @@ nothing checked (their gameplay, their backend's own behaviour).
165
165
 
166
166
  A game with a `server/`, saves or scores runs under its **plan** (`xcore plan <folder>`): Free by default (50 ms of CPU
167
167
  and 600 requests a minute, $1 of use a month, then capped and slow until the month ends). When the game needs more
168
- (a server that replays play, many players at once), tell them: a paid game slot makes it Creator
169
- (`xcore plan activate <folder>` once they have a slot), and the slot moves between their games at any time. Never
170
- activate or deactivate a game without asking.
168
+ (a server that replays play, many players at once), tell them: a paid slot makes it Creator
169
+ (`xcore plan activate <folder>` once they have one), and the paid slot moves between their games at any time. Everyone
170
+ has 2 free slots: a third game is refused on publish (`no-free-slot`) until they archive one on their console
171
+ (xgames.fun/console) or move one to a paid slot. Never activate, deactivate or archive a game without asking.
171
172
 
172
173
  Their own website can show the game (`xcore embed add https://theirsite.com <folder>` prints the `<iframe>`), and on
173
174
  Creator or Scale it can play at their own domain (`xcore domain add play.theirsite.com <folder>` prints two DNS records