@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.
- package/README.md +19 -1
- package/dist/cli.js +165 -6
- 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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 = [];
|