@playcraft/cli 0.1.63 → 0.1.65

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 (41) hide show
  1. package/README.md +24 -31
  2. package/SKILLS_REGISTRY.md +1 -1
  3. package/dist/commands/3d.js +41 -33
  4. package/dist/commands/build.js +70 -5
  5. package/dist/commands/create.js +4 -4
  6. package/dist/commands/inspect.js +4 -6
  7. package/dist/index.js +27 -19
  8. package/dist/model-adaptation/node-inspection.js +173 -0
  9. package/dist/model-adaptation/project-template.js +194 -0
  10. package/dist/model-adaptation/project.js +76 -0
  11. package/dist/model-adaptation/source.js +49 -0
  12. package/dist/model-optimization/optimize.js +281 -0
  13. package/dist/model-preparation/bindings.js +35 -0
  14. package/dist/model-preparation/commands.js +28 -0
  15. package/dist/model-preparation/geometry.js +241 -0
  16. package/dist/model-preparation/glb.js +176 -0
  17. package/dist/model-preparation/pipeline-engine.js +157 -0
  18. package/dist/model-preparation/pipeline-files.js +108 -0
  19. package/dist/model-preparation/pipeline-worker.js +32 -0
  20. package/dist/model-preparation/split-rigid.js +302 -0
  21. package/dist/model-preparation/vertex-repairs.js +63 -0
  22. package/dist/workspace-runtime/batch/deliver.js +2 -2
  23. package/dist/workspace-runtime/codex/app-server.js +11 -1
  24. package/dist/workspace-runtime/codex/delivery-tools.js +38 -5
  25. package/dist/workspace-runtime/codex/model-broker.js +19 -1
  26. package/dist/workspace-runtime/previews/publish.js +2 -2
  27. package/dist/workspace-runtime/serve.js +5 -0
  28. package/dist/workspace-runtime/server/directory-access.js +1 -1
  29. package/dist/workspace-runtime/server/dispatch.js +27 -47
  30. package/dist/workspace-runtime/server/http.js +12 -0
  31. package/dist/workspace-runtime/workspaces/build-isolation.js +75 -0
  32. package/dist/workspace-runtime/workspaces/model-build-inputs.js +27 -0
  33. package/dist/workspace-runtime/workspaces/model-preparation-store.js +134 -0
  34. package/dist/workspace-runtime/workspaces/model-preparation.js +357 -0
  35. package/dist/workspace-runtime/workspaces/platform-export.js +1 -0
  36. package/package.json +6 -4
  37. package/project-template/.claude/skills/playcraft-build-optimizer/SKILL.md +19 -39
  38. package/project-templates/empty/AGENTS.md +1 -1
  39. package/project-templates/phaser/.claude/skills/playcraft-build-optimizer/SKILL.md +19 -39
  40. package/project-templates/threejs/.claude/skills/playcraft-build-optimizer/SKILL.md +19 -39
  41. package/dist/workspace-runtime/workspaces/delivery-build.js +0 -440
