@bitmagic/cli 0.1.65-dev.2 → 0.1.65-dev.20

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 (69) hide show
  1. package/README.md +137 -14
  2. package/assets/skills/trailer-craft.md +180 -0
  3. package/dist/assets/materials.d.ts +9 -0
  4. package/dist/assets/materials.js +10 -2
  5. package/dist/assets/materials.js.map +1 -1
  6. package/dist/cli.d.ts +9 -0
  7. package/dist/cli.js +2 -0
  8. package/dist/cli.js.map +1 -1
  9. package/dist/commands/character.d.ts +18 -0
  10. package/dist/commands/character.js +268 -0
  11. package/dist/commands/character.js.map +1 -0
  12. package/dist/commands/forge.d.ts +15 -0
  13. package/dist/commands/forge.js +143 -38
  14. package/dist/commands/forge.js.map +1 -1
  15. package/dist/commands/generate.d.ts +31 -13
  16. package/dist/commands/generate.js +48 -56
  17. package/dist/commands/generate.js.map +1 -1
  18. package/dist/commands/trailer.js +1 -1
  19. package/dist/commands/trailer.js.map +1 -1
  20. package/dist/editor/animation-preview-page.js +43 -1
  21. package/dist/editor/animation-preview-page.js.map +1 -1
  22. package/dist/editor/asset-detail-panel.d.ts +2 -2
  23. package/dist/editor/asset-detail-panel.js +21 -0
  24. package/dist/editor/asset-detail-panel.js.map +1 -1
  25. package/dist/editor/asset-viewer.js +31 -8
  26. package/dist/editor/asset-viewer.js.map +1 -1
  27. package/dist/editor/shell-page.js +63 -1
  28. package/dist/editor/shell-page.js.map +1 -1
  29. package/dist/forge/edit-target.d.ts +30 -0
  30. package/dist/forge/edit-target.js +88 -0
  31. package/dist/forge/edit-target.js.map +1 -0
  32. package/dist/forge/prune-store.d.ts +46 -0
  33. package/dist/forge/prune-store.js +86 -0
  34. package/dist/forge/prune-store.js.map +1 -0
  35. package/dist/forge/remote-store.d.ts +36 -0
  36. package/dist/forge/remote-store.js +113 -0
  37. package/dist/forge/remote-store.js.map +1 -0
  38. package/dist/forge/run-pipeline.js +5 -1
  39. package/dist/forge/run-pipeline.js.map +1 -1
  40. package/dist/forge/stream.d.ts +5 -1
  41. package/dist/forge/stream.js +4 -0
  42. package/dist/forge/stream.js.map +1 -1
  43. package/dist/generate/character-body.d.ts +79 -0
  44. package/dist/generate/character-body.js +100 -0
  45. package/dist/generate/character-body.js.map +1 -0
  46. package/dist/mobile/asset-hosts.d.ts +7 -9
  47. package/dist/mobile/asset-hosts.js +7 -16
  48. package/dist/mobile/asset-hosts.js.map +1 -1
  49. package/dist/project/character-search.d.ts +30 -0
  50. package/dist/project/character-search.js +82 -0
  51. package/dist/project/character-search.js.map +1 -0
  52. package/dist/scaffold/asset-pipeline-skill-files.d.ts +38 -0
  53. package/dist/scaffold/asset-pipeline-skill-files.js +202 -0
  54. package/dist/scaffold/asset-pipeline-skill-files.js.map +1 -0
  55. package/dist/scaffold/blender-level-skill-files.js +3 -2
  56. package/dist/scaffold/blender-level-skill-files.js.map +1 -1
  57. package/dist/scaffold/project-files.js +16 -12
  58. package/dist/scaffold/project-files.js.map +1 -1
  59. package/dist/scaffold/project.js +4 -1
  60. package/dist/scaffold/project.js.map +1 -1
  61. package/dist/scaffold/skill-files.d.ts +32 -5
  62. package/dist/scaffold/skill-files.js +102 -63
  63. package/dist/scaffold/skill-files.js.map +1 -1
  64. package/dist/telemetry/creator-prompt.d.ts +18 -2
  65. package/dist/telemetry/creator-prompt.js +24 -3
  66. package/dist/telemetry/creator-prompt.js.map +1 -1
  67. package/dist/telemetry/report.js +31 -16
  68. package/dist/telemetry/report.js.map +1 -1
  69. package/package.json +4 -4
