@bitmagic/cli 0.1.65-dev.1 → 0.1.65-dev.10

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/README.md CHANGED
@@ -202,7 +202,7 @@ browser reloads itself on every change and the assets panel shows each generatio
202
202
  | `bitmagic publish [--visibility public\|private] [--name <name>] [--description <desc>] [--force] [--single-file] [--version-url] [--no-qr]` | Verify-gate, build if needed, and upload straight to GCS. `--single-file` publishes the offline bundle described under `bitmagic build`; switching the flag between runs always rebuilds, since it changes nothing on disk for the reuse check to notice. **Keeps the game's current visibility**; a new game is private until published with `--visibility public`. Prints the link worth sharing: `bitmagic.ai/play/<ID>/` — the game's page, with its name, cover and comments — for a public game, and `bitmagic.ai/games/<ID>/` for a private one. Both always serve the newest publish, so a link handed out today keeps working after tomorrow's publish; `--version-url` also prints the URL of this one build (`.../indexN.html`), for pinning a bug report to it. Shows the shared URL as a QR code so you can scan it onto a phone — drawn in the terminal, or written to `.bitmagic/publish-qr.png` when it cannot be. `--no-qr` skips both. |
203
203
  | `bitmagic self-update [--tag latest\|dev]` | Update the **CLI itself** to the newest build of its line, installing into the place this CLI actually lives rather than wherever the `npm` on your `PATH` would put it — which under nvm is routinely somewhere else. Reads the project's line from `bitmagic.json` when there is one, so it also fixes a wrong-line install; `--tag` overrides. Verifies the version on disk moved, and refuses (naming the path) for an `npx` run, a source checkout, or an install shape it cannot recognise. Not to be confused with `upgrade`, which is about the project's engine. |
204
204
  | `bitmagic upgrade [--engine <version>] [--force]` | Refresh vendored platform files and the CLI's own skills to the currently published engine. Refuses a dirty git tree unless `--force`. Also records the project's `environment` in `bitmagic.json` if it has none yet — the engine it just vendored came from that api-server — and says so when it does. |
205
- | `bitmagic generate <skybox\|background\|block-type\|sound\|music\|image\|video\|character\|animation\|model\|prop\|vehicle> ...` | Generate one asset directly into `src/work/world.json`. Costs sparks. `model` mints a **new** 3D object from a prompt, `prop` upgrades an existing placeholder **in place** (both produce voxels directly; `--mesh` takes the older mesh path; a low-poly project keeps the mesh as a `glb` asset — see **Art style**), `vehicle` adds a new drivable one, `animation` takes a parameter spec rather than a prompt, `block-type` registers a custom voxel block, `video` makes a short clip for a cutscene (played with `engine.playVideo("<id>")`) — see **Cutscene clips** below. `skybox` and `background` write the **same** field: pick one. |
205
+ | `bitmagic generate <skybox\|background\|block-type\|sound\|music\|image\|video\|character\|animation\|model\|prop\|vehicle> ...` | Generate one asset directly into `src/work/world.json`. Costs sparks. `model` mints a **new** 3D object from a prompt, `prop` upgrades an existing placeholder **in place** (both produce voxels directly; `--mesh` takes the older mesh path; a low-poly project keeps the mesh as a `glb` asset — see **Art style**), `vehicle` adds a new drivable one, `animation` takes a parameter spec rather than a prompt, `block-type` registers a custom voxel block, `video` makes a short clip for a cutscene (played with `engine.playVideo("<id>")`) — see **Cutscene clips** below. `skybox` and `background` write the **same** field: pick one. `character --library-only` takes a ready-made body off the shelf for free instead of generating — see **Taking a character off the shelf**. |
206
206
  | `bitmagic assets add <file> [--name <n>] [--asset-id <id>] [--keep-glb\|--voxelize] [--voxel-size <m>] [--max-voxel-size <m>] [--height <m>] [--hollow\|--solid] [--force]` | Upload **your own** file — `.glb`, `.vxl`, `.png/.jpg/.webp`, `.opus/.ogg/.m4a/.mp3`, `.json` — into the game's assets and write its entry into `src/work/world.json`. A `.glb` is voxelized in a headless browser (Chrome + a free port in 3000–3199, like `prop`) unless `--keep-glb` — or unless the project is low-poly (see **Art style**). Free — no sparks. See **Adding your own assets** below. |
207
207
  | `bitmagic assets list` | The assets in `world.json`: id, name, type, size, placed instances, and total references. Local, no network. |
208
208
  | `bitmagic assets materials <assetId> [--set <file.json>] [--auto] [--clear]` | Show what an asset's voxels look like, or say what they are **made of** — so a sword's steel blade shines and its leather grip does not. With no flag it prints the asset's colour groups; `--set` applies your verdicts; `--auto` lets a model decide (what `generate prop` and `generate model` already do on their own); `--clear` removes them. Free — no sparks, nothing generated. See **Voxel materials** below. |
@@ -215,6 +215,7 @@ browser reloads itself on every change and the assets panel shows each generatio
215
215
  | `bitmagic trailer analyze \| beats \| cut \| render \| merge` | The steps of `make` as separate commands: score a recording into highlight windows with contact sheets; detect a track's beat grid; turn `trailer/shots.json` into a frame-exact `edl.json`; render an EDL (`--ui` redraws the HUD, `--ui-keyframe-rate` caps how often it may change, `--logo`, `--music …`); join recordings from separate sittings. |
