@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 +104 -14
- package/commands/loot.mjs +13 -4
- package/commands/plan.mjs +16 -13
- package/commands/publish.mjs +14 -0
- package/commands/review.mjs +106 -0
- package/docs/SERVER.md +31 -11
- package/lib/add.mjs +1 -0
- package/lib/client/gamesfun.js +344 -8
- package/lib/client/godot/gamesfun.gd +10 -1
- package/lib/client/server/vouchers.js +15 -8
- package/lib/client/unity/GamesFun.cs +1 -1
- package/lib/dev/index.mjs +17 -1
- package/lib/dev/live.mjs +162 -0
- package/lib/dev/platform.mjs +5 -2
- package/lib/economy.mjs +12 -0
- package/lib/publish/adopt.mjs +11 -0
- package/lib/publish/plans.mjs +13 -4
- package/lib/review.mjs +951 -0
- package/lib/server/contract.mjs +4 -1
- package/package.json +2 -2
- package/skills/bring-your-own-game/SKILL.md +49 -8
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
|
-
|
|
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
|
|
529
|
-
| -------------------- | ------------------------------------------------------------------ |
|
|
530
|
-
| For |
|
|
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
|
|
532
|
-
| Saves and scores | 10 MB of saves; 8 boards of 10,000 entries | 1 GB; 16 boards of 1,000,000
|
|
533
|
-
| Use included a month | $1 | $10 a slot, shared by your Creator games
|
|
534
|
-
| Sites that embed it | 3 (`xcore embed`) | 10
|
|
535
|
-
| Domains of your own | none | 1 (`xcore domain`)
|
|
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.
|
|
539
|
-
days, prepaid in SOL or USDC
|
|
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.
|
|
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 =
|
|
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
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
? '
|
|
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;
|
package/commands/publish.mjs
CHANGED
|
@@ -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`),
|
|
297
|
-
|
|
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
|
-
|
|
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
|
],
|