@onceworlds/cli 0.2.1 → 0.4.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.
Files changed (3) hide show
  1. package/README.md +32 -10
  2. package/dist/cli.js +703 -27
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -9,8 +9,9 @@ players) with no server code.
9
9
  Deploys run from GitHub Actions: GitHub vouches for the repo, so there are no API
10
10
  keys. The easiest setup is to import the repo at https://onceworlds.com/dashboard/new,
11
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:
12
+ workflow also builds with npm, pnpm, yarn or bun when there's a build script). To write
13
+ the same files into a folder before it is on GitHub, run `npx @onceworlds/cli init` there.
14
+ By hand, the minimum is:
14
15
 
15
16
  ```yaml
16
17
  name: Deploy to Onceworlds
@@ -33,12 +34,13 @@ jobs:
33
34
 
34
35
  Pushes to the production branch (`main` unless you change it in the dashboard or
35
36
  `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 the GitHub
37
- account that owns the repo (or any member of its organization) before the first
38
- deploy. If a deploy fails, the dashboard shows why.
37
+ preview URL in the job log. Log in to https://onceworlds.com with a GitHub
38
+ account that has write access to the repo before the first deploy. Install the
39
+ Onceworlds GitHub App on that repo to use the dashboard and open the game while
40
+ private. If a deploy fails, the dashboard shows why.
39
41
 
40
- New games start private: only the repo's owner (or its organization's members)
41
- can play them. To share one, open it in the dashboard and pick Unlisted (anyone
42
+ New games start private: only accounts with write access to the repo can play
43
+ them. To share one, open it in the dashboard and pick Unlisted (anyone
42
44
  with the link) or Public (also listed on Onceworlds).
43
45
 
44
46
  ## Project file
@@ -62,18 +64,38 @@ with the link) or Public (also listed on Onceworlds).
62
64
  `index.html`. `productionBranch` (default `main`) picks the branch that goes live;
63
65
  the dashboard can change it too, and the file wins again only when this value changes.
64
66
 
65
- The rest fills in the game page: `genre`, a square `icon` (at least 128×128),
67
+ `controls` (`[{ "keys": ["W", "A", "S", "D"], "action": "Move" }]`, up to 12) and `help` (up to 400
68
+ 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
69
  16:9 `thumbnails` (at least 480 wide; the first is the cover) and `badges` (`id`,
67
- `name`, `description` and an optional square `icon`). Images are PNG, JPEG, WebP
70
+ `name`, `description` and an optional square `icon`). A game page holds at most 5
71
+ thumbnails and videos together, and a game at most 50 badges. Images are PNG, JPEG, WebP
68
72
  or GIF up to 5 MB, with paths relative to `onceworlds.json`. Live deploys apply
69
73
  these fields only when they change, so edits made in the dashboard last until then.
70
74
 
75
+ Make the art before the first deploy: players see the icon and thumbnails before the game, and a game
76
+ without them gets a plain colored placeholder. The icon is a simple, wordless 512×512 square; the thumbnails
77
+ are three to five 1280×720 pictures of the game being played, the best one first (it is the cover). Capture them
78
+ from the running game and commit them under `store/`. `onceworlds doctor` says which fields are missing.
79
+
71
80
  ## Commands
72
81
 
82
+ - `onceworlds init [dir]` sets a game folder up, with the files the dashboard's import commits. It writes
83
+ `onceworlds.json` (the link and name come from `package.json`'s name or the folder's; `dir` is your build's
84
+ output folder when `package.json` has a build script, else `.` or the folder with your `index.html`),
85
+ `.github/workflows/onceworlds.yml`, and the guide for AI coding tools as `AGENTS.md` with a `CLAUDE.md` that
86
+ imports it (next to a `AGENTS.md` of your own it writes `ONCEWORLDS.md` and adds a pointer to yours). It never
87
+ overwrites a file you have: `--force` replaces only what init writes, never your own `AGENTS.md` or `CLAUDE.md`.
88
+ Then commit, push, and import the repo at https://onceworlds.com/dashboard/new.
73
89
  - `onceworlds deploy [dir]` uploads only the files that changed and publishes the game
74
90
  (the workflow runs it). It lists every file it skipped because Onceworlds can't serve it.
75
91
  - `onceworlds dev [dir]` deploys to a local Onceworlds platform (for platform development)
76
- and redeploys on every save.
92
+ and redeploys on every save. `--players 3` also prints links for players 2 and 3 (`?player=N`): on a
93
+ local platform each is another player, so one person can fill a lobby.
94
+ - `onceworlds doctor [dir]` looks through the game's files for what games often ship with: no viewport
95
+ tag, text fields under 16px (Safari zooms the page when one is focused), `confirm()` and other dialogs
96
+ (blocked in the game frame), localStorage (gone on reload), and, for games with rooms, `Date.now()` for
97
+ shared time and no use of the platform's lobby. It only suggests: nothing in it stops a deploy
98
+ (`--strict` exits with an error on likely bugs, for CI).
77
99
 
78
100
  Games talk to the platform through the injected `window.onceworlds` SDK. The
79
101
  starter template's `AGENTS.md` documents it for AI coding tools.
package/dist/cli.js CHANGED
@@ -1,28 +1,230 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { execFileSync } from "node:child_process";
4
+ import { execFileSync as execFileSync2 } from "node:child_process";
5
5
  import { createHash } from "node:crypto";
6
6
  import { watch } from "node:fs";
