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/CHANGELOG.md +16 -0
- package/README.md +3 -1
- package/dist/cli.js +593 -55
- package/package.json +1 -1
- package/skills/arcade-building-games/SKILL.md +15 -10
- package/skills/arcade-fork/SKILL.md +25 -7
- package/skills/arcade-onboarding/SKILL.md +25 -9
- package/skills/arcade-publishing/SKILL.md +41 -3
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.
|
|
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,
|
|
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,
|
|
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,
|
|
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 ?? (
|
|
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 `[](${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
|
|
21442
|
-
import { isAbsolute as isAbsolute2, join as
|
|
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 =
|
|
21737
|
-
if (!
|
|
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(
|
|
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
|
-
|
|
22069
|
+
join6(dir, demo),
|
|
21774
22070
|
"-t",
|
|
21775
22071
|
"12",
|
|
21776
22072
|
"-an",
|
|
21777
22073
|
"-vf",
|
|
21778
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22114
|
+
join6(dir, target)
|
|
21815
22115
|
],
|
|
21816
22116
|
"GIF"
|
|
21817
22117
|
);
|
|
21818
|
-
if (statSync5(
|
|
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 (!
|
|
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 =
|
|
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(
|
|
22297
|
+
writeFileSync3(join6(dir, "arcade.json"), `${JSON.stringify(raw, null, 2)}
|
|
21849
22298
|
`);
|
|
21850
22299
|
out(
|
|
21851
|
-
`Made ${made} and ${gif} (${mb(statSync5(
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
22032
|
-
if (!
|
|
22033
|
-
const text =
|
|
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 =
|
|
22568
|
+
const dir = resolve4(dirArg ?? ".");
|
|
22057
22569
|
const raw = readArcadeRaw(dir);
|
|
22058
|
-
if (typeof raw.demo_video === "string" && !raw.preview_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(
|
|
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 &&
|
|
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(
|
|
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(
|
|
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,
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
|
22451
|
-
import { homedir as
|
|
22452
|
-
import { dirname as
|
|
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 =
|
|
22456
|
-
for (const candidate2 of [
|
|
22457
|
-
if (
|
|
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 =
|
|
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: () =>
|
|
22497
|
-
codex: () =>
|
|
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 ? [
|
|
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
|
-
|
|
23037
|
+
mkdirSync4(target, { recursive: true });
|
|
22509
23038
|
for (const name of names)
|
|
22510
|
-
cpSync(
|
|
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));
|