@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.
- package/README.md +23 -1
- package/dist/cli.js +166 -6
- 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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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": "
|
|
8
|
+
"onceworlds": "dist/cli.js"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"dist"
|