@gtanczyk/diorama 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (302) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +157 -2
  3. package/app/bin/diorama.mjs +13 -0
  4. package/app/src/actions/budget.ts +160 -0
  5. package/app/src/actions/build.ts +158 -0
  6. package/app/src/actions/cast.ts +267 -0
  7. package/app/src/actions/depth.ts +95 -0
  8. package/app/src/actions/extend.ts +36 -0
  9. package/app/src/actions/faces.ts +160 -0
  10. package/app/src/actions/feedback.ts +87 -0
  11. package/app/src/actions/fix.ts +382 -0
  12. package/app/src/actions/history.ts +93 -0
  13. package/app/src/actions/idea-preview.ts +65 -0
  14. package/app/src/actions/idea-review.ts +102 -0
  15. package/app/src/actions/idea-sketch.ts +172 -0
  16. package/app/src/actions/index.ts +32 -0
  17. package/app/src/actions/items.ts +94 -0
  18. package/app/src/actions/kids.ts +215 -0
  19. package/app/src/actions/language.ts +52 -0
  20. package/app/src/actions/models.ts +64 -0
  21. package/app/src/actions/motion.ts +119 -0
  22. package/app/src/actions/pictures.ts +49 -0
  23. package/app/src/actions/reactions.ts +36 -0
  24. package/app/src/actions/registry.ts +101 -0
  25. package/app/src/actions/share.ts +32 -0
  26. package/app/src/actions/sound.ts +73 -0
  27. package/app/src/actions/start.ts +154 -0
  28. package/app/src/actions/step-work.ts +150 -0
  29. package/app/src/actions/story.ts +101 -0
  30. package/app/src/actions/studio.ts +133 -0
  31. package/app/src/actions/style.ts +72 -0
  32. package/app/src/actions/video.ts +46 -0
  33. package/app/src/actions/view.ts +109 -0
  34. package/app/src/actions/weather.ts +38 -0
  35. package/app/src/actions/world.ts +290 -0
  36. package/app/src/commands/action.ts +39 -0
  37. package/app/src/commands/backdrop.ts +188 -0
  38. package/app/src/commands/browsers.ts +21 -0
  39. package/app/src/commands/build.ts +503 -0
  40. package/app/src/commands/cast.ts +161 -0
  41. package/app/src/commands/clip.ts +431 -0
  42. package/app/src/commands/derive.ts +167 -0
  43. package/app/src/commands/doctor.ts +74 -0
  44. package/app/src/commands/explore.ts +123 -0
  45. package/app/src/commands/export.ts +158 -0
  46. package/app/src/commands/extend.ts +75 -0
  47. package/app/src/commands/faces.ts +80 -0
  48. package/app/src/commands/icons.ts +69 -0
  49. package/app/src/commands/index.ts +37 -0
  50. package/app/src/commands/language.ts +87 -0
  51. package/app/src/commands/layout.ts +31 -0
  52. package/app/src/commands/masks.ts +117 -0
  53. package/app/src/commands/mcp.ts +52 -0
  54. package/app/src/commands/model.ts +284 -0
  55. package/app/src/commands/paint.ts +182 -0
  56. package/app/src/commands/plates.ts +97 -0
  57. package/app/src/commands/play.ts +195 -0
  58. package/app/src/commands/playthrough.ts +111 -0
  59. package/app/src/commands/publish.ts +78 -0
  60. package/app/src/commands/reactions.ts +40 -0
  61. package/app/src/commands/reel.ts +46 -0
  62. package/app/src/commands/setup.ts +85 -0
  63. package/app/src/commands/sound.ts +100 -0
  64. package/app/src/commands/sprites.ts +232 -0
  65. package/app/src/commands/story.ts +37 -0
  66. package/app/src/commands/studio.ts +65 -0
  67. package/app/src/commands/style.ts +87 -0
  68. package/app/src/commands/verify.ts +45 -0
  69. package/app/src/commands/voice-retry.ts +14 -0
  70. package/app/src/commands/voices.ts +145 -0
  71. package/app/src/commands/weather.ts +52 -0
  72. package/app/src/commands/world.ts +104 -0
  73. package/app/src/film/clip.ts +386 -0
  74. package/app/src/film/reel.ts +126 -0
  75. package/app/src/film/screencast.ts +123 -0
  76. package/app/src/image/mask.ts +29 -0
  77. package/app/src/image/overlay.ts +38 -0
  78. package/app/src/image/raster.ts +224 -0
  79. package/app/src/main.ts +28 -0
  80. package/app/src/meta/choice.ts +27 -0
  81. package/app/src/meta/http.ts +99 -0
  82. package/app/src/meta/memo.ts +105 -0
  83. package/app/src/meta/muse.ts +70 -0
  84. package/app/src/meta/openrouter.ts +93 -0
  85. package/app/src/meta/prices.ts +25 -0
  86. package/app/src/meta/sam.ts +83 -0
  87. package/app/src/meta/schema.ts +63 -0
  88. package/app/src/meta/spark.ts +220 -0
  89. package/app/src/meta/step.ts +14 -0
  90. package/app/src/meta/voice-file.ts +78 -0
  91. package/app/src/meta/voice.ts +103 -0
  92. package/app/src/model/animation.ts +38 -0
  93. package/app/src/model/blender/relax.py +284 -0
  94. package/app/src/model/blender.ts +115 -0
  95. package/app/src/model/build.ts +50 -0
  96. package/app/src/model/face.ts +21 -0
  97. package/app/src/model/fit.ts +181 -0
  98. package/app/src/model/glb.ts +241 -0
  99. package/app/src/model/plan.ts +151 -0
  100. package/app/src/model/quality.ts +48 -0
  101. package/app/src/model/space.ts +168 -0
  102. package/app/src/model/sprites.ts +293 -0
  103. package/app/src/model/views.ts +163 -0
  104. package/app/src/play/creature.ts +43 -0
  105. package/app/src/play/depth-occlusion.ts +150 -0
  106. package/app/src/play/exit-outline.ts +48 -0
  107. package/app/src/play/face3d.ts +34 -0
  108. package/app/src/play/focus.ts +78 -0
  109. package/app/src/play/geometry.ts +272 -0
  110. package/app/src/play/gestures.ts +23 -0
  111. package/app/src/play/glow.ts +187 -0
  112. package/app/src/play/lipsync.ts +69 -0
  113. package/app/src/play/living.ts +83 -0
  114. package/app/src/play/outline-path.ts +130 -0
  115. package/app/src/play/parallax.ts +200 -0
  116. package/app/src/play/path.ts +262 -0
  117. package/app/src/play/preview.ts +71 -0
  118. package/app/src/play/reactions.ts +92 -0
  119. package/app/src/play/rig.ts +172 -0
  120. package/app/src/play/shadow.ts +253 -0
  121. package/app/src/play/sound.ts +51 -0
  122. package/app/src/play/weather.ts +58 -0
  123. package/app/src/qa/explore.ts +136 -0
  124. package/app/src/qa/probe.ts +139 -0
  125. package/app/src/qa/route.ts +118 -0
  126. package/app/src/registry.ts +23 -0
  127. package/app/src/scene/animation-source.ts +36 -0
  128. package/app/src/scene/assets.ts +37 -0
  129. package/app/src/scene/autofix.ts +133 -0
  130. package/app/src/scene/backdrop.ts +326 -0
  131. package/app/src/scene/blockin.ts +189 -0
  132. package/app/src/scene/brief.ts +74 -0
  133. package/app/src/scene/carry.ts +177 -0
  134. package/app/src/scene/cast-fixes.ts +33 -0
  135. package/app/src/scene/cast.ts +199 -0
  136. package/app/src/scene/corrections.ts +164 -0
  137. package/app/src/scene/critic.ts +169 -0
  138. package/app/src/scene/depthmap.ts +149 -0
  139. package/app/src/scene/derive.ts +475 -0
  140. package/app/src/scene/diagnose.ts +132 -0
  141. package/app/src/scene/face-calibration.ts +65 -0
  142. package/app/src/scene/face-registration.ts +21 -0
  143. package/app/src/scene/face-work.ts +71 -0
  144. package/app/src/scene/faces-data.ts +48 -0
  145. package/app/src/scene/faces-settings.ts +20 -0
  146. package/app/src/scene/faces.ts +435 -0
  147. package/app/src/scene/glows.ts +153 -0
  148. package/app/src/scene/held.ts +109 -0
  149. package/app/src/scene/icons.ts +185 -0
  150. package/app/src/scene/layers.ts +822 -0
  151. package/app/src/scene/layout.ts +216 -0
  152. package/app/src/scene/locate.ts +118 -0
  153. package/app/src/scene/maskcheck.ts +43 -0
  154. package/app/src/scene/masks.ts +392 -0
  155. package/app/src/scene/occluders.ts +123 -0
  156. package/app/src/scene/occlusion-check.ts +138 -0
  157. package/app/src/scene/paint-layout.ts +135 -0
  158. package/app/src/scene/paint.ts +81 -0
  159. package/app/src/scene/photo.ts +123 -0
  160. package/app/src/scene/plates.ts +256 -0
  161. package/app/src/scene/playbook.ts +121 -0
  162. package/app/src/scene/preview.ts +23 -0
  163. package/app/src/scene/reactions.ts +118 -0
  164. package/app/src/scene/restyle.ts +53 -0
  165. package/app/src/scene/rig-check.ts +68 -0
  166. package/app/src/scene/rig.ts +379 -0
  167. package/app/src/scene/scale.ts +148 -0
  168. package/app/src/scene/shadows.ts +215 -0
  169. package/app/src/scene/stairs.ts +183 -0
  170. package/app/src/scene/status.ts +32 -0
  171. package/app/src/scene/verify.ts +156 -0
  172. package/app/src/scene/walk.ts +326 -0
  173. package/app/src/scene/warp.ts +419 -0
  174. package/app/src/scene/weather.ts +118 -0
  175. package/app/src/scene/words.ts +65 -0
  176. package/app/src/secrets.ts +219 -0
  177. package/app/src/setup/checks.ts +110 -0
  178. package/app/src/sound/cost.ts +74 -0
  179. package/app/src/sound/data.ts +89 -0
  180. package/app/src/sound/fixes.ts +23 -0
  181. package/app/src/sound/issues.ts +21 -0
  182. package/app/src/sound/language.ts +11 -0
  183. package/app/src/sound/lipsync-make.ts +98 -0
  184. package/app/src/sound/lipsync.ts +24 -0
  185. package/app/src/sound/make.ts +229 -0
  186. package/app/src/sound/musicgen.py +20 -0
  187. package/app/src/sound/pcm.ts +280 -0
  188. package/app/src/sound/plan.ts +154 -0
  189. package/app/src/sound/retry.ts +43 -0
  190. package/app/src/sound/settings.ts +28 -0
  191. package/app/src/sound/sources.ts +81 -0
  192. package/app/src/sound/spoken.ts +70 -0
  193. package/app/src/sound/voices.ts +579 -0
  194. package/app/src/speech/transcribe.ts +38 -0
  195. package/app/src/speech/types.ts +18 -0
  196. package/app/src/story/check.ts +230 -0
  197. package/app/src/story/design.ts +108 -0
  198. package/app/src/story/engine.ts +233 -0
  199. package/app/src/story/fixes.ts +54 -0
  200. package/app/src/story/npc-guidance.ts +7 -0
  201. package/app/src/story/phrases.ts +4 -0
  202. package/app/src/story/schema.ts +92 -0
  203. package/app/src/studio/agent.ts +555 -0
  204. package/app/src/studio/discovery.ts +28 -0
  205. package/app/src/studio/events.ts +48 -0
  206. package/app/src/studio/history.ts +124 -0
  207. package/app/src/studio/jobs.ts +291 -0
  208. package/app/src/studio/locks.ts +33 -0
  209. package/app/src/studio/mcp.ts +86 -0
  210. package/app/src/studio/operation.ts +18 -0
  211. package/app/src/studio/repair.ts +31 -0
  212. package/app/src/studio/server.ts +423 -0
  213. package/app/src/studio/trouble.ts +48 -0
  214. package/app/src/world/design.ts +72 -0
  215. package/app/src/world/drawings.ts +198 -0
  216. package/app/src/world/extend-stage.ts +109 -0
  217. package/app/src/world/extend.ts +123 -0
  218. package/app/src/world/extension-progress.ts +10 -0
  219. package/app/src/world/files.ts +62 -0
  220. package/app/src/world/idea-review.ts +32 -0
  221. package/app/src/world/language.ts +116 -0
  222. package/app/src/world/photo-limits.ts +2 -0
  223. package/app/src/world/pool.ts +41 -0
  224. package/app/src/world/schedule.ts +64 -0
  225. package/app/src/world/schema.ts +74 -0
  226. package/app/src/world/sketch.ts +81 -0
  227. package/app/src/world/style-plan.ts +90 -0
  228. package/app/src/world/style-reference.ts +16 -0
  229. package/app/src/world/styled.ts +51 -0
  230. package/app/src/world/told.ts +105 -0
  231. package/app/src/world/video-limits.ts +3 -0
  232. package/app/src/world/video.ts +98 -0
  233. package/app/studio/activity.tsx +124 -0
  234. package/app/studio/agent.tsx +384 -0
  235. package/app/studio/api.ts +111 -0
  236. package/app/studio/atelier.tsx +234 -0
  237. package/app/studio/audio-missing.tsx +25 -0
  238. package/app/studio/audio-setup.tsx +33 -0
  239. package/app/studio/blender-setup.tsx +30 -0
  240. package/app/studio/build.tsx +679 -0
  241. package/app/studio/camera.tsx +59 -0
  242. package/app/studio/cast-work.tsx +207 -0
  243. package/app/studio/cast.tsx +178 -0
  244. package/app/studio/concept-edit.tsx +47 -0
  245. package/app/studio/creator-idea.tsx +46 -0
  246. package/app/studio/custom-look.tsx +36 -0
  247. package/app/studio/faces-setup.tsx +28 -0
  248. package/app/studio/favicon.svg +11 -0
  249. package/app/studio/figure.tsx +314 -0
  250. package/app/studio/fonts/OFL.txt +93 -0
  251. package/app/studio/fonts/fonts.css +7 -0
  252. package/app/studio/fonts/fraunces-italic-latin-ext.woff2 +0 -0
  253. package/app/studio/fonts/fraunces-italic-latin.woff2 +0 -0
  254. package/app/studio/fonts/fraunces-normal-latin-ext.woff2 +0 -0
  255. package/app/studio/fonts/fraunces-normal-latin.woff2 +0 -0
  256. package/app/studio/fonts/instrument-sans-normal-latin-ext.woff2 +0 -0
  257. package/app/studio/fonts/instrument-sans-normal-latin.woff2 +0 -0
  258. package/app/studio/icons.tsx +42 -0
  259. package/app/studio/idea-card.tsx +42 -0
  260. package/app/studio/idea-review.tsx +181 -0
  261. package/app/studio/idea-storyboard.tsx +134 -0
  262. package/app/studio/index.html +15 -0
  263. package/app/studio/job-words.ts +33 -0
  264. package/app/studio/lightbox.tsx +49 -0
  265. package/app/studio/main.tsx +1329 -0
  266. package/app/studio/nav.ts +14 -0
  267. package/app/studio/place.tsx +264 -0
  268. package/app/studio/review-sketch.tsx +47 -0
  269. package/app/studio/say-it.tsx +49 -0
  270. package/app/studio/step-work.tsx +134 -0
  271. package/app/studio/story.tsx +394 -0
  272. package/app/studio/style-menu.tsx +81 -0
  273. package/app/studio/style.css +1390 -0
  274. package/app/studio/sure.tsx +54 -0
  275. package/app/studio/tip.tsx +74 -0
  276. package/app/studio/tsconfig.json +5 -0
  277. package/app/studio/video.tsx +73 -0
  278. package/app/studio/world.tsx +188 -0
  279. package/app/tsconfig.json +13 -0
  280. package/app/web/audio.ts +315 -0
  281. package/app/web/face.ts +131 -0
  282. package/app/web/face3d.ts +35 -0
  283. package/app/web/glows.ts +387 -0
  284. package/app/web/hero3d.ts +728 -0
  285. package/app/web/index.html +118 -0
  286. package/app/web/living.ts +404 -0
  287. package/app/web/main.ts +3769 -0
  288. package/app/web/pad.ts +174 -0
  289. package/app/web/reactions.ts +79 -0
  290. package/app/web/sheet.ts +36 -0
  291. package/app/web/synth.ts +140 -0
  292. package/app/web/tsconfig.json +5 -0
  293. package/app/web/ui.ts +263 -0
  294. package/app/web/weather.ts +306 -0
  295. package/app/web/words.ts +12 -0
  296. package/docs/characters.md +58 -0
  297. package/docs/npc-motion.md +69 -0
  298. package/docs/scenes.md +75 -0
  299. package/docs/setup.md +60 -0
  300. package/docs/sound.md +23 -0
  301. package/docs/studio.md +62 -0
  302. package/package.json +70 -4
