@xgamesfun/xcore 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +183 -84
- package/bin/xcore.mjs +13 -8
- package/commands/add.mjs +11 -8
- package/commands/adopt.mjs +9 -7
- package/commands/backend.mjs +2 -2
- package/commands/check.mjs +4 -4
- package/commands/db.mjs +218 -0
- package/commands/dev.mjs +8 -6
- package/commands/domain.mjs +8 -8
- package/commands/embed.mjs +3 -3
- package/commands/files.mjs +106 -0
- package/commands/login.mjs +9 -17
- package/commands/logout.mjs +15 -4
- package/commands/loot.mjs +2 -2
- package/commands/plan.mjs +22 -19
- package/commands/publish.mjs +54 -21
- package/commands/secret.mjs +6 -6
- package/docs/SERVER.md +218 -18
- package/lib/add.mjs +265 -40
- package/lib/browser.mjs +11 -2
- package/lib/check.mjs +10 -9
- package/lib/cli.mjs +24 -9
- package/lib/client/gamesfun.js +242 -122
- package/lib/client/godot/gamesfun.gd +17 -11
- package/lib/client/godot/gamesfun_peer.gd +2 -2
- package/lib/client/server/0001_init.sql +10 -0
- package/lib/client/server/database.js +271 -109
- package/lib/client/server/gf-db.js +55 -0
- package/lib/client/server/gf-docs.js +56 -0
- package/lib/client/server/rooms.js +16 -9
- package/lib/client/server/rpc.js +7 -7
- package/lib/client/server/stock.js +37 -0
- package/lib/client/server/vouchers.js +127 -46
- package/lib/client/unity/GamesFun.cs +15 -12
- package/lib/client/unity/GamesFun.jslib +1 -1
- package/lib/config-dir.mjs +1 -1
- package/lib/credentials.mjs +10 -5
- package/lib/db.mjs +355 -0
- package/lib/dev/d1.mjs +486 -0
- package/lib/dev/files.mjs +247 -0
- package/lib/dev/gf-local.js +69 -3
- package/lib/dev/index.mjs +68 -20
- package/lib/dev/limits.mjs +2 -2
- package/lib/dev/platform.mjs +111 -12
- package/lib/dev/server.mjs +54 -5
- package/lib/dev/tools.mjs +71 -18
- package/lib/files.mjs +208 -0
- package/lib/open.mjs +1 -1
- package/lib/play-policy.mjs +14 -5
- package/lib/publish/adopt.mjs +73 -33
- package/lib/publish/client.mjs +130 -20
- package/lib/publish/gate.mjs +4 -2
- package/lib/publish/hosting.mjs +5 -5
- package/lib/publish/index.mjs +162 -40
- package/lib/publish/oidc.mjs +26 -5
- package/lib/publish/page.mjs +1 -1
- package/lib/publish/plans.mjs +19 -12
- package/lib/publish/scan.mjs +78 -102
- package/lib/publish/secrets.mjs +4 -5
- package/lib/publish/site.mjs +9 -10
- package/lib/publish/tree.mjs +8 -8
- package/lib/secret-patterns.mjs +53 -16
- package/lib/secret-store.mjs +2 -2
- package/lib/server/bundle.mjs +113 -13
- package/lib/server/contract.mjs +440 -16
- package/lib/server/data-calls.mjs +136 -0
- package/lib/server/secrets.mjs +9 -7
- package/lib/solana-rpc.mjs +5 -5
- package/lib/state-folder.mjs +23 -0
- package/package.json +4 -4
- package/skills/bring-your-own-game/SKILL.md +10 -9
package/lib/client/gamesfun.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
// gamesfun.js: sign-in, saves, leaderboards, the game's own database and multiplayer rooms for a game published on
|
|
2
|
-
//
|
|
2
|
+
// xgames.fun, from any engine. One file with no dependencies: a <script src="gamesfun.js"> in the page (or an import of it), and Godot's and
|
|
3
3
|
// Unity's glue (xcore add signin) load the same file. It sets globalThis.GamesFun; every call answers
|
|
4
|
-
// {data, error, status} and none throws, so a game plays on, as a guest, wherever
|
|
5
|
-
//
|
|
4
|
+
// {data, error, status} and none throws, so a game plays on, as a guest, wherever xgames.fun is not there. A refusal from
|
|
5
|
+
// xgames.fun also carries code, its word for why (for example 'server-only': a board only the game's server writes).
|
|
6
6
|
//
|
|
7
7
|
// const gf = GamesFun;
|
|
8
|
-
// await gf.ready; // what the page already knows (a sign-in the
|
|
8
|
+
// await gf.ready; // what the page already knows (a sign-in the xgames.fun page has)
|
|
9
9
|
// gf.player; // null, or {id, name, level, wallet, x}
|
|
10
10
|
// gf.local; // true on this machine (localhost, xcore check, xcore dev)
|
|
11
11
|
// gf.dev; // true under xcore dev, where the game's server, saves and
|
|
12
|
-
// // sign-in answer as
|
|
12
|
+
// // sign-in answer as xgames.fun's will: "no server here" is
|
|
13
13
|
// // gf.local && !gf.dev, never gf.local alone
|
|
14
14
|
// await gf.signIn(); // from a click: the player signs in → player | null
|
|
15
15
|
// gf.onChange(player => …); // sign-in and sign-out
|
|
@@ -17,26 +17,27 @@
|
|
|
17
17
|
// // when true, only as a quiet line: X's login hides the window's state
|
|
18
18
|
// await gf.saves.put('main', {level: 3}); // this player's saves: JSON, 64 KB each, 16 a player
|
|
19
19
|
// await gf.scores.submit('best', 1200); // leaderboards: a player's best is kept
|
|
20
|
-
// await gf.db.add('levels', {name: 'Moon'}); // the game's own database (xcore add database)
|
|
20
|
+
// await gf.db.add('levels', {name: 'Moon'}); // the game's own database (xcore add database): each player
|
|
21
|
+
// // their own documents; gf.db.list pages with {after}
|
|
21
22
|
// await gf.api('scores', {method: 'POST', body: {}}); // the game's own server (server/), signed in
|
|
22
23
|
// const {data: room} = await gf.rooms.join(gf.rooms.fromLink() ?? gf.rooms.code()); // multiplayer (xcore add multiplayer)
|
|
23
24
|
// room.on('message', (from, data) => …); room.send({x: 1}); share gf.rooms.link(room.code)
|
|
24
25
|
//
|
|
25
|
-
// Where sign-in comes from (
|
|
26
|
-
// - inside
|
|
27
|
-
// only
|
|
28
|
-
// - on the game's own link or inside a post on X, a small window on
|
|
26
|
+
// Where sign-in comes from (xgames.fun's player sign-in: X first, a wallet as well):
|
|
27
|
+
// - inside xgames.fun's play page, the page above signs the player in and hands this page a token (gf:host / gf:auth);
|
|
28
|
+
// only xgames.fun's own page may: framed by any other site (one embedding the game), the game signs in by itself;
|
|
29
|
+
// - on the game's own link or inside a post on X, a small window on xgames.fun signs in (or a code the player types at
|
|
29
30
|
// <site>/go on any device), and this page asks the play host until it is answered (/player/v1/request);
|
|
30
31
|
// - on this machine (localhost, 127.0.0.1, xcore check), a local player and this browser's storage stand in, so the
|
|
31
32
|
// game can be made and tested before it is published;
|
|
32
|
-
// - in `xcore dev` (its pages set globalThis.GAMESFUN_DEV), the dev host answers as
|
|
33
|
+
// - in `xcore dev` (its pages set globalThis.GAMESFUN_DEV), the dev host answers as xgames.fun would: a local sign-in
|
|
33
34
|
// (each tab its own player), saves and scores on its platform, the game's own server for the database and rooms.
|
|
34
35
|
// The token lasts an hour and is kept in memory only. A server checks it with env.GF.verifyPlayer (server/), or any
|
|
35
36
|
// JWT library against <play host>/player/v1/key (a server of your own).
|
|
36
37
|
(function () {
|
|
37
38
|
'use strict';
|
|
38
39
|
if (globalThis.GamesFun) return;
|
|
39
|
-
var VERSION = '1.
|
|
40
|
+
var VERSION = '1.4.0';
|
|
40
41
|
var PLATFORM = '/platform/v1/';
|
|
41
42
|
var SEG = /^[A-Za-z0-9_-]{1,64}$/;
|
|
42
43
|
var POLL_MS = 2000;
|
|
@@ -48,11 +49,11 @@
|
|
|
48
49
|
var edge = pathSlug ? null : /^([a-z0-9]+(?:-[a-z0-9]+)*)-play\.([a-z0-9.-]+(?::\d+)?)$/.exec(loc.host);
|
|
49
50
|
var slug = pathSlug || (edge ? edge[1] : null);
|
|
50
51
|
var local = loc.protocol === 'file:' || /^(localhost|127\.\d+\.\d+\.\d+|\[::1\])$/.test(loc.hostname);
|
|
51
|
-
// xcore dev: everything goes to the dev host, as it would to
|
|
52
|
+
// xcore dev: everything goes to the dev host, as it would to xgames.fun; only the sign-in is its own
|
|
52
53
|
var dev = local && !!globalThis.GAMESFUN_DEV;
|
|
53
54
|
if (dev) local = false;
|
|
54
|
-
// on the game's own domain (play.mygame.com, xcore domain):
|
|
55
|
-
//
|
|
55
|
+
// on the game's own domain (play.mygame.com, xcore domain): xgames.fun's play edge says which game this is and where
|
|
56
|
+
// xgames.fun is (GET /platform/v1/site, asked once at load; gf.ready waits for it)
|
|
56
57
|
var site = null;
|
|
57
58
|
function siteOrigin(v) {
|
|
58
59
|
try {
|
|
@@ -60,7 +61,7 @@
|
|
|
60
61
|
if (u.origin !== v) return null;
|
|
61
62
|
if (u.protocol === 'https:' || (u.protocol === 'http:' && /^(localhost|127\.\d+\.\d+\.\d+)$/.test(u.hostname)))
|
|
62
63
|
return u.origin;
|
|
63
|
-
// a stand-in
|
|
64
|
+
// a stand-in xgames.fun in tests (http on a name this machine resolves)
|
|
64
65
|
return u.protocol === 'http:' && /\.test$/.test(u.hostname) ? u.origin : null;
|
|
65
66
|
} catch {
|
|
66
67
|
return null;
|
|
@@ -182,7 +183,7 @@
|
|
|
182
183
|
function signedOut() {
|
|
183
184
|
return Promise.resolve({ data: null, error: 'Sign in first.', status: 401 });
|
|
184
185
|
}
|
|
185
|
-
/**
|
|
186
|
+
/** xgames.fun's word for a refusal (r.code), kept on the answer made from r. */
|
|
186
187
|
function withCode(answer, r) {
|
|
187
188
|
if (r.code) answer.code = r.code;
|
|
188
189
|
return answer;
|
|
@@ -242,7 +243,7 @@
|
|
|
242
243
|
});
|
|
243
244
|
}
|
|
244
245
|
|
|
245
|
-
// ---- the page above (
|
|
246
|
+
// ---- the page above (xgames.fun's play page) ----
|
|
246
247
|
var above = null; // the parent window, once it answered gf:host
|
|
247
248
|
var aboveAuth = false;
|
|
248
249
|
var framed;
|
|
@@ -252,7 +253,7 @@
|
|
|
252
253
|
framed = true;
|
|
253
254
|
}
|
|
254
255
|
var waiters = [];
|
|
255
|
-
// a token from
|
|
256
|
+
// a token from xgames.fun: the player as the platform knows them (the token's subject), said once
|
|
256
257
|
function takeToken(token, guess, expires) {
|
|
257
258
|
gf.token = token;
|
|
258
259
|
gf.expires = expires;
|
|
@@ -281,7 +282,7 @@
|
|
|
281
282
|
gf.ready = new Promise(function (resolve) {
|
|
282
283
|
readyResolve = resolve;
|
|
283
284
|
});
|
|
284
|
-
// A message from the page above counts only from
|
|
285
|
+
// A message from the page above counts only from xgames.fun's own page (its origin, or www. of it): a site that
|
|
285
286
|
// embeds the game can't hand it a sign-in or take its sign-in over.
|
|
286
287
|
function fromSite(origin) {
|
|
287
288
|
var s = studio();
|
|
@@ -351,63 +352,79 @@
|
|
|
351
352
|
} else readyResolve(gf.player);
|
|
352
353
|
}
|
|
353
354
|
|
|
354
|
-
// ---- sign-in by itself: a request answered on
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
355
|
+
// ---- sign-in by itself: a request answered on xgames.fun (its own link, a post on X) ----
|
|
356
|
+
// One sign-in at a time: {promise, resolve, timer, popup, req}. A second click shares it, a sign-out ends it with
|
|
357
|
+
// null, and an answer from xgames.fun that comes after it ended (a poll already on its way) is dropped.
|
|
358
|
+
var pending = null;
|
|
358
359
|
function post(path, body) {
|
|
359
360
|
return call(
|
|
360
361
|
path,
|
|
361
362
|
{ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) },
|
|
362
|
-
'
|
|
363
|
+
'xgames.fun sign-in',
|
|
363
364
|
);
|
|
364
365
|
}
|
|
365
|
-
function
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
gf.code = null;
|
|
371
|
-
return done(null);
|
|
366
|
+
function closePopup(p) {
|
|
367
|
+
try {
|
|
368
|
+
if (p.popup) p.popup.close();
|
|
369
|
+
} catch {
|
|
370
|
+
/* closed */
|
|
372
371
|
}
|
|
373
|
-
|
|
374
|
-
|
|
372
|
+
p.popup = null;
|
|
373
|
+
}
|
|
374
|
+
// the sign-in `p` is over: answered with `player` (once; nothing if it ended already)
|
|
375
|
+
function endSignIn(p, player) {
|
|
376
|
+
if (pending !== p) return;
|
|
377
|
+
pending = null;
|
|
378
|
+
clearTimeout(p.timer);
|
|
379
|
+
p.req = null;
|
|
380
|
+
gf.code = null;
|
|
381
|
+
closePopup(p);
|
|
382
|
+
p.resolve(player);
|
|
383
|
+
}
|
|
384
|
+
function poll(p) {
|
|
385
|
+
if (pending !== p) return;
|
|
386
|
+
clearTimeout(p.timer);
|
|
387
|
+
if (p.req.expires <= Date.now()) return endSignIn(p, null);
|
|
388
|
+
p.timer = setTimeout(function () {
|
|
389
|
+
if (pending !== p || !p.req) return;
|
|
390
|
+
post('/player/v1/request/status', { id: p.req.id, secret: p.req.secret }).then(function (r) {
|
|
391
|
+
if (pending !== p || !p.req) return; // signed out (or ended) while this was on its way
|
|
375
392
|
var s = r.data || {};
|
|
376
393
|
if (s.state === 'signed-in') {
|
|
377
|
-
req = null;
|
|
394
|
+
p.req = null;
|
|
378
395
|
gf.code = null;
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
/* closed */
|
|
383
|
-
}
|
|
384
|
-
popup = null;
|
|
385
|
-
var p = s.player || {};
|
|
386
|
-
var x = p.x && p.x.handle ? p.x : null;
|
|
396
|
+
closePopup(p);
|
|
397
|
+
var pl = s.player || {};
|
|
398
|
+
var x = pl.x && pl.x.handle ? pl.x : null;
|
|
387
399
|
var guess = {
|
|
388
400
|
id: '',
|
|
389
|
-
name: x ? '@' + x.handle :
|
|
390
|
-
level:
|
|
391
|
-
wallet:
|
|
401
|
+
name: x ? '@' + x.handle : pl.wallet || '',
|
|
402
|
+
level: pl.level,
|
|
403
|
+
wallet: pl.wallet || '',
|
|
392
404
|
x: x,
|
|
393
405
|
};
|
|
394
|
-
|
|
406
|
+
// still this sign-in's until the token is taken: a click meanwhile waits for the same player
|
|
407
|
+
takeToken(s.token, guess, s.expires).then(function (player) {
|
|
408
|
+
endSignIn(p, player);
|
|
409
|
+
});
|
|
395
410
|
return;
|
|
396
411
|
}
|
|
397
|
-
if (s.state === 'expired')
|
|
398
|
-
|
|
399
|
-
gf.code = null;
|
|
400
|
-
return done(null);
|
|
401
|
-
}
|
|
402
|
-
poll(done);
|
|
412
|
+
if (s.state === 'expired') return endSignIn(p, null);
|
|
413
|
+
poll(p);
|
|
403
414
|
});
|
|
404
415
|
}, POLL_MS);
|
|
405
416
|
}
|
|
406
417
|
function requestSignIn(how) {
|
|
407
418
|
if (!slug) return Promise.resolve(null);
|
|
419
|
+
if (pending) return pending.promise;
|
|
420
|
+
var p = { promise: null, resolve: null, timer: null, popup: null, req: null };
|
|
421
|
+
p.promise = new Promise(function (resolve) {
|
|
422
|
+
p.resolve = resolve;
|
|
423
|
+
});
|
|
424
|
+
pending = p;
|
|
408
425
|
// the window opens at the click (one opened after an await is blocked), then goes to the code's page
|
|
409
426
|
try {
|
|
410
|
-
popup = globalThis.open(
|
|
427
|
+
p.popup = globalThis.open(
|
|
411
428
|
studio() + '/go',
|
|
412
429
|
'gamesfun-signin',
|
|
413
430
|
'popup,width=440,height=600,left=' +
|
|
@@ -416,33 +433,25 @@
|
|
|
416
433
|
Math.max(0, Math.round((screen.availHeight || 800) / 2 - 300)),
|
|
417
434
|
);
|
|
418
435
|
} catch {
|
|
419
|
-
popup = null;
|
|
436
|
+
p.popup = null;
|
|
420
437
|
}
|
|
421
|
-
|
|
422
|
-
if (
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
}
|
|
428
|
-
popup = null;
|
|
429
|
-
return null;
|
|
430
|
-
}
|
|
431
|
-
req = { id: r.data.id, secret: r.data.secret, expires: r.data.expires };
|
|
432
|
-
gf.code = { code: r.data.code, site: r.data.site, url: r.data.url, window: !!popup };
|
|
433
|
-
if (popup && !popup.closed) {
|
|
438
|
+
post('/player/v1/request', { slug: slug, with: how }).then(function (r) {
|
|
439
|
+
if (pending !== p) return; // signed out before xgames.fun answered
|
|
440
|
+
if (!r.data || !r.data.id) return endSignIn(p, null);
|
|
441
|
+
p.req = { id: r.data.id, secret: r.data.secret, expires: r.data.expires };
|
|
442
|
+
gf.code = { code: r.data.code, site: r.data.site, url: r.data.url, window: !!p.popup };
|
|
443
|
+
if (p.popup && !p.popup.closed) {
|
|
434
444
|
try {
|
|
435
|
-
popup.location.href = r.data.url + (r.data.url.indexOf('?') < 0 ? '?' : '&') + 'with=' + how;
|
|
445
|
+
p.popup.location.href = r.data.url + (r.data.url.indexOf('?') < 0 ? '?' : '&') + 'with=' + how;
|
|
436
446
|
} catch {
|
|
437
|
-
popup = null;
|
|
447
|
+
p.popup = null;
|
|
438
448
|
}
|
|
439
449
|
}
|
|
440
450
|
// always said: the game may show the code, and must when no window opened (a phone, a blocked window)
|
|
441
451
|
emit(codeFns, gf.code);
|
|
442
|
-
|
|
443
|
-
poll(resolve);
|
|
444
|
-
});
|
|
452
|
+
poll(p);
|
|
445
453
|
});
|
|
454
|
+
return p.promise;
|
|
446
455
|
}
|
|
447
456
|
|
|
448
457
|
/**
|
|
@@ -452,6 +461,8 @@
|
|
|
452
461
|
gf.signIn = function (opts) {
|
|
453
462
|
opts = opts || {};
|
|
454
463
|
var how = opts.how === 'wallet' ? 'wallet' : 'x';
|
|
464
|
+
// a sign-in on its way (to its last step, the player's token being taken): this click waits for the same one
|
|
465
|
+
if (pending && opts.prompt !== false) return pending.promise;
|
|
455
466
|
if (gf.token && gf.expires > Date.now() + 60e3) return Promise.resolve(gf.player);
|
|
456
467
|
if (dev) return opts.prompt === false ? Promise.resolve(gf.player) : devSignIn(opts.name);
|
|
457
468
|
if (local) {
|
|
@@ -482,8 +493,7 @@
|
|
|
482
493
|
return requestSignIn(how);
|
|
483
494
|
};
|
|
484
495
|
gf.signOut = function () {
|
|
485
|
-
|
|
486
|
-
req = null;
|
|
496
|
+
if (pending) endSignIn(pending, null);
|
|
487
497
|
gf.code = null;
|
|
488
498
|
if (local) store('signedIn', null);
|
|
489
499
|
if (dev) devTab(Object.assign(devTab() || {}, { signedIn: false }));
|
|
@@ -701,16 +711,27 @@
|
|
|
701
711
|
);
|
|
702
712
|
},
|
|
703
713
|
);
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
return
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
714
|
+
// xsolana.json; a game made before it was named so keeps loot.json (read only when there is no xsolana.json)
|
|
715
|
+
var specFile = function (name) {
|
|
716
|
+
return fetch(at(name), { cache: 'no-store' }).then(function (r) {
|
|
717
|
+
return { name: name, r: r };
|
|
718
|
+
});
|
|
719
|
+
};
|
|
720
|
+
var spec = specFile('xsolana.json')
|
|
721
|
+
.then(function (f) {
|
|
722
|
+
return f.r.status === 404 ? specFile('loot.json') : f;
|
|
723
|
+
})
|
|
724
|
+
.then(function (f) {
|
|
725
|
+
if (f.r.status === 404) throw new Error('This game has no economy here (no xsolana.json beside it).');
|
|
726
|
+
if (!f.r.ok) throw new Error(f.name + ' could not be read (' + f.r.status + ').');
|
|
727
|
+
return f.r.text().then(function (t) {
|
|
728
|
+
try {
|
|
729
|
+
return JSON.parse(t);
|
|
730
|
+
} catch {
|
|
731
|
+
throw new Error(f.name + ' is not JSON: check it with xcore loot check.');
|
|
732
|
+
}
|
|
733
|
+
});
|
|
712
734
|
});
|
|
713
|
-
});
|
|
714
735
|
// the game's own RPC (xcore add rpc: server/rpc.js at api/rpc, its URL a secret of the server) when it has one;
|
|
715
736
|
// else the play host's shared relay (PAGE_RPC). Asked once a page load; any other answer is the shared relay.
|
|
716
737
|
var own = fetch(gf.apiUrl('rpc'), { cache: 'no-store' })
|
|
@@ -843,11 +864,22 @@
|
|
|
843
864
|
},
|
|
844
865
|
};
|
|
845
866
|
|
|
846
|
-
// ---- the game's own database (xcore add database: server/ with a SQLite database on
|
|
847
|
-
// Documents in collections: {id, owner, value, updatedAt}.
|
|
848
|
-
//
|
|
867
|
+
// ---- the game's own database (xcore add database: server/ with a SQLite database on xgames.fun) ----
|
|
868
|
+
// Documents in collections: {id, owner, value, updatedAt}. Each player has a space of their own in a collection (the
|
|
869
|
+
// same id in two players' spaces is two documents); a signed-in player reads and writes their own (the rules at the
|
|
870
|
+
// top of server/database.js, or gamesfun.json → database.collections, say otherwise per collection). opts.owner names
|
|
871
|
+
// another space to read: 'server' (what the game's server wrote) or a player's id.
|
|
872
|
+
// On this machine only the local player's space exists, in this browser's storage.
|
|
849
873
|
function localDocs(c) {
|
|
850
|
-
|
|
874
|
+
var docs = store('db2:' + c);
|
|
875
|
+
if (docs) return docs;
|
|
876
|
+
// an older gamesfun.js kept one id per collection here: each document moves to its owner's space
|
|
877
|
+
var old = store('db:' + c) || {};
|
|
878
|
+
docs = {};
|
|
879
|
+
Object.keys(old).forEach(function (id) {
|
|
880
|
+
docs[old[id].owner + '/' + id] = old[id];
|
|
881
|
+
});
|
|
882
|
+
return docs;
|
|
851
883
|
}
|
|
852
884
|
function newId() {
|
|
853
885
|
var a = new Uint8Array(9);
|
|
@@ -865,53 +897,131 @@
|
|
|
865
897
|
})
|
|
866
898
|
.join('');
|
|
867
899
|
}
|
|
900
|
+
function localOwner(opts) {
|
|
901
|
+
var o = opts && opts.owner;
|
|
902
|
+
return !o || o === 'me' ? LOCAL_PLAYER.id : String(o);
|
|
903
|
+
}
|
|
904
|
+
// a list's place: the last document of a page ([updatedAt, owner, id]), handed back as it came
|
|
905
|
+
function localCursor(d) {
|
|
906
|
+
return btoa(JSON.stringify([d.updatedAt, d.owner, d.id]));
|
|
907
|
+
}
|
|
908
|
+
function localAfter(text) {
|
|
909
|
+
try {
|
|
910
|
+
var c = JSON.parse(atob(String(text)));
|
|
911
|
+
return Array.isArray(c) && c.length === 3 ? c : null;
|
|
912
|
+
} catch {
|
|
913
|
+
return null;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
function order3(a, b) {
|
|
917
|
+
var x = [a.updatedAt, a.owner, a.id],
|
|
918
|
+
y = [b.updatedAt, b.owner, b.id];
|
|
919
|
+
for (var i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
|
|
920
|
+
return 0;
|
|
921
|
+
}
|
|
922
|
+
function notYours() {
|
|
923
|
+
return Promise.resolve({ data: null, error: 'A player writes only their own documents.', status: 403 });
|
|
924
|
+
}
|
|
868
925
|
var localDb = {
|
|
869
|
-
get: function (c, id) {
|
|
870
|
-
return ok(localDocs(c)[id] || null);
|
|
926
|
+
get: function (c, id, opts) {
|
|
927
|
+
return ok(localDocs(c)[localOwner(opts) + '/' + id] || null);
|
|
871
928
|
},
|
|
872
|
-
put: function (c, id, value) {
|
|
929
|
+
put: function (c, id, value, opts) {
|
|
930
|
+
if (localOwner(opts) !== LOCAL_PLAYER.id) return notYours();
|
|
873
931
|
var docs = localDocs(c);
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
return ok(docs[id]);
|
|
932
|
+
var key = LOCAL_PLAYER.id + '/' + id;
|
|
933
|
+
docs[key] = { id: id, owner: LOCAL_PLAYER.id, value: value, updatedAt: Date.now() };
|
|
934
|
+
store('db2:' + c, docs);
|
|
935
|
+
return ok(docs[key]);
|
|
879
936
|
},
|
|
880
937
|
add: function (c, value) {
|
|
881
938
|
return localDb.put(c, newId(), value);
|
|
882
939
|
},
|
|
883
940
|
list: function (c, opts) {
|
|
884
941
|
opts = opts || {};
|
|
885
|
-
|
|
886
|
-
|
|
942
|
+
if (noOffset(opts)) return noOffset(opts);
|
|
943
|
+
var docs = localDocs(c);
|
|
944
|
+
var all = Object.keys(docs).map(function (k) {
|
|
945
|
+
return docs[k];
|
|
887
946
|
});
|
|
888
947
|
if (opts.owner)
|
|
889
948
|
all = all.filter(function (d) {
|
|
890
|
-
return d.owner === (opts
|
|
949
|
+
return d.owner === localOwner(opts);
|
|
891
950
|
});
|
|
951
|
+
var old = opts.order === 'old';
|
|
892
952
|
all.sort(function (a, b) {
|
|
893
|
-
return
|
|
953
|
+
return old ? order3(a, b) : order3(b, a);
|
|
954
|
+
});
|
|
955
|
+
if (opts.after) {
|
|
956
|
+
var at = localAfter(opts.after);
|
|
957
|
+
if (!at) return Promise.resolve({ data: null, error: "after is the last answer's after.", status: 400 });
|
|
958
|
+
var mark = { updatedAt: at[0], owner: at[1], id: at[2] };
|
|
959
|
+
all = all.filter(function (d) {
|
|
960
|
+
return old ? order3(d, mark) > 0 : order3(d, mark) < 0;
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
var n = Math.max(1, Math.min(100, opts.limit | 0 || 50));
|
|
964
|
+
var page = all.slice(0, n);
|
|
965
|
+
var more = all.length > n;
|
|
966
|
+
return Promise.resolve({
|
|
967
|
+
data: page,
|
|
968
|
+
after: more ? localCursor(page[page.length - 1]) : null,
|
|
969
|
+
more: more,
|
|
970
|
+
error: null,
|
|
971
|
+
status: 200,
|
|
894
972
|
});
|
|
895
|
-
var offset = opts.offset | 0;
|
|
896
|
-
return ok(all.slice(offset, offset + (opts.limit | 0 || 50)));
|
|
897
973
|
},
|
|
898
|
-
delete: function (c, id) {
|
|
974
|
+
delete: function (c, id, opts) {
|
|
975
|
+
if (localOwner(opts) !== LOCAL_PLAYER.id) return notYours();
|
|
899
976
|
var docs = localDocs(c);
|
|
900
|
-
var
|
|
901
|
-
|
|
902
|
-
|
|
977
|
+
var key = LOCAL_PLAYER.id + '/' + id;
|
|
978
|
+
var had = !!docs[key];
|
|
979
|
+
delete docs[key];
|
|
980
|
+
store('db2:' + c, docs);
|
|
903
981
|
return ok({ deleted: had });
|
|
904
982
|
},
|
|
905
983
|
};
|
|
906
984
|
function useLocalDb() {
|
|
907
985
|
return local;
|
|
908
986
|
}
|
|
987
|
+
var ownerSaid = {};
|
|
988
|
+
// offset paging is gone (each player has a space of their own, and a page follows the last one's place): a real
|
|
989
|
+
// offset is refused, so code that pages with it stops at once instead of reading its first page again and again;
|
|
990
|
+
// 0 (or none) is the first page, which is what it always was
|
|
991
|
+
function noOffset(opts) {
|
|
992
|
+
var o = opts && opts.offset;
|
|
993
|
+
return o !== undefined && o !== null && o !== '' && Number(o) !== 0
|
|
994
|
+
? Promise.resolve({
|
|
995
|
+
data: null,
|
|
996
|
+
error:
|
|
997
|
+
"gf.db.list takes no offset any more: pass the last answer's after for the next page (gf.db.list(c, {after}))",
|
|
998
|
+
status: 0,
|
|
999
|
+
code: 'offset',
|
|
1000
|
+
})
|
|
1001
|
+
: null;
|
|
1002
|
+
}
|
|
1003
|
+
function ownerQuery(opts) {
|
|
1004
|
+
return opts && opts.owner !== undefined && opts.owner !== null && opts.owner !== ''
|
|
1005
|
+
? '?owner=' + encodeURIComponent(opts.owner)
|
|
1006
|
+
: '';
|
|
1007
|
+
}
|
|
909
1008
|
gf.db = {
|
|
910
|
-
/**
|
|
911
|
-
|
|
1009
|
+
/** opts {owner: 'me' | 'server' | a player's id}; without it, the player's own (in a collection only the server
|
|
1010
|
+
* writes: the server's, then the player's own). Another player's document is read by its owner: gf.db.get(c, id,
|
|
1011
|
+
* {owner: doc.owner}). → {data: {id, owner, value, updatedAt} | null} */
|
|
1012
|
+
get: function (c, id, opts) {
|
|
912
1013
|
if (!seg(c) || !seg(id)) return bad('collection or id');
|
|
913
|
-
if (useLocalDb()) return localDb.get(c, id);
|
|
914
|
-
return gf.api('db/' + seg(c) + '/' + seg(id)).then(function (r) {
|
|
1014
|
+
if (useLocalDb()) return localDb.get(c, id, opts);
|
|
1015
|
+
return gf.api('db/' + seg(c) + '/' + seg(id) + ownerQuery(opts)).then(function (r) {
|
|
1016
|
+
// not in this player's space, and another player has a document with this id: null, as any document that is
|
|
1017
|
+
// not there, and said once, since an id alone named any player's before (gamesfun.js 1.3)
|
|
1018
|
+
if (r.code === 'owner') {
|
|
1019
|
+
if (!ownerSaid[c]) {
|
|
1020
|
+
ownerSaid[c] = true;
|
|
1021
|
+
if (globalThis.console) console.warn('gf.db.get: ' + r.error);
|
|
1022
|
+
}
|
|
1023
|
+
return { data: null, error: null, status: 404, code: 'owner', why: r.error };
|
|
1024
|
+
}
|
|
915
1025
|
return withCode(
|
|
916
1026
|
{ data: r.data ? r.data.item : null, error: r.status === 404 ? null : r.error, status: r.status },
|
|
917
1027
|
r.status === 404 ? {} : r,
|
|
@@ -928,7 +1038,7 @@
|
|
|
928
1038
|
});
|
|
929
1039
|
});
|
|
930
1040
|
},
|
|
931
|
-
/** A new document with an id made for it. → {data: the document} */
|
|
1041
|
+
/** A new document of the player's own, with an id made for it. → {data: the document} */
|
|
932
1042
|
add: function (c, value) {
|
|
933
1043
|
if (!seg(c)) return bad('collection');
|
|
934
1044
|
if (useLocalDb()) return localDb.add(c, value);
|
|
@@ -938,21 +1048,30 @@
|
|
|
938
1048
|
});
|
|
939
1049
|
});
|
|
940
1050
|
},
|
|
941
|
-
/** → {data: [document]
|
|
1051
|
+
/** → {data: [document], after, more}: newest first, a page at a time; pass `after` back for the next page.
|
|
1052
|
+
* opts {owner: 'me' | 'server' | a player's id, limit (≤ 100), after, order: 'new' | 'old'}; an offset but 0 is
|
|
1053
|
+
* refused.
|
|
1054
|
+
* The order is by when each document was last written: one written between two pages moves to the front, so a
|
|
1055
|
+
* listing taken while players write can show it twice or miss it. */
|
|
942
1056
|
list: function (c, opts) {
|
|
943
1057
|
opts = opts || {};
|
|
944
1058
|
if (!seg(c)) return bad('collection');
|
|
1059
|
+
if (noOffset(opts)) return noOffset(opts);
|
|
945
1060
|
if (useLocalDb()) return localDb.list(c, opts);
|
|
946
1061
|
var q = [];
|
|
947
|
-
['owner', 'limit', '
|
|
948
|
-
if (opts[k] !== undefined) q.push(k + '=' + encodeURIComponent(opts[k]));
|
|
1062
|
+
['owner', 'limit', 'after', 'order'].forEach(function (k) {
|
|
1063
|
+
if (opts[k] !== undefined && opts[k] !== null) q.push(k + '=' + encodeURIComponent(opts[k]));
|
|
949
1064
|
});
|
|
950
1065
|
return gf.api('db/' + seg(c) + (q.length ? '?' + q.join('&') : '')).then(function (r) {
|
|
951
|
-
|
|
1066
|
+
var a = pick(r, function (d) {
|
|
952
1067
|
return d.items;
|
|
953
1068
|
});
|
|
1069
|
+
a.after = (r.data && r.data.after) || null;
|
|
1070
|
+
a.more = !!(r.data && r.data.more);
|
|
1071
|
+
return a;
|
|
954
1072
|
});
|
|
955
1073
|
},
|
|
1074
|
+
/** Deletes the player's own document. → {data: {deleted}} */
|
|
956
1075
|
delete: function (c, id) {
|
|
957
1076
|
if (!seg(c) || !seg(id)) return bad('collection or id');
|
|
958
1077
|
if (useLocalDb()) return localDb.delete(c, id);
|
|
@@ -960,7 +1079,7 @@
|
|
|
960
1079
|
},
|
|
961
1080
|
};
|
|
962
1081
|
|
|
963
|
-
// ---- multiplayer rooms (xcore add multiplayer: server/rooms.js on
|
|
1082
|
+
// ---- multiplayer rooms (xcore add multiplayer: server/rooms.js on xgames.fun) ----
|
|
964
1083
|
// A room is a relay with a host: the first player in is the host (id 1), the others get ids of their own; a message
|
|
965
1084
|
// goes to one player (to: id), to everyone else (0), or to everyone but one (-id). On this machine, tabs of the same
|
|
966
1085
|
// browser share a room instead (BroadcastChannel), so two tabs play together before the game is published.
|
|
@@ -1228,16 +1347,17 @@
|
|
|
1228
1347
|
ch.postMessage({ t: 'who', nonce: nonce });
|
|
1229
1348
|
return new Promise(function (resolve) {
|
|
1230
1349
|
setTimeout(function () {
|
|
1350
|
+
// refused: answered (the channel closes below), as a refusal online is
|
|
1351
|
+
if (opts.host === true && replies.length)
|
|
1352
|
+
return resolve({ data: null, error: 'Someone is in this room already: choose another code.', status: 4009 });
|
|
1353
|
+
if (opts.host === false && !replies.length)
|
|
1354
|
+
return resolve({ data: null, error: 'No one is in this room.', status: 4004 });
|
|
1231
1355
|
var taken = {};
|
|
1232
1356
|
for (var i = 0; i < replies.length; i++) {
|
|
1233
1357
|
room.peers[replies[i].peer.id] = replies[i].peer;
|
|
1234
1358
|
times[replies[i].peer.id] = replies[i].joined;
|
|
1235
1359
|
taken[replies[i].peer.id] = true;
|
|
1236
1360
|
}
|
|
1237
|
-
if (opts.host === true && replies.length)
|
|
1238
|
-
return { data: null, error: 'Someone is in this room already: choose another code.', status: 4009 };
|
|
1239
|
-
if (opts.host === false && !replies.length)
|
|
1240
|
-
return { data: null, error: 'No one is in this room.', status: 4004 };
|
|
1241
1361
|
room.id = replies.length ? 0 : 1;
|
|
1242
1362
|
while (!room.id || taken[room.id]) room.id = 2 + Math.floor(Math.random() * 0x7ffffffd);
|
|
1243
1363
|
times[room.id] = joined;
|
|
@@ -1285,7 +1405,7 @@
|
|
|
1285
1405
|
for (var i = 0; i < a.length; i++) s += CODE_CHARS[a[i] % CODE_CHARS.length];
|
|
1286
1406
|
return s;
|
|
1287
1407
|
},
|
|
1288
|
-
/** The room this page was opened for (?room=CODE, as
|
|
1408
|
+
/** The room this page was opened for (?room=CODE, as xgames.fun's room links and their X cards pass it), or null. */
|
|
1289
1409
|
fromLink: function () {
|
|
1290
1410
|
try {
|
|
1291
1411
|
return roomCode(new URLSearchParams(loc.search || '').get('room'));
|
|
@@ -1293,7 +1413,7 @@
|
|
|
1293
1413
|
return null;
|
|
1294
1414
|
}
|
|
1295
1415
|
},
|
|
1296
|
-
/** The link that opens the game in this room:
|
|
1416
|
+
/** The link that opens the game in this room: xgames.fun's play page once published (its X card shows the room). */
|
|
1297
1417
|
link: function (code) {
|
|
1298
1418
|
var c = roomCode(code);
|
|
1299
1419
|
if (!c) return null;
|