@onceworlds/cli 0.12.0 → 0.14.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 +3 -3
- package/dist/cli.js +287 -83
- package/dist/emulator.js +1274 -333
- package/dist/sdk.js +3 -3
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
5
5
|
import { createHash as createHash5 } from "node:crypto";
|
|
6
|
-
import { existsSync as existsSync12, watch } from "node:fs";
|
|
7
|
-
import { mkdir as mkdir4, readdir as readdir4, readFile as readFile9, stat as stat2, writeFile as writeFile4 } from "node:fs/promises";
|
|
6
|
+
import { createReadStream, existsSync as existsSync12, watch } from "node:fs";
|
|
7
|
+
import { mkdir as mkdir4, open, readdir as readdir4, readFile as readFile9, stat as stat2, writeFile as writeFile4 } from "node:fs/promises";
|
|
8
8
|
import { basename as basename6, dirname as dirname10, join as join17, resolve as resolve12, sep as sep5 } from "node:path";
|
|
9
9
|
import { parseArgs } from "node:util";
|
|
10
10
|
|
|
@@ -717,6 +717,25 @@ var NEW_GAME_RESERVED_SLUGS = /* @__PURE__ */ new Set([
|
|
|
717
717
|
]);
|
|
718
718
|
var NEW_GAME_RESERVED_PREFIXES = ["onceworlds", "admin", "free-"];
|
|
719
719
|
var SLUG_AUTH_WORDS = /* @__PURE__ */ new Set(["login", "logon", "signin", "signon", "signup", "verify", "verification", "password", "passwords", "billing"]);
|
|
720
|
+
var NAME_AUTH_WORDS = /* @__PURE__ */ new Set([
|
|
721
|
+
"login",
|
|
722
|
+
"logon",
|
|
723
|
+
"signin",
|
|
724
|
+
"signup",
|
|
725
|
+
"verify",
|
|
726
|
+
"verification",
|
|
727
|
+
"password",
|
|
728
|
+
"billing",
|
|
729
|
+
"account",
|
|
730
|
+
"support",
|
|
731
|
+
"helpdesk",
|
|
732
|
+
"official",
|
|
733
|
+
"staff",
|
|
734
|
+
"admin",
|
|
735
|
+
"administrator",
|
|
736
|
+
"moderator",
|
|
737
|
+
"security"
|
|
738
|
+
]);
|
|
720
739
|
var BRANDS = ["onceworlds", "roblox", "robux", "vbucks", "fortnite", "minecraft", "xbox", "playstation", "nintendo"];
|
|
721
740
|
var LOOKALIKES = {
|
|
722
741
|
"0": "o",
|
|
@@ -795,6 +814,10 @@ function hasBrand(text2) {
|
|
|
795
814
|
}
|
|
796
815
|
return false;
|
|
797
816
|
}
|
|
817
|
+
function isReservedGameName(name) {
|
|
818
|
+
if (hasBrand(name)) return true;
|
|
819
|
+
return foldedWords(name).some((words3) => NAME_AUTH_WORDS.has(words3.join("")));
|
|
820
|
+
}
|
|
798
821
|
var DEFAULT_PRODUCTION_BRANCH = "main";
|
|
799
822
|
function isValidBranchName(name) {
|
|
800
823
|
return name.length > 0 && name.length <= 200 && /^[A-Za-z0-9._/-]+$/.test(name) && !/^[-/.]|[/.]$|\.\.|\/\/|\/\.|\.lock$|^HEAD$/.test(name);
|
|
@@ -838,10 +861,10 @@ function pageScripts(html) {
|
|
|
838
861
|
comment = html.indexOf("<!--", at);
|
|
839
862
|
continue;
|
|
840
863
|
}
|
|
841
|
-
const
|
|
842
|
-
const close =
|
|
864
|
+
const open2 = html.indexOf(">", script);
|
|
865
|
+
const close = open2 === -1 ? -1 : find(closing2, open2);
|
|
843
866
|
if (close === -1) return scripts;
|
|
844
|
-
scripts.push({ tag: html.slice(script,
|
|
867
|
+
scripts.push({ tag: html.slice(script, open2 + 1), body: html.slice(open2 + 1, close), at: open2 + 1 });
|
|
845
868
|
at = close + 8;
|
|
846
869
|
if (comment !== -1 && comment < at) comment = html.indexOf("<!--", at);
|
|
847
870
|
}
|
|
@@ -1482,9 +1505,26 @@ function checkGame(files) {
|
|
|
1482
1505
|
line: at,
|
|
1483
1506
|
level: "note",
|
|
1484
1507
|
id: "matches",
|
|
1485
|
-
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: '
|
|
1508
|
+
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: 'bar' }), or 'card' (see AGENTS.md, "Matches").`
|
|
1486
1509
|
});
|
|
1487
1510
|
}
|
|
1511
|
+
const lobbyLine = (id, level, test, message) => {
|
|
1512
|
+
const file = code.find((f) => test.test(f.text));
|
|
1513
|
+
if (file) found.push({ file: file.path, line: lineOf(file.text, file.text.search(test)), level, id, message });
|
|
1514
|
+
};
|
|
1515
|
+
lobbyLine("hide-lobby", "warn", /\.\s*hideLobby\s*\(/, "room.hideLobby does nothing now: the platform's strip or card always shows in the lobby. Take it out.");
|
|
1516
|
+
lobbyLine("own-countdown", "warn", /\bownCountdown\s*:\s*true\b/, "The 3-2-1 is the platform's. Drop ownCountdown and your own numeral, and start the match's action on matchstart, so one countdown shows.");
|
|
1517
|
+
const gameDrawn = /\blobby\s*:\s*['"]game['"]/;
|
|
1518
|
+
if (gameDrawn.test(all)) {
|
|
1519
|
+
if (/\.\s*setReady\s*\(/.test(all)) lobbyLine("game-lobby", "warn", gameDrawn, "lobby: 'game' with setReady: this game draws Ready itself, so it can't match the rest of the platform (Enter, the same words, a strip that's always there). Use lobby: 'bar' or 'card'.");
|
|
1520
|
+
} else if (/\blobby\s*:\s*['"]bar['"]/.test(all)) {
|
|
1521
|
+
const drawn = /(?:textContent|innerText|innerHTML|fillText\s*\(|label\s*:)\s*=?\s*['"`](?:<[^>]*>)*\s*(Ready|Ready up|Start|Start match|Not ready)\s*(?:<[^>]*>)*['"`]/;
|
|
1522
|
+
const htmlButton = /<button\b[^>]*>\s*(Ready|Ready up|Start match)\s*<\/button>/i;
|
|
1523
|
+
if (/\.\s*(setReady|startMatch)\s*\(/.test(all)) {
|
|
1524
|
+
const test = drawn.test(all) ? drawn : htmlButton;
|
|
1525
|
+
lobbyLine("double-ready", "warn", test, "This joins with lobby: 'bar', and draws a Ready or Start button of its own too: players see two. The strip's button and Enter are the way; take yours out.");
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1488
1528
|
if (/\bDate\s*\.\s*now\s*\(/.test(all) && !/\bonceworlds\s*\.\s*now\s*\(|\bow\s*\.\s*now\s*\(|\bmatchNow\s*\(/.test(all)) {
|
|
1489
1529
|
found.push({
|
|
1490
1530
|
file: host.path,
|
|
@@ -1570,17 +1610,17 @@ function keyName(key) {
|
|
|
1570
1610
|
return key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1);
|
|
1571
1611
|
}
|
|
1572
1612
|
var PRESET_KEYS = { wasd: ["W", "A", "S", "D"], ijkl: ["I", "J", "K", "L"], arrows: ["Arrows"] };
|
|
1573
|
-
function objectAt(text2,
|
|
1613
|
+
function objectAt(text2, open2) {
|
|
1574
1614
|
let depth = 0;
|
|
1575
1615
|
let quote = "";
|
|
1576
|
-
for (let i =
|
|
1616
|
+
for (let i = open2; i < text2.length; i++) {
|
|
1577
1617
|
const c = text2[i];
|
|
1578
1618
|
if (quote) {
|
|
1579
1619
|
if (c === "\\") i++;
|
|
1580
1620
|
else if (c === quote) quote = "";
|
|
1581
1621
|
} else if (c === '"' || c === "'" || c === "`") quote = c;
|
|
1582
1622
|
else if (c === "{") depth++;
|
|
1583
|
-
else if (c === "}" && --depth === 0) return text2.slice(
|
|
1623
|
+
else if (c === "}" && --depth === 0) return text2.slice(open2, i + 1);
|
|
1584
1624
|
}
|
|
1585
1625
|
return null;
|
|
1586
1626
|
}
|
|
@@ -2284,9 +2324,9 @@ var SCREENS2 = [
|
|
|
2284
2324
|
];
|
|
2285
2325
|
var DEFAULT_FONT = "sora";
|
|
2286
2326
|
var lineAt = (text2, index) => text2.slice(0, index).split("\n").length;
|
|
2287
|
-
function objectAt2(file, code,
|
|
2288
|
-
const end = closing(code,
|
|
2289
|
-
return end < 0 ? null : { text: file.text.slice(
|
|
2327
|
+
function objectAt2(file, code, open2) {
|
|
2328
|
+
const end = closing(code, open2);
|
|
2329
|
+
return end < 0 ? null : { text: file.text.slice(open2, end + 1), code: code.slice(open2, end + 1), from: open2 };
|
|
2290
2330
|
}
|
|
2291
2331
|
function constObject(files, name) {
|
|
2292
2332
|
const re = new RegExp(`(?:const|let|var)\\s+${name.replace(/\$/g, "\\$")}\\s*=\\s*\\{`);
|
|
@@ -2374,8 +2414,8 @@ function designFindings(input) {
|
|
|
2374
2414
|
if (f && !flow) flow = { file, code, at: f.index, shape: f[1] };
|
|
2375
2415
|
for (const m of code.matchAll(/(?<![\w$.])UI\s*\(\s*(\{)?/g)) {
|
|
2376
2416
|
if (ui) break;
|
|
2377
|
-
const
|
|
2378
|
-
ui = { file, code, at: m.index ?? 0, options: m[1] ? objectAt2(file, code,
|
|
2417
|
+
const open2 = (m.index ?? 0) + m[0].length - 1;
|
|
2418
|
+
ui = { file, code, at: m.index ?? 0, options: m[1] ? objectAt2(file, code, open2) : null };
|
|
2379
2419
|
}
|
|
2380
2420
|
}
|
|
2381
2421
|
if (!flow || !ui) return found;
|
|
@@ -2450,10 +2490,10 @@ function designFindings(input) {
|
|
|
2450
2490
|
const start = (m.index ?? 0) + m[0].length;
|
|
2451
2491
|
const name = file.text.slice(start, file.text.indexOf(m[1], start));
|
|
2452
2492
|
if (name === "flow.countdown") continue;
|
|
2453
|
-
const
|
|
2493
|
+
const open2 = (m.index ?? 0) + m[0].length - m[1].length - 1;
|
|
2454
2494
|
const end = closing(code, code.indexOf("(", m.index ?? 0));
|
|
2455
2495
|
if (end < 0) continue;
|
|
2456
|
-
const span = file.text.slice(
|
|
2496
|
+
const span = file.text.slice(open2, end);
|
|
2457
2497
|
const countdown = /phase\s*[!=]==?\s*['"]countdown['"]|['"]countdown['"]\s*===?\s*[\w$.?]*phase|\bflow\??\.countdown\b/.test(span);
|
|
2458
2498
|
const digits = /Math\s*\.\s*ceil\s*\(|['"`]GO!?['"`]/.test(span);
|
|
2459
2499
|
if (countdown && digits) {
|
|
@@ -2594,10 +2634,10 @@ function blank(text2) {
|
|
|
2594
2634
|
}
|
|
2595
2635
|
return out.join("");
|
|
2596
2636
|
}
|
|
2597
|
-
function closing(code,
|
|
2637
|
+
function closing(code, open2) {
|
|
2598
2638
|
const pairs = { "(": ")", "[": "]", "{": "}" };
|
|
2599
2639
|
const stack = [];
|
|
2600
|
-
for (let i =
|
|
2640
|
+
for (let i = open2; i < code.length; i++) {
|
|
2601
2641
|
const c = code[i];
|
|
2602
2642
|
if (pairs[c]) stack.push(pairs[c]);
|
|
2603
2643
|
else if (c === ")" || c === "]" || c === "}") {
|
|
@@ -2677,30 +2717,30 @@ function functionBody(code, from, to, name) {
|
|
|
2677
2717
|
function regions(text2, code) {
|
|
2678
2718
|
const out = [];
|
|
2679
2719
|
for (const m of code.matchAll(/\b(defineSystem|defineBotSystem|script|Flow\s*\.\s*(?:rounds|turns|coop|dropIn))\s*\(\s*\{/g)) {
|
|
2680
|
-
const
|
|
2681
|
-
const end = closing(code,
|
|
2720
|
+
const open2 = (m.index ?? 0) + m[0].length - 1;
|
|
2721
|
+
const end = closing(code, open2);
|
|
2682
2722
|
if (end < 0) continue;
|
|
2683
2723
|
const which = m[1].replace(/\s/g, "");
|
|
2684
|
-
const name = stringProp(text2, code,
|
|
2724
|
+
const name = stringProp(text2, code, open2, end, "name") ?? which;
|
|
2685
2725
|
if (which === "defineSystem" || which === "defineBotSystem") {
|
|
2686
|
-
const stage = stringProp(text2, code,
|
|
2687
|
-
const host = which === "defineBotSystem" || stringProp(text2, code,
|
|
2726
|
+
const stage = stringProp(text2, code, open2, end, "stage") ?? (which === "defineBotSystem" ? "fixed" : "update");
|
|
2727
|
+
const host = which === "defineBotSystem" || stringProp(text2, code, open2, end, "authority") === "host";
|
|
2688
2728
|
for (const fn of which === "defineBotSystem" ? ["think", "act"] : ["run"]) {
|
|
2689
|
-
const body = functionBody(code,
|
|
2690
|
-
if (body) out.push({ from: body[0], to: body[1], kind: "system", name, stage, host, defFrom:
|
|
2729
|
+
const body = functionBody(code, open2, end, fn);
|
|
2730
|
+
if (body) out.push({ from: body[0], to: body[1], kind: "system", name, stage, host, defFrom: open2, defTo: end });
|
|
2691
2731
|
}
|
|
2692
2732
|
} else if (which === "script") {
|
|
2693
2733
|
for (const hook of ["fixed", "update", "ready"]) {
|
|
2694
|
-
const body = functionBody(code,
|
|
2695
|
-
if (body) out.push({ from: body[0], to: body[1], kind: "script", name: hook, stage: hook === "fixed" ? "fixed" : "update", host: stringProp(text2, code,
|
|
2734
|
+
const body = functionBody(code, open2, end, hook);
|
|
2735
|
+
if (body) out.push({ from: body[0], to: body[1], kind: "script", name: hook, stage: hook === "fixed" ? "fixed" : "update", host: stringProp(text2, code, open2, end, "authority") === "host", defFrom: open2, defTo: end });
|
|
2696
2736
|
}
|
|
2697
2737
|
} else {
|
|
2698
2738
|
for (const rule of ["setup", "populate", "spawn", "isOver", "rank", "end", "begin", "bot"]) {
|
|
2699
2739
|
const re = new RegExp(`(?<![\\w$.])${rule}\\s*(\\(|:)`, "g");
|
|
2700
|
-
re.lastIndex =
|
|
2740
|
+
re.lastIndex = open2;
|
|
2701
2741
|
for (let r = re.exec(code); r && r.index < end; r = re.exec(code)) {
|
|
2702
|
-
const body = functionBody(code, Math.max(
|
|
2703
|
-
if (body) out.push({ from: body[0], to: body[1], kind: "rules", name: rule, stage: "fixed", host: rule === "populate" || rule === "isOver" || rule === "rank" || rule === "end", defFrom:
|
|
2742
|
+
const body = functionBody(code, Math.max(open2, enclosing(code, r.index)), end, rule);
|
|
2743
|
+
if (body) out.push({ from: body[0], to: body[1], kind: "rules", name: rule, stage: "fixed", host: rule === "populate" || rule === "isOver" || rule === "rank" || rule === "end", defFrom: open2, defTo: end });
|
|
2704
2744
|
}
|
|
2705
2745
|
}
|
|
2706
2746
|
}
|
|
@@ -2747,10 +2787,10 @@ function lintGame(input) {
|
|
|
2747
2787
|
for (const file of scripts) {
|
|
2748
2788
|
const code = blank(file.text);
|
|
2749
2789
|
for (const m of code.matchAll(/(?:const|let|var)\s+([\w$]+)\s*=\s*defineComponent\s*\(/g)) {
|
|
2750
|
-
const
|
|
2751
|
-
const end = closing(code,
|
|
2790
|
+
const open2 = code.indexOf("(", m.index ?? 0);
|
|
2791
|
+
const end = closing(code, open2);
|
|
2752
2792
|
if (end < 0) continue;
|
|
2753
|
-
if (/\bowner\s*:\s*['"]host['"]/.test(file.text.slice(
|
|
2793
|
+
if (/\bowner\s*:\s*['"]host['"]/.test(file.text.slice(open2, end))) hostComponents.add(m[1]);
|
|
2754
2794
|
}
|
|
2755
2795
|
}
|
|
2756
2796
|
for (const file of scripts) {
|
|
@@ -2807,16 +2847,16 @@ function lintGame(input) {
|
|
|
2807
2847
|
}
|
|
2808
2848
|
}
|
|
2809
2849
|
for (const m of code.matchAll(/\bwhile\s*\(\s*(?:true|1)\s*\)|\bfor\s*\(\s*;\s*;\s*\)/g)) {
|
|
2810
|
-
const
|
|
2811
|
-
const end =
|
|
2812
|
-
const body = end < 0 ? "" : code.slice(
|
|
2850
|
+
const open2 = code.indexOf("{", m.index ?? 0);
|
|
2851
|
+
const end = open2 < 0 ? -1 : closing(code, open2);
|
|
2852
|
+
const body = end < 0 ? "" : code.slice(open2, end);
|
|
2813
2853
|
if (!/\b(break|return|throw)\b/.test(body)) add(m.index ?? 0, "warn", "unbounded-spawn", "A loop with no break or return: it hangs the page. Give it an end, or do the work a step at a time in a system.");
|
|
2814
2854
|
}
|
|
2815
2855
|
const actionsFound = [];
|
|
2816
2856
|
for (const m of code.matchAll(/(?<![\w$])([\w$]+)\s*:\s*Input\s*\.\s*(button|axis2d|pointer)\s*\(\s*(\{)?/g)) {
|
|
2817
|
-
const
|
|
2818
|
-
const end = m[3] ? closing(code,
|
|
2819
|
-
const options = m[3] && end > 0 ? code.slice(
|
|
2857
|
+
const open2 = (m.index ?? 0) + m[0].length - 1;
|
|
2858
|
+
const end = m[3] ? closing(code, open2) : open2;
|
|
2859
|
+
const options = m[3] && end > 0 ? code.slice(open2, end) : "";
|
|
2820
2860
|
const touch = m[2] === "pointer" || (m[2] === "button" ? /(?<![\w$])touch\s*:/.test(options) : /(?<![\w$])stick\s*:\s*(?!false)/.test(options));
|
|
2821
2861
|
actionsFound.push({ name: m[1], kind: m[2], touch, at: m.index ?? 0 });
|
|
2822
2862
|
}
|
|
@@ -3187,7 +3227,7 @@ ${line3}
|
|
|
3187
3227
|
Next:
|
|
3188
3228
|
1. Check onceworlds.json: "slug" is your game's link (onceworlds.com/play/<slug>) and "name" is its title.
|
|
3189
3229
|
2. Commit these files and push the repo to GitHub.
|
|
3190
|
-
3. Open ${options.api}/
|
|
3230
|
+
3. Open ${options.api}/upload?new=github, import the repo, and every push publishes your game.`);
|
|
3191
3231
|
}
|
|
3192
3232
|
async function readPackage2(dir) {
|
|
3193
3233
|
try {
|
|
@@ -4321,7 +4361,7 @@ ${game.description ? `${game.description}
|
|
|
4321
4361
|
- \`npm test\`: the tests of rules.js (\`node --test\`, nothing to install).
|
|
4322
4362
|
- \`npx @onceworlds/cli check\`: common mistakes, before every deploy. \`shot --story\` shows its first minute.
|
|
4323
4363
|
- \`npx @onceworlds/cli deploy\` (after \`login\`) pushes a draft to play and publish at ${game.api}/create/${game.slug}. Or push the repo to GitHub
|
|
4324
|
-
and import it at ${game.api}/
|
|
4364
|
+
and import it at ${game.api}/upload?new=github: \`.github/workflows/onceworlds.yml\` deploys every push.
|
|
4325
4365
|
`
|
|
4326
4366
|
);
|
|
4327
4367
|
return files;
|
|
@@ -5347,6 +5387,106 @@ import { tmpdir as tmpdir2 } from "node:os";
|
|
|
5347
5387
|
import { dirname as dirname6, join as join8, resolve as resolve4 } from "node:path";
|
|
5348
5388
|
import { fileURLToPath as fileURLToPath2, pathToFileURL as pathToFileURL2 } from "node:url";
|
|
5349
5389
|
|
|
5390
|
+
// ../protocol/src/about.ts
|
|
5391
|
+
var ABOUT_LIMITS = {
|
|
5392
|
+
sourceLength: 2e4,
|
|
5393
|
+
blocks: 300,
|
|
5394
|
+
images: 40,
|
|
5395
|
+
videos: 6,
|
|
5396
|
+
youtube: 6,
|
|
5397
|
+
buttons: 12,
|
|
5398
|
+
links: 60,
|
|
5399
|
+
headings: 40,
|
|
5400
|
+
/** Pictures and videos uploaded for one game's About. */
|
|
5401
|
+
files: 40,
|
|
5402
|
+
fileNameLength: 40,
|
|
5403
|
+
headingLength: 100,
|
|
5404
|
+
captionLength: 200,
|
|
5405
|
+
titleLength: 80,
|
|
5406
|
+
buttonLabelLength: 40,
|
|
5407
|
+
urlLength: 500,
|
|
5408
|
+
columnsMin: 2,
|
|
5409
|
+
columnsMax: 3,
|
|
5410
|
+
galleryMin: 2,
|
|
5411
|
+
galleryMax: 12,
|
|
5412
|
+
listItems: 40,
|
|
5413
|
+
tableRows: 30,
|
|
5414
|
+
tableColumns: 5,
|
|
5415
|
+
imageMinSide: 32,
|
|
5416
|
+
imageMaxSide: 4096
|
|
5417
|
+
};
|
|
5418
|
+
var BLOCK_COMMANDS = /* @__PURE__ */ new Set(["section", "subsection", "image", "video", "youtube", "button", "divider", "begin", "end", "item", "column", "head", "row"]);
|
|
5419
|
+
var INLINE_WRAPS = /* @__PURE__ */ new Set(["b", "i", "spoiler"]);
|
|
5420
|
+
var KNOWN = [...BLOCK_COMMANDS, ...INLINE_WRAPS, "link"];
|
|
5421
|
+
var ABOUT_COMMANDS = [
|
|
5422
|
+
{ group: "Text", syntax: "A blank line", does: "Starts a new paragraph." },
|
|
5423
|
+
{ group: "Text", syntax: "\\section{Title}", does: "A heading.", tool: { label: "Heading", insert: "\\section{$Title$}\n" } },
|
|
5424
|
+
{ group: "Text", syntax: "\\subsection{Title}", does: "A smaller heading." },
|
|
5425
|
+
{ group: "Text", syntax: "\\b{bold} \\i{italic}", does: "Bold and italic.", tool: { label: "Bold", insert: "\\b{$text$}" } },
|
|
5426
|
+
{ group: "Text", syntax: "\\link{text}{https://...}", does: "A link. Also /games/name or /users/name.", tool: { label: "Link", insert: "\\link{$text$}{https://}" } },
|
|
5427
|
+
{ group: "Text", syntax: "\\begin{itemize} \\item One \\end{itemize}", does: "A list. enumerate numbers it.", tool: { label: "List", insert: "\\begin{itemize}\n\\item $One$\n\\item Two\n\\end{itemize}\n" } },
|
|
5428
|
+
{ group: "Text", syntax: "\\begin{quote}[Who] ... \\end{quote}", does: "A quote. Who said it is optional." },
|
|
5429
|
+
{ group: "Text", syntax: "\\spoiler{text}", does: "Hidden until hovered or focused." },
|
|
5430
|
+
{ group: "Text", syntax: "\\\\", does: "A line break." },
|
|
5431
|
+
{ group: "Text", syntax: "% note", does: "A comment, at the start of a line: not shown." },
|
|
5432
|
+
{ group: "Text", syntax: "\\{ \\} \\% \\[ \\]", does: "The character itself." },
|
|
5433
|
+
{ group: "Media", syntax: "\\image{name}{caption}", does: "A picture you uploaded, at its own size up to the page\u2019s width. The caption is optional.", tool: { label: "Image", insert: "\\image{$name$}\n" } },
|
|
5434
|
+
{ group: "Media", syntax: "\\video{name}{caption}", does: "A video you uploaded. It plays when pressed.", tool: { label: "Video", insert: "\\video{$name$}\n" } },
|
|
5435
|
+
{ group: "Media", syntax: "\\video[loop]{name}", does: "Plays muted on a loop while in view, like a GIF." },
|
|
5436
|
+
{ group: "Media", syntax: "\\youtube{address}", does: "A YouTube video. \\youtube[name]{address} shows your picture until it\u2019s played.", tool: { label: "YouTube", insert: "\\youtube{$https://youtu.be/$}\n" } },
|
|
5437
|
+
{ group: "Media", syntax: "\\begin{gallery} \\image{a} \\image{b} \\end{gallery}", does: "Pictures side by side." },
|
|
5438
|
+
{ group: "Layout", syntax: "\\button{Label}{https://...}", does: "A button. Buttons in a row sit together.", tool: { label: "Button", insert: "\\button{$Label$}{https://}\n" } },
|
|
5439
|
+
{ group: "Layout", syntax: "\\button{Play now}{play}", does: "Plays this game." },
|
|
5440
|
+
{ group: "Layout", syntax: "\\begin{columns} \\column ... \\column ... \\end{columns}", does: "2 or 3 columns, stacked on phones." },
|
|
5441
|
+
{ group: "Layout", syntax: "\\begin{table} \\head{A}{B} \\row{1}{2} \\end{table}", does: "A table, up to 5 columns." },
|
|
5442
|
+
{ group: "Layout", syntax: "\\begin{fold}{Title} ... \\end{fold}", does: "Closed until opened." },
|
|
5443
|
+
{ group: "Layout", syntax: "\\divider", does: "A line across." }
|
|
5444
|
+
];
|
|
5445
|
+
var ABOUT_EXAMPLE = `Hold the castle until dawn. Up to eight friends, one wall, and a night that gets longer every time.
|
|
5446
|
+
|
|
5447
|
+
\\image{castle}
|
|
5448
|
+
|
|
5449
|
+
\\section{Features}
|
|
5450
|
+
|
|
5451
|
+
\\begin{itemize}
|
|
5452
|
+
\\item \\b{Build between nights.} Walls, traps and towers, rebuilt however you like.
|
|
5453
|
+
\\item \\b{Twelve bosses.} Each has a weak spot, and none of them tell you where.
|
|
5454
|
+
\\item \\b{Raid your friends.} Your castle is a level they can attack.
|
|
5455
|
+
\\end{itemize}
|
|
5456
|
+
|
|
5457
|
+
\\video[loop]{night-raid}
|
|
5458
|
+
|
|
5459
|
+
\\section{Credits}
|
|
5460
|
+
|
|
5461
|
+
Made by two people. Music by a very kind friend.
|
|
5462
|
+
|
|
5463
|
+
\\button{Join the Discord}{https://discord.gg/example}
|
|
5464
|
+
`;
|
|
5465
|
+
function aboutGuide() {
|
|
5466
|
+
const groups = ["Text", "Media", "Layout"];
|
|
5467
|
+
const L = ABOUT_LIMITS;
|
|
5468
|
+
return [
|
|
5469
|
+
"# About (OnceTeX)",
|
|
5470
|
+
"",
|
|
5471
|
+
"A game page has a short description and, above How to play, an About section the creator writes in OnceTeX, a small LaTeX-like language.",
|
|
5472
|
+
"Write it in the dashboard: the game's About tab, with a live preview. There is no HTML or CSS: the commands below are all there is. Every game's text looks the same, so the pictures do the designing.",
|
|
5473
|
+
"",
|
|
5474
|
+
...groups.flatMap((group) => [`## ${group}`, "", ...ABOUT_COMMANDS.filter((c) => c.group === group).map((c) => `- \`${c.syntax}\`: ${c.does}`), ""]),
|
|
5475
|
+
"## Rules",
|
|
5476
|
+
"",
|
|
5477
|
+
`- Pictures and videos are the game's own uploads, up to ${L.files}, used by name (\`castle.png\` is \`castle\`). Outside pictures can't be shown.`,
|
|
5478
|
+
`- Limits: ${L.sourceLength.toLocaleString("en")} characters, ${L.images} pictures, ${L.videos} videos, ${L.youtube} YouTube videos, ${L.buttons} buttons, ${L.links} links, ${L.headings} headings.`,
|
|
5479
|
+
"- Links go in `\\link` and `\\button` only: an address, an email or a handle in plain text is refused.",
|
|
5480
|
+
"- Columns hold text, media, buttons, lists and quotes.",
|
|
5481
|
+
"",
|
|
5482
|
+
"## Example",
|
|
5483
|
+
"",
|
|
5484
|
+
"```latex",
|
|
5485
|
+
ABOUT_EXAMPLE.trimEnd(),
|
|
5486
|
+
"```"
|
|
5487
|
+
].join("\n");
|
|
5488
|
+
}
|
|
5489
|
+
|
|
5350
5490
|
// ../protocol/src/listing.ts
|
|
5351
5491
|
function badgeIdMaxLength() {
|
|
5352
5492
|
let n = 1;
|
|
@@ -5404,7 +5544,13 @@ function listingTopics() {
|
|
|
5404
5544
|
"The creator can also add badges on the dashboard."
|
|
5405
5545
|
].join("\n")
|
|
5406
5546
|
};
|
|
5407
|
-
|
|
5547
|
+
const about = {
|
|
5548
|
+
name: "about",
|
|
5549
|
+
title: "About",
|
|
5550
|
+
summary: "The long section of a game page, with pictures, videos, links and buttons: written in OnceTeX in the dashboard.",
|
|
5551
|
+
text: aboutGuide()
|
|
5552
|
+
};
|
|
5553
|
+
return [genres, badges, about];
|
|
5408
5554
|
}
|
|
5409
5555
|
|
|
5410
5556
|
// src/lookup.ts
|
|
@@ -7494,13 +7640,13 @@ var LocalRoom = class {
|
|
|
7494
7640
|
const blocker = startBlocker(lobby);
|
|
7495
7641
|
if (blocker) return this.error(ws, blocker.code, blocker.message);
|
|
7496
7642
|
const countdown = clampCountdown(msg.countdown, config.countdown ?? LIMITS.matchCountdownMs);
|
|
7497
|
-
this.mrec = this.enterStarting(this.mrec, rosterOf(lobby), Date.now(), config, countdown,
|
|
7643
|
+
this.mrec = this.enterStarting(this.mrec, rosterOf(lobby), Date.now(), config, countdown, true);
|
|
7498
7644
|
this.stepMatch();
|
|
7499
7645
|
}
|
|
7500
7646
|
hostOpen(ws, me, msg, config) {
|
|
7501
7647
|
if (!this.isHost(me.id)) return this.error(ws, "not_host", "Only the host can open or close the server");
|
|
7502
|
-
const
|
|
7503
|
-
if (config.pool && !
|
|
7648
|
+
const open2 = Boolean(msg.open);
|
|
7649
|
+
if (config.pool && !open2 && this.mrec.phase === "lobby") {
|
|
7504
7650
|
if (!this.takeHostAction(me.id, "host")) return this.error(ws, "slow_down", "Too many changes at once.");
|
|
7505
7651
|
const start = closingStart(this.lobbyView(config));
|
|
7506
7652
|
if ("code" in start) return this.error(ws, start.code, start.message);
|
|
@@ -7510,11 +7656,11 @@ var LocalRoom = class {
|
|
|
7510
7656
|
this.stepMatch();
|
|
7511
7657
|
return;
|
|
7512
7658
|
}
|
|
7513
|
-
if (config.pool &&
|
|
7514
|
-
if (config.open ===
|
|
7659
|
+
if (config.pool && open2 && this.mrec.byOpen && this.mrec.phase !== "lobby") return this.hostEnd(ws, me, config);
|
|
7660
|
+
if (config.open === open2) return;
|
|
7515
7661
|
if (!this.takeHostAction(me.id, "host")) return this.error(ws, "slow_down", "Too many changes at once.");
|
|
7516
|
-
config.open =
|
|
7517
|
-
this.broadcast({ t: "open", open });
|
|
7662
|
+
config.open = open2;
|
|
7663
|
+
this.broadcast({ t: "open", open: open2 });
|
|
7518
7664
|
this.hub.changed();
|
|
7519
7665
|
}
|
|
7520
7666
|
hostEnd(ws, me, config) {
|
|
@@ -8702,7 +8848,7 @@ async function gameSources(root) {
|
|
|
8702
8848
|
return out;
|
|
8703
8849
|
}
|
|
8704
8850
|
async function usesPlatformMatch(root) {
|
|
8705
|
-
return (await gameSources(root)).some((f) => /\b(setReady|startMatch|matchNow)\s*\(|\bmatch\s*\.\s*phase\b|\blobby\s*:\s*['"](card|bar)['"]|\bFlow\s*\.\s*(rounds|turns|coop)\s*\(|\bcreateMatch\s*\(/.test(f.text));
|
|
8851
|
+
return (await gameSources(root)).some((f) => /\b(setReady|startMatch|matchNow)\s*\(|\bmatch\s*\.\s*phase\b|\blobby\s*:\s*['"](card|bar)['"]|\bminPlayers\s*:|\bFlow\s*\.\s*(rounds|turns|coop)\s*\(|\bcreateMatch\s*\(/.test(f.text));
|
|
8706
8852
|
}
|
|
8707
8853
|
async function readProbe(p) {
|
|
8708
8854
|
try {
|
|
@@ -9498,16 +9644,16 @@ function fromScript(text2, addFont) {
|
|
|
9498
9644
|
const taken = /* @__PURE__ */ new Set();
|
|
9499
9645
|
const lists = [];
|
|
9500
9646
|
for (const m of code.matchAll(/(?<![\w$])([\w$]*fonts)\s*(?::|=(?![=>]))\s*\[/gi)) {
|
|
9501
|
-
const
|
|
9502
|
-
const end = closing(code,
|
|
9647
|
+
const open2 = m.index + m[0].length - 1;
|
|
9648
|
+
const end = closing(code, open2);
|
|
9503
9649
|
if (end < 0) continue;
|
|
9504
|
-
lists.push([
|
|
9505
|
-
for (let i =
|
|
9650
|
+
lists.push([open2, end]);
|
|
9651
|
+
for (let i = open2 + 1; i < end; i++) {
|
|
9506
9652
|
const c = code[i];
|
|
9507
9653
|
if (c !== "'" && c !== '"' && c !== "`") continue;
|
|
9508
9654
|
const literal = literalAt(text2, code, i);
|
|
9509
9655
|
if (!literal) break;
|
|
9510
|
-
const before = code.slice(
|
|
9656
|
+
const before = code.slice(open2, i).trimEnd();
|
|
9511
9657
|
if ((before.endsWith("[") || before.endsWith(",")) && !files.has(i)) add(i, literal.value, void 0, literal.dynamic);
|
|
9512
9658
|
i = literal.end;
|
|
9513
9659
|
}
|
|
@@ -12201,9 +12347,10 @@ function lintAny(input) {
|
|
|
12201
12347
|
for (const { file } of scripts) {
|
|
12202
12348
|
const join18 = /\brooms\s*\.\s*join\s*\(\s*\{[^}]*\blobby\s*:\s*["'](card|bar)["'][^}]*\}/.exec(file.text);
|
|
12203
12349
|
if (!join18 || /\bownCountdown\s*:\s*true\b/.test(join18[0])) continue;
|
|
12204
|
-
const
|
|
12205
|
-
|
|
12206
|
-
|
|
12350
|
+
const own = scripts.some(
|
|
12351
|
+
({ code: text2 }) => [...text2.matchAll(/\bstartsAt\b/g)].some((m) => /fillText|textContent|innerText|innerHTML|\btext\s*\(|draw\w*\s*\(/.test(text2.slice(m.index, m.index + 240).split(/\n\s*\}/)[0]))
|
|
12352
|
+
);
|
|
12353
|
+
if (own) push(file, join18.index, "warn", "countdown", `This joins with the platform's lobby ('${join18[1]}'), which draws the 3-2-1 itself, and the game seems to draw one too (it reads match.startsAt): players see two countdowns. Drop yours.`);
|
|
12207
12354
|
}
|
|
12208
12355
|
for (const { file, code: text2 } of scripts) {
|
|
12209
12356
|
for (const m of text2.matchAll(/\b(setState|send)\s*\(/g)) {
|
|
@@ -12246,15 +12393,41 @@ function statementEnd(code, from) {
|
|
|
12246
12393
|
return Math.min(code.length, from + 2e3);
|
|
12247
12394
|
}
|
|
12248
12395
|
|
|
12396
|
+
// ../protocol/src/deploy-files.ts
|
|
12397
|
+
var MB = 1024 * 1024;
|
|
12398
|
+
var GB = 1024 * MB;
|
|
12399
|
+
var DEPLOY_FREE_LIMITS = {
|
|
12400
|
+
deployFileBytes: 500 * MB,
|
|
12401
|
+
deployTotalBytes: GB,
|
|
12402
|
+
deployBytesPerOwnerPerDay: 10 * GB
|
|
12403
|
+
};
|
|
12404
|
+
var DEPLOY_REQUEST_BYTES = 95 * MB;
|
|
12405
|
+
var DEPLOY_PART_BYTES = 50 * MB;
|
|
12406
|
+
var NOT_A_WEB_GAME = [
|
|
12407
|
+
{ pattern: "(^|/)project\\.godot$", text: "This is a Godot project, not its web export. In Godot: Project > Export > Add > Web, with Thread Support off, and name the file index.html." },
|
|
12408
|
+
{ pattern: "\\.(rpy|rpyc|rpa)$", text: "This is a Ren'Py game, not its web build. In the Ren'Py launcher: Web, then Build Web Application." },
|
|
12409
|
+
{ pattern: "(^|/)(ProjectSettings|Assets)/|(^|/)UnityPlayer\\.dll$", text: "This is a Unity project, not a web build. Build for WebGL with Compression Format: Disabled." },
|
|
12410
|
+
{ pattern: "\\.py$", text: "Python games need a web export, such as pygbag." },
|
|
12411
|
+
{ pattern: "\\.(exe|apk|aab|ipa|dmg|msi|deb|rpm|appimage|x86_64)$|\\.app/", text: "This is a program to install. Games here run in the browser: export a web version with an index.html." }
|
|
12412
|
+
];
|
|
12413
|
+
function notAWebGame(paths) {
|
|
12414
|
+
for (const { pattern: pattern2, text: text2 } of NOT_A_WEB_GAME) {
|
|
12415
|
+
const test = new RegExp(pattern2, "i");
|
|
12416
|
+
if (paths.some((path) => test.test(path))) return text2;
|
|
12417
|
+
}
|
|
12418
|
+
return null;
|
|
12419
|
+
}
|
|
12420
|
+
|
|
12249
12421
|
// ../protocol/src/billing.ts
|
|
12250
12422
|
var PRO_LIMITS = {
|
|
12251
12423
|
gamesPerOwner: 250,
|
|
12252
12424
|
gamesCreatedPerCreatorPerDay: 20,
|
|
12253
12425
|
deployFiles: 1e4,
|
|
12254
|
-
|
|
12426
|
+
deployFileBytes: 2 * 1024 * 1024 * 1024,
|
|
12427
|
+
deployTotalBytes: 5 * 1024 * 1024 * 1024,
|
|
12255
12428
|
deploysPerOwnerPerDay: 1e3,
|
|
12256
12429
|
draftsPerOwnerPerDay: 5e3,
|
|
12257
|
-
deployBytesPerOwnerPerDay:
|
|
12430
|
+
deployBytesPerOwnerPerDay: 100 * 1024 * 1024 * 1024,
|
|
12258
12431
|
deployEntriesPerOwnerPerDay: 5e5,
|
|
12259
12432
|
deploymentsKeptPerGame: 50,
|
|
12260
12433
|
mediaUploadsPerDay: 1e3,
|
|
@@ -12274,8 +12447,9 @@ var MARKET_FREE_LIMITS = {
|
|
|
12274
12447
|
itemFiles: 50,
|
|
12275
12448
|
itemUploadBytesPerDay: 10 * 1024 * 1024 * 1024
|
|
12276
12449
|
};
|
|
12450
|
+
var FREE_OUTSIDE_LIMITS = { ...MARKET_FREE_LIMITS, ...DEPLOY_FREE_LIMITS };
|
|
12277
12451
|
var FREE_LIMITS = Object.fromEntries(
|
|
12278
|
-
Object.keys(PRO_LIMITS).map((key) => [key,
|
|
12452
|
+
Object.keys(PRO_LIMITS).map((key) => [key, FREE_OUTSIDE_LIMITS[key] ?? LIMITS[key]])
|
|
12279
12453
|
);
|
|
12280
12454
|
|
|
12281
12455
|
// ../protocol/src/config.ts
|
|
@@ -12298,12 +12472,15 @@ function checkProjectConfig(config) {
|
|
|
12298
12472
|
return out;
|
|
12299
12473
|
}
|
|
12300
12474
|
const c = config;
|
|
12301
|
-
if (typeof c.slug
|
|
12475
|
+
if (typeof c.slug === "string" && isReservedSlug(c.slug, false)) {
|
|
12476
|
+
error("slug", `The link "${c.slug}" in onceworlds.json isn't available.`, `Pick another link, like "star-party": it is the game's address, onceworlds.com/play/<slug>.`);
|
|
12477
|
+
} else if (typeof c.slug !== "string" || !isValidSlug(c.slug)) {
|
|
12302
12478
|
error("slug", `"slug" in onceworlds.json must be 1-40 lowercase letters, digits or single dashes (got ${JSON.stringify(c.slug)}).`, `Pick a link like "star-party": it is the game's address, onceworlds.com/play/<slug>.`);
|
|
12303
12479
|
}
|
|
12304
12480
|
const name = typeof c.name === "string" ? cleanLine(c.name) : "";
|
|
12305
12481
|
if (!name) error("name", '"name" is required in onceworlds.json.', 'Give the game its title: "name": "Star Party".');
|
|
12306
12482
|
else if (name.length > LIMITS.gameNameLength) error("name", `"name" in onceworlds.json can be at most ${LIMITS.gameNameLength} characters (it has ${name.length}).`, "Shorten the title.");
|
|
12483
|
+
else if (isReservedGameName(name)) note("name", `A game can't take the name "${name}".`, "Give it a title of its own.");
|
|
12307
12484
|
if (c.description !== void 0 && typeof c.description !== "string") {
|
|
12308
12485
|
error("description", '"description" in onceworlds.json must be text.', "Write one or two sentences about the game.");
|
|
12309
12486
|
} else if (typeof c.description === "string" && c.description.trim().length > DESCRIPTION_SHOWN) {
|
|
@@ -12369,15 +12546,8 @@ function checkProjectConfig(config) {
|
|
|
12369
12546
|
return out;
|
|
12370
12547
|
}
|
|
12371
12548
|
|
|
12372
|
-
// ../protocol/src/deploy-files.ts
|
|
12373
|
-
var LARGE_FILE_EXTENSIONS = ["wasm", "pck", "data", "zip"];
|
|
12374
|
-
var DEPLOY_LARGE_FILE_BYTES = 95 * 1024 * 1024;
|
|
12375
|
-
function deployFileLimit(path) {
|
|
12376
|
-
return LARGE_FILE_EXTENSIONS.includes(extensionOf(path)) ? DEPLOY_LARGE_FILE_BYTES : LIMITS.deployFileBytes;
|
|
12377
|
-
}
|
|
12378
|
-
|
|
12379
12549
|
// src/index.ts
|
|
12380
|
-
var CLI_VERSION = "0.
|
|
12550
|
+
var CLI_VERSION = "0.14.0";
|
|
12381
12551
|
var PRODUCTION_API = "https://onceworlds.com";
|
|
12382
12552
|
var LOCAL_API = "http://localhost:8787";
|
|
12383
12553
|
var USAGE = `Usage:
|
|
@@ -12385,7 +12555,7 @@ var USAGE = `Usage:
|
|
|
12385
12555
|
onceworlds logout Forget this computer's login
|
|
12386
12556
|
onceworlds whoami Who you're logged in as
|
|
12387
12557
|
onceworlds mcp Run as an MCP server, so Claude Code, Codex and other AI tools can build games
|
|
12388
|
-
on your account (they push drafts; only you publish, on onceworlds.com/
|
|
12558
|
+
on your account (they push drafts; only you publish, on onceworlds.com/library)
|
|
12389
12559
|
onceworlds new [dir] Start a game on the web platform and the SDK: index.html, game.js on the stack you pick
|
|
12390
12560
|
(--stack canvas, three, pixi or phaser, from a CDN; canvas by default), rules.js with tests
|
|
12391
12561
|
that run in Node, DESIGN.md to write first, the platform guide as AGENTS.md and the deploy
|
|
@@ -12397,7 +12567,7 @@ var USAGE = `Usage:
|
|
|
12397
12567
|
onceworlds init [project-dir] Set a game folder up for Onceworlds: writes onceworlds.json, the GitHub workflow
|
|
12398
12568
|
that publishes every push, and AGENTS.md and CLAUDE.md for AI coding tools
|
|
12399
12569
|
onceworlds deploy [project-dir] Upload the game (to ${PRODUCTION_API}): from GitHub Actions it goes live; after login
|
|
12400
|
-
it is a draft you play and publish
|
|
12570
|
+
it is a draft you play and publish from onceworlds.com/library
|
|
12401
12571
|
onceworlds dev [project-dir] Deploy to a local platform (${LOCAL_API}), then redeploy on every change
|
|
12402
12572
|
onceworlds serve [project-dir] Play any game in your own browser on this computer, offline, with no login: a local platform
|
|
12403
12573
|
(the play page, rooms, lobby and SDK, saves and badges in memory) at http://127.0.0.1:8788/.
|
|
@@ -12582,7 +12752,8 @@ async function deploy(projectDir, api2, tokenArg, quiet = false, dev2 = false) {
|
|
|
12582
12752
|
for (const path of skipped) console.warn(` ${path}`);
|
|
12583
12753
|
}
|
|
12584
12754
|
if (!files.some((f) => f.path === "index.html")) {
|
|
12585
|
-
|
|
12755
|
+
const hint = notAWebGame([...files.map((f) => f.path), ...skipped]);
|
|
12756
|
+
throw new CliError(`No index.html in ${root}. ${hint ? `${hint} Then set "dir" in onceworlds.json to the folder it makes.` : 'Set "dir" in onceworlds.json to the folder that contains it.'}`);
|
|
12586
12757
|
}
|
|
12587
12758
|
const sources = await readSources(files);
|
|
12588
12759
|
const unnamed = config.engine ? void 0 : sources.find((f) => engineImportAt(f.path, f.text) >= 0);
|
|
@@ -12619,8 +12790,9 @@ async function deploy(projectDir, api2, tokenArg, quiet = false, dev2 = false) {
|
|
|
12619
12790
|
console.log(` \u2191 uploading ${uploads.length} changed file(s), ${formatBytes(uploads.reduce((s, f) => s + f.size, 0))}`);
|
|
12620
12791
|
}
|
|
12621
12792
|
await runPool(uploads, 6, async (file) => {
|
|
12622
|
-
const
|
|
12623
|
-
|
|
12793
|
+
const url = `${api2}/api/deploy/${start.deploymentId}/blobs/${file.hash}`;
|
|
12794
|
+
if (file.size > DEPLOY_REQUEST_BYTES) return sendInParts(url, start.uploadToken, file);
|
|
12795
|
+
await send("PUT", url, start.uploadToken, await readFile9(file.abs));
|
|
12624
12796
|
});
|
|
12625
12797
|
const result = await send("POST", `${api2}/api/deploy/${start.deploymentId}/finalize`, start.uploadToken, {});
|
|
12626
12798
|
if (!quiet) {
|
|
@@ -12888,8 +13060,8 @@ Next:
|
|
|
12888
13060
|
npm test (the tests of rules.js: node --test, nothing to install)
|
|
12889
13061
|
onceworlds check (common mistakes; before every deploy)
|
|
12890
13062
|
onceworlds shot --story (its first minute, as a new player sees it)
|
|
12891
|
-
onceworlds login, then onceworlds deploy (a draft you play and publish
|
|
12892
|
-
To publish from a repo: push it to GitHub and import it at ${api2}/
|
|
13063
|
+
onceworlds login, then onceworlds deploy (a draft you play and publish from ${api2}/library)
|
|
13064
|
+
To publish from a repo: push it to GitHub and import it at ${api2}/upload?new=github (its workflow deploys every push).
|
|
12893
13065
|
Read AGENTS.md: the platform guide, for you and your AI tool. (onceworlds is npx @onceworlds/cli.)`);
|
|
12894
13066
|
return;
|
|
12895
13067
|
}
|
|
@@ -13018,6 +13190,11 @@ async function loadStore(projectDir, config) {
|
|
|
13018
13190
|
if (guide.help) store.help = guide.help;
|
|
13019
13191
|
return { store, images: [...images.values()] };
|
|
13020
13192
|
}
|
|
13193
|
+
async function hashFile(abs) {
|
|
13194
|
+
const hash2 = createHash5("sha256");
|
|
13195
|
+
for await (const chunk2 of createReadStream(abs)) hash2.update(chunk2);
|
|
13196
|
+
return hash2.digest("hex");
|
|
13197
|
+
}
|
|
13021
13198
|
async function collectFiles(root) {
|
|
13022
13199
|
const files = [];
|
|
13023
13200
|
const skipped = [];
|
|
@@ -13040,11 +13217,11 @@ async function collectFiles(root) {
|
|
|
13040
13217
|
if (!QUIET_EXTENSIONS.has(extensionOf(path))) skipped.push(path);
|
|
13041
13218
|
continue;
|
|
13042
13219
|
}
|
|
13043
|
-
const
|
|
13044
|
-
if (
|
|
13045
|
-
throw new CliError(`${path} is ${formatBytes(
|
|
13220
|
+
const { size } = await stat2(abs);
|
|
13221
|
+
if (size > PRO_LIMITS.deployFileBytes) {
|
|
13222
|
+
throw new CliError(`${path} is ${formatBytes(size)}; a file can be at most ${formatBytes(PRO_LIMITS.deployFileBytes)}.`);
|
|
13046
13223
|
}
|
|
13047
|
-
files.push({ path, abs, hash:
|
|
13224
|
+
files.push({ path, abs, hash: await hashFile(abs), size });
|
|
13048
13225
|
}
|
|
13049
13226
|
}
|
|
13050
13227
|
}
|
|
@@ -13291,7 +13468,33 @@ function mcpHost(api2) {
|
|
|
13291
13468
|
token: async () => (await readCredential(api2))?.token ?? null
|
|
13292
13469
|
};
|
|
13293
13470
|
}
|
|
13294
|
-
async function
|
|
13471
|
+
async function sendInParts(url, token, file) {
|
|
13472
|
+
const { uploadId, partBytes, parts } = await send("POST", `${url}/parts`, token, void 0);
|
|
13473
|
+
const etags = new Array(parts);
|
|
13474
|
+
const handle = await open(file.abs);
|
|
13475
|
+
try {
|
|
13476
|
+
await runPool(Array.from({ length: parts }, (_, i) => i), 3, async (i) => {
|
|
13477
|
+
const data = Buffer.alloc(Math.min(partBytes, file.size - i * partBytes));
|
|
13478
|
+
await handle.read(data, 0, data.byteLength, i * partBytes);
|
|
13479
|
+
const stored = await retrying(() => send("PUT", `${url}/parts/${i + 1}`, token, data, { "X-Upload-Id": uploadId }));
|
|
13480
|
+
etags[i] = stored.etag;
|
|
13481
|
+
});
|
|
13482
|
+
} finally {
|
|
13483
|
+
await handle.close();
|
|
13484
|
+
}
|
|
13485
|
+
await retrying(() => send("POST", `${url}/complete`, token, { uploadId, etags }));
|
|
13486
|
+
}
|
|
13487
|
+
async function retrying(request) {
|
|
13488
|
+
for (let attempt = 1; ; attempt++) {
|
|
13489
|
+
try {
|
|
13490
|
+
return await request();
|
|
13491
|
+
} catch (err) {
|
|
13492
|
+
if (attempt === 3) throw err;
|
|
13493
|
+
await new Promise((done) => setTimeout(done, attempt * 2e3));
|
|
13494
|
+
}
|
|
13495
|
+
}
|
|
13496
|
+
}
|
|
13497
|
+
async function send(method, url, token, body, headers = {}) {
|
|
13295
13498
|
const binary = body instanceof Uint8Array;
|
|
13296
13499
|
let res;
|
|
13297
13500
|
try {
|
|
@@ -13299,7 +13502,8 @@ async function send(method, url, token, body) {
|
|
|
13299
13502
|
method,
|
|
13300
13503
|
headers: {
|
|
13301
13504
|
Authorization: `Bearer ${token}`,
|
|
13302
|
-
...body === void 0 ? {} : { "Content-Type": binary ? "application/octet-stream" : "application/json" }
|
|
13505
|
+
...body === void 0 ? {} : { "Content-Type": binary ? "application/octet-stream" : "application/json" },
|
|
13506
|
+
...headers
|
|
13303
13507
|
},
|
|
13304
13508
|
body: body === void 0 ? void 0 : binary ? body : JSON.stringify(body)
|
|
13305
13509
|
});
|