@@ -0,0 +1,268 @@
1
+ /**
2
+ * `bitmagic character` — make one, look through the ready-made ones, or add one.
3
+ *
4
+ * Its own noun group rather than three flags on `bitmagic generate character`,
5
+ * because the shelf and the forge are two different requests that happened to
6
+ * share a command:
7
+ *
8
+ * generate costs sparks, makes a new body, may substitute a close stored one
9
+ * search free, read-only, shows what the shelf has and how close it is
10
+ * add free, puts a stored body in the game, never forges
11
+ *
12
+ * The old surface expressed "add" as `generate character --library-only`, a
13
+ * command that generates nothing, and it carried two flags that existed only to
14
+ * be refused on that path (`--head-scale` was accepted, reported and dropped;
15
+ * `--mesh-model` was rejected outright). Both refusals disappear here, because a
16
+ * command that never forges simply does not offer the flags that pick how a
17
+ * forge runs.
18
+ *
19
+ * `search` exists because no similarity floor can do this job. Measured against
20
+ * the live index, genuine matches score 0.624-0.831 while humanoid prompts the
21
+ * shelf cannot answer score 0.552-0.698 — overlapping, so a threshold that keeps
22
+ * the good ones keeps "a humanoid bee" for "a beekeeper" too. A person reading
23
+ * three captions settles in a second what a number cannot settle at all.
24
+ */
25
+ import { defineCommand } from 'citty';
26
+ import { CliError } from '../errors.js';
27
+ import { createOutput } from '../output.js';
28
+ import { apiGet } from '../http/client.js';
29
+ import { resolveArtStyle } from '../project/art-style.js';
30
+ import { findCachedCandidate, readCharacterSearch, writeCharacterSearch, } from '../project/character-search.js';
31
+ import { ORIGINAL_PROMPT_FLAG, withParent } from '../telemetry/creator-prompt.js';
32
+ import { JSON_FLAG, resolveRun, runAssetGeneration } from './generate.js';
33
+ import { APPLY_TO_PLAYER_ARG, buildCharacterBody, defaultVoxelizeForProject, FRESH_ARG, HEAD_SCALE_ARG, LIBRARY_ARG, MESH_MODEL_ARG, NAME_ARG, PROMPT_ARG, VOXELIZE_ARG, } from '../generate/character-body.js';
34
+ const generateCommand = defineCommand({
35
+ meta: {
36
+ name: 'generate',
37
+ description: 'Forge a new rigged character and add it to world.json assets. Costs sparks.',
38
+ },
39
+ args: {
40
+ name: NAME_ARG,
41
+ prompt: PROMPT_ARG,
42
+ 'apply-to-player': APPLY_TO_PLAYER_ARG,
43
+ 'head-scale': HEAD_SCALE_ARG,
44
+ fresh: FRESH_ARG,
45
+ library: LIBRARY_ARG,
46
+ voxelize: VOXELIZE_ARG,
47
+ 'mesh-model': MESH_MODEL_ARG,
48
+ 'original-prompt': ORIGINAL_PROMPT_FLAG,
49
+ json: JSON_FLAG,
50
+ },
51
+ async run({ args }) {
52
+ const body = buildCharacterBody({
53
+ name: args.name,
54
+ prompt: args.prompt,
55
+ applyToPlayer: args['apply-to-player'],
56
+ headScale: args['head-scale'],
57
+ fresh: args.fresh,
58
+ library: args.library,
59
+ voxelize: args.voxelize ?? defaultVoxelizeForProject(),
60
+ meshModel: args['mesh-model'],
61
+ });
62
+ await runAssetGeneration('character', 'character', withParent(body), args.json === true);
63
+ },
64
+ });
65
+ /** `62%` — the only form a similarity is ever shown in, so the table lines up. */
66
+ function percent(value) {
67
+ return `${Math.round(value * 100)}%`;
68
+ }
69
+ function renderCandidates(candidates, threshold, log) {
70
+ const width = Math.max(2, ...candidates.map((row) => row.sid.length));
71
+ log('');
72
+ log(` # ${'id'.padEnd(width)} match auto description`);
73
+ candidates.forEach((row, index) => {
74
+ // The "auto" column is why the floor is reported rather than applied: it
75
+ // says which of these automatic substitution would have taken, so a creator
76
+ // can see both that a body cleared the bar and that a weaker one exists.
77
+ const auto = row.similarity >= threshold ? ' yes' : ' -';
78
+ const label = row.subject ?? row.description ?? '';
79
+ log(` ${String(index + 1).padStart(1)} ${row.sid.padEnd(width)} ${percent(row.similarity).padStart(5)} ${auto} ${label.slice(0, 60)}`);
80
+ });
81
+ log('');
82
+ }
83
+ const searchCommand = defineCommand({
84
+ meta: {
85
+ name: 'search',
86
+ description: 'List the ready-made characters closest to a description. Free; changes nothing.',
87
+ },
88
+ args: {
89
+ prompt: {
90
+ type: 'string',
91
+ required: true,
92
+ description: 'What the character should look like.',
93
+ },
94
+ limit: {
95
+ type: 'string',
96
+ description: 'How many candidates to list, 1-16. Default 5.',
97
+ },
98
+ representation: {
99
+ type: 'string',
100
+ description: "Which shelf to search: 'voxel' or 'lowpoly'. Omit to follow the project's art style.",
101
+ },
102
+ json: JSON_FLAG,
103
+ },
104
+ async run({ args }) {
105
+ const json = args.json === true;
106
+ const { output, runDeps, context, environment, token } = await resolveRun(json);
107
+ const representation = (() => {
108
+ const asked = args.representation;
109
+ // `resolveArtStyle` also reports a game.json value that is neither word,
110
+ // which is the same note every other art-style-aware command prints.
111
+ if (asked === undefined)
112
+ return resolveArtStyle(context.root, output.info) === 'low-poly' ? 'lowpoly' : 'voxel';
113
+ if (asked !== 'voxel' && asked !== 'lowpoly') {
114
+ throw new CliError("--representation must be 'voxel' or 'lowpoly'.", 1, 'invalid_flag');
115
+ }
116
+ return asked;
117
+ })();
118
+ const query = new URLSearchParams({
119
+ gameId: context.metadata.gameId,
120
+ prompt: args.prompt,
121
+ representation,
122
+ });
123
+ if (args.limit !== undefined)
124
+ query.set('limit', args.limit);
125
+ const response = await apiGet(environment, `/api/cli/v1/characters/search?${query.toString()}`, token, runDeps);
126
+ // Written even when empty: "the shelf has nothing like this" is a result a
127
+ // re-run should not have to rediscover, and an empty cache makes `add --id`
128
+ // say "search first" rather than "no such id".
129
+ writeCharacterSearch(context.root, {
130
+ prompt: response.prompt,
131
+ representation,
132
+ threshold: response.threshold,
133
+ searchedAt: new Date().toISOString(),
134
+ candidates: response.candidates,
135
+ });
136
+ if (response.candidates.length === 0) {
137
+ const nearest = response.topSimilarity !== undefined
138
+ ? ` The closest was ${percent(response.topSimilarity)}, which had no usable file.`
139
+ : '';
140
+ output.info(`No ready-made ${representation} character matches "${response.prompt}".${nearest}`);
141
+ output.info(`Forge one instead: bitmagic character generate --name <name> --prompt "${response.prompt}"`);
142
+ output.result({ ok: true, kind: 'character search', representation, threshold: response.threshold, candidates: [], ...(response.reason ? { reason: response.reason } : {}) });
143
+ return;
144
+ }
145
+ output.info(`${response.candidates.length} ready-made ${representation} character(s) for "${response.prompt}":`);
146
+ renderCandidates(response.candidates, response.threshold, output.info);
147
+ const best = response.candidates[0];
148
+ if (best.similarity < response.threshold) {
149
+ // Said plainly rather than hidden: on this corpus a confident-looking
150
+ // caption can still be the wrong character, and the creator is the only
151
+ // one who can tell.
152
+ output.info(`None clears the ${percent(response.threshold)} bar automatic substitution requires — read the captions before adding one.`);
153
+ }
154
+ output.info(`Add one: bitmagic character add --id ${best.sid} --name <name>`);
155
+ output.result({
156
+ ok: true,
157
+ kind: 'character search',
158
+ representation,
159
+ threshold: response.threshold,
160
+ candidates: response.candidates,
161
+ });
162
+ },
163
+ });
164
+ /**
165
+ * What `bitmagic character add` will ask for, from its flags and the cached search.
166
+ *
167
+ * Pure, and separated from the command so the branch that matters — which of the
168
+ * two forms was asked for, and whether the cache can answer it — is testable
169
+ * without a project, a token or a network. It returns the character args rather
170
+ * than a body so the shared `buildCharacterBody` still owns the wire shape.
171
+ */
172
+ export function resolveAddRequest(args) {
173
+ if (args.id === undefined) {
174
+ if (args.prompt === undefined) {
175
+ throw new CliError('Pass --id (from `bitmagic character search`) or --prompt to take the closest match.', 1, 'invalid_flag');
176
+ }
177
+ // The no-id form: the server searches with no floor and adds the best usable
178
+ // body, or fails without charging. Deliberately still available — "any of
179
+ // these will do" is a real request, and it is one call rather than two.
180
+ return {
181
+ name: args.name,
182
+ prompt: args.prompt,
183
+ applyToPlayer: args.applyToPlayer,
184
+ libraryOnly: true,
185
+ voxelize: defaultVoxelizeForProject(),
186
+ };
187
+ }
188
+ if (args.record === null) {
189
+ throw new CliError('No character search to pick from. Run `bitmagic character search --prompt "..."` first.', 1, 'no_search');
190
+ }
191
+ const candidate = findCachedCandidate(args.record, args.id);
192
+ if (candidate === null) {
193
+ const known = args.record.candidates.map((row) => row.sid).join(', ');
194
+ throw new CliError(`"${args.id}" was not in the last character search`
195
+ + (known === '' ? ' (it found nothing).' : `. It offered: ${known}.`)
196
+ + ' Run `bitmagic character search` again to pick from a fresh list.', 1, 'unknown_candidate');
197
+ }
198
+ return {
199
+ name: args.name,
200
+ // The CREATOR's brief, not the library row's own caption: this is what a
201
+ // regeneration re-drives, so it must describe what they asked for.
202
+ prompt: args.prompt ?? args.record.prompt,
203
+ applyToPlayer: args.applyToPlayer,
204
+ libraryPick: {
205
+ sid: candidate.sid,
206
+ assetUrl: candidate.assetUrl,
207
+ similarity: candidate.similarity,
208
+ ...(candidate.description ? { description: candidate.description } : {}),
209
+ },
210
+ // The shelf that produced the row decides the medium, not the project's art
211
+ // style at the moment of adding: mixing them would hand a low-poly game a
212
+ // pile of cubes.
213
+ voxelize: args.record.representation === 'voxel',
214
+ };
215
+ }
216
+ const addCommand = defineCommand({
217
+ meta: {
218
+ name: 'add',
219
+ description: 'Add a ready-made character from the library to world.json assets. Free; never forges.',
220
+ },
221
+ args: {
222
+ name: NAME_ARG,
223
+ id: {
224
+ type: 'string',
225
+ description: 'The id of a candidate from `bitmagic character search`. Omit to take the closest match to --prompt.',
226
+ },
227
+ prompt: {
228
+ type: 'string',
229
+ description: 'What the character should look like. Required without --id; with --id it defaults to the search that found it.',
230
+ },
231
+ 'apply-to-player': APPLY_TO_PLAYER_ARG,
232
+ 'original-prompt': ORIGINAL_PROMPT_FLAG,
233
+ json: JSON_FLAG,
234
+ },
235
+ async run({ args }) {
236
+ const json = args.json === true;
237
+ const { context } = await resolveRun(json);
238
+ const output = createOutput(json);
239
+ // Only the --id form consults the cache; the --prompt form is a fresh ask.
240
+ const record = args.id === undefined ? null : readCharacterSearch(context.root);
241
+ const request = resolveAddRequest({
242
+ name: args.name,
243
+ id: args.id,
244
+ prompt: args.prompt,
245
+ applyToPlayer: args['apply-to-player'],
246
+ record,
247
+ });
248
+ if (request.libraryPick) {
249
+ // The search's own date, not an expiry: age is worth seeing, and the
250
+ // catalogue only changes when somebody promotes to it.
251
+ output.info(`Adding ${request.libraryPick.sid} (${percent(request.libraryPick.similarity)} match`
252
+ + `${record?.searchedAt ? `, from the search on ${record.searchedAt.slice(0, 10)}` : ''}).`);
253
+ }
254
+ await runAssetGeneration('character', 'character', withParent(buildCharacterBody(request)), json);
255
+ },
256
+ });
257
+ export const characterCommand = defineCommand({
258
+ meta: {
259
+ name: 'character',
260
+ description: 'Forge a character, search the ready-made ones, or add one to your game.',
261
+ },
262
+ subCommands: {
263
+ generate: generateCommand,
264
+ search: searchCommand,
265
+ add: addCommand,
266
+ },
267
+ });
268
+ //# sourceMappingURL=character.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"character.js","sourceRoot":"","sources":["../../src/commands/character.ts"],"names":[],"mappings":"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"}
@@ -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,8 @@ 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';
48
+ import { pruneForgeStore } from '../forge/prune-store.js';
46
49
  import { describeForgeFailure, requestForge } from '../forge/stream.js';
