@gtanczyk/diorama 0.2.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.
Files changed (45) hide show
  1. package/README.md +1 -0
  2. package/app/src/actions/fix.ts +28 -10
  3. package/app/src/actions/story.ts +28 -1
  4. package/app/src/actions/style.ts +3 -2
  5. package/app/src/commands/backdrop.ts +23 -3
  6. package/app/src/commands/clip.ts +7 -0
  7. package/app/src/commands/derive.ts +9 -0
  8. package/app/src/commands/export.ts +1 -0
  9. package/app/src/commands/language.ts +2 -1
  10. package/app/src/commands/layout.ts +9 -4
  11. package/app/src/commands/masks.ts +2 -1
  12. package/app/src/commands/paint.ts +15 -7
  13. package/app/src/commands/play.ts +1 -0
  14. package/app/src/commands/playthrough.ts +2 -2
  15. package/app/src/commands/world.ts +9 -6
  16. package/app/src/play/car.ts +2 -0
  17. package/app/src/play/geometry.ts +12 -2
  18. package/app/src/play/parallax.ts +2 -0
  19. package/app/src/qa/route.ts +12 -2
  20. package/app/src/scene/cast.ts +4 -2
  21. package/app/src/scene/critic.ts +3 -2
  22. package/app/src/scene/derive.ts +46 -1
  23. package/app/src/scene/layers.ts +52 -0
  24. package/app/src/scene/layout.ts +13 -5
  25. package/app/src/scene/paint-layout.ts +2 -1
  26. package/app/src/scene/paint.ts +15 -8
  27. package/app/src/scene/photo.ts +25 -2
  28. package/app/src/scene/status.ts +2 -1
  29. package/app/src/scene/vehicle.ts +22 -0
  30. package/app/src/scene/verify.ts +12 -0
  31. package/app/src/setup/checks.ts +2 -2
  32. package/app/src/sound/plan.ts +6 -1
  33. package/app/src/story/engine.ts +9 -4
  34. package/app/src/story/fixes.ts +11 -2
  35. package/app/src/studio/agent.ts +21 -8
  36. package/app/src/studio/repair.ts +1 -1
  37. package/app/src/world/design.ts +1 -0
  38. package/app/src/world/drawings.ts +47 -14
  39. package/app/src/world/schema.ts +5 -0
  40. package/app/web/living.ts +36 -0
  41. package/app/web/main.ts +192 -18
  42. package/docs/releasing.md +22 -0
  43. package/docs/scenes.md +10 -1
  44. package/docs/studio.md +3 -1
  45. package/package.json +2 -2
package/README.md CHANGED
@@ -113,6 +113,7 @@ Model answers are memoised by a hash of their inputs, so running a step again wi
113
113
  - [Characters, style and icons](docs/characters.md)
114
114
  - [NPC story stages, optional walks and travel](docs/npc-motion.md)
115
115
  - [Sound and voices](docs/sound.md)
116
+ - [Releasing and the changelog](docs/releasing.md) ([CHANGELOG.md](CHANGELOG.md))
116
117
 
117
118
  ## Built at the Meta AI Global Hackathon
118
119
 
