@xgamesfun/xcore 0.2.1 → 0.4.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 +139 -17
- package/commands/dev.mjs +31 -10
- package/commands/login.mjs +22 -11
- package/commands/logout.mjs +2 -0
- package/commands/loot.mjs +13 -4
- package/commands/publish.mjs +4 -0
- package/commands/review.mjs +106 -0
- package/docs/SERVER.md +120 -32
- package/lib/add.mjs +1 -0
- package/lib/check.mjs +1 -0
- package/lib/cli.mjs +2 -0
- package/lib/client/gamesfun.js +348 -9
- package/lib/client/godot/gamesfun.gd +10 -1
- package/lib/client/server/0001_init.sql +1 -1
- package/lib/client/server/database.js +2 -1
- package/lib/client/server/gf-db.js +6 -2
- package/lib/client/server/rooms.js +1 -0
- package/lib/client/server/vouchers.js +15 -8
- package/lib/client/unity/GamesFun.cs +1 -1
- package/lib/dev/files.mjs +102 -5
- package/lib/dev/gf-local.js +4 -0
- package/lib/dev/index.mjs +49 -10
- package/lib/dev/limits.mjs +55 -24
- package/lib/dev/live.mjs +162 -0
- package/lib/dev/plans.mjs +101 -0
- package/lib/dev/platform.mjs +490 -138
- package/lib/economy.mjs +12 -0
- package/lib/login-reach.mjs +184 -0
- package/lib/publish/adopt.mjs +11 -0
- package/lib/publish/index.mjs +16 -7
- package/lib/publish/names.mjs +128 -1
- package/lib/publish/scan.mjs +25 -1
- package/lib/publish/tree.mjs +9 -1
- package/lib/review.mjs +1166 -0
- package/lib/secret-patterns.mjs +36 -26
- package/lib/server/bundle.mjs +139 -28
- package/lib/server/contract.mjs +95 -12
- package/package.json +2 -2
- package/skills/bring-your-own-game/SKILL.md +58 -9
package/docs/SERVER.md
CHANGED
|
@@ -35,7 +35,7 @@ my-game/
|
|
|
35
35
|
| `main` | default `server/index.js`; a `.js` or `.mjs` file under `server/` |
|
|
36
36
|
| `objects` | 0–8 Durable Objects (a room, a run, a vault): `class` is the class the entry exports, `binding` the name it has on `env` (A–Z, 0–9 and `_`, starting with a letter, at most 32 characters, never `GF` or `GF_…`). Each gets its own SQLite storage. **Append only**: once published, keep every class (removing one deletes its data, so the upload refuses it) |
|
|
37
37
|
| `routes` | `"/api/*"`, the only value for now |
|
|
38
|
-
| `compatibilityFlags` | empty
|
|
38
|
+
| `compatibilityFlags` | empty. `["nodejs_compat"]` is not offered: with Node's built-in modules a server can open raw network connections, so xgames.fun refuses a publish that asks for it, and the kit's check says so first. Use the Web APIs a Worker has (`fetch`, `crypto.subtle`, `URL`, `TextEncoder`) |
|
|
39
39
|
|
|
40
40
|
Without a `gamesfun.json` server entry, a `server/index.js` alone is a server with no objects. `server/` and
|
|
41
41
|
`gamesfun.json` are **never served** as files, and a game with a server keeps no files under `api/` (its `/api/*` is
|
|
@@ -50,7 +50,7 @@ export class Vault extends DurableObject {
|
|
|
50
50
|
}
|
|
51
51
|
export default {
|
|
52
52
|
async fetch(request, env, ctx) {
|
|
53
|
-
/* every /api/* request of the game, URL unchanged */
|
|
53
|
+
/* every /api/* request of the game, URL unchanged: check the player's token first (below) */
|
|
54
54
|
},
|
|
55
55
|
};
|
|
56
56
|
```
|
|
@@ -63,24 +63,38 @@ export default {
|
|
|
63
63
|
`env.GF_GAME_ID`, `env.GF_SLUG` and `env.GF_CONTRACT` (`"gf-server-v1"`), and your own secrets as `env.<NAME>`
|
|
64
64
|
(below, "Secrets"). No key of xgames.fun's. Keep keys out of server code: publish refuses one it finds.
|
|
65
65
|
- **Runtime**: Cloudflare Workers, compatibility date `2025-06-01`.
|
|
66
|
-
- **Outbound `fetch
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
66
|
+
- **Outbound `fetch`**: the list in `backend.json` (and xgames.fun's own origins) applies to the calls your server's
|
|
67
|
+
entry makes while it answers a request; any other answers 403 with the header `x-gf-outbound: refused`. Do not rely
|
|
68
|
+
on it for anything else: keep what your Durable Objects call to what they need, in their own code. No number of calls
|
|
69
|
+
out a request is promised. To read Solana devnet from the server, call xgames.fun's relay,
|
|
70
|
+
`https://play.xgames.fun/loot/v1/rpc` (the methods loot's client uses, one request a call): the public devnet RPC
|
|
71
|
+
(`api.devnet.solana.com`) answers the Workers runtime with 403 ("Your IP or provider is blocked"), under `xcore dev`
|
|
72
|
+
as well.
|
|
73
|
+
- **Limits**: set by the game's plan (README §5, `xcore plan`). Free: 50 ms of CPU per request to `fetch`, **600
|
|
74
|
+
requests a minute per game and 120 from one player**; Creator: 200 ms, 6,000 and 600; a 5 MB bundle on every plan.
|
|
75
|
+
Requests with no good game token share half of the game's minute (Free: 300), so signed-in players always have the
|
|
76
|
+
other half. Past a rate, `/api/*` answers 429 with `retry-after: 60`, `scope` (`"player"` or `"game"`) and `plan`.
|
|
77
|
+
Keep frequent traffic,
|
|
74
78
|
such as positions in a real-time game, on a WebSocket rather than in many HTTP requests. A Durable
|
|
75
79
|
Object's WebSocket counts as one request for its whole life, but a room's messages are held to a few milliseconds
|
|
76
80
|
of CPU each **on every plan** (a bigger plan raises a request's CPU, not a room's): a room doing ~3 ms a message ran
|
|
77
81
|
for minutes, one doing ~15 ms was reset within seconds (every socket closes with code 1006). Do a room's work as
|
|
78
82
|
messages arrive rather than in timers, keep it to a millisecond or two per message, and reconnect on 1006. Heavy
|
|
79
83
|
work (a replay, a long simulation) goes in an HTTP request instead, which gets the plan's CPU.
|
|
80
|
-
- **Not allowed
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
+
- **Not allowed** (the kit's check names the file and line before anything is sent; xgames.fun refuses the same at
|
|
85
|
+
publish):
|
|
86
|
+
- **raw network connections**: an import, a re-export or an `import()` of `cloudflare:sockets`, `node:net`,
|
|
87
|
+
`node:tls`, `node:dgram`, `node:http2`, `node:module` or `node:child_process` (with or without `node:`), in the
|
|
88
|
+
server's own code or in a package it bundles. A game's server reaches the network with `fetch()` only: a
|
|
89
|
+
database driver or a mail or chat client that opens sockets runs on a host of your own;
|
|
90
|
+
- **an `import()` of a name made at run time** (`import(name)`): it could load any module. Import each module by
|
|
91
|
+
its own name; a path esbuild can list when it bundles (``import(`./levels/${name}.js`)``) is bundled, every
|
|
92
|
+
file there, and is fine. xgames.fun parses the bundle it is sent; the kit reads the same bundle with esbuild's
|
|
93
|
+
own parser, never by searching its text, so a word in a comment or a string is not an import;
|
|
94
|
+
- **Node's built-in modules** (`node:crypto`, `node:buffer`, `fs`, …): `nodejs_compat` is not offered, so use the
|
|
95
|
+
Web APIs a Worker has (`crypto.subtle`, `crypto.getRandomValues`, `TextEncoder`, `URL`);
|
|
96
|
+
- code made from text at run time (`eval`, `new Function`, `importScripts`; the runtime refuses them too), and files
|
|
97
|
+
from outside the game folder (packages from `node_modules` are fine).
|
|
84
98
|
- **One request at a time, between awaits.** A Durable Object holds other requests back while it waits on its own
|
|
85
99
|
storage, but an `await` of anything else (`fetch`, `env.GF.verifyPlayer`, another object) lets the next request
|
|
86
100
|
in. Read and update a player's state (a daily cap, a balance) with nothing but storage calls in between: verify
|
|
@@ -106,6 +120,36 @@ export default {
|
|
|
106
120
|
(Rapier's `rapier_wasm2d.js` is such a loader; a game running the same deterministic build in the page and in the
|
|
107
121
|
server got identical results.)
|
|
108
122
|
|
|
123
|
+
### Check the token first
|
|
124
|
+
|
|
125
|
+
A request with no good game token is a stranger's. Check the token before any work, and answer 401 at once without
|
|
126
|
+
one: the work your server does for callers with no token (reading the database, waking a room, calling out) counts
|
|
127
|
+
toward what caps your game, and a capped game's server is switched off until the month ends ("Publishing it", below).
|
|
128
|
+
|
|
129
|
+
```js
|
|
130
|
+
export default {
|
|
131
|
+
async fetch(request, env) {
|
|
132
|
+
// what is truly public (a leaderboard everyone sees) may come before this; nothing else does
|
|
133
|
+
const token = /^Bearer\s+(\S+)$/i.exec(request.headers.get('authorization') ?? '')?.[1];
|
|
134
|
+
if (!token) return Response.json({ why: 'Sign in first.' }, { status: 401 }); // no call, no work: cheap
|
|
135
|
+
const player = await env.GF.verifyPlayer(token); // its signature, its audience (this game) and its expiry
|
|
136
|
+
if (!player.ok) return Response.json({ why: 'Sign in again.' }, { status: 401 });
|
|
137
|
+
// from here on, player.sub is who is asking
|
|
138
|
+
return Response.json(await env.GF.saves.get(player.sub));
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
- `env.GF.verifyPlayer` checks the token's signature, that it was made for this game (its audience, `game:<slug>`)
|
|
144
|
+
and that it has not expired. A backend of your own that checks tokens with a JWT library checks all three:
|
|
145
|
+
`jwtVerify(token, keys, { issuer: 'xgames.fun', audience: 'game:<slug>' })`, never the signature alone (every
|
|
146
|
+
game's tokens are signed with one key, so a token without its audience checked is any game's).
|
|
147
|
+
- The page sends it for you: `GamesFun.api(path)` adds `Authorization: Bearer …` for a signed-in player. A WebSocket
|
|
148
|
+
cannot carry that header: send `GamesFun.token` in the address (below).
|
|
149
|
+
- `server/gf-db.js`'s `withPlayer(request, env)` is the same check (null without a token, with no call made).
|
|
150
|
+
- `xcore review` names a server of the game's own whose files do work and never check a token, and an entry whose
|
|
151
|
+
`fetch` does work before its first check (fine for what is truly public, and yours to decide).
|
|
152
|
+
|
|
109
153
|
### A room over a WebSocket
|
|
110
154
|
|
|
111
155
|
A Durable Object holds the room: every player's socket is accepted there, and a message from one goes to the others.
|
|
@@ -129,8 +173,14 @@ export class Room extends DurableObject {
|
|
|
129
173
|
export default {
|
|
130
174
|
async fetch(request, env) {
|
|
131
175
|
const url = new URL(request.url);
|
|
132
|
-
if (url.pathname
|
|
133
|
-
|
|
176
|
+
if (url.pathname !== '/api/room') return new Response('not found', { status: 404 });
|
|
177
|
+
// the player first, before the room is woken: a WebSocket cannot carry an Authorization header, so the page sends
|
|
178
|
+
// its token in the address
|
|
179
|
+
const token = url.searchParams.get('token');
|
|
180
|
+
if (!token) return new Response('sign in first', { status: 401 });
|
|
181
|
+
const player = await env.GF.verifyPlayer(token);
|
|
182
|
+
if (!player.ok) return new Response('sign in again', { status: 401 });
|
|
183
|
+
return env.ROOM.get(env.ROOM.idFromName('main')).fetch(request);
|
|
134
184
|
},
|
|
135
185
|
};
|
|
136
186
|
```
|
|
@@ -139,6 +189,7 @@ export default {
|
|
|
139
189
|
// the page
|
|
140
190
|
const url = new URL('api/room', document.baseURI);
|
|
141
191
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
192
|
+
url.searchParams.set('token', GamesFun.token);
|
|
142
193
|
const socket = new WebSocket(url);
|
|
143
194
|
```
|
|
144
195
|
|
|
@@ -146,21 +197,28 @@ const socket = new WebSocket(url);
|
|
|
146
197
|
server, so it notes the socket instead of failing on it.
|
|
147
198
|
|
|
148
199
|
**When you publish an update**, the page's files and the server change within about 30 seconds, not all at once, and
|
|
149
|
-
a room that
|
|
150
|
-
|
|
200
|
+
a room that is running keeps the previous code for up to about five minutes. Make your messages tolerate one version
|
|
201
|
+
talking to the other (a version number in the first message is enough to tell a player to reload).
|
|
151
202
|
|
|
152
203
|
### `env.GF`, the platform
|
|
153
204
|
|
|
154
205
|
Every call answers `{ok: true, …}` or `{ok: false, code, why}` (`why` is words a game may show); none throws.
|
|
155
206
|
|
|
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
|
|
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
|
|
207
|
+
| Call | Answer |
|
|
208
|
+
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
209
|
+
| `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 |
|
|
210
|
+
| `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 |
|
|
211
|
+
| `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 plan's boards free |
|
|
212
|
+
| `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") |
|
|
213
|
+
| `GF.info()` | `{ok, contract, gameId, slug}` |
|
|
214
|
+
| `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 |
|
|
215
|
+
|
|
216
|
+
**Who the player is.** `sub` is the player's account, never their wallet: `x:<X id>` for a player signed in with X,
|
|
217
|
+
or `g:<id>` for a Google account with no X, an id that is **different in every game** (so it never says who the
|
|
218
|
+
person is elsewhere). `wallet` is a claim of its own: the wallet linked to the account, or null when there is none, and
|
|
219
|
+
it can change (a player links another). Key a player's data (saves, rows, rooms) on `sub`; read `wallet` only for what
|
|
220
|
+
is about the wallet itself (paying it, what it holds). A game that kept a player's data by wallet reads `wallet` now,
|
|
221
|
+
and one that keys on `sub` must not expect a Google player's id to match the same person's in another game.
|
|
164
222
|
|
|
165
223
|
`player` is the game's own word for a player; normally `verifyPlayer`'s `sub`, so nobody can write another player's.
|
|
166
224
|
The page writes the same saves and boards for its own signed-in player (`/platform/v1/*`), so a board is the player's
|
|
@@ -171,7 +229,8 @@ server's own `submit`) makes a board only the server writes. From then on the pa
|
|
|
171
229
|
the page wrote while anyone could write it is removed (`setup` answers how many, `removed`). **A pattern** names every
|
|
172
230
|
board starting that way, now and to come: `GF.scores.setup('daily-*', {writer: 'server'})` before the first day, and
|
|
173
231
|
nobody fills a day's board ahead of time; `GF.scores.setup('*', {writer: 'server'})` keeps the page from making
|
|
174
|
-
boards at all (a board's own rule wins over a pattern, a longer pattern over a shorter one). A game has
|
|
232
|
+
boards at all (a board's own rule wins over a pattern, a longer pattern over a shorter one). A game has 8 boards on
|
|
233
|
+
Free (16 rules) and 16 on Creator and Scale (32 rules):
|
|
175
234
|
**`GF.scores.drop(board)`** removes one (a past day's, once its prizes are paid) and frees its place. Only the server
|
|
176
235
|
can set or lift a rule (`writer: 'any'`) or drop a board.
|
|
177
236
|
|
|
@@ -258,8 +317,9 @@ xcore secret set XSOLANA_RPC_URL # the RPC's URL with its key: the serve
|
|
|
258
317
|
`getProgramAccounts` only with filters and only of loot's own programs (loot, Metaplex Core, lookup tables); and
|
|
259
318
|
`sendTransaction` only for the game's own transactions (one with a loot instruction, or a plain SOL send). So a
|
|
260
319
|
stranger calling `api/rpc` cannot spend your RPC's credits on anything else. Mainnet URLs are not used (loot runs on devnet until it is audited).
|
|
261
|
-
- The server may call only `backend.json`'s origins: `xcore add rpc` adds the RPC's
|
|
262
|
-
key) when `XSOLANA_RPC_URL` is in `.dev.vars` or `.env`, and publish says when it
|
|
320
|
+
- The server's entry, where `api/rpc` runs, may call only `backend.json`'s origins: `xcore add rpc` adds the RPC's
|
|
321
|
+
(`https://host` alone, never the key) when `XSOLANA_RPC_URL` is in `.dev.vars` or `.env`, and publish says when it
|
|
322
|
+
is missing.
|
|
263
323
|
- Solana's public `https://api.devnet.solana.com` refuses calls from Cloudflare ("Your IP or provider is blocked",
|
|
264
324
|
measured 2026-11-03): use a provider's URL.
|
|
265
325
|
- Under `xcore dev`, put `XSOLANA_RPC_URL` in `.dev.vars` to run this path; in `.env` it is the dev server's own
|
|
@@ -284,6 +344,17 @@ A result is worth paying only as far as the server can tell it happened. Stronge
|
|
|
284
344
|
milliseconds must not add up). A script that plays at human speed still gets through: this limits what a cheater
|
|
285
345
|
earns, it does not stop one.
|
|
286
346
|
|
|
347
|
+
Say which one in the grant: `"trust": "decides"`, `"replays"` or `"bounds"` on every grant a voucher pays (what the
|
|
348
|
+
server holding its key checks before it signs, as `PAY.check` really does it; it needs the `@xgamesfun/solana` that has
|
|
349
|
+
`xcore loot review`, since 0.25.0 refuses the field). `xcore review` then reads the economy and the server as a cheater
|
|
350
|
+
would: what a script gets from each paid route without playing, a `PAY.check` that pays from the request alone, a
|
|
351
|
+
`payOnly` that lets one person claim from every wallet they make, and, with `--live`, each `api/` route asked with no
|
|
352
|
+
sign-in and with a forged one. Its `review.md` ends with the trade-offs to tell the person. The full guide, with
|
|
353
|
+
Solana's own exploit checklist, is `@xgamesfun/solana`'s skill reference
|
|
354
|
+
`skills/xsolana-economy/references/trust-and-cheating.md`; in short: a bounded result pays a script that waits and
|
|
355
|
+
reports, a replayed one pays the best run a script can find (unless randomness is revealed as the run goes), a decided
|
|
356
|
+
one pays what a bot that plays well wins.
|
|
357
|
+
|
|
287
358
|
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
359
|
plays well wins. Pay a bounded or replayed result only what you would accept losing to a patient script (small
|
|
289
360
|
amounts, capped a day), and keep entry fees and prize pools for results the server decided and chance (the oracle).
|
|
@@ -293,8 +364,9 @@ Tell players which one your game is.
|
|
|
293
364
|
cap that counts players or wallets multiplies with each one a person makes. What helps:
|
|
294
365
|
|
|
295
366
|
- **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
|
-
|
|
367
|
+
in `server/vouchers.js`), or with X or Google (`'account'`: a Google account is easier to make than an X one, so it
|
|
368
|
+
stops less farming), count your caps per account, and give the grant `perPerson` (the voucher carries a hash of the
|
|
369
|
+
player's X account, or `g:<Google id>` for a Google account with no X).
|
|
298
370
|
- **Totals.** Give every grant a `perDay` total in `xsolana.json`, so the most it pays a day is known whoever claims.
|
|
299
371
|
- **Holding something.** Gate a reward on an NFT the game sells (a pass, a creature): each identity then costs money.
|
|
300
372
|
- **Proven wallets.** Pay, and grant perks to, only a wallet the player proved is theirs: the one they signed in
|
|
@@ -378,7 +450,8 @@ voucher code into `server/gf-loot/` (bundled with the server, never a public fil
|
|
|
378
450
|
the result's own id, any text, used once; amount: base units, or `'grant'` for the grant's own amount, always
|
|
379
451
|
named so a forgotten amount never pays the most) or `{why}` to refuse. Until you write it, nothing is paid. Count a
|
|
380
452
|
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
|
-
|
|
453
|
+
Then give the grant its `trust` (what that check does: `decides`, `replays` or `bounds`) and run `xcore review`.
|
|
454
|
+
4. Who is paid: set `VOUCHERS.payOnly` to `'x'` or `'account'` for rewards worth farming (above, "Who gets paid"); the grant's
|
|
382
455
|
`perDay` total caps the rest.
|
|
383
456
|
5. `xcore loot approve` (the grant's change goes live), then `xcore publish`. `api/vouchers/key` answers the live
|
|
384
457
|
key's address once the secret is set; until then the server pays nothing.
|
|
@@ -608,3 +681,18 @@ my-vault: 278 files, 10.0 MB, nothing refused. Left out: .gamesfun/, .gitignore,
|
|
|
608
681
|
|
|
609
682
|
Hosted, the game's server answers at `https://<slug>-play.xgames.fun/api/…`. A blocked or over-limit server answers
|
|
610
683
|
`{error, message}` with 451, 429, 404, 503 or 502 instead of the server's own answer.
|
|
684
|
+
|
|
685
|
+
**A game that is unpublished, taken down or capped** (past its plan's money for the month: README "Its plan") has its
|
|
686
|
+
server switched off: every `api/*` request answers `451 {"error": "stopped", "reason": "unpublished" | "taken-down" |
|
|
687
|
+
"capped", "message"}`, its Durable Objects' alarms lapse and their sockets close. A room that is running stops within
|
|
688
|
+
about five minutes. Its rooms' data and its secrets are kept, and the server comes back by itself when the game is
|
|
689
|
+
published again (within 30 days, to be sure of its data) or its cap lifts (the month's turn, a paid slot, usage
|
|
690
|
+
credit); a version published while it is capped goes live with its pages, and its server waits. The page keeps
|
|
691
|
+
loading meanwhile, so a game whose server can be off says so when `api/` answers 451.
|
|
692
|
+
|
|
693
|
+
While the game's owner has it **paused for maintenance** (xgames.fun/console, the Live tab; README "Live"), xgames.fun
|
|
694
|
+
answers its `api/*` itself with `503 {ok: false, code: 'maintenance', why, since}` and `retry-after: 60`, WebSocket
|
|
695
|
+
upgrades included: the server's code does not run for a new request. What already runs keeps running (an open
|
|
696
|
+
WebSocket, a Durable Object's alarm, the `env.GF` calls they make); a server that should stop those reads the game's
|
|
697
|
+
`GET /platform/v1/live` (`maintenance` is `{message, since}` while paused). `xcore dev` does the same from
|
|
698
|
+
`.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
|
],
|
package/lib/check.mjs
CHANGED
|
@@ -556,6 +556,7 @@ export async function pageVerify(
|
|
|
556
556
|
const { preflight } = await import('./publish/index.mjs');
|
|
557
557
|
const { scanPage } = await import('./publish/adopt.mjs');
|
|
558
558
|
const pre = await preflight(game);
|
|
559
|
+
if (pre.linkName) notes.push(pre.linkName);
|
|
559
560
|
const scan = existsSync(roots.out)
|
|
560
561
|
? await scanPage(roots.out, {
|
|
561
562
|
backend: pre.backend,
|
package/lib/cli.mjs
CHANGED
|
@@ -28,6 +28,8 @@ export async function guard(fn, args = []) {
|
|
|
28
28
|
try {
|
|
29
29
|
return (await fn()) ?? 0;
|
|
30
30
|
} catch (e) {
|
|
31
|
+
// xgames.fun refused a game this computer's sign-in was not given: said as that, with what to do
|
|
32
|
+
if (e?.code === 'not-granted') await (await import('./login-reach.mjs')).explainNotGranted(e).catch(() => {});
|
|
31
33
|
const usage = e instanceof UsageError;
|
|
32
34
|
console.error(usage ? e.message : `xcore: ${e.message}`);
|
|
33
35
|
if (!usage && process.env.GAMESFUN_DEBUG) console.error(e.stack);
|