@@ -0,0 +1,357 @@
1
+ import { chmod, chown, lstat, mkdir, mkdtemp, readFile, readdir, realpath, writeFile } from 'node:fs/promises';
2
+ import { dirname, join, relative } from 'node:path';
3
+ import { tmpdir } from 'node:os';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { createRequire } from 'node:module';
6
+ import { canonicalModelAdaptation, hashOperationPayload, modelPreparationPayloadSchema, modelPreparationReceiptSchema, transitionOperation, } from '@playcraft/common/cloud-workspace/runtime';
7
+ import { workspaceRoot, revisionScope } from './registry.js';
8
+ import { runDeveloperCommand } from './developer-command.js';
9
+ import { openScratchToDevelopment } from './directory-access.js';
10
+ import { stopLauncherExecution } from '../codex/launcher-client.js';
11
+ import { assertOperationDirectoryAccess } from '../server/directory-access.js';
12
+ import { ModelPreparationStore, promotePreparationProject, targetPreparationSnapshot } from './model-preparation-store.js';
13
+ import { hash, hashData, preparationInventory, readPreparationFile, readPreparationJson, writePreparationJson } from '../../model-preparation/pipeline-files.js';
14
+ const active = new WeakMap();
15
+ const tasks = new WeakMap();
16
+ const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
17
+ const identity = (payload) => { const { resumeFromReceiptHash: _resume, ...value } = payload; return hashData(value); };
18
+ const requestKey = (workspaceId, payload) => hashData({ workspaceId, pipelineId: payload.pipelineId, stepId: payload.stepId });
19
+ export async function preparationToolManifest() {
20
+ const files = ['model-preparation/pipeline-worker.js', 'model-preparation/pipeline-engine.js', 'model-preparation/pipeline-files.js',
21
+ 'model-preparation/glb.js', 'model-preparation/geometry.js', 'model-preparation/split-rigid.js', 'model-preparation/vertex-repairs.js',
22
+ 'model-adaptation/node-inspection.js', 'model-adaptation/source.js', 'model-adaptation/project.js', 'model-adaptation/project-template.js',
23
+ 'model-optimization/optimize.js', 'workspace-runtime/workspaces/model-preparation.js', 'workspace-runtime/workspaces/model-preparation-store.js'];
24
+ const common = createRequire(import.meta.url).resolve('@playcraft/common/cloud-workspace/runtime');
25
+ const manifest = await Promise.all(files.map(async (path) => ({ path, contentHash: hash(await readFile(new URL(`../../../dist/${path}`, import.meta.url))) })));
26
+ const visitCommon = async (path, prefix = '') => {
27
+ for (const entry of await readdir(path, { withFileTypes: true })) {
28
+ if (entry.isSymbolicLink())
29
+ throw Error('model_prepare_tool_path_unsafe');
30
+ const name = prefix ? `${prefix}/${entry.name}` : entry.name;
31
+ if (entry.isDirectory())
32
+ await visitCommon(join(path, entry.name), name);
33
+ else if (entry.name.endsWith('.js'))
34
+ manifest.push({ path: `common/${name}`, contentHash: hash(await readFile(join(path, entry.name))) });
35
+ }
36
+ };
37
+ await visitCommon(dirname(common));
38
+ const require = createRequire(import.meta.url), threeRoot = dirname(dirname(require.resolve('three')));
39
+ for (const [name, path] of [['three-module', join(threeRoot, 'build/three.module.js')], ['three-core', join(threeRoot, 'build/three.core.js')],
40
+ ['three-loader', require.resolve('three/examples/jsm/loaders/GLTFLoader.js')], ['earcut', require.resolve('earcut')],
41
+ ...Object.keys(require.cache).filter(path => /sharp.*\.node$/.test(path)).sort().map((path, index) => [`sharp-native-${index}`, path])])
42
+ manifest.push({ path: `dependency/${name}`, contentHash: hash(await readFile(path)) });
43
+ manifest.push({ path: 'cli/package.json', contentHash: hash(await readFile(new URL('../../../package.json', import.meta.url))) });
44
+ manifest.sort((a, b) => a.path.localeCompare(b.path, 'en'));
45
+ return { schemaVersion: 1, files: manifest, toolHash: hashData(manifest) };
46
+ }
47
+ async function seal(path, groupId) {
48
+ const stat = await lstat(path);
49
+ if (stat.isSymbolicLink())
50
+ throw Error('model_prepare_path_unsafe');
51
+ if (stat.gid !== groupId)
52
+ await chown(path, stat.uid, groupId);
53
+ if (stat.isDirectory()) {
54
+ for (const entry of await readdir(path))
55
+ await seal(join(path, entry), groupId);
56
+ await chmod(path, 0o550);
57
+ }
58
+ else
59
+ await chmod(path, 0o440);
60
+ }
61
+ async function verifyOriginalSource(root, record) {
62
+ for (const file of record.sourceSnapshot ?? []) {
63
+ const actual = await readPreparationFile(root, file.path), before = record.sourceStats?.find(item => item.path === file.path);
64
+ if (actual.contentHash !== file.contentHash || actual.bytes.length !== file.bytes || before &&
65
+ ['ino', 'dev', 'size', 'mtimeMs', 'ctimeMs'].some(key => actual.stat[key] !== before[key]))
66
+ throw Error('model_prepare_source_changed');
67
+ }
68
+ }
69
+ async function snapshotSource(root, store, record) {
70
+ const files = [], stats = [];
71
+ let total = 0;
72
+ for (const file of record.payload.source.files) {
73
+ if (file.path.split('/').includes('.playcraft'))
74
+ throw Error('model_prepare_source_path_unsafe');
75
+ const actual = await readPreparationFile(root, file.path);
76
+ total += actual.bytes.length;
77
+ if (actual.contentHash !== file.contentHash || total > 128 * 1024 * 1024)
78
+ throw Error('model_prepare_source_identity_mismatch');
79
+ const path = join(store.root, 'source', file.path);
80
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 }); // private: controller-only preparation state
81
+ await writeFile(path, actual.bytes, { flag: 'wx', mode: 0o600 }).catch(async (error) => { if (error.code !== 'EEXIST' || hash(await readFile(path)) !== file.contentHash)
82
+ throw error; }); // private: controller-only preparation state
83
+ files.push({ ...file, bytes: actual.bytes.length });
84
+ const { ino, dev, size, mtimeMs, ctimeMs } = actual.stat;
85
+ stats.push({ path: file.path, ino, dev, size, mtimeMs, ctimeMs });
86
+ }
87
+ record.sourceSnapshot = files;
88
+ record.sourceStats = stats;
89
+ await verifyOriginalSource(root, record);
90
+ record.phase = 'snapshotted';
91
+ await store.write(record);
92
+ }
93
+ function receipt(record, status, reasonCode) {
94
+ const result = record.result;
95
+ const base = { kind: 'model-preparation', schemaVersion: 1, pipelineId: record.pipelineId, stepId: record.stepId,
96
+ status, ...(reasonCode ? { reasonCode } : {}), inputHash: record.inputHash, sourceScope: record.payload.sourceScope, source: record.payload.source,
97
+ ...(status === 'prepared' && result ? { model: result.model, adaptationPath: result.adaptationPath, adaptationHash: result.adaptationHash,
98
+ manifestPath: '.playcraft/model-build-inputs.json', modelBytes: result.modelBytes, triangles: result.triangles,
99
+ lineageFiles: result.lineageFiles, writePaths: record.transaction?.files.map(file => file.path) ?? [] } : { lineageFiles: [], writePaths: record.transaction?.published ?? [] }),
100
+ toolHash: record.toolHash, browserValidation: 'pending', modelAcceptance: false };
101
+ return modelPreparationReceiptSchema.parse({ ...base, receiptHash: hashData(base) });
102
+ }
103
+ export async function readModelPreparation(state, workspaceId, directoryName, pipelineId, stepId) {
104
+ await workspaceRoot(state, workspaceId, directoryName);
105
+ const record = await new ModelPreparationStore(state.config.stateDir, workspaceId, directoryName, pipelineId, stepId).read();
106
+ return record?.receipt ?? null;
107
+ }
108
+ export function trackModelPreparation(state, task) {
109
+ const known = tasks.get(state) ?? new Set();
110
+ tasks.set(state, known);
111
+ known.add(task);
112
+ void task.finally(() => known.delete(task)).catch(() => undefined);
113
+ }
114
+ export async function stopModelPreparations(state) {
115
+ for (const controller of active.get(state)?.values() ?? [])
116
+ controller.abort();
117
+ await Promise.allSettled([...(tasks.get(state) ?? [])]);
118
+ }
119
+ /** A controlled local step creates source and receipts; it never installs, builds, infers or validates a game. */
120
+ export async function runModelPreparation(state, operation, value, options) {
121
+ const payload = modelPreparationPayloadSchema.parse(value);
122
+ if (!operation.workspaceId || operation.directoryName !== 'basic' || payload.targetDirectoryName !== 'basic' ||
123
+ state.journal.getWorkspace(operation.workspaceId)?.targetKind !== 'standalone')
124
+ throw Error('model_prepare_target_scope_invalid');
125
+ const target = await workspaceRoot(state, operation.workspaceId, 'basic'), source = await workspaceRoot(state, payload.sourceScope.workspaceId, payload.sourceScope.directoryName);
126
+ const store = new ModelPreparationStore(state.config.stateDir, operation.workspaceId, 'basic', payload.pipelineId, payload.stepId);
127
+ const tools = await preparationToolManifest(), inputHash = hashOperationPayload(payload), semanticInputHash = identity(payload), key = requestKey(operation.workspaceId, payload);
128
+ const known = active.get(state) ?? new Map();
129
+ active.set(state, known);
130
+ if (known.has(key))
131
+ throw Error('model_prepare_already_running');
132
+ let record = await store.read();
133
+ if (record && (record.semanticInputHash !== semanticInputHash || record.toolHash !== tools.toolHash))
134
+ throw Error('model_prepare_resume_identity_mismatch');
135
+ if (payload.resumeFromReceiptHash && record?.receipt?.receiptHash !== payload.resumeFromReceiptHash)
136
+ throw Error('model_prepare_resume_receipt_mismatch');
137
+ if (record?.phase === 'uncertain' || record?.phase === 'running')
138
+ throw Error('model_prepare_recovery_required');
139
+ if (record?.receipt) {
140
+ if (record.inputHash !== inputHash) {
141
+ await writePreparationJson(join(store.root, 'receipts', `${record.receipt.receiptHash}.json`), record.receipt);
142
+ record.inputHash = inputHash;
143
+ record.payload = payload;
144
+ record.receipt = receipt(record, record.receipt.status, record.receipt.reasonCode);
145
+ await store.write(record);
146
+ }
147
+ return record.receipt;
148
+ }
149
+ const assertCurrent = async () => {
150
+ if (state.journal.destroyed() || (operation.leaseFence ?? 0) < state.acceptedLeaseFence || operation.generation !== state.config.generation || operation.runtimeEpoch !== state.runtimeEpoch)
151
+ throw Error('model_prepare_stale_operation');
152
+ await assertOperationDirectoryAccess(state, operation, 'exclusive');
153
+ };
154
+ await assertCurrent();
155
+ const controller = new AbortController();
156
+ known.set(key, controller);
157
+ const writeId = `model-prepare-${operation.gatewayOperationId ?? operation.operationId}`;
158
+ let stopped = true;
159
+ try {
160
+ const ownerPath = join(dirname(dirname(store.root)), 'target-owner.json');
161
+ const owner = await readPreparationJson(dirname(ownerPath), 'target-owner.json');
162
+ if (owner && owner.pipelineId !== payload.pipelineId)
163
+ throw Error('model_prepare_target_owned');
164
+ if (!owner)
165
+ await writePreparationJson(ownerPath, { pipelineId: payload.pipelineId, workspaceId: operation.workspaceId, directoryName: 'basic' });
166
+ if (!record) {
167
+ const targetSnapshot = await targetPreparationSnapshot(target);
168
+ if (payload.expectedTargetSnapshotHash && hashData(targetSnapshot) !== payload.expectedTargetSnapshotHash)
169
+ throw Error('model_prepare_target_changed');
170
+ record = { schemaVersion: 1, pipelineId: payload.pipelineId, stepId: payload.stepId, workspaceId: operation.workspaceId, directoryName: 'basic',
171
+ inputHash, semanticInputHash, toolHash: tools.toolHash, payload, phase: 'planned', createdAt: new Date().toISOString(), targetSnapshot };
172
+ await store.write(record);
173
+ await writePreparationJson(join(store.root, 'tools.json'), tools);
174
+ }
175
+ record.inputHash = inputHash;
176
+ record.payload = payload;
177
+ await store.write(record);
178
+ await writePreparationJson(join(state.config.stateDir, 'model-preparations', 'operations', `${operation.operationId}.json`), {
179
+ workspaceId: operation.workspaceId, directoryName: 'basic', pipelineId: payload.pipelineId, stepId: payload.stepId,
180
+ });
181
+ if (!record.sourceSnapshot)
182
+ await snapshotSource(source, store, record);
183
+ else
184
+ await verifyOriginalSource(source, record);
185
+ if (!record.result) {
186
+ let scratch = record.scratch;
187
+ if (scratch && !await lstat(scratch).then(value => value.isDirectory() && !value.isSymbolicLink(), () => false)) {
188
+ await writePreparationJson(join(store.root, 'lost-scratch', `${Date.now()}.json`), { path: scratch, reason: 'worker-scratch-unavailable', stagesRecomputedOnExplicitResume: true });
189
+ scratch = undefined;
190
+ }
191
+ if (!scratch) {
192
+ scratch = await realpath(await mkdtemp(join(tmpdir(), 'playcraft-model-prepare-')));
193
+ const groupId = (await lstat(target)).gid;
194
+ await openScratchToDevelopment(scratch, groupId);
195
+ await chmod(scratch, 0o750);
196
+ const inputRoot = join(scratch, 'input');
197
+ await mkdir(inputRoot);
198
+ for (const file of record.sourceSnapshot ?? []) {
199
+ const bytes = (await readPreparationFile(join(store.root, 'source'), file.path)).bytes, destination = join(inputRoot, file.path);
200
+ await mkdir(dirname(destination), { recursive: true });
201
+ await writeFile(destination, bytes, { flag: 'wx' });
202
+ }
203
+ await seal(inputRoot, groupId);
204
+ await mkdir(join(scratch, 'output'));
205
+ await openScratchToDevelopment(join(scratch, 'output'), groupId);
206
+ const configPath = join(scratch, 'input.json');
207
+ await writePreparationJson(configPath, { payload, sourceRoot: inputRoot, outputRoot: join(scratch, 'output'), toolHash: tools.toolHash, tools });
208
+ await seal(configPath, groupId);
209
+ record.scratch = scratch;
210
+ await store.write(record);
211
+ }
212
+ const configPath = join(scratch, 'input.json'), configHash = hash(await readFile(configPath));
213
+ const config = JSON.parse(await readFile(configPath, 'utf8'));
214
+ if (identity(modelPreparationPayloadSchema.parse(config.payload)) !== semanticInputHash || config.toolHash !== tools.toolHash || hashData(config.tools) !== hashData(tools) ||
215
+ config.sourceRoot !== join(scratch, 'input') || config.outputRoot !== join(scratch, 'output'))
216
+ throw Error('model_prepare_worker_input_changed');
217
+ record.phase = 'running';
218
+ await store.write(record);
219
+ const outcome = await runDeveloperCommand(state, { processId: writeId, cwd: scratch,
220
+ command: `${quote(process.execPath)} --max-old-space-size=768 ${quote(options.workerPath ?? fileURLToPath(new URL('../../../dist/model-preparation/pipeline-worker.js', import.meta.url)))} ${quote(configPath)} ${quote(configHash)}`,
221
+ timeoutMs: 600_000, signal: controller.signal, onOutput: () => undefined,
222
+ onStarted: async (process) => {
223
+ stopped = false;
224
+ await state.journal.putManagedWrite({ writeId,
225
+ workspaceId: revisionScope(state, operation.workspaceId, 'basic') ?? 'single', source: 'manual', directoryAccessMode: 'exclusive',
226
+ startedAt: new Date().toISOString(), pid: process.pid,
227
+ ...(process.launcher ? { executionMode: 'launcher', launcher: process.launcher, runtimeEpoch: state.runtimeEpoch } : { executionMode: 'local' }) });
228
+ } });
229
+ stopped = outcome.code !== undefined;
230
+ if (!stopped)
231
+ throw Error('model_prepare_process_unknown');
232
+ const writer = state.journal.listManagedWrites().find(item => item.writeId === writeId);
233
+ if (writer)
234
+ await state.journal.putManagedWrite({ ...writer, stoppedAt: new Date().toISOString() });
235
+ if (outcome.canceled)
236
+ throw Error('model_prepare_cancelled');
237
+ if (outcome.timedOut)
238
+ throw Error('model_prepare_timeout');
239
+ if (outcome.code !== 0)
240
+ throw Error('model_prepare_worker_failed');
241
+ const result = await readPreparationJson(join(scratch, 'output'), 'result.json', 8 * 1024 * 1024);
242
+ if (!result)
243
+ throw Error('model_prepare_worker_receipt_missing');
244
+ if (result.status === 'blocked') {
245
+ if (!/^([a-z]+_)+[a-z]+$/.test(result.reasonCode))
246
+ throw Error('model_prepare_worker_receipt_invalid');
247
+ record.phase = 'blocked';
248
+ record.receipt = receipt(record, 'blocked', result.reasonCode);
249
+ await store.write(record);
250
+ return record.receipt;
251
+ }
252
+ if (result.status !== 'prepared' || !/^adapt-[a-f0-9-]{36}\/project$/.test(relative(join(scratch, 'output'), result.projectRoot)) ||
253
+ await realpath(result.projectRoot) !== result.projectRoot)
254
+ throw Error('model_prepare_worker_receipt_invalid');
255
+ const actual = await preparationInventory(result.projectRoot);
256
+ if (canonicalModelAdaptation(actual) !== canonicalModelAdaptation(result.files))
257
+ throw Error('model_prepare_worker_output_changed');
258
+ const produced = join(store.root, 'produced');
259
+ await mkdir(produced, { recursive: true, mode: 0o700 }); // private: controller-only preparation state
260
+ for (const file of actual) {
261
+ const bytes = (await readPreparationFile(result.projectRoot, file.path)).bytes, path = join(produced, file.path);
262
+ if (hash(bytes) !== file.contentHash)
263
+ throw Error('model_prepare_worker_output_changed');
264
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 }); // private: controller-only preparation state
265
+ await writeFile(path, bytes, { flag: 'wx', mode: 0o600 }).catch(async (error) => { if (error.code !== 'EEXIST' || hash(await readFile(path)) !== file.contentHash)
266
+ throw error; }); // private: controller-only preparation state
267
+ }
268
+ if (canonicalModelAdaptation(await preparationInventory(produced)) !== canonicalModelAdaptation(actual))
269
+ throw Error('model_prepare_worker_output_changed');
270
+ record.result = { ...result, projectRoot: produced };
271
+ record.phase = 'produced';
272
+ await store.write(record);
273
+ }
274
+ if ((await preparationToolManifest()).toolHash !== tools.toolHash)
275
+ throw Error('model_prepare_tool_changed');
276
+ await verifyOriginalSource(source, record);
277
+ await assertCurrent();
278
+ if (payload.sourceScope.workspaceId === operation.workspaceId && payload.sourceScope.directoryName === 'basic') {
279
+ const writePaths = new Set(record.result.files.filter(file => !['AGENTS.md', 'README.md'].includes(file.path)).map(file => file.path));
280
+ if (payload.source.files.some(file => writePaths.has(file.path)))
281
+ throw Error('model_prepare_source_output_overlap');
282
+ }
283
+ await promotePreparationProject(store, target, record, { assertCurrent, onFile: options.onFile, signal: controller.signal });
284
+ await verifyOriginalSource(source, record);
285
+ record.phase = 'prepared';
286
+ record.receipt = receipt(record, 'prepared');
287
+ await store.write(record);
288
+ return record.receipt;
289
+ }
290
+ catch (error) {
291
+ if (record && stopped && error instanceof Error && /^model_prepare_(target_changed|source_changed|source_identity_mismatch|target_unsafe|source_output_overlap)$/.test(error.message)) {
292
+ record.phase = 'blocked';
293
+ record.receipt = receipt(record, 'blocked', error.message);
294
+ await store.write(record);
295
+ return record.receipt;
296
+ }
297
+ if (record) {
298
+ record.phase = stopped ? 'interrupted' : 'uncertain';
299
+ await store.write(record);
300
+ }
301
+ throw error;
302
+ }
303
+ finally {
304
+ known.delete(key);
305
+ }
306
+ }
307
+ /** Startup never relaunches a partial worker; it only confirms process termination and retains stages. */
308
+ export async function recoverModelPreparationOperations(state, recovery) {
309
+ for (const { operation, scopeKey } of state.journal.listOperations()) {
310
+ if (operation.kind !== 'model.prepare' || !operation.workspaceId || !['dispatched', 'accepted', 'uncertain'].includes(operation.status))
311
+ continue;
312
+ const id = operation.gatewayOperationId ?? operation.operationId, writer = state.journal.listManagedWrites().find(item => item.writeId === `model-prepare-${id}`);
313
+ let stopped = Boolean(writer?.stoppedAt);
314
+ if (writer && !stopped) {
315
+ if (recovery.confirmStopped)
316
+ stopped = await recovery.confirmStopped(writer);
317
+ else if (writer.executionMode === 'launcher' && recovery.identityMatches && recovery.launcherSocket && writer.launcher && writer.launcher.processId === writer.writeId) {
318
+ if (recovery.previousRuntimeEpoch !== undefined && recovery.runtimeEpoch !== undefined && recovery.runtimeEpoch > recovery.previousRuntimeEpoch && writer.launcher.runtimeEpoch < recovery.runtimeEpoch)
319
+ stopped = true;
320
+ else
321
+ try {
322
+ await stopLauncherExecution(recovery.launcherSocket, writer.launcher);
323
+ stopped = true;
324
+ }
325
+ catch { /* retain uncertain */ }
326
+ }
327
+ else if (writer.executionMode === 'local' && writer.pid) {
328
+ const absent = (pid) => { try {
329
+ process.kill(pid, 0);
330
+ return false;
331
+ }
332
+ catch (error) {
333
+ return error.code === 'ESRCH';
334
+ } };
335
+ stopped = absent(writer.pid) && absent(-writer.pid);
336
+ }
337
+ }
338
+ const binding = await readPreparationJson(state.config.stateDir, `model-preparations/operations/${operation.operationId}.json`);
339
+ const store = binding?.workspaceId === operation.workspaceId && binding.directoryName === 'basic'
340
+ ? new ModelPreparationStore(state.config.stateDir, binding.workspaceId, binding.directoryName, binding.pipelineId, binding.stepId) : undefined;
341
+ const record = await store?.read();
342
+ // onStarted persists the writer before opening its stdin gate; absent writer means it never ran.
343
+ if (!writer)
344
+ stopped = true;
345
+ if (writer && stopped && !writer.stoppedAt)
346
+ await state.journal.putManagedWrite({ ...writer, stoppedAt: new Date().toISOString() });
347
+ if (record?.receipt && stopped) {
348
+ await state.journal.putOperation(scopeKey, transitionOperation(operation, 'completed', { result: record.receipt }));
349
+ continue;
350
+ }
351
+ if (record && store) {
352
+ record.phase = stopped ? 'interrupted' : 'uncertain';
353
+ await store.write(record);
354
+ }
355
+ await state.journal.putOperation(scopeKey, transitionOperation(operation, stopped ? 'interrupted' : 'uncertain', { error: stopped ? 'model_prepare_interrupted' : 'model_prepare_process_unknown' }));
356
+ }
357
+ }
@@ -123,6 +123,7 @@ export async function startPlatformExport(state, input) {
123
123
  commitHash: commit,
124
124
  platforms: input.channels,
125
125
  clientRequestId: input.clientRequestId,
126
+ ...(input.options && Object.keys(input.options).length ? { options: input.options } : {}),
126
127
  },
