This commit is contained in:
2026-04-27 14:23:19 +08:00
parent 09d3fe59b3
commit fa2dbb310b
75 changed files with 7363 additions and 1487 deletions

View File

@@ -120,3 +120,23 @@ Rust 首版返回:
1. `platform-llm` 单测能捕获开启搜索时上游 JSON 包含 `web_search_options`
2. `api-server` 配置单测能验证角色扮演搜索开关默认开启、环境变量可关闭。
3. 角色扮演剧情、NPC 对话、推理战斗文本请求都通过同一辅助函数设置搜索开关,避免漏接。
## 9. AgentSession 创作问答联网搜索补充2026-04-26
### 9.1 目标
AgentSession 页面中的 RPG 世界共创、拼图共创、大鱼吃小鱼共创都属于创作问答链路。用户在这些页面里会要求模型补充现实题材、历史文化、地理器物、玩法参照与美术风格依据,因此创作 Agent 的文本问答默认开启上游联网搜索能力。
### 9.2 落地范围
1. `api-server` 配置增加 `GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED` / `CREATION_AGENT_LLM_WEB_SEARCH_ENABLED`,默认 `true`
2. `creation_agent_llm_turn` 作为三类 Agent 共用 LLM 骨架,必须接收显式 `enable_web_search` 参数,并在 `LlmTextRequest` 上设置该值。
3. RPG 世界共创、拼图共创、大鱼吃小鱼共创的普通消息接口与 SSE 流式消息接口都传入同一配置值,避免只有某一种入口开启。
4. RPG 世界共创里的动态状态推断属于对当前聊天状态的结构化判断,不需要联网搜索,继续保持默认关闭。
5. `/api/llm/chat/completions` 通用代理继续不默认开启联网搜索。
### 9.3 验收
1. `api-server` 配置单测覆盖创作 Agent 联网搜索开关默认开启、环境变量可关闭。
2. 创作 Agent 的共用 LLM 单测覆盖开启搜索时 `LlmTextRequest.enable_web_search``true`
3. 三类 Agent turn request 均包含 `enable_web_search` 字段,调用点全部来自 `state.config.creation_agent_llm_web_search_enabled`

View File

@@ -0,0 +1,23 @@
# 资产历史接口补齐拼图封面素材类型
日期:`2026-04-27`
## 背景
拼图结果页会通过 `/api/assets/history?kind=puzzle_cover_image` 读取历史封面素材,供“生成或更换图片”面板复用旧图。
该链路与角色主视觉、场景图共用同一资产历史接口,因此后端白名单一旦漏掉 `puzzle_cover_image`,前端就会收到 `400 Bad Request`,表现为拼图封面历史素材列表无法打开。
## 本次口径
1. `server-rs/crates/api-server/src/assets.rs` 中的历史素材类型白名单统一收口为单一常量源。
2. HTTP 层错误文案与实际支持列表由同一函数生成,避免后续再出现“校验改了但提示文案还是旧口径”的漂移。
3. 增加 `puzzle_cover_image` 的回归测试,确保拼图封面素材不会再次被历史接口遗漏。
## 后续约束
1. 新增历史素材类型时,必须同时更新:
- `api-server``SUPPORTED_ASSET_HISTORY_KINDS`
- `spacetime-module` 的历史素材白名单
- 对应前端调用常量与测试
2. 如果运行态仍返回旧白名单错误,优先检查本地 `api-server.exe` 是否已按最新源码重新编译并重启,而不是先回退前端类型参数。

View File

