@playcraft/cli 0.1.61-beta → 0.1.63

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 (23) hide show
  1. package/dist/commands/build.js +16 -7
  2. package/dist/utils/agent-api-client.js +22 -0
  3. package/dist/workspace-runtime/codex/model-broker.js +13 -3
  4. package/dist/workspace-runtime/previews/publish.js +28 -4
  5. package/dist/workspace-runtime/serve.js +5 -0
  6. package/dist/workspace-runtime/server/directory-access.js +1 -1
  7. package/dist/workspace-runtime/server/dispatch.js +31 -2
  8. package/dist/workspace-runtime/server/http.js +36 -0
  9. package/dist/workspace-runtime/workspaces/artifacts.js +17 -3
  10. package/dist/workspace-runtime/workspaces/model-build-inputs.js +201 -0
  11. package/dist/workspace-runtime/workspaces/procedural-3d/model-preview-build.js +184 -0
  12. package/dist/workspace-runtime/workspaces/procedural-3d/model-preview-recovery.js +77 -0
  13. package/dist/workspace-runtime/workspaces/procedural-3d/model-preview-store.js +170 -0
  14. package/dist/workspace-runtime/workspaces/procedural-3d/model-preview-worker.js +85 -0
  15. package/dist/workspace-runtime/workspaces/procedural-3d/read-candidates.js +219 -0
  16. package/package.json +4 -3
  17. package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +13 -3
  18. package/project-template/.claude/skills/playcraft-runtime-parameters/references/platform-protocol.md +12 -0
  19. package/project-template/.claude/skills/playcraft-runtime-parameters/references/preview-integration.md +1 -0
  20. package/project-template-v2/.playcraft/skills.lock.json +2 -2
  21. package/project-templates/empty/AGENTS.md +1 -1
  22. package/templates/preview-runtime/v1/README.md +20 -0
  23. package/templates/preview-runtime/v1/manifest.json +1 -1
