From b6c6640548ced6a861ff2ef3face9838300911c0 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 22:50:18 +0800 Subject: [PATCH] 1 --- ...SHIP_AND_PRIVATE_CHAT_DESIGN_2026-04-04.md | 2 +- ...ADER_AND_CLEAR_EFFECT_DESIGN_2026-04-27.md | 68 ++ ..._AND_INLINE_FUNCTION_OPTIONS_2026-04-25.md | 10 +- .../ADVENTURE_RUNTIME_DEV_EXPERIENCE.md | 37 + ...NE_SERVER_RUNTIME_BRIDGE_FIX_2026-04-27.md | 35 + ...ATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md | 4 +- ..._SETTLEMENT_PRESENTATION_FIX_2026-04-27.md | 111 ++ ...NTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md | 16 +- ...ZLE_RUNTIME_REAL_LEADERBOARD_2026-04-27.md | 105 ++ ...D_COMPANION_PRESENTATION_FIX_2026-04-27.md | 97 ++ ..._OPENING_STORY_BOOTSTRAP_FIX_2026-04-26.md | 83 ++ .../RPG_TEST_RUNTIME_END_BUTTON_2026-04-27.md | 28 + .../src/contracts/puzzleRuntimeSession.ts | 7 + server-rs/crates/api-server/src/app.rs | 8 + server-rs/crates/api-server/src/assets.rs | 11 +- .../src/creation_agent_anchor_templates.json | 2 +- server-rs/crates/api-server/src/puzzle.rs | 263 ++++- server-rs/crates/module-puzzle/src/lib.rs | 214 +++- .../shared-contracts/src/puzzle_runtime.rs | 9 + server-rs/crates/spacetime-client/src/lib.rs | 3 +- .../crates/spacetime-client/src/mapper.rs | 48 + .../src/module_bindings/mod.rs | 6 + .../puzzle_leaderboard_entry_row_type.rs | 70 ++ .../puzzle_leaderboard_submit_input_type.rs | 21 + ...bmit_puzzle_leaderboard_entry_procedure.rs | 59 ++ .../crates/spacetime-client/src/puzzle.rs | 28 + .../crates/spacetime-module/src/puzzle.rs | 279 ++++- src/App.tsx | 24 +- src/PuzzlePlaygroundApp.tsx | 2 + src/RpgRuntimeApp.tsx | 9 +- .../GameCanvasEntityLayer.test.tsx | 49 + .../game-canvas/GameCanvasEntityLayer.tsx | 33 +- .../game-canvas/GameCanvasShared.tsx | 6 + .../PlatformEntryFlowShellImpl.tsx | 74 +- .../platform-entry/platformEntryTypes.ts | 12 +- .../PuzzleRuntimeShell.test.tsx | 129 ++- .../puzzle-runtime/PuzzleRuntimeShell.tsx | 401 ++++++-- ...gEntryFlowShell.agent.interaction.test.tsx | 6 +- .../useRpgCreationEnterWorld.test.tsx | 32 +- .../rpg-entry/useRpgCreationEnterWorld.ts | 37 +- .../rpg-entry/useRpgCreationResultAutosave.ts | 2 +- .../RpgAdventurePanel.test.tsx | 53 +- .../rpg-runtime-panels/RpgAdventurePanel.tsx | 126 ++- .../RpgRuntimeShell.test.tsx | 277 +++++ .../rpg-runtime-shell/RpgRuntimeShell.tsx | 19 + src/components/rpg-runtime-shell/types.ts | 7 +- src/data/customWorldLibrary.test.ts | 46 +- src/data/customWorldLibrary.ts | 49 +- src/data/sceneEncounterPreviews.ts | 29 +- src/data/scenePresets.ts | 9 +- src/hooks/combat/battlePlan.test.ts | 162 ++- src/hooks/combat/battlePlan.ts | 966 ++++++++++++------ src/hooks/combat/escapeFlow.test.ts | 58 ++ src/hooks/combat/escapeFlow.ts | 132 ++- src/hooks/combat/playback.ts | 58 +- src/hooks/combat/resolvedChoice.test.ts | 44 + src/hooks/combat/resolvedChoice.ts | 18 +- .../rpg-runtime-story/choiceActions.test.ts | 200 ++++ src/hooks/rpg-runtime-story/choiceActions.ts | 38 +- .../npcEncounterActions.test.ts | 70 +- .../rpgRuntimeStoryGateway.ts | 101 +- .../runtimeStoryCoordinator.test.ts | 191 ++++ .../storyChoiceRuntime.test.ts | 97 ++ .../useRpgRuntimeNpcInteraction.ts | 74 +- src/hooks/rpg-session/useRpgRuntimeSession.ts | 4 +- .../rpg-session/useRpgSessionBootstrap.ts | 222 ++-- src/hooks/useGameFlow.customWorld.test.tsx | 62 +- src/index.css | 66 +- .../big-fish-gallery/bigFishGalleryClient.ts | 31 +- src/services/customWorldRoleReferences.ts | 70 ++ src/services/customWorldSceneActRuntime.ts | 53 +- src/services/puzzle-runtime/index.ts | 1 + .../puzzle-runtime/puzzleLocalRuntime.test.ts | 36 +- .../puzzle-runtime/puzzleLocalRuntime.ts | 173 +++- .../puzzle-runtime/puzzleRuntimeClient.ts | 23 + .../rpgCreationPreviewAdapter.test.ts | 47 +- .../rpg-creation/rpgCreationPreviewAdapter.ts | 21 +- 77 files changed, 5240 insertions(+), 833 deletions(-) create mode 100644 docs/design/PUZZLE_RUNTIME_HEADER_AND_CLEAR_EFFECT_DESIGN_2026-04-27.md create mode 100644 docs/experience/RPG_NEXT_SCENE_SERVER_RUNTIME_BRIDGE_FIX_2026-04-27.md create mode 100644 docs/technical/BATTLE_DIRECT_SETTLEMENT_PRESENTATION_FIX_2026-04-27.md create mode 100644 docs/technical/PUZZLE_RUNTIME_REAL_LEADERBOARD_2026-04-27.md create mode 100644 docs/technical/RPG_BATTLE_TURN_ORDER_AND_COMPANION_PRESENTATION_FIX_2026-04-27.md create mode 100644 docs/technical/RPG_TEST_RUNTIME_END_BUTTON_2026-04-27.md create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/puzzle_leaderboard_entry_row_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/puzzle_leaderboard_submit_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/submit_puzzle_leaderboard_entry_procedure.rs create mode 100644 src/components/rpg-runtime-shell/RpgRuntimeShell.test.tsx create mode 100644 src/services/customWorldRoleReferences.ts diff --git a/docs/design/COMPANION_FIRST_CONTACT_RELATIONSHIP_AND_PRIVATE_CHAT_DESIGN_2026-04-04.md b/docs/design/COMPANION_FIRST_CONTACT_RELATIONSHIP_AND_PRIVATE_CHAT_DESIGN_2026-04-04.md index 43a510da..c42a5428 100644 --- a/docs/design/COMPANION_FIRST_CONTACT_RELATIONSHIP_AND_PRIVATE_CHAT_DESIGN_2026-04-04.md +++ b/docs/design/COMPANION_FIRST_CONTACT_RELATIONSHIP_AND_PRIVATE_CHAT_DESIGN_2026-04-04.md @@ -240,7 +240,7 @@ function buildNpcFirstContactOptionCatalog( - 不能再用“某人看着你,像是在等你把话接下去”这类第三人称占位旁白充当可见对话历史首句,也不能在聊天 state 里本地硬编码一条替代台词。 - 当玩家在场景中第一次真正撞上角色型 NPC 并进入聊天时,应直接触发一轮由 NPC 主动开口的模型回复;这一轮只生成 NPC 自己的首句与后续可选回应,不得代替玩家补写未说过的话。 - 负好感或敌对关系不应跳过主动开口;如果玩家从 NPC 交互面板点击 `npc_chat`,且该角色尚未完成 `firstMeaningfulContactResolved`,仍要走同一条 NPC 主动开场链路。负好感只影响语气、敌对聊天指令与后续可选功能,不影响“由角色先发言”的首遇行为。 -- 好感度小于 `0` 的角色在聊天终止时不进入 `story_continue_adventure` 收束态。无论是玩家主动退出聊天,还是模型通过敌对聊天指令主动结束聊天,底部选项都固定收束为 `npc_fight` 与 `battle_escape_breakout`:按钮文案分别为“战斗”“逃跑”。点击“战斗”进入 NPC 战斗结算链路;点击“逃跑”执行现有 `battle_escape_breakout` function,完成脱离演出与后续状态更新。 +- 好感度小于 `0` 的角色在聊天终止时不进入 `story_continue_adventure` 收束态。无论是玩家主动退出聊天,还是模型通过敌对聊天指令主动结束聊天,底部选项都必须收束为一个 `npc_fight` 与多个 `battle_escape_breakout`:`npc_fight` 的按钮文案保持“战斗”,点击后仍进入 NPC 战斗结算链路;逃跑类选项按当前场景相邻场景展开为“逃往{场景名}”,并额外提供“逃回当前场景起点”。逃跑选项需要在 `runtimePayload` 中携带目标场景信息,点击后复用现有主角向左转身跑出屏幕的逃离演出,再在目标场景从左侧入场并面向右侧。 4. 首遇状态下,不允许前两项直接变成: - 深背景追问 diff --git a/docs/design/PUZZLE_RUNTIME_HEADER_AND_CLEAR_EFFECT_DESIGN_2026-04-27.md b/docs/design/PUZZLE_RUNTIME_HEADER_AND_CLEAR_EFFECT_DESIGN_2026-04-27.md new file mode 100644 index 00000000..b5f3cd40 --- /dev/null +++ b/docs/design/PUZZLE_RUNTIME_HEADER_AND_CLEAR_EFFECT_DESIGN_2026-04-27.md @@ -0,0 +1,68 @@ +# 拼图运行时顶栏与通关演出调整设计 + +## 背景 + +现有拼图运行时存在 4 个体验问题: + +1. 顶栏把 `PUZZLE`、`3*3 / 4*4` 这类系统标签直接暴露给玩家,主信息不够聚焦。 +2. 拼图块右下角数字会破坏原图识别,影响拼图沉浸感。 +3. 拼图运行时右上角缺少与 RPG 一致的设置入口,玩家不能在玩法内直接调整音量或处理中途退出。 +4. 通关后立即弹结算弹窗,会打断玩家先看到完整成图的奖励时刻。 + +## 本次落地结论 + +### 1. 顶栏信息层级 + +拼图运行时顶栏统一改成三段结构: + +1. 左侧保留返回按钮。 +2. 中间居中展示关卡主信息: + - 第一行:拼图关卡名 + - 第二行:作者昵称 + - 第三行:`第 N 关` +3. 右侧新增设置按钮。 + +同时移除以下冗余标识: + +1. `PUZZLE` +2. `3*3 / 4*4` + +网格规模仍可作为运行时内部状态存在,但不默认写在 UI 顶栏中。 + +### 2. 拼图块显示规则 + +运行时单块右下角编号全部移除。 + +原因: + +1. 玩家需要优先依赖画面主体、构图和色块识别位置。 +2. 编号覆盖会削弱“完整图片被逐步复原”的视觉奖励。 + +### 3. 设置能力 + +拼图运行时右上角设置按钮对齐 RPG 玩法内设置入口,打开独立弹层,不在当前面板下方展开内容。 + +本次至少保留以下能力: + +1. 音乐音量调节 +2. 本局进度查看 +3. 返回上一页 + +后续若 RPG 运行时设置继续扩展,拼图运行时应优先复用同类能力与交互层级。 + +### 4. 通关演出时序 + +拼图完成后不立即弹结算弹窗,统一按以下顺序执行: + +1. 保持完整成图画面可见。 +2. 播放一段从一个角扫到另一个角的对角线闪光特效。 +3. 闪光特效结束后额外等待 `0.5s`。 +4. 再弹出结算弹窗。 + +这样可以先给玩家完整成图的视觉奖励,再进入排行榜与下一关决策。 + +## 工程约束 + +1. 继续复用现有 `PuzzleRuntimeShell` 作为运行时承载组件,不新增平行页面。 +2. 设置弹层沿用现有像素风弹窗资源,不单独引入新的弹窗体系。 +3. 通关演出只作为前端表现层时序,不改动通关判定与排行榜数据来源。 diff --git a/docs/design/RPG_NPC_CHAT_HOSTILE_TERMINATION_AND_INLINE_FUNCTION_OPTIONS_2026-04-25.md b/docs/design/RPG_NPC_CHAT_HOSTILE_TERMINATION_AND_INLINE_FUNCTION_OPTIONS_2026-04-25.md index f924e5d9..c2512006 100644 --- a/docs/design/RPG_NPC_CHAT_HOSTILE_TERMINATION_AND_INLINE_FUNCTION_OPTIONS_2026-04-25.md +++ b/docs/design/RPG_NPC_CHAT_HOSTILE_TERMINATION_AND_INLINE_FUNCTION_OPTIONS_2026-04-25.md @@ -15,7 +15,7 @@ 5. 模型判定终止后,聊天面板不再继续提供聊天输入,只显示“继续”按钮,点击后沿用原流程继续生成冒险选项。 6. 点击“退出聊天”不再直接收起聊天页,也不立即进入剧情推理;它会发送一条结束聊天的玩家输入,对方回复后同样只显示“继续”按钮。 7. 正向 NPC 的退出聊天只是玩家主动收束,不代表模型强制中止,也不展示战斗/逃跑选项。 -8. 对负好感或敌对 NPC,在聊天终止后的后续流程仍沿用原敌对出口:继续推进后回到原有战斗或逃跑选择。 +8. 对负好感或敌对 NPC,在聊天终止后的后续流程仍沿用敌对出口:继续推进后展示一个“战斗”选项,以及按相邻场景和当前场景起点展开的多个逃跑选项。 9. 聊天候选中允许混入当前 NPC 可执行 function,例如交易、送礼、请求帮助、招募、接任务、交任务、开战、离开等。 10. Function 候选进入聊天上下文时只作为可触发动作,不在 UI 中展示说明类文本。 11. “换一换”在聊天态可用,用于在不推进对话的情况下改排/轮换当前候选;它不调用后端,不改变聊天历史。 @@ -68,6 +68,14 @@ 5. 相邻场景选项继续使用 `idle_travel_next_scene`,并在 `runtimePayload.targetSceneId` 中携带目标场景,后续点击沿用现有地图跳转结算。 6. 若没有场景幕数据,则继续使用当前可用选项作为兜底,不额外生成规则说明文案。 +## 8. 补充规则:敌对聊天逃跑目标展开 + +1. 负好感或敌对 NPC 聊天终止后,`npc_fight` 只保留一个,按钮文案固定为“战斗”,原有 NPC 战斗交互与结算链路不变。 +2. 原单一“逃跑”按钮改为多个 `battle_escape_breakout` 选项:当前场景每个相邻场景生成“逃往{场景名}”,并额外生成“逃回当前场景起点”。 +3. 逃往相邻场景的选项在 `runtimePayload.targetSceneId` 中写入目标场景 id;逃回起点的选项在 `runtimePayload.escapeReturnToSceneStart` 中写入 `true`,并保留当前场景 id 作为目标。 +4. 点击任一逃跑类选项时,先复用现有主角向左转身跑出屏幕的逃离动画,再把运行态切到目标场景或当前场景起点,最后从左侧入场并面向右侧。 +5. 逃跑类选项只负责运行态目标和表现,不重新请求剧情推理,也不把规则说明显示到 UI。 + ## 7. 验收 1. 负好感主 NPC 不再出现固定 `turnLimit: 5`。 diff --git a/docs/experience/ADVENTURE_RUNTIME_DEV_EXPERIENCE.md b/docs/experience/ADVENTURE_RUNTIME_DEV_EXPERIENCE.md index 938ef24a..6972f9a2 100644 --- a/docs/experience/ADVENTURE_RUNTIME_DEV_EXPERIENCE.md +++ b/docs/experience/ADVENTURE_RUNTIME_DEV_EXPERIENCE.md @@ -253,3 +253,40 @@ AI 可以解释世界,但不能私自改世界。 这类 AI 冒险 RPG 的开发,最难的不是“把功能做出来”,而是: **让 function 边界、世界状态、视觉演绎、移动端面板和大模型文本在同一套规则下稳定协作。** + +## 7. 聊天输入区布局补充经验 + +### 7.1 聊天框变大时,要优先增加“消息展示区”而不是只放大输入框 + +- 玩家感知里的“聊天框高度”主要来自消息气泡和剧情滚动区,不是输入栏本身。 +- 如果只把输入框做高,实际会压缩选项区和底部按钮区,移动端反而更挤。 +- 更稳妥的做法是: + 先让剧情/聊天滚动区在剩余空间里拿到更高的伸缩优先级,再微调输入条高度和底部留白。 +- 移动端不要随意给聊天区写死过大的最小高度,否则很容易把选项按钮和自定义输入一起挤出首屏。 + +### 7.2 底部输入区要向安全区贴近,但不能直接贴死 + +- 自定义输入要更贴近屏幕底部,应该缩小底部控制区的额外 padding,而不是去掉安全区。 +- `env(safe-area-inset-bottom)` 仍然要保留,否则刘海屏、手势条机型会出现输入框被顶起或遮挡的问题。 +- 正确方向是: + 保留安全区补偿,只减少设计层自己额外加上的底部留白。 + +### 7.3 底部操作区下沉时,要同步增加和聊天区之间的呼吸感 + +- 当“队伍 / 背包 / 换一换 / 退出聊天 / 自定义输入”整体下移后,上下区块更容易挤在一起。 +- 这时要略微增加聊天区和操作区之间的垂直间距,保证视觉层级仍然清楚。 +- 目标不是做出更厚的面板,而是让用户一眼分清: + 上面是正在发生的对话,下面是马上可点的操作。 + +## 8. 战斗态底部面板布局经验 + +### 8.1 战斗态不应继续保留剧情框占位 + +- 战斗画面里玩家关注的是敌我状态与可执行动作,剧情文本框如果继续占据底部高度,会直接挤压操作按钮。 +- 战斗态应隐藏剧情框组件,只保留操作区;战斗结果叙事可放到结算或下一次非战斗剧情里展示。 + +### 8.2 战斗选项数量要由剩余高度决定 + +- 不要固定渲染全部战斗选项,否则移动端低高度屏幕会把按钮挤出可点击区域。 +- 更稳妥的做法是测量底部操作区可用高度,用单个按钮的最小高度和间距计算本帧可显示数量。 +- 至少保留 1 个操作,避免极端高度下玩家看不到任何战斗入口。 diff --git a/docs/experience/RPG_NEXT_SCENE_SERVER_RUNTIME_BRIDGE_FIX_2026-04-27.md b/docs/experience/RPG_NEXT_SCENE_SERVER_RUNTIME_BRIDGE_FIX_2026-04-27.md new file mode 100644 index 00000000..e45c3c9e --- /dev/null +++ b/docs/experience/RPG_NEXT_SCENE_SERVER_RUNTIME_BRIDGE_FIX_2026-04-27.md @@ -0,0 +1,35 @@ +# RPG 下一场景服务端桥接修复(2026-04-27) + +## 问题现象 + +- 在 RPG 运行时点击“继续前往下一场景”后,玩家角色离场动画已经正常播放。 +- 但下一幕背景没有切过来,场景内 NPC / 敌对实体没有刷新出来。 +- 任务推进也没有跟着进入下一场景后的状态继续展示。 + +## 根因 + +- 这条链路当前走的是服务端 runtime action:`idle_travel_next_scene`。 +- 服务端 compat 返回的快照只表达“当前遭遇已经结束”,没有像前端本地旅行链路那样自动补齐: + - `currentScenePreset` 切换后的真实目标场景 + - 新场景的 `currentEncounter / sceneHostileNpcs` + - 新场景到达后触发的 quest / scene chapter 进度 +- 前端此前对服务端返回快照只做了 battle hydration,没有对“场景旅行”做二次桥接。 + +## 修复口径 + +- 在 [src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts](/C:/Genarrative/src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts) 中新增服务端场景旅行桥接: + - 当 functionId 为 `idle_travel_next_scene` 时,优先读取服务端返回的新场景 id。 + - 如果服务端仍未给出新场景 id,则回退到当前场景的 `forwardSceneId`。 + - 统一复用 `buildMapTravelResolution`,把前端现有的场景切换真相态补齐到服务端快照上。 +- 这样可以继续复用现有的: + - 场景背景切换 + - NPC / 敌对实体预览与入场 + - quest 与 scene chapter 的场景到达推进 + +## 后续约束 + +- 后续所有服务端 runtime 的“旅行 / 切场景 / 回营地”动作,如果返回的是最小快照,前端必须明确判断是否需要桥接到现有真相态工具。 +- 不要只把“动画播放结束”当成场景切换完成;真正完成的标准必须是: + - 新 `currentScenePreset` 已落地 + - 新场景 encounter / hostile NPC 已进入可渲染状态 + - 场景到达后的 quest / chapter 推进结果可见 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 0a56895f..ace7d6ed 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 @@ -546,9 +546,9 @@ function resolvePuzzleGridSize(clearedLevelCount: number): 3 | 4 { 1. 初始局面不是已完成态 2. 初始局面至少存在可推进空间 -3. 初始局面不能存在任何已经正确相邻的两块,避免玩家开局即看到自动合并块 +3. 初始局面不能存在任何在原图中相邻的两块互相贴边,避免玩家开局即看到接近完成的局部结构 -初始化算法必须对候选打乱结果做正确相邻关系扫描:若任意两块在当前棋盘四向相邻,且它们在原图中的正确坐标也以相同方向相邻,则该候选布局无效,需要继续洗牌。多次随机尝试仍未得到合法布局时,使用确定性反序布局兜底;该布局等价于完整棋盘旋转 180 度,可保证原图相邻块不会以正确方向相邻。 +初始化算法必须对候选打乱结果做原图相邻关系扫描:若任意两块在当前棋盘四向相邻,且它们在原图中的正确坐标也四向相邻,则该候选布局无效,需要继续洗牌。多次随机尝试仍未得到合法布局时,使用确定性约束搜索兜底,逐格放置拼块并排除所有原图相邻块贴边的候选。 ## 9.5 交互规则总览 diff --git a/docs/technical/BATTLE_DIRECT_SETTLEMENT_PRESENTATION_FIX_2026-04-27.md b/docs/technical/BATTLE_DIRECT_SETTLEMENT_PRESENTATION_FIX_2026-04-27.md new file mode 100644 index 00000000..d90adcdb --- /dev/null +++ b/docs/technical/BATTLE_DIRECT_SETTLEMENT_PRESENTATION_FIX_2026-04-27.md @@ -0,0 +1,111 @@ +# 战斗直结算与表现修复记录(2026-04-27) + +更新时间:`2026-04-27` + +## 1. 问题背景 + +本次修复针对 RPG 运行态战斗里两类直接可感知的问题: + +1. 战斗面板中的“普通攻击”“技能释放”等选项点击后,体验上像是又走了一次剧情/模型推演,而不是立刻播动作并结算伤害。 +2. 伤害飘字与敌方头顶血条在战斗里没有稳定可见,尤其是最后一击和脱战前后的几帧容易直接丢失。 + +## 2. 本次约束 + +为避免后续回归,本次明确补充以下工程约束: + +1. 只要已经处于 `inBattle = true` 的战斗面板阶段,`battle_*` 与战斗内 `inventory_use` 都必须走前端现有的本地确定性战斗播放链。 +2. 这类动作只负责“播放动作 -> 扣血/扣蓝/冷却 -> 刷新下一轮 options / 脱战收尾”,不能再回到服务端剧情推演分流。 +3. 脱战前最后一拍的画布表现必须保留,不能因为 `inBattle` 提前变成 `false` 就把伤害飘字、敌方血条和受击反馈一起关掉。 + +## 3. 落地改动 + +### 3.1 战斗动作分流 + +文件:`src/hooks/rpg-runtime-story/choiceActions.ts` + +调整: + +1. 新增“战斗直结算动作”判定。 +2. 当运行态已经处于战斗中时,`battle_*` 和 `inventory_use` 不再命中 `runServerRuntimeChoiceAction()`。 +3. 这些动作统一回到 `runLocalStoryChoiceContinuation()`,沿用现有 `buildBattlePlan + playback` 本地确定性链路。 + +结果: + +1. 普通攻击、技能释放、调息、逃跑、战斗内用物品都会直接播动作并结算。 +2. UI 不再把这类按钮误导成“剧情推演中”的体验。 + +### 3.2 战斗画布表现保留 + +文件:`src/components/game-canvas/GameCanvasEntityLayer.tsx` + +调整: + +1. 新增 `hasCombatAfterimage` 判定,用于识别“虽然 `inBattle` 已经开始收尾,但敌方仍处于受击/死亡帧或刚产生过伤害反馈”的阶段。 +2. 伤害反馈样本采集、飘字事件保留、敌我血条展示统一改为依赖 `shouldRenderCombatPresentation`,而不是只看 `inBattle`。 + +结果: + +1. 伤害数值飘字不会在最后一击前被提前清空。 +2. 敌方头顶血条能跟着受击与死亡过渡完整显示出来。 + +### 3.3 面板文案修正 + +文件:`src/components/rpg-runtime-panels/RpgAdventurePanel.tsx` + +调整: + +1. 运行态仍需锁输入的短暂阶段,如果当前处于战斗中,加载提示改为“战斗结算中...”。 +2. 非战斗态保留“剧情推演中...”。 + +结果: + +1. 战斗按钮点击后的反馈语义与真实执行链路一致。 +2. 用户不会再误判为“点击普通攻击/技能后又触发了一次模型推理”。 + +## 4. 回归关注点 + +后续若继续改 `server-rs` / Runtime Story 的战斗 option 下发,需要继续遵守: + +1. 服务端可以负责下发合法战斗选项和最终快照结构。 +2. 但战斗内直接动作的逐帧播放与即时结算,默认仍以前端确定性链路为准。 +3. 若未来要把这条链路整体迁回 `server-rs`,必须先补齐“无推理、可逐帧播放、可保留最终受击帧”的等价表现方案,再替换当前实现。 + +## 5. 追加修复(2026-04-27 晚) + +本日后续排查又发现一处更直接的战斗跳过根因: + +1. `src/hooks/combat/battlePlan.ts` 旧实现会在一次点击里继续跑完整段 `turnOrder`,而不是只结算“玩家一次声明动作 + 最多一次敌方反击”。 +2. `battle_attack_basic` 还会复用技能挑选逻辑,导致“普通攻击”可能被本地随机映射成别的技能动作。 + +本轮已追加修复: + +1. 本地战斗计划严格收束为单回合结算,不再一次点击连跑多轮。 +2. `battle_attack_basic` 固定走基础攻击,不再随机选技能。 +3. 战斗内 `inventory_use` 在本地计划中按单次动作消费物品、应用恢复/冷却收益,不再落回攻击型分支。 + +追加验收口径: + +1. 战斗中点击一次普通攻击 / 技能 / 物品 / 调息,不会直接把整场战斗过程跳过。 +2. 若敌人未被这一击打死,当前次结算结束后仍停留在战斗态,并刷新下一轮战斗选项。 +3. “普通攻击”不会显示成其他技能的动作与耗蓝结果。 + +## 6. 再追加修复(2026-04-27 夜间二次排查) + +用户复测后又暴露出一类更隐蔽的问题: + +1. 面板展示层可能仍在显示战斗选项,但逻辑态 `gameState.inBattle` 已短暂回落为 `false`。 +2. 旧实现里,`src/hooks/rpg-runtime-story/choiceActions.ts` 只要看到 `inBattle === false`,就会把 `battle_*` / `inventory_use` 重新分流回服务端 runtime。 +3. 这会让用户在“视觉上仍处于战斗”的窗口点击选项时,直接命中服务端快照结果,体感仍然是“点一下就把战斗过程跳过”。 + +本轮追加修正: + +1. 战斗直结算判定不再只依赖 `gameState.inBattle`。 +2. 只要当前选项本身属于 `battle_*` / `inventory_use`,并且任一战斗上下文仍然存在 + `currentBattleNpcId`、`currentNpcBattleMode`、存活敌人、当前故事仍在显示战斗选项 + 就必须继续走本地逐帧战斗链。 +3. 为此补充了回归测试,覆盖“`inBattle` 已短暂变成 `false`,但战斗展示仍在”的残留窗口。 + +新增验收口径: + +1. 即使 `inBattle` 出现一帧级别的提前回落,只要玩家看到的仍是战斗选项,点击后也不会跳回服务端直结算。 +2. 战斗面板残留显示期间点击 `battle_*` / `inventory_use`,仍应播放本地战斗过程,而不是直接跳到结果快照。 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 fc73c734..af516097 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 @@ -397,8 +397,8 @@ finalScore = tagSimilarityScore * 0.7 + sameAuthorScore * 0.3; 1. `build_initial_board_with_seed` 使用种子化洗牌生成初始棋盘,不再固定左移一格。 2. 正式 run 的种子由 `runId + profileId + levelIndex + gridSize` 派生;由于每次进入都会创建新的 `runId`,同一作品多次进入也会得到不同打乱样式。 3. 洗牌后若极端情况下仍为完成态,强制旋转一次,保证新关卡不是已完成局面。 -4. 初始棋盘不得存在任何已经正确相邻的两块;初始化会多次洗牌筛选,若极端情况下未命中,则使用反序排列兜底,避免开局自动出现合并块。 -5. `module-puzzle` 与本地 fallback 的测试都必须直接断言初始棋盘不存在正确相邻对,不能只检查 `mergedGroups = []`。 +4. 初始棋盘不得存在任何原图相邻块互相贴边;初始化会多次洗牌筛选,若极端情况下未命中,则使用确定性约束搜索兜底,避免开局出现局部连续结构。 +5. `module-puzzle` 与本地 fallback 的测试都必须直接断言初始棋盘不存在原图相邻贴边对,不能只检查 `mergedGroups = []`。 ### 11.2 局部重算与合并 @@ -442,3 +442,15 @@ finalScore = tagSimilarityScore * 0.7 + sameAuthorScore * 0.3; 1. `dragState` 持续记录 `currentX/currentY`,拖动中按指针偏移对单块或合并块做 `translate3d` 跟手反馈。 2. 棋盘与合并块交互层增加 `touch-none select-none`,避免移动端滚动、选中文本等默认行为打断拖动。 3. 松手后仍只提交 `pieceId + targetRow + targetCol`,最终交换、合并、拆分和通关继续以后端/本地运行态快照为准。 + +### 12.3 合并块外轮廓描边修正 + +用户反馈“合并的块的边界显示要描边自己的块的边界,不要搞一个正方形或者矩形的边界”后,移除合并块外接矩形 `ring` 层。运行态现在按合并组真实占据格逐格判断四向邻居:某一边没有同组合并格时才画该边描边,同组内部相邻边不画线。这样 L 形、长条或其他非矩形合并块只显示自身外轮廓,拖动热区仍只覆盖真实拼块格。 + +后续反馈要求合并块边界也要圆角后,外轮廓描边补充按四个角判断:只有相邻两条外露边同时存在的真实外轮廓角才应用圆角,内部拼接角保持直角且不显示分界线。 + +### 12.4 第二关后打乱规则旁路修正 + +用户反馈“从第二关开始打乱规则像是完全相同”后,检查发现 `api-server` 的本地下一关 fallback 仍使用旧版 `build_local_puzzle_board` 固定左移一格,没有复用 `module-puzzle` 的种子化初始化规则。该路径会在图库/正式推荐不可用、由 API 临时构造下一关时触发。 + +修正后 `api-server` 本地下一关构造改为调用 `module_puzzle::build_initial_board_with_seed`,种子由 `runId + profileId + levelIndex + gridSize` 派生;因此第二关、第三关以及后续 fallback 关卡也会得到不同布局,并继续满足“开局没有原图相邻块贴边”的约束。 diff --git a/docs/technical/PUZZLE_RUNTIME_REAL_LEADERBOARD_2026-04-27.md b/docs/technical/PUZZLE_RUNTIME_REAL_LEADERBOARD_2026-04-27.md new file mode 100644 index 00000000..fb842857 --- /dev/null +++ b/docs/technical/PUZZLE_RUNTIME_REAL_LEADERBOARD_2026-04-27.md @@ -0,0 +1,105 @@ +# 拼图运行时真实排行榜落地说明 + +更新时间:`2026-04-27` + +## 1. 背景 + +当前拼图关卡结束弹窗里的排行榜数据并不是真实用户成绩。 + +问题根因有两层: + +1. 前端本地运行态 `src/services/puzzle-runtime/puzzleLocalRuntime.ts` 在通关后会直接拼出几条演示昵称数据。 +2. `server-rs` 拼图运行时虽然已经预留了 `leaderboardEntries` 字段,但 `module-puzzle`、`spacetime-client`、`api-server` 还没有真实成绩表与聚合过程,因此接口层长期返回空数组。 + +这导致用户在结算弹窗里看到的是“看起来像真实排行榜,但实际上是本地假数据”的结果,和平台“真实用户数据”要求冲突。 + +## 2. 本次目标 + +本次改动只解决一个明确问题: + +1. 拼图关卡结束后的排行榜必须使用真实用户成绩。 +2. 删除现有前端演示昵称、演示耗时等假数据。 +3. 不在 UI 中默认塞入任何说明型占位文案。 + +## 3. 本次落地边界 + +为了控制改动范围,本次不把整套拼图运行态全部迁回后端,而是在当前“本地棋盘运行态”基础上补一条真实成绩回写链路: + +1. 拼图拖拽、交换、合并、拆分、通关判定,仍然沿用当前本地运行态。 +2. 玩家一旦通关,前端立即把当前关卡成绩提交到 `server-rs`。 +3. `server-rs` 将成绩写入 `SpacetimeDB` 成绩表,并返回该关卡的真实排行榜。 +4. 结算弹窗只展示后端返回的真实成绩榜单,不再混入本地演示数据。 + +这意味着: + +1. 这次不是“完整后端裁决化”。 +2. 这次是“先把排行榜真相源收回后端”,满足真实成绩展示要求。 + +## 4. 成绩真相源设计 + +新增拼图成绩表,按“关卡作品 + 网格规格 + 用户”维护最佳成绩。 + +建议字段: + +1. `entry_id` + 唯一主键。 +2. `profile_id` + 当前关卡作品 `profile_id`。 +3. `grid_size` + 当前成绩对应的拼图网格规格,至少区分 `3x3` 与 `4x4`。 +4. `user_id` + 成绩所属真实用户 ID。 +5. `nickname` + 成绩展示昵称。当前优先使用提交时的用户显示名快照。 +6. `best_elapsed_ms` + 用户在该关卡该规格下的最佳通关耗时。 +7. `last_run_id` + 最近一次刷新该最佳成绩的运行态 `run_id`。 +8. `updated_at` + 最后一次刷新时间。 + +## 5. 排行榜口径 + +排行榜必须遵守下面规则: + +1. 只读真实成绩表。 +2. 同一用户在同一 `profile_id + grid_size` 下只保留 1 条最佳成绩。 +3. 排序按 `best_elapsed_ms` 从小到大。 +4. 同耗时按 `updated_at` 更早者优先,再按 `user_id` 稳定排序。 +5. 返回前 `N` 条,当前阶段固定 `10` 条即可。 +6. 当前用户如果在榜单内,需要标记 `isCurrentPlayer = true`。 + +## 6. 接口落地 + +新增拼图排行榜提交接口: + +`POST /api/runtime/puzzle/runs/:runId/leaderboard` + +请求体至少包含: + +1. `profileId` +2. `gridSize` +3. `elapsedMs` +4. `nickname` + +返回体采用现有 `PuzzleRunResponse`,但要求: + +1. `run.currentLevel.leaderboardEntries` 返回真实榜单。 +2. `run.leaderboardEntries` 同步返回当前关卡真实榜单,方便现有结算弹窗兼容读取。 + +## 7. 前端改动规则 + +1. 删除 `puzzleLocalRuntime.ts` 中本地演示榜单构造逻辑。 +2. 本地通关后,运行态只保留真实通关耗时,不再生成假昵称榜单。 +3. 结算弹窗显示时,如果真实榜单尚未回写完成,可以显示加载态;但不能回退到假数据。 +4. 下一关开始后,当前关卡榜单状态清空。 + +## 8. 测试要求 + +至少覆盖: + +1. 通关后不会再生成本地假榜单。 +2. 同一用户重复通关同一关卡时,只保留更优成绩。 +3. 不同用户成绩会按耗时正确排序。 +4. `3x3` 与 `4x4` 不混榜。 +5. 下一关开启后上一关榜单不会污染新关卡。 diff --git a/docs/technical/RPG_BATTLE_TURN_ORDER_AND_COMPANION_PRESENTATION_FIX_2026-04-27.md b/docs/technical/RPG_BATTLE_TURN_ORDER_AND_COMPANION_PRESENTATION_FIX_2026-04-27.md new file mode 100644 index 00000000..cf5f0aca --- /dev/null +++ b/docs/technical/RPG_BATTLE_TURN_ORDER_AND_COMPANION_PRESENTATION_FIX_2026-04-27.md @@ -0,0 +1,97 @@ +# RPG 战斗回合顺序与同伴表现修复 + +更新时间:`2026-04-27` + +## 1. 问题归因 + +本轮复测暴露出的战斗问题,不再是单一的“按钮分流错误”,而是同一条战斗链上有三处模型不一致: + +1. `npc_fight / npc_spar` 的开战入口已经把运行态切进战斗,但后续本地回合计划仍停留在“玩家一下 + 敌人一下”的简化模型。 +2. 本地 `battlePlan` 虽然已经具备 `companion` 播放 step 类型,但实际上从未生成同伴出手步骤。 +3. 战斗入场后,同伴仍沿用和平探索态附近的锚点与层级,容易被主角遮住,体感上像“后面的角色消失了”。 + +因此本次修复必须同时覆盖开战后的回合规则与画布站位规则,而不是继续只补某个 `if` 分支。 + +## 2. 统一后的目标行为 + +### 2.1 开战入口 + +1. `npc_fight / npc_spar` 仍允许先走服务端 runtime action,把 `currentBattleNpcId / currentNpcBattleMode / inBattle` 等真实战斗态切好。 +2. 一旦战斗选项已经展示出来,后续 `battle_*` 与战斗内 `inventory_use` 必须继续走前端本地确定性回合链。 +3. 也就是说: + - 服务端负责“进入什么战斗、当前战斗对象是谁”。 + - 前端本地链负责“这一轮谁先动、谁受伤、怎么播放、什么时候脱战”。 + +### 2.2 一次点击的结算边界 + +1. 每次点击战斗按钮,只结算当前这一整轮。 +2. 一整轮的定义是:当前仍存活、且本轮应出手的单位,各自按速度最多行动一次。 +3. 这一轮里如果有人先被击败,后续轮到他时直接跳过,不补行动。 +4. 一旦满足战斗结束条件,立刻停止后续轮内动作,不再补跑剩余单位。 + +### 2.3 正式战斗与切磋的区别 + +1. `fight`: + - 玩家、存活同伴、敌方单位一起参与。 + - 胜利条件是敌方全部倒下。 + - 失败条件是主角生命降到 `0`。 +2. `spar`: + - 只保留主角与当前切磋对象对打。 + - 同伴继续显示在场上,但不参与出手和承伤。 + - 切磋终止仍沿用现有“任一方被压到保底线即结束”的规则。 + +## 3. 回合顺序规则 + +### 3.1 速度来源 + +1. 玩家与同伴使用角色战斗属性解析后的 `turnSpeed`。 +2. 敌方单位使用当前 `SceneHostileNpc.speed`。 +3. 同速时使用稳定顺序打破平手: + - 主角优先于同伴。 + - 同伴按队伍槽位顺序。 + - 敌方按当前战场数组顺序。 + +### 3.2 本轮排序 + +1. 开始结算当前点击时,先基于本轮起始快照收集所有可行动单位。 +2. 按速度从高到低生成本轮 `turnOrder`。 +3. 之后按这个顺序依次推进: + - 若该单位轮到时已经死亡,则跳过。 + - 若战斗已结束,则直接停止本轮。 + +## 4. 玩家动作规则 + +1. `battle_attack_basic`: + - 永远只执行基础攻击,不再随机映射到其他技能。 +2. `battle_use_skill`: + - 使用 `runtimePayload.skillId` 指定的技能。 +3. `battle_recover_breath`: + - 作为“消耗当前回合的一次非攻击动作”处理。 + - 恢复收益应发生在主角真正轮到自己的那个时点,而不是点击瞬间提前生效。 +4. `inventory_use`: + - 同样作为“消耗当前回合的一次非攻击动作”处理。 + - 物品消耗、回血回蓝、冷却推进、build buff 增加,都发生在主角轮到时。 + +## 5. 同伴出手规则 + +1. 正式战斗中,所有存活同伴都应参与本轮排序。 +2. 每名同伴每轮最多出手一次。 +3. 同伴默认使用自己的可用技能;若当前没有可释放技能,则回退到基础攻击。 +4. 同伴的蓝量、技能冷却和受击血量都持续写回 `gameState.companions`。 + +## 6. 战斗画布中的同伴表现规则 + +1. 战斗态下,同伴不能继续和主角共用同一横向锚点。 +2. 战斗队形改为“主角前、同伴后排左侧展开”: + - 仍保留上下两个槽位。 + - 横向偏移必须明显大于和平态,避免被主角主体遮挡。 +3. 同伴层级仍按场景脚底锚点参与排序,但战斗态需要给同伴少量前移权重,避免完全压在主角后面。 +4. 切磋模式同样保留同伴站位显示,只是不参与回合。 + +## 7. 验收口径 + +1. 点击一次战斗按钮后,不会再直接跳完整场,而是只播放当前一整轮。 +2. 若敌方速度高于主角,敌方可以在这一轮里先行动。 +3. 正式战斗里,同伴会参与轮番出手,并按速度插入行动顺序。 +4. 切磋里同伴不会出手,但也不会在开战后“消失”。 +5. 战斗入场后,主角后方同伴仍稳定可见,不会因为站位重叠被完全遮住。 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 index e6c93905..e869795c 100644 --- 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 @@ -150,3 +150,86 @@ npm run typecheck -- --pretty false ``` 以上局部测试、局部 ESLint 与全量类型检查已通过。后端代码未在本轮修改中触碰,因此仍不需要执行 `npm run api-server:maincloud`。 + +## 2026-04-27 第四轮复查修正 + +用户复测后仍出现“进入作品测试没有显示幕配置角色”。本轮继续沿真实结果页入口复查,确认前几轮的第一幕解析已经覆盖 `oppositeNpcId -> primaryNpcId -> encounterNpcIds`,但作品测试入口仍可能在进入选角前拿到旧 profile。 + +定位结论: + +1. 结果页展示和自动保存期望消费 `session.resultPreview.preview`,或者在缺少 resultPreview 时消费 `draftProfile.legacyResultProfile`。 +2. `rpgCreationPreviewAdapter.buildPreviewFromSession()` 原先优先规范化 `session.draftProfile`,会把基础草稿骨架当成运行态 profile。 +3. 当基础草稿骨架与结果页预览中的 `sceneChapterBlueprints`、角色、第一幕对面角色不一致时,作品测试即使后续严格读取第一幕,也会读取到旧世界数据。 + +本轮修正: + +1. `src/services/rpg-creation/rpgCreationPreviewAdapter.ts` + - 结果页 profile 解析顺序调整为:`session.resultPreview.preview -> draftProfile.legacyResultProfile -> draftProfile`。 + - 作品测试、结果页展示和自动保存使用同一份当前结果页 profile,避免选角后加载旧草稿骨架。 +2. `src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts` + - 覆盖 `resultPreview.preview` 优先于 `draftProfile`。 + - 覆盖缺少 resultPreview 时回退到 `draftProfile.legacyResultProfile`。 +3. `src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx` + - 将作品测试入口断言改为使用“结果页当前 profile”,保证入口语义与 UI 展示一致。 + +本轮语义补齐为:结果页点“作品测试”后,先用当前结果页 profile 进入角色选择;选完角色后再直接加载该 profile 的开局场景第一幕,并把第一幕 `oppositeNpcId` 作为对面 NPC 启动聊天。 + +验证命令: + +```bash +npm test -- --run src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx src/hooks/useGameFlow.customWorld.test.tsx src/data/sceneEncounterPreviews.test.ts src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts src/data/customWorldLibrary.test.ts +npx eslint src/services/rpg-creation/rpgCreationPreviewAdapter.ts src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx src/hooks/useGameFlow.customWorld.test.tsx src/data/sceneEncounterPreviews.ts src/data/sceneEncounterPreviews.test.ts src/hooks/rpg-runtime-story/storyChoiceRuntime.ts src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts src/data/customWorldLibrary.ts src/data/customWorldLibrary.test.ts src/data/scenePresets.ts src/services/customWorldSceneActRuntime.ts src/hooks/rpg-session/useRpgSessionBootstrap.ts src/services/customWorldRoleReferences.ts src/services/big-fish-gallery/bigFishGalleryClient.ts +npm run typecheck -- --pretty false +npm run check:encoding +``` + +以上相关测试、局部 ESLint、全量类型检查与编码检查均通过。后端代码未在本轮修改中触碰,因此未执行 `npm run api-server:maincloud`。 + +## 2026-04-27 第五轮误导链路闭口 + +第四轮修复后,继续清理会误导后续迭代的旧入口: + +1. `src/services/rpg-creation/rpgCreationPreviewAdapter.ts` + - 删除普通 `draftProfile -> CustomWorldProfile` 兜底。 + - Agent 结果页 profile 只允许来自 `session.resultPreview.preview`,或缺少 resultPreview 时来自明确的 `draftProfile.legacyResultProfile` 兼容快照。 + - 基础 `draftProfile` 不再能被静默当作运行态 profile,避免作品测试再次读到旧草稿骨架。 +2. `src/hooks/rpg-session/useRpgSessionBootstrap.ts` + - 自定义世界选角后的开局状态已经显式构造第一幕 encounter 时,直接返回开局状态。 + - 不再把自定义世界开局交给 `ensureSceneEncounterPreview()` 二次推断,避免旧的友好 NPC / 场景预览链路覆盖第一幕 `oppositeNpcId`。 +3. `src/components/rpg-entry/useRpgCreationEnterWorld.ts` 与 `src/components/rpg-entry/useRpgCreationResultAutosave.ts` + - 移除“只读 session.draftProfile / draftProfile 是真相源”这类已经误导本次排查的注释。 + - 明确作品测试读取当前结果页 profile,不静默回退到基础 draftProfile。 + +闭口后的主链路:结果页 profile -> 作品测试选角 -> 第一章第一幕 -> `oppositeNpcId` encounter。普通场景预览只作为非自定义世界或非开局场景的兜底,不再参与作品测试开局第一幕的角色裁决。 + +补充闭口: + +1. Agent 结果页作品测试与发布入口要求存在当前结果页 profile。 +2. 若当前结果页 profile 缺失,入口直接停止,不再使用 `generatedCustomWorldProfile` 旧内存态兜底。 + +## 2026-04-27 第六轮入口引用与测试态收口 + +用户复测后再次出现“进入后没有正确显示幕配置角色,且没有进入聊天状态”。本轮继续把问题压回作品测试真实入口,确认前几轮在标准 `oppositeNpcId` 写法下可以正确进入聊天,但真实生成数据可能把第一幕角色引用写成运行时 NPC 形态,例如 `character-npc-角色id`,或混用角色 id、名称、标题、角色职责文本。旧引用解析只覆盖了部分标准形态,导致第一幕 encounter 解析失败后,运行态会退回普通开局剧情或其他场景角色,自然也不会进入该幕 NPC 聊天。 + +本轮修正: + +1. `src/services/customWorldRoleReferences.ts` + - 角色引用归一化新增 `character-npc-*`、`npc-*`、`story-*`、`playable-*` 等运行时/草稿前缀剥离。 + - 角色别名新增“职责+姓名”“姓名+职责”等组合,兼容生成器把 `role` 文本写入幕槽位的情况。 +2. `src/hooks/rpg-session/useRpgSessionBootstrap.ts` + - 第一幕候选 NPC 解析在进入优先队列前先通过当前 profile 统一归一化。 + - 跳过当前玩家角色时,不再只比较 `character.id`,而是同时用角色引用解析比较 id/name,避免玩家本人被 `oppositeNpcId` 的别名误判为对面 NPC。 + - 自定义世界作品测试进入选择世界与选角后的运行态明确标记 `runtimeMode: 'test'`、`runtimePersistenceDisabled: true`,避免作品测试被普通游玩存档/自动保存链路污染。 +3. `src/hooks/useGameFlow.customWorld.test.tsx` + - 增加复现测试:当第一幕 `oppositeNpcId` 写成 `character-npc-story-act-only` 时,选角后仍必须命中陆衡,并由陆衡主动进入聊天。 + - 增加作品测试态断言,确保测试入口不参与普通持久化。 + +补充验证命令: + +```bash +npm test -- --run src/hooks/useGameFlow.customWorld.test.tsx src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.test.tsx +npx eslint src/hooks/rpg-session/useRpgSessionBootstrap.ts src/hooks/useGameFlow.customWorld.test.tsx src/services/customWorldRoleReferences.ts +npm run typecheck -- --pretty false +``` + +以上测试、ESLint 与类型检查已通过。后端代码未在本轮修改中触碰,因此仍不需要执行 `npm run api-server:maincloud`。 diff --git a/docs/technical/RPG_TEST_RUNTIME_END_BUTTON_2026-04-27.md b/docs/technical/RPG_TEST_RUNTIME_END_BUTTON_2026-04-27.md new file mode 100644 index 00000000..d16f0ffd --- /dev/null +++ b/docs/technical/RPG_TEST_RUNTIME_END_BUTTON_2026-04-27.md @@ -0,0 +1,28 @@ +# RPG 作品测试结束按钮补齐(2026-04-27) + +## 背景 + +世界创作结果页已经提供“作品测试”入口,但测试运行时此前缺少与“幕预览”一致的显式退出按钮。创作者进入测试后只能依赖浏览器返回、刷新或其他间接链路离开,不符合独立运行时面板的交互语义。 + +## 本次约束 + +1. “作品测试”进入的运行时必须显式标记为 `runtimeMode: "test"`。 +2. 测试态退出入口使用固定浮层按钮,文案为“结束测试”。 +3. “结束测试”不做保存,不写正式游玩存档。 +4. 从结果页进入作品测试后,结束测试必须返回当前结果页,而不是平台首页。 +5. 正式“进入世界 / 发布并进入世界”保持原有行为,不受本次退出按钮影响。 + +## 落地实现 + +1. `App.tsx` 为自定义世界运行时启动增加轻量 launch options,记录本次进入是 `play` 还是 `test`,以及测试结束后的返回 stage。 +2. `useRpgCreationEnterWorld.ts` 将结果页“作品测试”入口显式标记为 `mode: "test"`,并写入 `returnStage: "custom-world-result"`。 +3. `useRpgSessionBootstrap.ts` 支持自定义世界按启动模式写入 `runtimeMode` 与 `runtimePersistenceDisabled`: + - `test`:禁存; + - `play`:沿正式游玩链路运行。 +4. `RpgRuntimeShell.tsx` 在 `runtimeMode === "test"` 时叠加固定浮层按钮“结束测试”,点击后直接退出运行时并返回启动前页面。 + +## 验证 + +1. 结果页点击“作品测试”后可见“结束测试”按钮。 +2. 点击“结束测试”后返回结果页,且“作品测试 / 发布”按钮仍可继续操作。 +3. `useRpgSessionPersistence` 继续跳过测试态存档,不新增正式游玩记录。 diff --git a/packages/shared/src/contracts/puzzleRuntimeSession.ts b/packages/shared/src/contracts/puzzleRuntimeSession.ts index 81e1e7ac..1009f22e 100644 --- a/packages/shared/src/contracts/puzzleRuntimeSession.ts +++ b/packages/shared/src/contracts/puzzleRuntimeSession.ts @@ -79,6 +79,13 @@ export interface PuzzleRunResponse { run: PuzzleRunSnapshot; } +export interface SubmitPuzzleLeaderboardRequest { + profileId: string; + gridSize: PuzzleGridSize; + elapsedMs: number; + nickname: string; +} + export interface SwapPuzzlePiecesRequest { firstPieceId: string; secondPieceId: string; diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 73e091b1..b902c362 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -83,6 +83,7 @@ use crate::{ get_puzzle_agent_session, get_puzzle_gallery_detail, get_puzzle_run, get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work, start_puzzle_run, stream_puzzle_agent_message, submit_puzzle_agent_message, + submit_puzzle_leaderboard, swap_puzzle_pieces, }, refresh_session::refresh_session, @@ -694,6 +695,13 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/runtime/puzzle/runs/{run_id}/leaderboard", + post(submit_puzzle_leaderboard).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/custom-world/entity", post(generate_custom_world_entity).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/api-server/src/assets.rs b/server-rs/crates/api-server/src/assets.rs index 4c337666..b38e3ba6 100644 --- a/server-rs/crates/api-server/src/assets.rs +++ b/server-rs/crates/api-server/src/assets.rs @@ -28,11 +28,8 @@ use crate::{ }; // 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。 -const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 3] = [ - "character_visual", - "scene_image", - "puzzle_cover_image", -]; +const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 3] = + ["character_visual", "scene_image", "puzzle_cover_image"]; pub async fn create_direct_upload_ticket( State(state): State, @@ -480,7 +477,9 @@ mod tests { 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")); + assert!(!super::is_supported_asset_history_kind( + "puzzle_preview_image" + )); } #[test] diff --git a/server-rs/crates/api-server/src/creation_agent_anchor_templates.json b/server-rs/crates/api-server/src/creation_agent_anchor_templates.json index ce21edcd..4a6c55b1 100644 --- a/server-rs/crates/api-server/src/creation_agent_anchor_templates.json +++ b/server-rs/crates/api-server/src/creation_agent_anchor_templates.json @@ -62,7 +62,7 @@ "anchorQuestions": [ { "key": "themePromise", - "label": "题材承诺", + "label": "题材", "question": "这张拼图给玩家的题材和完成期待是什么?", "requiredEffect": "明确拼图主题、辨识度和完成后的满足感。" }, diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 54c7f427..7f18575d 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -17,7 +17,7 @@ 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 module_puzzle::{PuzzleBoardSnapshot, PuzzleGeneratedImageCandidate}; use platform_oss::{ LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest, OssSignedGetObjectUrlRequest, @@ -37,9 +37,10 @@ use shared_contracts::{ puzzle_gallery::{PuzzleGalleryDetailResponse, PuzzleGalleryResponse}, puzzle_runtime::{ AdvanceLocalPuzzleNextLevelRequest, DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse, - PuzzleCellPositionResponse, PuzzleMergedGroupStateResponse, PuzzlePieceStateResponse, - PuzzleRunResponse, PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse, - StartPuzzleRunRequest, SwapPuzzlePiecesRequest, + PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse, + PuzzleMergedGroupStateResponse, PuzzlePieceStateResponse, PuzzleRunResponse, + PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest, + SubmitPuzzleLeaderboardRequest, SwapPuzzlePiecesRequest, }, puzzle_works::{ PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse, @@ -53,7 +54,8 @@ use spacetime_client::{ PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, - PuzzleMergedGroupRecord, PuzzlePieceStateRecord, PuzzlePublishRecordInput, + PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, + PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, @@ -1104,6 +1106,54 @@ pub async fn advance_local_puzzle_next_level( )) } +pub async fn submit_puzzle_leaderboard( + State(state): State, + AxumPath(run_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_RUNTIME_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + + ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; + + let run = state + .spacetime_client() + .submit_puzzle_leaderboard_entry(PuzzleLeaderboardSubmitRecordInput { + run_id, + owner_user_id: authenticated.claims().user_id().to_string(), + profile_id: payload.profile_id, + grid_size: payload.grid_size, + elapsed_ms: payload.elapsed_ms.max(1_000), + nickname: payload.nickname.trim().to_string(), + submitted_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleRunResponse { + run: map_puzzle_run_response(run), + }, + )) +} + fn map_puzzle_agent_session_response( session: PuzzleAgentSessionRecord, ) -> PuzzleAgentSessionSnapshotResponse { @@ -1303,7 +1353,11 @@ 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(), + leaderboard_entries: run + .leaderboard_entries + .into_iter() + .map(map_puzzle_leaderboard_entry_response) + .collect(), } } @@ -1318,6 +1372,11 @@ fn map_puzzle_run_request_record(run: PuzzleRunSnapshotResponse) -> PuzzleRunRec previous_level_tags: run.previous_level_tags, current_level: run.current_level.map(map_puzzle_level_request_record), recommended_next_profile_id: run.recommended_next_profile_id, + leaderboard_entries: run + .leaderboard_entries + .into_iter() + .map(map_puzzle_leaderboard_request_record) + .collect(), } } @@ -1335,6 +1394,25 @@ fn map_puzzle_level_request_record( cover_image_src: level.cover_image_src, board: map_puzzle_board_request_record(level.board), status: level.status, + started_at_ms: level.started_at_ms, + cleared_at_ms: level.cleared_at_ms, + elapsed_ms: level.elapsed_ms, + leaderboard_entries: level + .leaderboard_entries + .into_iter() + .map(map_puzzle_leaderboard_request_record) + .collect(), + } +} + +fn map_puzzle_leaderboard_request_record( + entry: PuzzleLeaderboardEntryResponse, +) -> PuzzleLeaderboardEntryRecord { + PuzzleLeaderboardEntryRecord { + rank: entry.rank, + nickname: entry.nickname, + elapsed_ms: entry.elapsed_ms, + is_current_player: entry.is_current_player, } } @@ -1389,10 +1467,25 @@ 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(), + started_at_ms: level.started_at_ms, + cleared_at_ms: level.cleared_at_ms, + elapsed_ms: level.elapsed_ms, + leaderboard_entries: level + .leaderboard_entries + .into_iter() + .map(map_puzzle_leaderboard_entry_response) + .collect(), + } +} + +fn map_puzzle_leaderboard_entry_response( + entry: PuzzleLeaderboardEntryRecord, +) -> PuzzleLeaderboardEntryResponse { + PuzzleLeaderboardEntryResponse { + rank: entry.rank, + nickname: entry.nickname, + elapsed_ms: entry.elapsed_ms, + is_current_player: entry.is_current_player, } } @@ -1922,6 +2015,7 @@ fn build_next_run_from_parts( if !played_profile_ids.contains(&profile_id) { played_profile_ids.push(profile_id.clone()); } + let board = build_local_puzzle_board(grid_size, &run.run_id, &profile_id, next_level_index); PuzzleRunRecord { run_id: run.run_id.clone(), entry_profile_id: run.entry_profile_id, @@ -1939,52 +2033,125 @@ fn build_next_run_from_parts( author_display_name, theme_tags, cover_image_src, - board: build_local_puzzle_board(grid_size), + board, status: "playing".to_string(), + started_at_ms: (current_utc_micros().max(0) as u64) / 1_000, + cleared_at_ms: None, + elapsed_ms: None, + leaderboard_entries: Vec::new(), }), recommended_next_profile_id: None, + leaderboard_entries: Vec::new(), } } -fn build_local_puzzle_board(grid_size: u32) -> PuzzleBoardRecord { - let total = grid_size * grid_size; - let mut positions = (0..total) - .map(|index| PuzzleCellPositionRecord { - row: index / grid_size, - col: index % grid_size, - }) - .collect::>(); - if !positions.is_empty() { - let first = positions.remove(0); - positions.push(first); +fn build_local_puzzle_board( + grid_size: u32, + run_id: &str, + profile_id: &str, + level_index: u32, +) -> PuzzleBoardRecord { + let board = module_puzzle::build_initial_board_with_seed( + grid_size, + build_local_puzzle_shuffle_seed(run_id, profile_id, level_index, grid_size), + ) + .unwrap_or_else(|_| { + module_puzzle::build_initial_board_with_seed(3, 1) + .expect("fallback puzzle board should use supported grid size") + }); + map_puzzle_board_snapshot_record(board) +} + +fn build_local_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); } - let pieces = (0..total) - .map(|index| { - let current = - positions - .get(index as usize) - .cloned() - .unwrap_or(PuzzleCellPositionRecord { - row: index / grid_size, - col: index % grid_size, - }); - PuzzlePieceStateRecord { - 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(); + hash +} + +fn map_puzzle_board_snapshot_record(board: PuzzleBoardSnapshot) -> PuzzleBoardRecord { PuzzleBoardRecord { - rows: grid_size, - cols: grid_size, - pieces, - merged_groups: Vec::new(), - selected_piece_id: None, - all_tiles_resolved: false, + rows: board.rows, + cols: board.cols, + pieces: board + .pieces + .into_iter() + .map(|piece| PuzzlePieceStateRecord { + piece_id: piece.piece_id, + correct_row: piece.correct_row, + correct_col: piece.correct_col, + current_row: piece.current_row, + current_col: piece.current_col, + merged_group_id: piece.merged_group_id, + }) + .collect(), + merged_groups: board + .merged_groups + .into_iter() + .map(|group| PuzzleMergedGroupRecord { + group_id: group.group_id, + piece_ids: group.piece_ids, + occupied_cells: group + .occupied_cells + .into_iter() + .map(|cell| PuzzleCellPositionRecord { + row: cell.row, + col: cell.col, + }) + .collect(), + }) + .collect(), + selected_piece_id: board.selected_piece_id, + all_tiles_resolved: board.all_tiles_resolved, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn board_positions(board: &PuzzleBoardRecord) -> Vec<(u32, u32)> { + board + .pieces + .iter() + .map(|piece| (piece.current_row, piece.current_col)) + .collect() + } + + fn has_original_neighbor_pair(board: &PuzzleBoardRecord) -> bool { + board.pieces.iter().any(|piece| { + board.pieces.iter().any(|candidate| { + piece.piece_id != candidate.piece_id + && piece.current_row.abs_diff(candidate.current_row) + + piece.current_col.abs_diff(candidate.current_col) + == 1 + && piece.correct_row.abs_diff(candidate.correct_row) + + piece.correct_col.abs_diff(candidate.correct_col) + == 1 + }) + }) + } + + #[test] + fn local_next_level_board_shuffle_changes_by_level() { + let second = build_local_puzzle_board(3, "run-a", "profile-level-2", 2); + let third = build_local_puzzle_board(3, "run-a", "profile-level-3", 3); + + assert_ne!(board_positions(&second), board_positions(&third)); + assert!(!has_original_neighbor_pair(&second)); + assert!(!has_original_neighbor_pair(&third)); } } diff --git a/server-rs/crates/module-puzzle/src/lib.rs b/server-rs/crates/module-puzzle/src/lib.rs index 8fdacff5..448db2c6 100644 --- a/server-rs/crates/module-puzzle/src/lib.rs +++ b/server-rs/crates/module-puzzle/src/lib.rs @@ -239,6 +239,15 @@ pub struct PuzzleMergedGroupState { pub occupied_cells: Vec, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PuzzleLeaderboardEntry { + pub rank: u32, + pub nickname: String, + pub elapsed_ms: u64, + pub is_current_player: bool, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleBoardSnapshot { @@ -263,6 +272,10 @@ pub struct PuzzleRuntimeLevelSnapshot { pub cover_image_src: Option, pub board: PuzzleBoardSnapshot, pub status: PuzzleRuntimeLevelStatus, + pub started_at_ms: u64, + pub cleared_at_ms: Option, + pub elapsed_ms: Option, + pub leaderboard_entries: Vec, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -277,6 +290,7 @@ pub struct PuzzleRunSnapshot { pub previous_level_tags: Vec, pub current_level: Option, pub recommended_next_profile_id: Option, + pub leaderboard_entries: Vec, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -438,6 +452,18 @@ pub struct PuzzleRunNextLevelInput { pub advanced_at_micros: i64, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PuzzleLeaderboardSubmitInput { + pub run_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub grid_size: u32, + pub elapsed_ms: u64, + pub nickname: String, + pub submitted_at_micros: i64, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleAgentSessionProcedureResult { @@ -924,6 +950,7 @@ pub fn start_run_with_shuffle_seed( ) -> Result { let grid_size = resolve_puzzle_grid_size(cleared_level_count); let board = build_initial_board_with_seed(grid_size, shuffle_seed)?; + let started_at_ms = current_unix_ms(); Ok(PuzzleRunSnapshot { run_id: run_id.clone(), entry_profile_id: entry_profile.profile_id.clone(), @@ -943,8 +970,13 @@ pub fn start_run_with_shuffle_seed( cover_image_src: entry_profile.cover_image_src.clone(), board, status: PuzzleRuntimeLevelStatus::Playing, + started_at_ms, + cleared_at_ms: None, + elapsed_ms: None, + leaderboard_entries: Vec::new(), }), recommended_next_profile_id: None, + leaderboard_entries: Vec::new(), }) } @@ -1163,8 +1195,13 @@ pub fn advance_next_level( cover_image_src: next_profile.cover_image_src.clone(), board: next_board, status: PuzzleRuntimeLevelStatus::Playing, + started_at_ms: current_unix_ms(), + cleared_at_ms: None, + elapsed_ms: None, + leaderboard_entries: Vec::new(), }), recommended_next_profile_id: None, + leaderboard_entries: Vec::new(), }) } @@ -1389,7 +1426,6 @@ 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(); @@ -1399,16 +1435,15 @@ fn build_initial_pieces_without_correct_neighbors( ); 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) { + if !has_any_original_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)); + // 随机尝试耗尽后使用确定性约束搜索兜底,保证开局没有任意一对原图相邻块互相贴边。 + let fallback_pieces = build_original_neighbor_free_pieces(grid_size, shuffle_seed) + .unwrap_or_else(|| build_pieces_from_positions(grid_size, &base_positions)); + debug_assert!(!has_any_original_neighbor_pair(&fallback_pieces)); fallback_pieces } @@ -1422,16 +1457,6 @@ fn build_correct_positions(grid_size: u32) -> Vec { .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], @@ -1466,7 +1491,7 @@ fn ensure_board_is_not_solved(positions: &mut [PuzzleCellPosition], grid_size: u } } -fn has_any_correct_neighbor_pair(pieces: &[PuzzlePieceState]) -> bool { +fn has_any_original_neighbor_pair(pieces: &[PuzzlePieceState]) -> bool { let pieces_by_cell = pieces .iter() .map(|piece| ((piece.current_row, piece.current_col), piece)) @@ -1476,10 +1501,138 @@ fn has_any_correct_neighbor_pair(pieces: &[PuzzlePieceState]) -> bool { 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)) + .any(|neighbor| are_original_neighbors(piece, neighbor)) }) } +fn are_original_neighbors(left: &PuzzlePieceState, right: &PuzzlePieceState) -> bool { + left.correct_row.abs_diff(right.correct_row) + left.correct_col.abs_diff(right.correct_col) == 1 +} + +fn build_original_neighbor_free_pieces( + grid_size: u32, + shuffle_seed: u64, +) -> Option> { + let total = (grid_size * grid_size) as usize; + let mut piece_order = (0..total as u32).collect::>(); + sort_indices_by_seed(&mut piece_order, shuffle_seed ^ 0xa076_1d64_78bd_642f); + let mut cell_order = build_correct_positions(grid_size); + sort_cells_by_seed(&mut cell_order, shuffle_seed ^ 0xe703_7ed1_a0b4_28db); + + let mut placements = vec![None; total]; + let mut used_cells = BTreeSet::new(); + if place_neighbor_free_piece( + grid_size, + &piece_order, + &cell_order, + 0, + &mut placements, + &mut used_cells, + ) { + Some( + placements + .into_iter() + .enumerate() + .filter_map(|(index, current)| { + current.map(|current| PuzzlePieceState { + piece_id: format!("piece-{index}"), + correct_row: index as u32 / grid_size, + correct_col: index as u32 % grid_size, + current_row: current.row, + current_col: current.col, + merged_group_id: None, + }) + }) + .collect(), + ) + } else { + None + } +} + +fn place_neighbor_free_piece( + grid_size: u32, + piece_order: &[u32], + cell_order: &[PuzzleCellPosition], + depth: usize, + placements: &mut [Option], + used_cells: &mut BTreeSet<(u32, u32)>, +) -> bool { + let Some(piece_index) = piece_order.get(depth).copied() else { + return true; + }; + + for cell in cell_order { + if used_cells.contains(&(cell.row, cell.col)) { + continue; + } + if cell.row == piece_index / grid_size && cell.col == piece_index % grid_size { + continue; + } + if violates_original_neighbor_free_rule(grid_size, piece_index, cell.clone(), placements) { + continue; + } + + placements[piece_index as usize] = Some(cell.clone()); + used_cells.insert((cell.row, cell.col)); + if place_neighbor_free_piece( + grid_size, + piece_order, + cell_order, + depth + 1, + placements, + used_cells, + ) { + return true; + } + used_cells.remove(&(cell.row, cell.col)); + placements[piece_index as usize] = None; + } + + false +} + +fn violates_original_neighbor_free_rule( + grid_size: u32, + piece_index: u32, + cell: PuzzleCellPosition, + placements: &[Option], +) -> bool { + placements + .iter() + .enumerate() + .filter_map(|(placed_index, placed_cell)| { + placed_cell + .as_ref() + .map(|placed_cell| (placed_index as u32, placed_cell)) + }) + .any(|(placed_index, placed_cell)| { + let original_neighbors = (piece_index / grid_size).abs_diff(placed_index / grid_size) + + (piece_index % grid_size).abs_diff(placed_index % grid_size) + == 1; + let current_neighbors = + cell.row.abs_diff(placed_cell.row) + cell.col.abs_diff(placed_cell.col) == 1; + original_neighbors && current_neighbors + }) +} + +fn sort_indices_by_seed(indices: &mut [u32], seed: u64) { + indices.sort_by_key(|index| seeded_order_key(seed, u64::from(*index))); +} + +fn sort_cells_by_seed(cells: &mut [PuzzleCellPosition], seed: u64) { + cells.sort_by_key(|cell| seeded_order_key(seed, u64::from(cell.row * 16 + cell.col))); +} + +fn seeded_order_key(seed: u64, value: u64) -> u64 { + let mut state = seed ^ value.wrapping_mul(0x9e37_79b9_7f4a_7c15); + state ^= state >> 30; + state = state.wrapping_mul(0xbf58_476d_1ce4_e5b9); + state ^= state >> 27; + state = state.wrapping_mul(0x94d0_49bb_1331_11eb); + state ^ (state >> 31) +} + fn rebuild_board_snapshot( grid_size: u32, pieces: Vec, @@ -1808,15 +1961,32 @@ fn with_next_board(run: &PuzzleRunSnapshot, next_board: PuzzleBoardSnapshot) -> if let Some(current_level) = next_run.current_level.as_mut() { current_level.board = next_board; + if current_level.status != PuzzleRuntimeLevelStatus::Cleared && is_cleared { + let cleared_at_ms = current_unix_ms(); + current_level.cleared_at_ms = Some(cleared_at_ms); + current_level.elapsed_ms = + Some(cleared_at_ms.saturating_sub(current_level.started_at_ms).max(1_000)); + } current_level.status = next_level_status; } - if is_cleared { + if is_cleared && run.current_level.as_ref().map(|level| level.status) + != Some(PuzzleRuntimeLevelStatus::Cleared) + { next_run.cleared_level_count += 1; } next_run } +fn current_unix_ms() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|value| value.as_millis() as u64) + .unwrap_or(0) +} + #[cfg(test)] mod tests { use super::*; @@ -1951,14 +2121,14 @@ mod tests { } #[test] - fn initial_board_has_no_correct_neighbor_pairs() { + fn initial_board_has_no_original_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), + !has_any_original_neighbor_pair(&board.pieces), "grid_size={grid_size}, shuffle_seed={shuffle_seed}" ); } diff --git a/server-rs/crates/shared-contracts/src/puzzle_runtime.rs b/server-rs/crates/shared-contracts/src/puzzle_runtime.rs index 873ea070..0976a901 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_runtime.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_runtime.rs @@ -29,6 +29,15 @@ pub struct DragPuzzlePieceRequest { pub target_col: u32, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SubmitPuzzleLeaderboardRequest { + pub profile_id: String, + pub grid_size: u32, + pub elapsed_ms: u64, + pub nickname: String, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct PuzzleCellPositionResponse { diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index a2379f1f..0f22ad88 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -31,7 +31,8 @@ pub use mapper::{ PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, - PuzzleMergedGroupRecord, PuzzlePieceStateRecord, PuzzlePublishRecordInput, + PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, + PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index fc57f93a..67133a00 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -2272,6 +2272,11 @@ pub(crate) fn map_puzzle_run_snapshot(snapshot: DomainPuzzleRunSnapshot) -> Puzz .current_level .map(map_puzzle_runtime_level_snapshot), recommended_next_profile_id: snapshot.recommended_next_profile_id, + leaderboard_entries: snapshot + .leaderboard_entries + .into_iter() + .map(map_puzzle_leaderboard_entry) + .collect(), } } @@ -2289,6 +2294,25 @@ pub(crate) fn map_puzzle_runtime_level_snapshot( cover_image_src: snapshot.cover_image_src, board: map_puzzle_board_snapshot(snapshot.board), status: snapshot.status.as_str().to_string(), + started_at_ms: snapshot.started_at_ms, + cleared_at_ms: snapshot.cleared_at_ms, + elapsed_ms: snapshot.elapsed_ms, + leaderboard_entries: snapshot + .leaderboard_entries + .into_iter() + .map(map_puzzle_leaderboard_entry) + .collect(), + } +} + +pub(crate) fn map_puzzle_leaderboard_entry( + snapshot: module_puzzle::PuzzleLeaderboardEntry, +) -> PuzzleLeaderboardEntryRecord { + PuzzleLeaderboardEntryRecord { + rank: snapshot.rank, + nickname: snapshot.nickname, + elapsed_ms: snapshot.elapsed_ms, + is_current_player: snapshot.is_current_player, } } @@ -4374,6 +4398,14 @@ pub struct PuzzleMergedGroupRecord { pub occupied_cells: Vec, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleLeaderboardEntryRecord { + pub rank: u32, + pub nickname: String, + pub elapsed_ms: u64, + pub is_current_player: bool, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct PuzzleBoardRecord { pub rows: u32, @@ -4396,6 +4428,10 @@ pub struct PuzzleRuntimeLevelRecord { pub cover_image_src: Option, pub board: PuzzleBoardRecord, pub status: String, + pub started_at_ms: u64, + pub cleared_at_ms: Option, + pub elapsed_ms: Option, + pub leaderboard_entries: Vec, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -4409,6 +4445,18 @@ pub struct PuzzleRunRecord { pub previous_level_tags: Vec, pub current_level: Option, pub recommended_next_profile_id: Option, + pub leaderboard_entries: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleLeaderboardSubmitRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub grid_size: u32, + pub elapsed_ms: u64, + pub nickname: String, + pub submitted_at_micros: i64, } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index 62c0f774..9e2a8e66 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -294,6 +294,8 @@ pub mod puzzle_agent_session_row_type; pub mod puzzle_agent_stage_type; pub mod puzzle_draft_compile_input_type; pub mod puzzle_generated_images_save_input_type; +pub mod puzzle_leaderboard_entry_row_type; +pub mod puzzle_leaderboard_submit_input_type; pub mod puzzle_publication_status_type; pub mod puzzle_publish_input_type; pub mod puzzle_run_drag_input_type; @@ -442,6 +444,7 @@ pub mod submit_big_fish_input_procedure; pub mod submit_big_fish_message_procedure; pub mod submit_custom_world_agent_message_procedure; pub mod submit_puzzle_agent_message_procedure; +pub mod submit_puzzle_leaderboard_entry_procedure; pub mod swap_puzzle_pieces_procedure; pub mod treasure_interaction_action_type; pub mod treasure_record_procedure_result_type; @@ -755,6 +758,8 @@ pub use puzzle_agent_session_row_type::PuzzleAgentSessionRow; pub use puzzle_agent_stage_type::PuzzleAgentStage; pub use puzzle_draft_compile_input_type::PuzzleDraftCompileInput; pub use puzzle_generated_images_save_input_type::PuzzleGeneratedImagesSaveInput; +pub use puzzle_leaderboard_entry_row_type::PuzzleLeaderboardEntryRow; +pub use puzzle_leaderboard_submit_input_type::PuzzleLeaderboardSubmitInput; pub use puzzle_publication_status_type::PuzzlePublicationStatus; pub use puzzle_publish_input_type::PuzzlePublishInput; pub use puzzle_run_drag_input_type::PuzzleRunDragInput; @@ -903,6 +908,7 @@ pub use submit_big_fish_input_procedure::submit_big_fish_input; pub use submit_big_fish_message_procedure::submit_big_fish_message; pub use submit_custom_world_agent_message_procedure::submit_custom_world_agent_message; pub use submit_puzzle_agent_message_procedure::submit_puzzle_agent_message; +pub use submit_puzzle_leaderboard_entry_procedure::submit_puzzle_leaderboard_entry; pub use swap_puzzle_pieces_procedure::swap_puzzle_pieces; pub use treasure_interaction_action_type::TreasureInteractionAction; pub use treasure_record_procedure_result_type::TreasureRecordProcedureResult; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_leaderboard_entry_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_leaderboard_entry_row_type.rs new file mode 100644 index 00000000..a9b1a782 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_leaderboard_entry_row_type.rs @@ -0,0 +1,70 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleLeaderboardEntryRow { + pub entry_id: String, + pub profile_id: String, + pub grid_size: u32, + pub user_id: String, + pub nickname: String, + pub best_elapsed_ms: u64, + pub last_run_id: String, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for PuzzleLeaderboardEntryRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `PuzzleLeaderboardEntryRow`. +/// +/// Provides typed access to columns for query building. +pub struct PuzzleLeaderboardEntryRowCols { + pub entry_id: __sdk::__query_builder::Col, + pub profile_id: __sdk::__query_builder::Col, + pub grid_size: __sdk::__query_builder::Col, + pub user_id: __sdk::__query_builder::Col, + pub nickname: __sdk::__query_builder::Col, + pub best_elapsed_ms: __sdk::__query_builder::Col, + pub last_run_id: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for PuzzleLeaderboardEntryRow { + type Cols = PuzzleLeaderboardEntryRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + PuzzleLeaderboardEntryRowCols { + entry_id: __sdk::__query_builder::Col::new(table_name, "entry_id"), + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + grid_size: __sdk::__query_builder::Col::new(table_name, "grid_size"), + user_id: __sdk::__query_builder::Col::new(table_name, "user_id"), + nickname: __sdk::__query_builder::Col::new(table_name, "nickname"), + best_elapsed_ms: __sdk::__query_builder::Col::new(table_name, "best_elapsed_ms"), + last_run_id: __sdk::__query_builder::Col::new(table_name, "last_run_id"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `PuzzleLeaderboardEntryRow`. +/// +/// Provides typed access to indexed columns for query building. +pub struct PuzzleLeaderboardEntryRowIxCols { + pub entry_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for PuzzleLeaderboardEntryRow { + type IxCols = PuzzleLeaderboardEntryRowIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + PuzzleLeaderboardEntryRowIxCols { + entry_id: __sdk::__query_builder::IxCol::new(table_name, "entry_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for PuzzleLeaderboardEntryRow {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_leaderboard_submit_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_leaderboard_submit_input_type.rs new file mode 100644 index 00000000..daad92b8 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_leaderboard_submit_input_type.rs @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleLeaderboardSubmitInput { + pub run_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub grid_size: u32, + pub elapsed_ms: u64, + pub nickname: String, + pub submitted_at_micros: i64, +} + +impl __sdk::InModule for PuzzleLeaderboardSubmitInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/submit_puzzle_leaderboard_entry_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/submit_puzzle_leaderboard_entry_procedure.rs new file mode 100644 index 00000000..7df24564 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/submit_puzzle_leaderboard_entry_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::puzzle_leaderboard_submit_input_type::PuzzleLeaderboardSubmitInput; +use super::puzzle_run_procedure_result_type::PuzzleRunProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct SubmitPuzzleLeaderboardEntryArgs { + pub input: PuzzleLeaderboardSubmitInput, +} + +impl __sdk::InModule for SubmitPuzzleLeaderboardEntryArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `submit_puzzle_leaderboard_entry`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait submit_puzzle_leaderboard_entry { + fn submit_puzzle_leaderboard_entry(&self, input: PuzzleLeaderboardSubmitInput) { + self.submit_puzzle_leaderboard_entry_then(input, |_, _| {}); + } + + fn submit_puzzle_leaderboard_entry_then( + &self, + input: PuzzleLeaderboardSubmitInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl submit_puzzle_leaderboard_entry for super::RemoteProcedures { + fn submit_puzzle_leaderboard_entry_then( + &self, + input: PuzzleLeaderboardSubmitInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PuzzleRunProcedureResult>( + "submit_puzzle_leaderboard_entry", + SubmitPuzzleLeaderboardEntryArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/puzzle.rs b/server-rs/crates/spacetime-client/src/puzzle.rs index 4eaad1d8..c3c09287 100644 --- a/server-rs/crates/spacetime-client/src/puzzle.rs +++ b/server-rs/crates/spacetime-client/src/puzzle.rs @@ -462,4 +462,32 @@ impl SpacetimeClient { }) .await } + + pub async fn submit_puzzle_leaderboard_entry( + &self, + input: PuzzleLeaderboardSubmitRecordInput, + ) -> Result { + let procedure_input = PuzzleLeaderboardSubmitInput { + run_id: input.run_id, + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + grid_size: input.grid_size, + elapsed_ms: input.elapsed_ms, + nickname: input.nickname, + submitted_at_micros: input.submitted_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().submit_puzzle_leaderboard_entry_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_run_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } } diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index 6ee8fc83..a1087aa0 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -4,14 +4,14 @@ use module_puzzle::{ PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot, PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput, PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft, - PuzzleRunDragInput, PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunProcedureResult, - PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, - PuzzleSelectCoverImageInput, PuzzleWorkDeleteInput, PuzzleWorkGetInput, - PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkUpsertInput, PuzzleWorksListInput, - PuzzleWorksProcedureResult, apply_publish_overrides_to_draft, apply_selected_candidate, - build_result_preview, compile_result_draft, create_work_profile, infer_anchor_pack, - normalize_theme_tags, publish_work_profile, resolve_puzzle_grid_size, select_next_profile, - start_run, swap_pieces, + PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, PuzzleRunDragInput, PuzzleRunGetInput, + PuzzleRunNextLevelInput, PuzzleRunProcedureResult, PuzzleRunSnapshot, PuzzleRunStartInput, + PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, + PuzzleWorkDeleteInput, PuzzleWorkGetInput, PuzzleWorkProcedureResult, PuzzleWorkProfile, + PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult, + apply_publish_overrides_to_draft, apply_selected_candidate, build_result_preview, + compile_result_draft, create_work_profile, infer_anchor_pack, normalize_theme_tags, + publish_work_profile, resolve_puzzle_grid_size, select_next_profile, start_run, swap_pieces, }; use serde_json::from_str as json_from_str; use serde_json::to_string as json_to_string; @@ -102,6 +102,25 @@ pub struct PuzzleRuntimeRunRow { updated_at: Timestamp, } +/// 拼图关卡真实成绩表。 +/// 每个用户在同一作品同一网格规格下只保留一条最佳成绩,用于结算弹窗排行榜。 +#[spacetimedb::table( + accessor = puzzle_leaderboard_entry, + index(accessor = by_puzzle_leaderboard_profile_grid, btree(columns = [profile_id, grid_size])), + index(accessor = by_puzzle_leaderboard_user_profile_grid, btree(columns = [user_id, profile_id, grid_size])) +)] +pub struct PuzzleLeaderboardEntryRow { + #[primary_key] + entry_id: String, + profile_id: String, + grid_size: u32, + user_id: String, + nickname: String, + best_elapsed_ms: u64, + last_run_id: String, + updated_at: Timestamp, +} + #[spacetimedb::procedure] pub fn create_puzzle_agent_session( ctx: &mut ProcedureContext, @@ -460,6 +479,25 @@ pub fn advance_puzzle_next_level( } } +#[spacetimedb::procedure] +pub fn submit_puzzle_leaderboard_entry( + ctx: &mut ProcedureContext, + input: PuzzleLeaderboardSubmitInput, +) -> PuzzleRunProcedureResult { + match ctx.try_with_tx(|tx| submit_puzzle_leaderboard_entry_tx(tx, input.clone())) { + Ok(run) => PuzzleRunProcedureResult { + ok: true, + run_json: Some(serialize_json(&run)), + error_message: None, + }, + Err(message) => PuzzleRunProcedureResult { + ok: false, + run_json: None, + error_message: Some(message), + }, + } +} + fn create_puzzle_agent_session_tx( ctx: &TxContext, input: PuzzleAgentSessionCreateInput, @@ -1017,6 +1055,15 @@ fn start_puzzle_run_tx( let entry_profile = build_puzzle_work_profile_from_row(&entry_profile_row)?; let mut run = start_run(input.run_id.clone(), &entry_profile, 0).map_err(|error| error.to_string())?; + let current_grid_size = run.current_grid_size; + let current_profile_id = entry_profile.profile_id.clone(); + hydrate_puzzle_leaderboard_entries( + ctx, + &mut run, + &input.owner_user_id, + current_profile_id.as_str(), + current_grid_size, + ); run.recommended_next_profile_id = select_next_profile( &entry_profile, &run.played_profile_ids, @@ -1034,7 +1081,21 @@ fn get_puzzle_run_tx( input: PuzzleRunGetInput, ) -> Result { let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?; - deserialize_run(&row.snapshot_json) + let mut run = deserialize_run(&row.snapshot_json)?; + if let Some((profile_id, grid_size)) = run + .current_level + .as_ref() + .map(|level| (level.profile_id.clone(), level.grid_size)) + { + hydrate_puzzle_leaderboard_entries( + ctx, + &mut run, + &input.owner_user_id, + &profile_id, + grid_size, + ); + } + Ok(run) } fn swap_puzzle_pieces_tx( @@ -1098,6 +1159,15 @@ fn advance_puzzle_next_level_tx( .clone(); let mut next_run = module_puzzle::advance_next_level(¤t_run, &next_profile) .map_err(|error| error.to_string())?; + let next_grid_size = next_run.current_grid_size; + let next_profile_id = next_profile.profile_id.clone(); + hydrate_puzzle_leaderboard_entries( + ctx, + &mut next_run, + &input.owner_user_id, + &next_profile_id, + next_grid_size, + ); next_run.recommended_next_profile_id = select_next_profile(&next_profile, &next_run.played_profile_ids, &candidates) .map(|value| value.profile_id.clone()); @@ -1114,6 +1184,58 @@ fn advance_puzzle_next_level_tx( Ok(next_run) } +fn submit_puzzle_leaderboard_entry_tx( + ctx: &TxContext, + input: PuzzleLeaderboardSubmitInput, +) -> Result { + let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?; + let mut run = deserialize_run(&row.snapshot_json)?; + let current_level = run + .current_level + .as_ref() + .ok_or_else(|| "拼图关卡不存在".to_string())?; + if current_level.status != PuzzleRuntimeLevelStatus::Cleared { + return Err("当前关卡尚未通关".to_string()); + } + if current_level.profile_id != input.profile_id { + return Err("提交成绩的拼图作品与当前关卡不匹配".to_string()); + } + if current_level.grid_size != input.grid_size { + return Err("提交成绩的网格规格与当前关卡不匹配".to_string()); + } + + let nickname = input.nickname.trim(); + if nickname.is_empty() { + return Err("排行榜昵称不能为空".to_string()); + } + + upsert_puzzle_leaderboard_entry( + ctx, + &input.owner_user_id, + &input.profile_id, + input.grid_size, + nickname, + input.elapsed_ms.max(1_000), + &input.run_id, + input.submitted_at_micros, + ); + + let leaderboard_entries = list_puzzle_leaderboard_entries( + ctx, + &input.profile_id, + input.grid_size, + &input.owner_user_id, + 10, + ); + if let Some(level) = run.current_level.as_mut() { + level.elapsed_ms = Some(input.elapsed_ms.max(1_000)); + level.leaderboard_entries = leaderboard_entries.clone(); + } + run.leaderboard_entries = leaderboard_entries; + replace_puzzle_runtime_run(ctx, &row, &run, input.submitted_at_micros); + Ok(run) +} + fn build_puzzle_agent_session_snapshot( ctx: &TxContext, row: &PuzzleAgentSessionRow, @@ -1536,6 +1658,116 @@ fn refresh_next_profile_recommendation( Ok(()) } +fn hydrate_puzzle_leaderboard_entries( + ctx: &TxContext, + run: &mut PuzzleRunSnapshot, + current_user_id: &str, + profile_id: &str, + grid_size: u32, +) { + let leaderboard_entries = + list_puzzle_leaderboard_entries(ctx, profile_id, grid_size, current_user_id, 10); + run.leaderboard_entries = leaderboard_entries.clone(); + if let Some(level) = run.current_level.as_mut() { + level.leaderboard_entries = leaderboard_entries; + } +} + +fn build_puzzle_leaderboard_entry_id(user_id: &str, profile_id: &str, grid_size: u32) -> String { + format!("puzzle-leaderboard-{user_id}-{profile_id}-{grid_size}") +} + +fn upsert_puzzle_leaderboard_entry( + ctx: &TxContext, + user_id: &str, + profile_id: &str, + grid_size: u32, + nickname: &str, + elapsed_ms: u64, + run_id: &str, + updated_at_micros: i64, +) { + let entry_id = build_puzzle_leaderboard_entry_id(user_id, profile_id, grid_size); + let updated_at = Timestamp::from_micros_since_unix_epoch(updated_at_micros); + if let Some(existing) = ctx + .db + .puzzle_leaderboard_entry() + .entry_id() + .find(&entry_id) + { + let should_replace = elapsed_ms < existing.best_elapsed_ms + || (elapsed_ms == existing.best_elapsed_ms + && updated_at.to_micros_since_unix_epoch() + < existing.updated_at.to_micros_since_unix_epoch()); + let next_row = PuzzleLeaderboardEntryRow { + entry_id: existing.entry_id.clone(), + profile_id: existing.profile_id.clone(), + grid_size: existing.grid_size, + user_id: existing.user_id.clone(), + nickname: nickname.to_string(), + best_elapsed_ms: if should_replace { + elapsed_ms + } else { + existing.best_elapsed_ms + }, + last_run_id: if should_replace { + run_id.to_string() + } else { + existing.last_run_id.clone() + }, + updated_at, + }; + ctx.db + .puzzle_leaderboard_entry() + .entry_id() + .delete(&existing.entry_id); + ctx.db.puzzle_leaderboard_entry().insert(next_row); + return; + } + + ctx.db.puzzle_leaderboard_entry().insert(PuzzleLeaderboardEntryRow { + entry_id, + profile_id: profile_id.to_string(), + grid_size, + user_id: user_id.to_string(), + nickname: nickname.to_string(), + best_elapsed_ms: elapsed_ms, + last_run_id: run_id.to_string(), + updated_at, + }); +} + +fn list_puzzle_leaderboard_entries( + ctx: &TxContext, + profile_id: &str, + grid_size: u32, + current_user_id: &str, + limit: usize, +) -> Vec { + let mut rows = ctx + .db + .puzzle_leaderboard_entry() + .iter() + .filter(|row| row.profile_id == profile_id && row.grid_size == grid_size) + .collect::>(); + rows.sort_by(|left, right| { + left.best_elapsed_ms + .cmp(&right.best_elapsed_ms) + .then_with(|| left.updated_at.cmp(&right.updated_at)) + .then_with(|| left.user_id.cmp(&right.user_id)) + }); + rows.into_iter() + .take(limit) + .enumerate() + .map(|(index, row)| PuzzleLeaderboardEntry { + rank: index as u32 + 1, + nickname: row.nickname, + elapsed_ms: row.best_elapsed_ms, + is_current_player: row.user_id == current_user_id, + }) + .collect() +} + fn serialize_json(value: &T) -> String { json_to_string(value).unwrap_or_else(|_| "{}".to_string()) } @@ -1568,6 +1800,7 @@ mod tests { use super::*; use module_puzzle::{ build_generated_candidates, empty_anchor_pack, recommendation_score, tag_similarity_score, + PuzzleLeaderboardEntry, }; #[test] @@ -1582,6 +1815,7 @@ mod tests { previous_level_tags: vec!["蒸汽城市".to_string()], current_level: None, recommended_next_profile_id: None, + leaderboard_entries: Vec::new(), }; let serialized = serialize_json(&snapshot); let parsed = deserialize_run(&serialized).expect("run json should parse"); @@ -1681,4 +1915,31 @@ mod tests { > tag_similarity_score(&left.theme_tags, &right.theme_tags) ); } + + #[test] + fn puzzle_leaderboard_entries_sort_by_elapsed_time() { + let mut entries = vec![ + PuzzleLeaderboardEntry { + rank: 0, + nickname: "玩家 B".to_string(), + elapsed_ms: 5200, + is_current_player: false, + }, + PuzzleLeaderboardEntry { + rank: 0, + nickname: "玩家 A".to_string(), + elapsed_ms: 3100, + is_current_player: true, + }, + ]; + entries.sort_by(|left, right| left.elapsed_ms.cmp(&right.elapsed_ms)); + for (index, entry) in entries.iter_mut().enumerate() { + entry.rank = index as u32 + 1; + } + + assert_eq!(entries[0].nickname, "玩家 A"); + assert_eq!(entries[0].rank, 1); + assert_eq!(entries[1].nickname, "玩家 B"); + assert_eq!(entries[1].rank, 2); + } } diff --git a/src/App.tsx b/src/App.tsx index 83e0285b..08428da2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,10 @@ import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react' import { useAuthUi } from './components/auth/AuthUiContext'; import { PlatformEntryFlowShell } from './components/platform-entry/PlatformEntryFlowShell'; -import type { SelectionStage } from './components/platform-entry/platformEntryTypes'; +import type { + CustomWorldRuntimeLaunchOptions, + SelectionStage, +} from './components/platform-entry/platformEntryTypes'; import type { HydratedSavedGameSnapshot } from './persistence/runtimeSnapshotTypes'; import { APP_RUNTIME_ROUTES, @@ -40,6 +43,8 @@ export default function App() { const [selectionStage, setRawSelectionStage] = useState(() => resolveSelectionStageFromPath(window.location.pathname), ); + const [runtimeReturnStage, setRuntimeReturnStage] = + useState('platform'); const setSelectionStage = useCallback((stage: SelectionStage) => { setRawSelectionStage(stage); @@ -86,10 +91,17 @@ export default function App() { ); const handleCustomWorldSelect = useCallback( - (customWorldProfile: CustomWorldProfile) => { + ( + customWorldProfile: CustomWorldProfile, + options?: CustomWorldRuntimeLaunchOptions, + ) => { + // 中文注释:作品测试需要在结束测试后精确返回启动它的结果页; + // 正式进入世界仍保持既有平台首页返回语义。 + setRuntimeReturnStage(options?.returnStage ?? 'platform'); createRuntimeIntent({ kind: 'custom-world', profile: customWorldProfile, + mode: options?.mode ?? 'play', }); }, [createRuntimeIntent], @@ -102,7 +114,13 @@ export default function App() { if (isRuntimeActive) { return ( - + { + setIsRuntimeActive(false); + setSelectionStage(runtimeReturnStage); + }} + /> ); } diff --git a/src/PuzzlePlaygroundApp.tsx b/src/PuzzlePlaygroundApp.tsx index e4798653..57bc8aca 100644 --- a/src/PuzzlePlaygroundApp.tsx +++ b/src/PuzzlePlaygroundApp.tsx @@ -80,6 +80,8 @@ export default function PuzzlePlaygroundApp() { return ( void; }) { const gameShellProps = useRpgRuntimeSession(); const handledIntentTokenRef = useRef(null); @@ -32,14 +35,16 @@ export function RpgRuntimeApp({ handledIntentTokenRef.current = initialIntent.token; if (initialIntent.kind === 'custom-world') { - gameShellProps.entry.handleCustomWorldSelect(initialIntent.profile); + gameShellProps.entry.handleCustomWorldSelect(initialIntent.profile, { + mode: initialIntent.mode ?? 'play', + }); return; } gameShellProps.entry.handleContinueGame(initialIntent.snapshot); }, [gameShellProps.entry, initialIntent]); - return ; + return ; } export default RpgRuntimeApp; diff --git a/src/components/game-canvas/GameCanvasEntityLayer.test.tsx b/src/components/game-canvas/GameCanvasEntityLayer.test.tsx index 575ce3ce..fae85db4 100644 --- a/src/components/game-canvas/GameCanvasEntityLayer.test.tsx +++ b/src/components/game-canvas/GameCanvasEntityLayer.test.tsx @@ -187,6 +187,55 @@ describe('GameCanvasEntityLayer', () => { expect(html).not.toContain('好感度变化 +3'); }); + it('keeps hostile combat hp bar visible during post-hit afterimage frames', () => { + const html = renderToStaticMarkup( + '70%'} + groundBottom="18%" + stageLiftPx={68} + encounter={null} + sideAnchor="15%" + cameraAnchorX={0} + monsterAnchorMeters={3.2} + playerX={0} + />, + ); + + expect(html).toContain('from-rose-500 to-red-400'); + }); + it('renders scene act back-row encounters alongside the primary encounter', () => { const html = renderToStaticMarkup( ([]); const previousCombatSamplesRef = useRef | null>(null); const combatFeedbackSequenceRef = useRef(0); + const hasCombatAfterimage = useMemo( + () => + combatFeedbackEvents.length > 0 || + sceneCombatants.some( + (hostileNpc) => + hostileNpc.hp < hostileNpc.maxHp || + hostileNpc.animation === 'attack' || + hostileNpc.animation === 'die', + ), + [combatFeedbackEvents.length, sceneCombatants], + ); + const shouldRenderCombatPresentation = inBattle || hasCombatAfterimage; const shouldRenderPeacefulEncounter = Boolean(encounter) && (!inBattle || sceneCombatants.length === 0); const combatHealthSamples = useMemo( () => { - if (!inBattle) return []; + if (!shouldRenderCombatPresentation) return []; return [ {key: 'player', kind: 'player', hp: playerHp}, @@ -242,7 +255,7 @@ export function GameCanvasEntityLayer({ })), ]; }, - [companions, inBattle, playerHp, sceneCombatants], + [companions, playerHp, sceneCombatants, shouldRenderCombatPresentation], ); const combatFeedbackByTarget = useMemo(() => { const feedbackByTarget = new Map(); @@ -259,7 +272,7 @@ export function GameCanvasEntityLayer({ }; useEffect(() => { - if (!inBattle) { + if (!shouldRenderCombatPresentation) { previousCombatSamplesRef.current = null; setCombatFeedbackEvents([]); return; @@ -283,12 +296,14 @@ export function GameCanvasEntityLayer({ previousCombatSamplesRef.current = new Map( combatHealthSamples.map(sample => [sample.key, sample]), ); - }, [combatHealthSamples, inBattle]); + }, [combatHealthSamples, shouldRenderCombatPresentation]); return ( <> {companions.map(companion => { - const slotOffset = getCompanionSlotOffset(companion.slot); + const slotOffset = inBattle + ? getBattleCompanionSlotOffset(companion.slot) + : getCompanionSlotOffset(companion.slot); const feedbackTargetKey = `companion:${companion.npcId}`; const feedbackEvents = combatFeedbackByTarget.get(feedbackTargetKey) ?? []; const companionFacing = companion.facing ?? 'right'; @@ -314,7 +329,7 @@ export function GameCanvasEntityLayer({ style={{ left: companionAnchorLeft, bottom: companionAnchorBottom, - zIndex: getSceneEntityZIndex(playerBottomOffsetPx + slotOffset.bottom), + zIndex: getSceneEntityZIndex(playerBottomOffsetPx + slotOffset.bottom) + (inBattle ? 1 : 0), transition: 'left 260ms linear, bottom 180ms ease', }} > @@ -336,7 +351,7 @@ export function GameCanvasEntityLayer({ className="relative flex w-28 flex-col items-center" > - {inBattle && ( + {shouldRenderCombatPresentation && (
- {inBattle && ( + {shouldRenderCombatPresentation && (
- {inBattle && ( + {shouldRenderCombatPresentation && (
(null); const [puzzleRuntimeReturnStage, setPuzzleRuntimeReturnStage] = useState('puzzle-gallery-detail'); + const [isPuzzleLeaderboardBusy, setIsPuzzleLeaderboardBusy] = useState(false); + const submittedPuzzleLeaderboardKeysRef = useRef(new Set()); const [puzzleRun, setPuzzleRun] = useState(null); const [isPuzzleLoadingLibrary, setIsPuzzleLoadingLibrary] = useState(false); const [puzzleGenerationState, setPuzzleGenerationState] = @@ -1285,6 +1293,50 @@ export function PlatformEntryFlowShellImpl({ [isPuzzleBusy, puzzleRun], ); + useEffect(() => { + const currentLevel = puzzleRun?.currentLevel ?? null; + if (!puzzleRun || !currentLevel || currentLevel.status !== 'cleared') { + return; + } + if (currentLevel.elapsedMs === null) { + return; + } + if ((currentLevel.leaderboardEntries ?? []).length > 0) { + return; + } + + const submitKey = `${puzzleRun.runId}:${currentLevel.profileId}:${currentLevel.gridSize}:${currentLevel.elapsedMs}`; + if (submittedPuzzleLeaderboardKeysRef.current.has(submitKey)) { + return; + } + submittedPuzzleLeaderboardKeysRef.current.add(submitKey); + setIsPuzzleLeaderboardBusy(true); + + const payload: SubmitPuzzleLeaderboardRequest = { + profileId: currentLevel.profileId, + gridSize: currentLevel.gridSize, + elapsedMs: currentLevel.elapsedMs, + nickname: authUi?.user?.displayName?.trim() || '玩家', + }; + + void submitPuzzleLeaderboard(puzzleRun.runId, payload) + .then(({ run }) => { + setPuzzleRun(run); + }) + .catch((error) => { + submittedPuzzleLeaderboardKeysRef.current.delete(submitKey); + setPuzzleError(resolvePuzzleErrorMessage(error, '提交拼图排行榜失败。')); + }) + .finally(() => { + setIsPuzzleLeaderboardBusy(false); + }); + }, [ + authUi?.user?.displayName, + puzzleRun, + resolvePuzzleErrorMessage, + setPuzzleError, + ]); + const advancePuzzleLevel = useCallback(async () => { if (!puzzleRun || isPuzzleBusy) { return; @@ -2467,13 +2519,17 @@ export function PlatformEntryFlowShellImpl({ } > - { - setSelectionStage(puzzleRuntimeReturnStage); - }} + { + setSelectionStage(puzzleRuntimeReturnStage); + }} onSwapPieces={(payload) => { void swapPuzzlePiecesInRun(payload); }} diff --git a/src/components/platform-entry/platformEntryTypes.ts b/src/components/platform-entry/platformEntryTypes.ts index 0b19b98d..b6905707 100644 --- a/src/components/platform-entry/platformEntryTypes.ts +++ b/src/components/platform-entry/platformEntryTypes.ts @@ -4,6 +4,13 @@ import type { import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import type { CustomWorldProfile } from '../../types'; +export type CustomWorldRuntimeLaunchMode = 'play' | 'test'; + +export type CustomWorldRuntimeLaunchOptions = { + mode?: CustomWorldRuntimeLaunchMode; + returnStage?: SelectionStage | null; +}; + export type SelectionStage = | 'platform' | 'detail' @@ -38,5 +45,8 @@ export type PlatformEntryFlowShellProps = { savedSnapshot: HydratedSavedGameSnapshot | null; handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void; handleStartNewGame: () => void; - handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void; + handleCustomWorldSelect: ( + customWorldProfile: CustomWorldProfile, + options?: CustomWorldRuntimeLaunchOptions, + ) => void; }; diff --git a/src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx b/src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx index 17a95f8a..b76db29a 100644 --- a/src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx +++ b/src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx @@ -1,9 +1,10 @@ /* @vitest-environment jsdom */ -import { fireEvent, render, screen, within } from '@testing-library/react'; +import { act, fireEvent, render, screen, within } from '@testing-library/react'; import { expect, test, vi } from 'vitest'; import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession'; +import { AuthUiContext } from '../auth/AuthUiContext'; import { PuzzleRuntimeShell } from './PuzzleRuntimeShell'; vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({ @@ -18,6 +19,34 @@ vi.mock('../ResolvedAssetImage', () => ({ ResolvedAssetImage: () => null, })); +function createAuthValue() { + return { + user: null, + canAccessProtectedData: false, + openLoginModal: () => {}, + requireAuth: (action: () => void) => action(), + openSettingsModal: () => {}, + openAccountModal: () => {}, + logout: async () => {}, + musicVolume: 0.42, + setMusicVolume: vi.fn(), + platformTheme: 'light' as const, + setPlatformTheme: vi.fn(), + isHydratingSettings: false, + isPersistingSettings: false, + settingsError: null, + }; +} + +function renderPuzzleRuntime( + ui: React.ReactElement, + authValue = createAuthValue(), +) { + return render( + {ui}, + ); +} + const clearedRun: PuzzleRunSnapshot = { runId: 'run-1', entryProfileId: 'profile-1', @@ -85,9 +114,10 @@ const clearedRun: PuzzleRunSnapshot = { }; test('通关后显示结算弹窗、排行榜和下一关按钮', () => { + vi.useFakeTimers(); const onAdvanceNextLevel = vi.fn(); - render( + renderPuzzleRuntime( { />, ); + expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull(); + expect(screen.getByTestId('puzzle-clear-flash')).toBeTruthy(); + + act(() => { + vi.advanceTimersByTime(1_400); + }); + const dialog = screen.getByRole('dialog', { name: '通关完成' }); expect(within(dialog).getAllByText('0:12.34').length).toBeGreaterThan(0); expect(within(dialog).getByText('排行榜')).toBeTruthy(); @@ -106,10 +143,13 @@ test('通关后显示结算弹窗、排行榜和下一关按钮', () => { fireEvent.click(within(dialog).getByRole('button', { name: '下一关' })); expect(onAdvanceNextLevel).toHaveBeenCalledTimes(1); + vi.useRealTimers(); }); test('关闭通关弹窗后保留底部下一关入口', () => { - render( + vi.useFakeTimers(); + + renderPuzzleRuntime( { />, ); + act(() => { + vi.advanceTimersByTime(1_400); + }); fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' })); expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull(); expect(screen.getByRole('button', { name: /下一关/u })).toBeTruthy(); + vi.useRealTimers(); +}); + +test('右上角设置按钮打开拼图设置并支持音量调节', () => { + const authValue = createAuthValue(); + + renderPuzzleRuntime( + , + authValue, + ); + + fireEvent.click(screen.getByRole('button', { name: '打开拼图设置' })); + + const dialog = screen.getByRole('dialog', { name: '拼图设置' }); + const slider = within(dialog).getByRole('slider', { name: '拼图音乐音量' }); + fireEvent.change(slider, { target: { value: '77' } }); + + expect(within(dialog).getByText('第 1 关')).toBeTruthy(); + expect(authValue.setMusicVolume).toHaveBeenCalledWith(0.77); +}); + +test('合并块按实际拼块外轮廓描边', () => { + const mergedRun: PuzzleRunSnapshot = { + ...clearedRun, + currentLevel: { + ...clearedRun.currentLevel!, + status: 'playing', + board: { + ...clearedRun.currentLevel!.board, + allTilesResolved: false, + mergedGroups: [ + { + groupId: 'group-l', + pieceIds: ['piece-0', 'piece-1', 'piece-3'], + occupiedCells: [ + { row: 0, col: 0 }, + { row: 0, col: 1 }, + { row: 1, col: 0 }, + ], + }, + ], + pieces: clearedRun.currentLevel!.board.pieces.map((piece) => + ['piece-0', 'piece-1', 'piece-3'].includes(piece.pieceId) + ? { ...piece, mergedGroupId: 'group-l' } + : piece, + ), + }, + }, + }; + + const { container } = renderPuzzleRuntime( + , + ); + const outlinedPieces = container.querySelectorAll( + '[data-merged-piece-outline="true"]', + ); + + expect(outlinedPieces).toHaveLength(3); + expect(container.querySelector('.ring-2.ring-emerald-100\\/58')).toBeNull(); + expect(outlinedPieces[0]?.className).toContain('border-r-0'); + expect(outlinedPieces[0]?.className).toContain('border-b-0'); + expect(outlinedPieces[0]?.className).toContain('rounded-tl-[0.85rem]'); + expect(outlinedPieces[0]?.className).toContain('rounded-tr-none'); + expect(outlinedPieces[0]?.className).toContain('rounded-bl-none'); + expect(outlinedPieces[1]?.className).toContain('border-l-0'); + expect(outlinedPieces[1]?.className).toContain('rounded-tr-[0.85rem]'); + expect(outlinedPieces[2]?.className).toContain('border-t-0'); + expect(outlinedPieces[2]?.className).toContain('rounded-bl-[0.85rem]'); }); diff --git a/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx b/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx index c1593ac5..ec536065 100644 --- a/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx +++ b/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx @@ -1,4 +1,4 @@ -import { ArrowLeft, ArrowRight, Clock, Loader2, Trophy, X } from 'lucide-react'; +import { ArrowLeft, ArrowRight, Clock, Loader2, Trophy } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import type { @@ -9,7 +9,10 @@ import type { PuzzleRunSnapshot, SwapPuzzlePiecesRequest, } from '../../../packages/shared/src/contracts/puzzleRuntimeSession'; +import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets'; import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; +import { useAuthUi } from '../auth/AuthUiContext'; +import { PixelIcon } from '../PixelIcon'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; type PuzzleRuntimeShellProps = { @@ -29,7 +32,6 @@ type PuzzleBoardPieceViewModel = { correctRow: number; correctCol: number; mergedGroupId: string | null; - label: string; }; type PuzzleMergedGroupViewModel = { @@ -59,9 +61,41 @@ function buildBoardCells(board: PuzzleBoardSnapshot) { })); } -function buildPieceLabel(pieceId: string) { - const fallback = pieceId.slice(-2).toUpperCase(); - return fallback || '块'; +function buildLocalCellKey(row: number, col: number) { + return `${row}:${col}`; +} + +function resolveMergedPieceOutlineClass( + group: PuzzleMergedGroupViewModel, + piece: PuzzleMergedGroupViewModel['pieces'][number], +) { + const groupCellKeys = new Set( + group.pieces.map((groupPiece) => + buildLocalCellKey(groupPiece.localRow, groupPiece.localCol), + ), + ); + const hasTopEdge = !groupCellKeys.has( + buildLocalCellKey(piece.localRow - 1, piece.localCol), + ); + const hasRightEdge = !groupCellKeys.has( + buildLocalCellKey(piece.localRow, piece.localCol + 1), + ); + const hasBottomEdge = !groupCellKeys.has( + buildLocalCellKey(piece.localRow + 1, piece.localCol), + ); + const hasLeftEdge = !groupCellKeys.has( + buildLocalCellKey(piece.localRow, piece.localCol - 1), + ); + return [ + hasTopEdge ? 'border-t-2' : 'border-t-0', + hasRightEdge ? 'border-r-2' : 'border-r-0', + hasBottomEdge ? 'border-b-2' : 'border-b-0', + hasLeftEdge ? 'border-l-2' : 'border-l-0', + hasTopEdge && hasLeftEdge ? 'rounded-tl-[0.85rem]' : 'rounded-tl-none', + hasTopEdge && hasRightEdge ? 'rounded-tr-[0.85rem]' : 'rounded-tr-none', + hasBottomEdge && hasRightEdge ? 'rounded-br-[0.85rem]' : 'rounded-br-none', + hasBottomEdge && hasLeftEdge ? 'rounded-bl-[0.85rem]' : 'rounded-bl-none', + ].join(' '); } function buildMergedGroupViewModels( @@ -117,6 +151,10 @@ function formatElapsedMs(elapsedMs: number | null | undefined) { .padStart(2, '0')}`; } +const DEFAULT_PUZZLE_MUSIC_VOLUME = 0.6; +const PUZZLE_CLEAR_FLASH_DURATION_MS = 900; +const PUZZLE_CLEAR_DIALOG_DELAY_MS = 500; + /** * 拼图运行时壳层。 * 前端仅维护轻量选中态与拖拽目标,交换、合并、拆分与通关全部以后端快照为准。 @@ -130,7 +168,9 @@ export function PuzzleRuntimeShell({ onDragPiece, onAdvanceNextLevel, }: PuzzleRuntimeShellProps) { + const authUi = useAuthUi(); const [selectedPieceId, setSelectedPieceId] = useState(null); + const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false); const dragSessionRef = useRef<{ pieceId: string; pointerId: number; @@ -148,10 +188,21 @@ export function PuzzleRuntimeShell({ const dragOffsetRef = useRef<{ x: number; y: number } | null>(null); const pieceElementRefMap = useRef(new Map()); const groupElementRefMap = useRef(new Map()); - const [dismissedClearKey, setDismissedClearKey] = useState(null); + const [dismissedClearKey, setDismissedClearKey] = useState( + null, + ); + const [isClearFlashVisible, setIsClearFlashVisible] = useState(false); + const [isClearResultReady, setIsClearResultReady] = useState(false); + const clearPresentationKeyRef = useRef(null); + const clearPresentationTimeoutIdsRef = useRef([]); const boardRef = useRef(null); const currentLevel = run?.currentLevel ?? null; const board = currentLevel?.board ?? null; + const clearResultKey = currentLevel + ? `${run?.runId ?? 'run'}:${currentLevel.profileId}:${currentLevel.levelIndex}` + : null; + const musicVolume = authUi?.musicVolume ?? DEFAULT_PUZZLE_MUSIC_VOLUME; + const onMusicVolumeChange = authUi?.setMusicVolume ?? (() => {}); const { resolvedUrl: resolvedCoverImage } = useResolvedAssetReadUrl( currentLevel?.coverImageSrc ?? null, ); @@ -167,7 +218,6 @@ export function PuzzleRuntimeShell({ correctRow: piece.correctRow, correctCol: piece.correctCol, mergedGroupId: piece.mergedGroupId, - label: buildPieceLabel(piece.pieceId), })); }, [board]); @@ -206,7 +256,9 @@ export function PuzzleRuntimeShell({ return; } - const pieceElement = pieceElementRefMap.current.get(dragVisualTarget.pieceId); + const pieceElement = pieceElementRefMap.current.get( + dragVisualTarget.pieceId, + ); if (pieceElement) { pieceElement.style.transform = ''; pieceElement.style.willChange = ''; @@ -215,7 +267,9 @@ export function PuzzleRuntimeShell({ } if (dragVisualTarget.groupId) { - const groupElement = groupElementRefMap.current.get(dragVisualTarget.groupId); + const groupElement = groupElementRefMap.current.get( + dragVisualTarget.groupId, + ); if (groupElement) { groupElement.style.transform = ''; groupElement.style.willChange = ''; @@ -304,10 +358,66 @@ export function PuzzleRuntimeShell({ dragVisualFrameRef.current = window.requestAnimationFrame(flushDragVisual); }; - useEffect(() => () => { - cancelDragVisualFrame(); - resetDragVisualTarget(); - }, []); + useEffect( + () => () => { + cancelDragVisualFrame(); + resetDragVisualTarget(); + }, + [], + ); + + const clearPresentationTimeouts = () => { + for (const timeoutId of clearPresentationTimeoutIdsRef.current) { + window.clearTimeout(timeoutId); + } + clearPresentationTimeoutIdsRef.current = []; + }; + + useEffect( + () => () => { + clearPresentationTimeouts(); + }, + [], + ); + + useEffect(() => { + if (!currentLevel || !clearResultKey) { + clearPresentationKeyRef.current = null; + clearPresentationTimeouts(); + setIsClearFlashVisible(false); + setIsClearResultReady(false); + return; + } + + if (currentLevel.status !== 'cleared') { + clearPresentationKeyRef.current = null; + clearPresentationTimeouts(); + setIsClearFlashVisible(false); + setIsClearResultReady(false); + return; + } + + if ( + dismissedClearKey === clearResultKey || + clearPresentationKeyRef.current === clearResultKey + ) { + return; + } + + // 通关后先保留完整画面,再播放对角线闪光,最后延迟弹出结算弹窗。 + clearPresentationKeyRef.current = clearResultKey; + clearPresentationTimeouts(); + setIsClearFlashVisible(true); + setIsClearResultReady(false); + clearPresentationTimeoutIdsRef.current = [ + window.setTimeout(() => { + setIsClearFlashVisible(false); + }, PUZZLE_CLEAR_FLASH_DURATION_MS), + window.setTimeout(() => { + setIsClearResultReady(true); + }, PUZZLE_CLEAR_FLASH_DURATION_MS + PUZZLE_CLEAR_DIALOG_DELAY_MS), + ]; + }, [clearResultKey, currentLevel, dismissedClearKey]); if (!run || !currentLevel || !board) { return ( @@ -453,17 +563,18 @@ export function PuzzleRuntimeShell({ scheduleDragVisual(); }; - const statusLabel = - currentLevel.status === 'cleared' ? '已通关' : `${board.rows}x${board.cols}`; + const statusLabel = currentLevel.status === 'cleared' ? '已通关' : '进行中'; const nextAvailable = currentLevel.status === 'cleared' && Boolean(run.recommendedNextProfileId); - const clearResultKey = `${run.runId}:${currentLevel.profileId}:${currentLevel.levelIndex}`; + const levelLabel = `第 ${currentLevel.levelIndex} 关`; const leaderboardEntries = (currentLevel.leaderboardEntries ?? []).length > 0 ? currentLevel.leaderboardEntries : (run.leaderboardEntries ?? []); const isClearResultOpen = - currentLevel.status === 'cleared' && dismissedClearKey !== clearResultKey; + currentLevel.status === 'cleared' && + dismissedClearKey !== clearResultKey && + isClearResultReady; return (
@@ -478,26 +589,41 @@ export function PuzzleRuntimeShell({ ) : null}
-
- +
+
+ -
-
- PUZZLE -
-
- {currentLevel.levelName} -
-
- {currentLevel.authorDisplayName} · 第 {currentLevel.levelIndex} 关 ·{' '} - {statusLabel} +
+
+ {currentLevel.levelName} +
+
+ {currentLevel.authorDisplayName} +
+
+ {levelLabel} +
+ +
@@ -517,10 +643,7 @@ export function PuzzleRuntimeShell({ const isSelected = piece?.pieceId === selectedPieceId; return ( -
+
{ if (!piece) { @@ -542,7 +665,9 @@ export function PuzzleRuntimeShell({ : 'border-white/18 bg-white/12 text-white' : 'border-white/8 bg-black/18 text-white/20' } ${ - isMerged ? 'transition-colors' : 'transition-[background-color,border-color,box-shadow,opacity]' + isMerged + ? 'transition-colors' + : 'transition-[background-color,border-color,box-shadow,opacity]' }`} onPointerDown={(event) => { if (!piece || isMerged) { @@ -591,11 +716,6 @@ export function PuzzleRuntimeShell({
)}
- {!isMerged ? ( -
- {piece.label} -
- ) : null}
) : ( '' @@ -632,7 +752,11 @@ export function PuzzleRuntimeShell({ {group.pieces.map((piece) => (
))} -
))} @@ -717,6 +840,142 @@ export function PuzzleRuntimeShell({
+ {isClearFlashVisible ? ( +