47
50
  import { buildCliForgeDeps, createConsoleForgeLogger, readFrozenInput, runForgePipeline, seedForgeStore, } from '../forge/run-pipeline.js';
48
51
  /** Mirrors api-server's `FORGE_JOB_ID_PATTERN` so a typo is rejected here, before a round trip. */
@@ -74,6 +77,27 @@ export function deriveLevelName(explicit, prompt) {
74
77
  const derived = prompt.trim().split(/\s+/).slice(0, 6).join(' ').slice(0, 48).trim();
75
78
  return derived.length > 0 ? derived : 'level';
76
79
  }
80
+ /**
81
+ * `--edit` changes a level as it was designed, so it takes none of a fresh forge's steering: no
82
+ * prompt, no scene type, no reference image, no revision rounds, no start-level switch, and no
83
+ * resume (a resumed edit replays its frozen request, which `--edit` would contradict). Refused
84
+ * before any request, where it is still free.
85
+ */
86
+ export function assertEditFlags(args) {
87
+ const conflicts = [
88
+ ['prompt', typeof args.prompt === 'string' && args.prompt !== ''],
89
+ ['resume', typeof args.resume === 'string' && args.resume !== ''],
90
+ ['reference-image', typeof args['reference-image'] === 'string' && args['reference-image'] !== ''],
91
+ ['reference-image-url', typeof args['reference-image-url'] === 'string' && args['reference-image-url'] !== ''],
92
+ ['match-rounds', args['match-rounds'] !== undefined],
93
+ ['make-start', args['make-start'] === true],
94
+ ...MODE_FLAGS.map((mode) => [mode, args[mode] === true]),
95
+ ].filter(([, set]) => set).map(([flag]) => `--${flag}`);
96
+ if (conflicts.length > 0) {
97
+ throw new CliError(`--edit changes a forged level as it was designed and cannot be combined with ${conflicts.join(', ')}. `
98
+ + 'Describe the change alone: `bitmagic forge --edit "make the doors sliding bulkheads" --level <id or name>`.');
99
+ }
100
+ }
77
101
  /** The single selected mode, or undefined for plain terrain. */
