1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-28 10:57:40 +08:00
parent bb4100fca4
commit a9febe7678
28 changed files with 1342 additions and 89 deletions

View File

@@ -210,3 +210,23 @@ npm.cmd run build
- 没有人手逐个点击整局游戏所有 function 的视觉回放,本轮重点是自动化测试、服务端测试、内容校验与 smoke 门禁。
因此,本审计可以说明“当前 function 系统的自动化测试层状况”,但不等于“所有视觉演出与在线模型联动都已人工验证完毕”。
## 8. 执行回填2026-04-28修复聊天任务领取入口
- 问题现象:
- NPC 聊天中的待领取任务,点击“查看任务”进入详情后,再点“领取任务”没有把面板切到正式已接任务状态,表现上像“无法领取”。
- 根因:
- `npcChatQuestOfferUi.acceptPendingOffer()` 会异步把 `npc_quest_accept` 发到服务端。
- 但 [`src/components/rpg-runtime-panels/RpgAdventurePanel.tsx`](../../src/components/rpg-runtime-panels/RpgAdventurePanel.tsx) 里“待领取任务详情弹层”的 `onAcceptPendingNpcQuestOffer` 只返回了 `questId`,没有把它写入共享的 `pendingAcceptedQuestId`
- 结果是本来负责等待 quest 真正进入 `quests` 后再统一收口面板状态的 `useEffect` 根本不会触发。
- 修复:
-`onAcceptPendingNpcQuestOffer` 中补写 `setPendingAcceptedQuestId(acceptedQuestId)`,让待领取任务详情弹层复用普通 `npc_quest_accept` 已有的异步收口链。
- 新增 [`src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx`](../../src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx),覆盖“查看任务 -> 领取任务 -> 服务端异步写回 quest log -> 面板切到正式任务状态”的回归路径。
- 本次回归验证:
- `src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx`
- `src/components/rpg-runtime-panels/RpgAdventurePanel.test.tsx`
- `src/hooks/rpg-runtime-story/npcEncounterActions.test.ts`
- `src/hooks/rpg-runtime-story/choiceActions.test.ts`
- `src/hooks/rpg-runtime-story/runtimeStoryCoordinator.test.ts`
- `src/hooks/rpg-runtime-story/sessionActions.test.ts`
-`57` 条测试通过。

View File

@@ -0,0 +1,48 @@
# RPG 复活后继续冒险链路修复记录 2026-04-28
## 问题现象
RPG 运行态里,角色在战斗死亡并复活后,面板会显示一个“继续前进”入口。
此前这一步只有 `story_continue_adventure` 控制项,没有同步挂出复活后首场景应该展示的 `deferredOptions`。因此玩家点击继续后,系统会把它当作一次普通剧情续推入口,而不是单纯展示“复活后的下一批可选动作”。
在带有场景章节主 NPC 的自定义世界里,这会让玩家看起来像是“刚复活就直接和对面主 NPC 聊天”,造成复活后第一拍体验被主 NPC 对话链抢走。
## 根因结论
根因不在 NPC 聊天函数本身,而在死亡复活链没有沿用 `story_continue_adventure -> deferredOptions` 的延迟展示协议。
- 战后胜利链已经使用 `story_continue_adventure + deferredOptions`
- 死亡复活链此前只保留了 `story_continue_adventure`
- 结果是“继续前进”点击后无法走纯展示分支,只能落回普通续推
## 本次修复
本次在 `src/hooks/rpg-runtime-story/postBattleFlow.ts` 与复活调用链中补齐:
- `buildDeathStory(...)` 现在支持在复活文案上同步挂出 `deferredOptions`
- 这些 `deferredOptions` 复用 `buildFallbackStoryForState(...)` 产出的复活后可用入口
- 点击“继续前进”时只揭示这些入口,不再额外触发一次普通剧情推演
## 当前行为规则
角色死亡复活后:
1. 先显示“你在战斗中倒下,随后重新醒来”
2. 面板只展示一个 `story_continue_adventure`
3. 点击后展示复活后首场景已有的后续动作
4. 不应直接自动推进到主 NPC 聊天执行态
## 回归覆盖
已补两条测试:
- `src/hooks/rpg-runtime-story/choiceActions.test.ts`
- 覆盖本地战斗失败后的复活链
- `src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts`
- 覆盖服务端战斗失败后的复活链
两条测试都要求复活文案返回:
- `story_continue_adventure`
- 非空 `deferredOptions`

View File

@@ -0,0 +1,65 @@
# 大鱼作品列表 `items_json` 兼容修复 2026-04-28
## 背景
大鱼吃小鱼作品列表在 `server-rs` 链路里由 SpacetimeDB procedure 返回 `items_json`,再由 `spacetime-client` 反序列化成 `BigFishWorkSummaryRecord`
本轮出现的线上报错为:
```text
big fish works items_json 非法: missing field `owner_user_id`
```
这说明:
1. 客户端 record 结构已经把 `owner_user_id` 当成必填字段。
2. 某些历史 `items_json` 仍是旧字段集,没有带上 `owner_user_id`
3. 一旦直接按新结构强反序列化,整个 works 列表接口都会失败,而不是只丢失单字段。
## 根因判断
这不是前端展示问题,也不是 Axum 路由参数问题,而是:
1. SpacetimeDB procedure 输出 JSON 的结构发生过升级。
2. `spacetime-client` 映射层没有为旧 JSON 做向后兼容。
3. 作品列表是聚合读模型,一条旧记录就可能拖垮整批列表读取。
## 本次落地口径
本次只做最小风险修复,不改前端契约,不改现有表结构:
1. `server-rs/crates/spacetime-client/src/mapper.rs`
- 大鱼 works 反序列化改为先读兼容结构。
- `owner_user_id` 改为兼容层里的可缺省字段。
2. 私有 works 列表
- 若旧 JSON 缺 `owner_user_id`,用当前查询的 `owner_user_id` 回填。
- 这样不会破坏创作中心里依赖 `ownerUserId` 的恢复、归属和 key 逻辑。
3. 公开 gallery 列表
- 若旧 JSON 缺 `owner_user_id`,先回填空串,保证列表接口不再整体失败。
- 后续若公开画廊明确需要作者归属真相,再补模块端回填或数据修复。
4. 新增定向测试
- 覆盖“私有 works 旧 JSON 缺字段仍可回填”
- 覆盖“公开 works 旧 JSON 缺字段不再报错”
## 经验结论
以后只要是 `procedure -> items_json -> client record` 这类链路,都要默认遵守下面两条:
1. 聚合读模型的 JSON 字段升级不能假设全量历史数据同步完成。
2. `spacetime-client` 的映射层必须承担兼容旧 JSON 的责任,不能把结构升级风险直接抛给上层接口。
尤其是 works / gallery / library 这种平台入口级接口:
1. 允许单字段降级
2. 不允许整批列表因单字段缺失而 500 / 400
## 后续建议
如果后面继续演进大鱼 works 字段,推荐优先遵守:
1. 新增字段优先 `Option` 或兼容层解析。
2. 聚合 JSON 升级时同步补回归测试。
3. 如果字段已经进入前端关键逻辑,再决定是在模块端回填、客户端兜底,还是补历史数据迁移。

View File

