@playcraft/cli 0.1.62-beta → 0.1.64

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.
@@ -5,18 +5,19 @@ import { constants } from 'node:fs';
5
5
  import { lstat, mkdtemp, open, readdir, rm } from 'node:fs/promises';
6
6
  import { tmpdir } from 'node:os';
7
7
  import { join } from 'node:path';
8
- import { PREVIEW_HTML_MAX_BYTES, PREVIEW_LOG_TAIL_MAX, WORKSPACE_ARTIFACT_OUTPUT_DIRECTORY, previewPublishResultSchema, previewReservationSchema, transitionOperation, } from '@playcraft/common/cloud-workspace/runtime';
8
+ import { PREVIEW_HTML_MAX_BYTES, PREVIEW_LOG_TAIL_MAX, WORKSPACE_ARTIFACT_OUTPUT_DIRECTORY, WORKSPACE_PLAYABLE_OUTPUT_PATH, workspaceModelBuildSchema, previewPublishResultSchema, previewReservationSchema, transitionOperation, } from '@playcraft/common/cloud-workspace/runtime';
9
9
  import { excludeFromGit, readDependencies, scheduleDependencyInstall, } from '../workspaces/dependencies.js';
10
10
  import { runDeveloperCommand } from '../workspaces/developer-command.js';
11
11
  import { revisionScope, workspaceRoot } from '../workspaces/registry.js';
12
12
  import { classifyPreviewOutput, findExternalLocalReferences } from './single-file.js';
13
- import { finalizeWorkspacePlayable, workspacePlayableSignature } from './finalize.js';
13
+ import { finalizeWorkspacePlayable, readWorkspacePlayable, workspacePlayableSignature } from './finalize.js';
14
14
  import { refreshWorkspaceArtifacts } from '../workspaces/artifact-events.js';
15
15
  import { isolateWorkspaceForBuild } from '../workspaces/delivery-build.js';
16
16
  import { openScratchToDevelopment } from '../workspaces/directory-access.js';
17
17
  import { injectPreviewDiagnostics } from './diagnostics.js';
18
18
  import { OperationLogStore } from '../workspaces/operation-log.js';
19
19
  import { prepareRuntimePreview, RuntimePreviewBuildError } from './runtime-preview.js';
20
+ import { readWorkspaceModelBuildInputs, verifyWorkspaceModelBuildInputs, markWorkspaceModelBuild } from '../workspaces/model-build-inputs.js';
20
21
  import { driftScope, permissionDriftFromOutput, repairWorkspaceShares, } from '../workspaces/workspace-shares.js';
21
22
  const BUILD_TIMEOUT_MS = 10 * 60_000;
22
23
  /** Build output and the builder's own scratch folder live in the checkout, hidden from Git. */
