@gtanczyk/diorama 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -82,6 +82,33 @@ defineAction({
82
82
  },
83
83
  });
84
84
 
85
+ defineAction({
86
+ name: 'story_look',
87
+ description: 'Set what the hero says looking at one thing (a sign, a prop, a car), adding the look line where the story has none and rewording it where it has one. '
88
+ + 'Nothing else in the story changes (story_redo would write it all again). Kept across rebuilds. A spoken line needs run_step voices afterwards.',
89
+ input: z.object({ slug: Slug, place: Slug, thing: Slug, text: z.string().trim().min(1).max(400) }),
90
+ async run({ slug, place, thing, text }, ctx) {
91
+ const files = open(slug, ctx);
92
+ const blocked = agentBlocked(files.dir, ctx.by);
93
+ if (blocked) throw new ActionError(423, blocked);
94
+ const story = readStory(files);
95
+ if (!story) throw new ActionError(409, `${slug} has no story yet: run_step story first`);
96
+ const spot = files.world().places.find((p) => p.key === place);
97
+ if (!spot) throw new ActionError(400, `${place} is not a place of ${slug}`);
98
+ if (!spot.things.some((t) => t.key === thing)) throw new ActionError(400, `${thing} is not a thing in ${place}: ${spot.things.map((t) => t.key).join(', ')}`);
99
+ const busy = ctx.jobs.busy(slug);
100
+ if (busy) throw new ActionError(409, `${busy.label} is ${busy.state}; wait before editing story text`);
101
+ new History(files.dir).snapshot(`story look ${place}/${thing}`, ctx.by);
102
+ const fixes = readStoryFixes(files);
103
+ const look = { text, at: new Date().toISOString() };
104
+ fixes.looks = { ...fixes.looks, [`${place}/${thing}`]: look };
105
+ files.write(storyFixesFile(files), StoryFixes.parse(fixes));
106
+ const now = applyTexts(story, { texts: {}, looks: { [`${place}/${thing}`]: look } }).story;
107
+ files.write(files.path('story.json'), now);
108
+ return { place, thing, text, spoken: storyLines(files.world(), now).some((l) => l.text === text) };
109
+ },
110
+ });
111
+
85
112
  defineAction({
86
113
  name: 'story_redo',
87
114
  description: 'Have Spark write the story again to a wish ("the ferrywoman should want something in return", "after receiving the key she goes to the garden and thanks me there"); '
@@ -95,7 +122,7 @@ defineAction({
95
122
  const blocked = agentBlocked(files.dir, ctx.by);
96
123
  if (blocked) throw new ActionError(423, blocked);
97
124
  return ctx.jobs.start(slug, ['story', slug], wish ? 'rewrite story' : 'first story', ctx.by, () => {
98
- files.write(storyFixesFile(files), StoryFixes.parse({ ...(wish && { wish }), texts: {} }));
125
+ files.write(storyFixesFile(files), StoryFixes.parse({ ...(wish && { wish }), texts: {}, looks: readStoryFixes(files).looks }));
99
126
  });
100
127
  },
101
128
  });
@@ -4,6 +4,7 @@ import { z } from 'zod';
4
4
  import { readCastFixes } from '../scene/cast-fixes.ts';
5
5
  import { readCorrections } from '../scene/corrections.ts';
6
6
  import { agentBlocked } from '../studio/locks.ts';
7
+ import { kidsWorld } from '../world/drawings.ts';
7
8
  import { WorldFiles } from '../world/files.ts';
8
9
  import { Slug } from '../world/schema.ts';
9
10
  import { castKeys, planLine, SCOPES, stylePlan } from '../world/style-plan.ts';
@@ -22,7 +23,7 @@ defineAction({
22
23
  const fixes = readCastFixes(files);
23
24
  return {
24
25
  style: world.style,
25
- kids: world.audience === 'kids' || fs.existsSync(files.path('drawings')),
26
+ kids: kidsWorld(files, world),
26
27
  places: Object.fromEntries(world.places.map((p) => [p.key, readCorrections(files.place(p.key)).style ?? null])),
27
28
  cast: Object.fromEntries(castKeys(world, files).map((k) => [k, fixes[k]?.style ?? null])),
28
29
  painted: readStyled(files),
@@ -53,7 +54,7 @@ defineAction({
53
54
  if (scope === 'cast' && key && !castKeys(world, files).includes(key)) throw new ActionError(404, `${slug} has no character ${key}: ${castKeys(world, files).join(', ')}`);
54
55
  if (scope === 'world' && key) throw new ActionError(400, 'scope world takes no key');
55
56
  const plan = stylePlan(files, world, scope, key);
56
- const kids = world.audience === 'kids' || fs.existsSync(files.path('drawings'));
57
+ const kids = kidsWorld(files, world);
57
58
  if (!go) {
58
59
  return {
59
60
  estimate: plan,
@@ -149,6 +149,13 @@ async function gameTake(port: number, work: string, actions: Action[], zoom: boo
149
149
  clicks.push({ wall: await rec.click(at.x, at.y, { ms: 450, dwell: 150 }), ...at, place: here, action: a });
150
150
  continue;
151
151
  }
152
+ if (a.kind === 'go' && a.to) {
153
+ // A car's destination is picked on the board the page shows over it.
154
+ const at = await page.evaluate(`window.diorama.destinationPoint(${JSON.stringify(a.to)})`) as { x: number; y: number } | null;
155
+ if (!at) throw new Error(`no destination ${a.to} on the board`);
156
+ clicks.push({ wall: await rec.click(at.x, at.y, { ms: 650 }), ...at, place: here, action: a });
157
+ continue;
158
+ }
152
159
  const step: { use: string; verb?: string; with?: string } = a.kind === 'go' ? { use: a.via } : a.kind === 'talk' ? { use: a.npc } : { use: a.thing, verb: a.verb, ...(a.with ? { with: a.with } : {}) };
153
160
  // The bag is hidden in the clip: the item is lifted from it unseen and rides the cursor to its target.
154
161
  const pick = async () => {
@@ -156,6 +156,15 @@ defineCommand({
156
156
  });
157
157
  console.log(` ${place.key}: closed ${faced.closed} walk cells on an upright face (feet over thin air)${faced.beyond ? ` and ${faced.beyond} only they led to` : ''}`);
158
158
  }
159
+ if (place.kind === 'drive') {
160
+ // A drive's scenery streams past, so everything one clicks stays put as a card of its own (a road sign
161
+ // left in the scenery would slide away from where it is clicked).
162
+ const carded = new Set(geometry.occluders.map((o) => o.thing ?? o.key));
163
+ for (const t of geometry.things) {
164
+ if (carded.has(t.key) || !t.mask || t.role === 'npc') continue;
165
+ geometry.occluders.push({ key: `${t.key}#1`, thing: t.key, box: t.box, mask: t.mask, baseY: t.box[3] });
166
+ }
167
+ }
159
168
  files.write(at('geometry.json'), geometry);
160
169
  files.write(at('walk.png'), await walkPicture(painting, geometry, out.footprint));
161
170
  ran(at(), 'derive', carry?.old ?? painting);
@@ -8,6 +8,7 @@ import { castFixesFile, readCastFixes } from '../scene/cast-fixes.ts';
8
8
  import { SoundPlan } from '../sound/plan.ts';
9
9
  import { readStoryFixes, StoryFixes, storyFixesFile, textAt } from '../story/fixes.ts';
10
10
  import { Story } from '../story/schema.ts';
11
+ import { kidsWorld } from '../world/drawings.ts';
11
12
  import { WorldFiles } from '../world/files.ts';
12
13
  import { applyWorldTexts, languagePlan, planLine, relanguage, translateTexts, worldTexts } from '../world/language.ts';
13
14
  import { placeSources, soundPlanFrom } from './sound.ts';
@@ -32,7 +33,7 @@ defineCommand({
32
33
 
33
34
  if (!plan.same) {
34
35
  const texts = worldTexts(world, story);
35
- const kids = world.audience === 'kids' || fs.existsSync(files.path('drawings'));
36
+ const kids = kidsWorld(files, world);
36
37
  console.log(`translating ${Object.keys(texts).length} texts from ${plan.from} to ${plan.to}`);
37
38
  const translated = await translateTexts(files.memo, texts, plan.from, plan.to, kids);
38
39
  const now = applyWorldTexts(world, story, translated, plan.to);
@@ -3,8 +3,10 @@ import fs from 'node:fs';
3
3
  import { renderBlockin, renderPlan } from '../scene/blockin.ts';
4
4
  import { readCorrections } from '../scene/corrections.ts';
5
5
  import { layOut } from '../scene/layout.ts';
6
+ import { framePhoto, placePicture } from '../scene/photo.ts';
6
7
  import { defineCommand } from '../registry.ts';
7
8
  import { WorldFiles } from '../world/files.ts';
9
+ import { placeSketch } from '../world/drawings.ts';
8
10
  import { approvedConcept } from '../world/sketch.ts';
9
11
 
10
12
  defineCommand({
@@ -16,10 +18,13 @@ defineCommand({
16
18
  const world = files.world();
17
19
  const places = world.places.filter((p) => only.length === 0 || only.includes(p.key));
18
20
  await Promise.all(places.map(async (place) => {
19
- // A place made from a photo is measured from it, and the photo stands in for the block-in.
20
- const photoFile = files.place(place.key, 'photo.png');
21
- const photo = fs.existsSync(photoFile) ? fs.readFileSync(photoFile) : undefined;
22
- const layout = await layOut(files.memo, world, place, photo, readCorrections(files.place(place.key)).layout?.guidance, approvedConcept(files, 'place', place.key));
21
+ // A place made from a photo is measured from it, and the photo stands in for the block-in. So does a
22
+ // grown-up's sketch of the place: drawn in perspective, it is the camera Muse would paint anyway.
23
+ const sketch = !fs.existsSync(files.place(place.key, 'photo.png')) && !fs.existsSync(files.place(place.key, 'sketch.png')) ? placeSketch(files, place.key) : undefined;
24
+ if (sketch) files.write(files.place(place.key, 'sketch.png'), (await framePhoto(sketch)).image);
25
+ const picture = placePicture(files.place(place.key));
26
+ const photo = picture && fs.readFileSync(picture.file);
27
+ const layout = await layOut(files.memo, world, place, photo, readCorrections(files.place(place.key)).layout?.guidance, approvedConcept(files, 'place', place.key), picture?.kind);
23
28
  files.write(files.place(place.key, 'layout.json'), layout);
24
29
  const blockin = photo ?? await renderBlockin(layout, place);
25
30
  files.write(files.place(place.key, 'blockin.png'), blockin);
@@ -11,6 +11,7 @@ import { locateThings, markFloors } from '../scene/locate.ts';
11
11
  import { checkMasks, type MaskCheck } from '../scene/maskcheck.ts';
12
12
  import { maskFloors, maskThings } from '../scene/masks.ts';
13
13
  import { paintingLayoutHash } from '../scene/paint-layout.ts';
14
+ import { placePicture } from '../scene/photo.ts';
14
15
  import { maskBox, type Rect } from '../image/raster.ts';
15
16
  import { WorldFiles } from '../world/files.ts';
16
17
 
@@ -30,7 +31,7 @@ defineCommand({
30
31
  const painting = fs.readFileSync(at('painting.png'));
31
32
  // A rejected/stale checked painting must not be rescued by masks and invented floor strokes.
32
33
  // Legacy worlds without a report remain editable; rebuilding from paint checks them too.
33
- if (!fs.existsSync(at('photo.png')) && fs.existsSync(at('painting-layout.json'))) {
34
+ if (!placePicture(at()) && fs.existsSync(at('painting-layout.json'))) {
34
35
  const report = files.readJson(at('painting-layout.json')) as { ok?: boolean; inputs?: string };
35
36
  if (!report.ok || report.inputs !== paintingLayoutHash(place, layout, fs.readFileSync(at('blockin.png')), painting)) {
36
37
  throw new Error(`PAINT_LAYOUT_MISMATCH ${place.key}: painting/layout changed or its composition check failed; rebuild this place from paint to inspect and repair it before masks`);
@@ -8,8 +8,9 @@ import { addsPerson, critique, paintedPeople, repaint } from '../scene/critic.ts
8
8
  import { readCorrections, writeCorrections } from '../scene/corrections.ts';
9
9
  import { Layout } from '../scene/layout.ts';
10
10
  import { paintPlace } from '../scene/paint.ts';
11
+ import { heroVehicle } from '../scene/vehicle.ts';
11
12
  import { alignPainting, PaintingLayoutError, paintingLayoutHash } from '../scene/paint-layout.ts';
12
- import { paintPhoto } from '../scene/photo.ts';
13
+ import { paintPhoto, placePicture } from '../scene/photo.ts';
13
14
  import { restylePlace } from '../scene/restyle.ts';
14
15
  import { refusedByFilter, reword, withWords, worded, type Words } from '../scene/words.ts';
15
16
  import { defineCommand, takeOption } from '../registry.ts';
@@ -21,6 +22,9 @@ import { markStyled, placeStyle, readStyled } from '../world/styled.ts';
21
22
  /** Rewords Spark may try after the image safety filter refuses a painting, before the place is set aside. */
22
23
  const REWORDS = 2;
23
24
 
25
+ /** From this new take on, a place is painted without its approved concept (see paint below). */
26
+ const CONCEPT_TAKES = 2;
27
+
24
28
  defineCommand({
25
29
  name: 'paint',
26
30
  usage: 'paint <slug> [place...] brief, paint, critique and repaint laid-out places [--take N]',
@@ -39,8 +43,12 @@ defineCommand({
39
43
  const world = { ...base, style };
40
44
  const layout = Layout.parse(files.readJson(files.place(place.key, 'layout.json')));
41
45
  const blockin = fs.readFileSync(files.place(place.key, 'blockin.png'));
42
- const fromPhoto = fs.existsSync(files.place(place.key, 'photo.png'));
43
- const concept = approvedConcept(files, 'place', place.key);
46
+ const picture = placePicture(files.place(place.key));
47
+ const fromPhoto = !!picture;
48
+ // An approved concept steers Muse's camera toward its own; when it keeps pulling the painting off the plan
49
+ // (a second new take was asked for), the place is painted from the block-in alone.
50
+ const concept = take >= CONCEPT_TAKES ? undefined : approvedConcept(files, 'place', place.key);
51
+ if (take >= CONCEPT_TAKES && approvedConcept(files, 'place', place.key)) console.log(` ${place.key}: take ${take}, painted from the block-in without its concept`);
44
52
  const styleReference = worldStyleReference(files, style);
45
53
  // The first painting is kept while its block-in, layout and take stay the same, even without the model
46
54
  // cache (a world copied without memo/): a person's region repaint must not repaint the whole place.
@@ -90,7 +98,7 @@ defineCommand({
90
98
  const again = await critique(files.memo, worded(place, words), brief, first);
91
99
  // People are looked for on the painting as the game shows it, with every fix in, not only the first one.
92
100
  const shown = fs.existsSync(files.place(place.key, 'painting.png')) ? fs.readFileSync(files.place(place.key, 'painting.png')) : first;
93
- for (const p of shown === first ? [] : await paintedPeople(files.memo, place.key, shown)) {
101
+ for (const p of shown === first || place.kind === 'drive' ? [] : await paintedPeople(files.memo, place.key, shown)) {
94
102
  again.issues.push({ key: 'person', problem: 'unwanted', region: p.box, fix: `Paint out the ${p.what}: continue what is behind them (planks, water, wall), add nothing new` });
95
103
  }
96
104
  const had = new Set(found.issues.map((i) => JSON.stringify([i.region, i.fix])));
@@ -110,9 +118,9 @@ defineCommand({
110
118
  brief = withWords(await writeBrief(files.memo, world, told, layout), words);
111
119
  files.write(files.place(place.key, 'brief.json'), brief);
112
120
  try {
113
- first = fromPhoto
114
- ? await paintPhoto(files.memo, world, told, blockin, brief, take, concept, styleReference)
115
- : await paintPlace(files.memo, world, told, layout, blockin, brief, take, drawingsIn(files, place), concept, styleReference);
121
+ first = picture
122
+ ? await paintPhoto(files.memo, world, told, blockin, brief, take, concept, styleReference, picture.kind)
123
+ : await paintPlace(files.memo, world, told, layout, blockin, brief, take, drawingsIn(files, place), concept, styleReference, await heroVehicle(files, world, place));
116
124
  break;
117
125
  } catch (error) {
118
126
  if (!refusedByFilter(error)) throw error;
@@ -13,12 +13,12 @@ import { WorldFiles } from '../world/files.ts';
13
13
  import { chromium } from './explore.ts';
14
14
  import { serveWorld } from './play.ts';
15
15
 
16
- type Step = { use: string; verb?: string; with?: string } | { act: Action } | { wait: number };
16
+ type Step = { use: string; verb?: string; with?: string; to?: string } | { act: Action } | { wait: number };
17
17
 
18
18
  /** A story action as what a player does on the page: click the thing, or pick an answer. */
19
19
  export function toStep(action: Action): Step {
20
20
  switch (action.kind) {
21
- case 'go': return { use: action.via };
21
+ case 'go': return { use: action.via, ...(action.to && { to: action.to }) };
22
22
  case 'talk': return { use: action.npc };
23
23
  case 'do': return { use: action.thing, verb: action.verb, ...(action.with ? { with: action.with } : {}) };
24
24
  default: return { act: action };
@@ -3,7 +3,7 @@ import fs from 'node:fs';
3
3
  import { defineCommand, takeOption } from '../registry.ts';
4
4
  import { describePerson, describePhoto, framePhoto, normalisePhoto } from '../scene/photo.ts';
5
5
  import { designWorld, type Extra } from '../world/design.ts';
6
- import { drawingFiles, drawingsBrief, drawnIssues, fitPlaces, keepDrawings, KIDS_STYLE, oneHero, readDrawings, readKinds } from '../world/drawings.ts';
6
+ import { drawingFiles, drawingsBrief, drawnIssues, fitPlaces, grownUpDrawings, keepDrawings, KIDS_STYLE, oneHero, readDrawings, readKinds, sketchStyle } from '../world/drawings.ts';
7
7
  import { WorldFiles } from '../world/files.ts';
8
8
  import { hearAll, Idea, ideaFromTold, keepTold } from '../world/told.ts';
9
9
  import { CreativeChoices } from '../world/idea-review.ts';
@@ -11,7 +11,7 @@ import { CreativeChoices } from '../world/idea-review.ts';
11
11
  defineCommand({
12
12
  name: 'world',
13
13
  usage: 'world <slug> "<idea>" design a world (world.json) with Muse Spark [--places N] [--photo room.jpg: a real room is the first place] [--hero-photo me.jpg: a real person is the hero] '
14
- + '[--drawings DIR: photos of children\'s drawings become the hero, NPCs, items and places] [--told DIR: recordings of them telling the story; without an idea, the idea is written from them] [--kids: a game for 5-7 year olds; on with drawings or recordings] [--language Polish: the game\'s text and voices; with recordings, the language spoken]',
14
+ + '[--drawings DIR: photos of drawings (children\'s, or a grown-up\'s sketches, which keep their own medium and make no kids\' game) become the hero, NPCs, items and places] [--told DIR: recordings of them telling the story; without an idea, the idea is written from them] [--kids: a game for 5-7 year olds; on with drawings or recordings] [--language Polish: the game\'s text and voices; with recordings, the language spoken]',
15
15
  async run(args) {
16
16
  const places = Number(takeOption(args, 'places', '4'));
17
17
  const photoFile = takeOption(args, 'photo', '');
@@ -41,7 +41,10 @@ export async function designInitialWorld(files: WorldFiles, { places, photoFile
41
41
  // A drawn place over the places asked for goes into a place as a prop: places are what makes a build long.
42
42
  const drawn = fitPlaces(await readDrawings(files.memo, files), places);
43
43
  for (const d of drawn) console.log(`drawing ${d.file}: ${d.kind} ${d.key}, ${d.looks}`);
44
- const kids = kidsFlag || drawn.length > 0 || said.length > 0;
44
+ // A grown-up's sketches make a world in their own medium, not a kids' game.
45
+ const sketched = grownUpDrawings(drawn);
46
+ const drawnStyle = sketched ? sketchStyle(drawn) : KIDS_STYLE;
47
+ const kids = kidsFlag || (drawn.length > 0 && !sketched) || said.length > 0;
45
48
 
46
49
  let idea = given;
47
50
  // The story says who leads, unless the grown-up already picked a drawing as the hero.
@@ -83,11 +86,11 @@ export async function designInitialWorld(files: WorldFiles, { places, photoFile
83
86
  const characters = drawn.filter(d => d.kind === 'hero' || d.kind === 'npc').map(d => d.key);
84
87
  const chooseHero = !!(choices?.answers.length || choices?.note) && characters.length > 1 && !drawn.some(d => readKinds(files)[d.file] === 'hero');
85
88
  const asks = [
86
- drawn.length ? drawingsBrief(drawn, chosenStyle ?? KIDS_STYLE, chooseHero) : '',
89
+ drawn.length ? drawingsBrief(drawn, chosenStyle ?? drawnStyle, chooseHero) : '',
87
90
  chosenStyle ? `The creator explicitly chose this visual style: ${chosenStyle}. Preserve recognizable shapes and identities from all source drawings.` : '',
88
91
  other ? `Write the title and pitch in ${other}, the language of the players; everything else (style, shows, mood, looks, keys) stays in English for the painters.` : '',
89
92
  // The game shows a name over each speaker: in the players' language, as they call them.
90
- other || kids ? `Add "names": {"<key>": "name"} for the hero and every npc: a short name ${other ? `in ${other} ` : ''}the players would call them by (the name the children gave them, if they gave one; else a short friendly name a child would give).${drawn.length ? ` Name every drawn item and place too, as a child would say it${other ? ` in ${other}` : ''} ("the blue hat", "the mushroom forest").` : ''}` : '',
93
+ other || kids ? `Add "names": {"<key>": "name"} for the hero and every npc: a short name ${other ? `in ${other} ` : ''}the players would call them by (the name ${kids ? 'the children' : 'the creator'} gave them, if they gave one; else a short ${kids ? 'friendly name a child would give' : 'name that fits the world'}).${drawn.length ? ` Name every drawn item and place too, as ${kids ? 'a child' : 'a player'} would say it${other ? ` in ${other}` : ''} ("the blue hat", "the mushroom forest").` : ''}` : '',
91
94
  ].filter(Boolean);
92
95
  const extra: Extra | undefined = asks.length ? { brief: asks.join('\n\n'), issues: (w) => {
93
96
  if (!chooseHero) return drawnIssues(w, drawn);
@@ -96,7 +99,7 @@ export async function designInitialWorld(files: WorldFiles, { places, photoFile
96
99
  } } : undefined;
97
100
  const designed = await designWorld(files.memo, hero ? `${idea}\nThe hero is ${hero.looks}` : idea, places, room, extra);
98
101
  if (chooseHero) oneHero(drawn, designed.hero.key);
99
- const world = { ...designed, ...(drawn.length && { style: KIDS_STYLE }), ...(chosenStyle && { style: chosenStyle }), ...(hero && { hero }), ...(other && { language: other }), ...(kids && { audience: 'kids' as const }) };
102
+ const world = { ...designed, ...(drawn.length && { style: drawnStyle }), ...(chosenStyle && { style: chosenStyle }), ...(hero && { hero }), ...(other && { language: other }), ...(kids && { audience: 'kids' as const }) };
100
103
  files.write(files.path('world.json'), world);
101
104
  if (drawingFiles(files).length) files.write(files.path('drawings.json'), drawn);
102
105
  console.log(`${world.title}: ${world.places.map((p) => p.key).join(' · ')}`);
@@ -0,0 +1,2 @@
1
+ /** What a drive place's car is called: the build clears the road under it and the page bobs it. */
2
+ export const CAR = /\b(car|cars|convertible|cab|taxi|truck|van|jeep|pickup|bus|coupe|sedan|wagon|buggy)\b/i;
@@ -1,7 +1,7 @@
1
1
  import type { Page } from 'playwright';
2
2
  import type { Action } from '../story/engine.ts';
3
3
 
4
- type Step = { use: string; verb?: string; with?: string } | { act: Action } | { wait: number };
4
+ type Step = { use: string; verb?: string; with?: string; to?: string } | { act: Action } | { wait: number };
5
5
 
6
6
  /** Follow a route through the same pointer targets, inventory and answers as a person. */
7
7
  export async function playRoute(page: Page, steps: Step[]): Promise<{ ending: string | null; frames: number; time: number }> {
@@ -20,6 +20,14 @@ export async function playRoute(page: Page, steps: Step[]): Promise<{ ending: st
20
20
  await page.locator(`#talk button[data-action=${JSON.stringify(action)}]`).click();
21
21
  } else {
22
22
  if (step.with) await page.locator(`#inventory button[data-item=${JSON.stringify(step.with)}]`).click();
23
+ if (step.to) {
24
+ // One thing that leads to several places (a car): its destination is picked on the board the page shows.
25
+ const at = await page.evaluate(`window.diorama.destinationPoint(${JSON.stringify(step.to)})`) as { x: number; y: number } | null;
26
+ if (!at) throw new Error(`No destination ${step.to} on the board for ${step.use}`);
27
+ await page.mouse.click(at.x, at.y);
28
+ await settled();
29
+ continue;
30
+ }
23
31
  const point = await aimPoint(page, step);
24
32
  if (!point) throw new Error(`No clickable pixel for ${step.use}: its pointer target is hidden or covered`);
25
33
  await page.mouse.click(point.x, point.y, { button: step.verb === 'look' ? 'right' : 'left' });
@@ -83,7 +91,9 @@ export function aimPoint(page: Page, step: { use: string; verb?: string; with?:
83
91
  const rect = canvas.getBoundingClientRect();
84
92
  const box = game.pageRect(step.use) ?? rect;
85
93
  const hits = (x, y) => {
86
- const at = game.unzoom((x - rect.left) / rect.width * canvas.width, (y - rect.top) / rect.height * canvas.height);
94
+ // In frame pixels: the canvas holds fewer when the page lowers its resolution on a slow machine.
95
+ const frame = game.place.g.frame;
96
+ const at = game.unzoom((x - rect.left) / rect.width * frame.width, (y - rect.top) / rect.height * frame.height);
87
97
  return game.thingAt(...at)?.thing.key === step.use && document.elementFromPoint(x, y) === canvas;
88
98
  };
89
99
  const cx = box.left + box.width / 2, cy = box.top + box.height / 2;
@@ -1,4 +1,5 @@
1
1
  import { STYLE_REFERENCE_PROMPT } from '../world/style-reference.ts';
2
+ import { drawers } from '../world/drawings.ts';
2
3
  // The hero and every NPC as one painted figure each: Muse paints them alone on a plain ground, SAM cuts them out.
3
4
  import sharp from 'sharp';
4
5
  import type { Mask } from '../image/mask.ts';
@@ -76,9 +77,10 @@ Style: ${world.style}
76
77
  Nothing they sat or lay on: no bed, headboard, pillow, chair or blanket. Plain flat light grey background, no floor, no shadow, no other people, no props around, no text.`;
77
78
  }
78
79
 
79
- /** For a child's drawing: the character they drew, as they drew it, made whole enough to walk. */
80
+ /** For a drawing (a child's, or an artist's sketch): the character as drawn, made whole enough to walk. */
80
81
  export function drawingPrompt(world: World, who: Who): string {
81
- return `This is a child's drawing on paper. Paint the character in it as a single full-body character for a 2D point-and-click adventure game, so the child who drew it knows it at once: the same colours, the same clothes and features, the same shapes and proportions (a big head stays big), only cleaner and whole: every part coloured in solidly, with clear outlines and no white paper showing through, a finished character, not a sketch. Standing in a relaxed walking-ready pose, seen from the side in three-quarter view, facing to the right, the whole figure visible from the top of the head down to both feet (or paws), centred, about two thirds of the image tall.
82
+ const drawn = drawers(world);
83
+ return `This is ${drawn.one} on paper. Paint the character in it as a single full-body character for a 2D point-and-click adventure game, so ${drawn.who} knows it at once: the same colours, the same clothes and features, the same shapes and proportions (a big head stays big), only cleaner and whole: every part coloured in solidly, with clear outlines and no white paper showing through, a finished character, not a sketch. Standing in a relaxed walking-ready pose, seen from the side in three-quarter view, facing to the right, the whole figure visible from the top of the head down to both feet (or paws), centred, about two thirds of the image tall.
82
84
 
83
85
  Character: ${who.looks}
84
86
  Style: ${world.style}
@@ -32,7 +32,7 @@ List at most 4 problems a player would notice, worst first:
32
32
  - missing: a listed thing is not there, or a big part of the scene above is (a tall tower, a castle with two towers, a house on top): key "scene" for that
33
33
  - wrong: it is there but does not look like its description
34
34
  - flat: it is a plain block or smear instead of a painted object
35
- - unwanted: people (any person or figure at all${cast.length ? `, even one that looks like ${cast.join(' or ')}: they are added over the picture later, so a painted one would be a double` : ''}), ${paintedAnimals(brief).length ? 'animals other than the listed ones' : 'animals'}, text, letters or a border that should not be there
35
+ - unwanted: people (any person or figure at all${place.kind === 'drive' ? ' other than the hero at the wheel of the car, who belongs there' : ''}${cast.length ? `, even one that looks like ${cast.join(' or ')}: they are added over the picture later, so a painted one would be a double` : ''}), ${paintedAnimals(brief).length ? 'animals other than the listed ones' : 'animals'}, text, letters or a border that should not be there
36
36
  For each give the region to repaint as [x0, y0, x1, y1] in pixels and one sentence on what to paint there. Never ask for text or letters. If the painting is fine, give no issues.
37
37
 
38
38
  Never ask to paint a person in: the game adds its people over the picture.
@@ -42,7 +42,8 @@ Reply with JSON only: {"issues": [{"key": "...", "problem": "missing | wrong | f
42
42
  const found = { issues: parsed.issues.filter((i) => !addsPerson(i)) };
43
43
  // The critic weighs the whole picture and lets a figure pass when the place's words name one ("a child floats
44
44
  // candle boats"); people are asked about on their own, as every one of them is a double or a stranger.
45
- const people = await paintedPeople(memo, place.key, painting);
45
+ // A drive's hero is painted at the wheel on purpose: no one is looked for there.
46
+ const people = place.kind === 'drive' ? [] : await paintedPeople(memo, place.key, painting);
46
47
  const near = (a: number[], b: number[]) => Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) < 120;
47
48
  const extra = people.filter((p) => !found.issues.some((i) => i.problem === 'unwanted' && near(i.region, p.box))).map((p) => ({
48
49
  key: 'person', problem: 'unwanted' as const, region: pad(p.box, 24),
@@ -154,6 +154,15 @@ export function closeUpIssues(layout: Layout): string[] {
154
154
  : [];
155
155
  }
156
156
 
157
+ /** A drive place: a side view of a road that loops sideways, the car standing on it, the signs beside it. */
158
+ const DRIVE_LAYOUT = `
159
+ This place is the hero's car: it stands at the kerb with its engine running, the hero at the wheel, and from here the player picks where to drive. So:
160
+ - the car is the main thing: in the middle of the frame, a little below the centre, seen from behind at three-quarters so the driver shows through the window, about half the frame wide, its wheels on the street
161
+ - one floor, the street and pavement around the car, reaching the bottom edge
162
+ - the street and the city behind it fill the rest; leave the top right quarter of the frame calm (sky, a dark wall), the game lays its destination board there
163
+
164
+ `;
165
+
157
166
  function brief(world: World, place: Place): string {
158
167
  const { width: W, height: H } = FRAME;
159
168
  return `Lay out one place of a 2D point-and-click adventure as a block-in for a painter. The frame is ${W}x${H} px, x to the right, y down; the camera looks a little down at the place, like at a stage.
@@ -169,8 +178,7 @@ Give:
169
178
  - floors: 1 to 4 walkable areas below the horizon as outlines, nearest first; the first reaches the bottom edge; every other floor touches one listed before it (a bridge, steps or a path across water or a drop is a floor too), so the hero can walk everywhere; leave room to walk around things
170
179
  - things: every thing as a box standing on the floor: x (centre), footY (where it touches the floor), width, height in px, metres (real height), colour (main colour), shape (its silhouette: block = a box, stall, cupboard; low = bench, table, boat, cart, bed; post = lamp, pole, sign, column; tree; arch = door, gate, archway, window, tunnel; round = barrel, pot, rock, bush, well; steps = stairs or steps; flat = lies on the floor: rug, water, tracks); nearer things sit lower and are bigger; exits sit at the edge of a floor; every exit, item and person stands on a floor; leave a way round every big thing, and never stand one across the only way to a floor
171
180
  - lights: up to 6 visible sources (lamps, fires, windows) with x, y, colour
172
-
173
- Reply with JSON only:
181
+ ${place.kind === 'drive' ? DRIVE_LAYOUT : ''}Reply with JSON only:
174
182
  {"horizon": 0, "sky": "#rrggbb", "ground": "#rrggbb",
175
183
  "floors": [{"key": "...", "is": "what the floor is", "colour": "#rrggbb", "outline": [[x, y], ...]}],
176
184
  "things": [{"key": "...", "colour": "#rrggbb", "x": 0, "footY": 0, "width": 0, "height": 0, "metres": 1.0, "shape": "block"}],
@@ -185,9 +193,9 @@ export function repairSmallBoxes(layout: Layout): Layout {
185
193
  })) };
186
194
  }
187
195
 
188
- /** With a photo, measure existing positions without resizing them. */
189
- export async function layOut(memo: Memo, world: World, place: Place, photo?: Buffer, guidance?: string, concept?: Buffer): Promise<Layout> {
190
- const ask = () => (photo ? `${brief(world, place)}\n\nThe place already exists: it is the attached photo, ${FRAME.width}x${FRAME.height} px. Do not place anything; measure where each thing, floor and light is in it.` : brief(world, place));
196
+ /** With a photo (or a grown-up's sketch of the place), measure existing positions without resizing them. */
197
+ export async function layOut(memo: Memo, world: World, place: Place, photo?: Buffer, guidance?: string, concept?: Buffer, picture: 'photo' | 'sketch' = 'photo'): Promise<Layout> {
198
+ const ask = () => (photo ? `${brief(world, place)}\n\nThe place already exists: it is the attached ${picture === 'sketch' ? "artist's sketch (ignore the sketchbook page, notes and colour swatches)" : 'photo'}, ${FRAME.width}x${FRAME.height} px. Do not place anything; measure where each thing, floor and light is in it.` : brief(world, place));
191
199
  const base = ask() + (!photo && concept ? '\n\nThe attached image is the creator-approved concept for this place. Start from its camera and relative arrangement of the listed things, scaled to the output frame. Preserve the recognizable composition where possible, but adapt positions, sizes and floor connections to satisfy every walkability rule above. This is a concept, not a fixed photograph; do not copy inaccessible areas or invent extra things.' : '') + (guidance ? `\n\nLayout repair guidance: ${guidance}` : '');
192
200
  // Keep paid replies. An exhausted batch becomes feedback for the next batch, never an identical replay.
193
201
  const stateFile = path.join(memo.dir, 'layout-retries', `${inputHash(base, photo ?? '', ...(!photo && concept ? [concept] : []))}.json`);
@@ -51,7 +51,8 @@ const saysBlocked = (why: string) => BLOCKED.test(why);
51
51
  /** Fail closed on incomplete observations: an omitted floor is not evidence that it is connected. */
52
52
  export function paintedLayoutIssues(place: Place, layout: Layout, seen: PaintedLayout): string[] {
53
53
  const issues: string[] = [];
54
- if (seen.people) issues.push('unwanted people painted into the background; the game adds its cast separately');
54
+ // A drive's hero is painted at the wheel of the car: the car's card carries them.
55
+ if (seen.people && place.kind !== 'drive') issues.push('unwanted people painted into the background; the game adds its cast separately');
55
56
  if (Math.abs(seen.horizon - layout.horizon) > 70) issues.push(`camera horizon moved from ${layout.horizon} to ${Math.round(seen.horizon)}`);
56
57
  for (const t of place.things.filter((t) => t.role !== 'npc')) {
57
58
  const planned = layout.things.find((b) => b.key === t.key);
@@ -1,6 +1,8 @@
1
1
  import { STYLE_REFERENCE_PROMPT } from '../world/style-reference.ts';
2
+ import { drawers } from '../world/drawings.ts';
2
3
  // paint: blockin.png becomes painting.png through one Muse edit.
3
4
  import sharp from 'sharp';
5
+ import { VEHICLE_PROMPT } from './vehicle.ts';
4
6
  import type { Memo } from '../meta/memo.ts';
5
7
  import { museEdit } from '../meta/muse.ts';
6
8
  import type { Place, World } from '../world/schema.ts';
@@ -31,6 +33,9 @@ export function whereIs(layout: Layout, box: Layout['things'][number]): string {
31
33
  return `${back < 0.3 ? 'at the back' : back < 0.65 ? 'halfway back' : 'at the front'}, ${across}`;
32
34
  }
33
35
 
36
+ /** A drive place is seen from the side at car height, and its scenery loops sideways in the page. */
37
+ const DRIVE_PAINT = (world: World) => `The hero's car stands at the kerb with its engine running, lights on, a wisp of exhaust, seen from behind at three-quarters, with the hero at the wheel visible through the window or over the open top: ${world.hero.looks}. Keep the top right quarter of the picture calm, nothing important there.`;
38
+
34
39
  export function paintPrompt(world: World, place: Place, layout: Layout, brief?: Brief): string {
35
40
  const look = (key: string, fallback: string) => brief?.looks[key] ?? fallback;
36
41
  const masses = place.things
@@ -51,7 +56,7 @@ ${[...masses, ...floors].join('\n')}
51
56
 
52
57
  ${layoutContract(place, layout)}
53
58
 
54
- Seen a little from above, like a stage. Keep the floor areas clear to walk on. No people, ${noAnimals(brief)}, no text or letters, no border.${castNote(place)}${fallsIn(`${place.shows} ${place.mood}`) ? '\nThe rain or snow itself is animated over the picture later: paint no falling streaks, drops or flakes in the air, only what the weather leaves (wet sheen, puddles, snow lying on the ground).' : ''}`;
59
+ ${place.kind === 'drive' ? DRIVE_PAINT(world) : 'Seen a little from above, like a stage. Keep the floor areas clear to walk on.'} No ${place.kind === 'drive' ? 'other ' : ''}people, ${noAnimals(brief)}, no text or letters, no border.${castNote(place)}${fallsIn(`${place.shows} ${place.mood}`) ? '\nThe rain or snow itself is animated over the picture later: paint no falling streaks, drops or flakes in the air, only what the weather leaves (wet sheen, puddles, snow lying on the ground).' : ''}`;
55
60
  }
56
61
 
57
62
  function whereFloor(layout: Layout, f: Layout['floors'][number]): string {
@@ -60,22 +65,24 @@ function whereFloor(layout: Layout, f: Layout['floors'][number]): string {
60
65
  return whereIs(layout, mid);
61
66
  }
62
67
 
63
- /** Children's drawings of the place or of things in it, as more images after the block-in. */
68
+ /** Drawings of the place or of things in it (children's, or an artist's sketches), as more images after the block-in. */
64
69
  export type Drawing = { key: string; image: Buffer };
65
70
 
66
- export function drawingsPrompt(place: Place, drawings: Drawing[]): string {
71
+ export function drawingsPrompt(place: Place, drawings: Drawing[], kids = true): string {
67
72
  if (drawings.length === 0) return '';
73
+ const drawn = drawers({ audience: kids ? 'kids' : undefined });
68
74
  const which = drawings.map((d, i) => `image ${i + 2} is ${d.key === place.key ? 'this place' : `the ${d.key}`}`);
69
75
  return `
70
76
 
71
- The other images are children's drawings on paper: ${which.join(', ')}. Paint what they drew so the children know it at once: their colours, shapes and ideas. A drawing fills its paper, but its thing does not fill the place: paint each at its real size beside the others (a hat is smaller than a log, a person comes up to a door's top). The composition still comes only from the first image; leave out the paper, the table and any written names.`;
77
+ The other images are ${drawn.many} on paper: ${which.join(', ')}. ${kids ? 'Paint what they drew so the children know it at once: their colours, shapes and ideas.' : 'Paint what was drawn so the artist knows it at once: the same shapes, colours, ideas and the same hand and medium.'} A drawing fills its paper, but its thing does not fill the place: paint each at its real size beside the others (a hat is smaller than a log, a person comes up to a door's top). The composition still comes only from the first image; leave out the paper, the table and any written names.`;
72
78
  }
73
79
 
74
80
  /** One Muse edit, scaled back to the layout frame (Muse answers larger than asked). */
75
- export async function paintPlace(memo: Memo, world: World, place: Place, layout: Layout, blockin: Buffer, brief?: Brief, take = 0, drawings: Drawing[] = [], concept?: Buffer, styleReference?: Buffer): Promise<Buffer> {
76
- const prompt = paintPrompt(world, place, layout, brief) + drawingsPrompt(place, drawings)
81
+ export async function paintPlace(memo: Memo, world: World, place: Place, layout: Layout, blockin: Buffer, brief?: Brief, take = 0, drawings: Drawing[] = [], concept?: Buffer, styleReference?: Buffer, vehicle?: Buffer): Promise<Buffer> {
82
+ const prompt = paintPrompt(world, place, layout, brief) + drawingsPrompt(place, drawings, world.audience === 'kids')
77
83
  + (styleReference ? `\nImage ${drawings.length + 2}: ${STYLE_REFERENCE_PROMPT}` : '')
78
- + (concept ? '\nThe LAST image is the creator-approved concept for this place. Preserve its distinctive architecture, palette, materials and visual identity. The FIRST image remains the authoritative layout: adapt the approved appearance to those positions and clear walking areas. Do not add characters or extra objects from the concept.' : '');
79
- const painted = await museEdit(memo, { label: `paint:${place.key}`, prompt, images: [blockin, ...drawings.map((d) => d.image), ...(styleReference ? [styleReference] : []), ...(concept ? [concept] : [])], take });
84
+ + (concept ? `\nImage ${drawings.length + 2 + (styleReference ? 1 : 0)} is the creator-approved concept for this place. Preserve its distinctive architecture, palette, materials and visual identity. The FIRST image remains the authoritative layout: adapt the approved appearance to those positions and clear walking areas. Do not add characters or extra objects from the concept.` : '')
85
+ + (vehicle ? `\nThe LAST image ${VEHICLE_PROMPT}` : '');
86
+ const painted = await museEdit(memo, { label: `paint:${place.key}`, prompt, images: [blockin, ...drawings.map((d) => d.image), ...(styleReference ? [styleReference] : []), ...(concept ? [concept] : []), ...(vehicle ? [vehicle] : [])], take });
80
87
  return sharp(painted).resize(FRAME.width, FRAME.height, { fit: 'fill' }).png().toBuffer();
81
88
  }
@@ -1,6 +1,8 @@
1
1
  import { STYLE_REFERENCE_PROMPT } from '../world/style-reference.ts';
2
2
  // A real photo as a place: crop it to the game frame, let Spark read the room into things,
3
3
  // and let Muse repaint it in the world's style without moving anything.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
4
6
  import sharp from 'sharp';
5
7
  import { z } from 'zod';
6
8
  import type { Memo } from '../meta/memo.ts';
@@ -58,6 +60,27 @@ Reply with JSON only:
58
60
  throw new Error('Spark could not read the photo into a place in 3 tries');
59
61
  }
60
62
 
63
+ /** The picture a place was made from, which stands in for its block-in: a real photo, or a grown-up's sketch of the place. */
64
+ export function placePicture(dir: string): { file: string; kind: 'photo' | 'sketch' } | undefined {
65
+ for (const kind of ['photo', 'sketch'] as const) {
66
+ const file = path.join(dir, `${kind}.png`);
67
+ if (fs.existsSync(file)) return { file, kind };
68
+ }
69
+ return undefined;
70
+ }
71
+
72
+ export function sketchPaintPrompt(world: World, place: Place, brief?: Brief): string {
73
+ const looks = place.things.filter((t) => t.role !== 'npc').map((t) => `- ${t.key}: ${brief?.looks[t.key] ?? t.looks}`);
74
+ return `Finish this artist's sketchbook drawing as a background of a 2D point-and-click adventure game. Keep the camera and every object exactly where and as big as it is in the sketch: same buildings, same furniture, same floor, same perspective. Keep the artist's hand and medium; only finish it: clean confident lines, full colour, depth and light.
75
+
76
+ Style: ${world.style}
77
+ Mood: ${place.mood}
78
+ Objects to keep recognisable:
79
+ ${looks.join('\n')}
80
+
81
+ Leave out the sketchbook page itself: its edges and spine, the handwritten notes, colour swatches and any writing; continue the scene out to every edge of the frame. Remove every person; paint what is behind them. Keep the floor clear to walk on. No text or letters, no border.`;
82
+ }
83
+
61
84
  export function photoPaintPrompt(world: World, place: Place, brief?: Brief): string {
62
85
  const looks = place.things.filter((t) => t.role !== 'npc').map((t) => `- ${t.key}: ${brief?.looks[t.key] ?? t.looks}`);
63
86
  return `Repaint this photo as a finished background of a 2D point-and-click adventure game. Keep the camera and every object exactly where and as big as it is in the photo: same walls, same furniture, same door, same floor. Only the medium changes.
@@ -70,8 +93,8 @@ ${looks.join('\n')}
70
93
  Remove every person; paint the furniture and floor behind them. Keep the floor clear to walk on. No text or letters, no border.`;
71
94
  }
72
95
 
73
- export async function paintPhoto(memo: Memo, world: World, place: Place, photo: Buffer, brief?: Brief, take = 0, concept?: Buffer, styleReference?: Buffer): Promise<Buffer> {
74
- const prompt = photoPaintPrompt(world, place, brief) + (styleReference ? '\nThe LAST image: ' + STYLE_REFERENCE_PROMPT : '') + (concept ? '\nThe second image is the creator-approved visual concept: preserve its palette and treatment. The first photo is authoritative for the real room, furniture, perspective and layout.' : '');
96
+ export async function paintPhoto(memo: Memo, world: World, place: Place, photo: Buffer, brief?: Brief, take = 0, concept?: Buffer, styleReference?: Buffer, picture: 'photo' | 'sketch' = 'photo'): Promise<Buffer> {
97
+ const prompt = (picture === 'sketch' ? sketchPaintPrompt : photoPaintPrompt)(world, place, brief) + (styleReference ? '\nThe LAST image: ' + STYLE_REFERENCE_PROMPT : '') + (concept ? '\nThe second image is the creator-approved visual concept: preserve its palette and treatment. The first photo is authoritative for the real room, furniture, perspective and layout.' : '');
75
98
  const painted = await museEdit(memo, { label: `paint:${place.key}`, prompt, images: [photo, ...(concept ? [concept] : []), ...(styleReference ? [styleReference] : [])], take });
76
99
  return sharp(painted).resize(FRAME.width, FRAME.height, { fit: 'fill' }).png().toBuffer();
77
100
  }
@@ -7,6 +7,7 @@ import { World, type Place } from '../world/schema.ts';
7
7
  import { verifyPlace, type BackdropNotes, type MaskNotes, type PlateNotes, type Verdict } from './verify.ts';
8
8
  import { Layout } from './layout.ts';
9
9
  import { paintingLayoutHash } from './paint-layout.ts';
10
+ import { placePicture } from './photo.ts';
10
11
 
11
12
  export function currentVerdict(worldDir: string, key: string, place?: Place): Verdict | null {
12
13
  const dir = path.join(worldDir, 'places', key);
@@ -18,7 +19,7 @@ export function currentVerdict(worldDir: string, key: string, place?: Place): Ve
18
19
  if (!place) throw new Error(`no place ${key}`);
19
20
  const read = (file: string) => JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
20
21
  const optional = <T>(file: string) => (fs.existsSync(path.join(dir, file)) ? read(file) as T : {} as T);
21
- if (!fs.existsSync(path.join(dir, 'photo.png')) && fs.existsSync(path.join(dir, 'painting-layout.json'))) {
22
+ if (!placePicture(dir) && fs.existsSync(path.join(dir, 'painting-layout.json'))) {
22
23
  const r = read('painting-layout.json') as { ok?: boolean; inputs?: string; attempts?: Array<{ issues: string[] }> };
23
24
  if (!r.ok || r.inputs !== paintingLayoutHash(place, Layout.parse(read('layout.json')), fs.readFileSync(path.join(dir, 'blockin.png')), fs.readFileSync(path.join(dir, 'painting.png')))) {
24
25
  return { ok: false, errors: [{ code: 'PAINT_LAYOUT_MISMATCH', detail: r.attempts?.at(-1)?.issues.join('; ') || 'Composition check is pending or stale: rebuild from paint.' }], flags: [] };
@@ -0,0 +1,22 @@
1
+ // The hero's car (a drive place, kind "drive") is parked in every place it drives to. Muse paints each place on its
2
+ // own, so without a reference each parks a different car; the car as painted in the drive place, cut out by its
3
+ // mask, goes to every such place's painting as how the car looks.
4
+ import fs from 'node:fs';
5
+ import sharp from 'sharp';
6
+ import type { WorldFiles } from '../world/files.ts';
7
+ import type { Place, World } from '../world/schema.ts';
8
+
9
+ /** The drive place's car, cut out on white, for a place it is parked in; undefined before it is painted and masked. */
10
+ export async function heroVehicle(files: WorldFiles, world: World, place: Place): Promise<Buffer | undefined> {
11
+ const drive = world.places.find((p) => p.kind === 'drive');
12
+ if (!drive || drive.key === place.key || !place.exits.some((e) => e.to === drive.key)) return undefined;
13
+ const car = drive.exits[0]?.via;
14
+ const [painting, mask] = [files.place(drive.key, 'painting.png'), files.place(drive.key, 'masks', `${car}.png`)];
15
+ if (!car || !fs.existsSync(painting) || !fs.existsSync(mask)) return undefined;
16
+ const { width, height } = await sharp(painting).metadata();
17
+ const alpha = await sharp(mask).resize(width, height, { fit: 'fill' }).greyscale().raw().toBuffer();
18
+ const cut = await sharp(painting).removeAlpha().joinChannel(alpha, { raw: { width: width!, height: height!, channels: 1 } }).png().toBuffer();
19
+ return sharp(cut).trim().flatten({ background: '#ffffff' }).png().toBuffer();
20
+ }
21
+
22
+ export const VEHICLE_PROMPT = 'shows the hero\'s car: wherever the car stands in this place, paint exactly this car (body shape, colour, trim, wheels), parked and seen from this place\'s camera; whoever sits in it is not part of it, and nothing else in that image belongs in the place.';