@@ -22,19 +22,11 @@ async function fixPlace(ctx: ActionContext, slug: string, place: string, from: '
22
22
  const dir = path.join(ctx.root, slug);
23
23
  const blocked = agentBlocked(dir, ctx.by, place);
24
24
  if (blocked) throw new ActionError(423, blocked);
25
- // The agent's repair limit: after three fixes in a row that left the place unplayable, it is the person's turn.
26
- if (ctx.by === 'agent') {
27
- const since = new Date(Date.now() - 30 * 60_000).toISOString();
28
- const tries = ctx.jobs.list(slug).filter((j) => j.by === 'agent' && j.args.includes('--place') && j.args[j.args.indexOf('--place') + 1] === place && (j.queuedAt ?? '') >= since);
29
- const lastOk = tries.findLastIndex((j) => j.state === 'done');
30
- if (tries.slice(lastOk + 1).filter((j) => j.state === 'failed').length >= 3) {
31
- throw new ActionError(429, `three fixes of ${place} in a row left it unplayable: stop here and tell the person what you tried and what you would do next`);
32
- }
33
- }
25
+ const note = ctx.by === 'agent' ? repairGuard(ctx, slug, place, label, from) : undefined;
34
26
  const placeDir = path.join(dir, 'places', place);
35
27
  const needs = { layout: null, paint: 'blockin.png', masks: 'painting.png', derive: 'masks.json' }[from];
36
28
  if (needs && !fs.existsSync(path.join(placeDir, needs))) throw new ActionError(404, `${slug}/${place} has no ${needs} yet: build it first`);
37
- return ctx.jobs.start(slug, ['build', slug, '--from', from, '--to', 'verify', '--place', place], `${label} · ${place}`, ctx.by, () => {
29
+ const job = ctx.jobs.start(slug, ['build', slug, '--from', from, '--to', 'verify', '--place', place], `${label} · ${place}`, ctx.by, () => {
38
30
  // The job snapshots first, then changes its source. Queued fixes must not change a running build.
39
31
  fs.mkdirSync(placeDir, { recursive: true });
40
32
  const fixes = readCorrections(placeDir);
@@ -42,6 +34,32 @@ async function fixPlace(ctx: ActionContext, slug: string, place: string, from: '
42
34
  writeCorrections(placeDir, Corrections.parse(fixes));
43
35
  fs.rmSync(path.join(placeDir, 'verify.json'), { force: true });
44
36
  }, addedThing ? { place, key: addedThing } : undefined);
37
+ return note ? { ...job, note } : job;
38
+ }
39
+
40
+ /** Fixes that repaint or relayout cost a Muse call; cheap ones only rerun SAM and the walk grid. */
41
+ const COSTLY = new Set(['layout', 'paint']);
42
+
43
+ /**
44
+ * The agent's repair guardrail. A streak of fixes that left the place unplayable does not stop the agent: it steers it.
45
+ * After three, a fix that already failed in the streak is refused (it would fail the same way) and a new path goes
46
+ * ahead with a short reminder; after six, costly repaints and relayouts wait for the person, while cheap fixes stay open.
47
+ */
48
+ function repairGuard(ctx: ActionContext, slug: string, place: string, label: string, from: string): string | undefined {
49
+ const since = new Date(Date.now() - 30 * 60_000).toISOString();
50
+ const tries = ctx.jobs.list(slug).filter((j) => j.by === 'agent' && j.args.includes('--place') && j.args[j.args.indexOf('--place') + 1] === place && (j.queuedAt ?? '') >= since);
51
+ const lastOk = tries.findLastIndex((j) => j.state === 'done');
52
+ const failed = tries.slice(lastOk + 1).filter((j) => j.state === 'failed');
53
+ if (failed.length < 3) return undefined;
54
+ const tried = [...new Set(failed.map((j) => j.label.replace(` · ${place}`, '')))];
55
+ const paths = 'other paths: mask_brush (paint or erase a thing\'s mask by hand), floor_cells (add or take back 60 px floor cells), stand_set or hero_start_set, thing_remove, corrections_clear of a fix that made it worse, or ask_person';
56
+ if (tried.includes(label)) {
57
+ throw new ActionError(429, `${place}: "${label}" already failed in this streak (${failed.length} fixes in a row left it unplayable: ${tried.join(', ')}). Do not repeat it; look with place_view (walk, masks) and take a different path. ${paths}.`);
58
+ }
59
+ if (failed.length >= 6 && COSTLY.has(from)) {
60
+ throw new ActionError(429, `${place}: ${failed.length} fixes in a row left it unplayable (${tried.join(', ')}). A repaint or relayout now costs credits on a guess: ask_person first, or keep to the cheap fixes. ${paths}.`);
61
+ }
62
+ return `${failed.length} fixes of ${place} in a row left it unplayable (${tried.join(', ')}); this one is a new path. Check place_status after it ends; if it fails too, tell the person briefly what you tried.`;
45
63
  }
46
64
 
47
65
  const thingOf = (c: Corrections, key: string) => (c.things[key] ??= { strokes: [] });
@@ -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,
@@ -9,7 +9,7 @@ import { Geometry } from '../play/geometry.ts';
9
9
  import { defineCommand } from '../registry.ts';
10
10
  import { BACKDROP_GROW, backdropFor, smearedBackdrop, type Card } from '../scene/backdrop.ts';
11
11
  import { carryBetween, carryFrom, paintingFor, ran, remakes, tilesLine, withChange } from '../scene/carry.ts';
12
- import { cloudSheet, CRITTER, critterSheet, Depth, FALLING, fallSheet, farFlats, floatSheet, cloneFill, mistSheet, smokeSheet, waterSheet } from '../scene/layers.ts';
12
+ import { cloudSheet, CRITTER, critterSheet, Depth, FALLING, fallSheet, STREAM, streamSheet, farFlats, floatSheet, cloneFill, mistSheet, smokeSheet, waterSheet } from '../scene/layers.ts';
13
13
  import { maskFromPng as waterMaskOf } from '../image/mask.ts';
14
14
  import { WorldFiles } from '../world/files.ts';
15
15
 
@@ -80,8 +80,22 @@ defineCommand({
80
80
  png = await sharp(cloneFill(raw, FRAME.width, FRAME.height, dilate(critterMask, 3)), { raw: { width: FRAME.width, height: FRAME.height, channels: 3 } }).png().toBuffer();
81
81
  files.write(at('backdrop.png'), png);
82
82
  }
83
+ // Small things painted falling in a stream (code rain, snow, petals) fall on the page (layers/stream-<i>.png):
84
+ // cut out of the backdrop here, each column falling at its own pace. Other things' masks stay put (a doorway
85
+ // inside the rain keeps what is seen through it).
86
+ const looks = (k: string) => place.things.find((t) => t.key === k)?.looks ?? '';
87
+ const streamKeys = Object.keys(notes).filter((k) => !keys.includes(k) && !critterKeys.includes(k) && STREAM.test(`${k} ${notes[k].phrase} ${looks(k)}`) && fs.existsSync(at('masks', `${k}.png`)));
88
+ const streams: Array<{ png: Buffer; region: Buffer; box: [number, number, number, number] }> = [];
89
+ for (const k of streamKeys) {
90
+ const others = await Promise.all(Object.keys(notes).filter((o) => o !== k && fs.existsSync(at('masks', `${o}.png`))).map(async (o) => maskFromPng(fs.readFileSync(at('masks', `${o}.png`)))));
91
+ const made = await streamSheet(png, await maskFromPng(fs.readFileSync(at('masks', `${k}.png`))), dilate(unionAll(FRAME.width, FRAME.height, others), 2));
92
+ if (!made) continue;
93
+ png = made.backdrop;
94
+ streams.push(made);
95
+ }
96
+ if (streams.length) files.write(at('backdrop.png'), png);
83
97
  // Painted waterfalls pour (layers/fall-<i>.png); they stay out of the flat water below them.
84
- const fallKeys = Object.keys(notes).filter((k) => !keys.includes(k) && FALLING.test(`${k} ${notes[k].phrase}`) && fs.existsSync(at('masks', `${k}.png`)));
98
+ const fallKeys = Object.keys(notes).filter((k) => !keys.includes(k) && !streamKeys.includes(k) && FALLING.test(`${k} ${notes[k].phrase}`) && fs.existsSync(at('masks', `${k}.png`)));
85
99
  const fallMasks = await Promise.all(fallKeys.map(async (k) => maskFromPng(fs.readFileSync(at('masks', `${k}.png`)))));
86
100
  // The far view as flats one behind another (layers/), so it has depth of its own.
87
101
  fs.rmSync(at('layers'), { recursive: true, force: true });
@@ -133,6 +147,11 @@ defineCommand({
133
147
  const falls = (await Promise.all(fallMasks.map((m) => fallSheet(png, m)))).filter((f) => !!f);
134
148
  falls.forEach((f, i) => files.write(at('layers', `fall-${i}.png`), f.png));
135
149
  if (falls.length) console.log(` ${place.key}: ${falls.length} waterfall(s) pouring`);
150
+ streams.forEach((f, i) => {
151
+ files.write(at('layers', `stream-${i}.png`), f.png);
152
+ files.write(at('layers', `stream-${i}-region.png`), f.region);
153
+ });
154
+ if (streams.length) console.log(` ${place.key}: ${streams.length} stream(s) of small things falling (${streamKeys.join(', ')})`);
136
155
  const critters = critterKeys.length ? await critterSheet(withCritters, critterMask, wetMask) : [];
137
156
  critters.forEach((c, i) => files.write(at('layers', `critter-${i}.png`), c.png));
138
157
  if (critters.length) console.log(` ${place.key}: ${critters.length} painted animal(s) ${critters.some((c) => c.medium === 'air') ? 'flying and ' : ''}swimming (${critterKeys.join(', ')})`);
@@ -162,7 +181,7 @@ defineCommand({
162
181
  files.write(at('layers', 'mist-mask.png'), mist.mask);
163
182
  console.log(` ${place.key}: painted ${mist.phrase} drifting ${mist.drift === null ? 'with the wind' : mist.drift < 0 ? 'left' : 'right'}${mist.speed !== 1 ? ` x${mist.speed}` : ''}`);
164
183
  }
165
- if (far || water || mist || smoke.length || falls.length || critters.length || clouds) {
184
+ if (far || water || mist || smoke.length || falls.length || streams.length || critters.length || clouds) {
166
185
  if (far) {
167
186
  files.write(at('layers', 'sky.png'), far.sky);
168
187
  far.flats.forEach((f, i) => files.write(at('layers', `${i}.png`), f.png));
@@ -176,6 +195,7 @@ defineCommand({
176
195
  ...(smoke.length && { smoke: smoke.map((s, i) => ({ file: `layers/smoke-${i}.png`, box: s.box })) }),
177
196
  ...(floats.length && { floats: floats.map((s, i) => ({ file: `layers/float-${i}.png`, box: s.box })) }),
178
197
  ...(falls.length && { falls: falls.map((f, i) => ({ file: `layers/fall-${i}.png`, box: f.box })) }),
198
+ ...(streams.length && { streams: streams.map((f, i) => ({ file: `layers/stream-${i}.png`, region: `layers/stream-${i}-region.png`, box: f.box })) }),
179
199
  ...(critters.length && { critters: critters.map((c, i) => ({ file: `layers/critter-${i}.png`, box: c.box, medium: c.medium, facing: c.facing })) }),
180
200
  ...(clouds && { clouds: { box: clouds.box, clip: 'layers/cloud-clip.png', ...(clouds.keep && { keep: 'layers/cloud-keep.png' }), bands: clouds.bands.map((b, i) => ({ file: `layers/cloud-${i}.png`, box: b.box, speed: b.speed })) } }),
181
201
  });
@@ -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);
@@ -90,6 +90,7 @@ export async function exportWorld(slug: string, out: string, home?: string, url?
90
90
  entry.layers.mist.mask = await copy(entry.layers.mist.mask);
91
91
  }
92
92
  for (const f of entry.layers.flats) f.file = await art(f.file); // transparent around the flat
93
+ for (const f of entry.layers.streams ?? []) [f.file, f.region] = [await art(f.file), await copy(f.region)];
93
94
  for (const f of [...(entry.layers.smoke ?? []), ...(entry.layers.floats ?? []), ...(entry.layers.falls ?? []), ...(entry.layers.critters ?? []), ...(entry.layers.clouds?.bands ?? [])]) f.file = await art(f.file);
94
95
  if (entry.layers.clouds) {
95
96
  entry.layers.clouds.clip = await copy(entry.layers.clouds.clip);
@@ -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;
@@ -65,6 +65,7 @@ export function worldData(files: WorldFiles) {
65
65
  ...(flats.smoke?.length && { smoke: flats.smoke.map((f) => ({ ...f, file: rel(f.file) })) }),
66
66
  ...(flats.floats?.length && { floats: flats.floats.map((f) => ({ ...f, file: rel(f.file) })) }),
67
67
  ...(flats.falls?.length && { falls: flats.falls.map((f) => ({ ...f, file: rel(f.file) })) }),
68
+ ...(flats.streams?.length && { streams: flats.streams.map((f) => ({ ...f, file: rel(f.file), region: rel(f.region) })) }),
68
69
  ...(flats.critters?.length && { critters: flats.critters.map((f) => ({ ...f, file: rel(f.file) })) }),
69
70
  ...(flats.clouds && { clouds: { ...flats.clouds, clip: rel(flats.clouds.clip), ...(flats.clouds.keep && { keep: rel(flats.clouds.keep) }), bands: flats.clouds.bands.map((b) => ({ ...b, file: rel(b.file) })) } }),
70
71
  };
@@ -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;
@@ -122,11 +122,21 @@ export function hides(o: Occluder, x: number, feetY: number, support?: (key: str
122
122
  return feetY < floorYAt(o, x) - BEHIND_MARGIN;
123
123
  }
124
124
 
125
- /** Column runs [x0, x1) inside [left, right) where the occluder hides the person; a few runs per occluder. */
126
- export function hiddenRuns(o: Occluder, left: number, right: number, feetY: number, support?: (key: string) => Occluder | undefined): Array<[number, number]> {
125
+ /**
126
+ * Column runs [x0, x1) inside [left, right) where the occluder hides the person; a few runs per occluder.
127
+ * With `feetX` the person is one body at one distance: the floor line where their feet are decides for their
128
+ * whole box, so a slanted desk whose near leg is nearer than the feet and far leg farther hides all of them or
129
+ * none, never cuts them in half down one column. Past the occluder's ends its nearest column decides.
130
+ */
131
+ export function hiddenRuns(o: Occluder, left: number, right: number, feetY: number, support?: (key: string) => Occluder | undefined, feetX?: number): Array<[number, number]> {
127
132
  const runs: Array<[number, number]> = [];
128
133
  const x0 = Math.max(Math.floor(left), Math.floor(o.box[0]));
129
134
  const x1 = Math.min(Math.ceil(right), Math.ceil(o.box[2]));
135
+ if (feetX !== undefined) {
136
+ if (x1 <= x0) return runs;
137
+ const at = Math.min(o.box[2] - 0.5, Math.max(o.box[0] + 0.5, feetX));
138
+ return hides(o, at, feetY, support) ? [[x0, x1]] : runs;
139
+ }
130
140
  let start = -1;
131
141
  for (let x = x0; x < x1; x++) {
132
142
  const hid = hides(o, x + 0.5, feetY, support);
@@ -164,6 +164,8 @@ export type Layers = {
164
164
  floats?: Array<{ file: string; box: [number, number, number, number] }>;
165
165
  /** Painted waterfalls to set pouring: each a feathered copy of the fall over `box`. */
166
166
  falls?: Array<{ file: string; box: [number, number, number, number] }>;
167
+ /** Small things painted falling (code rain, snow, petals): what stands out of the paint over `box`, to fall column by column inside `region` (a soft mask). */
168
+ streams?: Array<{ file: string; region: string; box: [number, number, number, number] }>;
167
169
  /** Painted animals as small cards to swim (in the water) or fly (in the air), and which way each faces (1 right). */
168
170
  critters?: Array<{ file: string; box: [number, number, number, number]; medium: 'water' | 'air'; facing: 1 | -1 }>;
169
171
  /** The painted clouds as bands (bottom first) to drift with the wind at their own speeds, cut to `clip` over `box`; `keep`: what stays put in the sky. */
@@ -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),