@@ -86,7 +87,10 @@ export async function runPreviewPublish(state, operation, root) {
86
87
  await operationLog.append(operation.operationId, `$ preview build\n`).catch(() => undefined);
87
88
  try {
88
89
  await ensureDependencies(state, revisionScope(state, workspaceId, operation.directoryName) ?? workspaceId, root, output);
90
+ const modelInputs = await readWorkspaceModelBuildInputs(root, state.config.stateDir);
89
91
  const runtimePreview = await prepareRuntimePreview(state, operation, root);
92
+ if (modelInputs && runtimePreview)
93
+ throw new PreviewFailure('model_build_runtime_preview_unsupported');
90
94
  remotePreview = Boolean(runtimePreview);
91
95
  if (runtimePreview)
92
96
  await excludeFromGit(root, '/.playcraft-editor-preview/');
@@ -100,11 +104,31 @@ export async function runPreviewPublish(state, operation, root) {
100
104
  await openScratchToDevelopment(buildCopy, (await lstat(root)).gid);
101
105
  const project = join(buildCopy, 'project'), staged = join(buildCopy, 'output');
102
106
  await isolateWorkspaceForBuild(root, project);
107
+ if (modelInputs)
108
+ await verifyWorkspaceModelBuildInputs(project, modelInputs);
103
109
  await buildPreview(state, project, staged, operation.operationId, output, appendLog);
104
- await finalizeWorkspacePlayable(root, await readSingleHtml(staged), 'preview.publish', {
110
+ if (modelInputs)
111
+ await verifyWorkspaceModelBuildInputs(project, modelInputs);
112
+ if (modelInputs)
113
+ await verifyWorkspaceModelBuildInputs(root, modelInputs);
114
+ const built = await readSingleHtml(staged);
115
+ const finalBytes = modelInputs ? markWorkspaceModelBuild(built, modelInputs) : built;
116
+ await finalizeWorkspacePlayable(root, finalBytes, 'preview.publish', {
105
117
  expectedSignature: signature, source: { operationId: operation.operationId, ...(operation.sessionId ? { sessionId: operation.sessionId } : {}) },
106
118
  });
107
- await refreshWorkspaceArtifacts(state, workspaceId, undefined, operation.directoryName ?? 'basic');
119
+ if (modelInputs) {
120
+ await verifyWorkspaceModelBuildInputs(root, modelInputs);
121
+ const finalized = await readWorkspacePlayable(root);
122
+ if (!finalized.bytes.equals(injectPreviewDiagnostics(finalBytes)))
123
+ throw new PreviewFailure('model_build_output_changed');
124
+ const modelBuild = workspaceModelBuildSchema.parse({ ...modelInputs, buildOperationId: operation.gatewayOperationId ?? operation.operationId,
125
+ outputHash: createHash('sha256').update(finalized.bytes).digest('hex') });
126
+ await refreshWorkspaceArtifacts(state, workspaceId, { source: { kind: 'generated', tool: 'preview.publish', operationId: operation.operationId,
127
+ ...(operation.sessionId ? { sessionId: operation.sessionId } : {}) }, paths: [WORKSPACE_PLAYABLE_OUTPUT_PATH],
128
+ playable: { mode: 'success', modelBuild } }, operation.directoryName ?? 'basic');
129
+ }
130
+ else
131
+ await refreshWorkspaceArtifacts(state, workspaceId, undefined, operation.directoryName ?? 'basic');
108
132
  return;
109
133
  }
110
134
  await buildPreview(state, root, outputDir, operation.operationId, output, appendLog, runtimePreview.command);
@@ -3,12 +3,13 @@ import { constants } from 'node:fs';
3
3
  import { lstat, mkdir, open, readdir, rename, unlink } from 'node:fs/promises';
4
4
  import { basename, join } from 'node:path';
5
5
  import sharp from 'sharp';
6
- import { WORKSPACE_ARTIFACT_OUTPUT_DIRECTORY, isWorkspaceArtifactOutputPath, isWorkspaceArtifactOutputVersion, workspaceArtifactContentQuerySchema, workspaceArtifactListSchema, } from '@playcraft/common/cloud-workspace/runtime';
6
+ import { WORKSPACE_ARTIFACT_OUTPUT_DIRECTORY, isWorkspaceArtifactOutputPath, isWorkspaceArtifactOutputVersion, workspaceArtifactContentQuerySchema, workspaceArtifactListSchema, workspaceModelBuildSchema, } from '@playcraft/common/cloud-workspace/runtime';
7
7
  import { artifactBundleDigest, artifactDirectory, artifactFormat, artifactPath, artifactResources, snapshotArtifactBundle, snapshotArtifactFile } from './artifact-files.js';
8
8
  import { ARTIFACT_RECEIPTS_DIRECTORY, readArtifactReceipt } from './artifact-receipts.js';
9
9
  import { ensureSharedDirectory } from './shared-write.js';
10
10
  import { validateWorkspacePlayable } from '../previews/finalize.js';
11
11
  import { PREVIEW_HTML_MAX_BYTES, WORKSPACE_PLAYABLE_OUTPUT_PATH } from '@playcraft/common/cloud-workspace/runtime';
12
+ import { applyWorkspaceModelBuildStates, sameWorkspaceModelBuild } from './model-build-inputs.js';
12
13
  const serial = new Map();
13
14
  function digest(text) { return createHash('sha256').update(text).digest('hex'); }
14
15
  function artifactId(workspaceId, path) { return `artifact_${digest(`${workspaceId}\0${path}`).slice(0, 40)}`; }
@@ -86,7 +87,8 @@ export class WorkspaceArtifactStore {
86
87
  const task = previous.catch(() => undefined).then(() => this.scan(root, source, sourcePaths, playable));
87
88
  serial.set(key, task);
88
89
  try {
89
- return await task;
90
+ const result = await task;
91
+ return { ...result, list: await applyWorkspaceModelBuildStates(root, this.stateDir, result.list) };
90
92
  }
91
93
  finally {
92
94
  if (serial.get(key) === task)
@@ -214,6 +216,17 @@ export class WorkspaceArtifactStore {
214
216
  }
215
217
  catch { /* Files being written or temporarily missing are retried, never published as complete. */ }
216
218
  }
219
+ if (playable.modelBuild) {
220
+ const modelBuild = workspaceModelBuildSchema.parse(playable.modelBuild);
221
+ const files = await snapshotArtifactBundle(root, WORKSPACE_PLAYABLE_OUTPUT_PATH, objects);
222
+ const primary = files[0];
223
+ if (primary.sha256 !== modelBuild.outputHash || source.kind !== 'generated' || !source.operationId)
224
+ throw new Error('artifact_model_build_mismatch');
225
+ const version = { id: `version_${digest(`${modelBuild.buildOperationId}\0${modelBuild.inputHash}\0${primary.sha256}`).slice(0, 48)}`,
226
+ createdAt: new Date().toISOString(), path: WORKSPACE_PLAYABLE_OUTPUT_PATH, mimeType: primary.mimeType,
227
+ size: primary.size, sha256: primary.sha256, files, source, modelBuild };
228
+ await this.add(catalog, version, await this.versionKind(objects, version), objects);
229
+ }
217
230
  catalog.artifacts = catalog.artifacts.filter(artifact => {
218
231
  const current = artifact.versions.find(version => version.id === artifact.currentVersionId);
219
232
  return !isWorkspaceArtifactOutputVersion(current) || current.source.kind !== 'discovered' || !members.has(current.path);
@@ -267,7 +280,8 @@ export class WorkspaceArtifactStore {
267
280
  if (this.directoryName !== 'basic')
268
281
  version.directoryName = this.directoryName;
269
282
  const current = artifact.versions.find(item => item.id === artifact.currentVersionId);
270
- if (kind === 'playable' && current && artifactBundleDigest(current.files) === artifactBundleDigest(version.files))
283
+ if (kind === 'playable' && current && artifactBundleDigest(current.files) === artifactBundleDigest(version.files) &&
284
+ (!version.modelBuild || sameWorkspaceModelBuild(current.modelBuild, version.modelBuild)))
271
285
  return;
272
286
  if (!artifact.versions.some(item => item.id === version.id)) {
273
287
  // Display ordering is monotonic; the original generation time stays in source.generatedAt.
@@ -0,0 +1,201 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { posix } from 'node:path';
3
+ import { WORKSPACE_MODEL_BUILD_INPUTS_PATH, workspaceModelBuildInputsSchema, workspaceRelativePathSchema, workspaceModelPreviewParametersSchema, } from '@playcraft/common/cloud-workspace/runtime';
4
+ import { checkedFile, dependencyHash } from './procedural-3d/files.js';
5
+ import { canonicalJson } from './procedural-3d/review-contracts.js';
6
+ import { readProceduralModelCandidates } from './procedural-3d/read-candidates.js';
7
+ import { ModelPreviewStore } from './procedural-3d/model-preview-store.js';
8
+ const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
9
+ const sorted = (files) => [...files].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
10
+ const fileHash = (files) => digest(canonicalJson(sorted(files)));
11
+ const MAX_INPUT_BYTES = 256 * 1024 * 1024;
12
+ const MAX_INPUT_FILES = 512;
13
+ function unique(files) {
14
+ const byPath = new Map();
15
+ for (const file of files) {
16
+ const previous = byPath.get(file.path);
17
+ if (previous && previous.contentHash !== file.contentHash)
18
+ throw new Error('model_build_input_conflict');
19
+ byPath.set(file.path, file);
20
+ }
21
+ if (byPath.size > 256)
22
+ throw new Error('model_build_input_limit');
23
+ return sorted([...byPath.values()]);
24
+ }
25
+ function resourcePaths(path, bytes) {
26
+ let json = bytes;
27
+ if (/\.glb$/i.test(path)) {
28
+ if (bytes.length < 20 || bytes.readUInt32LE(0) !== 0x46546c67 || bytes.readUInt32LE(4) !== 2 ||
29
+ bytes.readUInt32LE(8) !== bytes.length || bytes.readUInt32LE(16) !== 0x4e4f534a)
30
+ throw new Error('model_build_resource_invalid');
31
+ const size = bytes.readUInt32LE(12);
32
+ if (size < 2 || size > 8 * 1024 * 1024 || size + 20 > bytes.length)
33
+ throw new Error('model_build_resource_invalid');
34
+ json = bytes.subarray(20, 20 + size);
35
+ }
36
+ if (json.length > 8 * 1024 * 1024)
37
+ throw new Error('model_build_resource_invalid');
38
+ let document;
39
+ try {
40
+ document = JSON.parse(json.toString('utf8').replace(/\0+$/, ''));
41
+ }
42
+ catch {
43
+ throw new Error('model_build_resource_invalid');
44
+ }
45
+ if (!document || document.asset?.version !== '2.0' ||
46
+ document.buffers !== undefined && !Array.isArray(document.buffers) || document.images !== undefined && !Array.isArray(document.images))
47
+ throw new Error('model_build_resource_invalid');
48
+ const paths = new Set([path]);
49
+ for (const item of [...document.buffers ?? [], ...document.images ?? []]) {
50
+ if (item?.uri === undefined)
51
+ continue;
52
+ if (typeof item.uri !== 'string')
53
+ throw new Error('model_build_resource_invalid');
54
+ if (item.uri.startsWith('data:'))
55
+ continue;
56
+ if (/^[a-z][a-z\d+.-]*:/i.test(item.uri) || /[\\?#]/.test(item.uri) || item.uri.startsWith('/'))
57
+ throw new Error('model_build_external_resource');
58
+ let decoded;
59
+ try {
60
+ decoded = decodeURIComponent(item.uri);
61
+ }
62
+ catch {
63
+ throw new Error('model_build_resource_invalid');
64
+ }
65
+ const relative = posix.normalize(posix.join(posix.dirname(path), decoded));
66
+ if (!workspaceRelativePathSchema.safeParse(relative).success || /^[a-z][a-z\d+.-]*:/i.test(decoded) || /[\\?#]/.test(decoded) || decoded.startsWith('/'))
67
+ throw new Error('model_build_resource_invalid');
68
+ paths.add(relative);
69
+ }
70
+ return [...paths].sort();
71
+ }
72
+ /** Capture declared inputs from stable files; project declarations cannot assert host verification. */
73
+ export async function readWorkspaceModelBuildInputs(root, stateDir) {
74
+ let manifest;
75
+ try {
76
+ manifest = await checkedFile(root, WORKSPACE_MODEL_BUILD_INPUTS_PATH, 512 * 1024);
77
+ }
78
+ catch (error) {
79
+ if (error.code === 'ENOENT')
80
+ return undefined;
81
+ throw error;
82
+ }
83
+ let declaration;
84
+ try {
85
+ declaration = workspaceModelBuildInputsSchema.parse(JSON.parse(manifest.toString('utf8')));
86
+ }
87
+ catch {
88
+ throw new Error('model_build_manifest_invalid');
89
+ }
90
+ const cache = new Map();
91
+ let bytesRead = manifest.length;
92
+ const read = async (path, expected) => {
93
+ let bytes = cache.get(path);
94
+ if (!bytes) {
95
+ if (cache.size >= MAX_INPUT_FILES || bytesRead >= MAX_INPUT_BYTES)
96
+ throw new Error('model_build_input_limit');
97
+ bytes = await checkedFile(root, path, Math.min(64 * 1024 * 1024, MAX_INPUT_BYTES - bytesRead));
98
+ cache.set(path, bytes);
99
+ bytesRead += bytes.length;
100
+ }
101
+ const contentHash = digest(bytes);
102
+ if (expected !== undefined && expected !== contentHash)
103
+ throw new Error('model_build_input_changed');
104
+ return { bytes, file: { path, contentHash } };
105
+ };
106
+ const candidates = declaration.models.some(model => model.source.kind === 'procedural')
107
+ ? await readProceduralModelCandidates(root, stateDir) : undefined;
108
+ const models = [];
109
+ for (const model of declaration.models) {
110
+ const inputs = [];
111
+ const adaptations = [];
112
+ for (const file of model.adaptationFiles)
113
+ adaptations.push((await read(file.path, file.contentHash)).file);
114
+ const source = model.source;
115
+ if (source.kind === 'resource') {
116
+ const primary = await read(source.path, source.contentHash);
117
+ if (JSON.stringify(resourcePaths(source.path, primary.bytes)) !== JSON.stringify(source.files.map(file => file.path).sort()))
118
+ throw new Error('model_build_resource_manifest_mismatch');
119
+ for (const file of source.files)
120
+ inputs.push((await read(file.path, file.contentHash)).file);
121
+ }
122
+ else {
123
+ const candidate = candidates?.candidates.find(item => item.runId === source.runId && item.candidateId === source.candidateId);
124
+ if (!candidate || candidate.integrity !== 'verified' || candidate.sourceHash !== source.sourceHash ||
125
+ candidate.dependencyHash !== source.dependencyHash)
126
+ throw new Error('model_build_source_unavailable');
127
+ const preview = await new ModelPreviewStore(stateDir, root).content(source.previewId);
128
+ if (preview.record.runId !== source.runId || preview.record.candidateId !== source.candidateId ||
129
+ preview.record.sourceHash !== source.sourceHash || preview.record.dependencyHash !== source.dependencyHash ||
130
+ preview.record.contentHash !== source.contentHash || preview.record.parameterHash !== source.parameterHash || !preview.record.parameters)
131
+ throw new Error('model_build_source_changed');
132
+ const prefix = `.playcraft/procedural-3d/runs/${source.runId}/candidates/${source.candidateId}/source/`;
133
+ for (const file of candidate.files) {
134
+ if (!file.path.startsWith(prefix))
135
+ throw new Error('model_build_source_changed');
136
+ const path = file.path.slice(prefix.length);
137
+ if (path.startsWith('__playcraft-review/'))
138
+ continue;
139
+ inputs.push((await read(posix.join(source.sourceRoot, path), file.contentHash)).file);
140
+ }
141
+ const parameters = await read(source.parametersPath);
142
+ try {
143
+ const values = workspaceModelPreviewParametersSchema.parse(JSON.parse(parameters.bytes.toString('utf8')));
144
+ if (canonicalJson(values) !== canonicalJson(preview.record.parameters))
145
+ throw new Error('mismatch');
146
+ }
147
+ catch {
148
+ throw new Error('model_build_parameters_changed');
149
+ }
150
+ adaptations.push(parameters.file);
151
+ }
152
+ const adaptationFiles = unique(adaptations);
153
+ models.push({ source, adaptationFiles, adaptationHash: fileHash(adaptationFiles), inputs: unique([...inputs, ...adaptationFiles]) });
154
+ }
155
+ const manifestHash = digest(manifest);
156
+ return { schemaVersion: 1, manifestPath: WORKSPACE_MODEL_BUILD_INPUTS_PATH, manifestHash,
157
+ inputHash: digest(canonicalJson({ manifestHash, models })), hostVerified: true, behaviorValidation: 'pending', models };
158
+ }
159
+ /** Validate the isolated copy and later the live inputs against this build's frozen snapshot. */
160
+ export async function verifyWorkspaceModelBuildInputs(root, snapshot) {
161
+ if (digest(await checkedFile(root, snapshot.manifestPath, 512 * 1024)) !== snapshot.manifestHash)
162
+ throw new Error('model_build_input_changed');
163
+ const files = unique(snapshot.models.flatMap(model => model.inputs));
164
+ let bytesRead = 0;
165
+ for (const file of files) {
166
+ if (bytesRead >= MAX_INPUT_BYTES)
167
+ throw new Error('model_build_input_limit');
168
+ const bytes = await checkedFile(root, file.path, Math.min(64 * 1024 * 1024, MAX_INPUT_BYTES - bytesRead));
169
+ bytesRead += bytes.length;
170
+ if (digest(bytes) !== file.contentHash)
171
+ throw new Error('model_build_input_changed');
172
+ }
173
+ for (const model of snapshot.models)
174
+ if (model.source.kind === 'procedural' &&
175
+ await dependencyHash(root) !== model.source.dependencyHash)
176
+ throw new Error('model_build_input_changed');
177
+ }
178
+ /** The marker makes otherwise identical HTML from different input declarations distinct artifacts. */
179
+ export function markWorkspaceModelBuild(bytes, snapshot) {
180
+ const html = bytes.toString('utf8').replace(/^<!--playcraft-model-inputs:v1:[a-f0-9]{64}:[a-f0-9]{64}-->\n/, '');
181
+ return Buffer.from(`<!--playcraft-model-inputs:v1:${snapshot.manifestHash}:${snapshot.inputHash}-->\n${html}`);
182
+ }
183
+ /** A current claim is calculated for this live read, never persisted as historical truth. */
184
+ export async function applyWorkspaceModelBuildStates(root, stateDir, list) {
185
+ if (!list.artifacts.some(artifact => artifact.versions.some(version => version.modelBuild)))
186
+ return list;
187
+ let current, unavailable = false;
188
+ try {
189
+ current = await readWorkspaceModelBuildInputs(root, stateDir);
190
+ }
191
+ catch (error) {
192
+ unavailable = error instanceof Error && /unavailable|limit|EACCES|EPERM/.test(error.message);
193
+ }
194
+ return { ...list, artifacts: list.artifacts.map(artifact => ({ ...artifact, versions: artifact.versions.map(version => version.modelBuild
195
+ ? { ...version, modelBuildState: unavailable ? 'unavailable' : current &&
196
+ current.manifestHash === version.modelBuild.manifestHash && current.inputHash === version.modelBuild.inputHash ? 'current' : 'stale' }
197
+ : version) })) };
198
+ }
199
+ export function sameWorkspaceModelBuild(left, right) {
200
+ return left?.manifestHash === right?.manifestHash && left?.inputHash === right?.inputHash && left?.outputHash === right?.outputHash;
201
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.1.62-beta",
3
+ "version": "0.1.64",
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.62-beta",
57
- "@playcraft/common": "0.1.62-beta",
56
+ "@playcraft/build": "0.1.64",
57
+ "@playcraft/common": "0.1.64",
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",
@@ -81,6 +82,7 @@
81
82
  "zod": "^3.25.76"
82
83
  },
83
84
  "devDependencies": {
85
+ "@noble/hashes": "2.0.1",
84
86
  "@types/cors": "^2.8.19",
85
87
  "@types/express": "^5.0.6",
86
88
  "@types/fluent-ffmpeg": "^2.1.28",
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  name: playcraft-runtime-parameters
3
- description: Initialize or extend configurable parameters in a Cloud Workspace game by extracting real controls, connecting production consumers and a separate development preview, and generating the platform parameter panel from the project declarations.
3
+ description: Initialize or extend configurable parameters in a Cloud Workspace game by extracting real controls and player-visible text, connecting production consumers and a separate development preview, and generating the platform parameter panel from the project declarations.
4
4
  compatibility: agent,opencode
5
- version: 1.0.0
5
+ version: 1.1.0
6
6
  surfaces:
7
7
  - sandbox-agent
8
8
  ---
@@ -21,9 +21,19 @@ Create or extend the schema, sparse theme overrides and platform labels. Connect
21
21
 
22
22
  For each consumed field, select an honest application mode: `live` for an in-place change, `restart-scene` for a change requiring a new game or scene, or `reload` when a new build is necessary. Register only real consumers. At least one meaningful live consumer should be connected when the game supports it; do not mark every field live to make initialization appear successful. Reuse the existing game's start/reset/dispose lifecycle for a restart.
23
23
 
24
+ ## Localize player-visible text
25
+
26
+ Games usually ship literal strings. By default, initialization also moves a small set of the text a player really sees into platform runtime text, so the panel can edit it per language and the preview can switch its language. Pick the most visible lines (the title, the primary call to action, key instructions or result messages), at most eight keys; text keys count as candidates in the same bounded first group. Author them with the text rules in the parameter protocol and register them as described in the integration reference.
27
+
28
+ - Extract only text that a real consumer displays and that can be refreshed in place. Skip text baked into images, identifiers, file names, analytics events and any string the game compares or parses.
29
+ - Keep the English default identical to what the game displayed before the change, and write real translations for the other seventeen languages. Keep placeholders and format tokens (for example `[remaining]` or `{count}`) unchanged in every language, and keep existing pluralization or ordering logic working.
30
+ - A game that already localizes its text keeps its own keys, files and language detection; do not add a parallel text system beside it.
31
+ - Finish a text candidate completely (shared resolver, production and preview consumers, tests) or do not add it. An unfinished candidate keeps the whole initialization from verifying, so leave text you cannot finish unchanged and say so in the final response instead of reporting it as blocked.
32
+ - If the game cannot refresh any text in place, keep its text unchanged rather than registering a language switch that does not work.
33
+
24
34
  ## Validate the generated project
25
35
 
26
- Add or extend `test:parameters` with executable checks for defaults, sparse overrides and changed real-consumer behavior. Keep or restore a successful production `build`; retain an existing `typecheck`. Use the separate preview entry and production release boundary described in the integration reference.
36
+ Add or extend `test:parameters` with executable checks for defaults, sparse overrides and changed real-consumer behavior. For text, also check that every referenced key has all eighteen languages in the global file, that a theme entry wins over the global one, that a blank string stays blank, that a missing language never borrows English, and that production and preview resolve the same text. Keep or restore a successful production `build`; retain an existing `typecheck`. Use the separate preview entry and production release boundary described in the integration reference.
27
37
 
28
38
  The platform runs its own checks, builds the first development preview and derives capabilities from the real handshake. Report concrete incomplete work when the project cannot be adapted; do not fabricate receipts, successful builds or live capability. Preserve reviewable partial changes on failure.
29
39
 
@@ -19,3 +19,15 @@ Image, video and other resource leaves retain `xplatform.value_type: "path"` plu
19
19
  Game text retains `xplatform.value_type: "i18n"` and an `i18n.runtime.<key>` reference. Keep global `i18n.runtime.json5` and per-theme `theme.i18n.runtime.json5` semantics and export the text from the real theme entry used in production. Supported languages are `en`, `zh`, `zh-TW`, `de`, `fr`, `es`, `ja`, `ko`, `pt`, `it`, `hi`, `bn`, `ur`, `ar`, `tr`, `ru`, `ms`, `id`. Keep complete language maps for overridden keys and remove an override only when its full value matches the inherited default. Explicit blank strings remain blank rather than falling back to another language.
20
20
 
21
21
  Arrays, complex structures and unsupported constraints keep their original meaning. Do not disguise them as ordinary primitive controls to obtain a writable panel. An initialization can leave such controls explicitly unsupported while connecting a useful supported set.
22
+
23
+ ## Authoring new game text
24
+
25
+ Use this when text the player sees is still a literal in the game. Text the game already exports through its own localization keeps those keys and files.
26
+
27
+ - **Schema leaf.** `type: "string"`, `default: "i18n.runtime.<key>"`, `xplatform.value_type: "i18n"`, plus the usual platform label. `<key>` is one flat identifier (letters and digits, at most 128 characters, no dots) that names the purpose, such as `headerTitle` or `downloadButton`, not the English wording. A key and its leaf are unique. Theme data does not receive this leaf's value and never contains translated text.
28
+ - **Global defaults.** `i18n.runtime.json5` in the same directory as the schema file. Each key maps every one of the eighteen languages to a string of at most 4096 characters; a key with fewer or more languages is invalid and its field stays disabled. The `en` entry equals the literal the game displayed before, so default behavior and visuals do not change.
29
+ - **Theme overrides.** `theme.i18n.runtime.json5` sits in the same directory as the theme data file and stays sparse. Do not create it for new text: the platform writes it on the first edit and removes a key again when all of its languages match the global default. Keep an existing file untouched.
30
+ - **Report evidence.** The global file, and the theme file when it exists, belong in the report `sources` of every text candidate. The candidate `fieldPath` is the schema leaf path; its origin names where the literal used to live, and its consumers name the code that now displays the resolved text.
31
+ - **Resolution.** Production and preview share one pure function: the theme map when the theme has the key, otherwise the global map; read only the selected language. A missing key or language yields an empty string. Never fall back to English or another language, and keep an explicit blank as blank. Do not mutate either document.
32
+ - **Language selection.** The preview supplies the selected language (see the integration reference). Production keeps the project's own language detection; a project with none uses the `lng` query parameter, then the browser language, then `en`. Normalize to a supported language by exact match, then base language, then `en`; `zh-TW` is a separate language from `zh`.
33
+ - **Delivery.** Production loads both files through its real theme or configuration entry so the delivered package under `dist/` contains the text it needs.
@@ -17,6 +17,7 @@ Import `connectPreviewRuntime` and `readWorkspacePreviewContext` from the copied
17
17
  - `createGame(context)` returns real `apply`, `restartScene`, `afterRender` and `dispose` behavior. Honor `context.signal` and `context.assertCurrent()` before asynchronous visible changes. `afterRender` waits for a real frame.
18
18
  - Live changes preserve progress and object identity where appropriate. Scene restart uses the game's own lifecycle and disposes obsolete objects, timers, event handlers and owned resources.
19
19
  - Image consumers use `resource: "image"`, logical paths and `context.loadImage`. Prepare before installation, retain the previous image on a recoverable load failure, and honor explicit same-path refreshes. See the copied guide for resource error and lifecycle behavior.
20
+ - Localized text registers each refreshable `runtimeI18n` key and the `previewContext.locale` language context as `live` fields, resolves with the production rule (selected language only, no cross-language fallback) and updates existing text objects in place. The preview language switch exists only when `previewContext.locale` is registered `live`. See the copied guide for the document shapes and which transported keys to trust.
20
21
 
21
22
  Do not route development updates through an advertising configuration cache. Real field registration and a version-matched handshake establish capability; an integration report cannot do so.
22
23
 
@@ -157,8 +157,8 @@
157
157
  },
158
158
  {
159
159
  "skillRef": "remix/playcraft-runtime-parameters",
160
- "version": "1.0.0",
161
- "artifactHash": "629de8166ca49e4cd3a8797607dcbe1ee378177a743371c96896c4cd7ad1468d",
160
+ "version": "1.1.0",
161
+ "artifactHash": "1cef081cdb9aa9861ea6c5b5142c9adc08d2c63615bf0b37bf35c71f3459d1f0",
162
162
  "surface": "sandbox-agent",
163
163
  "mountPath": ".playcraft/skills/remix__playcraft-runtime-parameters"
164
164
  },
@@ -26,6 +26,26 @@ The endpoint advertises `canRefreshImages: true` for explicit same-path content
26
26
 
27
27
  Content generations are independent per field. If an opening image and board image are being prepared together, a newer board-only refresh cancels only the old board read; the still-current opening image must finish and apply while preserving the newer board. Each application uses only the prepared fields that remain current and the latest permitted configuration. A preparation failure in a still-current batch retains the old images and marks all its unapplied fields as recoverable, so an explicit retry cannot silently forget a prepared sibling.
28
28
 
29
+ ## Localized text parameters
30
+
31
+ A game whose player-visible text is exported as `i18n.runtime.<key>` references registers each key it can really refresh, plus the language context. Use one group id (for example `localized-text`) for all of them:
32
+
33
+ ```ts
34
+ { documentId: 'runtimeI18n', path: ['headerTitle'], effectivePath: ['gamePlay', 'sections', 'top', 'title'], groupId: 'localized-text', mode: 'live' },
35
+ { documentId: 'previewContext', path: ['locale'], effectivePath: ['previewLocale'], groupId: 'localized-text', mode: 'live' },
36
+ ```
37
+
38
+ `path` is the flat text key; `effectivePath` is where the resolved text lands in the game's own configuration. The platform's preview-language switch is enabled only when `previewContext.locale` is registered as `live`, so register it only when the text really refreshes in place; without it the switch stays disabled.
39
+
40
+ Documents reaching `resolve(documents)`, `source.install` and the `apply` configuration:
41
+
42
+ - `documents.previewContext.locale` is the language selected in the panel, one of the eighteen supported languages. Normalize it defensively. `binding.locale` is the fixed session contract value and is not the selected language.
43
+ - `documents.runtimeI18n[key]` is `{ [selectedLanguage]: text }`, with the inherited global text already filled in. It never contains another language, so a missing entry means an empty string; do not fall back to English or another language.
44
+ - The snapshot can include keys the game did not register. Take transported text only for the keys you registered; every other key keeps the text bundled in this build.
45
+ - Resolve through the same pure function as production. Do not mutate the documents.
46
+
47
+ In `apply`, when `context.fields` contains a `localized-text` field, update the existing text objects (`setText`, relayout) rather than recreating the scene, and keep progress, timers, animations and the current screen. Check `context.assertCurrent()` before late side effects, and let `afterRender` wait for the real frame. An empty string is a valid value: hide or collapse the element cleanly instead of showing a placeholder or the key.
48
+
29
49
  ## Preview build
30
50
 
31
51
  The engineering project must install a compatible Vite as a development dependency. Set a separate package script:
@@ -4,7 +4,7 @@
4
4
  "protocolVersion": 1,
5
5
  "files": {
6
6
  "build-preview.mjs": "2bd512675d829cc07ceddcb58365a6f74d36ff93690f8d72f4bbaa1d45511c91",
7
- "README.md": "3e35ca2ce6eab84ba8688b400aee8ee5e69458466a13f00e459fd630575d9cbd",
7
+ "README.md": "907bb956f939b3d957ba83789e6131b2f4366fb22a239acda776df4ed22b7db2",
8
8
  "release-guard.mjs": "0c33d1559c68844f3760c3e754ee36ca70dc27151ef6f7ad62f29f95239679cc",
9
9
  "runtime.d.mts": "a366552166a4a93103306997058951416740eaf6d6f3d0a34854386b4f1e02f5",
10
10
  "runtime.d.ts": "a366552166a4a93103306997058951416740eaf6d6f3d0a34854386b4f1e02f5",