@xgamesfun/xcore 0.3.0 → 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 +52 -16
- package/commands/dev.mjs +31 -10
- package/commands/login.mjs +22 -11
- package/commands/logout.mjs +2 -0
- package/docs/SERVER.md +90 -22
- package/lib/check.mjs +1 -0
- package/lib/cli.mjs +2 -0
- package/lib/client/gamesfun.js +4 -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/dev/files.mjs +102 -5
- package/lib/dev/gf-local.js +4 -0
- package/lib/dev/index.mjs +32 -9
- package/lib/dev/limits.mjs +55 -24
- package/lib/dev/plans.mjs +101 -0
- package/lib/dev/platform.mjs +485 -136
- package/lib/login-reach.mjs +184 -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 +216 -1
- package/lib/secret-patterns.mjs +36 -26
- package/lib/server/bundle.mjs +139 -28
- package/lib/server/contract.mjs +91 -11
- package/package.json +2 -2
- package/skills/bring-your-own-game/SKILL.md +13 -4
package/README.md
CHANGED
|
@@ -191,14 +191,35 @@ has behind it, so the whole game runs before it is published:
|
|
|
191
191
|
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
192
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 |
|
|
193
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 |
|
|
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
|
|
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, and held to the limits of the plan it imitates (below) |
|
|
195
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 |
|
|
196
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 |
|
|
197
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`) |
|
|
198
198
|
|
|
199
199
|
Games made with any engine get all of it through `gamesfun.js` (`xcore add signin`): on a plain local server it stands
|
|
200
|
-
in with this browser's storage; under `xcore dev` it uses the real thing.
|
|
201
|
-
|
|
200
|
+
in with this browser's storage; under `xcore dev` it uses the real thing.
|
|
201
|
+
|
|
202
|
+
**The plan's limits, refused as published.** `xcore dev` imitates one plan, Free unless `--plan creator` or `--plan
|
|
203
|
+
scale` (it says which when it starts), and its platform refuses what xgames.fun refuses on that plan, with the same
|
|
204
|
+
codes: the plan's boards (8 on Free) and board rules (16), entries a board (10,000), save bytes a game (10 MB), and
|
|
205
|
+
reads (1,200) and writes (600 saves, 600 scores) a minute, the server's and the pages' counted apart; project files'
|
|
206
|
+
total and the plan's file calls a minute (120). What a page may do is the same on every plan: it makes `high` boards
|
|
207
|
+
only, two a player and half the plan's boards in all (the game's server makes the rest, `low` ones too, and
|
|
208
|
+
`GF.scores.list` shows who made each); one player's saves from the page keep at most 1/128 of the game's save bytes
|
|
209
|
+
(80 KB on Free), and with the game's saves full a player still keeps 4 KB; one player writes 60 saves and 30 scores a
|
|
210
|
+
minute; a body is JSON (`content-type: application/json`) of at most 96 KB. A board read from the page shows each player
|
|
211
|
+
as an id of the game's own (16 letters and digits), never their account, with `me: true` on the caller's entry; without
|
|
212
|
+
a player token it reads the top 100 only (`offset` plus `limit` past 100 answers 401), may get the same answer again
|
|
213
|
+
for 5 seconds, and has its own minute (a tenth of the plan's reads). Ranks and offsets are counted to 10,000 (past it a
|
|
214
|
+
rank is `10001` with `rankAtLeast: true`). A public project file is an image, audio, video, a font, JSON or plain text
|
|
215
|
+
whose first bytes are that type (else private), and `env.GF.files.put` holds at most 1 MB of a string or ArrayBuffer
|
|
216
|
+
body (a bigger file goes as a stream with its `length`).
|
|
217
|
+
|
|
218
|
+
What is not imitated, and said instead: one player's requests to the game's server a minute and its CPU a request (the
|
|
219
|
+
dev log says when a request would pass them), and one address's reads of `/platform/v1` at the play edge (120 a minute;
|
|
220
|
+
here every tab is one address). Not imitated at all: the network's delays, the owner's moderation (bans, blocked
|
|
221
|
+
words), the board ids of the published game (these are this machine's own), and the server's outbound `fetch`, which is
|
|
222
|
+
not limited to `backend.json`'s origins. The counts a minute start again when `xcore dev` does.
|
|
202
223
|
|
|
203
224
|
## 3. Players, a database, a backend
|
|
204
225
|
|
|
@@ -214,8 +235,9 @@ engine takes:
|
|
|
214
235
|
| A Godot 4 project | `addons/gamesfun/gamesfun.gd`, autoloaded as `GamesFun` | `await GamesFun.sign_in()`, `await GamesFun.saves_put("main", {})` |
|
|
215
236
|
| A Unity project | `Assets/Plugins/GamesFun/` (`GamesFun.jslib`, `GamesFun.cs`) | `await GamesFun.SignIn()`, `await GamesFun.SavesPut("main", json)` |
|
|
216
237
|
|
|
217
|
-
The same calls everywhere: sign in and out, the player (`id`, `name`, `level`, `wallet`, `x`
|
|
218
|
-
|
|
238
|
+
The same calls everywhere: sign in and out, the player (`id`, `name`, `level`, `wallet`, `x`; `id` is the player's
|
|
239
|
+
account, `x:<X id>` or for a Google account `g:<id>`, an id different in every game, and never the wallet, which is
|
|
240
|
+
`wallet`, empty when the account has none), saves (any JSON, 64 KB each, 16 a player), leaderboards (submit, top, the player's own), the game's database (below) and its own server
|
|
219
241
|
(`api`). Every call answers `{data, error, status}` and none throws, so a game plays on as a guest wherever xgames.fun is
|
|
220
242
|
not there.
|
|
221
243
|
|
|
@@ -232,7 +254,9 @@ from the page, so anyone can submit any score for themselves from the browser's
|
|
|
232
254
|
makes a board nobody can write that way: `env.GF.scores.setup('ranked', {writer: 'server'})` (or a pattern for boards
|
|
233
255
|
to come, `'daily-*'`), then the page's submits
|
|
234
256
|
to it are refused (their answer's `code` is `'server-only'`, HTTP 403) and the server submits each run it checked; the page reads the board as before
|
|
235
|
-
([docs/SERVER.md](docs/SERVER.md)).
|
|
257
|
+
([docs/SERVER.md](docs/SERVER.md)). A board a page makes ranks higher first; a lower-first board (a time) is made by
|
|
258
|
+
the game's server, and a page's `order: 'low'` for a new board is refused (`code: 'order'`). `scores.top` gives each
|
|
259
|
+
entry's `player` as an id of this game's own and marks the signed-in player's own entry `me: true`.
|
|
236
260
|
|
|
237
261
|
Where the sign-in comes from:
|
|
238
262
|
|
|
@@ -246,9 +270,15 @@ Where the sign-in comes from:
|
|
|
246
270
|
can be made and tested before it is published. Under `xcore dev`, a local sign-in (one player a tab) and the
|
|
247
271
|
platform answer as xgames.fun does, and the game's `server/` accepts its token.
|
|
248
272
|
|
|
249
|
-
The token lasts an hour and stays in the page's memory. The game's `server/` checks it with `env.GF.verifyPlayer
|
|
250
|
-
|
|
251
|
-
|
|
273
|
+
The token lasts an hour and stays in the page's memory. The game's `server/` checks it with `env.GF.verifyPlayer`,
|
|
274
|
+
first thing, before any work: a request without one answers 401 at once, since strangers' requests that do work count
|
|
275
|
+
toward what caps a game ([docs/SERVER.md](docs/SERVER.md), "Check the token first"). A backend of your own checks it
|
|
276
|
+
with any JWT library against `https://<slug>-play.xgames.fun/player/v1/key` (issuer `xgames.fun`, audience
|
|
277
|
+
`game:<slug>`). Its `sub` is the player's account (`x:<X id>`, or `g:<id>` for Google, different in every game);
|
|
278
|
+
`wallet` is a claim of its own, and may be absent: a game that keeps a player's data by wallet reads `wallet`.
|
|
279
|
+
xgames.fun replaces its signing key from time to time: a backend that keeps the key set must fetch it again when a
|
|
280
|
+
token names a key id (`kid`) it does not hold, once, not in a loop (most JWT libraries' remote key sets do).
|
|
281
|
+
`gamesfun.js` and the Godot and Unity clients check no token themselves.
|
|
252
282
|
|
|
253
283
|
The page calls the game's own server with `GamesFun.api(path, init)` (Godot `GamesFun.api(path, init)`, Unity
|
|
254
284
|
`GamesFun.Api(path, initJson)`): `path` is relative to the game's `api/` (`'finish'` is `api/finish`, at the
|
|
@@ -498,7 +528,7 @@ holds the addresses the economy went live at stays in the project (keep it in gi
|
|
|
498
528
|
runs the tools from a terminal. Going live is the person's own step, approved in their own wallet: `xcore loot
|
|
499
529
|
approve` in the folder with `xsolana.json`.
|
|
500
530
|
|
|
501
|
-
**When loot is upgraded.** This package pins the tools for one build of loot (0.
|
|
531
|
+
**When loot is upgraded.** This package pins the tools for one build of loot (0.26.0, on devnet since 2026-11-10). loot is upgraded in place on
|
|
502
532
|
devnet, and no version so far has carried accounts over: an economy that went live on an earlier build goes live
|
|
503
533
|
again (`xcore loot approve`), and the next publish brings `gf/loot/` up to the installed package. **Nothing runs by
|
|
504
534
|
itself** on chain: the page client sends what a player needs (settles, refunds, claims, a draw that is due); a
|
|
@@ -549,7 +579,10 @@ vouchers changed since its last review (it never refuses for it).
|
|
|
549
579
|
## 5. Publish
|
|
550
580
|
|
|
551
581
|
`xcore login` signs this computer in (your browser signs in to xgames.fun and hands it a publish token for 30
|
|
552
|
-
days; xcore never sees your password).
|
|
582
|
+
days; xcore never sees your password). On the approval page you tick the games this computer may work on, or your
|
|
583
|
+
whole account, and `xcore login` says which it got (`login --status` says it again). A game you publish as new from
|
|
584
|
+
that sign-in is added to it; a command for any other game is refused with code `not-granted` ("your sign-in was not
|
|
585
|
+
given this game"): run `xcore login` again and tick it. `xcore publish <folder>` then builds the game, checks it (the page
|
|
553
586
|
check on exactly the files built), and uploads its published folder with its `server/` bundle:
|
|
554
587
|
|
|
555
588
|
1. **The files**: the published folder, without hidden files, `evidence/`, `node_modules/`, `server/`, `supabase/` and the makers' notes at its top (`README.md`, `NOTES.md`, `LAUNCH.md` and the like). A `.env` file or a link
|
|
@@ -623,8 +656,11 @@ free their slot unless their link is still live. What the game's server, saves a
|
|
|
623
656
|
| Sites that embed it | 3 (`xcore embed`) | 10 | 25 |
|
|
624
657
|
| Domains of your own | none | 1 (`xcore domain`) | 5 |
|
|
625
658
|
|
|
626
|
-
Past what is included, a game is **capped** until the month ends: its server
|
|
627
|
-
|
|
659
|
+
Past what is included, a game is **capped** until the month ends: **its server is switched off** (every `api/*` request
|
|
660
|
+
answers 451 with `reason: "capped"`, and a room that is running stops within about five minutes), and saves and scores
|
|
661
|
+
slow down (60 save writes, 30 score submits and 60 reads a minute). Its pages keep loading and nothing is lost: the server comes
|
|
662
|
+
back by itself when the cap lifts ([docs/SERVER.md](docs/SERVER.md), "Publishing it"). An unpublished or taken-down
|
|
663
|
+
game's server is switched off the same way. Paid slots are $15 for
|
|
628
664
|
30 days, prepaid in SOL or USDC on xgames.fun (your console → Plans; no automatic renewal, 7 days' grace after the period
|
|
629
665
|
ends; when they end, their games go back to free slots and nothing is taken down).
|
|
630
666
|
Usage credit, bought the same way, lets Creator games go past what their slots include, at twice what xgames.fun pays,
|
|
@@ -706,9 +742,9 @@ without one needs none: the D1 database, `xcore db` and `xcore files` are new.
|
|
|
706
742
|
WebAssembly). Such a server runs on a host of your own, declared with `backend use custom`; or its logic moves into
|
|
707
743
|
`server/`, or into the page itself (WebAssembly is allowed: Pyodide runs Python in the browser). Each is a change to
|
|
708
744
|
how the game is built: the person who made it decides.
|
|
709
|
-
- **A Node server as the backend on xgames.fun**: `server/` is a Worker, not Node (Node built-ins
|
|
710
|
-
|
|
711
|
-
declared with `backend use custom`.
|
|
745
|
+
- **A Node server as the backend on xgames.fun**: `server/` is a Worker, not Node (no Node built-ins: `nodejs_compat`
|
|
746
|
+
is not offered; no `fs`, `child_process`, raw sockets or listening sockets). A Node backend runs on a host of your
|
|
747
|
+
own, declared with `backend use custom`.
|
|
712
748
|
- **Scripts, style sheets and fonts from a CDN**: the page loads code and styles from its own files only, so a copy goes
|
|
713
749
|
in the game (or the package is bundled).
|
|
714
750
|
- **A fallback to `index.html` for every path**: deep links of a client-side router reload to "not found"; use a hash
|
package/commands/dev.mjs
CHANGED
|
@@ -4,21 +4,25 @@ import { guard, parse, printJson, UsageError } from '../lib/cli.mjs';
|
|
|
4
4
|
import { resolveGame, shown } from '../lib/games.mjs';
|
|
5
5
|
import { madeByKit } from '../lib/publish/index.mjs';
|
|
6
6
|
import { CMD } from '../lib/command.mjs';
|
|
7
|
+
import { PLAN_NAMES } from '../lib/dev/plans.mjs';
|
|
7
8
|
|
|
8
|
-
const usage = `Usage: xcore dev [dir] [--port <n>] [--no-build] [--open] [--json]
|
|
9
|
+
const usage = `Usage: xcore dev [dir] [--port <n>] [--plan <name>] [--no-build] [--open] [--json]
|
|
9
10
|
|
|
10
11
|
Play the game on this machine as xgames.fun will host it, with everything a published game has behind it:
|
|
11
12
|
http://127.0.0.1:<port>/game/ the published files, with the play host's headers
|
|
12
13
|
/game/api/* the game's own server/ (Durable Objects, WebSockets, env.GF), reloaded on change
|
|
13
|
-
/platform/v1/* sign-in (a local player), saves and leaderboards, the same store env.GF uses
|
|
14
|
+
/platform/v1/* sign-in (a local player), saves and leaderboards, the same store env.GF uses,
|
|
15
|
+
with xgames.fun's limits on the plan it imitates (Free unless --plan)
|
|
14
16
|
/loot/v1/rpc the economy's Solana devnet RPC (XSOLANA_RPC_URL in .env, else xgames.fun's relay)
|
|
15
17
|
gamesfun.js (${CMD} add signin|database|multiplayer) uses all of it here, from any engine: open the page in two
|
|
16
18
|
windows to play a room together. Running server/ needs Miniflare (workerd), installed once on first use (~140 MB).
|
|
17
19
|
Saves, scores and Durable Object storage are kept in <dir>/.gamesfun/ (never published).
|
|
18
|
-
--port <n>
|
|
19
|
-
--
|
|
20
|
-
|
|
21
|
-
--
|
|
20
|
+
--port <n> the port (default 8787)
|
|
21
|
+
--plan <name> the plan to imitate: free (the default), creator or scale: its boards, saves, reads and writes a
|
|
22
|
+
minute and file sizes, as xgames.fun holds a game on that plan (${CMD} plan shows a game's own)
|
|
23
|
+
--no-build serve what is built already; do not run gamesfun.json page.build first
|
|
24
|
+
--open open the page in your browser
|
|
25
|
+
--json print where it runs as JSON, then keep running`;
|
|
22
26
|
|
|
23
27
|
export default {
|
|
24
28
|
name: 'dev',
|
|
@@ -28,12 +32,20 @@ export default {
|
|
|
28
32
|
guard(async () => {
|
|
29
33
|
const { values, positionals } = parse(
|
|
30
34
|
args,
|
|
31
|
-
{
|
|
35
|
+
{
|
|
36
|
+
port: { type: 'string' },
|
|
37
|
+
plan: { type: 'string' },
|
|
38
|
+
'no-build': { type: 'boolean' },
|
|
39
|
+
open: { type: 'boolean' },
|
|
40
|
+
},
|
|
32
41
|
usage,
|
|
33
42
|
);
|
|
34
43
|
const port = values.port === undefined ? 8787 : Number(values.port);
|
|
35
44
|
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
36
45
|
throw new UsageError(`--port is a port number\n\n${usage}`);
|
|
46
|
+
const plan = String(values.plan ?? 'free').toLowerCase();
|
|
47
|
+
if (!PLAN_NAMES.includes(plan))
|
|
48
|
+
throw new UsageError(`--plan is ${PLAN_NAMES.slice(0, -1).join(', ')} or ${PLAN_NAMES.at(-1)}\n\n${usage}`);
|
|
37
49
|
const dir = await resolveGame(positionals[0]);
|
|
38
50
|
if (madeByKit(dir)) {
|
|
39
51
|
console.error(
|
|
@@ -45,18 +57,27 @@ export default {
|
|
|
45
57
|
const { startDev } = await import('../lib/dev/index.mjs');
|
|
46
58
|
let dev;
|
|
47
59
|
try {
|
|
48
|
-
dev = await startDev(dir, { port, noBuild: values['no-build'] === true, log: say });
|
|
60
|
+
dev = await startDev(dir, { port, plan, noBuild: values['no-build'] === true, log: say });
|
|
49
61
|
} catch (e) {
|
|
50
62
|
if (e.code === 'EADDRINUSE')
|
|
51
63
|
throw new Error(`port ${port} is in use: pick another with --port <n>`, { cause: e });
|
|
52
64
|
throw e;
|
|
53
65
|
}
|
|
54
66
|
if (values.json)
|
|
55
|
-
printJson({
|
|
67
|
+
printJson({
|
|
68
|
+
url: dev.url,
|
|
69
|
+
origin: dev.origin,
|
|
70
|
+
files: dev.files,
|
|
71
|
+
server: dev.server,
|
|
72
|
+
rpc: dev.rpc,
|
|
73
|
+
plan: dev.plan,
|
|
74
|
+
});
|
|
56
75
|
else {
|
|
57
76
|
say(`\n${shown(dir)} on this machine: ${dev.url}`);
|
|
58
77
|
say(` server: ${dev.server ? (dev.server.running ? 'running' : 'not running (above)') : 'none (no server/)'}`);
|
|
59
|
-
say(
|
|
78
|
+
say(
|
|
79
|
+
` sign-in, saves, scores: a local player, with the ${dev.store.plan.name} plan's limits; kept in ${shown(dev.store.file)}`,
|
|
80
|
+
);
|
|
60
81
|
say(` project files (env.GF.files, xcore files): public ones at ${dev.files}/, an origin of their own`);
|
|
61
82
|
say(` economy RPC: ${dev.rpc}`);
|
|
62
83
|
say('Ctrl+C stops it.');
|
package/commands/login.mjs
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline/promises';
|
|
2
2
|
import { guard, parse, printJson } from '../lib/cli.mjs';
|
|
3
3
|
import { openUrl } from '../lib/open.mjs';
|
|
4
|
-
import {
|
|
4
|
+
import { signInState, studioOrigin } from '../lib/publish/client.mjs';
|
|
5
|
+
import { currentReach, loginWithReach, loginWords, reachWords } from '../lib/login-reach.mjs';
|
|
5
6
|
import { storeName } from '../lib/credentials.mjs';
|
|
6
7
|
import { CMD } from '../lib/command.mjs';
|
|
7
8
|
|
|
8
9
|
const usage = `Usage: xcore login [--code] [--status] [--json]
|
|
9
10
|
|
|
10
11
|
Sign in to xgames.fun so you can publish your games (${CMD} publish). Your browser opens xgames.fun: sign in
|
|
11
|
-
there
|
|
12
|
-
|
|
12
|
+
there, tick the games this computer may work on (or your whole account) and approve. xcore says which it got; a game
|
|
13
|
+
you publish as new from here is added to them, and for another, run ${CMD} login again and tick it. xcore never sees
|
|
14
|
+
your password; it keeps a publish token for 30 days in ${storeName()} (never in a game or in this folder).
|
|
15
|
+
${CMD} logout ends it.
|
|
13
16
|
--code no browser on this machine (SSH, a container): open the printed link anywhere and paste the code it shows
|
|
14
|
-
--status who this computer is signed in as, and where the token is kept
|
|
17
|
+
--status who this computer is signed in as, which games it reaches, and where the token is kept
|
|
15
18
|
GAMESFUN_STUDIO=<origin> signs in to another xgames.fun (a local Studio while developing).`;
|
|
16
19
|
|
|
17
20
|
export default {
|
|
@@ -24,13 +27,24 @@ export default {
|
|
|
24
27
|
const origin = studioOrigin();
|
|
25
28
|
if (values.status) {
|
|
26
29
|
const { token: _token, words, ...s } = await signInState(origin);
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
const reach = s.state === 'signed-in' ? await currentReach(origin) : null;
|
|
31
|
+
if (values.json)
|
|
32
|
+
printJson({
|
|
33
|
+
...s,
|
|
34
|
+
signedIn: s.state === 'signed-in',
|
|
35
|
+
...(reach ? { scope: reach.scope, games: reach.games, reachChecked: reach.checked } : {}),
|
|
36
|
+
});
|
|
37
|
+
else
|
|
38
|
+
console.log(
|
|
39
|
+
s.state === 'signed-in'
|
|
40
|
+
? `${words}${reachWords(reach) ? ` ${reachWords(reach)}` : ''}`
|
|
41
|
+
: `${words} Run: ${s.next}`,
|
|
42
|
+
);
|
|
29
43
|
return 0;
|
|
30
44
|
}
|
|
31
45
|
const rl = values.code ? createInterface({ input: process.stdin, output: process.stderr }) : null;
|
|
32
46
|
try {
|
|
33
|
-
const r = await
|
|
47
|
+
const r = await loginWithReach({
|
|
34
48
|
origin,
|
|
35
49
|
code: values.code,
|
|
36
50
|
open: url => openUrl(url, { web: true }),
|
|
@@ -38,10 +52,7 @@ export default {
|
|
|
38
52
|
log: m => console.error(m),
|
|
39
53
|
});
|
|
40
54
|
if (values.json) printJson({ studio: origin, ...r });
|
|
41
|
-
else
|
|
42
|
-
console.log(
|
|
43
|
-
`Signed in to ${origin} as ${r.email}. The token is kept in ${storeWords(r.store)} until ${new Date(r.expiresAt).toISOString().slice(0, 10)}. Next: ${CMD} publish`,
|
|
44
|
-
);
|
|
55
|
+
else console.log(loginWords(r));
|
|
45
56
|
return 0;
|
|
46
57
|
} finally {
|
|
47
58
|
rl?.close();
|
package/commands/logout.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { guard, parse, printJson } from '../lib/cli.mjs';
|
|
2
2
|
import { logout, studioOrigin } from '../lib/publish/client.mjs';
|
|
3
|
+
import { forgetReach } from '../lib/login-reach.mjs';
|
|
3
4
|
|
|
4
5
|
const usage = `Usage: xcore logout [--json]
|
|
5
6
|
|
|
@@ -26,6 +27,7 @@ export default {
|
|
|
26
27
|
const { values } = parse(args, {}, usage);
|
|
27
28
|
const origin = studioOrigin();
|
|
28
29
|
const r = await logout(origin);
|
|
30
|
+
await forgetReach(origin);
|
|
29
31
|
if (values.json) printJson({ studio: origin, ...r });
|
|
30
32
|
else
|
|
31
33
|
console.log(
|
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,8 +197,8 @@ 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
|
|
|
@@ -157,11 +208,18 @@ Every call answers `{ok: true, …}` or `{ok: false, code, why}` (`why` is words
|
|
|
157
208
|
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
158
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 |
|
|
159
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 |
|
|
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
|
|
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 |
|
|
161
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") |
|
|
162
213
|
| `GF.info()` | `{ok, contract, gameId, slug}` |
|
|
163
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 |
|
|
164
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.
|
|
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
|
|
167
225
|
own word until the server takes it: **`GF.scores.setup(board, {writer: 'server'})`** (or `writer: 'server'` in the
|
|
@@ -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
|
|
@@ -622,6 +682,14 @@ my-vault: 278 files, 10.0 MB, nothing refused. Left out: .gamesfun/, .gitignore,
|
|
|
622
682
|
Hosted, the game's server answers at `https://<slug>-play.xgames.fun/api/…`. A blocked or over-limit server answers
|
|
623
683
|
`{error, message}` with 451, 429, 404, 503 or 502 instead of the server's own answer.
|
|
624
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
|
+
|
|
625
693
|
While the game's owner has it **paused for maintenance** (xgames.fun/console, the Live tab; README "Live"), xgames.fun
|
|
626
694
|
answers its `api/*` itself with `503 {ok: false, code: 'maintenance', why, since}` and `retry-after: 60`, WebSocket
|
|
627
695
|
upgrades included: the server's code does not run for a new request. What already runs keeps running (an open
|
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);
|
package/lib/client/gamesfun.js
CHANGED
|
@@ -929,7 +929,10 @@
|
|
|
929
929
|
if (opts.order) body.order = opts.order;
|
|
930
930
|
return json('POST', PLATFORM + 'scores/' + seg(board), body, 'submitting the score');
|
|
931
931
|
},
|
|
932
|
-
/**
|
|
932
|
+
/**
|
|
933
|
+
* → {data: [{rank, player, name, score, at, me?}]}: `player` is an id of this game's own (never the account), `me`
|
|
934
|
+
* true on the signed-in player's entry. Anyone may read; signed out, the top 100 only (offset + limit past 100: 401)
|
|
935
|
+
*/
|
|
933
936
|
top: function (board, opts) {
|
|
934
937
|
opts = opts || {};
|
|
935
938
|
if (!seg(board)) return bad('board name');
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
-- does the same on this machine. Once this one is published, never change it: put the next change in
|
|
4
4
|
-- 0002_<what>.sql (ALTER TABLE players ADD COLUMN …).
|
|
5
5
|
CREATE TABLE players (
|
|
6
|
-
id TEXT PRIMARY KEY, -- the player's id: env.GF.verifyPlayer's sub (withPlayer in gf-db.js)
|
|
6
|
+
id TEXT PRIMARY KEY, -- the player's id: env.GF.verifyPlayer's sub (withPlayer in gf-db.js), never the wallet
|
|
7
7
|
gold INTEGER NOT NULL DEFAULT 0,
|
|
8
8
|
runs INTEGER NOT NULL DEFAULT 3, -- runs left today
|
|
9
9
|
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
// POST api/db/<collection> {value} a new id, the player's own → {ok, item}
|
|
10
10
|
// DELETE api/db/<collection>/<id> the player's own → {ok, deleted}
|
|
11
11
|
//
|
|
12
|
-
// A document is {id, owner, value, updatedAt}: `owner` is the player's id (env.GF.verifyPlayer's `sub
|
|
12
|
+
// A document is {id, owner, value, updatedAt}: `owner` is the player's id (env.GF.verifyPlayer's `sub`: the account,
|
|
13
|
+
// x:<X id> or for Google g:<id>, different in every game; never the wallet), or "server"
|
|
13
14
|
// for what this server's own code writes. Every player has a space of their own in each collection: a document is
|
|
14
15
|
// found by its collection, its owner and its id, so two players can both have a "main", and no player can take an id
|
|
15
16
|
// in anyone else's space. A page names whose document it wants with `owner`; without it, the player's own (in a
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// database on this machine. Add a migration for each change; never change one that was published.
|
|
6
6
|
//
|
|
7
7
|
// import { db, withPlayer } from './gf-db.js';
|
|
8
|
-
// const sub = await withPlayer(request, env); // the signed-in player's id, or null
|
|
8
|
+
// const sub = await withPlayer(request, env); // the signed-in player's id (never the wallet), or null
|
|
9
9
|
// if (!sub) return Response.json({ why: 'Sign in first.' }, { status: 401 });
|
|
10
10
|
// const me = await db(env).first('SELECT gold, runs FROM players WHERE id = ?', sub);
|
|
11
11
|
// // a rule in one statement, so two requests at once cannot both pass it
|
|
@@ -46,7 +46,11 @@ export function db(env) {
|
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* The signed-in player's id (env.GF.verifyPlayer's sub: the account, x:<X id> or for Google g:<id>, different in every
|
|
51
|
+
* game; never the wallet, which is v.wallet), from the token gamesfun.js sends; null for a guest, at once and with no
|
|
52
|
+
* call when the request has no token, so a stranger costs the server nothing: answer 401 then, before any work.
|
|
53
|
+
*/
|
|
50
54
|
export async function withPlayer(request, env) {
|
|
51
55
|
const token = /^Bearer\s+(\S+)$/i.exec(request.headers.get('authorization') ?? '')?.[1];
|
|
52
56
|
if (!token) return null;
|
|
@@ -177,6 +177,7 @@ export class GameRoom extends DurableObject {
|
|
|
177
177
|
let player = null;
|
|
178
178
|
if (m.token) {
|
|
179
179
|
const v = await this.env.GF.verifyPlayer(String(m.token));
|
|
180
|
+
// id: the account (x:<X id>, or g:<id> for Google, different in every game); the wallet is its own claim
|
|
180
181
|
if (v?.ok) player = { id: v.sub, x: v.x?.handle ?? null, wallet: v.wallet ?? null };
|
|
181
182
|
}
|
|
182
183
|
// its own storage: the room takes no other message while it waits on that
|