127
128
  });
128
129
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.1.63",
3
+ "version": "0.1.65",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -53,8 +53,8 @@
53
53
  "@gltf-transform/core": "^4.3.0",
54
54
  "@gltf-transform/extensions": "^4.3.0",
55
55
  "@gltf-transform/functions": "^4.3.0",
56
- "@playcraft/build": "0.1.63",
57
- "@playcraft/common": "0.1.63",
56
+ "@playcraft/build": "0.1.65",
57
+ "@playcraft/common": "0.1.65",
58
58
  "better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
59
59
  "chokidar": "^4.0.3",
60
60
  "commander": "^13.1.0",
@@ -62,6 +62,7 @@
62
62
  "cosmiconfig": "^9.0.0",
63
63
  "dotenv": "^17.2.3",
64
64
  "drizzle-orm": "^0.45.1",
65
+ "earcut": "3.2.4",
65
66
  "execa": "^9.6.1",
66
67
  "express": "^5.2.1",
67
68
  "fluent-ffmpeg": "^2.1.3",
@@ -76,11 +77,13 @@
76
77
  "picocolors": "^1.1.1",
77
78
  "playwright-core": "1.52.0",
78
79
  "sharp": "^0.34.5",
80
+ "three": "0.183.2",
79
81
  "update-notifier": "^7.3.1",
