diff --git a/docs/prd/AI_NATIVE_SQUARE_HOLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-04.md b/docs/prd/AI_NATIVE_SQUARE_HOLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-04.md index 8fd06305..5b835995 100644 --- a/docs/prd/AI_NATIVE_SQUARE_HOLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-04.md +++ b/docs/prd/AI_NATIVE_SQUARE_HOLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-04.md @@ -74,7 +74,8 @@ 8. 后端初始化单局形状队列、洞口兼容规则和计分状态。 9. 玩家拖拽或点击形状投入洞口。 10. 后端裁决投入结果、连击、扣时、失败、胜利和成绩。 -11. 前端只渲染后端快照与即时反馈,不承接正式规则真相。 +11. 形状贴图、封面图和背景图必须由后端图片生成接口生成或由创作者上传,前端只保存和展示 URL / data URL。 +12. 前端只渲染后端快照与即时反馈,不承接正式规则真相。 --- @@ -106,7 +107,23 @@ Agent 需要把玩家一句灵感收束为上述锚点,不允许逐项盘问 "themeText": "", "twistRule": "", "shapeCount": 12, - "difficulty": 4 + "difficulty": 4, + "shapeOptions": [ + { + "shapeKind": "circle", + "label": "圆形", + "imagePrompt": "一个圆形办公室印章贴纸" + } + ], + "holeOptions": [ + { + "holeId": "square-hole", + "holeKind": "square", + "label": "方洞", + "bonus": true + } + ], + "backgroundPrompt": "办公室纸箱玩具桌面背景" } } ``` @@ -119,6 +136,10 @@ Agent 需要把玩家一句灵感收束为上述锚点,不允许逐项盘问 4. `quickFillRequested=true` 时,模型应直接补齐剩余配置,后端把 `progressPercent` 固定为 `100`。 5. 模型不可用或结果无法解析时,接口返回明确错误,不允许用确定性模板伪装成 AI 回复。 6. 非流式消息接口和 SSE 流式消息接口都必须走同一套方洞 Agent turn,SSE 只额外负责把 `replyText` 增量回传。 +7. `shapeOptions` 至少包含 `6` 个候选项;缺失时后端用当前题材生成默认候选项。 +8. `holeOptions` 至少包含 `3` 个选项,最多 `6` 个选项;创作者可以自定义 label、洞口类型与是否为加分选项。 +9. `bonus=true` 只表示“该选项被后端判定为正确时额外加 50 分”,不是公开提示;运行态 UI 不允许直接显示哪个选项是加分选项。 +10. `backgroundPrompt` 用于生成运行态背景图;为空时后端用题材和反差规则拼出默认提示词。 --- @@ -147,7 +168,30 @@ Agent 需要把玩家一句灵感收束为上述锚点,不允许逐项盘问 其中 `square_hole_priority` 是参考视频核心反差的首选默认规则。 -## 6.3 前端表现 +## 6.3 计分规则 + +首版计分由 `module-square-hole` 统一裁决: + +1. 正确投入基础得分:`100 + 当前连击数 * 10`。 +2. 如果本次投入的洞口是创作者配置的 `bonus=true` 选项,并且本次投入被判定为正确,额外加 `50` 分。 +3. 错误投入不扣分,但连击清零。 +4. 时间到、主动退出或本局结束不追加结算奖励。 +5. 前端只展示后端返回的 `score / combo / bestCombo`,不自行计算分数。 + +## 6.4 图片资产规则 + +1. 草稿生成后必须进入生成进度页,后端按草稿配置生成: + - 封面图 `coverImageSrc` + - 背景图 `backgroundImageSrc` + - 每个 `shapeOptions[].imageSrc` +2. 创作者上传入口保留;上传图片可以覆盖生成图片。 +3. 图片生成失败时保留草稿和可编辑配置,结果页展示缺失槽位,允许创作者重试生成或上传替代图。 +4. 结果页必须展示每个形状选项及其图片、背景图、封面图和洞口选项配置。 +5. 运行态当前形状优先显示 `imageSrc`,没有图片时才回退到 CSS 形状。 +6. 运行态背景优先显示 `backgroundImageSrc`,没有图片时才回退到默认渐变。 +7. 运行态顶部不显示“方洞是唯一解”或等价真实规则提示;只保留时间、进度、分数和连击。 + +## 6.5 前端表现 1. 竖屏优先,桌面端居中显示游戏台。 2. 当前形状位于屏幕下半区域,洞板位于上半区域。 @@ -207,10 +251,13 @@ Agent 需要把玩家一句灵感收束为上述锚点,不允许逐项盘问 10. `coverImageSrc` 11. `shapeCount` 12. `difficulty` -13. `publicationStatus` -14. `playCount` -15. `updatedAt` -16. `publishedAt` +13. `shapeOptions` +14. `holeOptions` +15. `backgroundImageSrc` +16. `publicationStatus` +17. `playCount` +18. `updatedAt` +19. `publishedAt` ## 8.3 运行态 run snapshot @@ -228,9 +275,12 @@ Agent 需要把玩家一句灵感收束为上述锚点,不允许逐项盘问 12. `bestCombo` 13. `score` 14. `ruleLabel` -15. `currentShape` -16. `holes` -17. `lastFeedback` +15. `backgroundImageSrc` +16. `currentShape` +17. `holes` +18. `lastFeedback` + +运行态 `ruleLabel` 仅保留后端调试兼容字段,前端默认不展示。 --- diff --git a/docs/technical/SQUARE_HOLE_AGENT_LLM_TIMEOUT_GUARD_2026-05-05.md b/docs/technical/SQUARE_HOLE_AGENT_LLM_TIMEOUT_GUARD_2026-05-05.md new file mode 100644 index 00000000..a73a2c1d --- /dev/null +++ b/docs/technical/SQUARE_HOLE_AGENT_LLM_TIMEOUT_GUARD_2026-05-05.md @@ -0,0 +1,32 @@ +# 方洞挑战 Agent LLM 超时兜底修复 2026-05-05 + +## 1. 问题 + +现场错误: + +```text +方洞挑战聊天生成失败,请稍后重试。:LLM 请求超时,累计尝试 1 次 +``` + +方洞挑战创作 Agent 在同一轮流式 JSON 中需要返回 `replyText`、玩法配置、形状选项、洞口选项和图片提示词。模型可能先返回可见回复,再继续输出完整 JSON;如果上游在流式读取阶段超过通用 LLM 请求超时,后端会发送 SSE `error`,前端只能保留本地 warning 消息,本轮后端会话不会成功推进。 + +## 2. 根因 + +`platform-llm` 的 `LlmTextRequest` 只有全局 `AppConfig.llm_request_timeout_ms`。创作 Agent 统一走 Responses 流式协议,方洞提示词扩展为视觉资产配置后,单轮输出长度明显增加;通用 30 秒超时更适合普通聊天,不适合结构化创作 Agent 的完整 JSON 流。 + +`request_text` 的初始 HTTP 请求会按 `max_retries` 重试,但 `stream_text` 已经进入 `response.chunk()` 读取后,当前错误路径固定记录为一次读取超时,所以用户看到“累计尝试 1 次”。 + +## 3. 落地策略 + +1. 在 `platform-llm::LlmTextRequest` 增加请求级 `request_timeout_ms` 覆写。 +2. `execute_request` 优先使用请求级超时,没有覆写时继续使用全局配置。 +3. `creation_agent_llm_turn` 的流式 JSON 请求统一使用更长的创作 Agent 超时窗口。 +4. 该超时窗口只影响创作 Agent 的结构化流式 turn,不改变 RPG 运行时聊天、图片生成、SpacetimeDB procedure 或方洞玩法判定。 +5. 不新增 SpacetimeDB 表结构,不修改 `migration.rs`。 + +## 4. 验收标准 + +1. `platform-llm` 测试覆盖请求级 timeout 会让慢响应提前超时。 +2. `creation_agent_llm_turn` 测试覆盖流式 JSON 请求带创作 Agent timeout。 +3. `cargo test -p platform-llm -p api-server creation_agent --manifest-path server-rs/Cargo.toml` 通过。 +4. 后端代码变更后按项目约束运行 `npm run api-server:maincloud` 并确认 `/healthz`。 diff --git a/packages/shared/src/contracts/squareHoleAgent.ts b/packages/shared/src/contracts/squareHoleAgent.ts index 65ca2c70..0befbd27 100644 --- a/packages/shared/src/contracts/squareHoleAgent.ts +++ b/packages/shared/src/contracts/squareHoleAgent.ts @@ -35,6 +35,21 @@ export interface ExecuteSquareHoleActionRequest { coverImageSrc?: string | null; } +export interface SquareHoleShapeOption { + optionId: string; + shapeKind: string; + label: string; + imagePrompt: string; + imageSrc?: string | null; +} + +export interface SquareHoleHoleOption { + holeId: string; + holeKind: string; + label: string; + bonus: boolean; +} + export interface SquareHoleAnchorItemResponse { key: string; label: string; @@ -54,6 +69,11 @@ export interface SquareHoleCreatorConfig { twistRule: string; shapeCount: number; difficulty: number; + shapeOptions: SquareHoleShapeOption[]; + holeOptions: SquareHoleHoleOption[]; + backgroundPrompt: string; + coverImageSrc?: string | null; + backgroundImageSrc?: string | null; } export interface SquareHoleResultDraft { @@ -63,6 +83,11 @@ export interface SquareHoleResultDraft { twistRule: string; summary: string; tags: string[]; + coverImageSrc?: string | null; + backgroundPrompt: string; + backgroundImageSrc?: string | null; + shapeOptions: SquareHoleShapeOption[]; + holeOptions: SquareHoleHoleOption[]; shapeCount: number; difficulty: number; publishReady: boolean; diff --git a/packages/shared/src/contracts/squareHoleRuntime.ts b/packages/shared/src/contracts/squareHoleRuntime.ts index 910de894..09c8612e 100644 --- a/packages/shared/src/contracts/squareHoleRuntime.ts +++ b/packages/shared/src/contracts/squareHoleRuntime.ts @@ -37,6 +37,7 @@ export interface SquareHoleShapeSnapshot { shapeKind: SquareHoleShapeKind; label: string; color: string; + imageSrc?: string | null; } export interface SquareHoleHoleSnapshot { @@ -45,6 +46,7 @@ export interface SquareHoleHoleSnapshot { label: string; x: number; y: number; + bonus: boolean; } export interface SquareHoleRunSnapshot { @@ -62,6 +64,7 @@ export interface SquareHoleRunSnapshot { bestCombo: number; score: number; ruleLabel: string; + backgroundImageSrc?: string | null; currentShape?: SquareHoleShapeSnapshot | null; holes: SquareHoleHoleSnapshot[]; lastFeedback?: SquareHoleDropFeedback | null; diff --git a/packages/shared/src/contracts/squareHoleWorks.ts b/packages/shared/src/contracts/squareHoleWorks.ts index a45c7876..4284f24d 100644 --- a/packages/shared/src/contracts/squareHoleWorks.ts +++ b/packages/shared/src/contracts/squareHoleWorks.ts @@ -4,6 +4,21 @@ */ export type SquareHoleWorkPublicationStatus = 'draft' | 'published' | string; +export interface SquareHoleShapeOption { + optionId: string; + shapeKind: string; + label: string; + imagePrompt: string; + imageSrc?: string | null; +} + +export interface SquareHoleHoleOption { + holeId: string; + holeKind: string; + label: string; + bonus: boolean; +} + export interface PutSquareHoleWorkRequest { gameName: string; themeText?: string; @@ -11,6 +26,10 @@ export interface PutSquareHoleWorkRequest { summary: string; tags: string[]; coverImageSrc?: string | null; + backgroundPrompt?: string; + backgroundImageSrc?: string | null; + shapeOptions?: SquareHoleShapeOption[]; + holeOptions?: SquareHoleHoleOption[]; shapeCount: number; difficulty: number; } @@ -26,6 +45,10 @@ export interface SquareHoleWorkSummary { summary: string; tags: string[]; coverImageSrc?: string | null; + backgroundPrompt: string; + backgroundImageSrc?: string | null; + shapeOptions: SquareHoleShapeOption[]; + holeOptions: SquareHoleHoleOption[]; shapeCount: number; difficulty: number; publicationStatus: SquareHoleWorkPublicationStatus; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 152b76f3..99378c6d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -13,9 +13,6 @@ export * from './contracts/puzzleAgentSession'; export * from './contracts/puzzleResultPreview'; export * from './contracts/puzzleRuntimeSession'; export * from './contracts/puzzleWorkSummary'; -export * from './contracts/squareHoleAgent'; -export * from './contracts/squareHoleRuntime'; -export * from './contracts/squareHoleWorks'; export * from './contracts/rpgAgentActions'; export * from './contracts/rpgAgentAnchors'; export * from './contracts/rpgAgentDraft'; @@ -29,6 +26,19 @@ export * from './contracts/rpgRuntimeQuestAssist'; export * from './contracts/rpgRuntimeStoryAction'; export * from './contracts/rpgRuntimeStoryState'; export * from './contracts/runtime'; +export * from './contracts/squareHoleAgent'; +export * from './contracts/squareHoleRuntime'; +export type { + PutSquareHoleWorkRequest, + SquareHoleWorkDetailResponse, + SquareHoleHoleOption as SquareHoleWorkHoleOption, + SquareHoleWorkMutationResponse, + SquareHoleWorkProfile, + SquareHoleWorkPublicationStatus, + SquareHoleShapeOption as SquareHoleWorkShapeOption, + SquareHoleWorksResponse, + SquareHoleWorkSummary, +} from './contracts/squareHoleWorks'; export type * from './contracts/story'; export * from './http'; export * from './llm/narrativeLanguage'; diff --git a/server-rs/crates/api-server/src/creation_agent_llm_turn.rs b/server-rs/crates/api-server/src/creation_agent_llm_turn.rs index 4ace99a4..3a2cf825 100644 --- a/server-rs/crates/api-server/src/creation_agent_llm_turn.rs +++ b/server-rs/crates/api-server/src/creation_agent_llm_turn.rs @@ -3,6 +3,8 @@ use serde_json::Value as JsonValue; use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL; +pub(crate) const CREATION_AGENT_STREAM_REQUEST_TIMEOUT_MS: u64 = 120_000; + #[derive(Clone, Copy, Debug)] pub(crate) struct CreationAgentLlmTurnErrorMessages<'a> { pub model_unavailable: &'a str, @@ -138,6 +140,7 @@ fn build_creation_agent_llm_request( .with_model(CREATION_TEMPLATE_LLM_MODEL) .with_responses_api() .with_web_search(enable_web_search) + .with_request_timeout_ms(CREATION_AGENT_STREAM_REQUEST_TIMEOUT_MS) } pub(crate) async fn request_creation_agent_json_turn( @@ -246,9 +249,9 @@ mod tests { use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL; use super::{ - CreationAgentLlmTurnErrorMessages, build_creation_agent_llm_request, - extract_reply_text_from_partial_json, is_web_search_tool_unavailable, - parse_json_response_text, stream_creation_agent_json_turn, + CREATION_AGENT_STREAM_REQUEST_TIMEOUT_MS, CreationAgentLlmTurnErrorMessages, + build_creation_agent_llm_request, extract_reply_text_from_partial_json, + is_web_search_tool_unavailable, parse_json_response_text, stream_creation_agent_json_turn, }; #[test] @@ -277,6 +280,10 @@ mod tests { assert_eq!(request.model.as_deref(), Some(CREATION_TEMPLATE_LLM_MODEL)); assert_eq!(request.protocol, platform_llm::LlmTextProtocol::Responses); assert_eq!(request.messages.len(), 2); + assert_eq!( + request.request_timeout_ms, + Some(CREATION_AGENT_STREAM_REQUEST_TIMEOUT_MS) + ); } #[test] diff --git a/server-rs/crates/api-server/src/llm.rs b/server-rs/crates/api-server/src/llm.rs index 0f5da782..c4944c6b 100644 --- a/server-rs/crates/api-server/src/llm.rs +++ b/server-rs/crates/api-server/src/llm.rs @@ -43,6 +43,7 @@ pub async fn proxy_llm_chat_completions( .collect::>(), max_tokens: None, enable_web_search: false, + request_timeout_ms: None, }; if payload.stream { diff --git a/server-rs/crates/api-server/src/prompt/square_hole.rs b/server-rs/crates/api-server/src/prompt/square_hole.rs index 0620df4b..b533eb8a 100644 --- a/server-rs/crates/api-server/src/prompt/square_hole.rs +++ b/server-rs/crates/api-server/src/prompt/square_hole.rs @@ -20,10 +20,12 @@ pub(crate) const SQUARE_HOLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责 2. nextConfig 必须是完整对象,不能只输出 patch 3. replyText 必须是自然中文,不能提“字段”“结构”“JSON”“后端”等内部词 4. replyText 一次最多推进一个最关键问题 -5. 如果用户要求自动配置,就直接补齐可发布草稿需要的题材、反差规则、形状数量和难度,不要继续提问 +5. 如果用户要求自动配置,就直接补齐可发布草稿需要的题材、反差规则、形状数量、难度、形状选项、洞口选项和背景提示,不要继续提问 6. 默认核心反差优先使用“方洞万能”或“方洞优先”,但可以根据用户题材包装成更有记忆点的规则 7. progressPercent 范围只能是 0 到 100 8. shapeCount 只能是 6 到 24 的整数,difficulty 只能是 1 到 10 的整数 +9. shapeOptions 至少给 6 个,holeOptions 给 3 到 6 个,且至少一个 holeOptions.bonus 为 true +10. imagePrompt 和 backgroundPrompt 必须适合直接生成图片,不要包含 UI、文字、水印或解释 "#; const SQUARE_HOLE_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输出,不要输出其他文字: @@ -34,7 +36,24 @@ const SQUARE_HOLE_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输 "themeText": "", "twistRule": "", "shapeCount": 12, - "difficulty": 4 + "difficulty": 4, + "shapeOptions": [ + { + "optionId": "square-block", + "shapeKind": "square", + "label": "方块", + "imagePrompt": "玩具纸箱主题的方块贴纸图,透明背景,明亮可爱,游戏资产" + } + ], + "holeOptions": [ + { + "holeId": "square-hole", + "holeKind": "square", + "label": "方洞", + "bonus": true + } + ], + "backgroundPrompt": "玩具桌面上的纸箱洞板背景,中央留出操作空间" } }"#; @@ -42,8 +61,7 @@ pub(crate) const SQUARE_HOLE_AGENT_JSON_TURN_USER_PROMPT: &str = "请按约定 /// 方洞挑战草稿生成对话提示词脚本。 /// -/// 方洞首版只需要四个可写回 SpacetimeDB 的配置项,因此提示词直接围绕配置收束, -/// 不在模型输出层引入额外锚点,避免和当前持久化 schema 产生漂移。 +/// 方洞 Agent 负责输出完整玩法配置;后端会继续归一化缺失选项,避免模型偶发漏项导致草稿失败。 pub(crate) fn build_square_hole_agent_prompt( session: &SquareHoleAgentSessionRecord, quick_fill_requested: bool, @@ -53,7 +71,7 @@ pub(crate) fn build_square_hole_agent_prompt( "\n\n{}", render_quick_fill_extra_rules( "当前方洞挑战方向里的题材、反差规则、形状数量和难度", - "不要要求用户再提供洞口、形状、演出或难度信息", + "不要要求用户再提供洞口、形状、背景或难度信息", "输出完整 nextConfig,直接补齐空缺或仍过于泛化的项", "生成结果页", ) @@ -62,7 +80,7 @@ pub(crate) fn build_square_hole_agent_prompt( String::new() }; format!( - "模板目标:收束成可试玩、可发布的方洞挑战玩法草稿。{quick_fill_rules}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n是否要求自动配置:{quick_fill_requested_text}\n\n当前配置:\n{current_config}\n\n最近聊天记录:\n{chat_history}\n\n收束要求:\n1. themeText 描述本局的玩具、道具或场景题材,保持短句。\n2. twistRule 描述真实判定规则,优先体现方洞优先或类似反直觉逻辑。\n3. shapeCount 决定单局形状数量,移动端短局建议 8 到 16。\n4. difficulty 决定误导强度和节奏,建议 3 到 7。\n5. 用户给出明确方向时优先吸收并推进,不要机械问完四个问题。\n\n{contract}", + "模板目标:收束成可试玩、可发布的方洞挑战玩法草稿。{quick_fill_rules}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n是否要求自动配置:{quick_fill_requested_text}\n\n当前配置:\n{current_config}\n\n最近聊天记录:\n{chat_history}\n\n收束要求:\n1. themeText 描述本局的玩具、道具或场景题材,保持短句。\n2. twistRule 描述真实判定规则,优先体现方洞优先或类似反直觉逻辑。\n3. shapeCount 决定单局形状数量,移动端短局建议 8 到 16。\n4. difficulty 决定误导强度和节奏,建议 3 到 7。\n5. shapeOptions 必须给出至少 6 个可生成贴图的形状候选,每个 imagePrompt 都围绕主题生成。\n6. holeOptions 必须给出 3 到 6 个洞口,创作者可在结果页继续改;至少一个 bonus=true。\n7. backgroundPrompt 用于生成运行态背景,必须描述画面,不要写规则说明。\n8. 用户给出明确方向时优先吸收并推进,不要机械问完所有字段。\n\n{contract}", quick_fill_rules = quick_fill_rules, turn = session.current_turn.saturating_add(1), progress = session.progress_percent, @@ -76,11 +94,42 @@ pub(crate) fn build_square_hole_agent_prompt( } fn serialize_square_hole_session_config(session: &SquareHoleAgentSessionRecord) -> String { + let shape_options: Vec = session + .config + .shape_options + .iter() + .map(|option| { + json!({ + "optionId": option.option_id, + "shapeKind": option.shape_kind, + "label": option.label, + "imagePrompt": option.image_prompt, + "imageSrc": option.image_src, + }) + }) + .collect(); + let hole_options: Vec = session + .config + .hole_options + .iter() + .map(|option| { + json!({ + "holeId": option.hole_id, + "holeKind": option.hole_kind, + "label": option.label, + "bonus": option.bonus, + }) + }) + .collect(); + serde_json::to_string_pretty(&json!({ "themeText": session.config.theme_text, "twistRule": session.config.twist_rule, "shapeCount": session.config.shape_count, "difficulty": session.config.difficulty, + "shapeOptions": shape_options, + "holeOptions": hole_options, + "backgroundPrompt": session.config.background_prompt, })) .unwrap_or_else(|_| "{}".to_string()) } @@ -129,6 +178,11 @@ mod tests { twist_rule: "方洞万能".to_string(), shape_count: 12, difficulty: 4, + shape_options: Vec::new(), + hole_options: Vec::new(), + background_prompt: "积木纸箱桌面背景".to_string(), + cover_image_src: None, + background_image_src: None, }, draft: None, messages: vec![message("user", "做成办公室文具版")], @@ -159,6 +213,9 @@ mod tests { assert!(prompt.contains("用户刚刚主动要求你自动补充剩余关键字")); assert!(prompt.contains("不要再继续提问")); assert!(prompt.contains("nextConfig")); + assert!(prompt.contains("shapeOptions")); + assert!(prompt.contains("holeOptions")); + assert!(prompt.contains("backgroundPrompt")); assert!(prompt.contains("progressPercent 直接输出为 100")); } } diff --git a/server-rs/crates/api-server/src/square_hole.rs b/server-rs/crates/api-server/src/square_hole.rs index d324ab7c..b8fbaeb5 100644 --- a/server-rs/crates/api-server/src/square_hole.rs +++ b/server-rs/crates/api-server/src/square_hole.rs @@ -13,9 +13,11 @@ use axum::{ sse::{Event, Sse}, }, }; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use module_square_hole::{ SQUARE_HOLE_MESSAGE_ID_PREFIX, SQUARE_HOLE_PROFILE_ID_PREFIX, SQUARE_HOLE_RUN_ID_PREFIX, - SQUARE_HOLE_SESSION_ID_PREFIX, + SQUARE_HOLE_SESSION_ID_PREFIX, default_background_prompt, normalize_hole_options, + normalize_shape_options, }; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -24,8 +26,9 @@ use shared_contracts::{ CreateSquareHoleSessionRequest, ExecuteSquareHoleActionRequest, SendSquareHoleMessageRequest, SquareHoleActionResponse, SquareHoleAgentMessageResponse, SquareHoleAnchorItemResponse, SquareHoleAnchorPackResponse, - SquareHoleCreatorConfigResponse, SquareHoleResultDraftResponse, SquareHoleSessionResponse, - SquareHoleSessionSnapshotResponse, + SquareHoleCreatorConfigResponse, SquareHoleHoleOptionResponse, + SquareHoleResultDraftResponse, SquareHoleSessionResponse, + SquareHoleSessionSnapshotResponse, SquareHoleShapeOptionResponse, }, square_hole_runtime::{ DropSquareHoleShapeRequest, SquareHoleDropFeedbackResponse, SquareHoleDropResponse, @@ -33,7 +36,9 @@ use shared_contracts::{ SquareHoleShapeSnapshotResponse, StartSquareHoleRunRequest, StopSquareHoleRunRequest, }, square_hole_works::{ - PutSquareHoleWorkRequest, SquareHoleWorkDetailResponse, SquareHoleWorkMutationResponse, + PutSquareHoleWorkRequest, SquareHoleHoleOptionResponse as SquareHoleWorkHoleOptionResponse, + SquareHoleShapeOptionResponse as SquareHoleWorkShapeOptionResponse, + SquareHoleWorkDetailResponse, SquareHoleWorkMutationResponse, SquareHoleWorkProfileResponse, SquareHoleWorkSummaryResponse, SquareHoleWorksResponse, }, }; @@ -42,11 +47,11 @@ use spacetime_client::{ SpacetimeClientError, SquareHoleAgentMessageRecord, SquareHoleAgentMessageSubmitRecordInput, SquareHoleAgentSessionCreateRecordInput, SquareHoleAgentSessionRecord, SquareHoleAnchorItemRecord, SquareHoleAnchorPackRecord, SquareHoleCompileDraftRecordInput, - SquareHoleCreatorConfigRecord, SquareHoleDropFeedbackRecord, SquareHoleHoleSnapshotRecord, - SquareHoleResultDraftRecord, SquareHoleRunDropRecordInput, SquareHoleRunRecord, - SquareHoleRunRestartRecordInput, SquareHoleRunStartRecordInput, SquareHoleRunStopRecordInput, - SquareHoleRunTimeUpRecordInput, SquareHoleShapeSnapshotRecord, SquareHoleWorkProfileRecord, - SquareHoleWorkUpdateRecordInput, + SquareHoleCreatorConfigRecord, SquareHoleDropFeedbackRecord, SquareHoleHoleOptionRecord, + SquareHoleHoleSnapshotRecord, SquareHoleResultDraftRecord, SquareHoleRunDropRecordInput, + SquareHoleRunRecord, SquareHoleRunRestartRecordInput, SquareHoleRunStartRecordInput, + SquareHoleRunStopRecordInput, SquareHoleRunTimeUpRecordInput, SquareHoleShapeOptionRecord, + SquareHoleShapeSnapshotRecord, SquareHoleWorkProfileRecord, SquareHoleWorkUpdateRecordInput, }; use crate::{ @@ -54,6 +59,10 @@ use crate::{ api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, + openai_image_generation::{ + build_openai_image_http_client, create_openai_image_generation, + require_openai_image_settings, + }, request_context::RequestContext, square_hole_agent_turn::{ SquareHoleAgentTurnRequest, build_finalize_record_input, run_square_hole_agent_turn, @@ -77,6 +86,37 @@ struct SquareHoleConfigJson { twist_rule: String, shape_count: u32, difficulty: u32, + #[serde(default)] + shape_options: Vec, + #[serde(default)] + hole_options: Vec, + #[serde(default)] + background_prompt: String, + #[serde(default, deserialize_with = "deserialize_optional_string_as_default")] + cover_image_src: String, + #[serde(default, deserialize_with = "deserialize_optional_string_as_default")] + background_image_src: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SquareHoleConfigShapeOptionJson { + option_id: String, + shape_kind: String, + label: String, + image_prompt: String, + #[serde(default, deserialize_with = "deserialize_optional_string_as_default")] + image_src: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SquareHoleConfigHoleOptionJson { + hole_id: String, + hole_kind: String, + label: String, + #[serde(default)] + bonus: bool, } #[derive(Clone, Debug, Deserialize)] @@ -299,25 +339,37 @@ pub async fn execute_square_hole_agent_action( "sessionId", )?; - if payload.action.trim() != "square_hole_compile_draft" { - return Err(square_hole_bad_request( - &request_context, - SQUARE_HOLE_AGENT_PROVIDER, - "unknown square hole action", - )); - } - - let session = compile_square_hole_draft_for_session( - &state, - &request_context, - &authenticated, - session_id, - payload.game_name, - payload.summary, - payload.tags, - payload.cover_image_src, - ) - .await?; + let session = match payload.action.trim() { + "square_hole_compile_draft" => { + compile_square_hole_draft_for_session( + &state, + &request_context, + &authenticated, + session_id, + payload.game_name, + payload.summary, + payload.tags, + payload.cover_image_src, + ) + .await? + } + "square_hole_generate_visual_assets" => { + generate_square_hole_visual_assets_for_session( + &state, + &request_context, + &authenticated, + session_id, + ) + .await? + } + _ => { + return Err(square_hole_bad_request( + &request_context, + SQUARE_HOLE_AGENT_PROVIDER, + "unknown square hole action", + )); + } + }; Ok(json_success_body( Some(&request_context), @@ -491,6 +543,18 @@ pub async fn put_square_hole_work( .clone() .filter(|value| !value.trim().is_empty()) .unwrap_or(existing.theme_text); + let shape_options_json = payload + .shape_options + .clone() + .map(square_hole_work_shape_options_to_records) + .unwrap_or_else(|| existing.shape_options.clone()); + let shape_options_json = serialize_square_hole_shape_option_records(&shape_options_json); + let hole_options_json = payload + .hole_options + .clone() + .map(square_hole_work_hole_options_to_records) + .unwrap_or_else(|| existing.hole_options.clone()); + let hole_options_json = serialize_square_hole_hole_option_records(&hole_options_json); let item = state .spacetime_client() .update_square_hole_work(SquareHoleWorkUpdateRecordInput { @@ -502,6 +566,15 @@ pub async fn put_square_hole_work( summary_text: payload.summary, tags_json: serde_json::to_string(&normalize_tags(payload.tags)).unwrap_or_default(), cover_image_src: payload.cover_image_src.unwrap_or_default(), + background_prompt: payload + .background_prompt + .filter(|value| !value.trim().is_empty()) + .unwrap_or(existing.background_prompt), + background_image_src: payload + .background_image_src + .unwrap_or(existing.background_image_src.unwrap_or_default()), + shape_options_json, + hole_options_json, shape_count: payload.shape_count, difficulty: payload.difficulty, updated_at_micros: current_utc_micros(), @@ -986,6 +1059,231 @@ async fn compile_square_hole_draft_for_session( }) } +async fn generate_square_hole_visual_assets_for_session( + state: &AppState, + request_context: &RequestContext, + authenticated: &AuthenticatedAccessToken, + session_id: String, +) -> Result { + let owner_user_id = authenticated.claims().user_id().to_string(); + let session = state + .spacetime_client() + .get_square_hole_agent_session(session_id.clone(), owner_user_id.clone()) + .await + .map_err(|error| { + square_hole_error_response( + request_context, + SQUARE_HOLE_AGENT_PROVIDER, + map_square_hole_client_error(error), + ) + })?; + let profile_id = session + .draft + .as_ref() + .map(|draft| draft.profile_id.clone()) + .ok_or_else(|| { + square_hole_bad_request( + request_context, + SQUARE_HOLE_AGENT_PROVIDER, + "square hole 草稿尚未编译,不能生成图片资产", + ) + })?; + let mut work = state + .spacetime_client() + .get_square_hole_work_detail(profile_id.clone(), owner_user_id.clone()) + .await + .map_err(|error| { + square_hole_error_response( + request_context, + SQUARE_HOLE_AGENT_PROVIDER, + map_square_hole_client_error(error), + ) + })?; + + let cover_image_src = match work.cover_image_src.clone() { + Some(value) if !value.trim().is_empty() => Some(value), + _ => Some( + generate_square_hole_image_data_url( + state, + build_square_hole_cover_prompt(&work).as_str(), + "16:9", + "生成方洞挑战封面图失败", + ) + .await + .map_err(|error| { + square_hole_error_response(request_context, SQUARE_HOLE_AGENT_PROVIDER, error) + })?, + ), + }; + let background_image_src = match work.background_image_src.clone() { + Some(value) if !value.trim().is_empty() => Some(value), + _ => Some( + generate_square_hole_image_data_url( + state, + build_square_hole_background_prompt(&work).as_str(), + "16:9", + "生成方洞挑战背景图失败", + ) + .await + .map_err(|error| { + square_hole_error_response(request_context, SQUARE_HOLE_AGENT_PROVIDER, error) + })?, + ), + }; + let mut shape_options = work.shape_options.clone(); + let prompt_work = work.clone(); + for option in shape_options.iter_mut() { + if option + .image_src + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some() + { + continue; + } + option.image_src = Some( + generate_square_hole_image_data_url( + state, + build_square_hole_shape_prompt(&prompt_work, option).as_str(), + "1:1", + "生成方洞挑战形状贴图失败", + ) + .await + .map_err(|error| { + square_hole_error_response(request_context, SQUARE_HOLE_AGENT_PROVIDER, error) + })?, + ); + } + + work = state + .spacetime_client() + .update_square_hole_work(SquareHoleWorkUpdateRecordInput { + profile_id, + owner_user_id: owner_user_id.clone(), + game_name: work.game_name.clone(), + theme_text: work.theme_text.clone(), + twist_rule: work.twist_rule.clone(), + summary_text: work.summary.clone(), + tags_json: serde_json::to_string(&normalize_tags(work.tags.clone())) + .unwrap_or_default(), + cover_image_src: cover_image_src.clone().unwrap_or_default(), + background_prompt: work.background_prompt.clone(), + background_image_src: background_image_src.clone().unwrap_or_default(), + shape_options_json: serialize_square_hole_shape_option_records(&shape_options), + hole_options_json: serialize_square_hole_hole_option_records(&work.hole_options), + shape_count: work.shape_count, + difficulty: work.difficulty, + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + square_hole_error_response( + request_context, + SQUARE_HOLE_AGENT_PROVIDER, + map_square_hole_client_error(error), + ) + })?; + + let mut next_session = state + .spacetime_client() + .get_square_hole_agent_session(session_id, owner_user_id) + .await + .map_err(|error| { + square_hole_error_response( + request_context, + SQUARE_HOLE_AGENT_PROVIDER, + map_square_hole_client_error(error), + ) + })?; + if let Some(draft) = next_session.draft.as_mut() { + draft.cover_image_src = work.cover_image_src.clone(); + draft.background_image_src = work.background_image_src.clone(); + draft.background_prompt = work.background_prompt.clone(); + draft.shape_options = work.shape_options.clone(); + draft.hole_options = work.hole_options.clone(); + } + Ok(next_session) +} + +async fn generate_square_hole_image_data_url( + state: &AppState, + prompt: &str, + size: &str, + failure_context: &str, +) -> Result { + let settings = require_openai_image_settings(state)?; + let http_client = build_openai_image_http_client(&settings)?; + let generated = create_openai_image_generation( + &http_client, + &settings, + prompt, + Some(build_square_hole_negative_prompt().as_str()), + size, + 1, + &[], + failure_context, + ) + .await?; + let image = generated.images.into_iter().next().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "apimart", + "message": format!("{failure_context}:上游未返回图片"), + })) + })?; + + Ok(format!( + "data:{};base64,{}", + image.mime_type, + BASE64_STANDARD.encode(image.bytes) + )) +} + +fn build_square_hole_cover_prompt(work: &SquareHoleWorkProfileRecord) -> String { + format!( + "移动端休闲游戏封面图。主题:{}。玩法反差:{}。画面主体是贴着主题图案的几何形状正在靠近不同洞口,视觉清晰、色彩明快、偏游戏资产质感。不要文字、不要 UI、不要水印。", + clean_prompt_text(&work.theme_text, "奇怪形状"), + clean_prompt_text(&work.twist_rule, "反直觉分拣") + ) +} + +fn build_square_hole_background_prompt(work: &SquareHoleWorkProfileRecord) -> String { + let custom_prompt = work.background_prompt.trim(); + if !custom_prompt.is_empty() { + return format!( + "移动端休闲游戏运行背景。{}。画面中央预留清晰操作空间,边缘可有主题装饰,低噪声,不要文字、不要 UI、不要水印。", + custom_prompt + ); + } + + format!( + "移动端休闲游戏运行背景。主题:{}。柔和纵深、玩具盒或舞台感,中央预留清晰操作空间,不要文字、不要 UI、不要水印。", + clean_prompt_text(&work.theme_text, "奇怪形状") + ) +} + +fn build_square_hole_shape_prompt( + work: &SquareHoleWorkProfileRecord, + option: &SquareHoleShapeOptionRecord, +) -> String { + let image_prompt = option.image_prompt.trim(); + let option_prompt = if image_prompt.is_empty() { + format!("{} 主题的 {}", work.theme_text, option.label) + } else { + image_prompt.to_string() + }; + + format!( + "单个游戏道具贴图,透明或干净浅色背景。几何形状:{}。主题贴图:{}。要求主体居中、边缘清晰、适合贴在可拖拽形状上,不要文字、不要 UI、不要水印。", + clean_prompt_text(&option.label, "形状"), + clean_prompt_text(&option_prompt, "主题图案") + ) +} + +fn build_square_hole_negative_prompt() -> String { + "文字、水印、复杂 UI、真实人物、恐怖血腥、低清晰度、过度模糊、主体被裁切、多个主体".to_string() +} + fn map_square_hole_agent_session_response( session: SquareHoleAgentSessionRecord, ) -> SquareHoleSessionSnapshotResponse { @@ -1084,6 +1382,19 @@ fn map_square_hole_config_response( twist_rule: config.twist_rule, shape_count: config.shape_count, difficulty: config.difficulty, + shape_options: config + .shape_options + .into_iter() + .map(map_square_hole_shape_option_response) + .collect(), + hole_options: config + .hole_options + .into_iter() + .map(map_square_hole_hole_option_response) + .collect(), + background_prompt: config.background_prompt, + cover_image_src: config.cover_image_src, + background_image_src: config.background_image_src, } } @@ -1097,6 +1408,19 @@ fn map_square_hole_draft_response( twist_rule: draft.twist_rule, summary: draft.summary, tags: draft.tags, + cover_image_src: draft.cover_image_src, + background_prompt: draft.background_prompt, + background_image_src: draft.background_image_src, + shape_options: draft + .shape_options + .into_iter() + .map(map_square_hole_shape_option_response) + .collect(), + hole_options: draft + .hole_options + .into_iter() + .map(map_square_hole_hole_option_response) + .collect(), shape_count: draft.shape_count, difficulty: draft.difficulty, publish_ready: draft.publish_ready, @@ -1130,6 +1454,18 @@ fn map_square_hole_work_summary_response( summary: item.summary, tags: item.tags, cover_image_src: item.cover_image_src, + background_prompt: item.background_prompt, + background_image_src: item.background_image_src, + shape_options: item + .shape_options + .into_iter() + .map(map_square_hole_work_shape_option_response) + .collect(), + hole_options: item + .hole_options + .into_iter() + .map(map_square_hole_work_hole_option_response) + .collect(), shape_count: item.shape_count, difficulty: item.difficulty, publication_status: item.publication_status, @@ -1164,6 +1500,7 @@ fn map_square_hole_run_response(run: SquareHoleRunRecord) -> SquareHoleRunSnapsh best_combo: run.best_combo, score: run.score, rule_label: run.rule_label, + background_image_src: run.background_image_src, current_shape: run.current_shape.map(map_square_hole_shape_response), holes: run .holes @@ -1182,6 +1519,7 @@ fn map_square_hole_shape_response( shape_kind: item.shape_kind, label: item.label, color: item.color, + image_src: item.image_src, } } @@ -1194,6 +1532,53 @@ fn map_square_hole_hole_response( label: slot.label, x: slot.x, y: slot.y, + bonus: slot.bonus, + } +} + +fn map_square_hole_shape_option_response( + item: SquareHoleShapeOptionRecord, +) -> SquareHoleShapeOptionResponse { + SquareHoleShapeOptionResponse { + option_id: item.option_id, + shape_kind: item.shape_kind, + label: item.label, + image_prompt: item.image_prompt, + image_src: item.image_src, + } +} + +fn map_square_hole_hole_option_response( + item: SquareHoleHoleOptionRecord, +) -> SquareHoleHoleOptionResponse { + SquareHoleHoleOptionResponse { + hole_id: item.hole_id, + hole_kind: item.hole_kind, + label: item.label, + bonus: item.bonus, + } +} + +fn map_square_hole_work_shape_option_response( + item: SquareHoleShapeOptionRecord, +) -> SquareHoleWorkShapeOptionResponse { + SquareHoleWorkShapeOptionResponse { + option_id: item.option_id, + shape_kind: item.shape_kind, + label: item.label, + image_prompt: item.image_prompt, + image_src: item.image_src, + } +} + +fn map_square_hole_work_hole_option_response( + item: SquareHoleHoleOptionRecord, +) -> SquareHoleWorkHoleOptionResponse { + SquareHoleWorkHoleOptionResponse { + hole_id: item.hole_id, + hole_kind: item.hole_kind, + label: item.label, + bonus: item.bonus, } } @@ -1234,6 +1619,24 @@ fn build_config_from_create_request( .difficulty .unwrap_or(SQUARE_HOLE_DEFAULT_DIFFICULTY) .clamp(1, 10), + shape_options: square_hole_shape_records_to_config_json(normalize_shape_options( + Vec::new(), + payload + .theme_text + .as_deref() + .or(payload.seed_text.as_deref()) + .unwrap_or(SQUARE_HOLE_DEFAULT_THEME), + )), + hole_options: square_hole_hole_records_to_config_json(normalize_hole_options(Vec::new())), + background_prompt: default_background_prompt( + payload + .theme_text + .as_deref() + .or(payload.seed_text.as_deref()) + .unwrap_or(SQUARE_HOLE_DEFAULT_THEME), + ), + cover_image_src: String::new(), + background_image_src: String::new(), } } @@ -1246,12 +1649,27 @@ fn resolve_config_or_default( twist_rule: config.twist_rule.clone(), shape_count: config.shape_count.max(1), difficulty: config.difficulty.clamp(1, 10), + shape_options: square_hole_shape_records_to_config_json(config.shape_options.clone()), + hole_options: square_hole_hole_records_to_config_json(config.hole_options.clone()), + background_prompt: config.background_prompt.clone(), + cover_image_src: config.cover_image_src.clone().unwrap_or_default(), + background_image_src: config.background_image_src.clone().unwrap_or_default(), }) .unwrap_or_else(|| SquareHoleConfigJson { theme_text: SQUARE_HOLE_DEFAULT_THEME.to_string(), twist_rule: SQUARE_HOLE_DEFAULT_TWIST_RULE.to_string(), shape_count: SQUARE_HOLE_DEFAULT_SHAPE_COUNT, difficulty: SQUARE_HOLE_DEFAULT_DIFFICULTY, + shape_options: square_hole_shape_records_to_config_json(normalize_shape_options( + Vec::new(), + SQUARE_HOLE_DEFAULT_THEME, + )), + hole_options: square_hole_hole_records_to_config_json(normalize_hole_options( + Vec::new(), + )), + background_prompt: default_background_prompt(SQUARE_HOLE_DEFAULT_THEME), + cover_image_src: String::new(), + background_image_src: String::new(), }) } @@ -1259,6 +1677,13 @@ fn serialize_square_hole_config(config: &SquareHoleConfigJson) -> Option serde_json::to_string(config).ok() } +fn deserialize_optional_string_as_default<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + Ok(Option::::deserialize(deserializer)?.unwrap_or_default()) +} + fn build_seed_text( payload: &CreateSquareHoleSessionRequest, config: &SquareHoleConfigJson, @@ -1291,6 +1716,118 @@ fn normalize_tags(tags: Vec) -> Vec { result } +fn square_hole_shape_records_to_config_json( + options: Vec>, +) -> Vec { + options.into_iter().map(Into::into).collect() +} + +fn square_hole_hole_records_to_config_json( + options: Vec>, +) -> Vec { + options.into_iter().map(Into::into).collect() +} + +fn square_hole_work_shape_options_to_records( + options: Vec, +) -> Vec { + options + .into_iter() + .map(|option| SquareHoleShapeOptionRecord { + option_id: option.option_id, + shape_kind: option.shape_kind, + label: option.label, + image_prompt: option.image_prompt, + image_src: option.image_src.filter(|value| !value.trim().is_empty()), + }) + .collect() +} + +fn square_hole_work_hole_options_to_records( + options: Vec, +) -> Vec { + options + .into_iter() + .map(|option| SquareHoleHoleOptionRecord { + hole_id: option.hole_id, + hole_kind: option.hole_kind, + label: option.label, + bonus: option.bonus, + }) + .collect() +} + +fn serialize_square_hole_shape_option_records(options: &[SquareHoleShapeOptionRecord]) -> String { + let json_options: Vec = + options.iter().cloned().map(Into::into).collect(); + serde_json::to_string(&json_options).unwrap_or_default() +} + +fn serialize_square_hole_hole_option_records(options: &[SquareHoleHoleOptionRecord]) -> String { + let json_options: Vec = + options.iter().cloned().map(Into::into).collect(); + serde_json::to_string(&json_options).unwrap_or_default() +} + +fn clean_prompt_text(value: &str, fallback: &str) -> String { + let cleaned = value + .trim() + .split_whitespace() + .collect::>() + .join(" "); + if cleaned.is_empty() { + fallback.to_string() + } else { + cleaned.chars().take(180).collect() + } +} + +impl From for SquareHoleConfigShapeOptionJson { + fn from(option: module_square_hole::SquareHoleShapeOption) -> Self { + Self { + option_id: option.option_id, + shape_kind: option.shape_kind, + label: option.label, + image_prompt: option.image_prompt, + image_src: option.image_src.unwrap_or_default(), + } + } +} + +impl From for SquareHoleConfigShapeOptionJson { + fn from(option: SquareHoleShapeOptionRecord) -> Self { + Self { + option_id: option.option_id, + shape_kind: option.shape_kind, + label: option.label, + image_prompt: option.image_prompt, + image_src: option.image_src.unwrap_or_default(), + } + } +} + +impl From for SquareHoleConfigHoleOptionJson { + fn from(option: module_square_hole::SquareHoleHoleOption) -> Self { + Self { + hole_id: option.hole_id, + hole_kind: option.hole_kind, + label: option.label, + bonus: option.bonus, + } + } +} + +impl From for SquareHoleConfigHoleOptionJson { + fn from(option: SquareHoleHoleOptionRecord) -> Self { + Self { + hole_id: option.hole_id, + hole_kind: option.hole_kind, + label: option.label, + bonus: option.bonus, + } + } +} + fn resolve_author_display_name( state: &AppState, authenticated: &AuthenticatedAccessToken, diff --git a/server-rs/crates/api-server/src/square_hole_agent_turn.rs b/server-rs/crates/api-server/src/square_hole_agent_turn.rs index 961662ab..8bc9aaa3 100644 --- a/server-rs/crates/api-server/src/square_hole_agent_turn.rs +++ b/server-rs/crates/api-server/src/square_hole_agent_turn.rs @@ -1,6 +1,7 @@ use module_square_hole::{ SQUARE_HOLE_MAX_DIFFICULTY, SQUARE_HOLE_MAX_SHAPE_COUNT, SQUARE_HOLE_MIN_DIFFICULTY, - SQUARE_HOLE_MIN_SHAPE_COUNT, + SQUARE_HOLE_MIN_SHAPE_COUNT, SquareHoleHoleOption, SquareHoleShapeOption, + default_background_prompt, normalize_hole_options, normalize_shape_options, }; use platform_llm::LlmClient; use serde::{Deserialize, Serialize}; @@ -68,6 +69,34 @@ struct SquareHoleAgentConfigOutput { twist_rule: String, shape_count: u32, difficulty: u32, + shape_options: Vec, + hole_options: Vec, + background_prompt: String, + #[serde(default)] + cover_image_src: String, + #[serde(default)] + background_image_src: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SquareHoleAgentShapeOptionOutput { + option_id: String, + shape_kind: String, + label: String, + image_prompt: String, + #[serde(default)] + image_src: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SquareHoleAgentHoleOptionOutput { + hole_id: String, + hole_kind: String, + label: String, + #[serde(default)] + bonus: bool, } pub(crate) async fn run_square_hole_agent_turn( @@ -166,20 +195,136 @@ fn parse_model_config( )); } + let theme_text = read_text_field(value, "themeText") + .unwrap_or_else(|| session.config.theme_text.clone()); + let twist_rule = read_text_field(value, "twistRule") + .unwrap_or_else(|| session.config.twist_rule.clone()); + let shape_options = parse_shape_options(value, session, &theme_text); + let hole_options = parse_hole_options(value, session); + let background_prompt = read_text_field(value, "backgroundPrompt") + .or_else(|| { + session + .config + .background_prompt + .trim() + .is_empty() + .then(|| default_background_prompt(&theme_text)) + }) + .unwrap_or_else(|| session.config.background_prompt.clone()); + Ok(SquareHoleAgentConfigOutput { - theme_text: read_text_field(value, "themeText") - .unwrap_or_else(|| session.config.theme_text.clone()), - twist_rule: read_text_field(value, "twistRule") - .unwrap_or_else(|| session.config.twist_rule.clone()), + theme_text, + twist_rule, shape_count: read_u32_field(value, "shapeCount") .unwrap_or(session.config.shape_count) .clamp(SQUARE_HOLE_MIN_SHAPE_COUNT, SQUARE_HOLE_MAX_SHAPE_COUNT), difficulty: read_u32_field(value, "difficulty") .unwrap_or(session.config.difficulty) .clamp(SQUARE_HOLE_MIN_DIFFICULTY, SQUARE_HOLE_MAX_DIFFICULTY), + shape_options: shape_options + .into_iter() + .map(SquareHoleAgentShapeOptionOutput::from) + .collect(), + hole_options: hole_options + .into_iter() + .map(SquareHoleAgentHoleOptionOutput::from) + .collect(), + background_prompt, + cover_image_src: session.config.cover_image_src.clone().unwrap_or_default(), + background_image_src: session + .config + .background_image_src + .clone() + .unwrap_or_default(), }) } +fn parse_shape_options( + value: &JsonValue, + session: &SquareHoleAgentSessionRecord, + theme_text: &str, +) -> Vec { + let parsed = value + .get("shapeOptions") + .and_then(JsonValue::as_array) + .map(|items| { + items + .iter() + .enumerate() + .map(|(index, item)| SquareHoleShapeOption { + option_id: read_text_field(item, "optionId") + .unwrap_or_else(|| format!("shape-option-{index}")), + shape_kind: read_text_field(item, "shapeKind") + .unwrap_or_else(|| fallback_shape_kind(index).to_string()), + label: read_text_field(item, "label") + .unwrap_or_else(|| fallback_shape_label(index).to_string()), + image_prompt: read_text_field(item, "imagePrompt").unwrap_or_else(|| { + format!("{theme_text}主题的{}贴纸图,透明背景,明亮游戏资产", fallback_shape_label(index)) + }), + image_src: read_text_field(item, "imageSrc"), + }) + .collect::>() + }) + .unwrap_or_else(|| { + session + .config + .shape_options + .iter() + .map(|option| SquareHoleShapeOption { + option_id: option.option_id.clone(), + shape_kind: option.shape_kind.clone(), + label: option.label.clone(), + image_prompt: option.image_prompt.clone(), + image_src: option.image_src.clone(), + }) + .collect() + }); + + normalize_shape_options(parsed, theme_text) +} + +fn parse_hole_options( + value: &JsonValue, + session: &SquareHoleAgentSessionRecord, +) -> Vec { + let parsed = value + .get("holeOptions") + .and_then(JsonValue::as_array) + .map(|items| { + items + .iter() + .enumerate() + .map(|(index, item)| SquareHoleHoleOption { + hole_id: read_text_field(item, "holeId") + .unwrap_or_else(|| format!("hole-option-{index}")), + hole_kind: read_text_field(item, "holeKind") + .unwrap_or_else(|| fallback_shape_kind(index).to_string()), + label: read_text_field(item, "label") + .unwrap_or_else(|| fallback_hole_label(index).to_string()), + bonus: item + .get("bonus") + .and_then(JsonValue::as_bool) + .unwrap_or(index == 0), + }) + .collect::>() + }) + .unwrap_or_else(|| { + session + .config + .hole_options + .iter() + .map(|option| SquareHoleHoleOption { + hole_id: option.hole_id.clone(), + hole_kind: option.hole_kind.clone(), + label: option.label.clone(), + bonus: option.bonus, + }) + .collect() + }); + + normalize_hole_options(parsed) +} + fn read_text_field(value: &JsonValue, field_name: &str) -> Option { value .get(field_name) @@ -196,6 +341,62 @@ fn read_u32_field(value: &JsonValue, field_name: &str) -> Option { .and_then(|number| u32::try_from(number).ok()) } +fn fallback_shape_kind(index: usize) -> &'static str { + match index % 6 { + 0 => "square", + 1 => "circle", + 2 => "triangle", + 3 => "diamond", + 4 => "star", + _ => "arch", + } +} + +fn fallback_shape_label(index: usize) -> &'static str { + match fallback_shape_kind(index) { + "square" => "方块", + "circle" => "圆块", + "triangle" => "三角块", + "diamond" => "菱形块", + "star" => "星形块", + _ => "拱形块", + } +} + +fn fallback_hole_label(index: usize) -> &'static str { + match fallback_shape_kind(index) { + "square" => "方洞", + "circle" => "圆洞", + "triangle" => "三角洞", + "diamond" => "菱形洞", + "star" => "星形洞", + _ => "拱形洞", + } +} + +impl From for SquareHoleAgentShapeOptionOutput { + fn from(option: SquareHoleShapeOption) -> Self { + Self { + option_id: option.option_id, + shape_kind: option.shape_kind, + label: option.label, + image_prompt: option.image_prompt, + image_src: option.image_src.unwrap_or_default(), + } + } +} + +impl From for SquareHoleAgentHoleOptionOutput { + fn from(option: SquareHoleHoleOption) -> Self { + Self { + hole_id: option.hole_id, + hole_kind: option.hole_kind, + label: option.label, + bonus: option.bonus, + } + } +} + fn resolve_stage(progress_percent: u32) -> String { if progress_percent >= 100 { "ReadyToCompile" @@ -228,6 +429,11 @@ mod tests { twist_rule: "方洞万能".to_string(), shape_count: 12, difficulty: 4, + shape_options: Vec::new(), + hole_options: Vec::new(), + background_prompt: "纸箱玩具桌面背景".to_string(), + cover_image_src: None, + background_image_src: None, }, draft: None, messages: Vec::new(), @@ -260,7 +466,24 @@ mod tests { "themeText": "办公室文具", "twistRule": "所有文具最终都优先进入方洞", "shapeCount": 14, - "difficulty": 6 + "difficulty": 6, + "shapeOptions": [ + { + "optionId": "stamp", + "shapeKind": "circle", + "label": "圆形印章", + "imagePrompt": "办公室圆形印章贴纸" + } + ], + "holeOptions": [ + { + "holeId": "folder", + "holeKind": "square", + "label": "档案盒方洞", + "bonus": true + } + ], + "backgroundPrompt": "办公室桌面纸箱玩具背景" } }); @@ -276,6 +499,14 @@ mod tests { assert_eq!(output.next_config.twist_rule, "所有文具最终都优先进入方洞"); assert_eq!(output.next_config.shape_count, 14); assert_eq!(output.next_config.difficulty, 6); + assert!(output.next_config.shape_options.len() >= 6); + assert_eq!(output.next_config.shape_options[0].label, "圆形印章"); + assert_eq!(output.next_config.hole_options[0].label, "档案盒方洞"); + assert!(output.next_config.hole_options[0].bonus); + assert_eq!( + output.next_config.background_prompt, + "办公室桌面纸箱玩具背景" + ); } #[test] @@ -287,7 +518,10 @@ mod tests { "themeText": "霓虹积木", "twistRule": "方洞优先", "shapeCount": 99, - "difficulty": 0 + "difficulty": 0, + "shapeOptions": [], + "holeOptions": [], + "backgroundPrompt": "" } }); diff --git a/server-rs/crates/module-square-hole/src/application.rs b/server-rs/crates/module-square-hole/src/application.rs index a8693f12..ca7e1caf 100644 --- a/server-rs/crates/module-square-hole/src/application.rs +++ b/server-rs/crates/module-square-hole/src/application.rs @@ -2,11 +2,13 @@ use shared_kernel::{normalize_optional_string, normalize_required_string, normal use crate::commands::{default_tags_for_theme, validate_publish_requirements}; use crate::{ - SQUARE_HOLE_DEFAULT_DURATION_LIMIT_MS, SQUARE_HOLE_MAX_DIFFICULTY, SQUARE_HOLE_MIN_DIFFICULTY, - SquareHoleCreatorConfig, SquareHoleDropConfirmation, SquareHoleDropFeedback, - SquareHoleDropInput, SquareHoleDropRejectReason, SquareHoleError, SquareHoleHoleSnapshot, + SQUARE_HOLE_DEFAULT_DURATION_LIMIT_MS, SQUARE_HOLE_MAX_DIFFICULTY, + SQUARE_HOLE_MAX_HOLE_OPTION_COUNT, SQUARE_HOLE_MIN_DIFFICULTY, + SQUARE_HOLE_MIN_HOLE_OPTION_COUNT, SQUARE_HOLE_MIN_SHAPE_OPTION_COUNT, SquareHoleCreatorConfig, + SquareHoleDropConfirmation, SquareHoleDropFeedback, SquareHoleDropInput, + SquareHoleDropRejectReason, SquareHoleError, SquareHoleHoleOption, SquareHoleHoleSnapshot, SquareHolePublicationStatus, SquareHoleResultDraft, SquareHoleRunSnapshot, SquareHoleRunStatus, - SquareHoleShapeSnapshot, SquareHoleWorkProfile, + SquareHoleShapeOption, SquareHoleShapeSnapshot, SquareHoleWorkProfile, }; pub fn compile_result_draft( @@ -14,6 +16,10 @@ pub fn compile_result_draft( config: &SquareHoleCreatorConfig, ) -> SquareHoleResultDraft { let game_name = format!("{}方洞挑战", config.theme_text); + let shape_options = normalize_shape_options(config.shape_options.clone(), &config.theme_text); + let hole_options = normalize_hole_options(config.hole_options.clone()); + let background_prompt = normalize_required_string(&config.background_prompt) + .unwrap_or_else(|| default_background_prompt(&config.theme_text)); let summary = format!( "{}主题,{} 个形状,难度 {},真实规则:{}。", config.theme_text, config.shape_count, config.difficulty, config.twist_rule @@ -25,6 +31,11 @@ pub fn compile_result_draft( twist_rule: config.twist_rule.clone(), summary, tags: default_tags_for_theme(&config.theme_text), + cover_image_src: config.cover_image_src.clone(), + background_prompt, + background_image_src: config.background_image_src.clone(), + shape_options, + hole_options, shape_count: config.shape_count, difficulty: config.difficulty, publish_ready: false, @@ -59,7 +70,11 @@ pub fn create_work_profile( twist_rule: draft.twist_rule.clone(), summary: draft.summary.clone(), tags: normalize_string_list(draft.tags.clone()), - cover_image_src: None, + cover_image_src: draft.cover_image_src.clone(), + background_prompt: draft.background_prompt.clone(), + background_image_src: draft.background_image_src.clone(), + shape_options: normalize_shape_options(draft.shape_options.clone(), &draft.theme_text), + hole_options: normalize_hole_options(draft.hole_options.clone()), shape_count: draft.shape_count, difficulty: draft.difficulty, publication_status: SquareHolePublicationStatus::Draft, @@ -99,6 +114,7 @@ pub fn start_run_at( normalize_required_string(owner_user_id).ok_or(SquareHoleError::MissingOwnerUserId)?; let profile_id = normalize_required_string(profile_id).ok_or(SquareHoleError::MissingProfileId)?; + let shape_options = normalize_shape_options(config.shape_options.clone(), &config.theme_text); Ok(SquareHoleRunSnapshot { run_id, @@ -115,8 +131,14 @@ pub fn start_run_at( best_combo: 0, score: 0, rule_label: config.twist_rule.clone(), - current_shape: Some(build_shape_at(0, config.shape_count)), - holes: default_holes(), + background_image_src: config.background_image_src.clone(), + current_shape: Some(build_shape_at( + 0, + config.shape_count, + shape_options.as_slice(), + )), + shape_options, + holes: build_holes(config.hole_options.as_slice()), last_feedback: None, }) } @@ -160,14 +182,18 @@ pub fn confirm_drop_at( next.completed_shape_count = next.completed_shape_count.saturating_add(1); next.combo = next.combo.saturating_add(1); next.best_combo = next.best_combo.max(next.combo); - next.score = next.score.saturating_add(100 + next.combo * 10); + let bonus_score = if hole.bonus { 50 } else { 0 }; + next.score = next + .score + .saturating_add(100 + next.combo * 10 + bonus_score); next.current_shape = if next.completed_shape_count >= next.total_shape_count { next.status = SquareHoleRunStatus::Won; None } else { - Some(build_shape_at( + Some(build_shape_from_previous_options( next.completed_shape_count, next.total_shape_count, + next.shape_options.as_slice(), )) }; next.snapshot_version = next.snapshot_version.saturating_add(1); @@ -216,7 +242,23 @@ pub fn stop_run_at(run: &SquareHoleRunSnapshot) -> SquareHoleRunSnapshot { next } -pub fn build_shape_at(index: u32, total: u32) -> SquareHoleShapeSnapshot { +pub fn build_shape_at( + index: u32, + total: u32, + options: &[SquareHoleShapeOption], +) -> SquareHoleShapeSnapshot { + if let Some(option) = pick_shape_option(index, options) { + let shape_kind = option.shape_kind; + let label = option.label; + return SquareHoleShapeSnapshot { + shape_id: format!("square-hole-shape-{index:03}"), + color: fallback_shape_color(&shape_kind).to_string(), + shape_kind, + label, + image_src: option.image_src, + }; + } + let kind = if index + 1 == total { "square" } else if index % 4 == 0 { @@ -248,6 +290,7 @@ pub fn build_shape_at(index: u32, total: u32) -> SquareHoleShapeSnapshot { _ => "#c084fc", } .to_string(), + image_src: None, } } @@ -259,6 +302,7 @@ pub fn default_holes() -> Vec { label: "方洞".to_string(), x: 0.5, y: 0.28, + bonus: true, }, SquareHoleHoleSnapshot { hole_id: "circle-hole".to_string(), @@ -266,6 +310,7 @@ pub fn default_holes() -> Vec { label: "圆洞".to_string(), x: 0.24, y: 0.54, + bonus: false, }, SquareHoleHoleSnapshot { hole_id: "triangle-hole".to_string(), @@ -273,10 +318,240 @@ pub fn default_holes() -> Vec { label: "三角洞".to_string(), x: 0.76, y: 0.54, + bonus: false, }, ] } +pub fn default_shape_options(theme_text: &str) -> Vec { + let theme = normalize_required_string(theme_text).unwrap_or_else(|| "玩具".to_string()); + [ + ("square", "方块"), + ("circle", "圆块"), + ("triangle", "三角块"), + ("diamond", "菱形块"), + ("star", "星形块"), + ("arch", "拱形块"), + ] + .into_iter() + .map(|(kind, label)| SquareHoleShapeOption { + option_id: format!("{kind}-option"), + shape_kind: kind.to_string(), + label: label.to_string(), + image_prompt: format!("{theme}主题的{label}贴纸图,透明背景,明亮可爱,游戏资产"), + image_src: None, + }) + .collect() +} + +pub fn default_hole_options() -> Vec { + vec![ + SquareHoleHoleOption { + hole_id: "square-hole".to_string(), + hole_kind: "square".to_string(), + label: "方洞".to_string(), + bonus: true, + }, + SquareHoleHoleOption { + hole_id: "circle-hole".to_string(), + hole_kind: "circle".to_string(), + label: "圆洞".to_string(), + bonus: false, + }, + SquareHoleHoleOption { + hole_id: "triangle-hole".to_string(), + hole_kind: "triangle".to_string(), + label: "三角洞".to_string(), + bonus: false, + }, + ] +} + +pub fn normalize_shape_options( + options: Vec, + theme_text: &str, +) -> Vec { + let mut normalized = Vec::new(); + for (index, option) in options.into_iter().enumerate() { + let shape_kind = normalize_required_string(&option.shape_kind) + .unwrap_or_else(|| fallback_shape_kind(index)); + let label = normalize_required_string(&option.label) + .unwrap_or_else(|| fallback_shape_label(&shape_kind).to_string()); + let option_id = normalize_required_string(&option.option_id) + .unwrap_or_else(|| format!("{shape_kind}-option-{index}")); + let image_prompt = normalize_required_string(&option.image_prompt).unwrap_or_else(|| { + format!( + "{}主题的{}贴纸图,透明背景,明亮可爱,游戏资产", + normalize_required_string(theme_text).unwrap_or_else(|| "玩具".to_string()), + label + ) + }); + normalized.push(SquareHoleShapeOption { + option_id, + shape_kind, + label, + image_prompt, + image_src: option.image_src.and_then(normalize_required_string), + }); + } + + let defaults = default_shape_options(theme_text); + let mut default_index = 0; + while normalized.len() < SQUARE_HOLE_MIN_SHAPE_OPTION_COUNT { + let mut fallback = defaults[default_index % defaults.len()].clone(); + if normalized + .iter() + .any(|option| option.option_id == fallback.option_id) + { + fallback.option_id = format!("{}-{}", fallback.option_id, normalized.len()); + } + normalized.push(fallback); + default_index += 1; + } + normalized +} + +pub fn normalize_hole_options(options: Vec) -> Vec { + let mut normalized = Vec::new(); + for (index, option) in options + .into_iter() + .take(SQUARE_HOLE_MAX_HOLE_OPTION_COUNT) + .enumerate() + { + let hole_kind = normalize_required_string(&option.hole_kind) + .unwrap_or_else(|| fallback_shape_kind(index)); + let label = normalize_required_string(&option.label) + .unwrap_or_else(|| fallback_hole_label(&hole_kind).to_string()); + let hole_id = normalize_required_string(&option.hole_id) + .unwrap_or_else(|| format!("{hole_kind}-hole-{index}")); + normalized.push(SquareHoleHoleOption { + hole_id, + hole_kind, + label, + bonus: option.bonus, + }); + } + + for fallback in default_hole_options() { + if normalized.len() >= SQUARE_HOLE_MIN_HOLE_OPTION_COUNT { + break; + } + if !normalized + .iter() + .any(|option| option.hole_id == fallback.hole_id) + { + normalized.push(fallback); + } + } + + if normalized.iter().all(|option| !option.bonus) + && let Some(first) = normalized.first_mut() + { + first.bonus = true; + } + normalized +} + +pub fn default_background_prompt(theme_text: &str) -> String { + format!( + "{}主题的竖屏游戏背景,舞台中央有洞口面板,明亮夸张,适合移动端小游戏", + normalize_required_string(theme_text).unwrap_or_else(|| "玩具".to_string()) + ) +} + +fn build_holes(options: &[SquareHoleHoleOption]) -> Vec { + let normalized = normalize_hole_options(options.to_vec()); + let positions = [ + (0.5, 0.28), + (0.24, 0.54), + (0.76, 0.54), + (0.24, 0.78), + (0.5, 0.78), + (0.76, 0.78), + ]; + normalized + .into_iter() + .enumerate() + .map(|(index, option)| { + let (x, y) = positions[index.min(positions.len() - 1)]; + SquareHoleHoleSnapshot { + hole_id: option.hole_id, + hole_kind: option.hole_kind, + label: option.label, + x, + y, + bonus: option.bonus, + } + }) + .collect() +} + +fn build_shape_from_previous_options( + index: u32, + total: u32, + options: &[SquareHoleShapeOption], +) -> SquareHoleShapeSnapshot { + build_shape_at(index, total, options) +} + +fn pick_shape_option( + index: u32, + options: &[SquareHoleShapeOption], +) -> Option { + if options.is_empty() { + return None; + } + options.get(index as usize % options.len()).cloned() +} + +fn fallback_shape_kind(index: usize) -> String { + match index % 6 { + 0 => "square", + 1 => "circle", + 2 => "triangle", + 3 => "diamond", + 4 => "star", + _ => "arch", + } + .to_string() +} + +fn fallback_shape_label(kind: &str) -> &'static str { + match kind { + "square" => "方块", + "circle" => "圆块", + "triangle" => "三角块", + "diamond" => "菱形块", + "star" => "星形块", + "arch" => "拱形块", + _ => "形状块", + } +} + +fn fallback_hole_label(kind: &str) -> &'static str { + match kind { + "square" => "方洞", + "circle" => "圆洞", + "triangle" => "三角洞", + "diamond" => "菱形洞", + "star" => "星形洞", + "arch" => "拱形洞", + _ => "洞口", + } +} + +fn fallback_shape_color(kind: &str) -> &'static str { + match kind { + "square" => "#facc15", + "circle" => "#22c55e", + "triangle" => "#38bdf8", + "diamond" => "#fb7185", + "star" => "#c084fc", + "arch" => "#f97316", + _ => "#f8fafc", + } +} + fn is_shape_accepted_by_hole( shape: &SquareHoleShapeSnapshot, hole: &SquareHoleHoleSnapshot, @@ -315,6 +590,32 @@ mod tests { build_creator_config("玩具", "方洞万能", shape_count, 4).expect("config should be valid") } + fn test_config_with_bonus_hole(shape_count: u32) -> SquareHoleCreatorConfig { + SquareHoleCreatorConfig { + hole_options: vec![ + SquareHoleHoleOption { + hole_id: "square-hole".to_string(), + hole_kind: "square".to_string(), + label: "方洞".to_string(), + bonus: true, + }, + SquareHoleHoleOption { + hole_id: "circle-hole".to_string(), + hole_kind: "circle".to_string(), + label: "圆洞".to_string(), + bonus: false, + }, + SquareHoleHoleOption { + hole_id: "triangle-hole".to_string(), + hole_kind: "triangle".to_string(), + label: "三角洞".to_string(), + bonus: false, + }, + ], + ..test_config(shape_count) + } + } + #[test] fn draft_is_publishable_with_required_fields() { let draft = compile_result_draft("profile-1".to_string(), &test_config(8)); @@ -368,6 +669,33 @@ mod tests { assert_eq!(result.run.combo, 1); } + #[test] + fn bonus_hole_adds_extra_score_when_accepted() { + let run = start_run_at( + "run-1".to_string(), + "user-1".to_string(), + "profile-1".to_string(), + &test_config_with_bonus_hole(8), + 1_000, + ) + .expect("run should start"); + let result = confirm_drop_at( + &run, + &SquareHoleDropInput { + run_id: run.run_id.clone(), + owner_user_id: run.owner_user_id.clone(), + hole_id: "square-hole".to_string(), + client_snapshot_version: run.snapshot_version, + client_event_id: "event-1".to_string(), + dropped_at_ms: 1_100, + }, + ) + .expect("drop should resolve"); + + assert!(result.feedback.accepted); + assert_eq!(result.run.score, 160); + } + #[test] fn wrong_non_square_hole_rejects_and_resets_combo() { let mut run = start_run_at( @@ -378,7 +706,7 @@ mod tests { 1_000, ) .expect("run should start"); - run.current_shape = Some(build_shape_at(1, 8)); + run.current_shape = Some(build_shape_at(1, 8, &[])); run.combo = 2; let result = confirm_drop_at( @@ -413,7 +741,7 @@ mod tests { ) .expect("run should start"); run.completed_shape_count = 5; - run.current_shape = Some(build_shape_at(5, 6)); + run.current_shape = Some(build_shape_at(5, 6, &[])); let result = confirm_drop_at( &run, diff --git a/server-rs/crates/module-square-hole/src/commands.rs b/server-rs/crates/module-square-hole/src/commands.rs index 0686e363..a010bfb1 100644 --- a/server-rs/crates/module-square-hole/src/commands.rs +++ b/server-rs/crates/module-square-hole/src/commands.rs @@ -3,6 +3,7 @@ use shared_kernel::{normalize_required_string, normalize_string_list}; use crate::{ SQUARE_HOLE_MAX_DIFFICULTY, SQUARE_HOLE_MAX_SHAPE_COUNT, SQUARE_HOLE_MIN_DIFFICULTY, SQUARE_HOLE_MIN_SHAPE_COUNT, SquareHoleCreatorConfig, SquareHoleError, SquareHoleResultDraft, + normalize_hole_options, normalize_shape_options, }; pub fn validate_shape_count(value: u32) -> Result { @@ -36,6 +37,11 @@ pub fn build_creator_config( twist_rule: normalize_required_string(twist_rule).ok_or(SquareHoleError::MissingText)?, shape_count: validate_shape_count(shape_count)?, difficulty: validate_difficulty(difficulty)?, + shape_options: normalize_shape_options(Vec::new(), theme_text), + hole_options: normalize_hole_options(Vec::new()), + background_prompt: format!("{theme_text}主题的竖屏游戏背景,舞台中央有多个形状洞口"), + cover_image_src: None, + background_image_src: None, }) } @@ -84,6 +90,12 @@ pub fn validate_publish_requirements(draft: &SquareHoleResultDraft) -> Vec, + #[serde(default)] + pub hole_options: Vec, + #[serde(default)] + pub background_prompt: String, + #[serde(default)] + pub cover_image_src: Option, + #[serde(default)] + pub background_image_src: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SquareHoleShapeOption { + pub option_id: String, + pub shape_kind: String, + pub label: String, + pub image_prompt: String, + #[serde(default)] + pub image_src: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SquareHoleHoleOption { + pub hole_id: String, + pub hole_kind: String, + pub label: String, + #[serde(default)] + pub bonus: bool, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -66,6 +100,16 @@ pub struct SquareHoleResultDraft { pub twist_rule: String, pub summary: String, pub tags: Vec, + #[serde(default)] + pub cover_image_src: Option, + #[serde(default)] + pub background_prompt: String, + #[serde(default)] + pub background_image_src: Option, + #[serde(default)] + pub shape_options: Vec, + #[serde(default)] + pub hole_options: Vec, pub shape_count: u32, pub difficulty: u32, pub publish_ready: bool, @@ -85,6 +129,10 @@ pub struct SquareHoleWorkProfile { pub summary: String, pub tags: Vec, pub cover_image_src: Option, + pub background_prompt: String, + pub background_image_src: Option, + pub shape_options: Vec, + pub hole_options: Vec, pub shape_count: u32, pub difficulty: u32, pub publication_status: SquareHolePublicationStatus, @@ -100,6 +148,8 @@ pub struct SquareHoleShapeSnapshot { pub shape_kind: String, pub label: String, pub color: String, + #[serde(default)] + pub image_src: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -110,6 +160,8 @@ pub struct SquareHoleHoleSnapshot { pub label: String, pub x: f32, pub y: f32, + #[serde(default)] + pub bonus: bool, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -137,6 +189,10 @@ pub struct SquareHoleRunSnapshot { pub best_combo: u32, pub score: u32, pub rule_label: String, + #[serde(default)] + pub background_image_src: Option, + #[serde(default)] + pub shape_options: Vec, pub current_shape: Option, pub holes: Vec, pub last_feedback: Option, diff --git a/server-rs/crates/platform-llm/src/lib.rs b/server-rs/crates/platform-llm/src/lib.rs index 2b4ffbd2..2b296f7a 100644 --- a/server-rs/crates/platform-llm/src/lib.rs +++ b/server-rs/crates/platform-llm/src/lib.rs @@ -68,6 +68,7 @@ pub struct LlmTextRequest { pub max_tokens: Option, pub enable_web_search: bool, pub protocol: LlmTextProtocol, + pub request_timeout_ms: Option, } // 文本协议必须由业务请求显式选择,避免全局默认模型把不同场景混到同一上游形态。 @@ -421,6 +422,7 @@ impl LlmTextRequest { max_tokens: None, enable_web_search: false, protocol: LlmTextProtocol::ChatCompletions, + request_timeout_ms: None, } } @@ -451,6 +453,11 @@ impl LlmTextRequest { self } + pub fn with_request_timeout_ms(mut self, request_timeout_ms: u64) -> Self { + self.request_timeout_ms = Some(request_timeout_ms); + self + } + fn validate(&self) -> Result<(), LlmError> { if self.messages.is_empty() { return Err(LlmError::InvalidRequest( @@ -474,6 +481,14 @@ impl LlmTextRequest { )); } + if let Some(request_timeout_ms) = self.request_timeout_ms + && request_timeout_ms == 0 + { + return Err(LlmError::InvalidRequest( + "LLM request_timeout_ms 必须大于 0".to_string(), + )); + } + Ok(()) } @@ -484,6 +499,12 @@ impl LlmTextRequest { .filter(|value| !value.is_empty()) .unwrap_or(fallback_model) } + + fn resolved_request_timeout_ms(&self, fallback_timeout_ms: u64) -> u64 { + self.request_timeout_ms + .filter(|value| *value > 0) + .unwrap_or(fallback_timeout_ms) + } } impl LlmTextProtocol { @@ -825,7 +846,9 @@ impl LlmClient { .post(url.as_str()) .bearer_auth(self.config.api_key()) .json(&request_body) - .timeout(Duration::from_millis(self.config.request_timeout_ms())) + .timeout(Duration::from_millis( + request.resolved_request_timeout_ms(self.config.request_timeout_ms()), + )) .send() .await; @@ -1592,6 +1615,48 @@ mod tests { assert_eq!(response.response_id.as_deref(), Some("resp_retry")); } + #[tokio::test] + async fn request_text_uses_request_level_timeout_override() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); + let address = listener.local_addr().expect("listener should have addr"); + thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("request should connect"); + let _ = read_request(&mut stream); + thread::sleep(StdDuration::from_millis(200)); + write_response( + &mut stream, + MockResponse { + status_line: "200 OK", + content_type: "application/json; charset=utf-8", + body: r#"{"choices":[{"message":{"content":"too late"},"finish_reason":"stop"}]}"# + .to_string(), + extra_headers: Vec::new(), + }, + ); + }); + + let config = LlmConfig::new( + LlmProvider::Ark, + format!("http://{address}"), + "test-key".to_string(), + "test-model".to_string(), + 10_000, + 0, + 1, + ) + .expect("config should be valid"); + let client = LlmClient::new(config).expect("client should be created"); + + let error = client + .request_text( + LlmTextRequest::single_turn("系统", "用户").with_request_timeout_ms(20), + ) + .await + .expect_err("request override should timeout before the global timeout"); + + assert_eq!(error, LlmError::Timeout { attempts: 1 }); + } + #[tokio::test] async fn request_text_sends_web_search_options_when_enabled() { let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); diff --git a/server-rs/crates/shared-contracts/src/square_hole_agent.rs b/server-rs/crates/shared-contracts/src/square_hole_agent.rs index 97394b0f..905c074b 100644 --- a/server-rs/crates/shared-contracts/src/square_hole_agent.rs +++ b/server-rs/crates/shared-contracts/src/square_hole_agent.rs @@ -38,6 +38,26 @@ pub struct ExecuteSquareHoleActionRequest { pub cover_image_src: Option, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SquareHoleShapeOptionResponse { + pub option_id: String, + pub shape_kind: String, + pub label: String, + pub image_prompt: String, + #[serde(default)] + pub image_src: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SquareHoleHoleOptionResponse { + pub hole_id: String, + pub hole_kind: String, + pub label: String, + pub bonus: bool, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct SquareHoleAnchorItemResponse { @@ -63,6 +83,16 @@ pub struct SquareHoleCreatorConfigResponse { pub twist_rule: String, pub shape_count: u32, pub difficulty: u32, + #[serde(default)] + pub shape_options: Vec, + #[serde(default)] + pub hole_options: Vec, + #[serde(default)] + pub background_prompt: String, + #[serde(default)] + pub cover_image_src: Option, + #[serde(default)] + pub background_image_src: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -74,6 +104,16 @@ pub struct SquareHoleResultDraftResponse { pub twist_rule: String, pub summary: String, pub tags: Vec, + #[serde(default)] + pub cover_image_src: Option, + #[serde(default)] + pub background_prompt: String, + #[serde(default)] + pub background_image_src: Option, + #[serde(default)] + pub shape_options: Vec, + #[serde(default)] + pub hole_options: Vec, pub shape_count: u32, pub difficulty: u32, pub publish_ready: bool, diff --git a/server-rs/crates/shared-contracts/src/square_hole_runtime.rs b/server-rs/crates/shared-contracts/src/square_hole_runtime.rs index 2645362c..33549203 100644 --- a/server-rs/crates/shared-contracts/src/square_hole_runtime.rs +++ b/server-rs/crates/shared-contracts/src/square_hole_runtime.rs @@ -30,6 +30,8 @@ pub struct SquareHoleShapeSnapshotResponse { pub shape_kind: String, pub label: String, pub color: String, + #[serde(default)] + pub image_src: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -40,6 +42,7 @@ pub struct SquareHoleHoleSnapshotResponse { pub label: String, pub x: f32, pub y: f32, + pub bonus: bool, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -69,6 +72,8 @@ pub struct SquareHoleRunSnapshotResponse { pub score: u32, pub rule_label: String, #[serde(default)] + pub background_image_src: Option, + #[serde(default)] pub current_shape: Option, pub holes: Vec, #[serde(default)] diff --git a/server-rs/crates/shared-contracts/src/square_hole_works.rs b/server-rs/crates/shared-contracts/src/square_hole_works.rs index c1708b57..5eab4743 100644 --- a/server-rs/crates/shared-contracts/src/square_hole_works.rs +++ b/server-rs/crates/shared-contracts/src/square_hole_works.rs @@ -1,5 +1,25 @@ use serde::{Deserialize, Serialize}; +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SquareHoleShapeOptionResponse { + pub option_id: String, + pub shape_kind: String, + pub label: String, + pub image_prompt: String, + #[serde(default)] + pub image_src: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SquareHoleHoleOptionResponse { + pub hole_id: String, + pub hole_kind: String, + pub label: String, + pub bonus: bool, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct PutSquareHoleWorkRequest { @@ -11,6 +31,14 @@ pub struct PutSquareHoleWorkRequest { pub tags: Vec, #[serde(default)] pub cover_image_src: Option, + #[serde(default)] + pub background_prompt: Option, + #[serde(default)] + pub background_image_src: Option, + #[serde(default)] + pub shape_options: Option>, + #[serde(default)] + pub hole_options: Option>, pub shape_count: u32, pub difficulty: u32, } @@ -30,6 +58,14 @@ pub struct SquareHoleWorkSummaryResponse { pub tags: Vec, #[serde(default)] pub cover_image_src: Option, + #[serde(default)] + pub background_prompt: String, + #[serde(default)] + pub background_image_src: Option, + #[serde(default)] + pub shape_options: Vec, + #[serde(default)] + pub hole_options: Vec, pub shape_count: u32, pub difficulty: u32, pub publication_status: String, diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 72b28ae4..1829a452 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -55,11 +55,11 @@ pub use mapper::{ SquareHoleAgentMessageSubmitRecordInput, SquareHoleAgentSessionCreateRecordInput, SquareHoleAgentSessionRecord, SquareHoleAnchorItemRecord, SquareHoleAnchorPackRecord, SquareHoleCompileDraftRecordInput, SquareHoleCreatorConfigRecord, - SquareHoleDropConfirmationRecord, SquareHoleDropFeedbackRecord, SquareHoleHoleSnapshotRecord, - SquareHoleResultDraftRecord, SquareHoleRunDropRecordInput, SquareHoleRunRecord, - SquareHoleRunRestartRecordInput, SquareHoleRunStartRecordInput, SquareHoleRunStopRecordInput, - SquareHoleRunTimeUpRecordInput, SquareHoleShapeSnapshotRecord, SquareHoleWorkProfileRecord, - SquareHoleWorkUpdateRecordInput, + SquareHoleDropConfirmationRecord, SquareHoleDropFeedbackRecord, SquareHoleHoleOptionRecord, + SquareHoleHoleSnapshotRecord, SquareHoleResultDraftRecord, SquareHoleRunDropRecordInput, + SquareHoleRunRecord, SquareHoleRunRestartRecordInput, SquareHoleRunStartRecordInput, + SquareHoleRunStopRecordInput, SquareHoleRunTimeUpRecordInput, SquareHoleShapeOptionRecord, + SquareHoleShapeSnapshotRecord, SquareHoleWorkProfileRecord, SquareHoleWorkUpdateRecordInput, }; pub mod ai; @@ -144,9 +144,9 @@ use module_puzzle::{ PuzzleWorkProfile as DomainPuzzleWorkProfile, }; use module_runtime::{ - RuntimeBrowseHistoryRecord, RuntimePlatformTheme as DomainRuntimePlatformTheme, - RuntimeProfileDashboardRecord, RuntimeProfileInviteCodeRecord, RuntimeProfilePlayStatsRecord, - AnalyticsMetricQueryResponse as DomainAnalyticsMetricQueryResponse, + AnalyticsMetricQueryResponse as DomainAnalyticsMetricQueryResponse, RuntimeBrowseHistoryRecord, + RuntimePlatformTheme as DomainRuntimePlatformTheme, RuntimeProfileDashboardRecord, + RuntimeProfileInviteCodeRecord, RuntimeProfilePlayStatsRecord, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord, @@ -155,7 +155,7 @@ use module_runtime::{ RuntimeProfileTaskStatus as DomainRuntimeProfileTaskStatus, RuntimeProfileWalletLedgerEntryRecord, RuntimeReferralInviteCenterRecord, RuntimeReferralRedeemRecord, RuntimeSettingsRecord, RuntimeSnapshotRecord, - RuntimeTrackingScopeKind as DomainRuntimeTrackingScopeKind, + RuntimeTrackingScopeKind as DomainRuntimeTrackingScopeKind, build_analytics_metric_query_input, build_runtime_browse_history_clear_input, build_runtime_browse_history_list_input, build_runtime_browse_history_record, build_runtime_browse_history_sync_input, build_runtime_profile_dashboard_get_input, build_runtime_profile_dashboard_record, @@ -178,7 +178,6 @@ use module_runtime::{ build_runtime_profile_wallet_adjustment_input, build_runtime_profile_wallet_ledger_entry_record, build_runtime_profile_wallet_ledger_list_input, build_runtime_referral_invite_center_get_input, - build_analytics_metric_query_input, build_runtime_referral_invite_center_record, build_runtime_referral_redeem_input, build_runtime_referral_redeem_record, build_runtime_setting_get_input, build_runtime_setting_record, build_runtime_setting_upsert_input, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index d6c590d8..b3109a43 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -1585,14 +1585,9 @@ pub(crate) fn map_square_hole_agent_session_procedure_result( let session_json = result .session_json .ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole agent session 快照"))?; - let session = - serde_json::from_str::(&session_json).map_err( - |error| { - SpacetimeClientError::Runtime(format!( - "square hole session_json 非法: {error}" - )) - }, - )?; + let session = serde_json::from_str::(&session_json).map_err( + |error| SpacetimeClientError::Runtime(format!("square hole session_json 非法: {error}")), + )?; Ok(map_square_hole_agent_session_snapshot(session)) } @@ -1624,13 +1619,15 @@ pub(crate) fn map_square_hole_works_procedure_result( let items_json = result .items_json .ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole works 快照"))?; - let items = serde_json::from_str::>(&items_json).map_err( - |error| { + let items = + serde_json::from_str::>(&items_json).map_err(|error| { SpacetimeClientError::Runtime(format!("square hole works items_json 非法: {error}")) - }, - )?; + })?; - Ok(items.into_iter().map(map_square_hole_work_snapshot).collect()) + Ok(items + .into_iter() + .map(map_square_hole_work_snapshot) + .collect()) } pub(crate) fn map_square_hole_run_procedure_result( @@ -1656,18 +1653,14 @@ pub(crate) fn map_square_hole_drop_shape_procedure_result( let run_json = result .run_json .ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole drop run 快照"))?; - let feedback_json = result.feedback_json.ok_or_else(|| { - SpacetimeClientError::missing_snapshot("square hole drop feedback 快照") - })?; + let feedback_json = result + .feedback_json + .ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole drop feedback 快照"))?; let run = map_square_hole_run_json(run_json)?; - let feedback = - serde_json::from_str::(&feedback_json).map_err( - |error| { - SpacetimeClientError::Runtime(format!( - "square hole feedback_json 非法: {error}" - )) - }, - )?; + let feedback = serde_json::from_str::(&feedback_json) + .map_err(|error| { + SpacetimeClientError::Runtime(format!("square hole feedback_json 非法: {error}")) + })?; Ok(SquareHoleDropConfirmationRecord { status: result.status, @@ -2950,6 +2943,19 @@ fn map_square_hole_creator_config( twist_rule: snapshot.twist_rule, shape_count: snapshot.shape_count, difficulty: snapshot.difficulty, + shape_options: snapshot + .shape_options + .into_iter() + .map(map_square_hole_shape_option) + .collect(), + hole_options: snapshot + .hole_options + .into_iter() + .map(map_square_hole_hole_option) + .collect(), + background_prompt: snapshot.background_prompt, + cover_image_src: empty_string_to_none(snapshot.cover_image_src), + background_image_src: empty_string_to_none(snapshot.background_image_src), } } @@ -2963,6 +2969,19 @@ fn map_square_hole_result_draft( twist_rule: snapshot.twist_rule, summary: snapshot.summary_text, tags: snapshot.tags, + cover_image_src: empty_string_to_none(snapshot.cover_image_src), + background_prompt: snapshot.background_prompt, + background_image_src: empty_string_to_none(snapshot.background_image_src), + shape_options: snapshot + .shape_options + .into_iter() + .map(map_square_hole_shape_option) + .collect(), + hole_options: snapshot + .hole_options + .into_iter() + .map(map_square_hole_hole_option) + .collect(), shape_count: snapshot.shape_count, difficulty: snapshot.difficulty, publish_ready: false, @@ -2997,6 +3016,18 @@ fn map_square_hole_work_snapshot( summary: snapshot.summary_text, tags: snapshot.tags, cover_image_src: empty_string_to_none(snapshot.cover_image_src), + background_prompt: snapshot.background_prompt, + background_image_src: empty_string_to_none(snapshot.background_image_src), + shape_options: snapshot + .shape_options + .into_iter() + .map(map_square_hole_shape_option) + .collect(), + hole_options: snapshot + .hole_options + .into_iter() + .map(map_square_hole_hole_option) + .collect(), shape_count: snapshot.shape_count, difficulty: snapshot.difficulty, publication_status: normalize_square_hole_publication_status(&snapshot.publication_status) @@ -3032,6 +3063,7 @@ fn map_square_hole_run_snapshot(snapshot: SquareHoleRunJsonRecord) -> SquareHole best_combo: snapshot.best_combo, score: snapshot.score, rule_label: snapshot.rule_label, + background_image_src: empty_string_to_none(snapshot.background_image_src), current_shape: snapshot.current_shape.map(map_square_hole_shape_snapshot), holes: snapshot .holes @@ -3053,6 +3085,7 @@ fn map_square_hole_shape_snapshot( shape_kind: snapshot.shape_kind, label: snapshot.label, color: snapshot.color, + image_src: empty_string_to_none(snapshot.image_src), } } @@ -3065,6 +3098,30 @@ fn map_square_hole_hole_snapshot( label: snapshot.label, x: snapshot.x, y: snapshot.y, + bonus: snapshot.bonus, + } +} + +fn map_square_hole_shape_option( + snapshot: SquareHoleShapeOptionJsonRecord, +) -> SquareHoleShapeOptionRecord { + SquareHoleShapeOptionRecord { + option_id: snapshot.option_id, + shape_kind: snapshot.shape_kind, + label: snapshot.label, + image_prompt: snapshot.image_prompt, + image_src: empty_string_to_none(snapshot.image_src), + } +} + +fn map_square_hole_hole_option( + snapshot: SquareHoleHoleOptionJsonRecord, +) -> SquareHoleHoleOptionRecord { + SquareHoleHoleOptionRecord { + hole_id: snapshot.hole_id, + hole_kind: snapshot.hole_kind, + label: snapshot.label, + bonus: snapshot.bonus, } } @@ -3087,7 +3144,11 @@ fn build_square_hole_anchor_pack( let difficulty = config.difficulty.to_string(); SquareHoleAnchorPackRecord { theme: build_square_hole_anchor_item("theme", "题材主题", config.theme_text.as_str()), - twist_rule: build_square_hole_anchor_item("twistRule", "反差规则", config.twist_rule.as_str()), + twist_rule: build_square_hole_anchor_item( + "twistRule", + "反差规则", + config.twist_rule.as_str(), + ), shape_count: build_square_hole_anchor_item("shapeCount", "形状数量", shape_count.as_str()), difficulty: build_square_hole_anchor_item("difficulty", "难度", difficulty.as_str()), } @@ -5992,6 +6053,10 @@ pub struct SquareHoleWorkUpdateRecordInput { pub summary_text: String, pub tags_json: String, pub cover_image_src: String, + pub background_prompt: String, + pub background_image_src: String, + pub shape_options_json: String, + pub hole_options_json: String, pub shape_count: u32, pub difficulty: u32, pub updated_at_micros: i64, @@ -6059,6 +6124,28 @@ pub struct SquareHoleCreatorConfigRecord { pub twist_rule: String, pub shape_count: u32, pub difficulty: u32, + pub shape_options: Vec, + pub hole_options: Vec, + pub background_prompt: String, + pub cover_image_src: Option, + pub background_image_src: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SquareHoleShapeOptionRecord { + pub option_id: String, + pub shape_kind: String, + pub label: String, + pub image_prompt: String, + pub image_src: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SquareHoleHoleOptionRecord { + pub hole_id: String, + pub hole_kind: String, + pub label: String, + pub bonus: bool, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -6069,6 +6156,11 @@ pub struct SquareHoleResultDraftRecord { pub twist_rule: String, pub summary: String, pub tags: Vec, + pub cover_image_src: Option, + pub background_prompt: String, + pub background_image_src: Option, + pub shape_options: Vec, + pub hole_options: Vec, pub shape_count: u32, pub difficulty: u32, pub publish_ready: bool, @@ -6112,6 +6204,10 @@ pub struct SquareHoleWorkProfileRecord { pub summary: String, pub tags: Vec, pub cover_image_src: Option, + pub background_prompt: String, + pub background_image_src: Option, + pub shape_options: Vec, + pub hole_options: Vec, pub shape_count: u32, pub difficulty: u32, pub publication_status: String, @@ -6127,6 +6223,7 @@ pub struct SquareHoleShapeSnapshotRecord { pub shape_kind: String, pub label: String, pub color: String, + pub image_src: Option, } #[derive(Clone, Debug, PartialEq)] @@ -6136,6 +6233,7 @@ pub struct SquareHoleHoleSnapshotRecord { pub label: String, pub x: f32, pub y: f32, + pub bonus: bool, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -6162,6 +6260,7 @@ pub struct SquareHoleRunRecord { pub best_combo: u32, pub score: u32, pub rule_label: String, + pub background_image_src: Option, pub current_shape: Option, pub holes: Vec, pub last_feedback: Option, @@ -6185,6 +6284,37 @@ struct SquareHoleCreatorConfigJsonRecord { twist_rule: String, shape_count: u32, difficulty: u32, + #[serde(default)] + shape_options: Vec, + #[serde(default)] + hole_options: Vec, + #[serde(default)] + background_prompt: String, + #[serde(default)] + cover_image_src: String, + #[serde(default)] + background_image_src: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct SquareHoleShapeOptionJsonRecord { + option_id: String, + shape_kind: String, + label: String, + image_prompt: String, + #[serde(default)] + image_src: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct SquareHoleHoleOptionJsonRecord { + hole_id: String, + hole_kind: String, + label: String, + #[serde(default)] + bonus: bool, } #[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] @@ -6208,6 +6338,16 @@ struct SquareHoleDraftJsonRecord { twist_rule: String, summary_text: String, tags: Vec, + #[serde(default)] + cover_image_src: String, + #[serde(default)] + background_prompt: String, + #[serde(default)] + background_image_src: String, + #[serde(default)] + shape_options: Vec, + #[serde(default)] + hole_options: Vec, shape_count: u32, difficulty: u32, } @@ -6247,6 +6387,14 @@ struct SquareHoleWorkJsonRecord { summary_text: String, tags: Vec, cover_image_src: String, + #[serde(default)] + background_prompt: String, + #[serde(default)] + background_image_src: String, + #[serde(default)] + shape_options: Vec, + #[serde(default)] + hole_options: Vec, shape_count: u32, difficulty: u32, #[allow(dead_code)] @@ -6265,6 +6413,8 @@ struct SquareHoleShapeJsonRecord { shape_kind: String, label: String, color: String, + #[serde(default)] + image_src: String, } #[derive(Clone, Debug, PartialEq, serde::Deserialize)] @@ -6275,6 +6425,8 @@ struct SquareHoleHoleJsonRecord { label: String, x: f32, y: f32, + #[serde(default)] + bonus: bool, } #[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] @@ -6303,6 +6455,11 @@ struct SquareHoleRunJsonRecord { best_combo: u32, score: u32, rule_label: String, + #[serde(default)] + background_image_src: String, + #[serde(default)] + #[allow(dead_code)] + shape_options: Vec, current_shape: Option, holes: Vec, last_feedback: Option, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/square_hole_work_update_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_work_update_input_type.rs index 319351d4..dd20e4b6 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/square_hole_work_update_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_work_update_input_type.rs @@ -15,6 +15,10 @@ pub struct SquareHoleWorkUpdateInput { pub summary_text: String, pub tags_json: String, pub cover_image_src: String, + pub background_prompt: String, + pub background_image_src: String, + pub shape_options_json: String, + pub hole_options_json: String, pub shape_count: u32, pub difficulty: u32, pub updated_at_micros: i64, diff --git a/server-rs/crates/spacetime-client/src/square_hole.rs b/server-rs/crates/spacetime-client/src/square_hole.rs index c4c3e6cb..f0ade205 100644 --- a/server-rs/crates/spacetime-client/src/square_hole.rs +++ b/server-rs/crates/spacetime-client/src/square_hole.rs @@ -17,15 +17,14 @@ impl SpacetimeClient { }; self.call_after_connect(move |connection, sender| { - connection.procedures().create_square_hole_agent_session_then( - procedure_input, - move |_, result| { + connection + .procedures() + .create_square_hole_agent_session_then(procedure_input, move |_, result| { let mapped = result .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .and_then(map_square_hole_agent_session_procedure_result); send_once(&sender, mapped); - }, - ); + }); }) .await } @@ -67,15 +66,14 @@ impl SpacetimeClient { }; self.call_after_connect(move |connection, sender| { - connection.procedures().submit_square_hole_agent_message_then( - procedure_input, - move |_, result| { + connection + .procedures() + .submit_square_hole_agent_message_then(procedure_input, move |_, result| { let mapped = result .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .and_then(map_square_hole_agent_session_procedure_result); send_once(&sender, mapped); - }, - ); + }); }) .await } @@ -126,14 +124,15 @@ impl SpacetimeClient { }; self.call_after_connect(move |connection, sender| { - connection - .procedures() - .compile_square_hole_draft_then(procedure_input, move |_, result| { + connection.procedures().compile_square_hole_draft_then( + procedure_input, + move |_, result| { let mapped = result .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .and_then(map_square_hole_agent_session_procedure_result); send_once(&sender, mapped); - }); + }, + ); }) .await } @@ -151,20 +150,25 @@ impl SpacetimeClient { summary_text: input.summary_text, tags_json: input.tags_json, cover_image_src: input.cover_image_src, + background_prompt: input.background_prompt, + background_image_src: input.background_image_src, + shape_options_json: input.shape_options_json, + hole_options_json: input.hole_options_json, shape_count: input.shape_count, difficulty: input.difficulty, updated_at_micros: input.updated_at_micros, }; self.call_after_connect(move |connection, sender| { - connection - .procedures() - .update_square_hole_work_then(procedure_input, move |_, result| { + connection.procedures().update_square_hole_work_then( + procedure_input, + move |_, result| { let mapped = result .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .and_then(map_square_hole_work_procedure_result); send_once(&sender, mapped); - }); + }, + ); }) .await } @@ -182,14 +186,15 @@ impl SpacetimeClient { }; self.call_after_connect(move |connection, sender| { - connection - .procedures() - .publish_square_hole_work_then(procedure_input, move |_, result| { + connection.procedures().publish_square_hole_work_then( + procedure_input, + move |_, result| { let mapped = result .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .and_then(map_square_hole_work_procedure_result); send_once(&sender, mapped); - }); + }, + ); }) .await } @@ -221,14 +226,15 @@ impl SpacetimeClient { procedure_input: SquareHoleWorksListInput, ) -> Result, SpacetimeClientError> { self.call_after_connect(move |connection, sender| { - connection - .procedures() - .list_square_hole_works_then(procedure_input, move |_, result| { + connection.procedures().list_square_hole_works_then( + procedure_input, + move |_, result| { let mapped = result .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .and_then(map_square_hole_works_procedure_result); send_once(&sender, mapped); - }); + }, + ); }) .await } @@ -244,14 +250,15 @@ impl SpacetimeClient { }; self.call_after_connect(move |connection, sender| { - connection - .procedures() - .get_square_hole_work_detail_then(procedure_input, move |_, result| { + connection.procedures().get_square_hole_work_detail_then( + procedure_input, + move |_, result| { let mapped = result .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .and_then(map_square_hole_work_procedure_result); send_once(&sender, mapped); - }); + }, + ); }) .await } @@ -267,14 +274,15 @@ impl SpacetimeClient { }; self.call_after_connect(move |connection, sender| { - connection - .procedures() - .delete_square_hole_work_then(procedure_input, move |_, result| { + connection.procedures().delete_square_hole_work_then( + procedure_input, + move |_, result| { let mapped = result .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .and_then(map_square_hole_works_procedure_result); send_once(&sender, mapped); - }); + }, + ); }) .await } @@ -291,14 +299,15 @@ impl SpacetimeClient { }; self.call_after_connect(move |connection, sender| { - connection - .procedures() - .start_square_hole_run_then(procedure_input, move |_, result| { + connection.procedures().start_square_hole_run_then( + procedure_input, + move |_, result| { let mapped = result .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .and_then(map_square_hole_run_procedure_result); send_once(&sender, mapped); - }); + }, + ); }) .await } @@ -341,21 +350,21 @@ impl SpacetimeClient { }; self.call_after_connect(move |connection, sender| { - connection - .procedures() - .drop_square_hole_shape_then(procedure_input, move |_, result| { + connection.procedures().drop_square_hole_shape_then( + procedure_input, + move |_, result| { let mapped = result .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .and_then(map_square_hole_drop_shape_procedure_result) .map(|mut confirmation| { if confirmation.accepted { - confirmation.run.last_confirmed_action_id = - Some(client_event_id); + confirmation.run.last_confirmed_action_id = Some(client_event_id); } confirmation }); send_once(&sender, mapped); - }); + }, + ); }) .await } @@ -395,14 +404,15 @@ impl SpacetimeClient { }; self.call_after_connect(move |connection, sender| { - connection - .procedures() - .restart_square_hole_run_then(procedure_input, move |_, result| { + connection.procedures().restart_square_hole_run_then( + procedure_input, + move |_, result| { let mapped = result .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .and_then(map_square_hole_run_procedure_result); send_once(&sender, mapped); - }); + }, + ); }) .await } @@ -418,14 +428,15 @@ impl SpacetimeClient { }; self.call_after_connect(move |connection, sender| { - connection - .procedures() - .finish_square_hole_time_up_then(procedure_input, move |_, result| { + connection.procedures().finish_square_hole_time_up_then( + procedure_input, + move |_, result| { let mapped = result .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .and_then(map_square_hole_run_procedure_result); send_once(&sender, mapped); - }); + }, + ); }) .await } diff --git a/server-rs/crates/spacetime-module/src/square_hole/mod.rs b/server-rs/crates/spacetime-module/src/square_hole/mod.rs index 4390cc55..03ee9b1d 100644 --- a/server-rs/crates/spacetime-module/src/square_hole/mod.rs +++ b/server-rs/crates/spacetime-module/src/square_hole/mod.rs @@ -10,12 +10,17 @@ use module_square_hole::{ SquareHoleDropFeedback as DomainSquareHoleDropFeedback, SquareHoleDropInput as DomainSquareHoleDropInput, SquareHoleDropRejectReason as DomainSquareHoleDropRejectReason, + SquareHoleHoleOption as DomainSquareHoleHoleOption, SquareHoleHoleSnapshot as DomainSquareHoleHoleSnapshot, SquareHoleRunSnapshot as DomainSquareHoleRunSnapshot, SquareHoleRunStatus as DomainSquareHoleRunStatus, + SquareHoleShapeOption as DomainSquareHoleShapeOption, SquareHoleShapeSnapshot as DomainSquareHoleShapeSnapshot, build_creator_config as build_domain_creator_config, compile_result_draft as compile_domain_result_draft, confirm_drop_at as confirm_domain_drop_at, + default_background_prompt as default_domain_background_prompt, + normalize_hole_options as normalize_domain_hole_options, + normalize_shape_options as normalize_domain_shape_options, resolve_run_timer_at as resolve_domain_run_timer_at, start_run_at as start_domain_run_at, stop_run_at as stop_domain_run_at, }; @@ -432,6 +437,9 @@ fn compile_square_hole_draft_tx( domain_draft.blockers = module_square_hole::validate_publish_requirements(&domain_draft); domain_draft.publish_ready = domain_draft.blockers.is_empty(); + let cover_image_src = clean_optional(&input.cover_image_src) + .or_else(|| clean_optional(&Some(config.cover_image_src.clone()))) + .unwrap_or_default(); let draft = SquareHoleDraftSnapshot { profile_id: input.profile_id.clone(), game_name: domain_draft.game_name.clone(), @@ -439,6 +447,14 @@ fn compile_square_hole_draft_tx( twist_rule: domain_draft.twist_rule.clone(), summary_text: domain_draft.summary.clone(), tags: domain_draft.tags.clone(), + cover_image_src: cover_image_src.clone(), + background_prompt: domain_draft.background_prompt.clone(), + background_image_src: domain_draft + .background_image_src + .clone() + .unwrap_or_default(), + shape_options: shape_options_to_snapshot(&domain_draft.shape_options), + hole_options: hole_options_to_snapshot(&domain_draft.hole_options), shape_count: domain_draft.shape_count, difficulty: domain_draft.difficulty, }; @@ -454,7 +470,7 @@ fn compile_square_hole_draft_tx( twist_rule: config.twist_rule.clone(), summary_text: draft.summary_text.clone(), tags_json: to_json_string(&draft.tags), - cover_image_src: clean_optional(&input.cover_image_src).unwrap_or_default(), + cover_image_src, shape_count: config.shape_count, difficulty: config.difficulty, config_json: to_json_string(&config), @@ -493,12 +509,31 @@ fn update_square_hole_work_tx( ) -> Result { let current = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?; let tags = parse_tags(&input.tags_json)?; - let config = SquareHoleCreatorConfigSnapshot { + let current_config = parse_config(¤t.config_json)?; + let shape_options = parse_optional_json::>( + input.shape_options_json.as_str(), + "square_hole shape_options_json", + )? + .unwrap_or_else(|| current_config.shape_options.clone()); + let hole_options = parse_optional_json::>( + input.hole_options_json.as_str(), + "square_hole hole_options_json", + )? + .unwrap_or_else(|| current_config.hole_options.clone()); + let config = normalize_config(SquareHoleCreatorConfigSnapshot { theme_text: clean_string(&input.theme_text, "玩具"), twist_rule: clean_string(&input.twist_rule, "方洞万能"), shape_count: input.shape_count, difficulty: input.difficulty, - }; + shape_options, + hole_options, + background_prompt: clean_string( + &input.background_prompt, + &default_domain_background_prompt(&input.theme_text), + ), + cover_image_src: input.cover_image_src.trim().to_string(), + background_image_src: input.background_image_src.trim().to_string(), + }); validate_config(&config)?; let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); let next = SquareHoleWorkProfileRow { @@ -828,6 +863,10 @@ fn build_work_snapshot(row: &SquareHoleWorkProfileRow) -> Result bool { } fn default_config_from_seed(seed_text: &str) -> SquareHoleCreatorConfigSnapshot { - SquareHoleCreatorConfigSnapshot { + normalize_config(SquareHoleCreatorConfigSnapshot { theme_text: clean_string(seed_text, "玩具"), twist_rule: "方洞万能".to_string(), shape_count: 12, difficulty: 4, - } + shape_options: Vec::new(), + hole_options: Vec::new(), + background_prompt: String::new(), + cover_image_src: String::new(), + background_image_src: String::new(), + }) } fn parse_config_or_default(value: &str) -> SquareHoleCreatorConfigSnapshot { @@ -1053,17 +1097,34 @@ fn parse_config_or_default(value: &str) -> SquareHoleCreatorConfigSnapshot { } fn parse_config(value: &str) -> Result { - parse_json(value, "square_hole config_json").map( - |mut config: SquareHoleCreatorConfigSnapshot| { - config.theme_text = clean_string(&config.theme_text, "玩具"); - config.twist_rule = clean_string(&config.twist_rule, "方洞万能"); - config.difficulty = config.difficulty.clamp( - module_square_hole::SQUARE_HOLE_MIN_DIFFICULTY, - module_square_hole::SQUARE_HOLE_MAX_DIFFICULTY, - ); - config - }, - ) + parse_json(value, "square_hole config_json").map(normalize_config) +} + +fn normalize_config( + mut config: SquareHoleCreatorConfigSnapshot, +) -> SquareHoleCreatorConfigSnapshot { + config.theme_text = clean_string(&config.theme_text, "玩具"); + config.twist_rule = clean_string(&config.twist_rule, "方洞万能"); + config.difficulty = config.difficulty.clamp( + module_square_hole::SQUARE_HOLE_MIN_DIFFICULTY, + module_square_hole::SQUARE_HOLE_MAX_DIFFICULTY, + ); + config.background_prompt = clean_string( + &config.background_prompt, + &default_domain_background_prompt(&config.theme_text), + ); + config.cover_image_src = config.cover_image_src.trim().to_string(); + config.background_image_src = config.background_image_src.trim().to_string(); + + let shape_options = normalize_domain_shape_options( + domain_shape_options_from_snapshot(&config.shape_options), + &config.theme_text, + ); + let hole_options = + normalize_domain_hole_options(domain_hole_options_from_snapshot(&config.hole_options)); + config.shape_options = shape_options_to_snapshot(&shape_options); + config.hole_options = hole_options_to_snapshot(&hole_options); + config } fn parse_tags(value: &str) -> Result, String> { @@ -1071,6 +1132,13 @@ fn parse_tags(value: &str) -> Result, String> { Ok(normalize_tags(parsed)) } +fn parse_optional_json(value: &str, label: &str) -> Result, String> { + if value.trim().is_empty() { + return Ok(None); + } + parse_json(value, label).map(Some) +} + fn normalize_tags(tags: Vec) -> Vec { let mut result = Vec::new(); for tag in tags { @@ -1097,12 +1165,18 @@ fn normalize_stage(value: &str) -> String { fn domain_config_from_snapshot( config: &SquareHoleCreatorConfigSnapshot, ) -> Result { - build_domain_creator_config( + let mut domain = build_domain_creator_config( &config.theme_text, &config.twist_rule, config.shape_count, config.difficulty, - ) + )?; + domain.shape_options = domain_shape_options_from_snapshot(&config.shape_options); + domain.hole_options = domain_hole_options_from_snapshot(&config.hole_options); + domain.background_prompt = config.background_prompt.clone(); + domain.cover_image_src = empty_to_none(&config.cover_image_src); + domain.background_image_src = empty_to_none(&config.background_image_src); + Ok(domain) } fn snapshot_from_domain( @@ -1125,6 +1199,8 @@ fn snapshot_from_domain( best_combo: run.best_combo, score: run.score, rule_label: run.rule_label.clone(), + background_image_src: run.background_image_src.clone().unwrap_or_default(), + shape_options: shape_options_to_snapshot(&run.shape_options), current_shape: run.current_shape.as_ref().map(shape_from_domain), holes: run.holes.iter().map(hole_from_domain).collect(), last_feedback: run.last_feedback.as_ref().map(feedback_from_domain), @@ -1150,6 +1226,8 @@ fn domain_snapshot_from_snapshot( best_combo: snapshot.best_combo, score: snapshot.score, rule_label: snapshot.rule_label.clone(), + background_image_src: empty_to_none(&snapshot.background_image_src), + shape_options: domain_shape_options_from_snapshot(&snapshot.shape_options), current_shape: snapshot .current_shape .as_ref() @@ -1172,6 +1250,7 @@ fn shape_from_domain(shape: &DomainSquareHoleShapeSnapshot) -> SquareHoleShapeSn shape_kind: shape.shape_kind.clone(), label: shape.label.clone(), color: shape.color.clone(), + image_src: shape.image_src.clone().unwrap_or_default(), } } @@ -1181,6 +1260,7 @@ fn domain_shape_from_snapshot(shape: &SquareHoleShapeSnapshot) -> DomainSquareHo shape_kind: shape.shape_kind.clone(), label: shape.label.clone(), color: shape.color.clone(), + image_src: empty_to_none(&shape.image_src), } } @@ -1191,6 +1271,7 @@ fn hole_from_domain(hole: &DomainSquareHoleHoleSnapshot) -> SquareHoleHoleSnapsh label: hole.label.clone(), x: hole.x, y: hole.y, + bonus: hole.bonus, } } @@ -1201,9 +1282,68 @@ fn domain_hole_from_snapshot(hole: &SquareHoleHoleSnapshot) -> DomainSquareHoleH label: hole.label.clone(), x: hole.x, y: hole.y, + bonus: hole.bonus, } } +fn shape_options_to_snapshot( + options: &[DomainSquareHoleShapeOption], +) -> Vec { + options + .iter() + .map(|option| SquareHoleShapeOptionSnapshot { + option_id: option.option_id.clone(), + shape_kind: option.shape_kind.clone(), + label: option.label.clone(), + image_prompt: option.image_prompt.clone(), + image_src: option.image_src.clone().unwrap_or_default(), + }) + .collect() +} + +fn domain_shape_options_from_snapshot( + options: &[SquareHoleShapeOptionSnapshot], +) -> Vec { + options + .iter() + .map(|option| DomainSquareHoleShapeOption { + option_id: option.option_id.clone(), + shape_kind: option.shape_kind.clone(), + label: option.label.clone(), + image_prompt: option.image_prompt.clone(), + image_src: empty_to_none(&option.image_src), + }) + .collect() +} + +fn hole_options_to_snapshot( + options: &[DomainSquareHoleHoleOption], +) -> Vec { + options + .iter() + .map(|option| SquareHoleHoleOptionSnapshot { + hole_id: option.hole_id.clone(), + hole_kind: option.hole_kind.clone(), + label: option.label.clone(), + bonus: option.bonus, + }) + .collect() +} + +fn domain_hole_options_from_snapshot( + options: &[SquareHoleHoleOptionSnapshot], +) -> Vec { + options + .iter() + .map(|option| DomainSquareHoleHoleOption { + hole_id: option.hole_id.clone(), + hole_kind: option.hole_kind.clone(), + label: option.label.clone(), + bonus: option.bonus, + }) + .collect() +} + fn feedback_from_domain(feedback: &DomainSquareHoleDropFeedback) -> SquareHoleDropFeedbackSnapshot { SquareHoleDropFeedbackSnapshot { accepted: feedback.accepted, diff --git a/server-rs/crates/spacetime-module/src/square_hole/types.rs b/server-rs/crates/spacetime-module/src/square_hole/types.rs index b2c04e37..52e06e04 100644 --- a/server-rs/crates/spacetime-module/src/square_hole/types.rs +++ b/server-rs/crates/spacetime-module/src/square_hole/types.rs @@ -85,6 +85,10 @@ pub struct SquareHoleWorkUpdateInput { pub summary_text: String, pub tags_json: String, pub cover_image_src: String, + pub background_prompt: String, + pub background_image_src: String, + pub shape_options_json: String, + pub hole_options_json: String, pub shape_count: u32, pub difficulty: u32, pub updated_at_micros: i64, @@ -206,6 +210,37 @@ pub struct SquareHoleCreatorConfigSnapshot { pub twist_rule: String, pub shape_count: u32, pub difficulty: u32, + #[serde(default)] + pub shape_options: Vec, + #[serde(default)] + pub hole_options: Vec, + #[serde(default)] + pub background_prompt: String, + #[serde(default)] + pub cover_image_src: String, + #[serde(default)] + pub background_image_src: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SquareHoleShapeOptionSnapshot { + pub option_id: String, + pub shape_kind: String, + pub label: String, + pub image_prompt: String, + #[serde(default)] + pub image_src: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SquareHoleHoleOptionSnapshot { + pub hole_id: String, + pub hole_kind: String, + pub label: String, + #[serde(default)] + pub bonus: bool, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -228,6 +263,16 @@ pub struct SquareHoleDraftSnapshot { pub twist_rule: String, pub summary_text: String, pub tags: Vec, + #[serde(default)] + pub cover_image_src: String, + #[serde(default)] + pub background_prompt: String, + #[serde(default)] + pub background_image_src: String, + #[serde(default)] + pub shape_options: Vec, + #[serde(default)] + pub hole_options: Vec, pub shape_count: u32, pub difficulty: u32, } @@ -264,6 +309,14 @@ pub struct SquareHoleWorkSnapshot { pub summary_text: String, pub tags: Vec, pub cover_image_src: String, + #[serde(default)] + pub background_prompt: String, + #[serde(default)] + pub background_image_src: String, + #[serde(default)] + pub shape_options: Vec, + #[serde(default)] + pub hole_options: Vec, pub shape_count: u32, pub difficulty: u32, pub config: SquareHoleCreatorConfigSnapshot, @@ -281,6 +334,8 @@ pub struct SquareHoleShapeSnapshot { pub shape_kind: String, pub label: String, pub color: String, + #[serde(default)] + pub image_src: String, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -291,6 +346,8 @@ pub struct SquareHoleHoleSnapshot { pub label: String, pub x: f32, pub y: f32, + #[serde(default)] + pub bonus: bool, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -319,6 +376,10 @@ pub struct SquareHoleRunSnapshot { pub best_combo: u32, pub score: u32, pub rule_label: String, + #[serde(default)] + pub background_image_src: String, + #[serde(default)] + pub shape_options: Vec, pub current_shape: Option, pub holes: Vec, pub last_feedback: Option, diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 5020ee04..2bd4cb36 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -32,22 +32,6 @@ import type { Match3DWorkProfile, Match3DWorkSummary, } from '../../../packages/shared/src/contracts/match3dWorks'; -import type { - CreateSquareHoleSessionRequest, - ExecuteSquareHoleActionRequest, - SendSquareHoleMessageRequest, - SquareHoleActionResponse, - SquareHoleSessionResponse, - SquareHoleSessionSnapshot, -} from '../../../packages/shared/src/contracts/squareHoleAgent'; -import type { - DropSquareHoleShapeRequest, - SquareHoleRunSnapshot, -} from '../../../packages/shared/src/contracts/squareHoleRuntime'; -import type { - SquareHoleWorkProfile, - SquareHoleWorkSummary, -} from '../../../packages/shared/src/contracts/squareHoleWorks'; import type { PuzzleAgentActionRequest, PuzzleAgentOperationRecord, @@ -72,6 +56,21 @@ import type { ProfileSaveArchiveResumeResponse, ProfileSaveArchiveSummary, } from '../../../packages/shared/src/contracts/runtime'; +import type { + CreateSquareHoleSessionRequest, + ExecuteSquareHoleActionRequest, + SendSquareHoleMessageRequest, + SquareHoleActionResponse, + SquareHoleSessionResponse, + SquareHoleSessionSnapshot, +} from '../../../packages/shared/src/contracts/squareHoleAgent'; +import type { + SquareHoleRunSnapshot, +} from '../../../packages/shared/src/contracts/squareHoleRuntime'; +import type { + SquareHoleWorkProfile, + SquareHoleWorkSummary, +} from '../../../packages/shared/src/contracts/squareHoleWorks'; import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; import { buildPublicWorkStagePath, @@ -123,6 +122,7 @@ import { buildBigFishGenerationAnchorEntries, buildMiniGameDraftGenerationProgress, buildPuzzleGenerationAnchorEntries, + buildSquareHoleGenerationAnchorEntries, createMiniGameDraftGenerationState, type MiniGameDraftGenerationState, } from '../../services/miniGameDraftGenerationProgress'; @@ -552,8 +552,12 @@ function mapPublicWorkDetailToSquareHoleWork( summary: entry.summaryText, tags: entry.themeTags, coverImageSrc: entry.coverImageSrc, - shapeCount: 8, - difficulty: 4, + backgroundPrompt: entry.backgroundPrompt ?? '方洞挑战运行背景', + backgroundImageSrc: entry.backgroundImageSrc ?? null, + shapeOptions: entry.shapeOptions ?? [], + holeOptions: entry.holeOptions ?? [], + shapeCount: entry.shapeCount ?? 8, + difficulty: entry.difficulty ?? 4, publicationStatus: 'published', playCount: entry.playCount ?? 0, updatedAt: entry.updatedAt, @@ -581,7 +585,11 @@ function buildSquareHoleProfileFromSession( twistRule: draft.twistRule, summary: draft.summary, tags: draft.tags, - coverImageSrc: null, + coverImageSrc: draft.coverImageSrc ?? null, + backgroundPrompt: draft.backgroundPrompt, + backgroundImageSrc: draft.backgroundImageSrc ?? null, + shapeOptions: draft.shapeOptions, + holeOptions: draft.holeOptions, shapeCount: draft.shapeCount, difficulty: draft.difficulty, publicationStatus: 'draft', @@ -608,13 +616,6 @@ function mergeBigFishWorkSummary( : current; } -function mergeSquareHoleWorkSummary( - current: SquareHoleWorkSummary, - updated: SquareHoleWorkSummary, -): SquareHoleWorkSummary { - return current.profileId === updated.profileId ? updated : current; -} - async function resolvePublicWorkAuthorSummary( entry: PlatformPublicGalleryCard, ): Promise { @@ -1086,6 +1087,8 @@ export function PlatformEntryFlowShellImpl({ useState('square-hole-result'); const [isSquareHoleLoadingLibrary, setIsSquareHoleLoadingLibrary] = useState(false); + const [squareHoleGenerationState, setSquareHoleGenerationState] = + useState(null); const [bigFishRun, setBigFishRun] = useState(null); const [bigFishRuntimeShare, setBigFishRuntimeShare] = useState<{ @@ -1817,7 +1820,7 @@ export function PlatformEntryFlowShellImpl({ workspaceStage: 'square-hole-agent-workspace', resultStage: 'square-hole-result', platformStage: 'platform', - isCompileAction: (payload) => payload.action === 'square_hole_compile_draft', + isCompileAction: () => false, resolveErrorMessage: resolveSquareHoleErrorMessage, errorMessages: { open: '开启方洞挑战共创工作台失败。', @@ -1831,9 +1834,30 @@ export function PlatformEntryFlowShellImpl({ onSessionOpened: () => { setShowCreationTypeModal(false); }, + beforeExecuteAction: ({ payload }) => { + if (payload.action === 'square_hole_compile_draft') { + setSquareHoleGenerationState( + createMiniGameDraftGenerationState('square-hole'), + ); + setSelectionStage('square-hole-generating'); + } + if (payload.action === 'square_hole_generate_visual_assets') { + setSquareHoleGenerationState((current) => ({ + ...(current ?? createMiniGameDraftGenerationState('square-hole')), + phase: 'square-hole-cover', + completedAssetCount: 0, + totalAssetCount: 0, + error: null, + })); + setSelectionStage('square-hole-generating'); + } + }, onActionComplete: async ({ payload, response, setSession }) => { setSession(response.session); - if (payload.action !== 'square_hole_compile_draft') { + if ( + payload.action !== 'square_hole_compile_draft' && + payload.action !== 'square_hole_generate_visual_assets' + ) { return; } @@ -1843,12 +1867,79 @@ export function PlatformEntryFlowShellImpl({ return; } + if (payload.action === 'square_hole_compile_draft') { + try { + const assetResponse = await squareHoleCreationClient.executeAction( + response.session.sessionId, + { + action: 'square_hole_generate_visual_assets', + }, + ); + setSession(assetResponse.session); + const assetProfileId = assetResponse.session.draft?.profileId; + if (!assetProfileId) { + setSquareHoleProfile( + buildSquareHoleProfileFromSession(assetResponse.session), + ); + setSelectionStage('square-hole-result'); + return; + } + const { item } = await getSquareHoleWorkDetail(assetProfileId); + setSquareHoleProfile(item); + setSquareHoleGenerationState((current) => ({ + ...(current ?? createMiniGameDraftGenerationState('square-hole')), + phase: 'ready', + completedAssetCount: item.shapeOptions.length + 2, + totalAssetCount: item.shapeOptions.length + 2, + error: null, + })); + await refreshSquareHoleShelf().catch(() => undefined); + setSelectionStage('square-hole-result'); + } catch (error) { + const errorMessage = resolveSquareHoleErrorMessage( + error, + '生成方洞挑战图片失败。', + ); + setSquareHoleError(errorMessage); + setSquareHoleGenerationState((current) => ({ + ...(current ?? createMiniGameDraftGenerationState('square-hole')), + phase: 'failed', + error: errorMessage, + })); + setSquareHoleProfile(buildSquareHoleProfileFromSession(response.session)); + setSelectionStage('square-hole-generating'); + } + return; + } + try { const { item } = await getSquareHoleWorkDetail(profileId); setSquareHoleProfile(item); + setSquareHoleGenerationState((current) => ({ + ...(current ?? createMiniGameDraftGenerationState('square-hole')), + phase: 'ready', + completedAssetCount: item.shapeOptions.length + 2, + totalAssetCount: item.shapeOptions.length + 2, + error: null, + })); await refreshSquareHoleShelf().catch(() => undefined); + setSelectionStage('square-hole-result'); } catch { setSquareHoleProfile(buildSquareHoleProfileFromSession(response.session)); + setSelectionStage('square-hole-result'); + } + }, + onActionError: ({ payload, errorMessage }) => { + if ( + payload.action === 'square_hole_compile_draft' || + payload.action === 'square_hole_generate_visual_assets' + ) { + setSquareHoleGenerationState((current) => ({ + ...(current ?? createMiniGameDraftGenerationState('square-hole')), + phase: 'failed', + error: errorMessage, + })); + setSelectionStage('square-hole-generating'); } }, }); @@ -2047,6 +2138,7 @@ export function PlatformEntryFlowShellImpl({ setSquareHoleProfile(null); setSquareHoleRun(null); setSquareHoleError(null); + setSquareHoleGenerationState(null); setSquareHoleRuntimeReturnStage('square-hole-result'); setStreamingSquareHoleReplyText(''); setIsStreamingSquareHoleReply(false); @@ -2162,6 +2254,7 @@ export function PlatformEntryFlowShellImpl({ setSquareHoleGalleryEntries([]); setSquareHoleRun(null); setSquareHoleRuntimeReturnStage('square-hole-result'); + setSquareHoleGenerationState(null); setSquareHoleError(null); setStreamingSquareHoleReplyText(''); setIsStreamingSquareHoleReply(false); @@ -2291,6 +2384,7 @@ export function PlatformEntryFlowShellImpl({ const leaveSquareHoleFlow = useCallback(() => { setSquareHoleRun(null); setSquareHoleRuntimeReturnStage('square-hole-result'); + setSquareHoleGenerationState(null); squareHoleFlow.leaveFlow(); }, [squareHoleFlow]); @@ -2316,6 +2410,20 @@ export function PlatformEntryFlowShellImpl({ const executeSquareHoleAction = squareHoleFlow.executeAction; + const retrySquareHoleAssetGeneration = useCallback(() => { + const session = squareHoleSession; + if (!session?.draft?.profileId) { + void executeSquareHoleAction({ + action: 'square_hole_compile_draft', + }); + return; + } + + void executeSquareHoleAction({ + action: 'square_hole_generate_visual_assets', + }); + }, [executeSquareHoleAction, squareHoleSession]); + const executePuzzleAction = puzzleFlow.executeAction; const retryPuzzleDraftGeneration = useCallback(() => { @@ -4013,6 +4121,7 @@ export function PlatformEntryFlowShellImpl({ return; } + setSquareHoleGenerationState(null); const restoredSession = await squareHoleFlow.restoreDraft( item.sourceSessionId, ); @@ -5583,6 +5692,50 @@ export function PlatformEntryFlowShellImpl({ )} + {selectionStage === 'square-hole-generating' && ( + + } + > + { + setSelectionStage('square-hole-agent-workspace'); + }} + onRetry={retrySquareHoleAssetGeneration} + onInterrupt={undefined} + backLabel="返回创作中心" + settingActionLabel={null} + retryLabel="重新生成图片" + settingTitle="当前方洞挑战" + settingDescription={null} + progressTitle="方洞挑战图片生成进度" + activeBadgeLabel="图片生成中" + pausedBadgeLabel="图片生成已暂停" + idleBadgeLabel="等待返回结果页" + /> + + + )} + {selectionStage === 'square-hole-result' && squareHoleSession?.draft && ( 0 ? work.tags : [work.themeText, '方洞挑战'], playCount: work.playCount ?? 0, diff --git a/src/components/square-hole-result/SquareHoleResultView.tsx b/src/components/square-hole-result/SquareHoleResultView.tsx index d5711f46..56dc60af 100644 --- a/src/components/square-hole-result/SquareHoleResultView.tsx +++ b/src/components/square-hole-result/SquareHoleResultView.tsx @@ -4,13 +4,17 @@ import { ImagePlus, Loader2, Play, + Plus, Send, + Trash2, } from 'lucide-react'; import { type ChangeEvent, useEffect, useMemo, useState } from 'react'; import type { SquareHoleResultDraft } from '../../../packages/shared/src/contracts/squareHoleAgent'; import type { PutSquareHoleWorkRequest, + SquareHoleHoleOption, + SquareHoleShapeOption, SquareHoleWorkProfile, } from '../../../packages/shared/src/contracts/squareHoleWorks'; import { @@ -37,13 +41,26 @@ type SquareHoleResultEditState = { summary: string; tagsText: string; coverImageSrc: string; + backgroundPrompt: string; + backgroundImageSrc: string; themeText: string; twistRule: string; + shapeOptions: SquareHoleShapeOption[]; + holeOptions: SquareHoleHoleOption[]; shapeCountText: string; difficultyText: string; }; const SQUARE_HOLE_AUTOSAVE_DEBOUNCE_MS = 600; +const SQUARE_HOLE_SHAPE_KIND_OPTIONS = [ + 'square', + 'circle', + 'triangle', + 'star', + 'arch', + 'diamond', +]; +const SQUARE_HOLE_HOLE_KIND_OPTIONS = SQUARE_HOLE_SHAPE_KIND_OPTIONS; function normalizeTags(value: string) { return [ @@ -78,8 +95,12 @@ function createEditState( summary: profile.summary, tagsText: profile.tags.join(','), coverImageSrc: profile.coverImageSrc?.trim() || '', + backgroundPrompt: profile.backgroundPrompt || '', + backgroundImageSrc: profile.backgroundImageSrc?.trim() || '', themeText: profile.themeText, twistRule: profile.twistRule, + shapeOptions: profile.shapeOptions.map((option) => ({ ...option })), + holeOptions: profile.holeOptions.map((option) => ({ ...option })), shapeCountText: String(profile.shapeCount), difficultyText: String(profile.difficulty), }; @@ -95,6 +116,28 @@ function buildSavePayload( const twistRule = editState.twistRule.trim(); const summary = editState.summary.trim(); const tags = normalizeTags(editState.tagsText); + const shapeOptions = editState.shapeOptions + .map((option) => ({ + ...option, + optionId: option.optionId.trim(), + shapeKind: option.shapeKind.trim(), + label: option.label.trim(), + imagePrompt: option.imagePrompt.trim(), + imageSrc: option.imageSrc?.trim() || null, + })) + .filter( + (option) => + option.optionId && option.shapeKind && option.label && option.imagePrompt, + ); + const holeOptions = editState.holeOptions + .map((option) => ({ + ...option, + holeId: option.holeId.trim(), + holeKind: option.holeKind.trim(), + label: option.label.trim(), + bonus: Boolean(option.bonus), + })) + .filter((option) => option.holeId && option.holeKind && option.label); if ( !gameName || @@ -102,6 +145,8 @@ function buildSavePayload( !twistRule || !summary || tags.length === 0 || + shapeOptions.length === 0 || + holeOptions.length === 0 || !shapeCount || !difficulty ) { @@ -115,6 +160,10 @@ function buildSavePayload( summary, tags, coverImageSrc: editState.coverImageSrc.trim() || null, + backgroundPrompt: editState.backgroundPrompt.trim(), + backgroundImageSrc: editState.backgroundImageSrc.trim() || null, + shapeOptions, + holeOptions, shapeCount, difficulty, }; @@ -129,6 +178,21 @@ function buildPublishBlockers(editState: SquareHoleResultEditState) { ...(normalizeTags(editState.tagsText).length > 0 ? [] : ['至少需要 1 个标签。']), + ...(editState.shapeOptions.some( + (option) => + option.optionId.trim() && + option.shapeKind.trim() && + option.label.trim() && + option.imagePrompt.trim(), + ) + ? [] + : ['至少需要 1 个形状选项。']), + ...(editState.holeOptions.some( + (option) => + option.holeId.trim() && option.holeKind.trim() && option.label.trim(), + ) + ? [] + : ['至少需要 1 个洞口选项。']), ...(normalizeShapeCount(editState.shapeCountText) ? [] : ['形状数量需要在 6 到 24 之间。']), @@ -154,6 +218,31 @@ function readImageAsDataUrl(file: File) { }); } +function createShapeOption(index: number): SquareHoleShapeOption { + return { + optionId: `shape-${Date.now().toString(36)}-${index}`, + shapeKind: + SQUARE_HOLE_SHAPE_KIND_OPTIONS[ + index % SQUARE_HOLE_SHAPE_KIND_OPTIONS.length + ] ?? 'square', + label: `形状 ${index + 1}`, + imagePrompt: '主题贴图', + imageSrc: null, + }; +} + +function createHoleOption(index: number): SquareHoleHoleOption { + return { + holeId: `hole-${Date.now().toString(36)}-${index}`, + holeKind: + SQUARE_HOLE_HOLE_KIND_OPTIONS[ + index % SQUARE_HOLE_HOLE_KIND_OPTIONS.length + ] ?? 'square', + label: `洞口 ${index + 1}`, + bonus: index === 0, + }; +} + function buildPlayableProfile( profile: SquareHoleWorkProfile, editState: SquareHoleResultEditState, @@ -171,6 +260,10 @@ function buildPlayableProfile( summary: payload.summary, tags: payload.tags, coverImageSrc: payload.coverImageSrc, + backgroundPrompt: payload.backgroundPrompt ?? profile.backgroundPrompt, + backgroundImageSrc: payload.backgroundImageSrc, + shapeOptions: payload.shapeOptions ?? profile.shapeOptions, + holeOptions: payload.holeOptions ?? profile.holeOptions, shapeCount: payload.shapeCount, difficulty: payload.difficulty, }; @@ -241,7 +334,7 @@ export function SquareHoleResultView({ setEditState(createEditState(profile)); setAutoSaveState('idle'); setLocalError(null); - }, [profile.profileId, profile.updatedAt]); + }, [profile]); useEffect(() => { const payload = buildSavePayload(editState); @@ -250,14 +343,21 @@ export function SquareHoleResultView({ } const currentTags = normalizeTags(profile.tags.join(',')); + const currentShapeOptions = JSON.stringify(profile.shapeOptions); + const currentHoleOptions = JSON.stringify(profile.holeOptions); const changed = payload.gameName !== profile.gameName || payload.themeText !== profile.themeText || payload.twistRule !== profile.twistRule || payload.summary !== profile.summary || (payload.coverImageSrc ?? '') !== (profile.coverImageSrc ?? '') || + (payload.backgroundPrompt ?? '') !== (profile.backgroundPrompt ?? '') || + (payload.backgroundImageSrc ?? '') !== + (profile.backgroundImageSrc ?? '') || payload.shapeCount !== profile.shapeCount || payload.difficulty !== profile.difficulty || + JSON.stringify(payload.shapeOptions ?? []) !== currentShapeOptions || + JSON.stringify(payload.holeOptions ?? []) !== currentHoleOptions || payload.tags.length !== currentTags.length || payload.tags.some((tag, index) => tag !== currentTags[index]); @@ -330,6 +430,60 @@ export function SquareHoleResultView({ } }; + const handleBackgroundImageChange = async ( + event: ChangeEvent, + ) => { + const file = event.target.files?.[0] ?? null; + event.target.value = ''; + if (!file) { + return; + } + + try { + const dataUrl = await readImageAsDataUrl(file); + setEditState((current) => ({ + ...current, + backgroundImageSrc: dataUrl, + })); + setLocalError(null); + } catch (caughtError) { + setLocalError( + caughtError instanceof Error ? caughtError.message : '背景图读取失败。', + ); + } + }; + + const handleShapeImageChange = async ( + optionId: string, + event: ChangeEvent, + ) => { + const file = event.target.files?.[0] ?? null; + event.target.value = ''; + if (!file) { + return; + } + + try { + const dataUrl = await readImageAsDataUrl(file); + setEditState((current) => ({ + ...current, + shapeOptions: current.shapeOptions.map((option) => + option.optionId === optionId + ? { + ...option, + imageSrc: dataUrl, + } + : option, + ), + })); + setLocalError(null); + } catch (caughtError) { + setLocalError( + caughtError instanceof Error ? caughtError.message : '形状贴图读取失败。', + ); + } + }; + const handleStartTestRun = async () => { if (!canSubmit || isStartingTestRun) { setLocalError(blockers[0] ?? null); @@ -422,6 +576,31 @@ export function SquareHoleResultView({ {draft?.publishReady ?? profile.publishReady ? '可发布' : '草稿'} +
+ {editState.backgroundImageSrc ? ( +
+
@@ -497,6 +676,23 @@ export function SquareHoleResultView({ /> + +
+ +
+
+
+ 形状选项 +
+ +
+
+ {editState.shapeOptions.map((option) => ( +
+
+ +
+ + setEditState((current) => ({ + ...current, + shapeOptions: current.shapeOptions.map((entry) => + entry.optionId === option.optionId + ? { ...entry, label: event.target.value } + : entry, + ), + })) + } + className="w-full rounded-[0.85rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-2 text-sm font-semibold text-[var(--platform-text-strong)] outline-none" + /> + +
+ +
+