@@ -0,0 +1,136 @@
1
+ // The explorer: puts the hero on every part of the floor in the real play page and checks each
2
+ // frame for what players notice: standing inside things, drawn over things he is behind, hidden
3
+ // by things he is in front of, cut off by the frame. Spark looks at a contact sheet of the worst.
4
+ import sharp from 'sharp';
5
+ import { z } from 'zod';
6
+ import { emptyMask } from '../image/mask.ts';
7
+ import { PALETTE, svgLabel, tintOver } from '../image/overlay.ts';
8
+ import type { Memo } from '../meta/memo.ts';
9
+ import { askSparkJson } from '../meta/spark.ts';
10
+ import type { Geometry } from '../play/geometry.ts';
11
+ import { gridOf } from '../scene/verify.ts';
12
+ import { reach, roomy } from '../scene/walk.ts';
13
+ import type { ProbeHit } from './probe.ts';
14
+
15
+ export const ISSUES = ['feet-inside', 'drawn-over', 'over-box', 'hidden-in-front', 'clipped', 'invisible'] as const;
16
+ export type Issue = (typeof ISSUES)[number];
17
+ export type Checked = ProbeHit & { issues: Issue[]; worst?: string };
18
+
19
+ /** Up to `max` spots on walkable cells, on a coarser lattice for bigger floors, plus every thing's spot. */
20
+ export function positions(g: Geometry, max = 600): [number, number][] {
21
+ const grid = gridOf(g);
22
+ const open = [...grid.open.keys()].filter((i) => grid.open[i]);
23
+ let step = 2;
24
+ while (open.length / (step * step) > max && step < 16) step++;
25
+ const out: [number, number][] = [];
26
+ for (const i of open) {
27
+ const c = i % grid.cols;
28
+ const r = Math.floor(i / grid.cols);
29
+ if (c % step === 0 && r % step === 0) out.push(grid.centre(i));
30
+ }
31
+ for (const t of g.things) out.push(t.stand);
32
+ return out;
33
+ }
34
+
35
+ /** The code checks: which issues a probe shows. Thresholds keep a few stray pixels from counting. */
36
+ export function classify(hit: ProbeHit): Checked {
37
+ const issues: Issue[] = [];
38
+ const some = (n: number) => n >= Math.max(40, hit.drawn * 0.01);
39
+ const over = Object.entries(hit.overNearer).filter(([, n]) => some(n));
40
+ const hidden = Object.entries(hit.hiddenByFarther).filter(([, n]) => n >= Math.max(150, hit.drawn * 0.01));
41
+ if (hit.feetIn.length) issues.push('feet-inside');
42
+ if (over.length || some(hit.overDepth ?? 0)) issues.push('drawn-over');
43
+ // A box-only thing can't hide him, so standing where its box would overlap him looks wrong either way.
44
+ const boxed = Object.entries(hit.overBox ?? {}).filter(([, n]) => some(n));
45
+ if (boxed.length) issues.push('over-box');
46
+ if (hidden.length) issues.push('hidden-in-front');
47
+ if (hit.off > 0.3) issues.push('clipped');
48
+ if (hit.drawn > 0 && hit.hidden > 0.85 * hit.drawn) issues.push('invisible');
49
+ const worst = hit.feetIn[0] ?? over.sort((a, b) => b[1] - a[1])[0]?.[0] ?? (some(hit.overDepth ?? 0) ? 'depth' : undefined) ?? boxed.sort((a, b) => b[1] - a[1])[0]?.[0] ?? hidden.sort((a, b) => b[1] - a[1])[0]?.[0];
50
+ return { ...hit, issues, ...(worst && { worst }) };
51
+ }
52
+
53
+ /** Painting with each probed spot as a dot: green clean, red with issues, labelled by the thing involved. */
54
+ export async function heatmap(painting: Buffer, g: Geometry, checked: Checked[]): Promise<Buffer> {
55
+ const walk = emptyMask(g.frame.width, g.frame.height);
56
+ g.walk.forEach((row, r) => [...row].forEach((ch, c) => {
57
+ if (ch !== '#') return;
58
+ for (let y = r * g.cell; y < Math.min(g.frame.height, (r + 1) * g.cell); y++) walk.bits.fill(1, y * g.frame.width + c * g.cell, y * g.frame.width + Math.min(g.frame.width, (c + 1) * g.cell));
59
+ }));
60
+ const dots = checked.map((h) => `<circle cx="${h.x}" cy="${h.y}" r="${h.issues.length ? 6 : 3}" fill="${h.issues.length ? '#ff3030' : '#30ff70'}" stroke="#000" stroke-width="1"/>`);
61
+ const labelled = new Set<string>();
62
+ const labels = checked.filter((h) => h.worst && !labelled.has(h.worst) && labelled.add(h.worst)).map((h) => svgLabel(h.x + 8, h.y - 6, `${h.issues[0]}: ${h.worst}`, [255, 120, 120]));
63
+ return tintOver(painting, [{ mask: walk, rgb: PALETTE[4], alpha: 0.18 }], [...dots, ...labels].join(''));
64
+ }
65
+
66
+ /** A 4x4 sheet of numbered crops. */
67
+ export async function contactSheet(crops: Buffer[], size: number): Promise<Buffer> {
68
+ const cols = 4;
69
+ const rows = Math.ceil(crops.length / cols);
70
+ const tiles = await Promise.all(crops.map(async (crop, i) => ({
71
+ input: await sharp(crop).composite([{ input: Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}"><rect width="${size}" height="${size}" fill="none" stroke="#000" stroke-width="4"/>${svgLabel(8, 24, `#${i + 1}`, [255, 230, 0])}</svg>`) }]).png().toBuffer(),
72
+ left: (i % cols) * size,
73
+ top: Math.floor(i / cols) * size,
74
+ })));
75
+ return sharp({ create: { width: cols * size, height: rows * size, channels: 3, background: '#111' } }).composite(tiles).png().toBuffer();
76
+ }
77
+
78
+ const Judged = z.object({ wrong: z.array(z.object({ id: z.number(), why: z.string() })) });
79
+
80
+ export async function judge(memo: Memo, label: string, sheet: Buffer, count: number): Promise<z.infer<typeof Judged>['wrong']> {
81
+ const prompt = `This contact sheet shows ${count} tiles from a 2D point-and-click adventure, each a crop around the player character (a simple dark figure), numbered #1 to #${count} in the corner. Which tiles look wrong, and why? Look for: the character standing inside an object, partly hidden by an object they should be in front of, drawn over an object they should be behind, floating above or sunk into the ground, or the wrong size for the things around them.
82
+ Reply with JSON only: {"wrong": [{"id": 1, "why": "short reason"}]}, an empty list when all look right.`;
83
+ const { wrong } = Judged.parse(await askSparkJson(memo, { schema: Judged, label, prompt, images: [sheet] }));
84
+ return wrong.filter((w) => w.id >= 1 && w.id <= count);
85
+ }
86
+
87
+ /**
88
+ * Closes cells around spots with issues, never near the start or a thing's spot, and never more
89
+ * than `cap` of a floor's cells; a fix that would cut any floor off from the start is undone.
90
+ */
91
+ export function fixGrid(g: Geometry, checked: Checked[], cap = 0.35): { geometry: Geometry; closed: number; undone: string[] } {
92
+ const grid = gridOf(g);
93
+ const keep = new Set<number>();
94
+ for (const [x, y] of [g.start, ...g.things.map((t) => t.stand)]) {
95
+ const c0 = Math.floor(x / g.cell);
96
+ const r0 = Math.floor(y / g.cell);
97
+ for (let r = r0 - 3; r <= r0 + 3; r++) for (let c = c0 - 3; c <= c0 + 3; c++) if (grid.inside(c, r)) keep.add(grid.at(c, r));
98
+ }
99
+ const byZone = new Map<number, number[]>();
100
+ for (const h of checked) {
101
+ if (!h.issues.some((i) => i !== 'invisible')) continue;
102
+ const c0 = Math.floor(h.x / g.cell);
103
+ const r0 = Math.floor(h.y / g.cell);
104
+ for (let r = r0 - 1; r <= r0 + 1; r++) {
105
+ for (let c = c0 - 1; c <= c0 + 1; c++) {
106
+ if (!grid.inside(c, r)) continue;
107
+ const i = grid.at(c, r);
108
+ if (!grid.open[i] || keep.has(i)) continue;
109
+ byZone.set(grid.zone[i], [...(byZone.get(grid.zone[i]) ?? []), i]);
110
+ }
111
+ }
112
+ }
113
+ const undone: string[] = [];
114
+ let closed = 0;
115
+ const startCell = grid.cellOf(...g.start);
116
+ for (const [zone, cells] of byZone) {
117
+ const unique = [...new Set(cells)];
118
+ const size = grid.zone.filter((z, i) => z === zone && grid.open[i]).length;
119
+ if (unique.length > cap * size) {
120
+ undone.push(`floor ${zone}: fixing would close ${unique.length} of ${size} cells`);
121
+ continue;
122
+ }
123
+ unique.forEach((i) => (grid.open[i] = 0));
124
+ const reached = reach(grid, startCell, roomy(grid));
125
+ const stillThere = [...grid.zone.keys()].some((i) => grid.zone[i] === zone && reached[i]);
126
+ const spotsOk = g.things.every((t) => reached[grid.cellOf(...t.stand)] || !reach(gridOf(g), startCell)[grid.cellOf(...t.stand)]);
127
+ if (!stillThere || !spotsOk) {
128
+ unique.forEach((i) => (grid.open[i] = 1));
129
+ undone.push(`floor ${zone}: fixing would cut it or a thing's spot off`);
130
+ continue;
131
+ }
132
+ closed += unique.length;
133
+ }
134
+ const { walk } = grid.rowsOut();
135
+ return { geometry: { ...g, walk }, closed, undone };
136
+ }
@@ -0,0 +1,139 @@
1
+ // The in-page half of the explorer. It runs inside the play page (as a string, so no bundler
2
+ // helpers leak in) and uses the page's own renderer: whatever the game draws is what gets checked.
3
+ //
4
+ // For each position it renders three frames over the hero's box: the place without him (once),
5
+ // with him and occlusion, and with him but nothing put back. Drawn = differs from the empty
6
+ // place without occlusion; hidden = drawn but the occluded frame shows the empty place there.
7
+
8
+ export type ProbeHit = {
9
+ x: number;
10
+ y: number;
11
+ /** Hero pixels without occlusion, and how many of them occlusion put the painting back over. */
12
+ drawn: number;
13
+ hidden: number;
14
+ /** Visible hero pixels over a thing whose floor line is nearer than his feet: he should be behind it. */
15
+ overNearer: Record<string, number>;
16
+ /** Hidden hero pixels under a thing whose floor line is behind his feet: he should be in front of it. */
17
+ hiddenByFarther: Record<string, number>;
18
+ /** Visible hero pixels where the depth map says the painting stands nearer than his feet (a lamp post nobody masked). */
19
+ overDepth?: number;
20
+ /** Hero pixels inside the box of a thing with only a box for a mask, while his feet are behind its bottom. */
21
+ overBox: Record<string, number>;
22
+ /** Things whose floor footprint holds his feet: he would stand inside them. */
23
+ feetIn: string[];
24
+ /** Share of his height above the top of the frame or of his width outside its sides. */
25
+ off: number;
26
+ };
27
+
28
+ export const PROBE_JS = String.raw`async ({ placeKey, positions }) => {
29
+ const game = window.diorama;
30
+ if (!game.place || game.place.key !== placeKey) await game.enter(placeKey);
31
+ const place = game.place;
32
+ const g = place.g;
33
+ const W = g.frame.width, H = g.frame.height;
34
+ const canvas = document.getElementById('view');
35
+ const ctx = canvas.getContext('2d');
36
+ game.hero.path = []; game.speech = null; game.hover = undefined; game.grid = false; game.dark = 0;
37
+ if (typeof game.darkTarget === 'number') game.darkTarget = 0;
38
+
39
+ const lineAt = (line, x) => {
40
+ if (x <= line[0][0]) return line[0][1];
41
+ for (let i = 1; i < line.length; i++) {
42
+ if (x <= line[i][0]) {
43
+ const [x0, y0] = line[i - 1], [x1, y1] = line[i];
44
+ return x1 === x0 ? y1 : y0 + ((y1 - y0) * (x - x0)) / (x1 - x0);
45
+ }
46
+ }
47
+ return line[line.length - 1][1];
48
+ };
49
+ const occ = new Map(g.occluders.map((o) => [o.key, o]));
50
+ // Where each thing meets the floor at column x. Walls and water have no such line, and a coarse
51
+ // mask is a box around the thing, not its outline, so overlaps with it say nothing.
52
+ const things = place.hots.filter((h) => h.mask && !h.thing.coarse && h.thing.kind !== 'walk-on' && h.thing.kind !== 'wall' && h.thing.kind !== 'flat').map((h) => {
53
+ const own = g.occluders.filter((o) => (o.thing ?? o.key) === h.thing.key);
54
+ const base = (x) => {
55
+ const o = own.find((o) => x >= o.box[0] && x < o.box[2]) ?? own[0];
56
+ if (!o) return h.thing.box[3];
57
+ const under = o.on ? occ.get(o.on) : undefined;
58
+ const ref = under ?? o;
59
+ return ref.floor && ref.floor.length ? lineAt(ref.floor, x) : ref.baseY;
60
+ };
61
+ return { key: h.thing.key, bits: h.mask.bits, box: h.thing.box, base, solid: h.thing.kind === 'standing' };
62
+ });
63
+
64
+ // Standing things known only by a box: nobody should be drawn inside the box while behind its bottom.
65
+ const boxes = place.hots.filter((h) => h.thing.coarse && h.thing.kind === 'standing').map((h) => ({ key: h.thing.key, box: h.thing.box }));
66
+ const person = (y) => Math.max(8, g.scale.pxPerY * (y - g.scale.horizonY));
67
+ const region = (x0, y0, x1, y1) => ctx.getImageData(x0, y0, x1 - x0, y1 - y0).data;
68
+ game.hero.x = -5000; game.hero.y = H - 1;
69
+ game.render();
70
+ const empty = ctx.getImageData(0, 0, W, H).data;
71
+ const cuts = place.cuts;
72
+ const out = [];
73
+ for (const [x, y] of positions) {
74
+ const h = person(y);
75
+ const w = game.hero.img ? (h * game.hero.img.width) / game.hero.img.height : h * 0.36;
76
+ const off = Math.max(0, h - y) / h + (Math.max(0, w / 2 - x) + Math.max(0, x + w / 2 - W)) / w;
77
+ const x0 = Math.max(0, Math.floor(x - w / 2 - 3)), x1 = Math.min(W, Math.ceil(x + w / 2 + 3));
78
+ const y0 = Math.max(0, Math.floor(y - h - 3)), y1 = Math.min(H, Math.ceil(y + 4));
79
+ // foot: where the feet were drawn last (a stair's tread); a probe stands him afresh.
80
+ Object.assign(game.hero, { x, y, facing: 1, phase: 0, foot: undefined });
81
+ place.cuts = cuts; game.render();
82
+ const occluded = region(x0, y0, x1, y1);
83
+ place.cuts = []; game.depthOcclusion = false; game.render();
84
+ const bare = region(x0, y0, x1, y1);
85
+ place.cuts = cuts; game.depthOcclusion = true;
86
+ const front = game.frontOf ? game.frontOf([x0, y0, x1, y1], x, y) : null;
87
+ const hit = { x, y, drawn: 0, hidden: 0, overNearer: {}, overDepth: 0, hiddenByFarther: {}, overBox: {}, feetIn: [], off: Math.round(off * 100) / 100 };
88
+ const rw = x1 - x0;
89
+ for (let py = y0; py < y1; py++) {
90
+ // The shadow under his feet is not him.
91
+ if (py > y - Math.max(3, h * 0.04)) continue;
92
+ for (let px = x0; px < x1; px++) {
93
+ const r = ((py - y0) * rw + (px - x0)) * 4, f = (py * W + px) * 4;
94
+ const diff = Math.abs(bare[r] - empty[f]) + Math.abs(bare[r + 1] - empty[f + 1]) + Math.abs(bare[r + 2] - empty[f + 2]);
95
+ if (diff < 30) continue;
96
+ hit.drawn++;
97
+ const back = Math.abs(occluded[r] - empty[f]) + Math.abs(occluded[r + 1] - empty[f + 1]) + Math.abs(occluded[r + 2] - empty[f + 2]) < 12;
98
+ if (back) hit.hidden++;
99
+ // Still the hero as drawn with nothing put back: the depth map's work did not reach him there.
100
+ else if (front && front[(py - y0) * rw + (px - x0)] && Math.abs(occluded[r] - bare[r]) + Math.abs(occluded[r + 1] - bare[r + 1]) + Math.abs(occluded[r + 2] - bare[r + 2]) < 12) hit.overDepth++;
101
+ for (const b of boxes) {
102
+ if (px >= b.box[0] && px < b.box[2] && py >= b.box[1] && py < b.box[3] && y < b.box[3] - 4) hit.overBox[b.key] = (hit.overBox[b.key] ?? 0) + 1;
103
+ }
104
+ for (const t of things) {
105
+ if (px < t.box[0] || px >= t.box[2] || py < t.box[1] || py >= t.box[3] || !t.bits[py * W + px]) continue;
106
+ const base = t.base(px);
107
+ if (!back && base > y + 8) hit.overNearer[t.key] = (hit.overNearer[t.key] ?? 0) + 1;
108
+ if (back && base < y - 8) hit.hiddenByFarther[t.key] = (hit.hiddenByFarther[t.key] ?? 0) + 1;
109
+ }
110
+ }
111
+ }
112
+ // Inside a footprint: between where a thing meets the floor and a third of its width behind that.
113
+ for (const o of g.occluders) {
114
+ const cell = (o.foot ?? []).find(([fx]) => Math.abs(fx - x) <= 3);
115
+ if (cell && y > cell[1] + 2 && y < cell[2] - 2 && !hit.feetIn.includes(o.thing ?? o.key)) hit.feetIn.push(o.thing ?? o.key);
116
+ }
117
+ out.push(hit);
118
+ }
119
+ game.render();
120
+ return out;
121
+ }`;
122
+
123
+ /** Renders the hero at one spot and returns a PNG crop around him, for the juror's contact sheet. */
124
+ export const CROP_JS = String.raw`async ({ placeKey, x, y, size }) => {
125
+ const game = window.diorama;
126
+ if (!game.place || game.place.key !== placeKey) await game.enter(placeKey);
127
+ const g = game.place.g;
128
+ Object.assign(game.hero, { x, y, facing: 1, phase: 0, path: [] });
129
+ game.speech = null; game.hover = undefined; game.grid = false;
130
+ game.render();
131
+ const h = Math.max(8, g.scale.pxPerY * (y - g.scale.horizonY));
132
+ const side = Math.max(size, Math.ceil(h * 1.5));
133
+ const cx = Math.round(Math.min(g.frame.width - side / 2, Math.max(side / 2, x)));
134
+ const cy = Math.round(Math.min(g.frame.height - side / 2, Math.max(side / 2, y - h / 2)));
135
+ const c = document.createElement('canvas');
136
+ c.width = size; c.height = size;
137
+ c.getContext('2d').drawImage(document.getElementById('view'), cx - side / 2, cy - side / 2, side, side, 0, 0, size, size);
138
+ return c.toDataURL('image/png');
139
+ }`;
@@ -0,0 +1,118 @@
1
+ import type { Page } from 'playwright';
2
+ import type { Action } from '../story/engine.ts';
3
+
4
+ type Step = { use: string; verb?: string; with?: string } | { act: Action } | { wait: number };
5
+
6
+ /** Follow a route through the same pointer targets, inventory and answers as a person. */
7
+ export async function playRoute(page: Page, steps: Step[]): Promise<{ ending: string | null; frames: number; time: number }> {
8
+ // Track async dialogue actions triggered by DOM buttons, without invoking them ourselves.
9
+ await trackPending(page);
10
+ const settled = () => waitSettled(page);
11
+ for (const step of steps) {
12
+ if ('wait' in step) {
13
+ await page.evaluate(`window.diorama.wait(${JSON.stringify(step.wait)})`);
14
+ continue;
15
+ }
16
+ await settled();
17
+ if ('act' in step) {
18
+ if (step.act.kind !== 'choose' && step.act.kind !== 'leave') throw new Error(`No pointer control for ${step.act.kind}`);
19
+ const action = step.act.kind === 'choose' ? `choose:${step.act.index}` : 'leave';
20
+ await page.locator(`#talk button[data-action=${JSON.stringify(action)}]`).click();
21
+ } else {
22
+ if (step.with) await page.locator(`#inventory button[data-item=${JSON.stringify(step.with)}]`).click();
23
+ const point = await aimPoint(page, step);
24
+ if (!point) throw new Error(`No clickable pixel for ${step.use}: its pointer target is hidden or covered`);
25
+ await page.mouse.click(point.x, point.y, { button: step.verb === 'look' ? 'right' : 'left' });
26
+ }
27
+ await settled();
28
+ }
29
+ return page.evaluate('({ ending: window.diorama.state.ending ?? null, frames: window.diorama.frame, time: window.diorama.time })');
30
+ }
31
+
32
+ /** The page is still: in a place, not walking a scripted action, nobody speaking, not faded out. */
33
+ export const SETTLED = 'window.diorama.place && !window.diorama.busy && !window.diorama.pointerRoutePending && !window.diorama.speech && window.diorama.dark === 0';
34
+
35
+ /**
36
+ * Waits until the page is settled. Settled is a game-clock matter: on a busy machine a long line takes minutes of
37
+ * wall time. Each minute the game clock moved on counts as progress; only a minute without a frame is a game that hangs.
38
+ */
39
+ export async function waitSettled(page: Page): Promise<void> {
40
+ for (let last = -1; ;) {
41
+ try {
42
+ await page.waitForFunction(SETTLED, undefined, { timeout: 60_000 });
43
+ return;
44
+ } catch (error) {
45
+ const frame = await page.evaluate('window.diorama.frame') as number;
46
+ if (typeof frame !== 'number' || frame === last) throw error;
47
+ last = frame;
48
+ }
49
+ }
50
+ }
51
+
52
+ /** Counts the page's async dialogue actions started by DOM buttons (window.diorama.pointerRoutePending), once per page. */
53
+ export async function trackPending(page: Page): Promise<void> {
54
+ await page.evaluate(`(() => {
55
+ const game = window.diorama;
56
+ if (game.pointerRouteInstalled) return;
57
+ game.pointerRouteInstalled = true;
58
+ game.pointerRoutePending = 0;
59
+ const act = game.act.bind(game);
60
+ game.act = async (...args) => {
61
+ game.pointerRoutePending++;
62
+ try { return await act(...args); }
63
+ finally { game.pointerRoutePending--; }
64
+ };
65
+ })()`);
66
+ }
67
+
68
+ /**
69
+ * Where on the page a person would click to use `step.use` (with the item in hand, when `step.with`): the middle of
70
+ * its box when that hits it, else inside the largest exposed patch. Null when no pixel of it can be clicked.
71
+ * Throws when the place has no such thing or the click would not do `step.verb`.
72
+ */
73
+ export function aimPoint(page: Page, step: { use: string; verb?: string; with?: string }): Promise<{ x: number; y: number } | null> {
74
+ return page.evaluate<{ x: number; y: number } | null>(`(() => {
75
+ const step = ${JSON.stringify(step)};
76
+ const game = window.diorama;
77
+ const hot = game.hots().find(h => h.thing.key === step.use);
78
+ if (!hot) throw new Error(game.place.key + ' has no thing ' + step.use);
79
+ const action = game.actionFor(step.use, hot, step.verb === 'look' ? 'look' : undefined);
80
+ if (step.verb && (action.kind !== 'do' || action.verb !== step.verb || action.with !== step.with))
81
+ throw new Error('No pointer action ' + step.verb + ' for ' + step.use);
82
+ const canvas = document.querySelector('canvas');
83
+ const rect = canvas.getBoundingClientRect();
84
+ const box = game.pageRect(step.use) ?? rect;
85
+ const hits = (x, y) => {
86
+ const at = game.unzoom((x - rect.left) / rect.width * canvas.width, (y - rect.top) / rect.height * canvas.height);
87
+ return game.thingAt(...at)?.thing.key === step.use && document.elementFromPoint(x, y) === canvas;
88
+ };
89
+ const cx = box.left + box.width / 2, cy = box.top + box.height / 2;
90
+ if (hits(cx, cy)) return { x: cx, y: cy };
91
+ // Aim inside the largest exposed patch, not at its first edge pixel. An animated NPC can
92
+ // move between measuring and the actual pointer event, especially beside the inventory.
93
+ const left = Math.max(0, rect.left, box.left), top = Math.max(0, rect.top, box.top);
94
+ const right = Math.min(innerWidth, rect.right, box.right), bottom = Math.min(innerHeight, rect.bottom, box.bottom);
95
+ const stride = 4, cols = Math.ceil((right - left) / stride);
96
+ let previous = new Uint32Array(Math.max(0, cols) + 1), best = 0, aim = null;
97
+ for (let y = top + 0.5; y < bottom; y += stride) {
98
+ const row = new Uint32Array(previous.length);
99
+ for (let c = 1, x = left + 0.5; x < right; c++, x += stride) {
100
+ if (!hits(x, y)) continue;
101
+ row[c] = 1 + Math.min(row[c - 1], previous[c], previous[c - 1]);
102
+ if (row[c] > best) {
103
+ best = row[c];
104
+ aim = { x: x - (best - 1) * stride / 2, y: y - (best - 1) * stride / 2 };
105
+ }
106
+ }
107
+ previous = row;
108
+ }
109
+ if (aim && hits(aim.x, aim.y)) return aim;
110
+ // Preserve pixel-sized targets that the coarse search might step over.
111
+ for (let y = Math.max(0, rect.top, box.top) + 0.5; y < Math.min(innerHeight, rect.bottom, box.bottom); y++) {
112
+ for (let x = Math.max(0, rect.left, box.left) + 0.5; x < Math.min(innerWidth, rect.right, box.right); x++) {
113
+ if (hits(x, y)) return { x, y };
114
+ }
115
+ }
116
+ return null;
117
+ })()`);
118
+ }
@@ -0,0 +1,23 @@
1
+ // Every command module registers itself here on import.
2
+ export type Command = { name: string; usage: string; run: (args: string[]) => Promise<void> };
3
+
4
+ export const registry = new Map<string, Command>();
5
+
6
+ export function defineCommand(cmd: Command) {
7
+ registry.set(cmd.name, cmd);
8
+ }
9
+
10
+ /** Removes `--name value` from args and returns the value, or `fallback`. */
11
+ export function takeOption(args: string[], name: string, fallback: string): string {
12
+ const at = args.indexOf(`--${name}`);
13
+ if (at < 0) return fallback;
14
+ const [, value] = args.splice(at, 2);
15
+ return value ?? fallback;
16
+ }
17
+
18
+ /** With DIORAMA_EVENTS=1 (a job run by diorama studio), a line the studio reads as a live event; otherwise nothing. */
19
+ export function emit(type: string, data: Record<string, unknown> = {}) {
20
+ // A staged extension is not the live world: do not report its provisional verdict as a live place or trigger auto-repair.
21
+ if (process.env.DIORAMA_STAGED_BUILD === '1' && type === 'place.verdict') return;
22
+ if (process.env.DIORAMA_EVENTS === '1') process.stdout.write(`@@diorama ${JSON.stringify({ type, ...data })}\n`);
23
+ }
@@ -0,0 +1,36 @@
1
+ // Bind generated motion to the source painting and, for sprites, the model that made it.
2
+ import fs from 'node:fs';
3
+ import { createHash } from 'node:crypto';
4
+ import type { WorldFiles } from '../world/files.ts';
5
+ type Kind = 'rig' | 'model' | 'sprites';
6
+ type Binding = { painting: string; model?: string };
7
+ const hash = (file: string) => fs.existsSync(file) ? createHash('sha256').update(fs.readFileSync(file)).digest('hex') : undefined;
8
+ const manifest = (files: WorldFiles, key: string) => files.path('cast', key, 'animation-source.json');
9
+ const existing = (files: WorldFiles, key: string, kind: Kind) => files.path('cast', key, kind === 'rig' ? 'rig.json' : kind === 'model' ? 'model/model.json' : 'sprites/sprites.json');
10
+ const read = (files: WorldFiles, key: string): Partial<Record<Kind, Binding>> => fs.existsSync(manifest(files,key)) ? files.readJson(manifest(files,key)) as Partial<Record<Kind, Binding>> : {};
11
+ const current = (files: WorldFiles, key: string, kind: Kind): Binding | undefined => {
12
+ const painting = hash(files.path('cast',`${key}-painting.png`));
13
+ if (!painting) return;
14
+ const dir = files.path('cast', key, 'model');
15
+ const names = ['model.glb','model.json','front.png','back.png','left.png','right.png'];
16
+ const entries = kind === 'sprites' ? names.map(name => [name, hash(`${dir}/${name}`)] as const) : [];
17
+ const model = entries.some(([, value]) => value) ? createHash('sha256').update(JSON.stringify(entries)).digest('hex') : undefined;
18
+ return { painting, ...(model && {model}) };
19
+ };
20
+ /** Record legacy assets before replacing their source, without deleting paid work. */
21
+ export function bindExistingAnimation(files: WorldFiles, key: string) {
22
+ const saved=read(files,key); let changed=false;
23
+ for(const kind of ['rig','model','sprites'] as const) if(!saved[kind] && fs.existsSync(existing(files,key,kind))) {
24
+ const source=current(files,key,kind); if(source) { saved[kind]=source; changed=true; }
25
+ }
26
+ if(changed)files.write(manifest(files,key),saved);
27
+ }
28
+ export function bindAnimation(files: WorldFiles,key:string,kind:Kind) {
29
+ const source=current(files,key,kind); if(!source)return;
30
+ const saved=read(files,key);saved[kind]=source;files.write(manifest(files,key),saved);
31
+ }
32
+ export function animationCurrent(files:WorldFiles,key:string,kind:Kind):boolean {
33
+ const binding=read(files,key)[kind]; if(!binding)return true; // legacy remains usable until its source is replaced
34
+ const source=current(files,key,kind);
35
+ return !!source && source.painting===binding.painting && (!binding.model || source.model===binding.model);
36
+ }
@@ -0,0 +1,37 @@
1
+ // What the build made for the cast and the sound, apart from whether the places are playable: verify checks the
2
+ // places' geometry, and says nothing of a character with no walk or a bed that stayed silent.
3
+ import fs from 'node:fs';
4
+ import { modelProblem } from '../model/glb.ts';
5
+ import { liveVoiceFailures } from '../sound/data.ts';
6
+ import { SoundManifest } from '../sound/make.ts';
7
+ import { Voices } from '../sound/voices.ts';
8
+ import type { WorldFiles } from '../world/files.ts';
9
+ import { players } from '../world/drawings.ts';
10
+ import { readBodies } from './cast.ts';
11
+
12
+ export function assetReport(files: WorldFiles): string[] {
13
+ const world = files.world();
14
+ const keys = [world.hero.key, ...world.places.flatMap((p) => p.things.filter((t) => t.role === 'npc').map((t) => t.key)), ...players(files).map((p) => p.key)];
15
+ const has = (...f: string[]) => fs.existsSync(files.path(...f));
16
+ const model = (k: string) => has('cast', k, 'model', 'model.json') && !modelProblem(files.path('cast', k, 'model'));
17
+ const lines: string[] = [];
18
+ const count = (name: string, test: (k: string) => boolean, of = keys) => {
19
+ const missing = of.filter((k) => !test(k));
20
+ lines.push(`${name} ${of.length - missing.length}/${of.length}${missing.length ? ` (none: ${missing.join(', ')})` : ''}`);
21
+ };
22
+ count('figures', (k) => has('cast', `${k}.png`));
23
+ // A creature (not upright) moves as one body: it has no rig to miss.
24
+ const bodies = readBodies(files, fs.existsSync);
25
+ count('rig', (k) => has('cast', k, 'rig.json'), keys.filter((k) => has('cast', `${k}.png`) && bodies[k]?.upright !== false));
26
+ if (keys.some(model) || keys.some((k) => has('cast', k, 'model'))) count('3D model', model, keys.filter((k) => has('cast', k, 'model')));
27
+ if (has('voices.json')) {
28
+ const v = Voices.safeParse(files.readJson(files.path('voices.json')));
29
+ const failed = liveVoiceFailures(files);
30
+ if (v.success) lines.push(`voices ${v.data.lines.length} lines spoken${failed.length ? `, ${failed.length} not (${[...new Set(failed.map((f) => f.speaker))].join(', ')})` : ''}`);
31
+ }
32
+ if (has('sound', 'sounds.json')) {
33
+ const m = SoundManifest.safeParse(files.readJson(files.path('sound', 'sounds.json')));
34
+ if (m.success) lines.push(`sounds ${Object.keys(m.data.sounds).length} made${Object.keys(m.data.failures).length ? `, ${Object.keys(m.data.failures).length} silent (${Object.keys(m.data.failures).join(', ')}: sound_redo)` : ''}`);
35
+ }
36
+ return lines;
37
+ }
@@ -0,0 +1,133 @@
1
+ // The first, free tier of repair: fixes a build can make on its own, with no model call, before anyone is
2
+ // asked. Each is an ordinary correction (the same ones the fix tools write), so it shows in corrections_get,
3
+ // survives rebuilds and can be taken back with corrections_clear. Anything less certain is left to the agent.
4
+ import type { Geometry } from '../play/geometry.ts';
5
+ import type { Mask } from '../image/mask.ts';
6
+ import type { Corrections } from './corrections.ts';
7
+ import { diagnose } from './diagnose.ts';
8
+ import { GRID } from './locate.ts';
9
+ import type { Finding } from './verify.ts';
10
+ import { gridOf } from './verify.ts';
11
+ import { CELL, type Grid, islands, reach, roomy } from './walk.ts';
12
+
13
+ export type AutoFix = {
14
+ /** What was done, in a line for the report and the agent. */
15
+ what: string;
16
+ /** The step the fix feeds: the place is rebuilt from there. */
17
+ from: 'masks' | 'derive';
18
+ apply(c: Corrections): void;
19
+ };
20
+
21
+ /** A gap longer than this is no slip of a mask: it needs someone to look. */
22
+ const MAX_GAP = 3 * GRID;
23
+ /** Through stairs or a gangway the painting itself shows the way, so a longer gap is bridged. */
24
+ const MAX_GAP_WALK_ON = 6 * GRID;
25
+ /** Things the hero walks around, not over: a gap through one is not bridged blindly. */
26
+ const SOLID = new Set(['standing', 'wall']);
27
+
28
+ /** How tall a person may be at the front after the floor is cut back (verify allows up to 760). */
29
+ const NEAR_PX = 720;
30
+
31
+ /** How far a walk-on mask may stop short of a floor and still be taken to join it. */
32
+ const JOIN = 16;
33
+
34
+ /** Walk cells within JOIN px of a mask pixel. */
35
+ function nearMask(grid: Grid, m: Mask): Uint8Array {
36
+ const near = new Uint8Array(grid.open.length);
37
+ const reachCells = Math.ceil(JOIN / CELL);
38
+ for (let y = 0; y < m.height; y += 2) {
39
+ for (let x = 0; x < m.width; x += 2) {
40
+ if (!m.bits[y * m.width + x]) continue;
41
+ const c0 = Math.floor(x / CELL);
42
+ const r0 = Math.floor(y / CELL);
43
+ for (let r = r0 - reachCells; r <= r0 + reachCells; r++) {
44
+ for (let c = c0 - reachCells; c <= c0 + reachCells; c++) if (grid.inside(c, r)) near[grid.at(c, r)] = 1;
45
+ }
46
+ }
47
+ }
48
+ return near;
49
+ }
50
+
51
+ /**
52
+ * masks: the walk-on things' masks by key. Stairs only carry a bridge when their mask reaches both the patch
53
+ * and the reached floor: stairs that climb from the far side to somewhere else (water between the floors)
54
+ * join nothing, and a stroke across them would have the hero walk on water.
55
+ */
56
+ export function autoFixes(g: Geometry, floorKeys: string[], errors: Finding[], masks: Record<string, Mask> = {}): AutoFix[] {
57
+ const codes = new Set(errors.map((e) => e.code));
58
+ const d = diagnose(g);
59
+ const fixes: AutoFix[] = [];
60
+ if (codes.has('FLOOR_UNREACHABLE') || codes.has('UNREACHABLE')) {
61
+ const grid = gridOf(g);
62
+ const cells = roomy(grid);
63
+ const reached = reach(grid, grid.cellOf(...g.start), cells);
64
+ const parts = islands(grid, cells);
65
+ const joins = (key: string, patch: number[]) => {
66
+ const m = masks[key];
67
+ if (!m) return false;
68
+ const near = nearMask(grid, m);
69
+ return patch.some((i) => near[i]) && [...reached.keys()].some((i) => reached[i] && near[i]);
70
+ };
71
+ for (const p of d.patches) {
72
+ const gap = p.gap;
73
+ if (!gap || gap.things.some((t) => SOLID.has(t.kind ?? ''))) continue;
74
+ // Stairs or a gangway (a walk-on thing) standing in the gap are what joins the two levels: bridge along
75
+ // them even when the gap is long, but only when their mask touches both sides. Without one, a long gap is
76
+ // a real wall (or water) and needs someone to look: a repaint or a floor in the layout.
77
+ const patch = parts.find((x) => x.includes(grid.cellOf(...gap.from))) ?? [];
78
+ const walkOn = gap.things.filter((t) => t.kind === 'walk-on' && joins(t.key, patch));
79
+ const along = walkOn.length > 0;
80
+ if (gap.px > (along ? MAX_GAP_WALK_ON : MAX_GAP)) continue;
81
+ // Brush the reached floor across the gap: a stroke wide enough for the hero (two walk cells and a margin).
82
+ const floor = floorKeys[grid.zone[grid.cellOf(...gap.to)]] ?? floorKeys[p.floors[0]];
83
+ if (!floor) continue;
84
+ const points: [number, number][] = [gap.to, gap.from];
85
+ fixes.push({
86
+ what: `bridged ${p.cells} cells of floor ${p.floors.join('+') || 'none'} to ${floor} across ${gap.px} px (${gap.cells.join(' ') || 'thin gap'})${along ? ` along ${walkOn.map((t) => t.key).join(', ')}` : ''}`,
87
+ from: 'masks',
88
+ apply: (c) => {
89
+ const f = (c.floors[floor] ??= { add: [], remove: [], strokes: [] });
90
+ // The same bridge twice is no new fix: a build that tried it once leaves it to the agent.
91
+ if (!f.strokes.some((s) => JSON.stringify(s.points) === JSON.stringify(points))) f.strokes.push({ mode: 'add', radius: 14, points });
92
+ },
93
+ });
94
+ }
95
+ }
96
+ if (codes.has('SPOT_UNREACHABLE')) {
97
+ const wanted = new Set(errors.filter((e) => e.code === 'SPOT_UNREACHABLE').map((e) => e.key));
98
+ for (const s of d.spots) {
99
+ if (!wanted.has(s.thing) || !s.nearest) continue;
100
+ const at = s.nearest;
101
+ fixes.push({ what: `moved the stand spot of ${s.thing} to (${at.join(', ')}), the nearest reachable floor`, from: 'derive', apply: (c) => void ((c.things[s.thing] ??= { strokes: [] }).stand = at) });
102
+ }
103
+ }
104
+ // The hero would be a giant at the very front (a close camera, a big thing in the foreground): the bottom of
105
+ // the floor goes, up to where a person is NEAR_PX tall, as long as most of the floor stays walkable.
106
+ if (codes.has('SCALE_NEAR') && g.scale.pxPerY > 0) {
107
+ const cut = Math.floor(g.scale.horizonY + NEAR_PX / g.scale.pxPerY);
108
+ const rows = g.walk.map((row, r) => (row.includes('#') ? r : -1)).filter((r) => r >= 0);
109
+ const kept = rows.filter((r) => (r + 1) * g.cell <= cut).length;
110
+ const bottom = rows.length ? (rows[rows.length - 1] + 1) * g.cell : 0;
111
+ if (cut < bottom && kept >= rows.length / 2) {
112
+ const width = g.walk[0].length * g.cell;
113
+ const lines: [number, number][][] = [];
114
+ for (let y = cut + 12; y < bottom + 12; y += 16) lines.push([[0, y], [width, y]]);
115
+ fixes.push({
116
+ what: `took the floor below y ${cut} out of the walk, where a person would be over ${NEAR_PX} px tall`,
117
+ from: 'masks',
118
+ apply: (c) => {
119
+ // Walk-on things (a path, a jetty) are walked on too, so they lose the same strip.
120
+ const all = [...floorKeys.map((k) => (c.floors[k] ??= { add: [], remove: [], strokes: [] })), ...Object.keys(masks).map((k) => (c.things[k] ??= { strokes: [] }))];
121
+ for (const f of all) for (const points of lines) if (!f.strokes.some((x) => JSON.stringify(x.points) === JSON.stringify(points))) f.strokes.push({ mode: 'erase', radius: 12, points });
122
+ },
123
+ });
124
+ }
125
+ }
126
+ // Spark found the thing in another box: give SAM that box (one SAM call when the place is rebuilt).
127
+ for (const e of errors.filter((x) => x.code === 'MASK_WRONG' && x.key)) {
128
+ const box = e.box;
129
+ if (!box) continue;
130
+ fixes.push({ what: `gave SAM the box Spark sees ${e.key} in: [${box.join(', ')}]`, from: 'masks', apply: (c) => void ((c.things[e.key!] ??= { strokes: [] }).box = box) });
131
+ }
132
+ return fixes;
133
+ }