80
82
  "ws": "^8.19.0",
81
83
  "zod": "^3.25.76"
82
84
  },
83
85
  "devDependencies": {
86
+ "@noble/hashes": "2.0.1",
84
87
  "@types/cors": "^2.8.19",
85
88
  "@types/express": "^5.0.6",
86
89
  "@types/fluent-ffmpeg": "^2.1.28",
@@ -91,7 +94,6 @@
91
94
  "@types/update-notifier": "^6.0.8",
92
95
  "@types/ws": "^8.18.1",
93
96
  "esbuild": "^0.28.1",
94
- "three": "^0.183.2",
95
97
  "typescript": "^5.9.3",
96
98
  "vitest": "^3.2.4"
97
99
  }
@@ -30,11 +30,11 @@ triggers: 构建优化,build optimizer,压缩,compress,WebP,pngquant,文件大
30
30
 
31
31
  ```bash
32
32
  # 检查最终输出文件大小
33
- playcraft image info --input dist/index.html --json # 不适用,用 shell
33
+ playcraft image info --input dist/preview/index.html --json # 不适用,用 shell
34
34
 
35
35
  # Shell 方法:检查文件大小(字节)
36
- du -sh dist/index.html
37
- ls -lh dist/index.html
36
+ du -sh dist/preview/index.html
37
+ ls -lh dist/preview/index.html
38
38
 
39
39
  # 计算:若当前原始资产总大小为 X MB
40
40
  # 预计最终 HTML 大小 ≈ X × 1.33(Base64 膨胀)
@@ -112,34 +112,31 @@ grep -r "\.play()" game/ --include="*.js" --include="*.ts"
112
112
 
113
113
  ## 2. playcraft build 优化参数
114
114
 
115
+ - 产物固定为单文件 `dist/preview/index.html`,用于本地 review;在云工作区中它就是产物面板里的可玩预览。
116
+ - 下列参数只影响这份本地预览。渠道包(Meta、Google、AppLovin 等)只能通过平台导出生成,导出不会读取本地 build 的参数,渠道体积以导出时的校验为准。
117
+
115
118
  ### 完整优化构建命令
116
119
 
117
120
  ```bash
