@xgamesfun/xcore 0.2.0 → 0.3.0

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/README.md CHANGED
@@ -31,6 +31,7 @@ xcore add multiplayer ./my-game # rooms players share (Godot's own mu
31
31
  xcore backend use custom ./my-game --url https://api.my-game.example # only if it calls a backend you run
32
32
  xcore loot add ./my-game # only if it gets an economy (a Solana economy on loot)
33
33
  xcore add vouchers ./my-game # its server pays results it checked from a grant, with its own key
34
+ xcore review ./my-game # the security review: what a cheater gets (--live once published)
34
35
  xcore login && xcore publish ./my-game
35
36
  xcore embed add https://mygame.com ./my-game # your own site may show it (an <iframe>)
36
37
  xcore domain add play.mygame.com ./my-game # Creator or Scale: it plays at your own domain
@@ -191,6 +192,7 @@ has behind it, so the whole game runs before it is published:
191
192
  | `http://127.0.0.1:8787/game/` | the published files (it runs `page.build` first, unless `--no-build`), with the play host's headers |
192
193
  | `/game/api/*` | the game's `server/` under workerd, the runtime xgames.fun runs it on: Durable Objects with their storage kept, WebSockets, `env.GF`, the game's database (a local D1 in `.gamesfun/dev/d1/`, its migrations applied), and its secrets from `.dev.vars`. A change under `server/` or to `.dev.vars` reloads it. Miniflare (with workerd, about 140 MB) is installed once, the first time a game with a server runs |
193
194
  | `/platform/v1/*` | sign-in, saves and leaderboards: `gamesfun.js` signs in a local player (each tab its own, so two tabs play a room together), and `env.GF.verifyPlayer` accepts its token. Saves and scores are kept in `.gamesfun/dev-platform.json`, never published |
195
+ | `/platform/v1/live` | the game's live settings (`gf.live`, below): its announcement, config and maintenance, from `.gamesfun/live.json` (read again on each request, never published). With maintenance on there, the game's pages show the holding page and its `api/` and `/platform/v1/*` answer `503` with code `maintenance`, as hosted |
194
196
  | `http://127.0.0.1:8788/` | the game's public project files (`env.GF.files`, `xcore files`), kept in `.gamesfun/dev/files/`: another origin, as `<slug>.xgamesusercontent.com` is to the published game |
195
197
  | `/loot/v1/rpc` | the economy's Solana devnet RPC: xgames.fun's shared relay (what a published page talks to unless the game brings its own: `xcore add rpc`), or your own with `XSOLANA_RPC_URL` in `.env` (its URL is never shown; a call the shared relay would refuse is said in the terminal). The economy's calls answer once it is live on devnet (`xcore loot approve`) |
196
198
 
