@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.
Files changed (72) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +183 -84
  3. package/bin/xcore.mjs +13 -8
  4. package/commands/add.mjs +11 -8
  5. package/commands/adopt.mjs +9 -7
  6. package/commands/backend.mjs +2 -2
  7. package/commands/check.mjs +4 -4
  8. package/commands/db.mjs +218 -0
  9. package/commands/dev.mjs +8 -6
  10. package/commands/domain.mjs +8 -8
  11. package/commands/embed.mjs +3 -3
  12. package/commands/files.mjs +106 -0
  13. package/commands/login.mjs +9 -17
  14. package/commands/logout.mjs +15 -4
  15. package/commands/loot.mjs +2 -2
  16. package/commands/plan.mjs +22 -19
  17. package/commands/publish.mjs +54 -21
  18. package/commands/secret.mjs +6 -6
  19. package/docs/SERVER.md +218 -18
  20. package/lib/add.mjs +265 -40
  21. package/lib/browser.mjs +11 -2
  22. package/lib/check.mjs +10 -9
  23. package/lib/cli.mjs +24 -9
  24. package/lib/client/gamesfun.js +242 -122
  25. package/lib/client/godot/gamesfun.gd +17 -11
  26. package/lib/client/godot/gamesfun_peer.gd +2 -2
  27. package/lib/client/server/0001_init.sql +10 -0
  28. package/lib/client/server/database.js +271 -109
  29. package/lib/client/server/gf-db.js +55 -0
  30. package/lib/client/server/gf-docs.js +56 -0
  31. package/lib/client/server/rooms.js +16 -9
  32. package/lib/client/server/rpc.js +7 -7
  33. package/lib/client/server/stock.js +37 -0
  34. package/lib/client/server/vouchers.js +127 -46
  35. package/lib/client/unity/GamesFun.cs +15 -12
  36. package/lib/client/unity/GamesFun.jslib +1 -1
  37. package/lib/config-dir.mjs +1 -1
  38. package/lib/credentials.mjs +10 -5
  39. package/lib/db.mjs +355 -0
  40. package/lib/dev/d1.mjs +486 -0
  41. package/lib/dev/files.mjs +247 -0
  42. package/lib/dev/gf-local.js +69 -3
  43. package/lib/dev/index.mjs +68 -20
  44. package/lib/dev/limits.mjs +2 -2
  45. package/lib/dev/platform.mjs +111 -12
  46. package/lib/dev/server.mjs +54 -5
  47. package/lib/dev/tools.mjs +71 -18
  48. package/lib/files.mjs +208 -0
  49. package/lib/open.mjs +1 -1
  50. package/lib/play-policy.mjs +14 -5
  51. package/lib/publish/adopt.mjs +73 -33
  52. package/lib/publish/client.mjs +130 -20
  53. package/lib/publish/gate.mjs +4 -2
  54. package/lib/publish/hosting.mjs +5 -5
  55. package/lib/publish/index.mjs +162 -40
  56. package/lib/publish/oidc.mjs +26 -5
  57. package/lib/publish/page.mjs +1 -1
  58. package/lib/publish/plans.mjs +19 -12
  59. package/lib/publish/scan.mjs +78 -102
  60. package/lib/publish/secrets.mjs +4 -5
  61. package/lib/publish/site.mjs +9 -10
  62. package/lib/publish/tree.mjs +8 -8
  63. package/lib/secret-patterns.mjs +53 -16
  64. package/lib/secret-store.mjs +2 -2
  65. package/lib/server/bundle.mjs +113 -13
  66. package/lib/server/contract.mjs +440 -16
  67. package/lib/server/data-calls.mjs +136 -0
  68. package/lib/server/secrets.mjs +9 -7
  69. package/lib/solana-rpc.mjs +5 -5
  70. package/lib/state-folder.mjs +23 -0
  71. package/package.json +4 -4
  72. package/skills/bring-your-own-game/SKILL.md +10 -9