@@ -31,3 +31,4 @@
- [RPG_PUBLISH_GALLERY_REFRESH_FIX_2026-04-25.md](./RPG_PUBLISH_GALLERY_REFRESH_FIX_2026-04-25.md):记录 RPG 发布后首页 / 分类页公开作品列表刷新链路。
- [AGENT_EMPTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md](./AGENT_EMPTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md):记录 Agent 空会话不应进入作品草稿列表的后端判定规则。
- [BIG_FISH_PUBLISH_FEEDBACK_FIX_2026-04-26.md](./BIG_FISH_PUBLISH_FEEDBACK_FIX_2026-04-26.md):记录大鱼吃小鱼发布成功后结果页反馈与作品列表刷新的修复口径。
- [BIG_FISH_WORKS_JSON_COMPAT_FIX_2026-04-28.md](./BIG_FISH_WORKS_JSON_COMPAT_FIX_2026-04-28.md):记录大鱼作品列表 `items_json` 字段升级后的向后兼容修复口径,避免旧 JSON 直接打崩 works 接口。

View File

@@ -0,0 +1,58 @@
# 拼图结果页自动保存与标签发布门槛修复
## 背景
拼图结果页此前存在两个串联问题:
1. 创作者在结果页修改 `关卡名`、新增标签、删除标签,只会改前端本地 `editState`,不会立即写回拼图作品 profile。
2. 发布弹窗同时混用了旧 session 内的 `publishReady` 与前端本地编辑态,导致标签已经在界面里补够,但发布校验仍然盯着旧草稿里的标签数量,用户无法通过发布检验。
这会直接破坏拼图创作主链的可用性:用户明明已经在结果页补齐正式标签,却因为没有自动保存、也没有按当前编辑态重算门槛而卡在发布前。
## 修复目标
1. 拼图结果页中的 `关卡名``添加标签``删除标签` 统一接入自动保存。
2. 自动保存复用现有 `PUT /api/runtime/puzzle/works/:profileId`,不新增新系统。
3. 前端发布门槛与后端 `module-puzzle` 规则显式对齐,统一采用 `3~6` 个正式标签。
4. 发布弹窗要基于“当前可编辑态”判断是否通过,不再被旧 session 中可由本地编辑修复的 blocker 卡死。
## 实现口径
### 1. 结果页自动保存
- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
- 为拼图结果页显式透传稳定 `profileId`
- 草稿结果页默认按 `puzzle-session-* -> puzzle-profile-*` 规则推导 profileId已发布作品优先复用 `publishedProfileId`
- `src/components/puzzle-result/PuzzleResultView.tsx`
-`levelName / summary / themeTags` 相对当前 `draft` 发生变化时,触发防抖自动保存。
- 自动保存复用 `updatePuzzleWork(...)`,同步写回:
- `levelName`
- `summary`
- `themeTags`
- 当前正式图 `coverImageSrc / coverAssetId`
- 顶部只展示轻量保存状态角标:`保存中 / 已自动保存 / 保存失败`
### 2. 发布门槛统一
- 前端结果页发布判定不再使用“至少 1 个标签”的旧口径。
- 统一改为和后端 `module-puzzle` 一致的规则:正式标签数量必须在 `3 到 6` 之间。
### 3. 结果页 blocker 重算策略
`session.resultPreview.blockers` 中与可编辑字段直接相关的 blocker
- `MISSING_LEVEL_NAME`
- `INVALID_TAG_COUNT`
- `MISSING_COVER_IMAGE`
这些项不再原样阻断前端发布按钮,而是改由结果页基于当前 `editState + formalImageSrc` 重新计算。
其余后端 blocker 仍继续保留,避免前端绕过真正不可编辑的发布门禁。
## 验收点
1. 修改拼图关卡名后,不点发布也会自动写回作品 profile。
2. 添加标签、删除标签后,不点发布也会自动写回作品 profile。
3. 标签少于 `3` 个时,发布弹窗明确提示“正式标签数量必须在 3 到 6 之间”。
4. 标签补到 `3~6` 个后,无需刷新页面即可通过前端发布校验。
5. 结果页顶部能看到轻量自动保存状态,不额外堆叠说明文案。

View File

@@ -4,6 +4,7 @@
## 文档列表
- [PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md](./PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md):记录拼图结果页名称与标签编辑自动保存、发布门槛统一到 `3~6` 标签,以及前端发布校验不再被旧 session blocker 卡死的修复口径。
- [SPACETIMEDB_START_SH_EARLY_EXIT_DIAGNOSTICS_2026-04-27.md](./SPACETIMEDB_START_SH_EARLY_EXIT_DIAGNOSTICS_2026-04-27.md):记录发布包 `start.sh` 只输出“SpacetimeDB 进程在就绪前退出”时的诊断补强,启动失败或超时时自动回显 `logs/spacetimedb.log``server ping`、端口监听和 root-dir 相关进程。
- [RPG_AND_AGENT_CHAT_TRUE_SSE_STREAMING_2026-04-26.md](./RPG_AND_AGENT_CHAT_TRUE_SSE_STREAMING_2026-04-26.md):记录 RPG 运行时 NPC 聊天、RPG/自定义世界 Agent 与大鱼 Agent 从“拼完整 SSE 字符串后一次性返回”改为 `mpsc + Sse<Event>` 真流式输出的后端落地口径。
- [SPACETIMEDB_START_SH_ROOT_OWNER_FALSE_POSITIVE_FIX_2026-04-27.md](./SPACETIMEDB_START_SH_ROOT_OWNER_FALSE_POSITIVE_FIX_2026-04-27.md):记录发布包 `start.sh` root-dir 占用检测把 `grep -F .../.spacetimedb` 误判为 SpacetimeDB 实例的根因、脚本修复和现场处理方式。

View File

@@ -96,6 +96,9 @@
4. 前端 `storyChoiceContinuation` / `useRpgRuntimeNpcInteraction`
- `fight_defeat` 不能再被当成“本地 NPC 战斗胜利”进入战后收束
- 玩家死亡后必须直接走死亡复活链,且复活时重置到开局场景第一幕,不能先推进下一幕
5. 前端 `postBattleFlow`
- 复活回到开局场景时,必须重新走首幕 encounter preview 恢复链
- 第一幕主交互 NPC 与同幕陪衬 NPC 要继续沿用既有场景槽位,不能退化成全部站成一排
## 继续收口2026-04-28
@@ -118,6 +121,24 @@
- 非 NPC 通用敌对战斗 `!inBattle`
5. 这样可以保证作品测试、幕预览与正式游戏在“死亡后回开局第一幕”这一口径上继续对齐
## 继续收口:复活后首幕 NPC 与站位恢复2026-04-28
在继续复测后,又确认死亡复活链还有一层表现问题:
1. 角色虽然已经回到开局场景第一幕;
2. 但复活态旧实现只是重置 `currentSceneActState`,没有重新恢复第一幕 encounter preview
3. 于是画布只能把第一幕 NPC 都按普通 ambient 角色绘制;
4. 视觉上就会表现为:
- 主交互 NPC 没有按首幕重新成为前景目标
- 同幕 NPC 失去原本的前后排关系
- 最终看起来像“所有人站成一排”
本轮补充修正如下:
1. `buildRevivedFirstSceneState(...)` 在重置到首幕之后,立即复用 `ensureSceneEncounterPreview(...)`
2. 这样复活链与“开局进入世界 / 场景正常进场”继续共用同一套首幕恢复逻辑
3. 第一幕主交互 NPC、同幕陪衬 NPC 与既有槽位会一起恢复,不再额外发明一套复活专用站位规则
## 结论
本次修复后RPG 战斗 compat 主链的胜负判定口径变为:

View File

