@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.
- package/README.md +137 -14
- package/assets/skills/trailer-craft.md +180 -0
- package/dist/assets/materials.d.ts +9 -0
- package/dist/assets/materials.js +10 -2
- package/dist/assets/materials.js.map +1 -1
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +2 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/character.d.ts +18 -0
- package/dist/commands/character.js +268 -0
- package/dist/commands/character.js.map +1 -0
- package/dist/commands/forge.d.ts +15 -0
- package/dist/commands/forge.js +143 -38
- package/dist/commands/forge.js.map +1 -1
- package/dist/commands/generate.d.ts +31 -13
- package/dist/commands/generate.js +48 -56
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/trailer.js +1 -1
- package/dist/commands/trailer.js.map +1 -1
- package/dist/editor/animation-preview-page.js +43 -1
- package/dist/editor/animation-preview-page.js.map +1 -1
- package/dist/editor/asset-detail-panel.d.ts +2 -2
- package/dist/editor/asset-detail-panel.js +21 -0
- package/dist/editor/asset-detail-panel.js.map +1 -1
- package/dist/editor/asset-viewer.js +31 -8
- package/dist/editor/asset-viewer.js.map +1 -1
- package/dist/editor/shell-page.js +63 -1
- package/dist/editor/shell-page.js.map +1 -1
- package/dist/forge/edit-target.d.ts +30 -0
- package/dist/forge/edit-target.js +88 -0
- package/dist/forge/edit-target.js.map +1 -0
- package/dist/forge/prune-store.d.ts +46 -0
- package/dist/forge/prune-store.js +86 -0
- package/dist/forge/prune-store.js.map +1 -0
- package/dist/forge/remote-store.d.ts +36 -0
- package/dist/forge/remote-store.js +113 -0
- package/dist/forge/remote-store.js.map +1 -0
- package/dist/forge/run-pipeline.js +5 -1
- package/dist/forge/run-pipeline.js.map +1 -1
- package/dist/forge/stream.d.ts +5 -1
- package/dist/forge/stream.js +4 -0
- package/dist/forge/stream.js.map +1 -1
- package/dist/generate/character-body.d.ts +79 -0
- package/dist/generate/character-body.js +100 -0
- package/dist/generate/character-body.js.map +1 -0
- package/dist/mobile/asset-hosts.d.ts +7 -9
- package/dist/mobile/asset-hosts.js +7 -16
- package/dist/mobile/asset-hosts.js.map +1 -1
- package/dist/project/character-search.d.ts +30 -0
- package/dist/project/character-search.js +82 -0
- package/dist/project/character-search.js.map +1 -0
- package/dist/scaffold/asset-pipeline-skill-files.d.ts +38 -0
- package/dist/scaffold/asset-pipeline-skill-files.js +202 -0
- package/dist/scaffold/asset-pipeline-skill-files.js.map +1 -0
- package/dist/scaffold/blender-level-skill-files.js +3 -2
- package/dist/scaffold/blender-level-skill-files.js.map +1 -1
- package/dist/scaffold/project-files.js +16 -12
- package/dist/scaffold/project-files.js.map +1 -1
- package/dist/scaffold/project.js +4 -1
- package/dist/scaffold/project.js.map +1 -1
- package/dist/scaffold/skill-files.d.ts +32 -5
- package/dist/scaffold/skill-files.js +102 -63
- package/dist/scaffold/skill-files.js.map +1 -1
- package/dist/telemetry/creator-prompt.d.ts +18 -2
- package/dist/telemetry/creator-prompt.js +24 -3
- package/dist/telemetry/creator-prompt.js.map +1 -1
- package/dist/telemetry/report.js +31 -16
- package/dist/telemetry/report.js.map +1 -1
- 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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"}
|
package/dist/commands/forge.d.ts
CHANGED
|
@@ -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.";
|
package/dist/commands/forge.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
362
|
-
//
|
|
363
|
-
// prompt; the forge proceeds
|
|
364
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
},
|
|
453
|
+
}, editing
|
|
422
454
|
? {
|
|
423
|
-
// The
|
|
424
|
-
//
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.',
|