118
- # 标准优化构建(Meta 平台)
121
+ # 标准优化构建
119
122
  playcraft build . \
120
- --platform facebook \
121
123
  --compress-images \
122
124
  --image-quality 85 \
123
125
  --convert-webp \
124
126
  --js-minify \
125
- --css-minify \
126
- --output dist/
127
+ --css-minify
127
128
 
128
129
  # 带 3D 模型压缩的构建
129
130
  playcraft build . \
130
- --platform facebook \
131
131
  --compress-images \
132
132
  --image-quality 80 \
133
133
  --convert-webp \
134
134
  --compress-models \
135
135
  --model-compression draco \
136
- --js-minify \
137
- --output dist/
136
+ --js-minify
138
137
 
139
138
  # 分析体积占用(不构建,只分析)
140
- playcraft build . \
141
- --platform facebook \
142
- --analyze
139
+ playcraft build . --analyze
143
140
  ```
144
141
 
145
142
  ### 图片优化参数
@@ -170,25 +167,6 @@ playcraft build . \
170
167
  | `--js-minify` | 启用 JS 压缩(Terser + 混淆) |
171
168
  | `--css-minify` | 启用 CSS 压缩 |
172
169
 
173
- ### 多平台批量构建
174
-
175
- ```bash
176
- # 同时构建多个平台版本
177
- playcraft build-all . \
178
- --platforms facebook,google,applovin \
179
- --compress-images \
180
- --image-quality 85 \
181
- --convert-webp \
182
- --js-minify \
183
- --output dist/
184
-
185
- # 或按平台组
186
- playcraft build-all . \
187
- --group mainstream \ # 主流平台
188
- --compress-images \
189
- --convert-webp
190
- ```
191
-
192
170
  ---
193
171
 
194
172
  ## 3. 正确的优化执行顺序
@@ -207,9 +185,11 @@ playcraft build-all . \
207
185
  ↓
208
186
  5. playcraft build --js-minify --css-minify — 代码压缩
209
187
  ↓
210
- 6. 检查最终文件大小(必须 < 5 MB)
188
+ 6. 检查 dist/preview/index.html 大小(仅供参考,渠道体积以平台导出校验为准)
211
189
  ↓
212
190
  7. 合规验证清单(§1)
191
+ ↓
192
+ 8. 通过平台导出生成各渠道包
213
193
  ```