@@ -200,3 +200,47 @@
1. 自定义世界角色型敌人在战斗态不会再重复叠加场景立绘下沉偏移;
2. 相关战斗编队、runtime gateway 与 battle plan 既有回归继续通过。
## 9. 本轮继续修正:战斗结束后和平态站位被战斗坐标污染
在前面解决“开战瞬间跳位”后,用户继续反馈战斗结束时敌对角色站位仍会被改掉。继续顺着 NPC 战斗收尾链核对后,确认这次的问题不在画布层,而在“战后恢复使用了哪一份 encounter 真相”。
### 9.1 根因梳理
此前 `fight_victory` 收尾时,恢复 `currentEncounter` 的优先级仍可能落到:
1. 战斗中的 `currentEncounter`
2. `activeBattleHostiles[0]?.encounter`
这两份 encounter 都已经是战斗态里被压到前排中心位后的数据,`xMeters` 往往已经变成 `3.2`。因此即使战前和平态 NPC 原本站在更靠后的场景位置,战斗结束后也会被错误恢复到战斗中心位,表现为“打完架后角色站位被改掉”。
### 9.2 本次收口
这次修正把“战前原始 encounter 保存”和“战后 encounter 恢复”两端一起收口:
1. `sceneEncounterPreviews.ts`
- NPC 自动开战时立即保存战前原始 encounter
- 复用现有 `sparReturnEncounter` 存槽,避免新增一套并行状态
2. `rpgRuntimeStoryGateway.ts`
- 若服务端战斗快照未带回 `sparReturnEncounter`
- 网关自动沿用进入战斗前的原始 NPC encounter 回填
3. `useRpgRuntimeNpcInteraction.ts`
- `fight_victory` 恢复和平态时,优先使用保存下来的战前 encounter
- 只在缺失时才退回到 battle encounter / fallback encounter
### 9.3 效果
这样处理后:
1. 战斗结束后恢复到场景中的 NPC会回到战前那份 encounter 对应的位置;
2. 不会再把战斗前排中心位误带回和平态;
3. `fight``spar` 两条 NPC 战斗收尾链恢复口径保持一致;
4. 作品测试、幕预览与正式运行的战后站位表现继续对齐。
### 9.4 验证
本轮新增并通过了以下回归验证:
1. 负好感 NPC 自动开战后会保存战前 encounter
2. `npc_fight` 服务端空战场快照桥接后会保留战前 encounter
3. `fight_victory` 收尾时会恢复战前 encounter而不是战斗态 encounter。

View File

@@ -94,19 +94,19 @@
},
{
"templateId": "big_fish",
"displayName": "大鱼吃小鱼共创",
"displayName": "大鱼吃小鱼",
"creationGoal": "收束成可直接编译为竖屏大鱼吃小鱼玩法草稿的成长、生态、节奏方案。",
"anchorQuestions": [
{
"key": "gameplayPromise",
"label": "玩法承诺",
"question": "这版大鱼吃小鱼最核心的吞噬成长爽点是什么?",
"label": "玩法爽点",
"question": "核心的吞噬成长爽点是什么?",
"requiredEffect": "明确玩家为什么要持续吞噬、升级和冒险。"
},
{
"key": "ecologyVisualTheme",
"label": "生态视觉主题",
"question": "鱼群、场景和敌我生态的视觉主题是什么?",
"label": "视觉主题",
"question": "场景和形象的视觉主题是什么?",
"requiredEffect": "提供后续角色图、动作图和背景图的一致视觉方向。"
},
{

View File

@@ -80,12 +80,22 @@ impl SpacetimeClient {
procedure_input: BigFishWorksListInput,
) -> Result<Vec<BigFishWorkSummaryRecord>, SpacetimeClientError> {
self.call_after_connect(move |connection, sender| {
let fallback_owner_user_id = if procedure_input.published_only {
None
} else {
Some(procedure_input.owner_user_id.clone())
};
connection
.procedures()
.list_big_fish_works_then(procedure_input, move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_big_fish_works_procedure_result);
.and_then(|result| {
map_big_fish_works_procedure_result(
result,
fallback_owner_user_id.as_deref(),
)
});
send_once(&sender, mapped);
});
})
@@ -103,12 +113,18 @@ impl SpacetimeClient {
};
self.call_after_connect(move |connection, sender| {
let fallback_owner_user_id = Some(procedure_input.owner_user_id.clone());
connection
.procedures()
.delete_big_fish_work_then(procedure_input, move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_big_fish_works_procedure_result);
.and_then(|result| {
map_big_fish_works_procedure_result(
result,
fallback_owner_user_id.as_deref(),
)
});
send_once(&sender, mapped);
});
})

View File