@@ -0,0 +1,219 @@
1
+ import { lstat, readdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { z } from 'zod';
4
+ import { workspaceModelCandidateListSchema, workspaceModelCandidateSchema } from '@playcraft/common/cloud-workspace/runtime';
5
+ import { checkedFile, dependencyHash, digest, relativeFile } from './files.js';
6
+ import { ModelPreviewStore } from './model-preview-store.js';
7
+ const hash = z.string().regex(/^[a-f0-9]{64}$/);
8
+ const fileSchema = z.object({ path: z.string().refine(value => {
9
+ try {
10
+ relativeFile(value);
11
+ return true;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }), hash });
17
+ const candidateSchema = z.object({ id: z.string().uuid(), files: z.array(fileSchema).min(1).max(256),
18
+ sourcePaths: z.array(z.string()).min(1).max(128), sourceHash: hash, dependencyHash: hash,
19
+ pagePath: z.string(), specHash: hash, passId: z.string().min(1).max(128),
20
+ captures: z.array(fileSchema.extend({ view: z.string().min(1).max(64) })).max(16).optional(),
21
+ hostReview: z.object({ profileHash: hash }).optional(),
22
+ review: z.object({ decision: z.enum(['accept', 'revise']), sourceHash: hash }).optional(),
23
+ adoptedPath: z.string().optional() });
24
+ const runSchema = z.object({ schemaVersion: z.literal(1), runId: z.string().uuid(),
25
+ revision: z.number().int().nonnegative(), root: z.string(), artifactHash: hash,
26
+ referenceHash: hash, referencePath: z.string(), candidates: z.array(z.unknown()) });
27
+ const MAX_RUNS = 64;
28
+ const MAX_CANDIDATES = 128;
29
+ const MAX_BYTES = 256 * 1024 * 1024;
30
+ /** Read control-owned snapshots without invoking the Skill or changing a completed run. */
31
+ export async function readProceduralModelCandidates(root, stateDir) {
32
+ const result = { schemaVersion: 1, candidates: [], diagnostics: [],
33
+ truncated: false, scannedAt: new Date().toISOString() };
34
+ const controlRelative = `procedural-3d/${digest(root)}`;
35
+ const controlDirectory = join(stateDir, controlRelative);
36
+ const diagnose = (code, runId, candidateId) => {
37
+ if (result.diagnostics.length < 256)
38
+ result.diagnostics.push({ code, ...(runId ? { runId } : {}), ...(candidateId ? { candidateId } : {}) });
39
+ else
40
+ result.truncated = true;
41
+ };
42
+ let names;
43
+ try {
44
+ const info = await lstat(controlDirectory);
45
+ if (!info.isDirectory() || info.isSymbolicLink())
46
+ throw new Error('model_candidates_control_invalid');
47
+ names = (await readdir(controlDirectory)).filter(name => /^[a-f0-9-]{36}\.json$/i.test(name)).sort();
48
+ }
49
+ catch (error) {
50
+ if (error.code !== 'ENOENT')
51
+ diagnose('model_candidates_control_unavailable');
52
+ return result;
53
+ }
54
+ if (names.length > MAX_RUNS) {
55
+ result.truncated = true;
56
+ diagnose('model_candidates_run_limit');
57
+ }
58
+ let inspectedBytes = 0;
59
+ let inspectedCandidates = 0;
60
+ const seenCandidates = new Set();
61
+ const readBounded = async (base, path) => {
62
+ if (inspectedBytes >= MAX_BYTES)
63
+ throw new Error('model_candidates_byte_limit');
64
+ const bytes = await checkedFile(base, path, Math.min(16 * 1024 * 1024, MAX_BYTES - inspectedBytes));
65
+ inspectedBytes += bytes.length;
66
+ return bytes;
67
+ };
68
+ let currentDependencyHash;
69
+ try {
70
+ currentDependencyHash = await dependencyHash(root);
71
+ }
72
+ catch { /* Each candidate remains visible with a diagnostic. */ }
73
+ for (const name of names.slice(0, MAX_RUNS)) {
74
+ const runId = name.slice(0, -5);
75
+ if (!z.string().uuid().safeParse(runId).success) {
76
+ diagnose('model_candidates_run_invalid');
77
+ continue;
78
+ }
79
+ let run;
80
+ try {
81
+ run = runSchema.parse(JSON.parse((await checkedFile(stateDir, `${controlRelative}/${name}`, 4 * 1024 * 1024)).toString('utf8')));
82
+ if (run.root !== root || run.runId !== runId)
83
+ throw new Error('model_candidates_scope_mismatch');
84
+ }
85
+ catch {
86
+ diagnose('model_candidates_run_invalid', runId);
87
+ continue;
88
+ }
89
+ const jobRelative = `.playcraft/procedural-3d/runs/${runId}`;
90
+ const jobRoot = join(root, jobRelative);
91
+ let referenceVerified = false;
92
+ try {
93
+ referenceVerified = digest(await readBounded(jobRoot, run.referencePath)) === run.referenceHash;
94
+ }
95
+ catch { /* Reported against every affected candidate, without exposing filesystem errors. */ }
96
+ for (const raw of run.candidates) {
97
+ if (inspectedCandidates++ >= MAX_CANDIDATES) {
98
+ result.truncated = true;
99
+ diagnose('model_candidates_candidate_limit', runId);
100
+ break;
101
+ }
102
+ if (result.candidates.length >= MAX_CANDIDATES) {
103
+ result.truncated = true;
104
+ diagnose('model_candidates_candidate_limit', runId);
105
+ break;
106
+ }
107
+ const parsed = candidateSchema.safeParse(raw);
108
+ if (!parsed.success) {
109
+ diagnose('model_candidates_candidate_invalid', runId);
110
+ continue;
111
+ }
112
+ const candidate = parsed.data;
113
+ if (seenCandidates.has(`${runId}:${candidate.id}`)) {
114
+ diagnose('model_candidates_candidate_duplicate', runId, candidate.id);
115
+ continue;
116
+ }
117
+ seenCandidates.add(`${runId}:${candidate.id}`);
118
+ const candidatePrefix = `${jobRelative}/candidates/${candidate.id}`;
119
+ const source = new Set(candidate.sourcePaths);
120
+ try {
121
+ relativeFile(candidate.pagePath);
122
+ if (candidate.adoptedPath)
123
+ relativeFile(candidate.adoptedPath);
124
+ if (new Set(candidate.files.map(file => file.path)).size !== candidate.files.length ||
125
+ new Set(candidate.captures?.map(file => file.path)).size !== (candidate.captures?.length ?? 0) ||
126
+ candidate.sourcePaths.some(path => !candidate.files.some(file => file.path === path)) ||
127
+ !candidate.files.some(file => file.path === candidate.pagePath) ||
128
+ candidate.captures?.some(file => !file.path.startsWith(`candidates/${candidate.id}/captures/`)))
129
+ throw new Error('model_candidates_manifest_invalid');
130
+ }
131
+ catch {
132
+ diagnose('model_candidates_manifest_invalid', runId, candidate.id);
133
+ continue;
134
+ }
135
+ const item = {
136
+ runId, candidateId: candidate.id, revision: run.revision, passId: candidate.passId,
137
+ sourceHash: candidate.sourceHash, dependencyHash: candidate.dependencyHash,
138
+ referenceHash: run.referenceHash, skillArtifactHash: run.artifactHash,
139
+ files: candidate.files.map(file => ({ path: `${candidatePrefix}/source/${file.path}`, contentHash: file.hash, source: source.has(file.path) })),
140
+ captures: (candidate.captures ?? []).map(file => ({ path: `${jobRelative}/${file.path}`, contentHash: file.hash, view: file.view })),
141
+ preview: { kind: 'scene-preview', status: 'needs-build', entryPath: `${candidatePrefix}/source/${candidate.pagePath}`,
142
+ sourceHash: candidate.sourceHash, dependencyHash: candidate.dependencyHash,
143
+ ...(candidate.hostReview ? { profileHash: candidate.hostReview.profileHash } : {}), reason: 'candidate_scene_not_built' },
144
+ integrity: 'verified', ...(candidate.review ? { review: candidate.review } : {}),
145
+ ...(candidate.adoptedPath ? { adoptedPath: candidate.adoptedPath } : {}),
146
+ };
147
+ const invalidate = (code) => {
148
+ item.integrity = 'invalid';
149
+ if (item.preview.kind === 'scene-preview') {
150
+ item.preview.status = 'unavailable';
151
+ item.preview.reason = code;
152
+ }
153
+ diagnose(code, runId, candidate.id);
154
+ };
155
+ if (!workspaceModelCandidateSchema.safeParse(item).success) {
156
+ diagnose('model_candidates_manifest_invalid', runId, candidate.id);
157
+ continue;
158
+ }
159
+ if (!referenceVerified)
160
+ invalidate('model_candidates_reference_changed');
161
+ if (currentDependencyHash !== candidate.dependencyHash)
162
+ invalidate('model_candidates_dependencies_changed');
163
+ if (digest(JSON.stringify(candidate.files.map(({ path, hash }) => ({ path, hash })))) !== candidate.sourceHash)
164
+ invalidate('model_candidates_source_hash_changed');
165
+ if (!candidate.captures?.length)
166
+ invalidate('model_candidates_captures_missing');
167
+ if (candidate.review && candidate.review.sourceHash !== candidate.sourceHash)
168
+ invalidate('model_candidates_review_changed');
169
+ try {
170
+ if (digest(await readBounded(root, `${candidatePrefix}/spec.json`)) !== candidate.specHash)
171
+ invalidate('model_candidates_spec_changed');
172
+ }
173
+ catch {
174
+ invalidate('model_candidates_spec_unavailable');
175
+ }
176
+ for (const file of [...item.files, ...item.captures]) {
177
+ try {
178
+ const bytes = await readBounded(root, file.path);
179
+ file.size = bytes.length;
180
+ if (digest(bytes) !== file.contentHash)
181
+ throw new Error('model_candidates_file_changed');
182
+ }
183
+ catch (error) {
184
+ const limit = inspectedBytes >= MAX_BYTES || (error instanceof Error && error.message === 'model_candidates_byte_limit');
185
+ invalidate(limit ? 'model_candidates_byte_limit' : 'model_candidates_file_unavailable');
186
+ if (limit) {
187
+ result.truncated = true;
188
+ break;
189
+ }
190
+ }
191
+ }
192
+ result.candidates.push(item);
193
+ if (item.integrity === 'verified' && item.preview.kind === 'scene-preview') {
194
+ try {
195
+ const versions = await new ModelPreviewStore(stateDir, root).versions(item), preview = versions[0];
196
+ item.previewVersions = versions.map(version => ({ previewId: version.previewId, contentHash: version.contentHash,
197
+ createdAt: version.createdAt, ...(version.parameters ? { parameters: version.parameters } : {}),
198
+ ...(version.parameterHash ? { parameterHash: version.parameterHash } : {}) }));
199
+ if (preview)
200
+ Object.assign(item.preview, { status: 'ready', reason: 'candidate_scene_built',
201
+ previewId: preview.previewId, contentHash: preview.contentHash,
202
+ ...(preview.parameters ? { parameters: preview.parameters } : {}),
203
+ ...(preview.parameterHash ? { parameterHash: preview.parameterHash } : {}) });
204
+ }
205
+ catch {
206
+ invalidate('model_preview_receipt_invalid');
207
+ }
208
+ }
209
+ if (inspectedBytes >= MAX_BYTES)
210
+ break;
211
+ }
212
+ if (inspectedBytes >= MAX_BYTES || inspectedCandidates >= MAX_CANDIDATES) {
213
+ result.truncated = true;
214
+ diagnose(inspectedBytes >= MAX_BYTES ? 'model_candidates_byte_limit' : 'model_candidates_candidate_limit', runId);
215
+ break;
216
+ }
217
+ }
218
+ return workspaceModelCandidateListSchema.parse(result);
219
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.1.61-beta",
3
+ "version": "0.1.63",
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.61-beta",
57
- "@playcraft/common": "0.1.61-beta",
56
+ "@playcraft/build": "0.1.63",
57
+ "@playcraft/common": "0.1.63",
58
58
  "better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
59
59
  "chokidar": "^4.0.3",
60
60
  "commander": "^13.1.0",
@@ -91,6 +91,7 @@
91
91
  "@types/update-notifier": "^6.0.8",
92
92
  "@types/ws": "^8.18.1",
93
93
  "esbuild": "^0.28.1",
94
+ "three": "^0.183.2",
94
95
  "typescript": "^5.9.3",
95
96
  "vitest": "^3.2.4"
96
97
  }
@@ -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
  },
@@ -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. 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.
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.
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
 
@@ -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",