214
194
 
215
195
  **禁止行为**:
@@ -298,9 +278,9 @@ playcraft 3d optimize \
298
278
 
299
279
  ## 6. 常见审核被拒原因速查
300
280
 
301
- | 被拒原因 | 占比 | 检查方法 | 修复方法 |
302
- | ----------------- | ---- | ------------------------ | ------------------------------------------------------- |
303
- | 文件大小超过 5 MB | 45% | `ls -lh dist/index.html` | `--compress-images --convert-webp` 或降低 image-quality |
304
- | 包含外部请求 | 30% | `grep -r "https://"` | Developer 将外部资源内嵌 |
305
- | CTA 未实现 | 15% | `grep "FbPlayableAd"` | Developer 添加平台 CTA API |
306
- | 音频自动播放 | 10% | `grep "autoplay"` | Developer 改为用户交互触发 |
281
+ | 被拒原因 | 占比 | 检查方法 | 修复方法 |
282
+ | ----------------- | ---- | -------------------------------- | ------------------------------------------------------- |
283
+ | 文件大小超过 5 MB | 45% | `ls -lh dist/preview/index.html` | `--compress-images --convert-webp` 或降低 image-quality |
284
+ | 包含外部请求 | 30% | `grep -r "https://"` | Developer 将外部资源内嵌 |
285
+ | CTA 未实现 | 15% | `grep "FbPlayableAd"` | Developer 添加平台 CTA API |
286
+ | 音频自动播放 | 10% | `grep "autoplay"` | Developer 改为用户交互触发 |
@@ -65,7 +65,7 @@ The artifact panel shows final deliverables under `dist/` at the current project
65
65
 
