diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_BIG_FISH_GAME_CREATION_AND_GAMEPLAY_PRD_2026-04-22.md b/docs/prd/AI_NATIVE_AGENT_FIRST_BIG_FISH_GAME_CREATION_AND_GAMEPLAY_PRD_2026-04-22.md index d92dbf73..eae559f4 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_BIG_FISH_GAME_CREATION_AND_GAMEPLAY_PRD_2026-04-22.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_BIG_FISH_GAME_CREATION_AND_GAMEPLAY_PRD_2026-04-22.md @@ -193,8 +193,10 @@ Agent 在这一玩法里不负责实时玩法裁决,它只负责 3 件事: 4. 会补缺,不会平均盘问 5. 进度基于真实锚点完成度,而不是机械轮次 6. 当会话至少完成 `2` 轮后,工作区必须提供 `补充剩余关键字` 快捷动作。 - - 该动作只向 Agent 发送“请补充剩余关键字。”,由后端 Agent 根据当前锚点补齐缺失关键词。 + - 该动作沿用 RPG 聊天链路,仍走发送消息接口,但请求体必须携带 `quickFillRequested: true`。 + - 前端只发送“请补充剩余关键字。”作为本轮用户消息,由后端 Agent 根据当前锚点补齐缺失关键词。 - 前端不得自行推断成长阶梯、风险节奏或视觉母题,也不得直接改写锚点状态。 + - Agent 收到后必须进入自动补齐模式,不允许继续反问用户。 ## 7.3 大鱼吃小鱼玩法的 4 个最小高杠杆锚点 diff --git a/docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_FLOW_OPTIMIZATION_PRD_2026-04-06.md b/docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_FLOW_OPTIMIZATION_PRD_2026-04-06.md index 8e82c4a8..51182327 100644 --- a/docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_FLOW_OPTIMIZATION_PRD_2026-04-06.md +++ b/docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_FLOW_OPTIMIZATION_PRD_2026-04-06.md @@ -1,4 +1,4 @@ -# AI 原生自定义世界生成流程优化 PRD +# AI 原生自定义世界生成流程优化 PRD 更新时间:`2026-04-06` @@ -274,6 +274,31 @@ 这样既能保证世界最小成型,又不会把创作者门槛抬高。 +## 4.3.1 抽象统一“聊天补充设定”能力 + +RPG 创作工作台、拼图创作工作台、大鱼吃小鱼创作工作台都应走同一套“聊天补充设定”能力,而不是各自维护按钮、提交 payload 和自动补全规则。 + +统一能力需要覆盖: + +1. 普通聊天提交:只携带用户输入文本与统一生成的 `clientMessageId`。 +2. 总结当前设定:快捷动作统一展示为“总结当前设定”,但总结消息允许各玩法保留领域文案。 +3. 补充剩余设定:快捷动作统一展示为“补充剩余设定”,并统一进入自动补全模式。 + +用户完成至少两轮对话后,工作台展示“补充剩余设定”快捷操作。点击后前端只发送一次聊天消息,不再弹出额外表单或追问面板: + +- 消息文本固定为 `请补充剩余设定。` +- 请求必须携带 `quickFillRequested: true` +- `focusCardId` 固定为空,`selectedCardIds` 固定为空数组 + +后端各玩法 Agent 收到 `quickFillRequested: true` 后必须进入强制补全模式,并复用统一公共规则: + +1. 不再继续向用户追问缺失设定。 +2. 基于已有对话与已推断卡片自行补齐剩余关键字。 +3. 输出可进入对应生成结果的完整结构。 +4. 将进度推进到可提交状态,避免用户点击后仍停留在问答循环。 + +三入口只允许保留领域差异:聊天消息 ID 前缀、总结文案、最终生成按钮名称、领域 payload 额外字段、领域 prompt 对“缺失内容范围”的补充说明。除此之外,聊天动作的展示、触发语义与自动补全硬规则必须统一维护。 + ## 4.4 明确支持“锁定” 每张卡片、每个关键角色、每个关键地点都应支持锁定。 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 0421243e..d262256c 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 @@ -202,8 +202,9 @@ 2. 每轮只追问当前最影响图片生成质量的 `1` 个问题。 3. 当创作者已经说出足够信息时,优先总结,不重复追问。 4. 当会话至少完成 `2` 轮后,工作区必须提供 `补充剩余关键字` 快捷动作。 - - 该动作只向 Agent 发送“请补充剩余关键字。”,不在前端补数据、不伪造锚点状态。 - - Agent 收到后应优先补齐仍为 `待补充` / 空值的锚点关键词,并保持每次回复清爽直接。 + - 该动作沿用 RPG 聊天链路,仍走发送消息接口,但请求体必须携带 `quickFillRequested: true`。 + - 前端不补数据、不伪造锚点状态,只发送“请补充剩余关键字。”作为本轮用户消息。 + - Agent 收到后必须进入自动补齐模式,优先补齐仍为 `待补充` / 空值的锚点关键词,不允许继续反问用户。 5. 在进入结果页前,至少确认: - 一句题材承诺 - 一个主要视觉主体 diff --git a/docs/technical/BIG_FISH_DIRECTION_TOUCH_CONTROL_2026-04-24.md b/docs/technical/BIG_FISH_DIRECTION_TOUCH_CONTROL_2026-04-24.md new file mode 100644 index 00000000..3b4d3699 --- /dev/null +++ b/docs/technical/BIG_FISH_DIRECTION_TOUCH_CONTROL_2026-04-24.md @@ -0,0 +1,27 @@ +# 大鱼吃小鱼方向触控操作优化说明 + +## 背景 + +当前大鱼运行时使用左下固定虚拟摇杆,玩家必须点到摇杆区域才能移动。移动端实际体验应改为屏幕任意位置触控:第一次触点只建立方向原点,不直接产生移动;后续触点相对原点形成方向向量,角色按恒定速度朝该方向行动。 + +## 交互规则 + +1. 玩家在玩法舞台内按下时,记录第一个触点坐标为本次操作原点。 +2. 按下瞬间提交 `{ x: 0, y: 0 }`,保证一开始玩家不动。 +3. 手指/鼠标移动后,用“当前触点 - 原点”的向量计算方向。 +4. 输入只表达方向,不表达速度;超过死区后归一化为单位方向向量。 +5. 松开或取消触控后,清空操作原点并提交 `{ x: 0, y: 0 }`。 +6. 前端继续定时提交当前方向,即使没有玩家输入也提交零向量,让后端或本地直达局持续推进世界 tick。 + +## 本地直达局边界 + +- `/big-fish` 的本地占位局必须在玩家未操作时继续移动野生对象。 +- 玩家速度保持恒定,只由方向决定移动方向。 +- 野生对象使用确定性游动规则,避免直达入口看起来像静态截图。 + +## 验收口径 + +1. 在舞台任意位置按下时玩家不立即移动。 +2. 按住并拖动后,玩家朝拖动方向恒速移动。 +3. 松开后玩家停止。 +4. 不操作时野生对象仍会持续游动。 diff --git a/docs/technical/BIG_FISH_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md b/docs/technical/BIG_FISH_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md new file mode 100644 index 00000000..a0c4b96c --- /dev/null +++ b/docs/technical/BIG_FISH_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md @@ -0,0 +1,25 @@ +# 大鱼吃小鱼玩法直达路由说明 + +## 背景 + +现有前端已经包含 `BigFishRuntimeShell`,正式链路从创作中心或作品卡启动后端运行局。为了便于快速验收玩法手感,需要补一个不依赖后端会话的直达入口。 + +## 路由设计 + +- `/big-fish`:进入大鱼吃小鱼玩法直达页。 +- 路由挂在 `src/routing/appRoutes.tsx`,与 `/puzzle` 一样走现有轻量路由解析层,不新增独立路由系统。 +- 每个玩法仅保留一个直达入口,避免 `/play` 这类重复路径造成维护分叉。 + +## 运行态边界 + +- 直达页复用 `BigFishRuntimeShell`,不复制运行时 UI。 +- 初始快照由前端本地构造,背景使用内联 SVG 占位图。 +- 摇杆输入在本地推进角色位置、碰撞与成长等级,仅用于直达体验。 +- 该入口不改变正式 `api-server` 运行局、作品发布、资产生成和 SpacetimeDB 持久化链路。 + +## 验收口径 + +1. 浏览器访问 `/big-fish` 后直接显示竖屏大鱼吃小鱼舞台。 +2. 左下摇杆可移动玩家实体。 +3. 玩家碰到不高于自身等级的实体后成长,并在事件日志显示成长结果。 +4. 左上返回按钮在直达页语义为重开当前占位局。 diff --git a/docs/technical/PUZZLE_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md b/docs/technical/PUZZLE_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md new file mode 100644 index 00000000..389d9393 --- /dev/null +++ b/docs/technical/PUZZLE_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md @@ -0,0 +1,26 @@ +# 拼图玩法直达路由说明 + +## 背景 + +现有前端已经包含拼图运行时组件 `PuzzleRuntimeShell` 和本地运行时 `puzzleLocalRuntime`,但只能从平台创作中心、作品卡或拼图广场链路间接进入。为了快速验证玩法交互,需要补一个可直接打开的前端路由。 + +## 路由设计 + +- `/puzzle`:进入拼图玩法直达页。 +- 路由挂到现有 `src/routing/appRoutes.tsx` 的轻量路由解析层,不引入 React Router,也不新增独立路由系统。 + +## 运行态边界 + +- 直达页复用 `PuzzleRuntimeShell`,不复制棋盘 UI。 +- 初始关卡通过 `startLocalPuzzleRun` 生成,图片使用内联 SVG 占位图。 +- 交换、拖动、重开均走 `puzzleLocalRuntime`,保持与现有前端玩法实现一致。 +- 该入口仅用于直达体验和调试,不改变已发布拼图作品、Agent 创作、拼图广场和后端持久化链路。 + +## 验收口径 + +1. 浏览器访问 `/puzzle` 后直接显示全屏拼图画布。 +2. 棋盘应显示占位图切片,而不是空白格。 +3. 点击两块拼图可以交换;拖动拼图到目标格可以交换位置。 +4. 左上返回按钮在直达页语义为重开当前占位关卡。 + + diff --git a/docs/technical/README.md b/docs/technical/README.md index 0a848063..33314d47 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -1,9 +1,13 @@ -# 技术方案 +# 技术方案 这一组文档偏技术选型、实现路线和外部产品形态拆解。 ## 文档列表 +- [BIG_FISH_DIRECTION_TOUCH_CONTROL_2026-04-24.md](./BIG_FISH_DIRECTION_TOUCH_CONTROL_2026-04-24.md):记录大鱼吃小鱼从固定摇杆改为屏幕首触点方向控制,并要求本地直达局在未操作时保持对象运动。 +- [RUST_WORKSPACE_DEFAULT_BUILD_SCOPE_FIX_2026-04-25.md](./RUST_WORKSPACE_DEFAULT_BUILD_SCOPE_FIX_2026-04-25.md):记录 `server-rs` 无参数 `cargo build` 链接 `spacetime-module` 失败的根因,并冻结默认只构建原生 `api-server`、模块产物继续走 `spacetime build` 的命令边界。 +- [BIG_FISH_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md](./BIG_FISH_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md):记录 `/big-fish` 大鱼吃小鱼玩法直达入口,明确复用现有 `BigFishRuntimeShell` 和本地占位运行态的调试边界。 +- [PUZZLE_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md](./PUZZLE_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md):记录 `/puzzle` 拼图玩法直达入口,明确复用现有 `PuzzleRuntimeShell` 和本地占位图运行态的调试边界。 - [CREATION_AGENT_PUBLISH_GATE_NORMALIZE_WRITEBACK_FIX_2026-04-24.md](./CREATION_AGENT_PUBLISH_GATE_NORMALIZE_WRITEBACK_FIX_2026-04-24.md):记录结果页 profile 归一化回写丢失顶层 `worldHook / playerPremise` 导致 publish gate 继续误报结构 blocker 的根因,并冻结前端归一化保留发布字段的修复口径。 - [CUSTOM_WORLD_RESULT_ENTITY_GENERATION_FIX_2026-04-24.md](./CUSTOM_WORLD_RESULT_ENTITY_GENERATION_FIX_2026-04-24.md):记录世界结果页在 Agent 草稿模式下新增场景、新增 NPC 生成成功但结果页字段不可用的根因,并冻结 `api-server` 生成归一化层补齐 profile 字段的修复口径。 @@ -160,3 +164,5 @@ - 做实现选型时,优先看这一组。 - 做阶段排期时,把这一组和 `docs/planning/`、`docs/prd/` 一起看,更容易判断先后顺序。 + + diff --git a/docs/technical/RUST_WORKSPACE_DEFAULT_BUILD_SCOPE_FIX_2026-04-25.md b/docs/technical/RUST_WORKSPACE_DEFAULT_BUILD_SCOPE_FIX_2026-04-25.md new file mode 100644 index 00000000..dc64f65a --- /dev/null +++ b/docs/technical/RUST_WORKSPACE_DEFAULT_BUILD_SCOPE_FIX_2026-04-25.md @@ -0,0 +1,39 @@ +# Rust Workspace 默认构建范围修复(2026-04-25) + +## 背景 + +`server-rs` 同时包含原生运行的 `api-server` 与只应由 SpacetimeDB CLI 按 wasm 模块方式构建的 `spacetime-module`。 + +直接在 `server-rs` 下执行无参数 `cargo build` 时,Cargo 会尝试构建 workspace 的全部成员,其中 `spacetime-module` 是 `cdylib`,并依赖 `spacetimedb` 模块运行时导入函数。该 crate 不能作为 Windows 原生 DLL 链接,链接阶段会报 `LNK2019 unresolved external symbol`,例如 `datastore_insert_bsatn`、`console_log`、`table_id_from_name`。 + +## 决策 + +1. `cargo build` 默认只构建原生服务入口 `api-server`。 +2. `spacetime-module` 保留为 workspace member,便于 `cargo check --workspace --all-targets` 做类型检查。 +3. `spacetime-module` 的可发布产物必须继续通过 SpacetimeDB CLI 构建,不走无参数 `cargo build` 的原生链接路径。 + +## 落地 + +在 `server-rs/Cargo.toml` 增加: + +```toml +[workspace] +default-members = [ + "crates/api-server", +] +``` + +## 验证命令 + +```powershell +cd D:\Genarrative\server-rs +cargo build +cargo check --workspace --all-targets +spacetime build --module-path crates/spacetime-module +``` + +## 后续约束 + +- 日常本地编译原生后端用 `cargo build` 或 `cargo build -p api-server`。 +- 验证全部 Rust 目标用 `cargo check --workspace --all-targets`。 +- 构建 / 发布 SpacetimeDB 模块用 `spacetime build --module-path crates/spacetime-module` 或发布脚本,不要用原生 `cargo build -p spacetime-module`。 diff --git a/packages/shared/src/contracts/bigFish.ts b/packages/shared/src/contracts/bigFish.ts index f847aa24..a3248f14 100644 --- a/packages/shared/src/contracts/bigFish.ts +++ b/packages/shared/src/contracts/bigFish.ts @@ -9,6 +9,7 @@ export type CreateBigFishSessionRequest = { export type SendBigFishMessageRequest = { clientMessageId: string; text: string; + quickFillRequested?: boolean; }; export type BigFishActionId = diff --git a/packages/shared/src/contracts/puzzleAgentSession.ts b/packages/shared/src/contracts/puzzleAgentSession.ts index b6af08ef..bd0a1c91 100644 --- a/packages/shared/src/contracts/puzzleAgentSession.ts +++ b/packages/shared/src/contracts/puzzleAgentSession.ts @@ -51,6 +51,7 @@ export interface CreatePuzzleAgentSessionResponse { export interface SendPuzzleAgentMessageRequest { clientMessageId: string; text: string; + quickFillRequested?: boolean; } export interface SendPuzzleAgentMessageResponse extends PuzzleAgentActionResponse { diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index ce63588a..821314f0 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -3,6 +3,9 @@ [workspace] resolver = "2" +default-members = [ + "crates/api-server", +] members = [ "crates/api-server", "crates/module-ai", diff --git a/server-rs/crates/api-server/src/big_fish.rs b/server-rs/crates/api-server/src/big_fish.rs index c7c6a950..3079bcec 100644 --- a/server-rs/crates/api-server/src/big_fish.rs +++ b/server-rs/crates/api-server/src/big_fish.rs @@ -233,6 +233,7 @@ pub async fn submit_big_fish_message( BigFishAgentTurnRequest { llm_client: state.llm_client(), session: &submitted_session, + quick_fill_requested: payload.quick_fill_requested.unwrap_or(false), }, move |text| { draft_sink.persist_visible_text_async(text); @@ -321,6 +322,7 @@ pub async fn stream_big_fish_message( .map_err(|error| { big_fish_error_response(&request_context, map_big_fish_client_error(error)) })?; + let quick_fill_requested = payload.quick_fill_requested.unwrap_or(false); let mut draft_writer = AiGenerationDraftWriter::new(AiGenerationDraftContext::new( "big_fish", owner_user_id.as_str(), @@ -346,6 +348,7 @@ pub async fn stream_big_fish_message( BigFishAgentTurnRequest { llm_client: state.llm_client(), session: &submitted_session, + quick_fill_requested, }, |text| { draft_sink.persist_visible_text_async(text); diff --git a/server-rs/crates/api-server/src/big_fish_agent_turn.rs b/server-rs/crates/api-server/src/big_fish_agent_turn.rs index ea3cdd3c..b0046428 100644 --- a/server-rs/crates/api-server/src/big_fish_agent_turn.rs +++ b/server-rs/crates/api-server/src/big_fish_agent_turn.rs @@ -9,11 +9,13 @@ use spacetime_client::{ use crate::creation_agent_anchor_templates::{ get_creation_agent_anchor_template, render_anchor_question_block, }; +use crate::creation_agent_chat::render_quick_fill_extra_rules; #[derive(Clone, Debug)] pub(crate) struct BigFishAgentTurnRequest<'a> { pub llm_client: Option<&'a LlmClient>, pub session: &'a BigFishSessionRecord, + pub quick_fill_requested: bool, } #[derive(Clone, Debug)] @@ -115,7 +117,7 @@ where let llm_client = request .llm_client .ok_or_else(|| BigFishAgentTurnError::new("当前模型不可用,请稍后重试。"))?; - let prompt = build_big_fish_agent_prompt(request.session); + let prompt = build_big_fish_agent_prompt(request.session, request.quick_fill_requested); let mut latest_reply_text = String::new(); let response = llm_client .stream_text( @@ -146,7 +148,11 @@ where Ok(BigFishAgentTurnResult { assistant_reply_text: output.reply_text, stage: BigFishCreationStage::CollectingAnchors.as_str().to_string(), - progress_percent: output.progress_percent.min(100), + progress_percent: if request.quick_fill_requested { + 100 + } else { + output.progress_percent.min(100) + }, anchor_pack_json: serde_json::to_string(&output.next_anchor_pack) .unwrap_or_else(|_| "{}".to_string()), error_message: None, @@ -193,15 +199,33 @@ pub(crate) fn build_failed_finalize_record_input( } } -fn build_big_fish_agent_prompt(session: &BigFishSessionRecord) -> String { +fn build_big_fish_agent_prompt( + session: &BigFishSessionRecord, + quick_fill_requested: bool, +) -> String { let anchor_question_block = get_creation_agent_anchor_template("big_fish") .map(render_anchor_question_block) .unwrap_or_else(|| "模板目标:收束成可玩的竖屏大鱼吃小鱼玩法草稿。".to_string()); + let quick_fill_rules = if quick_fill_requested { + format!( + "\n\n{}", + render_quick_fill_extra_rules( + "当前玩法方向里的成长、生态、风险节奏等缺失关键词", + "不要要求用户再提供等级、鱼群、场景或节奏信息", + "输出完整 nextAnchorPack,直接补齐 value 为空或 status 为 missing 的项", + "生成结果页", + ) + ) + } else { + String::new() + }; format!( - "{anchor_question_block}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n当前 anchor pack:\n{anchor_pack}\n\n最近聊天记录:\n{chat_history}\n\n{contract}", + "{anchor_question_block}{quick_fill_rules}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n是否要求自动补充剩余关键字:{quick_fill_requested_text}\n\n当前 anchor pack:\n{anchor_pack}\n\n最近聊天记录:\n{chat_history}\n\n{contract}", anchor_question_block = anchor_question_block, + quick_fill_rules = quick_fill_rules, turn = session.current_turn.saturating_add(1), progress = session.progress_percent, + quick_fill_requested_text = if quick_fill_requested { "是" } else { "否" }, anchor_pack = serialize_record_anchor_pack(&session.anchor_pack), chat_history = serde_json::to_string_pretty(&build_chat_history(session.messages.as_slice())) @@ -399,3 +423,70 @@ fn extract_reply_text_from_partial_json(text: &str) -> Option { Some(result) } } + +#[cfg(test)] +mod tests { + use super::build_big_fish_agent_prompt; + + fn anchor_item( + key: &str, + label: &str, + value: &str, + status: &str, + ) -> spacetime_client::BigFishAnchorItemRecord { + spacetime_client::BigFishAnchorItemRecord { + key: key.to_string(), + label: label.to_string(), + value: value.to_string(), + status: status.to_string(), + } + } + + fn empty_session_record() -> spacetime_client::BigFishSessionRecord { + spacetime_client::BigFishSessionRecord { + session_id: "big-fish-session-test".to_string(), + current_turn: 2, + progress_percent: 60, + stage: "collecting_anchors".to_string(), + anchor_pack: spacetime_client::BigFishAnchorPackRecord { + gameplay_promise: anchor_item( + "gameplayPromise", + "玩法承诺", + "微光小鱼逆袭深海巨兽", + "confirmed", + ), + ecology_visual_theme: anchor_item( + "ecologyVisualTheme", + "生态视觉主题", + "幽蓝珊瑚海沟", + "confirmed", + ), + growth_ladder: anchor_item("growthLadder", "成长阶梯", "", "missing"), + risk_tempo: anchor_item("riskTempo", "风险节奏", "", "missing"), + }, + draft: None, + asset_slots: Vec::new(), + asset_coverage: spacetime_client::BigFishAssetCoverageRecord { + level_main_image_ready_count: 0, + level_motion_ready_count: 0, + background_ready: false, + required_level_count: 8, + publish_ready: false, + blockers: Vec::new(), + }, + messages: Vec::new(), + last_assistant_reply: None, + publish_ready: false, + updated_at: "2026-04-24T10:00:00.000Z".to_string(), + } + } + + #[test] + fn quick_fill_prompt_forbids_follow_up_questions() { + let prompt = build_big_fish_agent_prompt(&empty_session_record(), true); + + assert!(prompt.contains("用户刚刚主动要求你自动补充剩余关键字")); + assert!(prompt.contains("不要再继续提问")); + assert!(prompt.contains("progressPercent 直接输出为 100")); + } +} diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index 3dbafcaa..288eafee 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -3432,11 +3432,6 @@ mod tests { } #[test] - pub(crate) fn find_motion_template(id: &str) -> Option<&'static MotionTemplate> { - BUILT_IN_MOTION_TEMPLATES - .iter() - .find(|template| template.id == id.trim()) - } fn resolve_character_animation_model_uses_strategy_specific_field() { let payload = CharacterAnimationGenerateRequest { character_id: "hero".to_string(), diff --git a/server-rs/crates/api-server/src/creation_agent_chat.rs b/server-rs/crates/api-server/src/creation_agent_chat.rs new file mode 100644 index 00000000..8b25c033 --- /dev/null +++ b/server-rs/crates/api-server/src/creation_agent_chat.rs @@ -0,0 +1,25 @@ +/// 共创聊天中“补充剩余关键字”的统一提示规则。 +/// +/// RPG、拼图、大鱼吃小鱼都通过聊天补充设定;这里集中维护点击补全后必须进入 +/// 自行补齐、不可继续追问、进度推进到可提交状态的公共约束,避免各入口各写一套。 +pub(crate) fn render_quick_fill_extra_rules( + acceptance_scope: &str, + forbidden_follow_up: &str, + completion_target: &str, + submit_hint: &str, +) -> String { + format!( + r#"用户刚刚主动要求你自动补充剩余关键字。 + +这表示用户接受你基于当前方向自行补完仍缺失的关键设定:{acceptance_scope} + +本轮要求: +1. 不要再继续提问 +2. {forbidden_follow_up} +3. 必须保留已有已确认内容,并直接补齐缺失或仍为空的关键项 +4. 对你自行推断补齐的项,应标记或表达为系统推断;已有明确内容继续保持确认或锁定状态 +5. progressPercent 直接输出为 100 +6. {completion_target} +7. replyText 只做简短完成说明,引导用户可以{submit_hint},不能出现问号"#, + ) +} diff --git a/server-rs/crates/api-server/src/custom_world.rs b/server-rs/crates/api-server/src/custom_world.rs index 64d8d05c..c65c0b04 100644 --- a/server-rs/crates/api-server/src/custom_world.rs +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -2297,24 +2297,6 @@ fn has_custom_world_scene_act(profile: Option<&Map>) -> bool { .unwrap_or(false) } -fn ensure_non_empty( - request_context: &RequestContext, - value: &str, - field_name: &str, -) -> Result<(), Response> { - if value.trim().is_empty() { - return Err(custom_world_error_response( - request_context, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "custom-world-agent", - "message": format!("{field_name} is required"), - })), - )); - } - - Ok(()) -} - fn map_custom_world_publish_gate_response( gate: CustomWorldPublishGateRecord, ) -> CustomWorldPublishGateResponse { @@ -2476,6 +2458,24 @@ fn custom_world_error_response(request_context: &RequestContext, error: AppError error.into_response_with_context(Some(request_context)) } +fn ensure_non_empty( + request_context: &RequestContext, + value: &str, + field_name: &str, +) -> Result<(), Response> { + if value.trim().is_empty() { + return Err(custom_world_error_response( + request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world", + "message": format!("{field_name} is required"), + })), + )); + } + + Ok(()) +} + fn custom_world_sse_json_event(event_name: &str, payload: Value) -> Result { Event::default() .event(event_name) diff --git a/server-rs/crates/api-server/src/custom_world_agent_turn.rs b/server-rs/crates/api-server/src/custom_world_agent_turn.rs index 8ffa6d5d..ec5245bd 100644 --- a/server-rs/crates/api-server/src/custom_world_agent_turn.rs +++ b/server-rs/crates/api-server/src/custom_world_agent_turn.rs @@ -7,11 +7,12 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value as JsonValue, json}; use crate::custom_world_rpg_draft_prompts::{ - BASE_SYSTEM_PROMPT, GLOBAL_HARD_RULES, OUTPUT_CONTRACT_REMINDER, QUICK_FILL_EXTRA_RULES, + BASE_SYSTEM_PROMPT, GLOBAL_HARD_RULES, OUTPUT_CONTRACT_REMINDER, STATE_INFERENCE_OUTPUT_CONTRACT, STATE_INFERENCE_SYSTEM_PROMPT, extract_reply_text_from_partial_json, mode_rules, parse_conversation_mode, parse_drift_risk, - parse_json_response_text, parse_user_input_signal, render_chat_history_context, - render_current_anchor_context, render_dynamic_state_context, user_signal_rules, + parse_json_response_text, parse_user_input_signal, quick_fill_extra_rules, + render_chat_history_context, render_current_anchor_context, render_dynamic_state_context, + user_signal_rules, }; use spacetime_client::{ CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord, @@ -799,7 +800,7 @@ fn build_eight_anchor_single_turn_prompt( user_signal_rules(dynamic_state.user_input_signal).to_string(), ]; if quick_fill_requested { - blocks.push(QUICK_FILL_EXTRA_RULES.to_string()); + blocks.push(quick_fill_extra_rules()); } blocks.push(render_dynamic_state_context(dynamic_state)); blocks.push(render_current_anchor_context(current_anchor_content)); diff --git a/server-rs/crates/api-server/src/custom_world_rpg_draft_prompts.rs b/server-rs/crates/api-server/src/custom_world_rpg_draft_prompts.rs index 4bc56e8c..fe67bc7b 100644 --- a/server-rs/crates/api-server/src/custom_world_rpg_draft_prompts.rs +++ b/server-rs/crates/api-server/src/custom_world_rpg_draft_prompts.rs @@ -1,3 +1,4 @@ +use crate::creation_agent_chat::render_quick_fill_extra_rules; use crate::custom_world_agent_turn::{ EightAnchorContent, PromptConversationMode, PromptDriftRisk, PromptDynamicState, PromptUserInputSignal, @@ -41,15 +42,14 @@ pub(crate) const GLOBAL_HARD_RULES: &str = r#"全局硬约束: 11. 你输出的 JSON 必须可以被直接解析。 12. 输出字段顺序必须固定为:replyText、progressPercent、nextAnchorContent。"#; -pub(crate) const QUICK_FILL_EXTRA_RULES: &str = r#"用户刚刚主动要求你自动补全剩余设定。 - -这表示用户接受你基于当前方向自动补完剩余设定。 - -本轮要求: -1. 不要再继续提问 -2. 直接输出一版尽量完整的设定结构 -3. progressPercent 直接输出为 100 -4. replyText 要告诉用户现在可以进入“生成游戏设定草稿”"#; +pub(crate) fn quick_fill_extra_rules() -> String { + render_quick_fill_extra_rules( + "当前 RPG 世界方向里的剩余设定", + "不要要求用户再提供世界观、角色、冲突或禁忌信息", + "直接输出一版尽量完整的设定结构", + "进入“生成游戏设定草稿”", + ) +} pub(crate) const STATE_INFERENCE_SYSTEM_PROMPT: &str = r#"你是正式生成世界设定前的一步“创作状态识别器”。 你的职责不是直接生成新设定,而是先判断:下一轮正式生成应该用什么推进策略,尤其要判断 replyText 应该更偏确认、吸收、收束、纠偏,还是启发式提问。 diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index a4059d93..9bf18ace 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -16,6 +16,7 @@ mod character_animation_assets; mod character_visual_assets; mod config; mod creation_agent_anchor_templates; +mod creation_agent_chat; mod custom_world; mod custom_world_agent_entities; mod custom_world_agent_turn; diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index ff1ac1a8..5f665ed3 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -213,6 +213,7 @@ pub async fn submit_puzzle_agent_message( PuzzleAgentTurnRequest { llm_client: state.llm_client(), session: &submitted_session, + quick_fill_requested: payload.quick_fill_requested.unwrap_or(false), }, |_| {}, ) @@ -278,6 +279,7 @@ pub async fn stream_puzzle_agent_message( )?; let owner_user_id = authenticated.claims().user_id().to_string(); + let quick_fill_requested = payload.quick_fill_requested.unwrap_or(false); let session = state .spacetime_client() .submit_puzzle_agent_message(PuzzleAgentMessageSubmitRecordInput { @@ -315,6 +317,7 @@ pub async fn stream_puzzle_agent_message( PuzzleAgentTurnRequest { llm_client: state.llm_client(), session: &session, + quick_fill_requested, }, move |text| { let _ = reply_tx.send(text.to_string()); diff --git a/server-rs/crates/api-server/src/puzzle_agent_turn.rs b/server-rs/crates/api-server/src/puzzle_agent_turn.rs index e8090ee2..178ee360 100644 --- a/server-rs/crates/api-server/src/puzzle_agent_turn.rs +++ b/server-rs/crates/api-server/src/puzzle_agent_turn.rs @@ -9,11 +9,13 @@ use spacetime_client::{ use crate::creation_agent_anchor_templates::{ get_creation_agent_anchor_template, render_anchor_question_block, }; +use crate::creation_agent_chat::render_quick_fill_extra_rules; #[derive(Clone, Debug)] pub(crate) struct PuzzleAgentTurnRequest<'a> { pub llm_client: Option<&'a LlmClient>, pub session: &'a PuzzleAgentSessionRecord, + pub quick_fill_requested: bool, } #[derive(Clone, Debug)] @@ -122,7 +124,7 @@ where .llm_client .ok_or_else(|| PuzzleAgentTurnError::new("当前模型不可用,请稍后重试。"))?; - let prompt = build_puzzle_agent_prompt(request.session); + let prompt = build_puzzle_agent_prompt(request.session, request.quick_fill_requested); let mut latest_reply_text = String::new(); let response = llm_client .stream_text( @@ -155,7 +157,11 @@ where stage: resolve_puzzle_agent_stage(output.progress_percent) .as_str() .to_string(), - progress_percent: output.progress_percent, + progress_percent: if request.quick_fill_requested { + 100 + } else { + output.progress_percent + }, anchor_pack_json: serde_json::to_string(&output.next_anchor_pack).unwrap_or_else(|_| { serde_json::to_string(&empty_anchor_pack()).unwrap_or_else(|_| "{}".to_string()) }), @@ -207,15 +213,33 @@ pub(crate) fn build_failed_finalize_record_input( } } -fn build_puzzle_agent_prompt(session: &PuzzleAgentSessionRecord) -> String { +fn build_puzzle_agent_prompt( + session: &PuzzleAgentSessionRecord, + quick_fill_requested: bool, +) -> String { let anchor_question_block = get_creation_agent_anchor_template("puzzle") .map(render_anchor_question_block) .unwrap_or_else(|| "模板目标:收束成可以发布为拼图关卡的视觉方案。".to_string()); + let quick_fill_rules = if quick_fill_requested { + format!( + "\n\n{}", + render_quick_fill_extra_rules( + "当前题材方向里的拼图关键词", + "不要要求用户再提供素材、风格或禁忌", + "输出完整 nextAnchorPack,直接补齐 value 为空或 status 为 missing 的项", + "生成结果页", + ) + ) + } else { + String::new() + }; format!( - "{anchor_question_block}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n当前 anchor pack:\n{anchor_pack}\n\n最近聊天记录:\n{chat_history}\n\n{contract}", + "{anchor_question_block}{quick_fill_rules}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n是否要求自动补充剩余关键字:{quick_fill_requested_text}\n\n当前 anchor pack:\n{anchor_pack}\n\n最近聊天记录:\n{chat_history}\n\n{contract}", anchor_question_block = anchor_question_block, + quick_fill_rules = quick_fill_rules, turn = session.current_turn.saturating_add(1), progress = session.progress_percent, + quick_fill_requested_text = if quick_fill_requested { "是" } else { "否" }, anchor_pack = serde_json::to_string_pretty(&map_record_anchor_pack(&session.anchor_pack)) .unwrap_or_else(|_| "{}".to_string()), chat_history = @@ -430,7 +454,57 @@ mod tests { use module_puzzle::PuzzleAnchorStatus; use serde_json::json; - use super::{extract_reply_text_from_partial_json, parse_model_output}; + use super::{ + build_puzzle_agent_prompt, extract_reply_text_from_partial_json, parse_model_output, + }; + + fn empty_session_record() -> spacetime_client::PuzzleAgentSessionRecord { + spacetime_client::PuzzleAgentSessionRecord { + session_id: "puzzle-session-test".to_string(), + current_turn: 2, + progress_percent: 60, + stage: "collecting_anchors".to_string(), + anchor_pack: spacetime_client::PuzzleAnchorPackRecord { + theme_promise: spacetime_client::PuzzleAnchorItemRecord { + key: "themePromise".to_string(), + label: "题材承诺".to_string(), + value: "雨夜猫咪遗迹".to_string(), + status: "confirmed".to_string(), + }, + visual_subject: spacetime_client::PuzzleAnchorItemRecord { + key: "visualSubject".to_string(), + label: "画面主体".to_string(), + value: String::new(), + status: "missing".to_string(), + }, + visual_mood: spacetime_client::PuzzleAnchorItemRecord { + key: "visualMood".to_string(), + label: "视觉气质".to_string(), + value: String::new(), + status: "missing".to_string(), + }, + composition_hooks: spacetime_client::PuzzleAnchorItemRecord { + key: "compositionHooks".to_string(), + label: "拼图记忆点".to_string(), + value: String::new(), + status: "missing".to_string(), + }, + tags_and_forbidden: spacetime_client::PuzzleAnchorItemRecord { + key: "tagsAndForbidden".to_string(), + label: "标签与禁忌".to_string(), + value: String::new(), + status: "missing".to_string(), + }, + }, + draft: None, + messages: Vec::new(), + last_assistant_reply: None, + published_profile_id: None, + suggested_actions: Vec::new(), + result_preview: None, + updated_at: "2026-04-24T10:00:00.000Z".to_string(), + } + } #[test] fn extract_reply_text_from_partial_json_preserves_chinese_characters() { @@ -496,4 +570,13 @@ mod tests { "雨夜、猫咪、神庙遗迹;禁止文字水印" ); } + + #[test] + fn quick_fill_prompt_forbids_follow_up_questions() { + let prompt = build_puzzle_agent_prompt(&empty_session_record(), true); + + assert!(prompt.contains("用户刚刚主动要求你自动补充剩余关键字")); + assert!(prompt.contains("不要再继续提问")); + assert!(prompt.contains("progressPercent 直接输出为 100")); + } } diff --git a/server-rs/crates/module-custom-world/src/lib.rs b/server-rs/crates/module-custom-world/src/lib.rs index a99a8091..9d8fe120 100644 --- a/server-rs/crates/module-custom-world/src/lib.rs +++ b/server-rs/crates/module-custom-world/src/lib.rs @@ -1775,6 +1775,8 @@ mod tests { let error = validate_custom_world_profile_upsert_input(&CustomWorldProfileUpsertInput { profile_id: "cwprof_001".to_string(), owner_user_id: "user_001".to_string(), + public_work_code: None, + author_public_user_code: None, source_agent_session_id: None, world_name: "裂潮边城".to_string(), subtitle: "港口余烬".to_string(), diff --git a/server-rs/crates/shared-contracts/src/big_fish.rs b/server-rs/crates/shared-contracts/src/big_fish.rs index 3f7eecc7..282c163e 100644 --- a/server-rs/crates/shared-contracts/src/big_fish.rs +++ b/server-rs/crates/shared-contracts/src/big_fish.rs @@ -12,6 +12,8 @@ pub struct CreateBigFishSessionRequest { pub struct SendBigFishMessageRequest { pub client_message_id: String, pub text: String, + #[serde(default)] + pub quick_fill_requested: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/server-rs/crates/shared-contracts/src/puzzle_agent.rs b/server-rs/crates/shared-contracts/src/puzzle_agent.rs index 595fe98d..8275c885 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_agent.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_agent.rs @@ -12,6 +12,8 @@ pub struct CreatePuzzleAgentSessionRequest { pub struct SendPuzzleAgentMessageRequest { pub client_message_id: String, pub text: String, + #[serde(default)] + pub quick_fill_requested: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index ce285c7b..f6a0fa14 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -1573,6 +1573,9 @@ fn deserialize_run(value: &str) -> Result { #[cfg(test)] mod tests { use super::*; + use module_puzzle::{ + build_generated_candidates, empty_anchor_pack, recommendation_score, tag_similarity_score, + }; #[test] fn puzzle_json_round_trip_keeps_snapshot_shape() { diff --git a/src/BigFishPlaygroundApp.tsx b/src/BigFishPlaygroundApp.tsx new file mode 100644 index 00000000..d11179a4 --- /dev/null +++ b/src/BigFishPlaygroundApp.tsx @@ -0,0 +1,219 @@ +import { useCallback, useMemo, useState } from 'react'; + +import type { + BigFishAssetSlotResponse, + BigFishRuntimeEntityResponse, + BigFishRuntimeSnapshotResponse, + SubmitBigFishInputRequest, +} from '../packages/shared/src/contracts/bigFish'; +import { BigFishRuntimeShell } from './components/big-fish-runtime/BigFishRuntimeShell'; + +const BIG_FISH_BACKGROUND_IMAGE = + 'data:image/svg+xml;utf8,' + + encodeURIComponent(` + + + + + + + + + + + + + + + + + + + + +`); + +const WORLD_MIN_X = 60; +const WORLD_MAX_X = 780; +const WORLD_MIN_Y = 80; +const WORLD_MAX_Y = 1240; +const PLAYER_SPEED = 20; + +function clamp(value: number, min: number, max: number) { + return Math.max(min, Math.min(max, value)); +} + +function buildEntity( + entityId: string, + level: number, + x: number, + y: number, +): BigFishRuntimeEntityResponse { + return { + entityId, + level, + position: { x, y }, + radius: 12 + level * 5, + offscreenSeconds: 0, + }; +} + +function buildInitialRun(): BigFishRuntimeSnapshotResponse { + const leader = buildEntity('player-leader', 1, 360, 640); + return { + runId: `local-big-fish-run-${Date.now()}`, + sessionId: 'local-big-fish-session', + status: 'running', + tick: 0, + playerLevel: 1, + winLevel: 5, + leaderEntityId: leader.entityId, + ownedEntities: [leader], + wildEntities: [ + buildEntity('wild-small-1', 1, 250, 560), + buildEntity('wild-small-2', 1, 470, 760), + buildEntity('wild-mid-1', 2, 560, 520), + buildEntity('wild-mid-2', 3, 210, 820), + buildEntity('wild-boss-1', 5, 610, 930), + ], + cameraCenter: { ...leader.position }, + lastInput: { x: 0, y: 0 }, + eventLog: ['按住屏幕任意位置,再拖动控制方向。'], + updatedAt: new Date().toISOString(), + }; +} + +function distanceBetween( + first: BigFishRuntimeEntityResponse, + second: BigFishRuntimeEntityResponse, +) { + return Math.hypot( + first.position.x - second.position.x, + first.position.y - second.position.y, + ); +} + +function respawnWildEntity(entity: BigFishRuntimeEntityResponse, tick: number) { + const offset = tick * 37 + entity.level * 53; + return { + ...entity, + position: { + x: WORLD_MIN_X + (offset % Math.floor(WORLD_MAX_X - WORLD_MIN_X)), + y: WORLD_MIN_Y + ((offset * 7) % Math.floor(WORLD_MAX_Y - WORLD_MIN_Y)), + }, + }; +} + +function moveWildEntity(entity: BigFishRuntimeEntityResponse, tick: number) { + const phase = tick * 0.32 + entity.level * 1.7; + const speed = 6 + entity.level * 0.8; + const nextX = entity.position.x + Math.cos(phase) * speed; + const nextY = entity.position.y + Math.sin(phase * 0.73) * speed; + return { + ...entity, + position: { + x: clamp(nextX, WORLD_MIN_X, WORLD_MAX_X), + y: clamp(nextY, WORLD_MIN_Y, WORLD_MAX_Y), + }, + }; +} + +function applyLocalInput( + run: BigFishRuntimeSnapshotResponse, + input: SubmitBigFishInputRequest, +): BigFishRuntimeSnapshotResponse { + if (run.status !== 'running') { + return run; + } + + const leader = run.ownedEntities.find( + (entity) => entity.entityId === run.leaderEntityId, + ); + if (!leader) { + return run; + } + + const nextLeader = { + ...leader, + position: { + x: clamp(leader.position.x + input.x * PLAYER_SPEED, WORLD_MIN_X, WORLD_MAX_X), + y: clamp(leader.position.y + input.y * PLAYER_SPEED, WORLD_MIN_Y, WORLD_MAX_Y), + }, + }; + + let nextPlayerLevel = run.playerLevel; + const nextEvents = [...run.eventLog]; + const nextWildEntities = run.wildEntities.map((entity) => { + const movedEntity = moveWildEntity(entity, run.tick + 1); + const touched = distanceBetween(nextLeader, movedEntity) <= nextLeader.radius + movedEntity.radius; + if (!touched) { + return movedEntity; + } + + if (movedEntity.level <= nextPlayerLevel) { + nextPlayerLevel = Math.min(run.winLevel, nextPlayerLevel + 1); + nextEvents.push(`吞噬 Lv.${movedEntity.level},成长到 Lv.${nextPlayerLevel}`); + return respawnWildEntity(movedEntity, run.tick + nextPlayerLevel); + } + + nextEvents.push(`撞上 Lv.${movedEntity.level},暂时避开更大的鱼。`); + return movedEntity; + }); + + const scaledLeader = { + ...nextLeader, + level: nextPlayerLevel, + radius: 12 + nextPlayerLevel * 5, + }; + const status = nextPlayerLevel >= run.winLevel ? 'won' : 'running'; + if (status === 'won' && run.status !== 'won') { + nextEvents.push('已经成长为海域霸主。'); + } + + return { + ...run, + status, + tick: run.tick + 1, + playerLevel: nextPlayerLevel, + ownedEntities: [scaledLeader], + wildEntities: nextWildEntities, + cameraCenter: { ...scaledLeader.position }, + lastInput: input, + eventLog: nextEvents.slice(-5), + updatedAt: new Date().toISOString(), + }; +} + +export default function BigFishPlaygroundApp() { + const [run, setRun] = useState(buildInitialRun); + const assetSlots = useMemo( + () => [ + { + slotId: 'local-big-fish-background', + assetKind: 'stage_background', + status: 'ready', + assetUrl: BIG_FISH_BACKGROUND_IMAGE, + promptSnapshot: '本地直达入口占位海域背景', + updatedAt: new Date(0).toISOString(), + }, + ], + [], + ); + + const handleSubmitInput = useCallback((payload: SubmitBigFishInputRequest) => { + setRun((currentRun) => applyLocalInput(currentRun, payload)); + }, []); + + const handleRestart = useCallback(() => { + setRun(buildInitialRun()); + }, []); + + return ( + + ); +} diff --git a/src/PuzzlePlaygroundApp.tsx b/src/PuzzlePlaygroundApp.tsx new file mode 100644 index 00000000..e4798653 --- /dev/null +++ b/src/PuzzlePlaygroundApp.tsx @@ -0,0 +1,89 @@ +import { useMemo, useState } from 'react'; + +import type { + DragPuzzlePieceRequest, + SwapPuzzlePiecesRequest, +} from '../packages/shared/src/contracts/puzzleRuntimeSession'; +import type { PuzzleWorkSummary } from '../packages/shared/src/contracts/puzzleWorkSummary'; +import { PuzzleRuntimeShell } from './components/puzzle-runtime/PuzzleRuntimeShell'; +import { + advanceLocalPuzzleLevel, + dragLocalPuzzlePiece, + startLocalPuzzleRun, + swapLocalPuzzlePieces, +} from './services/puzzle-runtime/puzzleLocalRuntime'; + +const PLACEHOLDER_PUZZLE_IMAGE = + 'data:image/svg+xml;utf8,' + + encodeURIComponent(` + + + + + + + + + + + + + + + + + + + +`); + +function buildPlaceholderPuzzleWork(): PuzzleWorkSummary { + return { + workId: 'placeholder-puzzle-work', + profileId: 'placeholder-puzzle-profile', + ownerUserId: 'placeholder-user', + sourceSessionId: null, + authorDisplayName: '占位作者', + levelName: '暮色群山', + summary: '用于直达玩法调试的本地占位拼图。', + themeTags: ['占位', '风景', '调试'], + coverImageSrc: PLACEHOLDER_PUZZLE_IMAGE, + coverAssetId: null, + publicationStatus: 'published', + updatedAt: new Date(0).toISOString(), + publishedAt: new Date(0).toISOString(), + playCount: 0, + publishReady: true, + }; +} + +export default function PuzzlePlaygroundApp() { + const placeholderWork = useMemo(() => buildPlaceholderPuzzleWork(), []); + const [run, setRun] = useState(() => startLocalPuzzleRun(placeholderWork)); + + const handleSwapPieces = (payload: SwapPuzzlePiecesRequest) => { + setRun((currentRun) => swapLocalPuzzlePieces(currentRun, payload)); + }; + + const handleDragPiece = (payload: DragPuzzlePieceRequest) => { + setRun((currentRun) => dragLocalPuzzlePiece(currentRun, payload)); + }; + + const handleRestart = () => { + setRun(startLocalPuzzleRun(placeholderWork)); + }; + + const handleAdvanceNextLevel = () => { + setRun((currentRun) => advanceLocalPuzzleLevel(currentRun)); + }; + + return ( + + ); +} diff --git a/src/components/big-fish-creation/BigFishAgentWorkspace.interaction.test.tsx b/src/components/big-fish-creation/BigFishAgentWorkspace.interaction.test.tsx index c9d52eef..906ca63f 100644 --- a/src/components/big-fish-creation/BigFishAgentWorkspace.interaction.test.tsx +++ b/src/components/big-fish-creation/BigFishAgentWorkspace.interaction.test.tsx @@ -1,4 +1,4 @@ -/* @vitest-environment jsdom */ +/* @vitest-environment jsdom */ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -81,11 +81,12 @@ test('big fish workspace submits quick keyword fill request after two turns', as />, ); - await user.click(screen.getByRole('button', { name: '补充剩余关键字' })); + await user.click(screen.getByRole('button', { name: '补充剩余设定' })); expect(onSubmitMessage).toHaveBeenCalledWith( expect.objectContaining({ - text: '请补充剩余关键字。', + text: '请补充剩余设定。', + quickFillRequested: true, }), ); }); @@ -100,5 +101,5 @@ test('big fish workspace hides keyword fill before two turns', () => { />, ); - expect(screen.queryByRole('button', { name: '补充剩余关键字' })).toBeNull(); + expect(screen.queryByRole('button', { name: '补充剩余设定' })).toBeNull(); }); diff --git a/src/components/big-fish-creation/BigFishAgentWorkspace.tsx b/src/components/big-fish-creation/BigFishAgentWorkspace.tsx index bd9673ab..ac17fdf9 100644 --- a/src/components/big-fish-creation/BigFishAgentWorkspace.tsx +++ b/src/components/big-fish-creation/BigFishAgentWorkspace.tsx @@ -4,7 +4,12 @@ import type { ExecuteBigFishActionRequest, SendBigFishMessageRequest, } from '../../../packages/shared/src/contracts/bigFish'; -import { createCreationAgentClientMessageId } from '../../services/creation-agent'; +import { + buildCreationAgentChatMessage, + createCreationAgentChatQuickActions, + createCreationAgentClientMessageId, + resolveCreationAgentQuickActionMessage, +} from '../../services/creation-agent'; import { type CreationAgentAnchorView, type CreationAgentSessionView, @@ -84,35 +89,30 @@ export function BigFishAgentWorkspace({ isStreamingReply={Boolean(streamingReplyText)} isBusy={isBusy} error={error} - quickActions={[ - { - key: 'summarize', - label: '总结当前设定', - }, - { - key: 'quickFill', - label: '补充剩余关键字', - minTurn: 2, - }, - ]} + quickActions={createCreationAgentChatQuickActions()} onBack={onBack} onSubmitText={(text) => { - onSubmitMessage({ - clientMessageId: createCreationAgentClientMessageId('big-fish'), - text, - }); + onSubmitMessage( + buildCreationAgentChatMessage({ + clientMessageId: createCreationAgentClientMessageId('big-fish'), + text, + }), + ); }} onPrimaryAction={() => { onExecuteAction({ action: 'big_fish_compile_draft' }); }} onQuickAction={(action) => { - onSubmitMessage({ - clientMessageId: createCreationAgentClientMessageId('big-fish'), - text: - action.key === 'quickFill' - ? '请补充剩余关键字。' - : '请总结一下当前已经成形的大鱼吃小鱼设定。', - }); + const quickActionMessage = resolveCreationAgentQuickActionMessage( + action.key, + '请总结一下当前已经成形的大鱼吃小鱼设定。', + ); + onSubmitMessage( + buildCreationAgentChatMessage({ + clientMessageId: createCreationAgentClientMessageId('big-fish'), + ...quickActionMessage, + }), + ); }} /> ); diff --git a/src/components/big-fish-runtime/BigFishRuntimeShell.tsx b/src/components/big-fish-runtime/BigFishRuntimeShell.tsx index c729e33c..e49471d1 100644 --- a/src/components/big-fish-runtime/BigFishRuntimeShell.tsx +++ b/src/components/big-fish-runtime/BigFishRuntimeShell.tsx @@ -1,5 +1,5 @@ import { ArrowLeft, Loader2 } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, type PointerEvent } from 'react'; import type { BigFishAssetSlotResponse, @@ -9,6 +9,12 @@ import type { } from '../../../packages/shared/src/contracts/bigFish'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; +type TouchOrigin = { + pointerId: number; + x: number; + y: number; +}; + type BigFishRuntimeShellProps = { run: BigFishRuntimeSnapshotResponse | null; assetSlots?: BigFishAssetSlotResponse[]; @@ -34,6 +40,20 @@ function normalizeVector(x: number, y: number) { }; } +function resolveDirectionFromOrigin( + origin: TouchOrigin, + clientX: number, + clientY: number, +) { + const deadZone = 12; + const deltaX = clientX - origin.x; + const deltaY = clientY - origin.y; + if (Math.hypot(deltaX, deltaY) < deadZone) { + return { x: 0, y: 0 }; + } + return normalizeVector(deltaX, deltaY); +} + function projectEntity( entity: BigFishRuntimeEntityResponse, run: BigFishRuntimeSnapshotResponse, @@ -152,7 +172,8 @@ export function BigFishRuntimeShell({ onBack, onSubmitInput, }: BigFishRuntimeShellProps) { - const padRef = useRef(null); + const stageRef = useRef(null); + const [touchOrigin, setTouchOrigin] = useState(null); const [stick, setStick] = useState({ x: 0, y: 0 }); const stickRef = useRef(stick); @@ -163,7 +184,7 @@ export function BigFishRuntimeShell({ useEffect(() => { const timer = window.setInterval(() => { const current = stickRef.current; - // 即使摇杆静止也持续回传当前输入,让后端持续推进刷怪、清理与胜负裁决。 + // 即使没有方向输入也持续回传当前状态,让后端持续推进刷怪、清理与胜负裁决。 onSubmitInput(current); }, 220); @@ -172,20 +193,39 @@ export function BigFishRuntimeShell({ }; }, [onSubmitInput]); - const updateStickFromPointer = (clientX: number, clientY: number) => { - const pad = padRef.current; - if (!pad) { + const submitDirection = (direction: SubmitBigFishInputRequest) => { + setStick(direction); + onSubmitInput(direction); + }; + + const beginTouchControl = (event: PointerEvent) => { + if (event.target instanceof HTMLElement && event.target.closest('button')) { return; } - const rect = pad.getBoundingClientRect(); - const centerX = rect.left + rect.width / 2; - const centerY = rect.top + rect.height / 2; - const vector = normalizeVector( - (clientX - centerX) / (rect.width / 2), - (clientY - centerY) / (rect.height / 2), + event.currentTarget.setPointerCapture(event.pointerId); + setTouchOrigin({ + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + }); + submitDirection({ x: 0, y: 0 }); + }; + + const updateTouchControl = (event: PointerEvent) => { + if (!touchOrigin || touchOrigin.pointerId !== event.pointerId) { + return; + } + submitDirection( + resolveDirectionFromOrigin(touchOrigin, event.clientX, event.clientY), ); - setStick(vector); - onSubmitInput(vector); + }; + + const endTouchControl = (event: PointerEvent) => { + if (!touchOrigin || touchOrigin.pointerId !== event.pointerId) { + return; + } + setTouchOrigin(null); + submitDirection({ x: 0, y: 0 }); }; if (!run) { @@ -206,7 +246,14 @@ export function BigFishRuntimeShell({ return (
-
+
{backgroundAsset ? ( -
-
{ - event.currentTarget.setPointerCapture(event.pointerId); - updateStickFromPointer(event.clientX, event.clientY); - }} - onPointerMove={(event) => { - if (event.buttons <= 0) { - return; - } - updateStickFromPointer(event.clientX, event.clientY); - }} - onPointerUp={() => { - setStick({ x: 0, y: 0 }); - onSubmitInput({ x: 0, y: 0 }); - }} - onPointerCancel={() => { - setStick({ x: 0, y: 0 }); - onSubmitInput({ x: 0, y: 0 }); - }} - > -
-
-
- -
+
{isBusy ?
同步中...
: null} {error ?
{error}
: null} {run.eventLog.slice(-3).map((event) => ( diff --git a/src/components/creation-agent/CreationAgentWorkspace.test.tsx b/src/components/creation-agent/CreationAgentWorkspace.test.tsx index 8324086f..3d5d6192 100644 --- a/src/components/creation-agent/CreationAgentWorkspace.test.tsx +++ b/src/components/creation-agent/CreationAgentWorkspace.test.tsx @@ -1,8 +1,10 @@ -/* @vitest-environment jsdom */ +/* @vitest-environment jsdom */ import { fireEvent, render, screen } from '@testing-library/react'; import { afterEach, expect, test, vi } from 'vitest'; +import { createCreationAgentChatQuickActions } from '../../services/creation-agent'; + import { type CreationAgentTheme, CreationAgentWorkspace, @@ -243,17 +245,7 @@ test('creation agent workspace shows primary and progress actions at completed p loadingText="正在准备" composerPlaceholder="输入消息" primaryActionLabel="生成结果页" - quickActions={[ - { - key: 'summarize', - label: '总结当前设定', - }, - { - key: 'quickFill', - label: '补全剩余设定', - minTurn: 2, - }, - ]} + quickActions={createCreationAgentChatQuickActions()} onBack={() => {}} onSubmitText={() => {}} onPrimaryAction={() => {}} @@ -262,7 +254,7 @@ test('creation agent workspace shows primary and progress actions at completed p expect(screen.getByRole('button', { name: '生成结果页' })).toBeTruthy(); expect(screen.getByRole('button', { name: '总结当前设定' })).toBeTruthy(); - expect(screen.getByRole('button', { name: '补全剩余设定' })).toBeTruthy(); + expect(screen.getByRole('button', { name: '补充剩余设定' })).toBeTruthy(); }); test('creation agent workspace hides hero copy area when title and summary are absent', () => { diff --git a/src/components/custom-world-agent/CustomWorldAgentWorkspace.interaction.test.tsx b/src/components/custom-world-agent/CustomWorldAgentWorkspace.interaction.test.tsx index ba2f3886..10ea1bd2 100644 --- a/src/components/custom-world-agent/CustomWorldAgentWorkspace.interaction.test.tsx +++ b/src/components/custom-world-agent/CustomWorldAgentWorkspace.interaction.test.tsx @@ -1,4 +1,4 @@ -/* @vitest-environment jsdom */ +/* @vitest-environment jsdom */ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -108,11 +108,11 @@ test('workspace enables quick fill after at least two turns and submits quick fi />, ); - await user.click(screen.getByRole('button', { name: '补全剩余设定' })); + await user.click(screen.getByRole('button', { name: '补充剩余设定' })); expect(onSubmitMessage).toHaveBeenCalledWith( expect.objectContaining({ - text: '请补全剩余设定。', + text: '请补充剩余设定。', quickFillRequested: true, }), ); diff --git a/src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx b/src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx index 1673ca71..542f6e83 100644 --- a/src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx +++ b/src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx @@ -5,8 +5,11 @@ import type { SendCustomWorldAgentMessageRequest, } from '../../../packages/shared/src/contracts/customWorldAgent'; import { + buildCreationAgentChatMessage, + createCreationAgentChatQuickActions, createCreationAgentClientMessageId, isCreationAgentOperationBusy, + resolveCreationAgentQuickActionMessage, } from '../../services/creation-agent'; import { type CreationAgentAnchorView, @@ -166,13 +169,17 @@ export function CustomWorldAgentWorkspace({ isCreationAgentOperationBusy(activeOperation) || isStreamingReply; const submitMessage = (text: string, quickFillRequested = false) => { - onSubmitMessage({ - clientMessageId: createCreationAgentClientMessageId('custom-world'), - text, - quickFillRequested, - focusCardId: null, - selectedCardIds: [], - }); + onSubmitMessage( + buildCreationAgentChatMessage({ + clientMessageId: createCreationAgentClientMessageId('custom-world'), + text, + quickFillRequested, + extraPayload: { + focusCardId: null, + selectedCardIds: [], + }, + }), + ); }; return ( @@ -186,17 +193,7 @@ export function CustomWorldAgentWorkspace({ streamingReplyText={streamingReplyText} isStreamingReply={isStreamingReply} isBusy={isBusy} - quickActions={[ - { - key: 'summarize', - label: '总结当前设定', - }, - { - key: 'quickFill', - label: '补全剩余设定', - minTurn: 2, - }, - ]} + quickActions={createCreationAgentChatQuickActions()} onBack={onBack} onSubmitText={(text) => { submitMessage(text); @@ -207,12 +204,14 @@ export function CustomWorldAgentWorkspace({ }); }} onQuickAction={(action) => { - if (action.key === 'quickFill') { - submitMessage('请补全剩余设定。', true); - return; - } - - submitMessage('请总结一下当前已经成形的世界设定。'); + const quickActionMessage = resolveCreationAgentQuickActionMessage( + action.key, + '请总结一下当前已经成形的世界设定。', + ); + submitMessage( + quickActionMessage.text, + quickActionMessage.quickFillRequested, + ); }} /> ); diff --git a/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx b/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx index 34363323..36dfb586 100644 --- a/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx +++ b/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx @@ -1,4 +1,4 @@ -/* @vitest-environment jsdom */ +/* @vitest-environment jsdom */ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -80,11 +80,12 @@ test('puzzle workspace submits quick keyword fill request after two turns', asyn />, ); - await user.click(screen.getByRole('button', { name: '补充剩余关键字' })); + await user.click(screen.getByRole('button', { name: '补充剩余设定' })); expect(onSubmitMessage).toHaveBeenCalledWith( expect.objectContaining({ - text: '请补充剩余关键字。', + text: '请补充剩余设定。', + quickFillRequested: true, }), ); }); @@ -99,5 +100,5 @@ test('puzzle workspace hides keyword fill before two turns', () => { />, ); - expect(screen.queryByRole('button', { name: '补充剩余关键字' })).toBeNull(); + expect(screen.queryByRole('button', { name: '补充剩余设定' })).toBeNull(); }); diff --git a/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx b/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx index f7cf70ed..22a3300b 100644 --- a/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx +++ b/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx @@ -6,7 +6,12 @@ import type { PuzzleAgentSessionSnapshot, SendPuzzleAgentMessageRequest, } from '../../../packages/shared/src/contracts/puzzleAgentSession'; -import { createCreationAgentClientMessageId } from '../../services/creation-agent'; +import { + buildCreationAgentChatMessage, + createCreationAgentChatQuickActions, + createCreationAgentClientMessageId, + resolveCreationAgentQuickActionMessage, +} from '../../services/creation-agent'; import { type CreationAgentOperationView, type CreationAgentSessionView, @@ -100,35 +105,30 @@ export function PuzzleAgentWorkspace({ isStreamingReply={Boolean(streamingReplyText)} isBusy={isBusy} error={error} - quickActions={[ - { - key: 'summarize', - label: '总结当前设定', - }, - { - key: 'quickFill', - label: '补充剩余关键字', - minTurn: 2, - }, - ]} + quickActions={createCreationAgentChatQuickActions()} onBack={onBack} onSubmitText={(text) => { - onSubmitMessage({ - clientMessageId: createCreationAgentClientMessageId('puzzle'), - text, - }); + onSubmitMessage( + buildCreationAgentChatMessage({ + clientMessageId: createCreationAgentClientMessageId('puzzle'), + text, + }), + ); }} onPrimaryAction={() => { onExecuteAction({ action: 'compile_puzzle_draft' }); }} onQuickAction={(action) => { - onSubmitMessage({ - clientMessageId: createCreationAgentClientMessageId('puzzle'), - text: - action.key === 'quickFill' - ? '请补充剩余关键字。' - : '请总结一下当前已经成形的拼图设定。', - }); + const quickActionMessage = resolveCreationAgentQuickActionMessage( + action.key, + '请总结一下当前已经成形的拼图设定。', + ); + onSubmitMessage( + buildCreationAgentChatMessage({ + clientMessageId: createCreationAgentClientMessageId('puzzle'), + ...quickActionMessage, + }), + ); }} /> ); diff --git a/src/routing/appRoutes.test.ts b/src/routing/appRoutes.test.ts index f3a89a74..ffe9c12d 100644 --- a/src/routing/appRoutes.test.ts +++ b/src/routing/appRoutes.test.ts @@ -9,6 +9,18 @@ describe('matchAppRoute', () => { }); }); + it('routes puzzle playground path to the standalone puzzle runtime', () => { + expect(matchAppRoute('/puzzle')).toEqual({ + kind: 'puzzle-playground', + }); + }); + + it('routes big fish playground path to the standalone big fish runtime', () => { + expect(matchAppRoute('/BIG-FISH/')).toEqual({ + kind: 'big-fish-playground', + }); + }); + it('routes former standalone editor paths back to the main game', () => { expect(matchAppRoute('/item-editor/tools')).toEqual({ kind: 'game', diff --git a/src/routing/appRoutes.tsx b/src/routing/appRoutes.tsx index 49b5c16d..85101039 100644 --- a/src/routing/appRoutes.tsx +++ b/src/routing/appRoutes.tsx @@ -7,6 +7,12 @@ type AppRouteComponent = LazyExoticComponent< >; export type AppRouteMatch = + | { + kind: 'puzzle-playground'; + } + | { + kind: 'big-fish-playground'; + } | { kind: 'game'; }; @@ -20,6 +26,8 @@ export type ResolvedAppRoute = { }; const GameApp = lazy(() => import('../AuthenticatedApp')) as AppRouteComponent; +const BigFishPlaygroundApp = lazy(() => import('../BigFishPlaygroundApp')) as AppRouteComponent; +const PuzzlePlaygroundApp = lazy(() => import('../PuzzlePlaygroundApp')) as AppRouteComponent; function normalizeRoutePath(pathname: string) { const trimmedPathname = pathname.trim().toLowerCase(); @@ -32,7 +40,19 @@ function normalizeRoutePath(pathname: string) { } export function matchAppRoute(pathname: string): AppRouteMatch { - void normalizeRoutePath(pathname); + const normalizedPath = normalizeRoutePath(pathname); + + if (normalizedPath === '/puzzle') { + return { + kind: 'puzzle-playground', + }; + } + + if (normalizedPath === '/big-fish') { + return { + kind: 'big-fish-playground', + }; + } return { kind: 'game', @@ -42,6 +62,24 @@ export function matchAppRoute(pathname: string): AppRouteMatch { export function resolveAppRoute(pathname: string): ResolvedAppRoute { const matchedRoute = matchAppRoute(pathname); + if (matchedRoute.kind === 'puzzle-playground') { + return { + kind: 'puzzle-playground', + loadingEyebrow: '正在载入拼图', + loadingText: '正在进入拼图关卡...', + Component: PuzzlePlaygroundApp, + }; + } + + if (matchedRoute.kind === 'big-fish-playground') { + return { + kind: 'big-fish-playground', + loadingEyebrow: '正在载入大鱼', + loadingText: '正在进入玩法...', + Component: BigFishPlaygroundApp, + }; + } + return { kind: 'game', loadingEyebrow: '正在载入游戏', diff --git a/src/services/creation-agent/creationAgentChat.test.ts b/src/services/creation-agent/creationAgentChat.test.ts new file mode 100644 index 00000000..ec9e6a7d --- /dev/null +++ b/src/services/creation-agent/creationAgentChat.test.ts @@ -0,0 +1,58 @@ +import { expect, test } from 'vitest'; + +import { + CREATION_AGENT_QUICK_FILL_MESSAGE, + buildCreationAgentChatMessage, + createCreationAgentChatQuickActions, + resolveCreationAgentQuickActionMessage, +} from './creationAgentChat'; + +test('creation agent chat exposes the unified summary and quick fill actions', () => { + expect(createCreationAgentChatQuickActions()).toEqual([ + { + key: 'summarize', + label: '总结当前设定', + }, + { + key: 'quickFill', + label: '补充剩余设定', + minTurn: 2, + }, + ]); +}); + +test('creation agent chat resolves quick actions through one message contract', () => { + expect( + resolveCreationAgentQuickActionMessage('quickFill', '请总结当前设定。'), + ).toEqual({ + text: CREATION_AGENT_QUICK_FILL_MESSAGE, + quickFillRequested: true, + }); + + expect( + resolveCreationAgentQuickActionMessage('summarize', '请总结当前设定。'), + ).toEqual({ + text: '请总结当前设定。', + quickFillRequested: false, + }); +}); + +test('creation agent chat builds shared message payload with genre extras', () => { + expect( + buildCreationAgentChatMessage({ + clientMessageId: 'message-1', + text: '请补充剩余设定。', + quickFillRequested: true, + extraPayload: { + focusCardId: null, + selectedCardIds: [], + }, + }), + ).toEqual({ + clientMessageId: 'message-1', + text: '请补充剩余设定。', + quickFillRequested: true, + focusCardId: null, + selectedCardIds: [], + }); +}); diff --git a/src/services/creation-agent/creationAgentChat.ts b/src/services/creation-agent/creationAgentChat.ts new file mode 100644 index 00000000..443b44d2 --- /dev/null +++ b/src/services/creation-agent/creationAgentChat.ts @@ -0,0 +1,63 @@ +export const CREATION_AGENT_SUMMARY_ACTION_KEY = 'summarize'; +export const CREATION_AGENT_QUICK_FILL_ACTION_KEY = 'quickFill'; + +export const CREATION_AGENT_SUMMARY_ACTION_LABEL = '总结当前设定'; +export const CREATION_AGENT_QUICK_FILL_ACTION_LABEL = '补充剩余设定'; +export const CREATION_AGENT_QUICK_FILL_MESSAGE = '请补充剩余设定。'; + +type CreationAgentChatQuickAction = { + key: string; + label: string; + minTurn?: number; +}; + +type CreationAgentChatMessageBase = { + clientMessageId: string; + text: string; + quickFillRequested?: boolean; +}; + +export function createCreationAgentChatQuickActions(): CreationAgentChatQuickAction[] { + return [ + { + key: CREATION_AGENT_SUMMARY_ACTION_KEY, + label: CREATION_AGENT_SUMMARY_ACTION_LABEL, + }, + { + key: CREATION_AGENT_QUICK_FILL_ACTION_KEY, + label: CREATION_AGENT_QUICK_FILL_ACTION_LABEL, + minTurn: 2, + }, + ]; +} + +export function resolveCreationAgentQuickActionMessage( + actionKey: string, + summaryMessage: string, +) { + const quickFillRequested = actionKey === CREATION_AGENT_QUICK_FILL_ACTION_KEY; + + return { + text: quickFillRequested ? CREATION_AGENT_QUICK_FILL_MESSAGE : summaryMessage, + quickFillRequested, + }; +} + +export function buildCreationAgentChatMessage>({ + clientMessageId, + text, + quickFillRequested = false, + extraPayload, +}: { + clientMessageId: string; + text: string; + quickFillRequested?: boolean; + extraPayload?: TExtraPayload; +}): CreationAgentChatMessageBase & TExtraPayload { + return { + ...(extraPayload ?? ({} as TExtraPayload)), + clientMessageId, + text, + quickFillRequested, + }; +} diff --git a/src/services/creation-agent/index.ts b/src/services/creation-agent/index.ts index 8c1bd7f3..65c0980c 100644 --- a/src/services/creation-agent/index.ts +++ b/src/services/creation-agent/index.ts @@ -1,2 +1,3 @@ +export * from './creationAgentChat'; export * from './creationAgentProgress'; export * from './creationAgentSse'; diff --git a/src/services/puzzle-runtime/puzzleLocalRuntime.ts b/src/services/puzzle-runtime/puzzleLocalRuntime.ts index 5480b24c..cfa15349 100644 --- a/src/services/puzzle-runtime/puzzleLocalRuntime.ts +++ b/src/services/puzzle-runtime/puzzleLocalRuntime.ts @@ -68,7 +68,7 @@ function buildInitialBoard(gridSize: PuzzleGridSize): PuzzleBoardSnapshot { const pieces = Array.from({ length: gridSize * gridSize }, (_, index) => { const correctRow = Math.floor(index / gridSize); const correctCol = index % gridSize; - const current = shuffledPositions[index]; + const current = shuffledPositions[index] ?? { row: correctRow, col: correctCol }; return { pieceId: `piece-${index}`, correctRow,