beyond-simulator-web 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,7 +42,21 @@ beyond-simulator-web --workspace /absolute/path/to/workspace --open
42
42
 
43
43
  ## 与 MCP 配合
44
44
 
45
- MCP 显式调用 `qxqy_project_save` 保存工作区 JSON 后,`/` 每 700ms 检查当前文件,停止旧试玩、加载新存档并通过 SSE 刷新浏览器。该预览不写文件。`/editor` 不自动用磁盘内容覆盖正在编辑的草稿,需要手动“从工作区拉取存档”。
45
+ 编辑器「Lua 脚本 → 实机脚本同步」可将当前存档脚本显式复制到宿主本机的千星沙箱目录:配置随存档保存,先保存并预览差异,再人工确认复制。AI 可准备配置和源码;复制不会自动执行。详见 [目录配置、确认机制及端到端验证](../studio/docs/script-sync.md)。
46
+
47
+ MCP 显式调用 `qxqy_project_save` 保存工作区 JSON 后,`/` 每 700ms 检查当前文件。还没有打开存档时,每 5 秒重新发现存档并自动打开最新的 `qxqy-simulator-save`;已经打开时,只在该文件变化后停止旧试玩、重新加载,并通过 SSE 刷新浏览器。不会自动改看另一个路径。顶部刷新按钮可立即扫描。该预览不写文件。`/editor` 不自动用磁盘内容覆盖正在编辑的草稿,需要手动“从工作区拉取存档”。
48
+
49
+ MCP 0.3.0 起提供 `qxqy_preview_status()` 和 `qxqy_preview_open({ path: "game.save.json" })`:前者查询 Web 实际版本、工作区、当前存档和加载错误,后者直接让预览服务读取指定存档,无需重新保存。MCP 默认连接 `http://127.0.0.1:4173`,其他端口使用 MCP 的 `--web-url` 或 `QXQY_WEB_URL`;Web 启用口令时,在 MCP 环境中配置相同的 `QXQY_WEB_PASSWORD`。两边必须使用同一工作区,桥接会在读取和打开时校验。工具回执确认服务端已加载,浏览器通过 SSE 或定期状态同步刷新。
50
+
51
+ 页面“预览连接”显示 Web 版本、工作区、加载错误与扫描警告。发现列表只读取 JSON 的前 4 KiB,不再排除超过 8 MiB 的合法存档;在扫描预算内按修改时间取最新 50 项。自动扫描跳过隐藏条目及依赖/生成目录,最多 8 层、10,000 条目和 1,000 目录;达到限制或读取失败会显示汇总警告。明确指定的 `--file` / 预览工具路径不受列表筛选限制,但仍受工作区边界保护。损坏 JSON 会显示加载错误并继续重试;初始文件缺失不会导致整个 Web 服务无法启动。
52
+
53
+ 源码更新后需运行 `pnpm build` 并重启 Web/MCP;`pnpm start:web` 直接运行已有 `web/dist`,不会自动构建。npm 安装用户更新包后也需重启进程。
54
+
55
+ ### 2026-09-26 预览同步修复记录
56
+
57
+ Web/MCP 0.3.0、共享宿主所在 DSH 2.0.3:修复空工作区启动后跳过新文件发现、默认保存路径与打开路径脱节、大文件不出现在列表及前 50 项截断遗漏最新文件。补充显式预览桥接、加载错误与版本信息。证据为仓库内 `web/test/preview-state.test.mjs`、`studio/test/workspace.test.mjs`、`studio/test/controller-archive.test.mjs` 与实际启动两个进程的 `mcp/test/web-integration.test.mjs`;`evidence_source: observed`,运行端 `simulator`,`device_status: not_required`。包公开状态以 npm 审核结果为准。
58
+
59
+ 同日浏览器实测:启动试玩后将临时存档替换为损坏 JSON,页面显示加载错误并停止旧试玩;恢复原文件(保留原大小与时间戳)后错误清除并自动回到编辑预览。轮询不得因旧试玩状态屏蔽错误事件;存在加载错误时,即使文件时间戳与上次成功加载相同也需要重试。
46
60
 
47
61
  试玩使用固定 30 FPS Worker、增量场景和 Pixi/WebGL;刷新试玩页会接回已有 Worker,保留暂停和当前玩家。截图仍由 Host 根据统一场景输出 PNG。
48
62
 
@@ -39,6 +39,9 @@ const elements = {
39
39
  runtimeLogs: byId('runtime-logs'),
40
40
  clearLogs: byId('clear-logs'),
41
41
  toast: byId('toast'),
42
+ serverVersion: byId('server-version'),
43
+ workspacePath: byId('workspace-path'),
44
+ diagnostics: byId('preview-diagnostics'),
42
45
  }
