@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.
Files changed (3) hide show
  1. package/README.md +39 -20
  2. package/dist/cli.js +397 -22
  3. 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
- Add `.github/workflows/deploy.yml` to your game's repo:
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` go live at `onceworlds.com/play/<slug>`; other branches get a
30
- preview URL in the job log. Sign in at https://onceworlds.com with the GitHub
31
- account that owns the repo (or any member of its organization) before the first
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 the repo's owner (or its organization's members)
35
- can play them. When it's ready to share, open the game in the dashboard and click
36
- "Make public" in the Settings tab.
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": "star-party",
48
- "name": "Star Party",
49
- "description": "Grab stars with everyone online.",
51
+ "slug": "kart-rush",
52
+ "name": "Kart Rush",
53
+ "description": "Race friends around twisty tracks.",
50
54
  "dir": ".",
51
- "genre": "Party",
55
+ "genre": "Racing",
52
56
  "icon": "store/icon.png",
53
57
  "thumbnails": ["store/thumb-1.png", "store/thumb-2.png"],
54
- "badges": [{ "id": "first-star", "name": "First Star", "description": "Grab a star." }]
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
- The rest fills in the game page: `genre`, a square `icon` (at least 128×128),
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
- - `onceworlds dev [dir]` deploys to a local Onceworlds platform and redeploys on every save.
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
- roomMaxPlayersCap: 50,
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
- /** Bytes one player may send to a room per second, on average (bursts up to 4x). */
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 > 512) return false;
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> Deploy token (default: $ONCEWORLDS_TOKEN, GitHub Actions OIDC, or "dev" on localhost)
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": "star-party", "name": "Star Party", "dir": "." }
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: { api: { type: "string" }, token: { type: "string" }, help: { type: "boolean", short: "h" } }
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
- await dev(projectDir, api, values.token);
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, quiet);
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 ?? "main",
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 (this branch isn't ${config.productionBranch ?? "main"}): ${result.previewUrl}`);
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
- Watching ${root}. Press Ctrl+C to stop.
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, true)) + await readFile(join(projectDir, "onceworlds.json"), "utf8").catch(() => "");
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
- return { store: { genre, icon, thumbnails, badges }, images: [...images.values()] };
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, quiet) {
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
- "No deploy credentials. In GitHub Actions, add `permissions: id-token: write` to the workflow. Elsewhere, set ONCEWORLDS_TOKEN."
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onceworlds/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Deploy games to Onceworlds.",
5
5
  "homepage": "https://onceworlds.com",
6
6
  "type": "module",