@gtanczyk/diorama 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +157 -2
- package/app/bin/diorama.mjs +13 -0
- package/app/src/actions/budget.ts +152 -0
- package/app/src/actions/build.ts +158 -0
- package/app/src/actions/cast.ts +248 -0
- package/app/src/actions/depth.ts +95 -0
- package/app/src/actions/extend.ts +36 -0
- package/app/src/actions/faces.ts +160 -0
- package/app/src/actions/feedback.ts +87 -0
- package/app/src/actions/fix.ts +382 -0
- package/app/src/actions/history.ts +93 -0
- package/app/src/actions/idea-preview.ts +65 -0
- package/app/src/actions/idea-review.ts +102 -0
- package/app/src/actions/idea-sketch.ts +172 -0
- package/app/src/actions/index.ts +31 -0
- package/app/src/actions/items.ts +94 -0
- package/app/src/actions/kids.ts +215 -0
- package/app/src/actions/models.ts +64 -0
- package/app/src/actions/motion.ts +119 -0
- package/app/src/actions/pictures.ts +49 -0
- package/app/src/actions/reactions.ts +36 -0
- package/app/src/actions/registry.ts +101 -0
- package/app/src/actions/share.ts +32 -0
- package/app/src/actions/sound.ts +73 -0
- package/app/src/actions/start.ts +154 -0
- package/app/src/actions/step-work.ts +150 -0
- package/app/src/actions/story.ts +101 -0
- package/app/src/actions/studio.ts +133 -0
- package/app/src/actions/style.ts +72 -0
- package/app/src/actions/video.ts +46 -0
- package/app/src/actions/view.ts +109 -0
- package/app/src/actions/weather.ts +38 -0
- package/app/src/actions/world.ts +289 -0
- package/app/src/commands/action.ts +39 -0
- package/app/src/commands/backdrop.ts +188 -0
- package/app/src/commands/browsers.ts +21 -0
- package/app/src/commands/build.ts +503 -0
- package/app/src/commands/cast.ts +142 -0
- package/app/src/commands/clip.ts +431 -0
- package/app/src/commands/derive.ts +167 -0
- package/app/src/commands/doctor.ts +74 -0
- package/app/src/commands/explore.ts +123 -0
- package/app/src/commands/export.ts +142 -0
- package/app/src/commands/extend.ts +75 -0
- package/app/src/commands/faces.ts +80 -0
- package/app/src/commands/icons.ts +69 -0
- package/app/src/commands/index.ts +36 -0
- package/app/src/commands/layout.ts +31 -0
- package/app/src/commands/masks.ts +117 -0
- package/app/src/commands/mcp.ts +52 -0
- package/app/src/commands/model.ts +284 -0
- package/app/src/commands/paint.ts +182 -0
- package/app/src/commands/plates.ts +97 -0
- package/app/src/commands/play.ts +192 -0
- package/app/src/commands/playthrough.ts +111 -0
- package/app/src/commands/publish.ts +78 -0
- package/app/src/commands/reactions.ts +40 -0
- package/app/src/commands/reel.ts +46 -0
- package/app/src/commands/setup.ts +85 -0
- package/app/src/commands/sound.ts +90 -0
- package/app/src/commands/sprites.ts +232 -0
- package/app/src/commands/story.ts +37 -0
- package/app/src/commands/studio.ts +65 -0
- package/app/src/commands/style.ts +87 -0
- package/app/src/commands/verify.ts +45 -0
- package/app/src/commands/voice-retry.ts +14 -0
- package/app/src/commands/voices.ts +145 -0
- package/app/src/commands/weather.ts +52 -0
- package/app/src/commands/world.ts +104 -0
- package/app/src/film/clip.ts +386 -0
- package/app/src/film/reel.ts +126 -0
- package/app/src/film/screencast.ts +123 -0
- package/app/src/image/mask.ts +29 -0
- package/app/src/image/overlay.ts +38 -0
- package/app/src/image/raster.ts +224 -0
- package/app/src/main.ts +28 -0
- package/app/src/meta/choice.ts +27 -0
- package/app/src/meta/http.ts +99 -0
- package/app/src/meta/memo.ts +91 -0
- package/app/src/meta/muse.ts +70 -0
- package/app/src/meta/openrouter.ts +93 -0
- package/app/src/meta/prices.ts +25 -0
- package/app/src/meta/sam.ts +83 -0
- package/app/src/meta/schema.ts +63 -0
- package/app/src/meta/spark.ts +220 -0
- package/app/src/meta/step.ts +14 -0
- package/app/src/meta/voice-file.ts +78 -0
- package/app/src/meta/voice.ts +103 -0
- package/app/src/model/animation.ts +38 -0
- package/app/src/model/blender/relax.py +284 -0
- package/app/src/model/blender.ts +115 -0
- package/app/src/model/build.ts +50 -0
- package/app/src/model/face.ts +21 -0
- package/app/src/model/fit.ts +181 -0
- package/app/src/model/glb.ts +241 -0
- package/app/src/model/plan.ts +151 -0
- package/app/src/model/quality.ts +48 -0
- package/app/src/model/space.ts +168 -0
- package/app/src/model/sprites.ts +293 -0
- package/app/src/model/views.ts +163 -0
- package/app/src/play/creature.ts +43 -0
- package/app/src/play/depth-occlusion.ts +150 -0
- package/app/src/play/exit-outline.ts +48 -0
- package/app/src/play/face3d.ts +34 -0
- package/app/src/play/focus.ts +78 -0
- package/app/src/play/geometry.ts +272 -0
- package/app/src/play/gestures.ts +23 -0
- package/app/src/play/glow.ts +187 -0
- package/app/src/play/lipsync.ts +69 -0
- package/app/src/play/living.ts +83 -0
- package/app/src/play/outline-path.ts +130 -0
- package/app/src/play/parallax.ts +200 -0
- package/app/src/play/path.ts +262 -0
- package/app/src/play/preview.ts +71 -0
- package/app/src/play/reactions.ts +92 -0
- package/app/src/play/rig.ts +172 -0
- package/app/src/play/shadow.ts +253 -0
- package/app/src/play/sound.ts +51 -0
- package/app/src/play/weather.ts +58 -0
- package/app/src/qa/explore.ts +136 -0
- package/app/src/qa/probe.ts +139 -0
- package/app/src/qa/route.ts +118 -0
- package/app/src/registry.ts +23 -0
- package/app/src/scene/animation-source.ts +36 -0
- package/app/src/scene/assets.ts +37 -0
- package/app/src/scene/autofix.ts +133 -0
- package/app/src/scene/backdrop.ts +326 -0
- package/app/src/scene/blockin.ts +189 -0
- package/app/src/scene/brief.ts +74 -0
- package/app/src/scene/carry.ts +177 -0
- package/app/src/scene/cast-fixes.ts +31 -0
- package/app/src/scene/cast.ts +192 -0
- package/app/src/scene/corrections.ts +164 -0
- package/app/src/scene/critic.ts +169 -0
- package/app/src/scene/depthmap.ts +149 -0
- package/app/src/scene/derive.ts +475 -0
- package/app/src/scene/diagnose.ts +132 -0
- package/app/src/scene/face-calibration.ts +65 -0
- package/app/src/scene/face-registration.ts +21 -0
- package/app/src/scene/face-work.ts +71 -0
- package/app/src/scene/faces-data.ts +48 -0
- package/app/src/scene/faces-settings.ts +20 -0
- package/app/src/scene/faces.ts +435 -0
- package/app/src/scene/glows.ts +153 -0
- package/app/src/scene/held.ts +55 -0
- package/app/src/scene/icons.ts +185 -0
- package/app/src/scene/layers.ts +822 -0
- package/app/src/scene/layout.ts +216 -0
- package/app/src/scene/locate.ts +118 -0
- package/app/src/scene/maskcheck.ts +43 -0
- package/app/src/scene/masks.ts +392 -0
- package/app/src/scene/occluders.ts +123 -0
- package/app/src/scene/occlusion-check.ts +138 -0
- package/app/src/scene/paint-layout.ts +135 -0
- package/app/src/scene/paint.ts +81 -0
- package/app/src/scene/photo.ts +123 -0
- package/app/src/scene/plates.ts +256 -0
- package/app/src/scene/playbook.ts +121 -0
- package/app/src/scene/preview.ts +23 -0
- package/app/src/scene/reactions.ts +118 -0
- package/app/src/scene/restyle.ts +53 -0
- package/app/src/scene/rig-check.ts +68 -0
- package/app/src/scene/rig.ts +375 -0
- package/app/src/scene/scale.ts +148 -0
- package/app/src/scene/shadows.ts +207 -0
- package/app/src/scene/stairs.ts +183 -0
- package/app/src/scene/status.ts +32 -0
- package/app/src/scene/verify.ts +156 -0
- package/app/src/scene/walk.ts +326 -0
- package/app/src/scene/warp.ts +417 -0
- package/app/src/scene/weather.ts +118 -0
- package/app/src/scene/words.ts +65 -0
- package/app/src/secrets.ts +219 -0
- package/app/src/setup/checks.ts +110 -0
- package/app/src/sound/cost.ts +74 -0
- package/app/src/sound/data.ts +89 -0
- package/app/src/sound/fixes.ts +23 -0
- package/app/src/sound/issues.ts +21 -0
- package/app/src/sound/language.ts +11 -0
- package/app/src/sound/lipsync-make.ts +98 -0
- package/app/src/sound/lipsync.ts +24 -0
- package/app/src/sound/make.ts +229 -0
- package/app/src/sound/musicgen.py +20 -0
- package/app/src/sound/pcm.ts +280 -0
- package/app/src/sound/plan.ts +154 -0
- package/app/src/sound/retry.ts +43 -0
- package/app/src/sound/settings.ts +28 -0
- package/app/src/sound/sources.ts +81 -0
- package/app/src/sound/spoken.ts +70 -0
- package/app/src/sound/voices.ts +579 -0
- package/app/src/speech/transcribe.ts +38 -0
- package/app/src/speech/types.ts +18 -0
- package/app/src/story/check.ts +230 -0
- package/app/src/story/design.ts +108 -0
- package/app/src/story/engine.ts +233 -0
- package/app/src/story/fixes.ts +54 -0
- package/app/src/story/npc-guidance.ts +7 -0
- package/app/src/story/phrases.ts +4 -0
- package/app/src/story/schema.ts +92 -0
- package/app/src/studio/agent.ts +554 -0
- package/app/src/studio/discovery.ts +28 -0
- package/app/src/studio/events.ts +48 -0
- package/app/src/studio/history.ts +124 -0
- package/app/src/studio/jobs.ts +291 -0
- package/app/src/studio/locks.ts +33 -0
- package/app/src/studio/mcp.ts +86 -0
- package/app/src/studio/operation.ts +18 -0
- package/app/src/studio/repair.ts +31 -0
- package/app/src/studio/server.ts +421 -0
- package/app/src/studio/trouble.ts +48 -0
- package/app/src/world/design.ts +72 -0
- package/app/src/world/drawings.ts +198 -0
- package/app/src/world/extend-stage.ts +109 -0
- package/app/src/world/extend.ts +123 -0
- package/app/src/world/extension-progress.ts +10 -0
- package/app/src/world/files.ts +62 -0
- package/app/src/world/idea-review.ts +32 -0
- package/app/src/world/photo-limits.ts +2 -0
- package/app/src/world/pool.ts +41 -0
- package/app/src/world/schedule.ts +64 -0
- package/app/src/world/schema.ts +74 -0
- package/app/src/world/sketch.ts +81 -0
- package/app/src/world/style-plan.ts +90 -0
- package/app/src/world/style-reference.ts +16 -0
- package/app/src/world/styled.ts +51 -0
- package/app/src/world/told.ts +105 -0
- package/app/src/world/video-limits.ts +3 -0
- package/app/src/world/video.ts +98 -0
- package/app/studio/activity.tsx +124 -0
- package/app/studio/agent.tsx +384 -0
- package/app/studio/api.ts +111 -0
- package/app/studio/atelier.tsx +234 -0
- package/app/studio/audio-missing.tsx +25 -0
- package/app/studio/audio-setup.tsx +33 -0
- package/app/studio/blender-setup.tsx +30 -0
- package/app/studio/build.tsx +677 -0
- package/app/studio/camera.tsx +59 -0
- package/app/studio/cast-work.tsx +207 -0
- package/app/studio/cast.tsx +178 -0
- package/app/studio/concept-edit.tsx +47 -0
- package/app/studio/creator-idea.tsx +46 -0
- package/app/studio/custom-look.tsx +36 -0
- package/app/studio/faces-setup.tsx +28 -0
- package/app/studio/favicon.svg +11 -0
- package/app/studio/figure.tsx +314 -0
- package/app/studio/fonts/OFL.txt +93 -0
- package/app/studio/fonts/fonts.css +7 -0
- package/app/studio/fonts/fraunces-italic-latin-ext.woff2 +0 -0
- package/app/studio/fonts/fraunces-italic-latin.woff2 +0 -0
- package/app/studio/fonts/fraunces-normal-latin-ext.woff2 +0 -0
- package/app/studio/fonts/fraunces-normal-latin.woff2 +0 -0
- package/app/studio/fonts/instrument-sans-normal-latin-ext.woff2 +0 -0
- package/app/studio/fonts/instrument-sans-normal-latin.woff2 +0 -0
- package/app/studio/icons.tsx +42 -0
- package/app/studio/idea-card.tsx +42 -0
- package/app/studio/idea-review.tsx +181 -0
- package/app/studio/idea-storyboard.tsx +134 -0
- package/app/studio/index.html +15 -0
- package/app/studio/job-words.ts +33 -0
- package/app/studio/lightbox.tsx +49 -0
- package/app/studio/main.tsx +1328 -0
- package/app/studio/nav.ts +14 -0
- package/app/studio/place.tsx +264 -0
- package/app/studio/review-sketch.tsx +47 -0
- package/app/studio/say-it.tsx +49 -0
- package/app/studio/step-work.tsx +134 -0
- package/app/studio/story.tsx +394 -0
- package/app/studio/style-menu.tsx +81 -0
- package/app/studio/style.css +1390 -0
- package/app/studio/sure.tsx +54 -0
- package/app/studio/tip.tsx +74 -0
- package/app/studio/tsconfig.json +5 -0
- package/app/studio/video.tsx +73 -0
- package/app/studio/world.tsx +188 -0
- package/app/tsconfig.json +13 -0
- package/app/web/audio.ts +315 -0
- package/app/web/face.ts +131 -0
- package/app/web/face3d.ts +35 -0
- package/app/web/glows.ts +387 -0
- package/app/web/hero3d.ts +728 -0
- package/app/web/index.html +118 -0
- package/app/web/living.ts +404 -0
- package/app/web/main.ts +3751 -0
- package/app/web/pad.ts +174 -0
- package/app/web/reactions.ts +79 -0
- package/app/web/sheet.ts +36 -0
- package/app/web/synth.ts +140 -0
- package/app/web/tsconfig.json +5 -0
- package/app/web/ui.ts +263 -0
- package/app/web/weather.ts +306 -0
- package/app/web/words.ts +12 -0
- package/docs/characters.md +58 -0
- package/docs/npc-motion.md +69 -0
- package/docs/scenes.md +75 -0
- package/docs/setup.md +60 -0
- package/docs/sound.md +23 -0
- package/docs/studio.md +62 -0
- package/package.json +70 -4
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// What to do about each error a place can end a build with: the fixes to try, cheapest first, and the
|
|
2
|
+
// step the place is rebuilt from after them. The build report and the agent's repair brief both read it,
|
|
3
|
+
// so a failed gate says how to get past it instead of only that it failed.
|
|
4
|
+
import type { Finding } from './verify.ts';
|
|
5
|
+
|
|
6
|
+
export type Play = {
|
|
7
|
+
/** Where to look first. */
|
|
8
|
+
look: string;
|
|
9
|
+
/** Fix tools in the order to try them: each is a Studio action and keeps its fix in corrections.json. */
|
|
10
|
+
tools: string[];
|
|
11
|
+
/** Only when the tools did not help: it costs a model call or changes the place a lot. */
|
|
12
|
+
last?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const FLOOR_GAP: Play = {
|
|
16
|
+
look: 'place_view: the red patches are floor the hero cannot reach; each yellow dashed gap names the cells to bridge and the things standing in it. '
|
|
17
|
+
+ 'A red patch that is no floor at all (a wall, the sky, water) is taken back, not bridged: when corrections_get shows a brush stroke or added cells there, '
|
|
18
|
+
+ 'corrections_clear that floor (or mask_brush erase over it); floor_cells remove does not undo a brush stroke, strokes are painted after the cells',
|
|
19
|
+
tools: [
|
|
20
|
+
'floor_cells: add the cells that bridge the gap (stairs, a gangway, a jetty edge)',
|
|
21
|
+
'mask_fix kind: a thing drawn over the join that people walk on becomes walk-on',
|
|
22
|
+
'mask_fix phrase: name the floor better for SAM ("wooden jetty planks")',
|
|
23
|
+
],
|
|
24
|
+
last: 'repaint_region: paint steps or a plank across the gap (one Muse edit); when water or a drop parts the floors, or a standing thing is laid across the only way, the layout needs a floor that joins them or the thing moved (relayout and repaint: ask first)',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** The camera stands too close: the hero would be taller than the frame at the front. */
|
|
28
|
+
const CLOSE_UP: Play = {
|
|
29
|
+
look: 'place_status: the blue rulers on walk.png show how tall the hero is at the front; a DERIVE flag "too near the camera" counts the front cells shut for it',
|
|
30
|
+
tools: [
|
|
31
|
+
'run_step derive: shuts the front cells where the hero would be too tall; often enough on its own',
|
|
32
|
+
'floor_cells remove: floor cells at the very bottom edge that stretch the scale',
|
|
33
|
+
],
|
|
34
|
+
last: 'relayout and repaint the place with the camera farther back (layout now refuses a close camera; ask first)',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const PLAYBOOK: Record<string, Play> = {
|
|
38
|
+
PAINT_LAYOUT_MISMATCH: {
|
|
39
|
+
look: 'place_status.paintingLayout: measured composition problems; place_view layers blockin, first and painting compare the plan with the art',
|
|
40
|
+
tools: ['paint_retry: new take with the current plan, followed by composition checks and rebuilding this place'],
|
|
41
|
+
last: 'layout_fix: simplify the plan with concrete camera, floor joins and object-position guidance, then rebuild this place; allowed for an observed composition rejection',
|
|
42
|
+
},
|
|
43
|
+
FLOOR_UNREACHABLE: FLOOR_GAP,
|
|
44
|
+
UNREACHABLE: FLOOR_GAP,
|
|
45
|
+
NO_FLOOR: {
|
|
46
|
+
look: 'place_status: the floors list says how each floor mask was found; walk.png shows what is walkable',
|
|
47
|
+
tools: ['mask_fix phrase: a better name for the floor', 'floor_cells: add the cells people walk on'],
|
|
48
|
+
last: 'repaint_region: make the floor read as floor (one Muse edit)',
|
|
49
|
+
},
|
|
50
|
+
START_BLOCKED: {
|
|
51
|
+
look: 'place_view: the start (green dot) sits off the green floor',
|
|
52
|
+
tools: ['floor_cells: open the cells under the start', 'mask_brush: erase the thing mask that covers the start'],
|
|
53
|
+
},
|
|
54
|
+
SPOT_UNREACHABLE: {
|
|
55
|
+
look: 'place_view: the spot is a red dot, with a dashed line to the nearest reachable point',
|
|
56
|
+
tools: ['stand_set: a spot on reachable floor next to the thing', 'mask_fix box: the box where the thing really is'],
|
|
57
|
+
},
|
|
58
|
+
MASK_MISSING: {
|
|
59
|
+
look: 'place_status: the thing has no mask; masks/overlay.png shows what SAM found',
|
|
60
|
+
tools: ['mask_fix phrase: a name SAM can find', 'mask_fix box: the box where it is', 'mask_brush: paint its mask'],
|
|
61
|
+
last: 'repaint_region: make the thing visible (one Muse edit)',
|
|
62
|
+
},
|
|
63
|
+
MASK_WRONG: {
|
|
64
|
+
look: 'place_view layer masks: the thing\'s mask against the box it should be in; the error gives the box where Spark sees the thing',
|
|
65
|
+
tools: ['mask_fix box: the box from the error (or the one you see)', 'mask_fix phrase: a name SAM tells apart from its neighbour'],
|
|
66
|
+
last: 'repaint_region: when the thing is not painted at all (one Muse edit)',
|
|
67
|
+
},
|
|
68
|
+
CARD_CUT: {
|
|
69
|
+
look: 'place_view layer masks: the thing\'s mask leaves out a part of it (what is in or on it: fish over a bucket\'s rim, a lamp on its post); the flag\'s box is where its outline cuts through that part',
|
|
70
|
+
tools: ['mask_fix box: a box around the whole thing with the part; masks asks SAM by that box and joins what it finds to the mask, then derive and backdrop follow', 'mask_brush: paint the part onto its mask'],
|
|
71
|
+
},
|
|
72
|
+
THING_NOT_PAINTED: {
|
|
73
|
+
look: 'place_view layer painting: the thing\'s box shows something else, and Spark found the thing nowhere in the painting',
|
|
74
|
+
tools: ['mask_fix box: when you do see it painted somewhere else'],
|
|
75
|
+
last: 'repaint_region: paint the thing in at its box with its looks (one Muse edit), then rebuild the place from masks; the build already tried this once at the layout box (corrections_get shows it), so look at where it landed first',
|
|
76
|
+
},
|
|
77
|
+
FLOOR_LOST: {
|
|
78
|
+
look: 'place_view layer walk: the floor (stairs, a trail) is painted somewhere else than the layout put it, or not as floor at all, so nothing on it can be walked to; the build already asked for one new take of the painting',
|
|
79
|
+
tools: ['floor_cells add: the cells where the floor is painted, when it is painted as floor somewhere', 'mask_fix phrase: a name SAM finds the painted floor by'],
|
|
80
|
+
last: 'paint with a new take (run_step paint --take N for the place), then rebuild the place from masks',
|
|
81
|
+
},
|
|
82
|
+
THING_MISSING: {
|
|
83
|
+
look: 'world_status: the place lost the thing between layout and derive',
|
|
84
|
+
tools: ['run_step masks, then derive, for the world'],
|
|
85
|
+
},
|
|
86
|
+
HORIZON_LOW: {
|
|
87
|
+
look: 'place_status: walk.png draws the horizon as a dashed blue line and the hero as blue rulers',
|
|
88
|
+
tools: ['mask_fix phrase: the floor mask may reach into the sky', 'floor_cells remove: cells high up that are not floor'],
|
|
89
|
+
},
|
|
90
|
+
SCALE_NEAR: CLOSE_UP,
|
|
91
|
+
SCALE_FALLBACK: {
|
|
92
|
+
look: 'place_status: the DERIVE flags say why Spark gave no scale ("the asks failed": a hiccup; "N usable points": its answers did not fit); walk.png\'s blue rulers show how tall the hero is where',
|
|
93
|
+
tools: ['run_step derive with the place: Spark judges a person\'s height again (3 Spark calls), the walk floor and fixes stay'],
|
|
94
|
+
},
|
|
95
|
+
SCALE_FAR: {
|
|
96
|
+
look: 'place_status: the blue rulers on walk.png show how tall the hero is at the back',
|
|
97
|
+
tools: ['floor_cells remove: far floor cells near the horizon that no one should walk to'],
|
|
98
|
+
},
|
|
99
|
+
STEP_FAILED: {
|
|
100
|
+
look: 'job_wait or job_list: the last lines of the failed job say why the step broke',
|
|
101
|
+
tools: [
|
|
102
|
+
'layout_fix: when layout failed, save concrete camera, size or floor guidance and rebuild; works before any place files exist',
|
|
103
|
+
'job_retry: when it was a network or model hiccup (it starts at the step where the place broke)',
|
|
104
|
+
'paint_words: when paint was refused by the image safety filter (content_policy_violation) even after Spark reworded it: calm, plain words for the scene and the looks it names',
|
|
105
|
+
'PAINT_LAYOUT_MISMATCH: read place_status.paintingLayout and place_view layers blockin/first/painting; paint already tried a composition repair. Use paint_retry for a new take or layout_fix for a simpler connected plan; after 2 rejected plans in a row layout_fix waits for a paint_retry. Do not bridge displaced objects with floor_cells',
|
|
106
|
+
'the fix tool for what the error names, then rebuild the place',
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const NEAR_CAMERA = /too near the camera/;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A finding with what to do about it, when the playbook knows. flags: the place's verify flags; a floor cut off
|
|
115
|
+
* right after derive shut the front rows for a close camera is a close-up, not a gap to bridge.
|
|
116
|
+
*/
|
|
117
|
+
export function withPlay(f: Finding, flags: Finding[] = []): Finding & { play?: Play } {
|
|
118
|
+
const closeUp = (f.code === 'UNREACHABLE' || f.code === 'FLOOR_UNREACHABLE') && flags.some((x) => x.code === 'DERIVE' && NEAR_CAMERA.test(x.detail));
|
|
119
|
+
const play = closeUp ? CLOSE_UP : PLAYBOOK[f.code];
|
|
120
|
+
return play ? { ...f, play } : f;
|
|
121
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// A drawing brought to life at once, while the children are still drawing: a character as the figure the game
|
|
2
|
+
// will use (same prompt, same drawing, so cast finds it in the memo), a place or a thing as a quick painting.
|
|
3
|
+
import sharp from 'sharp';
|
|
4
|
+
import type { Memo } from '../meta/memo.ts';
|
|
5
|
+
import { museEdit } from '../meta/muse.ts';
|
|
6
|
+
import { KIDS_STYLE, type Drawn } from '../world/drawings.ts';
|
|
7
|
+
import type { World } from '../world/schema.ts';
|
|
8
|
+
import { paintFigure } from './cast.ts';
|
|
9
|
+
import { FRAME } from './layout.ts';
|
|
10
|
+
|
|
11
|
+
export async function previewDrawing(memo: Memo, drawn: Drawn, drawing: Buffer): Promise<Buffer> {
|
|
12
|
+
if (drawn.kind === 'hero' || drawn.kind === 'npc' || drawn.kind === 'player') {
|
|
13
|
+
const { figure } = await paintFigure(memo, { style: KIDS_STYLE } as World, { key: drawn.key, looks: drawn.looks }, 0, { drawing });
|
|
14
|
+
return figure;
|
|
15
|
+
}
|
|
16
|
+
const prompt = drawn.kind === 'place'
|
|
17
|
+
? `This is a child's drawing on paper of a place. Paint it as a finished background of a 2D adventure game, so the child knows it at once: the same things where they drew them, their colours and shapes, only whole and alive, with depth and light. No paper, no table, no written names, no people, no text.\n\nPlace: ${drawn.looks}\nStyle: ${KIDS_STYLE}`
|
|
18
|
+
: `This is a child's drawing on paper. Paint the thing in it alone, whole and centred on a plain pale background, as the child drew it: the same colours and shapes, only cleaner and alive. No paper, no table, no written names, no text.\n\nThing: ${drawn.looks}\nStyle: ${KIDS_STYLE}`;
|
|
19
|
+
const painted = await museEdit(memo, { label: `preview:${drawn.key}`, prompt, images: [drawing] });
|
|
20
|
+
return drawn.kind === 'place'
|
|
21
|
+
? sharp(painted).resize(FRAME.width, FRAME.height, { fit: 'fill' }).png().toBuffer()
|
|
22
|
+
: sharp(painted).resize(900, 900, { fit: 'inside' }).png().toBuffer();
|
|
23
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// One vision-language plan per place. Bind proposals to reachable walk cells and exact story actions.
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { Burst, Reactions } from '../play/reactions.ts';
|
|
4
|
+
import { When, type Story } from '../story/schema.ts';
|
|
5
|
+
import type { Geometry } from '../play/geometry.ts';
|
|
6
|
+
import type { Layout } from './layout.ts';
|
|
7
|
+
import type { Place, World } from '../world/schema.ts';
|
|
8
|
+
import { askSparkJson } from '../meta/spark.ts';
|
|
9
|
+
import { inputHash, type Memo } from '../meta/memo.ts';
|
|
10
|
+
import { gridOf } from './verify.ts';
|
|
11
|
+
import { reach } from './walk.ts';
|
|
12
|
+
|
|
13
|
+
const XY = z.tuple([z.number(), z.number()]);
|
|
14
|
+
export const ReactionPlan = z.object({
|
|
15
|
+
scene: z.string().min(1),
|
|
16
|
+
areas: z.array(z.object({
|
|
17
|
+
key: z.string().min(1), floor: z.string(),
|
|
18
|
+
polygon: z.array(XY).min(3).max(12).optional(),
|
|
19
|
+
step: Burst.optional(), enter: Burst.optional(), when: When,
|
|
20
|
+
once: z.boolean().default(false), cooldown: z.number().min(0.2).max(30).default(1.5),
|
|
21
|
+
})).max(8),
|
|
22
|
+
interactions: z.array(z.object({
|
|
23
|
+
key: z.string(), interaction: z.number().int().nonnegative(),
|
|
24
|
+
at: z.tuple([z.number().min(0).max(1), z.number().min(0).max(1)]),
|
|
25
|
+
burst: Burst, cooldown: z.number().min(0.2).max(30).default(1),
|
|
26
|
+
})).max(12),
|
|
27
|
+
});
|
|
28
|
+
export type ReactionPlan = z.infer<typeof ReactionPlan>;
|
|
29
|
+
export type ReactionInput = { world: World; place: Place; layout: Layout; geometry: Geometry; story: Story | null; weather?: unknown; hint?: string; painting: Buffer };
|
|
30
|
+
export function reactionSource(i: ReactionInput): string {
|
|
31
|
+
return inputHash('reactions-planner-2', JSON.stringify({ style: i.world.style, place: i.place, layout: i.layout, geometry: i.geometry, story: i.story, weather: i.weather, hint: i.hint }), i.painting);
|
|
32
|
+
}
|
|
33
|
+
function contains(p: number[][], x: number, y: number) {
|
|
34
|
+
let inside = false;
|
|
35
|
+
for (let i = 0, j = p.length - 1; i < p.length; j = i++) {
|
|
36
|
+
const [a, b] = p[i], [c, d] = p[j];
|
|
37
|
+
if ((b > y) !== (d > y) && x < (c - a) * (y - b) / (d - b) + a) inside = !inside;
|
|
38
|
+
}
|
|
39
|
+
return inside;
|
|
40
|
+
}
|
|
41
|
+
export function bindReactions(plan: ReactionPlan, input: ReactionInput): Reactions {
|
|
42
|
+
const { geometry: g, layout, story, place } = input;
|
|
43
|
+
const grid = gridOf(g);
|
|
44
|
+
const cellOf = (x: number, y: number) => grid.at(Math.floor(x / g.cell), Math.floor(y / g.cell));
|
|
45
|
+
const reachable = reach(grid, cellOf(...g.start));
|
|
46
|
+
const seen = new Set<string>();
|
|
47
|
+
const key = (k: string) => { if (seen.has(k)) throw new Error(`duplicate reaction key ${k}`); seen.add(k); };
|
|
48
|
+
const facts = new Set(story?.interactions.flatMap((it) => it.do.flatMap((e) => 'set' in e ? [e.set] : [])) ?? []);
|
|
49
|
+
for (const d of story?.dialogues ?? []) for (const l of d.lines) for (const c of l.choices) for (const e of c.do) if ('set' in e) facts.add(e.set);
|
|
50
|
+
const areas = plan.areas.map((a) => {
|
|
51
|
+
key(a.key);
|
|
52
|
+
if (!a.step && !a.enter) throw new Error(`${a.key}: needs step or enter`);
|
|
53
|
+
for (const f of a.when) {
|
|
54
|
+
const fact = f.replace(/^!/, '');
|
|
55
|
+
if (!(fact.startsWith('has:') ? story?.items.some((i) => i.key === fact.slice(4)) : fact.startsWith('at:') ? input.world.places.some((p) => p.key === fact.slice(3)) : facts.has(fact))) throw new Error(`${a.key}: unknown condition ${f}`);
|
|
56
|
+
}
|
|
57
|
+
const floor = layout.floors.findIndex((f) => f.key === a.floor);
|
|
58
|
+
if (floor < 0) throw new Error(`${a.key}: unknown floor ${a.floor}`);
|
|
59
|
+
if (a.polygon?.some(([x, y]) => x < 0 || y < 0 || x > g.frame.width || y > g.frame.height)) throw new Error(`${a.key}: polygon outside painting`);
|
|
60
|
+
const cells = g.walk.map((row, r) => [...row].map((ch, c) => ch === '#' && reachable[grid.at(c, r)] && g.zone?.[r]?.[c] === String(floor) && (!a.polygon || contains(a.polygon, (c + .5) * g.cell, (r + .5) * g.cell)) ? '#' : '.').join(''));
|
|
61
|
+
if (!cells.some((row) => row.includes('#'))) throw new Error(`${a.key}: no reachable cells on ${a.floor}`);
|
|
62
|
+
const { floor: _, polygon: __, ...rule } = a;
|
|
63
|
+
return { ...rule, cells };
|
|
64
|
+
});
|
|
65
|
+
// Two step recipes in one cell double every footstep; local patches must partition the floor.
|
|
66
|
+
const steps = new Set<number>();
|
|
67
|
+
for (const a of areas.filter((a) => a.step)) a.cells.forEach((row, r) => [...row].forEach((v, c) => {
|
|
68
|
+
if (v !== '#') return;
|
|
69
|
+
const i = grid.at(c, r);
|
|
70
|
+
if (steps.has(i)) throw new Error('step areas overlap: partition the polygons or use one recipe per floor');
|
|
71
|
+
steps.add(i);
|
|
72
|
+
}));
|
|
73
|
+
const assigned = new Set<number>();
|
|
74
|
+
const interactions = plan.interactions.map((r) => {
|
|
75
|
+
key(r.key);
|
|
76
|
+
if (assigned.has(r.interaction)) throw new Error(`${r.key}: duplicate interaction recipe`);
|
|
77
|
+
assigned.add(r.interaction);
|
|
78
|
+
const it = story?.interactions[r.interaction];
|
|
79
|
+
if (!it || it.place !== place.key || !['use', 'take'].includes(it.verb)) throw new Error(`${r.key}: interaction must be a use or take in this place`);
|
|
80
|
+
const thing = g.things.find((t) => t.key === it.thing);
|
|
81
|
+
if (!thing || !reachable[cellOf(...thing.stand)]) throw new Error(`${r.key}: no reachable target ${it.thing}`);
|
|
82
|
+
const { interaction: _, ...rule } = r;
|
|
83
|
+
return { ...rule, thing: it.thing, action: JSON.stringify(it) };
|
|
84
|
+
});
|
|
85
|
+
return Reactions.parse({ version: 1, source: reactionSource(input), scene: plan.scene, areas, interactions });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function planReactions(memo: Memo, input: ReactionInput): Promise<{ plan: ReactionPlan; reactions: Reactions }> {
|
|
89
|
+
const { world, place, layout, geometry: g, story } = input;
|
|
90
|
+
const actions = story?.interactions.flatMap((it, index) => it.place === place.key && ['use', 'take'].includes(it.verb) ? [{ index, ...it }] : []) ?? [];
|
|
91
|
+
const prompt = `Plan subtle physical reactions for this painted point-and-click scene. Return JSON only.
|
|
92
|
+
Style: ${world.style}
|
|
93
|
+
Place: ${JSON.stringify(place)}
|
|
94
|
+
Frame pixels: ${g.frame.width} x ${g.frame.height}. Floors, in zone order: ${JSON.stringify(layout.floors)}
|
|
95
|
+
Actual objects and their boxes/stand points: ${JSON.stringify(g.things)}
|
|
96
|
+
Weather: ${JSON.stringify(input.weather ?? {})}
|
|
97
|
+
Available story interactions, with global indexes: ${JSON.stringify(actions)}
|
|
98
|
+
Author's preference: ${input.hint ?? 'Choose restrained, natural effects automatically.'}
|
|
99
|
+
|
|
100
|
+
Return {scene: short explanation of material, wetness and style choices, areas: [], interactions: []}.
|
|
101
|
+
An area: {key, floor: exact floor key, polygon?: [[pixelX,pixelY],...], step?: burst, enter?: burst, when: [], once: false, cooldown: 1.5}.
|
|
102
|
+
Omit polygon for the whole floor; use it only for a clearly visible local patch (leaves, snow, dust, puddle) and keep it on that floor. The compiler clips it to reachable floor cells. No overlapping step areas: use a single recipe per floor or non-overlapping polygons. Enter means crossing into an area, not spawning there; use it only where entry really stirs something, not as a second effect on every ordinary floor. Conditions use story facts (!flag, has:item); normally [].
|
|
103
|
+
Look specifically for localized loose material on reachable ground, such as scattered straw near a hay cart, leaves beneath a tree, or a small puddle. When present, an enter-only polygon can add one small burst on crossing its boundary, with a cooldown. It may overlap a general step area. Do not place it on the solid cart/tree itself. Straw/chaff can use pale-gold leaves or dust. When there is no convincing patch, omit it.
|
|
104
|
+
A burst: {kind: dust|splash|snow|leaves|sparks, colour: #rrggbb, strength: 0.1..1}. Prefer 0.25..0.5. Dry loose soil/sand/dust: dust. Wet floors/puddles: splash. Lying snow: snow. Visible loose leaves: leaves. Sparks only for actual fire/magic/metal impact supported by the story. Clean wood, clean stone, indoor tiles: no effect. Rain in the air alone does not prove a floor is wet. Do not invent dusty surfaces to fill the lists; empty lists are valid.
|
|
105
|
+
An interaction: {key, interaction: global index above, at: [0..1,0..1] within that object's box, burst, cooldown: 1}.
|
|
106
|
+
Choose only physical use/take actions: dust dislodged from a dusty object, droplets shaken off a wet object, leaves stirred from vegetation, a supported magical response. Never attach a reaction to a refusal, looking, conversation, or a failed prerequisite. Do not add generic success glitter to clean items. Match the actual action outcome and object material. No more than 8 areas and 12 interactions.`;
|
|
107
|
+
let problem = '';
|
|
108
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
109
|
+
try {
|
|
110
|
+
const plan = ReactionPlan.parse(await askSparkJson(memo, { schema: ReactionPlan, label: `reactions:${place.key}`, images: [input.painting], prompt: prompt + problem }));
|
|
111
|
+
return { plan, reactions: bindReactions(plan, input) };
|
|
112
|
+
} catch (e) {
|
|
113
|
+
if (attempt === 2) throw e;
|
|
114
|
+
problem = `\nCorrect the previous plan: ${e instanceof Error ? e.message : String(e)}. Use only the provided floors, objects and actions.`;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
throw new Error('reaction planning failed');
|
|
118
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// restyle: a place or a character painted in one style, repainted in another by one Muse edit that keeps
|
|
2
|
+
// every outline where it is, so what was measured on the old picture still fits the new one.
|
|
3
|
+
import sharp from 'sharp';
|
|
4
|
+
import type { Memo } from '../meta/memo.ts';
|
|
5
|
+
import { museEdit } from '../meta/muse.ts';
|
|
6
|
+
import type { Place } from '../world/schema.ts';
|
|
7
|
+
import type { Brief } from './brief.ts';
|
|
8
|
+
import { FRAME } from './layout.ts';
|
|
9
|
+
import type { Drawing } from './paint.ts';
|
|
10
|
+
|
|
11
|
+
const KEEP = 'Keep the composition exactly: the camera, and every object, wall, door, floor and path where it is, at its size and in its shape, so an outline traced on the old picture still fits the new one. Only the medium changes: brushwork, rendering, palette and how light and shadow are painted.';
|
|
12
|
+
/** Muse keeps too much of the old look by itself (a cartoon keeps its black outlines under oil paint): say what goes. */
|
|
13
|
+
const CHANGE = 'It must look truly painted in the new style, not the old picture with a filter over it: whatever the old style has and the new one does not (black outlines, flat fills, cel shading) goes, and the forms are painted the new way.';
|
|
14
|
+
|
|
15
|
+
export function restylePlacePrompt(place: Place, was: string, style: string, brief?: Brief, drawings: Drawing[] = []): string {
|
|
16
|
+
const looks = place.things.filter((t) => t.role !== 'npc').map((t) => `- ${t.key}: ${brief?.looks[t.key] ?? t.looks}`);
|
|
17
|
+
const which = drawings.map((d, i) => `image ${i + 2} is ${d.key === place.key ? 'this place' : `the ${d.key}`}`);
|
|
18
|
+
return `Repaint this background of a 2D point-and-click adventure game in a new style. ${KEEP}
|
|
19
|
+
|
|
20
|
+
It is painted now as: ${was}
|
|
21
|
+
Paint it as: ${style}
|
|
22
|
+
${CHANGE}
|
|
23
|
+
|
|
24
|
+
Place: ${place.shows}
|
|
25
|
+
Mood: ${place.mood}
|
|
26
|
+
Things to keep recognisable, each where it is:
|
|
27
|
+
${looks.join('\n')}
|
|
28
|
+
|
|
29
|
+
Keep the floor clear to walk on. No people, no animals, no text or letters, no border.${which.length ? `
|
|
30
|
+
|
|
31
|
+
The other images are children's drawings on paper the game was made from: ${which.join(', ')}. Keep what they drew recognisable in the new style: their colours, shapes and ideas. The composition comes only from the first image; leave out the paper and any written names.` : ''}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The place's painting in the new style, at the layout frame. */
|
|
35
|
+
export async function restylePlace(memo: Memo, place: Place, painting: Buffer, was: string, style: string, brief?: Brief, take = 0, drawings: Drawing[] = []): Promise<Buffer> {
|
|
36
|
+
const prompt = restylePlacePrompt(place, was, style, brief, drawings);
|
|
37
|
+
// Muse traces crisp lines it is given, so a cartoon kept its black outlines under the oil paint: it gets the
|
|
38
|
+
// painting a little soft, which keeps every shape where it is and leaves the edges for the new style to paint.
|
|
39
|
+
const soft = await sharp(painting).blur(2.5).png().toBuffer();
|
|
40
|
+
const painted = await museEdit(memo, { label: `restyle:${place.key}`, prompt, images: [soft, ...drawings.map((d) => d.image)], take });
|
|
41
|
+
return sharp(painted).resize(FRAME.width, FRAME.height, { fit: 'fill' }).png().toBuffer();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function restyleFigurePrompt(looks: string, was: string, style: string, drawn: boolean): string {
|
|
45
|
+
return `Repaint this character of a 2D point-and-click adventure game in a new style. Keep the same character: face, hair, clothes, colours, body proportions and the exact pose, at the same place and size in the frame, so the outline matches the picture. Show the whole figure from head to feet. Only the medium changes.
|
|
46
|
+
|
|
47
|
+
Character: ${looks}
|
|
48
|
+
It is painted now as: ${was}
|
|
49
|
+
Paint it as: ${style}
|
|
50
|
+
${CHANGE}
|
|
51
|
+
${drawn ? '\nThe second image is the child\'s drawing the character came from: keep it recognisable to them in the new style, its colours, shapes and proportions.\n' : ''}
|
|
52
|
+
Plain flat light grey background, no floor, no shadow, no other people, no text.`;
|
|
53
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Judges a walk sheet: a walk that tears open (holes in a coat, a boot floating free, an arm off the shoulder) is
|
|
2
|
+
// marked, and the runtime slides the painted figure as one piece instead.
|
|
3
|
+
import sharp from 'sharp';
|
|
4
|
+
import { emptyMask, maskArea, type Mask } from '../image/mask.ts';
|
|
5
|
+
import { components } from '../image/raster.ts';
|
|
6
|
+
import type { Rig } from '../play/rig.ts';
|
|
7
|
+
|
|
8
|
+
type Raw = { width: number; height: number; data: Buffer };
|
|
9
|
+
|
|
10
|
+
const opaque = (r: Raw, min = 128): Mask => {
|
|
11
|
+
const m = emptyMask(r.width, r.height);
|
|
12
|
+
for (let i = 0; i < m.bits.length; i++) m.bits[i] = r.data[i * 4 + 3] >= min ? 1 : 0;
|
|
13
|
+
return m;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** See-through pixels the figure closes round: background pieces that do not reach the picture's edge. */
|
|
17
|
+
function holes(fig: Mask): number {
|
|
18
|
+
const bg = emptyMask(fig.width, fig.height);
|
|
19
|
+
fig.bits.forEach((b, i) => (bg.bits[i] = b ? 0 : 1));
|
|
20
|
+
let n = 0;
|
|
21
|
+
for (const c of components(bg)) {
|
|
22
|
+
let edge = false;
|
|
23
|
+
for (let i = 0; i < c.bits.length && !edge; i++) {
|
|
24
|
+
if (!c.bits[i]) continue;
|
|
25
|
+
const x = i % c.width;
|
|
26
|
+
const y = Math.floor(i / c.width);
|
|
27
|
+
edge = x === 0 || y === 0 || x === c.width - 1 || y === c.height - 1;
|
|
28
|
+
}
|
|
29
|
+
if (!edge) n += maskArea(c);
|
|
30
|
+
}
|
|
31
|
+
return n;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type RigCheck = {
|
|
35
|
+
ok: boolean;
|
|
36
|
+
/** Worst walk frame: see-through holes, and pixels in pieces cut off from the body, as shares of the figure. */
|
|
37
|
+
holes: number;
|
|
38
|
+
loose: number;
|
|
39
|
+
/** Why it fails, in a few words. */
|
|
40
|
+
why?: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Holes above this share of the figure, or loose pieces above LOOSE, and the walk is torn. */
|
|
44
|
+
export const HOLES = 0.004;
|
|
45
|
+
export const LOOSE = 0.002;
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
/** The same measure on a walk sheet (src/scene/warp.ts): holes and loose pieces in each walk cell. */
|
|
50
|
+
export async function checkSheet(rig: Rig, png: Buffer): Promise<RigCheck> {
|
|
51
|
+
const sheet = rig.sheet!;
|
|
52
|
+
const [cw, ch] = sheet.cell;
|
|
53
|
+
let worstHoles = 0;
|
|
54
|
+
let worstLoose = 0;
|
|
55
|
+
const cell = async (f: number) => opaque({ width: cw, height: ch, data: (await sharp(png).extract({ left: (f % sheet.cols) * cw, top: Math.floor(f / sheet.cols) * ch, width: cw, height: ch }).raw().toBuffer()) });
|
|
56
|
+
// Gaps the painting itself closes round (a hand on the hip) are not tears: only what the walk opens counts.
|
|
57
|
+
const painted = holes(await cell(sheet.frames));
|
|
58
|
+
for (let f = 0; f < sheet.frames; f++) {
|
|
59
|
+
const fig = await cell(f);
|
|
60
|
+
const area = maskArea(fig) || 1;
|
|
61
|
+
// Specks of a few pixels (a stray hair) do not count as a loose part.
|
|
62
|
+
const loose = components(fig).slice(1).reduce((s, c) => s + (maskArea(c) > area * 0.0005 ? maskArea(c) : 0), 0);
|
|
63
|
+
worstHoles = Math.max(worstHoles, Math.max(0, holes(fig) - painted) / area);
|
|
64
|
+
worstLoose = Math.max(worstLoose, loose / area);
|
|
65
|
+
}
|
|
66
|
+
const why = [worstHoles > HOLES && `opens holes (${(worstHoles * 100).toFixed(1)}% of the figure)`, worstLoose > LOOSE && `parts come loose (${(worstLoose * 100).toFixed(1)}%)`].filter(Boolean).join(', ');
|
|
67
|
+
return { ok: !why, holes: Math.round(worstHoles * 1e4) / 1e4, loose: Math.round(worstLoose * 1e4) / 1e4, ...(why && { why }) };
|
|
68
|
+
}
|