43
46
 
44
47
  const kindIcons = {
@@ -110,7 +113,7 @@ function renderArchives() {
110
113
  const previous = elements.archive.value
111
114
  elements.archive.replaceChildren()
112
115
  const archives = model?.archives || []
113
- if (!archives.length) {
116
+ if (!archives.length && !model?.activePath) {
114
117
  const empty = option('', '未发现 qxqy-simulator-save')
115
118
  empty.disabled = true
116
119
  elements.archive.append(empty)
@@ -119,6 +122,9 @@ function renderArchives() {
119
122
  for (const archive of archives) {
120
123
  elements.archive.append(option(archive.path, `${archive.name} · ${archive.path}`))
121
124
  }
125
+ if (model.activePath && !archives.some(archive => archive.path === model.activePath)) {
126
+ elements.archive.prepend(option(model.activePath, `${model.snapshot?.save?.name || '当前存档'} · ${model.activePath}`))
127
+ }
122
128
  elements.archive.value = model.activePath || previous || archives[0].path
123
129
  }
124
130
 
@@ -133,7 +139,7 @@ function renderDevices() {
133
139
  }
134
140
 
135
141
  function renderTree() {
136
- const rows = model?.snapshot?.tree || []
142
+ const rows = model?.activePath ? (model?.snapshot?.tree || []) : []
137
143
  elements.tree.replaceChildren()
138
144
  elements.treeCount.textContent = String(rows.length)
139
145
  elements.treeTitle.textContent = model?.snapshot?.asset?.type === 'client-control-template' ? '模板与子控件' : '控件树'
@@ -178,7 +184,7 @@ function addDetail(label, value) {
178
184
  }
179
185
 
180
186
  function renderNodeDetails() {
181
- const row = model?.snapshot?.tree?.find((item) => item.id === selectedId)
187
+ const row = model?.activePath && model?.snapshot?.tree?.find((item) => item.id === selectedId)
182
188
  elements.nodeDetails.replaceChildren()
183
189
  elements.nodeName.textContent = row?.name || '未选择'
184
190
  if (!row) return
@@ -209,17 +215,20 @@ function renderStage() {
209
215
  elements.stageEmpty.hidden = showEditor || showPlay
210
216
  elements.stageShell.classList.toggle('playing', mode === 'play' && Boolean(model?.play?.running))
211
217
  if (!hasArchive) {
212
- elements.stageEmpty.querySelector('strong').textContent = '等待工作区存档'
213
- elements.stageEmpty.querySelector('p').textContent = 'Codex 保存 qxqy-simulator-save JSON 后会自动出现在这里。'
218
+ elements.stageEmpty.querySelector('strong').textContent = model?.lastError ? '存档加载失败' : '等待工作区存档'
219
+ const watched = model?.workspace ? `正在监听 ${model.workspace}。` : ''
220
+ elements.stageEmpty.querySelector('p').textContent = model?.lastError
221
+ ? model.lastError
222
+ : `${watched}Codex 保存 qxqy-simulator-save JSON 后会自动出现在这里。`
214
223
  } else if (mode === 'play' && !model.play.running) {
215
224
  elements.stageEmpty.querySelector('strong').textContent = '试玩尚未启动'
216
225
  elements.stageEmpty.querySelector('p').textContent = '选择设备并点击“启动”,即可运行 Lua 和交互逻辑。'
217
226
  }
218
227
  if (showEditor) refreshEditorImage()
219
- elements.assetLabel.textContent = snapshot?.asset?.label || '等待存档'
220
- elements.saveName.textContent = snapshot?.save?.name || '未命名存档'
221
- elements.canvasLabel.textContent = snapshot?.canvas ? `${snapshot.canvas.label} · ${snapshot.canvas.width}×${snapshot.canvas.height}` : '—'
222
- elements.revisionLabel.textContent = `Revision ${snapshot?.version ?? '—'}`
228
+ elements.assetLabel.textContent = hasArchive ? (snapshot?.asset?.label || '等待存档') : '等待存档'
229
+ elements.saveName.textContent = hasArchive ? (snapshot?.save?.name || '未命名存档') : '未命名存档'
230
+ elements.canvasLabel.textContent = hasArchive && snapshot?.canvas ? `${snapshot.canvas.label} · ${snapshot.canvas.width}×${snapshot.canvas.height}` : '—'
231
+ elements.revisionLabel.textContent = hasArchive ? `Revision ${snapshot?.version ?? '—'}` : 'Revision —'
223
232
  elements.activePath.textContent = model?.activePath || '尚未打开存档'
224
233
  elements.playReadout.textContent = mode === 'play'
225
234
  ? model?.play?.running
@@ -309,7 +318,7 @@ function renderControls() {
309
318
  elements.playStop.disabled = busy || !running
310
319
  elements.asset.disabled = busy || mode === 'play'
311
320
  elements.device.disabled = busy || !hasArchive
312
- elements.archive.disabled = busy || !(model?.archives?.length)
321
+ elements.archive.disabled = busy || !(model?.archives?.length || model?.activePath)
313
322
  elements.reload.disabled = busy
314
323
  }
315
324
 
@@ -319,20 +328,27 @@ function render() {
319
328
  renderTree()
320
329
  renderNodeDetails()
321
330
  const snapshot = model?.snapshot
331
+ const hasArchive = Boolean(model?.activePath)
322
332
  elements.asset.value = snapshot?.asset?.type || 'server-control-template'
323
- elements.metricControls.textContent = String(snapshot?.tree?.length || 0)
324
- elements.metricScripts.textContent = String(snapshot?.scriptCount || 0)
325
- elements.metricCanvas.textContent = snapshot?.canvas ? `${snapshot.canvas.width}×${snapshot.canvas.height}` : '—'
333
+ elements.metricControls.textContent = hasArchive ? String(snapshot?.tree?.length || 0) : '0'
334
+ elements.metricScripts.textContent = hasArchive ? String(snapshot?.scriptCount || 0) : '0'
335
+ elements.metricCanvas.textContent = hasArchive && snapshot?.canvas ? `${snapshot.canvas.width}×${snapshot.canvas.height}` : '—'
326
336
  elements.runtimeLogs.textContent = formatLogs(model?.play?.logs)
337
+ elements.serverVersion.textContent = model?.version ? `Web ${model.version}` : '服务版本未知,请更新 Web'
338
+ elements.workspacePath.textContent = model?.workspace ? `工作区:${model.workspace}` : ''
339
+ elements.diagnostics.textContent = [model?.lastError, ...(model?.discovery?.warnings || [])].filter(Boolean).join('\n')
340
+ elements.diagnostics.hidden = !elements.diagnostics.textContent
341
+ elements.diagnostics.classList.toggle('error', Boolean(model?.lastError))
327
342
  renderControls()
328
343
  renderStage()
329
344
  syncPlayPolling()
330
345
  }
331
346
 
332
- async function refreshState({ quiet = false } = {}) {
333
- if (mode === 'play' && model?.play?.running && quiet) return
347
+ async function refreshState({ quiet = false, force = false, forceDiscovery = false } = {}) {
348
+ if (mode === 'play' && model?.play?.running && play.running && quiet && !force) return
334
349
  try {
335
- model = await requestJson('/api/state')
350
+ model = await requestJson(forceDiscovery ? '/api/state?refresh=1' : '/api/state')
351
+ if (!model?.play?.running && play.running) play.reset()
336
352
  if (!quiet && model?.play?.running && !play.running) {
337
353
  mode = 'play'
338
354
  await play.attach()
@@ -389,7 +405,15 @@ elements.reload.addEventListener('click', () => run(async () => {
389
405
  render()
390
406
  notify('已重新读取磁盘存档')
391
407
  } else {
392
- await refreshState()
408
+ await refreshState({ forceDiscovery: true })
409
+ const newest = model?.archives?.[0]?.path
410
+ if (newest && !model?.activePath) {
411
+ model = await requestJson('/api/open', { method: 'POST', body: JSON.stringify({ path: newest }) })
412
+ mode = 'editor'
413
+ selectedId = ''
414
+ render()
415
+ notify('已打开存档')
416
+ }
393
417
  }
394
418
  }))
395
419
 
@@ -447,13 +471,14 @@ events.addEventListener('error', () => setConnection('error', '正在重连'))
447
471
  events.addEventListener('state', (event) => {
448
472
  let change = {}
449
473
  try { change = JSON.parse(event.data) } catch {}
450
- if (change.reason === 'external-change') {
474
+ if (change.type === 'archive') {
451
475
  mode = 'editor'
476
+ selectedId = ''
452
477
  play.reset()
453
- notify('检测到 Codex/MCP 保存,预览已更新')
478
+ notify(change.reason === 'external-change' ? '检测到存档变化,预览已更新' : '预览存档已打开')
454
479
  }
455
480
  // 试玩画面有独立的轻量状态轮询;忽略 play 事件,避免重复刷新。
456
- if (change.type !== 'play') void refreshState({ quiet: true })
481
+ if (change.type !== 'play') void refreshState({ quiet: true, force: true })
457
482
  })
458
483
 
459
484
  setInterval(() => { void refreshState({ quiet: true }) }, 5000)