78
102
  export function selectMode(args) {
79
103
  const chosen = MODE_FLAGS.filter((mode) => args[mode] === true);
@@ -224,7 +248,9 @@ async function classifyForgedArchetypes(input) {
224
248
  for (const assetId of assetIds.slice(0, MAX_ARCHETYPES_TO_CLASSIFY)) {
225
249
  if (!voxelIds.has(assetId))
226
250
  continue;
227
- await classifyAssetMaterialsQuietly({ context, environment, token, assetId, log, session });
251
+ // A forged archetype baked with the level's material map already carries its
252
+ // classes; the model is only asked about the ones the bake could not class.
253
+ await classifyAssetMaterialsQuietly({ context, environment, token, assetId, log, session, skipIfClassed: true });
228
254
  }
229
255
  }
230
256
  const defaultDeps = (json) => commandRunDeps(json);
@@ -291,7 +317,7 @@ async function runBrowserSteps(options) {
291
317
  });
292
318
  await host.launch();
293
319
  await host.loadGame();
294
- const storage = createLocalForgeStorage(context.root);
320
+ const { storage } = options;
295
321
  const store = createForgedWorkStore(jobId, storage);
296
322
  const forgeDeps = buildCliForgeDeps({
297
323
  projectRoot: context.root,
@@ -357,11 +383,14 @@ export async function runForge(args, deps) {
357
383
  const runDeps = deps ?? defaultDeps(args.json === true);
358
384
  const prepared = await prepareForgeRequest(args, runDeps);
359
385
  const { context, environment, storage, input, resumeJobId, token, reference, lane } = prepared;
386
+ // An edit of a forged level (fresh, or a resumed edit): the frozen request names its parent.
387
+ const editing = input.parentJobId !== undefined;
360
388
  // 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) {
389
+ // request, picture and all; an edit never sees the picture — it changes a stored design):
390
+ // either the project default took effect, or — when there is no reference at all — a pointer
391
+ // to the command that would give the designer one. Neither is a prompt; the forge proceeds
392
+ // regardless.
393
+ if (resumeJobId === null && !editing) {
365
394
  const referenceLine = describeAppliedReference(reference);
366
395
  if (referenceLine)
367
396
  runDeps.log(referenceLine);
@@ -396,11 +425,14 @@ export async function runForge(args, deps) {
396
425
  onLayout: report.layout,
397
426
  // A side-on run is four steps (no level bake) — with the default list the
398
427
  // banners would read [n/5] and "Baking level" would sit pending forever.
399
- ...(lane === 'sideon' ? { steps: SIDEON_FORGE_STEPS } : {}),
428
+ // An edit keeps the five, with the design step named for what it does.
429
+ ...(editing ? { steps: EDIT_FORGE_STEPS } : lane === 'sideon' ? { steps: SIDEON_FORGE_STEPS } : {}),
400
430
  });
401
431
  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}"`);
432
+ ? `Resuming ${editing ? 'edit' : 'forge'} job ${resumeJobId} for game ${context.metadata.gameId}.`
433
+ : editing
434
+ ? `Editing level "${input.name}" of game ${context.metadata.gameId}: "${input.editPrompt ?? input.prompt}"`
435
+ : `Forging a ${lane === 'sideon' ? 'SIDE-ON 2D level' : 'level'} for game ${context.metadata.gameId}: "${input.prompt}"`);
404
436
  // Said BEFORE the billed request, never only in the post-forge notes: a
405
437
  // side-on forge discards a multi-level registry and the objects in it.
406
438
  if (lane === 'sideon' && resumeJobId === null) {
@@ -418,16 +450,24 @@ export async function runForge(args, deps) {
418
450
  else
419
451
  log(` ${message}`);
420
452
  },
421
- }, lane === 'sideon'
453
+ }, editing
422
454
  ? {
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.',
455
+ // The edit's own route, for the same loud-404 reason as the side-on lane: an older
456
+ // api-server would otherwise design a whole new level from an edit request and bill it.
457
+ path: '/api/cli/v1/forge-edit/stream',
458
+ notFoundMessage: 'This api-server cannot edit forged levels yet (the route is missing). '
459
+ + 'Nothing was sent or billed — update later, or re-forge the level with the change in its prompt.',
429
460
  }
