@xgamesfun/xcore 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +183 -84
- package/bin/xcore.mjs +13 -8
- package/commands/add.mjs +11 -8
- package/commands/adopt.mjs +9 -7
- package/commands/backend.mjs +2 -2
- package/commands/check.mjs +4 -4
- package/commands/db.mjs +218 -0
- package/commands/dev.mjs +8 -6
- package/commands/domain.mjs +8 -8
- package/commands/embed.mjs +3 -3
- package/commands/files.mjs +106 -0
- package/commands/login.mjs +9 -17
- package/commands/logout.mjs +15 -4
- package/commands/loot.mjs +2 -2
- package/commands/plan.mjs +22 -19
- package/commands/publish.mjs +54 -21
- package/commands/secret.mjs +6 -6
- package/docs/SERVER.md +218 -18
- package/lib/add.mjs +265 -40
- package/lib/browser.mjs +11 -2
- package/lib/check.mjs +10 -9
- package/lib/cli.mjs +24 -9
- package/lib/client/gamesfun.js +242 -122
- package/lib/client/godot/gamesfun.gd +17 -11
- package/lib/client/godot/gamesfun_peer.gd +2 -2
- package/lib/client/server/0001_init.sql +10 -0
- package/lib/client/server/database.js +271 -109
- package/lib/client/server/gf-db.js +55 -0
- package/lib/client/server/gf-docs.js +56 -0
- package/lib/client/server/rooms.js +16 -9
- package/lib/client/server/rpc.js +7 -7
- package/lib/client/server/stock.js +37 -0
- package/lib/client/server/vouchers.js +127 -46
- package/lib/client/unity/GamesFun.cs +15 -12
- package/lib/client/unity/GamesFun.jslib +1 -1
- package/lib/config-dir.mjs +1 -1
- package/lib/credentials.mjs +10 -5
- package/lib/db.mjs +355 -0
- package/lib/dev/d1.mjs +486 -0
- package/lib/dev/files.mjs +247 -0
- package/lib/dev/gf-local.js +69 -3
- package/lib/dev/index.mjs +68 -20
- package/lib/dev/limits.mjs +2 -2
- package/lib/dev/platform.mjs +111 -12
- package/lib/dev/server.mjs +54 -5
- package/lib/dev/tools.mjs +71 -18
- package/lib/files.mjs +208 -0
- package/lib/open.mjs +1 -1
- package/lib/play-policy.mjs +14 -5
- package/lib/publish/adopt.mjs +73 -33
- package/lib/publish/client.mjs +130 -20
- package/lib/publish/gate.mjs +4 -2
- package/lib/publish/hosting.mjs +5 -5
- package/lib/publish/index.mjs +162 -40
- package/lib/publish/oidc.mjs +26 -5
- package/lib/publish/page.mjs +1 -1
- package/lib/publish/plans.mjs +19 -12
- package/lib/publish/scan.mjs +78 -102
- package/lib/publish/secrets.mjs +4 -5
- package/lib/publish/site.mjs +9 -10
- package/lib/publish/tree.mjs +8 -8
- package/lib/secret-patterns.mjs +53 -16
- package/lib/secret-store.mjs +2 -2
- package/lib/server/bundle.mjs +113 -13
- package/lib/server/contract.mjs +440 -16
- package/lib/server/data-calls.mjs +136 -0
- package/lib/server/secrets.mjs +9 -7
- package/lib/solana-rpc.mjs +5 -5
- package/lib/state-folder.mjs +23 -0
- package/package.json +4 -4
- package/skills/bring-your-own-game/SKILL.md +10 -9
package/docs/SERVER.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Most games need no server: the page is the game. When a game needs one party everybody trusts (a leaderboard nobody
|
|
4
4
|
can write to from the console, a result that pays a prize, a room the host cannot cheat in), it can carry **its own
|
|
5
|
-
server code** in `server/`. `xcore publish` bundles it and puts it on
|
|
5
|
+
server code** in `server/`. `xcore publish` bundles it and puts it on xgames.fun beside the game's files, at the game's
|
|
6
6
|
own `/api/*`. You set up no account elsewhere, no keys and no deploy.
|
|
7
7
|
|
|
8
8
|
The contract is `gf-server-v1`. Every name and number in it lives in one module,
|
|
@@ -61,10 +61,10 @@ export default {
|
|
|
61
61
|
removed on the way in.
|
|
62
62
|
- **`env`**: `env.GF` (the platform, below), one Durable Object namespace per `objects` entry, the plain strings
|
|
63
63
|
`env.GF_GAME_ID`, `env.GF_SLUG` and `env.GF_CONTRACT` (`"gf-server-v1"`), and your own secrets as `env.<NAME>`
|
|
64
|
-
(below, "Secrets"). No key of
|
|
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`** reaches only the origins in your `backend.json` and
|
|
67
|
-
from the server, call
|
|
66
|
+
- **Outbound `fetch`** reaches only the origins in your `backend.json` and xgames.fun's own. To read Solana devnet
|
|
67
|
+
from the server, call xgames.fun's relay, `https://play.xgames.fun/loot/v1/rpc` (the methods loot's client uses, one
|
|
68
68
|
request a call): the public devnet RPC (`api.devnet.solana.com`) answers the Workers runtime with 403 ("Your IP or
|
|
69
69
|
provider is blocked"), under `xcore dev` as well.
|
|
70
70
|
- **Limits**: set by the game's plan (README §5, `xcore plan`). Free: 50 ms of CPU per request to `fetch`, 20
|
|
@@ -158,8 +158,9 @@ Every call answers `{ok: true, …}` or `{ok: false, code, why}` (`why` is words
|
|
|
158
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
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
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:
|
|
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
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 |
|
|
163
164
|
|
|
164
165
|
`player` is the game's own word for a player; normally `verifyPlayer`'s `sub`, so nobody can write another player's.
|
|
165
166
|
The page writes the same saves and boards for its own signed-in player (`/platform/v1/*`), so a board is the player's
|
|
@@ -174,6 +175,50 @@ boards at all (a board's own rule wins over a pattern, a longer pattern over a s
|
|
|
174
175
|
**`GF.scores.drop(board)`** removes one (a past day's, once its prizes are paid) and frees its place. Only the server
|
|
175
176
|
can set or lift a rule (`writer: 'any'`) or drop a board.
|
|
176
177
|
|
|
178
|
+
### Project files: `env.GF.files`
|
|
179
|
+
|
|
180
|
+
Files that you or the game's server write, never players: replays, generated levels, a large saved world, content
|
|
181
|
+
packs. xgames.fun keeps them for the game (Free 100 MB, Creator 10 GB, Scale 100 GB; 25 MB a file), and serves the
|
|
182
|
+
public ones from a site of their own, `https://<slug>.xgamesusercontent.com/<path>`.
|
|
183
|
+
|
|
184
|
+
```js
|
|
185
|
+
// the server keeps a replay, public: the page loads it from its address
|
|
186
|
+
const saved = await env.GF.files.put(`replays/${runId}.json`, JSON.stringify(replay), {
|
|
187
|
+
type: 'application/json',
|
|
188
|
+
public: true,
|
|
189
|
+
});
|
|
190
|
+
if (!saved.ok) return Response.json({ why: saved.why }, { status: saved.code === 'full' ? 507 : 400 });
|
|
191
|
+
return Response.json({ replay: saved.url });
|
|
192
|
+
|
|
193
|
+
// a file only the server reads
|
|
194
|
+
const world = await env.GF.files.get('worlds/main.bin');
|
|
195
|
+
if (world.ok) state = new Uint8Array(await new Response(world.body).arrayBuffer());
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
| Call | Answer |
|
|
199
|
+
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
200
|
+
| `GF.files.put(path, body, {type, public = false, length})` | `{ok, path, bytes, url?}`: `body` a string, an ArrayBuffer or a stream of bytes; a stream is held to exactly its `length` (more or fewer bytes: `code: "body"`, nothing stored), and one with no `length` is read up to 1 MB (longer: `code: "body"`); `url` when public; over the game's cap, `code: "full"` |
|
|
201
|
+
| `GF.files.get(path)` | `{ok, body, type, bytes, public}`: `body` is a stream of the bytes (`await new Response(body).text()`) |
|
|
202
|
+
| `GF.files.head(path)` | the same, without `body` |
|
|
203
|
+
| `GF.files.list({prefix, after, limit = 100})` | `{ok, items: [{path, bytes, type, public, updatedAt}], after}`: a prefix is empty or a folder ending in `/`; pass `after` on |
|
|
204
|
+
| `GF.files.delete(path)`, `GF.files.usage()` | `{ok, deleted}`, `{ok, bytes, files, cap}` |
|
|
205
|
+
|
|
206
|
+
- **Paths** are letters, digits, `.`, `_` and `-`, with `/` between folders, at most 512 characters, and no `.` or
|
|
207
|
+
`..` part: `levels/forest-3.json`.
|
|
208
|
+
- **The files site is a stranger to the game.** A file there cannot use the player's sign-in or call the game's
|
|
209
|
+
`api/`. Every answer carries `X-Content-Type-Options: nosniff` and `Content-Security-Policy: sandbox; default-src
|
|
210
|
+
'none'`. Pictures (PNG, JPEG, WebP, GIF, AVIF), sounds, videos, fonts, JSON and plain text are served as
|
|
211
|
+
themselves; anything else (HTML, SVG, JavaScript, CSS, XML, PDF) is a download, `application/octet-stream`. The
|
|
212
|
+
game's page may load pictures, sounds, fonts and `fetch` from it, never scripts or styles.
|
|
213
|
+
- **From your machine:** `xcore files put <file> [path] [--public]`, `ls`, `get`, `rm`, `usage`; with `--live`, the
|
|
214
|
+
game's files on xgames.fun.
|
|
215
|
+
- **Locally**, `xcore dev` keeps them in `.gamesfun/dev/files/` (never published) and serves the public ones on a port
|
|
216
|
+
of their own (`project files: … http://127.0.0.1:8788`), another origin to the page, as live. (Here it is the same
|
|
217
|
+
site, both being `127.0.0.1`; live, `xgamesusercontent.com` is another site too, so cookies and the like keep apart
|
|
218
|
+
there in a way they may not on this machine.) `xcore files` without `--live` reads and writes the same ones.
|
|
219
|
+
- Publish refuses a server that uses `env.GF.files` while xgames.fun does not keep project files yet (its
|
|
220
|
+
`GET /api/kit/me` lists `"files"` once it does).
|
|
221
|
+
|
|
177
222
|
### Secrets
|
|
178
223
|
|
|
179
224
|
A key the server needs and no player may see (a voucher-signing key, an outside API's key, an RPC URL with its key)
|
|
@@ -185,7 +230,7 @@ xcore secret list # the names; val
|
|
|
185
230
|
xcore secret delete VOUCHER_SEED
|
|
186
231
|
```
|
|
187
232
|
|
|
188
|
-
-
|
|
233
|
+
- xgames.fun keeps the value only on the server's host (Cloudflare), never stores or shows it, and never gives it to
|
|
189
234
|
the page. A new publish keeps the secrets.
|
|
190
235
|
- Set it once the game is published with its `server/`; it reaches the server's next request, with no publish.
|
|
191
236
|
- Names: A–Z, 0–9 and `_`, starting with a letter, not `GF` or `GF_*`, not a room's binding. Values up to 5,120
|
|
@@ -195,7 +240,7 @@ xcore secret delete VOUCHER_SEED
|
|
|
195
240
|
|
|
196
241
|
### The economy's own RPC
|
|
197
242
|
|
|
198
|
-
A game with an economy (`xcore loot add`) sends its Solana calls through
|
|
243
|
+
A game with an economy (`xcore loot add`) sends its Solana calls through xgames.fun's shared devnet relay by default
|
|
199
244
|
(the play host's `/loot/v1/rpc`), and every call counts toward the game's plan: $10 a million, the biggest cost a
|
|
200
245
|
player of an economy game has (a page reading balances every 5 s spends Free's $1 in ~140 player-hours). Bring the
|
|
201
246
|
game's own devnet RPC (Helius, Triton, QuickNode; their free plans fit a small game) instead:
|
|
@@ -266,21 +311,21 @@ about 3 ms of CPU for each second played, the largest line of its bill. Replay e
|
|
|
266
311
|
voucher, a ranked board) and a random sample of the rest (one in ten, say), and bound the others as above. A cheater
|
|
267
312
|
cannot tell which runs are replayed, and a run that pays is always one of them.
|
|
268
313
|
|
|
269
|
-
### Paying a result:
|
|
314
|
+
### Paying a result: xgames.fun's keeper, or a voucher key of your own
|
|
270
315
|
|
|
271
316
|
A reward for a result (a score, a win, a podium) is a grant in the game's economy, claimed with a voucher: a
|
|
272
317
|
signature that says this wallet earned it. The page can never sign one (anyone can change a page in the console), so
|
|
273
318
|
a voucher comes from one of two signers. Both start from a result your server stands behind (above); they differ in
|
|
274
319
|
who holds the key.
|
|
275
320
|
|
|
276
|
-
| | **
|
|
321
|
+
| | **xgames.fun's keeper** (`env.GF.loot.report`) | **your server's own key** (`xcore add vouchers`) |
|
|
277
322
|
| ------------------ | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
|
278
|
-
| Who signs |
|
|
323
|
+
| Who signs | xgames.fun, with the game's host key (made for the game, never shown to anyone) | your server, with `VOUCHER_SEED` (a secret only it reads) |
|
|
279
324
|
| What a result pays | a place: up to 16 winners a result, each paid its grant's own amount | any amount up to the grant's (by score, by rank), worked out by your server |
|
|
280
325
|
| How often | 120 reports an hour a game | your plan's request rates |
|
|
281
326
|
| To set up | the owner certifies the host key in Studio once; the grant names the keeper's key | `xcore add vouchers`, `xcore secret set VOUCHER_SEED --new-key`, its address in the grant |
|
|
282
327
|
| Under `xcore dev` | printed, not signed | signed with the key in `.dev.vars`, against devnet |
|
|
283
|
-
| Follows loot | when
|
|
328
|
+
| Follows loot | when xgames.fun updates the keeper | when you update xcore (publish keeps `server/gf-loot/` at its loot) |
|
|
284
329
|
|
|
285
330
|
**Use the keeper** when the server already decides who won and the prize is fixed by place: a room's winner, the top
|
|
286
331
|
three of a match, a hidden thing found first. No key to make or keep.
|
|
@@ -321,7 +366,7 @@ xcore add vouchers <folder> # needs the economy (xcore loot add) with a gra
|
|
|
321
366
|
It writes `server/vouchers.js` (routed at `api/vouchers/`, with its Durable Object `GameVouchers`), copies loot's
|
|
322
367
|
voucher code into `server/gf-loot/` (bundled with the server, never a public file), makes a **test key** for
|
|
323
368
|
`xcore dev` with its seed as `VOUCHER_SEED` in `.dev.vars` (and, in a folder kept in git, `.dev.vars` into
|
|
324
|
-
`.gitignore`), and says its address. The **live key** is made
|
|
369
|
+
`.gitignore`), and says its address. The **live key** is made here and sent to xgames.fun only, never kept on your machine:
|
|
325
370
|
|
|
326
371
|
1. `xcore publish`, then `xcore secret set VOUCHER_SEED --new-key`: a new key made and sent to the server only; it
|
|
327
372
|
prints the key's address.
|
|
@@ -332,7 +377,7 @@ voucher code into `server/gf-loot/` (bundled with the server, never a public fil
|
|
|
332
377
|
page's JSON, the signed-in player and `env`, and answers `{grant, round, amount}` (grant: its key or id; round:
|
|
333
378
|
the result's own id, any text, used once; amount: base units, or `'grant'` for the grant's own amount, always
|
|
334
379
|
named so a forgotten amount never pays the most) or `{why}` to refuse. Until you write it, nothing is paid. Count a
|
|
335
|
-
player's daily total in `PAY.paid` (it runs once a voucher is signed), not in `check`.
|
|
380
|
+
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`.
|
|
336
381
|
4. Who is paid: set `VOUCHERS.payOnly` to `'x'` for rewards worth farming (above, "Who gets paid"); the grant's
|
|
337
382
|
`perDay` total caps the rest.
|
|
338
383
|
5. `xcore loot approve` (the grant's change goes live), then `xcore publish`. `api/vouchers/key` answers the live
|
|
@@ -360,14 +405,14 @@ const { voucher } = await voucherFor(env, { grant: 'clear', wallet, round: match
|
|
|
360
405
|
gets the same voucher (with no chain read, and a fresh one when it is about to run out), never one for another
|
|
361
406
|
wallet or amount, and never one once the grant has been closed and made again (a new campaign pays new rounds only).
|
|
362
407
|
- **Rate:** a player may make `VOUCHERS.perMinute` pay and link requests a minute (20).
|
|
363
|
-
- **Keys:** the live key lives only on
|
|
408
|
+
- **Keys:** the live key lives only on xgames.fun's server and is never shown again; a lost or leaked one: run
|
|
364
409
|
`xcore secret set VOUCHER_SEED --new-key` again, put the new address in the grant in place of the old, approve. A
|
|
365
410
|
leaked key can pay no more than the grant's limits, so keep them tight. The test key in `.dev.vars` signs only
|
|
366
411
|
what the grant lets it sign.
|
|
367
412
|
- `person`: a player signed in with X is counted as one person, hashed with your key (never the account itself), for
|
|
368
413
|
a grant with `perPerson`. A player signed in with a wallet only has no person, so a grant with `perPerson` refuses
|
|
369
414
|
to pay them (`person`): sign in with X.
|
|
370
|
-
- Each pay is one request to your server and one read of the grant, through
|
|
415
|
+
- Each pay is one request to your server and one read of the grant, through xgames.fun's relay or your own RPC
|
|
371
416
|
(`xcore add rpc`).
|
|
372
417
|
- A grant's limits count claims, not amounts: a daily cap of tokens is the grant's amount with one claim a day, or
|
|
373
418
|
your server's own count in `PAY.check`.
|
|
@@ -375,15 +420,170 @@ const { voucher } = await voucherFor(env, { grant: 'clear', wallet, round: match
|
|
|
375
420
|
export `GameVouchers`, as `xcore add vouchers` says. `signVoucher` from `@xgamesfun/solana/results` is the same
|
|
376
421
|
signature, for a server that signs by itself.
|
|
377
422
|
|
|
423
|
+
## A database of its own
|
|
424
|
+
|
|
425
|
+
`xcore add database` gives the game **one D1 database** (SQLite, run by Cloudflare), made on xgames.fun by the
|
|
426
|
+
first publish that has one and kept between publishes. Its server reads it as `env.DB`. It holds all of the game's
|
|
427
|
+
own data: its records (guilds, match history, shop stock), a player's state with rules (runs left today, gold), and,
|
|
428
|
+
for a game with no server, the documents its page saves.
|
|
429
|
+
|
|
430
|
+
```json
|
|
431
|
+
{
|
|
432
|
+
"database": {
|
|
433
|
+
"engine": "d1",
|
|
434
|
+
"binding": "DB",
|
|
435
|
+
"migrations": "server/migrations",
|
|
436
|
+
"collections": { "*": { "read": "owner", "write": "owner" }, "profiles": { "read": "anyone", "write": "owner" } }
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
| Field | Rule |
|
|
442
|
+
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
443
|
+
| `engine` | `"d1"`, the only one |
|
|
444
|
+
| `binding` | default `"DB"`: its name on `env`. The rules of a Durable Object's binding (A–Z, 0–9 and `_`, not `GF` or `GF_…`), and not one of theirs |
|
|
445
|
+
| `migrations` | default `"server/migrations"`, a folder inside `server/` (never a public file). Files named `0001_init.sql`, `0002_add_guilds.sql` …: four digits, `_`, lowercase words; at most 200, 1 MB each, no statement over D1's 100,000 bytes |
|
|
446
|
+
| `collections` | who may read and write each collection of the page's documents (`gf.db`, at `api/db/`): read by the stock server, or by `server/gf-docs.js` in a server of its own (below). `read`: `"owner"` or `"anyone"`; `write`: `"owner"` or `"server"`. Without it, each player's own, as `"*"` |
|
|
447
|
+
|
|
448
|
+
**Migrations are append only.** Publish applies the new ones, in name order, before the new version goes live; a
|
|
449
|
+
migration that fails stops the publish (the version before keeps playing; the ones applied before it stay applied).
|
|
450
|
+
Once a migration is published it never changes: put the next change in a new file
|
|
451
|
+
(`0002_add_gold.sql`: `ALTER TABLE players ADD COLUMN gold INTEGER NOT NULL DEFAULT 0;`). A changed or missing one is
|
|
452
|
+
refused, by `xcore publish` before anything goes up and by xgames.fun, and so is an empty one (0 bytes). `xcore add
|
|
453
|
+
database` writes `server/migrations/.gitattributes` (`*.sql -text`), so git on Windows never changes a migration's
|
|
454
|
+
line endings (and so its sha256). Each database records what it has applied in its own table, `_gf_migrations`, in
|
|
455
|
+
the same batch as the migration: a restore to before a migration un-applies it, and the next publish applies it
|
|
456
|
+
again.
|
|
457
|
+
|
|
458
|
+
### From a server of its own
|
|
459
|
+
|
|
460
|
+
`xcore add database` in a game with a `server/` writes the block, an example first migration
|
|
461
|
+
(`server/migrations/0001_init.sql`, change it before you publish) and `server/gf-db.js`:
|
|
462
|
+
|
|
463
|
+
```js
|
|
464
|
+
import { db, withPlayer } from './gf-db.js';
|
|
465
|
+
|
|
466
|
+
export default {
|
|
467
|
+
async fetch(request, env) {
|
|
468
|
+
const sub = await withPlayer(request, env); // env.GF.verifyPlayer's sub, or null for a guest
|
|
469
|
+
if (!sub) return Response.json({ why: 'Sign in first.' }, { status: 401 });
|
|
470
|
+
await db(env).run('INSERT OR IGNORE INTO players (id) VALUES (?)', sub);
|
|
471
|
+
// a rule is one statement, so a burst of requests cannot spend the same run twice
|
|
472
|
+
const left = await db(env).first(
|
|
473
|
+
'UPDATE players SET runs = runs - 1 WHERE id = ? AND runs > 0 RETURNING runs',
|
|
474
|
+
sub,
|
|
475
|
+
);
|
|
476
|
+
if (!left) return Response.json({ why: 'No runs left today.' }, { status: 409 });
|
|
477
|
+
return Response.json({ runs: left.runs });
|
|
478
|
+
},
|
|
479
|
+
};
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
`db(env).all`, `.first`, `.run` and `.batch([[sql, ...params], …])` (all of them or none) are prepared statements:
|
|
483
|
+
pass every value as a `?` and an argument, never inside the SQL's text. `env.DB` is D1 itself, for anything else.
|
|
484
|
+
|
|
485
|
+
**The page's documents in a server of its own.** When the block has `collections`, `xcore add database` (and
|
|
486
|
+
`add multiplayer`, `rpc` and `vouchers`) also writes `server/gf-docs.js`, which answers `api/db/*` as the stock server
|
|
487
|
+
does, from the same table (`gf_docs`), under `collections` (it reads them from `gamesfun.json` when the server is
|
|
488
|
+
bundled). xcore's own `server/index.js` routes it first; in an index of your own, first thing in `fetch`:
|
|
489
|
+
|
|
490
|
+
```js
|
|
491
|
+
import { handleDocs } from './gf-docs.js';
|
|
492
|
+
const db = await handleDocs(request, env);
|
|
493
|
+
if (db) return db;
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
`docsOf(env)` is the server's own access to them, past the rules (`docsOf(env).put('news', 'today', {…})`). A game the
|
|
497
|
+
stock server served keeps its documents and rules when it gets a server of its own: `xcore add multiplayer` writes
|
|
498
|
+
the rules it had into `collections`, and `server/gf-docs.js` serves them. Publish refuses a game whose block has
|
|
499
|
+
`collections` while its server does not route `api/db/` (every `gf.db` call would answer 404), and says how.
|
|
500
|
+
|
|
501
|
+
That check reads the text of the files in `server/` only (not `server/gf-docs.js` or `server/database.js`): a call to
|
|
502
|
+
`handleDocs(` (or `handleDatabase(`) there, or `api/db` in the code. A router kept outside `server/` (a `src/router.js`
|
|
503
|
+
the server imports) is not read, so a game whose `api/db/` is routed only there is refused all the same. Keep xcore's
|
|
504
|
+
route in `server/`: the three lines above, first thing in `fetch` of `server/index.js` (or of another file in
|
|
505
|
+
`server/`), before the request reaches the router.
|
|
506
|
+
|
|
507
|
+
### With no server: the stock server
|
|
508
|
+
|
|
509
|
+
A game with the block and no `server/` gets **xcore's stock server** as its server: publish bundles it
|
|
510
|
+
([`lib/client/server/stock.js`](../lib/client/server/stock.js), with `collections` built in) and it serves the page's
|
|
511
|
+
`api/db/*` from the database, with the same routes and answers as the older `server/database.js`, so
|
|
512
|
+
`GamesFun.db` in the page is the same. Its documents are in a table it makes itself, `gf_docs`.
|
|
513
|
+
|
|
514
|
+
- **Each player has a space of their own** in every collection: a document is `{id, owner, value, updatedAt}`,
|
|
515
|
+
found by collection, owner and id, so two players can both have a `main` and nobody takes another's id. By default
|
|
516
|
+
a player reads and writes only their own.
|
|
517
|
+
- `read: "anyone"`: everyone reads the collection; another player's document by its owner,
|
|
518
|
+
`GamesFun.db.get('profiles', 'main', { owner: playerId })`, a player's list with `{ owner: playerId }`. An id alone
|
|
519
|
+
is the player's own: a `get` by id alone that finds only another player's document answers 400 `owner` ("name
|
|
520
|
+
whose document it is with `{owner}`"), and `GamesFun.db.get` gives `data: null` with `code: 'owner'` and says it
|
|
521
|
+
once in the console. Before each player had a space of their own (gamesfun.js 1.3, xcore 0.1.0) an id named any
|
|
522
|
+
player's document, so a page that reads another's by id alone needs its `owner` now (each document has it).
|
|
523
|
+
- `write: "server"`: only a server writes it (owner `"server"`); the page reads it without naming an owner, from the
|
|
524
|
+
server's space and then from the player's own (what the server wrote for that player, with `{owner: sub}`), so a
|
|
525
|
+
`read: "owner"` + `write: "server"` collection (an inbox, a verified record) is read by id alone. The stock server
|
|
526
|
+
has no code of yours, so such a collection needs a server of your own to fill it.
|
|
527
|
+
- A list comes newest first, a page at a time: `const page = await GamesFun.db.list('levels')`, then
|
|
528
|
+
`GamesFun.db.list('levels', { after: page.after })`; `after` is `null` on the last page. `offset` is gone: the
|
|
529
|
+
client and the server refuse it in words, so a loop that paged with it stops instead of reading page 1 again.
|
|
530
|
+
- The order is by when each document was **last written** (`updatedAt`), so a document written between two pages
|
|
531
|
+
moves to the front: a listing taken while players write can show it twice, or not at all. List what must be exact
|
|
532
|
+
in one page (`limit` up to 100), or keep it in a table of your own (a server with its own migrations) with an order
|
|
533
|
+
that does not change.
|
|
534
|
+
- Any `read` but `"anyone"` lists only the player's own (it fails closed), and `collections` (or `RULES`) is checked
|
|
535
|
+
when the server starts: a mistyped rule stops it, in words.
|
|
536
|
+
|
|
537
|
+
### On this machine, and on xgames.fun: `xcore db`
|
|
538
|
+
|
|
539
|
+
`xcore dev` runs a local D1 for the server, kept in `.gamesfun/dev/d1/` (hidden: never published), and applies the
|
|
540
|
+
same migrations when it starts and reloads. A migration changed after it was applied here keeps the server down with
|
|
541
|
+
the reason, as xgames.fun would refuse it; put it back, or delete `.gamesfun/dev/d1/` to start the local database
|
|
542
|
+
afresh.
|
|
543
|
+
|
|
544
|
+
```sh
|
|
545
|
+
xcore db info # here: its size, the migrations applied, its tables
|
|
546
|
+
xcore db query "SELECT * FROM players LIMIT 10" # one statement; reads only without --write
|
|
547
|
+
xcore db query "UPDATE players SET runs = 3 WHERE id = ?" --params '["dev:ab12"]' --write
|
|
548
|
+
xcore db export # SQL into .gamesfun/exports/ (or --out <file>)
|
|
549
|
+
xcore db import seed.sql # statements of your own, or an export; all or none
|
|
550
|
+
xcore db info --live # the same on xgames.fun, with --live
|
|
551
|
+
xcore db restore 2026-11-08T12:00:00Z --live # back in time, up to 30 days (D1 Time Travel)
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
`--live` goes through xgames.fun's owner routes with your sign-in (`xcore login`), and each call is kept in the
|
|
555
|
+
game's audit log. Without `--write`, a query is only `SELECT`, `EXPLAIN`, `PRAGMA table_info` or `table_list`, here
|
|
556
|
+
and live. `restore` says what it would go back to first, and goes on only with `--yes` or when you type `restore`.
|
|
557
|
+
It prints the command that undoes it, `xcore db restore <the moment before it> --live`. That moment is this
|
|
558
|
+
machine's clock, read just before the restore is asked for, because xgames.fun's restore takes a time, not a
|
|
559
|
+
bookmark: `restored.previous` (the bookmark from before it) is printed too, but cannot be passed back. A clock that
|
|
560
|
+
is off by more than the restore took to start puts the undo a little before or after the true moment; check it with
|
|
561
|
+
the preview (`xcore db restore <time> --live`, without `--yes`) before you go on.
|
|
562
|
+
**A game with an economy:** a restore puts back the database only. Loot balances, items and payouts on chain stay
|
|
563
|
+
as they are, so a player can keep what they won while the database forgets it; export first. An export holds your
|
|
564
|
+
players' data: keep it out of git and off shared drives.
|
|
565
|
+
|
|
566
|
+
Publish refuses a game with a database while xgames.fun does not run databases yet (its `GET /api/kit/me` lists
|
|
567
|
+
`"d1"` once it does), and says so.
|
|
568
|
+
|
|
569
|
+
**The older database**, `server/database.js` (one Durable Object with SQLite storage, from xcore 0.1.0), keeps
|
|
570
|
+
working for a game that has it, and `xcore add database` keeps it up to date; the check says it is the older way. It
|
|
571
|
+
has the same owner spaces, rules and paging, with `RULES` at its top and `dataOf(env)` for the server's own code:
|
|
572
|
+
without an owner it is the server's own space (`dataOf(env).put('news', 'today', …)`), with one a player's
|
|
573
|
+
(`dataOf(env).get('profiles', 'main', { owner: sub })`). In 0.1.0 a document was found by its id alone, whoever wrote
|
|
574
|
+
it, so 0.1.0 code that reads or writes a player's document by id alone needs `{ owner }`: `xcore add database` and
|
|
575
|
+
publish list each such call (the README's "Upgrading from 0.1.0").
|
|
576
|
+
|
|
378
577
|
## Running it locally
|
|
379
578
|
|
|
380
|
-
`xcore dev <folder>` runs `server/` under workerd, the runtime
|
|
579
|
+
`xcore dev <folder>` runs `server/` under workerd, the runtime xgames.fun runs it on, at the page's `/game/api/*`
|
|
381
580
|
(WebSockets too), with the game's page beside it and a local `env.GF`: `verifyPlayer` accepts the local sign-in that
|
|
382
581
|
`gamesfun.js` gets from it, and saves and scores are the ones the page reads through `/platform/v1/*` (kept in
|
|
383
582
|
`.gamesfun/dev-platform.json`). `env.GF.loot.report` is printed, not signed or paid; a voucher key of your own
|
|
384
583
|
(`xcore add vouchers`) signs for real, with the test key in `.dev.vars`. The dev host answers only `localhost`,
|
|
385
584
|
`127.0.0.1` and `[::1]`, so a page on another site can't reach it. Durable Object storage is kept in
|
|
386
|
-
`.gamesfun/dev-server/`,
|
|
585
|
+
`.gamesfun/dev-server/`, the game's database (a local D1, its migrations applied) in `.gamesfun/dev/d1/`, its project files in
|
|
586
|
+
`.gamesfun/dev/files/` (the public ones on a port of their own), its secrets come from `.dev.vars`, and a change under `server/`, to `gamesfun.json` or to
|
|
387
587
|
`.dev.vars` rebuilds and reloads it. Miniflare (with
|
|
388
588
|
workerd, about 140 MB) is installed once on first use, into xcore's state folder (`GAMESFUN_SERVER_DEPS=<folder>` uses
|
|
389
589
|
one installed elsewhere). What is not the same as hosted: no CPU or request limits (its log says when the page's requests would pass Free's
|
|
@@ -397,7 +597,7 @@ only the default export and the Durable Object classes exported (the check says
|
|
|
397
597
|
|
|
398
598
|
`xcore publish` bundles `server/` into **one file** with esbuild (minified, with the contract's own options), runs
|
|
399
599
|
the checks above and the secret scan over each server file and the bundle, and sends it with the game's files when
|
|
400
|
-
|
|
600
|
+
xgames.fun says it takes server code. Otherwise it publishes the files alone and says so. `xcore publish --check` shows
|
|
401
601
|
the bundle without publishing:
|
|
402
602
|
|
|
403
603
|
```
|