7
- import { readdir, readFile } from "node:fs/promises";
8
- import { join, resolve, sep } from "node:path";
7
+ import { readdir as readdir2, readFile as readFile2, stat as stat2 } from "node:fs/promises";
8
+ import { join as join2, 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 3-5 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
+
171
+ // src/init.ts
172
+ import { execFileSync } from "node:child_process";
173
+ import { randomBytes } from "node:crypto";
174
+ import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
175
+ import { basename, dirname, join } from "node:path";
176
+
11
177
  // ../protocol/src/index.ts
12
178
  var OIDC_AUDIENCE = "onceworlds";
13
179
  var LIMITS = {
14
180
  roomMaxPlayersDefault: 16,
15
- roomMaxPlayersCap: 50,
181
+ /**
182
+ * The most players a room seats. A room is one Durable Object that copies every update a player sends to everyone else, so
183
+ * what it costs grows with the square of its players: 30 is where a room stays well inside what one object serves.
184
+ */
185
+ roomMaxPlayersCap: 30,
16
186
  teamsMax: 8,
17
187
  messageBytes: 16 * 1024,
18
188
  presenceBytes: 1024,
19
189
  stateKeyLength: 64,
20
190
  stateValueBytes: 16 * 1024,
21
191
  stateKeys: 256,
192
+ /** Private values (room.setPrivate): keys per player, one value, and all of a player's together. */
193
+ privateKeys: 16,
194
+ privateValueBytes: 4 * 1024,
195
+ privateBytesPerPlayer: 8 * 1024,
196
+ /** The most messages a second one player may send a room (a room with more players gives each less: see roomBudget). */
22
197
  messagesPerSecond: 60,
23
- /** Bytes one player may send to a room per second, on average (bursts up to 4x). */
198
+ messagesPerSecondMin: 10,
199
+ /** Bytes one player may send to a room per second, on average (bursts up to 4x): the most, in a small room. */
24
200
  roomBytesPerSecond: 128 * 1024,
201
+ roomBytesPerSecondMin: 16 * 1024,
202
+ /** The most presence updates a second one player may send. */
25
203
  presenceHz: 20,
204
+ presenceHzMin: 5,
205
+ /**
206
+ * What a room takes in from all its players together each second, presence included (a Durable Object serves about 1000
207
+ * requests a second, and each update is then sent on to everyone else), and the bytes. Each player's share is this divided
208
+ * by the room's capacity, up to the per-player maximums above: rooms of up to 11 players get all of them.
209
+ */
210
+ roomMessagesPerSecond: 900,
211
+ roomBytesPerSecondTotal: 2 * 1024 * 1024,
212
+ /** A match's countdown before play begins, unless the room is set up otherwise (rooms.join({ countdown })). */
213
+ matchCountdownMs: 3e3,
214
+ matchCountdownMaxMs: 1e4,
215
+ /** A public lobby where half the players are ready waits this long for the rest before starting without them. */
216
+ matchIdleMs: 45e3,
217
+ /** A match that has waited this long for its players (paused, too few connected) is over. */
218
+ matchAbandonMs: 12e4,
219
+ /** A player who hasn't touched the game (pointer, keys, touch, gamepad) this long is idle (room.on('idle')). */
220
+ idleAfterMs: 3e4,
221
+ /** A running match whose connected players have all been idle this long is over: nobody is playing. */
222
+ idleAbandonMs: 5 * 6e4,
223
+ /**
224
+ * Rated matches two accounts on opposite sides may play against each other in one game's ladder per UTC day: past it,
225
+ * their matches aren't rated, so two accounts that keep getting matched in a quiet game can't farm wins.
226
+ */
227
+ rankedPairMatchesPerDay: 5,
26
228
  chatLength: 200,
27
229
  chatIntervalMs: 750,
28
230
  chatHistory: 50,
@@ -34,12 +236,24 @@ var LIMITS = {
34
236
  guestSaveBytesPerGame: 128 * 1024,
35
237
  deployFiles: 2e3,
36
238
  deployFileBytes: 25 * 1024 * 1024,
239
+ /** 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. */
240
+ deployHtmlBytes: 10 * 1024 * 1024,
241
+ /** Characters in a deployed file's path. */
242
+ deployPathLength: 200,
37
243
  deployTotalBytes: 100 * 1024 * 1024,
38
244
  envVars: 50,
39
245
  envValueBytes: 4 * 1024,
246
+ /** All of a game's public variables together, as injected into its pages. */
247
+ envTotalBytes: 16 * 1024,
248
+ /** Proxied request bodies: routes that take a file (audio to transcribe, an image to edit). */
40
249
  proxyRequestBytes: 1024 * 1024,
250
+ /** Proxied JSON bodies for text, embeddings and image generation. */
251
+ proxyTextRequestBytes: 256 * 1024,
252
+ /** Proxied text-to-speech bodies (priced per character). */
253
+ proxySpeechRequestBytes: 16 * 1024,
41
254
  proxyResponseBytes: 10 * 1024 * 1024,
42
- proxyMaxOutputTokens: 8192,
255
+ /** Output tokens per proxied text call: a request asking for more is lowered to this, one that doesn't say gets it. */
256
+ proxyMaxOutputTokens: 4096,
43
257
  proxyCallsPerDay: 2e3,
44
258
  proxyCallsPerDayMax: 1e5,
45
259
  proxyCallsPerPlayerPerDay: 100,
@@ -48,13 +262,35 @@ var LIMITS = {
48
262
  proxyMediaCallsPerDayMax: 1e4,
49
263
  proxyMediaCallsPerPlayerPerDay: 20,
50
264
  proxyMediaCallsPerNetworkPerDay: 25,
265
+ /**
266
+ * Daily units: each proxied call's estimated tokens (request bytes / 3 plus its output
267
+ * cap) times its model's weight. Per game (the creator can set up to the max), per
268
+ * signed-in player, per guest, and per guests' network.
269
+ */
270
+ proxyUnitsPerDay: 2e6,
271
+ proxyUnitsPerDayMax: 1e8,
272
+ proxyUnitsPerPlayerPerDay: 25e4,
273
+ proxyUnitsPerGuestPerDay: 1e5,
274
+ proxyUnitsPerNetworkPerDay: 5e5,
51
275
  errorReportsPerPage: 10,
52
276
  gameNameLength: 60,
53
277
  gameDescriptionLength: 1e3,
54
- galleryItems: 10,
278
+ galleryItems: 5,
55
279
  mediaImageBytes: 5 * 1024 * 1024,
56
280
  mediaVideoBytes: 30 * 1024 * 1024,
57
- badgesPerGame: 100,
281
+ /** GIFs are served as they are (never resized), so they're small: at most this many bytes and pixels a side. */
282
+ mediaGifBytes: 1024 * 1024,
283
+ mediaGifSide: 512,
284
+ /** Media an account uploads in the dashboard (or a repo owner's deploys add to game pages) in any 24 hours. */
285
+ mediaUploadsPerDay: 200,
286
+ mediaBytesPerDay: 500 * 1024 * 1024,
287
+ badgesPerGame: 50,
288
+ /** 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. */
289
+ controlHints: 12,
290
+ controlHintKeys: 4,
291
+ controlKeyLength: 12,
292
+ controlActionLength: 40,
293
+ helpLength: 400,
58
294
  badgeNameLength: 50,
59
295
  badgeDescriptionLength: 200,
60
296
  leaderboardsPerGame: 25,
@@ -62,20 +298,41 @@ var LIMITS = {
62
298
  gamesPerOwner: 50,
63
299
  /** New games per creator in any rolling 24-hour period. Redeploys do not count. */
64
300
  gamesCreatedPerCreatorPerDay: 3,
301
+ /** An account made with just a username (no email, Google or GitHub) can publish a new game by upload once it is this old. */
302
+ uploadAccountAgeMs: 7 * 24 * 60 * 6e4,
65
303
  deploysPerOwnerPerDay: 100,
66
304
  /** Finished versions retained per game, including its live version. */
67
305
  deploymentsKeptPerGame: 10,
68
306
  deployBytesPerOwnerPerDay: 2 * 1024 * 1024 * 1024,
307
+ /** Files listed by all of a repo owner's deploys in any 24 hours (each is a record to keep and clean up). */
308
+ deployEntriesPerOwnerPerDay: 1e5,
69
309
  bioLength: 500,
310
+ /** Settings > Download my data: rows from any one table, the characters of saves, and how often an account may ask. */
311
+ exportRows: 2e3,
312
+ exportSaveChars: 4 * 1024 * 1024,
313
+ exportEveryMs: 60 * 6e4,
70
314
  friendsMax: 200,
315
+ /** Requests one player has sent that are still waiting. */
71
316
  friendRequestsMax: 100,
317
+ /** Requests waiting for one player: more wait until they answer some. */
318
+ friendRequestsIncomingMax: 50,
319
+ /** New friend requests an account sends in 24 hours (asking again after cancelling counts again)... */
320
+ friendRequestsPerDay: 20,
321
+ /** ...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. */
322
+ friendRequestsPerNetworkPerDay: 120,
323
+ /** Friend requests that reach a player's devices (push) in a day, at most one every 10 minutes. */
324
+ friendRequestPushesPerDay: 5,
72
325
  passwordMinLength: 8,
73
326
  passwordMaxLength: 72,
74
327
  /** Account creation attempts, with or without email, per network (IP, or IPv6 /64) in 24 hours. */
75
328
  signupsPerNetworkPerDay: 100,
329
+ /** The same per wide network (an IPv6 /48); an IPv4 address with a proof of work may go on to this too. */
330
+ signupsPerWidePerDay: 300,
76
331
  /** New guest players per network per day (each one can hold saves). */
77
332
  guestsPerNetworkPerDay: 300,
78
- /** Visits a game gets per network (IP, or IPv6 /64) per day: a script can't pump the count. */
333
+ /** The same per wide network (an IPv6 /48); an IPv4 address with a proof of work may go on to this too. */
334
+ guestsPerWidePerDay: 1500,
335
+ /** Visits a game gets per network (IPv4 address, or IPv6 /48) per day: a script can't pump the count. */
79
336
  visitsPerNetworkPerDay: 10,
80
337
  /** Seconds of visible play before a play counts as a visit. */
81
338
  visitSeconds: 10,
@@ -95,7 +352,11 @@ var LIMITS = {
95
352
  conversationsListed: 50,
96
353
  notificationsListed: 50,
97
354
  /** Friends one game invite can go to. */
98
- invitesPerRequest: 10
355
+ invitesPerRequest: 10,
356
+ /** 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.) */
357
+ inviteLiveMs: 30 * 6e4,
358
+ /** How long an invite to a party can be answered. */
359
+ partyInviteMs: 60 * 6e4
99
360
  };
100
361
  var CONTENT_TYPES = {
101
362
  html: "text/html; charset=utf-8",
@@ -163,7 +424,7 @@ function contentTypeFor(path) {
163
424
  return CONTENT_TYPES[extensionOf(path)];
164
425
  }
165
426
  function isValidDeployPath(path) {
166
- if (path.length === 0 || path.length > 512) return false;
427
+ if (path.length === 0 || path.length > LIMITS.deployPathLength) return false;
167
428
  if (path.startsWith("/") || path.includes("\\")) return false;
168
429
  if (/[\u0000-\u001f\u007f]/.test(path)) return false;
169
430
  for (const segment of path.split("/")) {
@@ -173,10 +434,213 @@ function isValidDeployPath(path) {
173
434
  }
174
435
  var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
175
436
  var RESERVED_SLUGS = /* @__PURE__ */ new Set(["www", "api", "play", "admin", "static", "assets", "docs", "status"]);
437
+ var NEW_GAME_RESERVED_SLUGS = /* @__PURE__ */ new Set([
438
+ "star-party",
439
+ "login",
440
+ "log-in",
441
+ "logon",
442
+ "logout",
443
+ "signin",
444
+ "sign-in",
445
+ "signup",
446
+ "sign-up",
447
+ "register",
448
+ "auth",
449
+ "oauth",
450
+ "sso",
451
+ "verify",
452
+ "verified",
453
+ "verification",
454
+ "support",
455
+ "help",
456
+ "helpdesk",
457
+ "contact",
458
+ "official",
459
+ "staff",
460
+ "team",
461
+ "moderator",
462
+ "moderators",
463
+ "mod",
464
+ "mods",
465
+ "security",
466
+ "account",
467
+ "accounts",
468
+ "password",
469
+ "passwords",
470
+ "reset",
471
+ "billing",
472
+ "payment",
473
+ "payments",
474
+ "robux",
475
+ "vbucks",
476
+ "v-bucks",
477
+ "nitro",
478
+ "gift",
479
+ "gifts",
480
+ "giftcard",
481
+ "free",
482
+ "settings",
483
+ "dashboard",
484
+ "discover",
485
+ "games",
486
+ "users",
487
+ "friends",
488
+ "notifications",
489
+ "terms",
490
+ "privacy",
491
+ "changelog",
492
+ "create",
493
+ "guide",
494
+ "about",
495
+ "blog",
496
+ "news",
497
+ "mail",
498
+ "email"
499
+ ]);
500
+ var NEW_GAME_RESERVED_PREFIXES = ["onceworlds", "admin", "free-"];
501
+ var SLUG_AUTH_WORDS = /* @__PURE__ */ new Set(["login", "logon", "signin", "signon", "signup", "verify", "verification", "password", "passwords", "billing"]);
502
+ var BRANDS = ["onceworlds", "roblox", "robux", "vbucks", "fortnite", "minecraft", "xbox", "playstation", "nintendo"];
503
+ var LOOKALIKES = {
504
+ "0": "o",
505
+ "3": "e",
506
+ "4": "a",
507
+ "5": "s",
508
+ "7": "t",
509
+ "8": "b",
510
+ "@": "a",
511
+ "$": "s",
512
+ "|": "l",
513
+ "!": "i",
514
+ "\u0430": "a",
515
+ "\u0432": "b",
516
+ "\u0435": "e",
517
+ "\u043A": "k",
518
+ "\u043C": "m",
519
+ "\u043D": "h",
520
+ "\u043E": "o",
521
+ "\u0440": "p",
522
+ "\u0441": "c",
523
+ "\u0442": "t",
524
+ "\u0443": "y",
525
+ "\u0445": "x",
526
+ "\u0455": "s",
527
+ "\u0456": "i",
528
+ "\u0458": "j",
529
+ "\u04CF": "l",
530
+ "\u0501": "d",
531
+ "\u051D": "w",
532
+ "\u03B1": "a",
533
+ "\u03B2": "b",
534
+ "\u03B5": "e",
535
+ "\u03B9": "i",
536
+ "\u03BA": "k",
537
+ "\u03BD": "v",
538
+ "\u03BF": "o",
539
+ "\u03C1": "p",
540
+ "\u03C4": "t",
541
+ "\u03C5": "u",
542
+ "\u03C7": "x",
543
+ "\u03C9": "w"
544
+ };
176
545
  function isValidSlug(slug) {
177
546
  return SLUG_RE.test(slug) && !slug.includes("--") && !RESERVED_SLUGS.has(slug);
178
547
  }
548
+ function isReservedSlug(slug, newGame = true) {
549
+ if (RESERVED_SLUGS.has(slug)) return true;
550
+ if (!newGame) return false;
551
+ if (NEW_GAME_RESERVED_SLUGS.has(slug) || NEW_GAME_RESERVED_PREFIXES.some((prefix) => slug.startsWith(prefix))) return true;
552
+ if (!SLUG_RE.test(slug)) return false;
553
+ const parts = slug.split("-");
554
+ if (parts.some((part, i) => SLUG_AUTH_WORDS.has(part) || SLUG_AUTH_WORDS.has(part + (parts[i + 1] ?? "")))) return true;
555
+ return hasBrand(slug);
556
+ }
557
+ function foldedWords(text) {
558
+ const base = text.normalize("NFKD").toLowerCase().replace(/[\u0300-\u036f]/g, "");
559
+ const variants = [];
560
+ for (const one of ["l", "i"]) {
561
+ const mapped = [...base].map((c) => c === "1" ? one : LOOKALIKES[c] ?? c);
562
+ const words = mapped.map((c) => /[a-z]/.test(c) ? c : /[0-9]/.test(c) ? "" : " ").join("").split(" ").filter(Boolean);
563
+ variants.push(words, words.map((w) => w.replace(/vv/g, "w")));
564
+ }
565
+ return variants;
566
+ }
567
+ function hasBrand(text) {
568
+ for (const words of foldedWords(text)) {
569
+ if (words.some((word) => BRANDS.some((brand) => word.includes(brand)))) return true;
570
+ for (let i = 0; i < words.length; i++) {
571
+ let joined = words[i];
572
+ for (let j = i + 1; j < words.length && joined.length < 16; j++) {
573
+ joined += words[j];
574
+ if (BRANDS.includes(joined)) return true;
575
+ }
576
+ }
577
+ }
578
+ return false;
579
+ }
179
580
  var DEFAULT_PRODUCTION_BRANCH = "main";
581
+ function deployWorkflow() {
582
+ return `name: Deploy to Onceworlds
583
+
584
+ # Branches only: tags don't deploy.
585
+ on:
586
+ push:
587
+ branches: ['**']
588
+
589
+ # A newer push to the same branch replaces a deploy still running.
590
+ concurrency:
591
+ group: onceworlds-\${{ github.ref }}
592
+ cancel-in-progress: true
593
+
594
+ permissions:
595
+ contents: read
596
+ id-token: write # lets the Onceworlds CLI prove which repo is deploying; no API keys needed
597
+
598
+ jobs:
599
+ deploy:
600
+ runs-on: ubuntu-latest
601
+ steps:
602
+ # The build never needs the repo's GitHub token, so it isn't left in .git/config.
603
+ - uses: actions/checkout@v5
604
+ with:
605
+ persist-credentials: false
606
+
607
+ - uses: actions/setup-node@v5
608
+ with:
609
+ node-version: 22
610
+
611
+ - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
612
+ if: hashFiles('bun.lock', 'bun.lockb') != ''
613
+
614
+ # Only if package.json has a build script, with the package manager its lockfile belongs to.
615
+ - name: Build
616
+ run: |
617
+ if [ ! -f package.json ] || [ "$(npm pkg get scripts.build)" = "{}" ]; then
618
+ exit 0
619
+ elif [ -f pnpm-lock.yaml ]; then
620
+ corepack enable
621
+ pnpm install --frozen-lockfile
622
+ pnpm run build
623
+ elif [ -f yarn.lock ]; then
624
+ corepack enable
625
+ yarn install
626
+ yarn run build
627
+ elif [ -f bun.lock ] || [ -f bun.lockb ]; then
628
+ bun install
629
+ bun run build
630
+ elif [ -f package-lock.json ]; then
631
+ npm ci
632
+ npm run build
633
+ else
634
+ npm install
635
+ npm run build
636
+ fi
637
+
638
+ # Pushes to the production branch (set in the dashboard or onceworlds.json,
639
+ # default "main") go live; other branches get a preview URL in the job log.
640
+ - name: Deploy
641
+ run: npx --yes @onceworlds/cli@0 deploy
642
+ `;
643
+ }
180
644
  var BADGE_KEY_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
181
645
  var GENRES = [
182
646
  "Action",
@@ -196,6 +660,30 @@ var GENRES = [
196
660
  "Party",
197
661
  "Other"
198
662
  ];
663
+ var tidy = (text) => text.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
664
+ function checkGuide(controls, help) {
665
+ const hints = [];
666
+ if (controls !== void 0 && controls !== null) {
667
+ if (!Array.isArray(controls)) throw new Error('"controls" must be a list, like [{ "keys": ["W", "A", "S", "D"], "action": "Move" }]');
668
+ if (controls.length > LIMITS.controlHints) throw new Error(`"controls" can have at most ${LIMITS.controlHints} items`);
669
+ controls.forEach((item, i) => {
670
+ const { keys, action } = item ?? {};
671
+ const name = `controls[${i}]`;
672
+ if (!Array.isArray(keys) || keys.length === 0 || keys.length > LIMITS.controlHintKeys) {
673
+ throw new Error(`${name}.keys must be 1-${LIMITS.controlHintKeys} keys or buttons, like ["Space"]`);
674
+ }
675
+ const cleaned = keys.map((k) => typeof k === "string" ? tidy(k) : "");
676
+ 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"`);
677
+ const text2 = typeof action === "string" ? tidy(action) : "";
678
+ if (!text2 || text2.length > LIMITS.controlActionLength) throw new Error(`${name}.action is what the keys do, in at most ${LIMITS.controlActionLength} characters`);
679
+ hints.push({ keys: cleaned, action: text2 });
680
+ });
681
+ }
682
+ if (help !== void 0 && help !== null && typeof help !== "string") throw new Error('"help" must be text');
683
+ const text = typeof help === "string" ? help.replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, " ").replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n").trim() : "";
684
+ if (text.length > LIMITS.helpLength) throw new Error(`"help" can be at most ${LIMITS.helpLength} characters`);
685
+ return { controls: hints, help: text };
686
+ }
199
687
  var MAX_IMAGE_SIDE = 4096;
200
688
  function imageInfo(bytes) {
201
689
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
@@ -253,16 +741,149 @@ function imageSizeProblem(slot, width, height) {
253
741
  return null;
254
742
  }
255
743
 
744
+ // src/init.ts
745
+ var OUTPUT_DIRS = ["dist", "build", "out", "public", "web", "www", "export"];
746
+ var WORKFLOW_PATH = ".github/workflows/onceworlds.yml";
747
+ async function init(dir, options) {
748
+ const log = options.log ?? console.log;
749
+ const { force } = options;
750
+ const at = (path) => join(dir, path);
751
+ const read = (path) => readFile(at(path), "utf8").catch(() => null);
752
+ const exists = (path) => stat(at(path)).then(() => true, () => false);
753
+ const lines = [];
754
+ let kept = false;
755
+ const put = async (path, content, label = path) => {
756
+ try {
757
+ await mkdir(dirname(at(path)), { recursive: true });
758
+ await writeFile(at(path), content, { flag: force ? "w" : "wx" });
759
+ lines.push(` wrote ${label}`);
760
+ } catch (err) {
761
+ if (err.code !== "EEXIST") throw err;
762
+ lines.push(` kept ${path} (it exists already)`);
763
+ kept = true;
764
+ }
765
+ };
766
+ const append = async (path, existing, line, label) => {
767
+ await writeFile(at(path), `${existing.trimEnd()}
768
+
769
+ ${line}
770
+ `);
771
+ lines.push(` wrote ${label}`);
772
+ };
773
+ const pkg = await readPackage(dir);
774
+ const source = typeof pkg?.name === "string" && pkg.name.replace(/^@[^/]+\//, "") || basename(dir);
775
+ const fromName = slugFrom(source);
776
+ const slug = fromName ?? `game-${randomBytes(3).toString("hex")}`;
777
+ const found = await findDir(pkg, exists);
778
+ const branch = defaultBranch(dir);
779
+ const config = { slug, name: titleFrom(source), description: "", dir: found.dir, ...branch ? { productionBranch: branch } : {} };
780
+ await put("onceworlds.json", `${JSON.stringify(config, null, 2)}
781
+ `, `onceworlds.json (link "${slug}", publishing "${found.dir}")`);
782
+ const present = await findWorkflow(dir);
783
+ if (present && present !== WORKFLOW_PATH) lines.push(` kept ${present} (it already deploys with the Onceworlds CLI)`);
784
+ else await put(WORKFLOW_PATH, deployWorkflow());
785
+ const guide = await fetchGuide(options.fetch ?? fetch, options.api, (line) => lines.push(line));
786
+ const agents = await read("AGENTS.md");
787
+ const separate = agents !== null && !/^# Onceworlds\b/.test(agents);
788
+ const guidePath = separate ? "ONCEWORLDS.md" : "AGENTS.md";
789
+ if (guide !== null) await put(guidePath, guide);
790
+ if (guide !== null || await exists(guidePath)) {
791
+ if (separate && !agents.includes("ONCEWORLDS.md")) {
792
+ await append("AGENTS.md", agents, "## Onceworlds\n\nThis game is published on Onceworlds. Read ONCEWORLDS.md for the platform rules and SDK.", "AGENTS.md (a pointer to ONCEWORLDS.md)");
793
+ }
794
+ const claude = await read("CLAUDE.md");
795
+ if (claude === null) await put("CLAUDE.md", `@${guidePath}
796
+ `);
797
+ else if (!claude.includes(guidePath)) await append("CLAUDE.md", claude, `@${guidePath}`, `CLAUDE.md (a pointer to ${guidePath})`);
798
+ }
799
+ for (const line of lines) log(line);
800
+ if (fromName === null) log(` note "${source}" can't be a game's link, so the link is ${slug}. Change slug in onceworlds.json.`);
801
+ if (found.note) log(` note ${found.note}`);
802
+ if (kept) log(" note --force replaces the files init writes. Your own AGENTS.md and CLAUDE.md are never replaced.");
803
+ log(`
804
+ Next:
805
+ 1. Check onceworlds.json: "slug" is your game's link (onceworlds.com/play/<slug>) and "name" is its title.
806
+ 2. Commit these files and push the repo to GitHub.
807
+ 3. Open ${options.api}/dashboard/new, import the repo, and every push publishes your game.`);
808
+ }
809
+ async function readPackage(dir) {
810
+ try {
811
+ const parsed = JSON.parse(await readFile(join(dir, "package.json"), "utf8"));
812
+ return parsed && typeof parsed === "object" ? parsed : null;
813
+ } catch {
814
+ return null;
815
+ }
816
+ }
817
+ function slugFrom(name) {
818
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/, "");
819
+ return isValidSlug(slug) && !isReservedSlug(slug) ? slug : null;
820
+ }
821
+ function titleFrom(name) {
822
+ const title = name.replace(/[-_.]+/g, " ").trim().replace(/\s+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
823
+ return (title || "My Game").slice(0, LIMITS.gameNameLength);
824
+ }
825
+ async function findDir(pkg, exists) {
826
+ if (typeof pkg?.scripts?.build === "string") {
827
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
828
+ const out = deps.next ? "out" : deps["react-scripts"] || deps["@sveltejs/kit"] ? "build" : "dist";
829
+ return { dir: out, note: `"dir" is ${out}, where the build script is expected to write the game. Change it in onceworlds.json if yours writes elsewhere.` };
830
+ }
831
+ if (await exists("index.html")) return { dir: "." };
832
+ for (const name of OUTPUT_DIRS) if (await exists(`${name}/index.html`)) return { dir: name };
833
+ return { dir: ".", note: `No index.html here yet. Set "dir" in onceworlds.json to the folder that has your game's index.html.` };
834
+ }
835
+ function defaultBranch(dir) {
836
+ const git = (args) => {
837
+ try {
838
+ return execFileSync("git", args, { cwd: dir, stdio: ["ignore", "pipe", "ignore"] }).toString().trim() || null;
839
+ } catch {
840
+ return null;
841
+ }
842
+ };
843
+ return git(["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"])?.replace(/^origin\//, "") ?? git(["symbolic-ref", "-q", "--short", "HEAD"]);
844
+ }
845
+ async function findWorkflow(dir) {
846
+ const folder = join(dir, ".github/workflows");
847
+ const names = await readdir(folder).catch(() => []);
848
+ for (const name of names.filter((n) => /\.ya?ml$/.test(n)).slice(0, 20)) {
849
+ if ((await readFile(join(folder, name), "utf8").catch(() => "")).includes("@onceworlds/cli")) return `.github/workflows/${name}`;
850
+ }
851
+ return null;
852
+ }
853
+ async function fetchGuide(fetcher, api, warn) {
854
+ const url = `${api}/agents.md`;
855
+ let problem;
856
+ try {
857
+ const res = await fetcher(url, { signal: AbortSignal.timeout(1e4) });
858
+ const text = res.ok ? await res.text() : "";
859
+ if (text.trim()) return text;
860
+ problem = res.ok ? "it is empty" : `${res.status}`;
861
+ } catch (err) {
862
+ problem = err instanceof Error ? err.message : String(err);
863
+ }
864
+ warn(` ! Skipped AGENTS.md and CLAUDE.md: could not fetch ${url} (${problem}). Run init again when it works.`);
865
+ return null;
866
+ }
867
+
256
868
  // src/index.ts
257
869
  var PRODUCTION_API = "https://onceworlds.com";
258
870
  var LOCAL_API = "http://localhost:8787";
259
871
  var USAGE = `Usage:
872
+ onceworlds init [project-dir] Set a game folder up for Onceworlds: writes onceworlds.json, the GitHub workflow
873
+ that publishes every push, and AGENTS.md and CLAUDE.md for AI coding tools
260
874
  onceworlds deploy [project-dir] Upload the game and publish it (to ${PRODUCTION_API})
261
875
  onceworlds dev [project-dir] Deploy to a local platform (${LOCAL_API}), then redeploy on every change
876
+ onceworlds doctor [project-dir] Look for what games often ship with: no viewport tag, small text fields,
877
+ localStorage, dialogs, Date.now() for shared time, keys with no touch
878
+ controls, a missing icon, thumbnails or How to play (suggestions only)
262
879
 
263
880
  Options:
264
881
  --api <url> Platform URL (default: $ONCEWORLDS_API, else the ones above)
265
882
  --token <t> Local platform's deploy token (default: "dev")
883
+ --players <n> dev: also print links for players 2 to n, to play a multiplayer game with yourself
884
+ --strict doctor: exit with an error when it finds a likely bug (not for notes)
885
+ --force init: replace onceworlds.json, the workflow and the guide when they exist already
886
+ (never your own AGENTS.md or CLAUDE.md)
266
887
 
267
888
  Deploys to onceworlds.com run from GitHub Actions: the repo's Onceworlds
268
889
  workflow runs this on every push, and GitHub vouches for the repo.
@@ -277,7 +898,14 @@ var QUIET_EXTENSIONS = /* @__PURE__ */ new Set(["md", "ts", "tsx", "jsx", "lock"
277
898
  async function main() {
278
899
  const { values, positionals } = parseArgs({
279
900
  allowPositionals: true,
280
- options: { api: { type: "string" }, token: { type: "string" }, help: { type: "boolean", short: "h" } }
901
+ options: {
902
+ api: { type: "string" },
903
+ token: { type: "string" },
904
+ players: { type: "string" },
905
+ strict: { type: "boolean" },
906
+ force: { type: "boolean" },
907
+ help: { type: "boolean", short: "h" }
908
+ }
281
909
  });
282
910
  const [command, projectArg] = positionals;
283
911
  if (values.help || !command || command === "help") {
@@ -286,10 +914,17 @@ async function main() {
286
914
  }
287
915
  const projectDir = resolve(projectArg ?? ".");
288
916
  const api = (values.api ?? process.env.ONCEWORLDS_API ?? (command === "dev" ? LOCAL_API : PRODUCTION_API)).replace(/\/+$/, "");
289
- if (command === "deploy") {
917
+ if (command === "init") {
918
+ if (!(await stat2(projectDir).catch(() => null))?.isDirectory()) throw new CliError(`${projectDir} isn't a folder.`);
919
+ await init(projectDir, { api, force: values.force === true });
920
+ } else if (command === "deploy") {
290
921
  await deploy(projectDir, api, values.token);
291
922
  } else if (command === "dev") {
292
- await dev(projectDir, api, values.token);
923
+ const players = Number(values.players ?? 1);
924
+ if (!Number.isInteger(players) || players < 1 || players > 9) throw new CliError("--players is a number from 1 to 9.");
925
+ await dev(projectDir, api, values.token, players);
926
+ } else if (command === "doctor") {
927
+ await doctor(projectDir, values.strict === true);
293
928
  } else {
294
929
  throw new CliError(`Unknown command "${command}".
295
930
 
@@ -307,6 +942,12 @@ async function deploy(projectDir, api, tokenArg, quiet = false) {
307
942
  if (!files.some((f) => f.path === "index.html")) {
308
943
  throw new CliError(`No index.html in ${root}. Set "dir" in onceworlds.json to the folder that contains it.`);
309
944
  }
945
+ if (!quiet) {
946
+ const findings = [...checkGame(await readSources(files)), ...checkListing(config)];
947
+ for (const f of findings.filter((x) => x.level === "warn")) console.warn(` ! ${f.file}:${f.line} ${f.message}`);
948
+ const notes = findings.filter((x) => x.level === "note").length;
949
+ if (notes > 0) console.log(` ${notes} suggestion${notes === 1 ? "" : "s"} for this game: run "npx @onceworlds/cli doctor" to see them.`);
950
+ }
310
951
  const total = files.reduce((sum, f) => sum + f.size, 0);
311
952
  if (total > LIMITS.deployTotalBytes) throw new CliError(`Game is ${formatBytes(total)}; the limit is ${formatBytes(LIMITS.deployTotalBytes)}.`);
312
953
  const { store, images } = await loadStore(projectDir, config);
@@ -329,7 +970,7 @@ async function deploy(projectDir, api, tokenArg, quiet = false) {
329
970
  console.log(` \u2191 uploading ${uploads.length} changed file(s), ${formatBytes(uploads.reduce((s, f) => s + f.size, 0))}`);
330
971
  }
331
972
  await runPool(uploads, 6, async (file) => {
332
- const body = await readFile(file.abs);
973
+ const body = await readFile2(file.abs);
333
974
  await send("PUT", `${api}/api/deploy/${start.deploymentId}/blobs/${file.hash}`, start.uploadToken, body);
334
975
  });
335
976
  const result = await send("POST", `${api}/api/deploy/${start.deploymentId}/finalize`, start.uploadToken, {});
@@ -341,15 +982,16 @@ async function deploy(projectDir, api, tokenArg, quiet = false) {
341
982
  }
342
983
  return result;
343
984
  }
344
- async function dev(projectDir, api, tokenArg) {
985
+ async function dev(projectDir, api, tokenArg, players = 1) {
345
986
  const config = await loadConfig(projectDir);
346
987
  const root = resolve(projectDir, config.dir ?? ".");
347
988
  const first = await deploy(projectDir, api, tokenArg);
348
989
  console.log(`
349
- Play (reloads on every change): ${first.playUrl}?watch
350
- Watching ${root}. Press Ctrl+C to stop.
990
+ Play (reloads on every change): ${first.playUrl}?watch`);
991
+ for (let n = 2; n <= players; n++) console.log(` Player ${n}: ${first.playUrl}?player=${n}&watch`);
992
+ console.log(` Watching ${root}. Press Ctrl+C to stop.
351
993
  `);
352
- const projectSignature = async () => signature((await collectFiles(root)).files) + await readFile(join(projectDir, "onceworlds.json"), "utf8").catch(() => "");
994
+ const projectSignature = async () => signature((await collectFiles(root)).files) + await readFile2(join2(projectDir, "onceworlds.json"), "utf8").catch(() => "");
353
995
  let lastSignature = await projectSignature();
354
996
  let timer;
355
997
  let running = false;
@@ -388,11 +1030,36 @@ async function dev(projectDir, api, tokenArg) {
388
1030
  await new Promise(() => {
389
1031
  });
390
1032
  }
1033
+ async function readSources(files) {
1034
+ const sources = [];
1035
+ for (const file of files) {
1036
+ if (!/\.(html?|m?js|css)$/i.test(file.path) || file.size > 15e5) continue;
1037
+ sources.push({ path: file.path, text: await readFile2(file.abs, "utf8") });
1038
+ }
1039
+ return sources;
1040
+ }
1041
+ async function doctor(projectDir, strict) {
1042
+ const config = await loadConfig(projectDir);
1043
+ const root = resolve(projectDir, config.dir ?? ".");
1044
+ const { files } = await collectFiles(root);
1045
+ const sources = await readSources(files);
1046
+ const findings = [...checkGame(sources), ...checkListing(config)];
1047
+ console.log(`Checked ${sources.length} file${sources.length === 1 ? "" : "s"} in ${root}.`);
1048
+ if (findings.length === 0) {
1049
+ console.log("Nothing to suggest.");
1050
+ return;
1051
+ }
1052
+ for (const f of findings) console.log(` ${f.level === "warn" ? "!" : "\xB7"} ${f.file}:${f.line} ${f.message}`);
1053
+ const warnings = findings.filter((f) => f.level === "warn").length;
1054
+ console.log(`
1055
+ ${warnings} likely problem${warnings === 1 ? "" : "s"}, ${findings.length - warnings} note${findings.length - warnings === 1 ? "" : "s"}. Nothing here stops a deploy.`);
1056
+ if (strict && warnings > 0) process.exitCode = 1;
1057
+ }
391
1058
  async function loadConfig(projectDir) {
392
- const file = join(projectDir, "onceworlds.json");
1059
+ const file = join2(projectDir, "onceworlds.json");
393
1060
  let config;
394
1061
  try {
395
- config = JSON.parse(await readFile(file, "utf8"));
1062
+ config = JSON.parse(await readFile2(file, "utf8"));
396
1063
  } catch (err) {
397
1064
  const missing = err.code === "ENOENT";
398
1065
  throw new CliError(missing ? `No onceworlds.json in ${projectDir}.
@@ -413,7 +1080,7 @@ async function loadStore(projectDir, config) {
413
1080
  if (!abs.startsWith(projectDir + sep)) throw new CliError(`"${field}" in onceworlds.json (${value}) must be inside the project folder.`);
414
1081
  let data;
415
1082
  try {
416
- data = await readFile(abs);
1083
+ data = await readFile2(abs);
417
1084
  } catch {
418
1085
  throw new CliError(`"${field}" in onceworlds.json: ${value} doesn't exist.`);
419
1086
  }
@@ -460,7 +1127,16 @@ async function loadStore(projectDir, config) {
460
1127
  const badgeIcon = b.icon === void 0 ? null : await image(b.icon, "badge", `${field}.icon`);
461
1128
  badges.push({ key, name, description, icon: badgeIcon });
462
1129
  }
463
- return { store: { genre, icon, thumbnails, badges }, images: [...images.values()] };
1130
+ let guide;
1131
+ try {
1132
+ guide = checkGuide(config.controls, config.help);
1133
+ } catch (err) {
1134
+ throw new CliError(`${err.message} in onceworlds.json.`);
1135
+ }
1136
+ const store = { genre, icon, thumbnails, badges };
1137
+ if (guide.controls.length > 0) store.controls = guide.controls;
1138
+ if (guide.help) store.help = guide.help;
1139
+ return { store, images: [...images.values()] };
464
1140
  }
465
1141
  async function collectFiles(root) {
466
1142
  const files = [];
@@ -468,13 +1144,13 @@ async function collectFiles(root) {
468
1144
  async function walk(dir, prefix) {
469
1145
  let entries;
470
1146
  try {
471
- entries = await readdir(dir, { withFileTypes: true });
1147
+ entries = await readdir2(dir, { withFileTypes: true });
472
1148
  } catch {
473
1149
  throw new CliError(`Cannot read ${dir}. Check "dir" in onceworlds.json.`);
474
1150
  }
475
1151
  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
476
1152
  if (entry.name.startsWith(".") || QUIET_SKIPS.has(entry.name)) continue;
477
- const abs = join(dir, entry.name);
1153
+ const abs = join2(dir, entry.name);
478
1154
  const path = prefix + entry.name;
479
1155
  if (entry.isDirectory()) {
480
1156
  await walk(abs, `${path}/`);
@@ -483,7 +1159,7 @@ async function collectFiles(root) {
483
1159
  if (!QUIET_EXTENSIONS.has(extensionOf(path))) skipped.push(path);
484
1160
  continue;
485
1161
  }
486
- const data = await readFile(abs);
1162
+ const data = await readFile2(abs);
487
1163
  if (data.byteLength > LIMITS.deployFileBytes) {
488
1164
  throw new CliError(`${path} is ${formatBytes(data.byteLength)}; files can be at most ${formatBytes(LIMITS.deployFileBytes)}.`);
489
1165
  }
@@ -501,7 +1177,7 @@ function signature(files) {
501
1177
  function gitInfo(cwd) {
502
1178
  const run = (args) => {
503
1179
  try {
504
- return execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim() || void 0;
1180
+ return execFileSync2("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim() || void 0;
505
1181
  } catch {
506
1182
  return void 0;
507
1183
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onceworlds/cli",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Deploy games to Onceworlds.",
5
5
  "homepage": "https://onceworlds.com",
6
6
  "type": "module",