@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/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 open = html.indexOf(">", script);
842
- const close = open === -1 ? -1 : find(closing2, open);
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, open + 1), body: html.slice(open + 1, close), at: open + 1 });
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: 'card' }) (see AGENTS.md, "Matches").`
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, open) {
1613
+ function objectAt(text2, open2) {
1574
1614
  let depth = 0;
1575
1615
  let quote = "";
1576
- for (let i = open; i < text2.length; 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(open, i + 1);
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, open) {
2288
- const end = closing(code, open);
2289
- return end < 0 ? null : { text: file.text.slice(open, end + 1), code: code.slice(open, end + 1), from: open };
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 open = (m.index ?? 0) + m[0].length - 1;
2378
- ui = { file, code, at: m.index ?? 0, options: m[1] ? objectAt2(file, code, open) : null };
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 open = (m.index ?? 0) + m[0].length - m[1].length - 1;
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(open, end);
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, open) {
2637
+ function closing(code, open2) {
2598
2638
  const pairs = { "(": ")", "[": "]", "{": "}" };
2599
2639
  const stack = [];
2600
- for (let i = open; i < code.length; 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 open = (m.index ?? 0) + m[0].length - 1;
2681
- const end = closing(code, open);
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, open, end, "name") ?? which;
2724
+ const name = stringProp(text2, code, open2, end, "name") ?? which;
2685
2725
  if (which === "defineSystem" || which === "defineBotSystem") {
2686
- const stage = stringProp(text2, code, open, end, "stage") ?? (which === "defineBotSystem" ? "fixed" : "update");
2687
- const host = which === "defineBotSystem" || stringProp(text2, code, open, end, "authority") === "host";
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, open, end, fn);
2690
- if (body) out.push({ from: body[0], to: body[1], kind: "system", name, stage, host, defFrom: open, defTo: end });
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, open, end, hook);
2695
- if (body) out.push({ from: body[0], to: body[1], kind: "script", name: hook, stage: hook === "fixed" ? "fixed" : "update", host: stringProp(text2, code, open, end, "authority") === "host", defFrom: open, defTo: end });
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 = open;
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(open, enclosing(code, r.index)), end, rule);
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: open, defTo: end });
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 open = code.indexOf("(", m.index ?? 0);
2751
- const end = closing(code, open);
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(open, end))) hostComponents.add(m[1]);
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 open = code.indexOf("{", m.index ?? 0);
2811
- const end = open < 0 ? -1 : closing(code, open);
2812
- const body = end < 0 ? "" : code.slice(open, end);
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 open = (m.index ?? 0) + m[0].length - 1;
2818
- const end = m[3] ? closing(code, open) : open;
2819
- const options = m[3] && end > 0 ? code.slice(open, end) : "";
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}/create?new=github, import the repo, and every push publishes your game.`);
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}/create?new=github: \`.github/workflows/onceworlds.yml\` deploys every push.
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
- return [genres, badges];
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, msg.open === true);
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 open = Boolean(msg.open);
7503
- if (config.pool && !open && this.mrec.phase === "lobby") {
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 && open && this.mrec.byOpen && this.mrec.phase !== "lobby") return this.hostEnd(ws, me, config);
7514
- if (config.open === open) return;
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 = 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 open = m.index + m[0].length - 1;
9502
- const end = closing(code, open);
9647
+ const open2 = m.index + m[0].length - 1;
9648
+ const end = closing(code, open2);
9503
9649
  if (end < 0) continue;
9504
- lists.push([open, end]);
9505
- for (let i = open + 1; i < end; 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(open, i).trimEnd();
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 all = scripts.map((s) => s.code).join("\n");
12205
- const own = /\bstartsAt\b/.exec(all) && /countdown|count_down|\b3\s*,\s*2\s*,\s*1\b/i.test(all);
12206
- 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. Say ownCountdown: true in rooms.join to draw yours alone, or drop yours.`);
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
- deployTotalBytes: 500 * 1024 * 1024,
12426
+ deployFileBytes: 2 * 1024 * 1024 * 1024,
12427
+ deployTotalBytes: 5 * 1024 * 1024 * 1024,
12255
12428
  deploysPerOwnerPerDay: 1e3,
12256
12429
  draftsPerOwnerPerDay: 5e3,
12257
- deployBytesPerOwnerPerDay: 10 * 1024 * 1024 * 1024,
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, key in MARKET_FREE_LIMITS ? MARKET_FREE_LIMITS[key] : LIMITS[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 !== "string" || !isValidSlug(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.12.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/create)
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 on onceworlds.com/create
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
- throw new CliError(`No index.html in ${root}. Set "dir" in onceworlds.json to the folder that contains it.`);
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 body = await readFile9(file.abs);
12623
- await send("PUT", `${api2}/api/deploy/${start.deploymentId}/blobs/${file.hash}`, start.uploadToken, body);
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 on ${api2}/create)
12892
- To publish from a repo: push it to GitHub and import it at ${api2}/create?new=github (its workflow deploys every push).
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 data = await readFile9(abs);
13044
- if (data.byteLength > deployFileLimit(path)) {
13045
- throw new CliError(`${path} is ${formatBytes(data.byteLength)}; files like it can be at most ${formatBytes(deployFileLimit(path))}.`);
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: createHash5("sha256").update(data).digest("hex"), size: data.byteLength });
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 send(method, url, token, body) {
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
  });