picopilot 0.1.4 → 0.1.5
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/commands/cart-includes.d.ts +11 -0
- package/dist/commands/cart-includes.d.ts.map +1 -0
- package/dist/commands/cart-includes.js +26 -0
- package/dist/commands/cart-includes.js.map +1 -0
- package/dist/commands/export.d.ts.map +1 -1
- package/dist/commands/export.js +62 -77
- package/dist/commands/export.js.map +1 -1
- package/dist/commands/label-cart.d.ts +72 -0
- package/dist/commands/label-cart.d.ts.map +1 -0
- package/dist/commands/label-cart.js +228 -0
- package/dist/commands/label-cart.js.map +1 -0
- package/dist/commands/playtest.d.ts.map +1 -1
- package/dist/commands/playtest.js +2 -23
- package/dist/commands/playtest.js.map +1 -1
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +61 -10
- package/dist/commands/serve.js.map +1 -1
- package/dist/engine/gfx/label.d.ts +15 -9
- package/dist/engine/gfx/label.d.ts.map +1 -1
- package/dist/engine/gfx/label.js +130 -38
- package/dist/engine/gfx/label.js.map +1 -1
- package/dist/engine/pico8/session-daemon-entry.d.ts +1 -1
- package/dist/engine/pico8/session-daemon-entry.js +1 -1
- package/dist/engine/pico8/session-daemon-main.d.ts +1 -1
- package/dist/engine/pico8/session-daemon-main.js +1 -1
- package/dist/engine/pico8/session-daemon.d.ts +1 -1
- package/dist/engine/pico8/session-daemon.d.ts.map +1 -1
- package/dist/engine/pico8/session-daemon.js +19 -19
- package/dist/engine/pico8/session-daemon.js.map +1 -1
- package/dist/engine/pico8/session.d.ts +1 -1
- package/dist/engine/pico8/session.js +1 -1
- package/dist/engine/pico8/supervisor.d.ts +1 -1
- package/dist/engine/pico8/supervisor.js +1 -1
- package/dist/engine/shrinko/adapter.d.ts +1 -1
- package/package.json +2 -1
- package/src/commands/audio.test.ts +12 -0
- package/src/commands/cart-includes.ts +27 -0
- package/src/commands/export.test.ts +327 -3
- package/src/commands/export.ts +72 -86
- package/src/commands/label-cart.ts +304 -0
- package/src/commands/lint.test.ts +6 -0
- package/src/commands/minify.test.ts +6 -0
- package/src/commands/playtest.test.ts +3 -0
- package/src/commands/playtest.ts +2 -23
- package/src/commands/run.test.ts +6 -0
- package/src/commands/serve.test.ts +138 -2
- package/src/commands/serve.ts +72 -10
- package/src/commands/tokens.test.ts +19 -0
- package/src/commands/verify.test.ts +12 -0
- package/src/engine/gfx/fixtures/pico8-screenshot-384.png +0 -0
- package/src/engine/gfx/label.test.ts +467 -1
- package/src/engine/gfx/label.ts +147 -41
- package/src/engine/pico8/session-daemon-entry.ts +1 -1
- package/src/engine/pico8/session-daemon-main.ts +1 -1
- package/src/engine/pico8/session-daemon.test.ts +32 -1
- package/src/engine/pico8/session-daemon.ts +25 -17
- package/src/engine/pico8/session.test.ts +1 -1
- package/src/engine/pico8/session.ts +1 -1
- package/src/engine/pico8/shell.test.ts +1 -1
- package/src/engine/pico8/supervisor.ts +1 -1
- package/src/engine/shrinko/adapter.ts +1 -1
- package/src/engine/skills/skills.test.ts +10 -0
- package/src/skills/picopilot-debug/SKILL.md +18 -1
|
@@ -13,11 +13,14 @@ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
|
|
13
13
|
import {encodePng} from '../engine/gfx/index.js';
|
|
14
14
|
import {PICO8_PALETTE} from '../engine/gfx/index.js';
|
|
15
15
|
import type {
|
|
16
|
+
DriveCommand,
|
|
17
|
+
DriveOptions,
|
|
16
18
|
ExportOptions,
|
|
17
19
|
Pico8Adapter,
|
|
20
|
+
Pico8DriveResult,
|
|
18
21
|
Pico8ExportResult,
|
|
19
22
|
} from '../engine/pico8/index.js';
|
|
20
|
-
import {pico8NotFound} from '../engine/pico8/index.js';
|
|
23
|
+
import {BlockDecoder, pico8NotFound} from '../engine/pico8/index.js';
|
|
21
24
|
import {type Pico8AdapterFactory, registerExport} from './export.js';
|
|
22
25
|
|
|
23
26
|
/** A trivial valid cart the command reads (existence + a path to hand the adapter). */
|
|
@@ -265,6 +268,92 @@ describe('picopilot export: --label (bake a PNG in as the __label__ splash)', ()
|
|
|
265
268
|
expect(exitCode).not.toBe(0);
|
|
266
269
|
expect(stdout).toContain('128x128');
|
|
267
270
|
});
|
|
271
|
+
|
|
272
|
+
it('accepts a PICO-8 screenshot as-is (384x384, as `run` writes it) and bakes it in', async () => {
|
|
273
|
+
const shot = readFileSync(
|
|
274
|
+
new URL(
|
|
275
|
+
'../engine/gfx/fixtures/pico8-screenshot-384.png',
|
|
276
|
+
import.meta.url,
|
|
277
|
+
),
|
|
278
|
+
);
|
|
279
|
+
const labelFile = join(dir, 'shot.png');
|
|
280
|
+
writeFileSync(labelFile, shot);
|
|
281
|
+
let labeledCart = '';
|
|
282
|
+
const {exitCode} = await runExport(() => {
|
|
283
|
+
const inner = stubAdapter('writes-bundle');
|
|
284
|
+
return {
|
|
285
|
+
...inner,
|
|
286
|
+
export: async (o: ExportOptions) => {
|
|
287
|
+
labeledCart = readFileSync(o.cartPath, 'utf8');
|
|
288
|
+
return inner.export(o);
|
|
289
|
+
},
|
|
290
|
+
} as Pico8Adapter;
|
|
291
|
+
}, ['export', cartPath, join(dir, 'out'), '--label', labelFile, '--json']);
|
|
292
|
+
expect(exitCode).toBe(0);
|
|
293
|
+
const label = labeledCart.split('__label__\n')[1]!.split('\n');
|
|
294
|
+
// The fixture: red (8) top-left quadrant, blue (12) bottom-right, dark blue (1) bg.
|
|
295
|
+
expect(label[10]![10]).toBe('8');
|
|
296
|
+
expect(label[100]![100]).toBe('c');
|
|
297
|
+
expect(label[100]![10]).toBe('1');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('removes the labeled copy even when the export step throws', async () => {
|
|
301
|
+
const labelFile = join(dir, 'label.png');
|
|
302
|
+
writeFileSync(labelFile, labelPng());
|
|
303
|
+
await runExport(
|
|
304
|
+
() =>
|
|
305
|
+
({
|
|
306
|
+
...stubAdapter('writes-bundle'),
|
|
307
|
+
export: async () => {
|
|
308
|
+
throw new Error('boom');
|
|
309
|
+
},
|
|
310
|
+
}) as Pico8Adapter,
|
|
311
|
+
['export', cartPath, join(dir, 'out'), '--label', labelFile, '--json'],
|
|
312
|
+
).catch(() => undefined);
|
|
313
|
+
expect(
|
|
314
|
+
readdirSync(dir).filter((f) => f.startsWith('.picopilot-export-')),
|
|
315
|
+
).toEqual([]);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('points a wrong-size label error at passing a picopilot screenshot directly', async () => {
|
|
319
|
+
const odd = encodePng({
|
|
320
|
+
width: 200,
|
|
321
|
+
height: 200,
|
|
322
|
+
rgb: new Uint8Array(200 * 200 * 3),
|
|
323
|
+
});
|
|
324
|
+
const labelFile = join(dir, 'odd.png');
|
|
325
|
+
writeFileSync(labelFile, odd);
|
|
326
|
+
const {stdout, exitCode} = await runExport(
|
|
327
|
+
() => stubAdapter('writes-bundle'),
|
|
328
|
+
['export', cartPath, join(dir, 'out'), '--label', labelFile, '--json'],
|
|
329
|
+
);
|
|
330
|
+
expect(exitCode).not.toBe(0);
|
|
331
|
+
expect(stdout).toContain('label-wrong-size');
|
|
332
|
+
expect(stdout).toContain('384x384');
|
|
333
|
+
expect(stdout.toLowerCase()).toContain('screenshot');
|
|
334
|
+
expect(stdout).toContain('scaled for you');
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('points an unsupported-PNG label error at the same fix', async () => {
|
|
338
|
+
// A valid PNG signature + IHDR claiming an interlaced image: unsupported.
|
|
339
|
+
const png = encodePng({
|
|
340
|
+
width: 128,
|
|
341
|
+
height: 128,
|
|
342
|
+
rgb: new Uint8Array(128 * 128 * 3),
|
|
343
|
+
});
|
|
344
|
+
// IHDR data starts at byte 16; the interlace byte is the 13th (offset 28).
|
|
345
|
+
png[28] = 1;
|
|
346
|
+
const labelFile = join(dir, 'interlaced.png');
|
|
347
|
+
writeFileSync(labelFile, png);
|
|
348
|
+
const {stdout, exitCode} = await runExport(
|
|
349
|
+
() => stubAdapter('writes-bundle'),
|
|
350
|
+
['export', cartPath, join(dir, 'out'), '--label', labelFile, '--json'],
|
|
351
|
+
);
|
|
352
|
+
expect(exitCode).not.toBe(0);
|
|
353
|
+
expect(stdout).toContain('label-unsupported-png');
|
|
354
|
+
expect(stdout.toLowerCase()).toContain('screenshot');
|
|
355
|
+
expect(stdout).toContain('scaled for you');
|
|
356
|
+
});
|
|
268
357
|
});
|
|
269
358
|
|
|
270
359
|
describe('picopilot export: labelless cart + failure paths', () => {
|
|
@@ -276,7 +365,7 @@ describe('picopilot export: labelless cart + failure paths', () => {
|
|
|
276
365
|
expect(exitCode).toBe(0);
|
|
277
366
|
const out = JSON.parse(stdout);
|
|
278
367
|
expect(out.labelWarning).toBe(true);
|
|
279
|
-
expect(stdout
|
|
368
|
+
expect(stdout).toContain('--label-frame 0');
|
|
280
369
|
});
|
|
281
370
|
|
|
282
371
|
it('errors when PICO-8 produced no js payload (labelless bail)', async () => {
|
|
@@ -285,7 +374,7 @@ describe('picopilot export: labelless cart + failure paths', () => {
|
|
|
285
374
|
['export', cartPath, join(dir, 'out'), '--json'],
|
|
286
375
|
);
|
|
287
376
|
expect(exitCode).not.toBe(0);
|
|
288
|
-
expect(stdout
|
|
377
|
+
expect(stdout).toContain('--label-frame 0');
|
|
289
378
|
});
|
|
290
379
|
});
|
|
291
380
|
|
|
@@ -308,3 +397,238 @@ describe('picopilot export: cart-not-found (picopilot-side error, distinct from
|
|
|
308
397
|
expect(called).toBe(false);
|
|
309
398
|
});
|
|
310
399
|
});
|
|
400
|
+
|
|
401
|
+
/** The real PICO-8 384x384 screenshot fixture: red (8) top-left, blue (12) bottom-right, dark blue (1) bg. */
|
|
402
|
+
const SHOT_FIXTURE = new URL(
|
|
403
|
+
'../engine/gfx/fixtures/pico8-screenshot-384.png',
|
|
404
|
+
import.meta.url,
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Wraps {@link stubAdapter} with a `drive` that plays the label-frame capture:
|
|
409
|
+
* records the {@link DriveOptions} and the driven cart text, then (by default)
|
|
410
|
+
* writes the screenshot fixture into `shotDir` as PICO-8 would and reports it.
|
|
411
|
+
* Also records the cart text handed to `export`.
|
|
412
|
+
*/
|
|
413
|
+
function frameAdapter(
|
|
414
|
+
seen: {
|
|
415
|
+
drive?: DriveOptions;
|
|
416
|
+
drivenCart?: string;
|
|
417
|
+
exportedCart?: string;
|
|
418
|
+
exportCalled?: boolean;
|
|
419
|
+
},
|
|
420
|
+
driveResult: 'captures' | Pico8DriveResult = 'captures',
|
|
421
|
+
labelWarning = false,
|
|
422
|
+
): Pico8Adapter {
|
|
423
|
+
const inner = stubAdapter('writes-bundle', undefined, labelWarning);
|
|
424
|
+
return {
|
|
425
|
+
...inner,
|
|
426
|
+
async drive(o: DriveOptions): Promise<Pico8DriveResult> {
|
|
427
|
+
seen.drive = o;
|
|
428
|
+
seen.drivenCart = readFileSync(o.cartPath, 'utf8');
|
|
429
|
+
if (driveResult !== 'captures') return driveResult;
|
|
430
|
+
const shot = join(o.shotDir, 'label0.png');
|
|
431
|
+
writeFileSync(shot, readFileSync(SHOT_FIXTURE));
|
|
432
|
+
return {
|
|
433
|
+
ok: true,
|
|
434
|
+
value: {screenshots: [shot], printh: '', exitReason: 'sentinel'},
|
|
435
|
+
};
|
|
436
|
+
},
|
|
437
|
+
async export(o: ExportOptions) {
|
|
438
|
+
seen.exportCalled = true;
|
|
439
|
+
seen.exportedCart = readFileSync(o.cartPath, 'utf8');
|
|
440
|
+
return inner.export(o);
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Decodes the command blocks a drive call was handed. */
|
|
446
|
+
function decodeScript(blocks: Uint8Array): DriveCommand[] {
|
|
447
|
+
return new BlockDecoder().push(blocks);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
describe('picopilot export: --label-frame (capture the label from the game itself)', () => {
|
|
451
|
+
it('advances N frames with NO input, shoots once, and bakes that frame in as the label', async () => {
|
|
452
|
+
const seen: Parameters<typeof frameAdapter>[0] = {};
|
|
453
|
+
const {stdout, exitCode} = await runExport(
|
|
454
|
+
() => frameAdapter(seen),
|
|
455
|
+
['export', cartPath, join(dir, 'out'), '--label-frame', '5', '--json'],
|
|
456
|
+
);
|
|
457
|
+
expect(exitCode).toBe(0);
|
|
458
|
+
// The script: 6 single-frame steps (frames 0..5), a SHOT after the 6th,
|
|
459
|
+
// then QUIT; the only input is "nothing held".
|
|
460
|
+
const script = decodeScript(seen.drive!.blocks);
|
|
461
|
+
expect(script.filter((c) => c.op === 'step').length).toBe(6);
|
|
462
|
+
expect(script.filter((c) => c.op === 'shot').length).toBe(1);
|
|
463
|
+
const shotAt = script.findIndex((c) => c.op === 'shot');
|
|
464
|
+
expect(script.slice(0, shotAt).filter((c) => c.op === 'step').length).toBe(
|
|
465
|
+
6,
|
|
466
|
+
);
|
|
467
|
+
expect(
|
|
468
|
+
script
|
|
469
|
+
.filter((c) => c.op === 'input')
|
|
470
|
+
.every((c) => c.op === 'input' && c.held === 0),
|
|
471
|
+
).toBe(true);
|
|
472
|
+
expect(script[script.length - 1]!.op).toBe('quit');
|
|
473
|
+
// The exported cart carries the captured frame as its label.
|
|
474
|
+
const label = seen.exportedCart!.split('__label__\n')[1]!.split('\n');
|
|
475
|
+
expect(label[10]![10]).toBe('8');
|
|
476
|
+
expect(label[100]![100]).toBe('c');
|
|
477
|
+
expect(label[100]![10]).toBe('1');
|
|
478
|
+
expect(JSON.parse(stdout).labelSource).toBe('frame');
|
|
479
|
+
// The user's cart is untouched and no temp files are left beside it.
|
|
480
|
+
expect(readFileSync(cartPath, 'utf8')).not.toContain('__label__');
|
|
481
|
+
expect(readdirSync(dir).filter((f) => f.startsWith('.'))).toEqual([]);
|
|
482
|
+
// The capture dir (PICO-8's -desktop) is removed after reading the frame.
|
|
483
|
+
expect(existsSync(seen.drive!.shotDir)).toBe(false);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('--label-frame 0 shoots right after the first step (the first drawn frame)', async () => {
|
|
487
|
+
const seen: Parameters<typeof frameAdapter>[0] = {};
|
|
488
|
+
await runExport(
|
|
489
|
+
() => frameAdapter(seen),
|
|
490
|
+
['export', cartPath, join(dir, 'out'), '--label-frame', '0', '--json'],
|
|
491
|
+
);
|
|
492
|
+
const ops = decodeScript(seen.drive!.blocks).map((c) => c.op);
|
|
493
|
+
expect(ops).toEqual(['input', 'step', 'shot', 'quit']);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it("inlines the cart's #include so the throwaway driven cart is self-contained", async () => {
|
|
497
|
+
writeFileSync(join(dir, 'main.lua'), 'function _draw() cls(9) end\n');
|
|
498
|
+
writeFileSync(
|
|
499
|
+
cartPath,
|
|
500
|
+
'pico-8 cartridge // http://www.pico-8.com\nversion 42\n__lua__\n#include main.lua\n',
|
|
501
|
+
);
|
|
502
|
+
const seen: Parameters<typeof frameAdapter>[0] = {};
|
|
503
|
+
await runExport(
|
|
504
|
+
() => frameAdapter(seen),
|
|
505
|
+
['export', cartPath, join(dir, 'out'), '--label-frame', '3', '--json'],
|
|
506
|
+
);
|
|
507
|
+
expect(seen.drivenCart).toContain('cls(9)');
|
|
508
|
+
// The driven cart is NOT the one exported: the export copy keeps the include.
|
|
509
|
+
expect(seen.exportedCart).toContain('#include main.lua');
|
|
510
|
+
expect(seen.exportedCart).not.toContain('serial(');
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("does not run the cart when it already has a label (the cart's label wins)", async () => {
|
|
514
|
+
const labeled = join(dir, 'labeled.p8');
|
|
515
|
+
writeFileSync(labeled, `${CART_TEXT}__label__\n${'0'.repeat(128)}\n`);
|
|
516
|
+
const seen: Parameters<typeof frameAdapter>[0] = {};
|
|
517
|
+
const {stdout, exitCode} = await runExport(
|
|
518
|
+
() => frameAdapter(seen),
|
|
519
|
+
['export', labeled, join(dir, 'out'), '--label-frame', '5', '--json'],
|
|
520
|
+
);
|
|
521
|
+
expect(exitCode).toBe(0);
|
|
522
|
+
expect(seen.drive).toBeUndefined();
|
|
523
|
+
expect(JSON.parse(stdout).labelSource).toBe('cart');
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it('rejects --label together with --label-frame, before running anything', async () => {
|
|
527
|
+
const labelFile = join(dir, 'label.png');
|
|
528
|
+
writeFileSync(labelFile, labelPng());
|
|
529
|
+
const seen: Parameters<typeof frameAdapter>[0] = {};
|
|
530
|
+
const {stdout, exitCode} = await runExport(
|
|
531
|
+
() => frameAdapter(seen),
|
|
532
|
+
[
|
|
533
|
+
'export',
|
|
534
|
+
cartPath,
|
|
535
|
+
join(dir, 'out'),
|
|
536
|
+
'--label',
|
|
537
|
+
labelFile,
|
|
538
|
+
'--label-frame',
|
|
539
|
+
'5',
|
|
540
|
+
'--json',
|
|
541
|
+
],
|
|
542
|
+
);
|
|
543
|
+
expect(exitCode).not.toBe(0);
|
|
544
|
+
expect(stdout).toContain('label-flags-conflict');
|
|
545
|
+
expect(seen.drive).toBeUndefined();
|
|
546
|
+
expect(seen.exportCalled).toBeUndefined();
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it('returns pico8-not-found when PICO-8 is absent for the capture (no export attempted)', async () => {
|
|
550
|
+
const seen: Parameters<typeof frameAdapter>[0] = {};
|
|
551
|
+
const {stdout, exitCode} = await runExport(
|
|
552
|
+
() => frameAdapter(seen, pico8NotFound()),
|
|
553
|
+
['export', cartPath, join(dir, 'out'), '--label-frame', '5', '--json'],
|
|
554
|
+
);
|
|
555
|
+
expect(exitCode).not.toBe(0);
|
|
556
|
+
expect(stdout).toContain('pico8-not-found');
|
|
557
|
+
expect(seen.exportCalled).toBeUndefined();
|
|
558
|
+
expect(existsSync(seen.drive!.shotDir)).toBe(false);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it('removes the capture dir and leaves no copy when the capture run throws', async () => {
|
|
562
|
+
const seen: Parameters<typeof frameAdapter>[0] = {};
|
|
563
|
+
await runExport(
|
|
564
|
+
() => ({
|
|
565
|
+
...frameAdapter(seen),
|
|
566
|
+
async drive(o: DriveOptions): Promise<Pico8DriveResult> {
|
|
567
|
+
seen.drive = o;
|
|
568
|
+
throw new Error('boom');
|
|
569
|
+
},
|
|
570
|
+
}),
|
|
571
|
+
['export', cartPath, join(dir, 'out'), '--label-frame', '5', '--json'],
|
|
572
|
+
).catch(() => undefined);
|
|
573
|
+
expect(existsSync(seen.drive!.shotDir)).toBe(false);
|
|
574
|
+
expect(readdirSync(dir).filter((f) => f.startsWith('.'))).toEqual([]);
|
|
575
|
+
expect(seen.exportCalled).toBeUndefined();
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('errors as label-frame-not-captured (with a CTA) when no frame was captured', async () => {
|
|
579
|
+
const seen: Parameters<typeof frameAdapter>[0] = {};
|
|
580
|
+
const {stdout, exitCode} = await runExport(
|
|
581
|
+
() =>
|
|
582
|
+
frameAdapter(seen, {
|
|
583
|
+
ok: true,
|
|
584
|
+
value: {
|
|
585
|
+
screenshots: [],
|
|
586
|
+
printh: 'runtime error line 3',
|
|
587
|
+
exitReason: 'timeout',
|
|
588
|
+
},
|
|
589
|
+
}),
|
|
590
|
+
['export', cartPath, join(dir, 'out'), '--label-frame', '5', '--json'],
|
|
591
|
+
);
|
|
592
|
+
expect(exitCode).not.toBe(0);
|
|
593
|
+
expect(stdout).toContain('label-frame-not-captured');
|
|
594
|
+
expect(stdout).toContain('timeout');
|
|
595
|
+
expect(stdout).toContain('runtime error line 3');
|
|
596
|
+
// The CTA reproduces the SAME no-input frame via a session (not
|
|
597
|
+
// `playtest run`, whose generic input and fixed shot frames differ).
|
|
598
|
+
expect(stdout).toContain('playtest step <id> --frames 6');
|
|
599
|
+
expect(stdout).toContain('playtest shot <id>');
|
|
600
|
+
expect(stdout).not.toContain('playtest run');
|
|
601
|
+
// A timeout also points at the backstop.
|
|
602
|
+
expect(stdout).toContain('--backstop-ms');
|
|
603
|
+
expect(seen.exportCalled).toBeUndefined();
|
|
604
|
+
expect(existsSync(seen.drive!.shotDir)).toBe(false);
|
|
605
|
+
expect(readdirSync(dir).filter((f) => f.startsWith('.'))).toEqual([]);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it('leaves an unreadable cart to PICO-8 when no label flag is passed (no throw)', async () => {
|
|
609
|
+
const dirCart = join(dir, 'weird.p8');
|
|
610
|
+
execFileSync('mkdir', [dirCart]);
|
|
611
|
+
const seen: {options?: ExportOptions} = {};
|
|
612
|
+
const {exitCode} = await runExport(
|
|
613
|
+
() => stubAdapter('writes-bundle', seen),
|
|
614
|
+
['export', dirCart, join(dir, 'out'), '--json'],
|
|
615
|
+
);
|
|
616
|
+
expect(exitCode).toBe(0);
|
|
617
|
+
expect(seen.options!.cartPath).toBe(dirCart);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it('reports labelSource for the other paths (image / none)', async () => {
|
|
621
|
+
const labelFile = join(dir, 'label.png');
|
|
622
|
+
writeFileSync(labelFile, labelPng());
|
|
623
|
+
const withImage = await runExport(
|
|
624
|
+
() => stubAdapter('writes-bundle'),
|
|
625
|
+
['export', cartPath, join(dir, 'a'), '--label', labelFile, '--json'],
|
|
626
|
+
);
|
|
627
|
+
expect(JSON.parse(withImage.stdout).labelSource).toBe('image');
|
|
628
|
+
const none = await runExport(
|
|
629
|
+
() => stubAdapter('writes-bundle', undefined, true),
|
|
630
|
+
['export', cartPath, join(dir, 'b'), '--json'],
|
|
631
|
+
);
|
|
632
|
+
expect(JSON.parse(none.stdout).labelSource).toBe('none');
|
|
633
|
+
});
|
|
634
|
+
});
|
package/src/commands/export.ts
CHANGED
|
@@ -1,22 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
existsSync,
|
|
3
|
-
mkdirSync,
|
|
4
|
-
mkdtempSync,
|
|
5
|
-
readdirSync,
|
|
6
|
-
readFileSync,
|
|
7
|
-
rmSync,
|
|
8
|
-
writeFileSync,
|
|
9
|
-
} from 'node:fs';
|
|
1
|
+
import {existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync} from 'node:fs';
|
|
10
2
|
import {tmpdir} from 'node:os';
|
|
11
|
-
import {
|
|
3
|
+
import {isAbsolute, join, resolve} from 'node:path';
|
|
12
4
|
import {type Cli, z} from 'incur';
|
|
13
|
-
import {Cart, CartParseError} from '../engine/cart/index.js';
|
|
14
|
-
import {labelHexFromPng, LabelError} from '../engine/gfx/index.js';
|
|
15
5
|
import {
|
|
16
6
|
EXPORT_HTML_NAME,
|
|
17
7
|
type Pico8Adapter,
|
|
8
|
+
type Pico8ExportResult,
|
|
18
9
|
ShellPico8Adapter,
|
|
19
10
|
} from '../engine/pico8/index.js';
|
|
11
|
+
import {prepareLabeledCart} from './label-cart.js';
|
|
20
12
|
|
|
21
13
|
/**
|
|
22
14
|
* Injects the PICO-8 adapter `export` uses (defaults to the shell adapter over
|
|
@@ -83,7 +75,15 @@ export function registerExport(
|
|
|
83
75
|
.string()
|
|
84
76
|
.optional()
|
|
85
77
|
.describe(
|
|
86
|
-
'
|
|
78
|
+
'PNG to bake in as the cart __label__ (the export splash), for a cart that has none: a `run`/`playtest` screenshot as-is (any 128*k square, e.g. 384x384, is scaled down for you) or any 128x128 PNG. Ignored if the cart already has a label.',
|
|
79
|
+
),
|
|
80
|
+
labelFrame: z
|
|
81
|
+
.number()
|
|
82
|
+
.int()
|
|
83
|
+
.min(0)
|
|
84
|
+
.optional()
|
|
85
|
+
.describe(
|
|
86
|
+
'Capture the label from the game itself: run the cart headlessly with NO input and use frame N (0-based: 0 = the first drawn frame, usually the title screen) as the __label__. Needs PICO-8; the cart need not cooperate. Exclusive with --label; ignored if the cart already has a label.',
|
|
87
87
|
),
|
|
88
88
|
payloadOnly: z
|
|
89
89
|
.boolean()
|
|
@@ -97,7 +97,7 @@ export function registerExport(
|
|
|
97
97
|
.positive()
|
|
98
98
|
.default(DEFAULT_BACKSTOP_MS)
|
|
99
99
|
.describe(
|
|
100
|
-
'Safety backstop: kill PICO-8 after this many ms if the export never completes.',
|
|
100
|
+
'Safety backstop: kill PICO-8 after this many ms if the export (or the --label-frame capture run) never completes.',
|
|
101
101
|
),
|
|
102
102
|
}),
|
|
103
103
|
// incur resolves these from the (test-overridable) env source and hands
|
|
@@ -123,6 +123,11 @@ export function registerExport(
|
|
|
123
123
|
files: z
|
|
124
124
|
.array(z.string())
|
|
125
125
|
.describe('Absolute paths of every file the bundle contains.'),
|
|
126
|
+
labelSource: z
|
|
127
|
+
.enum(['cart', 'image', 'frame', 'none'])
|
|
128
|
+
.describe(
|
|
129
|
+
"Where the bundle's label came from: the cart's own __label__, --label, --label-frame, or none.",
|
|
130
|
+
),
|
|
126
131
|
labelWarning: z
|
|
127
132
|
.boolean()
|
|
128
133
|
.describe(
|
|
@@ -136,9 +141,16 @@ export function registerExport(
|
|
|
136
141
|
args: {cart: 'game.p8', dest: './website/static/games/my-game'},
|
|
137
142
|
},
|
|
138
143
|
{
|
|
139
|
-
description:
|
|
144
|
+
description:
|
|
145
|
+
'Bake a `run` screenshot in as the label splash for a labelless cart',
|
|
140
146
|
args: {cart: 'game.p8', dest: './website/static/games/my-game'},
|
|
141
|
-
options: {label: './
|
|
147
|
+
options: {label: './shots/<name>.png'},
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
description:
|
|
151
|
+
'Capture the title screen (frame 0) as the label splash, no image needed',
|
|
152
|
+
args: {cart: 'game.p8', dest: './website/static/games/my-game'},
|
|
153
|
+
options: {labelFrame: 0},
|
|
142
154
|
},
|
|
143
155
|
{
|
|
144
156
|
description:
|
|
@@ -161,56 +173,27 @@ export function registerExport(
|
|
|
161
173
|
});
|
|
162
174
|
}
|
|
163
175
|
|
|
164
|
-
// --label <png
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const existingLabel = cart.getSection('label');
|
|
186
|
-
if (existingLabel === undefined || existingLabel.trim() === '') {
|
|
187
|
-
const hex = labelHexFromPng(readFileSync(labelPath));
|
|
188
|
-
cart.setSection('label', hex);
|
|
189
|
-
labeledCopy = join(
|
|
190
|
-
dirname(cartPath),
|
|
191
|
-
`.picopilot-export-${process.pid}-${Date.now()}.p8`,
|
|
192
|
-
);
|
|
193
|
-
writeFileSync(labeledCopy, cart.serialize());
|
|
194
|
-
exportCartPath = labeledCopy;
|
|
195
|
-
}
|
|
196
|
-
// else: cart already has a label; keep it, ignore --label.
|
|
197
|
-
} catch (e) {
|
|
198
|
-
if (e instanceof LabelError) {
|
|
199
|
-
return error({
|
|
200
|
-
code: `label-${e.code}`,
|
|
201
|
-
message: e.message,
|
|
202
|
-
exitCode: 1,
|
|
203
|
-
});
|
|
204
|
-
}
|
|
205
|
-
if (e instanceof CartParseError) {
|
|
206
|
-
return error({
|
|
207
|
-
code: `cart-${e.code}`,
|
|
208
|
-
message: `could not parse the cart to inject a label: ${e.message}`,
|
|
209
|
-
exitCode: 1,
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
throw e;
|
|
213
|
-
}
|
|
176
|
+
// --label <png> / --label-frame <n>: bake an image, or a captured frame of
|
|
177
|
+
// the running cart, in as the __label__ of a throwaway copy (the user's
|
|
178
|
+
// cart is never mutated; an existing label wins). Shared with `serve`,
|
|
179
|
+
// see label-cart.ts.
|
|
180
|
+
const adapter = adapterFactory(env as NodeJS.ProcessEnv);
|
|
181
|
+
const labeled = await prepareLabeledCart(
|
|
182
|
+
cartPath,
|
|
183
|
+
{
|
|
184
|
+
label: options.label,
|
|
185
|
+
labelFrame: options.labelFrame,
|
|
186
|
+
backstopMs: options.backstopMs,
|
|
187
|
+
},
|
|
188
|
+
(o) => adapter.drive(o),
|
|
189
|
+
);
|
|
190
|
+
if (!labeled.ok) {
|
|
191
|
+
return error({
|
|
192
|
+
code: labeled.code,
|
|
193
|
+
message: labeled.message,
|
|
194
|
+
exitCode: 1,
|
|
195
|
+
...(labeled.cta ? {cta: labeled.cta} : {}),
|
|
196
|
+
});
|
|
214
197
|
}
|
|
215
198
|
|
|
216
199
|
// Output dir: a user-given dest (point it at the showcase), else an
|
|
@@ -221,24 +204,21 @@ export function registerExport(
|
|
|
221
204
|
? args.dest
|
|
222
205
|
: resolve(process.cwd(), args.dest)
|
|
223
206
|
: mkdtempSync(join(tmpdir(), 'picopilot-export-'));
|
|
224
|
-
if (!existsSync(outDir)) mkdirSync(outDir, {recursive: true});
|
|
225
|
-
|
|
226
|
-
const adapter = adapterFactory(env as NodeJS.ProcessEnv);
|
|
227
|
-
const result = await adapter.export({
|
|
228
|
-
cartPath: exportCartPath,
|
|
229
|
-
outDir,
|
|
230
|
-
htmlName: EXPORT_HTML_NAME,
|
|
231
|
-
backstopMs: options.backstopMs,
|
|
232
|
-
});
|
|
233
207
|
|
|
234
|
-
//
|
|
235
|
-
// path
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
208
|
+
// The labeled copy lives in the user's cart dir, so remove it on EVERY
|
|
209
|
+
// path once PICO-8 has read it, including a throw from the dir setup or
|
|
210
|
+
// the adapter.
|
|
211
|
+
let result: Pico8ExportResult;
|
|
212
|
+
try {
|
|
213
|
+
if (!existsSync(outDir)) mkdirSync(outDir, {recursive: true});
|
|
214
|
+
result = await adapter.export({
|
|
215
|
+
cartPath: labeled.cartPath,
|
|
216
|
+
outDir,
|
|
217
|
+
htmlName: EXPORT_HTML_NAME,
|
|
218
|
+
backstopMs: options.backstopMs,
|
|
219
|
+
});
|
|
220
|
+
} finally {
|
|
221
|
+
labeled.cleanup();
|
|
242
222
|
}
|
|
243
223
|
|
|
244
224
|
if (!result.ok) {
|
|
@@ -259,7 +239,7 @@ export function registerExport(
|
|
|
259
239
|
return error({
|
|
260
240
|
code: 'export-failed',
|
|
261
241
|
message: labelWarning
|
|
262
|
-
? 'PICO-8 produced no bundle (the cart has no __label__ to embed). Give it one with --label <
|
|
242
|
+
? 'PICO-8 produced no bundle (the cart has no __label__ to embed). Give it one with --label-frame 0 (captures the title screen for you), or --label <screenshot.png> (a `picopilot run`/`playtest` screenshot works as-is), then re-export.'
|
|
263
243
|
: 'PICO-8 produced no export bundle. Check the cart runs without a boot error.',
|
|
264
244
|
exitCode: 1,
|
|
265
245
|
});
|
|
@@ -285,9 +265,14 @@ export function registerExport(
|
|
|
285
265
|
'The cart has no __label__, so the export splash is blank. Add one for a nicer bundle:',
|
|
286
266
|
commands: [
|
|
287
267
|
{
|
|
288
|
-
command: 'export --label
|
|
268
|
+
command: 'export <cart> <dest> --label-frame 0',
|
|
269
|
+
description:
|
|
270
|
+
'Capture the title screen (frame 0) of the running cart as the label, no image needed.',
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
command: 'export --label <screenshot.png>',
|
|
289
274
|
description:
|
|
290
|
-
'Bake a
|
|
275
|
+
'Bake a `run`/`playtest` screenshot in as the label splash (passed as-is, it is scaled for you), or capture one in PICO-8 (F7) before exporting.',
|
|
291
276
|
},
|
|
292
277
|
],
|
|
293
278
|
}
|
|
@@ -299,6 +284,7 @@ export function registerExport(
|
|
|
299
284
|
html: htmlPath,
|
|
300
285
|
js: jsPath,
|
|
301
286
|
files: [...files],
|
|
287
|
+
labelSource: labeled.labelSource,
|
|
302
288
|
labelWarning,
|
|
303
289
|
},
|
|
304
290
|
cta === undefined ? undefined : {cta},
|