430
- : undefined);
461
+ : lane === 'sideon'
462
+ ? {
463
+ // The sibling route, so an api-server deployed before the side-on lane
464
+ // 404s LOUDLY here — before any model call and before any charge —
465
+ // instead of silently running a wrong 3D forge.
466
+ path: '/api/cli/v1/forge-sideon/stream',
467
+ notFoundMessage: 'This api-server does not support 2D side-view forging yet (the route is missing). '
468
+ + 'Nothing was sent or billed — update later, or build the level from the web creator once it ships there.',
469
+ }
470
+ : undefined);
431
471
  if (!outcome.success) {
432
472
  throw new CliError(describeForgeFailure(outcome));
433
473
  }
@@ -437,14 +477,39 @@ export async function runForge(args, deps) {
437
477
  log(`Design and geometry are saved server-side as job ${jobId}.`);
438
478
  // From here on the design and the geometry are PAID FOR and saved server-side. Every failure
439
479
  // below must therefore point at the resume rather than at re-running from scratch.
440
- await seedForgeStore(createForgedWorkStore(jobId, storage), artifact, input, platformerMovement);
480
+ if (editing) {
481
+ // The parent's bakes live in THIS project's store, if it forged the level here. The
482
+ // server's plan is an upper bound; the seed says what this machine could actually reuse.
483
+ const parentJobId = outcome.parentJobId ?? input.parentJobId;
484
+ const world = readWorldJson(projectWorldJsonPath(context.root));
485
+ const seed = await seedEditStore({
486
+ store: createForgedWorkStore(jobId, storage),
487
+ parentStore: createForgedWorkStore(parentJobId, storage),
488
+ artifact,
489
+ input,
490
+ reuse: outcome.reuse ?? bakeEverythingPlan(artifact, 'the server returned no reuse plan'),
491
+ worldAssets: Array.isArray(world.assets) ? world.assets.filter(isJsonObject) : [],
492
+ });
493
+ log(describeEditSeed(seed));
494
+ if (platformerMovement)
495
+ await createForgedWorkStore(jobId, storage).put('platformer-movement', platformerMovement);
496
+ }
497
+ else {
498
+ await seedForgeStore(createForgedWorkStore(jobId, storage), artifact, input, platformerMovement);
499
+ }
441
500
  try {
442
- await runBrowserSteps({ context, environment, jobId, token, deps: { ...runDeps, log }, tracker, lane });
501
+ await runBrowserSteps({ context, environment, jobId, token, deps: { ...runDeps, log }, tracker, lane, storage });
443
502
  }
444
503
  catch (error) {
445
504
  throw decorateBrowserStepError(error, jobId);
446
505
  }
447
- output.result({ ok: true, jobId, gameId: context.metadata.gameId, name: input.name });
506
+ // The level is in world.json: the finished jobs it no longer names (an
507
+ // edited level keeps only its newest) have nothing left to give.
508
+ pruneForgeStore({ projectRoot: context.root, world: readWorldJson(projectWorldJsonPath(context.root)), keep: [jobId], log });
509
+ output.result({
510
+ ok: true, jobId, gameId: context.metadata.gameId, name: input.name,
511
+ ...(editing ? { edited: true, levelAssetId: input.assetId, parentJobId: outcome.parentJobId ?? input.parentJobId } : {}),
512
+ });
448
513
  });
