evolutionary-arcade 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +13 -11
- package/dist/cli.js +493 -76
- package/package.json +2 -10
- package/skills/arcade-building-games/SKILL.md +162 -10
- package/skills/arcade-building-games/arcade-scores.js +214 -0
- package/skills/arcade-getting-started/SKILL.md +11 -6
- package/skills/arcade-onboarding/SKILL.md +157 -0
- package/skills/arcade-publishing/SKILL.md +14 -6
- package/skills/arcade-remix-and-blend/SKILL.md +3 -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.2.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",
|
|
@@ -25,21 +25,13 @@ var package_default = {
|
|
|
25
25
|
"dist",
|
|
26
26
|
"skills",
|
|
27
27
|
"README.md",
|
|
28
|
+
"CHANGELOG.md",
|
|
28
29
|
"LICENSE"
|
|
29
30
|
],
|
|
30
31
|
engines: {
|
|
31
32
|
node: ">=20"
|
|
32
33
|
},
|
|
33
34
|
homepage: "https://evolutionaryarcade.com",
|
|
34
|
-
bugs: {
|
|
35
|
-
url: "https://github.com/DamDam98/evolutionary-arcade/issues",
|
|
36
|
-
email: "hello@evolutionaryarcade.com"
|
|
37
|
-
},
|
|
38
|
-
repository: {
|
|
39
|
-
type: "git",
|
|
40
|
-
url: "git+https://github.com/DamDam98/evolutionary-arcade.git",
|
|
41
|
-
directory: "packages/cli"
|
|
42
|
-
},
|
|
43
35
|
keywords: [
|
|
44
36
|
"games",
|
|
45
37
|
"arcade",
|
|
@@ -184,6 +176,7 @@ async function request(path, init = {}) {
|
|
|
184
176
|
import { Command, InvalidArgumentError, Option } from "commander";
|
|
185
177
|
|
|
186
178
|
// src/commands/account.ts
|
|
179
|
+
import { spawn } from "child_process";
|
|
187
180
|
import { existsSync as existsSync2, readFileSync as readFileSync2, statSync } from "fs";
|
|
188
181
|
import { hostname, platform } from "os";
|
|
189
182
|
import { extname } from "path";
|
|
@@ -236,7 +229,20 @@ var ExitError = class extends Error {
|
|
|
236
229
|
};
|
|
237
230
|
|
|
238
231
|
// src/commands/account.ts
|
|
239
|
-
|
|
232
|
+
function openBrowser(url2) {
|
|
233
|
+
if (process.env.ARCADE_NO_BROWSER === "1" || process.env.CI) return false;
|
|
234
|
+
const [cmd, args] = process.platform === "darwin" ? ["open", [url2]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url2]] : ["xdg-open", [url2]];
|
|
235
|
+
try {
|
|
236
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
237
|
+
child.on("error", () => {
|
|
238
|
+
});
|
|
239
|
+
child.unref();
|
|
240
|
+
return true;
|
|
241
|
+
} catch {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
async function login(opts = {}) {
|
|
240
246
|
const current = savedLogin();
|
|
241
247
|
if (current?.handle)
|
|
242
248
|
out(
|
|
@@ -255,8 +261,16 @@ async function login() {
|
|
|
255
261
|
throw new ExitError(why ?? `Couldn't start login (${res.status}). Try again in a minute.`);
|
|
256
262
|
}
|
|
257
263
|
const start = await res.json();
|
|
258
|
-
|
|
264
|
+
const opened = opts.browser !== false && openBrowser(start.verification_uri_complete);
|
|
265
|
+
out(
|
|
266
|
+
`${opened ? "Opened" : "Open"} ${cyan(start.verification_uri_complete)}${opened ? " in your browser" : ""}`
|
|
267
|
+
);
|
|
259
268
|
out(`and confirm the code ${bold(start.user_code)}`);
|
|
269
|
+
out(
|
|
270
|
+
dim(
|
|
271
|
+
"New here? You'll pick a handle. It's permanent and becomes your profile address (/u/<handle>), so the name people know you by (like your channel) is a good pick."
|
|
272
|
+
)
|
|
273
|
+
);
|
|
260
274
|
out(dim("Waiting for approval\u2026 (this can run in the background)"));
|
|
261
275
|
let interval = start.interval;
|
|
262
276
|
const deadline = Date.now() + start.expires_in * 1e3;
|
|
@@ -275,9 +289,12 @@ async function login() {
|
|
|
275
289
|
saveToken(body.access_token);
|
|
276
290
|
const me = await request("/api/me");
|
|
277
291
|
if (me.user?.handle) saveToken(body.access_token, me.user.handle);
|
|
278
|
-
|
|
292
|
+
const handle = me.user?.handle;
|
|
293
|
+
out(`Signed in as ${cyan(`@${handle ?? "?"}`)}.`);
|
|
294
|
+
if (handle) out(`Your profile: ${cyan(`${apiUrl()}/u/${handle}`)}`);
|
|
295
|
+
else out(`Pick your handle at ${cyan(`${apiUrl()}/welcome`)} before you publish.`);
|
|
279
296
|
next(
|
|
280
|
-
"
|
|
297
|
+
"back in your agent, carry on with onboarding (arcade guide onboarding): find your games, pick, publish."
|
|
281
298
|
);
|
|
282
299
|
return;
|
|
283
300
|
}
|
|
@@ -580,7 +597,15 @@ async function search(query, opts) {
|
|
|
580
597
|
|
|
581
598
|
// src/commands/games.ts
|
|
582
599
|
import { createHash } from "crypto";
|
|
583
|
-
import {
|
|
600
|
+
import {
|
|
601
|
+
existsSync as existsSync3,
|
|
602
|
+
mkdirSync as mkdirSync2,
|
|
603
|
+
readdirSync,
|
|
604
|
+
readFileSync as readFileSync3,
|
|
605
|
+
renameSync as renameSync2,
|
|
606
|
+
statSync as statSync2,
|
|
607
|
+
writeFileSync as writeFileSync2
|
|
608
|
+
} from "fs";
|
|
584
609
|
import { createServer } from "http";
|
|
585
610
|
import { networkInterfaces } from "os";
|
|
586
611
|
import { dirname as dirname2, join as join2, normalize, resolve, sep } from "path";
|
|
@@ -20253,6 +20278,46 @@ function date4(params) {
|
|
|
20253
20278
|
return _coercedDate(ZodDate, params);
|
|
20254
20279
|
}
|
|
20255
20280
|
|
|
20281
|
+
// ../format/src/license.ts
|
|
20282
|
+
var GAME_LICENSE = "MIT";
|
|
20283
|
+
var LICENSE_HOLDER_PLACEHOLDER = "<your arcade handle>";
|
|
20284
|
+
var MIT_ONLY = 'Every game on the arcade is open source under MIT. Set license to "MIT", or leave it out.';
|
|
20285
|
+
var isLicenseFile = (path) => /^(licen[cs]e|copying|unlicense)([-_][a-z0-9]+)*(\.(md|txt))?$/i.test(path);
|
|
20286
|
+
var isMitText = (text) => /permission is hereby granted, free of charge/i.test(text);
|
|
20287
|
+
function licenseFileProblem(path, text) {
|
|
20288
|
+
if (!isMitText(text))
|
|
20289
|
+
return `${path} isn't the MIT license. Every game on the arcade is MIT, so make it MIT, and keep other people's licenses in licenses/<name>/.`;
|
|
20290
|
+
if (text.includes(LICENSE_HOLDER_PLACEHOLDER))
|
|
20291
|
+
return `${path} still says ${LICENSE_HOLDER_PLACEHOLDER}. Put your @handle there.`;
|
|
20292
|
+
return null;
|
|
20293
|
+
}
|
|
20294
|
+
function mitLicense(year, holder) {
|
|
20295
|
+
return `MIT License
|
|
20296
|
+
|
|
20297
|
+
Copyright (c) ${year} ${holder}
|
|
20298
|
+
|
|
20299
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
20300
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
20301
|
+
in the Software without restriction, including without limitation the rights
|
|
20302
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
20303
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
20304
|
+
furnished to do so, subject to the following conditions:
|
|
20305
|
+
|
|
20306
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20307
|
+
copies or substantial portions of the Software.
|
|
20308
|
+
|
|
20309
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20310
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
20311
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
20312
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20313
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20314
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
20315
|
+
SOFTWARE.
|
|
20316
|
+
`;
|
|
20317
|
+
}
|
|
20318
|
+
var IMPORT_LICENSES = ["MIT", "CC0-1.0", "Unlicense"];
|
|
20319
|
+
var IMPORT_RULE = "Imported games must be MIT, or public domain (CC0 or the Unlicense).";
|
|
20320
|
+
|
|
20256
20321
|
// ../format/src/slug.ts
|
|
20257
20322
|
var SLUG_MIN = 3;
|
|
20258
20323
|
var SLUG_MAX = 40;
|
|
@@ -20398,6 +20463,13 @@ var provenanceSchema = external_exports.looseObject({
|
|
|
20398
20463
|
cost_usd: external_exports.number().nonnegative().optional(),
|
|
20399
20464
|
wall_time_minutes: external_exports.number().nonnegative().optional(),
|
|
20400
20465
|
cost_basis: external_exports.enum(["api-equivalent", "billed"]).optional()
|
|
20466
|
+
}).optional(),
|
|
20467
|
+
// The community prompt (evolutionaryarcade.com/prompts) the game was built from: its author's
|
|
20468
|
+
// handle and the prompt's id. The game page shows "Prompted by @handle" when the arcade's own
|
|
20469
|
+
// record of that prompt agrees.
|
|
20470
|
+
prompted_by: external_exports.object({
|
|
20471
|
+
handle: external_exports.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Use the prompter's handle, without the @.").max(40),
|
|
20472
|
+
prompt_id: external_exports.string().regex(/^[0-9a-z]{26}$/, "Use the prompt's id from its page.")
|
|
20401
20473
|
}).optional()
|
|
20402
20474
|
}).superRefine((p, ctx) => {
|
|
20403
20475
|
const bytes = utf8Length(JSON.stringify(p));
|
|
@@ -20411,7 +20483,33 @@ var provenanceSchema = external_exports.looseObject({
|
|
|
20411
20483
|
var inputSchema = external_exports.object({
|
|
20412
20484
|
keyboard_mouse: external_exports.boolean().default(true),
|
|
20413
20485
|
gamepad: external_exports.boolean().default(false),
|
|
20414
|
-
touch: external_exports.boolean().default(false)
|
|
20486
|
+
touch: external_exports.boolean().default(false),
|
|
20487
|
+
// Steers by tilting the phone (DeviceOrientation / DeviceMotion), with a touch fallback.
|
|
20488
|
+
tilt: external_exports.boolean().default(false)
|
|
20489
|
+
}).superRefine((input2, ctx) => {
|
|
20490
|
+
if (input2.tilt && !input2.touch) {
|
|
20491
|
+
ctx.addIssue({
|
|
20492
|
+
code: "custom",
|
|
20493
|
+
path: ["tilt"],
|
|
20494
|
+
message: "Tilt games play on phones, so set input.touch too, with touch controls for players who don't allow motion."
|
|
20495
|
+
});
|
|
20496
|
+
}
|
|
20497
|
+
});
|
|
20498
|
+
var MAX_LEADERBOARDS = 4;
|
|
20499
|
+
var LEADERBOARD_FORMATS = ["points", "time_ms", "distance_m"];
|
|
20500
|
+
var leaderboardSchema = external_exports.object({
|
|
20501
|
+
id: external_exports.string().regex(
|
|
20502
|
+
/^[a-z0-9][a-z0-9-]{0,31}$/,
|
|
20503
|
+
"Use up to 32 lowercase letters, digits, and hyphens, starting with a letter or digit."
|
|
20504
|
+
),
|
|
20505
|
+
label: external_exports.string().trim().min(1).max(40),
|
|
20506
|
+
order: external_exports.enum(["desc", "asc"]).default("desc"),
|
|
20507
|
+
format: external_exports.enum(LEADERBOARD_FORMATS).default("points"),
|
|
20508
|
+
min: external_exports.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).default(0),
|
|
20509
|
+
max: external_exports.number().int().positive().max(Number.MAX_SAFE_INTEGER)
|
|
20510
|
+
}).superRefine((b, ctx) => {
|
|
20511
|
+
if (b.min >= b.max)
|
|
20512
|
+
ctx.addIssue({ code: "custom", path: ["max"], message: "Set max above min." });
|
|
20415
20513
|
});
|
|
20416
20514
|
var relPath = external_exports.string().min(1, { abort: true }).max(300).refine(
|
|
20417
20515
|
(p) => !p.startsWith("/") && !p.split("/").includes(".."),
|
|
@@ -20423,17 +20521,30 @@ var MEDIA_CONTENT_TYPES = {
|
|
|
20423
20521
|
jpeg: "image/jpeg",
|
|
20424
20522
|
webp: "image/webp",
|
|
20425
20523
|
mp4: "video/mp4",
|
|
20426
|
-
webm: "video/webm"
|
|
20524
|
+
webm: "video/webm",
|
|
20525
|
+
// Only for preview_gif: the CLI makes it from the demo for emails and link previews.
|
|
20526
|
+
gif: "image/gif"
|
|
20427
20527
|
};
|
|
20428
20528
|
var ext = (p) => p.split(".").pop()?.toLowerCase() ?? "";
|
|
20429
20529
|
var imagePath = relPath.refine(
|
|
20430
20530
|
(p) => ["png", "jpg", "jpeg", "webp"].includes(ext(p)),
|
|
20431
20531
|
"Use a .png, .jpg, or .webp image."
|
|
20432
20532
|
);
|
|
20533
|
+
var gifPath = relPath.refine((p) => ext(p) === "gif", "Use a .gif made from the demo.");
|
|
20433
20534
|
var videoPath = relPath.refine(
|
|
20434
20535
|
(p) => ["mp4", "webm"].includes(ext(p)),
|
|
20435
20536
|
"Use an .mp4 or .webm video."
|
|
20436
20537
|
);
|
|
20538
|
+
var sourceSchema = external_exports.object({
|
|
20539
|
+
repo: external_exports.string().regex(
|
|
20540
|
+
/^https:\/\/github\.com\/[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]{1,100}$/,
|
|
20541
|
+
"Use the repo's address, like https://github.com/owner/repo."
|
|
20542
|
+
),
|
|
20543
|
+
owner_id: external_exports.number().int().positive(),
|
|
20544
|
+
author: external_exports.string().trim().min(1).max(100),
|
|
20545
|
+
license: external_exports.enum(IMPORT_LICENSES),
|
|
20546
|
+
license_file: relPath
|
|
20547
|
+
});
|
|
20437
20548
|
function mediaContentType(path) {
|
|
20438
20549
|
return MEDIA_CONTENT_TYPES[ext(path)] ?? null;
|
|
20439
20550
|
}
|
|
@@ -20453,18 +20564,26 @@ var arcadeSchema = external_exports.object({
|
|
|
20453
20564
|
description: external_exports.string().trim().min(1).max(2e3),
|
|
20454
20565
|
tags: external_exports.array(external_exports.string().trim().toLowerCase().min(1).max(30)).max(12).default([]),
|
|
20455
20566
|
controls: external_exports.string().max(1e3).optional(),
|
|
20456
|
-
input: inputSchema.default({ keyboard_mouse: true, gamepad: false, touch: false }),
|
|
20567
|
+
input: inputSchema.default({ keyboard_mouse: true, gamepad: false, touch: false, tilt: false }),
|
|
20457
20568
|
// The game saves progress to the player's profile through the arcade page (arcade-saves.js
|
|
20458
20569
|
// in the arcade-building-games skill). A creator's claim, like input.gamepad.
|
|
20459
20570
|
profile_saves: external_exports.boolean().default(false),
|
|
20571
|
+
// Every game here is MIT (license.ts). Saying so is optional; anything else is refused.
|
|
20572
|
+
license: external_exports.literal(GAME_LICENSE).default(GAME_LICENSE),
|
|
20573
|
+
leaderboards: external_exports.array(leaderboardSchema).max(MAX_LEADERBOARDS).default([]),
|
|
20460
20574
|
// Where the playable build lives inside the upload (e.g. "dist" for a Vite game).
|
|
20461
20575
|
play: external_exports.object({ root: external_exports.string().default("."), entry: external_exports.string().default("index.html") }).default({ root: ".", entry: "index.html" }),
|
|
20462
20576
|
thumbnail: imagePath,
|
|
20463
20577
|
screenshots: external_exports.array(imagePath).min(1).max(12),
|
|
20464
20578
|
demo_video: videoPath.optional(),
|
|
20465
20579
|
preview_video: videoPath.optional(),
|
|
20580
|
+
// A short looping GIF cut from the demo (`arcade publish` makes it), for places a video can't
|
|
20581
|
+
// play: "someone remixed your game" emails and link previews. The CLI requires it whenever
|
|
20582
|
+
// there's a demo.
|
|
20583
|
+
preview_gif: gifPath.optional(),
|
|
20466
20584
|
lineage: lineageSchema.default({ kind: "original", parents: [] }),
|
|
20467
|
-
provenance: provenanceSchema.default({ subagent_models: [] })
|
|
20585
|
+
provenance: provenanceSchema.default({ subagent_models: [] }),
|
|
20586
|
+
source: sourceSchema.optional()
|
|
20468
20587
|
}).superRefine((a, ctx) => {
|
|
20469
20588
|
if (a.lineage.parents.some((p) => p.slug === a.slug)) {
|
|
20470
20589
|
ctx.addIssue({
|
|
@@ -20473,6 +20592,15 @@ var arcadeSchema = external_exports.object({
|
|
|
20473
20592
|
message: "A game can't be its own parent."
|
|
20474
20593
|
});
|
|
20475
20594
|
}
|
|
20595
|
+
const ids = a.leaderboards.map((b) => b.id);
|
|
20596
|
+
ids.forEach((id, i) => {
|
|
20597
|
+
if (ids.indexOf(id) !== i)
|
|
20598
|
+
ctx.addIssue({
|
|
20599
|
+
code: "custom",
|
|
20600
|
+
path: ["leaderboards", i, "id"],
|
|
20601
|
+
message: `Two leaderboards are called "${id}". Give each its own id.`
|
|
20602
|
+
});
|
|
20603
|
+
});
|
|
20476
20604
|
});
|
|
20477
20605
|
var friendlyIssue = (iss) => {
|
|
20478
20606
|
const path = iss.path ?? [];
|
|
@@ -20494,6 +20622,8 @@ var friendlyIssue = (iss) => {
|
|
|
20494
20622
|
if (limit) return `Descriptions can be up to ${limit} characters.`;
|
|
20495
20623
|
if (missing) return "Add a short description so players know what they're in for.";
|
|
20496
20624
|
break;
|
|
20625
|
+
case "license":
|
|
20626
|
+
return MIT_ONLY;
|
|
20497
20627
|
case "tags":
|
|
20498
20628
|
if (limit) return `Use up to ${limit} tags.`;
|
|
20499
20629
|
break;
|
|
@@ -20516,6 +20646,18 @@ var friendlyIssue = (iss) => {
|
|
|
20516
20646
|
case "lineage.parents.*.generation":
|
|
20517
20647
|
case "lineage.based_on.generation":
|
|
20518
20648
|
return "That generation id doesn't look right. Re-run the arcade command that made this folder.";
|
|
20649
|
+
case "leaderboards":
|
|
20650
|
+
if (limit) return `A game can have up to ${limit} leaderboards.`;
|
|
20651
|
+
break;
|
|
20652
|
+
case "leaderboards.*.label":
|
|
20653
|
+
if (limit) return `Leaderboard labels can be up to ${limit} characters.`;
|
|
20654
|
+
if (missing) return "Add a label for this leaderboard.";
|
|
20655
|
+
break;
|
|
20656
|
+
case "leaderboards.*.max":
|
|
20657
|
+
if (iss.input === void 0) return "Add a max: the best score a real player could reach.";
|
|
20658
|
+
break;
|
|
20659
|
+
case "source.license":
|
|
20660
|
+
return IMPORT_RULE;
|
|
20519
20661
|
case "provenance.orchestrator.model":
|
|
20520
20662
|
case "provenance.subagent_models.*.model":
|
|
20521
20663
|
if (missing) return "Add the model's name.";
|
|
@@ -20913,6 +21055,15 @@ function writeArcade(dir, arcade) {
|
|
|
20913
21055
|
writeFileSync2(join2(dir, "arcade.json"), `${JSON.stringify(arcade, null, 2)}
|
|
20914
21056
|
`);
|
|
20915
21057
|
}
|
|
21058
|
+
function writeLicense(dir) {
|
|
21059
|
+
const handle = savedLogin()?.handle;
|
|
21060
|
+
const holder = handle ? `@${handle}` : LICENSE_HOLDER_PLACEHOLDER;
|
|
21061
|
+
writeFileSync2(join2(dir, "LICENSE"), mitLicense((/* @__PURE__ */ new Date()).getFullYear(), holder));
|
|
21062
|
+
}
|
|
21063
|
+
function addSourceLicense(src, into) {
|
|
21064
|
+
if (src.license && !src.files.some((f) => isLicenseFile(f.path)))
|
|
21065
|
+
writeFileSync2(join2(into, "LICENSE"), src.license);
|
|
21066
|
+
}
|
|
20916
21067
|
function derived2(src, overrides) {
|
|
20917
21068
|
const a = src.arcade ?? {};
|
|
20918
21069
|
const keep = [
|
|
@@ -20922,11 +21073,13 @@ function derived2(src, overrides) {
|
|
|
20922
21073
|
"controls",
|
|
20923
21074
|
"input",
|
|
20924
21075
|
"profile_saves",
|
|
21076
|
+
"leaderboards",
|
|
20925
21077
|
"play",
|
|
20926
21078
|
"thumbnail",
|
|
20927
21079
|
"screenshots",
|
|
20928
21080
|
"demo_video",
|
|
20929
|
-
"preview_video"
|
|
21081
|
+
"preview_video",
|
|
21082
|
+
"preview_gif"
|
|
20930
21083
|
];
|
|
20931
21084
|
const out2 = { schema: "arcade/v0" };
|
|
20932
21085
|
for (const k of keep) if (a[k] !== void 0) out2[k] = a[k];
|
|
@@ -20941,6 +21094,7 @@ async function pull(slug, dirArg, opts) {
|
|
|
20941
21094
|
const dir = resolve(dirArg ?? slug);
|
|
20942
21095
|
ensureEmptyDir(dir);
|
|
20943
21096
|
const n = await download(src, dir);
|
|
21097
|
+
addSourceLicense(src, dir);
|
|
20944
21098
|
writeArcade(
|
|
20945
21099
|
dir,
|
|
20946
21100
|
derived2(src, {
|
|
@@ -20959,6 +21113,15 @@ async function fork(slug, dirArg, opts) {
|
|
|
20959
21113
|
const dir = resolve(dirArg ?? newSlug);
|
|
20960
21114
|
ensureEmptyDir(dir);
|
|
20961
21115
|
const n = await download(src, dir);
|
|
21116
|
+
const theirs = src.files.filter((f) => isLicenseFile(f.path));
|
|
21117
|
+
const kept = theirs.length > 0 || !!src.license;
|
|
21118
|
+
if (kept) {
|
|
21119
|
+
const into = join2(dir, "licenses", src.game.slug);
|
|
21120
|
+
mkdirSync2(into, { recursive: true });
|
|
21121
|
+
for (const f of theirs) renameSync2(join2(dir, f.path), join2(into, f.path));
|
|
21122
|
+
addSourceLicense(src, into);
|
|
21123
|
+
}
|
|
21124
|
+
writeLicense(dir);
|
|
20962
21125
|
writeArcade(
|
|
20963
21126
|
dir,
|
|
20964
21127
|
derived2(src, {
|
|
@@ -20975,6 +21138,11 @@ async function fork(slug, dirArg, opts) {
|
|
|
20975
21138
|
`Forked ${bold(src.game.title)} v${src.version} by @${src.game.owner} (${plural(n, "file")}) into ${dir}`
|
|
20976
21139
|
);
|
|
20977
21140
|
placeholderLine(newSlug);
|
|
21141
|
+
out(
|
|
21142
|
+
dim(
|
|
21143
|
+
`LICENSE is yours (MIT, like every game here).${kept ? ` ${src.game.title}'s is in licenses/${src.game.slug}/. Keep it.` : ""}`
|
|
21144
|
+
)
|
|
21145
|
+
);
|
|
20978
21146
|
out(dim("The arcade-remix-and-blend skill covers how to fork well."));
|
|
20979
21147
|
next(
|
|
20980
21148
|
`${cd(dirArg ?? newSlug)}arcade dev to play it first, then make it yours and run arcade publish --dry-run`
|
|
@@ -20988,7 +21156,10 @@ async function blend(slugs, opts) {
|
|
|
20988
21156
|
const newSlug = await freeSlug(blendSlugBase(sources.map((s) => s.game.slug)));
|
|
20989
21157
|
const dir = resolve(opts.into ?? newSlug);
|
|
20990
21158
|
ensureEmptyDir(dir);
|
|
20991
|
-
for (const src of sources)
|
|
21159
|
+
for (const src of sources) {
|
|
21160
|
+
await download(src, join2(dir, "parents", src.game.slug));
|
|
21161
|
+
addSourceLicense(src, join2(dir, "parents", src.game.slug));
|
|
21162
|
+
}
|
|
20992
21163
|
const lines = [
|
|
20993
21164
|
`# Blend: ${sources.map((s) => s.game.title).join(" + ")}`,
|
|
20994
21165
|
"",
|
|
@@ -20999,10 +21170,11 @@ async function blend(slugs, opts) {
|
|
|
20999
21170
|
- Its prompt: ${s.prompt.slice(0, 400).replace(/\n/g, " ")}${s.prompt.length > 400 ? "\u2026" : ""}` : ""}`
|
|
21000
21171
|
),
|
|
21001
21172
|
"",
|
|
21002
|
-
"When it's playable, delete `parents/` (or keep only what you use), fill in arcade.json, and run `arcade publish`."
|
|
21173
|
+
"When it's playable, move each parent's LICENSE to `licenses/<slug>/` (every game here is MIT, and MIT asks that it stays with the code), delete `parents/` (or keep only what you use), fill in arcade.json, and run `arcade publish`."
|
|
21003
21174
|
];
|
|
21004
21175
|
writeFileSync2(join2(dir, "BLEND.md"), `${lines.join("\n")}
|
|
21005
21176
|
`);
|
|
21177
|
+
writeLicense(dir);
|
|
21006
21178
|
writeArcade(dir, {
|
|
21007
21179
|
schema: "arcade/v0",
|
|
21008
21180
|
slug: newSlug,
|
|
@@ -21028,6 +21200,7 @@ async function blend(slugs, opts) {
|
|
|
21028
21200
|
);
|
|
21029
21201
|
}
|
|
21030
21202
|
var REGEN_SAVES_NOTE = `The original saves players' progress to their profiles. To save it in yours too, copy arcade-saves.js from the arcade-building-games skill, set "profile_saves": true, and follow that skill's Profile saves rules. Use a save key of your own, and list the original's in "from" only if this prompt describes its save format. A regen by someone other than the owner saves to profiles once the owner makes it main.`;
|
|
21203
|
+
var regenScoresNote = (boards) => `The original posts scores to global leaderboards (${boards.join(", ")}). To post to them from yours too, copy arcade-scores.js from the arcade-building-games skill, list boards with the same ids under "leaderboards", and follow that skill's Leaderboards rules. A regen by someone other than the owner posts scores once the owner makes it main.`;
|
|
21031
21204
|
async function regen(slug, dirArg, opts) {
|
|
21032
21205
|
const src = await fetchSource(slug, opts.generation);
|
|
21033
21206
|
if (!src.prompt) {
|
|
@@ -21038,13 +21211,18 @@ async function regen(slug, dirArg, opts) {
|
|
|
21038
21211
|
const dir = resolve(dirArg ?? `${slug}-regen`);
|
|
21039
21212
|
ensureEmptyDir(dir);
|
|
21040
21213
|
mkdirSync2(join2(dir, "media"), { recursive: true });
|
|
21214
|
+
writeLicense(dir);
|
|
21041
21215
|
const saves = src.arcade?.profile_saves === true;
|
|
21216
|
+
const boards = Array.isArray(src.arcade?.leaderboards) ? src.arcade.leaderboards.map((b) => String(b?.id ?? "")) : [];
|
|
21217
|
+
const scoresNote = boards.length ? regenScoresNote(boards) : null;
|
|
21042
21218
|
writeFileSync2(
|
|
21043
21219
|
join2(dir, "PROMPT.md"),
|
|
21044
21220
|
`# Regen of ${src.game.title} v${src.version}
|
|
21045
21221
|
|
|
21046
21222
|
Build this game from scratch with your own model. ${saves ? `${REGEN_SAVES_NOTE}
|
|
21047
21223
|
|
|
21224
|
+
` : ""}${scoresNote ? `${scoresNote}
|
|
21225
|
+
|
|
21048
21226
|
` : ""}This is the original prompt, word for word:
|
|
21049
21227
|
|
|
21050
21228
|
---
|
|
@@ -21058,13 +21236,16 @@ ${src.prompt}
|
|
|
21058
21236
|
slug: src.game.slug,
|
|
21059
21237
|
lineage: { kind: "regen", based_on: { generation: src.generation, version: src.version } },
|
|
21060
21238
|
provenance: { process: "one-shot", prompt: src.prompt, subagent_models: [] },
|
|
21061
|
-
profile_saves: void 0
|
|
21239
|
+
profile_saves: void 0,
|
|
21062
21240
|
// dropped when written: a fresh build hasn't claimed it yet
|
|
21241
|
+
leaderboards: void 0
|
|
21242
|
+
// the same
|
|
21063
21243
|
})
|
|
21064
21244
|
);
|
|
21065
21245
|
out(`Ready to regen ${bold(src.game.title)} v${src.version} in ${dir}`);
|
|
21066
21246
|
out("The prompt is in PROMPT.md. No code was downloaded, so build it fresh.");
|
|
21067
21247
|
if (saves) out(yellow(REGEN_SAVES_NOTE));
|
|
21248
|
+
if (scoresNote) out(yellow(scoresNote));
|
|
21068
21249
|
out(
|
|
21069
21250
|
dim(
|
|
21070
21251
|
`Your generation joins v${src.version}'s stack and shows on your profile. The owner picks the main one.`
|
|
@@ -21088,7 +21269,7 @@ function newGame(slug, dirArg) {
|
|
|
21088
21269
|
description: STARTER_DESCRIPTION,
|
|
21089
21270
|
tags: [],
|
|
21090
21271
|
controls: STARTER_CONTROLS,
|
|
21091
|
-
input: { keyboard_mouse: true, gamepad: false, touch: false },
|
|
21272
|
+
input: { keyboard_mouse: true, gamepad: false, touch: false, tilt: false },
|
|
21092
21273
|
play: { root: ".", entry: "index.html" },
|
|
21093
21274
|
thumbnail: "media/thumbnail.png",
|
|
21094
21275
|
screenshots: ["media/screenshot-1.png"],
|
|
@@ -21116,6 +21297,7 @@ function newGame(slug, dirArg) {
|
|
|
21116
21297
|
`// ${title}: start here. Keep every URL relative and load nothing from the network.
|
|
21117
21298
|
`
|
|
21118
21299
|
);
|
|
21300
|
+
writeLicense(dir);
|
|
21119
21301
|
out(`Started ${bold(title)} in ${dir}`);
|
|
21120
21302
|
out(dim("Read the arcade-building-games skill before you write code."));
|
|
21121
21303
|
next(`${cd(dirArg ?? slug)}arcade dev, build your game, then arcade publish --dry-run`);
|
|
@@ -21199,7 +21381,7 @@ function dev(dirArg, opts) {
|
|
|
21199
21381
|
|
|
21200
21382
|
// src/commands/publish.ts
|
|
21201
21383
|
import { execFileSync } from "child_process";
|
|
21202
|
-
import { existsSync as existsSync5, readFileSync as readFileSync7, writeFileSync as writeFileSync3 } from "fs";
|
|
21384
|
+
import { existsSync as existsSync5, readFileSync as readFileSync7, statSync as statSync5, writeFileSync as writeFileSync3 } from "fs";
|
|
21203
21385
|
import { isAbsolute as isAbsolute2, join as join5, relative as relative2, resolve as resolve3, sep as sep4 } from "path";
|
|
21204
21386
|
|
|
21205
21387
|
// src/files.ts
|
|
@@ -21282,6 +21464,55 @@ function walkGame(root) {
|
|
|
21282
21464
|
return { files, skipped, skippedLinks };
|
|
21283
21465
|
}
|
|
21284
21466
|
|
|
21467
|
+
// src/fixes.ts
|
|
21468
|
+
var CAPTURE_GUIDE = 'arcade guide publishing, "Capturing media"';
|
|
21469
|
+
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}.`;
|
|
21470
|
+
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.`;
|
|
21471
|
+
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}).`;
|
|
21472
|
+
var fromDemoFix = "Run `arcade media preview` to make it from the demo.";
|
|
21473
|
+
var ARCADE_JSON_TEMPLATE = [
|
|
21474
|
+
'"schema": "arcade/v0"',
|
|
21475
|
+
'"slug": the permanent web address, 3 to 40 lowercase letters, digits, and single hyphens. Pick it with the creator.',
|
|
21476
|
+
'"title": up to 80 characters',
|
|
21477
|
+
'"description": one or two sentences a player reads before pressing play',
|
|
21478
|
+
'"controls": every key, button, and touch a player needs',
|
|
21479
|
+
'"input": { "keyboard_mouse": true, "gamepad": false, "touch": false } (true only for paths that work end to end)',
|
|
21480
|
+
`"thumbnail": "media/thumbnail.png". ${thumbnailFix("media/thumbnail.png")}`,
|
|
21481
|
+
`"screenshots": ["media/shot-1.png", "media/shot-2.png"]. Real play at 1280x720, 1 to 12 of them.`,
|
|
21482
|
+
'"play": { "root": "dist" } only if the playable build lives in a subfolder'
|
|
21483
|
+
];
|
|
21484
|
+
function explainIssue(issue2, raw) {
|
|
21485
|
+
const line = issue2.path && issue2.message.startsWith(`${issue2.path} `) ? issue2.message : `${issue2.path || "arcade.json"}: ${issue2.message}`;
|
|
21486
|
+
const fix = fixFor(issue2, raw);
|
|
21487
|
+
return fix ? `${line} Fix: ${fix}` : line;
|
|
21488
|
+
}
|
|
21489
|
+
function fixFor(issue2, raw) {
|
|
21490
|
+
const path = issue2.path;
|
|
21491
|
+
const value = (key, fallback) => typeof raw[key] === "string" && raw[key] ? raw[key] : fallback;
|
|
21492
|
+
if (path === "thumbnail")
|
|
21493
|
+
return `${thumbnailFix(value("thumbnail", "media/thumbnail.png"))} Then set "thumbnail" to that path.`;
|
|
21494
|
+
if (path === "screenshots" || path.startsWith("screenshots."))
|
|
21495
|
+
return `${screenshotFix("media/shot-1.png")} List the paths in "screenshots".`;
|
|
21496
|
+
if (path === "demo_video") return `${demoFix("media/demo.mp4")} Then set "demo_video".`;
|
|
21497
|
+
if (path === "preview_video" || path === "preview_gif") return fromDemoFix;
|
|
21498
|
+
if (path === "title") return `Set "title" to the game's name as players should see it.`;
|
|
21499
|
+
if (path === "description")
|
|
21500
|
+
return 'Set "description" to one or two sentences: the fantasy and the goal.';
|
|
21501
|
+
if (path === "slug")
|
|
21502
|
+
return 'Set "slug" to the web address you chose with the creator. It is permanent.';
|
|
21503
|
+
if (path === "schema") return 'Add "schema": "arcade/v0" at the top.';
|
|
21504
|
+
return null;
|
|
21505
|
+
}
|
|
21506
|
+
function missingMediaLine(path, raw) {
|
|
21507
|
+
if (raw.thumbnail === path) return `${path} (thumbnail): ${thumbnailFix(path)}`;
|
|
21508
|
+
if (Array.isArray(raw.screenshots) && raw.screenshots.includes(path))
|
|
21509
|
+
return `${path} (screenshot): ${screenshotFix(path)}`;
|
|
21510
|
+
if (raw.demo_video === path) return `${path} (demo): ${demoFix(path)}`;
|
|
21511
|
+
if (raw.preview_video === path) return `${path} (hover preview): ${fromDemoFix}`;
|
|
21512
|
+
if (raw.preview_gif === path) return `${path} (GIF): ${fromDemoFix}`;
|
|
21513
|
+
return path;
|
|
21514
|
+
}
|
|
21515
|
+
|
|
21285
21516
|
// src/secrets.ts
|
|
21286
21517
|
import { readFileSync as readFileSync5 } from "fs";
|
|
21287
21518
|
var MAX_SCAN_BYTES = 5 * 1024 * 1024;
|
|
@@ -21447,7 +21678,12 @@ function missingSessions(named) {
|
|
|
21447
21678
|
function readArcadeRaw(dir) {
|
|
21448
21679
|
const path = join5(dir, "arcade.json");
|
|
21449
21680
|
if (!existsSync5(path))
|
|
21450
|
-
throw new ExitError(
|
|
21681
|
+
throw new ExitError(
|
|
21682
|
+
`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):`,
|
|
21683
|
+
2,
|
|
21684
|
+
ARCADE_JSON_TEMPLATE,
|
|
21685
|
+
"Starting a brand-new game instead? arcade new <slug>"
|
|
21686
|
+
);
|
|
21451
21687
|
try {
|
|
21452
21688
|
return JSON.parse(readFileSync7(path, "utf8"));
|
|
21453
21689
|
} catch (e) {
|
|
@@ -21462,48 +21698,77 @@ function hasFfmpeg() {
|
|
|
21462
21698
|
return false;
|
|
21463
21699
|
}
|
|
21464
21700
|
}
|
|
21701
|
+
function runFfmpeg(args, what) {
|
|
21702
|
+
try {
|
|
21703
|
+
execFileSync("ffmpeg", ["-y", "-loglevel", "error", ...args], {
|
|
21704
|
+
stdio: ["ignore", "ignore", "pipe"]
|
|
21705
|
+
});
|
|
21706
|
+
} catch (e) {
|
|
21707
|
+
const last = String(e.stderr ?? "").trim().split("\n").filter(Boolean).pop();
|
|
21708
|
+
throw new ExitError(`ffmpeg couldn't make the ${what}${last ? `: ${last}` : "."}`);
|
|
21709
|
+
}
|
|
21710
|
+
}
|
|
21465
21711
|
function makePreview(dir, demo, target = "media/preview.mp4") {
|
|
21466
21712
|
if (!hasFfmpeg()) return null;
|
|
21467
|
-
|
|
21468
|
-
|
|
21469
|
-
"
|
|
21713
|
+
runFfmpeg(
|
|
21714
|
+
[
|
|
21715
|
+
"-i",
|
|
21716
|
+
join5(dir, demo),
|
|
21717
|
+
"-t",
|
|
21718
|
+
"12",
|
|
21719
|
+
"-an",
|
|
21720
|
+
"-vf",
|
|
21721
|
+
"scale=854:-2,fps=30",
|
|
21722
|
+
"-c:v",
|
|
21723
|
+
"libx264",
|
|
21724
|
+
"-preset",
|
|
21725
|
+
"veryfast",
|
|
21726
|
+
"-crf",
|
|
21727
|
+
"30",
|
|
21728
|
+
"-pix_fmt",
|
|
21729
|
+
"yuv420p",
|
|
21730
|
+
"-movflags",
|
|
21731
|
+
"+faststart",
|
|
21732
|
+
join5(dir, target)
|
|
21733
|
+
],
|
|
21734
|
+
"preview"
|
|
21735
|
+
);
|
|
21736
|
+
return target;
|
|
21737
|
+
}
|
|
21738
|
+
var MAX_GIF_BYTES = 5 * 1024 * 1024;
|
|
21739
|
+
function makeGif(dir, demo, target = "media/preview.gif") {
|
|
21740
|
+
if (!hasFfmpeg()) return null;
|
|
21741
|
+
const tries = [
|
|
21742
|
+
{ width: 480, fps: 12, seconds: 6 },
|
|
21743
|
+
{ width: 360, fps: 10, seconds: 5 }
|
|
21744
|
+
];
|
|
21745
|
+
for (const t of tries) {
|
|
21746
|
+
runFfmpeg(
|
|
21470
21747
|
[
|
|
21471
|
-
"-y",
|
|
21472
|
-
"-loglevel",
|
|
21473
|
-
"error",
|
|
21474
21748
|
"-i",
|
|
21475
21749
|
join5(dir, demo),
|
|
21476
21750
|
"-t",
|
|
21477
|
-
|
|
21751
|
+
String(t.seconds),
|
|
21478
21752
|
"-an",
|
|
21479
21753
|
"-vf",
|
|
21480
|
-
|
|
21481
|
-
"-
|
|
21482
|
-
"
|
|
21483
|
-
"-preset",
|
|
21484
|
-
"veryfast",
|
|
21485
|
-
"-crf",
|
|
21486
|
-
"30",
|
|
21487
|
-
"-pix_fmt",
|
|
21488
|
-
"yuv420p",
|
|
21489
|
-
"-movflags",
|
|
21490
|
-
"+faststart",
|
|
21754
|
+
`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`,
|
|
21755
|
+
"-loop",
|
|
21756
|
+
"0",
|
|
21491
21757
|
join5(dir, target)
|
|
21492
21758
|
],
|
|
21493
|
-
|
|
21759
|
+
"GIF"
|
|
21494
21760
|
);
|
|
21495
|
-
|
|
21496
|
-
const last = String(e.stderr ?? "").trim().split("\n").filter(Boolean).pop();
|
|
21497
|
-
throw new ExitError(`ffmpeg couldn't make the preview${last ? `: ${last}` : "."}`);
|
|
21761
|
+
if (statSync5(join5(dir, target)).size <= MAX_GIF_BYTES) return target;
|
|
21498
21762
|
}
|
|
21499
|
-
|
|
21763
|
+
throw new ExitError(
|
|
21764
|
+
`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.`
|
|
21765
|
+
);
|
|
21500
21766
|
}
|
|
21501
|
-
|
|
21502
|
-
|
|
21503
|
-
const raw = readArcadeRaw(dir);
|
|
21767
|
+
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.";
|
|
21768
|
+
function demoFor(dir, raw, what) {
|
|
21504
21769
|
if (typeof raw.demo_video !== "string")
|
|
21505
21770
|
throw new ExitError(
|
|
21506
|
-
|
|
21771
|
+
`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").`,
|
|
21507
21772
|
2
|
|
21508
21773
|
);
|
|
21509
21774
|
if (!existsSync5(join5(dir, raw.demo_video)))
|
|
@@ -21511,15 +21776,33 @@ async function mediaPreview(dirArg) {
|
|
|
21511
21776
|
`${raw.demo_video} isn't in the folder yet. Record a demo first (see arcade-publishing).`,
|
|
21512
21777
|
2
|
|
21513
21778
|
);
|
|
21514
|
-
|
|
21515
|
-
|
|
21516
|
-
|
|
21517
|
-
|
|
21518
|
-
|
|
21779
|
+
return raw.demo_video;
|
|
21780
|
+
}
|
|
21781
|
+
async function mediaPreview(dirArg) {
|
|
21782
|
+
const dir = resolve3(dirArg ?? ".");
|
|
21783
|
+
const raw = readArcadeRaw(dir);
|
|
21784
|
+
const demo = demoFor(dir, raw, "a preview");
|
|
21785
|
+
const made = makePreview(dir, demo);
|
|
21786
|
+
if (!made) throw new ExitError(NO_FFMPEG);
|
|
21787
|
+
const gif = makeGif(dir, demo);
|
|
21788
|
+
if (!gif) throw new ExitError(NO_FFMPEG);
|
|
21519
21789
|
raw.preview_video = made;
|
|
21790
|
+
raw.preview_gif = gif;
|
|
21791
|
+
writeFileSync3(join5(dir, "arcade.json"), `${JSON.stringify(raw, null, 2)}
|
|
21792
|
+
`);
|
|
21793
|
+
out(
|
|
21794
|
+
`Made ${made} and ${gif} (${mb(statSync5(join5(dir, gif)).size)}) and added them to arcade.json.`
|
|
21795
|
+
);
|
|
21796
|
+
}
|
|
21797
|
+
async function mediaGif(dirArg) {
|
|
21798
|
+
const dir = resolve3(dirArg ?? ".");
|
|
21799
|
+
const raw = readArcadeRaw(dir);
|
|
21800
|
+
const gif = makeGif(dir, demoFor(dir, raw, "a GIF"));
|
|
21801
|
+
if (!gif) throw new ExitError(NO_FFMPEG);
|
|
21802
|
+
raw.preview_gif = gif;
|
|
21520
21803
|
writeFileSync3(join5(dir, "arcade.json"), `${JSON.stringify(raw, null, 2)}
|
|
21521
21804
|
`);
|
|
21522
|
-
out(`Made ${
|
|
21805
|
+
out(`Made ${gif} (${mb(statSync5(join5(dir, gif)).size)}) and added it to arcade.json.`);
|
|
21523
21806
|
}
|
|
21524
21807
|
function describeAction(p, parentNames) {
|
|
21525
21808
|
switch (p.action) {
|
|
@@ -21564,6 +21847,11 @@ function modelCard(a) {
|
|
|
21564
21847
|
);
|
|
21565
21848
|
return lines;
|
|
21566
21849
|
}
|
|
21850
|
+
function leaderboardLines(a) {
|
|
21851
|
+
return a.leaderboards.map(
|
|
21852
|
+
(b) => ` ${b.label} (${b.id}): ${b.order === "asc" ? "lower" : "higher"} is better, ${b.min.toLocaleString("en-US")} to ${b.max.toLocaleString("en-US")} ${b.format}`
|
|
21853
|
+
);
|
|
21854
|
+
}
|
|
21567
21855
|
var when = (iso) => iso ? new Date(iso).toLocaleString("en-US", {
|
|
21568
21856
|
month: "short",
|
|
21569
21857
|
day: "numeric",
|
|
@@ -21576,7 +21864,7 @@ function listWords(words) {
|
|
|
21576
21864
|
return `${words.slice(0, -1).join(", ")}${words.length > 2 ? "," : ""} and ${words.at(-1)}`;
|
|
21577
21865
|
}
|
|
21578
21866
|
var HOME_PATH = /(?:\/Users\/|\/home\/|[A-Za-z]:\\Users\\)[^\s/\\"'`]+/;
|
|
21579
|
-
function headsUp(arcade, files, alreadyOnArcade, secretWarnings) {
|
|
21867
|
+
function headsUp(arcade, files, alreadyOnArcade, secretWarnings, dir) {
|
|
21580
21868
|
const notes = [];
|
|
21581
21869
|
const kind = arcade.lineage.kind;
|
|
21582
21870
|
const derived3 = kind === "fork" || kind === "blend" || kind === "regen";
|
|
@@ -21602,6 +21890,7 @@ function headsUp(arcade, files, alreadyOnArcade, secretWarnings) {
|
|
|
21602
21890
|
if (shots) words.push(shots === 1 ? "screenshot" : plural(shots, "screenshot"));
|
|
21603
21891
|
if (reused(arcade.demo_video)) words.push("demo");
|
|
21604
21892
|
if (reused(arcade.preview_video)) words.push("hover preview");
|
|
21893
|
+
if (reused(arcade.preview_gif)) words.push("GIF");
|
|
21605
21894
|
if (words.length) {
|
|
21606
21895
|
const one = words.length === 1 && shots <= 1;
|
|
21607
21896
|
const whose = kind === "regen" ? "the original's" : kind === "fork" ? "the parent's" : "a parent's";
|
|
@@ -21636,6 +21925,30 @@ function headsUp(arcade, files, alreadyOnArcade, secretWarnings) {
|
|
|
21636
21925
|
`${what} a home folder (${hit}). Prompts and notes are public, so edit paths out.`
|
|
21637
21926
|
);
|
|
21638
21927
|
}
|
|
21928
|
+
if (!arcade.controls?.trim())
|
|
21929
|
+
notes.push(
|
|
21930
|
+
'There are no controls. Add "controls" with every key, button, and touch a player needs.'
|
|
21931
|
+
);
|
|
21932
|
+
if (!arcade.demo_video)
|
|
21933
|
+
notes.push(
|
|
21934
|
+
`There's no demo. Phones show the demo for keyboard-only games, and cards play a preview cut from it: record 15 to 30 s of real play (${CAPTURE_GUIDE}).`
|
|
21935
|
+
);
|
|
21936
|
+
const age = (p2) => {
|
|
21937
|
+
try {
|
|
21938
|
+
return p2 ? statSync5(join5(dir, p2)).mtimeMs : null;
|
|
21939
|
+
} catch {
|
|
21940
|
+
return null;
|
|
21941
|
+
}
|
|
21942
|
+
};
|
|
21943
|
+
const demoAt = age(arcade.demo_video);
|
|
21944
|
+
const stale = [
|
|
21945
|
+
["hover preview", age(arcade.preview_video)],
|
|
21946
|
+
["GIF", age(arcade.preview_gif)]
|
|
21947
|
+
].filter(([, t]) => demoAt !== null && t !== null && t < demoAt);
|
|
21948
|
+
if (stale.length)
|
|
21949
|
+
notes.push(
|
|
21950
|
+
`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"}.`
|
|
21951
|
+
);
|
|
21639
21952
|
const thumb = files.find((f) => f.path === arcade.thumbnail);
|
|
21640
21953
|
if (thumb && thumb.size > 500 * 1024) {
|
|
21641
21954
|
const webp = arcade.thumbnail.replace(/\.[^.]+$/, ".webp");
|
|
@@ -21657,6 +21970,16 @@ function headsUp(arcade, files, alreadyOnArcade, secretWarnings) {
|
|
|
21657
21970
|
);
|
|
21658
21971
|
return notes;
|
|
21659
21972
|
}
|
|
21973
|
+
async function fillLicenseHolder(dir) {
|
|
21974
|
+
const path = join5(dir, "LICENSE");
|
|
21975
|
+
if (!existsSync5(path)) return;
|
|
21976
|
+
const text = readFileSync7(path, "utf8");
|
|
21977
|
+
if (!text.includes(LICENSE_HOLDER_PLACEHOLDER)) return;
|
|
21978
|
+
const handle = savedLogin()?.handle ?? (await request("/api/me").catch(() => null))?.user?.handle;
|
|
21979
|
+
if (!handle) return;
|
|
21980
|
+
writeFileSync3(path, text.replaceAll(LICENSE_HOLDER_PLACEHOLDER, `@${handle}`));
|
|
21981
|
+
out(dim(`Put @${handle} in LICENSE.`));
|
|
21982
|
+
}
|
|
21660
21983
|
function allowedPaths(dir, paths = []) {
|
|
21661
21984
|
return new Set(
|
|
21662
21985
|
paths.map(
|
|
@@ -21695,6 +22018,20 @@ async function publish(dirArg, opts) {
|
|
|
21695
22018
|
out(yellow(`No hover preview: ${e.message} Cards will show the thumbnail.`));
|
|
21696
22019
|
}
|
|
21697
22020
|
}
|
|
22021
|
+
if (typeof raw.demo_video === "string" && !raw.preview_gif && existsSync5(join5(dir, raw.demo_video))) {
|
|
22022
|
+
const made = makeGif(dir, raw.demo_video);
|
|
22023
|
+
if (!made)
|
|
22024
|
+
throw new ExitError(
|
|
22025
|
+
"This game has a demo, so it needs a GIF made from it, and that takes ffmpeg.",
|
|
22026
|
+
2,
|
|
22027
|
+
[NO_FFMPEG],
|
|
22028
|
+
"Then run `arcade media preview` (or publish again, which makes it for you)."
|
|
22029
|
+
);
|
|
22030
|
+
raw.preview_gif = made;
|
|
22031
|
+
writeFileSync3(join5(dir, "arcade.json"), `${JSON.stringify(raw, null, 2)}
|
|
22032
|
+
`);
|
|
22033
|
+
out(dim(`Made a GIF from the demo (${made}, ${mb(statSync5(join5(dir, made)).size)}).`));
|
|
22034
|
+
}
|
|
21698
22035
|
const prov = raw.provenance ?? {};
|
|
21699
22036
|
const orch = prov.orchestrator ?? null;
|
|
21700
22037
|
const named = opts.session ?? [];
|
|
@@ -21735,8 +22072,19 @@ async function publish(dirArg, opts) {
|
|
|
21735
22072
|
out(dim("arcade.json already has token counts, so --session wasn't used."));
|
|
21736
22073
|
}
|
|
21737
22074
|
const parsed = parseArcadeJson(raw);
|
|
21738
|
-
if (!parsed.ok)
|
|
22075
|
+
if (!parsed.ok)
|
|
22076
|
+
throw new ExitError(
|
|
22077
|
+
"arcade.json has problems:",
|
|
22078
|
+
2,
|
|
22079
|
+
parsed.issues.map((i) => explainIssue(i, raw)),
|
|
22080
|
+
"Fix every one, then run the same command again. Don't delete fields to get past a check."
|
|
22081
|
+
);
|
|
21739
22082
|
const arcade = parsed.value;
|
|
22083
|
+
if (arcade.demo_video && !arcade.preview_gif)
|
|
22084
|
+
throw new ExitError("A game with a demo needs a GIF made from it (preview_gif).", 2, [
|
|
22085
|
+
`${arcade.demo_video} (demo): record it first, then run \`arcade media preview\`.`
|
|
22086
|
+
]);
|
|
22087
|
+
await fillLicenseHolder(dir);
|
|
21740
22088
|
const { files, skipped, skippedLinks } = walkGame(dir);
|
|
21741
22089
|
const bad = files.filter((f) => !f.type);
|
|
21742
22090
|
if (bad.length) {
|
|
@@ -21746,14 +22094,22 @@ async function publish(dirArg, opts) {
|
|
|
21746
22094
|
bad.map((f) => `${f.path} (move it out of the folder, or delete it)`)
|
|
21747
22095
|
);
|
|
21748
22096
|
}
|
|
21749
|
-
const
|
|
22097
|
+
const notMit = files.filter((f) => isLicenseFile(f.path)).map((f) => licenseFileProblem(f.path, readFileSync7(f.abs, "utf8"))).filter((p) => p !== null);
|
|
22098
|
+
if (notMit.length) throw new ExitError("The game's license needs fixing:", 2, notMit);
|
|
22099
|
+
const media = [
|
|
22100
|
+
arcade.thumbnail,
|
|
22101
|
+
...arcade.screenshots,
|
|
22102
|
+
arcade.demo_video,
|
|
22103
|
+
arcade.preview_video,
|
|
22104
|
+
arcade.preview_gif
|
|
22105
|
+
];
|
|
21750
22106
|
const missingMedia = media.filter((m) => !!m && !files.some((f) => f.path === m));
|
|
21751
22107
|
if (missingMedia.length) {
|
|
21752
22108
|
throw new ExitError(
|
|
21753
22109
|
"arcade.json points at files that aren't in the folder:",
|
|
21754
22110
|
2,
|
|
21755
|
-
missingMedia,
|
|
21756
|
-
|
|
22111
|
+
missingMedia.map((m) => missingMediaLine(m, raw)),
|
|
22112
|
+
`Capture media from real play of this build (${CAPTURE_GUIDE}).`
|
|
21757
22113
|
);
|
|
21758
22114
|
}
|
|
21759
22115
|
const allowed = allowedPaths(dir, opts.allowSecret);
|
|
@@ -21787,6 +22143,24 @@ async function publish(dirArg, opts) {
|
|
|
21787
22143
|
try {
|
|
21788
22144
|
prepared = await request("/api/publish/prepare", { method: "POST", json: body });
|
|
21789
22145
|
} catch (e) {
|
|
22146
|
+
if (opts.dryRun && e instanceof ApiError && e.status === 401) {
|
|
22147
|
+
const notes2 = headsUp(arcade, files, () => false, secrets.warnings, dir);
|
|
22148
|
+
out(
|
|
22149
|
+
green(
|
|
22150
|
+
`Local checks passed for ${arcade.title} (${arcade.slug}): arcade.json, ${plural(files.length, "file")} (${mb(files.reduce((n, f) => n + f.size, 0))}), license, media, and secrets.`
|
|
22151
|
+
)
|
|
22152
|
+
);
|
|
22153
|
+
if (notes2.length) {
|
|
22154
|
+
out(bold(yellow("Heads up:")));
|
|
22155
|
+
for (const n of notes2) out(` - ${n}`);
|
|
22156
|
+
}
|
|
22157
|
+
throw new ExitError(
|
|
22158
|
+
"The arcade's own checks (the slug, the limits) and the full file list need you signed in.",
|
|
22159
|
+
1,
|
|
22160
|
+
[],
|
|
22161
|
+
"Run `arcade login`, approve it in the browser, then run `arcade publish --dry-run` again."
|
|
22162
|
+
);
|
|
22163
|
+
}
|
|
21790
22164
|
throw fromApi(e);
|
|
21791
22165
|
}
|
|
21792
22166
|
const { plan, missing } = prepared;
|
|
@@ -21802,9 +22176,14 @@ async function publish(dirArg, opts) {
|
|
|
21802
22176
|
const totalBytes = files.reduce((n, f) => n + f.size, 0);
|
|
21803
22177
|
const newFiles = files.filter(isNew);
|
|
21804
22178
|
const uploadBytes = newFiles.reduce((n, f) => n + f.size, 0);
|
|
21805
|
-
const notes = headsUp(arcade, files, onArcade, secrets.warnings);
|
|
22179
|
+
const notes = headsUp(arcade, files, onArcade, secrets.warnings, dir);
|
|
21806
22180
|
out("");
|
|
21807
22181
|
out(`${pink("\u25CF")} This will publish ${describeAction(plan, parentNames)}`);
|
|
22182
|
+
out(
|
|
22183
|
+
dim(
|
|
22184
|
+
" Published games are open source under MIT. Anything you bundle from others keeps its own license and must be yours to share."
|
|
22185
|
+
)
|
|
22186
|
+
);
|
|
21808
22187
|
out("");
|
|
21809
22188
|
out(
|
|
21810
22189
|
bold(
|
|
@@ -21824,6 +22203,11 @@ async function publish(dirArg, opts) {
|
|
|
21824
22203
|
out(bold("Model card:"));
|
|
21825
22204
|
for (const l of modelCard(arcade)) out(l);
|
|
21826
22205
|
out(dim(` ${plural(stats.linesOfCode, "line")} of code in ${plural(stats.codeFiles, "file")}`));
|
|
22206
|
+
if (arcade.leaderboards.length) {
|
|
22207
|
+
out("");
|
|
22208
|
+
out(bold("Leaderboards (scores outside these limits are refused):"));
|
|
22209
|
+
for (const l of leaderboardLines(arcade)) out(l);
|
|
22210
|
+
}
|
|
21827
22211
|
if (notes.length) {
|
|
21828
22212
|
out("");
|
|
21829
22213
|
out(bold(yellow("Heads up:")));
|
|
@@ -21846,6 +22230,7 @@ async function publish(dirArg, opts) {
|
|
|
21846
22230
|
uploadBytes,
|
|
21847
22231
|
linesOfCode: stats.linesOfCode,
|
|
21848
22232
|
codeFiles: stats.codeFiles,
|
|
22233
|
+
leaderboards: arcade.leaderboards,
|
|
21849
22234
|
headsUp: notes
|
|
21850
22235
|
};
|
|
21851
22236
|
if (opts.dryRun) {
|
|
@@ -22018,13 +22403,35 @@ function skillsDir() {
|
|
|
22018
22403
|
"The bundled skills are missing from this install. Reinstall evolutionary-arcade."
|
|
22019
22404
|
);
|
|
22020
22405
|
}
|
|
22021
|
-
function
|
|
22022
|
-
|
|
22406
|
+
function skillNames() {
|
|
22407
|
+
return readdirSync4(skillsDir()).filter((n) => n.startsWith("arcade-")).sort();
|
|
22408
|
+
}
|
|
22409
|
+
function resolveSkill(name) {
|
|
22410
|
+
const want = name.startsWith("arcade-") ? name : `arcade-${name}`;
|
|
22411
|
+
const names = skillNames();
|
|
22412
|
+
if (names.includes(want)) return want;
|
|
22413
|
+
throw new ExitError(
|
|
22414
|
+
`There's no skill called ${name}.`,
|
|
22415
|
+
2,
|
|
22416
|
+
names.map((n) => n.replace(/^arcade-/, ""))
|
|
22417
|
+
);
|
|
22418
|
+
}
|
|
22419
|
+
function guide(name, opts = {}) {
|
|
22420
|
+
if (opts.list) {
|
|
22421
|
+
for (const n of skillNames()) out(`arcade guide ${n.replace(/^arcade-/, "")}`);
|
|
22422
|
+
return;
|
|
22423
|
+
}
|
|
22424
|
+
const skill = resolveSkill(name ?? "getting-started");
|
|
22425
|
+
const text = readFileSync8(join6(skillsDir(), skill, "SKILL.md"), "utf8");
|
|
22023
22426
|
if (opts.raw) {
|
|
22024
22427
|
process.stdout.write(text);
|
|
22025
22428
|
return;
|
|
22026
22429
|
}
|
|
22027
|
-
out(
|
|
22430
|
+
out(
|
|
22431
|
+
dim(
|
|
22432
|
+
`The ${skill} skill. Read any other with arcade guide <name> (${skillNames().map((n) => n.replace(/^arcade-/, "")).join(", ")}), no restart needed.`
|
|
22433
|
+
)
|
|
22434
|
+
);
|
|
22028
22435
|
out("");
|
|
22029
22436
|
process.stdout.write(text.replace(/^---\n[\s\S]*?\n---\n+/, ""));
|
|
22030
22437
|
}
|
|
@@ -22034,7 +22441,7 @@ var TARGETS = {
|
|
|
22034
22441
|
};
|
|
22035
22442
|
function installSkills(opts) {
|
|
22036
22443
|
const src = skillsDir();
|
|
22037
|
-
const names =
|
|
22444
|
+
const names = skillNames();
|
|
22038
22445
|
const targets = opts.dir ? [resolve4(opts.dir)] : (opts.target === "all" || !opts.target ? Object.keys(TARGETS) : [opts.target]).map((t) => {
|
|
22039
22446
|
const f = TARGETS[t];
|
|
22040
22447
|
if (!f) throw new ExitError(`Unknown --target ${t}. Use claude, codex, or all.`, 2);
|
|
@@ -22048,7 +22455,7 @@ function installSkills(opts) {
|
|
|
22048
22455
|
}
|
|
22049
22456
|
out(dim(names.join(", ")));
|
|
22050
22457
|
next(
|
|
22051
|
-
"restart
|
|
22458
|
+
"no restart needed. In this chat, read a skill with arcade guide <name> (start with arcade guide onboarding). New agent sessions load them on their own."
|
|
22052
22459
|
);
|
|
22053
22460
|
}
|
|
22054
22461
|
|
|
@@ -22085,17 +22492,23 @@ Examples:
|
|
|
22085
22492
|
arcade publish --dry-run see exactly what would go public
|
|
22086
22493
|
arcade info last-signal versions, generations, and which one is main
|
|
22087
22494
|
|
|
22088
|
-
Agents: run \`arcade guide\` first.
|
|
22495
|
+
Agents: run \`arcade guide\` first. To put games someone already made on the arcade,
|
|
22496
|
+
run \`arcade guide onboarding\`. Everything you publish is public, including the source.`;
|
|
22089
22497
|
function buildProgram() {
|
|
22090
22498
|
const program = new Command().name("arcade").description(
|
|
22091
|
-
"Evolutionary Arcade: an open-source
|
|
22499
|
+
"Evolutionary Arcade: an arcade of open-source, AI-made browser games.\nPublish, update, regen, fork, and blend games with your own coding agent."
|
|
22092
22500
|
).version(VERSION).showHelpAfterError().addHelpText("after", ROOT_HELP);
|
|
22093
22501
|
program.commandsGroup("Getting started:");
|
|
22094
|
-
program.command("guide
|
|
22095
|
-
|
|
22502
|
+
program.command("guide [skill]").description(
|
|
22503
|
+
"print a skill into this chat, no restart needed (default: getting-started; agents read this first)"
|
|
22504
|
+
).option("--raw", "print the exact skill file, frontmatter included").option("--list", "list the skills you can print").action((skill, opts) => guide(skill, opts)).addHelpText(
|
|
22505
|
+
"after",
|
|
22506
|
+
"\nExamples:\n arcade guide onboarding put games you already made on the arcade\n arcade guide publishing arcade.json, media, the dry run"
|
|
22507
|
+
);
|
|
22508
|
+
program.command("skills").description("manage the bundled agent skills").command("install").description("copy the arcade skills into ~/.claude/skills and/or ~/.agents/skills").addOption(
|
|
22096
22509
|
new Option("--target <target>", "where to install").choices(["claude", "codex", "all"]).default("all")
|
|
22097
22510
|
).option("--dir <path>", "install into this folder instead").action(installSkills);
|
|
22098
|
-
program.command("login").description("sign in
|
|
22511
|
+
program.command("login").description("open the sign-in page in your browser and wait for your approval (device code)").option("--no-browser", "only print the link (also: ARCADE_NO_BROWSER=1)").action(login);
|
|
22099
22512
|
program.command("logout").description("sign out and revoke this machine's token").action(logout);
|
|
22100
22513
|
program.command("whoami").description("show who you're signed in as").option("--json", "print JSON").action(whoami);
|
|
22101
22514
|
program.helpCommand("help [command]", "show help for a command");
|
|
@@ -22127,7 +22540,11 @@ function buildProgram() {
|
|
|
22127
22540
|
"after",
|
|
22128
22541
|
"\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"
|
|
22129
22542
|
);
|
|
22130
|
-
program.command("media").description("media helpers
|
|
22543
|
+
const media = program.command("media").description("media helpers (they need ffmpeg)");
|
|
22544
|
+
media.command("preview [dir]").description(
|
|
22545
|
+
"remake media/preview.mp4 (12 s) and media/preview.gif (6 s) from your demo, after every new demo"
|
|
22546
|
+
).action(mediaPreview);
|
|
22547
|
+
media.command("gif [dir]").description("remake only media/preview.gif from your demo").action(mediaGif);
|
|
22131
22548
|
program.command("main <slug> <generation>").description("(owner) pick which generation is main for its version").action(setMain);
|
|
22132
22549
|
program.command("unpublish <slug>").description("(owner) take your game down").action((slug) => setPublished(slug, false));
|
|
22133
22550
|
program.command("republish <slug>").description("(owner) bring your game back").action((slug) => setPublished(slug, true));
|