@@ -0,0 +1,55 @@
1
+ // The game's database on xgames.fun (a D1 database of its own: gamesfun.json → database), for this server's own code.
2
+ // Written by `xcore add database`; running it again brings this file up to date.
3
+ // Its tables come from the migrations in server/migrations/ (0001_init.sql, then 0002_….sql): xgames.fun applies the
4
+ // new ones, in name order, before the version that brings them goes live, and `xcore dev` does the same with a
5
+ // database on this machine. Add a migration for each change; never change one that was published.
6
+ //
7
+ // import { db, withPlayer } from './gf-db.js';
8
+ // const sub = await withPlayer(request, env); // the signed-in player's id, or null
9
+ // if (!sub) return Response.json({ why: 'Sign in first.' }, { status: 401 });
10
+ // const me = await db(env).first('SELECT gold, runs FROM players WHERE id = ?', sub);
11
+ // // a rule in one statement, so two requests at once cannot both pass it
12
+ // const left = await db(env).first('UPDATE players SET runs = runs - 1 WHERE id = ? AND runs > 0 RETURNING runs', sub);
13
+ // await db(env).batch([['INSERT INTO runs (player) VALUES (?)', sub], ['UPDATE players SET gold = gold + 5 WHERE id = ?', sub]]);
14
+ //
15
+ // Always pass a value as a ? and an argument, never inside the SQL's text: a player's words in the SQL are a way in.
16
+
17
+ /** The database's name on env (gamesfun.json → database.binding). */
18
+ const BINDING = '__BINDING__';
19
+
20
+ /** Prepared queries on the game's database. */
21
+ export function db(env) {
22
+ const d1 = env[BINDING];
23
+ if (!d1) throw new Error(`The game's database is not bound as env.${BINDING} (gamesfun.json → database).`);
24
+ const statement = (sql, params) => d1.prepare(sql).bind(...params);
25
+ return {
26
+ /** Every row it answers. → [row] */
27
+ async all(sql, ...params) {
28
+ return (await statement(sql, params).all()).results ?? [];
29
+ },
30
+ /** The first row, or null. */
31
+ first(sql, ...params) {
32
+ return statement(sql, params).first();
33
+ },
34
+ /** A write. → {changes, lastRowId} */
35
+ async run(sql, ...params) {
36
+ const r = await statement(sql, params).run();
37
+ return { changes: r.meta?.changes ?? 0, lastRowId: r.meta?.last_row_id ?? null };
38
+ },
39
+ /** Several statements, all of them or none: [[sql, ...params], …]. → the rows of each */
40
+ async batch(list) {
41
+ const done = await d1.batch(list.map(([sql, ...params]) => statement(sql, params)));
42
+ return done.map(r => r.results ?? []);
43
+ },
44
+ /** D1 itself, for anything else (env.<BINDING>). */
45
+ d1,
46
+ };
47
+ }
48
+
49
+ /** The signed-in player's id (env.GF.verifyPlayer's sub), from the token gamesfun.js sends; null for a guest. */
50
+ export async function withPlayer(request, env) {
51
+ const token = /^Bearer\s+(\S+)$/i.exec(request.headers.get('authorization') ?? '')?.[1];
52
+ if (!token) return null;
53
+ const v = await env.GF.verifyPlayer(token);
54
+ return v?.ok && typeof v.sub === 'string' && v.sub ? v.sub : null;
55
+ }
@@ -0,0 +1,56 @@
1
+ // The page's documents (gf.db in gamesfun.js) in the game's own D1 database, for a game with a server of its own.
2
+ // Written by `xcore add` (database, multiplayer, rpc, vouchers) when gamesfun.json → database has `collections`;
3
+ // running it again brings this file up to date. It answers api/db/* with the same routes and answers as xcore's stock
4
+ // server, from the same table (gf_docs), under the same rules: gamesfun.json → database.collections, read from that
5
+ // file when the server is bundled, so the rules are in one place. Route it first thing in the server's fetch:
6
+ //
7
+ // import { handleDocs } from './gf-docs.js';
8
+ // const db = await handleDocs(request, env);
9
+ // if (db) return db;
10
+ //
11
+ // GET api/db/<collection>?owner=me|server|<id>&limit=&after=&order=new|old → {ok, items, more, after}
12
+ // GET api/db/<collection>/<id>?owner=me|server|<id> → {ok, item}
13
+ // PUT api/db/<collection>/<id> {value} the signed-in player's own → {ok, item}
14
+ // POST api/db/<collection> {value} a new id, the player's own → {ok, item}
15
+ // DELETE api/db/<collection>/<id> the player's own → {ok, deleted}
16
+ //
17
+ // This server's own code reads and writes past the rules with docsOf(env) (the server's own space, owner "server", by
18
+ // default; a player's with {owner: sub}): await docsOf(env).put('news', 'today', {…}).
19
+ import config from '../gamesfun.json';
20
+
21
+ /** Who may read and write each collection: gamesfun.json → database.collections ('*' is every collection not named). */
22
+ const RULES = config?.database?.collections ?? { '*': { read: 'owner', write: 'owner' } };
23
+ /** The database's name on env (gamesfun.json → database.binding). */
24
+ const BINDING = config?.database?.binding ?? 'DB';
25
+
26
+ export const LIMITS = { valueBytes: 64 * 1024, docsPerPlayer: 1000, list: 100 };
27
+
28
+ // __DOCS_CORE__
29
+
30
+ /** D1 as createData's exec(query, ...bindings) → rows. */
31
+ const d1 =
32
+ db =>
33
+ async (q, ...b) => {
34
+ const r = await db
35
+ .prepare(q)
36
+ .bind(...b)
37
+ .all();
38
+ return r.results ?? [];
39
+ };
40
+
41
+ let data = null;
42
+ const docs = env => {
43
+ const db = env[BINDING];
44
+ if (!db) throw new Error(`The game's database is not bound as env.${BINDING} (gamesfun.json → database).`);
45
+ return (data ??= createData(d1(db), { rules: RULES, table: 'gf_docs' }));
46
+ };
47
+
48
+ /** The page's calls to api/db/…, from the game's database; null for any other path, so the server's routes go on. */
49
+ export function handleDocs(request, env) {
50
+ return routeDatabase(request, env, (op, args) => docs(env)[op](...args));
51
+ }
52
+
53
+ /** This server's own access to the documents, past the players' rules (as server/database.js's dataOf). */
54
+ export function docsOf(env) {
55
+ return serverData((op, args) => docs(env)[op](...args));
56
+ }
@@ -1,4 +1,4 @@
1
- // The game's multiplayer rooms on games.fun, written into server/ by `xcore add multiplayer`. Part of the game's server
1
+ // The game's multiplayer rooms on xgames.fun, written into server/ by `xcore add multiplayer`. Part of the game's server
2
2
  // code (docs/SERVER.md): one Durable Object a room, holding its players' WebSockets at the game's own
