@xgamesfun/xcore 0.2.0 → 0.3.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.
@@ -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 first, a wallet as well):
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.4.0';
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
- var guess = { id: '', name: x ? '@' + x.handle : a.short || '', level: a.level, wallet: a.wallet || '', x: x };
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 === 'wallet' ? 'wallet' : 'x';
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(how === 'x' ? { type: 'gf:auth-signin', with: 'x' } : { type: 'gf:auth-signin' }, '*');
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;
@@ -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 "wallet". -> Dictionary or null
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
 
@@ -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 sign-in costs nothing: cap what a person can take with
54
- * VOUCHERS.payOnly 'x' (only players signed in with X, counted per X account) for rewards worth farming, and give each
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) | 'x' (only players signed in with X); perMinute: pay and link requests a
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
- /** One person (an X account) as the grant counts them: never the account itself, and a different hash per grant. */
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.x?.id ?? null,
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 "wallet". → the player JSON, or "null".</summary>
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
 
package/lib/dev/index.mjs CHANGED
@@ -4,6 +4,9 @@
4
4
  // /game/api/* the game's own server/ under workerd (./server.mjs), WebSockets too, sent on as /api/*; for a
5
5
  // game with a database and no server/, xcore's stock server, with a local D1 (./d1.mjs)
6
6
  // /platform/v1/* sign-in, saves and leaderboards (./platform.mjs), the store env.GF reads as well
7
+ // /platform/v1/live the game's announcement, config and maintenance, from .gamesfun/live.json (./live.mjs); while
8
+ // maintenance is on there, the holding page for its HTML and 503 'maintenance' for its api/
9
+ // and platform calls, as xgames.fun's play edge does
7
10
  // <files origin>/* the game's public project files (env.GF.files, xcore files), on a port of its own: another
8
11
  // origin to the browser (cross-origin, though same-site: both are 127.0.0.1), as
9
12
  // <slug>.xgamesusercontent.com is live, where it is another site too (./files.mjs)
@@ -20,6 +23,7 @@ import { ownOrigins } from '../publish/client.mjs';
20
23
  import { readServerConfig, API_PREFIX } from '../server/contract.mjs';
21
24
  import { PAGE_RPC, rpcWords, solanaRpc } from '../solana-rpc.mjs';
22
25
  import { handlePlatform, createPlatformStore } from './platform.mjs';
26
+ import { createLiveRoutes, readLive, LIVE_FILE } from './live.mjs';
23
27
  import { createLimitWatch } from './limits.mjs';
24
28
  import { serveFiles } from './files.mjs';
25
29
 
@@ -217,6 +221,14 @@ export async function startDev(
217
221
 
218
222
  const limits = createLimitWatch({ log });
219
223
  const apiAt = `/game${API_PREFIX}`; // the page's api/ beside it: /game/api/
224
+ const live = createLiveRoutes({ dir: game, apiAt, say: problems => log(`live: ${problems.join('; ')}`) });
225
+ const said = await readLive(game);
226
+ if (said.exists) {
227
+ const l = said.live;
228
+ log(
229
+ `live: ${LIVE_FILE}${l.maintenance ? ' (maintenance on: the game shows its holding page)' : ''}${l.announcement ? ', an announcement' : ''}${l.version ? `, config v${l.version}` : ''}`,
230
+ );
231
+ }
220
232
  const route = async (req, res, url) => {
221
233
  // only this machine's own names: a page on another site that points a name of its own at 127.0.0.1 (DNS rebinding)
222
234
  // must not reach the platform, its sign-in tokens or the game's server here
@@ -226,6 +238,7 @@ export async function startDev(
226
238
  return true;
227
239
  }
228
240
  if (url.pathname === PAGE_RPC) return (await forwardRpc(req, res), true);
241
+ if (await live.handle(req, res, url)) return true;
229
242
  if (await handlePlatform(req, res, url, store)) return true;
230
243
  if (url.pathname.startsWith(apiAt) || url.pathname === apiAt.slice(0, -1)) {
231
244
  const rest = url.pathname.slice('/game'.length) + url.search;
@@ -259,9 +272,12 @@ export async function startDev(
259
272
  await files.close();
260
273
  throw e;
261
274
  }
262
- site.server.on('upgrade', (req, socket, head) => {
275
+ site.server.on('upgrade', async (req, socket, head) => {
263
276
  if (!isLocalHost(req.headers.host)) return socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
264
277
  const u = new URL(req.url, 'http://x');
278
+ // paused (.gamesfun/live.json maintenance): refused, as xgames.fun refuses the game's api/ then
279
+ if (await live.refuseUpgrade(u.pathname))
280
+ return socket.end('HTTP/1.1 503 Service Unavailable\r\nRetry-After: 60\r\nConnection: close\r\n\r\n');
265
281
  if (server && u.pathname.startsWith(apiAt)) {
266
282
  limits.upgrade(u.pathname.slice('/game'.length));
267
283
  server.upgrade(req, socket, head, u.pathname.slice('/game'.length) + u.search);