@xgamesfun/xcore 0.2.1 → 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.
@@ -0,0 +1,162 @@
1
+ // The game's live operations on this machine, for `xcore dev`. Hosted, the game's owner sets an announcement, a remote
2
+ // config and maintenance mode on xgames.fun/console (the Live tab), and the play edge answers GET /platform/v1/live
3
+ // itself (gamesfun.js's gf.live). Here they come from a file the creator writes, read again on every request:
4
+ //
5
+ // <game>/.gamesfun/live.json (a hidden folder: never published)
6
+ // {
7
+ // "announcement": {"text": "Double XP all weekend!", "level": "info", "startsAt": "2026-11-09T18:00:00Z", "endsAt": null},
8
+ // "config": {"difficulty": "hard", "event": true},
9
+ // "maintenance": {"message": "Back at 7pm"} // or true, or null / left out: not paused
10
+ // }
11
+ //
12
+ // The rules are the hosted ones (xgames.fun's play edge, parseLive): the announcement only between its start and end,
13
+ // plain text cut to 280 characters, info or warning; the config a JSON object of at most 8,192 bytes (a bigger one, or
14
+ // one that is not an object, is left out here and said in the log: xgames.fun refuses it). While maintenance is on, the
15
+ // dev host serves the holding page for the game's HTML pages and answers 503 {ok: false, code: 'maintenance'} to its
16
+ // /game/api/* and /platform/v1/* (but /platform/v1/live and the local sign-in), as xgames.fun does.
17
+ import { readFile } from 'node:fs/promises';
18
+ import { createHash } from 'node:crypto';
19
+ import path from 'node:path';
20
+
21
+ // Shown to people as written (the same on Windows); path.join(dir, LIVE_FILE) still reads it there.
22
+ export const LIVE_FILE = '.gamesfun/live.json';
23
+ export const LIVE_TEXT_CHARS = 280;
24
+ export const LIVE_CONFIG_BYTES = 8192;
25
+ export const MAINTENANCE_DEFAULT = 'This game is paused for maintenance. It will be back soon.';
26
+ export const LIVE_EMPTY = Object.freeze({ announcement: null, config: {}, version: 0, maintenance: null });
27
+
28
+ // eslint-disable-next-line no-control-regex -- control characters never reach a player's screen (tabs and new lines may)
29
+ const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f]/g;
30
+ const text = (v, max) => (typeof v === 'string' ? v.replace(CONTROL, '').trim().slice(0, max) : '');
31
+ const time = v => {
32
+ const t = typeof v === 'number' ? v : typeof v === 'string' && v ? Date.parse(v) : NaN;
33
+ return Number.isFinite(t) && t > 0 ? t : null;
34
+ };
35
+
36
+ /**
37
+ * The file's value → {live: {announcement, config, version, maintenance} as players would get it at `now`, problems:
38
+ * [words]}. Times may be ms or ISO text here (the console sends ms).
39
+ */
40
+ export function parseLiveFile(value, now = Date.now()) {
41
+ const problems = [];
42
+ if (value === null || value === undefined) return { live: LIVE_EMPTY, problems };
43
+ if (typeof value !== 'object' || Array.isArray(value))
44
+ return { live: LIVE_EMPTY, problems: [`${LIVE_FILE} is a JSON object: {announcement, config, maintenance}.`] };
45
+ let announcement = null;
46
+ const a = value.announcement;
47
+ if (a && typeof a === 'object' && text(a.text, LIVE_TEXT_CHARS)) {
48
+ const startsAt = time(a.startsAt),
49
+ endsAt = time(a.endsAt);
50
+ if ([...a.text].length > LIVE_TEXT_CHARS)
51
+ problems.push(`the announcement is cut to ${LIVE_TEXT_CHARS} characters (xgames.fun refuses a longer one)`);
52
+ if (a.level !== undefined && a.level !== 'info' && a.level !== 'warning')
53
+ problems.push(`an announcement's level is info or warning, not ${JSON.stringify(a.level)}`);
54
+ if ((startsAt === null || startsAt <= now) && (endsAt === null || endsAt > now))
55
+ announcement = {
56
+ id: createHash('sha256').update(`${a.text}\n${startsAt}\n${endsAt}`).digest('hex').slice(0, 12),
57
+ text: text(a.text, LIVE_TEXT_CHARS),
58
+ level: a.level === 'warning' ? 'warning' : 'info',
59
+ startsAt,
60
+ endsAt,
61
+ };
62
+ } else if (a) problems.push('the announcement needs its text: {"text": "…"}');
63
+ let config = {};
64
+ const c = value.config;
65
+ if (c !== undefined && c !== null) {
66
+ const bytes = Buffer.byteLength(JSON.stringify(c));
67
+ if (typeof c !== 'object' || Array.isArray(c)) problems.push('the config is a JSON object: {"difficulty": "hard"}');
68
+ else if (bytes > LIVE_CONFIG_BYTES)
69
+ problems.push(`the config is ${bytes} bytes as JSON; xgames.fun takes ${LIVE_CONFIG_BYTES} at most`);
70
+ else config = c;
71
+ }
72
+ const m = value.maintenance;
73
+ const maintenance =
74
+ m === true || (m && typeof m === 'object')
75
+ ? { message: text(m?.message, LIVE_TEXT_CHARS) || MAINTENANCE_DEFAULT, since: time(m?.since) ?? null }
76
+ : null;
77
+ const version = Object.keys(config).length ? (Number.isSafeInteger(value.version) ? value.version : 1) : 0;
78
+ return { live: { announcement, config, version, maintenance }, problems };
79
+ }
80
+
81
+ /** Reads <game>/.gamesfun/live.json now. → {live, problems, exists}; never throws (a broken file is said, not fatal). */
82
+ export async function readLive(dir, now = Date.now()) {
83
+ let raw;
84
+ try {
85
+ raw = await readFile(path.join(dir, LIVE_FILE), 'utf8');
86
+ } catch {
87
+ return { live: LIVE_EMPTY, problems: [], exists: false };
88
+ }
89
+ let value;
90
+ try {
91
+ value = JSON.parse(raw);
92
+ } catch (e) {
93
+ return { live: LIVE_EMPTY, problems: [`${LIVE_FILE} is not JSON: ${e.message}`], exists: true };
94
+ }
95
+ return { ...parseLiveFile(value, now), exists: true };
96
+ }
97
+
98
+ /** The holding page xgames.fun's play edge serves for the game's HTML while it is paused (503, never cached). */
99
+ export function maintenancePage(maintenance) {
100
+ const esc = s =>
101
+ s.replace(/[&<>"']/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[ch]);
102
+ return (
103
+ '<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta http-equiv="refresh" content="60"><title>Back soon</title>' +
104
+ '<body style="margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0b0e;color:#d0d0d8;font:16px/1.5 system-ui,sans-serif">' +
105
+ `<main style="max-width:32rem;padding:24px;text-align:center"><p style="margin:0 0 8px;font-weight:600;color:#fff">Paused for maintenance</p><p style="margin:0">${esc(maintenance.message)}</p>` +
106
+ `<p style="margin:16px 0 0;font-size:13px;opacity:.6">xcore dev: ${LIVE_FILE} has maintenance on.</p></main></body>`
107
+ );
108
+ }
109
+ /** The refusal of /api/* and /platform/v1/* while paused, as xgames.fun's play edge answers it. */
110
+ export const maintenanceBody = m => ({ ok: false, code: 'maintenance', why: m.message, since: m.since });
111
+
112
+ const LIVE_PATH = '/platform/v1/live';
113
+ /**
114
+ * The dev host's live routes (./index.mjs route, tried before the platform's): GET /platform/v1/live from the file, and
115
+ * while maintenance is on, the holding page for the game's HTML under /game/ and 503 'maintenance' for /game/api/* and
116
+ * /platform/v1/* (not /platform/v1/live, nor the local sign-in /platform/v1/dev/*). → true when it answered.
117
+ * `say(problems)`: the file's problems, each time they change.
118
+ */
119
+ export function createLiveRoutes({ dir, apiAt = '/game/api/', say = () => {}, now = () => Date.now() }) {
120
+ let said = '';
121
+ const current = async () => {
122
+ const r = await readLive(dir, now());
123
+ const words = r.problems.join('; ');
124
+ if (words !== said) {
125
+ said = words;
126
+ if (words) say(r.problems);
127
+ }
128
+ return r.live;
129
+ };
130
+ const isApi = p => p.startsWith(apiAt) || p === apiAt.slice(0, -1);
131
+ const isPlatform = p => p.startsWith('/platform/v1/') && p !== LIVE_PATH && !p.startsWith('/platform/v1/dev/');
132
+ const isPage = (p, method) =>
133
+ (method === 'GET' || method === 'HEAD') &&
134
+ p.startsWith('/game/') &&
135
+ !isApi(p) &&
136
+ (p.endsWith('/') || /\.html?$/i.test(p));
137
+ const send = (res, status, type, body, extra = {}) => {
138
+ res.writeHead(status, { 'content-type': type, 'cache-control': 'no-store', ...extra });
139
+ res.end(body);
140
+ return true;
141
+ };
142
+ return {
143
+ current,
144
+ /** Whether a WebSocket upgrade to `pathname` is refused now (the game is paused). */
145
+ async refuseUpgrade(pathname) {
146
+ return isApi(pathname) && !!(await current()).maintenance;
147
+ },
148
+ async handle(req, res, url) {
149
+ const p = url.pathname;
150
+ if (p === LIVE_PATH && (req.method === 'GET' || req.method === 'HEAD'))
151
+ return send(res, 200, 'application/json; charset=utf-8', JSON.stringify({ ok: true, ...(await current()) }));
152
+ if (!isApi(p) && !isPlatform(p) && !isPage(p, req.method)) return false;
153
+ const m = (await current()).maintenance;
154
+ if (!m) return false;
155
+ if (isPage(p, req.method))
156
+ return send(res, 503, 'text/html; charset=utf-8', maintenancePage(m), { 'retry-after': '60' });
157
+ return send(res, 503, 'application/json; charset=utf-8', JSON.stringify(maintenanceBody(m)), {
158
+ 'retry-after': '60',
159
+ });
160
+ },
161
+ };
162
+ }
@@ -116,10 +116,13 @@ export function createPlatformStore({
116
116
  expires: exp * 1000,
117
117
  };
118
118
  }
119
- /** env.GF.verifyPlayer, on this machine: xgames.fun's answer shape (no name: hosted it has none), plus `dev: true`. */
119
+ /**
120
+ * env.GF.verifyPlayer, on this machine: xgames.fun's answer shape (no name: hosted it has none), plus `dev: true`.
121
+ * `banned` is always null here: xcore dev has no owner's bans (hosted, it is the creator's ban on any of the token's ids).
122
+ */
120
123
  function verifyPlayer(token) {
121
124
  const { name: _name, ...v } = readToken(token);
122
- return v;
125
+ return v.ok ? { ...v, banned: null } : v;
123
126
  }
124
127
  /** The local sign-in token's claims as verifyPlayer answers them, plus the local player's `name` (the page's /me). */
125
128
  function readToken(token) {
package/lib/economy.mjs CHANGED
@@ -35,6 +35,18 @@ export function solanaCommand(env = process.env) {
35
35
  return [process.execPath, path.join(root, bin)];
36
36
  }
37
37
 
38
+ /** Does the installed package's command have `command` (a version from before it says it has none)? Read from its
39
+ * command's own source, so nothing runs. */
40
+ export function solanaHas(command, env = process.env) {
41
+ const xsolana = solanaCommand(env);
42
+ if (!xsolana) return false;
43
+ try {
44
+ return new RegExp(`['"]${command}['"]`).test(readFileSync(xsolana[1], 'utf8'));
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
38
50
  // ---- The page client in a game ---------------------------------------------------------------------------------------
39
51
  // A published page has no node_modules: the package's page side (@xgamesfun/solana/play and every file it imports, all
40
52
  // relative plain ES modules with no dependency) is copied into a game with an economy as gf/loot/. Its record
@@ -728,6 +728,7 @@ export async function scanPage(out, { backend = [], limits = DEFAULT_LIMITS, pre
728
728
 
729
729
  let rootPaths = 0,
730
730
  sw = false,
731
+ fullscreen = false, // a way to go full screen: the page's own button, an engine's, or GamesFun.fullscreen()
731
732
  devices = new Map(), // what it asks for → where
732
733
  callsApi = [];
733
734
  // a file the page loads by a path from the root: the first three are listed, the rest counted
@@ -903,6 +904,10 @@ export async function scanPage(out, { backend = [], limits = DEFAULT_LIMITS, pre
903
904
  });
904
905
  }
905
906
  if (!engine && /serviceWorker\s*\.\s*register\s*\(/.test(text)) sw = true;
907
+ if (
908
+ /\b(?:webkitR|r)equestFullscreen\b|\bfullscreen\s*\(|gf:fullscreen|window_set_mode|\bSetFullscreen\b/.test(text)
909
+ )
910
+ fullscreen = true;
906
911
  for (const [what, re] of [
907
912
  ['the camera or microphone', /getUserMedia\s*\(/],
908
913
  ['the location', /geolocation\s*\.\s*(getCurrentPosition|watchPosition)/],
@@ -977,6 +982,12 @@ export async function scanPage(out, { backend = [], limits = DEFAULT_LIMITS, pre
977
982
  what: `${at('index.html')} names no tab icon: browsers ask for /favicon.ico, and the live game's console shows a 404 for it`,
978
983
  fix: 'Add one beside the page and link it relatively: <link rel="icon" href="./icon.png"> (or put a favicon.ico at the root of what is published)',
979
984
  });
985
+ // most players come from a post on X or a phone, where the game first shows in a small frame
986
+ if (!fullscreen && files.some(f => /\.html?$/i.test(f.rel)))
987
+ warn.push({
988
+ what: 'it has no way to go full screen: in a post on X and on a phone the game first shows in a small frame',
989
+ fix: "Give it a full screen button that calls GamesFun.fullscreen() (gamesfun.js, xcore add signin): on xgames.fun the page around it goes full screen (or fills the screen in an app's own browser, X's among them), and elsewhere the browser does, or the game opens in a tab of its own. Or call the browser's element.requestFullscreen() from a click",
990
+ });
980
991
  if (sw)
981
992
  warn.push({
982
993
  what: 'it registers a service worker',