@xgamesfun/xcore 0.2.1 → 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
@@ -579,7 +665,7 @@ A game made with xcore 0.1.0 that has `server/database.js` (`xcore add database`
579
665
  without one needs none: the D1 database, `xcore db` and `xcore files` are new.
580
666
 
581
667
  1. **Run `xcore add database` again.** It brings `server/database.js` up to date (your `RULES` and `LIMITS` are
582
- 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
583
669
  needs an owner (step 3) as `file:line` with its change; `xcore publish` lists them again until each names its
584
670
  owner. On its first start the new `database.js` moves the documents to a table keyed by collection, owner and id,
585
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
@@ -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);
@@ -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
  ],