Merge branch 'codex/backend-rewrite-spacetimedb' of http://82.157.175.59:3000/GenarrativeAI/Genarrative into codex/backend-rewrite-spacetimedb
This commit is contained in:
@@ -193,8 +193,10 @@ Agent 在这一玩法里不负责实时玩法裁决,它只负责 3 件事:
|
|||||||
4. 会补缺,不会平均盘问
|
4. 会补缺,不会平均盘问
|
||||||
5. 进度基于真实锚点完成度,而不是机械轮次
|
5. 进度基于真实锚点完成度,而不是机械轮次
|
||||||
6. 当会话至少完成 `2` 轮后,工作区必须提供 `补充剩余关键字` 快捷动作。
|
6. 当会话至少完成 `2` 轮后,工作区必须提供 `补充剩余关键字` 快捷动作。
|
||||||
- 该动作只向 Agent 发送“请补充剩余关键字。”,由后端 Agent 根据当前锚点补齐缺失关键词。
|
- 该动作沿用 RPG 聊天链路,仍走发送消息接口,但请求体必须携带 `quickFillRequested: true`。
|
||||||
|
- 前端只发送“请补充剩余关键字。”作为本轮用户消息,由后端 Agent 根据当前锚点补齐缺失关键词。
|
||||||
- 前端不得自行推断成长阶梯、风险节奏或视觉母题,也不得直接改写锚点状态。
|
- 前端不得自行推断成长阶梯、风险节奏或视觉母题,也不得直接改写锚点状态。
|
||||||
|
- Agent 收到后必须进入自动补齐模式,不允许继续反问用户。
|
||||||
|
|
||||||
## 7.3 大鱼吃小鱼玩法的 4 个最小高杠杆锚点
|
## 7.3 大鱼吃小鱼玩法的 4 个最小高杠杆锚点
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# AI 原生自定义世界生成流程优化 PRD
|
# AI 原生自定义世界生成流程优化 PRD
|
||||||
|
|
||||||
更新时间:`2026-04-06`
|
更新时间:`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 明确支持“锁定”
|
## 4.4 明确支持“锁定”
|
||||||
|
|
||||||
每张卡片、每个关键角色、每个关键地点都应支持锁定。
|
每张卡片、每个关键角色、每个关键地点都应支持锁定。
|
||||||
|
|||||||
@@ -202,8 +202,9 @@
|
|||||||
2. 每轮只追问当前最影响图片生成质量的 `1` 个问题。
|
2. 每轮只追问当前最影响图片生成质量的 `1` 个问题。
|
||||||
3. 当创作者已经说出足够信息时,优先总结,不重复追问。
|
3. 当创作者已经说出足够信息时,优先总结,不重复追问。
|
||||||
4. 当会话至少完成 `2` 轮后,工作区必须提供 `补充剩余关键字` 快捷动作。
|
4. 当会话至少完成 `2` 轮后,工作区必须提供 `补充剩余关键字` 快捷动作。
|
||||||
- 该动作只向 Agent 发送“请补充剩余关键字。”,不在前端补数据、不伪造锚点状态。
|
- 该动作沿用 RPG 聊天链路,仍走发送消息接口,但请求体必须携带 `quickFillRequested: true`。
|
||||||
- Agent 收到后应优先补齐仍为 `待补充` / 空值的锚点关键词,并保持每次回复清爽直接。
|
- 前端不补数据、不伪造锚点状态,只发送“请补充剩余关键字。”作为本轮用户消息。
|
||||||
|
- Agent 收到后必须进入自动补齐模式,优先补齐仍为 `待补充` / 空值的锚点关键词,不允许继续反问用户。
|
||||||
5. 在进入结果页前,至少确认:
|
5. 在进入结果页前,至少确认:
|
||||||
- 一句题材承诺
|
- 一句题材承诺
|
||||||
- 一个主要视觉主体
|
- 一个主要视觉主体
|
||||||
|
|||||||
@@ -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. 不操作时野生对象仍会持续游动。
|
||||||
@@ -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. 左上返回按钮在直达页语义为重开当前占位局。
|
||||||
26
docs/technical/PUZZLE_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md
Normal file
26
docs/technical/PUZZLE_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# 拼图玩法直达路由说明
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
现有前端已经包含拼图运行时组件 `PuzzleRuntimeShell` 和本地运行时 `puzzleLocalRuntime`,但只能从平台创作中心、作品卡或拼图广场链路间接进入。为了快速验证玩法交互,需要补一个可直接打开的前端路由。
|
||||||
|
|
||||||
|
## 路由设计
|
||||||
|
|
||||||
|
- `/puzzle`:进入拼图玩法直达页。
|
||||||
|
- 路由挂到现有 `src/routing/appRoutes.tsx` 的轻量路由解析层,不引入 React Router,也不新增独立路由系统。
|
||||||
|
|
||||||
|
## 运行态边界
|
||||||
|
|
||||||
|
- 直达页复用 `PuzzleRuntimeShell`,不复制棋盘 UI。
|
||||||
|
- 初始关卡通过 `startLocalPuzzleRun` 生成,图片使用内联 SVG 占位图。
|
||||||
|
- 交换、拖动、重开均走 `puzzleLocalRuntime`,保持与现有前端玩法实现一致。
|
||||||
|
- 该入口仅用于直达体验和调试,不改变已发布拼图作品、Agent 创作、拼图广场和后端持久化链路。
|
||||||
|
|
||||||
|
## 验收口径
|
||||||
|
|
||||||
|
1. 浏览器访问 `/puzzle` 后直接显示全屏拼图画布。
|
||||||
|
2. 棋盘应显示占位图切片,而不是空白格。
|
||||||
|
3. 点击两块拼图可以交换;拖动拼图到目标格可以交换位置。
|
||||||
|
4. 左上返回按钮在直达页语义为重开当前占位关卡。
|
||||||
|
|
||||||
|
|
||||||
@@ -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 的根因,并冻结前端归一化保留发布字段的修复口径。
|
- [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 字段的修复口径。
|
- [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/` 一起看,更容易判断先后顺序。
|
- 做阶段排期时,把这一组和 `docs/planning/`、`docs/prd/` 一起看,更容易判断先后顺序。
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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`。
|
||||||
@@ -9,6 +9,7 @@ export type CreateBigFishSessionRequest = {
|
|||||||
export type SendBigFishMessageRequest = {
|
export type SendBigFishMessageRequest = {
|
||||||
clientMessageId: string;
|
clientMessageId: string;
|
||||||
text: string;
|
text: string;
|
||||||
|
quickFillRequested?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BigFishActionId =
|
export type BigFishActionId =
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export interface CreatePuzzleAgentSessionResponse {
|
|||||||
export interface SendPuzzleAgentMessageRequest {
|
export interface SendPuzzleAgentMessageRequest {
|
||||||
clientMessageId: string;
|
clientMessageId: string;
|
||||||
text: string;
|
text: string;
|
||||||
|
quickFillRequested?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SendPuzzleAgentMessageResponse extends PuzzleAgentActionResponse {
|
export interface SendPuzzleAgentMessageResponse extends PuzzleAgentActionResponse {
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
default-members = [
|
||||||
|
"crates/api-server",
|
||||||
|
]
|
||||||
members = [
|
members = [
|
||||||
"crates/api-server",
|
"crates/api-server",
|
||||||
"crates/module-ai",
|
"crates/module-ai",
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ pub async fn submit_big_fish_message(
|
|||||||
BigFishAgentTurnRequest {
|
BigFishAgentTurnRequest {
|
||||||
llm_client: state.llm_client(),
|
llm_client: state.llm_client(),
|
||||||
session: &submitted_session,
|
session: &submitted_session,
|
||||||
|
quick_fill_requested: payload.quick_fill_requested.unwrap_or(false),
|
||||||
},
|
},
|
||||||
move |text| {
|
move |text| {
|
||||||
draft_sink.persist_visible_text_async(text);
|
draft_sink.persist_visible_text_async(text);
|
||||||
@@ -321,6 +322,7 @@ pub async fn stream_big_fish_message(
|
|||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
big_fish_error_response(&request_context, map_big_fish_client_error(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(
|
let mut draft_writer = AiGenerationDraftWriter::new(AiGenerationDraftContext::new(
|
||||||
"big_fish",
|
"big_fish",
|
||||||
owner_user_id.as_str(),
|
owner_user_id.as_str(),
|
||||||
@@ -346,6 +348,7 @@ pub async fn stream_big_fish_message(
|
|||||||
BigFishAgentTurnRequest {
|
BigFishAgentTurnRequest {
|
||||||
llm_client: state.llm_client(),
|
llm_client: state.llm_client(),
|
||||||
session: &submitted_session,
|
session: &submitted_session,
|
||||||
|
quick_fill_requested,
|
||||||
},
|
},
|
||||||
|text| {
|
|text| {
|
||||||
draft_sink.persist_visible_text_async(text);
|
draft_sink.persist_visible_text_async(text);
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ use spacetime_client::{
|
|||||||
use crate::creation_agent_anchor_templates::{
|
use crate::creation_agent_anchor_templates::{
|
||||||
get_creation_agent_anchor_template, render_anchor_question_block,
|
get_creation_agent_anchor_template, render_anchor_question_block,
|
||||||
};
|
};
|
||||||
|
use crate::creation_agent_chat::render_quick_fill_extra_rules;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) struct BigFishAgentTurnRequest<'a> {
|
pub(crate) struct BigFishAgentTurnRequest<'a> {
|
||||||
pub llm_client: Option<&'a LlmClient>,
|
pub llm_client: Option<&'a LlmClient>,
|
||||||
pub session: &'a BigFishSessionRecord,
|
pub session: &'a BigFishSessionRecord,
|
||||||
|
pub quick_fill_requested: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -115,7 +117,7 @@ where
|
|||||||
let llm_client = request
|
let llm_client = request
|
||||||
.llm_client
|
.llm_client
|
||||||
.ok_or_else(|| BigFishAgentTurnError::new("当前模型不可用,请稍后重试。"))?;
|
.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 mut latest_reply_text = String::new();
|
||||||
let response = llm_client
|
let response = llm_client
|
||||||
.stream_text(
|
.stream_text(
|
||||||
@@ -146,7 +148,11 @@ where
|
|||||||
Ok(BigFishAgentTurnResult {
|
Ok(BigFishAgentTurnResult {
|
||||||
assistant_reply_text: output.reply_text,
|
assistant_reply_text: output.reply_text,
|
||||||
stage: BigFishCreationStage::CollectingAnchors.as_str().to_string(),
|
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)
|
anchor_pack_json: serde_json::to_string(&output.next_anchor_pack)
|
||||||
.unwrap_or_else(|_| "{}".to_string()),
|
.unwrap_or_else(|_| "{}".to_string()),
|
||||||
error_message: None,
|
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")
|
let anchor_question_block = get_creation_agent_anchor_template("big_fish")
|
||||||
.map(render_anchor_question_block)
|
.map(render_anchor_question_block)
|
||||||
.unwrap_or_else(|| "模板目标:收束成可玩的竖屏大鱼吃小鱼玩法草稿。".to_string());
|
.unwrap_or_else(|| "模板目标:收束成可玩的竖屏大鱼吃小鱼玩法草稿。".to_string());
|
||||||
|
let quick_fill_rules = if quick_fill_requested {
|
||||||
format!(
|
format!(
|
||||||
"{anchor_question_block}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n当前 anchor pack:\n{anchor_pack}\n\n最近聊天记录:\n{chat_history}\n\n{contract}",
|
"\n\n{}",
|
||||||
|
render_quick_fill_extra_rules(
|
||||||
|
"当前玩法方向里的成长、生态、风险节奏等缺失关键词",
|
||||||
|
"不要要求用户再提供等级、鱼群、场景或节奏信息",
|
||||||
|
"输出完整 nextAnchorPack,直接补齐 value 为空或 status 为 missing 的项",
|
||||||
|
"生成结果页",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"{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,
|
anchor_question_block = anchor_question_block,
|
||||||
|
quick_fill_rules = quick_fill_rules,
|
||||||
turn = session.current_turn.saturating_add(1),
|
turn = session.current_turn.saturating_add(1),
|
||||||
progress = session.progress_percent,
|
progress = session.progress_percent,
|
||||||
|
quick_fill_requested_text = if quick_fill_requested { "是" } else { "否" },
|
||||||
anchor_pack = serialize_record_anchor_pack(&session.anchor_pack),
|
anchor_pack = serialize_record_anchor_pack(&session.anchor_pack),
|
||||||
chat_history =
|
chat_history =
|
||||||
serde_json::to_string_pretty(&build_chat_history(session.messages.as_slice()))
|
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<String> {
|
|||||||
Some(result)
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3432,11 +3432,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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() {
|
fn resolve_character_animation_model_uses_strategy_specific_field() {
|
||||||
let payload = CharacterAnimationGenerateRequest {
|
let payload = CharacterAnimationGenerateRequest {
|
||||||
character_id: "hero".to_string(),
|
character_id: "hero".to_string(),
|
||||||
|
|||||||
25
server-rs/crates/api-server/src/creation_agent_chat.rs
Normal file
25
server-rs/crates/api-server/src/creation_agent_chat.rs
Normal file
@@ -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},不能出现问号"#,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2297,24 +2297,6 @@ fn has_custom_world_scene_act(profile: Option<&Map<String, Value>>) -> bool {
|
|||||||
.unwrap_or(false)
|
.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(
|
fn map_custom_world_publish_gate_response(
|
||||||
gate: CustomWorldPublishGateRecord,
|
gate: CustomWorldPublishGateRecord,
|
||||||
) -> CustomWorldPublishGateResponse {
|
) -> CustomWorldPublishGateResponse {
|
||||||
@@ -2476,6 +2458,24 @@ fn custom_world_error_response(request_context: &RequestContext, error: AppError
|
|||||||
error.into_response_with_context(Some(request_context))
|
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, AppError> {
|
fn custom_world_sse_json_event(event_name: &str, payload: Value) -> Result<Event, AppError> {
|
||||||
Event::default()
|
Event::default()
|
||||||
.event(event_name)
|
.event(event_name)
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ use serde::{Deserialize, Serialize};
|
|||||||
use serde_json::{Value as JsonValue, json};
|
use serde_json::{Value as JsonValue, json};
|
||||||
|
|
||||||
use crate::custom_world_rpg_draft_prompts::{
|
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,
|
STATE_INFERENCE_OUTPUT_CONTRACT, STATE_INFERENCE_SYSTEM_PROMPT,
|
||||||
extract_reply_text_from_partial_json, mode_rules, parse_conversation_mode, parse_drift_risk,
|
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,
|
parse_json_response_text, parse_user_input_signal, quick_fill_extra_rules,
|
||||||
render_current_anchor_context, render_dynamic_state_context, user_signal_rules,
|
render_chat_history_context, render_current_anchor_context, render_dynamic_state_context,
|
||||||
|
user_signal_rules,
|
||||||
};
|
};
|
||||||
use spacetime_client::{
|
use spacetime_client::{
|
||||||
CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord,
|
CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord,
|
||||||
@@ -799,7 +800,7 @@ fn build_eight_anchor_single_turn_prompt(
|
|||||||
user_signal_rules(dynamic_state.user_input_signal).to_string(),
|
user_signal_rules(dynamic_state.user_input_signal).to_string(),
|
||||||
];
|
];
|
||||||
if quick_fill_requested {
|
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_dynamic_state_context(dynamic_state));
|
||||||
blocks.push(render_current_anchor_context(current_anchor_content));
|
blocks.push(render_current_anchor_context(current_anchor_content));
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::creation_agent_chat::render_quick_fill_extra_rules;
|
||||||
use crate::custom_world_agent_turn::{
|
use crate::custom_world_agent_turn::{
|
||||||
EightAnchorContent, PromptConversationMode, PromptDriftRisk, PromptDynamicState,
|
EightAnchorContent, PromptConversationMode, PromptDriftRisk, PromptDynamicState,
|
||||||
PromptUserInputSignal,
|
PromptUserInputSignal,
|
||||||
@@ -41,15 +42,14 @@ pub(crate) const GLOBAL_HARD_RULES: &str = r#"全局硬约束:
|
|||||||
11. 你输出的 JSON 必须可以被直接解析。
|
11. 你输出的 JSON 必须可以被直接解析。
|
||||||
12. 输出字段顺序必须固定为:replyText、progressPercent、nextAnchorContent。"#;
|
12. 输出字段顺序必须固定为:replyText、progressPercent、nextAnchorContent。"#;
|
||||||
|
|
||||||
pub(crate) const QUICK_FILL_EXTRA_RULES: &str = r#"用户刚刚主动要求你自动补全剩余设定。
|
pub(crate) fn quick_fill_extra_rules() -> String {
|
||||||
|
render_quick_fill_extra_rules(
|
||||||
这表示用户接受你基于当前方向自动补完剩余设定。
|
"当前 RPG 世界方向里的剩余设定",
|
||||||
|
"不要要求用户再提供世界观、角色、冲突或禁忌信息",
|
||||||
本轮要求:
|
"直接输出一版尽量完整的设定结构",
|
||||||
1. 不要再继续提问
|
"进入“生成游戏设定草稿”",
|
||||||
2. 直接输出一版尽量完整的设定结构
|
)
|
||||||
3. progressPercent 直接输出为 100
|
}
|
||||||
4. replyText 要告诉用户现在可以进入“生成游戏设定草稿”"#;
|
|
||||||
|
|
||||||
pub(crate) const STATE_INFERENCE_SYSTEM_PROMPT: &str = r#"你是正式生成世界设定前的一步“创作状态识别器”。
|
pub(crate) const STATE_INFERENCE_SYSTEM_PROMPT: &str = r#"你是正式生成世界设定前的一步“创作状态识别器”。
|
||||||
你的职责不是直接生成新设定,而是先判断:下一轮正式生成应该用什么推进策略,尤其要判断 replyText 应该更偏确认、吸收、收束、纠偏,还是启发式提问。
|
你的职责不是直接生成新设定,而是先判断:下一轮正式生成应该用什么推进策略,尤其要判断 replyText 应该更偏确认、吸收、收束、纠偏,还是启发式提问。
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ mod character_animation_assets;
|
|||||||
mod character_visual_assets;
|
mod character_visual_assets;
|
||||||
mod config;
|
mod config;
|
||||||
mod creation_agent_anchor_templates;
|
mod creation_agent_anchor_templates;
|
||||||
|
mod creation_agent_chat;
|
||||||
mod custom_world;
|
mod custom_world;
|
||||||
mod custom_world_agent_entities;
|
mod custom_world_agent_entities;
|
||||||
mod custom_world_agent_turn;
|
mod custom_world_agent_turn;
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ pub async fn submit_puzzle_agent_message(
|
|||||||
PuzzleAgentTurnRequest {
|
PuzzleAgentTurnRequest {
|
||||||
llm_client: state.llm_client(),
|
llm_client: state.llm_client(),
|
||||||
session: &submitted_session,
|
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 owner_user_id = authenticated.claims().user_id().to_string();
|
||||||
|
let quick_fill_requested = payload.quick_fill_requested.unwrap_or(false);
|
||||||
let session = state
|
let session = state
|
||||||
.spacetime_client()
|
.spacetime_client()
|
||||||
.submit_puzzle_agent_message(PuzzleAgentMessageSubmitRecordInput {
|
.submit_puzzle_agent_message(PuzzleAgentMessageSubmitRecordInput {
|
||||||
@@ -315,6 +317,7 @@ pub async fn stream_puzzle_agent_message(
|
|||||||
PuzzleAgentTurnRequest {
|
PuzzleAgentTurnRequest {
|
||||||
llm_client: state.llm_client(),
|
llm_client: state.llm_client(),
|
||||||
session: &session,
|
session: &session,
|
||||||
|
quick_fill_requested,
|
||||||
},
|
},
|
||||||
move |text| {
|
move |text| {
|
||||||
let _ = reply_tx.send(text.to_string());
|
let _ = reply_tx.send(text.to_string());
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ use spacetime_client::{
|
|||||||
use crate::creation_agent_anchor_templates::{
|
use crate::creation_agent_anchor_templates::{
|
||||||
get_creation_agent_anchor_template, render_anchor_question_block,
|
get_creation_agent_anchor_template, render_anchor_question_block,
|
||||||
};
|
};
|
||||||
|
use crate::creation_agent_chat::render_quick_fill_extra_rules;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) struct PuzzleAgentTurnRequest<'a> {
|
pub(crate) struct PuzzleAgentTurnRequest<'a> {
|
||||||
pub llm_client: Option<&'a LlmClient>,
|
pub llm_client: Option<&'a LlmClient>,
|
||||||
pub session: &'a PuzzleAgentSessionRecord,
|
pub session: &'a PuzzleAgentSessionRecord,
|
||||||
|
pub quick_fill_requested: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -122,7 +124,7 @@ where
|
|||||||
.llm_client
|
.llm_client
|
||||||
.ok_or_else(|| PuzzleAgentTurnError::new("当前模型不可用,请稍后重试。"))?;
|
.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 mut latest_reply_text = String::new();
|
||||||
let response = llm_client
|
let response = llm_client
|
||||||
.stream_text(
|
.stream_text(
|
||||||
@@ -155,7 +157,11 @@ where
|
|||||||
stage: resolve_puzzle_agent_stage(output.progress_percent)
|
stage: resolve_puzzle_agent_stage(output.progress_percent)
|
||||||
.as_str()
|
.as_str()
|
||||||
.to_string(),
|
.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(|_| {
|
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())
|
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")
|
let anchor_question_block = get_creation_agent_anchor_template("puzzle")
|
||||||
.map(render_anchor_question_block)
|
.map(render_anchor_question_block)
|
||||||
.unwrap_or_else(|| "模板目标:收束成可以发布为拼图关卡的视觉方案。".to_string());
|
.unwrap_or_else(|| "模板目标:收束成可以发布为拼图关卡的视觉方案。".to_string());
|
||||||
|
let quick_fill_rules = if quick_fill_requested {
|
||||||
format!(
|
format!(
|
||||||
"{anchor_question_block}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n当前 anchor pack:\n{anchor_pack}\n\n最近聊天记录:\n{chat_history}\n\n{contract}",
|
"\n\n{}",
|
||||||
|
render_quick_fill_extra_rules(
|
||||||
|
"当前题材方向里的拼图关键词",
|
||||||
|
"不要要求用户再提供素材、风格或禁忌",
|
||||||
|
"输出完整 nextAnchorPack,直接补齐 value 为空或 status 为 missing 的项",
|
||||||
|
"生成结果页",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"{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,
|
anchor_question_block = anchor_question_block,
|
||||||
|
quick_fill_rules = quick_fill_rules,
|
||||||
turn = session.current_turn.saturating_add(1),
|
turn = session.current_turn.saturating_add(1),
|
||||||
progress = session.progress_percent,
|
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))
|
anchor_pack = serde_json::to_string_pretty(&map_record_anchor_pack(&session.anchor_pack))
|
||||||
.unwrap_or_else(|_| "{}".to_string()),
|
.unwrap_or_else(|_| "{}".to_string()),
|
||||||
chat_history =
|
chat_history =
|
||||||
@@ -430,7 +454,57 @@ mod tests {
|
|||||||
use module_puzzle::PuzzleAnchorStatus;
|
use module_puzzle::PuzzleAnchorStatus;
|
||||||
use serde_json::json;
|
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]
|
#[test]
|
||||||
fn extract_reply_text_from_partial_json_preserves_chinese_characters() {
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1775,6 +1775,8 @@ mod tests {
|
|||||||
let error = validate_custom_world_profile_upsert_input(&CustomWorldProfileUpsertInput {
|
let error = validate_custom_world_profile_upsert_input(&CustomWorldProfileUpsertInput {
|
||||||
profile_id: "cwprof_001".to_string(),
|
profile_id: "cwprof_001".to_string(),
|
||||||
owner_user_id: "user_001".to_string(),
|
owner_user_id: "user_001".to_string(),
|
||||||
|
public_work_code: None,
|
||||||
|
author_public_user_code: None,
|
||||||
source_agent_session_id: None,
|
source_agent_session_id: None,
|
||||||
world_name: "裂潮边城".to_string(),
|
world_name: "裂潮边城".to_string(),
|
||||||
subtitle: "港口余烬".to_string(),
|
subtitle: "港口余烬".to_string(),
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ pub struct CreateBigFishSessionRequest {
|
|||||||
pub struct SendBigFishMessageRequest {
|
pub struct SendBigFishMessageRequest {
|
||||||
pub client_message_id: String,
|
pub client_message_id: String,
|
||||||
pub text: String,
|
pub text: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub quick_fill_requested: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ pub struct CreatePuzzleAgentSessionRequest {
|
|||||||
pub struct SendPuzzleAgentMessageRequest {
|
pub struct SendPuzzleAgentMessageRequest {
|
||||||
pub client_message_id: String,
|
pub client_message_id: String,
|
||||||
pub text: String,
|
pub text: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub quick_fill_requested: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
|||||||
@@ -1573,6 +1573,9 @@ fn deserialize_run(value: &str) -> Result<PuzzleRunSnapshot, String> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use module_puzzle::{
|
||||||
|
build_generated_candidates, empty_anchor_pack, recommendation_score, tag_similarity_score,
|
||||||
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn puzzle_json_round_trip_keeps_snapshot_shape() {
|
fn puzzle_json_round_trip_keeps_snapshot_shape() {
|
||||||
|
|||||||
219
src/BigFishPlaygroundApp.tsx
Normal file
219
src/BigFishPlaygroundApp.tsx
Normal file
@@ -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(`
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1280">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="water" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#38bdf8" />
|
||||||
|
<stop offset="0.52" stop-color="#0f766e" />
|
||||||
|
<stop offset="1" stop-color="#020617" />
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="light" cx="50%" cy="12%" r="52%">
|
||||||
|
<stop offset="0" stop-color="#ecfeff" stop-opacity="0.72" />
|
||||||
|
<stop offset="1" stop-color="#ecfeff" stop-opacity="0" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="720" height="1280" fill="url(#water)" />
|
||||||
|
<rect width="720" height="1280" fill="url(#light)" />
|
||||||
|
<path d="M70 324 C164 268 256 384 362 320 C492 242 582 330 656 282" fill="none" stroke="#a7f3d0" stroke-width="16" stroke-linecap="round" opacity="0.28" />
|
||||||
|
<path d="M34 760 C156 700 238 806 372 724 C520 634 606 746 704 682" fill="none" stroke="#bae6fd" stroke-width="18" stroke-linecap="round" opacity="0.18" />
|
||||||
|
<circle cx="120" cy="210" r="18" fill="#ecfeff" opacity="0.36" />
|
||||||
|
<circle cx="548" cy="410" r="12" fill="#ecfeff" opacity="0.28" />
|
||||||
|
<circle cx="304" cy="590" r="10" fill="#ecfeff" opacity="0.24" />
|
||||||
|
<path d="M0 1060 C128 1010 244 1096 366 1030 C492 962 612 1026 720 976 V1280 H0 Z" fill="#022c22" opacity="0.62" />
|
||||||
|
</svg>`);
|
||||||
|
|
||||||
|
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<BigFishAssetSlotResponse[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
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 (
|
||||||
|
<BigFishRuntimeShell
|
||||||
|
run={run}
|
||||||
|
assetSlots={assetSlots}
|
||||||
|
onBack={handleRestart}
|
||||||
|
onSubmitInput={handleSubmitInput}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
src/PuzzlePlaygroundApp.tsx
Normal file
89
src/PuzzlePlaygroundApp.tsx
Normal file
@@ -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(`
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 960">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="sky" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#fef3c7" />
|
||||||
|
<stop offset="0.45" stop-color="#fb7185" />
|
||||||
|
<stop offset="1" stop-color="#312e81" />
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="glow" cx="42%" cy="34%" r="46%">
|
||||||
|
<stop offset="0" stop-color="#ffffff" stop-opacity="0.78" />
|
||||||
|
<stop offset="1" stop-color="#ffffff" stop-opacity="0" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="960" height="960" fill="url(#sky)" />
|
||||||
|
<circle cx="312" cy="282" r="210" fill="url(#glow)" />
|
||||||
|
<path d="M0 680 C170 610 278 724 424 650 C574 574 704 612 960 512 V960 H0 Z" fill="#1e1b4b" opacity="0.9" />
|
||||||
|
<path d="M0 766 C178 710 320 794 492 732 C642 678 780 708 960 652 V960 H0 Z" fill="#111827" opacity="0.78" />
|
||||||
|
<path d="M160 356 C238 298 326 304 388 376 C456 456 550 436 626 374 C710 306 824 330 882 410" fill="none" stroke="#fff7ed" stroke-width="18" stroke-linecap="round" opacity="0.72" />
|
||||||
|
<path d="M204 502 h552" stroke="#ffffff" stroke-width="16" stroke-linecap="round" opacity="0.3" />
|
||||||
|
<path d="M268 566 h424" stroke="#ffffff" stroke-width="12" stroke-linecap="round" opacity="0.22" />
|
||||||
|
</svg>`);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<PuzzleRuntimeShell
|
||||||
|
run={run}
|
||||||
|
onBack={handleRestart}
|
||||||
|
onSwapPieces={handleSwapPieces}
|
||||||
|
onDragPiece={handleDragPiece}
|
||||||
|
onAdvanceNextLevel={handleAdvanceNextLevel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
/* @vitest-environment jsdom */
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
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(onSubmitMessage).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import type {
|
|||||||
ExecuteBigFishActionRequest,
|
ExecuteBigFishActionRequest,
|
||||||
SendBigFishMessageRequest,
|
SendBigFishMessageRequest,
|
||||||
} from '../../../packages/shared/src/contracts/bigFish';
|
} from '../../../packages/shared/src/contracts/bigFish';
|
||||||
import { createCreationAgentClientMessageId } from '../../services/creation-agent';
|
import {
|
||||||
|
buildCreationAgentChatMessage,
|
||||||
|
createCreationAgentChatQuickActions,
|
||||||
|
createCreationAgentClientMessageId,
|
||||||
|
resolveCreationAgentQuickActionMessage,
|
||||||
|
} from '../../services/creation-agent';
|
||||||
import {
|
import {
|
||||||
type CreationAgentAnchorView,
|
type CreationAgentAnchorView,
|
||||||
type CreationAgentSessionView,
|
type CreationAgentSessionView,
|
||||||
@@ -84,35 +89,30 @@ export function BigFishAgentWorkspace({
|
|||||||
isStreamingReply={Boolean(streamingReplyText)}
|
isStreamingReply={Boolean(streamingReplyText)}
|
||||||
isBusy={isBusy}
|
isBusy={isBusy}
|
||||||
error={error}
|
error={error}
|
||||||
quickActions={[
|
quickActions={createCreationAgentChatQuickActions()}
|
||||||
{
|
|
||||||
key: 'summarize',
|
|
||||||
label: '总结当前设定',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'quickFill',
|
|
||||||
label: '补充剩余关键字',
|
|
||||||
minTurn: 2,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
onBack={onBack}
|
onBack={onBack}
|
||||||
onSubmitText={(text) => {
|
onSubmitText={(text) => {
|
||||||
onSubmitMessage({
|
onSubmitMessage(
|
||||||
|
buildCreationAgentChatMessage({
|
||||||
clientMessageId: createCreationAgentClientMessageId('big-fish'),
|
clientMessageId: createCreationAgentClientMessageId('big-fish'),
|
||||||
text,
|
text,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onPrimaryAction={() => {
|
onPrimaryAction={() => {
|
||||||
onExecuteAction({ action: 'big_fish_compile_draft' });
|
onExecuteAction({ action: 'big_fish_compile_draft' });
|
||||||
}}
|
}}
|
||||||
onQuickAction={(action) => {
|
onQuickAction={(action) => {
|
||||||
onSubmitMessage({
|
const quickActionMessage = resolveCreationAgentQuickActionMessage(
|
||||||
|
action.key,
|
||||||
|
'请总结一下当前已经成形的大鱼吃小鱼设定。',
|
||||||
|
);
|
||||||
|
onSubmitMessage(
|
||||||
|
buildCreationAgentChatMessage({
|
||||||
clientMessageId: createCreationAgentClientMessageId('big-fish'),
|
clientMessageId: createCreationAgentClientMessageId('big-fish'),
|
||||||
text:
|
...quickActionMessage,
|
||||||
action.key === 'quickFill'
|
}),
|
||||||
? '请补充剩余关键字。'
|
);
|
||||||
: '请总结一下当前已经成形的大鱼吃小鱼设定。',
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ArrowLeft, Loader2 } from 'lucide-react';
|
import { ArrowLeft, Loader2 } from 'lucide-react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState, type PointerEvent } from 'react';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
BigFishAssetSlotResponse,
|
BigFishAssetSlotResponse,
|
||||||
@@ -9,6 +9,12 @@ import type {
|
|||||||
} from '../../../packages/shared/src/contracts/bigFish';
|
} from '../../../packages/shared/src/contracts/bigFish';
|
||||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||||
|
|
||||||
|
type TouchOrigin = {
|
||||||
|
pointerId: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
|
||||||
type BigFishRuntimeShellProps = {
|
type BigFishRuntimeShellProps = {
|
||||||
run: BigFishRuntimeSnapshotResponse | null;
|
run: BigFishRuntimeSnapshotResponse | null;
|
||||||
assetSlots?: BigFishAssetSlotResponse[];
|
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(
|
function projectEntity(
|
||||||
entity: BigFishRuntimeEntityResponse,
|
entity: BigFishRuntimeEntityResponse,
|
||||||
run: BigFishRuntimeSnapshotResponse,
|
run: BigFishRuntimeSnapshotResponse,
|
||||||
@@ -152,7 +172,8 @@ export function BigFishRuntimeShell({
|
|||||||
onBack,
|
onBack,
|
||||||
onSubmitInput,
|
onSubmitInput,
|
||||||
}: BigFishRuntimeShellProps) {
|
}: BigFishRuntimeShellProps) {
|
||||||
const padRef = useRef<HTMLDivElement | null>(null);
|
const stageRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [touchOrigin, setTouchOrigin] = useState<TouchOrigin | null>(null);
|
||||||
const [stick, setStick] = useState({ x: 0, y: 0 });
|
const [stick, setStick] = useState({ x: 0, y: 0 });
|
||||||
const stickRef = useRef(stick);
|
const stickRef = useRef(stick);
|
||||||
|
|
||||||
@@ -163,7 +184,7 @@ export function BigFishRuntimeShell({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
const current = stickRef.current;
|
const current = stickRef.current;
|
||||||
// 即使摇杆静止也持续回传当前输入,让后端持续推进刷怪、清理与胜负裁决。
|
// 即使没有方向输入也持续回传当前状态,让后端持续推进刷怪、清理与胜负裁决。
|
||||||
onSubmitInput(current);
|
onSubmitInput(current);
|
||||||
}, 220);
|
}, 220);
|
||||||
|
|
||||||
@@ -172,20 +193,39 @@ export function BigFishRuntimeShell({
|
|||||||
};
|
};
|
||||||
}, [onSubmitInput]);
|
}, [onSubmitInput]);
|
||||||
|
|
||||||
const updateStickFromPointer = (clientX: number, clientY: number) => {
|
const submitDirection = (direction: SubmitBigFishInputRequest) => {
|
||||||
const pad = padRef.current;
|
setStick(direction);
|
||||||
if (!pad) {
|
onSubmitInput(direction);
|
||||||
|
};
|
||||||
|
|
||||||
|
const beginTouchControl = (event: PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (event.target instanceof HTMLElement && event.target.closest('button')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rect = pad.getBoundingClientRect();
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
const centerX = rect.left + rect.width / 2;
|
setTouchOrigin({
|
||||||
const centerY = rect.top + rect.height / 2;
|
pointerId: event.pointerId,
|
||||||
const vector = normalizeVector(
|
x: event.clientX,
|
||||||
(clientX - centerX) / (rect.width / 2),
|
y: event.clientY,
|
||||||
(clientY - centerY) / (rect.height / 2),
|
});
|
||||||
|
submitDirection({ x: 0, y: 0 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTouchControl = (event: PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!touchOrigin || touchOrigin.pointerId !== event.pointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitDirection(
|
||||||
|
resolveDirectionFromOrigin(touchOrigin, event.clientX, event.clientY),
|
||||||
);
|
);
|
||||||
setStick(vector);
|
};
|
||||||
onSubmitInput(vector);
|
|
||||||
|
const endTouchControl = (event: PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!touchOrigin || touchOrigin.pointerId !== event.pointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTouchOrigin(null);
|
||||||
|
submitDirection({ x: 0, y: 0 });
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!run) {
|
if (!run) {
|
||||||
@@ -206,7 +246,14 @@ export function BigFishRuntimeShell({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[100] flex justify-center bg-slate-950 text-white">
|
<div className="fixed inset-0 z-[100] flex justify-center bg-slate-950 text-white">
|
||||||
<div className="relative h-full w-full max-w-[430px] overflow-hidden bg-[radial-gradient(circle_at_50%_20%,rgba(34,211,238,0.2),transparent_28%),radial-gradient(circle_at_20%_80%,rgba(16,185,129,0.18),transparent_26%),linear-gradient(180deg,#082f49,#020617)]">
|
<div
|
||||||
|
ref={stageRef}
|
||||||
|
className="relative h-full w-full max-w-[430px] touch-none overflow-hidden bg-[radial-gradient(circle_at_50%_20%,rgba(34,211,238,0.2),transparent_28%),radial-gradient(circle_at_20%_80%,rgba(16,185,129,0.18),transparent_26%),linear-gradient(180deg,#082f49,#020617)]"
|
||||||
|
onPointerDown={beginTouchControl}
|
||||||
|
onPointerMove={updateTouchControl}
|
||||||
|
onPointerUp={endTouchControl}
|
||||||
|
onPointerCancel={endTouchControl}
|
||||||
|
>
|
||||||
{backgroundAsset ? (
|
{backgroundAsset ? (
|
||||||
<ResolvedAssetImage
|
<ResolvedAssetImage
|
||||||
src={backgroundAsset}
|
src={backgroundAsset}
|
||||||
@@ -251,40 +298,7 @@ export function BigFishRuntimeShell({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="absolute bottom-6 left-4 z-30">
|
<div className="pointer-events-none absolute bottom-6 right-4 z-30 max-w-[13rem] space-y-2 text-right text-xs text-white/72">
|
||||||
<div
|
|
||||||
ref={padRef}
|
|
||||||
role="presentation"
|
|
||||||
className="relative h-28 w-28 rounded-full border border-white/18 bg-black/24 backdrop-blur"
|
|
||||||
onPointerDown={(event) => {
|
|
||||||
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 });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="absolute left-1/2 top-1/2 h-11 w-11 -translate-x-1/2 -translate-y-1/2 rounded-full bg-cyan-200 shadow-lg shadow-cyan-950/30"
|
|
||||||
style={{
|
|
||||||
transform: `translate(calc(-50% + ${stick.x * 34}px), calc(-50% + ${stick.y * 34}px))`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="absolute bottom-6 right-4 z-30 max-w-[13rem] space-y-2 text-right text-xs text-white/72">
|
|
||||||
{isBusy ? <div>同步中...</div> : null}
|
{isBusy ? <div>同步中...</div> : null}
|
||||||
{error ? <div className="text-rose-200">{error}</div> : null}
|
{error ? <div className="text-rose-200">{error}</div> : null}
|
||||||
{run.eventLog.slice(-3).map((event) => (
|
{run.eventLog.slice(-3).map((event) => (
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
/* @vitest-environment jsdom */
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
import { afterEach, expect, test, vi } from 'vitest';
|
import { afterEach, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { createCreationAgentChatQuickActions } from '../../services/creation-agent';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type CreationAgentTheme,
|
type CreationAgentTheme,
|
||||||
CreationAgentWorkspace,
|
CreationAgentWorkspace,
|
||||||
@@ -243,17 +245,7 @@ test('creation agent workspace shows primary and progress actions at completed p
|
|||||||
loadingText="正在准备"
|
loadingText="正在准备"
|
||||||
composerPlaceholder="输入消息"
|
composerPlaceholder="输入消息"
|
||||||
primaryActionLabel="生成结果页"
|
primaryActionLabel="生成结果页"
|
||||||
quickActions={[
|
quickActions={createCreationAgentChatQuickActions()}
|
||||||
{
|
|
||||||
key: 'summarize',
|
|
||||||
label: '总结当前设定',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'quickFill',
|
|
||||||
label: '补全剩余设定',
|
|
||||||
minTurn: 2,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
onBack={() => {}}
|
onBack={() => {}}
|
||||||
onSubmitText={() => {}}
|
onSubmitText={() => {}}
|
||||||
onPrimaryAction={() => {}}
|
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();
|
||||||
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', () => {
|
test('creation agent workspace hides hero copy area when title and summary are absent', () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* @vitest-environment jsdom */
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
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(onSubmitMessage).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
text: '请补全剩余设定。',
|
text: '请补充剩余设定。',
|
||||||
quickFillRequested: true,
|
quickFillRequested: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import type {
|
|||||||
SendCustomWorldAgentMessageRequest,
|
SendCustomWorldAgentMessageRequest,
|
||||||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||||
import {
|
import {
|
||||||
|
buildCreationAgentChatMessage,
|
||||||
|
createCreationAgentChatQuickActions,
|
||||||
createCreationAgentClientMessageId,
|
createCreationAgentClientMessageId,
|
||||||
isCreationAgentOperationBusy,
|
isCreationAgentOperationBusy,
|
||||||
|
resolveCreationAgentQuickActionMessage,
|
||||||
} from '../../services/creation-agent';
|
} from '../../services/creation-agent';
|
||||||
import {
|
import {
|
||||||
type CreationAgentAnchorView,
|
type CreationAgentAnchorView,
|
||||||
@@ -166,13 +169,17 @@ export function CustomWorldAgentWorkspace({
|
|||||||
isCreationAgentOperationBusy(activeOperation) || isStreamingReply;
|
isCreationAgentOperationBusy(activeOperation) || isStreamingReply;
|
||||||
|
|
||||||
const submitMessage = (text: string, quickFillRequested = false) => {
|
const submitMessage = (text: string, quickFillRequested = false) => {
|
||||||
onSubmitMessage({
|
onSubmitMessage(
|
||||||
|
buildCreationAgentChatMessage({
|
||||||
clientMessageId: createCreationAgentClientMessageId('custom-world'),
|
clientMessageId: createCreationAgentClientMessageId('custom-world'),
|
||||||
text,
|
text,
|
||||||
quickFillRequested,
|
quickFillRequested,
|
||||||
|
extraPayload: {
|
||||||
focusCardId: null,
|
focusCardId: null,
|
||||||
selectedCardIds: [],
|
selectedCardIds: [],
|
||||||
});
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -186,17 +193,7 @@ export function CustomWorldAgentWorkspace({
|
|||||||
streamingReplyText={streamingReplyText}
|
streamingReplyText={streamingReplyText}
|
||||||
isStreamingReply={isStreamingReply}
|
isStreamingReply={isStreamingReply}
|
||||||
isBusy={isBusy}
|
isBusy={isBusy}
|
||||||
quickActions={[
|
quickActions={createCreationAgentChatQuickActions()}
|
||||||
{
|
|
||||||
key: 'summarize',
|
|
||||||
label: '总结当前设定',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'quickFill',
|
|
||||||
label: '补全剩余设定',
|
|
||||||
minTurn: 2,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
onBack={onBack}
|
onBack={onBack}
|
||||||
onSubmitText={(text) => {
|
onSubmitText={(text) => {
|
||||||
submitMessage(text);
|
submitMessage(text);
|
||||||
@@ -207,12 +204,14 @@ export function CustomWorldAgentWorkspace({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onQuickAction={(action) => {
|
onQuickAction={(action) => {
|
||||||
if (action.key === 'quickFill') {
|
const quickActionMessage = resolveCreationAgentQuickActionMessage(
|
||||||
submitMessage('请补全剩余设定。', true);
|
action.key,
|
||||||
return;
|
'请总结一下当前已经成形的世界设定。',
|
||||||
}
|
);
|
||||||
|
submitMessage(
|
||||||
submitMessage('请总结一下当前已经成形的世界设定。');
|
quickActionMessage.text,
|
||||||
|
quickActionMessage.quickFillRequested,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* @vitest-environment jsdom */
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
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(onSubmitMessage).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ import type {
|
|||||||
PuzzleAgentSessionSnapshot,
|
PuzzleAgentSessionSnapshot,
|
||||||
SendPuzzleAgentMessageRequest,
|
SendPuzzleAgentMessageRequest,
|
||||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||||
import { createCreationAgentClientMessageId } from '../../services/creation-agent';
|
import {
|
||||||
|
buildCreationAgentChatMessage,
|
||||||
|
createCreationAgentChatQuickActions,
|
||||||
|
createCreationAgentClientMessageId,
|
||||||
|
resolveCreationAgentQuickActionMessage,
|
||||||
|
} from '../../services/creation-agent';
|
||||||
import {
|
import {
|
||||||
type CreationAgentOperationView,
|
type CreationAgentOperationView,
|
||||||
type CreationAgentSessionView,
|
type CreationAgentSessionView,
|
||||||
@@ -100,35 +105,30 @@ export function PuzzleAgentWorkspace({
|
|||||||
isStreamingReply={Boolean(streamingReplyText)}
|
isStreamingReply={Boolean(streamingReplyText)}
|
||||||
isBusy={isBusy}
|
isBusy={isBusy}
|
||||||
error={error}
|
error={error}
|
||||||
quickActions={[
|
quickActions={createCreationAgentChatQuickActions()}
|
||||||
{
|
|
||||||
key: 'summarize',
|
|
||||||
label: '总结当前设定',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'quickFill',
|
|
||||||
label: '补充剩余关键字',
|
|
||||||
minTurn: 2,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
onBack={onBack}
|
onBack={onBack}
|
||||||
onSubmitText={(text) => {
|
onSubmitText={(text) => {
|
||||||
onSubmitMessage({
|
onSubmitMessage(
|
||||||
|
buildCreationAgentChatMessage({
|
||||||
clientMessageId: createCreationAgentClientMessageId('puzzle'),
|
clientMessageId: createCreationAgentClientMessageId('puzzle'),
|
||||||
text,
|
text,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onPrimaryAction={() => {
|
onPrimaryAction={() => {
|
||||||
onExecuteAction({ action: 'compile_puzzle_draft' });
|
onExecuteAction({ action: 'compile_puzzle_draft' });
|
||||||
}}
|
}}
|
||||||
onQuickAction={(action) => {
|
onQuickAction={(action) => {
|
||||||
onSubmitMessage({
|
const quickActionMessage = resolveCreationAgentQuickActionMessage(
|
||||||
|
action.key,
|
||||||
|
'请总结一下当前已经成形的拼图设定。',
|
||||||
|
);
|
||||||
|
onSubmitMessage(
|
||||||
|
buildCreationAgentChatMessage({
|
||||||
clientMessageId: createCreationAgentClientMessageId('puzzle'),
|
clientMessageId: createCreationAgentClientMessageId('puzzle'),
|
||||||
text:
|
...quickActionMessage,
|
||||||
action.key === 'quickFill'
|
}),
|
||||||
? '请补充剩余关键字。'
|
);
|
||||||
: '请总结一下当前已经成形的拼图设定。',
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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', () => {
|
it('routes former standalone editor paths back to the main game', () => {
|
||||||
expect(matchAppRoute('/item-editor/tools')).toEqual({
|
expect(matchAppRoute('/item-editor/tools')).toEqual({
|
||||||
kind: 'game',
|
kind: 'game',
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ type AppRouteComponent = LazyExoticComponent<
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
export type AppRouteMatch =
|
export type AppRouteMatch =
|
||||||
|
| {
|
||||||
|
kind: 'puzzle-playground';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'big-fish-playground';
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
kind: 'game';
|
kind: 'game';
|
||||||
};
|
};
|
||||||
@@ -20,6 +26,8 @@ export type ResolvedAppRoute = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const GameApp = lazy(() => import('../AuthenticatedApp')) as AppRouteComponent;
|
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) {
|
function normalizeRoutePath(pathname: string) {
|
||||||
const trimmedPathname = pathname.trim().toLowerCase();
|
const trimmedPathname = pathname.trim().toLowerCase();
|
||||||
@@ -32,7 +40,19 @@ function normalizeRoutePath(pathname: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function matchAppRoute(pathname: string): AppRouteMatch {
|
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 {
|
return {
|
||||||
kind: 'game',
|
kind: 'game',
|
||||||
@@ -42,6 +62,24 @@ export function matchAppRoute(pathname: string): AppRouteMatch {
|
|||||||
export function resolveAppRoute(pathname: string): ResolvedAppRoute {
|
export function resolveAppRoute(pathname: string): ResolvedAppRoute {
|
||||||
const matchedRoute = matchAppRoute(pathname);
|
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 {
|
return {
|
||||||
kind: 'game',
|
kind: 'game',
|
||||||
loadingEyebrow: '正在载入游戏',
|
loadingEyebrow: '正在载入游戏',
|
||||||
|
|||||||
58
src/services/creation-agent/creationAgentChat.test.ts
Normal file
58
src/services/creation-agent/creationAgentChat.test.ts
Normal file
@@ -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: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
63
src/services/creation-agent/creationAgentChat.ts
Normal file
63
src/services/creation-agent/creationAgentChat.ts
Normal file
@@ -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<TExtraPayload extends object = Record<string, never>>({
|
||||||
|
clientMessageId,
|
||||||
|
text,
|
||||||
|
quickFillRequested = false,
|
||||||
|
extraPayload,
|
||||||
|
}: {
|
||||||
|
clientMessageId: string;
|
||||||
|
text: string;
|
||||||
|
quickFillRequested?: boolean;
|
||||||
|
extraPayload?: TExtraPayload;
|
||||||
|
}): CreationAgentChatMessageBase & TExtraPayload {
|
||||||
|
return {
|
||||||
|
...(extraPayload ?? ({} as TExtraPayload)),
|
||||||
|
clientMessageId,
|
||||||
|
text,
|
||||||
|
quickFillRequested,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
|
export * from './creationAgentChat';
|
||||||
export * from './creationAgentProgress';
|
export * from './creationAgentProgress';
|
||||||
export * from './creationAgentSse';
|
export * from './creationAgentSse';
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ function buildInitialBoard(gridSize: PuzzleGridSize): PuzzleBoardSnapshot {
|
|||||||
const pieces = Array.from({ length: gridSize * gridSize }, (_, index) => {
|
const pieces = Array.from({ length: gridSize * gridSize }, (_, index) => {
|
||||||
const correctRow = Math.floor(index / gridSize);
|
const correctRow = Math.floor(index / gridSize);
|
||||||
const correctCol = index % gridSize;
|
const correctCol = index % gridSize;
|
||||||
const current = shuffledPositions[index];
|
const current = shuffledPositions[index] ?? { row: correctRow, col: correctCol };
|
||||||
return {
|
return {
|
||||||
pieceId: `piece-${index}`,
|
pieceId: `piece-${index}`,
|
||||||
correctRow,
|
correctRow,
|
||||||
|
|||||||
Reference in New Issue
Block a user