@@ -1278,6 +1278,7 @@ pub(crate) fn map_big_fish_session_procedure_result(
pub(crate) fn map_big_fish_works_procedure_result(
result: BigFishWorksProcedureResult,
fallback_owner_user_id: Option<&str>,
) -> Result<Vec<BigFishWorkSummaryRecord>, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::Procedure(
@@ -1292,9 +1293,15 @@ pub(crate) fn map_big_fish_works_procedure_result(
"SpacetimeDB procedure 未返回 big fish works 快照".to_string(),
)
})?;
serde_json::from_str::<Vec<BigFishWorkSummaryRecord>>(&items_json).map_err(|error| {
SpacetimeClientError::Runtime(format!("big fish works items_json 非法: {error}"))
})
let items = serde_json::from_str::<Vec<CompatibleBigFishWorkSummaryRecord>>(&items_json)
.map_err(|error| {
SpacetimeClientError::Runtime(format!("big fish works items_json 非法: {error}"))
})?;
Ok(items
.into_iter()
.map(|item| item.into_record(fallback_owner_user_id))
.collect())
}
pub(crate) fn map_story_session_procedure_result(
@@ -4601,6 +4608,120 @@ pub struct BigFishWorkSummaryRecord {
pub background_ready: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
struct CompatibleBigFishWorkSummaryRecord {
work_id: String,
source_session_id: String,
#[serde(default)]
owner_user_id: Option<String>,
title: String,
subtitle: String,
summary: String,
cover_image_src: Option<String>,
status: String,
updated_at_micros: i64,
publish_ready: bool,
level_count: u32,
level_main_image_ready_count: u32,
level_motion_ready_count: u32,
background_ready: bool,
}
impl CompatibleBigFishWorkSummaryRecord {
fn into_record(self, fallback_owner_user_id: Option<&str>) -> BigFishWorkSummaryRecord {
BigFishWorkSummaryRecord {
work_id: self.work_id,
source_session_id: self.source_session_id,
// 中文注释:兼容旧 works JSON 没有 owner_user_id 的历史数据,避免一次字段升级把整个作品列表打崩。
owner_user_id: self.owner_user_id.unwrap_or_else(|| {
fallback_owner_user_id
.map(str::to_string)
.unwrap_or_default()
}),
title: self.title,
subtitle: self.subtitle,
summary: self.summary,
cover_image_src: self.cover_image_src,
status: self.status,
updated_at_micros: self.updated_at_micros,
publish_ready: self.publish_ready,
level_count: self.level_count,
level_main_image_ready_count: self.level_main_image_ready_count,
level_motion_ready_count: self.level_motion_ready_count,
background_ready: self.background_ready,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn big_fish_works_mapper_backfills_missing_owner_user_id_for_private_lists() {
let result = BigFishWorksProcedureResult {
ok: true,
items_json: Some(
r#"[{
"work_id":"big-fish-work-session-1",
"source_session_id":"session-1",
"title":"深海草稿",
"subtitle":"副标题",
"summary":"摘要",
"cover_image_src":null,
"status":"draft",
"updated_at_micros":123,
"publish_ready":false,
"level_count":8,
"level_main_image_ready_count":0,
"level_motion_ready_count":0,
"background_ready":false
}]"#
.to_string(),
),
error_message: None,
};
let items = map_big_fish_works_procedure_result(result, Some("user-1"))
.expect("旧 works JSON 应能被兼容解析");
assert_eq!(items.len(), 1);
assert_eq!(items[0].owner_user_id, "user-1");
}
#[test]
fn big_fish_works_mapper_keeps_empty_owner_when_gallery_legacy_json_lacks_field() {
let result = BigFishWorksProcedureResult {
ok: true,
items_json: Some(
r#"[{
"work_id":"big-fish-work-session-2",
"source_session_id":"session-2",
"title":"公开作品",
"subtitle":"副标题",
"summary":"摘要",
"cover_image_src":null,
"status":"published",
"updated_at_micros":456,
"publish_ready":true,
"level_count":8,
"level_main_image_ready_count":8,
"level_motion_ready_count":16,
"background_ready":true
}]"#
.to_string(),
),
error_message: None,
};
let items = map_big_fish_works_procedure_result(result, None)
.expect("公开 works 旧 JSON 也不应因缺字段报错");
assert_eq!(items.len(), 1);
assert!(items[0].owner_user_id.is_empty());
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResolveNpcBattleInteractionInput {
pub npc_interaction: DomainResolveNpcInteractionInput,

View File

@@ -316,6 +316,17 @@ function buildAgentResultPublishGateView(
};
}
function buildPuzzleResultProfileId(sessionId: string | null | undefined) {
const normalizedSessionId = sessionId?.trim();
if (!normalizedSessionId) {
return null;
}
const stableSuffix = normalizedSessionId.startsWith('puzzle-session-')
? normalizedSessionId.slice('puzzle-session-'.length)
: normalizedSessionId;
return `puzzle-profile-${stableSuffix}`;
}
const CustomWorldGenerationView = lazy(async () => {
const module = await import('../CustomWorldGenerationView');
return {
@@ -2450,6 +2461,10 @@ export function PlatformEntryFlowShellImpl({
>
<PuzzleResultView
session={puzzleSession}
profileId={
puzzleSession.publishedProfileId ??
buildPuzzleResultProfileId(puzzleSession.sessionId)
}
isBusy={isPuzzleBusy}
error={puzzleError}
onBack={() => {

View File

@@ -1,16 +1,18 @@
// @vitest-environment jsdom
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient';
import * as puzzleWorksService from '../../services/puzzle-works';
import { PuzzleResultView } from './PuzzleResultView';
vi.mock('../ResolvedAssetImage', () => ({
@@ -31,6 +33,15 @@ vi.mock('../../services/puzzle-works/puzzleAssetClient', () => ({
},
}));
vi.mock('../../services/puzzle-works', () => ({
updatePuzzleWork: vi.fn(),
}));
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
function createSession(
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
): PuzzleAgentSessionSnapshot {
@@ -149,6 +160,39 @@ function createSession(
}
describe('PuzzleResultView', () => {
test('auto saves renamed title to the puzzle work profile', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
item: {} as never,
});
render(
<PuzzleResultView
session={createSession()}
profileId="puzzle-profile-session-1"
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
fireEvent.change(screen.getByDisplayValue('雨夜猫街'), {
target: { value: '暖灯猫街' },
});
await act(async () => {
await vi.runAllTimersAsync();
});
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenCalledWith(
'puzzle-profile-session-1',
expect.objectContaining({
levelName: '暖灯猫街',
summary: '屋檐下的猫与暖灯街角。',
themeTags: ['猫咪', '雨夜'],
}),
);
});
test('uses two tabs without author preview or persistent publish validation', () => {
render(
<PuzzleResultView
@@ -168,9 +212,13 @@ describe('PuzzleResultView', () => {
});
test('edits theme tags with chips instead of a persistent tag input', () => {
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
item: {} as never,
});
render(
<PuzzleResultView
session={createSession()}
profileId="puzzle-profile-session-1"
onBack={() => {}}
onExecuteAction={() => {}}
/>,
@@ -256,6 +304,78 @@ describe('PuzzleResultView', () => {
);
});
test('requires at least three theme tags before publish can pass', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
fireEvent.click(screen.getByLabelText('删除标签 猫咪'));
fireEvent.click(screen.getByRole('button', { name: //u }));
const dialog = screen.getByRole('dialog', { name: '发布拼图作品' });
expect(
within(dialog).getByText('正式标签数量必须在 3 到 6 之间。'),
).toBeTruthy();
expect(
(
within(dialog).getByRole('button', {
name: '发布到广场',
}) as HTMLButtonElement
).disabled,
).toBe(true);
});
test('auto saves added and removed theme tags', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
item: {} as never,
});
render(
<PuzzleResultView
session={createSession()}
profileId="puzzle-profile-session-1"
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
fireEvent.click(screen.getByLabelText('新增题材标签'));
fireEvent.change(screen.getByLabelText('新题材标签'), {
target: { value: '暖灯' },
});
fireEvent.click(screen.getByRole('button', { name: '添加' }));
await act(async () => {
await vi.runAllTimersAsync();
});
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenLastCalledWith(
'puzzle-profile-session-1',
expect.objectContaining({
themeTags: ['猫咪', '雨夜', '暖灯'],
}),
);
fireEvent.click(screen.getByLabelText('删除标签 猫咪'));
await act(async () => {
await vi.runAllTimersAsync();
});
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenLastCalledWith(
'puzzle-profile-session-1',
expect.objectContaining({
themeTags: ['雨夜', '暖灯'],
}),
);
});
test('generates one image from the picture description and replaces current image', () => {
const onExecuteAction = vi.fn();

View File

@@ -15,6 +15,7 @@ import { createPortal } from 'react-dom';
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type { PuzzleResultDraft } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { updatePuzzleWork } from '../../services/puzzle-works';
import {
puzzleAssetClient,
type PuzzleHistoryAsset,
@@ -24,6 +25,7 @@ import { ResolvedAssetImage } from '../ResolvedAssetImage';
type PuzzleResultViewProps = {
session: PuzzleAgentSessionSnapshot;
profileId?: string | null;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
@@ -32,6 +34,7 @@ type PuzzleResultViewProps = {
};
type PuzzleResultTab = 'basic' | 'images';
type PuzzleAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
type DraftEditState = {
levelName: string;
@@ -39,6 +42,10 @@ type DraftEditState = {
themeTags: string[];
};
const PUZZLE_MIN_THEME_TAG_COUNT = 3;
const PUZZLE_MAX_THEME_TAG_COUNT = 6;
const PUZZLE_AUTOSAVE_DEBOUNCE_MS = 600;
function normalizeThemeTagInput(value: string) {
return [
...new Set(
@@ -84,7 +91,16 @@ function publishBlockedReason(session: PuzzleAgentSessionSnapshot) {
if (!session.resultPreview) {
return ['等待结果页草稿完成后再发布。'];
}
return session.resultPreview.blockers.map((entry) => entry.message);
return session.resultPreview.blockers
.filter(
(entry) =>
![
'MISSING_LEVEL_NAME',
'INVALID_TAG_COUNT',
'MISSING_COVER_IMAGE',
].includes(entry.code),
)
.map((entry) => entry.message);
}
function buildPublishReady(
@@ -96,7 +112,10 @@ function buildPublishReady(
const blockers = [
...publishBlockedReason(session),
...(editState.levelName.trim() ? [] : ['关卡名不能为空。']),
...(editState.themeTags.length > 0 ? [] : ['至少需要 1 个题材标签。']),
...(editState.themeTags.length >= PUZZLE_MIN_THEME_TAG_COUNT &&
editState.themeTags.length <= PUZZLE_MAX_THEME_TAG_COUNT
? []
: [`正式标签数量必须在 ${PUZZLE_MIN_THEME_TAG_COUNT}${PUZZLE_MAX_THEME_TAG_COUNT} 之间。`]),
...(formalImageSrc ? [] : ['请先选择一张正式拼图图片。']),
];
@@ -105,7 +124,8 @@ function buildPublishReady(
publishReady:
Boolean(session.resultPreview?.publishReady) &&
Boolean(editState.levelName.trim()) &&
editState.themeTags.length > 0 &&
editState.themeTags.length >= PUZZLE_MIN_THEME_TAG_COUNT &&
editState.themeTags.length <= PUZZLE_MAX_THEME_TAG_COUNT &&
Boolean(formalImageSrc),
};
}
@@ -130,12 +150,29 @@ function resolvePuzzleFormalImageSrc(draft: PuzzleResultDraft) {
}
function PuzzleResultHeader({
autoSaveState,
isBusy,
onBack,
}: {
autoSaveState: PuzzleAutoSaveState;
isBusy: boolean;
onBack: () => void;
}) {
const autoSaveBadge =
autoSaveState === 'saving' ? (
<div className="platform-pill platform-pill--warm px-3 py-1 text-[11px]">
</div>
) : autoSaveState === 'saved' ? (
<div className="platform-pill platform-pill--success px-3 py-1 text-[11px]">
</div>
) : autoSaveState === 'error' ? (
<div className="platform-pill platform-pill--rose px-3 py-1 text-[11px]">
</div>
) : null;
return (
<div className="mb-4 flex items-center justify-between gap-3">
<button
@@ -149,6 +186,7 @@ function PuzzleResultHeader({
</span>
</button>
{autoSaveBadge}
</div>
);
}
@@ -871,6 +909,7 @@ function PuzzleResultActionBar({
*/
export function PuzzleResultView({
session,
profileId = null,
isBusy = false,
error = null,
onBack,
@@ -884,15 +923,77 @@ export function PuzzleResultView({
const [editState, setEditState] = useState<DraftEditState | null>(
draft ? createDraftEditState(draft) : null,
);
const [autoSaveState, setAutoSaveState] = useState<PuzzleAutoSaveState>('idle');
const [autoSaveError, setAutoSaveError] = useState<string | null>(null);
useEffect(() => {
if (!draft) {
setEditState(null);
setAutoSaveState('idle');
setAutoSaveError(null);
return;
}
setEditState(createDraftEditState(draft));
setAutoSaveState('idle');
setAutoSaveError(null);
}, [draft]);
useEffect(() => {
if (!draft || !editState || !profileId) {
return;
}
const normalizedLevelName = editState.levelName.trim();
const normalizedSummary = editState.summary.trim();
const normalizedTags = normalizeThemeTagInput(editState.themeTags.join(''));
const draftLevelName = draft.levelName.trim();
const draftSummary = draft.summary.trim();
const draftTags = normalizeThemeTagInput(draft.themeTags.join(''));
const levelNameChanged = normalizedLevelName !== draftLevelName;
const summaryChanged = normalizedSummary !== draftSummary;
const tagsChanged =
normalizedTags.length !== draftTags.length ||
normalizedTags.some((tag, index) => tag !== draftTags[index]);
if (!levelNameChanged && !summaryChanged && !tagsChanged) {
return;
}
setAutoSaveState('saving');
setAutoSaveError(null);
let cancelled = false;
const timer = window.setTimeout(() => {
void updatePuzzleWork(profileId, {
levelName: normalizedLevelName,
summary: normalizedSummary,
themeTags: normalizedTags,
coverImageSrc: formalImageSrc || null,
coverAssetId: draft.coverAssetId ?? null,
})
.then(() => {
if (cancelled) {
return;
}
setAutoSaveState('saved');
})
.catch((saveError) => {
if (cancelled) {
return;
}
setAutoSaveState('error');
setAutoSaveError(
saveError instanceof Error ? saveError.message : '自动保存失败。',
);
});
}, PUZZLE_AUTOSAVE_DEBOUNCE_MS);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [draft, editState, formalImageSrc, profileId]);
const publishState = useMemo(() => {
if (!draft || !editState) {
return {
@@ -915,7 +1016,11 @@ export function PuzzleResultView({
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full flex-col xl:max-w-[min(100%,98rem)] xl:px-1 2xl:max-w-[min(100%,112rem)]">
<PuzzleResultHeader isBusy={isBusy} onBack={onBack} />
<PuzzleResultHeader
autoSaveState={autoSaveState}
isBusy={isBusy}
onBack={onBack}
/>
<PuzzleResultTabs
activeTab={activeTab}
@@ -953,6 +1058,11 @@ export function PuzzleResultView({
{error}
</div>
) : null}
{!error && autoSaveError ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{autoSaveError}
</div>
) : null}
<PuzzleResultActionBar
draft={draft}

View File

@@ -0,0 +1,236 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { beforeAll, expect, test, vi } from 'vitest';
import {
AnimationState,
type Character,
type QuestLogEntry,
type StoryMoment,
type StoryOption,
WorldType,
} from '../../types';
import { RpgAdventurePanel } from './RpgAdventurePanel';
function createCharacter(): Character {
return {
id: 'hero',
name: '沈行',
title: '试剑客',
description: '测试主角',
backstory: '测试背景',
avatar: '/hero.png',
portrait: '/hero.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 10,
intelligence: 8,
spirit: 9,
},
personality: 'calm',
skills: [],
adventureOpenings: {},
} as Character;
}
function createOption(functionId: string, actionText: string): StoryOption {
return {
functionId,
actionText,
text: actionText,
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
};
}
function createPendingQuest(): QuestLogEntry {
return {
id: 'quest-liu-1',
issuerNpcId: 'npc-liu',
issuerNpcName: '柳无声',
sceneId: 'scene-bamboo',
title: '竹林密信',
description: '替柳无声查清竹林中的密信来源。',
summary: '去竹林查清密信来源。',
objective: {
kind: 'inspect_treasure',
requiredCount: 1,
},
progress: 0,
status: 'active',
reward: {
affinityBonus: 5,
currency: 10,
items: [],
},
rewardText: '完成后可获得报酬。',
};
}
function createPendingQuestStory(quest: QuestLogEntry): StoryMoment {
const viewOption = createOption('npc_chat_quest_offer_view', '查看任务');
viewOption.runtimePayload = {
npcChatQuestOfferAction: 'view',
};
const replaceOption = createOption('npc_chat_quest_offer_replace', '更换任务');
replaceOption.runtimePayload = {
npcChatQuestOfferAction: 'replace',
};
const abandonOption = createOption('npc_chat_quest_offer_abandon', '放弃任务');
abandonOption.runtimePayload = {
npcChatQuestOfferAction: 'abandon',
};
return {
text: '柳无声把真正的委托说了出来。',
displayMode: 'dialogue',
dialogue: [
{ speaker: 'npc', speakerName: '柳无声', text: '这件事我只想正式托付给你。' },
],
options: [viewOption, replaceOption, abandonOption],
npcChatState: {
npcId: 'npc-liu',
npcName: '柳无声',
turnCount: 2,
customInputPlaceholder: '输入你想对 TA 说的话',
pendingQuestOffer: {
quest,
},
},
};
}
function createAcceptedQuestStory(quest: QuestLogEntry): StoryMoment {
return {
text: '柳无声把接下来的线索正式交给了你。',
displayMode: 'dialogue',
dialogue: [
{ speaker: 'npc', speakerName: '柳无声', text: '这件事我只想正式托付给你。' },
{ speaker: 'player', text: '这件事我愿意接下,你把关键要点交给我。' },
{ speaker: 'npc', speakerName: '柳无声', text: '先去竹林查清密信来源。' },
],
options: [
createOption('npc_chat', '这件事里你最担心哪一步'),
createOption('npc_chat', '我回来时你最想先知道什么'),
],
npcChatState: {
npcId: 'npc-liu',
npcName: '柳无声',
turnCount: 2,
customInputPlaceholder: '输入你想对 TA 说的话',
pendingQuestOffer: null,
},
};
}
function QuestOfferHarness() {
const pendingQuest = createPendingQuest();
const [currentStory, setCurrentStory] = useState<StoryMoment>(
createPendingQuestStory(pendingQuest),
);
const [quests, setQuests] = useState<QuestLogEntry[]>([]);
const acceptPendingOffer = vi.fn(() => {
queueMicrotask(() => {
setQuests([pendingQuest]);
setCurrentStory(createAcceptedQuestStory(pendingQuest));
});
return pendingQuest.id;
});
return (
<RpgAdventurePanel
aiError={null}
currentStory={currentStory}
isLoading={false}
displayedOptions={currentStory.options}
hideOptions={false}
canRefreshOptions={false}
onRefreshOptions={() => undefined}
onChoice={() => undefined}
onSubmitNpcChatInput={() => true}
onExitNpcChat={() => true}
onOpenCharacter={() => undefined}
onOpenInventory={() => undefined}
playerCharacter={createCharacter()}
worldType={WorldType.WUXIA}
quests={quests}
questUi={{
acknowledgeQuestCompletion: () => undefined,
claimQuestReward: () => null,
}}
npcChatQuestOfferUi={{
replacePendingOffer: () => false,
abandonPendingOffer: () => false,
acceptPendingOffer,
}}
goalStack={{
northStarGoal: null,
activeGoal: null,
immediateStepGoal: null,
supportGoals: [],
}}
goalPulse={null}
onDismissGoalPulse={() => undefined}
battleRewardUi={{
reward: null,
dismiss: () => undefined,
}}
playerHp={100}
playerMaxHp={100}
playerMana={20}
playerMaxMana={20}
playerSkillCooldowns={{}}
inBattle={false}
currentNpcBattleMode={null}
statistics={{
playTimeMs: 0,
hostileNpcsDefeated: 0,
questsAccepted: 0,
questsCompleted: 0,
questsTurnedIn: 0,
itemsUsed: 0,
scenesTraveled: 0,
currentSceneName: '竹林古道',
playerCurrency: 0,
inventoryItemCount: 0,
inventoryStackCount: 0,
activeCompanionCount: 0,
rosterCompanionCount: 0,
}}
musicVolume={0.6}
onMusicVolumeChange={() => undefined}
onSaveAndExit={() => undefined}
/>
);
}
beforeAll(() => {
if (!HTMLElement.prototype.scrollTo) {
HTMLElement.prototype.scrollTo = () => undefined;
}
});
test('quest offer accept button reuses the shared accepted-quest follow-up chain', async () => {
const user = userEvent.setup();
render(<QuestOfferHarness />);
await user.click(screen.getByRole('button', { name: //u }));
await user.click(await screen.findByRole('button', { name: '领取任务' }));
expect(await screen.findByText('任务进度0/1')).toBeTruthy();
expect(screen.getAllByText('竹林密信').length).toBeGreaterThan(0);
expect(screen.queryByText('待领取')).toBeNull();
expect(screen.getByText('这件事里你最担心哪一步')).toBeTruthy();
});

View File

@@ -1724,6 +1724,9 @@ export function RpgAdventurePanel({
onAcceptPendingNpcQuestOffer={() => {
const acceptedQuestId = npcChatQuestOfferUi.acceptPendingOffer();
if (!acceptedQuestId) return null;
// 中文注释:待领取任务详情弹层走的是异步服务端接取链路,
// 这里先记录 questId等 quest 真正进入日志后再由 effect 统一收口面板状态。
setPendingAcceptedQuestId(acceptedQuestId);
setSelectedQuestId(null);
return acceptedQuestId;
}}

View File

@@ -137,6 +137,7 @@ describe('sceneEncounterPreviews', () => {
expect(resolved.currentEncounter).toBeNull();
expect(resolved.currentBattleNpcId).toBe('npc-trader');
expect(resolved.currentNpcBattleMode).toBe('fight');
expect(resolved.sparReturnEncounter).toEqual(state.currentEncounter);
expect(resolved.sceneHostileNpcs).toHaveLength(1);
expect(resolved.sceneHostileNpcs[0]?.encounter?.npcName).toBe('Trader Lin');
});

View File

@@ -240,7 +240,9 @@ function buildResolvedNpcBattleState(state: GameState, encounter: Encounter) {
currentBattleNpcId: battleNpcId,
currentNpcBattleMode: 'fight' as const,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
// 中文注释NPC 开战后要保留战前原始遭遇,供战斗收尾时恢复和平态站位。
// 这里复用现有 sparReturnEncounter 存槽,避免战后误把 battle encounter 的临时坐标带回场景。
sparReturnEncounter: encounter,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,

View File

@@ -132,67 +132,16 @@ function createBattleOption(functionId = 'battle_all_in_crush'): StoryOption {
};
}
function createFallbackStory(text = 'fallback'): StoryMoment {
function createFallbackStory(
text = 'fallback',
options: StoryOption[] = [],
): StoryMoment {
return {
text,
options: [],
options,
};
}
function createCustomWorldProfileForSceneAct(sceneId: string) {
return {
id: 'custom-world-test',
name: '场景幕重置测试',
summary: '用于验证战败后回到首幕。',
playableNpcs: [],
storyNpcs: [],
sceneChapterBlueprints: [
{
id: `${sceneId}-chapter`,
sceneId,
title: '测试章节',
summary: '测试章节摘要',
linkedThreadIds: [],
linkedLandmarkIds: [],
acts: [
{
id: `${sceneId}-act-1`,
sceneId,
title: '第一幕',
summary: '开场第一幕',
stageCoverage: ['opening'],
backgroundImageSrc: '/act-1.png',
encounterNpcIds: [],
primaryNpcId: null,
oppositeNpcId: null,
eventDescription: '第一幕事件',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '完成第一幕目标',
transitionHook: '第一幕过渡',
},
{
id: `${sceneId}-act-2`,
sceneId,
title: '第二幕',
summary: '推进第二幕',
stageCoverage: ['expansion'],
backgroundImageSrc: '/act-2.png',
encounterNpcIds: [],
primaryNpcId: null,
oppositeNpcId: null,
eventDescription: '第二幕事件',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '完成第二幕目标',
transitionHook: '第二幕过渡',
},
],
},
],
} as NonNullable<GameState['customWorldProfile']>;
}
const neverNpcEncounter = (
encounter: GameState['currentEncounter'],
): encounter is Encounter => false;
@@ -692,10 +641,8 @@ describe('createStoryChoiceActions', () => {
it('keeps local npc defeat on the death revive chain and resets to the first scene act', async () => {
vi.useFakeTimers();
const firstScene = getScenePresetsByWorld(WorldType.WUXIA)[0]!;
const customWorldProfile = createCustomWorldProfileForSceneAct(firstScene.id);
const state = {
...createBaseState(),
customWorldProfile,
currentScenePreset: firstScene,
storyEngineMemory: {
discoveredFactIds: [],
@@ -735,7 +682,6 @@ describe('createStoryChoiceActions', () => {
}));
const setCurrentStory = vi.fn();
const setGameState = vi.fn();
const { handleChoice } = createStoryChoiceActions({
gameState: state,
currentStory: createFallbackStory(),
@@ -763,7 +709,7 @@ describe('createStoryChoiceActions', () => {
skillCooldowns: {},
})),
buildStoryFromResponse: vi.fn((_, __, response) => response),
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
buildFallbackStoryForState: vi.fn(() => createFallbackStory('fallback')),
generateStoryForState: vi.fn(),
getAvailableOptionsForState: vi.fn(() => null),
getStoryGenerationHostileNpcs: vi.fn(() => []),
@@ -809,22 +755,24 @@ describe('createStoryChoiceActions', () => {
id: firstScene.id,
}),
playerHp: 100,
playerMana: 20,
inBattle: false,
currentNpcBattleOutcome: null,
storyEngineMemory: expect.objectContaining({
currentSceneActState: expect.objectContaining({
sceneId: firstScene.id,
currentActId: `${firstScene.id}-act-1`,
currentActIndex: 0,
}),
}),
}),
);
const revivedState = setGameState.mock.calls[1]?.[0] as GameState;
expect(revivedState.currentBattleNpcId).toBeNull();
expect(revivedState.currentNpcBattleMode).toBeNull();
expect(revivedState.currentNpcBattleOutcome).toBeNull();
expect(
revivedState.currentEncounter !== null || revivedState.sceneHostileNpcs.length > 0,
).toBe(true);
expect(setCurrentStory).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.stringContaining('重新醒来'),
}),
);
vi.useRealTimers();
});
it('settles escape locally without ai continuation', async () => {

View File

@@ -721,6 +721,59 @@ describe('npcEncounterActions', () => {
expect(result).toBeNull();
});
it('restores the pre-battle encounter after fight_victory instead of using the battle encounter position', () => {
const preBattleEncounter = {
...createEncounter(),
xMeters: 12,
context: '断桥外侧',
};
const battleEncounter = {
...createEncounter(),
xMeters: 3.2,
context: '战斗中心位',
};
const actions = createNpcEncounterActions({
gameState: createState({
currentEncounter: battleEncounter,
inBattle: true,
currentBattleNpcId: 'npc-rival',
currentNpcBattleMode: 'fight',
currentNpcBattleOutcome: 'fight_victory',
sparReturnEncounter: preBattleEncounter,
sceneHostileNpcs: [
{
id: 'npc-rival',
name: '断桥客',
action: '逼近',
description: '拦路旧敌',
animation: 'idle',
xMeters: 3.2,
yOffset: 0,
facing: 'left',
attackRange: 1.4,
speed: 7,
hp: 0,
maxHp: 12,
renderKind: 'npc',
encounter: battleEncounter,
},
],
}),
});
const result = actions.finalizeNpcBattleResult(
actions.gameState,
actions.gameState.playerCharacter!,
'fight',
'fight_victory',
);
expect(result).not.toBeNull();
expect(result?.nextState.currentEncounter).toEqual(preBattleEncounter);
expect(result?.nextState.currentEncounter?.xMeters).toBe(12);
expect(result?.nextState.sparReturnEncounter).toBeNull();
});
it('streams a model-driven npc-initiated opening on first meaningful contact', async () => {
const encounter = createEncounter();
streamNpcChatTurnMock.mockResolvedValueOnce({

View File

@@ -0,0 +1,327 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
const { ensureSceneEncounterPreviewMock } = vi.hoisted(() => ({
ensureSceneEncounterPreviewMock: vi.fn(),
}));
vi.mock('../../data/sceneEncounterPreviews', () => ({
ensureSceneEncounterPreview: ensureSceneEncounterPreviewMock,
}));
import { setRuntimeCustomWorldProfile } from '../../data/customWorldRuntime';
import { getScenePresetsByWorld } from '../../data/scenePresets';
import { AnimationState, type GameState, WorldType } from '../../types';
import { buildRevivedFirstSceneState } from './postBattleFlow';
function createBackstoryReveal(label: string) {
return {
publicSummary: `${label}的公开背景`,
chapters: [
{
id: `${label}-surface`,
title: '表层来意',
affinityRequired: 15,
teaser: `${label}先收着话。`,
content: `${label}把真正目的藏在后面。`,
contextSnippet: `${label}表面上仍在试探。`,
},
{
id: `${label}-scar`,
title: '旧事裂痕',
affinityRequired: 30,
teaser: `${label}提到旧事会迟疑。`,
content: `${label}背后压着旧伤。`,
contextSnippet: `${label}仍被旧事牵制。`,
},
{
id: `${label}-hidden`,
title: '隐藏执念',
affinityRequired: 60,
teaser: `${label}真正执念并不在表面。`,
content: `${label}真正想守住的是另一条暗线。`,
contextSnippet: `${label}另有没说出口的理由。`,
},
{
id: `${label}-final`,
title: '最终底牌',
affinityRequired: 90,
teaser: `${label}手里还扣着底牌。`,
content: `${label}掌握能改写局势的最后证据。`,
contextSnippet: `${label}最后底牌还没翻出。`,
},
],
};
}
function createStoryRole(id: string, name: string, hostile = false) {
return {
id,
name,
title: `${name}的头衔`,
role: hostile ? '敌对角色' : '同幕角色',
description: `${name}的测试描述`,
backstory: `${name}的测试背景`,
personality: '冷静克制',
motivation: hostile ? '阻拦玩家继续向前' : '观察局势变化',
combatStyle: hostile ? '正面压制' : '后排支援',
initialAffinity: hostile ? -20 : 12,
relationshipHooks: [],
tags: [],
backstoryReveal: createBackstoryReveal(name),
skills: [],
initialItems: [],
};
}
function createReviveState(): GameState {
const customWorldProfile = {
id: 'custom-revive-test',
name: '复活回场测试世界',
subtitle: '首幕站位恢复',
summary: '用于验证复活后第一幕 NPC 会按既有 encounter preview 恢复。',
settingText: '围绕开局营地与第一幕对峙角色展开的自定义世界。',
tone: '紧张、克制',
playerGoal: '复活后重新回到第一幕并面对主交互角色。',
templateWorldType: WorldType.WUXIA,
majorFactions: [],
coreConflicts: [],
attributeSchema: {
id: 'schema:test',
worldId: 'CUSTOM',
schemaVersion: 1,
schemaName: '测试属性',
generatedFrom: {
worldType: WorldType.CUSTOM,
worldName: '复活回场测试世界',
settingSummary: '首幕站位恢复',
tone: '紧张、克制',
conflictCore: '复活后重新面对主交互角色',
},
slots: [],
},
playableNpcs: [],
storyNpcs: [
createStoryRole('npc-front', '正面对手', true),
createStoryRole('npc-back-1', '后排甲'),
createStoryRole('npc-back-2', '后排乙'),
],
items: [],
landmarks: [],
camp: {
id: 'custom-scene-camp',
name: '开局营地',
description: '用于复活回场测试。',
visualDescription: '营地火光映着即将重开的第一幕。',
imageSrc: '/camp.png',
sceneNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
connections: [],
narrativeResidues: null,
},
sceneChapterBlueprints: [
{
id: 'custom-scene-camp-chapter',
sceneId: 'custom-scene-camp',
title: '开局章节',
summary: '复活后应回到这里的第一幕。',
sceneTaskDescription: '',
linkedThreadIds: [],
linkedLandmarkIds: [],
acts: [
{
id: 'custom-scene-camp-act-1',
sceneId: 'custom-scene-camp',
title: '第一幕',
summary: '主交互角色与后排角色一同出现。',
stageCoverage: ['opening'],
backgroundImageSrc: '/act-1.png',
encounterNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
primaryNpcId: 'npc-front',
oppositeNpcId: 'npc-front',
eventDescription: '第一幕事件',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '重新进入首幕',
transitionHook: '首幕回场',
},
{
id: 'custom-scene-camp-act-2',
sceneId: 'custom-scene-camp',
title: '第二幕',
summary: '这是死亡前已经推进到的幕。',
stageCoverage: ['expansion'],
backgroundImageSrc: '/act-2.png',
encounterNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
primaryNpcId: 'npc-front',
oppositeNpcId: 'npc-front',
eventDescription: '第二幕事件',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '推进第二幕',
transitionHook: '第二幕推进',
},
],
},
],
} as NonNullable<GameState['customWorldProfile']>;
setRuntimeCustomWorldProfile(customWorldProfile);
const firstScene = getScenePresetsByWorld(WorldType.CUSTOM)[0]!;
return {
worldType: WorldType.CUSTOM,
customWorldProfile,
playerCharacter: {
id: 'hero',
name: '测试主角',
title: '旅人',
description: '测试角色',
backstory: '测试背景',
avatar: '/hero.png',
portrait: '/hero.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 10,
intelligence: 10,
spirit: 10,
},
personality: 'calm',
skills: [],
adventureOpenings: {},
},
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
characterChats: {},
animationState: AnimationState.DIE,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: firstScene,
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 0,
playerMaxHp: 100,
playerMana: 0,
playerMaxMana: 20,
playerSkillCooldowns: {},
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {
'npc-front': {
affinity: -20,
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: false,
},
'npc-back-1': {
affinity: 8,
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: false,
},
'npc-back-2': {
affinity: 6,
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: 'npc-front',
currentNpcBattleMode: 'fight',
currentNpcBattleOutcome: 'fight_defeat',
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
storyEngineMemory: {
discoveredFactIds: [],
activeThreadIds: [],
resolvedScarIds: [],
recentCarrierIds: [],
currentSceneActState: {
sceneId: 'custom-scene-camp',
chapterId: 'custom-scene-camp-chapter',
currentActId: 'custom-scene-camp-act-2',
currentActIndex: 1,
completedActIds: ['custom-scene-camp-act-1'],
visitedActIds: ['custom-scene-camp-act-1', 'custom-scene-camp-act-2'],
},
},
} as GameState;
}
describe('postBattleFlow', () => {
afterEach(() => {
ensureSceneEncounterPreviewMock.mockReset();
setRuntimeCustomWorldProfile(null);
});
it('rebuilds revived first-scene state through encounter preview restoration', () => {
const reviveState = createReviveState();
const previewRestoredState = {
...reviveState,
currentEncounter: {
id: 'npc-front',
kind: 'npc' as const,
characterId: 'npc-front',
npcName: '正面对手',
npcDescription: '正面对手的测试描述',
npcAvatar: '正',
context: '敌对角色',
xMeters: 12,
},
};
ensureSceneEncounterPreviewMock.mockReturnValue(previewRestoredState);
const revived = buildRevivedFirstSceneState(reviveState);
expect(ensureSceneEncounterPreviewMock).toHaveBeenCalledWith(
expect.objectContaining({
currentScenePreset: expect.objectContaining({
id: 'custom-scene-camp',
}),
currentEncounter: null,
sceneHostileNpcs: [],
playerHp: 100,
playerMana: 20,
inBattle: false,
currentNpcBattleOutcome: null,
storyEngineMemory: expect.objectContaining({
currentSceneActState: expect.objectContaining({
currentActId: 'custom-scene-camp-act-1',
currentActIndex: 0,
}),
}),
}),
);
expect(revived).toBe(previewRestoredState);
});
});

View File

@@ -1,4 +1,5 @@
import { getScenePresetById, getScenePresetsByWorld } from '../../data/scenePresets';
import { ensureSceneEncounterPreview } from '../../data/sceneEncounterPreviews';
import {
advanceSceneActRuntimeState,
buildInitialSceneActRuntimeState,
@@ -169,7 +170,7 @@ export function buildRevivedFirstSceneState(state: GameState): GameState {
storyEngineMemory: undefined,
});
return {
const revivedBaseState = {
...state,
currentScenePreset: firstScene,
currentEncounter: null,
@@ -195,19 +196,34 @@ export function buildRevivedFirstSceneState(state: GameState): GameState {
...storyEngineMemory,
currentSceneActState: firstActState,
},
};
} satisfies GameState;
// 中文注释:角色复活后要回到“开局进入世界”同一套首幕 encounter preview
// 构建链,而不是只清空战斗态。这样第一幕主交互 NPC 与同幕陪衬 NPC
// 会按既有槽位一起恢复,避免退化成所有人站成一排。
return ensureSceneEncounterPreview(revivedBaseState);
}
export function buildDeathStory(state: GameState): StoryMoment {
export function buildDeathStory(
state: GameState,
deferredOptions?: StoryOption[],
): StoryMoment {
const firstSceneName =
state.worldType
? getScenePresetsByWorld(state.worldType)[0]?.name
: state.currentScenePreset?.name;
return {
text: firstSceneName
? `你在战斗中倒下,随后在${firstSceneName}重新醒来。`
: '你在战斗中倒下,随后重新醒来。',
options: [buildContinueOption()],
// 中文注释:复活后的“继续前进”只负责揭示已经准备好的首场景入口,
// 不能再次触发普通剧情推演,否则容易直接被推进到主 NPC 执行态。
deferredOptions:
deferredOptions && deferredOptions.length > 0
? deferredOptions
: undefined,
streaming: false,
};
}

View File

@@ -301,6 +301,13 @@ function bridgeServerNpcBattleSnapshot(params: {
sceneHostileNpcs: resolvedBattleFormation,
currentEncounter: null,
npcInteractionActive: false,
// 中文注释:服务端兼容链路若未带回战前遭遇,则沿用进入战斗前的原始 encounter
// 让后续 fight_victory / spar_complete 都能恢复到正确站位,而不是战斗中的临时坐标。
sparReturnEncounter:
snapshotState.sparReturnEncounter ??
(previousState.currentEncounter?.kind === 'npc'
? previousState.currentEncounter
: null),
},
} satisfies HydratedSavedGameSnapshot;
}

View File

@@ -937,6 +937,9 @@ describe('runtimeStoryCoordinator', () => {
yOffset: 62,
},
]);
expect(result.hydratedSnapshot.gameState.sparReturnEncounter).toEqual(
gameState.currentEncounter,
);
});
it('realigns non-empty npc_fight battle snapshots back to the visible pre-battle formation', async () => {

View File

@@ -446,8 +446,12 @@ export async function runLocalStoryChoiceContinuation(params: {
],
};
fallbackState = revivedState;
const revivedDeferredOptions =
params.buildFallbackStoryForState(revivedState, params.character).options;
params.setGameState(revivedState);
params.setCurrentStory(buildDeathStory(revivedState));
params.setCurrentStory(
buildDeathStory(revivedState, revivedDeferredOptions),
);
return;
}

View File

@@ -527,7 +527,10 @@ describe('storyChoiceRuntime', () => {
setIsLoading: vi.fn(),
setGameState,
setCurrentStory: setCurrentStory as (story: StoryMoment) => void,
buildFallbackStoryForState: () => createStory('fallback'),
buildFallbackStoryForState: () =>
createStory('fallback', [
createOption('idle_explore_forward'),
]),
turnVisualMs: 1,
});
@@ -541,6 +544,11 @@ describe('storyChoiceRuntime', () => {
expect(setCurrentStory).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.stringContaining('重新醒来'),
options: [
expect.objectContaining({
functionId: 'story_continue_adventure',
}),
],
}),
);
expect(setCurrentStory).not.toHaveBeenCalledWith(

View File

@@ -350,8 +350,12 @@ export async function runServerRuntimeChoiceAction(params: {
params.setGameState(deathState);
await sleep(PLAYER_REVIVE_DELAY_MS);
const revivedState = buildRevivedFirstSceneState(deathState);
const revivedDeferredOptions =
params.buildFallbackStoryForState(revivedState, params.character).options;
params.setGameState(revivedState);
params.setCurrentStory(buildDeathStory(revivedState));
params.setCurrentStory(
buildDeathStory(revivedState, revivedDeferredOptions),
);
return;
}

View File

@@ -490,6 +490,7 @@ export function createStoryNpcEncounterActions({
(hostileNpc) => hostileNpc.id,
);
const restoredEncounter =
state.sparReturnEncounter ??
(state.currentEncounter?.kind === 'npc' ? state.currentEncounter : null) ??
activeBattleHostiles[0]?.encounter ??
({