66
66
  When a generated asset is ready for delivery, save or copy it into `dist/` with a stable relative path. Keep the resources required by a model or document within `dist/` and preserve their relative links. Reuse the same delivery path for a revised artifact so its versions remain grouped; the platform retains its history. Use different paths for independent candidates or deliverables.
67
67
 
68
- Run playable builds from the project root (`playcraft build --mode full --platform playcraft --format html .`). In a workspace this command builds in an isolated copy, validates the result, and retains the playable at `dist/playable/index.html` while preserving other deliverables. A successful agent command or turn that writes a valid self-contained HTML (HyperText Markup Language, 超文本标记语言) file at this same path is collected as the same playable preview. No separate publish action is required to view it. A base build's `dist/index.html` can still require resource files and remains a document. The page's generate-preview action rebuilds the current project through the same validation. After a successful build the command also publishes the playable and prints `Public URL: <address>`; when you report the build, link that address as the playable instead of the sandbox path, since it opens directly and can be shared. If the command prints `Public URL unavailable`, report that the build succeeded without a public address and give the reason it printed. Temporary `.playcraft-preview-out/` and `.playcraft-temp/` files are not deliverables. Preserve other artifacts already in `dist/` when rebuilding a playable.
68
+ Run playable builds from the project root with `playcraft build`. It takes no channel or format options and always produces the review build at `dist/preview/index.html`; channel packages come only from platform export. In a workspace this command builds in an isolated copy, validates the result, and retains the playable at `dist/preview/index.html` while preserving other deliverables. A successful agent command or turn that writes a valid self-contained HTML (HyperText Markup Language, 超文本标记语言) file at this same path is collected as the same playable preview. No separate publish action is required to view it. A base build's `dist/index.html` can still require resource files and remains a document. The page's generate-preview action rebuilds the current project through the same validation. After a successful build the command also publishes the playable and prints `Public URL: <address>`; when you report the build, link that address as the playable instead of the sandbox path, since it opens directly and can be shared. If the command prints `Public URL unavailable`, report that the build succeeded without a public address and give the reason it printed. Temporary `.playcraft-preview-out/` and `.playcraft-temp/` files are not deliverables. Preserve other artifacts already in `dist/` when rebuilding a playable.
69
69
 
70
70
  Keep this convention in `AGENTS.md`, the project's standard instruction filename. Do not create a separate `AGENT.md`.
