@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
|
@@ -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
|
|
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
|
|
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 &&
|
|
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) {
|
package/lib/client/server/rpc.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
// The game's own Solana RPC on
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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).
|
|
22
|
-
//
|
|
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
|
|
49
|
-
* total there, not in check, so a refusal after check (no proven wallet, a round already used) costs
|
|
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'; //
|
|
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
|
-
*
|
|
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
|
|
152
|
-
const
|
|
153
|
-
if (
|
|
154
|
-
|
|
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,
|
|
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 =
|
|
245
|
-
const
|
|
246
|
-
// the same result asked again: its voucher, with no chain read (
|
|
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
|
|
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
|
|
260
|
-
if (
|
|
261
|
-
|
|
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.
|
|
289
|
-
|
|
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
|
|
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
|
-
|
|
394
|
-
|
|
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
|
|
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:
|
|
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\":\"
|
|
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
|
|
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
|
-
|
|
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;
|
|
97
|
-
|
|
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
|
-
+ "\"
|
|
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
|
|
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 (
|
|
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 = "
|
|
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
|
|
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:
|
|
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) {
|
package/lib/config-dir.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The folder that keeps a person's
|
|
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';
|