@@ -253,6 +253,11 @@ function resolvePuzzleGridSize(clearedLevelCount: number): 3 | 4 {
1. 单块拖到单块位置:执行交换。
2. 合并块拖到任意目标锚点:保持内部相对布局整体重排。
3. 若合并块整体平移后覆盖到多个单块,被覆盖单块必须与合并块腾出的原格子做一对一交换,禁止把多个单块回填到同一个源格。
4. 一对一交换必须满足:
- 每个被覆盖单块只移动一次。
- 每个被腾出的源格只接收一个被覆盖单块。
- 若腾出的源格数量与被覆盖单块数量不一致,本次拖动视为非法,不更新棋盘。
3. 单块拖到合并块占据位置:先拆分目标合并块,再执行交换,最后重算合并。
### 7.5 通关
@@ -382,3 +387,58 @@ finalScore = tagSimilarityScore * 0.7 + sameAuthorScore * 0.3;
### 10.4 冻结说明
截至本次验收,拼图玩法已满足 PRD 要求的最小产品闭环;未继续扩展排行榜、提示、体力、异形拼块、倒计时、前端本地裁决等超出本轮需求的能力。
## 11. 2026-04-26 运行态机制补齐记录
本次按 PRD 第 9 章补齐拼图运行态的未完成机制,落点保持在 `server-rs/crates/module-puzzle` 领域层;前端本地兜底只同步表现和离线闭环,不改变后端真相源。
### 11.1 棋盘初始化
1. `build_initial_board_with_seed` 使用种子化洗牌生成初始棋盘,不再固定左移一格。
2. 正式 run 的种子由 `runId + profileId + levelIndex + gridSize` 派生;由于每次进入都会创建新的 `runId`,同一作品多次进入也会得到不同打乱样式。
3. 洗牌后若极端情况下仍为完成态,强制旋转一次,保证新关卡不是已完成局面。
4. 初始棋盘不得存在任何已经正确相邻的两块;初始化会多次洗牌筛选,若极端情况下未命中,则使用反序排列兜底,避免开局自动出现合并块。
5. `module-puzzle` 与本地 fallback 的测试都必须直接断言初始棋盘不存在正确相邻对,不能只检查 `mergedGroups = []`
### 11.2 局部重算与合并
1. 交换后只把源格、目标格和四向邻格纳入重算范围。
2. 拖动后把源格、目标格、被移动合并块边界格、被拆分目标合并块格子纳入重算范围。
3. 对受影响范围内的旧合并组先拆回单块,再按正确四向相邻关系重新生成合并组;未受影响的旧合并组保留。
4. 每次生成快照时统一重编号合并组,避免保留组与新组出现重复 `groupId`
### 11.3 拖动与拆分
1. 单块拖到单块位置时执行交换。
2. 合并块拖动时保持内部相对布局,以被拖动块作为锚点整体平移。
3. 单块拖入合并块占据位置时,先拆分目标合并块,再完成本次交换,最后按受影响范围重新合并。
### 11.4 本次新增验证
1. `cargo test -p module-puzzle` 覆盖:每次 run 不同打乱、正确相邻自动合并、单块拖入合并块拆分目标组。
2. `npm run test -- src/services/puzzle-runtime/puzzleLocalRuntime.test.ts` 覆盖:本地兜底每次启动不同打乱、交换后正确相邻自动合并、通关后推进下一关。
3. `npm run check:encoding` 已通过,确认中文文档未被编码写坏。
## 12. 2026-04-26 二次运行态缺口补齐
本次继续按 PRD 第 9.12 与第 14.4 节收敛两个剩余缺口:
1. 通关判定必须同时支持“所有拼块回到正确位置”和“所有拼块汇成一个覆盖全盘的大合并块”。领域层以 `all_tiles_resolved` 作为唯一对外真相,但其计算来源必须包含这两个条件。
2. 运行态底部不再常驻玩法说明文字,只保留短状态反馈、错误反馈和下一关动作;点击/拖动规则不写成长期 UI 文案。
### 12.1 合并块可见性修正
用户反馈“正确连接的块自动合并没有看到”后,确认原实现只把已合并格子染成绿色,仍按单块逐格渲染,视觉上无法形成“合并块”。本次运行态画布改为:
1. 根据 `mergedGroups` 计算合并块外接矩形。
2. 原单格位置让位为透明占位。
3. 在棋盘上叠加一个跨格整体层,内部仍按原图切片拼接,但外边框、阴影和拖动事件都属于同一个合并块。
4. 合并块整体层以组内第一块作为拖动锚点,继续沿用后端/本地运行态的合并块拖动裁决。
### 12.2 拖动可用性修正
用户反馈“没有办法拖动拼图块”后,确认原交互只在 pointer move 超过阈值后记录 `dragging = true`,没有持续记录当前指针位置,也没有把拖动中的块做视觉平移;移动端还可能被浏览器默认触控手势抢占。修正如下:
1. `dragState` 持续记录 `currentX/currentY`,拖动中按指针偏移对单块或合并块做 `translate3d` 跟手反馈。
2. 棋盘与合并块交互层增加 `touch-none select-none`,避免移动端滚动、选中文本等默认行为打断拖动。
3. 松手后仍只提交 `pieceId + targetRow + targetCol`,最终交换、合并、拆分和通关继续以后端/本地运行态快照为准。

View File

@@ -0,0 +1,31 @@
# 拼图生成图片资源代理修复
日期:`2026-04-27`
## 背景
拼图结果页的“生成或更换图片”会在 `api-server` 中调用 DashScope 生成图片,再把候选图上传到 OSS最终以 `/generated-puzzle-assets/...` 旧兼容路径写回 `PuzzleGeneratedImageCandidate.image_src` 与草稿封面字段。
本次排查发现拼图图片写入路径已经进入 `platform-oss::LegacyAssetPrefix::PuzzleAssets`,但后端 Axum 旧资源代理和 Vite 本地代理没有挂载 `/generated-puzzle-assets`。这会导致候选图或正式图无法读取;后续如果把已有候选图作为参考图继续更换图片,也会让参考图读取链路失效。
## 修复口径
1. `server-rs/crates/api-server/src/legacy_generated_assets.rs` 增加 `proxy_generated_puzzle_assets(...)`,复用统一的 OSS 签名读取逻辑。
2. `server-rs/crates/api-server/src/app.rs` 挂载 `/generated-puzzle-assets/{*path}`,与角色、大鱼、自定义世界图片资源前缀保持一致。
3. `vite.config.ts` 增加 `/generated-puzzle-assets` dev proxy保证本地网页端不会因为 Vite 代理缺口读不到后端资源。
## 后续约束
1. 任何新增 `LegacyAssetPrefix` 都必须同时检查:
- `platform-oss` 前缀枚举
- `api-server` 旧资源代理路由
- Vite dev proxy
- 前端 `isGeneratedLegacyPath(...)` 是否能识别
2. 拼图候选图 JSON 仍保持 SpacetimeDB 持久化结构 `PuzzleGeneratedImageCandidate` 的 snake_case 字段,不把 HTTP camelCase 响应结构写入 `draft_json`
3. 图片生成、OSS 读写和外部参考图解析继续留在 `api-server`,不能下沉到 SpacetimeDB reducer。
## 验收
1. `npm run check:encoding`
2. `cargo check -p api-server --manifest-path server-rs/Cargo.toml`
3. `npm run api-server:maincloud` 重启后,点击拼图结果页“生成或更换图片”,候选图应能写回并正常展示。

View File

@@ -0,0 +1,26 @@
# 拼图图片提示词脚本拆分
## 背景
拼图结果页的图片生成已经由 `server-rs/crates/api-server/src/puzzle.rs` 负责外部 I/O 编排、DashScope 请求、候选图落 OSS 与 SpacetimeDB 持久化。原先正式提示词和反向提示词也内联在同一文件里,后续调整拼图图片画面约束时容易误碰生成任务、资产绑定或候选池逻辑。
## 本轮落地边界
1. 拼图图片提示词统一放到 `server-rs/crates/api-server/src/prompt/puzzle_image.rs`
2. `puzzle.rs` 只负责读取提示词构建结果,并继续处理 DashScope、OSS、SpacetimeDB 写回。
3. 提示词模块只暴露:
- `build_puzzle_image_prompt(level_name, prompt)`
- `PUZZLE_DEFAULT_NEGATIVE_PROMPT`
4. 文生图和图生图继续共用同一份最终提示词,避免同一玩法下出现两套画面约束。
## 编码约束
1. 不把图片生成逻辑下沉到 SpacetimeDB reducer外部 I/O 必须留在 `api-server`
2. 不改候选图 JSON 持久化结构,仍使用 `module-puzzle::PuzzleGeneratedImageCandidate` 对应的 snake_case 字段。
3. 不改前端 UI 文案和交互;本轮只拆后端提示词脚本。
4. 后续若调整拼图图片风格、尺寸、禁止元素或切块可读性要求,优先修改 `prompt/puzzle_image.rs`,再按需补测试。
## 验收
1. `cargo test -p api-server puzzle_image` 通过。
2. `npm run check:encoding` 通过,确认新增中文文档和 Rust 注释仍是 UTF-8。

View File

@@ -0,0 +1,96 @@
# 拼图关卡通关弹窗与排行榜落地设计
更新时间:`2026-04-26`
## 1. 本次目标
玩家每完成拼图运行时的一关后,立即弹出独立结算弹窗。弹窗需要显示:
1. 本关通关时间。
2. 本关排行榜。
3. 排行榜条目包含名次、昵称、通关时间。
4. 下一关按钮,点击后进入下一关。
弹窗不能实现成当前面板下方追加内容,也不能在画布底部长期堆玩法说明。
## 2. 当前工程状态
拼图运行时已有两条能力边界:
1. 正式后端链路:`server-rs` 已有 `puzzle_runtime` 契约、`module-puzzle` 运行态模型与 `api-server` 路由。
2. 第一版单机运行态例外:前端当前实际通过 `startLocalPuzzleRun``swapLocalPuzzlePieces``dragLocalPuzzlePiece` 维护单次游玩内存快照,下一关通过 `advanceLocalPuzzleNextLevel` 交给 Rust HTTP 侧生成候选。
本次不新增旧 `server-node` 逻辑,不引入 PostgreSQL不从前端计算跨作品正式排行榜。
## 3. V1 排行榜边界
由于当前 PRD 已明确“第一版运行态采用单机本地版本”,本次排行榜采用可迁移的本地关卡榜结构:
1. 每个 `PuzzleRunSnapshot` 携带 `leaderboardEntries`
2. 每个 `PuzzleRuntimeLevelSnapshot` 携带:
- `startedAtMs`:本关开始时间。
- `clearedAtMs`:本关通关时间。
- `elapsedMs`:本关耗时。
- `leaderboardEntries`:当前关卡榜单。
3. 本地榜单生成规则只用于 V1 展示:
- 当前玩家昵称使用现有作者/玩家显示名兜底。
- 当前玩家通关后写入本关榜单。
- 追加少量稳定的系统样例成绩,按耗时升序排序。
- 名次由排序后顺序生成。
4. 后续正式迁移到 SpacetimeDB 时,字段可直接迁移为 `puzzle_level_clear_record` 或公共榜单 view前端弹窗不需要重做结构。
## 4. 数据结构
前端共享契约新增:
```ts
interface PuzzleLeaderboardEntry {
rank: number;
nickname: string;
elapsedMs: number;
isCurrentPlayer?: boolean;
}
```
运行态快照新增:
```ts
interface PuzzleRuntimeLevelSnapshot {
startedAtMs: number;
clearedAtMs: number | null;
elapsedMs: number | null;
leaderboardEntries: PuzzleLeaderboardEntry[];
}
interface PuzzleRunSnapshot {
leaderboardEntries: PuzzleLeaderboardEntry[];
}
```
Rust shared-contracts 与 api-server 映射同步补同名 camelCase 响应字段,确保 HTTP 本地下一关接口能透传这些字段。
## 5. 交互规则
1. 当前关卡从 `playing` 首次变为 `cleared` 后,弹出结算弹窗。
2. 弹窗打开时不允许点击背景关闭,避免误触跳过结算。
3. 弹窗保留关闭按钮,关闭后仍可通过底部下一关按钮继续。
4. 弹窗内“下一关”按钮直接调用现有 `onAdvanceNextLevel`
5. 下一关准备中时按钮禁用并显示加载态。
6. 进入下一关后,弹窗自动关闭,等待下一次通关再打开。
## 6. UI 要求
1. 移动端优先:弹窗最大宽度控制在窄屏内,榜单可纵向滚动。
2. PC 端:弹窗居中,信息密度保持克制。
3. 不在弹窗中写玩法规则说明。
4. 排行榜只展示名次、昵称、通关时间,当前玩家行用轻量高亮。
## 7. 验收点
1. 通关后能看到独立弹窗。
2. 弹窗显示本关耗时。
3. 排行榜显示名次、昵称、通关时间。
4. 点击弹窗内下一关按钮能触发进入下一关。
5. 进入下一关后弹窗消失。
6. `npm run check:encoding` 通过。
7. 拼图 runtime 相关单测通过。

View File

@@ -0,0 +1,37 @@
# 拼图结果页正式图刷新修复
日期:`2026-04-27`
## 背景
拼图结果页点击“生成并替换当前图片”后,后端会返回最新 session并把新图片写回 `draft.coverImageSrc`。但前端正式图展示仍可能命中旧的签名 URL 或浏览器图片缓存,导致当前正式图看起来没有变化。
这个问题在 `/generated-puzzle-assets/...` 私有资源链路下尤其明显:
1. 结果页展示依赖 `ResolvedAssetImage -> useResolvedAssetReadUrl -> /api/assets/read-url`
2. 读地址服务会缓存同一路径的签名 URL
3. 如果同一路径对应的资源内容在短时间内被更新,页面可能继续显示旧图
## 修复口径
1. `src/services/assetReadUrlService.ts`
-`resolveAssetReadUrl(...)` 增加 `refreshKey`
- 当调用方显式传入 `refreshKey` 时,跳过本地签名缓存
- 在最终图片 URL 上追加 `_v` 参数,强制浏览器拉取当前版本
2. `src/components/ResolvedAssetImage.tsx`
- 支持透传 `refreshKey`
3. `src/components/puzzle-result/PuzzleResultView.tsx`
- 拼图结果页正式图与发布弹窗正式图统一使用 `session.updatedAt + coverImageSrc` 作为刷新锚点
- 保证每次“生成并替换当前图片”回写新 session 后,结果页主图和发布预览图都会同步刷新
## 回归验证
1. `src/hooks/useResolvedAssetReadUrl.test.tsx`
- 新增 `refreshKey` 变化时重新获取签名地址并带 `_v` 参数的测试
2. `src/components/puzzle-result/PuzzleResultView.test.tsx`
- 新增 session 更新后正式图切换到新 `coverImageSrc` 的回归测试
## 后续约束
1. 任何使用 `/generated-*` 私有资源且存在“同一业务位重复生成替换”的界面,都应明确是否需要 `refreshKey`
2. 如果后端后续改成稳定对象键覆写,也不能移除前端结果页的主动刷新锚点

View File

@@ -0,0 +1,50 @@
# 拼图运行时拖动跟手延迟修复
日期:`2026-04-27`
## 1. 背景
拼图玩法运行时已经支持单块拖动、合并块整体拖动与拆分,但实测拖动时拼块会明显慢于手指或鼠标,表现为“拖着走但始终落后半拍”,尤其在移动端更明显。
本次目标只修复前端拖动跟手延迟,不改变拼图交换、合并、拆分、通关等玩法规则。
## 2. 根因
本轮定位到拖动延迟主要来自两个前端渲染问题:
1. `pointermove` 每一帧都调用 `setDragState`,导致整个 `PuzzleRuntimeShell` 和整盘拼图格子持续重渲染。
2. 拼块节点默认挂了 `transition`,拖动过程中的 `transform` 会被浏览器当成缓动动画处理,视觉上进一步放大“慢半拍”。
这两个问题叠加后,即使后端或本地运行态裁决没有延迟,前端拖动视觉仍然会滞后。
## 3. 修复口径
### 3.1 拖动视觉更新改为直写 DOM
拖动中的位移不再依赖 React state 持续驱动,而是改成:
1. `pointerdown` 只记录起点和 pointer 信息。
2. 超过拖动阈值后,只做一次 `setDraggingPieceId` 用于切换拖动态样式。
3. 后续 `pointermove` 通过 `requestAnimationFrame` 合帧,把位移直接写入目标拼块或合并块容器的 `style.transform`
这样可以避免每一帧都触发整盘 React 重渲染。
### 3.2 收紧过渡属性
拼块节点不再使用包含 `transform` 的通用 `transition`,只保留颜色、边框、阴影、透明度等非位移动画属性,避免拖动中的 transform 被浏览器插值缓动。
### 3.3 裁决边界保持不变
本次只优化拖动阶段的视觉反馈:
1. `pointerup` 后仍然走现有 `onDragPiece`
2. 单块交换、拖到合并块后拆分、合并块整体重排,继续沿用当前本地运行态规则。
3. 不新增前端本地裁决,不把玩法真相从既有运行态实现中分叉出去。
## 4. 验收标准
1. 单块拖动时拼块视觉位置应紧跟手指或鼠标,不再出现明显缓动拖尾。
2. 合并块整体拖动时,组容器应同步跟手移动。
3. 点击选中与拖动阈值判定仍保持原语义,不因为优化误触发交换。
4. 运行时现有结算弹窗、排行榜和下一关入口不受影响。
5. 定向测试覆盖拖动提交坐标的行为,并运行编码检查确保中文文档未被写坏。

View File

@@ -4,7 +4,12 @@
## 文档列表
- [PUZZLE_RUNTIME_DRAG_RESPONSE_FIX_2026-04-27.md](./PUZZLE_RUNTIME_DRAG_RESPONSE_FIX_2026-04-27.md):记录拼图运行时拖动跟手延迟的前端根因,冻结 `requestAnimationFrame + DOM transform` 直写方案与不改玩法裁决边界。
- [ASSET_HISTORY_PUZZLE_COVER_KIND_FIX_2026-04-27.md](./ASSET_HISTORY_PUZZLE_COVER_KIND_FIX_2026-04-27.md):记录资产历史接口补齐 `puzzle_cover_image` 白名单、错误文案与回归测试的修复口径。
- [PUZZLE_IMAGE_PROMPT_MODULE_EXTRACTION_2026-04-27.md](./PUZZLE_IMAGE_PROMPT_MODULE_EXTRACTION_2026-04-27.md):记录拼图图片生成提示词从 `puzzle.rs` 拆到 `prompt/puzzle_image.rs` 的后端边界,保持 DashScope、OSS 与 SpacetimeDB 写回逻辑不变。
- [PUZZLE_IMAGE_ASSET_PROXY_FIX_2026-04-27.md](./PUZZLE_IMAGE_ASSET_PROXY_FIX_2026-04-27.md):记录拼图生成图写入 `/generated-puzzle-assets` 后必须同步补齐 Axum 旧资源代理与 Vite dev proxy避免结果页候选图和参考图读取链路失效。
- [RPG_AND_AGENT_CHAT_TRUE_SSE_STREAMING_2026-04-26.md](./RPG_AND_AGENT_CHAT_TRUE_SSE_STREAMING_2026-04-26.md):记录 RPG 运行时 NPC 聊天、RPG/自定义世界 Agent 与大鱼 Agent 从“拼完整 SSE 字符串后一次性返回”改为 `mpsc + Sse<Event>` 真流式输出的后端落地口径。
- [RPG_SCENE_ACT_BACK_ROW_RUNTIME_PRESENTATION_FIX_2026-04-26.md](./RPG_SCENE_ACT_BACK_ROW_RUNTIME_PRESENTATION_FIX_2026-04-26.md):记录多幕场景后排两个角色未进入幕预览和正式游戏画布的根因,冻结当前幕环境角色渲染、运行时场景 id 别名匹配与主角色优先相遇口径。
- [RPG_BATTLE_HEALTHBAR_AND_ACTION_PRESENTATION_FIX_2026-04-26.md](./RPG_BATTLE_HEALTHBAR_AND_ACTION_PRESENTATION_FIX_2026-04-26.md):记录 RPG 战斗血条安全锚点、服务端战斗回包前端短表现,以及 `battle_use_skill` 指定技能兜底结算的修复口径。
- [SPACETIMEDB_TABLE_CATALOG.md](./SPACETIMEDB_TABLE_CATALOG.md):持续维护当前 SpacetimeDB 表目录,按领域说明每张表的作用、字段结构、索引和常用 `spacetime sql` 查询模板。
- [RPG_OPENING_SCENE_ACT_IMAGE_PRESENTATION_SYNC_2026-04-26.md](./RPG_OPENING_SCENE_ACT_IMAGE_PRESENTATION_SYNC_2026-04-26.md):记录开局场景与普通场景复用同一场景展示解析服务,修复列表幕缩略图和详情幕背景预览图片不一致的问题。

View File

@@ -0,0 +1,152 @@
# RPG 运行态首段剧情启动修复记录2026-04-26
## 背景
点击 RPG 玩法测试作品进入游戏后,画布能正常显示地图、玩家和当前场景标题,但底部冒险区域为空,没有剧情文本和操作按钮。
进一步复查后确认,空白并不只是 `currentStory` 未生成。作品测试与正常进入游戏都应该走同一条链路:开局场景第一幕 -> 当前幕主 NPC 出现在对面 -> 直接开始聊天。正式运行态当时没有把第一幕的 NPC 配置合并进场景 NPC 列表,导致第一幕主 NPC 找不到。
## 问题定位
1. RPG 作品进入运行态后,`handleCharacterSelect()` 会把 `GameState.currentScene` 切到 `Story`,并设置玩家角色、场景和运行时状态。
2. 冒险面板挂载条件是 `visibleGameState.playerCharacter && visibleCurrentStory`,而 `currentStory``useRpgRuntimeStoryController` 管理。
3. 当前进入 `Story` 后没有自动请求首段剧情,导致 `visibleCurrentStory` 一直为 `null`,路由器不会挂载 `RpgRuntimePanelRouter`,底部区域因此保持空白。
4. 幕预览会显式构造 `previewScenePreset``previewEncounter``currentSceneActState`;正式运行态原先只从 `landmark.sceneNpcIds` 编译 `ScenePreset.npcs`,没有把 `sceneChapterBlueprints[].acts[].encounterNpcIds / primaryNpcId / oppositeNpcId` 合并进去,所以第一幕主角色不会稳定出现。
5. 首段普通剧情自动生成如果不避让 `currentEncounter`,会抢在 NPC 聊天流程前进入 loading导致“直接和当前幕主 NPC 聊天”的产品语义被破坏。
## 落地约束
1. 修复必须补齐真实运行态数据链路,不能只在 UI 上写静态提示文案。
2. 首段剧情仍使用现有 `generateInitialStory()``buildStoryFromResponse()` 处理,保持前端只负责表现和运行态装配。
3. 请求失败时使用现有 fallback story 生成逻辑,保证冒险面板仍有可交互选项。
4. 正常进入游戏和作品测试必须同源:优先从开局第一幕所在场景启动,并加载该幕的主 NPC / 遭遇。
5. 第一幕已有 `currentEncounter` 时,由 NPC 交互流接管首轮聊天,不再启动普通开局叙事。
## 本次修改
1. `src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.ts`
- 增加 Story 场景启动 effect当存在 `playerCharacter``worldType``currentScene === 'Story'`,同时 `currentStory` 为空时,自动调用 `generateStoryForState()` 请求首段剧情。
- 增加 request key避免同一场景重复触发并发首段请求请求结束后释放 key允许失败后再次触发。
- 请求失败时设置 `aiError`,并回退到 `buildFallbackStoryForState()`
2. `src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.test.tsx`
- 覆盖“进入 Story 场景且首段剧情为空时自动请求开局剧情”。
- 覆盖“已有当前幕 NPC 遭遇时不抢先请求普通开局剧情”,保证 NPC 交互流可以直接接管首轮聊天。
3. `src/hooks/rpg-session/useRpgSessionBootstrap.ts`
- 自定义世界选角进入游戏时,优先解析 `sceneChapterBlueprints` 的第一章第一幕所在场景。
- 开局场景选择优先绑定 `profile.landmarks[0]` 对应的章节/第一幕,避免章节数组顺序与场景列表顺序不一致时进入后续场景。
- 写入首个 `currentSceneActState`,让运行态背景、同幕角色和首个 encounter 使用同一套第一幕数据。
- 兼容旧作品:缺少多幕章节时,回退到第一个带场景角色的 landmark而不是停在空营地。
4. `src/data/scenePresets.ts`
- 场景编译时把多幕配置中的 `primaryNpcId``oppositeNpcId``encounterNpcIds` 合并进正式 `ScenePreset.npcs`
- 支持第一幕 NPC 只存在于多幕配置、不存在于旧 `landmark.sceneNpcIds` 的新作品数据。
5. `src/data/customWorldLibrary.ts`
- 保存档规范化时保留 `acts[].sceneId`,不再强制用章节 `sceneId` 覆盖,避免第一幕真实场景丢失。
6. `src/services/customWorldSceneActRuntime.ts`
- 场景章节匹配同时识别运行态场景 id、landmark id、章节 linked landmark 和 act scene id。
- 当前幕 NPC 集合同时包含 `primaryNpcId``oppositeNpcId``encounterNpcIds`,避免生成数据只写对面角色时被运行态漏掉。
- 正式场景遭遇的焦点 NPC 优先读取 `oppositeNpcId`,再回退到 `primaryNpcId` 和首个 encounter NPC。
7. `src/data/sceneEncounterPreviews.test.ts`
- 覆盖运行态场景 id 与 landmark id 不一致时仍能解析当前幕 NPC。
- 覆盖章节 `sceneId` 是抽象值、第一幕 `act.sceneId` 才是真实 landmark且只写 `oppositeNpcId` 时仍能解析当前幕 NPC。
- 覆盖当前幕对面 NPC 会优先成为正式场景 encounter。
8. `src/hooks/useGameFlow.customWorld.test.tsx`
- 覆盖正常进入自定义世界时会进入第一幕场景,并加载只存在于第一幕配置里的对面 NPC。
- 覆盖章节数组第一项不是开局场景时,仍以第一个 landmark 的第一幕作为开局。
## 2026-04-27 复查修正
用户复测后确认:开局场景本身可以是 `camp`,不能再把 `profile.landmarks[0]` 当作更高优先级的“真实开局”。运行态必须直接信任 `sceneChapterBlueprints[0].acts[0]`
1. `src/hooks/rpg-session/useRpgSessionBootstrap.ts`
- 自定义世界确认角色后,开局场景优先解析第一章第一幕的 `act.sceneId`,再回退到章节 `sceneId``linkedLandmarkIds`
- `custom-scene-camp` 可以作为正式开局场景进入,不再被第一个 landmark 覆盖。
- 只有缺少多幕章节时,才回退到旧作品的“带 NPC 地标 / 第一个地标”兼容逻辑。
2. `src/services/customWorldSceneActRuntime.ts`
- 当前幕解析兼容精简 profile 和旧快照,避免 `landmarks` 缺失时中断 NPC 聊天链路。
- 章节匹配继续同时识别运行态场景 id、camp id、landmark id、章节关联地标和 act scene id。
3. `src/hooks/useGameFlow.customWorld.test.tsx`
- 新增接近真实运行态的 hook 组合断言:选择世界、确认角色后进入 `custom-scene-camp` 的第一幕,当前 encounter 是 `oppositeNpcId` 对应的陆衡,并触发 NPC 主动开场聊天。
本轮修正后的产品语义:作品测试和正常进入游戏保持同源,进入游戏后以开局场景第一幕为准,直接加载当前幕对面 NPC并由 NPC 主动开启聊天。
## 验证
已执行:
```bash
npm test -- --run src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.test.tsx
npm test -- --run src/hooks/useGameFlow.customWorld.test.tsx src/data/sceneEncounterPreviews.test.ts src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.test.tsx
npx eslint src/data/customWorldLibrary.ts src/data/scenePresets.ts src/services/customWorldSceneActRuntime.ts src/hooks/rpg-session/useRpgSessionBootstrap.ts src/hooks/useGameFlow.customWorld.test.tsx src/data/sceneEncounterPreviews.test.ts src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.ts src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.test.tsx
npm run typecheck -- --pretty false
npm run check:encoding
```
局部测试、局部 ESLint、全量类型检查与编码检查均通过。后端代码未在本次任务中修改因此未执行 `npm run api-server:maincloud`
## 2026-04-27 第二轮复查修正
用户继续复测后发现两类问题:
1. 第一幕已经配置了 `oppositeNpcId`,但运行态对面角色仍可能不是第一幕主 NPC。
2. 战斗后 React 报错 `Encountered two children with the same key, monster-16`
本轮定位结论:
1. 第一幕 encounter 选择原先先走“友好 NPC 池”。如果第一幕 `oppositeNpcId` 是负好感或敌对标记角色,会被友好池过滤掉,随后可能回退到同幕其他角色,导致开局对面角色不对。
2. 负好感有限聊天原先只识别 `primaryNpcId`。当产品语义要求 `oppositeNpcId` 是第一幕正面对话角色时,敌意对面角色仍应先开聊天,而不是直接触发战斗。
3. 战斗奖励弹层按 `battleReward.id + hostileNpc.id` 作为 key同一场战斗击败两个同 preset 怪物时,两个条目都会是 `monster-16`,从而触发 React 重复 key 报错。
本轮修改:
1. `src/data/sceneEncounterPreviews.ts`
- 新增当前幕专用 NPC 池:只要角色属于 `primaryNpcId / oppositeNpcId / encounterNpcIds`,即使是负好感或敌对标记,也允许进入当前幕 encounter 候选。
- 只有非幕级随机遭遇继续使用原友好 NPC 池,避免误改普通野外战斗规则。
2. `src/services/customWorldSceneActRuntime.ts`
- 负好感有限聊天同时识别 `primaryNpcId``oppositeNpcId`
- 当前幕解析优先尊重 `currentSceneActState.chapterId/currentActId`,再回退到场景匹配,避免同一场景多章节时抢错当前幕。
3. `src/hooks/rpg-runtime-story/storyChoiceRuntime.ts`
- 战斗奖励中的 `defeatedHostileNpcs` 增加 `renderKey`,包含怪物 id、名称、位置与序号。
4. `src/components/rpg-runtime-panels/RpgAdventurePanelOverlays.tsx`
- 战斗奖励击败列表优先使用 `renderKey` 作为 React key。
本轮补充测试:
1. `src/data/sceneEncounterPreviews.test.ts`
- 覆盖第一幕 `oppositeNpcId` 是敌意角色时,仍作为正式 encounter且不会自动进入战斗。
2. `src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts`
- 覆盖同一场战斗击败两个 `monster-16` 时,奖励摘要生成唯一 `renderKey`
验证命令:
```bash
npm test -- --run src/data/sceneEncounterPreviews.test.ts src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts src/hooks/useGameFlow.customWorld.test.tsx
npx eslint src/data/sceneEncounterPreviews.ts src/data/sceneEncounterPreviews.test.ts src/services/customWorldSceneActRuntime.ts src/hooks/rpg-runtime-story/storyChoiceRuntime.ts src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts src/hooks/rpg-runtime-story/uiTypes.ts src/components/rpg-runtime-panels/RpgAdventurePanelOverlays.tsx
```
以上局部测试与局部 ESLint 已通过。后端代码未在本轮修改中触碰,因此不需要执行 `npm run api-server:maincloud`
## 2026-04-27 第三轮复查修正
用户再次复测后确认,作品测试选完角色仍没有稳定等价于“开局场景第一幕的幕测试”。本轮重新沿真实入口链路复查:作品测试结果页进入世界后,会先进入角色选择页;真正的开局状态是在 `handleCharacterSelect()` 中生成。上一轮主要修在场景遭遇预览层,仍让自定义世界选角后的 `currentEncounter` 先置空,再由 `ensureSceneEncounterPreview()` 推断第一幕 NPC因此一旦候选池、场景编译或角色敌意标记出现偏差开局对面角色仍可能漂移。
本轮修正:
1. `src/hooks/rpg-session/useRpgSessionBootstrap.ts`
- 在选角确认阶段显式解析 `sceneChapterBlueprints[0].acts[0]`,将作品测试开局直接绑定到第一章第一幕。
- 第一幕 encounter 选择顺序固定为 `oppositeNpcId -> primaryNpcId -> encounterNpcIds`,并跳过当前玩家角色。
- 优先从当前场景编译出的 `ScenePreset.npcs` 构造 encounter如果第一幕角色只存在于 `storyNpcs/playableNpcs`,也会直接从作品角色配置构造 encounter不再依赖预览兜底。
- 为构造出的开局 encounter 同步初始化 `npcStates`,保证后续 NPC 主动开场聊天可以读取正确关系状态。
2. `src/hooks/useGameFlow.customWorld.test.tsx`
- 增加断言:选角后当前 encounter 必须是第一幕 `oppositeNpcId` 对应的陆衡,不能回退到 `primaryNpcId`
本轮语义收敛为:作品测试选择角色完成后,不再只是“进入自定义世界后生成一个场景遭遇”,而是直接加载开局场景第一幕的运行态快照;对面角色由第一幕 `oppositeNpcId` 决定,并由 NPC 主动开启聊天。
验证命令:
```bash
npm test -- --run src/hooks/useGameFlow.customWorld.test.tsx src/data/sceneEncounterPreviews.test.ts src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts
npx eslint src/hooks/rpg-session/useRpgSessionBootstrap.ts src/hooks/useGameFlow.customWorld.test.tsx src/data/sceneEncounterPreviews.ts src/services/customWorldSceneActRuntime.ts src/hooks/rpg-runtime-story/storyChoiceRuntime.ts src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts src/hooks/rpg-runtime-story/uiTypes.ts src/components/rpg-runtime-panels/RpgAdventurePanelOverlays.tsx
npm run typecheck -- --pretty false
```
以上局部测试、局部 ESLint 与全量类型检查已通过。后端代码未在本轮修改中触碰,因此仍不需要执行 `npm run api-server:maincloud`

View File

@@ -0,0 +1,41 @@
# RPG 幕场景后排角色运行时展示修复2026-04-26
## 背景
多幕场景编辑器允许每一幕配置 `3` 个角色槽位:第一槽位是主角色,后两个槽位是后排同幕角色。问题出在配置已经写入 `sceneChapterBlueprints[*].acts[*].encounterNpcIds` 后,幕预览只稳定展示第一槽位主角色,后排两个角色没有进入真实游戏画布。
## 根因
1. 幕预览启动时只把 `encounterNpcIds[0]` 转成 `currentEncounter`
2. `GameCanvasEntityLayer` 原先只渲染玩家、同伴、战斗敌人和单个 `currentEncounter`,没有“当前幕环境角色”层。
3. 正式自定义世界运行时场景 id 使用 `custom-scene-landmark-*`,而 `sceneChapterBlueprints` 常保存原始 `landmark.id`,导致部分正式游戏场景不能稳定命中当前幕蓝图。
4. 场景相遇逻辑虽会读取当前幕 NPC 池,但会从池中随机选角色,可能让后排角色顶替主角色成为正式相遇对象。
## 修复口径
1. `customWorldSceneActRuntime` 增加自定义世界运行时场景别名匹配:
- `custom-scene-camp`
- `profile.camp.id`
- `custom-scene-landmark-{index}`
- `profile.landmarks[index].id`
2. `GameCanvasRuntime` 根据当前活跃幕的 `encounterNpcIds`,从 `currentScenePreset.npcs` 中解析除 `currentEncounter` 外的后排角色,并传给画布实体层。
3. `GameCanvasEntityLayer` 新增 `sceneActAmbientEncounters` 渲染分支:
- 仅在非战斗态展示;
- 使用同一列后排站位,上下错开;
- 不抢占 `currentEncounter`,因此聊天、战斗、有限聊天仍由主角色驱动;
- 后排角色仍可点击打开详情。
4. `sceneEncounterPreviews` 在当前幕存在 `primaryNpcId` 时,正式相遇优先选择主角色,后排角色保留为同幕可见实体。
## 正式游戏检查结论
后端草稿与发布档案中的 `encounterNpcIds` 没有丢失,本次问题主要在前端运行时装配与画布展示层。修复后:
1. 幕预览会展示主角色和后排两个角色。
2. 正式游戏进入对应自定义世界场景时,可通过运行时场景 id 命中原始幕蓝图。
3. 正式游戏的当前交互目标仍是 `primaryNpcId`,后排两个角色按当前幕环境实体展示。
## 回归
1. `GameCanvasEntityLayer.test.tsx` 覆盖后排两个 `sceneActAmbientEncounters` 与主角色同时渲染。
2. `sceneEncounterPreviews.test.ts` 覆盖运行时场景 id 对原始 landmark id 的幕蓝图匹配。
3. `sceneEncounterPreviews.test.ts` 覆盖正式相遇优先选择当前幕 `primaryNpcId`

View File

@@ -0,0 +1,20 @@
# 运行时预览与测试作品存档隔离2026-04-26
## 背景
幕预览和测试作品用于创作者检查玩法表现,不能被当作玩家正式游玩记录。若这类运行时复用正式 RPG 壳、story action 或 snapshot 接口,必须在进入个人存档页、游玩统计、作品游玩历史前被过滤。
## 落地约束
1. 前端预览态 `GameState` 必须写入 `runtimeMode: "preview"``runtimeMode: "test"`
2. 前端可同步写入 `runtimePersistenceDisabled: true` 作为更明确的禁存标记。
3. `useRpgSessionPersistence` 自动存档必须跳过上述预览/测试态。
4. runtime story 网关仍提交带禁存标记的 `snapshot`,避免服务端退回读取用户正式快照;服务端必须按禁存标记返回临时响应而不落库。
5. SpacetimeDB projection 层必须兜底识别上述标记:即便有旧入口误写 `runtime_snapshot`,也不刷新 `profile_save_archive``profile_played_world``profile_dashboard_state` 和钱包流水。
## 当前实现
1. 幕预览运行时在启动游戏壳时写入 `runtimeMode: "preview"``runtimePersistenceDisabled: true`
2. 前端自动存档会跳过预览/测试态。
3. runtime story 接口收到预览/测试快照时,只构造本次响应所需的临时 snapshot不写入 `runtime_snapshot`
4. `server-rs/crates/spacetime-module/src/runtime/profile.rs` 在 profile projection 同步前统一短路预览/测试快照。

View File

@@ -43,3 +43,17 @@
1. `custom_world_rpg_draft_prompts.rs` 只作为兼容 re-export后续不要在该文件新增提示词正文。
2. `runtime_story/compat/ai.rs` 只负责读取状态、调用 LLM 和组装返回,不再内联 NPC 对话或剧情导演提示词。
3. 后续所有 Agent 共创聊天、运行时角色聊天的提示词调整统一进入 `src/prompt/`
## 6. 运行时 NPC 聊天 Prompt 归并
2026-04-26 追加收口:
1. 删除 `server-rs/crates/api-server/src/runtime_chat_prompt.rs` 独立提示词脚本,避免 `runtime_chat` 相关提示词散落在 `src/` 根目录。
2. `server-rs/crates/api-server/src/prompt/runtime_chat.rs` 统一承接:
- 运行时剧情导演 system prompt 与 user prompt。
- NPC 对话导演 system prompt 与 user prompt。
- NPC 单轮聊天回复 system prompt 与 user prompt。
- NPC 下一轮 `suggestions` / `functionSuggestions` 的 JSON 输出约束。
- LLM 不可用时的聊天 reply、普通 choice、function choice 兜底生成。
3. `server-rs/crates/api-server/src/runtime_chat.rs` 只保留 Axum SSE、LLM 调用、解析、好感变化、结束聊天判断等流程编排,不再直接承载提示词正文或 choice 文案兜底。
4. 后续调整聊天 choice 语气、候选数量、`functionOptions` 描述方式、敌对聊天收束策略时,优先修改 `prompt/runtime_chat.rs`