@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.
- package/README.md +104 -14
- package/commands/loot.mjs +13 -4
- package/commands/plan.mjs +16 -13
- package/commands/publish.mjs +14 -0
- package/commands/review.mjs +106 -0
- package/docs/SERVER.md +31 -11
- package/lib/add.mjs +1 -0
- package/lib/client/gamesfun.js +344 -8
- package/lib/client/godot/gamesfun.gd +10 -1
- package/lib/client/server/vouchers.js +15 -8
- package/lib/client/unity/GamesFun.cs +1 -1
- package/lib/dev/index.mjs +17 -1
- package/lib/dev/live.mjs +162 -0
- package/lib/dev/platform.mjs +5 -2
- package/lib/economy.mjs +12 -0
- package/lib/publish/adopt.mjs +11 -0
- package/lib/publish/plans.mjs +13 -4
- package/lib/review.mjs +951 -0
- package/lib/server/contract.mjs +4 -1
- package/package.json +2 -2
- package/skills/bring-your-own-game/SKILL.md +49 -8
package/lib/dev/live.mjs
ADDED
|
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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
|
+
}
|
package/lib/dev/platform.mjs
CHANGED
|
@@ -116,10 +116,13 @@ export function createPlatformStore({
|
|
|
116
116
|
expires: exp * 1000,
|
|
117
117
|
};
|
|
118
118
|
}
|
|
119
|
-
/**
|
|
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
|
package/lib/publish/adopt.mjs
CHANGED
|
@@ -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',
|
package/lib/publish/plans.mjs
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// `xcore plan`'s calls to xgames.fun. A plan is a game's backend tier: Free by default (strict), or
|
|
2
2
|
// Creator when its owner puts it in one of their paid game slots (priced per game slot a month; a slot moves between
|
|
3
3
|
// games at any time without changing the bill), or Scale, which xgames.fun's operator grants.
|
|
4
|
-
//
|
|
4
|
+
// Every game sits in a slot: 2 free ones for everyone (their games on Free), any more are paid (their games on Creator).
|
|
5
|
+
// GET /api/kit/plan → {ok, slots, used, billing, capCents, monthCents, includedCents, games,
|
|
6
|
+
// gameSlots?: {free: {slots, used}, paid: {slots, used}, granted, open}}
|
|
7
|
+
// (slots/used: the paid slots; gameSlots: from xgames.fun 2026-11-08)
|
|
5
8
|
// GET /api/kit/games/<gameId>/plan → {ok, game, tier, capped, comp, limits, monthCents, includedCents, slots, used}
|
|
6
9
|
// POST /api/kit/games/<gameId>/plan {"active": bool} → the same, and changed
|
|
7
10
|
// The game is the folder's own (its .gamesfun/publish.json for this Studio); the credential is publish's.
|
|
@@ -69,7 +72,7 @@ export function describeGame(r) {
|
|
|
69
72
|
lines.push(
|
|
70
73
|
' capped: its server answers slowly (60 requests a minute) until the month ends, or until it gets more room',
|
|
71
74
|
);
|
|
72
|
-
if (r.slots !== undefined) lines.push(`
|
|
75
|
+
if (r.slots !== undefined) lines.push(` paid slots: ${r.used ?? 0} of ${r.slots} in use`);
|
|
73
76
|
return lines.join('\n');
|
|
74
77
|
}
|
|
75
78
|
|
|
@@ -78,12 +81,18 @@ export function describeAccount(r) {
|
|
|
78
81
|
const open = r.payments?.on || r.billing;
|
|
79
82
|
const until = r.periodEnd ? `, paid through ${new Date(r.periodEnd).toISOString().slice(0, 10)}` : '';
|
|
80
83
|
const how = r.payments?.on
|
|
81
|
-
? ` (buy or renew on xgames.fun:
|
|
84
|
+
? ` (buy or renew on xgames.fun: your console → Plans, in ${(r.payments.currencies ?? ['SOL', 'USDC']).join(' or ')}${r.payments.cluster === 'devnet' ? ', devnet test payments' : ''})`
|
|
82
85
|
: open
|
|
83
86
|
? ''
|
|
84
87
|
: ' (buying slots is not open on this xgames.fun yet)';
|
|
88
|
+
const s = r.gameSlots;
|
|
85
89
|
const lines = [
|
|
86
|
-
|
|
90
|
+
...(s
|
|
91
|
+
? [
|
|
92
|
+
`Free slots: ${s.free.used} of ${s.free.slots} in use (their games run on Free)${s.open === 0 ? '; a new game needs one: archive a game or move one to a paid slot' : ''}`,
|
|
93
|
+
]
|
|
94
|
+
: []),
|
|
95
|
+
`${s ? 'Paid slots' : 'Game slots'}: ${r.used ?? 0} of ${r.slots ?? 0} in use${s ? ' (their games run on Creator)' : ''}${until}${how}`,
|
|
87
96
|
`This month: ${dollars(r.monthCents)}${r.includedCents == null ? '' : ` of ${dollars(r.includedCents)} included`}${r.creditCents ? `; usage credit ${dollars(r.creditCents)}` : ''}${r.capCents ? `; spending cap ${dollars(r.capCents)}` : ''}`,
|
|
88
97
|
];
|
|
89
98
|
for (const g of r.games ?? [])
|