@xgamesfun/xcore 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +139 -17
- package/commands/dev.mjs +31 -10
- package/commands/login.mjs +22 -11
- package/commands/logout.mjs +2 -0
- package/commands/loot.mjs +13 -4
- package/commands/publish.mjs +4 -0
- package/commands/review.mjs +106 -0
- package/docs/SERVER.md +120 -32
- package/lib/add.mjs +1 -0
- package/lib/check.mjs +1 -0
- package/lib/cli.mjs +2 -0
- package/lib/client/gamesfun.js +348 -9
- package/lib/client/godot/gamesfun.gd +10 -1
- package/lib/client/server/0001_init.sql +1 -1
- package/lib/client/server/database.js +2 -1
- package/lib/client/server/gf-db.js +6 -2
- package/lib/client/server/rooms.js +1 -0
- package/lib/client/server/vouchers.js +15 -8
- package/lib/client/unity/GamesFun.cs +1 -1
- package/lib/dev/files.mjs +102 -5
- package/lib/dev/gf-local.js +4 -0
- package/lib/dev/index.mjs +49 -10
- package/lib/dev/limits.mjs +55 -24
- package/lib/dev/live.mjs +162 -0
- package/lib/dev/plans.mjs +101 -0
- package/lib/dev/platform.mjs +490 -138
- package/lib/economy.mjs +12 -0
- package/lib/login-reach.mjs +184 -0
- package/lib/publish/adopt.mjs +11 -0
- package/lib/publish/index.mjs +16 -7
- package/lib/publish/names.mjs +128 -1
- package/lib/publish/scan.mjs +25 -1
- package/lib/publish/tree.mjs +9 -1
- package/lib/review.mjs +1166 -0
- package/lib/secret-patterns.mjs +36 -26
- package/lib/server/bundle.mjs +139 -28
- package/lib/server/contract.mjs +95 -12
- package/package.json +2 -2
- package/skills/bring-your-own-game/SKILL.md +58 -9
package/lib/client/gamesfun.js
CHANGED
|
@@ -6,15 +6,18 @@
|
|
|
6
6
|
//
|
|
7
7
|
// const gf = GamesFun;
|
|
8
8
|
// await gf.ready; // what the page already knows (a sign-in the xgames.fun page has)
|
|
9
|
-
// gf.player; // null, or {id, name, level, wallet, x}
|
|
9
|
+
// gf.player; // null, or {id, name, level, wallet, x, google}
|
|
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
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
|
+
// await gf.signIn({how: 'google'}); // the same with Google ('x', the default: with X)
|
|
15
16
|
// gf.onChange(player => …); // sign-in and sign-out
|
|
16
17
|
// gf.onCode(({code, site, url, window}) => …); // the code: show it plainly when window is false (blocked);
|
|
17
18
|
// // when true, only as a quiet line: X's login hides the window's state
|
|
19
|
+
// await gf.fullscreen(); // from a click: full screen (again: back); → {on, how}
|
|
20
|
+
// gf.onFullscreen(on => …); // it went in or out (Esc, the page, the browser)
|
|
18
21
|
// await gf.saves.put('main', {level: 3}); // this player's saves: JSON, 64 KB each, 16 a player
|
|
19
22
|
// await gf.scores.submit('best', 1200); // leaderboards: a player's best is kept
|
|
20
23
|
// await gf.db.add('levels', {name: 'Moon'}); // the game's own database (xcore add database): each player
|
|
@@ -22,8 +25,13 @@
|
|
|
22
25
|
// await gf.api('scores', {method: 'POST', body: {}}); // the game's own server (server/), signed in
|
|
23
26
|
// const {data: room} = await gf.rooms.join(gf.rooms.fromLink() ?? gf.rooms.code()); // multiplayer (xcore add multiplayer)
|
|
24
27
|
// room.on('message', (from, data) => …); room.send({x: 1}); share gf.rooms.link(room.code)
|
|
28
|
+
// const {data: live} = await gf.live.get(); // what the owner set on xgames.fun/console (Live), no republish:
|
|
29
|
+
// // {announcement, config, version, maintenance}; a banner shows
|
|
30
|
+
// // the announcement (gf.live.banner(false) turns it off)
|
|
31
|
+
// gf.live.onChange(live => …); // asked again every minute while the page is in view
|
|
25
32
|
//
|
|
26
|
-
// Where sign-in comes from (xgames.fun's player sign-in: X
|
|
33
|
+
// Where sign-in comes from (xgames.fun's player sign-in: X or Google; a wallet is linked to that account on xgames.fun's
|
|
34
|
+
// account page and comes with every sign-in, but never signs anyone in: a copycat game could relay that signature):
|
|
27
35
|
// - inside xgames.fun's play page, the page above signs the player in and hands this page a token (gf:host / gf:auth);
|
|
28
36
|
// only xgames.fun's own page may: framed by any other site (one embedding the game), the game signs in by itself;
|
|
29
37
|
// - 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
|
|
@@ -37,7 +45,7 @@
|
|
|
37
45
|
(function () {
|
|
38
46
|
'use strict';
|
|
39
47
|
if (globalThis.GamesFun) return;
|
|
40
|
-
var VERSION = '1.
|
|
48
|
+
var VERSION = '1.6.0';
|
|
41
49
|
var PLATFORM = '/platform/v1/';
|
|
42
50
|
var SEG = /^[A-Za-z0-9_-]{1,64}$/;
|
|
43
51
|
var POLL_MS = 2000;
|
|
@@ -147,6 +155,9 @@
|
|
|
147
155
|
var why = (data && (data.why || data.error)) || label + ' answered ' + res.status;
|
|
148
156
|
var no = { data: null, error: why, status: res.status };
|
|
149
157
|
if (data && data.code) no.code = data.code;
|
|
158
|
+
// the game was paused (Live, maintenance) while it was open: the banner says so at once (xgames.fun's
|
|
159
|
+
// refusal is a 503; a game's own server that says 'maintenance' with another status is its own business)
|
|
160
|
+
if (res.status === 503 && data && data.code === 'maintenance') liveMaintenance(data);
|
|
150
161
|
return no;
|
|
151
162
|
});
|
|
152
163
|
})
|
|
@@ -269,7 +280,16 @@
|
|
|
269
280
|
if (gf.token && a.status !== 'signing') setPlayer(null, '', 0);
|
|
270
281
|
} else if (token !== gf.token) {
|
|
271
282
|
var x = a.x && a.x.handle ? a.x : null;
|
|
272
|
-
|
|
283
|
+
// the name players see: @handle for X; a Google account's player name (never with an @, which only X has)
|
|
284
|
+
var g = a.google && a.google.name ? { name: String(a.google.name) } : null;
|
|
285
|
+
var guess = {
|
|
286
|
+
id: '',
|
|
287
|
+
name: x ? '@' + x.handle : g ? g.name : a.short || '',
|
|
288
|
+
level: a.level,
|
|
289
|
+
wallet: a.wallet || '',
|
|
290
|
+
x: x,
|
|
291
|
+
google: g,
|
|
292
|
+
};
|
|
273
293
|
done = takeToken(token, guess, a.expires || Date.now() + 3600e3);
|
|
274
294
|
}
|
|
275
295
|
return done.then(function () {
|
|
@@ -326,6 +346,8 @@
|
|
|
326
346
|
above = e.source;
|
|
327
347
|
aboveAuth = e.data.auth !== false;
|
|
328
348
|
readyResolve(gf.player);
|
|
349
|
+
} else if (e.data.type === 'gf:fullscreen-state') {
|
|
350
|
+
setFull(e.data.on === true);
|
|
329
351
|
} else if (e.data.type === 'gf:auth') {
|
|
330
352
|
above = e.source;
|
|
331
353
|
takeAuth(e.data).then(function () {
|
|
@@ -396,12 +418,14 @@
|
|
|
396
418
|
closePopup(p);
|
|
397
419
|
var pl = s.player || {};
|
|
398
420
|
var x = pl.x && pl.x.handle ? pl.x : null;
|
|
421
|
+
var g = pl.google && pl.google.name ? { name: String(pl.google.name) } : null;
|
|
399
422
|
var guess = {
|
|
400
423
|
id: '',
|
|
401
|
-
name: x ? '@' + x.handle : pl.wallet || '',
|
|
424
|
+
name: x ? '@' + x.handle : g ? g.name : pl.wallet || '',
|
|
402
425
|
level: pl.level,
|
|
403
426
|
wallet: pl.wallet || '',
|
|
404
427
|
x: x,
|
|
428
|
+
google: g,
|
|
405
429
|
};
|
|
406
430
|
// still this sign-in's until the token is taken: a click meanwhile waits for the same player
|
|
407
431
|
takeToken(s.token, guess, s.expires).then(function (player) {
|
|
@@ -456,11 +480,12 @@
|
|
|
456
480
|
|
|
457
481
|
/**
|
|
458
482
|
* The player's sign-in. From a click (a window may open). → player | null. `prompt: false` opens nothing and only
|
|
459
|
-
* takes a sign-in the page already has. `how`: 'x' (default) or 'wallet'
|
|
483
|
+
* takes a sign-in the page already has. `how`: 'x' (default) or 'google' ('wallet', from before, signs in with X: a
|
|
484
|
+
* wallet signs nobody in now; the player links one on xgames.fun's account page).
|
|
460
485
|
*/
|
|
461
486
|
gf.signIn = function (opts) {
|
|
462
487
|
opts = opts || {};
|
|
463
|
-
var how = opts.how === '
|
|
488
|
+
var how = opts.how === 'google' ? 'google' : 'x';
|
|
464
489
|
// a sign-in on its way (to its last step, the player's token being taken): this click waits for the same one
|
|
465
490
|
if (pending && opts.prompt !== false) return pending.promise;
|
|
466
491
|
if (gf.token && gf.expires > Date.now() + 60e3) return Promise.resolve(gf.player);
|
|
@@ -487,7 +512,7 @@
|
|
|
487
512
|
} else waiters.push(wait); // an answer without a token yet (the page is still signing)
|
|
488
513
|
};
|
|
489
514
|
waiters.push(wait);
|
|
490
|
-
above.postMessage(
|
|
515
|
+
above.postMessage({ type: 'gf:auth-signin', with: how }, '*');
|
|
491
516
|
});
|
|
492
517
|
}
|
|
493
518
|
return requestSignIn(how);
|
|
@@ -502,6 +527,317 @@
|
|
|
502
527
|
return Promise.resolve(null);
|
|
503
528
|
};
|
|
504
529
|
|
|
530
|
+
// ---- live: what the game's owner sets on xgames.fun/console (the Live tab), with no republish ----
|
|
531
|
+
// GET /platform/v1/live, answered by the play edge itself from one cached record a game: {announcement: {id, text,
|
|
532
|
+
// level: 'info'|'warning', startsAt, endsAt} | null, config: {…}, version, maintenance: {message, since} | null}. The
|
|
533
|
+
// config is the owner's JSON object (8 KB); while maintenance is on, players who open the game get xgames.fun's
|
|
534
|
+
// holding page instead, and the game's api/ and saves and scores answer code 'maintenance'. A change reaches players
|
|
535
|
+
// within about 35 seconds. On this machine (localhost, xcore check) nothing is live; `xcore dev` answers from the
|
|
536
|
+
// game's .gamesfun/live.json; where there is no such route (an older host) nothing is live and nothing is asked again.
|
|
537
|
+
// A small banner shows the announcement (each one until the player closes it), and a pause that starts while the
|
|
538
|
+
// game is open. Off: globalThis.GAMESFUN_LIVE_BANNER = false before this file, data-live-banner="off" on its
|
|
539
|
+
// <script>, or gf.live.banner(false) before the page has loaded.
|
|
540
|
+
var LIVE_EMPTY = { announcement: null, config: {}, version: 0, maintenance: null };
|
|
541
|
+
var LIVE_POLL_MS = 60000; // asked again this often while gf.live.onChange has a listener (and the page is in view)
|
|
542
|
+
var LIVE_BANNER_MS = 300000; // and this often for the banner alone
|
|
543
|
+
var liveValue = null,
|
|
544
|
+
liveAt = 0,
|
|
545
|
+
liveFlight = null,
|
|
546
|
+
liveFns = [],
|
|
547
|
+
liveGone = false,
|
|
548
|
+
liveTimer = null,
|
|
549
|
+
banner = null,
|
|
550
|
+
bannerKey = '',
|
|
551
|
+
bannerEnd = null;
|
|
552
|
+
var me = globalThis.document && document.currentScript;
|
|
553
|
+
var bannerOn =
|
|
554
|
+
globalThis.GAMESFUN_LIVE_BANNER !== false &&
|
|
555
|
+
!(me && me.getAttribute && me.getAttribute('data-live-banner') === 'off');
|
|
556
|
+
var num = function (v) {
|
|
557
|
+
return typeof v === 'number' && isFinite(v) && v > 0 ? v : null;
|
|
558
|
+
};
|
|
559
|
+
/** An answer → what a game gets: an announcement only inside its window (by this clock too), a plain-object config. */
|
|
560
|
+
function liveClean(d) {
|
|
561
|
+
if (!d || typeof d !== 'object') return LIVE_EMPTY;
|
|
562
|
+
var now = Date.now();
|
|
563
|
+
var a = d.announcement;
|
|
564
|
+
a =
|
|
565
|
+
a && typeof a === 'object' && typeof a.text === 'string' && a.text
|
|
566
|
+
? {
|
|
567
|
+
id: String(a.id || 'a'),
|
|
568
|
+
text: a.text,
|
|
569
|
+
level: a.level === 'warning' ? 'warning' : 'info',
|
|
570
|
+
startsAt: num(a.startsAt),
|
|
571
|
+
endsAt: num(a.endsAt),
|
|
572
|
+
}
|
|
573
|
+
: null;
|
|
574
|
+
if (a && ((a.startsAt && a.startsAt > now) || (a.endsAt && a.endsAt <= now))) a = null;
|
|
575
|
+
var m = d.maintenance;
|
|
576
|
+
return {
|
|
577
|
+
announcement: a,
|
|
578
|
+
config: d.config && typeof d.config === 'object' && !Array.isArray(d.config) ? d.config : {},
|
|
579
|
+
version: num(d.version) || 0,
|
|
580
|
+
maintenance: m && typeof m === 'object' ? { message: String(m.message || ''), since: num(m.since) } : null,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
function setLive(v) {
|
|
584
|
+
var before = liveValue ? JSON.stringify(liveValue) : '';
|
|
585
|
+
liveValue = v;
|
|
586
|
+
gf.live.value = v;
|
|
587
|
+
if (JSON.stringify(v) !== before) {
|
|
588
|
+
emit(liveFns, v);
|
|
589
|
+
renderBanner();
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
function liveRefresh() {
|
|
593
|
+
if (local) {
|
|
594
|
+
setLive(LIVE_EMPTY);
|
|
595
|
+
return ok(LIVE_EMPTY);
|
|
596
|
+
}
|
|
597
|
+
if (liveFlight) return liveFlight;
|
|
598
|
+
liveFlight = call(PLATFORM + 'live', { method: 'GET' }, "the game's live settings").then(function (r) {
|
|
599
|
+
liveFlight = null;
|
|
600
|
+
liveAt = Date.now();
|
|
601
|
+
if (r.data) {
|
|
602
|
+
setLive(liveClean(r.data));
|
|
603
|
+
return { data: liveValue, error: null, status: r.status };
|
|
604
|
+
}
|
|
605
|
+
// no such route here (an older host): nothing is live, and nothing is asked again
|
|
606
|
+
if (r.status === 404) {
|
|
607
|
+
liveGone = true;
|
|
608
|
+
setLive(LIVE_EMPTY);
|
|
609
|
+
return { data: liveValue, error: null, status: 404 };
|
|
610
|
+
}
|
|
611
|
+
// not reachable now: what was known stays
|
|
612
|
+
return withCode({ data: liveValue || LIVE_EMPTY, error: r.error, status: r.status }, r);
|
|
613
|
+
});
|
|
614
|
+
return liveFlight;
|
|
615
|
+
}
|
|
616
|
+
function liveMaintenance(d) {
|
|
617
|
+
var v = liveValue || LIVE_EMPTY;
|
|
618
|
+
if (v.maintenance) return;
|
|
619
|
+
setLive({
|
|
620
|
+
announcement: v.announcement,
|
|
621
|
+
config: v.config,
|
|
622
|
+
version: v.version,
|
|
623
|
+
maintenance: { message: String(d.why || ''), since: num(d.since) },
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
function livePoll() {
|
|
627
|
+
if (liveTimer || liveGone || local || typeof setInterval !== 'function') return;
|
|
628
|
+
liveTimer = setInterval(function () {
|
|
629
|
+
if (liveGone) {
|
|
630
|
+
clearInterval(liveTimer);
|
|
631
|
+
liveTimer = null;
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (globalThis.document && document.visibilityState === 'hidden') return;
|
|
635
|
+
var every = liveFns.length ? LIVE_POLL_MS : bannerOn ? LIVE_BANNER_MS : 0;
|
|
636
|
+
if (every && Date.now() - liveAt >= every) liveRefresh();
|
|
637
|
+
}, 15000);
|
|
638
|
+
}
|
|
639
|
+
// the banner: one line at the top, above the game, closable; text only (never HTML)
|
|
640
|
+
function renderBanner() {
|
|
641
|
+
var doc = globalThis.document;
|
|
642
|
+
if (!doc || typeof doc.createElement !== 'function') return;
|
|
643
|
+
if (!doc.body) {
|
|
644
|
+
doc.addEventListener('DOMContentLoaded', renderBanner, { once: true });
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
var v = liveValue,
|
|
648
|
+
show = null;
|
|
649
|
+
if (bannerOn && v && v.maintenance)
|
|
650
|
+
show = {
|
|
651
|
+
key: 'm' + (v.maintenance.since || ''),
|
|
652
|
+
text: v.maintenance.message || 'This game is paused for maintenance.',
|
|
653
|
+
level: 'warning',
|
|
654
|
+
};
|
|
655
|
+
else if (bannerOn && v && v.announcement)
|
|
656
|
+
show = { key: v.announcement.id, text: v.announcement.text, level: v.announcement.level };
|
|
657
|
+
if (show && store('live-closed') === show.key) show = null;
|
|
658
|
+
clearTimeout(bannerEnd);
|
|
659
|
+
if (!show) {
|
|
660
|
+
if (banner && banner.parentNode) banner.parentNode.removeChild(banner);
|
|
661
|
+
banner = null;
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
// an announcement that ends while the game is open goes when it ends
|
|
665
|
+
var ends = !v.maintenance && v.announcement.endsAt;
|
|
666
|
+
if (ends)
|
|
667
|
+
bannerEnd = setTimeout(
|
|
668
|
+
function () {
|
|
669
|
+
setLive(liveClean(liveValue));
|
|
670
|
+
},
|
|
671
|
+
Math.min(Math.max(ends - Date.now(), 0), 2147483647),
|
|
672
|
+
);
|
|
673
|
+
if (!banner) {
|
|
674
|
+
banner = doc.createElement('div');
|
|
675
|
+
banner.setAttribute('data-gamesfun', 'live');
|
|
676
|
+
var root = banner.attachShadow ? banner.attachShadow({ mode: 'open' }) : banner;
|
|
677
|
+
root.innerHTML =
|
|
678
|
+
'<div role="status" aria-live="polite" style="position:fixed;top:8px;left:50%;transform:translateX(-50%);z-index:2147483647;' +
|
|
679
|
+
'box-sizing:border-box;max-width:min(640px,calc(100vw - 16px));display:flex;align-items:flex-start;gap:10px;padding:8px 10px 8px 14px;' +
|
|
680
|
+
'border-radius:10px;font:14px/1.4 system-ui,-apple-system,sans-serif;box-shadow:0 4px 18px rgba(0,0,0,.35);pointer-events:auto">' +
|
|
681
|
+
'<span style="white-space:pre-wrap;overflow-wrap:anywhere"></span>' +
|
|
682
|
+
'<button type="button" aria-label="Close" style="all:unset;cursor:pointer;padding:0 4px;font-size:18px;line-height:1;opacity:.75">×</button></div>';
|
|
683
|
+
root.querySelector('button').addEventListener('click', function () {
|
|
684
|
+
store('live-closed', bannerKey);
|
|
685
|
+
renderBanner();
|
|
686
|
+
});
|
|
687
|
+
doc.body.appendChild(banner);
|
|
688
|
+
}
|
|
689
|
+
var root2 = banner.shadowRoot || banner;
|
|
690
|
+
var box = root2.querySelector('[role=status]');
|
|
691
|
+
box.style.background = show.level === 'warning' ? '#4a3510' : '#1d1d26';
|
|
692
|
+
box.style.color = show.level === 'warning' ? '#ffe6b0' : '#e8e8f0';
|
|
693
|
+
box.style.border = '1px solid ' + (show.level === 'warning' ? '#a8761c' : '#3a3a4a');
|
|
694
|
+
root2.querySelector('span').textContent = show.text;
|
|
695
|
+
bannerKey = show.key;
|
|
696
|
+
}
|
|
697
|
+
gf.live = {
|
|
698
|
+
/** The last answer (null until one came). */
|
|
699
|
+
value: null,
|
|
700
|
+
/** → {data: {announcement, config, version, maintenance}}: what is known if it is under a minute old, else asked. */
|
|
701
|
+
get: function () {
|
|
702
|
+
if (liveValue && (liveGone || local || Date.now() - liveAt < LIVE_POLL_MS) && !liveFlight) return ok(liveValue);
|
|
703
|
+
return liveRefresh();
|
|
704
|
+
},
|
|
705
|
+
/** Asks now. → as get */
|
|
706
|
+
refresh: liveRefresh,
|
|
707
|
+
/** fn(live) on every change; while any listener is there it is asked every minute (the page in view). → unsubscribe */
|
|
708
|
+
onChange: function (fn) {
|
|
709
|
+
if (typeof fn === 'function') liveFns.push(fn);
|
|
710
|
+
livePoll();
|
|
711
|
+
if (!liveAt && !liveFlight) liveRefresh();
|
|
712
|
+
return function () {
|
|
713
|
+
liveFns = liveFns.filter(function (f) {
|
|
714
|
+
return f !== fn;
|
|
715
|
+
});
|
|
716
|
+
};
|
|
717
|
+
},
|
|
718
|
+
/** The default banner on (true) or off (false): a game that shows the announcement its own way turns it off. */
|
|
719
|
+
banner: function (on) {
|
|
720
|
+
bannerOn = on !== false;
|
|
721
|
+
renderBanner();
|
|
722
|
+
if (bannerOn && !local) {
|
|
723
|
+
livePoll();
|
|
724
|
+
if (!liveAt && !liveFlight) liveRefresh();
|
|
725
|
+
}
|
|
726
|
+
},
|
|
727
|
+
};
|
|
728
|
+
// the banner's first look once the page has loaded (so a game's own scripts can turn it off first), then every 5 min
|
|
729
|
+
if (!local && globalThis.document && typeof document.addEventListener === 'function') {
|
|
730
|
+
var liveStart = function () {
|
|
731
|
+
setTimeout(function () {
|
|
732
|
+
if (bannerOn && !liveAt && !liveFlight) liveRefresh();
|
|
733
|
+
if (bannerOn) livePoll();
|
|
734
|
+
}, 0);
|
|
735
|
+
};
|
|
736
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', liveStart, { once: true });
|
|
737
|
+
else liveStart();
|
|
738
|
+
document.addEventListener('visibilitychange', function () {
|
|
739
|
+
if (document.visibilityState !== 'visible' || liveGone || !(liveFns.length || bannerOn)) return;
|
|
740
|
+
if (liveAt && Date.now() - liveAt >= LIVE_POLL_MS) liveRefresh();
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// ---- full screen, from a click: a post on X and a phone show the game in a small frame first ----
|
|
745
|
+
// Inside xgames.fun's play page the page around the game goes full screen (gf:fullscreen), or fills the screen where the
|
|
746
|
+
// browser has no full screen (an app's own browser: X's, Phantom's); a click in the game lets that page ask. Anywhere
|
|
747
|
+
// else the browser's own full screen; framed where that is not allowed (another site's frame, a post on X), the game's
|
|
748
|
+
// own link in a tab of its own. An iPhone's Safari has no full screen for a page: {on: false, how: 'none'}, the game
|
|
749
|
+
// plays as it is (its button can say "Add to Home Screen" there).
|
|
750
|
+
var fullFns = [];
|
|
751
|
+
gf.isFullscreen = false;
|
|
752
|
+
function setFull(on) {
|
|
753
|
+
if (gf.isFullscreen === on) return;
|
|
754
|
+
gf.isFullscreen = on;
|
|
755
|
+
emit(fullFns, on);
|
|
756
|
+
}
|
|
757
|
+
gf.onFullscreen = function (fn) {
|
|
758
|
+
if (typeof fn === 'function') fullFns.push(fn);
|
|
759
|
+
return function () {
|
|
760
|
+
fullFns = fullFns.filter(function (f) {
|
|
761
|
+
return f !== fn;
|
|
762
|
+
});
|
|
763
|
+
};
|
|
764
|
+
};
|
|
765
|
+
var doc = globalThis.document;
|
|
766
|
+
function nativeFull() {
|
|
767
|
+
return !!doc && !!(doc.fullscreenElement || doc.webkitFullscreenElement);
|
|
768
|
+
}
|
|
769
|
+
if (doc && doc.addEventListener)
|
|
770
|
+
['fullscreenchange', 'webkitfullscreenchange'].forEach(function (ev) {
|
|
771
|
+
doc.addEventListener(ev, function () {
|
|
772
|
+
setFull(nativeFull());
|
|
773
|
+
});
|
|
774
|
+
});
|
|
775
|
+
/** on: true (in), false (out), or left out (the other way). → {data: {on, how: 'page'|'browser'|'tab'|'none'}} */
|
|
776
|
+
gf.fullscreen = function (on) {
|
|
777
|
+
var want = on === undefined ? !gf.isFullscreen : !!on;
|
|
778
|
+
if (above) {
|
|
779
|
+
// the play page answers gf:fullscreen-state; it tells only a change, so a refusal there leaves it as it was
|
|
780
|
+
try {
|
|
781
|
+
above.postMessage({ type: 'gf:fullscreen', v: 1, on: want }, '*');
|
|
782
|
+
} catch {
|
|
783
|
+
/* gone */
|
|
784
|
+
}
|
|
785
|
+
return new Promise(function (resolve) {
|
|
786
|
+
var off,
|
|
787
|
+
timer = setTimeout(function () {
|
|
788
|
+
off();
|
|
789
|
+
resolve(ok({ on: gf.isFullscreen, how: 'page' }));
|
|
790
|
+
}, 1500);
|
|
791
|
+
off = gf.onFullscreen(function (now) {
|
|
792
|
+
clearTimeout(timer);
|
|
793
|
+
off();
|
|
794
|
+
resolve(ok({ on: now, how: 'page' }));
|
|
795
|
+
});
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
var el = doc && doc.documentElement;
|
|
799
|
+
if (!want) {
|
|
800
|
+
var exit = doc && (doc.exitFullscreen || doc.webkitExitFullscreen);
|
|
801
|
+
if (exit && nativeFull())
|
|
802
|
+
return Promise.resolve(exit.call(doc)).then(
|
|
803
|
+
function () {
|
|
804
|
+
return ok({ on: false, how: 'browser' });
|
|
805
|
+
},
|
|
806
|
+
function () {
|
|
807
|
+
return ok({ on: nativeFull(), how: 'browser' });
|
|
808
|
+
},
|
|
809
|
+
);
|
|
810
|
+
return Promise.resolve(ok({ on: false, how: 'none' }));
|
|
811
|
+
}
|
|
812
|
+
var req = el && (el.requestFullscreen || el.webkitRequestFullscreen);
|
|
813
|
+
var allowed = doc && doc.fullscreenEnabled !== false && doc.webkitFullscreenEnabled !== false;
|
|
814
|
+
var tab = function () {
|
|
815
|
+
// framed by a page that allows no full screen (a post on X): the game's own link, where it can
|
|
816
|
+
// (noopener: open answers null either way, so 'tab' says it was asked for, not that a popup blocker let it)
|
|
817
|
+
if (!framed || !globalThis.open) return ok({ on: false, how: 'none' });
|
|
818
|
+
try {
|
|
819
|
+
globalThis.open(loc.href, '_blank', 'noopener');
|
|
820
|
+
} catch {
|
|
821
|
+
return ok({ on: false, how: 'none' });
|
|
822
|
+
}
|
|
823
|
+
return ok({ on: false, how: 'tab' });
|
|
824
|
+
};
|
|
825
|
+
if (!req || !allowed) return Promise.resolve(tab());
|
|
826
|
+
return Promise.resolve()
|
|
827
|
+
.then(function () {
|
|
828
|
+
return req.call(el);
|
|
829
|
+
})
|
|
830
|
+
.then(
|
|
831
|
+
function () {
|
|
832
|
+
setFull(nativeFull());
|
|
833
|
+
return ok({ on: nativeFull(), how: 'browser' });
|
|
834
|
+
},
|
|
835
|
+
function () {
|
|
836
|
+
return tab();
|
|
837
|
+
},
|
|
838
|
+
);
|
|
839
|
+
};
|
|
840
|
+
|
|
505
841
|
// ---- saves: this player's own, any JSON value (64 KB each, 16 a player) ----
|
|
506
842
|
function seg(s) {
|
|
507
843
|
return SEG.test(String(s)) ? encodeURIComponent(s) : null;
|
|
@@ -593,7 +929,10 @@
|
|
|
593
929
|
if (opts.order) body.order = opts.order;
|
|
594
930
|
return json('POST', PLATFORM + 'scores/' + seg(board), body, 'submitting the score');
|
|
595
931
|
},
|
|
596
|
-
/**
|
|
932
|
+
/**
|
|
933
|
+
* → {data: [{rank, player, name, score, at, me?}]}: `player` is an id of this game's own (never the account), `me`
|
|
934
|
+
* true on the signed-in player's entry. Anyone may read; signed out, the top 100 only (offset + limit past 100: 401)
|
|
935
|
+
*/
|
|
597
936
|
top: function (board, opts) {
|
|
598
937
|
opts = opts || {};
|
|
599
938
|
if (!seg(board)) return bad('board name');
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
# await GamesFun.saves_put("main", {"level": 3}) # this player's saves (JSON, 64 KB each, 16 a player)
|
|
11
11
|
# var r = await GamesFun.saves_get("main") # r.data is the value, or null
|
|
12
12
|
# await GamesFun.scores_submit("best", 1200) # leaderboards: r.data {improved, best, rank}
|
|
13
|
+
# await GamesFun.fullscreen() # from a button: full screen (again: back)
|
|
13
14
|
# var top = await GamesFun.scores_top("best") # top.data: [{rank, player, name, score, at}]
|
|
14
15
|
# await GamesFun.db_add("levels", {"name": "Moon"}) # the game's own database (xcore add database)
|
|
15
16
|
# multiplayer.multiplayer_peer = await GamesFun.join_room(code) # Godot's multiplayer over xgames.fun (xcore add multiplayer)
|
|
@@ -122,7 +123,7 @@ func _run(path: String, args: Array):
|
|
|
122
123
|
return result
|
|
123
124
|
|
|
124
125
|
|
|
125
|
-
## The player's sign-in, from a button press (a window may open). how: "x" or "
|
|
126
|
+
## The player's sign-in, from a button press (a window may open). how: "x" or "google". -> Dictionary or null
|
|
126
127
|
func sign_in(how := "x"):
|
|
127
128
|
var p = await _run("signIn", [{"how": how}])
|
|
128
129
|
_set_player(p)
|
|
@@ -134,6 +135,14 @@ func sign_out():
|
|
|
134
135
|
_set_player(null)
|
|
135
136
|
|
|
136
137
|
|
|
138
|
+
## Full screen, from a button press: on xgames.fun the page around the game goes full screen (or fills the screen in an
|
|
139
|
+
## app's own browser, X's among them); elsewhere the browser's own, or the game's link in a tab of its own (a post on X).
|
|
140
|
+
## on: true, false, or null (the other way). -> {"on": bool, "how": "page" | "browser" | "tab" | "none"}
|
|
141
|
+
func fullscreen(on = null):
|
|
142
|
+
var r = await _run("fullscreen", [] if on == null else [on])
|
|
143
|
+
return r.get("data") if r is Dictionary and r.get("data") != null else {"on": false, "how": "none"}
|
|
144
|
+
|
|
145
|
+
|
|
137
146
|
func is_signed_in() -> bool:
|
|
138
147
|
return player != null
|
|
139
148
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
-- does the same on this machine. Once this one is published, never change it: put the next change in
|
|
4
4
|
-- 0002_<what>.sql (ALTER TABLE players ADD COLUMN …).
|
|
5
5
|
CREATE TABLE players (
|
|
6
|
-
id TEXT PRIMARY KEY, -- the player's id: env.GF.verifyPlayer's sub (withPlayer in gf-db.js)
|
|
6
|
+
id TEXT PRIMARY KEY, -- the player's id: env.GF.verifyPlayer's sub (withPlayer in gf-db.js), never the wallet
|
|
7
7
|
gold INTEGER NOT NULL DEFAULT 0,
|
|
8
8
|
runs INTEGER NOT NULL DEFAULT 3, -- runs left today
|
|
9
9
|
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
// POST api/db/<collection> {value} a new id, the player's own → {ok, item}
|
|
10
10
|
// DELETE api/db/<collection>/<id> the player's own → {ok, deleted}
|
|
11
11
|
//
|
|
12
|
-
// A document is {id, owner, value, updatedAt}: `owner` is the player's id (env.GF.verifyPlayer's `sub
|
|
12
|
+
// A document is {id, owner, value, updatedAt}: `owner` is the player's id (env.GF.verifyPlayer's `sub`: the account,
|
|
13
|
+
// x:<X id> or for Google g:<id>, different in every game; never the wallet), or "server"
|
|
13
14
|
// for what this server's own code writes. Every player has a space of their own in each collection: a document is
|
|
14
15
|
// found by its collection, its owner and its id, so two players can both have a "main", and no player can take an id
|
|
15
16
|
// in anyone else's space. A page names whose document it wants with `owner`; without it, the player's own (in a
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// database on this machine. Add a migration for each change; never change one that was published.
|
|
6
6
|
//
|
|
7
7
|
// import { db, withPlayer } from './gf-db.js';
|
|
8
|
-
// const sub = await withPlayer(request, env); // the signed-in player's id, or null
|
|
8
|
+
// const sub = await withPlayer(request, env); // the signed-in player's id (never the wallet), or null
|
|
9
9
|
// if (!sub) return Response.json({ why: 'Sign in first.' }, { status: 401 });
|
|
10
10
|
// const me = await db(env).first('SELECT gold, runs FROM players WHERE id = ?', sub);
|
|
11
11
|
// // a rule in one statement, so two requests at once cannot both pass it
|
|
@@ -46,7 +46,11 @@ export function db(env) {
|
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* The signed-in player's id (env.GF.verifyPlayer's sub: the account, x:<X id> or for Google g:<id>, different in every
|
|
51
|
+
* game; never the wallet, which is v.wallet), from the token gamesfun.js sends; null for a guest, at once and with no
|
|
52
|
+
* call when the request has no token, so a stranger costs the server nothing: answer 401 then, before any work.
|
|
53
|
+
*/
|
|
50
54
|
export async function withPlayer(request, env) {
|
|
51
55
|
const token = /^Bearer\s+(\S+)$/i.exec(request.headers.get('authorization') ?? '')?.[1];
|
|
52
56
|
if (!token) return null;
|
|
@@ -177,6 +177,7 @@ export class GameRoom extends DurableObject {
|
|
|
177
177
|
let player = null;
|
|
178
178
|
if (m.token) {
|
|
179
179
|
const v = await this.env.GF.verifyPlayer(String(m.token));
|
|
180
|
+
// id: the account (x:<X id>, or g:<id> for Google, different in every game); the wallet is its own claim
|
|
180
181
|
if (v?.ok) player = { id: v.sub, x: v.x?.handle ?? null, wallet: v.wallet ?? null };
|
|
181
182
|
}
|
|
182
183
|
// its own storage: the room takes no other message while it waits on that
|
|
@@ -50,9 +50,9 @@ import ECONOMY from '__ECONOMY__';
|
|
|
50
50
|
* player's daily total there, not in check, so a refusal after check (no proven wallet, a round already used) costs
|
|
51
51
|
* them nothing. Not again for the same round asked twice at once or signed anew near its expiry; if paid throws, the
|
|
52
52
|
* round is not counted and a later ask for it runs paid again.
|
|
53
|
-
* Every wallet is a new player on a chain, and a wallet
|
|
54
|
-
*
|
|
55
|
-
* grant a perDay total in xsolana.json.
|
|
53
|
+
* Every wallet is a new player on a chain, and a wallet costs nothing: cap what a person can take with VOUCHERS.payOnly
|
|
54
|
+
* 'x' (only players signed in with X, counted per X account) or 'account' (X or Google, counted per account; a Google
|
|
55
|
+
* account is easier to make than an X one) for rewards worth farming, and give each grant a perDay total in xsolana.json.
|
|
56
56
|
*/
|
|
57
57
|
export const PAY = {
|
|
58
58
|
async check() {
|
|
@@ -60,8 +60,8 @@ export const PAY = {
|
|
|
60
60
|
},
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
-
/** payOnly: 'any' (every signed-in player) | '
|
|
64
|
-
* player may make a minute; linkSeconds: how long a link text may be signed. */
|
|
63
|
+
/** payOnly: 'any' (every signed-in player) | 'account' (players signed in with X or Google) | 'x' (only players signed
|
|
64
|
+
* in with X); perMinute: pay and link requests a player may make a minute; linkSeconds: how long a link text may be signed. */
|
|
65
65
|
export const VOUCHERS = {
|
|
66
66
|
seconds: 7 * 86400,
|
|
67
67
|
renewWithin: 3600,
|
|
@@ -106,7 +106,12 @@ async function roundOf(id) {
|
|
|
106
106
|
return hex(new Uint8Array(await crypto.subtle.digest('SHA-256', te.encode(text))).subarray(0, 16));
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* The person a signed-in player is, for per-person limits: their X account's id, else their Google account's (g:<id>;
|
|
111
|
+
* an account with both is its X), else null (a wallet's own sign-in from before 2026-11-09).
|
|
112
|
+
*/
|
|
113
|
+
const personOf = player => player?.x?.id ?? (player?.google?.id ? `g:${player.google.id}` : null);
|
|
114
|
+
/** One person (an X account, or a Google account with no X) as the grant counts them: never the account itself, and a different hash per grant. */
|
|
110
115
|
async function personHashOf(signer, grantAddress, person) {
|
|
111
116
|
if (person === null || person === undefined || person === '') return null;
|
|
112
117
|
const own = await hmac(signer.raw, 'xcore voucher person v1');
|
|
@@ -313,7 +318,7 @@ async function signRound(env, { grant, wallet, round, amount, person = null }) {
|
|
|
313
318
|
const r = await roundOf(round);
|
|
314
319
|
if (!r) return refuse(400, 'round', 'A voucher needs the result’s own id (round), used once.');
|
|
315
320
|
if (g.perPerson > 0 && (person === null || person === undefined || person === ''))
|
|
316
|
-
return refuse(403, 'person', 'This reward counts people: sign in with X to be paid.');
|
|
321
|
+
return refuse(403, 'person', 'This reward counts people: sign in with X or Google to be paid.');
|
|
317
322
|
let units;
|
|
318
323
|
try {
|
|
319
324
|
// 'grant': the grant's own amount (the voucher names 0); otherwise a whole number of base units above 0
|
|
@@ -439,6 +444,8 @@ export async function handleVouchers(request, env) {
|
|
|
439
444
|
if (method !== 'POST') return reply(refuse(405, 'method', 'POST api/vouchers/pay.'));
|
|
440
445
|
if (VOUCHERS.payOnly === 'x' && !player.x?.id)
|
|
441
446
|
return reply(refuse(403, 'x-only', 'This game pays players signed in with X: sign in with X to be paid.'));
|
|
447
|
+
if (VOUCHERS.payOnly === 'account' && !personOf(player))
|
|
448
|
+
return reply(refuse(403, 'account-only', 'This game pays players signed in with X or Google: sign in to be paid.'));
|
|
442
449
|
const b = await bodyOf(request);
|
|
443
450
|
if (!b.ok) return reply(b);
|
|
444
451
|
const body = b.body;
|
|
@@ -462,7 +469,7 @@ export async function handleVouchers(request, env) {
|
|
|
462
469
|
wallet,
|
|
463
470
|
round: pays.round,
|
|
464
471
|
amount: pays.amount,
|
|
465
|
-
person: player
|
|
472
|
+
person: personOf(player),
|
|
466
473
|
});
|
|
467
474
|
// 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
475
|
// throws puts it back, so a later ask runs it again (the voucher stays the round's either way)
|
|
@@ -71,7 +71,7 @@ public class GamesFun : MonoBehaviour
|
|
|
71
71
|
#endif
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
/// <summary>The player's sign-in, from a button press (a window may open). how: "x" or "
|
|
74
|
+
/// <summary>The player's sign-in, from a button press (a window may open). how: "x" or "google". → the player JSON, or "null".</summary>
|
|
75
75
|
public static Task<string> SignIn(string how = "x") => Call("signIn", "[{\"how\":" + Str(how) + "}]");
|
|
76
76
|
public static Task<string> SignOut() => Call("signOut", "[]");
|
|
77
77
|
|