@onceworlds/cli 0.1.0 → 0.2.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 +23 -1
  2. package/dist/cli.js +166 -6
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -31,17 +31,39 @@ preview URL in the job log. Sign in at https://onceworlds.com with the GitHub
31
31
  account that owns the repo (or any member of its organization) before the first
32
32
  deploy.
33
33
 
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.
40
+
34
41
  ## Project file
35
42
 
36
43
  `onceworlds.json` at the root of the repo:
37
44
 
38
45
  ```json
39
- { "slug": "star-party", "name": "Star Party", "description": "Grab stars with everyone online.", "dir": "." }
46
+ {
47
+ "slug": "star-party",
48
+ "name": "Star Party",
49
+ "description": "Grab stars with everyone online.",
50
+ "dir": ".",
51
+ "genre": "Party",
52
+ "icon": "store/icon.png",
53
+ "thumbnails": ["store/thumb-1.png", "store/thumb-2.png"],
54
+ "badges": [{ "id": "first-star", "name": "First Star", "description": "Grab a star." }]
55
+ }
40
56
  ```
41
57
 
42
58
  `dir` is the folder to publish (for example `dist` after a build); it must contain
43
59
  `index.html`. `productionBranch` (default `main`) picks the branch that goes live.
44
60
 
61
+ The rest fills in the game page: `genre`, a square `icon` (at least 128×128),
62
+ 16:9 `thumbnails` (at least 480 wide; the first is the cover) and `badges` (`id`,
63
+ `name`, `description` and an optional square `icon`). Images are PNG, JPEG, WebP
64
+ or GIF up to 5 MB, with paths relative to `onceworlds.json`. Live deploys apply
65
+ these fields only when they change, so edits made in the dashboard last until then.
66
+
45
67
  ## Commands
46
68
 
47
69
  - `onceworlds deploy [dir]` uploads only the files that changed and publishes the game.