@@ -217,6 +219,14 @@ each, 16 a player), leaderboards (submit, top, the player's own), the game's dat
217
219
  (`api`). Every call answers `{data, error, status}` and none throws, so a game plays on as a guest wherever xgames.fun is
218
220
  not there.
219
221
 
222
+ **Full screen.** Most players meet a game in a post on X or on a phone, where it shows in a small frame first; `xcore
223
+ adopt` warns when a game has no way to go full screen. A button that calls `GamesFun.fullscreen()` from the click
224
+ (`await GamesFun.fullscreen()` in Godot; `Screen.fullScreen` in Unity) does it wherever the game is: in xgames.fun's
225
+ play page the page around the game goes full screen (or fills the screen in an app's own browser, X's among them), on
226
+ its own link the browser does, and in a frame that allows none (a post on X on a computer) the game opens in a tab of
227
+ its own. It answers `{on, how: 'page' | 'browser' | 'tab' | 'none'}` (`none`: an iPhone's Safari, which has no full
228
+ screen for a page); pressed again it comes back, and `GamesFun.onFullscreen(on => …)` follows every change.
229
+
220
230
  **A leaderboard is the player's own word until the server takes it.** A signed-in player submits their own scores
221
231
  from the page, so anyone can submit any score for themselves from the browser's console. A game with its own server
222
232
  makes a board nobody can write that way: `env.GF.scores.setup('ranked', {writer: 'server'})` (or a pattern for boards
@@ -246,6 +256,59 @@ published origin and under a folder alike), `init` is `fetch`'s (an object `body
246
256
  player's token goes with it as `Authorization: Bearer …`, which the server checks with `env.GF.verifyPlayer` before
247
257
  it trusts who is asking. It answers `{data, error, status}` like every other call.
248
258
 
259
+ ### Live: an announcement, a remote config and maintenance, with no republish (`gf.live`)
260
+
261
+ On xgames.fun/console, a game's **Live** tab sets three things its players get within about 35 seconds, with no
262
+ republish (xgames.fun's play edge answers them itself from one cached record a game):
263
+
264
+ - **an announcement**: a short line of plain text (280 characters), `info` or `warning`, with an optional start and end;
265
+ - **a remote config**: a small JSON object (8 KB) the game reads at run time: a difficulty, an event on or off. The
266
+ console keeps its last 10 versions and reverts with a click;
267
+ - **maintenance**: the game paused with a message. Players who open it get a plain holding page from xgames.fun instead
268
+ of the game, its server's `api/*` (WebSockets too) and the page's saves and leaderboards answer `503` with code
269
+ `maintenance` (the server's code does not run), and turning it off brings the game back within about 35 seconds (up
270
+ to a minute). What is already running (an open WebSocket, an alarm) is not stopped.
271
+
272
+ `gamesfun.js` (1.5.0) reads them:
273
+
274
+ ```js
275
+ const { data: live } = await GamesFun.live.get(); // {announcement, config, version, maintenance}
276
+ const hard = live.config.difficulty === 'hard';
277
+ GamesFun.live.onChange(live => applyEvent(live.config.event)); // asked again every minute while the page is in view
278
+ await GamesFun.live.refresh(); // ask now
279
+ ```
280
+
281
+ `get()` answers what it knows if that is under a minute old, else asks; like every call it answers
282
+ `{data, error, status}` and never throws (unreachable: the last value, else nothing live). `announcement` is null
283
+ outside its start and end, `config` is `{}` when none is set, `maintenance` is null or `{message, since}`.
284
+
285
+ **The banner.** By default `gamesfun.js` shows the announcement in a small closable line at the top of the page (each
286
+ announcement until the player closes it), and a pause that starts while the game is open (any call answering
287
+ `maintenance` shows it at once). A game that shows them its own way turns it off with `data-live-banner="off"` on its
288
+ `<script src="gamesfun.js">`, `globalThis.GAMESFUN_LIVE_BANNER = false` before it loads, or
289
+ `GamesFun.live.banner(false)` before the page has loaded. Without the banner and without `onChange`, nothing is asked
290
+ until the game calls `get()`.
291
+
292
+ **On this machine** nothing is live on a plain local server; under `xcore dev`, `.gamesfun/live.json` stands in for the
293
+ console (read again on every request):
294
+
295
+ ```json
296
+ {
297
+ "announcement": {
298
+ "text": "Double XP all weekend!",
299
+ "level": "info",
300
+ "startsAt": null,
301
+ "endsAt": "2026-11-12T00:00:00Z"
302
+ },
303
+ "config": { "difficulty": "hard", "event": true },
304
+ "maintenance": null
305
+ }
306
+ ```
307
+
308
+ `"maintenance": {"message": "Back at 7pm"}` (or `true`) pauses the game as hosted. Times are ms or ISO text; what the
309
+ console would refuse (a config over 8 KB or not an object, an announcement over 280 characters) is said in the
310
+ terminal. Godot and Unity reach `gf.live` only through their JavaScript bridge for now.
311
+
249
312
  ### A database of its own: `xcore add database`
250
313
 
251
314
  `xcore add database <folder>` adds sign-in, and a database the game owns: **one D1 database** (SQLite) a game, made on
@@ -460,6 +523,29 @@ only to players signed in with X (`VOUCHERS.payOnly`). A grant names up to three
460
523
  pay the same grant while you move from one to the other.
461
524
  [docs/SERVER.md](docs/SERVER.md), "Paying a result", has both, side by side.
462
525
 
526
+ ### Review: `xcore review`
527
+
528
+ A game that pays is read the way a cheater reads it before players are paid. `xcore review <folder>` writes
529
+ `evidence/review-N/review.md` and `review.json`, prints what it found, and exits 1 on any high finding:
530
+
531
+ - **The economy**: `@xgamesfun/solana`'s own review (`xcore loot review`): each route that pays, what it trusts, its
532
+ ceiling, and the trade-offs in words. An installed version without it says so in one line, and the rest goes on.
533
+ - **The server**: `PAY.check` still the template (it pays nothing) or one that pays from the request alone (it never
534
+ reads `env`, so nothing the server keeps); `VOUCHERS.payOnly` against the grants (`'any'` with `perPerson`, whose
535
+ claims fail; `'any'` on a grant worth farming); a voucher key and the grants that name it; `trust` on every grant a
536
+ voucher pays; `.dev.vars` in git or in the published folder; a seed or key in the page's files; the test key in a
537
+ grant; every `api/` route the code names.
538
+ - **Live** (`--live`, after publishing; `--live <origin>` for another, such as `xcore dev`'s
539
+ `http://127.0.0.1:<port>/game/`): each of those routes asked with no sign-in and with a forged one, GET and POST (a
540
+ route that pays gets an empty body: nothing it sends can spend or sign); `api/vouchers/key` against the grants' keys;
541
+ the published economy's `live` against the folder's.
542
+
543
+ Each grant a voucher pays says how much its server checks before signing: `"trust": "decides"`, `"replays"` or
544
+ `"bounds"` (docs/SERVER.md, "How much your server can check"; the field needs the `@xgamesfun/solana` that has
545
+ `review`: 0.25.0 refuses it). `review.md` ends with the trade-offs for the person: for each paid route, its level,
546
+ what a cheater gets, and what the stronger level would cost. `xcore publish` says when a game that pays through
547
+ vouchers changed since its last review (it never refuses for it).
548
+
463
549
  ## 5. Publish
464
550
 
465
551
  `xcore login` signs this computer in (your browser signs in to xgames.fun and hands it a publish token for 30
@@ -523,24 +609,28 @@ games. `GAMESFUN_STUDIO=<origin>` publishes to another xgames.fun (a Studio on y
523
609
 
524
610
  ### Its plan: `xcore plan`
525
611
 
526
- Hosting the page is free. What the game's server, saves and scores may use is the game's plan:
612
+ Every game sits in a **slot**. Everyone gets **2 free slots**, whose games run on Free; any more games need a **paid
613
+ slot** ($15 for 30 days), whose game runs on Creator. A new game is refused while both free slots are taken: archive one
614
+ you no longer need (on your console, xgames.fun/console; unpublish it first) or move one to a paid slot. Archived games
615
+ free their slot unless their link is still live. What the game's server, saves and scores may use is its plan:
527
616
 
528
- | | Free | Creator | Scale |
529
- | -------------------- | ------------------------------------------------------------------ | --------------------------------------------------------- | ----------------------- |
530
- | For | every game, by default | a game in one of your paid game slots (per game, a month) | granted by xgames.fun |
531
- | Server | 50 ms of CPU a request; 600 requests a minute, 120 from one player | 200 ms; 6,000 a minute, 600 from one player | 1,000 ms; 30,000, 1,200 |
532
- | Saves and scores | 10 MB of saves; 8 boards of 10,000 entries | 1 GB; 16 boards of 1,000,000 | 10 GB; 16 of 1,000,000 |
533
- | Use included a month | $1 | $10 a slot, shared by your Creator games | as granted |
534
- | Sites that embed it | 3 (`xcore embed`) | 10 | 25 |
535
- | Domains of your own | none | 1 (`xcore domain`) | 5 |
617
+ | | Free | Creator | Scale |
618
+ | -------------------- | ------------------------------------------------------------------ | ---------------------------------------------------- | ----------------------- |
619
+ | For | a game in one of your 2 free slots | a game in one of your paid slots (per game, a month) | granted by xgames.fun |
620
+ | Server | 50 ms of CPU a request; 600 requests a minute, 120 from one player | 200 ms; 6,000 a minute, 600 from one player | 1,000 ms; 30,000, 1,200 |
621
+ | Saves and scores | 10 MB of saves; 8 boards of 10,000 entries | 1 GB; 16 boards of 1,000,000 | 10 GB; 16 of 1,000,000 |
622
+ | Use included a month | $1 | $10 a slot, shared by your Creator games | as granted |
623
+ | Sites that embed it | 3 (`xcore embed`) | 10 | 25 |
624
+ | Domains of your own | none | 1 (`xcore domain`) | 5 |
536
625
 
537
626
  Past what is included, a game is **capped** until the month ends: its server answers at most 60 requests a minute (20
538
- from one player) and saves and scores slow down, but nothing is lost and the game keeps working. Slots are $15 for 30
539
- days, prepaid in SOL or USDC in xgames.fun Studio (Plans; no automatic renewal, 7 days' grace after the period ends).
627
+ from one player) and saves and scores slow down, but nothing is lost and the game keeps working. Paid slots are $15 for
628
+ 30 days, prepaid in SOL or USDC on xgames.fun (your console → Plans; no automatic renewal, 7 days' grace after the period
629
+ ends; when they end, their games go back to free slots and nothing is taken down).
540
630
  Usage credit, bought the same way, lets Creator games go past what their slots include, at twice what xgames.fun pays,
541
- instead of slowing down. A slot moves between your games at any time
631
+ instead of slowing down. A paid slot moves between your games at any time
542
632
  (`xcore plan deactivate <one>`, `xcore plan activate <another>`) without changing the bill. `xcore plan [folder]`
543
- shows a game's plan, its limits and this month's use; `--all` your slots and every game. A game's server sees a 429
633
+ shows a game's plan, its limits and this month's use; `--all` your free and paid slots and every game. A game's server sees a 429
544
634
  with `scope` (`"player"` or `"game"`) and `plan` when it goes past a rate.
545
635
 
546
636
  ### Your own site and your own domain: `xcore embed`, `xcore domain`
@@ -575,7 +665,7 @@ A game made with xcore 0.1.0 that has `server/database.js` (`xcore add database`
575
665
  without one needs none: the D1 database, `xcore db` and `xcore files` are new.
576
666
 
577
667
  1. **Run `xcore add database` again.** It brings `server/database.js` up to date (your `RULES` and `LIMITS` are
578
- kept), with `gamesfun.js` (1.4.0) and the Godot or Unity glue, and lists each `dataOf(env)` call in `server/` that
668
+ kept), with `gamesfun.js` (1.6.0) and the Godot or Unity glue, and lists each `dataOf(env)` call in `server/` that
579
669
  needs an owner (step 3) as `file:line` with its change; `xcore publish` lists them again until each names its
580
670
  owner. On its first start the new `database.js` moves the documents to a table keyed by collection, owner and id,
581
671
  each under its owner. After that, do not roll the game back to a version with 0.1.0's `database.js`: its writes
package/commands/loot.mjs CHANGED
@@ -9,10 +9,11 @@ import { existsSync } from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { guard, parse, printJson } from '../lib/cli.mjs';
11
11
  import { resolveGame, shown } from '../lib/games.mjs';
12
- import { ECONOMY_FILE, solanaCommand } from '../lib/economy.mjs';
12
+ import { ECONOMY_FILE, solanaCommand, solanaHas } from '../lib/economy.mjs';
13
13
  import { addLoot } from '../lib/publish/adopt.mjs';
14
14
  import { solanaRpc } from '../lib/solana-rpc.mjs';
15
15
  import { CMD } from '../lib/command.mjs';
16
+ import { NO_REVIEW } from '../lib/review.mjs';
16
17
 
17
18
  const usage = `Usage: xcore loot <command> [options]
18
19
 
@@ -21,6 +22,8 @@ A game's economy on loot, one shared Solana program (devnet only until it is aud
21
22
  xcore loot add [game] where the game's economy (${ECONOMY_FILE}) and its page client (gf/loot/) go, and the
22
23
  copy once the economy is there (a brought game: the folder its build copies as it is)
23
24
  xcore loot check|simulate|cost|explain [file] refusals and warnings; where the money goes; what it costs; in words
25
+ xcore loot review [file] what a cheater gets from each route that pays, and the trade-offs in words (xcore
26
+ review adds the game's server and, with --live, the published game)
24
27
  xcore loot approve [file] in a game's folder: put its economy live, approved in your own wallet
25
28
  xcore loot help everything else xsolana does (its help says \`xsolana <command>\`: run each as
26
29
  \`xcore loot <command>\`)
@@ -35,7 +38,7 @@ copy: never edit it; publish brings it up to date). A brought game with a build
35
38
  copies as it is (gamesfun.json page.static), so every build carries them. It prints the next steps.`;
36
39
 
37
40
  /** xsolana's commands that read an economy file ([file], default xsolana.json). */
38
- const ON_FILE = new Set(['check', 'simulate', 'cost', 'explain', 'publish', 'status', 'approve']);
41
+ const ON_FILE = new Set(['check', 'simulate', 'cost', 'explain', 'review', 'publish', 'status', 'approve']);
39
42
 
40
43
  export default {
41
44
  name: 'loot',
@@ -70,6 +73,11 @@ export default {
70
73
  );
71
74
  return 1;
72
75
  }
76
+ // an installed package from before `review`: what to do, instead of its help
77
+ if (args[0] === 'review' && !solanaHas('review')) {
78
+ console.error(`xcore: ${NO_REVIEW}.`);
79
+ return 1;
80
+ }
73
81
  // a game made before the file was named xsolana.json keeps loot.json: given to the command when it names no file
74
82
  const named = args.slice(1).some(a => a.endsWith('.json'));
75
83
  let rest =
@@ -99,9 +107,10 @@ export default {
99
107
  };
100
108
 
101
109
  /** xsolana's commands a person runs as `xcore loot <command>`. */
102
- const COMMANDS = 'guide|examples|check|simulate|cost|explain|publish|approve|status|connect|mcp|tool|version|help';
110
+ const COMMANDS =
111
+ 'guide|examples|check|simulate|cost|explain|review|publish|approve|status|connect|mcp|tool|version|help';
103
112
  /** Its tools an agent calls (xsolana_check …) that are also a command here. */
104
- const TOOLS = 'guide|examples|check|simulate|cost|explain|publish|status';
113
+ const TOOLS = 'guide|examples|check|simulate|cost|explain|review|publish|status';
105
114
 
106
115
  /**
107
116
  * xsolana's text as xcore's: its pinned npx forms and its `xsolana <command>` become `xcore loot <command>`, and the
package/commands/plan.mjs CHANGED
@@ -12,17 +12,18 @@ const usage = `Usage: xcore plan [dir] [--json]
12
12
  What a game's backend may use on xgames.fun (its server, saves and scores), and what it used this month.
13
13
  Free every game, by default: a small server (50 ms of CPU a request, 600 requests a minute) and $1 of use a
14
14
  month; past that its server is capped (slow) until the month ends
15
- Creator a game in one of your paid game slots: 200 ms a request, 6,000 requests a minute, 1 GB of saves, and
16
- $10 of use a month per slot, shared by your Creator games
15
+ Creator a game in one of your paid slots: 200 ms a request, 6,000 requests a minute, 1 GB of saves, and
16
+ $10 of use a month per paid slot, shared by your Creator games
17
17
  Scale granted by xgames.fun for a game that needs more
18
18
  Every plan lets sites of yours embed the game (Free 3, Creator 10, Scale 25: ${CMD} embed); Creator and Scale serve
19
19
  it on domains of your own (1 and 5: ${CMD} domain).
20
- A slot is $15 for 30 days, prepaid in SOL or USDC on xgames.fun (Studio → Plans; no automatic renewal), and moves
21
- between your games at any time without changing the bill. Usage credit, bought the same way, lets Creator games go
22
- past what their slots include (at twice what xgames.fun pays) instead of slowing down.
23
- activate put this folder's game in a free slot
24
- deactivate take it out (it goes back to Free and the slot is free for another game)
25
- --all your slots and every game's plan`;
20
+ Every game sits in a slot. Everyone gets 2 free slots, whose games run on Free; any more games need a paid slot,
21
+ $15 for 30 days, prepaid in SOL or USDC on xgames.fun (your console → Plans; no automatic renewal), whose game runs on
22
+ Creator. A paid slot moves between your games at any time without changing the bill. Usage credit, bought the same way,
23
+ lets Creator games go past what their slots include (at twice what xgames.fun pays) instead of slowing down.
24
+ activate move this folder's game to a paid slot (its free slot opens for another game)
25
+ deactivate move it back to a free slot (one must be open; its paid slot opens for another game)
26
+ --all your free and paid slots and every game's plan`;
26
27
 
27
28
  export default {
28
29
  name: 'plan',
@@ -57,7 +58,9 @@ export default {
57
58
  const r = await setActive(dir, verb === 'activate').catch(e => {
58
59
  if (e?.status === 409)
59
60
  throw new Error(
60
- `${e.message} Free one with ${CMD} plan deactivate <another game>, or buy a slot on xgames.fun (Studio → Plans, in SOL or USDC).`,
61
+ e.code === 'no-free-slot'
62
+ ? `${e.message} Archive a game you no longer need on your console (xgames.fun/console), or move another game to a paid slot.`
63
+ : `${e.message} Open one with ${CMD} plan deactivate <another game>, or buy a paid slot on xgames.fun (your console → Plans, in SOL or USDC).`,
61
64
  );
62
65
  return plain(e);
63
66
  });
@@ -66,11 +69,11 @@ export default {
66
69
  const what =
67
70
  verb === 'activate'
68
71
  ? r.changed
69
- ? 'In a slot: Creator from its next request.'
70
- : 'Already in a slot.'
72
+ ? 'In a paid slot: Creator from its next request; its free slot is open for another game.'
73
+ : 'Already in a paid slot.'
71
74
  : r.changed
72
- ? 'Out of its slot: Free from its next request, and the slot is free.'
73
- : 'It was not in a slot.';
75
+ ? 'Back in a free slot: Free from its next request, and its paid slot is open for another game.'
76
+ : 'It was not in a paid slot.';
74
77
  console.log(`${what}\n${describeGame(r)}`);
75
78
  }
76
79
  return 0;
@@ -8,6 +8,7 @@ import { studioOrigin } from '../lib/publish/client.mjs';
8
8
  import { gate } from '../lib/publish/gate.mjs';
9
9
  import { buildFailure, pageRoots, runBuild } from '../lib/publish/page.mjs';
10
10
  import { CMD } from '../lib/command.mjs';
11
+ import { reviewNudge } from '../lib/review.mjs';
11
12
 
12
13
  const usage = `Usage: xcore publish [dir] [--draft] [--new] [--title <name>] [--status] [--check [--verify [--port <n>]]] [--json]
13
14
 
@@ -68,6 +69,9 @@ export function publishCommand({
68
69
  );
69
70
  const dir = await resolveGame(positionals[0]);
70
71
  const origin = studioOrigin();
72
+ // a game that pays results through vouchers, changed since its last security review: one line, never a refusal
73
+ const nudge = values.status || values.json ? null : reviewNudge(dir);
74
+ if (nudge) console.error(nudge);
71
75
  if (values.status) {
72
76
  const s = await publishStatus(dir, { origin });
73
77
  if (values.json) printJson(s);
@@ -132,6 +136,16 @@ export function publishCommand({
132
136
  newGame: values.new,
133
137
  title: values.title ?? null,
134
138
  progress: values.json ? () => {} : l => console.error(l),
139
+ }).catch(e => {
140
+ // A new game needs one of the account's 2 free slots (xgames.fun's words say so); name the commands that free one.
141
+ if (e?.code === 'no-free-slot')
142
+ throw Object.assign(
143
+ new Error(
144
+ `${e.message}\n ${CMD} plan activate <another game's folder> moves that game to a paid slot\n ${CMD} plan --all your slots and games; archive one on ${origin}/console`,
145
+ ),
146
+ { status: e.status, code: e.code },
147
+ );
148
+ throw e;
135
149
  });
136
150
  if (values.json) printJson(r);
137
151
  else
@@ -0,0 +1,106 @@
1
+ // `xcore review [game] [--live [origin]]`: the security review of a brought game (../lib/review.mjs): its economy by
2
+ // @xgamesfun/solana's own review, its server and page by this package's reading, and with --live the published game
3
+ // asked as a stranger. The record goes in <game>/evidence/review-N/; any high finding exits 1.
4
+ import { guard, parse, printJson, UsageError } from '../lib/cli.mjs';
5
+ import { resolveGame, shown } from '../lib/games.mjs';
6
+ import { readLinks } from '../lib/publish/index.mjs';
7
+ import { studioOrigin } from '../lib/publish/client.mjs';
8
+ import { CMD } from '../lib/command.mjs';
9
+
10
+ const usage = `Usage: xcore review [game] [--live [origin]] [--out <dir>] [--json]
11
+
12
+ The security review of a game, read as a cheater would: what a script gets without playing, and what to fix first.
13
+ the economy @xgamesfun/solana's own review of the economy file (each route that pays: what it trusts, its ceiling,
14
+ its findings, the trade-offs in words), when the installed one has it
15
+ the server PAY.check written or still the template, or paying from the request alone; VOUCHERS.payOnly against
16
+ the grants; the voucher key and the grants that name it; "trust" on every grant a voucher pays;
17
+ .dev.vars in git or in the published folder; a seed or key in the page's files; every api/ route
18
+ --live the published game asked as a stranger: each api/ route with no sign-in and with a forged one (GET
19
+ and POST; a route that pays gets an empty body, so nothing it sends can spend or sign);
20
+ api/vouchers/key against the grants' keys; the published economy's "live" against this folder's.
21
+ The origin defaults to the game's link from its last publish; under ${CMD} dev, give its address
22
+ (--live http://127.0.0.1:<port>/game/).
23
+ --out <dir> where the record goes (default: <game>/evidence/review-N/: review.md and review.json)
24
+ --json the record as JSON
25
+ review.md ends with the trade-offs for the person: for each route that pays, how the server checks it (decides,
26
+ replays or bounds), what a cheater gets, and what the stronger level would cost. Any high finding exits 1.
27
+ docs/SERVER.md, "How much your server can check", is the guide.`;
28
+
29
+ /** --live takes an origin or nothing: `--live`, `--live <http…>`, `--live=<http…>`. → {args without it, live} */
30
+ export function liveFlag(args) {
31
+ const rest = [];
32
+ let live = null;
33
+ for (let i = 0; i < args.length; i++) {
34
+ const a = args[i];
35
+ if (a === '--live') {
36
+ live = true;
37
+ if (/^https?:\/\//i.test(args[i + 1] ?? '')) live = args[++i];
38
+ } else if (a.startsWith('--live=')) live = a.slice('--live='.length) || true;
39
+ else rest.push(a);
40
+ }
41
+ return { rest, live };
42
+ }
43
+
44
+ export default {
45
+ name: 'review',
46
+ summary: 'the security review: what a cheater gets from its economy and server, with --live the published game',
47
+ usage,
48
+ run: args =>
49
+ guard(async () => {
50
+ const { rest, live } = liveFlag(args);
51
+ const { values, positionals } = parse(rest, { out: { type: 'string' } }, usage);
52
+ const dir = await resolveGame(positionals[0]);
53
+ let base = null,
54
+ liveWhy = `not asked for (${CMD} review --live)`;
55
+ if (typeof live === 'string') {
56
+ try {
57
+ base = new URL(live).href;
58
+ } catch {
59
+ throw new UsageError(`--live takes an address (https://… or http://127.0.0.1:<port>/game/)\n\n${usage}`);
60
+ }
61
+ } else if (live) {
62
+ const known = (await readLinks(dir))[studioOrigin()];
63
+ base = known?.gameUrl ?? null;
64
+ if (!base) {
65
+ liveWhy = `${shown(dir)} has no published link here: publish it, or run ${CMD} dev and give its address (--live http://127.0.0.1:<port>/game/)`;
66
+ if (!values.json) console.error(`Not asked live: ${liveWhy}.`);
67
+ }
68
+ }
69
+ const { reviewGame } = await import('../lib/review.mjs');
70
+ if (base && !values.json) console.error(`Asking ${base} as a stranger (no sign-in, and a forged one)…`);
71
+ const r = await reviewGame(dir, { live: base, liveWhy, out: values.out ?? null });
72
+ if (values.json) printJson(r);
73
+ else console.log(summary(r, shown(dir)));
74
+ return r.ok ? 0 : 1;
75
+ }, args),
76
+ };
77
+
78
+ /** What the command prints: the verdict, each finding, and where the record and the trade-offs are. */
79
+ function summary(r, where) {
80
+ const c = r.counts;
81
+ const lines = [
82
+ `review: ${r.ok ? 'PASS' : 'FAIL'} ${c.high} high, ${c.medium} medium, ${c.low} low, ${c.info} info · evidence ${shown(r.outDir)}`,
83
+ ];
84
+ for (const f of r.findings) {
85
+ lines.push(` ${f.severity.padEnd(6)} ${f.id}${f.route ? ` (${f.route})` : ''}: ${f.message}`);
86
+ if (f.fix && f.severity !== 'info') lines.push(` fix: ${f.fix}`);
87
+ }
88
+ lines.push(
89
+ r.economy.file
90
+ ? r.economy.available
91
+ ? `economy: ${r.economy.file}, ${r.economy.routes.length} routes reviewed by @xgamesfun/solana ${r.economy.version}`
92
+ : `economy: ${r.economy.file} not reviewed: ${r.economy.why}`
93
+ : `economy: ${where} has none`,
94
+ `server: ${r.server.routes.length} api/ routes named${r.server.facts.vouchers ? `; PAY.check ${r.server.facts.pay === 'template' ? 'is still the template' : r.server.facts.pay}, payOnly ${r.server.facts.payOnly ?? '?'}` : ''}`,
95
+ r.live ? `live: ${r.live.base}, ${r.live.probes.length} requests` : `live: ${r.liveWhy}`,
96
+ );
97
+ for (const t of r.tradeoffs)
98
+ lines.push(
99
+ `paid route ${t.route}: ${t.trust ? `${t.trust}${t.declared ? '' : ' (read so by the economy review; not declared)'}` : 'trust not declared'}`,
100
+ );
101
+ if (r.tradeoffs.length)
102
+ lines.push(
103
+ `For the person: ${shown(r.outDir)}/review.md ends with each paid route's level, what a cheater gets and what the stronger level costs. Tell them, and let them choose.`,
104
+ );
105
+ return lines.join('\n');
106
+ }
package/docs/SERVER.md CHANGED
@@ -153,14 +153,14 @@ version talking to the other (a version number in the first message is enough to
153
153
 
154
154
  Every call answers `{ok: true, …}` or `{ok: false, code, why}` (`why` is words a game may show); none throws.
155
155
 
156
- | Call | Answer |
157
- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
158
- | `GF.verifyPlayer(token)` | `{ok, sub, wallet, x, lvl, exp}`: who the player is, from the sign-in token the page sends (no display name: show `x.handle` when they signed in with X, else a name the page sends, cleaned) |
159
- | `GF.saves.get(player, key = 'main')`, `.put(player, key, value)`, `.delete(player, key)`, `.list(player)` | a player's saves: any JSON value, 64 KB each, 16 a player |
160
- | `GF.scores.submit(board, player, score, {name, order, writer})`, `.setup(board, {order, writer})`, `.top(board, {limit, offset})`, `.best(board, player)`, `.remove(board, player)`, `.drop(board)` | leaderboards: a player's best is kept; `order` `"high"` (default) or `"low"`; `writer: "server"`: only the server writes the board (or every board a pattern names: `daily-*`, `*`); `drop`: the board gone, its place among the 16 free |
161
- | `GF.loot.report({round, endedAt, winners})`, `GF.loot.status()` | a result that pays prizes by place: xgames.fun's keeper signs it with the game's host key (set up by the owner in Studio); for amounts your server works out, your own key (below, "Paying a result") |
162
- | `GF.info()` | `{ok, contract, gameId, slug}` |
163
- | `GF.files.put(path, body, {type, public, length})`, `.get(path)`, `.head(path)`, `.list({prefix, after})`, `.delete(path)`, `.usage()` | the game's project files (below, "Project files"): replays, levels, content packs; public ones on the files site |
156
+ | Call | Answer |
157
+ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
158
+ | `GF.verifyPlayer(token)` | `{ok, sub, wallet, x, lvl, exp, banned}`: who the player is, from the sign-in token the page sends (no display name: show `x.handle` when they signed in with X, else a name the page sends, cleaned). `banned`: the creator's ban from the console (`{kind: 'ban' \| 'hide', until}`, or null; always null in `xcore dev`). A token made before a ban still verifies for its hour, so refuse `kind: 'ban'` yourself |
159
+ | `GF.saves.get(player, key = 'main')`, `.put(player, key, value)`, `.delete(player, key)`, `.list(player)` | a player's saves: any JSON value, 64 KB each, 16 a player |
160
+ | `GF.scores.submit(board, player, score, {name, order, writer})`, `.setup(board, {order, writer})`, `.top(board, {limit, offset})`, `.best(board, player)`, `.remove(board, player)`, `.drop(board)` | leaderboards: a player's best is kept; `order` `"high"` (default) or `"low"`; `writer: "server"`: only the server writes the board (or every board a pattern names: `daily-*`, `*`); `drop`: the board gone, its place among the 16 free |
161
+ | `GF.loot.report({round, endedAt, winners})`, `GF.loot.status()` | a result that pays prizes by place: xgames.fun's keeper signs it with the game's host key (set up by the owner in Studio); for amounts your server works out, your own key (below, "Paying a result") |
162
+ | `GF.info()` | `{ok, contract, gameId, slug}` |
163
+ | `GF.files.put(path, body, {type, public, length})`, `.get(path)`, `.head(path)`, `.list({prefix, after})`, `.delete(path)`, `.usage()` | the game's project files (below, "Project files"): replays, levels, content packs; public ones on the files site |
164
164
 
165
165
  `player` is the game's own word for a player; normally `verifyPlayer`'s `sub`, so nobody can write another player's.
166
166
  The page writes the same saves and boards for its own signed-in player (`/platform/v1/*`), so a board is the player's
@@ -284,6 +284,17 @@ A result is worth paying only as far as the server can tell it happened. Stronge
284
284
  milliseconds must not add up). A script that plays at human speed still gets through: this limits what a cheater
285
285
  earns, it does not stop one.
286
286
 
287
+ Say which one in the grant: `"trust": "decides"`, `"replays"` or `"bounds"` on every grant a voucher pays (what the
288
+ server holding its key checks before it signs, as `PAY.check` really does it; it needs the `@xgamesfun/solana` that has
289
+ `xcore loot review`, since 0.25.0 refuses the field). `xcore review` then reads the economy and the server as a cheater
290
+ would: what a script gets from each paid route without playing, a `PAY.check` that pays from the request alone, a
291
+ `payOnly` that lets one person claim from every wallet they make, and, with `--live`, each `api/` route asked with no
292
+ sign-in and with a forged one. Its `review.md` ends with the trade-offs to tell the person. The full guide, with
293
+ Solana's own exploit checklist, is `@xgamesfun/solana`'s skill reference
294
+ `skills/xsolana-economy/references/trust-and-cheating.md`; in short: a bounded result pays a script that waits and
295
+ reports, a replayed one pays the best run a script can find (unless randomness is revealed as the run goes), a decided
296
+ one pays what a bot that plays well wins.
297
+
287
298
  Even a result the server decided is worth what a good bot can earn: a bot that plays well wins what a player who
288
299
  plays well wins. Pay a bounded or replayed result only what you would accept losing to a patient script (small
289
300
  amounts, capped a day), and keep entry fees and prize pools for results the server decided and chance (the oracle).
@@ -293,8 +304,9 @@ Tell players which one your game is.
293
304
  cap that counts players or wallets multiplies with each one a person makes. What helps:
294
305
 
295
306
  - **A person, not a wallet.** For rewards worth farming, pay only players signed in with X (`VOUCHERS.payOnly = 'x'`
296
- in `server/vouchers.js`), count your caps per X account, and give the grant `perPerson` (the voucher carries a
297
- hash of the player's X account).
307
+ in `server/vouchers.js`), or with X or Google (`'account'`: a Google account is easier to make than an X one, so it
308
+ stops less farming), count your caps per account, and give the grant `perPerson` (the voucher carries a hash of the
309
+ player's X account, or `g:<Google id>` for a Google account with no X).
298
310
  - **Totals.** Give every grant a `perDay` total in `xsolana.json`, so the most it pays a day is known whoever claims.
299
311
  - **Holding something.** Gate a reward on an NFT the game sells (a pass, a creature): each identity then costs money.
300
312
  - **Proven wallets.** Pay, and grant perks to, only a wallet the player proved is theirs: the one they signed in
@@ -378,7 +390,8 @@ voucher code into `server/gf-loot/` (bundled with the server, never a public fil
378
390
  the result's own id, any text, used once; amount: base units, or `'grant'` for the grant's own amount, always
379
391
  named so a forgotten amount never pays the most) or `{why}` to refuse. Until you write it, nothing is paid. Count a
380
392
  player's daily total in `PAY.paid` (it runs once a round, after its voucher is signed; again only if it threw), not in `check`.
381
- 4. Who is paid: set `VOUCHERS.payOnly` to `'x'` for rewards worth farming (above, "Who gets paid"); the grant's
393
+ Then give the grant its `trust` (what that check does: `decides`, `replays` or `bounds`) and run `xcore review`.
394
+ 4. Who is paid: set `VOUCHERS.payOnly` to `'x'` or `'account'` for rewards worth farming (above, "Who gets paid"); the grant's
382
395
  `perDay` total caps the rest.
383
396
  5. `xcore loot approve` (the grant's change goes live), then `xcore publish`. `api/vouchers/key` answers the live
384
397
  key's address once the secret is set; until then the server pays nothing.
@@ -608,3 +621,10 @@ my-vault: 278 files, 10.0 MB, nothing refused. Left out: .gamesfun/, .gitignore,
608
621
 
609
622
  Hosted, the game's server answers at `https://<slug>-play.xgames.fun/api/…`. A blocked or over-limit server answers
610
623
  `{error, message}` with 451, 429, 404, 503 or 502 instead of the server's own answer.
624
+
625
+ While the game's owner has it **paused for maintenance** (xgames.fun/console, the Live tab; README "Live"), xgames.fun
626
+ answers its `api/*` itself with `503 {ok: false, code: 'maintenance', why, since}` and `retry-after: 60`, WebSocket
627
+ upgrades included: the server's code does not run for a new request. What already runs keeps running (an open
628
+ WebSocket, a Durable Object's alarm, the `env.GF` calls they make); a server that should stop those reads the game's
629
+ `GET /platform/v1/live` (`maintenance` is `{message, since}` while paused). `xcore dev` does the same from
630
+ `.gamesfun/live.json`.
package/lib/add.mjs CHANGED
@@ -603,6 +603,7 @@ export async function addVouchers(dir) {
603
603
  ? []
604
604
  : [`${where} has no grant yet: add one that pays results (xsolana_examples "rewards for a score").`]),
605
605
  'Write PAY.check at the top of server/vouchers.js: what your server checks before a result pays (docs/SERVER.md, "How much your server can check"), and PAY.paid to count what was paid. Until then nothing is paid. Every wallet is a new player and a wallet sign-in is free: for rewards worth farming set VOUCHERS.payOnly to \'x\' (players signed in with X) and give the grant a perDay total.',
606
+ `Say in each grant that pays results how much PAY.check really checks: "trust": "decides", "replays" or "bounds" (once the installed @xgamesfun/solana takes it: the one with ${CMD} loot review). Then ${CMD} review: what a cheater gets from each paid route, and what to fix before publishing; tell the person, and let them choose the level.`,
606
607
  `The page links its wallet once (await gf.loot.link(): the wallet signs that it is this player's; a wallet sign-in needs none), then asks and claims: const r = await gf.api('vouchers/pay', {method: 'POST', body: {runId}}); then await gf.loot.call('claimGrant', '<grant>', {voucher: r.data.voucher}). A voucher pays only a wallet the player proved. Your server's own code (a room that decided a match) pays with voucherFor(env, {grant, wallet: await walletOf(env, player), round}) from './vouchers.js'.`,
607
608
  `Each pay is one request to the game's server and one read of the grant through the relay (or your own RPC: ${CMD} add rpc); publish keeps server/gf-loot/ at the installed @xgamesfun/solana, so the vouchers follow loot when xcore does.`,
608
609
  ],