71
71
 
@@ -30,11 +30,11 @@ triggers: 构建优化,build optimizer,压缩,compress,WebP,pngquant,文件大
30
30
 
31
31
  ```bash
32
32
  # 检查最终输出文件大小
33
- playcraft image info --input dist/index.html --json # 不适用,用 shell
33
+ playcraft image info --input dist/preview/index.html --json # 不适用,用 shell
34
34
 
35
35
  # Shell 方法:检查文件大小(字节)
36
- du -sh dist/index.html
37
- ls -lh dist/index.html
36
+ du -sh dist/preview/index.html
37
+ ls -lh dist/preview/index.html
38
38
 
39
39
  # 计算:若当前原始资产总大小为 X MB
40
40
  # 预计最终 HTML 大小 ≈ X × 1.33(Base64 膨胀)
@@ -112,34 +112,31 @@ grep -r "\.play()" game/ --include="*.js" --include="*.ts"
112
112
 
113
113
  ## 2. playcraft build 优化参数
114
114
 
115
+ - 产物固定为单文件 `dist/preview/index.html`,用于本地 review;在云工作区中它就是产物面板里的可玩预览。
116
+ - 下列参数只影响这份本地预览。渠道包(Meta、Google、AppLovin 等)只能通过平台导出生成,导出不会读取本地 build 的参数,渠道体积以导出时的校验为准。
117
+
115
118
  ### 完整优化构建命令
116
119
 
117
120
  ```bash
118
- # 标准优化构建(Meta 平台)
121
+ # 标准优化构建
119
122
  playcraft build . \
120
- --platform facebook \
121
123
  --compress-images \
122
124
  --image-quality 85 \
123
125
  --convert-webp \
124
126
  --js-minify \
125
- --css-minify \
126
- --output dist/
127
+ --css-minify
127
128
 
128
129
  # 带 3D 模型压缩的构建
129
130
  playcraft build . \
130
- --platform facebook \
131
131
  --compress-images \
132
132
  --image-quality 80 \
133
133
  --convert-webp \
134
134
  --compress-models \
135
135
  --model-compression draco \
136
- --js-minify \
137
- --output dist/
136
+ --js-minify
138
137
 
139
138
  # 分析体积占用(不构建,只分析)
140
- playcraft build . \
141
- --platform facebook \
142
- --analyze
139
+ playcraft build . --analyze
143
140
  ```
144
141
 
145
142
  ### 图片优化参数
@@ -170,25 +167,6 @@ playcraft build . \
170
167
  | `--js-minify` | 启用 JS 压缩(Terser + 混淆) |
171
168
  | `--css-minify` | 启用 CSS 压缩 |
172
169
 
173
- ### 多平台批量构建
174
-
175
- ```bash
176
- # 同时构建多个平台版本
177
- playcraft build-all . \
178
- --platforms facebook,google,applovin \
179
- --compress-images \
180
- --image-quality 85 \
181
- --convert-webp \
182
- --js-minify \
183
- --output dist/
184
-
185
- # 或按平台组
186
- playcraft build-all . \
187
- --group mainstream \ # 主流平台
188
- --compress-images \
189
- --convert-webp
190
- ```
191
-
192
170
  ---
193
171
 
194
172
  ## 3. 正确的优化执行顺序
@@ -207,9 +185,11 @@ playcraft build-all . \
207
185
  ↓
208
186
  5. playcraft build --js-minify --css-minify — 代码压缩
209
187
  ↓
210
- 6. 检查最终文件大小(必须 < 5 MB)
188
+ 6. 检查 dist/preview/index.html 大小(仅供参考,渠道体积以平台导出校验为准)
211
189
  ↓
212
190
  7. 合规验证清单(§1)
191
+ ↓
192
+ 8. 通过平台导出生成各渠道包
213
193
  ```
214
194
 
215
195
  **禁止行为**:
@@ -298,9 +278,9 @@ playcraft 3d optimize \
298
278
 
299
279
  ## 6. 常见审核被拒原因速查
300
280
 
301
- | 被拒原因 | 占比 | 检查方法 | 修复方法 |
302
- | ----------------- | ---- | ------------------------ | ------------------------------------------------------- |
303
- | 文件大小超过 5 MB | 45% | `ls -lh dist/index.html` | `--compress-images --convert-webp` 或降低 image-quality |
304
- | 包含外部请求 | 30% | `grep -r "https://"` | Developer 将外部资源内嵌 |
305
- | CTA 未实现 | 15% | `grep "FbPlayableAd"` | Developer 添加平台 CTA API |
306
- | 音频自动播放 | 10% | `grep "autoplay"` | Developer 改为用户交互触发 |
281
+ | 被拒原因 | 占比 | 检查方法 | 修复方法 |
282
+ | ----------------- | ---- | -------------------------------- | ------------------------------------------------------- |
283
+ | 文件大小超过 5 MB | 45% | `ls -lh dist/preview/index.html` | `--compress-images --convert-webp` 或降低 image-quality |
284
+ | 包含外部请求 | 30% | `grep -r "https://"` | Developer 将外部资源内嵌 |
285
+ | CTA 未实现 | 15% | `grep "FbPlayableAd"` | Developer 添加平台 CTA API |
286
+ | 音频自动播放 | 10% | `grep "autoplay"` | Developer 改为用户交互触发 |