package/dist/cli.js CHANGED
@@ -13,19 +13,25 @@ var OIDC_AUDIENCE = "onceworlds";
13
13
  var LIMITS = {
14
14
  roomMaxPlayersDefault: 16,
15
15
  roomMaxPlayersCap: 50,
16
- publicServersPerGame: 20,
16
+ teamsMax: 8,
17
17
  messageBytes: 16 * 1024,
18
18
  presenceBytes: 1024,
19
19
  stateKeyLength: 64,
20
20
  stateValueBytes: 16 * 1024,
21
21
  stateKeys: 256,
22
22
  messagesPerSecond: 60,
23
+ /** Bytes one player may send to a room per second, on average (bursts up to 4x). */
24
+ roomBytesPerSecond: 128 * 1024,
23
25
  presenceHz: 20,
24
26
  chatLength: 200,
25
27
  chatIntervalMs: 750,
26
28
  chatHistory: 50,
29
+ chatChannelsPerPlayer: 10,
27
30
  saveValueBytes: 64 * 1024,
28
31
  saveKeysPerGame: 100,
32
+ /** All of one player's saves in one game together (guests get less until they sign in). */
33
+ saveBytesPerGame: 1024 * 1024,
34
+ guestSaveBytesPerGame: 128 * 1024,
29
35
  deployFiles: 2e3,
30
36
  deployFileBytes: 25 * 1024 * 1024,
31
37
  deployTotalBytes: 100 * 1024 * 1024,
@@ -34,7 +40,25 @@ var LIMITS = {
34
40
  proxyRequestBytes: 1024 * 1024,
35
41
  proxyResponseBytes: 10 * 1024 * 1024,
36
42
  proxyCallsPerDay: 2e3,
37
- errorReportsPerPage: 10
43
+ errorReportsPerPage: 10,
44
+ gameNameLength: 60,
45
+ gameDescriptionLength: 1e3,
46
+ galleryItems: 10,
47
+ mediaImageBytes: 5 * 1024 * 1024,
48
+ mediaVideoBytes: 30 * 1024 * 1024,
49
+ badgesPerGame: 100,
50
+ badgeNameLength: 50,
51
+ badgeDescriptionLength: 200,
52
+ leaderboardsPerGame: 25,
53
+ leaderboardTop: 100,
54
+ gamesPerOwner: 50,
55
+ deploysPerOwnerPerDay: 100,
56
+ deployBytesPerOwnerPerDay: 2 * 1024 * 1024 * 1024,
57
+ bioLength: 500,
58
+ friendsMax: 200,
59
+ friendRequestsMax: 100,
60
+ passwordMinLength: 8,
61
+ passwordMaxLength: 72
38
62
  };
39
63
  var CONTENT_TYPES = {
40
64
  html: "text/html; charset=utf-8",
@@ -108,6 +132,81 @@ var RESERVED_SLUGS = /* @__PURE__ */ new Set(["www", "api", "play", "admin", "st
108
132
  function isValidSlug(slug) {
109
133
  return SLUG_RE.test(slug) && !slug.includes("--") && !RESERVED_SLUGS.has(slug);
110
134
  }
135
+ var BADGE_KEY_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
136
+ var GENRES = [
137
+ "Action",
138
+ "Adventure",
139
+ "Fighting",
140
+ "Obby",
141
+ "Puzzle",
142
+ "Racing",
143
+ "Roleplay",
144
+ "Shooter",
145
+ "Simulation",
146
+ "Sports",
147
+ "Strategy",
148
+ "Survival",
149
+ "Tycoon",
150
+ "Horror",
151
+ "Party",
152
+ "Other"
153
+ ];
154
+ var MAX_IMAGE_SIDE = 4096;
155
+ function imageInfo(bytes) {
156
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
157
+ const ascii = (at, text) => text.split("").every((c, i) => bytes[at + i] === c.charCodeAt(0));
158
+ if (bytes.length >= 24 && bytes[0] === 137 && ascii(1, "PNG\r\n\n") && ascii(12, "IHDR")) {
159
+ return { type: "image/png", width: view.getUint32(16), height: view.getUint32(20) };
160
+ }
161
+ if (bytes.length >= 10 && (ascii(0, "GIF87a") || ascii(0, "GIF89a"))) {
162
+ return { type: "image/gif", width: view.getUint16(6, true), height: view.getUint16(8, true) };
163
+ }
164
+ if (bytes.length >= 30 && ascii(0, "RIFF") && ascii(8, "WEBP")) {
165
+ if (ascii(12, "VP8 ")) {
166
+ return { type: "image/webp", width: view.getUint16(26, true) & 16383, height: view.getUint16(28, true) & 16383 };
167
+ }
168
+ if (ascii(12, "VP8L") && bytes[20] === 47) {
169
+ const bits = view.getUint32(21, true);
170
+ return { type: "image/webp", width: (bits & 16383) + 1, height: (bits >> 14 & 16383) + 1 };
171
+ }
172
+ if (ascii(12, "VP8X")) {
173
+ const width = (bytes[24] | bytes[25] << 8 | bytes[26] << 16) + 1;
174
+ const height = (bytes[27] | bytes[28] << 8 | bytes[29] << 16) + 1;
175
+ return { type: "image/webp", width, height };
176
+ }
177
+ return null;
178
+ }
179
+ if (bytes.length >= 4 && bytes[0] === 255 && bytes[1] === 216) {
180
+ let at = 2;
181
+ while (at + 9 < bytes.length) {
182
+ if (bytes[at] !== 255) return null;
183
+ const marker = bytes[at + 1];
184
+ if (marker === 255) {
185
+ at++;
186
+ continue;
187
+ }
188
+ const length = view.getUint16(at + 2);
189
+ const isFrame = marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204;
190
+ if (isFrame) return { type: "image/jpeg", width: view.getUint16(at + 7), height: view.getUint16(at + 5) };
191
+ at += 2 + length;
192
+ }
193
+ }
194
+ return null;
195
+ }
196
+ function imageSizeProblem(slot, width, height) {
197
+ if (width < 1 || height < 1 || width > MAX_IMAGE_SIDE || height > MAX_IMAGE_SIDE) {
198
+ return `Images can be at most ${MAX_IMAGE_SIDE}\xD7${MAX_IMAGE_SIDE} pixels.`;
199
+ }
200
+ if (slot === "thumbnail") {
201
+ if (Math.abs(width * 9 - height * 16) > 0.02 * width * 9) return "Thumbnails must be 16:9, like 1920\xD71080.";
202
+ if (width < 480) return "Thumbnails must be at least 480 pixels wide.";
203
+ return null;
204
+ }
205
+ const min = slot === "icon" ? 128 : 64;
206
+ if (Math.abs(width - height) > Math.max(2, 0.01 * Math.max(width, height))) return "This image must be square.";
207
+ if (width < min) return `This image must be at least ${min}\xD7${min} pixels.`;
208
+ return null;
209
+ }
111
210
 
112
211
  // src/index.ts
113
212
  var PRODUCTION_API = "https://onceworlds.com";
@@ -158,6 +257,7 @@ async function deploy(projectDir, api, tokenArg, quiet = false) {
158
257
  }
159
258
  const total = files.reduce((sum, f) => sum + f.size, 0);
160
259
  if (total > LIMITS.deployTotalBytes) throw new CliError(`Game is ${formatBytes(total)}; the limit is ${formatBytes(LIMITS.deployTotalBytes)}.`);
260
+ const { store, images } = await loadStore(projectDir, config);
161
261
  if (!quiet) console.log(`Deploying ${config.slug} (${files.length} files, ${formatBytes(total)}) to ${api}`);
162
262
  const token = await getToken(api, tokenArg);
163
263
  const start = await send("POST", `${api}/api/deploy`, token, {
@@ -165,12 +265,13 @@ async function deploy(projectDir, api, tokenArg, quiet = false) {
165
265
  slug: config.slug,
166
266
  name: config.name,
167
267
  description: config.description ?? "",
168
- productionBranch: config.productionBranch ?? "main"
268
+ productionBranch: config.productionBranch ?? "main",
269
+ store
169
270
  },
170
271
  files: files.map(({ path, hash, size }) => ({ path, hash, size })),
171
272
  git: gitInfo(projectDir)
172
273
  });
173
- const byHash = new Map(files.map((f) => [f.hash, f]));
274
+ const byHash = new Map([...images, ...files].map((f) => [f.hash, f]));
174
275
  const uploads = start.missing.map((hash) => byHash.get(hash));
175
276
  if (uploads.length > 0 && !quiet) {
176
277
  console.log(` \u2191 uploading ${uploads.length} changed file(s), ${formatBytes(uploads.reduce((s, f) => s + f.size, 0))}`);
@@ -183,6 +284,7 @@ async function deploy(projectDir, api, tokenArg, quiet = false) {
183
284
  if (!quiet) {
184
285
  if (result.production) console.log(`\u2713 Live: ${result.playUrl}`);
185
286
  else console.log(`\u2713 Preview (this branch isn't ${config.productionBranch ?? "main"}): ${result.previewUrl}`);
287
+ for (const note of result.notes ?? []) console.log(` ${note}`);
186
288
  }
187
289
  return result;
188
290
  }
@@ -194,7 +296,8 @@ async function dev(projectDir, api, tokenArg) {
194
296
  Play (reloads on every change): ${first.playUrl}?watch
195
297
  Watching ${root}. Press Ctrl+C to stop.
196
298
  `);
197
- let lastSignature = signature(await collectFiles(root, true));
299
+ const projectSignature = async () => signature(await collectFiles(root, true)) + await readFile(join(projectDir, "onceworlds.json"), "utf8").catch(() => "");
300
+ let lastSignature = await projectSignature();
198
301
  let timer;
199
302
  let running = false;
200
303
  let again = false;
@@ -205,7 +308,7 @@ async function dev(projectDir, api, tokenArg) {
205
308
  }
206
309
  running = true;
207
310
  try {
208
- const current = signature(await collectFiles(root, true));
311
+ const current = await projectSignature();
209
312
  if (current !== lastSignature) {
210
313
  await deploy(projectDir, api, tokenArg, true);
211
314
  lastSignature = current;
@@ -249,6 +352,63 @@ ${USAGE}` : `Could not parse ${file}: ${err.message}`);
249
352
  if (typeof config.name !== "string" || !config.name.trim()) throw new CliError('"name" is required in onceworlds.json.');
250
353
  return config;
251
354
  }
355
+ async function loadStore(projectDir, config) {
356
+ const images = /* @__PURE__ */ new Map();
357
+ const image = async (value, slot, field) => {
358
+ if (typeof value !== "string" || !value.trim()) throw new CliError(`"${field}" in onceworlds.json must be the path of an image, like "store/icon.png".`);
359
+ const abs = resolve(projectDir, value);
360
+ if (!abs.startsWith(projectDir + sep)) throw new CliError(`"${field}" in onceworlds.json (${value}) must be inside the project folder.`);
361
+ let data;
362
+ try {
363
+ data = await readFile(abs);
364
+ } catch {
365
+ throw new CliError(`"${field}" in onceworlds.json: ${value} doesn't exist.`);
366
+ }
367
+ if (data.byteLength > LIMITS.mediaImageBytes) {
368
+ throw new CliError(`${value} is ${formatBytes(data.byteLength)}; game page images can be at most ${formatBytes(LIMITS.mediaImageBytes)}.`);
369
+ }
370
+ const info = imageInfo(data);
371
+ if (!info) throw new CliError(`${value} ("${field}") must be a PNG, JPEG, WebP or GIF.`);
372
+ const problem = imageSizeProblem(slot, info.width, info.height);
373
+ if (problem) throw new CliError(`${value} ("${field}") is ${info.width}\xD7${info.height}. ${problem}`);
374
+ const hash = createHash("sha256").update(data).digest("hex");
375
+ images.set(hash, { path: value, abs, hash, size: data.byteLength });
376
+ return { hash, size: data.byteLength };
377
+ };
378
+ const list = (value, max, field) => {
379
+ if (value === void 0) return [];
380
+ if (!Array.isArray(value)) throw new CliError(`"${field}" in onceworlds.json must be a list.`);
381
+ if (value.length > max) throw new CliError(`"${field}" in onceworlds.json can have at most ${max} items.`);
382
+ return value;
383
+ };
384
+ let genre = "";
385
+ if (config.genre !== void 0 && config.genre !== "") {
386
+ genre = GENRES.find((g) => g.toLowerCase() === String(config.genre).toLowerCase()) ?? "";
387
+ if (!genre) throw new CliError(`"genre" in onceworlds.json must be one of: ${GENRES.join(", ")}.`);
388
+ }
389
+ const icon = config.icon === void 0 ? null : await image(config.icon, "icon", "icon");
390
+ const thumbnails = [];
391
+ for (const [i, path] of list(config.thumbnails, LIMITS.galleryItems, "thumbnails").entries()) {
392
+ thumbnails.push(await image(path, "thumbnail", `thumbnails[${i}]`));
393
+ }
394
+ const badges = [];
395
+ for (const [i, value] of list(config.badges, LIMITS.badgesPerGame, "badges").entries()) {
396
+ const b = value ?? {};
397
+ const field = `badges[${i}]`;
398
+ const key = typeof b.id === "string" ? b.id : "";
399
+ if (!BADGE_KEY_RE.test(key)) throw new CliError(`"${field}.id" in onceworlds.json must be lowercase letters, digits and dashes, like "first-win".`);
400
+ if (badges.some((other) => other.key === key)) throw new CliError(`Two badges in onceworlds.json have the id "${key}".`);
401
+ const name = typeof b.name === "string" ? b.name.trim() : "";
402
+ if (!name || name.length > LIMITS.badgeNameLength) throw new CliError(`Badge "${key}" needs a name of 1-${LIMITS.badgeNameLength} characters.`);
403
+ const description = typeof b.description === "string" ? b.description.trim() : "";
404
+ if (description.length > LIMITS.badgeDescriptionLength) {
405
+ throw new CliError(`Badge "${key}": descriptions can be at most ${LIMITS.badgeDescriptionLength} characters.`);
406
+ }
407
+ const badgeIcon = b.icon === void 0 ? null : await image(b.icon, "badge", `${field}.icon`);
408
+ badges.push({ key, name, description, icon: badgeIcon });
409
+ }
410
+ return { store: { genre, icon, thumbnails, badges }, images: [...images.values()] };
411
+ }
252
412
  async function collectFiles(root, quiet) {
253
413
  const files = [];
254
414
  const skipped = [];
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@onceworlds/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Deploy games to Onceworlds.",
5
5
  "homepage": "https://onceworlds.com",
6
6
  "type": "module",
7
7
  "bin": {
8
- "onceworlds": "./dist/cli.js"
8
+ "onceworlds": "dist/cli.js"
9
9
  },
10
10
  "files": [
11
11
  "dist"