449
514
  }
450
515
  async function prepareForgeRequest(args, deps) {
@@ -455,7 +520,33 @@ async function prepareForgeRequest(args, deps) {
455
520
  throw new CliError('--resume takes a job id of 1-64 characters (letters, digits, "_" or "-") — the one printed '
456
521
  + 'by the run you are resuming.');
457
522
  }
458
- const storage = createLocalForgeStorage(context.root);
523
+ const environment = resolveEnvironmentForCommand({ cwd: context.root, baseDir: deps.baseDir });
524
+ // Resolved on first use and shared by the store and the request: the store
525
+ // behind every read below is MIRRORED to api-server (remote-store.ts), so a
526
+ // job started on another machine can be resumed or edited here — its frozen
527
+ // request and bakes are fetched when the local copy lacks them — and only
528
+ // such a fetch, or the request itself, needs the credential.
529
+ let tokenPromise;
530
+ const accessToken = () => {
531
+ tokenPromise ??= getAccessToken(environment, resolveAuth0ClientId(environment), { fetch: deps.fetch, sleep: deps.sleep, now: deps.now }, deps.baseDir);
532
+ return tokenPromise;
533
+ };
534
+ const storage = createMirroredForgeStorage(createLocalForgeStorage(context.root), {
535
+ environment, accessToken, fetch: deps.fetch, warn: (message) => deps.log(`warning: ${message}`),
536
+ });
537
+ // A resume replays the frozen request — including its reference image — so a file passed
538
+ // alongside `--resume` would be uploaded and then silently ignored. Refused instead: the
539
+ // caller clearly wanted the picture to matter, and it cannot here.
540
+ if (resumeJobId !== null && typeof args['reference-image'] === 'string' && args['reference-image'] !== '') {
541
+ throw new CliError('A resumed forge replays the original request, reference image and all — --reference-image '
542
+ + 'cannot change it. Drop --resume to start a fresh forge with this picture.');
543
+ }
544
+ // Same rule for the rounds: they are frozen with the job, and a resume that skips the design
545
+ // step never runs them anyway.
546
+ if (resumeJobId !== null && args['match-rounds'] !== undefined) {
547
+ throw new CliError('A resumed forge replays the original request — --match-rounds cannot change it. '
548
+ + 'Drop --resume to start a fresh forge.');
549
+ }
459
550
  const frozen = resumeJobId !== null ? await readFrozenInput(storage, resumeJobId) : null;
460
551
  // Route by the project's dimension HERE, before any token/server work:
461
552
  // steps 1-2 bill. 3D projects keep today's pipeline; side-view 2D projects
@@ -468,24 +559,32 @@ async function prepareForgeRequest(args, deps) {
468
559
  // Refused here, where it is still free.
469
560
  if (lane === 'sideon')
470
561
  assertNotOpenWater(context.root);
562
+ // `--edit`: change a forged level instead of designing one. The request is
563
+ // frozen from the level's own record (its name, its asset, the job that built
564
+ // it) and the change; everything a fresh forge steers by is refused.
565
+ const editPrompt = typeof args.edit === 'string' && args.edit.trim().length > 0 ? args.edit.trim() : null;
566
+ if (editPrompt !== null) {
567
+ assertEditFlags(args);
568
+ if (lane === 'sideon') {
569
+ 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.');
570
+ }
571
+ const target = resolveEditTarget(readWorldJson(projectWorldJsonPath(context.root)), typeof args.level === 'string' ? args.level : undefined);
572
+ const input = buildEditJobInput({
573
+ target, editPrompt, gameId: context.metadata.gameId, groundPlane: readGroundPlane(context.root),
574
+ });
575
+ return {
576
+ context, environment, storage, input, resumeJobId: null, token: await accessToken(), lane,
577
+ reference: resolveReferenceImageUrl(args, context.root),
578
+ };
579
+ }
580
+ if (typeof args.level === 'string' && args.level !== '') {
581
+ throw new CliError('--level says which level --edit changes; pass --edit "<the change>" with it.');
582
+ }
471
583
  const mode = selectMode(args);
472
584
  if (lane === 'sideon' && mode !== undefined) {
473
585
  throw new CliError(`--${mode} does not apply to a 2D side-view project — the side-on forge derives the layout `
474
586
  + 'from the prompt (and GAME-DESIGN.md). Drop the flag and describe the level instead.');
475
587
  }
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
588
  const referenceMatchRounds = parseNumberFlag('match-rounds', typeof args['match-rounds'] === 'string' ? args['match-rounds'] : undefined, { min: 0, max: 3, integer: true });
490
589
  const reference = resumeJobId !== null
491
590
  ? resolveReferenceImageUrl(args, context.root)
@@ -523,9 +622,7 @@ async function prepareForgeRequest(args, deps) {
523
622
  ...(blockSize !== undefined ? { voxelBlockSize: blockSize } : {}),
524
623
  lane,
525
624
  };
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);
625
+ const token = await accessToken();
529
626
  return { context, environment, storage, input, resumeJobId, token, reference, lane };
530
627
  }
531
628
  catch (error) {
@@ -608,6 +705,14 @@ export const forgeCommand = defineCommand({
608
705
  type: 'boolean',
609
706
  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
707
  },
708
+ edit: {
709
+ type: 'string',
710
+ 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.',
711
+ },
712
+ level: {
713
+ type: 'string',
714
+ description: 'With --edit: the level to change, by id or name (`bitmagic levels list`). Omit when the project has one forged level.',
715
+ },
611
716
  json: {
612
717
  type: 'boolean',
613
718
  description: 'Print the result as JSON on stdout; all progress goes to stderr.',