@playcraft/cli 0.1.55-beta → 0.1.56-beta

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 (25) hide show
  1. package/dist/commands/build.js +38 -0
  2. package/dist/workspace-runtime/previews/finalize.js +64 -0
  3. package/dist/workspace-runtime/previews/publish.js +31 -8
  4. package/dist/workspace-runtime/server/http.js +5 -5
  5. package/dist/workspace-runtime/workspaces/artifact-events.js +136 -23
  6. package/dist/workspace-runtime/workspaces/artifact-files.js +3 -1
  7. package/dist/workspace-runtime/workspaces/artifact-receipts.js +27 -4
  8. package/dist/workspace-runtime/workspaces/artifacts.js +115 -17
  9. package/dist/workspace-runtime/workspaces/asset-publication-access.js +5 -1
  10. package/dist/workspace-runtime/workspaces/delivery-build.js +97 -7
  11. package/dist/workspace-runtime/workspaces/directory-access.js +13 -0
  12. package/dist/workspace-runtime/workspaces/git.js +13 -0
  13. package/dist/workspace-runtime/workspaces/parameter-assets.js +38 -15
  14. package/dist/workspace-runtime/workspaces/parameters.js +61 -21
  15. package/dist/workspace-runtime/workspaces/theme-asset-index.js +123 -13
  16. package/package.json +3 -3
  17. package/project-templates/empty/AGENTS.md +1 -1
  18. package/templates/preview-runtime/v1/README.md +8 -0
  19. package/templates/preview-runtime/v1/manifest.json +7 -7
  20. package/templates/preview-runtime/v1/runtime.d.mts +1 -1
  21. package/templates/preview-runtime/v1/runtime.d.ts +1 -1
  22. package/templates/preview-runtime/v1/runtime.mjs +238 -47
  23. package/templates/preview-runtime/v1/types/adapters.d.ts +3 -1
  24. package/templates/preview-runtime/v1/types/preview.d.ts +1 -0
  25. package/templates/preview-runtime/v1/types/protocol.d.ts +37 -0
@@ -3,9 +3,10 @@ import { createHash } from 'node:crypto';
3
3
  import { posix, relative, resolve, sep } from 'node:path';
4
4
  import JSON5 from 'json5';
5
5
  import { parameterDocumentSchema, parameterPatchPayloadSchema, parameterCatalog, validateParameterChange, workspaceRelativePathSchema, WORKSPACE_FILE_CONTENT_MAX, } from '@playcraft/common/cloud-workspace/runtime';
6
- import { InvalidWorkspacePathError, writeWorkspaceFile, } from './files.js';
6
+ import { InvalidWorkspacePathError, writeWorkspaceFile } from './files.js';
7
7
  import { editJson5Paths, ParameterEditError } from './json5-edit.js';
8
- import { prepareParameterAssetRegistration, writeParameterAssetRegistration } from './parameter-assets.js';
8
+ import { prepareParameterAssetRegistration, writeParameterAssetRegistration, } from './parameter-assets.js';
9
+ import { themeAssetIndexFingerprint } from './theme-asset-index.js';
9
10
  const MANIFEST_PATH = '.playcraft/workspace.json';
10
11
  const DEFAULT_SCHEMA_PATH = 'src/theme/theme.schema.json5';
11
12
  const THEME_INDEX_PATH = 'src/theme/index.ts';
@@ -22,7 +23,9 @@ export async function readWorkspaceParameters(root, revisions) {
22
23
  throw new ParameterEditError('parameter_source_changed');
23
24
  }
