@onceworlds/cli 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 +39 -20
- package/dist/cli.js +397 -22
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,12 +6,17 @@ players) with no server code.
|
|
|
6
6
|
|
|
7
7
|
## Deploy from GitHub Actions
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Deploys run from GitHub Actions: GitHub vouches for the repo, so there are no API
|
|
10
|
+
keys. The easiest setup is to import the repo at https://onceworlds.com/dashboard/new,
|
|
11
|
+
which adds `onceworlds.json` and `.github/workflows/onceworlds.yml` for you (that
|
|
12
|
+
workflow also builds with npm, pnpm, yarn or bun when there's a build script). By
|
|
13
|
+
hand, the minimum is:
|
|
10
14
|
|
|
11
15
|
```yaml
|
|
12
16
|
name: Deploy to Onceworlds
|
|
13
17
|
on:
|
|
14
18
|
push:
|
|
19
|
+
branches: ['**']
|
|
15
20
|
permissions:
|
|
16
21
|
contents: read
|
|
17
22
|
id-token: write # proves which repo is deploying; no API keys needed
|
|
@@ -26,17 +31,16 @@ jobs:
|
|
|
26
31
|
- run: npx --yes @onceworlds/cli@latest deploy
|
|
27
32
|
```
|
|
28
33
|
|
|
29
|
-
Pushes to `main`
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
deploy.
|
|
34
|
+
Pushes to the production branch (`main` unless you change it in the dashboard or
|
|
35
|
+
`onceworlds.json`) go live at `onceworlds.com/play/<slug>`; other branches get a
|
|
36
|
+
preview URL in the job log. Log in to https://onceworlds.com with a GitHub
|
|
37
|
+
account that has write access to the repo before the first deploy. Install the
|
|
38
|
+
Onceworlds GitHub App on that repo to use the dashboard and open the game while
|
|
39
|
+
private. If a deploy fails, the dashboard shows why.
|
|
33
40
|
|
|
34
|
-
New games start private: only
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
You can also skip the setup: import the repo at https://onceworlds.com/dashboard/new,
|
|
39
|
-
which adds `onceworlds.json` and this workflow for you.
|
|
41
|
+
New games start private: only accounts with write access to the repo can play
|
|
42
|
+
them. To share one, open it in the dashboard and pick Unlisted (anyone
|
|
43
|
+
with the link) or Public (also listed on Onceworlds).
|
|
40
44
|
|
|
41
45
|
## Project file
|
|
42
46
|
|
|
@@ -44,30 +48,45 @@ which adds `onceworlds.json` and this workflow for you.
|
|
|
44
48
|
|
|
45
49
|
```json
|
|
46
50
|
{
|
|
47
|
-
"slug": "
|
|
48
|
-
"name": "
|
|
49
|
-
"description": "
|
|
51
|
+
"slug": "kart-rush",
|
|
52
|
+
"name": "Kart Rush",
|
|
53
|
+
"description": "Race friends around twisty tracks.",
|
|
50
54
|
"dir": ".",
|
|
51
|
-
"genre": "
|
|
55
|
+
"genre": "Racing",
|
|
52
56
|
"icon": "store/icon.png",
|
|
53
57
|
"thumbnails": ["store/thumb-1.png", "store/thumb-2.png"],
|
|
54
|
-
"badges": [{ "id": "first-
|
|
58
|
+
"badges": [{ "id": "first-win", "name": "Top Step", "description": "Win a race." }]
|
|
55
59
|
}
|
|
56
60
|
```
|
|
57
61
|
|
|
58
62
|
`dir` is the folder to publish (for example `dist` after a build); it must contain
|
|
59
|
-
`index.html`. `productionBranch` (default `main`) picks the branch that goes live
|
|
63
|
+
`index.html`. `productionBranch` (default `main`) picks the branch that goes live;
|
|
64
|
+
the dashboard can change it too, and the file wins again only when this value changes.
|
|
60
65
|
|
|
61
|
-
|
|
66
|
+
`controls` (`[{ "keys": ["W", "A", "S", "D"], "action": "Move" }]`, up to 12) and `help` (up to 400
|
|
67
|
+
characters) fill "How to play" in the platform menu. The rest fills in the game page: `genre`, a square `icon` (at least 128×128),
|
|
62
68
|
16:9 `thumbnails` (at least 480 wide; the first is the cover) and `badges` (`id`,
|
|
63
69
|
`name`, `description` and an optional square `icon`). Images are PNG, JPEG, WebP
|
|
64
70
|
or GIF up to 5 MB, with paths relative to `onceworlds.json`. Live deploys apply
|
|
65
71
|
these fields only when they change, so edits made in the dashboard last until then.
|
|
66
72
|
|
|
73
|
+
Make the art before the first deploy: players see the icon and thumbnails before the game, and a game
|
|
74
|
+
without them gets a plain colored placeholder. The icon is a simple, wordless 512×512 square; the thumbnails
|
|
75
|
+
are three to five 1280×720 pictures of the game being played, the best one first (it is the cover). Capture them
|
|
76
|
+
from the running game and commit them under `store/`. `onceworlds doctor` says which fields are missing.
|
|
77
|
+
|
|
67
78
|
## Commands
|
|
68
79
|
|
|
69
|
-
- `onceworlds deploy [dir]` uploads only the files that changed and publishes the game
|
|
70
|
-
|
|
80
|
+
- `onceworlds deploy [dir]` uploads only the files that changed and publishes the game
|
|
81
|
+
(the workflow runs it). It lists every file it skipped because Onceworlds can't serve it.
|
|
82
|
+
- `onceworlds dev [dir]` deploys to a local Onceworlds platform (for platform development)
|
|
83
|
+
and redeploys on every save. `--players 3` also prints links for players 2 and 3 (`?player=N`): on a
|
|
84
|
+
local platform each is another player, so one person can fill a lobby.
|
|
85
|
+
- `onceworlds doctor [dir]` looks through the game's files for what games often ship with: no viewport
|
|
86
|
+
tag, text fields under 16px (Safari zooms the page when one is focused), `confirm()` and other dialogs
|
|
87
|
+
(blocked in the game frame), localStorage (gone on reload), and, for games with rooms, `Date.now()` for
|
|
88
|
+
shared time and no use of the platform's lobby. It only suggests: nothing in it stops a deploy
|
|
89
|
+
(`--strict` exits with an error on likely bugs, for CI).
|
|
71
90
|
|
|
72
91
|
Games talk to the platform through the injected `window.onceworlds` SDK. The
|
|
73
92
|
starter template's `AGENTS.md` documents it for AI coding tools.
|
package/dist/cli.js
CHANGED
|
@@ -8,21 +8,212 @@ import { readdir, readFile } from "node:fs/promises";
|
|
|
8
8
|
import { join, resolve, sep } from "node:path";
|
|
9
9
|
import { parseArgs } from "node:util";
|
|
10
10
|
|
|
11
|
+
// src/doctor.ts
|
|
12
|
+
var ROOM_MAX_PLAYERS = 30;
|
|
13
|
+
var MINIFIED_LINE = 400;
|
|
14
|
+
var lineOf = (text, index) => text.slice(0, index).split("\n").length;
|
|
15
|
+
function looksMinified(text) {
|
|
16
|
+
const lines = text.split("\n");
|
|
17
|
+
return text.length > 2e4 && text.length / lines.length > MINIFIED_LINE;
|
|
18
|
+
}
|
|
19
|
+
function cssIn(file) {
|
|
20
|
+
if (/\.css$/i.test(file.path)) return [{ css: file.text, offset: 0 }];
|
|
21
|
+
if (!/\.html?$/i.test(file.path)) return [];
|
|
22
|
+
const blocks = [];
|
|
23
|
+
for (const match of file.text.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)) blocks.push({ css: match[1], offset: (match.index ?? 0) + match[0].indexOf(match[1]) });
|
|
24
|
+
return blocks;
|
|
25
|
+
}
|
|
26
|
+
function smallFields(file) {
|
|
27
|
+
const found = [];
|
|
28
|
+
for (const { css, offset } of cssIn(file)) {
|
|
29
|
+
for (const rule of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
|
30
|
+
const selector = rule[1];
|
|
31
|
+
if (!/(^|[\s,>+~(])(input|textarea|select)\b/i.test(selector) && !/\.[\w-]*(input|field|textbox)\b/i.test(selector)) continue;
|
|
32
|
+
if (/type\s*=\s*["']?(range|checkbox|radio|color|button|submit|file)/i.test(selector)) continue;
|
|
33
|
+
const size = /(?:^|[;\s])font-size\s*:\s*(\d+(?:\.\d+)?)px/i.exec(rule[2]) ?? /(?:^|[;\s])font\s*:\s*[^;]*?\b(\d+(?:\.\d+)?)px/i.exec(rule[2]);
|
|
34
|
+
if (!size || Number(size[1]) >= 16) continue;
|
|
35
|
+
const at = offset + (rule.index ?? 0) + rule[0].indexOf(size[0]);
|
|
36
|
+
found.push({
|
|
37
|
+
file: file.path,
|
|
38
|
+
line: lineOf(file.text, at),
|
|
39
|
+
level: "warn",
|
|
40
|
+
id: "field-size",
|
|
41
|
+
message: `A text field at ${size[1]}px: Safari on iPhone and iPad zooms the whole page when it's focused. Use at least 16px.`
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return found;
|
|
46
|
+
}
|
|
47
|
+
var LINE_RULES = [
|
|
48
|
+
{
|
|
49
|
+
id: "dialogs",
|
|
50
|
+
level: "warn",
|
|
51
|
+
test: /(?<![\w$])(?:window\s*\.\s*)?(alert|confirm|prompt)\s*\(/,
|
|
52
|
+
// `confirm() {` (a method), `function prompt(`, `.confirm(` on some object of the game's own.
|
|
53
|
+
except: /(?<![\w$])(alert|confirm|prompt)\s*\([^()]*\)\s*\{|function\s+(alert|confirm|prompt)\b|(?<!window)\.\s*(alert|confirm|prompt)\s*\(/,
|
|
54
|
+
message: "alert(), confirm() and prompt() are blocked in the game frame (confirm() answers false at once). Draw your own message or dialog."
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "fullscreen",
|
|
58
|
+
level: "note",
|
|
59
|
+
test: /\.(webkitR|r)equestFullscreen\s*\(/,
|
|
60
|
+
message: "Use onceworlds.ui.requestFullscreen(): the platform keeps its menu and chat available in fullscreen, and handles iPhone."
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: "storage",
|
|
64
|
+
level: "note",
|
|
65
|
+
test: /\b(localStorage|sessionStorage)\b|document\.cookie/,
|
|
66
|
+
message: "localStorage, sessionStorage and cookies are kept in memory in the game frame and are gone on reload (fine for caches and a fallback outside the platform). Use onceworlds.save.set/get for progress and settings that should stay."
|
|
67
|
+
}
|
|
68
|
+
];
|
|
69
|
+
function checkListing(config, file = "onceworlds.json") {
|
|
70
|
+
const found = [];
|
|
71
|
+
const note = (id, message) => found.push({ file, line: 1, level: "note", id, message });
|
|
72
|
+
const filled = (value) => typeof value === "string" && value.trim() !== "";
|
|
73
|
+
if (!filled(config.icon)) {
|
|
74
|
+
note("listing-icon", `No "icon": the game's card shows a plain colored placeholder. Add a square image (512\xD7512, simple, no words), like "icon": "store/icon.png".`);
|
|
75
|
+
}
|
|
76
|
+
if (!Array.isArray(config.thumbnails) || config.thumbnails.length === 0) {
|
|
77
|
+
note("listing-thumbnails", 'No "thumbnails": the game page and its card have no cover. Add three or more 16:9 pictures (1280\xD7720) of the game being played; the first is the cover.');
|
|
78
|
+
}
|
|
79
|
+
if (!filled(config.genre)) note("listing-genre", `No "genre": players can't find the game under a Discover genre. Pick one of the genres in AGENTS.md.`);
|
|
80
|
+
const noControls = !Array.isArray(config.controls) || config.controls.length === 0;
|
|
81
|
+
if (noControls && !filled(config.help)) {
|
|
82
|
+
note("listing-help", `No "controls" or "help": the menu's How to play is empty. List every key or button the game uses and a line on how to win.`);
|
|
83
|
+
}
|
|
84
|
+
return found;
|
|
85
|
+
}
|
|
86
|
+
function checkGame(files) {
|
|
87
|
+
const found = [];
|
|
88
|
+
const code = files.filter((f) => /\.(html?|m?js|css)$/i.test(f.path));
|
|
89
|
+
const index = code.find((f) => f.path === "index.html");
|
|
90
|
+
if (index && !/<meta[^>]+name\s*=\s*["']viewport["']/i.test(index.text)) {
|
|
91
|
+
found.push({
|
|
92
|
+
file: index.path,
|
|
93
|
+
line: 1,
|
|
94
|
+
level: "warn",
|
|
95
|
+
id: "viewport",
|
|
96
|
+
message: 'No viewport meta tag: phones show the page zoomed out. Add <meta name="viewport" content="width=device-width, initial-scale=1">.'
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
for (const file of code) {
|
|
100
|
+
found.push(...smallFields(file));
|
|
101
|
+
if (looksMinified(file.text) || /\.css$/i.test(file.path)) continue;
|
|
102
|
+
const lines = file.text.split("\n");
|
|
103
|
+
for (const rule of LINE_RULES) {
|
|
104
|
+
const first = lines.findIndex((line) => !/^\s*(\/\/|\*|\/\*)/.test(line) && rule.test.test(line) && !rule.except?.test(line));
|
|
105
|
+
if (first !== -1) found.push({ file: file.path, line: first + 1, level: rule.level, id: rule.id, message: rule.message });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const all = code.map((f) => f.text).join("\n");
|
|
109
|
+
if (/\brooms\s*\.\s*join\s*\(/.test(all)) {
|
|
110
|
+
const host = code.find((f) => /\brooms\s*\.\s*join\s*\(/.test(f.text));
|
|
111
|
+
const at = lineOf(host.text, host.text.search(/\brooms\s*\.\s*join\s*\(/));
|
|
112
|
+
if (!/\b(lobby\s*:|startMatch|matchstart|setReady|minPlayers)/.test(all)) {
|
|
113
|
+
found.push({
|
|
114
|
+
file: host.path,
|
|
115
|
+
line: at,
|
|
116
|
+
level: "note",
|
|
117
|
+
id: "matches",
|
|
118
|
+
message: `This joins a room but doesn't use the platform's lobby or match lifecycle. That's right for a world people drop in and out of; a game with rounds gets ready-up, a countdown, spectators and pausing from rooms.join({ lobby: 'card' }) (see AGENTS.md, "Matches").`
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
if (/\bDate\s*\.\s*now\s*\(/.test(all) && !/\bonceworlds\s*\.\s*now\s*\(|\bow\s*\.\s*now\s*\(|\bmatchNow\s*\(/.test(all)) {
|
|
122
|
+
found.push({
|
|
123
|
+
file: host.path,
|
|
124
|
+
line: at,
|
|
125
|
+
level: "note",
|
|
126
|
+
id: "clock",
|
|
127
|
+
message: "Time from Date.now() differs between players' devices. Use onceworlds.now() for anything players share, and room.matchNow() for a match's deadlines."
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const size = /\bmaxPlayers\s*:\s*(\d+)\b/g;
|
|
132
|
+
for (const file of code) {
|
|
133
|
+
for (const match of file.text.matchAll(size)) {
|
|
134
|
+
if (Number(match[1]) <= ROOM_MAX_PLAYERS) continue;
|
|
135
|
+
found.push({
|
|
136
|
+
file: file.path,
|
|
137
|
+
line: lineOf(file.text, match.index ?? 0),
|
|
138
|
+
level: "note",
|
|
139
|
+
id: "room-size",
|
|
140
|
+
message: `A room seats at most ${ROOM_MAX_PLAYERS} players: maxPlayers: ${match[1]} gives ${ROOM_MAX_PLAYERS}. The bigger the room, the less each player may send (room.budget), so a full room stays smooth.`
|
|
141
|
+
});
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const keyboardKeys = /\b(ArrowLeft|ArrowRight|ArrowUp|ArrowDown|KeyW|KeyA|KeyS|KeyD)\b|\.key\s*={2,3}\s*['"](?:w|a|s|d|ArrowLeft|ArrowRight|ArrowUp|ArrowDown)['"]|case\s+['"](?:w|a|s|d|ArrowLeft|ArrowRight|ArrowUp|ArrowDown)['"]|keys\s*\.\s*has\s*\(\s*['"](?:w|a|s|d|arrowleft|arrowright|arrowup|arrowdown)['"]/;
|
|
146
|
+
const keyboard = keyboardKeys.test(all);
|
|
147
|
+
if (keyboard && !/\bcontrols\s*\??\.\s*(?:set|stick|touch|pressed)\b|\b(?:set|sync)Controls\s*\(/i.test(all)) {
|
|
148
|
+
const touchable = /\b(pointerdown|touchstart|mousedown|click)\b/.test(all);
|
|
149
|
+
const file = code.find((f) => keyboardKeys.test(f.text));
|
|
150
|
+
found.push({
|
|
151
|
+
file: file.path,
|
|
152
|
+
line: lineOf(file.text, file.text.search(keyboardKeys)),
|
|
153
|
+
level: touchable ? "note" : "warn",
|
|
154
|
+
id: "touch-controls",
|
|
155
|
+
message: touchable ? "Steered by keys, with no on-screen controls: phones show none. Ask for the platform's with onceworlds.controls.set({ stick: 'wasd' }) (see AGENTS.md, phones and tablets)." : "Steered by keys, and nothing responds to touch: on a phone the game can't be played. Ask for the platform's on-screen controls with onceworlds.controls.set({ stick: 'wasd', buttons: [...] })."
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
if (/\bdevicePixelRatio\b/.test(all) && !/\bsettings\s*\.\s*pixelRatio\b/.test(all)) {
|
|
159
|
+
const file = code.find((f) => /\bdevicePixelRatio\b/.test(f.text));
|
|
160
|
+
found.push({
|
|
161
|
+
file: file.path,
|
|
162
|
+
line: lineOf(file.text, file.text.search(/\bdevicePixelRatio\b/)),
|
|
163
|
+
level: "note",
|
|
164
|
+
id: "pixel-ratio",
|
|
165
|
+
message: "Size canvases with onceworlds.settings.pixelRatio(2): it caps the resolution and follows the player's Graphics setting, so tablets and older phones keep their frame rate."
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return found.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
|
|
169
|
+
}
|
|
170
|
+
|
|
11
171
|
// ../protocol/src/index.ts
|
|
12
172
|
var OIDC_AUDIENCE = "onceworlds";
|
|
13
173
|
var LIMITS = {
|
|
14
174
|
roomMaxPlayersDefault: 16,
|
|
15
|
-
|
|
175
|
+
/**
|
|
176
|
+
* The most players a room seats. A room is one Durable Object that copies every update a player sends to everyone else, so
|
|
177
|
+
* what it costs grows with the square of its players: 30 is where a room stays well inside what one object serves.
|
|
178
|
+
*/
|
|
179
|
+
roomMaxPlayersCap: 30,
|
|
16
180
|
teamsMax: 8,
|
|
17
181
|
messageBytes: 16 * 1024,
|
|
18
182
|
presenceBytes: 1024,
|
|
19
183
|
stateKeyLength: 64,
|
|
20
184
|
stateValueBytes: 16 * 1024,
|
|
21
185
|
stateKeys: 256,
|
|
186
|
+
/** Private values (room.setPrivate): keys per player, one value, and all of a player's together. */
|
|
187
|
+
privateKeys: 16,
|
|
188
|
+
privateValueBytes: 4 * 1024,
|
|
189
|
+
privateBytesPerPlayer: 8 * 1024,
|
|
190
|
+
/** The most messages a second one player may send a room (a room with more players gives each less: see roomBudget). */
|
|
22
191
|
messagesPerSecond: 60,
|
|
23
|
-
|
|
192
|
+
messagesPerSecondMin: 10,
|
|
193
|
+
/** Bytes one player may send to a room per second, on average (bursts up to 4x): the most, in a small room. */
|
|
24
194
|
roomBytesPerSecond: 128 * 1024,
|
|
195
|
+
roomBytesPerSecondMin: 16 * 1024,
|
|
196
|
+
/** The most presence updates a second one player may send. */
|
|
25
197
|
presenceHz: 20,
|
|
198
|
+
presenceHzMin: 5,
|
|
199
|
+
/**
|
|
200
|
+
* What a room takes in from all its players together each second, presence included (a Durable Object serves about 1000
|
|
201
|
+
* requests a second, and each update is then sent on to everyone else), and the bytes. Each player's share is this divided
|
|
202
|
+
* by the room's capacity, up to the per-player maximums above: rooms of up to 11 players get all of them.
|
|
203
|
+
*/
|
|
204
|
+
roomMessagesPerSecond: 900,
|
|
205
|
+
roomBytesPerSecondTotal: 2 * 1024 * 1024,
|
|
206
|
+
/** A match's countdown before play begins, unless the room is set up otherwise (rooms.join({ countdown })). */
|
|
207
|
+
matchCountdownMs: 3e3,
|
|
208
|
+
matchCountdownMaxMs: 1e4,
|
|
209
|
+
/** A public lobby where half the players are ready waits this long for the rest before starting without them. */
|
|
210
|
+
matchIdleMs: 45e3,
|
|
211
|
+
/** A match that has waited this long for its players (paused, too few connected) is over. */
|
|
212
|
+
matchAbandonMs: 12e4,
|
|
213
|
+
/** A player who hasn't touched the game (pointer, keys, touch, gamepad) this long is idle (room.on('idle')). */
|
|
214
|
+
idleAfterMs: 3e4,
|
|
215
|
+
/** A running match whose connected players have all been idle this long is over: nobody is playing. */
|
|
216
|
+
idleAbandonMs: 5 * 6e4,
|
|
26
217
|
chatLength: 200,
|
|
27
218
|
chatIntervalMs: 750,
|
|
28
219
|
chatHistory: 50,
|
|
@@ -34,37 +225,128 @@ var LIMITS = {
|
|
|
34
225
|
guestSaveBytesPerGame: 128 * 1024,
|
|
35
226
|
deployFiles: 2e3,
|
|
36
227
|
deployFileBytes: 25 * 1024 * 1024,
|
|
228
|
+
/** HTML files (.html, .htm) get the SDK injected as they're served (streamed: only the first bytes are read for it), so they're held to less than other files. */
|
|
229
|
+
deployHtmlBytes: 10 * 1024 * 1024,
|
|
230
|
+
/** Characters in a deployed file's path. */
|
|
231
|
+
deployPathLength: 200,
|
|
37
232
|
deployTotalBytes: 100 * 1024 * 1024,
|
|
38
233
|
envVars: 50,
|
|
39
234
|
envValueBytes: 4 * 1024,
|
|
235
|
+
/** All of a game's public variables together, as injected into its pages. */
|
|
236
|
+
envTotalBytes: 16 * 1024,
|
|
237
|
+
/** Proxied request bodies: routes that take a file (audio to transcribe, an image to edit, model inputs on Replicate and fal). */
|
|
40
238
|
proxyRequestBytes: 1024 * 1024,
|
|
239
|
+
/** Proxied JSON bodies for text, embeddings and image generation. */
|
|
240
|
+
proxyTextRequestBytes: 256 * 1024,
|
|
241
|
+
/** Proxied text-to-speech bodies (priced per character). */
|
|
242
|
+
proxySpeechRequestBytes: 16 * 1024,
|
|
41
243
|
proxyResponseBytes: 10 * 1024 * 1024,
|
|
244
|
+
/** Output tokens per proxied text call: a request asking for more is lowered to this, one that doesn't say gets it. */
|
|
245
|
+
proxyMaxOutputTokens: 4096,
|
|
42
246
|
proxyCallsPerDay: 2e3,
|
|
247
|
+
proxyCallsPerDayMax: 1e5,
|
|
248
|
+
proxyCallsPerPlayerPerDay: 100,
|
|
249
|
+
proxyCallsPerNetworkPerDay: 1e3,
|
|
250
|
+
proxyMediaCallsPerDay: 100,
|
|
251
|
+
proxyMediaCallsPerDayMax: 1e4,
|
|
252
|
+
proxyMediaCallsPerPlayerPerDay: 20,
|
|
253
|
+
proxyMediaCallsPerNetworkPerDay: 25,
|
|
254
|
+
/**
|
|
255
|
+
* Daily units: each proxied call's estimated tokens (request bytes / 3 plus its output
|
|
256
|
+
* cap) times its model's weight. Per game (the creator can set up to the max), per
|
|
257
|
+
* signed-in player, per guest, and per guests' network.
|
|
258
|
+
*/
|
|
259
|
+
proxyUnitsPerDay: 2e6,
|
|
260
|
+
proxyUnitsPerDayMax: 1e8,
|
|
261
|
+
proxyUnitsPerPlayerPerDay: 25e4,
|
|
262
|
+
proxyUnitsPerGuestPerDay: 1e5,
|
|
263
|
+
proxyUnitsPerNetworkPerDay: 5e5,
|
|
43
264
|
errorReportsPerPage: 10,
|
|
44
265
|
gameNameLength: 60,
|
|
45
266
|
gameDescriptionLength: 1e3,
|
|
46
267
|
galleryItems: 10,
|
|
47
268
|
mediaImageBytes: 5 * 1024 * 1024,
|
|
48
269
|
mediaVideoBytes: 30 * 1024 * 1024,
|
|
270
|
+
/** GIFs are served as they are (never resized), so they're small: at most this many bytes and pixels a side. */
|
|
271
|
+
mediaGifBytes: 1024 * 1024,
|
|
272
|
+
mediaGifSide: 512,
|
|
273
|
+
/** Media an account uploads in the dashboard (or a repo owner's deploys add to game pages) in any 24 hours. */
|
|
274
|
+
mediaUploadsPerDay: 200,
|
|
275
|
+
mediaBytesPerDay: 500 * 1024 * 1024,
|
|
49
276
|
badgesPerGame: 100,
|
|
277
|
+
/** The menu's "How to play": up to this many controls (each with a few keys and a short action), and a few lines of help. */
|
|
278
|
+
controlHints: 12,
|
|
279
|
+
controlHintKeys: 4,
|
|
280
|
+
controlKeyLength: 12,
|
|
281
|
+
controlActionLength: 40,
|
|
282
|
+
helpLength: 400,
|
|
50
283
|
badgeNameLength: 50,
|
|
51
284
|
badgeDescriptionLength: 200,
|
|
52
285
|
leaderboardsPerGame: 25,
|
|
53
286
|
leaderboardTop: 100,
|
|
54
287
|
gamesPerOwner: 50,
|
|
288
|
+
/** New games per creator in any rolling 24-hour period. Redeploys do not count. */
|
|
289
|
+
gamesCreatedPerCreatorPerDay: 3,
|
|
55
290
|
deploysPerOwnerPerDay: 100,
|
|
291
|
+
/** Finished versions retained per game, including its live version. */
|
|
292
|
+
deploymentsKeptPerGame: 10,
|
|
56
293
|
deployBytesPerOwnerPerDay: 2 * 1024 * 1024 * 1024,
|
|
294
|
+
/** Files listed by all of a repo owner's deploys in any 24 hours (each is a record to keep and clean up). */
|
|
295
|
+
deployEntriesPerOwnerPerDay: 1e5,
|
|
57
296
|
bioLength: 500,
|
|
58
297
|
friendsMax: 200,
|
|
298
|
+
/** Requests one player has sent that are still waiting. */
|
|
59
299
|
friendRequestsMax: 100,
|
|
300
|
+
/** Requests waiting for one player: more wait until they answer some. */
|
|
301
|
+
friendRequestsIncomingMax: 50,
|
|
302
|
+
/** New friend requests an account sends in 24 hours (asking again after cancelling counts again)... */
|
|
303
|
+
friendRequestsPerDay: 20,
|
|
304
|
+
/** ...and all accounts under a week old on one network (IPv4 address, or IPv6 /48) together: a bot farm's accounts share one, a school's established ones are not counted. */
|
|
305
|
+
friendRequestsPerNetworkPerDay: 120,
|
|
306
|
+
/** Friend requests that reach a player's devices (push) in a day, at most one every 10 minutes. */
|
|
307
|
+
friendRequestPushesPerDay: 5,
|
|
60
308
|
passwordMinLength: 8,
|
|
61
|
-
passwordMaxLength: 72
|
|
309
|
+
passwordMaxLength: 72,
|
|
310
|
+
/** Account creation attempts, with or without email, per network (IP, or IPv6 /64) in 24 hours. */
|
|
311
|
+
signupsPerNetworkPerDay: 100,
|
|
312
|
+
/** The same per wide network (an IPv6 /48); an IPv4 address with a proof of work may go on to this too. */
|
|
313
|
+
signupsPerWidePerDay: 300,
|
|
314
|
+
/** New guest players per network per day (each one can hold saves). */
|
|
315
|
+
guestsPerNetworkPerDay: 300,
|
|
316
|
+
/** The same per wide network (an IPv6 /48); an IPv4 address with a proof of work may go on to this too. */
|
|
317
|
+
guestsPerWidePerDay: 1500,
|
|
318
|
+
/** Visits a game gets per network (IPv4 address, or IPv6 /48) per day: a script can't pump the count. */
|
|
319
|
+
visitsPerNetworkPerDay: 10,
|
|
320
|
+
/** Seconds of visible play before a play counts as a visit. */
|
|
321
|
+
visitSeconds: 10,
|
|
322
|
+
/** Distinct errors kept per deploy of a game (more are counted by nobody). */
|
|
323
|
+
errorsPerDeployment: 100,
|
|
324
|
+
/** On-screen controls: buttons beside the thumbstick, and their labels. */
|
|
325
|
+
controlButtons: 4,
|
|
326
|
+
controlLabelLength: 12,
|
|
327
|
+
/** Friends in one party, the leader included. */
|
|
328
|
+
partySize: 6,
|
|
329
|
+
partyChatHistory: 50,
|
|
330
|
+
/** Messages outside games: saved chats between friends. */
|
|
331
|
+
messageLength: 500,
|
|
332
|
+
messagesPerPage: 50,
|
|
333
|
+
groupChatMembers: 20,
|
|
334
|
+
groupChatNameLength: 40,
|
|
335
|
+
conversationsListed: 50,
|
|
336
|
+
notificationsListed: 50,
|
|
337
|
+
/** Friends one game invite can go to. */
|
|
338
|
+
invitesPerRequest: 10,
|
|
339
|
+
/** How long an invite can be joined: it's for now. (It's over sooner when the sender leaves the server it points at, or you're already there.) */
|
|
340
|
+
inviteLiveMs: 30 * 6e4,
|
|
341
|
+
/** How long an invite to a party can be answered. */
|
|
342
|
+
partyInviteMs: 60 * 6e4
|
|
62
343
|
};
|
|
63
344
|
var CONTENT_TYPES = {
|
|
64
345
|
html: "text/html; charset=utf-8",
|
|
65
346
|
htm: "text/html; charset=utf-8",
|
|
66
347
|
js: "text/javascript; charset=utf-8",
|
|
67
348
|
mjs: "text/javascript; charset=utf-8",
|
|
349
|
+
cjs: "text/javascript; charset=utf-8",
|
|
68
350
|
css: "text/css; charset=utf-8",
|
|
69
351
|
json: "application/json; charset=utf-8",
|
|
70
352
|
map: "application/json; charset=utf-8",
|
|
@@ -90,6 +372,7 @@ var CONTENT_TYPES = {
|
|
|
90
372
|
opus: "audio/ogg",
|
|
91
373
|
wav: "audio/wav",
|
|
92
374
|
m4a: "audio/mp4",
|
|
375
|
+
aac: "audio/aac",
|
|
93
376
|
flac: "audio/flac",
|
|
94
377
|
mp4: "video/mp4",
|
|
95
378
|
webm: "video/webm",
|
|
@@ -100,6 +383,11 @@ var CONTENT_TYPES = {
|
|
|
100
383
|
mtl: "text/plain; charset=utf-8",
|
|
101
384
|
fbx: "application/octet-stream",
|
|
102
385
|
wasm: "application/wasm",
|
|
386
|
+
// Shaders, loaded as text.
|
|
387
|
+
glsl: "text/plain; charset=utf-8",
|
|
388
|
+
vert: "text/plain; charset=utf-8",
|
|
389
|
+
frag: "text/plain; charset=utf-8",
|
|
390
|
+
wgsl: "text/plain; charset=utf-8",
|
|
103
391
|
pck: "application/octet-stream",
|
|
104
392
|
data: "application/octet-stream",
|
|
105
393
|
woff: "font/woff",
|
|
@@ -119,7 +407,7 @@ function contentTypeFor(path) {
|
|
|
119
407
|
return CONTENT_TYPES[extensionOf(path)];
|
|
120
408
|
}
|
|
121
409
|
function isValidDeployPath(path) {
|
|
122
|
-
if (path.length === 0 || path.length >
|
|
410
|
+
if (path.length === 0 || path.length > LIMITS.deployPathLength) return false;
|
|
123
411
|
if (path.startsWith("/") || path.includes("\\")) return false;
|
|
124
412
|
if (/[\u0000-\u001f\u007f]/.test(path)) return false;
|
|
125
413
|
for (const segment of path.split("/")) {
|
|
@@ -132,6 +420,7 @@ var RESERVED_SLUGS = /* @__PURE__ */ new Set(["www", "api", "play", "admin", "st
|
|
|
132
420
|
function isValidSlug(slug) {
|
|
133
421
|
return SLUG_RE.test(slug) && !slug.includes("--") && !RESERVED_SLUGS.has(slug);
|
|
134
422
|
}
|
|
423
|
+
var DEFAULT_PRODUCTION_BRANCH = "main";
|
|
135
424
|
var BADGE_KEY_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
|
|
136
425
|
var GENRES = [
|
|
137
426
|
"Action",
|
|
@@ -151,6 +440,30 @@ var GENRES = [
|
|
|
151
440
|
"Party",
|
|
152
441
|
"Other"
|
|
153
442
|
];
|
|
443
|
+
var tidy = (text) => text.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
|
|
444
|
+
function checkGuide(controls, help) {
|
|
445
|
+
const hints = [];
|
|
446
|
+
if (controls !== void 0 && controls !== null) {
|
|
447
|
+
if (!Array.isArray(controls)) throw new Error('"controls" must be a list, like [{ "keys": ["W", "A", "S", "D"], "action": "Move" }]');
|
|
448
|
+
if (controls.length > LIMITS.controlHints) throw new Error(`"controls" can have at most ${LIMITS.controlHints} items`);
|
|
449
|
+
controls.forEach((item, i) => {
|
|
450
|
+
const { keys, action } = item ?? {};
|
|
451
|
+
const name = `controls[${i}]`;
|
|
452
|
+
if (!Array.isArray(keys) || keys.length === 0 || keys.length > LIMITS.controlHintKeys) {
|
|
453
|
+
throw new Error(`${name}.keys must be 1-${LIMITS.controlHintKeys} keys or buttons, like ["Space"]`);
|
|
454
|
+
}
|
|
455
|
+
const cleaned = keys.map((k) => typeof k === "string" ? tidy(k) : "");
|
|
456
|
+
if (cleaned.some((k) => !k || k.length > LIMITS.controlKeyLength)) throw new Error(`${name}.keys are short names (at most ${LIMITS.controlKeyLength} characters), like "Space" or "Left click"`);
|
|
457
|
+
const text2 = typeof action === "string" ? tidy(action) : "";
|
|
458
|
+
if (!text2 || text2.length > LIMITS.controlActionLength) throw new Error(`${name}.action is what the keys do, in at most ${LIMITS.controlActionLength} characters`);
|
|
459
|
+
hints.push({ keys: cleaned, action: text2 });
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
if (help !== void 0 && help !== null && typeof help !== "string") throw new Error('"help" must be text');
|
|
463
|
+
const text = typeof help === "string" ? help.replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, " ").replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n").trim() : "";
|
|
464
|
+
if (text.length > LIMITS.helpLength) throw new Error(`"help" can be at most ${LIMITS.helpLength} characters`);
|
|
465
|
+
return { controls: hints, help: text };
|
|
466
|
+
}
|
|
154
467
|
var MAX_IMAGE_SIDE = 4096;
|
|
155
468
|
function imageInfo(bytes) {
|
|
156
469
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
@@ -214,22 +527,36 @@ var LOCAL_API = "http://localhost:8787";
|
|
|
214
527
|
var USAGE = `Usage:
|
|
215
528
|
onceworlds deploy [project-dir] Upload the game and publish it (to ${PRODUCTION_API})
|
|
216
529
|
onceworlds dev [project-dir] Deploy to a local platform (${LOCAL_API}), then redeploy on every change
|
|
530
|
+
onceworlds doctor [project-dir] Look for what games often ship with: no viewport tag, small text fields,
|
|
531
|
+
localStorage, dialogs, Date.now() for shared time, keys with no touch
|
|
532
|
+
controls, a missing icon, thumbnails or How to play (suggestions only)
|
|
217
533
|
|
|
218
534
|
Options:
|
|
219
535
|
--api <url> Platform URL (default: $ONCEWORLDS_API, else the ones above)
|
|
220
|
-
--token <t>
|
|
536
|
+
--token <t> Local platform's deploy token (default: "dev")
|
|
537
|
+
--players <n> dev: also print links for players 2 to n, to play a multiplayer game with yourself
|
|
538
|
+
--strict doctor: exit with an error when it finds a likely bug (not for notes)
|
|
539
|
+
|
|
540
|
+
Deploys to onceworlds.com run from GitHub Actions: the repo's Onceworlds
|
|
541
|
+
workflow runs this on every push, and GitHub vouches for the repo.
|
|
221
542
|
|
|
222
543
|
The project directory needs an onceworlds.json:
|
|
223
|
-
{ "slug": "
|
|
544
|
+
{ "slug": "my-game", "name": "My Game", "dir": "." }
|
|
224
545
|
`;
|
|
225
546
|
var CliError = class extends Error {
|
|
226
547
|
};
|
|
227
548
|
var QUIET_SKIPS = /* @__PURE__ */ new Set(["node_modules", "onceworlds.json", "package.json", "package-lock.json", "tsconfig.json", "jsconfig.json", "LICENSE"]);
|
|
228
|
-
var QUIET_EXTENSIONS = /* @__PURE__ */ new Set(["md", "ts", "tsx", "jsx", "lock", "log", "yml", "yaml", "sh"]);
|
|
549
|
+
var QUIET_EXTENSIONS = /* @__PURE__ */ new Set(["md", "ts", "tsx", "jsx", "lock", "lockb", "log", "yml", "yaml", "sh"]);
|
|
229
550
|
async function main() {
|
|
230
551
|
const { values, positionals } = parseArgs({
|
|
231
552
|
allowPositionals: true,
|
|
232
|
-
options: {
|
|
553
|
+
options: {
|
|
554
|
+
api: { type: "string" },
|
|
555
|
+
token: { type: "string" },
|
|
556
|
+
players: { type: "string" },
|
|
557
|
+
strict: { type: "boolean" },
|
|
558
|
+
help: { type: "boolean", short: "h" }
|
|
559
|
+
}
|
|
233
560
|
});
|
|
234
561
|
const [command, projectArg] = positionals;
|
|
235
562
|
if (values.help || !command || command === "help") {
|
|
@@ -241,7 +568,11 @@ async function main() {
|
|
|
241
568
|
if (command === "deploy") {
|
|
242
569
|
await deploy(projectDir, api, values.token);
|
|
243
570
|
} else if (command === "dev") {
|
|
244
|
-
|
|
571
|
+
const players = Number(values.players ?? 1);
|
|
572
|
+
if (!Number.isInteger(players) || players < 1 || players > 9) throw new CliError("--players is a number from 1 to 9.");
|
|
573
|
+
await dev(projectDir, api, values.token, players);
|
|
574
|
+
} else if (command === "doctor") {
|
|
575
|
+
await doctor(projectDir, values.strict === true);
|
|
245
576
|
} else {
|
|
246
577
|
throw new CliError(`Unknown command "${command}".
|
|
247
578
|
|
|
@@ -251,10 +582,20 @@ ${USAGE}`);
|
|
|
251
582
|
async function deploy(projectDir, api, tokenArg, quiet = false) {
|
|
252
583
|
const config = await loadConfig(projectDir);
|
|
253
584
|
const root = resolve(projectDir, config.dir ?? ".");
|
|
254
|
-
const files = await collectFiles(root
|
|
585
|
+
const { files, skipped } = await collectFiles(root);
|
|
586
|
+
if (skipped.length > 0) {
|
|
587
|
+
console.warn(` ! ${skipped.length} unsupported file${skipped.length === 1 ? "" : "s"} not deployed:`);
|
|
588
|
+
for (const path of skipped) console.warn(` ${path}`);
|
|
589
|
+
}
|
|
255
590
|
if (!files.some((f) => f.path === "index.html")) {
|
|
256
591
|
throw new CliError(`No index.html in ${root}. Set "dir" in onceworlds.json to the folder that contains it.`);
|
|
257
592
|
}
|
|
593
|
+
if (!quiet) {
|
|
594
|
+
const findings = [...checkGame(await readSources(files)), ...checkListing(config)];
|
|
595
|
+
for (const f of findings.filter((x) => x.level === "warn")) console.warn(` ! ${f.file}:${f.line} ${f.message}`);
|
|
596
|
+
const notes = findings.filter((x) => x.level === "note").length;
|
|
597
|
+
if (notes > 0) console.log(` ${notes} suggestion${notes === 1 ? "" : "s"} for this game: run "npx @onceworlds/cli doctor" to see them.`);
|
|
598
|
+
}
|
|
258
599
|
const total = files.reduce((sum, f) => sum + f.size, 0);
|
|
259
600
|
if (total > LIMITS.deployTotalBytes) throw new CliError(`Game is ${formatBytes(total)}; the limit is ${formatBytes(LIMITS.deployTotalBytes)}.`);
|
|
260
601
|
const { store, images } = await loadStore(projectDir, config);
|
|
@@ -265,7 +606,7 @@ async function deploy(projectDir, api, tokenArg, quiet = false) {
|
|
|
265
606
|
slug: config.slug,
|
|
266
607
|
name: config.name,
|
|
267
608
|
description: config.description ?? "",
|
|
268
|
-
productionBranch: config.productionBranch ??
|
|
609
|
+
productionBranch: config.productionBranch ?? DEFAULT_PRODUCTION_BRANCH,
|
|
269
610
|
store
|
|
270
611
|
},
|
|
271
612
|
files: files.map(({ path, hash, size }) => ({ path, hash, size })),
|
|
@@ -282,21 +623,23 @@ async function deploy(projectDir, api, tokenArg, quiet = false) {
|
|
|
282
623
|
});
|
|
283
624
|
const result = await send("POST", `${api}/api/deploy/${start.deploymentId}/finalize`, start.uploadToken, {});
|
|
284
625
|
if (!quiet) {
|
|
626
|
+
const branch = result.productionBranch ?? start.productionBranch;
|
|
285
627
|
if (result.production) console.log(`\u2713 Live: ${result.playUrl}`);
|
|
286
|
-
else console.log(`\u2713 Preview
|
|
628
|
+
else console.log(`\u2713 Preview${branch ? ` (only ${branch} goes live)` : ""}: ${result.previewUrl}`);
|
|
287
629
|
for (const note of result.notes ?? []) console.log(` ${note}`);
|
|
288
630
|
}
|
|
289
631
|
return result;
|
|
290
632
|
}
|
|
291
|
-
async function dev(projectDir, api, tokenArg) {
|
|
633
|
+
async function dev(projectDir, api, tokenArg, players = 1) {
|
|
292
634
|
const config = await loadConfig(projectDir);
|
|
293
635
|
const root = resolve(projectDir, config.dir ?? ".");
|
|
294
636
|
const first = await deploy(projectDir, api, tokenArg);
|
|
295
637
|
console.log(`
|
|
296
|
-
Play (reloads on every change): ${first.playUrl}?watch
|
|
297
|
-
|
|
638
|
+
Play (reloads on every change): ${first.playUrl}?watch`);
|
|
639
|
+
for (let n = 2; n <= players; n++) console.log(` Player ${n}: ${first.playUrl}?player=${n}&watch`);
|
|
640
|
+
console.log(` Watching ${root}. Press Ctrl+C to stop.
|
|
298
641
|
`);
|
|
299
|
-
const projectSignature = async () => signature(await collectFiles(root
|
|
642
|
+
const projectSignature = async () => signature((await collectFiles(root)).files) + await readFile(join(projectDir, "onceworlds.json"), "utf8").catch(() => "");
|
|
300
643
|
let lastSignature = await projectSignature();
|
|
301
644
|
let timer;
|
|
302
645
|
let running = false;
|
|
@@ -335,6 +678,31 @@ async function dev(projectDir, api, tokenArg) {
|
|
|
335
678
|
await new Promise(() => {
|
|
336
679
|
});
|
|
337
680
|
}
|
|
681
|
+
async function readSources(files) {
|
|
682
|
+
const sources = [];
|
|
683
|
+
for (const file of files) {
|
|
684
|
+
if (!/\.(html?|m?js|css)$/i.test(file.path) || file.size > 15e5) continue;
|
|
685
|
+
sources.push({ path: file.path, text: await readFile(file.abs, "utf8") });
|
|
686
|
+
}
|
|
687
|
+
return sources;
|
|
688
|
+
}
|
|
689
|
+
async function doctor(projectDir, strict) {
|
|
690
|
+
const config = await loadConfig(projectDir);
|
|
691
|
+
const root = resolve(projectDir, config.dir ?? ".");
|
|
692
|
+
const { files } = await collectFiles(root);
|
|
693
|
+
const sources = await readSources(files);
|
|
694
|
+
const findings = [...checkGame(sources), ...checkListing(config)];
|
|
695
|
+
console.log(`Checked ${sources.length} file${sources.length === 1 ? "" : "s"} in ${root}.`);
|
|
696
|
+
if (findings.length === 0) {
|
|
697
|
+
console.log("Nothing to suggest.");
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
for (const f of findings) console.log(` ${f.level === "warn" ? "!" : "\xB7"} ${f.file}:${f.line} ${f.message}`);
|
|
701
|
+
const warnings = findings.filter((f) => f.level === "warn").length;
|
|
702
|
+
console.log(`
|
|
703
|
+
${warnings} likely problem${warnings === 1 ? "" : "s"}, ${findings.length - warnings} note${findings.length - warnings === 1 ? "" : "s"}. Nothing here stops a deploy.`);
|
|
704
|
+
if (strict && warnings > 0) process.exitCode = 1;
|
|
705
|
+
}
|
|
338
706
|
async function loadConfig(projectDir) {
|
|
339
707
|
const file = join(projectDir, "onceworlds.json");
|
|
340
708
|
let config;
|
|
@@ -407,9 +775,18 @@ async function loadStore(projectDir, config) {
|
|
|
407
775
|
const badgeIcon = b.icon === void 0 ? null : await image(b.icon, "badge", `${field}.icon`);
|
|
408
776
|
badges.push({ key, name, description, icon: badgeIcon });
|
|
409
777
|
}
|
|
410
|
-
|
|
778
|
+
let guide;
|
|
779
|
+
try {
|
|
780
|
+
guide = checkGuide(config.controls, config.help);
|
|
781
|
+
} catch (err) {
|
|
782
|
+
throw new CliError(`${err.message} in onceworlds.json.`);
|
|
783
|
+
}
|
|
784
|
+
const store = { genre, icon, thumbnails, badges };
|
|
785
|
+
if (guide.controls.length > 0) store.controls = guide.controls;
|
|
786
|
+
if (guide.help) store.help = guide.help;
|
|
787
|
+
return { store, images: [...images.values()] };
|
|
411
788
|
}
|
|
412
|
-
async function collectFiles(root
|
|
789
|
+
async function collectFiles(root) {
|
|
413
790
|
const files = [];
|
|
414
791
|
const skipped = [];
|
|
415
792
|
async function walk(dir, prefix) {
|
|
@@ -439,9 +816,8 @@ async function collectFiles(root, quiet) {
|
|
|
439
816
|
}
|
|
440
817
|
}
|
|
441
818
|
await walk(root, "");
|
|
442
|
-
if (skipped.length > 0 && !quiet) console.warn(` ! skipped unsupported files: ${skipped.join(", ")}`);
|
|
443
819
|
if (files.length > LIMITS.deployFiles) throw new CliError(`Too many files (${files.length}); the limit is ${LIMITS.deployFiles}.`);
|
|
444
|
-
return files;
|
|
820
|
+
return { files, skipped };
|
|
445
821
|
}
|
|
446
822
|
function signature(files) {
|
|
447
823
|
return files.map((f) => `${f.path}:${f.hash}`).join("\n");
|
|
@@ -458,7 +834,6 @@ function gitInfo(cwd) {
|
|
|
458
834
|
}
|
|
459
835
|
async function getToken(api, tokenArg) {
|
|
460
836
|
if (tokenArg) return tokenArg;
|
|
461
|
-
if (process.env.ONCEWORLDS_TOKEN) return process.env.ONCEWORLDS_TOKEN;
|
|
462
837
|
const requestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
|
463
838
|
const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
|
464
839
|
if (requestUrl && requestToken) {
|
|
@@ -471,7 +846,7 @@ async function getToken(api, tokenArg) {
|
|
|
471
846
|
const host = new URL(api).hostname;
|
|
472
847
|
if (host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host.endsWith(".localhost")) return "dev";
|
|
473
848
|
throw new CliError(
|
|
474
|
-
"
|
|
849
|
+
"Deploys to Onceworlds run from GitHub Actions: push to GitHub and the repo's Onceworlds workflow deploys (it needs `permissions: id-token: write`)."
|
|
475
850
|
);
|
|
476
851
|
}
|
|
477
852
|
async function send(method, url, token, body) {
|