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/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.1.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
- async function login() {
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
- out(`Open ${cyan(start.verification_uri_complete)}`);
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
- out(`Signed in as ${cyan(`@${me.user?.handle ?? "?"}`)}.`);
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
- "arcade new <slug> to start a game, or arcade fork <game> to build on one. New here? arcade guide"
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 { existsSync as existsSync3, mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync3, statSync as statSync2, writeFileSync as writeFileSync2 } from "fs";
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) await download(src, join2(dir, "parents", src.game.slug));
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(`No arcade.json in ${dir}. Start with \`arcade new <slug>\`.`, 2);
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
- try {
21468
- execFileSync(
21469
- "ffmpeg",
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
- "12",
21751
+ String(t.seconds),
21478
21752
  "-an",
21479
21753
  "-vf",
21480
- "scale=854:-2,fps=30",
21481
- "-c:v",
21482
- "libx264",
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
- { stdio: ["ignore", "ignore", "pipe"] }
21759
+ "GIF"
21494
21760
  );
21495
- } catch (e) {
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
- return target;
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
- async function mediaPreview(dirArg) {
21502
- const dir = resolve3(dirArg ?? ".");
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
- "arcade.json has no demo_video to make a preview from. Record a demo first (see arcade-publishing).",
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
- const made = makePreview(dir, raw.demo_video);
21515
- if (!made)
21516
- throw new ExitError(
21517
- "ffmpeg isn't installed. Install it (e.g. `brew install ffmpeg`) and try again."
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 ${made} and added it to arcade.json.`);
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) throw new ExitError("arcade.json has problems:", 2, parsed.errors);
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 media = [arcade.thumbnail, ...arcade.screenshots, arcade.demo_video, arcade.preview_video];
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
- "Capture media from your build, and run `arcade media preview` after a new demo (see arcade-publishing)."
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 guide(opts = {}) {
22022
- const text = readFileSync8(join6(skillsDir(), "arcade-getting-started", "SKILL.md"), "utf8");
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(dim("The arcade-getting-started skill (arcade skills install gives your agent all four)"));
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 = readdirSync4(src).filter((n) => n.startsWith("arcade-"));
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 your agent so it picks up the skills, then tell it: use the arcade-getting-started skill."
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. Everything you publish is public, including the source.`;
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 arcade of AI-made browser games.\nPublish, update, regen, fork, and blend games with your own coding agent."
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").description("print the getting-started skill (read this first if you are an agent)").option("--raw", "print the exact skill file, frontmatter included").action(guide);
22095
- program.command("skills").description("manage the bundled agent skills").command("install").description("copy the 4 arcade skills into ~/.claude/skills and/or ~/.agents/skills").addOption(
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 through your browser (device code)").action(login);
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").command("preview [dir]").description("make media/preview.mp4 (12 s) from your demo with ffmpeg").action(mediaPreview);
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));