evolutionary-arcade 0.2.1 → 0.4.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
@@ -13,7 +13,7 @@ import { dirname, isAbsolute, join } from "path";
13
13
  // package.json
14
14
  var package_default = {
15
15
  name: "evolutionary-arcade",
16
- version: "0.2.1",
16
+ version: "0.4.0",
17
17
  description: "The arcade CLI for Evolutionary Arcade. Publish, update, regen, fork, and blend AI-made browser games with your own coding agent.",
18
18
  type: "module",
19
19
  license: "MIT",
@@ -5693,7 +5693,7 @@ var recursive = /* @__PURE__ */ new WeakMap();
5693
5693
  var NONE = 0;
5694
5694
  var ASSUMED = 1;
5695
5695
  var PROVEN = 2;
5696
- function isRecursive(inst, stack, resolve5) {
5696
+ function isRecursive(inst, stack, resolve6) {
5697
5697
  const cached2 = recursive.get(inst);
5698
5698
  if (cached2 !== void 0)
5699
5699
  return cached2 ? PROVEN : NONE;
@@ -5703,7 +5703,7 @@ function isRecursive(inst, stack, resolve5) {
5703
5703
  let result = NONE;
5704
5704
  const check2 = (child) => {
5705
5705
  if (result !== PROVEN && child?._zod) {
5706
- const answer = isRecursive(child, stack, resolve5);
5706
+ const answer = isRecursive(child, stack, resolve6);
5707
5707
  if (answer > result)
5708
5708
  result = answer;
5709
5709
  }
@@ -5714,7 +5714,7 @@ function isRecursive(inst, stack, resolve5) {
5714
5714
  const desc = Object.getOwnPropertyDescriptor(sh, key);
5715
5715
  if (spread && !desc.enumerable)
5716
5716
  continue;
5717
- const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve5) : NONE;
5717
+ const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve6) : NONE;
5718
5718
  if (child > answer)
5719
5719
  answer = child;
5720
5720
  }
@@ -5778,7 +5778,7 @@ function isRecursive(inst, stack, resolve5) {
5778
5778
  break;
5779
5779
  // `$ZodLazy` caches its inner on the def, so a resolved edge is followed exactly
5780
5780
  case "lazy": {
5781
- const inner = def._cachedInner ?? (resolve5 ? inst._zod.innerType : void 0);
5781
+ const inner = def._cachedInner ?? (resolve6 ? inst._zod.innerType : void 0);
5782
5782
  merge2(inner ? isRecursive(inner, stack, false) : ASSUMED);
5783
5783
  break;
5784
5784
  }
@@ -20592,6 +20592,14 @@ var videoPath = relPath.refine(
20592
20592
  (p) => ["mp4", "webm"].includes(ext(p)),
20593
20593
  "Use an .mp4 or .webm video."
20594
20594
  );
20595
+ var ORIENTATIONS = ["landscape", "portrait", "both"];
20596
+ var verticalMediaSchema = external_exports.object({
20597
+ thumbnail: imagePath.optional(),
20598
+ screenshots: external_exports.array(imagePath).max(12).default([]),
20599
+ demo_video: videoPath.optional(),
20600
+ preview_video: videoPath.optional(),
20601
+ preview_gif: gifPath.optional()
20602
+ });
20595
20603
  var sourceSchema = external_exports.object({
20596
20604
  repo: external_exports.string().regex(
20597
20605
  /^https:\/\/github\.com\/[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]{1,100}$/,
@@ -20638,6 +20646,8 @@ var arcadeSchema = external_exports.object({
20638
20646
  // play: "someone remixed your game" emails and link previews. The CLI requires it whenever
20639
20647
  // there's a demo.
20640
20648
  preview_gif: gifPath.optional(),
20649
+ orientation: external_exports.enum(ORIENTATIONS).default("landscape"),
20650
+ media_vertical: verticalMediaSchema.optional(),
20641
20651
  lineage: lineageSchema.default({ kind: "original", parents: [] }),
20642
20652
  provenance: provenanceSchema.default({ subagent_models: [] }),
20643
20653
  source: sourceSchema.optional()
@@ -20649,6 +20659,20 @@ var arcadeSchema = external_exports.object({
20649
20659
  message: "A game can't be its own parent."
20650
20660
  });
20651
20661
  }
20662
+ if (a.orientation !== "landscape" && a.input.touch) {
20663
+ const v = a.media_vertical;
20664
+ const missing = [
20665
+ !v?.thumbnail && "a thumbnail",
20666
+ !v?.screenshots.length && "at least one screenshot",
20667
+ a.demo_video && !v?.demo_video && "a demo"
20668
+ ].filter((x) => typeof x === "string");
20669
+ if (missing.length)
20670
+ ctx.addIssue({
20671
+ code: "custom",
20672
+ path: ["media_vertical"],
20673
+ message: `A ${a.orientation === "both" ? "game that also plays upright" : "portrait game"} needs vertical media from real play at 390x844: ${missing.join(", ")}.`
20674
+ });
20675
+ }
20652
20676
  const ids = a.leaderboards.map((b) => b.id);
20653
20677
  ids.forEach((id, i) => {
20654
20678
  if (ids.indexOf(id) !== i)
@@ -20695,6 +20719,11 @@ var friendlyIssue = (iss) => {
20695
20719
  if (limit) return `Use up to ${limit} screenshots.`;
20696
20720
  if (missing) return "Add at least one screenshot.";
20697
20721
  break;
20722
+ case "orientation":
20723
+ return 'Use "landscape", "portrait", or "both": the way the game plays on a phone.';
20724
+ case "media_vertical.screenshots":
20725
+ if (limit) return `Use up to ${limit} vertical screenshots.`;
20726
+ break;
20698
20727
  case "lineage.kind":
20699
20728
  return "lineage.kind was changed; re-run the arcade command that made this folder.";
20700
20729
  case "lineage.parents":
@@ -21023,6 +21052,68 @@ function normalizeModel(raw) {
21023
21052
  return { name: raw.trim(), family, listed: false };
21024
21053
  }
21025
21054
 
21055
+ // ../format/src/shareText.ts
21056
+ var ARCADE_NAME = "Evolutionary Arcade";
21057
+ var gameUrl = (origin, slug) => `${origin}/g/${slug}`;
21058
+ var profileUrl = (origin, handle) => `${origin}/u/${handle}`;
21059
+ var creditName = (c) => c.x ? `@${c.x.replace(/^@/, "")}` : c.handle;
21060
+ var X_LIMIT = 280;
21061
+ var X_LINK = 23;
21062
+ function xLength(text) {
21063
+ return [...text.replace(/https?:\/\/\S+/g, "x".repeat(X_LINK))].length;
21064
+ }
21065
+ var xIntentUrl = (text) => `https://x.com/intent/post?text=${encodeURIComponent(text)}`;
21066
+ var listOf = (items) => items.length <= 2 ? items.join(" and ") : `${items.slice(0, -1).join(", ")}, and ${items[items.length - 1]}`;
21067
+ function fit(build) {
21068
+ for (let max = 80; max >= 8; max -= 4) {
21069
+ const text = build((t) => t.length > max ? `${t.slice(0, max - 1).trimEnd()}\u2026` : t);
21070
+ if (xLength(text) <= X_LIMIT) return text;
21071
+ }
21072
+ return build((t) => t.slice(0, 8));
21073
+ }
21074
+ function descriptionLine(game) {
21075
+ return `Play ${game.title} in your browser (and remix it): ${gameUrl(game.origin, game.slug)}`;
21076
+ }
21077
+ function publishShare(game) {
21078
+ const url2 = gameUrl(game.origin, game.slug);
21079
+ if (game.version && game.version > 1)
21080
+ return fit(
21081
+ (cut) => `${cut(game.title)} v${game.version} is live on ${ARCADE_NAME}. Play it in your browser, then remix it: ${url2}`
21082
+ );
21083
+ return fit(
21084
+ (cut) => `I made ${cut(game.title)}, a browser game you can play right now. It's open source, so you can remix it too: ${url2}`
21085
+ );
21086
+ }
21087
+ function remixShare(game) {
21088
+ const url2 = gameUrl(game.origin, game.slug);
21089
+ const parents = game.parents ?? [];
21090
+ if (!parents.length) return publishShare(game);
21091
+ return fit((cut) => {
21092
+ const credits = parents.map((p) => `${cut(p.title)} by ${creditName(p)}`);
21093
+ return game.kind === "blend" ? `I blended ${listOf(credits)} into ${cut(game.title)}. Play it in your browser: ${url2}` : `I remixed ${credits[0]} into ${cut(game.title)}. Play it in your browser: ${url2}`;
21094
+ });
21095
+ }
21096
+ function libraryShare(opts) {
21097
+ const n = opts.games === 1 ? "a game" : `${opts.games} games`;
21098
+ return `I put ${n} on ${ARCADE_NAME}. Play in your browser, no account needed, and remix anything: ${profileUrl(opts.origin, opts.handle)}`;
21099
+ }
21100
+ var libraryDescriptionLine = (origin, handle) => `Play my games (and remix them): ${profileUrl(origin, handle)}`;
21101
+ var shareDraft = (game) => game.kind === "fork" || game.kind === "blend" ? remixShare(game) : publishShare(game);
21102
+ function badgeUrls(t, theme = "light") {
21103
+ const path = t.slug ? `/badge/${t.slug}.svg` : `/badge/u/${t.handle}.svg`;
21104
+ const link = t.slug ? gameUrl(t.origin, t.slug) : profileUrl(t.origin, t.handle ?? "");
21105
+ return { image: `${t.origin}${path}${theme === "dark" ? "?theme=dark" : ""}`, link };
21106
+ }
21107
+ var BADGE_ALT = `Play on ${ARCADE_NAME}`;
21108
+ function badgeMarkdown(t, theme = "light") {
21109
+ const { image, link } = badgeUrls(t, theme);
21110
+ return `[![${BADGE_ALT}](${image})](${link})`;
21111
+ }
21112
+ function badgeHtml(t, theme = "light") {
21113
+ const { image, link } = badgeUrls(t, theme);
21114
+ return `<a href="${link}"><img src="${image}" alt="${BADGE_ALT}" height="36"></a>`;
21115
+ }
21116
+
21026
21117
  // src/commands/games.ts
21027
21118
  var STARTER_DESCRIPTION = "What a player reads on the card. One or two sentences.";
21028
21119
  var STARTER_CONTROLS = "WASD move, mouse aim, click to act";
@@ -21136,7 +21227,9 @@ function derived2(src, overrides) {
21136
21227
  "screenshots",
21137
21228
  "demo_video",
21138
21229
  "preview_video",
21139
- "preview_gif"
21230
+ "preview_gif",
21231
+ "orientation",
21232
+ "media_vertical"
21140
21233
  ];
21141
21234
  const out2 = { schema: "arcade/v0" };
21142
21235
  for (const k of keep) if (a[k] !== void 0) out2[k] = a[k];
@@ -21438,8 +21531,8 @@ function dev(dirArg, opts) {
21438
21531
 
21439
21532
  // src/commands/publish.ts
21440
21533
  import { execFileSync } from "child_process";
21441
- import { existsSync as existsSync5, readFileSync as readFileSync7, statSync as statSync5, writeFileSync as writeFileSync3 } from "fs";
21442
- import { isAbsolute as isAbsolute2, join as join5, relative as relative2, resolve as resolve3, sep as sep4 } from "path";
21534
+ import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync8, statSync as statSync5, writeFileSync as writeFileSync3 } from "fs";
21535
+ import { dirname as dirname3, isAbsolute as isAbsolute2, join as join6, relative as relative2, resolve as resolve4, sep as sep4 } from "path";
21443
21536
 
21444
21537
  // src/files.ts
21445
21538
  import { createHash as createHash2 } from "crypto";
@@ -21523,6 +21616,7 @@ function walkGame(root) {
21523
21616
 
21524
21617
  // src/fixes.ts
21525
21618
  var CAPTURE_GUIDE = 'arcade guide publishing, "Capturing media"';
21619
+ var VERTICAL = "media/vertical";
21526
21620
  var thumbnailFix = (path) => `Run the game (arcade dev), capture a 1280x720 frame of real play, mid-action with no title screen or added text, and save it as ${path}.`;
21527
21621
  var screenshotFix = (path) => `Capture a different moment of real play at 1280x720 and save it as ${path}. Aim for 2 to 4 screenshots in all.`;
21528
21622
  var demoFix = (path) => `Record 15 to 30 s of real play at 1280x720 as H.264 and save it as ${path}, with the action starting in the first second (${CAPTURE_GUIDE}).`;
@@ -21552,6 +21646,10 @@ function fixFor(issue2, raw) {
21552
21646
  return `${screenshotFix("media/shot-1.png")} List the paths in "screenshots".`;
21553
21647
  if (path === "demo_video") return `${demoFix("media/demo.mp4")} Then set "demo_video".`;
21554
21648
  if (path === "preview_video" || path === "preview_gif") return fromDemoFix;
21649
+ if (path === "orientation")
21650
+ return 'Play the game on a phone (390x844 with touch): "landscape" if it needs the phone sideways, "portrait" if it plays upright, "both" if it works either way.';
21651
+ if (path === "media_vertical" || path.startsWith("media_vertical."))
21652
+ return `Run the game at 390x844 with touch emulation and capture upright play into ${VERTICAL}/ (thumbnail.png, shot-1.png, and demo.mp4 if the game has a landscape demo), list them under "media_vertical", then run \`arcade media vertical\` for the preview and GIF (${CAPTURE_GUIDE}). If the game really only plays sideways, set "orientation": "landscape" instead.`;
21555
21653
  if (path === "title") return `Set "title" to the game's name as players should see it.`;
21556
21654
  if (path === "description")
21557
21655
  return 'Set "description" to one or two sentences: the fantasy and the goal.';
@@ -21567,6 +21665,10 @@ function missingMediaLine(path, raw) {
21567
21665
  if (raw.demo_video === path) return `${path} (demo): ${demoFix(path)}`;
21568
21666
  if (raw.preview_video === path) return `${path} (hover preview): ${fromDemoFix}`;
21569
21667
  if (raw.preview_gif === path) return `${path} (GIF): ${fromDemoFix}`;
21668
+ const v = raw.media_vertical ?? {};
21669
+ const vertical = [v.thumbnail, v.demo_video, v.preview_video, v.preview_gif].includes(path) || Array.isArray(v.screenshots) && v.screenshots.includes(path);
21670
+ if (vertical)
21671
+ return `${path} (vertical): ${raw.orientation === "portrait" || raw.orientation === "both" ? `capture it from real play at 390x844 with touch; previews and GIFs come from \`arcade media vertical\`.` : "run `arcade media vertical` to make the vertical set from the landscape media."}`;
21570
21672
  return path;
21571
21673
  }
21572
21674
 
@@ -21731,10 +21833,203 @@ function missingSessions(named) {
21731
21833
  return named.filter((id) => !found.has(id));
21732
21834
  }
21733
21835
 
21836
+ // src/commands/share.ts
21837
+ import { existsSync as existsSync5, readFileSync as readFileSync7 } from "fs";
21838
+ import { homedir as homedir3 } from "os";
21839
+ import { join as join5, resolve as resolve3 } from "path";
21840
+ var X_HOSTS = /* @__PURE__ */ new Set([
21841
+ "x.com",
21842
+ "www.x.com",
21843
+ "twitter.com",
21844
+ "www.twitter.com",
21845
+ "mobile.twitter.com"
21846
+ ]);
21847
+ var X_RESERVED = /* @__PURE__ */ new Set([
21848
+ "intent",
21849
+ "home",
21850
+ "i",
21851
+ "share",
21852
+ "search",
21853
+ "explore",
21854
+ "hashtag",
21855
+ "settings"
21856
+ ]);
21857
+ function xHandleFromLinks(links) {
21858
+ for (const l of links) {
21859
+ try {
21860
+ const u = new URL(l.url);
21861
+ const name = u.pathname.split("/").filter(Boolean)[0];
21862
+ if (X_HOSTS.has(u.hostname) && name && !X_RESERVED.has(name.toLowerCase()) && /^[A-Za-z0-9_]{1,15}$/.test(name))
21863
+ return name;
21864
+ } catch {
21865
+ }
21866
+ }
21867
+ return null;
21868
+ }
21869
+ async function xHandle(handle) {
21870
+ try {
21871
+ const p = await request(`/api/users/${encodeURIComponent(handle)}`, {
21872
+ auth: false
21873
+ });
21874
+ return xHandleFromLinks(p.user.links ?? []);
21875
+ } catch {
21876
+ return null;
21877
+ }
21878
+ }
21879
+ var badges = (t) => ({
21880
+ markdown: badgeMarkdown(t),
21881
+ html: badgeHtml(t),
21882
+ markdownDark: badgeMarkdown(t, "dark"),
21883
+ htmlDark: badgeHtml(t, "dark")
21884
+ });
21885
+ function findGif(slug, dir, hosted) {
21886
+ const folders = [dir, process.cwd(), join5(homedir3(), "arcade-games", slug)].filter(
21887
+ (d) => Boolean(d)
21888
+ );
21889
+ for (const folder of folders) {
21890
+ try {
21891
+ const raw = JSON.parse(readFileSync7(join5(folder, "arcade.json"), "utf8"));
21892
+ if (raw.slug !== slug || typeof raw.preview_gif !== "string") continue;
21893
+ const path = resolve3(folder, raw.preview_gif);
21894
+ if (existsSync5(path)) return path;
21895
+ } catch {
21896
+ }
21897
+ }
21898
+ return hosted ? new URL(hosted, apiUrl()).href : null;
21899
+ }
21900
+ async function gameShare(slug, opts = {}) {
21901
+ const d = await fetchGame(slug);
21902
+ if (!d || d.removed || !d.game.title) throw noSuchGame(slug);
21903
+ const origin = apiUrl();
21904
+ const handles = [...new Set((d.parents ?? []).map((p) => p.owner))];
21905
+ const xs = new Map(await Promise.all(handles.map(async (h) => [h, await xHandle(h)])));
21906
+ const parents = [];
21907
+ const seen = /* @__PURE__ */ new Set();
21908
+ for (const p of d.parents ?? []) {
21909
+ if (seen.has(p.slug)) continue;
21910
+ seen.add(p.slug);
21911
+ parents.push({ title: p.title, handle: p.owner, x: xs.get(p.owner) ?? null });
21912
+ }
21913
+ const kind = d.game.kind ?? (parents.length > 1 ? "blend" : parents.length ? "fork" : "original");
21914
+ const game = {
21915
+ origin,
21916
+ slug: d.game.slug,
21917
+ title: d.game.title,
21918
+ kind,
21919
+ parents,
21920
+ version: opts.version ?? d.generation?.version
21921
+ };
21922
+ const post = shareDraft(game);
21923
+ const hostedGif = d.game.gifUrl ?? null;
21924
+ return {
21925
+ kind: "game",
21926
+ slug: d.game.slug,
21927
+ title: d.game.title,
21928
+ url: gameUrl(origin, d.game.slug),
21929
+ description: descriptionLine(game),
21930
+ post,
21931
+ postLength: xLength(post),
21932
+ xIntent: xIntentUrl(post),
21933
+ gif: findGif(d.game.slug, opts.dir, hostedGif),
21934
+ badge: badges({ origin, slug: d.game.slug })
21935
+ };
21936
+ }
21937
+ async function signedInHandle() {
21938
+ const me = await request("/api/me");
21939
+ if (!me.user?.handle) throw new ExitError("You're not signed in. Run `arcade login`.");
21940
+ return me.user.handle;
21941
+ }
21942
+ async function profileShare(handleArg) {
21943
+ const handle = (handleArg ?? await signedInHandle()).replace(/^@/, "");
21944
+ const origin = apiUrl();
21945
+ let profile;
21946
+ try {
21947
+ profile = await request(`/api/users/${encodeURIComponent(handle)}`, {
21948
+ auth: false
21949
+ });
21950
+ } catch (e) {
21951
+ if (e instanceof ApiError && e.status === 404)
21952
+ throw new ExitError(`No creator called @${handle} on the arcade.`);
21953
+ throw e;
21954
+ }
21955
+ const list = await request(
21956
+ `/api/games?owner=${encodeURIComponent(handle)}&sort=new&limit=60`,
21957
+ { auth: false }
21958
+ );
21959
+ const post = libraryShare({ origin, handle, games: profile.stats.games });
21960
+ return {
21961
+ kind: "profile",
21962
+ handle,
21963
+ url: profileUrl(origin, handle),
21964
+ description: libraryDescriptionLine(origin, handle),
21965
+ post,
21966
+ postLength: xLength(post),
21967
+ xIntent: xIntentUrl(post),
21968
+ games: list.games.map((g) => ({ slug: g.slug, title: g.title, url: gameUrl(origin, g.slug) })),
21969
+ badge: badges({ origin, handle })
21970
+ };
21971
+ }
21972
+ function printBadge(b) {
21973
+ out(bold("Badge for a README, itch page, or website:"));
21974
+ out(` Markdown (light): ${b.markdown}`);
21975
+ out(` Markdown (dark): ${b.markdownDark}`);
21976
+ out(` HTML (light): ${b.html}`);
21977
+ out(` HTML (dark): ${b.htmlDark}`);
21978
+ }
21979
+ function printShare(s, opts = {}) {
21980
+ out("");
21981
+ out(pink(bold("Share it")) + dim(" (drafts for you to post; the arcade never posts for you)"));
21982
+ out(` Link: ${cyan(s.url)}`);
21983
+ out(` Video description: ${s.description}`);
21984
+ out(` X post (${s.postLength}/280):`);
21985
+ out(` ${s.post}`);
21986
+ out(` Post it on X: ${s.xIntent}`);
21987
+ if (s.kind === "game")
21988
+ out(
21989
+ ` Attach this GIF: ${s.gif ?? "none yet (run arcade media preview in the game folder, then publish)"}`
21990
+ );
21991
+ else for (const g of s.games) out(` ${g.title} ${dim(g.url)}`);
21992
+ if (opts.badge) {
21993
+ out("");
21994
+ printBadge(s.badge);
21995
+ } else
21996
+ out(
21997
+ dim(
21998
+ ` Badge for your README or itch page: arcade share ${s.kind === "game" ? s.slug : "--profile"} --badge`
21999
+ )
22000
+ );
22001
+ }
22002
+ async function share(slug, opts) {
22003
+ if (!slug && !opts.profile)
22004
+ throw new ExitError(
22005
+ "Name a game (arcade share <slug>), or use --profile for your whole library.",
22006
+ 2
22007
+ );
22008
+ const s = opts.profile ? await profileShare(typeof opts.profile === "string" ? opts.profile : void 0) : await gameShare(slug);
22009
+ if (opts.json) {
22010
+ printJson(s);
22011
+ return;
22012
+ }
22013
+ if (opts.badge && !opts.json) {
22014
+ out(`${bold(s.kind === "game" ? s.title : `@${s.handle}`)} ${cyan(s.url)}`);
22015
+ out("");
22016
+ printBadge(s.badge);
22017
+ return;
22018
+ }
22019
+ printShare(s);
22020
+ }
22021
+ async function shareAfterPublish(slug, dir, version2) {
22022
+ try {
22023
+ printShare(await gameShare(slug, { dir, version: version2 }));
22024
+ } catch {
22025
+ out(dim(`Share it later: arcade share ${slug}`));
22026
+ }
22027
+ }
22028
+
21734
22029
  // src/commands/publish.ts
21735
22030
  function readArcadeRaw(dir) {
21736
- const path = join5(dir, "arcade.json");
21737
- if (!existsSync5(path))
22031
+ const path = join6(dir, "arcade.json");
22032
+ if (!existsSync6(path))
21738
22033
  throw new ExitError(
21739
22034
  `No arcade.json in ${dir}. Write one at the top of the game folder with at least these fields (arcade guide publishing has a full example):`,
21740
22035
  2,
@@ -21742,7 +22037,7 @@ function readArcadeRaw(dir) {
21742
22037
  "Starting a brand-new game instead? arcade new <slug>"
21743
22038
  );
21744
22039
  try {
21745
- return JSON.parse(readFileSync7(path, "utf8"));
22040
+ return JSON.parse(readFileSync8(path, "utf8"));
21746
22041
  } catch (e) {
21747
22042
  throw new ExitError(`arcade.json isn't valid JSON: ${e.message}`, 2);
21748
22043
  }
@@ -21765,17 +22060,18 @@ function runFfmpeg(args, what) {
21765
22060
  throw new ExitError(`ffmpeg couldn't make the ${what}${last ? `: ${last}` : "."}`);
21766
22061
  }
21767
22062
  }
21768
- function makePreview(dir, demo, target = "media/preview.mp4") {
22063
+ function makePreview(dir, demo, target = "media/preview.mp4", scale = "scale=854:-2") {
21769
22064
  if (!hasFfmpeg()) return null;
22065
+ mkdirSync3(dirname3(join6(dir, target)), { recursive: true });
21770
22066
  runFfmpeg(
21771
22067
  [
21772
22068
  "-i",
21773
- join5(dir, demo),
22069
+ join6(dir, demo),
21774
22070
  "-t",
21775
22071
  "12",
21776
22072
  "-an",
21777
22073
  "-vf",
21778
- "scale=854:-2,fps=30",
22074
+ `${scale},fps=30`,
21779
22075
  "-c:v",
21780
22076
  "libx264",
21781
22077
  "-preset",
@@ -21786,16 +22082,20 @@ function makePreview(dir, demo, target = "media/preview.mp4") {
21786
22082
  "yuv420p",
21787
22083
  "-movflags",
21788
22084
  "+faststart",
21789
- join5(dir, target)
22085
+ join6(dir, target)
21790
22086
  ],
21791
22087
  "preview"
21792
22088
  );
21793
22089
  return target;
21794
22090
  }
21795
22091
  var MAX_GIF_BYTES = 5 * 1024 * 1024;
21796
- function makeGif(dir, demo, target = "media/preview.gif") {
22092
+ function makeGif(dir, demo, target = "media/preview.gif", vertical = false) {
21797
22093
  if (!hasFfmpeg()) return null;
21798
- const tries = [
22094
+ mkdirSync3(dirname3(join6(dir, target)), { recursive: true });
22095
+ const tries = vertical ? [
22096
+ { width: 360, fps: 12, seconds: 6 },
22097
+ { width: 270, fps: 10, seconds: 5 }
22098
+ ] : [
21799
22099
  { width: 480, fps: 12, seconds: 6 },
21800
22100
  { width: 360, fps: 10, seconds: 5 }
21801
22101
  ];
@@ -21803,7 +22103,7 @@ function makeGif(dir, demo, target = "media/preview.gif") {
21803
22103
  runFfmpeg(
21804
22104
  [
21805
22105
  "-i",
21806
- join5(dir, demo),
22106
+ join6(dir, demo),
21807
22107
  "-t",
21808
22108
  String(t.seconds),
21809
22109
  "-an",
@@ -21811,16 +22111,165 @@ function makeGif(dir, demo, target = "media/preview.gif") {
21811
22111
  `fps=${t.fps},scale=${t.width}:-2:flags=lanczos,split[a][b];[a]palettegen=max_colors=128:stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=4`,
21812
22112
  "-loop",
21813
22113
  "0",
21814
- join5(dir, target)
22114
+ join6(dir, target)
21815
22115
  ],
21816
22116
  "GIF"
21817
22117
  );
21818
- if (statSync5(join5(dir, target)).size <= MAX_GIF_BYTES) return target;
22118
+ if (statSync5(join6(dir, target)).size <= MAX_GIF_BYTES) return target;
21819
22119
  }
21820
22120
  throw new ExitError(
21821
22121
  `The GIF came out over ${MAX_GIF_BYTES / 1024 / 1024} MB, even small. Re-cut the demo so its first 5 seconds are less busy (a later -ss), then run \`arcade media preview\` again.`
21822
22122
  );
21823
22123
  }
22124
+ var VERTICAL_W = 1080;
22125
+ var VERTICAL_H = 1920;
22126
+ var BLURRED_FILL = [
22127
+ "split=2[bg][fg]",
22128
+ `[bg]scale=${VERTICAL_W}:${VERTICAL_H}:force_original_aspect_ratio=increase,crop=${VERTICAL_W}:${VERTICAL_H},scale=270:480,gblur=sigma=10,scale=${VERTICAL_W}:${VERTICAL_H},eq=brightness=-0.22:saturation=1.1[b]`,
22129
+ `[fg]scale=${VERTICAL_W}:-2[f]`,
22130
+ "[b][f]overlay=(W-w)/2:(H-h)/2"
22131
+ ].join(";");
22132
+ var MAX_VIDEO_BYTES = 25 * 1024 * 1024;
22133
+ var VERTICAL_DIR = "media/vertical";
22134
+ function fillImage(dir, src, target) {
22135
+ if (!hasFfmpeg()) return null;
22136
+ mkdirSync3(dirname3(join6(dir, target)), { recursive: true });
22137
+ runFfmpeg(
22138
+ [
22139
+ "-i",
22140
+ join6(dir, src),
22141
+ "-filter_complex",
22142
+ BLURRED_FILL,
22143
+ "-frames:v",
22144
+ "1",
22145
+ "-q:v",
22146
+ "3",
22147
+ join6(dir, target)
22148
+ ],
22149
+ `vertical version of ${src}`
22150
+ );
22151
+ return target;
22152
+ }
22153
+ function fillDemo(dir, demo, target = `${VERTICAL_DIR}/demo.mp4`) {
22154
+ if (!hasFfmpeg()) return null;
22155
+ mkdirSync3(dirname3(join6(dir, target)), { recursive: true });
22156
+ for (const crf of ["28", "34"]) {
22157
+ runFfmpeg(
22158
+ [
22159
+ "-i",
22160
+ join6(dir, demo),
22161
+ "-t",
22162
+ "30",
22163
+ "-an",
22164
+ "-filter_complex",
22165
+ `${BLURRED_FILL},fps=30,format=yuv420p`,
22166
+ "-c:v",
22167
+ "libx264",
22168
+ "-preset",
22169
+ "veryfast",
22170
+ "-crf",
22171
+ crf,
22172
+ "-movflags",
22173
+ "+faststart",
22174
+ join6(dir, target)
22175
+ ],
22176
+ "vertical demo"
22177
+ );
22178
+ if (statSync5(join6(dir, target)).size <= MAX_VIDEO_BYTES) return target;
22179
+ }
22180
+ throw new ExitError(
22181
+ `The vertical demo came out over ${MAX_VIDEO_BYTES / 1024 / 1024} MB. Record a shorter demo (15 to 20 s), then run \`arcade media vertical\` again.`
22182
+ );
22183
+ }
22184
+ var orientationOf = (raw) => raw.orientation === "portrait" || raw.orientation === "both" ? raw.orientation : "landscape";
22185
+ var verticalOf = (raw) => raw.media_vertical && typeof raw.media_vertical === "object" ? { ...raw.media_vertical } : {};
22186
+ var exists = (dir, p) => typeof p === "string" && existsSync6(join6(dir, p));
22187
+ function fillVerticalSet(dir, raw) {
22188
+ const v = verticalOf(raw);
22189
+ const made = [];
22190
+ const skipped = [];
22191
+ const image = (src, target) => {
22192
+ try {
22193
+ made.push(fillImage(dir, src, target));
22194
+ return target;
22195
+ } catch (e) {
22196
+ if (!(e instanceof ExitError)) throw e;
22197
+ skipped.push(e.message);
22198
+ return null;
22199
+ }
22200
+ };
22201
+ if (exists(dir, raw.thumbnail)) {
22202
+ const t = image(raw.thumbnail, `${VERTICAL_DIR}/thumbnail.jpg`);
22203
+ if (t) v.thumbnail = t;
22204
+ }
22205
+ if (Array.isArray(raw.screenshots)) {
22206
+ v.screenshots = raw.screenshots.filter((p) => exists(dir, p)).map((p, i) => image(p, `${VERTICAL_DIR}/shot-${i + 1}.jpg`)).filter((p) => p !== null);
22207
+ }
22208
+ if (exists(dir, raw.demo_video)) {
22209
+ v.demo_video = fillDemo(dir, raw.demo_video);
22210
+ v.preview_video = makePreview(
22211
+ dir,
22212
+ v.demo_video,
22213
+ `${VERTICAL_DIR}/preview.mp4`,
22214
+ "scale=-2:854"
22215
+ );
22216
+ v.preview_gif = makeGif(dir, v.demo_video, `${VERTICAL_DIR}/preview.gif`, true);
22217
+ made.push(v.demo_video, v.preview_video, v.preview_gif);
22218
+ }
22219
+ if (made.length) raw.media_vertical = v;
22220
+ return { made, skipped };
22221
+ }
22222
+ function ensureVertical(dir, raw) {
22223
+ const v = verticalOf(raw);
22224
+ const save = () => writeFileSync3(join6(dir, "arcade.json"), `${JSON.stringify(raw, null, 2)}
22225
+ `);
22226
+ if (orientationOf(raw) === "landscape") {
22227
+ const needDemo = exists(dir, raw.demo_video) && !v.demo_video;
22228
+ const needThumb = exists(dir, raw.thumbnail) && !v.thumbnail;
22229
+ if (!needDemo && !needThumb) return;
22230
+ if (!hasFfmpeg()) {
22231
+ if (needDemo)
22232
+ throw new ExitError(
22233
+ "This game has a demo, so phones need a vertical cut of it (over a blurred fill), and that takes ffmpeg.",
22234
+ 2,
22235
+ [NO_FFMPEG],
22236
+ "Then run `arcade media vertical` (or publish again, which makes it for you)."
22237
+ );
22238
+ out(
22239
+ dim("No vertical thumbnail: ffmpeg isn't installed. Phones will show the landscape one.")
22240
+ );
22241
+ return;
22242
+ }
22243
+ const { made, skipped } = fillVerticalSet(dir, raw);
22244
+ if (made.length) save();
22245
+ if (made.length)
22246
+ out(
22247
+ dim(
22248
+ `Made the vertical set for phones, over a blurred fill (${plural(made.length, "file")} in ${VERTICAL_DIR}/).`
22249
+ )
22250
+ );
22251
+ for (const m of skipped)
22252
+ out(yellow(`No vertical copy: ${m} Phones will show the landscape one.`));
22253
+ return;
22254
+ }
22255
+ if (exists(dir, v.demo_video) && !v.preview_gif) {
22256
+ if (!hasFfmpeg())
22257
+ throw new ExitError("The vertical demo needs a GIF made from it, and that takes ffmpeg.", 2, [
22258
+ NO_FFMPEG
22259
+ ]);
22260
+ const made = cutVerticalPreviews(dir, raw);
22261
+ save();
22262
+ out(dim(`Made a vertical preview and GIF from the vertical demo (${made.join(", ")}).`));
22263
+ }
22264
+ }
22265
+ function cutVerticalPreviews(dir, raw) {
22266
+ const v = verticalOf(raw);
22267
+ if (!exists(dir, v.demo_video)) return [];
22268
+ v.preview_video = makePreview(dir, v.demo_video, `${VERTICAL_DIR}/preview.mp4`, "scale=-2:854");
22269
+ v.preview_gif = makeGif(dir, v.demo_video, `${VERTICAL_DIR}/preview.gif`, true);
22270
+ raw.media_vertical = v;
22271
+ return [v.preview_video, v.preview_gif];
22272
+ }
21824
22273
  var NO_FFMPEG = "ffmpeg isn't installed. Install it (macOS: `brew install ffmpeg`, Ubuntu: `sudo apt install ffmpeg`, Windows: `winget install ffmpeg`) and try again.";
21825
22274
  function demoFor(dir, raw, what) {
21826
22275
  if (typeof raw.demo_video !== "string")
@@ -21828,7 +22277,7 @@ function demoFor(dir, raw, what) {
21828
22277
  `arcade.json has no demo_video to make ${what} from. Record 15-30 s of real play at 1280x720 into media/demo.mp4 and set "demo_video": "media/demo.mp4" (arcade guide publishing, "Capturing media").`,
21829
22278
  2
21830
22279
  );
21831
- if (!existsSync5(join5(dir, raw.demo_video)))
22280
+ if (!existsSync6(join6(dir, raw.demo_video)))
21832
22281
  throw new ExitError(
21833
22282
  `${raw.demo_video} isn't in the folder yet. Record a demo first (see arcade-publishing).`,
21834
22283
  2
@@ -21836,7 +22285,7 @@ function demoFor(dir, raw, what) {
21836
22285
  return raw.demo_video;
21837
22286
  }
21838
22287
  async function mediaPreview(dirArg) {
21839
- const dir = resolve3(dirArg ?? ".");
22288
+ const dir = resolve4(dirArg ?? ".");
21840
22289
  const raw = readArcadeRaw(dir);
21841
22290
  const demo = demoFor(dir, raw, "a preview");
21842
22291
  const made = makePreview(dir, demo);
@@ -21845,21 +22294,51 @@ async function mediaPreview(dirArg) {
21845
22294
  if (!gif) throw new ExitError(NO_FFMPEG);
21846
22295
  raw.preview_video = made;
21847
22296
  raw.preview_gif = gif;
21848
- writeFileSync3(join5(dir, "arcade.json"), `${JSON.stringify(raw, null, 2)}
22297
+ writeFileSync3(join6(dir, "arcade.json"), `${JSON.stringify(raw, null, 2)}
21849
22298
  `);
21850
22299
  out(
21851
- `Made ${made} and ${gif} (${mb(statSync5(join5(dir, gif)).size)}) and added them to arcade.json.`
22300
+ `Made ${made} and ${gif} (${mb(statSync5(join6(dir, gif)).size)}) and added them to arcade.json.`
21852
22301
  );
21853
22302
  }
21854
22303
  async function mediaGif(dirArg) {
21855
- const dir = resolve3(dirArg ?? ".");
22304
+ const dir = resolve4(dirArg ?? ".");
21856
22305
  const raw = readArcadeRaw(dir);
21857
22306
  const gif = makeGif(dir, demoFor(dir, raw, "a GIF"));
21858
22307
  if (!gif) throw new ExitError(NO_FFMPEG);
21859
22308
  raw.preview_gif = gif;
21860
- writeFileSync3(join5(dir, "arcade.json"), `${JSON.stringify(raw, null, 2)}
22309
+ writeFileSync3(join6(dir, "arcade.json"), `${JSON.stringify(raw, null, 2)}
22310
+ `);
22311
+ out(`Made ${gif} (${mb(statSync5(join6(dir, gif)).size)}) and added it to arcade.json.`);
22312
+ }
22313
+ async function mediaVertical(dirArg) {
22314
+ const dir = resolve4(dirArg ?? ".");
22315
+ const raw = readArcadeRaw(dir);
22316
+ if (!hasFfmpeg()) throw new ExitError(NO_FFMPEG);
22317
+ const orientation = orientationOf(raw);
22318
+ let made;
22319
+ if (orientation === "landscape") {
22320
+ if (!exists(dir, raw.thumbnail))
22321
+ throw new ExitError(
22322
+ `The vertical set is made from the landscape media, and ${typeof raw.thumbnail === "string" ? `${raw.thumbnail} isn't in the folder yet` : "arcade.json has no thumbnail"}. Capture the landscape media first (${CAPTURE_GUIDE}).`,
22323
+ 2
22324
+ );
22325
+ const r = fillVerticalSet(dir, raw);
22326
+ made = r.made;
22327
+ for (const m of r.skipped) out(yellow(`Skipped: ${m}`));
22328
+ } else {
22329
+ made = cutVerticalPreviews(dir, raw);
22330
+ if (!made.length)
22331
+ throw new ExitError(
22332
+ `This game plays ${orientation === "portrait" ? "upright" : "both ways"}, so its vertical media comes from real upright play, not a blurred fill. Record 15 to 30 s at 390x844 with touch into ${VERTICAL_DIR}/demo.mp4, set "media_vertical.demo_video", then run this again (${CAPTURE_GUIDE}).`,
22333
+ 2
22334
+ );
22335
+ }
22336
+ writeFileSync3(join6(dir, "arcade.json"), `${JSON.stringify(raw, null, 2)}
21861
22337
  `);
21862
- out(`Made ${gif} (${mb(statSync5(join5(dir, gif)).size)}) and added it to arcade.json.`);
22338
+ out(
22339
+ `Made ${plural(made.length, "vertical file")} (${orientation === "landscape" ? "landscape media over a blurred fill, 1080x1920" : "cut from your vertical demo"}) and set media_vertical:`
22340
+ );
22341
+ for (const m of made) out(` ${m} ${dim(mb(statSync5(join6(dir, m)).size))}`);
21863
22342
  }
21864
22343
  function describeAction(p, parentNames) {
21865
22344
  switch (p.action) {
@@ -21920,6 +22399,19 @@ function listWords(words) {
21920
22399
  if (words.length <= 1) return words.join("");
21921
22400
  return `${words.slice(0, -1).join(", ")}${words.length > 2 ? "," : ""} and ${words.at(-1)}`;
21922
22401
  }
22402
+ var FULLSCREEN_CALL = /\b(?:request|webkitRequest|mozRequest|msRequest)Full[sS]creen\b/;
22403
+ var CODE_FILE = /\.(?:html?|m?js|jsx|tsx?)$/i;
22404
+ function fullscreenCalls(files) {
22405
+ const hits = [];
22406
+ for (const f of files) {
22407
+ if (!CODE_FILE.test(f.path) || f.path.startsWith("parents/") || f.size > 2 * 1024 * 1024)
22408
+ continue;
22409
+ const lines = readFileSync8(f.abs, "utf8").split("\n");
22410
+ const i = lines.findIndex((l) => FULLSCREEN_CALL.test(l));
22411
+ if (i >= 0) hits.push(`${f.path}:${i + 1}`);
22412
+ }
22413
+ return hits;
22414
+ }
21923
22415
  var HOME_PATH = /(?:\/Users\/|\/home\/|[A-Za-z]:\\Users\\)[^\s/\\"'`]+/;
21924
22416
  function headsUp(arcade, files, alreadyOnArcade, secretWarnings, dir) {
21925
22417
  const notes = [];
@@ -21992,7 +22484,7 @@ function headsUp(arcade, files, alreadyOnArcade, secretWarnings, dir) {
21992
22484
  );
21993
22485
  const age = (p2) => {
21994
22486
  try {
21995
- return p2 ? statSync5(join5(dir, p2)).mtimeMs : null;
22487
+ return p2 ? statSync5(join6(dir, p2)).mtimeMs : null;
21996
22488
  } catch {
21997
22489
  return null;
21998
22490
  }
@@ -22006,6 +22498,26 @@ function headsUp(arcade, files, alreadyOnArcade, secretWarnings, dir) {
22006
22498
  notes.push(
22007
22499
  `The ${stale.map(([w]) => w).join(" and ")} ${stale.length > 1 ? "are" : "is"} older than the demo. Run \`arcade media preview\` to remake ${stale.length > 1 ? "them" : "it"}.`
22008
22500
  );
22501
+ const v = arcade.media_vertical;
22502
+ if (arcade.orientation === "landscape") {
22503
+ if (!v?.thumbnail)
22504
+ notes.push(
22505
+ "There's no vertical thumbnail, so phones show the landscape one. Run `arcade media vertical` (it needs ffmpeg)."
22506
+ );
22507
+ const vStale = [
22508
+ ["vertical thumbnail", age(v?.thumbnail), age(arcade.thumbnail)],
22509
+ ["vertical demo", age(v?.demo_video), demoAt]
22510
+ ].filter(([, t, src]) => t !== null && src !== null && t < src);
22511
+ if (vStale.length)
22512
+ notes.push(
22513
+ `The ${vStale.map(([w]) => w).join(" and ")} ${vStale.length > 1 ? "are" : "is"} older than the landscape media. Run \`arcade media vertical\` to remake the vertical set.`
22514
+ );
22515
+ }
22516
+ const fullscreen = fullscreenCalls(files);
22517
+ if (fullscreen.length)
22518
+ notes.push(
22519
+ `${listWords(fullscreen.slice(0, 3))}${fullscreen.length > 3 ? ` and ${fullscreen.length - 3} more` : ""} ${fullscreen.length > 1 ? "call" : "calls"} requestFullscreen: the arcade's player owns fullscreen; remove your fullscreen button.`
22520
+ );
22009
22521
  const thumb = files.find((f) => f.path === arcade.thumbnail);
22010
22522
  if (thumb && thumb.size > 500 * 1024) {
22011
22523
  const webp = arcade.thumbnail.replace(/\.[^.]+$/, ".webp");
@@ -22028,9 +22540,9 @@ function headsUp(arcade, files, alreadyOnArcade, secretWarnings, dir) {
22028
22540
  return notes;
22029
22541
  }
22030
22542
  async function fillLicenseHolder(dir) {
22031
- const path = join5(dir, "LICENSE");
22032
- if (!existsSync5(path)) return;
22033
- const text = readFileSync7(path, "utf8");
22543
+ const path = join6(dir, "LICENSE");
22544
+ if (!existsSync6(path)) return;
22545
+ const text = readFileSync8(path, "utf8");
22034
22546
  if (!text.includes(LICENSE_HOLDER_PLACEHOLDER)) return;
22035
22547
  const handle = savedLogin()?.handle ?? (await request("/api/me").catch(() => null))?.user?.handle;
22036
22548
  if (!handle) return;
@@ -22053,14 +22565,14 @@ async function publish(dirArg, opts) {
22053
22565
  );
22054
22566
  useStdoutForJson();
22055
22567
  }
22056
- const dir = resolve3(dirArg ?? ".");
22568
+ const dir = resolve4(dirArg ?? ".");
22057
22569
  const raw = readArcadeRaw(dir);
22058
- if (typeof raw.demo_video === "string" && !raw.preview_video && existsSync5(join5(dir, raw.demo_video))) {
22570
+ if (typeof raw.demo_video === "string" && !raw.preview_video && existsSync6(join6(dir, raw.demo_video))) {
22059
22571
  try {
22060
22572
  const made = makePreview(dir, raw.demo_video);
22061
22573
  if (made) {
22062
22574
  raw.preview_video = made;
22063
- writeFileSync3(join5(dir, "arcade.json"), `${JSON.stringify(raw, null, 2)}
22575
+ writeFileSync3(join6(dir, "arcade.json"), `${JSON.stringify(raw, null, 2)}
22064
22576
  `);
22065
22577
  out(dim(`Made a 12 s hover preview (${made}).`));
22066
22578
  } else {
@@ -22075,7 +22587,7 @@ async function publish(dirArg, opts) {
22075
22587
  out(yellow(`No hover preview: ${e.message} Cards will show the thumbnail.`));
22076
22588
  }
22077
22589
  }
22078
- if (typeof raw.demo_video === "string" && !raw.preview_gif && existsSync5(join5(dir, raw.demo_video))) {
22590
+ if (typeof raw.demo_video === "string" && !raw.preview_gif && existsSync6(join6(dir, raw.demo_video))) {
22079
22591
  const made = makeGif(dir, raw.demo_video);
22080
22592
  if (!made)
22081
22593
  throw new ExitError(
@@ -22085,10 +22597,11 @@ async function publish(dirArg, opts) {
22085
22597
  "Then run `arcade media preview` (or publish again, which makes it for you)."
22086
22598
  );
22087
22599
  raw.preview_gif = made;
22088
- writeFileSync3(join5(dir, "arcade.json"), `${JSON.stringify(raw, null, 2)}
22600
+ writeFileSync3(join6(dir, "arcade.json"), `${JSON.stringify(raw, null, 2)}
22089
22601
  `);
22090
- out(dim(`Made a GIF from the demo (${made}, ${mb(statSync5(join5(dir, made)).size)}).`));
22602
+ out(dim(`Made a GIF from the demo (${made}, ${mb(statSync5(join6(dir, made)).size)}).`));
22091
22603
  }
22604
+ ensureVertical(dir, raw);
22092
22605
  const prov = raw.provenance ?? {};
22093
22606
  const orch = prov.orchestrator ?? null;
22094
22607
  const named = opts.session ?? [];
@@ -22141,6 +22654,14 @@ async function publish(dirArg, opts) {
22141
22654
  throw new ExitError("A game with a demo needs a GIF made from it (preview_gif).", 2, [
22142
22655
  `${arcade.demo_video} (demo): record it first, then run \`arcade media preview\`.`
22143
22656
  ]);
22657
+ if (arcade.media_vertical?.demo_video && !arcade.media_vertical.preview_gif)
22658
+ throw new ExitError(
22659
+ "The vertical demo needs a GIF made from it (media_vertical.preview_gif).",
22660
+ 2,
22661
+ [
22662
+ `${arcade.media_vertical.demo_video} (vertical demo): record it first, then run \`arcade media vertical\`.`
22663
+ ]
22664
+ );
22144
22665
  await fillLicenseHolder(dir);
22145
22666
  const { files, skipped, skippedLinks } = walkGame(dir);
22146
22667
  const bad = files.filter((f) => !f.type);
@@ -22151,14 +22672,19 @@ async function publish(dirArg, opts) {
22151
22672
  bad.map((f) => `${f.path} (move it out of the folder, or delete it)`)
22152
22673
  );
22153
22674
  }
22154
- const notMit = files.filter((f) => isLicenseFile(f.path)).map((f) => licenseFileProblem(f.path, readFileSync7(f.abs, "utf8"))).filter((p) => p !== null);
22675
+ const notMit = files.filter((f) => isLicenseFile(f.path)).map((f) => licenseFileProblem(f.path, readFileSync8(f.abs, "utf8"))).filter((p) => p !== null);
22155
22676
  if (notMit.length) throw new ExitError("The game's license needs fixing:", 2, notMit);
22156
22677
  const media = [
22157
22678
  arcade.thumbnail,
22158
22679
  ...arcade.screenshots,
22159
22680
  arcade.demo_video,
22160
22681
  arcade.preview_video,
22161
- arcade.preview_gif
22682
+ arcade.preview_gif,
22683
+ arcade.media_vertical?.thumbnail,
22684
+ ...arcade.media_vertical?.screenshots ?? [],
22685
+ arcade.media_vertical?.demo_video,
22686
+ arcade.media_vertical?.preview_video,
22687
+ arcade.media_vertical?.preview_gif
22162
22688
  ];
22163
22689
  const missingMedia = media.filter((m) => !!m && !files.some((f) => f.path === m));
22164
22690
  if (missingMedia.length) {
@@ -22185,7 +22711,7 @@ async function publish(dirArg, opts) {
22185
22711
  path: f.path,
22186
22712
  size: f.size,
22187
22713
  get bytes() {
22188
- return readFileSync7(f.abs);
22714
+ return readFileSync8(f.abs);
22189
22715
  }
22190
22716
  })),
22191
22717
  arcade.play.root,
@@ -22323,7 +22849,7 @@ async function publish(dirArg, opts) {
22323
22849
  delete nextProv.build;
22324
22850
  nextProv.subagent_models = [];
22325
22851
  raw.provenance = nextProv;
22326
- writeFileSync3(join5(dir, "arcade.json"), `${JSON.stringify(raw, null, 2)}
22852
+ writeFileSync3(join6(dir, "arcade.json"), `${JSON.stringify(raw, null, 2)}
22327
22853
  `);
22328
22854
  }
22329
22855
  if (opts.json) {
@@ -22334,7 +22860,9 @@ async function publish(dirArg, opts) {
22334
22860
  version: result.version,
22335
22861
  generation: result.generation,
22336
22862
  main: result.main,
22337
- url: result.url
22863
+ url: result.url,
22864
+ // The drafts the agent shows the creator. Missing only if the arcade couldn't be read back.
22865
+ share: await gameShare(result.slug, { dir, version: result.version }).catch(() => null)
22338
22866
  });
22339
22867
  return;
22340
22868
  }
@@ -22354,6 +22882,7 @@ async function publish(dirArg, opts) {
22354
22882
  `It's in the stack. The owner decides which generation is main (arcade main ${result.slug} ${result.generation}).`
22355
22883
  )
22356
22884
  );
22885
+ await shareAfterPublish(result.slug, dir, result.version);
22357
22886
  if (becameUpdate)
22358
22887
  next(`keep building here. Publishing this folder again makes v${result.version + 1}.`);
22359
22888
  else next(`publishing this folder again adds another generation to v${result.version}'s stack.`);
@@ -22377,7 +22906,7 @@ async function uploadAll(files, missing) {
22377
22906
  for (; ; ) {
22378
22907
  const job = jobs.shift();
22379
22908
  if (!job) return;
22380
- const body = readFileSync7(job.f.abs);
22909
+ const body = readFileSync8(job.f.abs);
22381
22910
  const ext2 = job.f.path.split(".").pop() ?? "";
22382
22911
  const path = job.kind === "blob" ? `/api/uploads/blob/${job.f.sha256}` : `/api/uploads/media/${job.f.sha256}?ext=${ext2}`;
22383
22912
  await request(path, {
@@ -22447,14 +22976,14 @@ async function setPublished(slug, published) {
22447
22976
  }
22448
22977
 
22449
22978
  // src/commands/skills.ts
22450
- import { cpSync, existsSync as existsSync6, mkdirSync as mkdirSync3, readdirSync as readdirSync4, readFileSync as readFileSync8 } from "fs";
22451
- import { homedir as homedir3 } from "os";
22452
- import { dirname as dirname3, join as join6, resolve as resolve4 } from "path";
22979
+ import { cpSync, existsSync as existsSync7, mkdirSync as mkdirSync4, readdirSync as readdirSync4, readFileSync as readFileSync9 } from "fs";
22980
+ import { homedir as homedir4 } from "os";
22981
+ import { dirname as dirname4, join as join7, resolve as resolve5 } from "path";
22453
22982
  import { fileURLToPath } from "url";
22454
22983
  function skillsDir() {
22455
- const here = dirname3(fileURLToPath(import.meta.url));
22456
- for (const candidate2 of [join6(here, "..", "skills"), join6(here, "..", "..", "skills")]) {
22457
- if (existsSync6(join6(candidate2, "arcade-getting-started", "SKILL.md"))) return candidate2;
22984
+ const here = dirname4(fileURLToPath(import.meta.url));
22985
+ for (const candidate2 of [join7(here, "..", "skills"), join7(here, "..", "..", "skills")]) {
22986
+ if (existsSync7(join7(candidate2, "arcade-getting-started", "SKILL.md"))) return candidate2;
22458
22987
  }
22459
22988
  throw new ExitError(
22460
22989
  "The bundled skills are missing from this install. Reinstall evolutionary-arcade."
@@ -22479,7 +23008,7 @@ function guide(name, opts = {}) {
22479
23008
  return;
22480
23009
  }
22481
23010
  const skill = resolveSkill(name ?? "getting-started");
22482
- const text = readFileSync8(join6(skillsDir(), skill, "SKILL.md"), "utf8");
23011
+ const text = readFileSync9(join7(skillsDir(), skill, "SKILL.md"), "utf8");
22483
23012
  if (opts.raw) {
22484
23013
  process.stdout.write(text);
22485
23014
  return;
@@ -22493,21 +23022,21 @@ function guide(name, opts = {}) {
22493
23022
  process.stdout.write(text.replace(/^---\n[\s\S]*?\n---\n+/, ""));
22494
23023
  }
22495
23024
  var TARGETS = {
22496
- claude: () => join6(homedir3(), ".claude", "skills"),
22497
- codex: () => join6(homedir3(), ".agents", "skills")
23025
+ claude: () => join7(homedir4(), ".claude", "skills"),
23026
+ codex: () => join7(homedir4(), ".agents", "skills")
22498
23027
  };
22499
23028
  function installSkills(opts) {
22500
23029
  const src = skillsDir();
22501
23030
  const names = skillNames();
22502
- const targets = opts.dir ? [resolve4(opts.dir)] : (opts.target === "all" || !opts.target ? Object.keys(TARGETS) : [opts.target]).map((t) => {
23031
+ const targets = opts.dir ? [resolve5(opts.dir)] : (opts.target === "all" || !opts.target ? Object.keys(TARGETS) : [opts.target]).map((t) => {
22503
23032
  const f = TARGETS[t];
22504
23033
  if (!f) throw new ExitError(`Unknown --target ${t}. Use claude, codex, or all.`, 2);
22505
23034
  return f();
22506
23035
  });
22507
23036
  for (const target of targets) {
22508
- mkdirSync3(target, { recursive: true });
23037
+ mkdirSync4(target, { recursive: true });
22509
23038
  for (const name of names)
22510
- cpSync(join6(src, name), join6(target, name), { recursive: true, force: true });
23039
+ cpSync(join7(src, name), join7(target, name), { recursive: true, force: true });
22511
23040
  out(`Installed ${names.length} skills into ${target}`);
22512
23041
  }
22513
23042
  out(dim(names.join(", ")));
@@ -22599,11 +23128,20 @@ function buildProgram() {
22599
23128
  "after",
22600
23129
  "\nExamples:\n arcade publish --dry-run check it and see the file list\n arcade publish --yes publish after you've read the dry run\n arcade publish --yes --no-stats when these tokens aren't from this build"
22601
23130
  );
23131
+ program.command("share [slug]").description(
23132
+ "print ready-to-post drafts for a game: link, video-description line, X post, GIF (you post them)"
23133
+ ).option("--profile [handle]", "share your whole library (default: you)").option("--badge", 'print the "Play on Evolutionary Arcade" badge as Markdown and HTML').option("--json", "print JSON (for agents)").action(share).addHelpText(
23134
+ "after",
23135
+ "\nExamples:\n arcade share night-shift the Share block publish printed\n arcade share --profile your whole library, for video descriptions\n arcade share night-shift --badge badge for a README or itch page"
23136
+ );
22602
23137
  const media = program.command("media").description("media helpers (they need ffmpeg)");
22603
23138
  media.command("preview [dir]").description(
22604
23139
  "remake media/preview.mp4 (12 s) and media/preview.gif (6 s) from your demo, after every new demo"
22605
23140
  ).action(mediaPreview);
22606
23141
  media.command("gif [dir]").description("remake only media/preview.gif from your demo").action(mediaGif);
23142
+ media.command("vertical [dir]").description(
23143
+ "make the phone (9:16) set in media/vertical/: landscape games get their media over a blurred fill at 1080x1920; portrait and both games get a preview and GIF from their vertical demo"
23144
+ ).action(mediaVertical);
22607
23145
  program.command("main <slug> <generation>").description("(owner) pick which generation is main for its version").action(setMain);
22608
23146
  program.command("unpublish <slug>").description("(owner) take your game down").action((slug) => setPublished(slug, false));
22609
23147
  program.command("republish <slug>").description("(owner) bring your game back").action((slug) => setPublished(slug, true));