3
3
  // api/rooms/<CODE>. Nothing to sign up for and no keys; publish puts it up with the game. The page joins through
4
4
  // gamesfun.js (GamesFun.rooms.join), Godot's high-level multiplayer through the GamesFun peer, Unity through
@@ -27,7 +27,7 @@ import { DurableObject } from 'cloudflare:workers';
27
27
  export const ROOM = {
28
28
  /** Most players in one room. */
29
29
  maxPlayers: 16,
30
- /** Only players signed in to games.fun may join (their games.fun id and X handle come with them). */
30
+ /** Only players signed in to xgames.fun may join (their xgames.fun id and X handle come with them). */
31
31
  signedIn: false,
32
32
  /** The largest message, in bytes. */
33
33
  messageBytes: 64 * 1024,
@@ -172,19 +172,23 @@ export class GameRoom extends DurableObject {
172
172
  /* not JSON */
173
173
  }
174
174
  if (m?.t !== 'hello') return ws.close(4000, 'Say hello first.');
175
+ // the sign-in check first: it goes out to xgames.fun, and the room takes other players' hellos while it waits, so
176
+ // who is in the room is read only after it (two players hosting one code at once: one host, the other refused)
177
+ let player = null;
178
+ if (m.token) {
179
+ const v = await this.env.GF.verifyPlayer(String(m.token));
180
+ if (v?.ok) player = { id: v.sub, x: v.x?.handle ?? null, wallet: v.wallet ?? null };
181
+ }
182
+ // its own storage: the room takes no other message while it waits on that
183
+ const locked = (await this.ctx.storage.get('locked')) === true;
184
+ // from here to the seat taken below, nothing is awaited: the checks and the seat are one step
175
185
  const players = seated(this.ctx);
176
186
  // a Host button opens a room of its own, a Join button joins one that is there (as over ENet)
177
187
  if (m.host === true && players.length)
178
188
  return ws.close(4009, 'Someone is in this room already: choose another code.');
179
189
  if (m.host === false && !players.length) return ws.close(4004, 'No one is in this room.');
180
190
  if (players.length >= ROOM.maxPlayers) return ws.close(4003, 'The room is full.');
181
- if (players.length && (await this.ctx.storage.get('locked')) === true) return ws.close(4003, 'The room is closed.');
182
- if (!players.length) await this.ctx.storage.delete('locked');
183
- let player = null;
184
- if (m.token) {
185
- const v = await this.env.GF.verifyPlayer(String(m.token));
186
- if (v?.ok) player = { id: v.sub, x: v.x?.handle ?? null, wallet: v.wallet ?? null };
187
- }
191
+ if (players.length && locked) return ws.close(4003, 'The room is closed.');
188
192
  if (ROOM.signedIn && !player) return ws.close(4001, 'Sign in to join this room.');
189
193
  const name = String(m.name ?? player?.x ?? '')
190
194
  // eslint-disable-next-line no-control-regex -- a name never carries control characters
@@ -208,6 +212,9 @@ export class GameRoom extends DurableObject {
208
212
  });
209
213
  const join = JSON.stringify({ t: 'join', peer: { id, name, player } });
210
214
  for (const p of players) said(p.ws, join);
215
+ // the first in: a lock left from the room's last players goes (asked after the seat is taken, so it is never
216
+ // between this player's seat and the room hearing of it)
217
+ if (!players.length && locked) await this.ctx.storage.delete('locked');
211
218
  }
212
219
 
