@onceworlds/cli 0.1.1 → 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 +19 -1
  2. package/dist/cli.js +165 -6
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -35,17 +35,35 @@ New games start private: only the repo's owner (or its organization's members)
35
35
  can play them. When it's ready to share, open the game in the dashboard and click
36
36
  "Make public" in the Settings tab.
37
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
+
38
41
  ## Project file
39
42
 
40
43
  `onceworlds.json` at the root of the repo:
41
44
 
42
45
  ```json
43
- { "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
+ }
44
56
  ```
45
57
 
46
58
  `dir` is the folder to publish (for example `dist` after a build); it must contain
47
59
  `index.html`. `productionBranch` (default `main`) picks the branch that goes live.
48
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
+
49
67
  ## Commands
50
68
 
51
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))}`);
@@ -195,7 +296,8 @@ async function dev(projectDir, api, tokenArg) {
195
296
  Play (reloads on every change): ${first.playUrl}?watch
196
297
  Watching ${root}. Press Ctrl+C to stop.
197
298
  `);
198
- 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();
199
301
  let timer;
200
302
  let running = false;
201
303
  let again = false;
@@ -206,7 +308,7 @@ async function dev(projectDir, api, tokenArg) {
206
308
  }
207
309
  running = true;
208
310
  try {
209
- const current = signature(await collectFiles(root, true));
311
+ const current = await projectSignature();
210
312
  if (current !== lastSignature) {
211
313
  await deploy(projectDir, api, tokenArg, true);
212
314
  lastSignature = current;
@@ -250,6 +352,63 @@ ${USAGE}` : `Could not parse ${file}: ${err.message}`);
250
352
  if (typeof config.name !== "string" || !config.name.trim()) throw new CliError('"name" is required in onceworlds.json.');
251
353
  return config;
252
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
+ }
253
412
  async function collectFiles(root, quiet) {
254
413
  const files = [];
255
414
  const skipped = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onceworlds/cli",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Deploy games to Onceworlds.",
5
5
  "homepage": "https://onceworlds.com",
6
6
  "type": "module",