From fa2dbb310b386e5265f718c52c369f934e46d3f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Mon, 27 Apr 2026 14:23:19 +0800 Subject: [PATCH] 1 --- ...ATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md | 75 +- ...ER_PLATFORM_LLM_PROXY_DESIGN_2026-04-21.md | 20 + ...ISTORY_PUZZLE_COVER_KIND_FIX_2026-04-27.md | 23 + ...NTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md | 60 + ...PUZZLE_IMAGE_ASSET_PROXY_FIX_2026-04-27.md | 31 + ...AGE_PROMPT_MODULE_EXTRACTION_2026-04-27.md | 26 + ...RESULT_MODAL_AND_LEADERBOARD_2026-04-26.md | 96 ++ ...ULT_FORMAL_IMAGE_REFRESH_FIX_2026-04-27.md | 37 + ...LE_RUNTIME_DRAG_RESPONSE_FIX_2026-04-27.md | 50 + docs/technical/README.md | 5 + ..._OPENING_STORY_BOOTSTRAP_FIX_2026-04-26.md | 152 ++ ...ROW_RUNTIME_PRESENTATION_FIX_2026-04-26.md | 41 + ..._TEST_SAVE_ARCHIVE_ISOLATION_2026-04-26.md | 20 + ...TION_PROMPT_SCRIPT_MIGRATION_2026-04-25.md | 14 + .../src/contracts/puzzleAgentActions.ts | 1 + .../src/contracts/puzzleRuntimeSession.ts | 12 + server-rs/crates/api-server/src/app.rs | 6 +- server-rs/crates/api-server/src/assets.rs | 38 +- server-rs/crates/api-server/src/big_fish.rs | 2 + .../api-server/src/big_fish_agent_turn.rs | 2 + server-rs/crates/api-server/src/config.rs | 29 + .../api-server/src/creation_agent_llm_turn.rs | 33 +- .../crates/api-server/src/custom_world.rs | 2 + .../api-server/src/custom_world_agent_turn.rs | 4 + .../api-server/src/legacy_generated_assets.rs | 7 + server-rs/crates/api-server/src/main.rs | 1 - server-rs/crates/api-server/src/prompt/mod.rs | 1 + .../api-server/src/prompt/puzzle_image.rs | 44 + .../api-server/src/prompt/runtime_chat.rs | 723 ++++++++++ server-rs/crates/api-server/src/puzzle.rs | 408 +++++- .../api-server/src/puzzle_agent_turn.rs | 2 + .../crates/api-server/src/runtime_chat.rs | 68 +- .../api-server/src/runtime_chat_prompt.rs | 644 --------- .../crates/api-server/src/runtime_save.rs | 70 +- .../api-server/src/runtime_story/compat.rs | 65 +- .../src/runtime_story/compat/tests.rs | 127 ++ server-rs/crates/module-puzzle/src/lib.rs | 675 ++++++++- .../shared-contracts/src/puzzle_agent.rs | 2 + .../shared-contracts/src/puzzle_runtime.rs | 20 + .../src/asset_metadata/objects.rs | 6 +- .../crates/spacetime-module/src/puzzle.rs | 34 +- .../spacetime-module/src/runtime/profile.rs | 27 + src/components/CustomWorldEntityCatalog.tsx | 48 +- src/components/ResolvedAssetImage.tsx | 6 +- .../GameCanvasEntityLayer.test.tsx | 49 + .../game-canvas/GameCanvasEntityLayer.tsx | 125 +- .../game-canvas/GameCanvasRuntime.tsx | 45 +- .../PlatformEntryFlowShellImpl.tsx | 62 +- .../puzzle-result/PuzzleResultView.test.tsx | 425 ++++++ .../puzzle-result/PuzzleResultView.tsx | 1284 ++++++++++++----- .../PuzzleRuntimeShell.test.tsx | 126 ++ .../puzzle-runtime/PuzzleRuntimeShell.tsx | 546 ++++++- .../RpgCreationEntityEditorShared.tsx | 61 +- .../RpgAdventurePanelOverlays.tsx | 3 +- src/data/customWorldLibrary.ts | 4 +- src/data/sceneEncounterPreviews.test.ts | 346 ++++- src/data/sceneEncounterPreviews.ts | 129 +- src/data/scenePresets.ts | 87 +- .../storyChoiceRuntime.test.ts | 29 + .../rpg-runtime-story/storyChoiceRuntime.ts | 10 +- src/hooks/rpg-runtime-story/uiTypes.ts | 2 +- .../useRpgRuntimeStoryController.test.tsx | 213 +++ .../useRpgRuntimeStoryController.ts | 89 +- .../rpg-session/useRpgSessionBootstrap.ts | 257 +++- .../rpg-session/useRpgSessionPersistence.ts | 3 + src/hooks/useGameFlow.customWorld.test.tsx | 176 ++- src/hooks/useResolvedAssetReadUrl.test.tsx | 58 +- src/hooks/useResolvedAssetReadUrl.ts | 9 +- src/services/assetReadUrlService.ts | 67 +- src/services/customWorldSceneActRuntime.ts | 130 +- .../puzzle-runtime/puzzleLocalRuntime.test.ts | 234 +++ .../puzzle-runtime/puzzleLocalRuntime.ts | 479 +++++- .../puzzle-works/puzzleAssetClient.ts | 37 + src/types/game.ts | 3 + vite.config.ts | 5 + 75 files changed, 7363 insertions(+), 1487 deletions(-) create mode 100644 docs/technical/ASSET_HISTORY_PUZZLE_COVER_KIND_FIX_2026-04-27.md create mode 100644 docs/technical/PUZZLE_IMAGE_ASSET_PROXY_FIX_2026-04-27.md create mode 100644 docs/technical/PUZZLE_IMAGE_PROMPT_MODULE_EXTRACTION_2026-04-27.md create mode 100644 docs/technical/PUZZLE_LEVEL_CLEAR_RESULT_MODAL_AND_LEADERBOARD_2026-04-26.md create mode 100644 docs/technical/PUZZLE_RESULT_FORMAL_IMAGE_REFRESH_FIX_2026-04-27.md create mode 100644 docs/technical/PUZZLE_RUNTIME_DRAG_RESPONSE_FIX_2026-04-27.md create mode 100644 docs/technical/RPG_RUNTIME_OPENING_STORY_BOOTSTRAP_FIX_2026-04-26.md create mode 100644 docs/technical/RPG_SCENE_ACT_BACK_ROW_RUNTIME_PRESENTATION_FIX_2026-04-26.md create mode 100644 docs/technical/RUNTIME_PREVIEW_TEST_SAVE_ARCHIVE_ISOLATION_2026-04-26.md create mode 100644 server-rs/crates/api-server/src/prompt/puzzle_image.rs delete mode 100644 server-rs/crates/api-server/src/runtime_chat_prompt.rs create mode 100644 src/components/puzzle-result/PuzzleResultView.test.tsx create mode 100644 src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx create mode 100644 src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.test.tsx create mode 100644 src/services/puzzle-works/puzzleAssetClient.ts diff --git a/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md b/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md index 7b239e39..0a56895f 100644 --- a/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md +++ b/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md @@ -142,7 +142,7 @@ 落地规则: 1. 已发布拼图作品必须优先通过 `sourceSessionId` 恢复原 Agent session。 -2. 恢复后的结果页沿用原草稿、候选图、正式图、标题、摘要和标签;创作者可以继续改标题、摘要、标签,并重新生成或切换图片。 +2. 恢复后的结果页沿用原草稿、当前正式图、标题、摘要和标签;创作者可以继续改标题、摘要、标签,并重新生成图片。 3. 再次点击发布时不得创建新作品,必须覆盖同一个 `profileId / workId`。 4. 覆盖发布只更新作品内容、更新时间、发布时间与广场投影;不得清零 `playCount`,不得改变作品归属。 5. 如果历史作品缺少 `sourceSessionId`,前端只能退回作品详情,不伪造编辑 session。 @@ -262,7 +262,7 @@ interface PuzzleAnchorPack { 1. 展示当前关卡名 2. 管理拼图图片生成 3. 展示并编辑题材标签 -4. 预览作者信息 +4. 执行作品测试 5. 执行发布 ## 7.2 结果页必备字段 @@ -276,10 +276,28 @@ interface PuzzleAnchorPack { 本次建议同时加入: 1. `Agent 理解摘要` -2. `创作者署名预览` -3. `图片生成状态` +2. `图片生成状态` +3. `作品测试按钮` 4. `发布按钮` +## 7.2.1 2026-04-26 结果页收口规则 + +本轮拼图结果页必须对齐 RPG 结果页的工作台形态,不再保留右侧信息总表。 + +落地规则: + +1. 页面顶部保留轻量返回区,主体使用页签式内容区,底部右侧使用操作区。 +2. 结果页只分 `2` 个 Tab: + - `基本信息`:只包含关卡名和题材标签。 + - `拼图图片`:直接展示当前正式图、画面描述输入与生成入口。 +3. 删除作者预览模块,不在结果页展示作者名、作者卡片或作者 HUD 说明。 +4. 删除常驻发布校验模块,不在页面右侧或内容区预先展示阻断项。 +5. 发布按钮放在屏幕右下角操作区,与 `作品测试` 并列;移动端保持底部可触达,页面内容区独立滚动。 +6. 点击 `发布` 时才弹出发布面板;如果不满足发布条件,发布面板展示阻断项并拦截正式发布。 +7. `作品测试` 不触发发布校验,直接用当前草稿进入拼图运行时。 +8. 移动端需要保证 Tab 内容区、图片编辑区、发布面板都可以正常纵向滚动;PC 端内容区使用更宽的预览与输入密度。 +9. 题材标签不显示输入框常驻编辑态;只展示已有标签 chip,支持删除已有标签,并通过新增动作添加新标签。 + ## 7.3 关卡名规则 关卡名生成规则建议如下: @@ -312,21 +330,45 @@ interface PuzzleAnchorPack { 1. 根据当前锚点生成正式拼图图片 2. 重新生成 -3. 应用某一张候选图 +3. 上传一张可选参考图后进行图生图 +4. 从历史拼图素材库中选择一张素材作为参考图 -第一版建议生成规则: +### 7.5.1 2026-04-27 单图替换规则 -1. 默认一次生成 `2` 张候选图 -2. 创作者选择 `1` 张作为正式图 -3. 正式图确定后,写回作品主图 +本轮起拼图结果页不再做多候选抽卡,而是采用“当前图直接替换”的轻量生成机制。 + +落地规则: + +1. 每次点击生成只请求 `1` 张拼图图片。 +2. 新图片生成成功后立即替换当前正式图,写回 `coverImageSrc / coverAssetId / selectedCandidateId`。 +3. `draft.candidates` 只保留当前这张图片对应的持久化记录,避免结果页继续展示历史候选池。 +4. 不再提供“应用某一张候选图”的创作端交互;`select_puzzle_image` 只保留为兼容旧草稿的后端能力。 +5. 再次生成失败时不得清空旧正式图,用户仍可发布或测试旧图。 +6. 参考图只影响本次生成请求,不持久化为拼图作品字段。 +7. 历史拼图素材库读取 `asset_kind = puzzle_cover_image` 的资产记录,只用于选择参考图,不直接替换正式图。 +8. 从历史素材库选择素材后,前端把该素材的 `imageSrc` 作为 `referenceImageSrc` 传入下一次生成请求。 +9. 本地上传参考图与历史素材参考图互斥;后选择者覆盖先选择者。 + +前端 UI 规则: + +1. `拼图图片` Tab 左侧或上方展示当前正式图,右侧或下方展示画面描述输入与生成按钮。 +2. 画面描述输入框上方必须显示标签 `画面描述`。 +3. `添加参考图` 按钮放入画面描述输入框右下角,作为小型 icon/button,不单独占用输入框外的大按钮区。 +4. 历史素材入口与添加参考图并列放在输入框右下角,打开独立素材选择面板,不在当前面板下方展开。 +5. UI 不默认写玩法规则说明,只展示必要状态、预览和操作。 +6. 移动端中输入框右下角按钮必须可点且不遮挡正文输入;输入框需要预留底部内边距。 后端落地契约: 1. `api-server` 写入 SpacetimeDB 的候选图 JSON 必须使用 `module-puzzle::PuzzleGeneratedImageCandidate` 持久化结构。 2. 持久化字段名保持 Rust 侧 `snake_case`,例如 `candidate_id`、`image_src`、`asset_id`、`actual_prompt`、`source_type`。 3. 面向前端的 HTTP 响应仍由 `shared-contracts` 单独映射为 `camelCase`,不能把响应层字段名直接写入 SpacetimeDB JSON。 -4. 多次生成候选图时必须追加到当前候选池,不能清空已有候选图;已有正式选择保持不变,新追加候选图默认不抢占 `selected` 状态。 -5. 追加生成时 `candidate_id` 必须按当前候选数量续号,避免前端列表 key 与后端选择动作命中旧候选图。 +4. 多次生成图片时必须替换当前候选记录,不能继续追加候选池;新记录默认 `selected = true`。 +5. 生成 `candidate_id` 可以继续按当前候选数量续号以保留调试可读性,但写入草稿时只保留新记录。 +6. 前端生成动作可携带 `referenceImageSrc`,字段语义对齐 RPG 场景图接口:支持单张 Data URL 或已有 `/generated-*` 旧路径。 +7. 当 `referenceImageSrc` 存在时,`api-server` 必须先把参考图归一化为 Data URL,再对齐 RPG 场景图接口调用 DashScope `multimodal-generation/generation` 的 messages 图生图接口;无参考图时继续走现有 `text2image/image-synthesis` 文生图接口。 +8. 图生图产物落盘、单图替换、正式图写回、发布校验与文生图完全复用同一套拼图图片链路。 +9. `api-server` 的历史素材接口必须允许 `puzzle_cover_image`,SpacetimeDB 侧历史素材 procedure 同步允许该类型。 ## 7.6 拼图图片资产要求 @@ -343,9 +385,11 @@ interface PuzzleAnchorPack { 交互要求如下: -1. 生成图片时打开独立面板,不在当前卡片下方内联堆出大块内容。 -2. 标签编辑应为轻量标签编辑器,不做大表单。 -3. 发布按钮必须固定清晰,不与图片生成操作混淆。 +1. 图片生成不再通过作者预览旁的小弹窗承载,而是在 `拼图图片` Tab 内围绕当前正式图直接承载。 +2. 标签编辑应为轻量标签 chip 编辑器,不做大表单,不常驻显示标签输入框。 +3. 发布按钮必须固定在结果页右下操作区,不与图片生成操作混淆。 +4. 点击发布后才展示发布阻断项;阻断项只存在于发布面板里。 +5. `作品测试` 与 `发布` 同属右下操作区,测试动作不被发布阻断项拦截。 --- @@ -502,6 +546,9 @@ function resolvePuzzleGridSize(clearedLevelCount: number): 3 | 4 { 1. 初始局面不是已完成态 2. 初始局面至少存在可推进空间 +3. 初始局面不能存在任何已经正确相邻的两块,避免玩家开局即看到自动合并块 + +初始化算法必须对候选打乱结果做正确相邻关系扫描:若任意两块在当前棋盘四向相邻,且它们在原图中的正确坐标也以相同方向相邻,则该候选布局无效,需要继续洗牌。多次随机尝试仍未得到合法布局时,使用确定性反序布局兜底;该布局等价于完整棋盘旋转 180 度,可保证原图相邻块不会以正确方向相邻。 ## 9.5 交互规则总览 diff --git a/docs/technical/API_SERVER_PLATFORM_LLM_PROXY_DESIGN_2026-04-21.md b/docs/technical/API_SERVER_PLATFORM_LLM_PROXY_DESIGN_2026-04-21.md index c949163e..00c7b881 100644 --- a/docs/technical/API_SERVER_PLATFORM_LLM_PROXY_DESIGN_2026-04-21.md +++ b/docs/technical/API_SERVER_PLATFORM_LLM_PROXY_DESIGN_2026-04-21.md @@ -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`。 diff --git a/docs/technical/ASSET_HISTORY_PUZZLE_COVER_KIND_FIX_2026-04-27.md b/docs/technical/ASSET_HISTORY_PUZZLE_COVER_KIND_FIX_2026-04-27.md new file mode 100644 index 00000000..abe8fd50 --- /dev/null +++ b/docs/technical/ASSET_HISTORY_PUZZLE_COVER_KIND_FIX_2026-04-27.md @@ -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` 是否已按最新源码重新编译并重启,而不是先回退前端类型参数。 diff --git a/docs/technical/PUZZLE_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md b/docs/technical/PUZZLE_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md index ff080ded..fc73c734 100644 --- a/docs/technical/PUZZLE_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md +++ b/docs/technical/PUZZLE_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md @@ -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`,最终交换、合并、拆分和通关继续以后端/本地运行态快照为准。 diff --git a/docs/technical/PUZZLE_IMAGE_ASSET_PROXY_FIX_2026-04-27.md b/docs/technical/PUZZLE_IMAGE_ASSET_PROXY_FIX_2026-04-27.md new file mode 100644 index 00000000..670b7c02 --- /dev/null +++ b/docs/technical/PUZZLE_IMAGE_ASSET_PROXY_FIX_2026-04-27.md @@ -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` 重启后,点击拼图结果页“生成或更换图片”,候选图应能写回并正常展示。 diff --git a/docs/technical/PUZZLE_IMAGE_PROMPT_MODULE_EXTRACTION_2026-04-27.md b/docs/technical/PUZZLE_IMAGE_PROMPT_MODULE_EXTRACTION_2026-04-27.md new file mode 100644 index 00000000..54e8a675 --- /dev/null +++ b/docs/technical/PUZZLE_IMAGE_PROMPT_MODULE_EXTRACTION_2026-04-27.md @@ -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。 diff --git a/docs/technical/PUZZLE_LEVEL_CLEAR_RESULT_MODAL_AND_LEADERBOARD_2026-04-26.md b/docs/technical/PUZZLE_LEVEL_CLEAR_RESULT_MODAL_AND_LEADERBOARD_2026-04-26.md new file mode 100644 index 00000000..5c6a0ebf --- /dev/null +++ b/docs/technical/PUZZLE_LEVEL_CLEAR_RESULT_MODAL_AND_LEADERBOARD_2026-04-26.md @@ -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 相关单测通过。 diff --git a/docs/technical/PUZZLE_RESULT_FORMAL_IMAGE_REFRESH_FIX_2026-04-27.md b/docs/technical/PUZZLE_RESULT_FORMAL_IMAGE_REFRESH_FIX_2026-04-27.md new file mode 100644 index 00000000..89e5d684 --- /dev/null +++ b/docs/technical/PUZZLE_RESULT_FORMAL_IMAGE_REFRESH_FIX_2026-04-27.md @@ -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. 如果后端后续改成稳定对象键覆写,也不能移除前端结果页的主动刷新锚点 diff --git a/docs/technical/PUZZLE_RUNTIME_DRAG_RESPONSE_FIX_2026-04-27.md b/docs/technical/PUZZLE_RUNTIME_DRAG_RESPONSE_FIX_2026-04-27.md new file mode 100644 index 00000000..7fa465b9 --- /dev/null +++ b/docs/technical/PUZZLE_RUNTIME_DRAG_RESPONSE_FIX_2026-04-27.md @@ -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. 定向测试覆盖拖动提交坐标的行为,并运行编码检查确保中文文档未被写坏。 diff --git a/docs/technical/README.md b/docs/technical/README.md index 9e61e28c..9a7f3195 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -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` 真流式输出的后端落地口径。 +- [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):记录开局场景与普通场景复用同一场景展示解析服务,修复列表幕缩略图和详情幕背景预览图片不一致的问题。 diff --git a/docs/technical/RPG_RUNTIME_OPENING_STORY_BOOTSTRAP_FIX_2026-04-26.md b/docs/technical/RPG_RUNTIME_OPENING_STORY_BOOTSTRAP_FIX_2026-04-26.md new file mode 100644 index 00000000..e6c93905 --- /dev/null +++ b/docs/technical/RPG_RUNTIME_OPENING_STORY_BOOTSTRAP_FIX_2026-04-26.md @@ -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`。 diff --git a/docs/technical/RPG_SCENE_ACT_BACK_ROW_RUNTIME_PRESENTATION_FIX_2026-04-26.md b/docs/technical/RPG_SCENE_ACT_BACK_ROW_RUNTIME_PRESENTATION_FIX_2026-04-26.md new file mode 100644 index 00000000..d093214b --- /dev/null +++ b/docs/technical/RPG_SCENE_ACT_BACK_ROW_RUNTIME_PRESENTATION_FIX_2026-04-26.md @@ -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`。 diff --git a/docs/technical/RUNTIME_PREVIEW_TEST_SAVE_ARCHIVE_ISOLATION_2026-04-26.md b/docs/technical/RUNTIME_PREVIEW_TEST_SAVE_ARCHIVE_ISOLATION_2026-04-26.md new file mode 100644 index 00000000..146fded9 --- /dev/null +++ b/docs/technical/RUNTIME_PREVIEW_TEST_SAVE_ARCHIVE_ISOLATION_2026-04-26.md @@ -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 同步前统一短路预览/测试快照。 diff --git a/docs/technical/SERVER_RS_GENERATION_PROMPT_SCRIPT_MIGRATION_2026-04-25.md b/docs/technical/SERVER_RS_GENERATION_PROMPT_SCRIPT_MIGRATION_2026-04-25.md index 70da17e3..20d02fe5 100644 --- a/docs/technical/SERVER_RS_GENERATION_PROMPT_SCRIPT_MIGRATION_2026-04-25.md +++ b/docs/technical/SERVER_RS_GENERATION_PROMPT_SCRIPT_MIGRATION_2026-04-25.md @@ -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`。 diff --git a/packages/shared/src/contracts/puzzleAgentActions.ts b/packages/shared/src/contracts/puzzleAgentActions.ts index 2cc6c2c4..bfb65203 100644 --- a/packages/shared/src/contracts/puzzleAgentActions.ts +++ b/packages/shared/src/contracts/puzzleAgentActions.ts @@ -43,6 +43,7 @@ export type PuzzleAgentActionRequest = | { action: 'generate_puzzle_images'; promptText?: string | null; + referenceImageSrc?: string | null; candidateCount?: number; } | { diff --git a/packages/shared/src/contracts/puzzleRuntimeSession.ts b/packages/shared/src/contracts/puzzleRuntimeSession.ts index 22ebcf83..81e1e7ac 100644 --- a/packages/shared/src/contracts/puzzleRuntimeSession.ts +++ b/packages/shared/src/contracts/puzzleRuntimeSession.ts @@ -20,6 +20,13 @@ export interface PuzzleMergedGroupState { occupiedCells: PuzzleCellPosition[]; } +export interface PuzzleLeaderboardEntry { + rank: number; + nickname: string; + elapsedMs: number; + isCurrentPlayer?: boolean; +} + export interface PuzzleBoardSnapshot { rows: number; cols: number; @@ -40,6 +47,10 @@ export interface PuzzleRuntimeLevelSnapshot { coverImageSrc: string | null; board: PuzzleBoardSnapshot; status: 'playing' | 'cleared'; + startedAtMs: number; + clearedAtMs: number | null; + elapsedMs: number | null; + leaderboardEntries: PuzzleLeaderboardEntry[]; } export interface PuzzleRunSnapshot { @@ -52,6 +63,7 @@ export interface PuzzleRunSnapshot { previousLevelTags: string[]; currentLevel: PuzzleRuntimeLevelSnapshot | null; recommendedNextProfileId: string | null; + leaderboardEntries: PuzzleLeaderboardEntry[]; } export interface StartPuzzleRunRequest { diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 4323ea3b..b5fcb2d3 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -68,7 +68,7 @@ use crate::{ proxy_generated_animations, proxy_generated_big_fish_assets, proxy_generated_character_drafts, proxy_generated_characters, proxy_generated_custom_world_covers, proxy_generated_custom_world_scenes, - proxy_generated_qwen_sprites, + proxy_generated_puzzle_assets, proxy_generated_qwen_sprites, }, llm::proxy_llm_chat_completions, login_options::auth_login_options, @@ -188,6 +188,10 @@ pub fn build_router(state: AppState) -> Router { "/generated-big-fish-assets/{*path}", get(proxy_generated_big_fish_assets), ) + .route( + "/generated-puzzle-assets/{*path}", + get(proxy_generated_puzzle_assets), + ) .route( "/generated-custom-world-scenes/{*path}", get(proxy_generated_custom_world_scenes), diff --git a/server-rs/crates/api-server/src/assets.rs b/server-rs/crates/api-server/src/assets.rs index 122e26e1..4c337666 100644 --- a/server-rs/crates/api-server/src/assets.rs +++ b/server-rs/crates/api-server/src/assets.rs @@ -27,6 +27,13 @@ use crate::{ state::AppState, }; +// 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。 +const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 3] = [ + "character_visual", + "scene_image", + "puzzle_cover_image", +]; + pub async fn create_direct_upload_ticket( State(state): State, Extension(request_context): Extension, @@ -118,11 +125,11 @@ pub async fn get_asset_history( Query(query): Query, ) -> Result, AppError> { let asset_kind = query.kind.trim().to_string(); - if asset_kind != "character_visual" && asset_kind != "scene_image" { + if !is_supported_asset_history_kind(asset_kind.as_str()) { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "field": "kind", - "message": "历史素材类型只支持 character_visual 或 scene_image", + "message": supported_asset_history_kind_message(), })), ); } @@ -288,6 +295,17 @@ fn format_asset_owner_label(owner_user_id: Option<&str>) -> String { format!("账号 {owner_user_id}") } +fn is_supported_asset_history_kind(asset_kind: &str) -> bool { + SUPPORTED_ASSET_HISTORY_KINDS.contains(&asset_kind) +} + +fn supported_asset_history_kind_message() -> String { + format!( + "历史素材类型只支持 {}", + SUPPORTED_ASSET_HISTORY_KINDS.join("、") + ) +} + async fn build_confirm_asset_object_upsert_input( oss_client: &platform_oss::OssClient, payload: ConfirmAssetObjectRequest, @@ -457,6 +475,22 @@ mod tests { type HmacSha1 = Hmac; + #[test] + fn asset_history_kind_support_includes_puzzle_cover_image() { + assert!(super::is_supported_asset_history_kind("character_visual")); + assert!(super::is_supported_asset_history_kind("scene_image")); + assert!(super::is_supported_asset_history_kind("puzzle_cover_image")); + assert!(!super::is_supported_asset_history_kind("puzzle_preview_image")); + } + + #[test] + fn asset_history_kind_message_lists_all_supported_kinds() { + assert_eq!( + super::supported_asset_history_kind_message(), + "历史素材类型只支持 character_visual、scene_image、puzzle_cover_image" + ); + } + #[tokio::test] async fn direct_upload_ticket_returns_service_unavailable_when_oss_missing() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); diff --git a/server-rs/crates/api-server/src/big_fish.rs b/server-rs/crates/api-server/src/big_fish.rs index 300c539a..75ca44a0 100644 --- a/server-rs/crates/api-server/src/big_fish.rs +++ b/server-rs/crates/api-server/src/big_fish.rs @@ -238,6 +238,7 @@ pub async fn submit_big_fish_message( llm_client: state.llm_client(), session: &submitted_session, quick_fill_requested: payload.quick_fill_requested.unwrap_or(false), + enable_web_search: state.config.creation_agent_llm_web_search_enabled, }, move |text| { draft_sink.persist_visible_text_async(text); @@ -350,6 +351,7 @@ pub async fn stream_big_fish_message( llm_client: state.llm_client(), session: &submitted_session, quick_fill_requested, + enable_web_search: state.config.creation_agent_llm_web_search_enabled, }, move |text| { let _ = reply_tx.send(text.to_string()); diff --git a/server-rs/crates/api-server/src/big_fish_agent_turn.rs b/server-rs/crates/api-server/src/big_fish_agent_turn.rs index b66f96a6..b8d9c8a7 100644 --- a/server-rs/crates/api-server/src/big_fish_agent_turn.rs +++ b/server-rs/crates/api-server/src/big_fish_agent_turn.rs @@ -19,6 +19,7 @@ pub(crate) struct BigFishAgentTurnRequest<'a> { pub llm_client: Option<&'a LlmClient>, pub session: &'a BigFishSessionRecord, pub quick_fill_requested: bool, + pub enable_web_search: bool, } #[derive(Clone, Debug)] @@ -122,6 +123,7 @@ where request.llm_client, format!("{BIG_FISH_AGENT_SYSTEM_PROMPT}\n\n{prompt}"), "请按约定输出这一轮的 JSON。", + request.enable_web_search, CreationAgentLlmTurnErrorMessages { model_unavailable: "当前模型不可用,请稍后重试。", generation_failed: "大鱼吃小鱼聊天生成失败,请稍后重试。", diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index eee1aade..e2c497d5 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -81,6 +81,7 @@ pub struct AppConfig { pub llm_max_retries: u32, pub llm_retry_backoff_ms: u64, pub rpg_llm_web_search_enabled: bool, + pub creation_agent_llm_web_search_enabled: bool, pub dashscope_base_url: String, pub dashscope_api_key: Option, pub dashscope_scene_image_model: String, @@ -170,6 +171,7 @@ impl Default for AppConfig { llm_max_retries: DEFAULT_MAX_RETRIES, llm_retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS, rpg_llm_web_search_enabled: true, + creation_agent_llm_web_search_enabled: true, dashscope_base_url: "https://dashscope.aliyuncs.com/api/v1".to_string(), dashscope_api_key: None, dashscope_scene_image_model: "wan2.2-t2i-flash".to_string(), @@ -475,6 +477,13 @@ impl AppConfig { config.rpg_llm_web_search_enabled = rpg_llm_web_search_enabled; } + if let Some(creation_agent_llm_web_search_enabled) = read_first_bool_env(&[ + "GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED", + "CREATION_AGENT_LLM_WEB_SEARCH_ENABLED", + ]) { + config.creation_agent_llm_web_search_enabled = creation_agent_llm_web_search_enabled; + } + if let Some(dashscope_base_url) = read_first_non_empty_env(&["DASHSCOPE_BASE_URL"]) { config.dashscope_base_url = dashscope_base_url; } @@ -843,4 +852,24 @@ mod tests { std::env::remove_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED"); } } + + #[test] + fn from_env_reads_creation_agent_llm_web_search_switch() { + let _guard = ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("env lock should not poison"); + + unsafe { + std::env::remove_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED"); + std::env::set_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED", "false"); + } + + let config = AppConfig::from_env(); + assert!(!config.creation_agent_llm_web_search_enabled); + + unsafe { + std::env::remove_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED"); + } + } } diff --git a/server-rs/crates/api-server/src/creation_agent_llm_turn.rs b/server-rs/crates/api-server/src/creation_agent_llm_turn.rs index 47e29eb9..9aee3da5 100644 --- a/server-rs/crates/api-server/src/creation_agent_llm_turn.rs +++ b/server-rs/crates/api-server/src/creation_agent_llm_turn.rs @@ -21,6 +21,7 @@ pub(crate) async fn stream_creation_agent_json_turn( llm_client: Option<&LlmClient>, system_prompt: String, user_prompt: impl Into, + enable_web_search: bool, messages: CreationAgentLlmTurnErrorMessages<'_>, mut on_reply_update: F, build_error: impl Fn(String) -> E, @@ -33,10 +34,7 @@ where let mut latest_reply_text = String::new(); let response = llm_client .stream_text( - LlmTextRequest::new(vec![ - LlmMessage::system(system_prompt), - LlmMessage::user(user_prompt.into()), - ]), + build_creation_agent_llm_request(system_prompt, user_prompt.into(), enable_web_search), |delta: &LlmStreamDelta| { if let Some(reply_progress) = extract_reply_text_from_partial_json(delta.accumulated_text.as_str()) @@ -61,6 +59,19 @@ where Ok(CreationAgentJsonTurnOutput { parsed }) } +fn build_creation_agent_llm_request( + system_prompt: String, + user_prompt: String, + enable_web_search: bool, +) -> LlmTextRequest { + // 创作 Agent 是否联网由 api-server 配置集中传入,避免各玩法各自散落默认值。 + LlmTextRequest::new(vec![ + LlmMessage::system(system_prompt), + LlmMessage::user(user_prompt), + ]) + .with_web_search(enable_web_search) +} + pub(crate) async fn request_creation_agent_json_turn( llm_client: &LlmClient, system_prompt: String, @@ -149,7 +160,10 @@ fn read_reply_text(parsed: &JsonValue) -> Option { #[cfg(test)] mod tests { - use super::{extract_reply_text_from_partial_json, parse_json_response_text}; + use super::{ + build_creation_agent_llm_request, extract_reply_text_from_partial_json, + parse_json_response_text, + }; #[test] fn extracts_reply_text_from_partial_json_with_chinese_text() { @@ -167,4 +181,13 @@ mod tests { assert_eq!(parsed["replyText"].as_str(), Some("好")); } + + #[test] + fn builds_stream_request_with_web_search_when_enabled() { + let request = + build_creation_agent_llm_request("系统提示".to_string(), "用户提示".to_string(), true); + + assert!(request.enable_web_search); + assert_eq!(request.messages.len(), 2); + } } diff --git a/server-rs/crates/api-server/src/custom_world.rs b/server-rs/crates/api-server/src/custom_world.rs index b3045271..721d49d1 100644 --- a/server-rs/crates/api-server/src/custom_world.rs +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -759,6 +759,7 @@ pub async fn submit_custom_world_agent_message( session: &session, quick_fill_requested: payload.quick_fill_requested.unwrap_or(false), focus_card_id: payload.focus_card_id.clone(), + enable_web_search: state.config.creation_agent_llm_web_search_enabled, }, move |text| { draft_sink.persist_visible_text_async(text); @@ -910,6 +911,7 @@ pub async fn stream_custom_world_agent_message( session: &session, quick_fill_requested, focus_card_id, + enable_web_search: state.config.creation_agent_llm_web_search_enabled, }, move |text| { let _ = reply_tx.send(text.to_string()); diff --git a/server-rs/crates/api-server/src/custom_world_agent_turn.rs b/server-rs/crates/api-server/src/custom_world_agent_turn.rs index 37ba251d..7aee625f 100644 --- a/server-rs/crates/api-server/src/custom_world_agent_turn.rs +++ b/server-rs/crates/api-server/src/custom_world_agent_turn.rs @@ -28,6 +28,7 @@ pub(crate) struct CustomWorldAgentTurnRequest<'a> { pub session: &'a CustomWorldAgentSessionRecord, pub quick_fill_requested: bool, pub focus_card_id: Option, + pub enable_web_search: bool, } #[derive(Clone, Debug)] @@ -214,6 +215,7 @@ where request.session.progress_percent, request.quick_fill_requested, ¤t_anchor_content, + request.enable_web_search, on_reply_update, ) .await?; @@ -476,6 +478,7 @@ async fn stream_single_turn( progress_percent: u32, quick_fill_requested: bool, current_anchor_content: &EightAnchorContent, + enable_web_search: bool, on_reply_update: F, ) -> Result where @@ -505,6 +508,7 @@ where Some(llm_client), prompt, "请按约定输出这一轮的 JSON。", + enable_web_search, CreationAgentLlmTurnErrorMessages { model_unavailable: "当前模型不可用,请稍后重试。", generation_failed: "这一轮设定生成失败,请稍后重试。", diff --git a/server-rs/crates/api-server/src/legacy_generated_assets.rs b/server-rs/crates/api-server/src/legacy_generated_assets.rs index 1a150c3c..19b5df3f 100644 --- a/server-rs/crates/api-server/src/legacy_generated_assets.rs +++ b/server-rs/crates/api-server/src/legacy_generated_assets.rs @@ -39,6 +39,13 @@ pub async fn proxy_generated_big_fish_assets( proxy_legacy_generated_asset(state, LegacyAssetPrefix::BigFishAssets, path).await } +pub async fn proxy_generated_puzzle_assets( + State(state): State, + Path(path): Path, +) -> Response { + proxy_legacy_generated_asset(state, LegacyAssetPrefix::PuzzleAssets, path).await +} + pub async fn proxy_generated_custom_world_scenes( State(state): State, Path(path): Path, diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 407c52ef..e3480c7d 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -46,7 +46,6 @@ mod request_context; mod response_headers; mod runtime_browse_history; mod runtime_chat; -mod runtime_chat_prompt; mod runtime_inventory; mod runtime_profile; mod runtime_save; diff --git a/server-rs/crates/api-server/src/prompt/mod.rs b/server-rs/crates/api-server/src/prompt/mod.rs index 69f3c72c..c1c332be 100644 --- a/server-rs/crates/api-server/src/prompt/mod.rs +++ b/server-rs/crates/api-server/src/prompt/mod.rs @@ -2,5 +2,6 @@ pub(crate) mod agent_chat; pub(crate) mod character_animation; pub(crate) mod character_visual; pub(crate) mod foundation_draft; +pub(crate) mod puzzle_image; pub(crate) mod runtime_chat; pub(crate) mod scene_background; diff --git a/server-rs/crates/api-server/src/prompt/puzzle_image.rs b/server-rs/crates/api-server/src/prompt/puzzle_image.rs new file mode 100644 index 00000000..89645b5c --- /dev/null +++ b/server-rs/crates/api-server/src/prompt/puzzle_image.rs @@ -0,0 +1,44 @@ +/// 拼图图片生成的默认反向提示词。 +/// +/// 这里单独收口拼图图片提示词,避免图片生成链路、候选图持久化和 DashScope 请求编排 +/// 混在同一个脚本里,后续调画风或资产约束时只需要改这一处。 +pub(crate) const PUZZLE_DEFAULT_NEGATIVE_PROMPT: &str = + "低清晰度,低质量,文字水印,畸形构图,过度模糊,重复肢体,画面脏污"; + +/// 根据拼图关卡名和创作者输入构造最终发给图片模型的提示词。 +pub(crate) fn build_puzzle_image_prompt(level_name: &str, prompt: &str) -> String { + format!( + concat!( + "请生成一张适合正方形拼图关卡的高清插画。", + "关卡名:{level_name}。", + "画面主体:{prompt}。", + "画面要求:1:1 正方形画布,适配 3x3 或 4x4 拼图切块,", + "主体要清晰集中,前中后景层次明确,局部细节丰富但不要杂乱,", + "避免文字、水印、边框和 UI 元素。" + ), + level_name = level_name, + prompt = prompt, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_puzzle_image_prompt_keeps_puzzle_asset_constraints() { + let prompt = build_puzzle_image_prompt("雨夜神庙", "猫咪在发光遗迹前寻找线索"); + + assert!(prompt.contains("雨夜神庙")); + assert!(prompt.contains("猫咪在发光遗迹前寻找线索")); + assert!(prompt.contains("正方形拼图关卡")); + assert!(prompt.contains("3x3 或 4x4")); + assert!(prompt.contains("避免文字、水印、边框和 UI 元素")); + } + + #[test] + fn default_negative_prompt_blocks_text_and_low_quality_assets() { + assert!(PUZZLE_DEFAULT_NEGATIVE_PROMPT.contains("低清晰度")); + assert!(PUZZLE_DEFAULT_NEGATIVE_PROMPT.contains("文字水印")); + } +} diff --git a/server-rs/crates/api-server/src/prompt/runtime_chat.rs b/server-rs/crates/api-server/src/prompt/runtime_chat.rs index acda44a8..e5c984a2 100644 --- a/server-rs/crates/api-server/src/prompt/runtime_chat.rs +++ b/server-rs/crates/api-server/src/prompt/runtime_chat.rs @@ -112,3 +112,726 @@ pub(crate) fn build_runtime_reasoned_story_user_prompt( "请基于以下运行时状态,为这一轮战斗结算生成一段 120 字以内的结果叙事,并自然引出下一组选项。\n{state_prompt}" ) } + +pub(crate) const NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT: &str = r#"你是角色扮演 RPG 里的当前 NPC。 +你只输出这名 NPC 此刻会对玩家说的一轮回复。 +只输出纯中文口语回复正文,不要输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。 +- 如果这是第一次真正接触中的首轮回复,第一句必须先用自然招呼或开场判断起手,不能写成第三人称占位旁白。 +回复长度控制在 1 到 3 句,必须紧接玩家刚说的话,自然推进气氛、情报或关系。"#; + +pub(crate) const NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT: &str = r#"你要为 RPG NPC 聊天生成下一步候选,并判断敌对聊天是否已经收束。 +只输出 JSON,不要输出 Markdown 或解释。 +JSON 结构: +{"shouldEndChat":false,"terminationReason":null,"suggestions":["温和共情台词","冷静追问台词","施压质疑台词"],"functionSuggestions":[{"functionId":"...","actionText":"玩家动作文本"}]} +- suggestions 是玩家下一轮可直接说出口的中文短句,每条 20 字以内;三条必须按顺序导向不同氛围和好感结果。 +- suggestions 第 1 条温和共情,通常让气氛缓和、好感上升;第 2 条冷静追问或试探,通常保持中性但推进情报;第 3 条施压、质疑或立场冲突,通常让气氛变紧、好感下降或付出代价。 +- functionSuggestions 只能从用户提示提供的 functionOptions 中挑选,不要发明 functionId。 +- functionSuggestions 的 actionText 必须像玩家可点击动作,不暴露 functionId,不写规则说明。 +- 非敌对聊天 shouldEndChat 必须为 false。 +- 敌对聊天可以随时 shouldEndChat=true,且敌对 NPC 更偏好在话不投机、被威胁、玩家退出、底线被触碰时结束聊天。"#; + +#[derive(Debug)] +pub(crate) struct NpcChatTurnPromptInput<'a> { + pub world_type: &'a str, + pub character: &'a Value, + pub encounter: &'a Value, + pub monsters: &'a [Value], + pub history: &'a [Value], + pub context: &'a Value, + pub conversation_history: &'a [Value], + pub dialogue: &'a [Value], + pub combat_context: Option<&'a Value>, + pub player_message: &'a str, + pub npc_state: &'a Value, + pub npc_initiates_conversation: bool, + pub chat_directive: Option<&'a Value>, +} + +pub(crate) fn build_npc_chat_turn_reply_prompt(payload: &NpcChatTurnPromptInput<'_>) -> String { + let encounter = describe_encounter(payload.encounter); + let context = as_record(payload.context); + let npc_state = as_record(payload.npc_state); + let chat_directive = payload.chat_directive.and_then(as_record); + let conversation_history = if !payload.conversation_history.is_empty() { + payload.conversation_history + } else { + payload.dialogue + }; + let opening_camp_background = + context.and_then(|record| read_string(record.get("openingCampBackground"))); + let opening_camp_dialogue = + context.and_then(|record| read_string(record.get("openingCampDialogue"))); + let allowed_topics = context + .and_then(|record| record.get("encounterAllowedTopics")) + .map(read_string_array) + .unwrap_or_default(); + let blocked_topics = context + .and_then(|record| record.get("encounterBlockedTopics")) + .map(read_string_array) + .unwrap_or_default(); + let is_first_meaningful_contact = context + .and_then(|record| read_bool(record.get("isFirstMeaningfulContact"))) + .unwrap_or(false); + let affinity = npc_state + .and_then(|record| read_number(record.get("affinity"))) + .unwrap_or(0.0); + let chatted_count = npc_state + .and_then(|record| read_number(record.get("chattedCount"))) + .unwrap_or(0.0); + let limit_reason = chat_directive.and_then(|record| read_string(record.get("limitReason"))); + let turn_limit = chat_directive + .and_then(|record| read_number(record.get("turnLimit"))) + .unwrap_or(0.0) + .max(0.0); + let remaining_turns = chat_directive + .and_then(|record| read_number(record.get("remainingTurns"))) + .unwrap_or(0.0) + .max(0.0); + let closing_mode = chat_directive.and_then(|record| read_string(record.get("closingMode"))); + let is_limited_negative_affinity_chat = + limit_reason.as_deref() == Some("negative_affinity") && turn_limit > 0.0; + let is_hostile_model_chat = chat_directive + .and_then(|record| read_string(record.get("terminationMode"))) + .as_deref() + == Some("hostile_model") + || chat_directive + .and_then(|record| read_bool(record.get("isHostileChat"))) + .unwrap_or(false); + let is_player_exit_turn = chat_directive + .and_then(|record| read_string(record.get("terminationReason"))) + .as_deref() + == Some("player_exit"); + let is_foreshadow_close_turn = closing_mode.as_deref() == Some("foreshadow_close") + || chat_directive + .and_then(|record| read_bool(record.get("forceExitAfterTurn"))) + .unwrap_or(false); + let has_npc_reply_in_history = conversation_history.iter().any(|item| { + as_record(item) + .and_then(|turn| read_string(turn.get("speaker"))) + .is_some_and(|speaker| speaker == "npc") + }); + let is_first_npc_spoken_turn = + is_first_meaningful_contact && !has_npc_reply_in_history && chatted_count <= 0.0; + let first_contact_relation_stance = describe_first_contact_relation_stance( + context.and_then(|record| record.get("firstContactRelationStance")), + ); + let combat_context_block = payload.combat_context.and_then(describe_npc_combat_context); + + [ + Some(build_npc_dialogue_prompt_base(payload)), + Some(describe_npc_conversation_history( + conversation_history, + encounter.npc_name.as_str(), + )), + combat_context_block, + opening_camp_background.map(|text| format!("营地开场背景:{text}")), + opening_camp_dialogue.map(|text| format!("刚刚发生的第一段对话:{text}")), + Some(format!("当前关系值:{}", format_prompt_number(affinity))), + Some(format!("已聊天轮次:{}", format_prompt_number(chatted_count))), + if is_first_npc_spoken_turn { + Some(format!( + "当前接触阶段:第一次真正接触({first_contact_relation_stance})。这是这次聊天里 {} 第一次真正对玩家开口。", + encounter.npc_name + )) + } else { + None + }, + if is_first_npc_spoken_turn { + Some("第一句必须先用一句自然招呼或开场判断起手,再顺着玩家刚刚的话往下接。".to_string()) + } else { + None + }, + if is_first_npc_spoken_turn { + Some("不要写成“某人看着你,像是在等你把话接下去”这类第三人称占位旁白,也不要把整轮写成设定说明。".to_string()) + } else { + None + }, + if payload.npc_initiates_conversation { + Some(format!( + "当前要求:这是 {} 主动开口的第一句,不要假装玩家已经先说过话。", + encounter.npc_name + )) + } else { + None + }, + if allowed_topics.is_empty() { + None + } else { + Some(format!("当前更适合先谈:{}", allowed_topics.join("、"))) + }, + if blocked_topics.is_empty() { + None + } else { + Some(format!("当前避免直接说破:{}", blocked_topics.join("、"))) + }, + if is_limited_negative_affinity_chat { + Some(format!( + "当前相遇属于负好感主角色有限聊天,本次总上限 {} 轮。", + format_prompt_number(turn_limit) + )) + } else { + None + }, + if is_hostile_model_chat { + Some("当前是敌对或负好感聊天。对方不受固定回合限制,但随时可能不耐烦、结束谈话并把局势推向战斗或驱逐。".to_string()) + } else { + None + }, + if is_hostile_model_chat { + Some("敌对角色更偏好短促、戒备、带威胁的回应;如果玩家逼问、挑衅、退场或话题触到底线,回复应自然收束到对峙前一刻。".to_string()) + } else { + None + }, + if is_player_exit_turn { + Some("玩家正在主动结束这轮聊天。请对这个收束动作作出回应,并留下自然的下一步入口。回复后聊天会结束。".to_string()) + } else { + None + }, + if is_limited_negative_affinity_chat { + Some(format!( + "在你回复完这一轮之后,还剩 {} 轮可以继续聊。", + format_prompt_number(remaining_turns) + )) + } else { + None + }, + if is_limited_negative_affinity_chat && !is_foreshadow_close_turn { + Some("语气可以戒备、冷淡、带刺,但不要立刻转成开战,也不要把对话硬掐死。".to_string()) + } else { + None + }, + if is_foreshadow_close_turn { + Some("这是最后一轮回复。必须带有收束感,但不能只用“别问了”“滚开”之类的话把聊天粗暴截断。".to_string()) + } else { + None + }, + if is_foreshadow_close_turn { + Some("最后一轮必须抛出能推动后续剧情的明确铺垫,例如威胁、线索、条件、去处、人物、未说完的真相或下一步悬念。".to_string()) + } else { + None + }, + if is_foreshadow_close_turn { + Some("回复后这轮聊天会结束,所以不要邀请继续闲聊,也不要直接宣布已经开战。".to_string()) + } else { + None + }, + if payload.npc_initiates_conversation { + Some("玩家此刻还没有先说话,请直接写 NPC 主动开口时会说的第一轮回复。".to_string()) + } else { + Some(format!("玩家刚刚说:{}", payload.player_message.trim())) + }, + if payload.npc_initiates_conversation { + Some(format!( + "现在请只写 {} 主动开口时会说的话。", + encounter.npc_name + )) + } else { + Some(format!( + "现在请只写 {} 这一轮会回复玩家的话。", + encounter.npc_name + )) + }, + ] + .into_iter() + .flatten() + .filter(|text| !text.trim().is_empty()) + .collect::>() + .join("\n\n") +} + +pub(crate) fn build_npc_chat_turn_suggestion_prompt( + payload: &NpcChatTurnPromptInput<'_>, + npc_reply: &str, +) -> String { + let encounter = describe_encounter(payload.encounter); + let conversation_history = if !payload.conversation_history.is_empty() { + payload.conversation_history + } else { + payload.dialogue + }; + let combat_context_block = payload.combat_context.and_then(describe_npc_combat_context); + let chat_directive = payload.chat_directive.and_then(as_record); + let is_hostile_model_chat = chat_directive + .and_then(|record| read_string(record.get("terminationMode"))) + .as_deref() + == Some("hostile_model") + || chat_directive + .and_then(|record| read_bool(record.get("isHostileChat"))) + .unwrap_or(false); + let is_player_exit_turn = chat_directive + .and_then(|record| read_string(record.get("terminationReason"))) + .as_deref() + == Some("player_exit"); + let function_options_block = chat_directive + .and_then(|record| record.get("functionOptions")) + .map(describe_function_options) + .filter(|text| !text.trim().is_empty()); + + [ + Some(build_npc_dialogue_prompt_base(payload)), + Some(describe_npc_conversation_history( + conversation_history, + encounter.npc_name.as_str(), + )), + combat_context_block, + function_options_block, + if payload.npc_initiates_conversation { + Some("玩家尚未先开口,这一轮是 NPC 主动发起聊天。".to_string()) + } else { + Some(format!("玩家刚刚说:{}", payload.player_message)) + }, + Some(format!("NPC 刚刚回复:{npc_reply}")), + if is_hostile_model_chat { + Some("这是敌对或负好感聊天。你需要判断这轮是否应该结束聊天;敌对角色更偏好随时终止并转入对峙。".to_string()) + } else { + Some("这是非敌对聊天,shouldEndChat 必须为 false。".to_string()) + }, + if is_player_exit_turn { + Some("玩家已经选择结束聊天,shouldEndChat 必须为 true,terminationReason 必须为 player_exit。".to_string()) + } else { + None + }, + Some("suggestions 必须按顺序生成三种明显不同的玩家台词:温和共情、冷静追问或试探、施压质疑;不要给出同一种态度的近义句。".to_string()), + Some("functionSuggestions 从 functionOptions 中挑可触发动作并改写 actionText。".to_string()), + Some("只输出 JSON:{\"shouldEndChat\":false,\"terminationReason\":null,\"suggestions\":[\"...\"],\"functionSuggestions\":[{\"functionId\":\"...\",\"actionText\":\"...\"}]}".to_string()), + ] + .into_iter() + .flatten() + .filter(|text| !text.trim().is_empty()) + .collect::>() + .join("\n\n") +} + +pub(crate) fn build_deterministic_npc_reply( + npc_name: &str, + player_message: &str, + npc_initiates_conversation: bool, +) -> String { + // LLM 不可用时仍由后端返回稳定中文对白,保证相遇和点击聊天链路不断。 + if npc_initiates_conversation { + return format!("{npc_name}看向你,先开口说道:“你来了。先别急着走,我正有话想和你说。”"); + } + format!("{npc_name}听完你的话,回应道:“{player_message}。我明白你的意思,我们继续说。”") +} + +pub(crate) fn build_deterministic_chat_suggestions( + npc_name: &str, + player_message: &str, +) -> Vec { + // 建议只承载玩家可点选的行动意图,不在 UI 里额外塞说明文案。 + vec![ + format!("{npc_name},我想先听你说"), + "这件事哪里不对劲".to_string(), + if player_message.contains('帮') || player_message.contains('忙') { + "先别绕,说清代价".to_string() + } else { + "你是不是还瞒着我".to_string() + }, + ] +} + +pub(crate) fn build_fallback_npc_chat_suggestions(player_message: &str) -> Vec { + let topic = player_message.trim().chars().take(8).collect::(); + let topic = if topic.is_empty() { + "刚才那句".to_string() + } else { + topic + }; + + vec![ + "我愿意先听你说完".to_string(), + format!("这事和{topic}有关吗"), + "你别再避重就轻".to_string(), + ] +} + +pub(crate) fn build_fallback_function_suggestions(chat_directive: Option<&Value>) -> Vec { + read_function_options(chat_directive) + .into_iter() + .filter(|option| { + read_string_field(option, "functionId") + .as_deref() + .is_some_and(|function_id| function_id != "npc_chat") + }) + .take(2) + .filter_map(|option| { + let function_id = read_string_field(option, "functionId")?; + let action_text = read_string_field(option, "actionText")?; + Some(json!({ + "functionId": function_id, + "actionText": action_text, + })) + }) + .collect() +} + +fn describe_function_options(value: &Value) -> String { + let lines = value + .as_array() + .map(|items| { + items + .iter() + .take(8) + .filter_map(|item| { + let record = as_record(item)?; + let function_id = read_string(record.get("functionId"))?; + let action_text = read_string(record.get("actionText"))?; + let detail_text = read_string(record.get("detailText")); + let action = read_string(record.get("action")); + Some(format!( + "- functionId: {function_id}; actionText: {action_text}; action: {}; detail: {}", + action.unwrap_or_else(|| "unknown".to_string()), + detail_text.unwrap_or_else(|| "无".to_string()), + )) + }) + .collect::>() + }) + .unwrap_or_default(); + + if lines.is_empty() { + return String::new(); + } + + let mut result = vec!["当前聊天中可改写为动作候选的 functionOptions:".to_string()]; + result.extend(lines); + result.join("\n") +} + +fn build_npc_dialogue_prompt_base(payload: &NpcChatTurnPromptInput<'_>) -> String { + let encounter = describe_encounter(payload.encounter); + + [ + format!("世界:{}", describe_world(payload.world_type)), + describe_scene_context(payload.context), + describe_character("玩家 / ", payload.character), + encounter.block, + describe_monsters(payload.monsters), + describe_story_history(payload.history), + ] + .into_iter() + .filter(|text| !text.trim().is_empty()) + .collect::>() + .join("\n\n") +} + +struct EncounterDescription { + npc_name: String, + block: String, +} + +fn describe_encounter(encounter: &Value) -> EncounterDescription { + let record = as_record(encounter); + let npc_name = record + .and_then(|item| read_string(item.get("npcName"))) + .unwrap_or_else(|| "眼前角色".to_string()); + let context_text = record + .and_then(|item| read_string(item.get("context"))) + .or_else(|| record.and_then(|item| read_string(item.get("npcDescription")))) + .unwrap_or_else(|| "你们正在当前遭遇里继续对话。".to_string()); + + EncounterDescription { + npc_name: npc_name.clone(), + block: format!("当前对象:{npc_name}\n对象背景:{context_text}"), + } +} + +fn describe_first_contact_relation_stance(value: Option<&Value>) -> String { + match value.and_then(|item| item.as_str()).map(str::trim) { + Some("guarded") => "戒备试探".to_string(), + Some("neutral") => "正常交流但仍不熟".to_string(), + Some("cooperative") => "已有善意,先确认合作节奏".to_string(), + Some("bonded") => "明显信任,但仍是第一次正式对上人".to_string(), + _ => "第一次真正接触".to_string(), + } +} + +fn describe_world(world_type: &str) -> String { + match world_type { + "WUXIA" => "边城模板".to_string(), + "XIANXIA" => "灵潮模板".to_string(), + "CUSTOM" => "自定义世界".to_string(), + value if !value.trim().is_empty() => value.to_string(), + _ => "未知世界".to_string(), + } +} + +fn describe_stats(label: &str, record: Option<&serde_json::Map>) -> String { + let hp = record + .and_then(|item| read_number(item.get("hp"))) + .unwrap_or(0.0); + let max_hp = record + .and_then(|item| read_number(item.get("maxHp"))) + .unwrap_or(hp) + .max(1.0); + let mana = record + .and_then(|item| read_number(item.get("mana"))) + .unwrap_or(0.0); + let max_mana = record + .and_then(|item| read_number(item.get("maxMana"))) + .unwrap_or(mana) + .max(1.0); + + format!( + "{label}生命 {}/{},灵力 {}/{}", + format_prompt_number(hp), + format_prompt_number(max_hp), + format_prompt_number(mana), + format_prompt_number(max_mana) + ) +} + +fn describe_character(label: &str, value: &Value) -> String { + let record = as_record(value); + let name = record + .and_then(|item| read_string(item.get("name"))) + .unwrap_or_else(|| "未知角色".to_string()); + let title = record + .and_then(|item| read_string(item.get("title"))) + .unwrap_or_else(|| "未知称号".to_string()); + let description = record + .and_then(|item| read_string(item.get("description"))) + .unwrap_or_else(|| "暂无额外描述".to_string()); + let personality = record + .and_then(|item| read_string(item.get("personality"))) + .unwrap_or_else(|| "性格信息未显式提供".to_string()); + + [ + format!("{label}姓名:{name}"), + format!("{label}称号:{title}"), + format!("{label}描述:{description}"), + format!("{label}性格:{personality}"), + ] + .join("\n") +} + +fn describe_story_history(history: &[Value]) -> String { + if history.is_empty() { + return "近期剧情:暂无。".to_string(); + } + + let lines = history + .iter() + .rev() + .take(4) + .collect::>() + .into_iter() + .rev() + .filter_map(|item| as_record(item).and_then(|record| read_string(record.get("text")))) + .collect::>(); + + if lines.is_empty() { + "近期剧情:暂无。".to_string() + } else { + let mut result = vec!["近期剧情:".to_string()]; + result.extend(lines.into_iter().map(|line| format!("- {line}"))); + result.join("\n") + } +} + +fn describe_npc_conversation_history(history: &[Value], npc_name: &str) -> String { + if history.is_empty() { + return "当前聊天记录:暂无。".to_string(); + } + + let lines = history + .iter() + .rev() + .take(10) + .collect::>() + .into_iter() + .rev() + .filter_map(|item| { + let record = as_record(item)?; + let speaker = read_string(record.get("speaker")); + let speaker_name = read_string(record.get("speakerName")); + let text = read_string(record.get("text"))?; + + match speaker.as_deref() { + Some("player") => Some(format!("- 玩家:{text}")), + Some("npc") => Some(format!( + "- {}:{text}", + speaker_name.unwrap_or_else(|| npc_name.to_string()) + )), + Some("system") => Some(format!("- 系统提示:{text}")), + _ => Some(format!( + "- {}:{text}", + speaker_name.unwrap_or_else(|| "同伴".to_string()) + )), + } + }) + .collect::>(); + + if lines.is_empty() { + "当前聊天记录:暂无。".to_string() + } else { + let mut result = vec!["当前聊天记录:".to_string()]; + result.extend(lines); + result.join("\n") + } +} + +fn describe_npc_combat_context(combat_context: &Value) -> Option { + let record = as_record(combat_context)?; + let summary = read_string(record.get("summary")); + let battle_outcome = read_string(record.get("battleOutcome")); + let log_lines = record + .get("logLines") + .map(read_string_array) + .unwrap_or_default() + .into_iter() + .take(6) + .collect::>(); + if summary.is_none() && log_lines.is_empty() { + return None; + } + + let outcome_text = match battle_outcome.as_deref() { + Some("spar_complete") => Some("切磋刚刚结束。".to_string()), + Some("victory") => Some("战斗刚刚分出胜负。".to_string()), + _ => None, + }; + let mut lines = vec!["刚刚结束的交锋:".to_string()]; + if let Some(text) = outcome_text { + lines.push(text); + } + if let Some(text) = summary { + lines.push(format!("- 结果摘要:{text}")); + } + if !log_lines.is_empty() { + lines.push("- 战斗日志:".to_string()); + lines.extend(log_lines.into_iter().map(|line| format!(" - {line}"))); + } + Some(lines.join("\n")) +} + +fn describe_scene_context(context: &Value) -> String { + let record = as_record(context); + let scene_name = record + .and_then(|item| read_string(item.get("sceneName"))) + .unwrap_or_else(|| "当前区域".to_string()); + let scene_description = record + .and_then(|item| read_string(item.get("sceneDescription"))) + .unwrap_or_else(|| "周围气氛仍未完全安定。".to_string()); + let in_battle = if record + .and_then(|item| read_bool(item.get("inBattle"))) + .unwrap_or(false) + { + "战斗中" + } else { + "非战斗" + }; + let custom_world_profile = record + .and_then(|item| item.get("customWorldProfile")) + .and_then(as_record); + let custom_world_name = custom_world_profile.and_then(|item| read_string(item.get("name"))); + let custom_world_summary = + custom_world_profile.and_then(|item| read_string(item.get("summary"))); + + [ + Some(format!( + "世界补充:{}", + custom_world_name.unwrap_or_else(|| "无".to_string()) + )), + custom_world_summary.map(|text| format!("世界摘要:{text}")), + Some(format!("场景:{scene_name}")), + Some(format!("场景描述:{scene_description}")), + Some(format!("当前状态:{in_battle}")), + Some(describe_stats("玩家", record)), + ] + .into_iter() + .flatten() + .collect::>() + .join("\n") +} + +fn describe_monsters(monsters: &[Value]) -> String { + if monsters.is_empty() { + return "当前敌对目标:无。".to_string(); + } + + let lines = monsters + .iter() + .take(4) + .filter_map(|item| { + let record = as_record(item)?; + let name = read_string(record.get("name")) + .or_else(|| read_string(record.get("npcName"))) + .or_else(|| read_string(record.get("id")))?; + let hp = read_number(record.get("hp")).unwrap_or(0.0); + let max_hp = read_number(record.get("maxHp")).unwrap_or(hp).max(1.0); + + Some(format!( + "- {name}(生命 {}/{})", + format_prompt_number(hp), + format_prompt_number(max_hp) + )) + }) + .collect::>(); + + if lines.is_empty() { + "当前敌对目标:无。".to_string() + } else { + let mut result = vec!["当前敌对目标:".to_string()]; + result.extend(lines); + result.join("\n") + } +} + +fn read_function_options(chat_directive: Option<&Value>) -> Vec<&Value> { + chat_directive + .and_then(|directive| directive.get("functionOptions")) + .and_then(Value::as_array) + .map(|items| items.iter().collect::>()) + .unwrap_or_default() +} + +fn read_string_field(value: &Value, field: &str) -> Option { + value + .get(field) + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(ToOwned::to_owned) +} + +fn read_string(value: Option<&Value>) -> Option { + value + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(ToOwned::to_owned) +} + +fn read_number(value: Option<&Value>) -> Option { + value + .and_then(Value::as_f64) + .filter(|number| number.is_finite()) +} + +fn read_bool(value: Option<&Value>) -> Option { + value.and_then(Value::as_bool) +} + +fn read_string_array(value: &Value) -> Vec { + value + .as_array() + .map(|items| { + items + .iter() + .filter_map(|item| read_string(Some(item))) + .collect::>() + }) + .unwrap_or_default() +} + +fn as_record(value: &Value) -> Option<&serde_json::Map> { + value.as_object() +} + +fn format_prompt_number(value: f64) -> String { + if value.fract() == 0.0 { + format!("{}", value as i64) + } else { + value.to_string() + } +} diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 8e9c120b..54c7f427 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -12,12 +12,16 @@ use axum::{ sse::{Event, Sse}, }, }; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use module_assets::{ AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input, build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id, }; use module_puzzle::PuzzleGeneratedImageCandidate; -use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest}; +use platform_oss::{ + LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest, + OssSignedGetObjectUrlRequest, +}; use serde_json::{Map, Value, json}; use shared_contracts::{ puzzle_agent::{ @@ -64,6 +68,7 @@ use crate::{ api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, + prompt::puzzle_image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt}, puzzle_agent_turn::{ PuzzleAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input, run_puzzle_agent_turn, @@ -78,8 +83,6 @@ const PUZZLE_GALLERY_PROVIDER: &str = "puzzle-gallery"; const PUZZLE_RUNTIME_PROVIDER: &str = "puzzle-runtime"; const PUZZLE_TEXT_TO_IMAGE_MODEL: &str = "wan2.2-t2i-flash"; const PUZZLE_ENTITY_KIND: &str = "puzzle_work"; -const PUZZLE_DEFAULT_NEGATIVE_PROMPT: &str = - "低清晰度,低质量,文字水印,畸形构图,过度模糊,重复肢体,画面脏污"; pub async fn create_puzzle_agent_session( State(state): State, @@ -216,6 +219,7 @@ pub async fn submit_puzzle_agent_message( llm_client: state.llm_client(), session: &submitted_session, quick_fill_requested: payload.quick_fill_requested.unwrap_or(false), + enable_web_search: state.config.creation_agent_llm_web_search_enabled, }, |_| {}, ) @@ -320,6 +324,7 @@ pub async fn stream_puzzle_agent_message( llm_client: state.llm_client(), session: &session, quick_fill_requested, + enable_web_search: state.config.creation_agent_llm_web_search_enabled, }, move |text| { let _ = reply_tx.send(text.to_string()); @@ -447,7 +452,7 @@ pub async fn execute_puzzle_agent_action( ( "compile_puzzle_draft", "完整拼图草稿", - "已编译草稿、生成候选图并应用正式图片。", + "已编译草稿、生成拼图图片并应用为正式图。", session, ) } @@ -468,7 +473,8 @@ pub async fn execute_puzzle_agent_action( .clone() .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| draft.summary.clone()); - let candidate_count = payload.candidate_count.unwrap_or(2).clamp(1, 2); + // 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。 + let candidate_count = 1; let candidate_start_index = draft.candidates.len(); let candidates = generate_puzzle_image_candidates( &state, @@ -476,6 +482,7 @@ pub async fn execute_puzzle_agent_action( &session.session_id, &draft.level_name, &prompt, + payload.reference_image_src.as_deref(), candidate_count, candidate_start_index, ) @@ -521,8 +528,8 @@ pub async fn execute_puzzle_agent_action( }; ( "generate_puzzle_images", - "候选图生成", - "已生成 2 张候选拼图图像。", + "拼图图片生成", + "已生成并替换当前拼图图片。", session, ) } @@ -1296,6 +1303,7 @@ fn map_puzzle_run_response(run: PuzzleRunRecord) -> PuzzleRunSnapshotResponse { previous_level_tags: run.previous_level_tags, current_level: run.current_level.map(map_puzzle_runtime_level_response), recommended_next_profile_id: run.recommended_next_profile_id, + leaderboard_entries: Vec::new(), } } @@ -1381,6 +1389,10 @@ fn map_puzzle_runtime_level_response( cover_image_src: level.cover_image_src, board: map_puzzle_board_response(level.board), status: level.status, + started_at_ms: 0, + cleared_at_ms: None, + elapsed_ms: None, + leaderboard_entries: Vec::new(), } } @@ -1476,7 +1488,8 @@ async fn compile_puzzle_draft_with_initial_cover( &compiled_session.session_id, &draft.level_name, &draft.summary, - 2, + None, + 1, draft.candidates.len(), ) .await @@ -1619,23 +1632,53 @@ async fn generate_puzzle_image_candidates( session_id: &str, level_name: &str, prompt: &str, + reference_image_src: Option<&str>, candidate_count: u32, candidate_start_index: usize, ) -> Result, String> { - let count = candidate_count.clamp(1, 2); + let count = candidate_count.clamp(1, 1); let settings = require_puzzle_dashscope_settings(state).map_err(|error| error.message().to_string())?; let http_client = build_puzzle_dashscope_http_client(&settings) .map_err(|error| error.message().to_string())?; - let generated = create_puzzle_text_to_image_generation( - &http_client, - &settings, - build_puzzle_image_prompt(level_name, prompt).as_str(), - PUZZLE_DEFAULT_NEGATIVE_PROMPT, - "1024*1024", - count, - ) - .await + let actual_prompt = build_puzzle_image_prompt(level_name, prompt); + let reference_image = match reference_image_src + .map(str::trim) + .filter(|value| !value.is_empty()) + { + Some(source) => Some( + resolve_puzzle_reference_image_as_data_url(state, &http_client, source) + .await + .map_err(|error| error.message().to_string())?, + ), + None => None, + }; + // 中文注释:SpacetimeDB reducer 不能做外部 I/O,参考图读取与 DashScope 图生图都必须停留在 api-server。 + let generated = match reference_image.as_deref() { + Some(reference_image) => { + create_puzzle_image_to_image_generation( + &http_client, + &settings, + actual_prompt.as_str(), + PUZZLE_DEFAULT_NEGATIVE_PROMPT, + "1024*1024", + count, + reference_image, + ) + .await + } + None => { + create_puzzle_text_to_image_generation( + &http_client, + &settings, + actual_prompt.as_str(), + PUZZLE_DEFAULT_NEGATIVE_PROMPT, + "1024*1024", + count, + ) + .await + } + } .map_err(|error| error.message().to_string())?; let mut items = Vec::with_capacity(generated.images.len()); @@ -1661,9 +1704,10 @@ async fn generate_puzzle_image_candidates( image_src: asset.image_src, asset_id: asset.asset_id, prompt: prompt.to_string(), - actual_prompt: Some(prompt.to_string()), + actual_prompt: Some(actual_prompt.clone()), source_type: "generated".to_string(), - selected: candidate_start_index == 0 && index == 0, + // 单图生成结果总是直接成为当前正式图。 + selected: index == 0, }); } @@ -1740,7 +1784,8 @@ async fn build_local_next_puzzle_run( &session.session_id, &draft.level_name, &draft.summary, - 2, + None, + 1, draft.candidates.len(), ) .await @@ -1946,6 +1991,7 @@ fn build_local_puzzle_board(grid_size: u32) -> PuzzleBoardRecord { struct PuzzleDashScopeSettings { base_url: String, api_key: String, + reference_image_model: String, request_timeout_ms: u64, } @@ -1960,6 +2006,11 @@ struct PuzzleDownloadedImage { bytes: Vec, } +struct ParsedPuzzleImageDataUrl { + mime_type: String, + bytes: Vec, +} + struct GeneratedPuzzleAssetResponse { image_src: String, asset_id: String, @@ -1994,6 +2045,7 @@ fn require_puzzle_dashscope_settings( Ok(PuzzleDashScopeSettings { base_url: base_url.to_string(), api_key: api_key.to_string(), + reference_image_model: state.config.dashscope_reference_image_model.clone(), request_timeout_ms: state.config.dashscope_image_request_timeout_ms.max(1), }) } @@ -2036,7 +2088,7 @@ async fn create_puzzle_text_to_image_generation( candidate_count: u32, ) -> Result { let mut parameters = Map::from_iter([ - ("n".to_string(), json!(candidate_count.clamp(1, 2))), + ("n".to_string(), json!(candidate_count.clamp(1, 1))), ("size".to_string(), Value::String(size.to_string())), ("prompt_extend".to_string(), Value::Bool(true)), ("watermark".to_string(), Value::Bool(false)), @@ -2127,7 +2179,7 @@ async fn create_puzzle_text_to_image_generation( let mut images = Vec::with_capacity(image_urls.len()); for image_url in image_urls .into_iter() - .take(candidate_count.clamp(1, 2) as usize) + .take(candidate_count.clamp(1, 1) as usize) { images.push(download_puzzle_remote_image(http_client, image_url.as_str()).await?); } @@ -2150,6 +2202,270 @@ async fn create_puzzle_text_to_image_generation( ) } +async fn resolve_puzzle_reference_image_as_data_url( + state: &AppState, + http_client: &reqwest::Client, + source: &str, +) -> Result { + let trimmed = source.trim(); + if trimmed.is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "puzzle", + "field": "referenceImageSrc", + "message": "参考图不能为空。", + })), + ); + } + + if let Some(parsed) = parse_puzzle_image_data_url(trimmed) { + return Ok(format!( + "data:{};base64,{}", + parsed.mime_type, + BASE64_STANDARD.encode(parsed.bytes) + )); + } + + if !trimmed.starts_with('/') { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "puzzle", + "field": "referenceImageSrc", + "message": "参考图必须是 Data URL 或 /generated-* 旧路径。", + })), + ); + } + + let object_key = trimmed.trim_start_matches('/'); + if LegacyAssetPrefix::from_object_key(object_key).is_none() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "puzzle", + "field": "referenceImageSrc", + "message": "参考图当前只支持 /generated-* 旧路径。", + })), + ); + } + + let oss_client = state.oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS 未完成环境变量配置", + })) + })?; + let signed = oss_client + .sign_get_object_url(OssSignedGetObjectUrlRequest { + object_key: object_key.to_string(), + expire_seconds: Some(60), + }) + .map_err(map_puzzle_asset_oss_error)?; + let response = http_client + .get(signed.signed_url) + .send() + .await + .map_err(|error| { + map_puzzle_dashscope_request_error(format!("读取拼图参考图失败:{error}")) + })?; + let status = response.status(); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("image/png") + .to_string(); + let body = response.bytes().await.map_err(|error| { + map_puzzle_dashscope_request_error(format!("读取拼图参考图内容失败:{error}")) + })?; + if !status.is_success() { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "aliyun-oss", + "message": format!("读取参考图失败,状态码:{status}"), + "objectKey": object_key, + })), + ); + } + if body.is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "aliyun-oss", + "message": "读取参考图失败:对象内容为空", + "objectKey": object_key, + })), + ); + } + + Ok(format!( + "data:{};base64,{}", + content_type, + BASE64_STANDARD.encode(body) + )) +} + +async fn create_puzzle_image_to_image_generation( + http_client: &reqwest::Client, + settings: &PuzzleDashScopeSettings, + prompt: &str, + negative_prompt: &str, + size: &str, + candidate_count: u32, + reference_image: &str, +) -> Result { + let mut content = vec![json!({ "image": reference_image })]; + content.push(json!({ "text": prompt })); + + let response = http_client + .post(format!( + "{}/services/aigc/multimodal-generation/generation", + settings.base_url + )) + .header( + reqwest::header::AUTHORIZATION, + format!("Bearer {}", settings.api_key), + ) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .json(&json!({ + "model": settings.reference_image_model.as_str(), + "input": { + "messages": [ + { + "role": "user", + "content": content, + } + ], + }, + "parameters": { + "n": candidate_count.clamp(1, 1), + "size": size, + "negative_prompt": negative_prompt, + "prompt_extend": true, + "watermark": false, + }, + })) + .send() + .await + .map_err(|error| { + map_puzzle_dashscope_request_error(format!("创建拼图参考图生成任务失败:{error}")) + })?; + let status = response.status(); + let response_text = response.text().await.map_err(|error| { + map_puzzle_dashscope_request_error(format!("读取拼图参考图生成响应失败:{error}")) + })?; + if !status.is_success() { + return Err(map_puzzle_dashscope_upstream_error( + response_text.as_str(), + "创建拼图参考图生成任务失败", + )); + } + let payload = parse_puzzle_json_payload(response_text.as_str(), "解析拼图参考图生成响应失败")?; + let image_urls = extract_puzzle_image_urls(&payload); + if image_urls.is_empty() { + let task_id = extract_puzzle_task_id(&payload).ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "dashscope", + "message": "拼图参考图生成未返回 task_id 或图片地址", + })) + })?; + return wait_puzzle_generated_images( + http_client, + settings, + task_id.as_str(), + candidate_count, + "拼图参考图生成任务失败", + ) + .await; + } + let mut images = Vec::with_capacity(candidate_count.clamp(1, 1) as usize); + for image_url in image_urls + .into_iter() + .take(candidate_count.clamp(1, 1) as usize) + { + images.push(download_puzzle_remote_image(http_client, image_url.as_str()).await?); + } + Ok(PuzzleGeneratedImages { + task_id: format!("puzzle-ref-{}", current_utc_micros()), + images, + }) +} + +async fn wait_puzzle_generated_images( + http_client: &reqwest::Client, + settings: &PuzzleDashScopeSettings, + task_id: &str, + candidate_count: u32, + failure_message: &str, +) -> Result { + let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms); + + while Instant::now() < deadline { + let poll_response = http_client + .get(format!("{}/tasks/{}", settings.base_url, task_id)) + .header( + reqwest::header::AUTHORIZATION, + format!("Bearer {}", settings.api_key), + ) + .send() + .await + .map_err(|error| { + map_puzzle_dashscope_request_error(format!("查询拼图图片生成任务失败:{error}")) + })?; + let poll_status = poll_response.status(); + let poll_text = poll_response.text().await.map_err(|error| { + map_puzzle_dashscope_request_error(format!("读取拼图图片生成任务响应失败:{error}")) + })?; + if !poll_status.is_success() { + return Err(map_puzzle_dashscope_upstream_error( + poll_text.as_str(), + "查询拼图图片生成任务失败", + )); + } + + let poll_payload = + parse_puzzle_json_payload(poll_text.as_str(), "解析拼图图片生成任务响应失败")?; + let task_status = find_first_puzzle_string_by_key(&poll_payload, "task_status") + .unwrap_or_default() + .trim() + .to_string(); + if task_status == "SUCCEEDED" { + let image_urls = extract_puzzle_image_urls(&poll_payload); + if image_urls.is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "dashscope", + "message": "拼图图片生成成功但未返回图片地址", + })), + ); + } + + let mut images = Vec::with_capacity(image_urls.len()); + for image_url in image_urls + .into_iter() + .take(candidate_count.clamp(1, 1) as usize) + { + images.push(download_puzzle_remote_image(http_client, image_url.as_str()).await?); + } + return Ok(PuzzleGeneratedImages { + task_id: task_id.to_string(), + images, + }); + } + if matches!(task_status.as_str(), "FAILED" | "UNKNOWN") { + return Err(map_puzzle_dashscope_upstream_error( + poll_text.as_str(), + failure_message, + )); + } + sleep(Duration::from_secs(2)).await; + } + + Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "dashscope", + "message": "拼图图片生成超时或未返回图片地址", + })), + ) +} + async fn download_puzzle_remote_image( http_client: &reqwest::Client, image_url: &str, @@ -2278,12 +2594,6 @@ async fn persist_puzzle_generated_asset( }) } -fn build_puzzle_image_prompt(level_name: &str, prompt: &str) -> String { - format!( - "生成一张适合做正方形拼图关卡的高清插画。关卡名:{level_name}。画面要求:{prompt}。必须有清晰主体、丰富但不混乱的区域层次、适合被切成 3x3 或 4x4 拼图块,避免文字、水印、边框和 UI 元素。" - ) -} - fn build_puzzle_asset_metadata( owner_user_id: &str, session_id: &str, @@ -2307,6 +2617,46 @@ fn parse_puzzle_json_payload(raw_text: &str, fallback_message: &str) -> Result Option { + let body = value.strip_prefix("data:")?; + let (mime_type, data) = body.split_once(";base64,")?; + if !mime_type.starts_with("image/") { + return None; + } + let bytes = decode_puzzle_base64(data)?; + Some(ParsedPuzzleImageDataUrl { + mime_type: mime_type.to_string(), + bytes, + }) +} + +fn decode_puzzle_base64(value: &str) -> Option> { + let cleaned = value.trim().replace(char::is_whitespace, ""); + let mut output = Vec::with_capacity(cleaned.len() * 3 / 4); + let mut buffer = 0u32; + let mut bits = 0u8; + + for byte in cleaned.bytes() { + let value = match byte { + b'A'..=b'Z' => byte - b'A', + b'a'..=b'z' => byte - b'a' + 26, + b'0'..=b'9' => byte - b'0' + 52, + b'+' => 62, + b'/' => 63, + b'=' => break, + _ => return None, + } as u32; + buffer = (buffer << 6) | value; + bits += 6; + while bits >= 8 { + bits -= 8; + output.push(((buffer >> bits) & 0xFF) as u8); + } + } + + Some(output) +} + fn extract_puzzle_task_id(payload: &Value) -> Option { find_first_puzzle_string_by_key(payload, "task_id") } diff --git a/server-rs/crates/api-server/src/puzzle_agent_turn.rs b/server-rs/crates/api-server/src/puzzle_agent_turn.rs index 37981f6e..a1f7803f 100644 --- a/server-rs/crates/api-server/src/puzzle_agent_turn.rs +++ b/server-rs/crates/api-server/src/puzzle_agent_turn.rs @@ -19,6 +19,7 @@ pub(crate) struct PuzzleAgentTurnRequest<'a> { pub llm_client: Option<&'a LlmClient>, pub session: &'a PuzzleAgentSessionRecord, pub quick_fill_requested: bool, + pub enable_web_search: bool, } #[derive(Clone, Debug)] @@ -128,6 +129,7 @@ where request.llm_client, format!("{PUZZLE_AGENT_SYSTEM_PROMPT}\n\n{prompt}"), "请按约定输出这一轮的 JSON。", + request.enable_web_search, CreationAgentLlmTurnErrorMessages { model_unavailable: "当前模型不可用,请稍后重试。", generation_failed: "拼图聊天生成失败,请稍后重试。", diff --git a/server-rs/crates/api-server/src/runtime_chat.rs b/server-rs/crates/api-server/src/runtime_chat.rs index c4e4eb5b..17cc223b 100644 --- a/server-rs/crates/api-server/src/runtime_chat.rs +++ b/server-rs/crates/api-server/src/runtime_chat.rs @@ -14,12 +14,14 @@ use std::convert::Infallible; use crate::{ http_error::AppError, - request_context::RequestContext, - runtime_chat_prompt::{ + prompt::runtime_chat::{ NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT, NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT, - NpcChatTurnPromptInput, build_npc_chat_turn_reply_prompt, + NpcChatTurnPromptInput, build_deterministic_chat_suggestions, + build_deterministic_npc_reply, build_fallback_function_suggestions, + build_fallback_npc_chat_suggestions, build_npc_chat_turn_reply_prompt, build_npc_chat_turn_suggestion_prompt, }, + request_context::RequestContext, state::AppState, }; @@ -256,66 +258,6 @@ where Some((npc_reply, suggestions, function_suggestions, force_exit)) } -fn build_deterministic_npc_reply( - npc_name: &str, - player_message: &str, - npc_initiates_conversation: bool, -) -> String { - // Rust API 尚未迁入旧 Node 的完整 LLM NPC 聊天编排前,先由后端提供稳定兜底,保证相遇与选项聊天链路不断。 - if npc_initiates_conversation { - return format!("{npc_name}看向你,先开口说道:“你来了。先别急着走,我正有话想和你说。”"); - } - format!("{npc_name}听完你的话,回应道:“{player_message}。我明白你的意思,我们继续说。”") -} - -fn build_deterministic_chat_suggestions(npc_name: &str, player_message: &str) -> Vec { - // 建议只承载玩家可点选的行动意图,不在 UI 里额外塞说明文案。 - vec![ - format!("{npc_name},我想先听你说"), - "这件事哪里不对劲".to_string(), - if player_message.contains('帮') || player_message.contains('忙') { - "先别绕,说清代价".to_string() - } else { - "你是不是还瞒着我".to_string() - }, - ] -} - -fn build_fallback_npc_chat_suggestions(player_message: &str) -> Vec { - let topic = player_message.trim().chars().take(8).collect::(); - let topic = if topic.is_empty() { - "刚才那句".to_string() - } else { - topic - }; - - vec![ - "我愿意先听你说完".to_string(), - format!("这事和{topic}有关吗"), - "你别再避重就轻".to_string(), - ] -} - -fn build_fallback_function_suggestions(chat_directive: Option<&Value>) -> Vec { - read_function_options(chat_directive) - .into_iter() - .filter(|option| { - read_string_field(option, "functionId") - .as_deref() - .is_some_and(|function_id| function_id != "npc_chat") - }) - .take(2) - .filter_map(|option| { - let function_id = read_string_field(option, "functionId")?; - let action_text = read_string_field(option, "actionText")?; - Some(json!({ - "functionId": function_id, - "actionText": action_text, - })) - }) - .collect() -} - fn build_completion_directive(chat_directive: Option<&Value>, force_exit: bool) -> Value { let Some(directive) = chat_directive else { return Value::Null; diff --git a/server-rs/crates/api-server/src/runtime_chat_prompt.rs b/server-rs/crates/api-server/src/runtime_chat_prompt.rs deleted file mode 100644 index 7c00d8e1..00000000 --- a/server-rs/crates/api-server/src/runtime_chat_prompt.rs +++ /dev/null @@ -1,644 +0,0 @@ -use serde_json::Value; - -pub(crate) const NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT: &str = r#"你是角色扮演 RPG 里的当前 NPC。 -你只输出这名 NPC 此刻会对玩家说的一轮回复。 -只输出纯中文口语回复正文,不要输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。 -- 如果这是第一次真正接触中的首轮回复,第一句必须先用自然招呼或开场判断起手,不能写成第三人称占位旁白。 -回复长度控制在 1 到 3 句,必须紧接玩家刚说的话,自然推进气氛、情报或关系。"#; - -pub(crate) const NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT: &str = r#"你要为 RPG NPC 聊天生成下一步候选,并判断敌对聊天是否已经收束。 -只输出 JSON,不要输出 Markdown 或解释。 -JSON 结构: -{"shouldEndChat":false,"terminationReason":null,"suggestions":["温和共情台词","冷静追问台词","施压质疑台词"],"functionSuggestions":[{"functionId":"...","actionText":"玩家动作文本"}]} -- suggestions 是玩家下一轮可直接说出口的中文短句,每条 20 字以内;三条必须按顺序导向不同氛围和好感结果。 -- suggestions 第 1 条温和共情,通常让气氛缓和、好感上升;第 2 条冷静追问或试探,通常保持中性但推进情报;第 3 条施压、质疑或立场冲突,通常让气氛变紧、好感下降或付出代价。 -- functionSuggestions 只能从用户提示提供的 functionOptions 中挑选,不要发明 functionId。 -- functionSuggestions 的 actionText 必须像玩家可点击动作,不暴露 functionId,不写规则说明。 -- 非敌对聊天 shouldEndChat 必须为 false。 -- 敌对聊天可以随时 shouldEndChat=true,且敌对 NPC 更偏好在话不投机、被威胁、玩家退出、底线被触碰时结束聊天。"#; - -#[derive(Debug)] -pub(crate) struct NpcChatTurnPromptInput<'a> { - pub world_type: &'a str, - pub character: &'a Value, - pub encounter: &'a Value, - pub monsters: &'a [Value], - pub history: &'a [Value], - pub context: &'a Value, - pub conversation_history: &'a [Value], - pub dialogue: &'a [Value], - pub combat_context: Option<&'a Value>, - pub player_message: &'a str, - pub npc_state: &'a Value, - pub npc_initiates_conversation: bool, - pub chat_directive: Option<&'a Value>, -} - -pub(crate) fn build_npc_chat_turn_reply_prompt(payload: &NpcChatTurnPromptInput<'_>) -> String { - let encounter = describe_encounter(payload.encounter); - let context = as_record(payload.context); - let npc_state = as_record(payload.npc_state); - let chat_directive = payload.chat_directive.and_then(as_record); - let conversation_history = if !payload.conversation_history.is_empty() { - payload.conversation_history - } else { - payload.dialogue - }; - let opening_camp_background = - context.and_then(|record| read_string(record.get("openingCampBackground"))); - let opening_camp_dialogue = - context.and_then(|record| read_string(record.get("openingCampDialogue"))); - let allowed_topics = context - .and_then(|record| record.get("encounterAllowedTopics")) - .map(read_string_array) - .unwrap_or_default(); - let blocked_topics = context - .and_then(|record| record.get("encounterBlockedTopics")) - .map(read_string_array) - .unwrap_or_default(); - let is_first_meaningful_contact = context - .and_then(|record| read_bool(record.get("isFirstMeaningfulContact"))) - .unwrap_or(false); - let affinity = npc_state - .and_then(|record| read_number(record.get("affinity"))) - .unwrap_or(0.0); - let chatted_count = npc_state - .and_then(|record| read_number(record.get("chattedCount"))) - .unwrap_or(0.0); - let limit_reason = chat_directive.and_then(|record| read_string(record.get("limitReason"))); - let turn_limit = chat_directive - .and_then(|record| read_number(record.get("turnLimit"))) - .unwrap_or(0.0) - .max(0.0); - let remaining_turns = chat_directive - .and_then(|record| read_number(record.get("remainingTurns"))) - .unwrap_or(0.0) - .max(0.0); - let closing_mode = chat_directive.and_then(|record| read_string(record.get("closingMode"))); - let is_limited_negative_affinity_chat = - limit_reason.as_deref() == Some("negative_affinity") && turn_limit > 0.0; - let is_hostile_model_chat = chat_directive - .and_then(|record| read_string(record.get("terminationMode"))) - .as_deref() - == Some("hostile_model") - || chat_directive - .and_then(|record| read_bool(record.get("isHostileChat"))) - .unwrap_or(false); - let is_player_exit_turn = chat_directive - .and_then(|record| read_string(record.get("terminationReason"))) - .as_deref() - == Some("player_exit"); - let is_foreshadow_close_turn = closing_mode.as_deref() == Some("foreshadow_close") - || chat_directive - .and_then(|record| read_bool(record.get("forceExitAfterTurn"))) - .unwrap_or(false); - let has_npc_reply_in_history = conversation_history.iter().any(|item| { - as_record(item) - .and_then(|turn| read_string(turn.get("speaker"))) - .is_some_and(|speaker| speaker == "npc") - }); - let is_first_npc_spoken_turn = - is_first_meaningful_contact && !has_npc_reply_in_history && chatted_count <= 0.0; - let first_contact_relation_stance = describe_first_contact_relation_stance( - context.and_then(|record| record.get("firstContactRelationStance")), - ); - let combat_context_block = payload.combat_context.and_then(describe_npc_combat_context); - - [ - Some(build_npc_dialogue_prompt_base(payload)), - Some(describe_npc_conversation_history( - conversation_history, - encounter.npc_name.as_str(), - )), - combat_context_block, - opening_camp_background.map(|text| format!("营地开场背景:{text}")), - opening_camp_dialogue.map(|text| format!("刚刚发生的第一段对话:{text}")), - Some(format!("当前关系值:{}", format_prompt_number(affinity))), - Some(format!("已聊天轮次:{}", format_prompt_number(chatted_count))), - if is_first_npc_spoken_turn { - Some(format!( - "当前接触阶段:第一次真正接触({first_contact_relation_stance})。这是这次聊天里 {} 第一次真正对玩家开口。", - encounter.npc_name - )) - } else { - None - }, - if is_first_npc_spoken_turn { - Some("第一句必须先用一句自然招呼或开场判断起手,再顺着玩家刚刚的话往下接。".to_string()) - } else { - None - }, - if is_first_npc_spoken_turn { - Some("不要写成“某人看着你,像是在等你把话接下去”这类第三人称占位旁白,也不要把整轮写成设定说明。".to_string()) - } else { - None - }, - if payload.npc_initiates_conversation { - Some(format!( - "当前要求:这是 {} 主动开口的第一句,不要假装玩家已经先说过话。", - encounter.npc_name - )) - } else { - None - }, - if allowed_topics.is_empty() { - None - } else { - Some(format!("当前更适合先谈:{}", allowed_topics.join("、"))) - }, - if blocked_topics.is_empty() { - None - } else { - Some(format!("当前避免直接说破:{}", blocked_topics.join("、"))) - }, - if is_limited_negative_affinity_chat { - Some(format!( - "当前相遇属于负好感主角色有限聊天,本次总上限 {} 轮。", - format_prompt_number(turn_limit) - )) - } else { - None - }, - if is_hostile_model_chat { - Some("当前是敌对或负好感聊天。对方不受固定回合限制,但随时可能不耐烦、结束谈话并把局势推向战斗或驱逐。".to_string()) - } else { - None - }, - if is_hostile_model_chat { - Some("敌对角色更偏好短促、戒备、带威胁的回应;如果玩家逼问、挑衅、退场或话题触到底线,回复应自然收束到对峙前一刻。".to_string()) - } else { - None - }, - if is_player_exit_turn { - Some("玩家正在主动结束这轮聊天。请对这个收束动作作出回应,并留下自然的下一步入口。回复后聊天会结束。".to_string()) - } else { - None - }, - if is_limited_negative_affinity_chat { - Some(format!( - "在你回复完这一轮之后,还剩 {} 轮可以继续聊。", - format_prompt_number(remaining_turns) - )) - } else { - None - }, - if is_limited_negative_affinity_chat && !is_foreshadow_close_turn { - Some("语气可以戒备、冷淡、带刺,但不要立刻转成开战,也不要把对话硬掐死。".to_string()) - } else { - None - }, - if is_foreshadow_close_turn { - Some("这是最后一轮回复。必须带有收束感,但不能只用“别问了”“滚开”之类的话把聊天粗暴截断。".to_string()) - } else { - None - }, - if is_foreshadow_close_turn { - Some("最后一轮必须抛出能推动后续剧情的明确铺垫,例如威胁、线索、条件、去处、人物、未说完的真相或下一步悬念。".to_string()) - } else { - None - }, - if is_foreshadow_close_turn { - Some("回复后这轮聊天会结束,所以不要邀请继续闲聊,也不要直接宣布已经开战。".to_string()) - } else { - None - }, - if payload.npc_initiates_conversation { - Some("玩家此刻还没有先说话,请直接写 NPC 主动开口时会说的第一轮回复。".to_string()) - } else { - Some(format!("玩家刚刚说:{}", payload.player_message.trim())) - }, - if payload.npc_initiates_conversation { - Some(format!( - "现在请只写 {} 主动开口时会说的话。", - encounter.npc_name - )) - } else { - Some(format!( - "现在请只写 {} 这一轮会回复玩家的话。", - encounter.npc_name - )) - }, - ] - .into_iter() - .flatten() - .filter(|text| !text.trim().is_empty()) - .collect::>() - .join("\n\n") -} - -pub(crate) fn build_npc_chat_turn_suggestion_prompt( - payload: &NpcChatTurnPromptInput<'_>, - npc_reply: &str, -) -> String { - let encounter = describe_encounter(payload.encounter); - let conversation_history = if !payload.conversation_history.is_empty() { - payload.conversation_history - } else { - payload.dialogue - }; - let combat_context_block = payload.combat_context.and_then(describe_npc_combat_context); - let chat_directive = payload.chat_directive.and_then(as_record); - let is_hostile_model_chat = chat_directive - .and_then(|record| read_string(record.get("terminationMode"))) - .as_deref() - == Some("hostile_model") - || chat_directive - .and_then(|record| read_bool(record.get("isHostileChat"))) - .unwrap_or(false); - let is_player_exit_turn = chat_directive - .and_then(|record| read_string(record.get("terminationReason"))) - .as_deref() - == Some("player_exit"); - let function_options_block = chat_directive - .and_then(|record| record.get("functionOptions")) - .map(describe_function_options) - .filter(|text| !text.trim().is_empty()); - - [ - Some(build_npc_dialogue_prompt_base(payload)), - Some(describe_npc_conversation_history( - conversation_history, - encounter.npc_name.as_str(), - )), - combat_context_block, - function_options_block, - if payload.npc_initiates_conversation { - Some("玩家尚未先开口,这一轮是 NPC 主动发起聊天。".to_string()) - } else { - Some(format!("玩家刚刚说:{}", payload.player_message)) - }, - Some(format!("NPC 刚刚回复:{npc_reply}")), - if is_hostile_model_chat { - Some("这是敌对或负好感聊天。你需要判断这轮是否应该结束聊天;敌对角色更偏好随时终止并转入对峙。".to_string()) - } else { - Some("这是非敌对聊天,shouldEndChat 必须为 false。".to_string()) - }, - if is_player_exit_turn { - Some("玩家已经选择结束聊天,shouldEndChat 必须为 true,terminationReason 必须为 player_exit。".to_string()) - } else { - None - }, - Some("suggestions 必须按顺序生成三种明显不同的玩家台词:温和共情、冷静追问或试探、施压质疑;不要给出同一种态度的近义句。".to_string()), - Some("functionSuggestions 从 functionOptions 中挑可触发动作并改写 actionText。".to_string()), - Some("只输出 JSON:{\"shouldEndChat\":false,\"terminationReason\":null,\"suggestions\":[\"...\"],\"functionSuggestions\":[{\"functionId\":\"...\",\"actionText\":\"...\"}]}".to_string()), - ] - .into_iter() - .flatten() - .filter(|text| !text.trim().is_empty()) - .collect::>() - .join("\n\n") -} - -fn describe_function_options(value: &Value) -> String { - let lines = value - .as_array() - .map(|items| { - items - .iter() - .take(8) - .filter_map(|item| { - let record = as_record(item)?; - let function_id = read_string(record.get("functionId"))?; - let action_text = read_string(record.get("actionText"))?; - let detail_text = read_string(record.get("detailText")); - let action = read_string(record.get("action")); - Some(format!( - "- functionId: {function_id}; actionText: {action_text}; action: {}; detail: {}", - action.unwrap_or_else(|| "unknown".to_string()), - detail_text.unwrap_or_else(|| "无".to_string()), - )) - }) - .collect::>() - }) - .unwrap_or_default(); - - if lines.is_empty() { - return String::new(); - } - - let mut result = vec!["当前聊天中可改写为动作候选的 functionOptions:".to_string()]; - result.extend(lines); - result.join("\n") -} - -fn build_npc_dialogue_prompt_base(payload: &NpcChatTurnPromptInput<'_>) -> String { - let encounter = describe_encounter(payload.encounter); - - [ - format!("世界:{}", describe_world(payload.world_type)), - describe_scene_context(payload.context), - describe_character("玩家 / ", payload.character), - encounter.block, - describe_monsters(payload.monsters), - describe_story_history(payload.history), - ] - .into_iter() - .filter(|text| !text.trim().is_empty()) - .collect::>() - .join("\n\n") -} - -struct EncounterDescription { - npc_name: String, - block: String, -} - -fn describe_encounter(encounter: &Value) -> EncounterDescription { - let record = as_record(encounter); - let npc_name = record - .and_then(|item| read_string(item.get("npcName"))) - .unwrap_or_else(|| "眼前角色".to_string()); - let context_text = record - .and_then(|item| read_string(item.get("context"))) - .or_else(|| record.and_then(|item| read_string(item.get("npcDescription")))) - .unwrap_or_else(|| "你们正在当前遭遇里继续对话。".to_string()); - - EncounterDescription { - npc_name: npc_name.clone(), - block: format!("当前对象:{npc_name}\n对象背景:{context_text}"), - } -} - -fn describe_first_contact_relation_stance(value: Option<&Value>) -> String { - match value.and_then(|item| item.as_str()).map(str::trim) { - Some("guarded") => "戒备试探".to_string(), - Some("neutral") => "正常交流但仍不熟".to_string(), - Some("cooperative") => "已有善意,先确认合作节奏".to_string(), - Some("bonded") => "明显信任,但仍是第一次正式对上人".to_string(), - _ => "第一次真正接触".to_string(), - } -} - -fn describe_world(world_type: &str) -> String { - match world_type { - "WUXIA" => "边城模板".to_string(), - "XIANXIA" => "灵潮模板".to_string(), - "CUSTOM" => "自定义世界".to_string(), - value if !value.trim().is_empty() => value.to_string(), - _ => "未知世界".to_string(), - } -} - -fn describe_stats(label: &str, record: Option<&serde_json::Map>) -> String { - let hp = record - .and_then(|item| read_number(item.get("hp"))) - .unwrap_or(0.0); - let max_hp = record - .and_then(|item| read_number(item.get("maxHp"))) - .unwrap_or(hp) - .max(1.0); - let mana = record - .and_then(|item| read_number(item.get("mana"))) - .unwrap_or(0.0); - let max_mana = record - .and_then(|item| read_number(item.get("maxMana"))) - .unwrap_or(mana) - .max(1.0); - - format!( - "{label}生命 {}/{},灵力 {}/{}", - format_prompt_number(hp), - format_prompt_number(max_hp), - format_prompt_number(mana), - format_prompt_number(max_mana) - ) -} - -fn describe_character(label: &str, value: &Value) -> String { - let record = as_record(value); - let name = record - .and_then(|item| read_string(item.get("name"))) - .unwrap_or_else(|| "未知角色".to_string()); - let title = record - .and_then(|item| read_string(item.get("title"))) - .unwrap_or_else(|| "未知称号".to_string()); - let description = record - .and_then(|item| read_string(item.get("description"))) - .unwrap_or_else(|| "暂无额外描述".to_string()); - let personality = record - .and_then(|item| read_string(item.get("personality"))) - .unwrap_or_else(|| "性格信息未显式提供".to_string()); - - [ - format!("{label}姓名:{name}"), - format!("{label}称号:{title}"), - format!("{label}描述:{description}"), - format!("{label}性格:{personality}"), - ] - .join("\n") -} - -fn describe_story_history(history: &[Value]) -> String { - if history.is_empty() { - return "近期剧情:暂无。".to_string(); - } - - let lines = history - .iter() - .rev() - .take(4) - .collect::>() - .into_iter() - .rev() - .filter_map(|item| as_record(item).and_then(|record| read_string(record.get("text")))) - .collect::>(); - - if lines.is_empty() { - "近期剧情:暂无。".to_string() - } else { - let mut result = vec!["近期剧情:".to_string()]; - result.extend(lines.into_iter().map(|line| format!("- {line}"))); - result.join("\n") - } -} - -fn describe_npc_conversation_history(history: &[Value], npc_name: &str) -> String { - if history.is_empty() { - return "当前聊天记录:暂无。".to_string(); - } - - let lines = history - .iter() - .rev() - .take(10) - .collect::>() - .into_iter() - .rev() - .filter_map(|item| { - let record = as_record(item)?; - let speaker = read_string(record.get("speaker")); - let speaker_name = read_string(record.get("speakerName")); - let text = read_string(record.get("text"))?; - - match speaker.as_deref() { - Some("player") => Some(format!("- 玩家:{text}")), - Some("npc") => Some(format!( - "- {}:{text}", - speaker_name.unwrap_or_else(|| npc_name.to_string()) - )), - Some("system") => Some(format!("- 系统提示:{text}")), - _ => Some(format!( - "- {}:{text}", - speaker_name.unwrap_or_else(|| "同伴".to_string()) - )), - } - }) - .collect::>(); - - if lines.is_empty() { - "当前聊天记录:暂无。".to_string() - } else { - let mut result = vec!["当前聊天记录:".to_string()]; - result.extend(lines); - result.join("\n") - } -} - -fn describe_npc_combat_context(combat_context: &Value) -> Option { - let record = as_record(combat_context)?; - let summary = read_string(record.get("summary")); - let battle_outcome = read_string(record.get("battleOutcome")); - let log_lines = record - .get("logLines") - .map(read_string_array) - .unwrap_or_default() - .into_iter() - .take(6) - .collect::>(); - if summary.is_none() && log_lines.is_empty() { - return None; - } - - let outcome_text = match battle_outcome.as_deref() { - Some("spar_complete") => Some("切磋刚刚结束。".to_string()), - Some("victory") => Some("战斗刚刚分出胜负。".to_string()), - _ => None, - }; - let mut lines = vec!["刚刚结束的交锋:".to_string()]; - if let Some(text) = outcome_text { - lines.push(text); - } - if let Some(text) = summary { - lines.push(format!("- 结果摘要:{text}")); - } - if !log_lines.is_empty() { - lines.push("- 战斗日志:".to_string()); - lines.extend(log_lines.into_iter().map(|line| format!(" - {line}"))); - } - Some(lines.join("\n")) -} - -fn describe_scene_context(context: &Value) -> String { - let record = as_record(context); - let scene_name = record - .and_then(|item| read_string(item.get("sceneName"))) - .unwrap_or_else(|| "当前区域".to_string()); - let scene_description = record - .and_then(|item| read_string(item.get("sceneDescription"))) - .unwrap_or_else(|| "周围气氛仍未完全安定。".to_string()); - let in_battle = if record - .and_then(|item| read_bool(item.get("inBattle"))) - .unwrap_or(false) - { - "战斗中" - } else { - "非战斗" - }; - let custom_world_profile = record - .and_then(|item| item.get("customWorldProfile")) - .and_then(as_record); - let custom_world_name = custom_world_profile.and_then(|item| read_string(item.get("name"))); - let custom_world_summary = - custom_world_profile.and_then(|item| read_string(item.get("summary"))); - - [ - Some(format!( - "世界补充:{}", - custom_world_name.unwrap_or_else(|| "无".to_string()) - )), - custom_world_summary.map(|text| format!("世界摘要:{text}")), - Some(format!("场景:{scene_name}")), - Some(format!("场景描述:{scene_description}")), - Some(format!("当前状态:{in_battle}")), - Some(describe_stats("玩家", record)), - ] - .into_iter() - .flatten() - .collect::>() - .join("\n") -} - -fn describe_monsters(monsters: &[Value]) -> String { - if monsters.is_empty() { - return "当前敌对目标:无。".to_string(); - } - - let lines = monsters - .iter() - .take(4) - .filter_map(|item| { - let record = as_record(item)?; - let name = read_string(record.get("name")) - .or_else(|| read_string(record.get("npcName"))) - .or_else(|| read_string(record.get("id")))?; - let hp = read_number(record.get("hp")).unwrap_or(0.0); - let max_hp = read_number(record.get("maxHp")).unwrap_or(hp).max(1.0); - - Some(format!( - "- {name}(生命 {}/{})", - format_prompt_number(hp), - format_prompt_number(max_hp) - )) - }) - .collect::>(); - - if lines.is_empty() { - "当前敌对目标:无。".to_string() - } else { - let mut result = vec!["当前敌对目标:".to_string()]; - result.extend(lines); - result.join("\n") - } -} - -fn read_string(value: Option<&Value>) -> Option { - value - .and_then(Value::as_str) - .map(str::trim) - .filter(|text| !text.is_empty()) - .map(ToOwned::to_owned) -} - -fn read_number(value: Option<&Value>) -> Option { - value - .and_then(Value::as_f64) - .filter(|number| number.is_finite()) -} - -fn read_bool(value: Option<&Value>) -> Option { - value.and_then(Value::as_bool) -} - -fn read_string_array(value: &Value) -> Vec { - value - .as_array() - .map(|items| { - items - .iter() - .filter_map(|item| read_string(Some(item))) - .collect::>() - }) - .unwrap_or_default() -} - -fn as_record(value: &Value) -> Option<&serde_json::Map> { - value.as_object() -} - -fn format_prompt_number(value: f64) -> String { - if value.fract() == 0.0 { - format!("{}", value as i64) - } else { - value.to_string() - } -} diff --git a/server-rs/crates/api-server/src/runtime_save.rs b/server-rs/crates/api-server/src/runtime_save.rs index 6a3b2b43..7a6277db 100644 --- a/server-rs/crates/api-server/src/runtime_save.rs +++ b/server-rs/crates/api-server/src/runtime_save.rs @@ -4,6 +4,7 @@ use axum::{ http::StatusCode, response::Response, }; +use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros}; use serde::Deserialize; use serde_json::{Value, json}; use shared_contracts::runtime::{ @@ -70,8 +71,8 @@ pub async fn put_runtime_snapshot( let updated_at_micros = offset_datetime_to_unix_micros(now); let saved_at_micros = offset_datetime_to_unix_micros(saved_at); - let record = state - .put_runtime_snapshot_record( + let record = if is_non_persistent_runtime_snapshot(&payload.game_state) { + build_transient_runtime_snapshot_record( user_id, saved_at_micros, payload.bottom_tab, @@ -79,10 +80,21 @@ pub async fn put_runtime_snapshot( payload.current_story, updated_at_micros, ) - .await - .map_err(|error| { - runtime_save_error_response(&request_context, map_runtime_save_client_error(error)) - })?; + } else { + state + .put_runtime_snapshot_record( + user_id, + saved_at_micros, + payload.bottom_tab, + payload.game_state, + payload.current_story, + updated_at_micros, + ) + .await + .map_err(|error| { + runtime_save_error_response(&request_context, map_runtime_save_client_error(error)) + })? + }; Ok(json_success_body( Some(&request_context), @@ -184,6 +196,52 @@ fn build_saved_game_snapshot_response( } } +fn build_transient_runtime_snapshot_record( + user_id: String, + saved_at_micros: i64, + bottom_tab: String, + game_state: Value, + current_story: Option, + updated_at_micros: i64, +) -> module_runtime::RuntimeSnapshotRecord { + // 中文注释:预览/测试入口可得到本次响应,但不能覆盖用户正式当前快照。 + module_runtime::RuntimeSnapshotRecord { + user_id, + version: SAVE_SNAPSHOT_VERSION, + saved_at: format_utc_micros(saved_at_micros), + saved_at_micros, + bottom_tab, + game_state_json: game_state.to_string(), + current_story_json: current_story.as_ref().map(Value::to_string), + game_state, + current_story, + created_at_micros: updated_at_micros, + updated_at_micros, + } +} + +fn is_non_persistent_runtime_snapshot(game_state: &Value) -> bool { + let Some(game_state) = game_state.as_object() else { + return false; + }; + + if game_state + .get("runtimePersistenceDisabled") + .and_then(Value::as_bool) + .unwrap_or(false) + { + return true; + } + + matches!( + game_state + .get("runtimeMode") + .and_then(Value::as_str) + .map(str::trim), + Some("preview") | Some("test") + ) +} + fn build_profile_save_archive_summary_response( record: &module_runtime::RuntimeProfileSaveArchiveRecord, ) -> ProfileSaveArchiveSummaryResponse { diff --git a/server-rs/crates/api-server/src/runtime_story/compat.rs b/server-rs/crates/api-server/src/runtime_story/compat.rs index 7aad190c..ce9e931f 100644 --- a/server-rs/crates/api-server/src/runtime_story/compat.rs +++ b/server-rs/crates/api-server/src/runtime_story/compat.rs @@ -8,7 +8,7 @@ use module_npc::{ NpcRelationStance, build_initial_stance_profile as build_module_npc_initial_stance_profile, build_relation_state as build_module_npc_relation_state, }; -use module_runtime::RuntimeSnapshotRecord; +use module_runtime::{RuntimeSnapshotRecord, SAVE_SNAPSHOT_VERSION, format_utc_micros}; use module_runtime_story_compat::{ CONTINUE_ADVENTURE_FUNCTION_ID, CurrentEncounterNpcQuestContext, GeneratedStoryPayload, PendingQuestOfferContext, RuntimeStoryActionResponseParts, StoryResolution, @@ -376,15 +376,28 @@ async fn persist_runtime_story_snapshot( ) })? .unwrap_or(now); + let saved_at_micros = offset_datetime_to_unix_micros(saved_at); + let updated_at_micros = offset_datetime_to_unix_micros(now); + + if is_non_persistent_runtime_story_snapshot(&snapshot) { + return Ok(build_transient_runtime_snapshot_record( + user_id, + saved_at_micros, + snapshot.bottom_tab, + snapshot.game_state, + snapshot.current_story, + updated_at_micros, + )); + } state .put_runtime_snapshot_record( user_id, - offset_datetime_to_unix_micros(saved_at), + saved_at_micros, snapshot.bottom_tab, snapshot.game_state, snapshot.current_story, - offset_datetime_to_unix_micros(now), + updated_at_micros, ) .await .map_err(|error| { @@ -392,6 +405,52 @@ async fn persist_runtime_story_snapshot( }) } +fn build_transient_runtime_snapshot_record( + user_id: String, + saved_at_micros: i64, + bottom_tab: String, + game_state: Value, + current_story: Option, + updated_at_micros: i64, +) -> RuntimeSnapshotRecord { + // 中文注释:预览/测试只需要本次响应里的 hydrated snapshot,不能写入正式存档表。 + RuntimeSnapshotRecord { + user_id, + version: SAVE_SNAPSHOT_VERSION, + saved_at: format_utc_micros(saved_at_micros), + saved_at_micros, + bottom_tab, + game_state_json: game_state.to_string(), + current_story_json: current_story.as_ref().map(Value::to_string), + game_state, + current_story, + created_at_micros: updated_at_micros, + updated_at_micros, + } +} + +fn is_non_persistent_runtime_story_snapshot(snapshot: &RuntimeStorySnapshotPayload) -> bool { + let Some(game_state) = snapshot.game_state.as_object() else { + return false; + }; + + if game_state + .get("runtimePersistenceDisabled") + .and_then(Value::as_bool) + .unwrap_or(false) + { + return true; + } + + matches!( + game_state + .get("runtimeMode") + .and_then(Value::as_str) + .map(str::trim), + Some("preview") | Some("test") + ) +} + fn validate_snapshot_payload(snapshot: &RuntimeStorySnapshotPayload) -> Result<(), String> { if normalize_required_string(snapshot.bottom_tab.as_str()).is_none() { return Err("snapshot.bottomTab 不能为空".to_string()); diff --git a/server-rs/crates/api-server/src/runtime_story/compat/tests.rs b/server-rs/crates/api-server/src/runtime_story/compat/tests.rs index 181c941e..b76f3155 100644 --- a/server-rs/crates/api-server/src/runtime_story/compat/tests.rs +++ b/server-rs/crates/api-server/src/runtime_story/compat/tests.rs @@ -191,6 +191,133 @@ async fn runtime_story_routes_resolve_through_rust_route_boundary() { ); } +#[tokio::test] +async fn runtime_story_preview_snapshot_returns_transient_response_without_overwriting_save() { + let state = seed_authenticated_state().await; + let token = issue_access_token(&state); + let app = build_router(state); + + let formal_response = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri("/api/runtime/save/snapshot") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .header("x-genarrative-response-envelope", "v1") + .body(Body::from( + json!({ + "bottomTab": "adventure", + "gameState": { + "runtimeSessionId": "runtime-main", + "runtimeActionVersion": 1, + "worldType": "WUXIA", + "playerCharacter": { "id": "hero" }, + "currentScene": "Story", + "runtimeStats": { "playTimeMs": 0 }, + "storyHistory": [] + }, + "currentStory": { + "text": "正式存档里的故事。", + "options": [] + } + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + assert_eq!(formal_response.status(), StatusCode::OK); + + let preview_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/runtime/story/actions/resolve") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .header("x-genarrative-response-envelope", "v1") + .body(Body::from( + json!({ + "sessionId": "runtime-main", + "clientVersion": 3, + "action": { + "type": "story_choice", + "functionId": "idle_rest_focus" + }, + "snapshot": { + "bottomTab": "adventure", + "gameState": { + "runtimeSessionId": "runtime-main", + "runtimeActionVersion": 3, + "runtimeMode": "preview", + "runtimePersistenceDisabled": true, + "playerHp": 10, + "playerMaxHp": 30, + "playerMana": 2, + "playerMaxMana": 12, + "storyHistory": [] + }, + "currentStory": { + "text": "幕预览里的临时故事。", + "options": [] + } + } + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + assert_eq!(preview_response.status(), StatusCode::OK); + let preview_payload: Value = serde_json::from_slice( + &preview_response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(), + ) + .expect("response should be json"); + assert_eq!( + preview_payload["data"]["snapshot"]["gameState"]["runtimeMode"], + json!("preview") + ); + + let saved_response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/runtime/save/snapshot") + .header("authorization", format!("Bearer {token}")) + .header("x-genarrative-response-envelope", "v1") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + assert_eq!(saved_response.status(), StatusCode::OK); + let saved_payload: Value = serde_json::from_slice( + &saved_response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(), + ) + .expect("response should be json"); + + assert_eq!( + saved_payload["data"]["currentStory"]["text"], + json!("正式存档里的故事。") + ); + assert!(saved_payload["data"]["gameState"]["runtimeMode"].is_null()); +} + #[tokio::test] async fn runtime_story_action_resolve_rejects_client_version_conflict() { let state = seed_authenticated_state().await; diff --git a/server-rs/crates/module-puzzle/src/lib.rs b/server-rs/crates/module-puzzle/src/lib.rs index fa6e98cd..8fdacff5 100644 --- a/server-rs/crates/module-puzzle/src/lib.rs +++ b/server-rs/crates/module-puzzle/src/lib.rs @@ -15,6 +15,7 @@ pub const PUZZLE_PROFILE_ID_PREFIX: &str = "puzzle-profile-"; pub const PUZZLE_RUN_ID_PREFIX: &str = "puzzle-run-"; pub const PUZZLE_MIN_TAG_COUNT: usize = 3; pub const PUZZLE_MAX_TAG_COUNT: usize = 6; +const PUZZLE_INITIAL_SHUFFLE_ATTEMPTS: u64 = 64; #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -680,7 +681,7 @@ pub fn build_generated_candidates( ) -> Result, PuzzleFieldError> { let session_id = normalize_required_string(session_id).ok_or(PuzzleFieldError::MissingSessionId)?; - let count = candidate_count.max(1).min(2); + let count = candidate_count.max(1).min(1); let prompt = normalize_required_string(prompt_text.unwrap_or(&draft.summary)) .unwrap_or_else(|| draft.summary.clone()); @@ -690,7 +691,7 @@ pub fn build_generated_candidates( let candidate_id = format!("{session_id}-candidate-{}", index + 1); PuzzleGeneratedImageCandidate { candidate_id: candidate_id.clone(), - // 拼图候选图的正式持久化由 api-server 上传 OSS;这里仅保留 reducer + // 拼图图片的正式持久化由 api-server 上传 OSS;这里仅保留 reducer // 单测/保底路径构造,前缀必须与 OSS 兼容路由一致,不能再指向 public 目录。 image_src: format!( "/generated-puzzle-assets/{session_id}/{candidate_seed}/cover.svg" @@ -884,37 +885,18 @@ pub fn resolve_puzzle_grid_size(cleared_level_count: u32) -> u32 { } pub fn build_initial_board(grid_size: u32) -> Result { + build_initial_board_with_seed(grid_size, 0) +} + +pub fn build_initial_board_with_seed( + grid_size: u32, + shuffle_seed: u64, +) -> Result { if !matches!(grid_size, 3 | 4) { return Err(PuzzleFieldError::InvalidGridSize); } - let total = grid_size * grid_size; - let mut positions = (0..total) - .map(|index| PuzzleCellPosition { - row: index / grid_size, - col: index % grid_size, - }) - .collect::>(); - - if total > 1 { - positions.rotate_left(1); - } - - let pieces = (0..total) - .map(|index| { - let correct_row = index / grid_size; - let correct_col = index % grid_size; - let current = &positions[index as usize]; - PuzzlePieceState { - piece_id: format!("piece-{index}"), - correct_row, - correct_col, - current_row: current.row, - current_col: current.col, - merged_group_id: None, - } - }) - .collect::>(); + let pieces = build_initial_pieces_without_correct_neighbors(grid_size, shuffle_seed); Ok(rebuild_board_snapshot(grid_size, pieces, None)) } @@ -925,7 +907,23 @@ pub fn start_run( cleared_level_count: u32, ) -> Result { let grid_size = resolve_puzzle_grid_size(cleared_level_count); - let board = build_initial_board(grid_size)?; + let shuffle_seed = puzzle_shuffle_seed( + &run_id, + &entry_profile.profile_id, + cleared_level_count + 1, + grid_size, + ); + start_run_with_shuffle_seed(run_id, entry_profile, cleared_level_count, shuffle_seed) +} + +pub fn start_run_with_shuffle_seed( + run_id: String, + entry_profile: &PuzzleWorkProfile, + cleared_level_count: u32, + shuffle_seed: u64, +) -> Result { + let grid_size = resolve_puzzle_grid_size(cleared_level_count); + let board = build_initial_board_with_seed(grid_size, shuffle_seed)?; Ok(PuzzleRunSnapshot { run_id: run_id.clone(), entry_profile_id: entry_profile.profile_id.clone(), @@ -989,7 +987,23 @@ pub fn swap_pieces( pieces[second_index].current_row = first_row; pieces[second_index].current_col = first_col; - let next_board = rebuild_board_snapshot(current_level.grid_size, pieces, None); + let affected_cells = [ + PuzzleCellPosition { + row: first_row, + col: first_col, + }, + PuzzleCellPosition { + row: second_row, + col: second_col, + }, + ]; + let next_board = rebuild_board_snapshot_for_affected_cells( + current_level.grid_size, + ¤t_level.board, + pieces, + affected_cells, + None, + ); Ok(with_next_board(run, next_board)) } @@ -1019,13 +1033,91 @@ pub fn drag_piece_or_group( .ok_or(PuzzleFieldError::MissingPieceId)?; let source_group_id = pieces[piece_index].merged_group_id.clone(); - match source_group_id { + let operation_cells = match source_group_id { Some(group_id) => drag_group(&mut pieces, &group_id, target_row, target_col, grid_size)?, None => drag_single_piece(&mut pieces, piece_index, target_row, target_col)?, + }; + + let next_board = rebuild_board_snapshot_for_affected_cells( + grid_size, + ¤t_level.board, + pieces, + operation_cells, + None, + ); + Ok(with_next_board(run, next_board)) +} + +pub fn rebuild_board_snapshot_for_affected_cells( + grid_size: u32, + previous_board: &PuzzleBoardSnapshot, + pieces: Vec, + affected_cells: impl IntoIterator, + selected_piece_id: Option, +) -> PuzzleBoardSnapshot { + let affected_scope = expand_affected_cells(grid_size, affected_cells); + if affected_scope.is_empty() || previous_board.merged_groups.is_empty() { + return rebuild_board_snapshot(grid_size, pieces, selected_piece_id); } - let next_board = rebuild_board_snapshot(grid_size, pieces, None); - Ok(with_next_board(run, next_board)) + let mut recalculated_piece_ids = pieces + .iter() + .filter(|piece| affected_scope.contains(&(piece.current_row, piece.current_col))) + .map(|piece| piece.piece_id.clone()) + .collect::>(); + let previous_piece_by_id = previous_board + .pieces + .iter() + .map(|piece| (piece.piece_id.clone(), piece)) + .collect::>(); + + for piece_id in recalculated_piece_ids.clone() { + if let Some(previous_piece) = previous_piece_by_id.get(&piece_id) + && let Some(group_id) = previous_piece.merged_group_id.as_deref() + { + add_previous_group_piece_ids(previous_board, group_id, &mut recalculated_piece_ids); + } + } + + let mut preserved_groups = Vec::new(); + for group in &previous_board.merged_groups { + if group + .piece_ids + .iter() + .any(|piece_id| recalculated_piece_ids.contains(piece_id)) + { + continue; + } + let occupied_cells = group + .piece_ids + .iter() + .filter_map(|piece_id| { + pieces + .iter() + .find(|piece| piece.piece_id == *piece_id) + .map(|piece| PuzzleCellPosition { + row: piece.current_row, + col: piece.current_col, + }) + }) + .collect::>(); + if occupied_cells.len() == group.piece_ids.len() { + preserved_groups.push(PuzzleMergedGroupState { + group_id: group.group_id.clone(), + piece_ids: group.piece_ids.clone(), + occupied_cells, + }); + } + } + + let recalculated_pieces = pieces + .iter() + .filter(|piece| recalculated_piece_ids.contains(&piece.piece_id)) + .cloned() + .collect::>(); + let mut next_groups = preserved_groups; + next_groups.extend(resolve_merged_groups(&recalculated_pieces)); + rebuild_board_snapshot_with_groups(grid_size, pieces, next_groups, selected_piece_id) } pub fn advance_next_level( @@ -1042,7 +1134,13 @@ pub fn advance_next_level( let next_cleared_count = run.cleared_level_count; let next_grid_size = resolve_puzzle_grid_size(next_cleared_count); - let next_board = build_initial_board(next_grid_size)?; + let shuffle_seed = puzzle_shuffle_seed( + &run.run_id, + &next_profile.profile_id, + run.current_level_index + 1, + next_grid_size, + ); + let next_board = build_initial_board_with_seed(next_grid_size, shuffle_seed)?; let mut played_profile_ids = run.played_profile_ids.clone(); played_profile_ids.push(next_profile.profile_id.clone()); @@ -1258,12 +1356,146 @@ fn split_phrase_list(value: &str) -> Vec { .collect() } +fn puzzle_shuffle_seed(run_id: &str, profile_id: &str, level_index: u32, grid_size: u32) -> u64 { + let mut hash = 0xcbf2_9ce4_8422_2325_u64; + for byte in run_id + .bytes() + .chain(profile_id.bytes()) + .chain(level_index.to_le_bytes()) + .chain(grid_size.to_le_bytes()) + { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +fn shuffle_positions(positions: &mut [PuzzleCellPosition], seed: u64) { + if positions.len() <= 1 { + return; + } + + let mut state = seed ^ ((positions.len() as u64) << 32) ^ 0x9e37_79b9_7f4a_7c15; + for index in (1..positions.len()).rev() { + state = state + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + let swap_index = (state % ((index + 1) as u64)) as usize; + positions.swap(index, swap_index); + } +} + +fn build_initial_pieces_without_correct_neighbors( + grid_size: u32, + shuffle_seed: u64, +) -> Vec { + let total = grid_size * grid_size; + let base_positions = build_correct_positions(grid_size); + for attempt in 0..PUZZLE_INITIAL_SHUFFLE_ATTEMPTS { + let mut positions = base_positions.clone(); + shuffle_positions( + &mut positions, + shuffle_seed.wrapping_add(attempt.wrapping_mul(0x9e37_79b9_7f4a_7c15)), + ); + ensure_board_is_not_solved(&mut positions, grid_size); + let pieces = build_pieces_from_positions(grid_size, &positions); + if !has_any_correct_neighbor_pair(&pieces) { + return pieces; + } + } + + // 反序布局等价于把完整棋盘旋转 180 度;任意原图相邻块在当前棋盘中的方向都会反向, + // 因此可作为“开局没有正确相邻块”的确定性兜底。 + let fallback_pieces = + build_pieces_from_positions(grid_size, &build_reverse_positions(total, grid_size)); + debug_assert!(!has_any_correct_neighbor_pair(&fallback_pieces)); + fallback_pieces +} + +fn build_correct_positions(grid_size: u32) -> Vec { + let total = grid_size * grid_size; + (0..total) + .map(|index| PuzzleCellPosition { + row: index / grid_size, + col: index % grid_size, + }) + .collect() +} + +fn build_reverse_positions(total: u32, grid_size: u32) -> Vec { + (0..total) + .rev() + .map(|index| PuzzleCellPosition { + row: index / grid_size, + col: index % grid_size, + }) + .collect() +} + +fn build_pieces_from_positions( + grid_size: u32, + positions: &[PuzzleCellPosition], +) -> Vec { + positions + .iter() + .enumerate() + .map(|(index, current)| { + let index = index as u32; + PuzzlePieceState { + piece_id: format!("piece-{index}"), + correct_row: index / grid_size, + correct_col: index % grid_size, + current_row: current.row, + current_col: current.col, + merged_group_id: None, + } + }) + .collect() +} + +fn ensure_board_is_not_solved(positions: &mut [PuzzleCellPosition], grid_size: u32) { + if positions.len() <= 1 { + return; + } + + let is_solved = positions.iter().enumerate().all(|(index, position)| { + position.row == index as u32 / grid_size && position.col == index as u32 % grid_size + }); + if is_solved { + positions.rotate_left(1); + } +} + +fn has_any_correct_neighbor_pair(pieces: &[PuzzlePieceState]) -> bool { + let pieces_by_cell = pieces + .iter() + .map(|piece| ((piece.current_row, piece.current_col), piece)) + .collect::>(); + + pieces.iter().any(|piece| { + neighbor_cells(piece.current_row, piece.current_col) + .into_iter() + .filter_map(|cell| pieces_by_cell.get(&cell)) + .any(|neighbor| are_correct_neighbors(piece, neighbor)) + }) +} + fn rebuild_board_snapshot( grid_size: u32, - mut pieces: Vec, + pieces: Vec, selected_piece_id: Option, ) -> PuzzleBoardSnapshot { let merged_groups = resolve_merged_groups(&pieces); + rebuild_board_snapshot_with_groups(grid_size, pieces, merged_groups, selected_piece_id) +} + +fn rebuild_board_snapshot_with_groups( + grid_size: u32, + mut pieces: Vec, + merged_groups: Vec, + selected_piece_id: Option, +) -> PuzzleBoardSnapshot { + let merged_groups = normalize_group_ids(merged_groups); let group_by_piece = merged_groups .iter() .flat_map(|group| { @@ -1279,9 +1511,13 @@ fn rebuild_board_snapshot( piece.merged_group_id = group_by_piece.get(&piece.piece_id).cloned(); } - let all_tiles_resolved = pieces.iter().all(|piece| { + let all_pieces_in_correct_cells = pieces.iter().all(|piece| { piece.correct_row == piece.current_row && piece.correct_col == piece.current_col }); + let all_pieces_merged_into_one_group = merged_groups + .iter() + .any(|group| group.piece_ids.len() == pieces.len() && pieces.len() > 1); + let all_tiles_resolved = all_pieces_in_correct_cells || all_pieces_merged_into_one_group; PuzzleBoardSnapshot { rows: grid_size, @@ -1293,6 +1529,50 @@ fn rebuild_board_snapshot( } } +fn normalize_group_ids(groups: Vec) -> Vec { + groups + .into_iter() + .enumerate() + .map(|(index, group)| PuzzleMergedGroupState { + group_id: format!("group-{}", index + 1), + ..group + }) + .collect() +} + +fn expand_affected_cells( + grid_size: u32, + cells: impl IntoIterator, +) -> BTreeSet<(u32, u32)> { + let mut scope = BTreeSet::new(); + for cell in cells { + if cell.row >= grid_size || cell.col >= grid_size { + continue; + } + scope.insert((cell.row, cell.col)); + for (row, col) in neighbor_cells(cell.row, cell.col) { + if row < grid_size && col < grid_size { + scope.insert((row, col)); + } + } + } + scope +} + +fn add_previous_group_piece_ids( + previous_board: &PuzzleBoardSnapshot, + group_id: &str, + piece_ids: &mut BTreeSet, +) { + if let Some(group) = previous_board + .merged_groups + .iter() + .find(|group| group.group_id == group_id) + { + piece_ids.extend(group.piece_ids.iter().cloned()); + } +} + fn resolve_merged_groups(pieces: &[PuzzlePieceState]) -> Vec { let pieces_by_cell = pieces .iter() @@ -1385,17 +1665,32 @@ fn drag_single_piece( piece_index: usize, target_row: u32, target_col: u32, -) -> Result<(), PuzzleFieldError> { +) -> Result, PuzzleFieldError> { let target_index = pieces .iter() .position(|piece| piece.current_row == target_row && piece.current_col == target_col) .ok_or(PuzzleFieldError::InvalidTargetCell)?; + let mut affected_cells = vec![ + PuzzleCellPosition { + row: pieces[piece_index].current_row, + col: pieces[piece_index].current_col, + }, + PuzzleCellPosition { + row: target_row, + col: target_col, + }, + ]; + if let Some(target_group_id) = pieces[target_index].merged_group_id.clone() { for piece in pieces .iter_mut() .filter(|piece| piece.merged_group_id.as_deref() == Some(target_group_id.as_str())) { + affected_cells.push(PuzzleCellPosition { + row: piece.current_row, + col: piece.current_col, + }); piece.merged_group_id = None; } } @@ -1410,7 +1705,7 @@ fn drag_single_piece( pieces[target_index].current_row = source_row; pieces[target_index].current_col = source_col; } - Ok(()) + Ok(affected_cells) } fn drag_group( @@ -1419,7 +1714,7 @@ fn drag_group( target_row: u32, target_col: u32, grid_size: u32, -) -> Result<(), PuzzleFieldError> { +) -> Result, PuzzleFieldError> { let group_indices = pieces .iter() .enumerate() @@ -1456,8 +1751,19 @@ fn drag_group( .iter() .map(|index| (pieces[*index].current_row, pieces[*index].current_col)) .collect::>(); + let mut affected_cells = source_positions + .iter() + .map(|(row, col)| PuzzleCellPosition { + row: *row, + col: *col, + }) + .collect::>(); for (index, next_row, next_col) in &target_positions { + affected_cells.push(PuzzleCellPosition { + row: *next_row, + col: *next_col, + }); if let Some(target_piece_index) = pieces.iter().position(|piece| { piece.current_row == *next_row && piece.current_col == *next_col @@ -1473,6 +1779,14 @@ fn drag_group( .copied() .ok_or(PuzzleFieldError::InvalidOperation)?; pieces[target_piece_index].merged_group_id = None; + affected_cells.push(PuzzleCellPosition { + row: pieces[target_piece_index].current_row, + col: pieces[target_piece_index].current_col, + }); + affected_cells.push(PuzzleCellPosition { + row: fallback.0, + col: fallback.1, + }); pieces[target_piece_index].current_row = fallback.0; pieces[target_piece_index].current_col = fallback.1; } @@ -1480,7 +1794,7 @@ fn drag_group( pieces[*index].current_col = *next_col; } - Ok(()) + Ok(affected_cells) } fn with_next_board(run: &PuzzleRunSnapshot, next_board: PuzzleBoardSnapshot) -> PuzzleRunSnapshot { @@ -1553,13 +1867,13 @@ mod tests { } #[test] - fn generated_candidates_use_oss_compatible_prefix() { + fn generated_candidate_uses_oss_compatible_prefix_and_single_image() { let anchor_pack = infer_anchor_pack("雨夜猫咪", Some("雨夜猫咪")); let draft = compile_result_draft(&anchor_pack, &[]); let candidates = build_generated_candidates("session-1", None, &draft, 2, 1_000) .expect("candidates should build"); - assert_eq!(candidates.len(), 2); + assert_eq!(candidates.len(), 1); assert!( candidates[0] .image_src @@ -1611,6 +1925,281 @@ mod tests { ); } + #[test] + fn initial_board_shuffle_changes_by_run_id() { + let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]); + let first = start_run("run-random-a".to_string(), &profile, 0).expect("first run"); + let second = start_run("run-random-b".to_string(), &profile, 0).expect("second run"); + let first_positions = first + .current_level + .expect("first level") + .board + .pieces + .into_iter() + .map(|piece| (piece.current_row, piece.current_col)) + .collect::>(); + let second_positions = second + .current_level + .expect("second level") + .board + .pieces + .into_iter() + .map(|piece| (piece.current_row, piece.current_col)) + .collect::>(); + + assert_ne!(first_positions, second_positions); + } + + #[test] + fn initial_board_has_no_correct_neighbor_pairs() { + for grid_size in [3, 4] { + for shuffle_seed in 0..128 { + let board = build_initial_board_with_seed(grid_size, shuffle_seed).expect("board"); + + assert!(board.merged_groups.is_empty()); + assert!( + !has_any_correct_neighbor_pair(&board.pieces), + "grid_size={grid_size}, shuffle_seed={shuffle_seed}" + ); + } + } + } + + #[test] + fn correct_neighbors_auto_merge_after_swap() { + let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]); + let mut run = + start_run_with_shuffle_seed("run-merge".to_string(), &profile, 0, 7).expect("run"); + let current_level = run.current_level.as_mut().expect("level"); + current_level.board = rebuild_board_snapshot( + 3, + vec![ + PuzzlePieceState { + piece_id: "piece-0".to_string(), + correct_row: 0, + correct_col: 0, + current_row: 1, + current_col: 1, + merged_group_id: None, + }, + PuzzlePieceState { + piece_id: "piece-1".to_string(), + correct_row: 0, + correct_col: 1, + current_row: 0, + current_col: 1, + merged_group_id: None, + }, + PuzzlePieceState { + piece_id: "piece-2".to_string(), + correct_row: 0, + correct_col: 2, + current_row: 2, + current_col: 2, + merged_group_id: None, + }, + PuzzlePieceState { + piece_id: "piece-3".to_string(), + correct_row: 1, + correct_col: 0, + current_row: 0, + current_col: 2, + merged_group_id: None, + }, + PuzzlePieceState { + piece_id: "piece-4".to_string(), + correct_row: 1, + correct_col: 1, + current_row: 1, + current_col: 0, + merged_group_id: None, + }, + PuzzlePieceState { + piece_id: "piece-5".to_string(), + correct_row: 1, + correct_col: 2, + current_row: 2, + current_col: 0, + merged_group_id: None, + }, + PuzzlePieceState { + piece_id: "piece-6".to_string(), + correct_row: 2, + correct_col: 0, + current_row: 0, + current_col: 0, + merged_group_id: None, + }, + PuzzlePieceState { + piece_id: "piece-7".to_string(), + correct_row: 2, + correct_col: 1, + current_row: 1, + current_col: 2, + merged_group_id: None, + }, + PuzzlePieceState { + piece_id: "piece-8".to_string(), + correct_row: 2, + correct_col: 2, + current_row: 2, + current_col: 1, + merged_group_id: None, + }, + ], + None, + ); + + let swapped = swap_pieces(&run, "piece-0", "piece-6").expect("swap"); + let board = &swapped.current_level.as_ref().expect("level").board; + let group = board + .merged_groups + .iter() + .find(|group| { + group.piece_ids.contains(&"piece-0".to_string()) + && group.piece_ids.contains(&"piece-1".to_string()) + }) + .expect("piece-0 and piece-1 should merge"); + + assert_eq!(group.piece_ids.len(), 2); + } + + #[test] + fn single_piece_dragging_into_group_splits_target_group() { + let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]); + let mut run = + start_run_with_shuffle_seed("run-split".to_string(), &profile, 0, 9).expect("run"); + let current_level = run.current_level.as_mut().expect("level"); + current_level.board = rebuild_board_snapshot( + 3, + vec![ + PuzzlePieceState { + piece_id: "piece-0".to_string(), + correct_row: 0, + correct_col: 0, + current_row: 0, + current_col: 0, + merged_group_id: None, + }, + PuzzlePieceState { + piece_id: "piece-1".to_string(), + correct_row: 0, + correct_col: 1, + current_row: 0, + current_col: 1, + merged_group_id: None, + }, + PuzzlePieceState { + piece_id: "piece-2".to_string(), + correct_row: 0, + correct_col: 2, + current_row: 2, + current_col: 2, + merged_group_id: None, + }, + PuzzlePieceState { + piece_id: "piece-3".to_string(), + correct_row: 1, + correct_col: 0, + current_row: 1, + current_col: 0, + merged_group_id: None, + }, + PuzzlePieceState { + piece_id: "piece-4".to_string(), + correct_row: 1, + correct_col: 1, + current_row: 1, + current_col: 1, + merged_group_id: None, + }, + PuzzlePieceState { + piece_id: "piece-5".to_string(), + correct_row: 1, + correct_col: 2, + current_row: 1, + current_col: 2, + merged_group_id: None, + }, + PuzzlePieceState { + piece_id: "piece-6".to_string(), + correct_row: 2, + correct_col: 0, + current_row: 2, + current_col: 0, + merged_group_id: None, + }, + PuzzlePieceState { + piece_id: "piece-7".to_string(), + correct_row: 2, + correct_col: 1, + current_row: 2, + current_col: 1, + merged_group_id: None, + }, + PuzzlePieceState { + piece_id: "piece-8".to_string(), + correct_row: 2, + correct_col: 2, + current_row: 0, + current_col: 2, + merged_group_id: None, + }, + ], + None, + ); + + let dragged = drag_piece_or_group(&run, "piece-8", 0, 1).expect("drag"); + let board = &dragged.current_level.as_ref().expect("level").board; + + assert_eq!( + board + .pieces + .iter() + .find(|piece| piece.piece_id == "piece-8") + .map(|piece| (piece.current_row, piece.current_col)), + Some((0, 1)) + ); + assert!( + board + .merged_groups + .iter() + .all(|group| !(group.piece_ids.contains(&"piece-0".to_string()) + && group.piece_ids.contains(&"piece-1".to_string()))) + ); + } + + #[test] + fn one_full_board_group_marks_level_cleared() { + let pieces = (0..9) + .map(|index| PuzzlePieceState { + piece_id: format!("piece-{index}"), + correct_row: index / 3, + correct_col: index % 3, + current_row: index / 3, + current_col: (index + 1) % 3, + merged_group_id: None, + }) + .collect::>(); + let board = rebuild_board_snapshot_with_groups( + 3, + pieces, + vec![PuzzleMergedGroupState { + group_id: "group-full".to_string(), + piece_ids: (0..9).map(|index| format!("piece-{index}")).collect(), + occupied_cells: (0..9) + .map(|index| PuzzleCellPosition { + row: index / 3, + col: (index + 1) % 3, + }) + .collect(), + }], + None, + ); + + assert!(board.all_tiles_resolved); + } + #[test] fn apply_publish_overrides_updates_draft_truth() { let anchor_pack = infer_anchor_pack("雨夜猫咪神庙", Some("雨夜猫咪神庙")); diff --git a/server-rs/crates/shared-contracts/src/puzzle_agent.rs b/server-rs/crates/shared-contracts/src/puzzle_agent.rs index 42a6c279..4409984c 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_agent.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_agent.rs @@ -23,6 +23,8 @@ pub struct ExecutePuzzleAgentActionRequest { #[serde(default)] pub prompt_text: Option, #[serde(default)] + pub reference_image_src: Option, + #[serde(default)] pub candidate_count: Option, #[serde(default)] pub candidate_id: Option, diff --git a/server-rs/crates/shared-contracts/src/puzzle_runtime.rs b/server-rs/crates/shared-contracts/src/puzzle_runtime.rs index 1c3d6452..873ea070 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_runtime.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_runtime.rs @@ -56,6 +56,16 @@ pub struct PuzzleMergedGroupStateResponse { pub occupied_cells: Vec, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PuzzleLeaderboardEntryResponse { + pub rank: u32, + pub nickname: String, + pub elapsed_ms: u64, + #[serde(default)] + pub is_current_player: bool, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct PuzzleBoardSnapshotResponse { @@ -82,6 +92,14 @@ pub struct PuzzleRuntimeLevelSnapshotResponse { pub cover_image_src: Option, pub board: PuzzleBoardSnapshotResponse, pub status: String, + #[serde(default)] + pub started_at_ms: u64, + #[serde(default)] + pub cleared_at_ms: Option, + #[serde(default)] + pub elapsed_ms: Option, + #[serde(default)] + pub leaderboard_entries: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -98,6 +116,8 @@ pub struct PuzzleRunSnapshotResponse { pub current_level: Option, #[serde(default)] pub recommended_next_profile_id: Option, + #[serde(default)] + pub leaderboard_entries: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs b/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs index 5da67eca..19097f69 100644 --- a/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs +++ b/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs @@ -3,6 +3,7 @@ use crate::*; const ASSET_HISTORY_MAX_LIMIT: usize = 120; const ASSET_HISTORY_CHARACTER_VISUAL_KIND: &str = "character_visual"; const ASSET_HISTORY_SCENE_IMAGE_KIND: &str = "scene_image"; +const ASSET_HISTORY_PUZZLE_COVER_IMAGE_KIND: &str = "puzzle_cover_image"; #[spacetimedb::table( accessor = asset_object, @@ -199,8 +200,11 @@ fn list_asset_history( let asset_kind = input.asset_kind.trim(); if asset_kind != ASSET_HISTORY_CHARACTER_VISUAL_KIND && asset_kind != ASSET_HISTORY_SCENE_IMAGE_KIND + && asset_kind != ASSET_HISTORY_PUZZLE_COVER_IMAGE_KIND { - return Err("历史素材类型只支持 character_visual 或 scene_image".to_string()); + return Err( + "历史素材类型只支持 character_visual、scene_image 或 puzzle_cover_image".to_string(), + ); } let limit = usize::try_from(input.limit) diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index c5d7890d..69e41011 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -685,7 +685,7 @@ fn save_puzzle_generated_images_tx( if candidates.is_empty() { return Err("拼图候选图不能为空".to_string()); } - append_generated_candidates(&mut draft, candidates); + replace_generated_candidate(&mut draft, candidates); draft.generation_status = "ready".to_string(); if let Some(selected) = draft .candidates @@ -724,7 +724,7 @@ fn save_puzzle_generated_images_tx( stage: next_stage, anchor_pack_json: row.anchor_pack_json.clone(), draft_json: Some(serialize_json(&draft)), - last_assistant_reply: Some("候选图已经生成,请选择正式拼图图片。".to_string()), + last_assistant_reply: Some("拼图图片已经生成,并已替换当前正式图。".to_string()), published_profile_id: row.published_profile_id.clone(), created_at: row.created_at, updated_at: saved_at, @@ -1510,21 +1510,19 @@ fn increment_puzzle_profile_play_count( ); } -fn append_generated_candidates( +fn replace_generated_candidate( draft: &mut PuzzleResultDraft, candidates: Vec, ) { - let has_selected_candidate = draft.candidates.iter().any(|entry| entry.selected); - // 再次生成图片是扩充候选池,不覆盖创作者已经看到或已经选择的候选图。 - // 若已有正式选择,新追加候选图保持未选中,避免同一草稿出现多个 selected。 - draft - .candidates - .extend(candidates.into_iter().map(|mut candidate| { - if has_selected_candidate { - candidate.selected = false; - } + // 结果页生图采用单图替换:每次只保留最新图片,并立即作为正式图。 + draft.candidates = candidates + .into_iter() + .take(1) + .map(|mut candidate| { + candidate.selected = true; candidate - })); + }) + .collect(); } fn list_published_puzzle_profiles(ctx: &TxContext) -> Result, String> { @@ -1634,7 +1632,7 @@ mod tests { } #[test] - fn puzzle_generated_images_are_appended_without_clearing_existing_candidates() { + fn puzzle_generated_images_replace_existing_candidate() { let anchor_pack = infer_anchor_pack("蒸汽城市雨夜猫咪", Some("蒸汽城市雨夜猫咪")); let mut draft = compile_result_draft(&anchor_pack, &[]); draft.candidates = vec![PuzzleGeneratedImageCandidate { @@ -1647,7 +1645,7 @@ mod tests { selected: true, }]; - append_generated_candidates( + replace_generated_candidate( &mut draft, vec![PuzzleGeneratedImageCandidate { candidate_id: "session-1-candidate-2".to_string(), @@ -1660,11 +1658,9 @@ mod tests { }], ); - assert_eq!(draft.candidates.len(), 2); - assert_eq!(draft.candidates[0].candidate_id, "session-1-candidate-1"); + assert_eq!(draft.candidates.len(), 1); + assert_eq!(draft.candidates[0].candidate_id, "session-1-candidate-2"); assert!(draft.candidates[0].selected); - assert_eq!(draft.candidates[1].candidate_id, "session-1-candidate-2"); - assert!(!draft.candidates[1].selected); } #[test] diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 78d593f5..8db7107a 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -434,6 +434,10 @@ pub(crate) fn sync_profile_projections_from_snapshot( let game_state_object = game_state.as_object(); let saved_at = Timestamp::from_micros_since_unix_epoch(snapshot.saved_at_micros); + if is_non_persistent_runtime_snapshot(&game_state) { + return Ok(()); + } + sync_profile_dashboard_from_snapshot(ctx, snapshot, game_state_object, saved_at); sync_profile_save_archive_from_snapshot(ctx, snapshot, &game_state, saved_at)?; @@ -740,6 +744,10 @@ fn resolve_profile_save_archive_meta( game_state: &JsonValue, current_story_json: Option<&str>, ) -> Option { + if is_non_persistent_runtime_snapshot(game_state) { + return None; + } + let game_state_object = game_state.as_object(); let world_meta = resolve_profile_world_snapshot_meta(game_state_object)?; let story_engine_memory = game_state_object @@ -813,6 +821,25 @@ fn resolve_profile_save_archive_meta( }) } +fn is_non_persistent_runtime_snapshot(game_state: &JsonValue) -> bool { + let Some(game_state) = game_state.as_object() else { + return false; + }; + + if game_state + .get("runtimePersistenceDisabled") + .and_then(JsonValue::as_bool) + .unwrap_or(false) + { + return true; + } + + matches!( + read_string_from_json(game_state.get("runtimeMode")).as_deref(), + Some("preview") | Some("test") + ) +} + fn build_builtin_world_title(world_type: &str) -> String { match world_type { "WUXIA" => "武侠世界".to_string(), diff --git a/src/components/CustomWorldEntityCatalog.tsx b/src/components/CustomWorldEntityCatalog.tsx index 9551a707..3acaca42 100644 --- a/src/components/CustomWorldEntityCatalog.tsx +++ b/src/components/CustomWorldEntityCatalog.tsx @@ -926,27 +926,6 @@ export function CustomWorldEntityCatalog({ -
-
- {attributeSlots.map((slot) => ( -
-
- {slot.name} -
-
- {buildAttributeSlotSummary(slot) || slot.definition} -
-
- ))} -
-
-
+
+
+
+ 角色维度 +
+ {profile.attributeSchema?.schemaName ? ( +
+ {profile.attributeSchema.schemaName} +
+ ) : null} +
+
+ {attributeSlots.map((slot) => ( +
+
+ {slot.name} +
+
+ {buildAttributeSlotSummary(slot) || slot.definition} +
+
+ ))} +
+
{structuredFoundationEntries.map((entry) => (
& { src?: string | null; fallbackSrc?: string | null; + refreshKey?: string | number | null; }; export function ResolvedAssetImage({ src, fallbackSrc, alt, + refreshKey, ...rest }: ResolvedAssetImageProps) { - const { resolvedUrl } = useResolvedAssetReadUrl(src); + const { resolvedUrl } = useResolvedAssetReadUrl(src, { + refreshKey, + }); const finalSrc = resolvedUrl || fallbackSrc?.trim() || ''; if (!finalSrc) { diff --git a/src/components/game-canvas/GameCanvasEntityLayer.test.tsx b/src/components/game-canvas/GameCanvasEntityLayer.test.tsx index 3b23f5dd..575ce3ce 100644 --- a/src/components/game-canvas/GameCanvasEntityLayer.test.tsx +++ b/src/components/game-canvas/GameCanvasEntityLayer.test.tsx @@ -78,6 +78,7 @@ function renderEntityLayer(effectNpcId: string | null) { return renderToStaticMarkup( { expect(html).not.toContain('npc-affinity-effect-npc-liu'); expect(html).not.toContain('好感度变化 +3'); }); + + it('renders scene act back-row encounters alongside the primary encounter', () => { + const html = renderToStaticMarkup( + '70%'} + groundBottom="18%" + stageLiftPx={68} + encounter={createEncounter({ id: 'npc-primary', npcName: '主角色' })} + sideAnchor="15%" + cameraAnchorX={0} + monsterAnchorMeters={3.2} + playerX={0} + />, + ); + + expect(html).toContain('查看主角色详情'); + expect(html).toContain('查看后排甲详情'); + expect(html).toContain('查看后排乙详情'); + }); }); diff --git a/src/components/game-canvas/GameCanvasEntityLayer.tsx b/src/components/game-canvas/GameCanvasEntityLayer.tsx index d94ad171..fc3e8dc0 100644 --- a/src/components/game-canvas/GameCanvasEntityLayer.tsx +++ b/src/components/game-canvas/GameCanvasEntityLayer.tsx @@ -51,6 +51,7 @@ type MonsterSpriteConfig = (typeof MONSTERS_BY_WORLD)[WorldType.WUXIA][number]; interface GameCanvasEntityLayerProps { companions: CompanionRenderState[]; + sceneActAmbientEncounters: Encounter[]; currentScenePreset: ScenePresetInfo | null; sceneTransitionToken: number; isSceneTransitionEntering: boolean; @@ -93,6 +94,13 @@ interface GameCanvasEntityLayerProps { playerX: number; } +const SCENE_ACT_BACK_ROW_ANCHOR_X_METERS = RESOLVED_ENTITY_X_METERS + 1.08; +const SCENE_ACT_BACK_ROW_OFFSET_PX = [62, -46] as const; + +function addCssPxOffset(value: string, offsetPx: number) { + return offsetPx === 0 ? value : `calc(${value} + ${offsetPx}px)`; +} + function CombatFloatingNumber({ event, onDone, @@ -177,6 +185,7 @@ function CombatReactiveSpriteFrame({ export function GameCanvasEntityLayer({ companions, + sceneActAmbientEncounters, currentScenePreset, sceneTransitionToken, isSceneTransitionEntering, @@ -415,9 +424,16 @@ export function GameCanvasEntityLayer({
- {sceneCombatants.map(hostileNpc => { + {sceneCombatants.map((hostileNpc, index) => { const npcEncounter = hostileNpc.encounter; if (!npcEncounter) return null; + const hostileRenderKey = [ + hostileNpc.id, + npcEncounter.id ?? npcEncounter.npcName, + hostileNpc.xMeters, + hostileNpc.yOffset ?? 0, + index, + ].join(':'); const config = monsters.find(item => item.id === hostileNpc.id); const renderOffset = MONSTER_RENDER_OFFSETS[hostileNpc.id] ?? {x: 0, y: 0}; const npcCharacter = npcEncounter?.characterId ? getCharacterById(npcEncounter.characterId) : null; @@ -453,7 +469,7 @@ export function GameCanvasEntityLayer({ return (
); })()} + + {!inBattle && + sceneActAmbientEncounters.map((ambientEncounter, index) => { + const ambientOffsetPx = SCENE_ACT_BACK_ROW_OFFSET_PX[index]; + if (ambientOffsetPx === undefined) { + return null; + } + + const ambientResolvedCharacter = + ambientEncounter.kind !== 'treasure' && ambientEncounter.characterId + ? getCharacterById(ambientEncounter.characterId) + : null; + const ambientMonsterConfig = + !ambientResolvedCharacter && + ambientEncounter.kind === 'npc' && + ambientEncounter.monsterPresetId + ? monsters.find(item => item.id === ambientEncounter.monsterPresetId) ?? null + : null; + const ambientHostileBottomOffsetPx = ambientMonsterConfig + ? getHostileNpcSceneBottomOffsetPx(ambientMonsterConfig) + : getSceneNpcVisualBottomOffsetPx(ambientEncounter); + const ambientBottomOffsetPx = ambientResolvedCharacter + ? getEncounterCharacterBottomOffsetPx( + stageLiftPx, + ambientEncounter, + ambientResolvedCharacter, + ambientOffsetPx, + ) + : stageLiftPx + ambientHostileBottomOffsetPx + ambientOffsetPx; + const ambientFacing = getFacingTowardPlayer( + SCENE_ACT_BACK_ROW_ANCHOR_X_METERS, + playerX, + ); + const ambientBottom = ambientEncounter.characterId + ? getEncounterCharacterOpponentBottom( + groundBottom, + stageLiftPx, + ambientEncounter, + getCharacterById(ambientEncounter.characterId), + ) + : `calc(${groundBottom} + ${stageLiftPx + ambientHostileBottomOffsetPx}px)`; + + return ( +
+ onEntitySelect?.({kind: 'npc', encounter: ambientEncounter}) + : null + } + ariaLabel={ + ambientEncounter.kind === 'npc' + ? `查看${ambientEncounter.npcName}详情` + : undefined + } + className="relative flex w-28 flex-col items-center" + > +
+ {ambientResolvedCharacter && + !ambientEncounter.visual && + !ambientEncounter.imageSrc?.trim() ? ( + + ) : ambientMonsterConfig ? ( + + ) : ( + + )} +
+ {/* 幕后排角色只是同幕可见实体,不抢占当前交互目标。 */} + {npcAffinityEffect?.npcId === + (ambientEncounter.id ?? ambientEncounter.npcName) ? ( + + ) : null} +
+
+ ); + })} ); } diff --git a/src/components/game-canvas/GameCanvasRuntime.tsx b/src/components/game-canvas/GameCanvasRuntime.tsx index ea3ce859..66a0468d 100644 --- a/src/components/game-canvas/GameCanvasRuntime.tsx +++ b/src/components/game-canvas/GameCanvasRuntime.tsx @@ -2,8 +2,12 @@ import {useEffect, useLayoutEffect, useRef, useState} from 'react'; import {resolveCompatibilityTemplateWorldType} from '../../data/customWorldRuntime'; import {MONSTERS_BY_WORLD, PLAYER_BASE_X_METERS} from '../../data/hostileNpcs'; -import {resolveActiveSceneActBackgroundImage} from '../../services/customWorldSceneActRuntime'; -import {AnimationState, WorldType} from '../../types'; +import {buildEncounterFromSceneNpc} from '../../data/scenePresets'; +import { + resolveActiveSceneActBackgroundImage, + resolveActiveSceneActEncounterNpcIds, +} from '../../services/customWorldSceneActRuntime'; +import {AnimationState, type Encounter, type SceneNpc, WorldType} from '../../types'; import {GameCanvasEffectLayer} from './GameCanvasEffectLayer'; import {GameCanvasEntityLayer} from './GameCanvasEntityLayer'; import {GameCanvasOverlayLayer} from './GameCanvasOverlayLayer'; @@ -66,6 +70,42 @@ export function GameCanvasRuntime({ const backgroundSrc = activeSceneActBackground || currentScenePreset?.imageSrc || (resolvedWorldType === WorldType.WUXIA ? '/scene_bg/45_PixelSky.png' : '/scene_bg/47_PixelSky.png'); + const activeSceneActEncounterNpcIds = + currentScenePreset?.id + ? resolveActiveSceneActEncounterNpcIds({ + profile: customWorldProfile, + sceneId: currentScenePreset.id, + storyEngineMemory, + }) + : []; + const activeSceneActNpcIdSet = new Set(activeSceneActEncounterNpcIds); + const sceneActAmbientEncounters = (currentScenePreset?.npcs ?? []) + .filter((npc: SceneNpc) => { + if (activeSceneActNpcIdSet.size === 0) { + return false; + } + + const candidateIds = [npc.id, npc.characterId].filter( + (value): value is string => Boolean(value), + ); + const encounterIds = [encounter?.id, encounter?.characterId].filter( + (value): value is string => Boolean(value), + ); + + return ( + candidateIds.some((id) => activeSceneActNpcIdSet.has(id)) && + !candidateIds.some((id) => encounterIds.includes(id)) + ); + }) + .slice(0, 2) + .map((npc: SceneNpc, index): Encounter => { + const npcEncounter = buildEncounterFromSceneNpc(npc); + return { + ...npcEncounter, + xMeters: 3.2 + 1.08, + id: npcEncounter.id ?? `${npc.id}:ambient-${index}`, + }; + }); const monsters = resolvedWorldType ? MONSTERS_BY_WORLD[resolvedWorldType] : []; const groundBottom = '18%'; const stageLiftPx = 68; @@ -181,6 +221,7 @@ export function GameCanvasRuntime({ /> (null); const [puzzleDetailReturnTarget, setPuzzleDetailReturnTarget] = useState(null); + const [puzzleRuntimeReturnStage, setPuzzleRuntimeReturnStage] = + useState('puzzle-gallery-detail'); const [puzzleRun, setPuzzleRun] = useState(null); const [isPuzzleLoadingLibrary, setIsPuzzleLoadingLibrary] = useState(false); const [puzzleGenerationState, setPuzzleGenerationState] = @@ -944,6 +949,7 @@ export function PlatformEntryFlowShellImpl({ setPuzzleOperation(null); setPuzzleWorks([]); setSelectedPuzzleDetail(null); + setPuzzleRuntimeReturnStage('puzzle-gallery-detail'); setPuzzleRun(null); setPuzzleGenerationState(null); setIsPuzzleNextLevelGenerating(false); @@ -1087,6 +1093,7 @@ export function PlatformEntryFlowShellImpl({ const { item } = await getPuzzleGalleryDetail(profileId); setSelectedPuzzleDetail(item); setPuzzleRun(startLocalPuzzleRun(item)); + setPuzzleRuntimeReturnStage('puzzle-gallery-detail'); setSelectionStage('puzzle-runtime'); } catch (error) { setPuzzleError(resolvePuzzleErrorMessage(error, '启动拼图玩法失败。')); @@ -1097,6 +1104,57 @@ export function PlatformEntryFlowShellImpl({ [isPuzzleBusy, resolvePuzzleErrorMessage, setSelectionStage], ); + const buildPuzzleTestWork = useCallback( + (draft: PuzzleResultDraft) => { + const profileId = + puzzleSession?.publishedProfileId ?? + `draft-${puzzleSession?.sessionId ?? 'puzzle'}-test`; + const now = new Date().toISOString(); + + return { + workId: `test-${profileId}`, + profileId, + ownerUserId: authUi?.user?.id ?? 'current-user', + sourceSessionId: puzzleSession?.sessionId ?? null, + authorDisplayName: authUi?.user?.displayName ?? '玩家', + levelName: draft.levelName, + summary: draft.summary, + themeTags: draft.themeTags, + coverImageSrc: draft.coverImageSrc, + coverAssetId: draft.coverAssetId, + publicationStatus: 'draft', + updatedAt: now, + publishedAt: null, + playCount: 0, + publishReady: Boolean(puzzleSession?.resultPreview?.publishReady), + } satisfies PuzzleWorkSummary; + }, + [ + authUi?.user?.displayName, + authUi?.user?.id, + puzzleSession?.publishedProfileId, + puzzleSession?.resultPreview?.publishReady, + puzzleSession?.sessionId, + ], + ); + + const startPuzzleTestRunFromDraft = useCallback( + (draft: PuzzleResultDraft) => { + if (!draft.coverImageSrc) { + setPuzzleError('请先选择一张正式拼图图片。'); + return; + } + + const testWork = buildPuzzleTestWork(draft); + setSelectedPuzzleDetail(testWork); + setPuzzleRun(startLocalPuzzleRun(testWork)); + setPuzzleRuntimeReturnStage('puzzle-result'); + setPuzzleError(null); + setSelectionStage('puzzle-runtime'); + }, + [buildPuzzleTestWork, setSelectionStage], + ); + const submitBigFishInput = useCallback( (payload: SubmitBigFishInputRequest) => { if (!bigFishRun || bigFishInputInFlightRef.current) { @@ -2186,7 +2244,6 @@ export function PlatformEntryFlowShellImpl({ }> { @@ -2195,6 +2252,7 @@ export function PlatformEntryFlowShellImpl({ onExecuteAction={(payload) => { void executePuzzleAction(payload); }} + onStartTestRun={startPuzzleTestRunFromDraft} /> @@ -2252,7 +2310,7 @@ export function PlatformEntryFlowShellImpl({ isBusy={isPuzzleBusy || isPuzzleNextLevelGenerating} error={puzzleError} onBack={() => { - setSelectionStage('puzzle-gallery-detail'); + setSelectionStage(puzzleRuntimeReturnStage); }} onSwapPieces={(payload) => { void swapPuzzlePiecesInRun(payload); diff --git a/src/components/puzzle-result/PuzzleResultView.test.tsx b/src/components/puzzle-result/PuzzleResultView.test.tsx new file mode 100644 index 00000000..6ea38574 --- /dev/null +++ b/src/components/puzzle-result/PuzzleResultView.test.tsx @@ -0,0 +1,425 @@ +// @vitest-environment jsdom + +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; + +import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession'; +import { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient'; +import { PuzzleResultView } from './PuzzleResultView'; + +vi.mock('../ResolvedAssetImage', () => ({ + ResolvedAssetImage: ({ + src, + alt, + className, + }: { + src?: string | null; + alt?: string; + className?: string; + }) => (src ? {alt} : null), +})); + +vi.mock('../../services/puzzle-works/puzzleAssetClient', () => ({ + puzzleAssetClient: { + listHistoryAssets: vi.fn(), + }, +})); + +function createSession( + overrides: Partial = {}, +): PuzzleAgentSessionSnapshot { + const baseSession: PuzzleAgentSessionSnapshot = { + sessionId: 'puzzle-session-1', + currentTurn: 2, + progressPercent: 88, + stage: 'ready_to_publish', + anchorPack: { + themePromise: { + key: 'themePromise', + label: '题材承诺', + value: '雨夜猫咪', + status: 'confirmed', + }, + visualSubject: { + key: 'visualSubject', + label: '画面主体', + value: '屋檐下的猫', + status: 'confirmed', + }, + visualMood: { + key: 'visualMood', + label: '视觉气质', + value: '温暖', + status: 'confirmed', + }, + compositionHooks: { + key: 'compositionHooks', + label: '拼图记忆点', + value: '雨滴与灯牌', + status: 'confirmed', + }, + tagsAndForbidden: { + key: 'tagsAndForbidden', + label: '标签与禁忌', + value: '猫咪、雨夜', + status: 'confirmed', + }, + }, + draft: { + levelName: '雨夜猫街', + summary: '屋檐下的猫与暖灯街角。', + themeTags: ['猫咪', '雨夜'], + forbiddenDirectives: [], + creatorIntent: null, + anchorPack: { + themePromise: { + key: 'themePromise', + label: '题材承诺', + value: '雨夜猫咪', + status: 'confirmed', + }, + visualSubject: { + key: 'visualSubject', + label: '画面主体', + value: '屋檐下的猫', + status: 'confirmed', + }, + visualMood: { + key: 'visualMood', + label: '视觉气质', + value: '温暖', + status: 'confirmed', + }, + compositionHooks: { + key: 'compositionHooks', + label: '拼图记忆点', + value: '雨滴与灯牌', + status: 'confirmed', + }, + tagsAndForbidden: { + key: 'tagsAndForbidden', + label: '标签与禁忌', + value: '猫咪、雨夜', + status: 'confirmed', + }, + }, + candidates: [ + { + candidateId: 'candidate-1', + imageSrc: '/puzzle/candidate-1.png', + assetId: 'asset-1', + prompt: '雨夜猫咪', + actualPrompt: null, + sourceType: 'generated', + selected: true, + }, + ], + selectedCandidateId: 'candidate-1', + coverImageSrc: '/puzzle/candidate-1.png', + coverAssetId: 'asset-1', + generationStatus: 'ready', + metadata: null, + }, + messages: [], + lastAssistantReply: null, + publishedProfileId: null, + suggestedActions: [], + resultPreview: null, + updatedAt: '2026-04-26T10:00:00.000Z', + }; + + const session = { + ...baseSession, + resultPreview: { + draft: baseSession.draft!, + publishReady: true, + blockers: [], + qualityFindings: [], + }, + ...overrides, + } satisfies PuzzleAgentSessionSnapshot; + + return session; +} + +describe('PuzzleResultView', () => { + test('uses two tabs without author preview or persistent publish validation', () => { + render( + {}} + onExecuteAction={() => {}} + onStartTestRun={() => {}} + />, + ); + + expect(screen.getByRole('button', { name: '基本信息' })).toBeTruthy(); + expect(screen.getByRole('button', { name: '拼图图片' })).toBeTruthy(); + expect(screen.queryByText('作者预览')).toBeNull(); + expect(screen.queryByText('发布校验')).toBeNull(); + expect(screen.getByRole('button', { name: /作品测试/u })).toBeTruthy(); + expect(screen.getByRole('button', { name: /发布/u })).toBeTruthy(); + }); + + test('edits theme tags with chips instead of a persistent tag input', () => { + render( + {}} + onExecuteAction={() => {}} + />, + ); + + expect(screen.queryByLabelText('新题材标签')).toBeNull(); + + fireEvent.click(screen.getByLabelText('删除标签 猫咪')); + expect(screen.queryByText('猫咪')).toBeNull(); + expect(screen.getByText('雨夜')).toBeTruthy(); + + fireEvent.click(screen.getByLabelText('新增题材标签')); + fireEvent.change(screen.getByLabelText('新题材标签'), { + target: { value: '暖灯' }, + }); + fireEvent.click(screen.getByRole('button', { name: '添加' })); + + expect(screen.getByText('暖灯')).toBeTruthy(); + expect(screen.queryByLabelText('新题材标签')).toBeNull(); + }); + + test('shows blockers only after clicking publish and blocks publish action', () => { + const onExecuteAction = vi.fn(); + + render( + {}} + onExecuteAction={onExecuteAction} + />, + ); + + expect(screen.queryByText('请先选择正式图')).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: /发布/u })); + const dialog = screen.getByRole('dialog', { name: '发布拼图作品' }); + expect(within(dialog).getByText('请先选择正式图')).toBeTruthy(); + + fireEvent.click(within(dialog).getByRole('button', { name: '发布到广场' })); + expect(onExecuteAction).not.toHaveBeenCalled(); + }); + + test('starts work test from the current editable draft', () => { + const onStartTestRun = vi.fn(); + + render( + {}} + onExecuteAction={() => {}} + onStartTestRun={onStartTestRun} + />, + ); + + fireEvent.change(screen.getByDisplayValue('雨夜猫街'), { + target: { value: '暖灯猫街' }, + }); + fireEvent.click(screen.getByLabelText('新增题材标签')); + fireEvent.change(screen.getByLabelText('新题材标签'), { + target: { value: '暖灯' }, + }); + fireEvent.click(screen.getByRole('button', { name: '添加' })); + fireEvent.click(screen.getByRole('button', { name: /作品测试/u })); + + expect(onStartTestRun).toHaveBeenCalledWith( + expect.objectContaining({ + levelName: '暖灯猫街', + themeTags: ['猫咪', '雨夜', '暖灯'], + }), + ); + }); + + test('generates one image from the picture description and replaces current image', () => { + const onExecuteAction = vi.fn(); + + render( + {}} + onExecuteAction={onExecuteAction} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '拼图图片' })); + expect(screen.getByText('画面描述')).toBeTruthy(); + expect(screen.queryByText(/候选图/u)).toBeNull(); + + fireEvent.change(screen.getByLabelText('画面描述'), { + target: { value: '一只猫在雨夜灯牌下回头。' }, + }); + fireEvent.click(screen.getByRole('button', { name: /生成并替换当前图片/u })); + + expect(onExecuteAction).toHaveBeenCalledWith({ + action: 'generate_puzzle_images', + promptText: '一只猫在雨夜灯牌下回头。', + referenceImageSrc: undefined, + candidateCount: 1, + }); + }); + + test('selects a history puzzle asset as reference image for the next generation', async () => { + const onExecuteAction = vi.fn(); + vi.mocked(puzzleAssetClient.listHistoryAssets).mockResolvedValue([ + { + assetObjectId: 'asset-history-1', + assetKind: 'puzzle_cover_image', + imageSrc: '/generated-puzzle-assets/history/image.png', + ownerUserId: 'user-1', + ownerLabel: '账号 user-1', + profileId: null, + entityId: 'puzzle-session-1', + createdAt: '2026-04-27T10:00:00.000Z', + updatedAt: '2026-04-27T10:00:00.000Z', + }, + ]); + + render( + {}} + onExecuteAction={onExecuteAction} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '拼图图片' })); + fireEvent.click(screen.getByLabelText('从历史拼图素材库选择')); + + const dialog = await screen.findByRole('dialog', { + name: '选择历史拼图素材', + }); + fireEvent.click(within(dialog).getByRole('button', { name: /账号 user-1/u })); + + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: '选择历史拼图素材' })).toBeNull(); + }); + + fireEvent.click(screen.getByRole('button', { name: /生成并替换当前图片/u })); + + expect(onExecuteAction).toHaveBeenLastCalledWith({ + action: 'generate_puzzle_images', + promptText: '屋檐下的猫与暖灯街角。', + referenceImageSrc: '/generated-puzzle-assets/history/image.png', + candidateCount: 1, + }); + }); + + test('refreshes the current formal image when session cover image changes', async () => { + const { rerender } = render( + {}} + onExecuteAction={() => {}} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '拼图图片' })); + expect(screen.getByRole('img', { name: '雨夜猫街' }).getAttribute('src')).toBe( + '/puzzle/candidate-1.png', + ); + + rerender( + {}} + onExecuteAction={() => {}} + />, + ); + + await waitFor(() => { + expect(screen.getByRole('img', { name: '雨夜猫街' }).getAttribute('src')).toBe( + '/puzzle/candidate-2.png', + ); + }); + }); + + test('prefers the selected latest candidate image when coverImageSrc lags behind', async () => { + render( + {}} + onExecuteAction={() => {}} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '拼图图片' })); + + await waitFor(() => { + expect(screen.getByRole('img', { name: '雨夜猫街' }).getAttribute('src')).toBe( + '/puzzle/candidate-2.png', + ); + }); + }); +}); diff --git a/src/components/puzzle-result/PuzzleResultView.tsx b/src/components/puzzle-result/PuzzleResultView.tsx index 3979167b..e1fc05f3 100644 --- a/src/components/puzzle-result/PuzzleResultView.tsx +++ b/src/components/puzzle-result/PuzzleResultView.tsx @@ -2,223 +2,908 @@ import { ArrowLeft, CheckCircle2, ImagePlus, + Images, Loader2, + Play, + Plus, Sparkles, + X, } from 'lucide-react'; -import { useEffect, useMemo, useState } from 'react'; +import { type ChangeEvent, useEffect, useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions'; -import type { - PuzzleResultDraft, -} from '../../../packages/shared/src/contracts/puzzleAgentDraft'; +import type { PuzzleResultDraft } from '../../../packages/shared/src/contracts/puzzleAgentDraft'; import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession'; -import type { AuthUser } from '../../services/authService'; +import { + puzzleAssetClient, + type PuzzleHistoryAsset, +} from '../../services/puzzle-works/puzzleAssetClient'; +import { useAuthUi } from '../auth/AuthUiContext'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; type PuzzleResultViewProps = { session: PuzzleAgentSessionSnapshot; - author: AuthUser | null; isBusy?: boolean; error?: string | null; onBack: () => void; onExecuteAction: (payload: PuzzleAgentActionRequest) => void; + onStartTestRun?: (draft: PuzzleResultDraft) => void; }; -type PuzzleImageStudioModalProps = { - draft: PuzzleResultDraft; - isBusy: boolean; - onClose: () => void; - onGenerate: (promptText?: string | null) => void; - onSelectCandidate: (candidateId: string) => void; +type PuzzleResultTab = 'basic' | 'images'; + +type DraftEditState = { + levelName: string; + summary: string; + themeTags: string[]; }; function normalizeThemeTagInput(value: string) { - return value - .split(/[\n,,、]/u) - .map((entry) => entry.trim()) - .filter(Boolean); + return [ + ...new Set( + value + .split(/[\n,,、]/u) + .map((entry) => entry.trim()) + .filter(Boolean), + ), + ]; +} + +function createDraftEditState(draft: PuzzleResultDraft): DraftEditState { + return { + levelName: draft.levelName, + summary: draft.summary, + themeTags: normalizeThemeTagInput(draft.themeTags.join(',')), + }; +} + +function readPuzzleReferenceImageAsDataUrl(file: File) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(new Error('参考图读取失败,请重试。')); + reader.onload = () => resolve(String(reader.result || '')); + reader.readAsDataURL(file); + }); +} + +function formatHistoryAssetDate(value: string) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value || ''; + } + return date.toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); } function publishBlockedReason(session: PuzzleAgentSessionSnapshot) { if (!session.resultPreview) { - return []; + return ['等待结果页草稿完成后再发布。']; } return session.resultPreview.blockers.map((entry) => entry.message); } -function PuzzleImageStudioModal({ - draft, - isBusy, - onClose, - onGenerate, - onSelectCandidate, -}: PuzzleImageStudioModalProps) { - const [promptText, setPromptText] = useState(draft.summary); +function buildPublishReady( + session: PuzzleAgentSessionSnapshot, + draft: PuzzleResultDraft, + editState: DraftEditState, +) { + const formalImageSrc = resolvePuzzleFormalImageSrc(draft); + const blockers = [ + ...publishBlockedReason(session), + ...(editState.levelName.trim() ? [] : ['关卡名不能为空。']), + ...(editState.themeTags.length > 0 ? [] : ['至少需要 1 个题材标签。']), + ...(formalImageSrc ? [] : ['请先选择一张正式拼图图片。']), + ]; + + return { + blockers: [...new Set(blockers.filter(Boolean))], + publishReady: + Boolean(session.resultPreview?.publishReady) && + Boolean(editState.levelName.trim()) && + editState.themeTags.length > 0 && + Boolean(formalImageSrc), + }; +} + +function resolvePuzzleFormalImageSrc(draft: PuzzleResultDraft) { + const selectedCandidate = + draft.candidates.find( + (candidate) => + candidate.selected || + (draft.selectedCandidateId + ? candidate.candidateId === draft.selectedCandidateId + : false), + ) ?? + draft.candidates[draft.candidates.length - 1] ?? + null; return ( -
-
-
-
- 拼图图片工坊 -
-
- 用当前锚点生成候选图,再选择一张作为正式图。 -
-
+ selectedCandidate?.imageSrc?.trim() || + draft.coverImageSrc?.trim() || + '' + ); +} -
-
-
-
- PROMPT -
-