213
220
  allowed(ws, id) {
@@ -1,12 +1,12 @@
1
- // The game's own Solana RPC on games.fun, written into server/ by `xcore add rpc`. Part of the game's server code
1
+ // The game's own Solana RPC on xgames.fun, written into server/ by `xcore add rpc`. Part of the game's server code
2
2
  // (docs/SERVER.md): the economy's page client (gamesfun.js → GamesFun.loot) reads and sends through the game's own
3
- // api/rpc instead of games.fun's shared relay (/loot/v1/rpc), so the game's calls go to the RPC its owner pays for
3
+ // api/rpc instead of xgames.fun's shared relay (/loot/v1/rpc), so the game's calls go to the RPC its owner pays for
4
4
  // (Helius, Triton, QuickNode: a devnet one, like loot) and never count toward the game's plan as relay calls.
5
5
  //
6
6
  // The RPC's URL (its key in it) is a secret of the game's server, never the page's:
7
- // xcore secret set XSOLANA_RPC_URL <folder> on games.fun (asked for, never shown)
7
+ // xcore secret set XSOLANA_RPC_URL <folder> on xgames.fun (asked for, never shown)
8
8
  // XSOLANA_RPC_URL=… in the folder's .dev.vars under xcore dev
9
- // Unset, api/rpc says so and the page keeps using games.fun's shared relay: nothing breaks.
9
+ // Unset, api/rpc says so and the page keeps using xgames.fun's shared relay: nothing breaks.
10
10
  //
11
11
  // GET api/rpc → {gamesfun: 'rpc', own: true | false} what the page asks first, once a page load
12
12
  // POST api/rpc one JSON-RPC request → the RPC's own answer
@@ -16,7 +16,7 @@
16
16
  // call counts as one request to the game's server (its plan's rates).
17
17
 
18
18
  export const RPC = {
19
- /** The JSON-RPC methods loot's page client calls (the same list as games.fun's shared relay). */
19
+ /** The JSON-RPC methods loot's page client calls (the same list as xgames.fun's shared relay). */
20
20
  methods: [
21
21
  'getAccountInfo',
22
22
  'getAsset',
@@ -145,7 +145,7 @@ export async function handleRpc(request, env) {
145
145
  return refuse(
146
146
  null,
147
147
  -32000,
148
- "This game has no RPC of its own (XSOLANA_RPC_URL, a devnet https URL): use games.fun's /loot/v1/rpc.",
148
+ "This game has no RPC of its own (XSOLANA_RPC_URL, a devnet https URL): use xgames.fun's /loot/v1/rpc.",
149
149
  503,
150
150
  );
151
151
  const text = await request.text();
@@ -195,7 +195,7 @@ export async function handleRpc(request, env) {
195
195
  }
196
196
  const type = upstream.headers.get('content-type') ?? '';
197
197
  if (!upstream.ok && !type.includes('json')) {
198
- // games.fun's own refusal (an origin not in backend.json) or the RPC's: in words, never the URL with its key
198
+ // xgames.fun's own refusal (an origin not in backend.json) or the RPC's: in words, never the URL with its key
199
199
  const said = (await upstream.text()).split(rpc).join(new URL(rpc).origin).slice(0, 300);
200
200
  return refuse(call.id, -32000, `The game's RPC call was refused (${upstream.status}): ${said}`, 502);
201
201
  }
@@ -0,0 +1,37 @@
1
+ // xcore's stock server: the server a game gets when gamesfun.json has a database and the game has no server/ of its
2
+ // own. It serves the page's api/db/* (gf.db in gamesfun.js) from the game's D1 database, with the same routes and
3
+ // answers as server/database.js (./database.js: the two share that code), under the rules in gamesfun.json →
4
+ // database.collections. Its documents live in a table of its own, gf_docs, which it makes itself; the game's own
5
+ // migrations make any other tables. xcore bundles this file with the rules and the binding's name (gf:stock) as the
6
+ // game's server, so nothing of it is in the game folder.
7
+ import { createData, routeDatabase } from './database.js';
8
+ import SETTINGS from 'gf:stock';
9
+
10
+ /** D1 as createData's exec(query, ...bindings) → rows. */
11
+ function d1(db) {
12
+ return async (q, ...b) => {
13
+ const r = await db
14
+ .prepare(q)
15
+ .bind(...b)
16
+ .all();
17
+ return r.results ?? [];
18
+ };
19
+ }
20
+
21
+ let data = null;
22
+ const dataOf = env => {
23
+ const db = env[SETTINGS.binding];
24
+ if (!db) throw new Error(`The game's database is not bound as env.${SETTINGS.binding}.`);
25
+ return (data ??= createData(d1(db), { rules: SETTINGS.rules, table: 'gf_docs' }));
26
+ };
27
+
28
+ export default {
29
+ async fetch(request, env) {
30
+ const db = await routeDatabase(request, env, (op, args) => dataOf(env)[op](...args));
31
+ if (db) return db;
32
+ // gamesfun.js asks once a page load whether the game has its own RPC (xcore add rpc): it has not
33
+ if (request.method === 'GET' && /\/api\/rpc\/?$/.test(new URL(request.url).pathname))
34
+ return Response.json({ gamesfun: 'rpc', own: false });
35
+ return Response.json({ ok: false, code: 'unknown', why: 'Not a route of this game.' }, { status: 404 });
36
+ },
37
+ };
@@ -1,8 +1,8 @@
1
- // The game's own voucher key on games.fun, written into server/ by `xcore add vouchers`. Part of the game's server code
1
+ // The game's own voucher key on xgames.fun, written into server/ by `xcore add vouchers`. Part of the game's server code
2
2
  // (docs/SERVER.md, "Paying a result"): a reward for a result (a score, a win, a clear) is a grant in the game's economy
3
3
  // claimed with a voucher, and this server signs the voucher once its own check says the result pays. The key is the
4
4
  // owner's secret, never the page's:
5
- // xcore secret set VOUCHER_SEED --new-key <folder> on games.fun: a live key made there, never on this machine
5
+ // xcore secret set VOUCHER_SEED --new-key <folder> on xgames.fun: a live key, sent there only, never kept on this machine
6
6
  // VOUCHER_SEED=… in the folder's .dev.vars under xcore dev: a key of its own, for testing
7
7
  // Unset, nothing is paid and api/vouchers/key says so.
8
8
  //
@@ -18,8 +18,9 @@
18
18
  //
19
19
  // One result pays one wallet once: each voucher names a round (the result's own id) and the GameVouchers Durable
20
20
  // Object remembers every round it signed, so the same result asked again gets the same voucher, never one for another
21
- // wallet or amount, and never one for the grant once it has been made again (a new campaign pays new rounds). The
22
- // voucher bytes are loot's own (server/gf-loot/, a copy of @xgamesfun/solana that publish keeps current).
21
+ // wallet or amount, and never one for the grant once it has been made again (a new campaign pays new rounds). A round
22
+ // goes free → reserved → signed, and roundStep below is the one place that moves it on. The voucher bytes are loot's
23
+ // own (server/gf-loot/, a copy of @xgamesfun/solana that publish keeps current).
23
24
 
24
25
  import { DurableObject } from 'cloudflare:workers';
25
26
  import { proofRequest, signVoucher, verifyProof } from './gf-loot/results/index.js';
@@ -45,8 +46,10 @@ import ECONOMY from '__ECONOMY__';
45
46
  * return { grant: 'clear', round: body.runId, amount: 'grant' }; // the grant's own amount, once a run
46
47
  * },
47
48
  *
48
- * paid (optional) runs once a voucher is signed, with what check answered and the voucher: count a player's daily
49
- * total there, not in check, so a refusal after check (no proven wallet, a round already used) costs them nothing.
49
+ * paid (optional) runs once a round, after its voucher is signed, with what check answered and the voucher: count a
50
+ * player's daily total there, not in check, so a refusal after check (no proven wallet, a round already used) costs
51
+ * them nothing. Not again for the same round asked twice at once or signed anew near its expiry; if paid throws, the
52
+ * round is not counted and a later ask for it runs paid again.
50
53
  * Every wallet is a new player on a chain, and a wallet sign-in costs nothing: cap what a person can take with
51
54
  * VOUCHERS.payOnly 'x' (only players signed in with X, counted per X account) for rewards worth farming, and give each
52
55
  * grant a perDay total in xsolana.json.
@@ -68,7 +71,7 @@ export const VOUCHERS = {
68
71
  linkSeconds: 600,
69
72
  };
70
73
 
71
- const RELAY = 'https://play.xgames.fun/loot/v1/rpc'; // games.fun's devnet relay (the public RPC refuses Workers)
74
+ const RELAY = 'https://play.xgames.fun/loot/v1/rpc'; // xgames.fun's devnet relay (the public RPC refuses Workers)
72
75
  const PATH = /\/api\/vouchers\/(key|pay|link)\/?$/;
73
76
  const te = new TextEncoder();
74
77
  const hex = bytes => Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
@@ -131,6 +134,88 @@ function grantOf(grant) {
131
134
  return { ok: true, project: live.project, id: g.id, key: g.key, perPerson: Number(g.limits?.perPerson ?? 0) };
132
135
  }
133
136
 
137
+ /** A round's record in GameVouchers: `${grant id}:${round}`. */
138
+ const roundKey = (grant, round) => `${grant}:${round}`;
139
+
140
+ /**
141
+ * A round's record, {incarnation, wallet, amount, voucher, counted}, is in one of three states:
142
+ * free no record: no wallet has asked for this result yet.
143
+ * reserved voucher null: the round is this wallet's while its voucher is signed, so a second wallet asking at once
144
+ * is refused. A signing that failed leaves it here: the same wallet may sign it again, at a corrected
145
+ * amount (nothing was handed out for it); any other wallet is still refused.
146
+ * signed a voucher: this wallet and this amount for good. Asked again it gets the same voucher, or one signed
147
+ * anew (same wallet, round and amount) when that one is near its expiry or was signed with an earlier
148
+ * voucher key: the program pays a wallet each round once, so a second signature never pays twice.
149
+ * counted: PAY.paid ran for the round (false until it has; a signed record from before counted existed had its paid
150
+ * run when it was signed, so it counts as counted). A record of an earlier incarnation of the grant is never signed
151
+ * again: grant-remade.
152
+ */
153
+ const stateOf = record => (!record ? 'free' : record.voucher ? 'signed' : 'reserved');
154
+ const countedOf = record => record.counted ?? stateOf(record) === 'signed';
155
+
156
+ /** The voucher a signed record can give back as it is: this wallet and amount, the current key, not near expiry. */
157
+ const freshFor = (record, { wallet, amount, publicKey, now }) =>
158
+ stateOf(record) === 'signed' &&
159
+ record.wallet === wallet &&
160
+ record.amount === amount &&
161
+ record.voucher.publicKey === publicKey &&
162
+ record.voucher.expires - now > VOUCHERS.renewWithin;
163
+
164
+ const roundUsed = () =>
165
+ refuse(
166
+ 409,
167
+ 'round-used',
168
+ 'This result has its voucher already (for another wallet or amount): a new result needs its own round.',
169
+ );
170
+
171
+ /**
172
+ * The one place a round's record moves on. `had` is the record (or undefined), `event` one of
173
+ * {ask: {incarnation, wallet, amount, publicKey, now}} a wallet asks for the round's voucher
174
+ * {signed: {incarnation, wallet, amount, voucher}} the voucher an ask signed, to keep
175
+ * {count: true} | {count: false} PAY.paid is to run (or it threw: not counted after all)
176
+ * → {record?: the record to store (none: unchanged), answer}. An ask answers {ok, sign: true} (sign it), {ok, voucher}
177
+ * (the one it has) or a refusal; signed answers {ok} or a refusal (the round moved on meanwhile: this voucher is not
178
+ * given out); count answers {ok, count} (true: run paid now).
179
+ */
180
+ function roundStep(had, event) {
181
+ const state = stateOf(had);
182
+ if (event.ask) {
183
+ const { incarnation, wallet, amount } = event.ask;
184
+ if (state === 'free')
185
+ return {
186
+ record: { incarnation, wallet, amount, voucher: null, counted: false },
187
+ answer: { ok: true, sign: true },
188
+ };
189
+ if (had.incarnation !== incarnation)
190
+ return {
191
+ answer: refuse(
192
+ 409,
193
+ 'grant-remade',
194
+ 'This result was paid by the grant before it was made again; a new campaign pays new rounds.',
195
+ ),
196
+ };
197
+ if (had.wallet !== wallet) return { answer: roundUsed() };
198
+ // reserved: nothing was handed out, so its wallet may sign it again, at the amount asked now
199
+ if (state === 'reserved') return { record: { ...had, amount, counted: false }, answer: { ok: true, sign: true } };
200
+ if (had.amount !== amount) return { answer: roundUsed() };
201
+ if (freshFor(had, event.ask)) return { answer: { ok: true, voucher: had.voucher } };
202
+ return { answer: { ok: true, sign: true } }; // near its expiry, or an earlier key's: signed anew
203
+ }
204
+ if (event.signed) {
205
+ const { incarnation, wallet, amount, voucher } = event.signed;
206
+ if (state === 'free' || had.incarnation !== incarnation || had.wallet !== wallet || had.amount !== amount)
207
+ return { answer: roundUsed() };
208
+ return { record: { ...had, voucher, counted: countedOf(had) }, answer: { ok: true } };
209
+ }
210
+ if (event.count === true) {
211
+ if (state !== 'signed' || countedOf(had)) return { answer: { ok: true, count: false } };
212
+ return { record: { ...had, counted: true }, answer: { ok: true, count: true } };
213
+ }
214
+ if (event.count === false && state === 'signed' && had.counted === true)
215
+ return { record: { ...had, counted: false }, answer: { ok: true, count: false } };
216
+ return { answer: { ok: true, count: false } };
217
+ }
218
+
134
219
  /**
135
220
  * The Durable Object that remembers each round signed and each player's linked wallet (gamesfun.json →
136
221
  * server.objects: GameVouchers, GAME_VOUCHERS).
@@ -145,17 +230,13 @@ export class GameVouchers extends DurableObject {
145
230
  return this.ctx.storage.get(key);
146
231
  }
147
232
  /**
148
- * The round's record, or null when `want` was just stored as it. One step: an object takes no other call while it
149
- * waits on its own storage, so two requests for the same round at once can't both find it free.
233
+ * A round's record moved on by `event` (roundStep), and its answer. One step: an object takes no other call while it
234
+ * waits on its own storage, so two requests for the same round at once can't both find it free, or both count it.
150
235
  */
151
- async take(key, want) {
152
- const had = await this.ctx.storage.get(key);
153
- if (had) return had;
154
- await this.ctx.storage.put(key, want);
155
- return null;
156
- }
157
- remember(key, value) {
158
- return this.ctx.storage.put(key, value);
236
+ async step(key, event) {
237
+ const { record, answer } = roundStep(await this.ctx.storage.get(key), event);
238
+ if (record) await this.ctx.storage.put(key, record);
239
+ return answer;
159
240
  }
160
241
  /** One more request from `player` this minute: false past `limit`. */
161
242
  hit(player, limit) {
@@ -212,7 +293,13 @@ export async function walletOf(env, player) {
212
293
  * person? (an X account id: a grant with perPerson pays only a person)}. The caller names the wallet: a proven one
213
294
  * (walletOf). → {ok, voucher, again?} (again: the voucher this result had already) | {ok: false, code, why}
214
295
  */
215
- export async function voucherFor(env, { grant, wallet, round, amount, person = null }) {
296
+ export async function voucherFor(env, ask) {
297
+ const { key: _key, ...out } = await signRound(env, ask);
298
+ return out;
299
+ }
300
+
301
+ /** voucherFor's work, with the round's record key (`key`) when it answers a voucher. */
302
+ async function signRound(env, { grant, wallet, round, amount, person = null }) {
216
303
  const signer = await voucherKey(env.VOUCHER_SEED);
217
304
  if (!signer)
218
305
  return refuse(
@@ -241,12 +328,12 @@ export async function voucherFor(env, { grant, wallet, round, amount, person = n
241
328
  "Name the amount: base units above 0, or 'grant' for the grant's own amount (never left out, so it never pays the most by mistake).",
242
329
  );
243
330
  const stub = stubOf(env);
244
- const key = `${g.id}:${r}`;
245
- const now = Math.floor(Date.now() / 1000);
246
- // the same result asked again: its voucher, with no chain read (a stale incarnation's voucher pays nothing anyway)
331
+ const key = roundKey(g.id, r);
332
+ const want = { wallet, amount: String(units), publicKey: signer.address, now: Math.floor(Date.now() / 1000) };
333
+ // the same result asked again: its voucher, with no chain read. (One of an earlier incarnation of the grant comes
334
+ // back here too: it pays nothing, and telling would take the chain read this saves; past here it is grant-remade.)
247
335
  const seen = await stub.peek(key);
248
- if (seen?.voucher && seen.wallet === wallet && seen.amount === String(units))
249
- if (seen.voucher.expires - now > VOUCHERS.renewWithin) return { ok: true, voucher: seen.voucher, again: true };
336
+ if (freshFor(seen, want)) return { ok: true, voucher: seen.voucher, again: true, key };
250
337
  // the grant as it is now: a voucher pays only the incarnation it was signed for
251
338
  const rpc =
252
339
  typeof env.XSOLANA_RPC_URL === 'string' && env.XSOLANA_RPC_URL.trim() ? env.XSOLANA_RPC_URL.trim() : RELAY;
@@ -256,23 +343,9 @@ export async function voucherFor(env, { grant, wallet, round, amount, person = n
256
343
  return refuse(409, 'grant', `The grant ${g.key ?? g.id} is not on the chain (closed, or not live yet).`);
257
344
  const incarnation = String(decode('Grant', account.data).incarnation);
258
345
  // the round is this wallet's from here on, before anything is signed (a second wallet asking at once is refused)
259
- const had = await stub.take(key, { incarnation, wallet, amount: String(units), voucher: null });
260
- if (had) {
261
- if (had.incarnation !== incarnation)
262
- return refuse(
263
- 409,
264
- 'grant-remade',
265
- 'This result was paid by the grant before it was made again; a new campaign pays new rounds.',
266
- );
267
- if (had.wallet !== wallet || had.amount !== String(units))
268
- return refuse(
269
- 409,
270
- 'round-used',
271
- 'This result has its voucher already (for another wallet or amount): a new result needs its own round.',
272
- );
273
- if (had.voucher && had.voucher.expires - now > VOUCHERS.renewWithin)
274
- return { ok: true, voucher: had.voucher, again: true };
275
- }
346
+ const asked = await stub.step(key, { ask: { ...want, incarnation } });
347
+ if (!asked.ok) return asked;
348
+ if (asked.voucher) return { ok: true, voucher: asked.voucher, again: true, key };
276
349
  const voucher = await signVoucher({
277
350
  signer,
278
351
  game: g.project,
@@ -280,13 +353,14 @@ export async function voucherFor(env, { grant, wallet, round, amount, person = n
280
353
  incarnation,
281
354
  wallet,
282
355
  round: r,
283
- now,
356
+ now: want.now,
284
357
  seconds: VOUCHERS.seconds,
285
358
  personHash: await personHashOf(signer, address, person),
286
359
  amount: units,
287
360
  });
288
- await stub.remember(key, { incarnation, wallet, amount: String(units), voucher });
289
- return { ok: true, voucher };
361
+ const kept = await stub.step(key, { signed: { incarnation, wallet, amount: want.amount, voucher } });
362
+ if (!kept.ok) return kept;
363
+ return { ok: true, voucher, key };
290
364
  }
291
365
 
292
366
  /** The signed-in player of a request (env.GF.verifyPlayer's answer), or a refusal. */
@@ -383,14 +457,21 @@ export async function handleVouchers(request, env) {
383
457
  );
384
458
  if (isWallet(body?.wallet) && body.wallet !== wallet)
385
459
  return reply(refuse(403, 'wallet', `This pays your proven wallet (${wallet.slice(0, 4)}…), not the one named.`));
386
- const out = await voucherFor(env, {
460
+ const { key, ...out } = await signRound(env, {
387
461
  grant: pays.grant,
388
462
  wallet,
389
463
  round: pays.round,
390
464
  amount: pays.amount,
391
465
  person: player.x?.id ?? null,
392
466
  });
393
- if (out.ok && !out.again && typeof PAY.paid === 'function')
394
- await PAY.paid({ body, player, env, pays, voucher: out.voucher });
467
+ // paid once a round: the object flips the round to counted once, so two asks at once can't both run it; one that
468
+ // throws puts it back, so a later ask runs it again (the voucher stays the round's either way)
469
+ if (out.ok && typeof PAY.paid === 'function' && (await stub.step(key, { count: true })).count)
470
+ try {
471
+ await PAY.paid({ body, player, env, pays, voucher: out.voucher });
472
+ } catch (e) {
473
+ await stub.step(key, { count: false }).catch(() => {});
474
+ throw e;
475
+ }
395
476
  return reply(out);
396
477
  }
@@ -1,9 +1,9 @@
1
- // GamesFun: sign-in, saves, leaderboards and the game's own database on games.fun, for a Unity WebGL build.
1
+ // GamesFun: sign-in, saves, leaderboards and the game's own database on xgames.fun, for a Unity WebGL build.
2
2
  // Written by `xcore add signin` (with GamesFun.jslib beside it); `xcore add signin` again brings it up to date.
3
3
  // Every call answers the JSON text {"data": …, "error": …, "status": …}; parse it with JsonUtility or your own JSON
4
4
  // library. Outside a WebGL build (the editor, a desktop build) the error says so and the game plays on. In Player
5
5
  // Settings → Publishing Settings, keep Compression Format Disabled (or turn on Decompression Fallback) and keep
6
- // threads off: games.fun sends files as they are and its pages are not cross-origin isolated.
6
+ // threads off: xgames.fun sends files as they are and its pages are not cross-origin isolated.
7
7
  //
8
8
  // string player = await GamesFun.SignIn(); // from a button press: the player JSON, or "null"
9
9
  // GamesFun.PlayerChanged += json => { … }; // sign-in and sign-out
@@ -44,7 +44,7 @@ public class GamesFun : MonoBehaviour
44
44
 
45
45
  private static int next;
46
46
  private static readonly Dictionary<int, TaskCompletionSource<string>> pending = new Dictionary<int, TaskCompletionSource<string>>();
47
- private const string NotHere = "{\"data\":null,\"error\":\"games.fun runs in a WebGL build only\",\"status\":0}";
47
+ private const string NotHere = "{\"data\":null,\"error\":\"xgames.fun runs in a WebGL build only\",\"status\":0}";
48
48
 
49
49
  [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
50
50
  private static void Boot()
@@ -57,7 +57,7 @@ public class GamesFun : MonoBehaviour
57
57
  #endif
58
58
  }
59
59
 
60
- /// <summary>One call into games.fun's page client: a path ("saves.put") and its arguments as a JSON array.</summary>
60
+ /// <summary>One call into xgames.fun's page client: a path ("saves.put") and its arguments as a JSON array.</summary>
61
61
  public static Task<string> Call(string path, string argsJson)
62
62
  {
63
63
  #if UNITY_WEBGL && !UNITY_EDITOR
@@ -90,13 +90,16 @@ public class GamesFun : MonoBehaviour
90
90
  Call("scores.top", "[" + Str(board) + ",{\"limit\":" + limit + ",\"offset\":" + offset + "}]");
91
91
  public static Task<string> ScoresMine(string board) => Call("scores.mine", "[" + Str(board) + "]");
92
92
 
93
- public static Task<string> DbGet(string collection, string id) => Call("db.get", "[" + Str(collection) + "," + Str(id) + "]");
93
+ /// <summary>owner: "me" (or null: the player's own), "server" or a player's id.</summary>
94
+ public static Task<string> DbGet(string collection, string id, string owner = null) =>
95
+ Call("db.get", "[" + Str(collection) + "," + Str(id) + (owner != null ? ",{\"owner\":" + Str(owner) + "}" : "") + "]");
94
96
  public static Task<string> DbPut(string collection, string id, string valueJson) => Call("db.put", "[" + Str(collection) + "," + Str(id) + "," + valueJson + "]");
95
97
  public static Task<string> DbAdd(string collection, string valueJson) => Call("db.add", "[" + Str(collection) + "," + valueJson + "]");
96
- /// <summary>owner: "me", a player id, or null for everyone's; order: "new" or "old".</summary>
97
- public static Task<string> DbList(string collection, string owner = null, int limit = 50, int offset = 0, string order = "new") =>
98
+ /// <summary>owner: "me", "server", a player id, or null for everyone's; after: the last answer's "after" (null on
99
+ /// the last page), for the next page; order: "new" or "old".</summary>
100
+ public static Task<string> DbList(string collection, string owner = null, int limit = 50, string after = null, string order = "new") =>
98
101
  Call("db.list", "[" + Str(collection) + ",{" + (owner != null ? "\"owner\":" + Str(owner) + "," : "")
99
- + "\"limit\":" + limit + ",\"offset\":" + offset + ",\"order\":" + Str(order) + "}]");
102
+ + (after != null ? "\"after\":" + Str(after) + "," : "") + "\"limit\":" + limit + ",\"order\":" + Str(order) + "}]");
100
103
  public static Task<string> DbDelete(string collection, string id) => Call("db.delete", "[" + Str(collection) + "," + Str(id) + "]");
101
104
 
102
105
  // ---- the game's Solana economy (xcore loot add: xsolana.json and gf/loot/ beside the build's index.html) ----
@@ -134,14 +137,14 @@ public class GamesFun : MonoBehaviour
134
137
  return b.ToString();
135
138
  }
136
139
 
137
- /// <summary>The room the page was opened for (?room=CODE, as games.fun's room links pass it), or null.</summary>
140
+ /// <summary>The room the page was opened for (?room=CODE, as xgames.fun's room links pass it), or null.</summary>
138
141
  public static async Task<string> RoomFromLink()
139
142
  {
140
143
  var r = await Call("rooms.fromLink", "[]");
141
144
  return r == "null" || r.StartsWith("{") ? null : r.Trim('"');
142
145
  }
143
146
 
144
- /// <summary>The link that opens the game in this room (games.fun's play page once published).</summary>
147
+ /// <summary>The link that opens the game in this room (xgames.fun's play page once published).</summary>
145
148
  public static async Task<string> RoomLink(string code)
146
149
  {
147
150
  var r = await Call("rooms.link", "[" + Str(code) + "]");
@@ -170,7 +173,7 @@ public class GamesFun : MonoBehaviour
170
173
  return room;
171
174
  #else
172
175
  await Task.Yield();
173
- RoomError = "games.fun rooms run in a WebGL build only";
176
+ RoomError = "xgames.fun rooms run in a WebGL build only";
174
177
  return null;
175
178
  #endif
176
179
  }
@@ -236,7 +239,7 @@ public class GamesFun : MonoBehaviour
236
239
  }
237
240
 
238
241
  /// <summary>
239
- /// A games.fun room (GamesFun.JoinRoom): a relay with a host. The first player in is the host (Id 1); a message goes to
242
+ /// A xgames.fun room (GamesFun.JoinRoom): a relay with a host. The first player in is the host (Id 1); a message goes to
240
243
  /// one player (to: their id), to everyone else (0), or to everyone but one (-id). Events come on Unity's main thread.
241
244
  /// </summary>
242
245
  public class GamesFunRoom
@@ -1,4 +1,4 @@
1
- // GamesFun for a Unity WebGL build: games.fun's page client (gamesfun.js, carried here as base64) and the two entry
1
+ // GamesFun for a Unity WebGL build: xgames.fun's page client (gamesfun.js, carried here as base64) and the two entry
2
2
  // points GamesFun.cs calls. Written by `xcore add signin`; `xcore add signin` again brings it up to date.
3
3
  mergeInto(LibraryManager.library, {
4
4
  GamesFunInit: function (objectNamePtr) {
@@ -1,4 +1,4 @@
1
- // The folder that keeps a person's games.fun sign-in on this machine (and an installed kit's state, under state/): the
1
+ // The folder that keeps a person's xgames.fun sign-in on this machine (and an installed kit's state, under state/): the
2
2
  // same folder for this package and the kit, so signing in with either is signing in with both.
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';