216
216
  | `bitmagic trailer publish <file.mp4> [--no-attach]` | Upload a trailer (`.mp4`/`.webm`, ≤ 200 MiB, header-checked so a renamed image is refused before upload) to the CDN under `game-media/<gameId>/` and put it at the front of the game page gallery. Re-running replaces the previous trailer (remembered in `.bitmagic/trailer/published.json`); the page is capped at 12 media items. Works before the first publish — the page goes live on the first `bitmagic publish --visibility public`. `--no-attach` uploads only. |
217
217
  | `bitmagic forge --prompt "..." [--city\|--dungeon\|--platformer\|--freeform] [--name <name>] [--resume <jobId>]` | Design and bake a whole playable level into `world.json`. Costs sparks, and takes many minutes — the bake alone can take twenty. `--resume <jobId>` picks a failed run back up from the last step that finished, rather than redoing the whole thing. |
218
+ | `bitmagic forge --edit "..." [--level <id or name>]` | Change a detail of a level this project forged — a door style, the light fixtures, a prop — without re-forging it. The stored design is patched and only what the change touches is re-made; the level is replaced in place. See [Editing a forged level](#editing-a-forged-level). |
218
219
  | `bitmagic tools <list\|path\|install> [bundle]` | The script bundles the CLI ships for skills that drive Blender or Python (`blender-level`, `unity-import`): what they are, where this install keeps them, and `install` to copy one into the project's `tools/`. Local, no network, no sparks. |
219
220
  | `bitmagic levels <list\|add\|rename\|set-start\|remove> ...` | Manage the levels `forge` creates: which one boots, their names, registering a bake you already have, deleting one. Local, no network, no sparks. See **Managing levels** below. |
220
221
 
@@ -1207,6 +1208,42 @@ exact `--glb-url … --voxel-size …` command to retry the browser step without
1207
1208
  If the finished asset reports that it will not drive, the project's vendored engine predates
1208
1209
  vehicle-fitment derivation: run `bitmagic upgrade`, then regenerate.
1209
1210
 
1211
+ ## Editing a forged level
1212
+
1213
+ A forged level's design is kept on its record, so a detail can be changed without designing and
1214
+ baking the world again:
1215
+
1216
+ ```bash
1217
+ bitmagic forge --edit "make the doors sliding bulkheads"
1218
+ bitmagic forge --edit "warm amber lanterns instead of the lamps" --level "Freighter Mk IV"
1219
+ bitmagic forge --edit "recolour the hull plating a rusty red" --level level_1788942022362_yujy5i
1220
+ ```
1221
+
1222
+ The change goes to the designer as one small call that returns the handful of edits to the stored
1223
+ design — a fraction of a design's cost — and the level is then built from the edited design under
1224
+ a new job. What that build re-makes is decided by comparing it with the original, object by object:
1225
+
1226
+ | The change touches | What is re-made |
1227
+ |---|---|
1228
+ | the door style | the door leaves and the key pickups |
1229
+ | the light fixture (kind, colour, flicker) | the fixtures |
1230
+ | a prop or wall-decor kit piece | that piece |
1231
+ | a material colour, a wall/floor/ceiling kit piece, the rooms, the viewports, the terrain | the level bake and most objects |
1232
+
1233
+ Everything else — the baked level, every untouched object — is reused, so a door-style edit takes
1234
+ about as long as baking three small objects. The run prints what it reused. The level is replaced
1235
+ in place: same level id, doors, keys and placed objects rewritten, your other levels untouched. An
1236
+ object you have since regenerated in high quality is kept as it is.
1237
+
1238
+ One thing it needs: the level must have been forged by a CLI that records the job on the level
1239
+ and mirrors its bakes to the server — anything forged before that is re-forged once and editable
1240
+ from then on, from any machine. (A forge keeps its resumable state in the project's
1241
+ `.bitmagic/forge` and mirrors the browser-baked records to api-server as it goes, so `--resume`
1242
+ and `--edit` both work from a clone that never ran the original forge; a mirror that fails is
1243
+ said once and the run continues from the local copy.) `--level` takes the level's id or name (`bitmagic levels list`), and
1244
+ can be left out when the project has one forged level. `--edit` takes no other forge flag; a 2D
1245
+ side-view level cannot be edited yet.
1246
+
1210
1247
  ## Managing levels
1211
1248
 
1212
1249
  `bitmagic forge` bakes levels but could not manage them: only a game's *first* forge became the
@@ -1274,11 +1311,37 @@ read by everything that used to assume cubes:
1274
1311
  | `bitmagic cover`, `bitmagic reference make` | MagicaVoxel-style prompt lead and tail | flat-shaded low-poly lead and tail (same image model) |
1275
1312
  | `bitmagic assets add x.glb` | voxelized in a headless browser | kept as a `glb` asset; `--voxelize` bakes it |
1276
1313
  | `bitmagic generate model` / `prop` | voxel master, baked in the browser | mesh generated, re-uploaded into the project, registered as `glb` (`--height` / the placeholder's fitBox); `--voxelize` bakes it |
1277
- | `bitmagic generate character` | voxelized rig, library search first | the textured low-poly rig, no library; `--voxelize` overrides |
1314
+ | `bitmagic generate character` | voxelized rig, library search first | the textured low-poly rig; its own library shelf, searched only where the server has enabled it; `--voxelize` overrides |
1278
1315
  | `bitmagic generate vehicle` | voxel | still voxel — the builder is voxel-native; the command says so |
1279
1316
  | `bitmagic judge` with no `## Art direction` | grades the style it sees | judges within low-poly |
1280
1317
  | the engine on load | sunset hemisphere light and a faint bloom | neither; a `MeshLevel` brings its own lighting |
1281
1318
 
1319
+ ### Taking a character off the shelf
1320
+
1321
+ `bitmagic generate character --library-only` skips generation entirely and hands
1322
+ back the closest ready-made body in the library. It is free and takes about a
1323
+ second, against $0.15 for a voxel forge or $0.60 for a low-poly one, and several
1324
+ minutes either way.
1325
+
1326
+ It differs from the ordinary library search in three ways worth knowing:
1327
+
1328
+ - **It ignores the quality bar.** Normal generation only substitutes a stored
1329
+ body when it is a close match; this takes the best there is. The output tells
1330
+ you how close it came, and says so plainly when the match is weak.
1331
+ - **It never forges.** If the library has nothing usable the command fails and
1332
+ charges nothing, rather than quietly falling back to a paid generation — you
1333
+ ran it to avoid spending, so it does not spend.
1334
+ - **It works regardless of whether automatic substitution is enabled.** Asking
1335
+ for a stored body is not the same as the server deciding to give you one.
1336
+
1337
+ `--head-scale` cannot be honoured (proportions are baked into a stored body; it
1338
+ is reported and ignored), and `--mesh-model` is rejected outright, since it
1339
+ selects how a character would be forged and nothing is forged.
1340
+
1341
+ The shelf you get depends on the project's art style, like every other character
1342
+ request: a voxel game searches voxel bodies, a low-poly game searches low-poly
1343
+ ones.
1344
+
1282
1345
  In a low-poly game the textured rig IS the product, so `bitmagic generate character` forges it with
1283
1346
  `runware-meshy-6`: an even remesh with symmetric limbs and an enforced A-pose, rather than a TRELLIS
1284
1347
  reconstruction that is only as low-poly as the concept image happened to be. **That costs about
@@ -0,0 +1,180 @@
1
+ # Trailer craft — structure, pacing, and on-screen text
2
+
3
+ Read this before composing a cut or putting any text on screen. Compressed
4
+ from Derek Lieu's trailer-editing essays (trailer editor for Half-Life: Alyx,
5
+ Dead Cells, Spelunky 2) plus general trailer-typography guidance; sources at
6
+ the bottom.
7
+
8
+ ## Ordering: Genre → Hook → Anchors → Content
9
+
10
+ - **Genre** in 1–2 shots: the viewer must know what kind of game this is
11
+ within the first seconds. For our games one gameplay shot usually does it.
12
+ - **Hook** — the thing only this game has — as soon as genre is established:
13
+ ideally inside the first 10–15 s, never later than the first third.
14
+ Showing generic-but-good content before the hook makes the game read as
15
+ "a lesser version of an old game".
16
+ - **Anchors** — the features people expect from the genre (for a racer:
17
+ overtakes, drifts, crashes, varied tracks) fill the middle.
18
+ - **Content** — breadth (levels, modes, vehicles) comes last, once the viewer
19
+ already cares.
20
+
21
+ ## Timeline layout: constraints first, footage last
22
+
23
+ Lay the timeline out as a skeleton and only then hunt for footage — not the
24
+ other way round:
25
+
26
+ 1. Write the section skeleton: **Cold open → Introduction → Escalation →
27
+ Climax → Breath → Button/End card**. Sections have different lengths and
28
+ energy; they are not equal slices.
29
+ 2. Reserve the **end card** first: 2–3 s, ONE card (logo + tagline + CTA),
30
+ never a sequence of closing screens.
31
+ 3. Place any **body title cards** next, as placeholders with their copy
32
+ written — each one names what the following footage must prove.
33
+ 4. Only now fill footage, chosen to demonstrate whatever the neighbouring
34
+ card or section claims.
35
+
36
+ This turns "pick 15 great clips" (squishy, drifts toward sameness) into
37
+ "find the one clip that proves this beat" (constrained, self-diversifying).
38
+
39
+ ## Pacing
40
+
41
+ - Sections need **ebb and flow**: build to a peak, then give a deliberate
42
+ breathing shot (~5 s, calmer, wider) before the next build or the finale.
43
+ The stops emphasize the fast; the fast emphasizes the slow.
44
+ - **Rule of three**: never more than three similar things in a row — three
45
+ crashes, three drift shots, three of anything. Viewers extrapolate after
46
+ two; the fourth is dead time.
47
+ - **Do not cut on every beat.** Snap cuts to the grid, but vary clip lengths
48
+ (2, 3, 4, 6 beats) and sometimes let a musical moment land on an in-clip
49
+ action (a landing, an impact) instead of a cut. Constant per-beat cutting
50
+ reads as a sizzle reel, and gets nauseating.
51
+ - Average shot length is a genre statement: ~1.3 s reads as AAA shooter,
52
+ ~4 s reads as contemplative indie. An arcade racer sits around 1.5–3 s
53
+ with a few longer holds where one continuous action is the payoff.
54
+ - **More editing is not better editing.** A cut must clarify or escalate;
55
+ cutting fast to look "like a real trailer" hides the game. If a single
56
+ take shows the whole stunt, keep the take.
57
+ - Total length: under 90 s unless there's a reason; 30–45 s is a sweet spot
58
+ for social/store use.
59
+
60
+ ## Variety — the five axes
61
+
62
+ Audit the cut on each axis; the labels list in the shots file is the tool
63
+ (read the labels top to bottom — repetition is visible in text before you
64
+ ever re-watch):
65
+
66
+ 1. **Content** — different actions, not the same action in new places.
67
+ Escalate variants (jump → bigger jump) rather than repeating.
68
+ 2. **Composition** — vary camera distance and framing; chase cam all the way
69
+ through is monotone. Watch eye-trace: the viewer's focus point should not
70
+ teleport across the frame on a cut.
71
+ 3. **Energy** — intense vs. quiet moments; contrast matters more than speed.
72
+ 4. **Pacing** — vary section tempo, not just shot tempo.
73
+ 5. **Shot length** — vary beats-per-clip even inside a fast section.
74
+
75
+ ## Where the shots come from
76
+
77
+ Played footage is the default and the bulk of any trailer: it is the only
78
+ thing that shows what the game DOES, and a trailer of pure scenery reads as a
79
+ tech demo however pretty it is. But two of the beats above are ones a chase
80
+ camera almost never hands you:
81
+
82
+ - the **cold open**, which wants the level to look composed and deliberate;
83
+ - the **breath**, which wants one calm wide shot with nothing happening.
84
+
85
+ Both are what an authored **camera fly-through** is for — a path through the
86
+ level with the game running underneath, so traffic still drives and animations
87
+ still play while the camera moves on rails. Recording one is cheap and nobody
88
+ has to play it, so a fly-through is worth having even when there is plenty of
89
+ gameplay: cut one or two of its shots into a gameplay trailer rather than
90
+ building a trailer out of it.
91
+
92
+ A fly-through alone is the right whole trailer in a narrow set of cases — a
93
+ world reveal, an architectural or exploration piece, a level that is the
94
+ feature — and whenever there is no one available to play. Otherwise it is a
95
+ source of shots, not a substitute for showing the game.
96
+
97
+ ## On-screen text
98
+
99
+ When to use text at all: only when footage cannot say it. A card that
100
+ narrates what the next shot visibly shows is noise. Default for our
101
+ gameplay trailers: **end card only**; add body cards only when they carry
102
+ information the footage can't (game name early, a hook that isn't visual,
103
+ release date).
104
+
105
+ Writing the cards:
106
+ - Copy must sound like this game and no other. Exercise: describe the game
107
+ in three words; build cards from what's left after cutting everything
108
+ generic.
109
+ - ≤ 6 words per card. Evocative verbs beat feature lists ("Forge your own
110
+ path", not "Over 40 unique tracks").
111
+ - Banned: content quantities ("5 unique levels"), genre-descriptor stacks
112
+ ("a 2D rogue-lite action adventure"), player-verb lists ("Race! Drift!
113
+ Win!"), and stock phrases ("In a world…", "Get ready…", "Experience…",
114
+ "…like never before").
115
+
116
+ Showing the cards:
117
+ - **Intercut, don't overlay**: a card gets its own beat between gameplay
118
+ shots (dimmed still or plain background). Never ask the viewer to read
119
+ while action plays — they do neither. Small persistent text (logo bug,
120
+ date) is the exception.
121
+ - Reading time: ~0.5–0.7 s per word, minimum 2.5 s on screen; end card
122
+ 2–3 s (ours may run longer over a music outro).
123
+ - Cards ride the same beat grid as the cuts — a card is a clip.
124
+ - Typography must match the game's art quality and universe: one typeface
125
+ family for the whole trailer, generous letter-spacing for all-caps,
126
+ strong contrast against footage (our title raster already adds shadow),
127
+ subtle animation only (fade/scale-in) — a static default-font card reads
128
+ as a last-minute add-on.
129
+
130
+ ## Structure template (30–45 s, gameplay-only, end card close)
131
+
132
+ | Section | ~share | What goes here |
133
+ |---|---|---|
134
+ | Cold open | 5–15% | The single most striking moment; genre obvious at a glance |
135
+ | Introduction | 15–20% | Clean, readable driving; establish look + one anchor |
136
+ | Escalation | 35–45% | Varied action, rising energy, hook prominent early in it |
137
+ | Climax | 15–20% | Densest, loudest stretch; biggest moments back to back |
138
+ | Breath | ~5% | One calmer, wider shot; lets the climax land |
139
+ | End card | 2–3 s+ | Logo, tagline, CTA — over the music's outro |
140
+
141
+ ## Selection rules already paid for
142
+
143
+ These came out of review rounds on real cuts. They cost time to learn and
144
+ nothing to apply, so apply them before the first render, not after.
145
+
146
+ - **Never use slow motion.** On gameplay footage it reads as the game running
147
+ at a low framerate, not as drama. `speed` stays 1.0.
148
+ - **Numbers are not interest.** Event `severity` / `impactSpeed` ranks
149
+ car-stops-dead-against-a-wall highest — the worst possible clip. A crash
150
+ earns its slot only if things visibly break (`destruction` events, debris)
151
+ and motion continues through the hit.
152
+ - **Low speed can BE the drama.** Wheelspin recoveries throwing dirt or snow,
153
+ plowing back onto the track, being overtaken while struggling
154
+ (`position-change` with `gained: false`), `off-track` +
155
+ `hard-acceleration` clusters — these are among the best clips and score
156
+ near zero on any speed metric.
157
+ - **"Some shots of X" means a few.** The failure mode is 0-or-100: one
158
+ standing start is an opener, five is a broken trailer.
159
+ - **Quota check before rendering.** Read the shots list labels top to bottom.
160
+ Max one standing start; no more than three of any shot type in the whole
161
+ cut and never three similar in a row; vary track or biome, camera distance
162
+ and action type (the five axes above).
163
+ - **Menu and selection clips need `"ui": false`** — the HUD replay only knows
164
+ the gameplay HUD and will paint it over menus.
165
+ - **Verify on pixels.** Contact sheets and stills for every candidate, and
166
+ stills from the RENDERED file for anything you changed. The timeline knows
167
+ about events; it cannot see framing, occlusion or beauty.
168
+ - **Iterate cheaply, keep history.** Editing the shots and re-rendering is
169
+ minutes, and revisions auto-version — so render rather than debate, and
170
+ compare against the previous `-rN` before delivering.
171
+
172
+ ## Sources
173
+
174
+ - [How to Hook the Audience and How Quickly to Do it](https://www.derek-lieu.com/blog/2022/10/24/how-to-hook-the-audience-and-how-quickly-to-do-it)
175
+ - [Step Two of Starting a Game Trailer Timeline](https://www.derek-lieu.com/blog/2023/2/19/step-two-of-starting-a-game-trailer-timeline)
176
+ - [How to Think About Pacing in a Trailer](https://www.derek-lieu.com/blog/2020/11/16/how-to-think-about-pacing-in-a-trailer)
177
+ - [Variety is the Spice of Trailers](https://www.derek-lieu.com/blog/2019/3/27/variety-is-the-spice-of-trailers)
178
+ - [More Trailer Editing is not Better Trailer Editing](https://www.derek-lieu.com/blog/2023/4/23/more-trailer-editing-is-not-better-trailer-editing)
179
+ - [How to Write Trailer Title Cards With Strong Messaging](https://www.derek-lieu.com/blog/2020/4/19/how-to-write-trailer-title-cards-with-strong-messaging)
180
+ - [10 Common Indie Game Trailer Mistakes & How to Fix Them](https://www.derek-lieu.com/blog/2020/9/14/10-common-indie-game-trailer-mistakes-and-how-to-fix-them)
package/dist/cli.d.ts CHANGED
@@ -479,6 +479,14 @@ declare const rawCommands: {
479
479
  readonly type: "boolean";
480
480
  readonly description: "Boot the game into this level. Without it, only a game's FIRST forge becomes the start level, so a later forge lands in the level list unseen.";
481
481
  };
482
+ readonly edit: {
483
+ readonly type: "string";
484
+ readonly description: "Change a detail of a level this project forged (\"make the doors sliding bulkheads\", \"warm amber lanterns instead of the lamps\") without re-forging it: the stored design is patched and only what the change touches is re-made. Replaces the level in place. Takes --level; no other forge flag.";
485
+ };
486
+ readonly level: {
487
+ readonly type: "string";
488
+ readonly description: "With --edit: the level to change, by id or name (`bitmagic levels list`). Omit when the project has one forged level.";
489
+ };
482
490
  readonly json: {
483
491
  readonly type: "boolean";
484
492
  readonly description: "Print the result as JSON on stdout; all progress goes to stderr.";
@@ -11,6 +11,13 @@ type ModeFlag = (typeof MODE_FLAGS)[number];
11
11
  * the server receives the answer rather than recomputing it.
12
12
  */
13
13
  export declare function deriveLevelName(explicit: string | undefined, prompt: string): string;
14
+ /**
15
+ * `--edit` changes a level as it was designed, so it takes none of a fresh forge's steering: no
16
+ * prompt, no scene type, no reference image, no revision rounds, no start-level switch, and no
17
+ * resume (a resumed edit replays its frozen request, which `--edit` would contradict). Refused
18
+ * before any request, where it is still free.
19
+ */
20
+ export declare function assertEditFlags(args: Record<string, unknown>): void;
14
21
  /** The single selected mode, or undefined for plain terrain. */
15
22
  export declare function selectMode(args: Record<string, unknown>): ModeFlag | undefined;
16
23
  /**
@@ -144,6 +151,14 @@ export declare const forgeCommand: import("citty").CommandDef<{
144
151
  readonly type: "boolean";
145
152
  readonly description: "Boot the game into this level. Without it, only a game's FIRST forge becomes the start level, so a later forge lands in the level list unseen.";
146
153
  };
154
+ readonly edit: {
155
+ readonly type: "string";
156
+ readonly description: "Change a detail of a level this project forged (\"make the doors sliding bulkheads\", \"warm amber lanterns instead of the lamps\") without re-forging it: the stored design is patched and only what the change touches is re-made. Replaces the level in place. Takes --level; no other forge flag.";
157
+ };
158
+ readonly level: {
159
+ readonly type: "string";
160
+ readonly description: "With --edit: the level to change, by id or name (`bitmagic levels list`). Omit when the project has one forged level.";
161
+ };
147
162
  readonly json: {
148
163
  readonly type: "boolean";
149
164
  readonly description: "Print the result as JSON on stdout; all progress goes to stderr.";
@@ -19,7 +19,8 @@
19
19
  * nothing, and is my project still intact?" is the first question.
20
20
  */
21
21
  import { defineCommand } from 'citty';
22
- import { buildAssetCatalog, createForgedWorkStore, SIDEON_FORGE_STEPS, } from '@bitmagic/world-forger/pipeline/index.js';
22
+ import { buildAssetCatalog, createForgedWorkStore, describeEditSeed, EDIT_FORGE_STEPS, seedEditStore, SIDEON_FORGE_STEPS, } from '@bitmagic/world-forger/pipeline/index.js';
23
+ import { bakeEverythingPlan, buildEditJobInput, resolveEditTarget } from '../forge/edit-target.js';
23
24
  import { CliError } from '../errors.js';
24
25
  import { createOutput } from '../output.js';
25
26
  import { creatorPromptField, ORIGINAL_PROMPT_FLAG } from '../telemetry/creator-prompt.js';
@@ -43,6 +44,7 @@ import { assertNotOpenWater, describeSideonRegistryLoss, readCharacterRunSpeed,
43
44
  import { reserveLanePort } from '../local-port.js';
44
45
  import { createForgeProgressTracker } from '../forge/progress.js';
45
46
  import { createLocalForgeStorage } from '../forge/local-store.js';
47
+ import { createMirroredForgeStorage } from '../forge/remote-store.js';
46
48
  import { describeForgeFailure, requestForge } from '../forge/stream.js';
47
49
  import { buildCliForgeDeps, createConsoleForgeLogger, readFrozenInput, runForgePipeline, seedForgeStore, } from '../forge/run-pipeline.js';
48
50
  /** Mirrors api-server's `FORGE_JOB_ID_PATTERN` so a typo is rejected here, before a round trip. */
@@ -74,6 +76,27 @@ export function deriveLevelName(explicit, prompt) {
74
76
  const derived = prompt.trim().split(/\s+/).slice(0, 6).join(' ').slice(0, 48).trim();
75
77
  return derived.length > 0 ? derived : 'level';
76
78
  }
79
+ /**
80
+ * `--edit` changes a level as it was designed, so it takes none of a fresh forge's steering: no
81
+ * prompt, no scene type, no reference image, no revision rounds, no start-level switch, and no
82
+ * resume (a resumed edit replays its frozen request, which `--edit` would contradict). Refused
83
+ * before any request, where it is still free.
84
+ */
85
+ export function assertEditFlags(args) {
86
+ const conflicts = [
87
+ ['prompt', typeof args.prompt === 'string' && args.prompt !== ''],
88
+ ['resume', typeof args.resume === 'string' && args.resume !== ''],
89
+ ['reference-image', typeof args['reference-image'] === 'string' && args['reference-image'] !== ''],
90
+ ['reference-image-url', typeof args['reference-image-url'] === 'string' && args['reference-image-url'] !== ''],
91
+ ['match-rounds', args['match-rounds'] !== undefined],
92
+ ['make-start', args['make-start'] === true],
93
+ ...MODE_FLAGS.map((mode) => [mode, args[mode] === true]),
94
+ ].filter(([, set]) => set).map(([flag]) => `--${flag}`);
95
+ if (conflicts.length > 0) {
96
+ throw new CliError(`--edit changes a forged level as it was designed and cannot be combined with ${conflicts.join(', ')}. `
97
+ + 'Describe the change alone: `bitmagic forge --edit "make the doors sliding bulkheads" --level <id or name>`.');
98
+ }
99
+ }
77
100
  /** The single selected mode, or undefined for plain terrain. */
78
101
  export function selectMode(args) {
79
102
  const chosen = MODE_FLAGS.filter((mode) => args[mode] === true);
@@ -291,7 +314,7 @@ async function runBrowserSteps(options) {
291
314
  });
292
315
  await host.launch();
293
316
  await host.loadGame();
294
- const storage = createLocalForgeStorage(context.root);
317
+ const { storage } = options;
295
318
  const store = createForgedWorkStore(jobId, storage);
296
319
  const forgeDeps = buildCliForgeDeps({
297
320
  projectRoot: context.root,
@@ -357,11 +380,14 @@ export async function runForge(args, deps) {
357
380
  const runDeps = deps ?? defaultDeps(args.json === true);
358
381
  const prepared = await prepareForgeRequest(args, runDeps);
359
382
  const { context, environment, storage, input, resumeJobId, token, reference, lane } = prepared;
383
+ // An edit of a forged level (fresh, or a resumed edit): the frozen request names its parent.
384
+ const editing = input.parentJobId !== undefined;
360
385
  // One advisory line about the reference image, fresh runs only (a resume replays the frozen
361
- // request, picture and all): either the project default took effect, or — when there is no
362
- // reference at all — a pointer to the command that would give the designer one. Neither is a
363
- // prompt; the forge proceeds regardless.
364
- if (resumeJobId === null) {
386
+ // request, picture and all; an edit never sees the picture — it changes a stored design):
387
+ // either the project default took effect, or — when there is no reference at all — a pointer
388
+ // to the command that would give the designer one. Neither is a prompt; the forge proceeds
389
+ // regardless.
390
+ if (resumeJobId === null && !editing) {
365
391
  const referenceLine = describeAppliedReference(reference);
366
392
  if (referenceLine)
367
393
  runDeps.log(referenceLine);
@@ -396,11 +422,14 @@ export async function runForge(args, deps) {
396
422
  onLayout: report.layout,
397
423
  // A side-on run is four steps (no level bake) — with the default list the
398
424
  // banners would read [n/5] and "Baking level" would sit pending forever.
399
- ...(lane === 'sideon' ? { steps: SIDEON_FORGE_STEPS } : {}),
425
+ // An edit keeps the five, with the design step named for what it does.
426
+ ...(editing ? { steps: EDIT_FORGE_STEPS } : lane === 'sideon' ? { steps: SIDEON_FORGE_STEPS } : {}),
400
427
  });
401
428
  log(resumeJobId !== null
402
- ? `Resuming forge job ${resumeJobId} for game ${context.metadata.gameId}.`
403
- : `Forging a ${lane === 'sideon' ? 'SIDE-ON 2D level' : 'level'} for game ${context.metadata.gameId}: "${input.prompt}"`);
429
+ ? `Resuming ${editing ? 'edit' : 'forge'} job ${resumeJobId} for game ${context.metadata.gameId}.`
430
+ : editing
431
+ ? `Editing level "${input.name}" of game ${context.metadata.gameId}: "${input.editPrompt ?? input.prompt}"`
432
+ : `Forging a ${lane === 'sideon' ? 'SIDE-ON 2D level' : 'level'} for game ${context.metadata.gameId}: "${input.prompt}"`);
404
433
  // Said BEFORE the billed request, never only in the post-forge notes: a
405
434
  // side-on forge discards a multi-level registry and the objects in it.
406
435
  if (lane === 'sideon' && resumeJobId === null) {
@@ -418,16 +447,24 @@ export async function runForge(args, deps) {
418
447
  else
419
448
  log(` ${message}`);
420
449
  },
421
- }, lane === 'sideon'
450
+ }, editing
422
451
  ? {
423
- // The sibling route, so an api-server deployed before the side-on lane
424
- // 404s LOUDLY here — before any model call and before any charge —
425
- // instead of silently running a wrong 3D forge.
426
- path: '/api/cli/v1/forge-sideon/stream',
427
- notFoundMessage: 'This api-server does not support 2D side-view forging yet (the route is missing). '
428
- + 'Nothing was sent or billed — update later, or build the level from the web creator once it ships there.',
452
+ // The edit's own route, for the same loud-404 reason as the side-on lane: an older
453
+ // api-server would otherwise design a whole new level from an edit request and bill it.
454
+ path: '/api/cli/v1/forge-edit/stream',
455
+ notFoundMessage: 'This api-server cannot edit forged levels yet (the route is missing). '
456
+ + 'Nothing was sent or billed — update later, or re-forge the level with the change in its prompt.',
429
457
  }
430
- : undefined);
458
+ : lane === 'sideon'
459
+ ? {
460
+ // The sibling route, so an api-server deployed before the side-on lane
461
+ // 404s LOUDLY here — before any model call and before any charge —
462
+ // instead of silently running a wrong 3D forge.
463
+ path: '/api/cli/v1/forge-sideon/stream',
464
+ notFoundMessage: 'This api-server does not support 2D side-view forging yet (the route is missing). '
465
+ + 'Nothing was sent or billed — update later, or build the level from the web creator once it ships there.',
466
+ }
467
+ : undefined);
431
468
  if (!outcome.success) {
432
469
  throw new CliError(describeForgeFailure(outcome));
433
470
  }
@@ -437,14 +474,36 @@ export async function runForge(args, deps) {
437
474
  log(`Design and geometry are saved server-side as job ${jobId}.`);
438
475
  // From here on the design and the geometry are PAID FOR and saved server-side. Every failure
439
476
  // below must therefore point at the resume rather than at re-running from scratch.
440
- await seedForgeStore(createForgedWorkStore(jobId, storage), artifact, input, platformerMovement);
477
+ if (editing) {
478
+ // The parent's bakes live in THIS project's store, if it forged the level here. The
479
+ // server's plan is an upper bound; the seed says what this machine could actually reuse.
480
+ const parentJobId = outcome.parentJobId ?? input.parentJobId;
481
+ const world = readWorldJson(projectWorldJsonPath(context.root));
482
+ const seed = await seedEditStore({
483
+ store: createForgedWorkStore(jobId, storage),
484
+ parentStore: createForgedWorkStore(parentJobId, storage),
485
+ artifact,
486
+ input,
487
+ reuse: outcome.reuse ?? bakeEverythingPlan(artifact, 'the server returned no reuse plan'),
488
+ worldAssets: Array.isArray(world.assets) ? world.assets.filter(isJsonObject) : [],
489
+ });
490
+ log(describeEditSeed(seed));
491
+ if (platformerMovement)
492
+ await createForgedWorkStore(jobId, storage).put('platformer-movement', platformerMovement);
493
+ }
494
+ else {
495
+ await seedForgeStore(createForgedWorkStore(jobId, storage), artifact, input, platformerMovement);
496
+ }
441
497
  try {
442
- await runBrowserSteps({ context, environment, jobId, token, deps: { ...runDeps, log }, tracker, lane });
498
+ await runBrowserSteps({ context, environment, jobId, token, deps: { ...runDeps, log }, tracker, lane, storage });
443
499
  }
444
500
  catch (error) {
445
501
  throw decorateBrowserStepError(error, jobId);
446
502
  }
447
- output.result({ ok: true, jobId, gameId: context.metadata.gameId, name: input.name });
503
+ output.result({
504
+ ok: true, jobId, gameId: context.metadata.gameId, name: input.name,
505
+ ...(editing ? { edited: true, levelAssetId: input.assetId, parentJobId: outcome.parentJobId ?? input.parentJobId } : {}),
506
+ });
448
507
  });
449
508
  }
450
509
  async function prepareForgeRequest(args, deps) {
@@ -455,7 +514,33 @@ async function prepareForgeRequest(args, deps) {
455
514
  throw new CliError('--resume takes a job id of 1-64 characters (letters, digits, "_" or "-") — the one printed '
456
515
  + 'by the run you are resuming.');
457
516
  }
458
- const storage = createLocalForgeStorage(context.root);
517
+ const environment = resolveEnvironmentForCommand({ cwd: context.root, baseDir: deps.baseDir });
518
+ // Resolved on first use and shared by the store and the request: the store
519
+ // behind every read below is MIRRORED to api-server (remote-store.ts), so a
520
+ // job started on another machine can be resumed or edited here — its frozen
521
+ // request and bakes are fetched when the local copy lacks them — and only
522
+ // such a fetch, or the request itself, needs the credential.
523
+ let tokenPromise;
524
+ const accessToken = () => {
525
+ tokenPromise ??= getAccessToken(environment, resolveAuth0ClientId(environment), { fetch: deps.fetch, sleep: deps.sleep, now: deps.now }, deps.baseDir);
526
+ return tokenPromise;
527
+ };
528
+ const storage = createMirroredForgeStorage(createLocalForgeStorage(context.root), {
529
+ environment, accessToken, fetch: deps.fetch, warn: (message) => deps.log(`warning: ${message}`),
530
+ });
531
+ // A resume replays the frozen request — including its reference image — so a file passed
532
+ // alongside `--resume` would be uploaded and then silently ignored. Refused instead: the
533
+ // caller clearly wanted the picture to matter, and it cannot here.
534
+ if (resumeJobId !== null && typeof args['reference-image'] === 'string' && args['reference-image'] !== '') {
535
+ throw new CliError('A resumed forge replays the original request, reference image and all — --reference-image '
536
+ + 'cannot change it. Drop --resume to start a fresh forge with this picture.');
537
+ }
538
+ // Same rule for the rounds: they are frozen with the job, and a resume that skips the design
539
+ // step never runs them anyway.
540
+ if (resumeJobId !== null && args['match-rounds'] !== undefined) {
541
+ throw new CliError('A resumed forge replays the original request — --match-rounds cannot change it. '
542
+ + 'Drop --resume to start a fresh forge.');
543
+ }
459
544
  const frozen = resumeJobId !== null ? await readFrozenInput(storage, resumeJobId) : null;
460
545
  // Route by the project's dimension HERE, before any token/server work:
461
546
  // steps 1-2 bill. 3D projects keep today's pipeline; side-view 2D projects
@@ -468,24 +553,32 @@ async function prepareForgeRequest(args, deps) {
468
553
  // Refused here, where it is still free.
469
554
  if (lane === 'sideon')
470
555
  assertNotOpenWater(context.root);
556
+ // `--edit`: change a forged level instead of designing one. The request is
557
+ // frozen from the level's own record (its name, its asset, the job that built
558
+ // it) and the change; everything a fresh forge steers by is refused.
559
+ const editPrompt = typeof args.edit === 'string' && args.edit.trim().length > 0 ? args.edit.trim() : null;
560
+ if (editPrompt !== null) {
561
+ assertEditFlags(args);
562
+ if (lane === 'sideon') {
563
+ throw new CliError('--edit changes a forged 3D level; a 2D side-view level cannot be edited yet — re-forge it with the change in its prompt.');
564
+ }
565
+ const target = resolveEditTarget(readWorldJson(projectWorldJsonPath(context.root)), typeof args.level === 'string' ? args.level : undefined);
566
+ const input = buildEditJobInput({
567
+ target, editPrompt, gameId: context.metadata.gameId, groundPlane: readGroundPlane(context.root),
568
+ });
569
+ return {
570
+ context, environment, storage, input, resumeJobId: null, token: await accessToken(), lane,
571
+ reference: resolveReferenceImageUrl(args, context.root),
572
+ };
573
+ }
574
+ if (typeof args.level === 'string' && args.level !== '') {
575
+ throw new CliError('--level says which level --edit changes; pass --edit "<the change>" with it.');
576
+ }
471
577
  const mode = selectMode(args);
472
578
  if (lane === 'sideon' && mode !== undefined) {
473
579
  throw new CliError(`--${mode} does not apply to a 2D side-view project — the side-on forge derives the layout `
474
580
  + 'from the prompt (and GAME-DESIGN.md). Drop the flag and describe the level instead.');
475
581
  }
476
- // A resume replays the frozen request — including its reference image — so a file passed
477
- // alongside `--resume` would be uploaded and then silently ignored. Refused instead: the
478
- // caller clearly wanted the picture to matter, and it cannot here.
479
- if (resumeJobId !== null && typeof args['reference-image'] === 'string' && args['reference-image'] !== '') {
480
- throw new CliError('A resumed forge replays the original request, reference image and all — --reference-image '
481
- + 'cannot change it. Drop --resume to start a fresh forge with this picture.');
482
- }
483
- // Same rule for the rounds: they are frozen with the job, and a resume that skips the design
484
- // step never runs them anyway.
485
- if (resumeJobId !== null && args['match-rounds'] !== undefined) {
486
- throw new CliError('A resumed forge replays the original request — --match-rounds cannot change it. '
487
- + 'Drop --resume to start a fresh forge.');
488
- }
489
582
  const referenceMatchRounds = parseNumberFlag('match-rounds', typeof args['match-rounds'] === 'string' ? args['match-rounds'] : undefined, { min: 0, max: 3, integer: true });
490
583
  const reference = resumeJobId !== null
491
584
  ? resolveReferenceImageUrl(args, context.root)
@@ -523,9 +616,7 @@ async function prepareForgeRequest(args, deps) {
523
616
  ...(blockSize !== undefined ? { voxelBlockSize: blockSize } : {}),
524
617
  lane,
525
618
  };
526
- const environment = resolveEnvironmentForCommand({ cwd: context.root, baseDir: deps.baseDir });
527
- const clientId = resolveAuth0ClientId(environment);
528
- const token = await getAccessToken(environment, clientId, { fetch: deps.fetch, sleep: deps.sleep, now: deps.now }, deps.baseDir);
619
+ const token = await accessToken();
529
620
  return { context, environment, storage, input, resumeJobId, token, reference, lane };
530
621
  }
531
622
  catch (error) {
@@ -608,6 +699,14 @@ export const forgeCommand = defineCommand({
608
699
  type: 'boolean',
609
700
  description: 'Boot the game into this level. Without it, only a game\'s FIRST forge becomes the start level, so a later forge lands in the level list unseen.',
610
701
  },
702
+ edit: {
703
+ type: 'string',
704
+ description: 'Change a detail of a level this project forged ("make the doors sliding bulkheads", "warm amber lanterns instead of the lamps") without re-forging it: the stored design is patched and only what the change touches is re-made. Replaces the level in place. Takes --level; no other forge flag.',
705
+ },
706
+ level: {
707
+ type: 'string',
708
+ description: 'With --edit: the level to change, by id or name (`bitmagic levels list`). Omit when the project has one forged level.',
709
+ },
611
710
  json: {
612
711
  type: 'boolean',
613
712
  description: 'Print the result as JSON on stdout; all progress goes to stderr.',