@onceworlds/cli 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.
- package/README.md +20 -7
- package/dist/cli.js +336 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -33,12 +33,13 @@ jobs:
|
|
|
33
33
|
|
|
34
34
|
Pushes to the production branch (`main` unless you change it in the dashboard or
|
|
35
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
|
|
37
|
-
account that
|
|
38
|
-
|
|
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.
|
|
39
40
|
|
|
40
|
-
New games start private: only
|
|
41
|
-
|
|
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
|
|
42
43
|
with the link) or Public (also listed on Onceworlds).
|
|
43
44
|
|
|
44
45
|
## Project file
|
|
@@ -62,18 +63,30 @@ with the link) or Public (also listed on Onceworlds).
|
|
|
62
63
|
`index.html`. `productionBranch` (default `main`) picks the branch that goes live;
|
|
63
64
|
the dashboard can change it too, and the file wins again only when this value changes.
|
|
64
65
|
|
|
65
|
-
|
|
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),
|
|
66
68
|
16:9 `thumbnails` (at least 480 wide; the first is the cover) and `badges` (`id`,
|
|
67
69
|
`name`, `description` and an optional square `icon`). Images are PNG, JPEG, WebP
|
|
68
70
|
or GIF up to 5 MB, with paths relative to `onceworlds.json`. Live deploys apply
|
|
69
71
|
these fields only when they change, so edits made in the dashboard last until then.
|
|
70
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
|
+
|
|
71
78
|
## Commands
|
|
72
79
|
|
|
73
80
|
- `onceworlds deploy [dir]` uploads only the files that changed and publishes the game
|
|
74
81
|
(the workflow runs it). It lists every file it skipped because Onceworlds can't serve it.
|
|
75
82
|
- `onceworlds dev [dir]` deploys to a local Onceworlds platform (for platform development)
|
|
76
|
-
and redeploys on every save.
|
|
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).
|
|
77
90
|
|
|
78
91
|
Games talk to the platform through the injected `window.onceworlds` SDK. The
|
|
79
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,12 +225,24 @@ 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,
|
|
42
|
-
|
|
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,
|
|
43
246
|
proxyCallsPerDay: 2e3,
|
|
44
247
|
proxyCallsPerDayMax: 1e5,
|
|
45
248
|
proxyCallsPerPlayerPerDay: 100,
|
|
@@ -48,13 +251,35 @@ var LIMITS = {
|
|
|
48
251
|
proxyMediaCallsPerDayMax: 1e4,
|
|
49
252
|
proxyMediaCallsPerPlayerPerDay: 20,
|
|
50
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,
|
|
51
264
|
errorReportsPerPage: 10,
|
|
52
265
|
gameNameLength: 60,
|
|
53
266
|
gameDescriptionLength: 1e3,
|
|
54
267
|
galleryItems: 10,
|
|
55
268
|
mediaImageBytes: 5 * 1024 * 1024,
|
|
56
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,
|
|
57
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,
|
|
58
283
|
badgeNameLength: 50,
|
|
59
284
|
badgeDescriptionLength: 200,
|
|
60
285
|
leaderboardsPerGame: 25,
|
|
@@ -66,16 +291,31 @@ var LIMITS = {
|
|
|
66
291
|
/** Finished versions retained per game, including its live version. */
|
|
67
292
|
deploymentsKeptPerGame: 10,
|
|
68
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,
|
|
69
296
|
bioLength: 500,
|
|
70
297
|
friendsMax: 200,
|
|
298
|
+
/** Requests one player has sent that are still waiting. */
|
|
71
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,
|
|
72
308
|
passwordMinLength: 8,
|
|
73
309
|
passwordMaxLength: 72,
|
|
74
310
|
/** Account creation attempts, with or without email, per network (IP, or IPv6 /64) in 24 hours. */
|
|
75
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,
|
|
76
314
|
/** New guest players per network per day (each one can hold saves). */
|
|
77
315
|
guestsPerNetworkPerDay: 300,
|
|
78
|
-
/**
|
|
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. */
|
|
79
319
|
visitsPerNetworkPerDay: 10,
|
|
80
320
|
/** Seconds of visible play before a play counts as a visit. */
|
|
81
321
|
visitSeconds: 10,
|
|
@@ -95,7 +335,11 @@ var LIMITS = {
|
|
|
95
335
|
conversationsListed: 50,
|
|
96
336
|
notificationsListed: 50,
|
|
97
337
|
/** Friends one game invite can go to. */
|
|
98
|
-
invitesPerRequest: 10
|
|
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
|
|
99
343
|
};
|
|
100
344
|
var CONTENT_TYPES = {
|
|
101
345
|
html: "text/html; charset=utf-8",
|
|
@@ -163,7 +407,7 @@ function contentTypeFor(path) {
|
|
|
163
407
|
return CONTENT_TYPES[extensionOf(path)];
|
|
164
408
|
}
|
|
165
409
|
function isValidDeployPath(path) {
|
|
166
|
-
if (path.length === 0 || path.length >
|
|
410
|
+
if (path.length === 0 || path.length > LIMITS.deployPathLength) return false;
|
|
167
411
|
if (path.startsWith("/") || path.includes("\\")) return false;
|
|
168
412
|
if (/[\u0000-\u001f\u007f]/.test(path)) return false;
|
|
169
413
|
for (const segment of path.split("/")) {
|
|
@@ -196,6 +440,30 @@ var GENRES = [
|
|
|
196
440
|
"Party",
|
|
197
441
|
"Other"
|
|
198
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
|
+
}
|
|
199
467
|
var MAX_IMAGE_SIDE = 4096;
|
|
200
468
|
function imageInfo(bytes) {
|
|
201
469
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
@@ -259,10 +527,15 @@ var LOCAL_API = "http://localhost:8787";
|
|
|
259
527
|
var USAGE = `Usage:
|
|
260
528
|
onceworlds deploy [project-dir] Upload the game and publish it (to ${PRODUCTION_API})
|
|
261
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)
|
|
262
533
|
|
|
263
534
|
Options:
|
|
264
535
|
--api <url> Platform URL (default: $ONCEWORLDS_API, else the ones above)
|
|
265
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)
|
|
266
539
|
|
|
267
540
|
Deploys to onceworlds.com run from GitHub Actions: the repo's Onceworlds
|
|
268
541
|
workflow runs this on every push, and GitHub vouches for the repo.
|
|
@@ -277,7 +550,13 @@ var QUIET_EXTENSIONS = /* @__PURE__ */ new Set(["md", "ts", "tsx", "jsx", "lock"
|
|
|
277
550
|
async function main() {
|
|
278
551
|
const { values, positionals } = parseArgs({
|
|
279
552
|
allowPositionals: true,
|
|
280
|
-
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
|
+
}
|
|
281
560
|
});
|
|
282
561
|
const [command, projectArg] = positionals;
|
|
283
562
|
if (values.help || !command || command === "help") {
|
|
@@ -289,7 +568,11 @@ async function main() {
|
|
|
289
568
|
if (command === "deploy") {
|
|
290
569
|
await deploy(projectDir, api, values.token);
|
|
291
570
|
} else if (command === "dev") {
|
|
292
|
-
|
|
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);
|
|
293
576
|
} else {
|
|
294
577
|
throw new CliError(`Unknown command "${command}".
|
|
295
578
|
|
|
@@ -307,6 +590,12 @@ async function deploy(projectDir, api, tokenArg, quiet = false) {
|
|
|
307
590
|
if (!files.some((f) => f.path === "index.html")) {
|
|
308
591
|
throw new CliError(`No index.html in ${root}. Set "dir" in onceworlds.json to the folder that contains it.`);
|
|
309
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
|
+
}
|
|
310
599
|
const total = files.reduce((sum, f) => sum + f.size, 0);
|
|
311
600
|
if (total > LIMITS.deployTotalBytes) throw new CliError(`Game is ${formatBytes(total)}; the limit is ${formatBytes(LIMITS.deployTotalBytes)}.`);
|
|
312
601
|
const { store, images } = await loadStore(projectDir, config);
|
|
@@ -341,13 +630,14 @@ async function deploy(projectDir, api, tokenArg, quiet = false) {
|
|
|
341
630
|
}
|
|
342
631
|
return result;
|
|
343
632
|
}
|
|
344
|
-
async function dev(projectDir, api, tokenArg) {
|
|
633
|
+
async function dev(projectDir, api, tokenArg, players = 1) {
|
|
345
634
|
const config = await loadConfig(projectDir);
|
|
346
635
|
const root = resolve(projectDir, config.dir ?? ".");
|
|
347
636
|
const first = await deploy(projectDir, api, tokenArg);
|
|
348
637
|
console.log(`
|
|
349
|
-
Play (reloads on every change): ${first.playUrl}?watch
|
|
350
|
-
|
|
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.
|
|
351
641
|
`);
|
|
352
642
|
const projectSignature = async () => signature((await collectFiles(root)).files) + await readFile(join(projectDir, "onceworlds.json"), "utf8").catch(() => "");
|
|
353
643
|
let lastSignature = await projectSignature();
|
|
@@ -388,6 +678,31 @@ async function dev(projectDir, api, tokenArg) {
|
|
|
388
678
|
await new Promise(() => {
|
|
389
679
|
});
|
|
390
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
|
+
}
|
|
391
706
|
async function loadConfig(projectDir) {
|
|
392
707
|
const file = join(projectDir, "onceworlds.json");
|
|
393
708
|
let config;
|
|
@@ -460,7 +775,16 @@ async function loadStore(projectDir, config) {
|
|
|
460
775
|
const badgeIcon = b.icon === void 0 ? null : await image(b.icon, "badge", `${field}.icon`);
|
|
461
776
|
badges.push({ key, name, description, icon: badgeIcon });
|
|
462
777
|
}
|
|
463
|
-
|
|
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()] };
|
|
464
788
|
}
|
|
465
789
|
async function collectFiles(root) {
|
|
466
790
|
const files = [];
|