24
25
  async function sourceFingerprint(root, located, ownIndexWrite) {
25
- const selectedIndex = located.dataPath ? posix.join(posix.dirname(located.dataPath), 'index.ts') : undefined;
26
+ const selectedIndex = located.dataPath
27
+ ? posix.join(posix.dirname(located.dataPath), 'index.ts')
28
+ : undefined;
26
29
  const sources = await Promise.all([
27
30
  located.schemaPath ? readOptionalText(root, located.schemaPath) : undefined,
28
31
  located.schemaPath ? readOptionalText(root, platformI18nPath(located.schemaPath)) : undefined,
@@ -30,20 +33,40 @@ async function sourceFingerprint(root, located, ownIndexWrite) {
30
33
  readOptionalText(root, THEME_INDEX_PATH),
31
34
  selectedIndex ? readOptionalText(root, selectedIndex) : undefined,
32
35
  ]);
33
- const hashes = sources.map(text => text === undefined ? null : createHash('sha256').update(text).digest('hex'));
36
+ const hashes = sources.map((text) => text === undefined ? null : createHash('sha256').update(text).digest('hex'));
37
+ const rawIndexHash = hashes[4];
38
+ if (sources[4] !== undefined) {
39
+ try {
40
+ const data = located.dataPath ? await readOptionalText(root, located.dataPath) : undefined;
41
+ hashes[4] = themeAssetIndexFingerprint(sources[4], themeImageReferences(sources[0] === undefined ? {} : JSON5.parse(sources[0]), data === undefined ? {} : JSON5.parse(data)));
42
+ }
43
+ catch {
44
+ // Invalid configuration cannot authorize a relaxed resource identity.
45
+ hashes[4] = rawIndexHash;
46
+ }
47
+ }
34
48
  if (ownIndexWrite) {
35
49
  // Only our exact staged registry bytes may differ from the baseline. An external edit,
36
50
  // including a schema/manifest change during the write callback, still fails the old guard.
37
- if (selectedIndex !== ownIndexWrite.path || hashes[4] !== ownIndexWrite.contentHash)
51
+ if (selectedIndex !== ownIndexWrite.path || rawIndexHash !== ownIndexWrite.contentHash)
38
52
  throw new ParameterEditError('parameter_source_conflict');
39
- hashes[4] = ownIndexWrite.previousHash;
53
+ hashes[4] = ownIndexWrite.previousFingerprint;
40
54
  }
41
- return createHash('sha256').update(JSON.stringify({
55
+ return createHash('sha256')
56
+ .update(JSON.stringify({
42
57
  schemaPath: located.schemaPath ?? null,
43
58
  dataPath: located.dataPath ?? null,
44
59
  themeId: located.themeId ?? null,
45
60
  sources: hashes,
46
- })).digest('hex');
61
+ }))
62
+ .digest('hex');
63
+ }
64
+ function themeImageReferences(schema, data) {
65
+ return parameterCatalog(schema, data).flatMap((field) => field.asset?.kind === 'image' &&
66
+ typeof field.value === 'string' &&
67
+ field.value.startsWith('./assets/')
68
+ ? [field.value]
69
+ : []);
47
70
  }
48
71
  async function readParameterDocuments(root, revisions, located) {
49
72
  if (located.errors.some((item) => item.reason === 'parameter_manifest_invalid')) {
@@ -79,7 +102,9 @@ async function readParameterDocuments(root, revisions, located) {
79
102
  schema: schemaFile.value,
80
103
  schemaHash: schemaFile.contentHash,
81
104
  schemaRevision: revisions[located.schemaPath]?.revision ?? 0,
82
- ...(platformFile.status === 'ok' ? { platformI18n: platformFile.value, platformI18nPath: i18nPath } : {}),
105
+ ...(platformFile.status === 'ok'
106
+ ? { platformI18n: platformFile.value, platformI18nPath: i18nPath }
107
+ : {}),
83
108
  };
84
109
  if (platformFile.status === 'invalid') {
85
110
  return parameterDocumentSchema.parse({
@@ -128,8 +153,8 @@ export async function patchWorkspaceParameters(root, revisions, payload, observe
128
153
  if (document.status === 'missing' || document.status === 'schema_only' || !document.dataPath) {
129
154
  throw new ParameterEditError('parameter_data_missing');
130
155
  }
131
- if ((parsed.sourceFingerprint && parsed.sourceFingerprint !== document.sourceFingerprint)
132
- || (observed && observed.dataPath !== document.dataPath)) {
156
+ if ((parsed.sourceFingerprint && parsed.sourceFingerprint !== document.sourceFingerprint) ||
157
+ (observed && observed.dataPath !== document.dataPath)) {
133
158
  throw new ParameterEditError('parameter_source_conflict');
134
159
  }
135
160
  const changes = 'changes' in parsed ? parsed.changes : [{ fieldPath: parsed.fieldPath, value: parsed.value }];
@@ -150,27 +175,33 @@ export async function patchWorkspaceParameters(root, revisions, payload, observe
150
175
  throw new ParameterEditError('parameter_document_invalid');
151
176
  }
152
177
  const current = await locateParameterFiles(root);
153
- if (await sourceFingerprint(root, current) !== document.sourceFingerprint) {
178
+ if ((await sourceFingerprint(root, current)) !== document.sourceFingerprint) {
154
179
  throw new ParameterEditError('parameter_source_conflict');
155
180
  }
156
181
  const catalog = parameterCatalog(document.schema, document.data);
157
- const bindings = changes.flatMap(change => {
182
+ const bindings = changes.flatMap((change) => {
158
183
  if (change.op === 'remove' || typeof change.value !== 'string' || !change.value)
159
184
  return [];
160
- const field = catalog.find(entry => entry.key === change.fieldPath.join('.'));
185
+ const field = catalog.find((entry) => entry.key === change.fieldPath.join('.'));
161
186
  return field?.asset ? [{ value: change.value, asset: field.asset }] : [];
162
187
  });
163
- const registration = await prepareParameterAssetRegistration(root, document.dataPath, bindings);
188
+ const registration = await prepareParameterAssetRegistration(root, document.dataPath, bindings, themeImageReferences(document.schema, document.data));
164
189
  // Register first: a failed sparse save can leave an unused asset, never a dangling saved reference.
165
190
  const additionalWrites = [];
166
191
  let ownIndexWrite;
167
192
  if (registration) {
168
193
  const written = await writeParameterAssetRegistration(root, registration);
169
194
  additionalWrites.push(written);
170
- ownIndexWrite = { path: written.path, previousHash: registration.previousHash, contentHash: written.contentHash };
195
+ ownIndexWrite = {
196
+ path: written.path,
197
+ previousHash: registration.previousHash,
198
+ previousFingerprint: registration.previousFingerprint,
199
+ contentHash: written.contentHash,
200
+ };
171
201
  await options.onAdditionalWrite?.(written);
172
202
  }
173
- if (await sourceFingerprint(root, await locateParameterFiles(root), ownIndexWrite) !== document.sourceFingerprint)
203
+ if ((await sourceFingerprint(root, await locateParameterFiles(root), ownIndexWrite)) !==
204
+ document.sourceFingerprint)
174
205
  throw new ParameterEditError('parameter_source_conflict');
175
206
  const latest = await readOptionalText(root, document.dataPath);
176
207
  if (latest !== source)
@@ -232,8 +263,12 @@ function declaredPaths(value) {
232
263
  if (Object.keys(record).some((key) => !['schemaPath', 'dataPath', 'themeId'].includes(key))) {
233
264
  return undefined;
234
265
  }
235
- const schemaPath = record.schemaPath === undefined ? undefined : workspaceRelativePathSchema.safeParse(record.schemaPath);
236
- const dataPath = record.dataPath === undefined ? undefined : workspaceRelativePathSchema.safeParse(record.dataPath);
266
+ const schemaPath = record.schemaPath === undefined
267
+ ? undefined
268
+ : workspaceRelativePathSchema.safeParse(record.schemaPath);
269
+ const dataPath = record.dataPath === undefined
270
+ ? undefined
271
+ : workspaceRelativePathSchema.safeParse(record.dataPath);
237
272
  const themeId = record.themeId === undefined
238
273
  ? undefined
239
274
  : typeof record.themeId === 'string' && record.themeId.trim() && record.themeId.length <= 128
@@ -258,7 +293,8 @@ async function readDeclaredTheme(root) {
258
293
  const text = contents.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
259
294
  const match = text.match(/export\s+\{\s*default\s*\}\s+from\s+['"]\.\/([^'"]+)['"]/);
260
295
  const themeId = match?.[1]?.trim();
261
- return themeId && workspaceRelativePathSchema.safeParse(`${THEME_ROOT}/${themeId}/theme.data.json5`).success
296
+ return themeId &&
297
+ workspaceRelativePathSchema.safeParse(`${THEME_ROOT}/${themeId}/theme.data.json5`).success
262
298
  ? themeId
263
299
  : undefined;
264
300
  }
@@ -292,7 +328,11 @@ async function readOptionalJson5(root, relativePath) {
292
328
  if (text === undefined)
293
329
  return { status: 'missing' };
294
330
  try {
295
- return { status: 'ok', value: JSON5.parse(text), contentHash: createHash('sha256').update(text).digest('hex') };
331
+ return {
332
+ status: 'ok',
333
+ value: JSON5.parse(text),
334
+ contentHash: createHash('sha256').update(text).digest('hex'),
335
+ };
296
336
  }
297
337
  catch {
298
338
  return { status: 'invalid', reason: 'parameter_document_invalid' };
@@ -1,8 +1,11 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import JSON5 from 'json5';
3
+ import { parameterAssetMimeType, parameterAssetValueError, } from '@playcraft/common/cloud-workspace/runtime';
3
4
  export class ThemeAssetIndexError extends Error {
4
5
  code = 'parameter_asset_index_unsupported';
5
- constructor() { super('parameter_asset_index_unsupported'); }
6
+ constructor() {
7
+ super('parameter_asset_index_unsupported');
8
+ }
6
9
  }
7
10
  /** A bounded lexer recognizes the standard literal registry without evaluating project code. */
8
11
  function lex(source) {
@@ -84,9 +87,13 @@ function registry(source) {
84
87
  for (let i = exportStart + 3; i < tokens.length && exportDepth; i++) {
85
88
  const token = tokens[i];
86
89
  const previous = tokens[i - 1]?.text;
87
- const properKey = previous === ':' && (tokens[i - 2]?.text === 'assetRecordByPath' || tokens[i - 2]?.string === 'assetRecordByPath');
88
- if (exportDepth === 1 && token.text === 'assetRecordByPath' && [',', '}'].includes(tokens[i + 1]?.text ?? '')
89
- && (previous === '{' || previous === ',' || properKey))
90
+ const properKey = previous === ':' &&
91
+ (tokens[i - 2]?.text === 'assetRecordByPath' ||
92
+ tokens[i - 2]?.string === 'assetRecordByPath');
93
+ if (exportDepth === 1 &&
94
+ token.text === 'assetRecordByPath' &&
95
+ [',', '}'].includes(tokens[i + 1]?.text ?? '') &&
96
+ (previous === '{' || previous === ',' || properKey))
90
97
  exportsRegistry = true;
91
98
  if (token.string === undefined && token.text === '{')
92
99
  exportDepth++;
@@ -103,13 +110,17 @@ function registry(source) {
103
110
  }
104
111
  if (tokens[++cursor]?.text !== '{')
105
112
  throw new ThemeAssetIndexError();
113
+ const start = tokens[cursor].start;
106
114
  cursor++;
107
115
  const entries = new Map();
108
116
  let lastValueEnd, trailingComma = false;
109
117
  while (tokens[cursor]?.text !== '}') {
110
118
  const key = tokens[cursor], colon = tokens[cursor + 1], value = tokens[cursor + 2];
111
- if (key?.string === undefined || colon?.text !== ':' || !value || !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value.text)
112
- || entries.has(key.string))
119
+ if (key?.string === undefined ||
120
+ colon?.text !== ':' ||
121
+ !value ||
122
+ !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value.text) ||
123
+ entries.has(key.string))
113
124
  throw new ThemeAssetIndexError();
114
125
  entries.set(key.string, value.text);
115
126
  lastValueEnd = value.end;
@@ -125,15 +136,110 @@ function registry(source) {
125
136
  const end = tokens[cursor].start;
126
137
  const imports = new Map();
127
138
  for (let i = 0; i < tokens.length - 3; i++) {
128
- if (tokens[i].text === 'import' && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(tokens[i + 1].text)
129
- && tokens[i + 1].text !== 'type' && tokens[i + 2].text === 'from' && tokens[i + 3].string !== undefined)
139
+ if (tokens[i].text === 'import' &&
140
+ /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(tokens[i + 1].text) &&
141
+ tokens[i + 1].text !== 'type' &&
142
+ tokens[i + 2].text === 'from' &&
143
+ tokens[i + 3].string !== undefined)
130
144
  imports.set(tokens[i + 3].string, tokens[i + 1].text);
131
145
  }
132
- return { end, lastValueEnd, trailingComma, entries, imports, tokens };
146
+ return { start, end, lastValueEnd, trailingComma, entries, imports, tokens };
147
+ }
148
+ /** Only literal, exclusively registered image imports belong to the replaceable resource domain. */
149
+ export function themeAssetIndexFingerprint(source, requiredImageValues = []) {
150
+ const hash = (value) => createHash('sha256').update(value).digest('hex');
151
+ try {
152
+ const state = registry(source), tokens = state.tokens;
153
+ // Mutation, aliases, getters and other registry consumers cannot be separated safely.
154
+ if (tokens.filter((token) => token.string === undefined && token.text === 'assetRecordByPath')
155
+ .length !== 2)
156
+ return hash(source);
157
+ const references = new Map(), imports = new Map();
158
+ const values = new Map();
159
+ for (const token of tokens) {
160
+ if (token.string === undefined)
161
+ references.set(token.text, (references.get(token.text) ?? 0) + 1);
162
+ }
163
+ for (let i = 0; i < tokens.length - 4; i++) {
164
+ if (tokens[i].text !== 'import' ||
165
+ tokens[i + 2].text !== 'from' ||
166
+ tokens[i + 3].string === undefined ||
167
+ tokens[i + 4].text !== ';')
168
+ continue;
169
+ const variable = tokens[i + 1].text, imported = imports.get(variable) ?? [];
170
+ imported.push({ path: tokens[i + 3].string, start: i });
171
+ imports.set(variable, imported);
172
+ }
173
+ for (const [value, variable] of state.entries)
174
+ values.set(variable, [...(values.get(variable) ?? []), value]);
175
+ const imageEntries = new Set();
176
+ const replacements = [];
177
+ for (const [variable, bindings] of imports) {
178
+ if (bindings.length !== 1)
179
+ continue;
180
+ const binding = bindings[0], importPath = binding.path;
181
+ const value = importPath.startsWith('./assets/')
182
+ ? importPath
183
+ : importPath.startsWith('../../assets/')
184
+ ? 'src/' + importPath.slice(6)
185
+ : undefined;
186
+ if (!value)
187
+ continue;
188
+ const mime = parameterAssetMimeType(value);
189
+ if (!mime?.startsWith('image/') ||
190
+ parameterAssetValueError({
191
+ kind: 'image',
192
+ mimeTypes: [mime],
193
+ extensions: [],
194
+ maxBytes: 32 * 1024 * 1024,
195
+ }, value))
196
+ continue;
197
+ const registered = values.get(variable) ?? [];
198
+ if (registered.some((key) => key !== value) ||
199
+ references.get(variable) !== registered.length + 1)
200
+ continue;
201
+ for (const key of registered)
202
+ imageEntries.add(key);
203
+ const start = tokens[binding.start].start;
204
+ let end = tokens[binding.start + 4].end;
205
+ if (source.startsWith('\r\n', end))
206
+ end += 2;
207
+ else if (source[end] === '\n')
208
+ end++;
209
+ replacements.push({ start, end, content: '' });
210
+ }
211
+ const start = tokens.findIndex((token) => token.start === state.start);
212
+ const end = tokens.findIndex((token) => token.start === state.end);
213
+ if (start < 0 || end <= start)
214
+ return hash(source);
215
+ // Shared src/assets defaults belong to the existing build loader. Only theme-relative
216
+ // effective images require this selected theme's literal registry.
217
+ const missingThemeImages = [
218
+ ...new Set(requiredImageValues.filter((value) => value.startsWith('./assets/') && !state.entries.has(value))),
219
+ ].sort();
220
+ replacements.push({
221
+ start: tokens[start].end,
222
+ end: tokens[end].start,
223
+ content: JSON.stringify([...state.entries].filter(([value]) => !imageEntries.has(value))),
224
+ });
225
+ let canonical = source;
226
+ for (const replacement of replacements.sort((a, b) => b.start - a.start))
227
+ canonical =
228
+ canonical.slice(0, replacement.start) +
229
+ replacement.content +
230
+ canonical.slice(replacement.end);
231
+ return hash('theme-asset-code-v1:' + JSON.stringify({ source: canonical, missingThemeImages }));
232
+ }
233
+ catch {
234
+ // Unknown project syntax retains the original strict byte identity.
235
+ return hash(source);
236
+ }
133
237
  }
134
238
  /** Add only literal imports/entries; retain user exports, comments, unrelated mappings and files. */
135
239
  export function registerThemeAsset(source, value, importPath) {
136
- if (!value || !importPath.startsWith('./') && !importPath.startsWith('../') || /[\r\n\x00]/.test(importPath))
240
+ if (!value ||
241
+ (!importPath.startsWith('./') && !importPath.startsWith('../')) ||
242
+ /[\r\n\x00]/.test(importPath))
137
243
  throw new ThemeAssetIndexError();
138
244
  const state = registry(source);
139
245
  const imported = state.imports.get(importPath);
@@ -143,8 +249,11 @@ export function registerThemeAsset(source, value, importPath) {
143
249
  return source;
144
250
  throw new ThemeAssetIndexError();
145
251
  }
146
- const variable = imported ?? '__playcraftParameterAsset_' + createHash('sha256').update(importPath).digest('hex').slice(0, 16);
147
- if (!imported && state.tokens.some(token => token.string === undefined && token.text === variable))
252
+ const variable = imported ??
253
+ '__playcraftParameterAsset_' +
254
+ createHash('sha256').update(importPath).digest('hex').slice(0, 16);
255
+ if (!imported &&
256
+ state.tokens.some((token) => token.string === undefined && token.text === variable))
148
257
  throw new ThemeAssetIndexError();
149
258
  let result = source;
150
259
  let end = state.end;
@@ -152,6 +261,7 @@ export function registerThemeAsset(source, value, importPath) {
152
261
  result = result.slice(0, state.lastValueEnd) + ',' + result.slice(state.lastValueEnd);
153
262
  end++;
154
263
  }
155
- result = result.slice(0, end) + `\n ${JSON.stringify(value)}: ${variable},\n` + result.slice(end);
264
+ result =
265
+ result.slice(0, end) + `\n ${JSON.stringify(value)}: ${variable},\n` + result.slice(end);
156
266
  return (imported ? '' : `import ${variable} from ${JSON.stringify(importPath)};\n`) + result;
157
267
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.1.55-beta",
3
+ "version": "0.1.56-beta",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -52,8 +52,8 @@
52
52
  "@gltf-transform/core": "^4.3.0",
53
53
  "@gltf-transform/extensions": "^4.3.0",
54
54
  "@gltf-transform/functions": "^4.3.0",
55
- "@playcraft/build": "0.1.55-beta",
56
- "@playcraft/common": "0.1.55-beta",
55
+ "@playcraft/build": "0.1.56-beta",
56
+ "@playcraft/common": "0.1.56-beta",
57
57
  "better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
58
58
  "chokidar": "^4.0.3",
59
59
  "commander": "^13.1.0",
@@ -46,7 +46,7 @@ The artifact panel shows final deliverables under `dist/` at the current project
46
46
 
47
47
  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.
48
48
 
49
- Run playable builds from the project root and place their final output under `dist/` (`playcraft build --mode full --platform playcraft --format html --output ./dist .`). A base build's `dist/index.html` can still require resource files; it is not proof of a self-contained playable. The workspace's playable publish action validates its isolated temporary build, then retains the exact single-file result at `dist/playable/index.html`. Temporary `.playcraft-preview-out/` and `.playcraft-temp/` files are not deliverables. Preserve other artifacts already in `dist/` when rebuilding a playable.
49
+ 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. Public sharing is a separate explicit action. Temporary `.playcraft-preview-out/` and `.playcraft-temp/` files are not deliverables. Preserve other artifacts already in `dist/` when rebuilding a playable.
50
50
 
51
51
  Keep this convention in `AGENTS.md`, the project's standard instruction filename. Do not create a separate `AGENT.md`.
52
52
 
@@ -14,6 +14,14 @@ Create your own `src/preview/runtime.ts`. Import `connectPreviewRuntime` and `re
14
14
  - `createGame(context)` returns actual `apply(context)`, `restartScene(context)`, `afterRender(context)` and `dispose()` callbacks. Check `context.assertCurrent()` and honor `context.signal` before late effects. `afterRender` must wait for real rendering, not a successful placeholder.
15
15
  - Dispose the endpoint on page exit. Advertising SDK configuration remains the production concern; do not route development updates through advertising caches.
16
16
 
17
+ ## Runtime image parameters
18
+
19
+ Register an image consumer with `resource: 'image'` and `mode: 'live'`. Keep its logical asset path in a preview-only resolved property used by `effectivePath`; do not compare an embedded image address with the raw parameter path. `context.loadImage(field, logicalPath)` requests the current declared image through the host and returns a decoded image element. Empty paths clear the consumer without reading a file. The host validates the field against the current schema and configuration; the game receives image bytes, never platform file addresses or credentials.
20
+
21
+ Prepare all required images before changing visible objects, call `assertCurrent()` after asynchronous work and immediately before installing textures, then wait for the real engine frame. Keep old objects intact until preparation succeeds. Release old preview-owned textures and overlay references after a successful replacement and on disposal. Never remove an engine texture still used by another object.
22
+
23
+ `ResourceLoadError` is exported from `runtime.mjs`. Live adapters let this error reach the runtime, which retains the last known installation and reports a recoverable, field-level resource failure. Do not catch an arbitrary update exception and claim successful fallback. During initial `createGame`, catch only this load error to start with an embedded default image or empty background; the runtime still reports the saved image as unapplied. A later patch of that field retries it; unrelated parameter updates can continue. `resourceErrors` and file-save receipts are independent. Save logical paths and let the original production asset registry include the file when building delivery output.
24
+
17
25
  ## Preview build
18
26
 
19
27
  The engineering project must install a compatible Vite as a development dependency. Set a separate package script:
@@ -4,14 +4,14 @@
4
4
  "protocolVersion": 1,
5
5
  "files": {
6
6
  "build-preview.mjs": "2bd512675d829cc07ceddcb58365a6f74d36ff93690f8d72f4bbaa1d45511c91",
7
- "README.md": "6b248413bf2a3f096796b9aaf8fe77bc4aa01d862d47337be85af52a8b02e021",
7
+ "README.md": "f104440b3acb23adb88bde0dff23840fa170a14f477d1d7338575eca75e73571",
8
8
  "release-guard.mjs": "0c33d1559c68844f3760c3e754ee36ca70dc27151ef6f7ad62f29f95239679cc",
9
- "runtime.d.mts": "03c2a2453ca136c7e21a4a43e3320d306d06b52cf8d46d9936c998cc98d754ad",
10
- "runtime.d.ts": "03c2a2453ca136c7e21a4a43e3320d306d06b52cf8d46d9936c998cc98d754ad",
11
- "runtime.mjs": "5a7efbfe4f6a7b020f112f0ef88bb4d1f9812a629ea8c2bc563f8a738d055bc5",
12
- "types/adapters.d.ts": "785df9c5795e5e463ddf84f1acfea0eeebbfef631f19ddb5b49f1dabc7f0dba3",
13
- "types/preview.d.ts": "1649e0a576a700263170d82addf6c0c7ed59e23767851241d054fc15928e044f",
14
- "types/protocol.d.ts": "7af05fff6758df872efa3c6fd1310b5bdd65f03969dcaa361814d47881fcd45a",
9
+ "runtime.d.mts": "a366552166a4a93103306997058951416740eaf6d6f3d0a34854386b4f1e02f5",
10
+ "runtime.d.ts": "a366552166a4a93103306997058951416740eaf6d6f3d0a34854386b4f1e02f5",
11
+ "runtime.mjs": "25b33218ff32dfeaaad7bcd69647cbf662fa4312d49b62724a2104fd27d9b001",
12
+ "types/adapters.d.ts": "29b5c18d4576ab00eca44875b5c4eae2fc0e0ed6ab5595dbbaf6ba9b9fd8beed",
13
+ "types/preview.d.ts": "19ea55ed6faf4faa4f4fc62a2d70309d7ba917fc165c2c149856798e1b4197f8",
14
+ "types/protocol.d.ts": "716e83802336c4e7377abc1b433c77045ed59997a45646fe2fdbe55cb82c5e62",
15
15
  "types/runtime.d.ts": "efcd429b24d75b06afb3f05ec3f8134efc381ede0dd59cb43ec5770e2559c77b"
16
16
  }
17
17
  }
@@ -1,3 +1,3 @@
1
- export { connectPreviewRuntime, readWorkspacePreviewContext } from './types/preview.js';
1
+ export { connectPreviewRuntime, readWorkspacePreviewContext, ResourceLoadError } from './types/preview.js';
2
2
  export type * from './types/adapters.js';
3
3
  export type * from './types/protocol.js';
@@ -1,3 +1,3 @@
1
- export { connectPreviewRuntime, readWorkspacePreviewContext } from './types/preview.js';
1
+ export { connectPreviewRuntime, readWorkspacePreviewContext, ResourceLoadError } from './types/preview.js';
2
2
  export type * from './types/adapters.js';
3
3
  export type * from './types/protocol.js';