@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.
Files changed (3) hide show
  1. package/README.md +20 -7
  2. package/dist/cli.js +336 -12
  3. 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 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.
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 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
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
- 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),
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
- 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,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
- proxyMaxOutputTokens: 8192,
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
- /** Visits a game gets per network (IP, or IPv6 /64) per day: a script can't pump the count. */
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 > 512) return false;
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: { 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
+ }
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
- 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);
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
- 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.
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
- 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()] };
464
788
  }
465
789
  async function collectFiles(root) {
466
790
  const files = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onceworlds/cli",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Deploy games to Onceworlds.",
5
5
  "homepage": "https://onceworlds.com",
6
6
  "type": "module",