Extend square-hole creation flow with visual asset timeout guard

This commit is contained in:
kdletters
2026-05-05 15:27:09 +08:00
parent 2252afb080
commit 60b667a9d1
30 changed files with 2838 additions and 215 deletions

View File

@@ -74,7 +74,8 @@
8. 后端初始化单局形状队列、洞口兼容规则和计分状态。 8. 后端初始化单局形状队列、洞口兼容规则和计分状态。
9. 玩家拖拽或点击形状投入洞口。 9. 玩家拖拽或点击形状投入洞口。
10. 后端裁决投入结果、连击、扣时、失败、胜利和成绩。 10. 后端裁决投入结果、连击、扣时、失败、胜利和成绩。
11. 前端只渲染后端快照与即时反馈,不承接正式规则真相 11. 形状贴图、封面图和背景图必须由后端图片生成接口生成或由创作者上传,前端只保存和展示 URL / data URL
12. 前端只渲染后端快照与即时反馈,不承接正式规则真相。
--- ---
@@ -106,7 +107,23 @@ Agent 需要把玩家一句灵感收束为上述锚点,不允许逐项盘问
"themeText": "", "themeText": "",
"twistRule": "", "twistRule": "",
"shapeCount": 12, "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` 4. `quickFillRequested=true` 时,模型应直接补齐剩余配置,后端把 `progressPercent` 固定为 `100`
5. 模型不可用或结果无法解析时,接口返回明确错误,不允许用确定性模板伪装成 AI 回复。 5. 模型不可用或结果无法解析时,接口返回明确错误,不允许用确定性模板伪装成 AI 回复。
6. 非流式消息接口和 SSE 流式消息接口都必须走同一套方洞 Agent turnSSE 只额外负责把 `replyText` 增量回传。 6. 非流式消息接口和 SSE 流式消息接口都必须走同一套方洞 Agent turnSSE 只额外负责把 `replyText` 增量回传。
7. `shapeOptions` 至少包含 `6` 个候选项;缺失时后端用当前题材生成默认候选项。
8. `holeOptions` 至少包含 `3` 个选项,最多 `6` 个选项;创作者可以自定义 label、洞口类型与是否为加分选项。
9. `bonus=true` 只表示“该选项被后端判定为正确时额外加 50 分”,不是公开提示;运行态 UI 不允许直接显示哪个选项是加分选项。
10. `backgroundPrompt` 用于生成运行态背景图;为空时后端用题材和反差规则拼出默认提示词。
--- ---
@@ -147,7 +168,30 @@ Agent 需要把玩家一句灵感收束为上述锚点,不允许逐项盘问
其中 `square_hole_priority` 是参考视频核心反差的首选默认规则。 其中 `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. 竖屏优先,桌面端居中显示游戏台。 1. 竖屏优先,桌面端居中显示游戏台。
2. 当前形状位于屏幕下半区域,洞板位于上半区域。 2. 当前形状位于屏幕下半区域,洞板位于上半区域。
@@ -207,10 +251,13 @@ Agent 需要把玩家一句灵感收束为上述锚点,不允许逐项盘问
10. `coverImageSrc` 10. `coverImageSrc`
11. `shapeCount` 11. `shapeCount`
12. `difficulty` 12. `difficulty`
13. `publicationStatus` 13. `shapeOptions`
14. `playCount` 14. `holeOptions`
15. `updatedAt` 15. `backgroundImageSrc`
16. `publishedAt` 16. `publicationStatus`
17. `playCount`
18. `updatedAt`
19. `publishedAt`
## 8.3 运行态 run snapshot ## 8.3 运行态 run snapshot
@@ -228,9 +275,12 @@ Agent 需要把玩家一句灵感收束为上述锚点,不允许逐项盘问
12. `bestCombo` 12. `bestCombo`
13. `score` 13. `score`
14. `ruleLabel` 14. `ruleLabel`
15. `currentShape` 15. `backgroundImageSrc`
16. `holes` 16. `currentShape`
17. `lastFeedback` 17. `holes`
18. `lastFeedback`
运行态 `ruleLabel` 仅保留后端调试兼容字段,前端默认不展示。
--- ---

View File

@@ -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`

View File

@@ -35,6 +35,21 @@ export interface ExecuteSquareHoleActionRequest {
coverImageSrc?: string | null; 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 { export interface SquareHoleAnchorItemResponse {
key: string; key: string;
label: string; label: string;
@@ -54,6 +69,11 @@ export interface SquareHoleCreatorConfig {
twistRule: string; twistRule: string;
shapeCount: number; shapeCount: number;
difficulty: number; difficulty: number;
shapeOptions: SquareHoleShapeOption[];
holeOptions: SquareHoleHoleOption[];
backgroundPrompt: string;
coverImageSrc?: string | null;
backgroundImageSrc?: string | null;
} }
export interface SquareHoleResultDraft { export interface SquareHoleResultDraft {
@@ -63,6 +83,11 @@ export interface SquareHoleResultDraft {
twistRule: string; twistRule: string;
summary: string; summary: string;
tags: string[]; tags: string[];
coverImageSrc?: string | null;
backgroundPrompt: string;
backgroundImageSrc?: string | null;
shapeOptions: SquareHoleShapeOption[];
holeOptions: SquareHoleHoleOption[];
shapeCount: number; shapeCount: number;
difficulty: number; difficulty: number;
publishReady: boolean; publishReady: boolean;

View File

@@ -37,6 +37,7 @@ export interface SquareHoleShapeSnapshot {
shapeKind: SquareHoleShapeKind; shapeKind: SquareHoleShapeKind;
label: string; label: string;
color: string; color: string;
imageSrc?: string | null;
} }
export interface SquareHoleHoleSnapshot { export interface SquareHoleHoleSnapshot {
@@ -45,6 +46,7 @@ export interface SquareHoleHoleSnapshot {
label: string; label: string;
x: number; x: number;
y: number; y: number;
bonus: boolean;
} }
export interface SquareHoleRunSnapshot { export interface SquareHoleRunSnapshot {
@@ -62,6 +64,7 @@ export interface SquareHoleRunSnapshot {
bestCombo: number; bestCombo: number;
score: number; score: number;
ruleLabel: string; ruleLabel: string;
backgroundImageSrc?: string | null;
currentShape?: SquareHoleShapeSnapshot | null; currentShape?: SquareHoleShapeSnapshot | null;
holes: SquareHoleHoleSnapshot[]; holes: SquareHoleHoleSnapshot[];
lastFeedback?: SquareHoleDropFeedback | null; lastFeedback?: SquareHoleDropFeedback | null;

View File

@@ -4,6 +4,21 @@
*/ */
export type SquareHoleWorkPublicationStatus = 'draft' | 'published' | string; 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 { export interface PutSquareHoleWorkRequest {
gameName: string; gameName: string;
themeText?: string; themeText?: string;
@@ -11,6 +26,10 @@ export interface PutSquareHoleWorkRequest {
summary: string; summary: string;
tags: string[]; tags: string[];
coverImageSrc?: string | null; coverImageSrc?: string | null;
backgroundPrompt?: string;
backgroundImageSrc?: string | null;
shapeOptions?: SquareHoleShapeOption[];
holeOptions?: SquareHoleHoleOption[];
shapeCount: number; shapeCount: number;
difficulty: number; difficulty: number;
} }
@@ -26,6 +45,10 @@ export interface SquareHoleWorkSummary {
summary: string; summary: string;
tags: string[]; tags: string[];
coverImageSrc?: string | null; coverImageSrc?: string | null;
backgroundPrompt: string;
backgroundImageSrc?: string | null;
shapeOptions: SquareHoleShapeOption[];
holeOptions: SquareHoleHoleOption[];
shapeCount: number; shapeCount: number;
difficulty: number; difficulty: number;
publicationStatus: SquareHoleWorkPublicationStatus; publicationStatus: SquareHoleWorkPublicationStatus;

View File

@@ -13,9 +13,6 @@ export * from './contracts/puzzleAgentSession';
export * from './contracts/puzzleResultPreview'; export * from './contracts/puzzleResultPreview';
export * from './contracts/puzzleRuntimeSession'; export * from './contracts/puzzleRuntimeSession';
export * from './contracts/puzzleWorkSummary'; export * from './contracts/puzzleWorkSummary';
export * from './contracts/squareHoleAgent';
export * from './contracts/squareHoleRuntime';
export * from './contracts/squareHoleWorks';
export * from './contracts/rpgAgentActions'; export * from './contracts/rpgAgentActions';
export * from './contracts/rpgAgentAnchors'; export * from './contracts/rpgAgentAnchors';
export * from './contracts/rpgAgentDraft'; export * from './contracts/rpgAgentDraft';
@@ -29,6 +26,19 @@ export * from './contracts/rpgRuntimeQuestAssist';
export * from './contracts/rpgRuntimeStoryAction'; export * from './contracts/rpgRuntimeStoryAction';
export * from './contracts/rpgRuntimeStoryState'; export * from './contracts/rpgRuntimeStoryState';
export * from './contracts/runtime'; 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 type * from './contracts/story';
export * from './http'; export * from './http';
export * from './llm/narrativeLanguage'; export * from './llm/narrativeLanguage';

View File

@@ -3,6 +3,8 @@ use serde_json::Value as JsonValue;
use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL; 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)] #[derive(Clone, Copy, Debug)]
pub(crate) struct CreationAgentLlmTurnErrorMessages<'a> { pub(crate) struct CreationAgentLlmTurnErrorMessages<'a> {
pub model_unavailable: &'a str, pub model_unavailable: &'a str,
@@ -138,6 +140,7 @@ fn build_creation_agent_llm_request(
.with_model(CREATION_TEMPLATE_LLM_MODEL) .with_model(CREATION_TEMPLATE_LLM_MODEL)
.with_responses_api() .with_responses_api()
.with_web_search(enable_web_search) .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<E>( pub(crate) async fn request_creation_agent_json_turn<E>(
@@ -246,9 +249,9 @@ mod tests {
use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL; use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL;
use super::{ use super::{
CreationAgentLlmTurnErrorMessages, build_creation_agent_llm_request, CREATION_AGENT_STREAM_REQUEST_TIMEOUT_MS, CreationAgentLlmTurnErrorMessages,
extract_reply_text_from_partial_json, is_web_search_tool_unavailable, build_creation_agent_llm_request, extract_reply_text_from_partial_json,
parse_json_response_text, stream_creation_agent_json_turn, is_web_search_tool_unavailable, parse_json_response_text, stream_creation_agent_json_turn,
}; };
#[test] #[test]
@@ -277,6 +280,10 @@ mod tests {
assert_eq!(request.model.as_deref(), Some(CREATION_TEMPLATE_LLM_MODEL)); assert_eq!(request.model.as_deref(), Some(CREATION_TEMPLATE_LLM_MODEL));
assert_eq!(request.protocol, platform_llm::LlmTextProtocol::Responses); assert_eq!(request.protocol, platform_llm::LlmTextProtocol::Responses);
assert_eq!(request.messages.len(), 2); assert_eq!(request.messages.len(), 2);
assert_eq!(
request.request_timeout_ms,
Some(CREATION_AGENT_STREAM_REQUEST_TIMEOUT_MS)
);
} }
#[test] #[test]

View File

@@ -43,6 +43,7 @@ pub async fn proxy_llm_chat_completions(
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
max_tokens: None, max_tokens: None,
enable_web_search: false, enable_web_search: false,
request_timeout_ms: None,
}; };
if payload.stream { if payload.stream {

View File

@@ -20,10 +20,12 @@ pub(crate) const SQUARE_HOLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责
2. nextConfig 必须是完整对象,不能只输出 patch 2. nextConfig 必须是完整对象,不能只输出 patch
3. replyText 必须是自然中文不能提“字段”“结构”“JSON”“后端”等内部词 3. replyText 必须是自然中文不能提“字段”“结构”“JSON”“后端”等内部词
4. replyText 一次最多推进一个最关键问题 4. replyText 一次最多推进一个最关键问题
5. 如果用户要求自动配置,就直接补齐可发布草稿需要的题材、反差规则、形状数量难度,不要继续提问 5. 如果用户要求自动配置,就直接补齐可发布草稿需要的题材、反差规则、形状数量难度、形状选项、洞口选项和背景提示,不要继续提问
6. 默认核心反差优先使用“方洞万能”或“方洞优先”,但可以根据用户题材包装成更有记忆点的规则 6. 默认核心反差优先使用“方洞万能”或“方洞优先”,但可以根据用户题材包装成更有记忆点的规则
7. progressPercent 范围只能是 0 到 100 7. progressPercent 范围只能是 0 到 100
8. shapeCount 只能是 6 到 24 的整数difficulty 只能是 1 到 10 的整数 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 输出,不要输出其他文字: const SQUARE_HOLE_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输出,不要输出其他文字:
@@ -34,7 +36,24 @@ const SQUARE_HOLE_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输
"themeText": "", "themeText": "",
"twistRule": "", "twistRule": "",
"shapeCount": 12, "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 的配置项,因此提示词直接围绕配置收束, /// 方洞 Agent 负责输出完整玩法配置;后端会继续归一化缺失选项,避免模型偶发漏项导致草稿失败。
/// 不在模型输出层引入额外锚点,避免和当前持久化 schema 产生漂移。
pub(crate) fn build_square_hole_agent_prompt( pub(crate) fn build_square_hole_agent_prompt(
session: &SquareHoleAgentSessionRecord, session: &SquareHoleAgentSessionRecord,
quick_fill_requested: bool, quick_fill_requested: bool,
@@ -53,7 +71,7 @@ pub(crate) fn build_square_hole_agent_prompt(
"\n\n{}", "\n\n{}",
render_quick_fill_extra_rules( render_quick_fill_extra_rules(
"当前方洞挑战方向里的题材、反差规则、形状数量和难度", "当前方洞挑战方向里的题材、反差规则、形状数量和难度",
"不要要求用户再提供洞口、形状、演出或难度信息", "不要要求用户再提供洞口、形状、背景或难度信息",
"输出完整 nextConfig直接补齐空缺或仍过于泛化的项", "输出完整 nextConfig直接补齐空缺或仍过于泛化的项",
"生成结果页", "生成结果页",
) )
@@ -62,7 +80,7 @@ pub(crate) fn build_square_hole_agent_prompt(
String::new() String::new()
}; };
format!( 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, quick_fill_rules = quick_fill_rules,
turn = session.current_turn.saturating_add(1), turn = session.current_turn.saturating_add(1),
progress = session.progress_percent, progress = session.progress_percent,
@@ -76,11 +94,42 @@ pub(crate) fn build_square_hole_agent_prompt(
} }
fn serialize_square_hole_session_config(session: &SquareHoleAgentSessionRecord) -> String { fn serialize_square_hole_session_config(session: &SquareHoleAgentSessionRecord) -> String {
let shape_options: Vec<JsonValue> = 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<JsonValue> = 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!({ serde_json::to_string_pretty(&json!({
"themeText": session.config.theme_text, "themeText": session.config.theme_text,
"twistRule": session.config.twist_rule, "twistRule": session.config.twist_rule,
"shapeCount": session.config.shape_count, "shapeCount": session.config.shape_count,
"difficulty": session.config.difficulty, "difficulty": session.config.difficulty,
"shapeOptions": shape_options,
"holeOptions": hole_options,
"backgroundPrompt": session.config.background_prompt,
})) }))
.unwrap_or_else(|_| "{}".to_string()) .unwrap_or_else(|_| "{}".to_string())
} }
@@ -129,6 +178,11 @@ mod tests {
twist_rule: "方洞万能".to_string(), twist_rule: "方洞万能".to_string(),
shape_count: 12, shape_count: 12,
difficulty: 4, difficulty: 4,
shape_options: Vec::new(),
hole_options: Vec::new(),
background_prompt: "积木纸箱桌面背景".to_string(),
cover_image_src: None,
background_image_src: None,
}, },
draft: None, draft: None,
messages: vec![message("user", "做成办公室文具版")], messages: vec![message("user", "做成办公室文具版")],
@@ -159,6 +213,9 @@ mod tests {
assert!(prompt.contains("用户刚刚主动要求你自动补充剩余关键字")); assert!(prompt.contains("用户刚刚主动要求你自动补充剩余关键字"));
assert!(prompt.contains("不要再继续提问")); assert!(prompt.contains("不要再继续提问"));
assert!(prompt.contains("nextConfig")); assert!(prompt.contains("nextConfig"));
assert!(prompt.contains("shapeOptions"));
assert!(prompt.contains("holeOptions"));
assert!(prompt.contains("backgroundPrompt"));
assert!(prompt.contains("progressPercent 直接输出为 100")); assert!(prompt.contains("progressPercent 直接输出为 100"));
} }
} }

View File

@@ -13,9 +13,11 @@ use axum::{
sse::{Event, Sse}, sse::{Event, Sse},
}, },
}; };
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use module_square_hole::{ use module_square_hole::{
SQUARE_HOLE_MESSAGE_ID_PREFIX, SQUARE_HOLE_PROFILE_ID_PREFIX, SQUARE_HOLE_RUN_ID_PREFIX, 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::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::{Value, json};
@@ -24,8 +26,9 @@ use shared_contracts::{
CreateSquareHoleSessionRequest, ExecuteSquareHoleActionRequest, CreateSquareHoleSessionRequest, ExecuteSquareHoleActionRequest,
SendSquareHoleMessageRequest, SquareHoleActionResponse, SquareHoleAgentMessageResponse, SendSquareHoleMessageRequest, SquareHoleActionResponse, SquareHoleAgentMessageResponse,
SquareHoleAnchorItemResponse, SquareHoleAnchorPackResponse, SquareHoleAnchorItemResponse, SquareHoleAnchorPackResponse,
SquareHoleCreatorConfigResponse, SquareHoleResultDraftResponse, SquareHoleSessionResponse, SquareHoleCreatorConfigResponse, SquareHoleHoleOptionResponse,
SquareHoleSessionSnapshotResponse, SquareHoleResultDraftResponse, SquareHoleSessionResponse,
SquareHoleSessionSnapshotResponse, SquareHoleShapeOptionResponse,
}, },
square_hole_runtime::{ square_hole_runtime::{
DropSquareHoleShapeRequest, SquareHoleDropFeedbackResponse, SquareHoleDropResponse, DropSquareHoleShapeRequest, SquareHoleDropFeedbackResponse, SquareHoleDropResponse,
@@ -33,7 +36,9 @@ use shared_contracts::{
SquareHoleShapeSnapshotResponse, StartSquareHoleRunRequest, StopSquareHoleRunRequest, SquareHoleShapeSnapshotResponse, StartSquareHoleRunRequest, StopSquareHoleRunRequest,
}, },
square_hole_works::{ square_hole_works::{
PutSquareHoleWorkRequest, SquareHoleWorkDetailResponse, SquareHoleWorkMutationResponse, PutSquareHoleWorkRequest, SquareHoleHoleOptionResponse as SquareHoleWorkHoleOptionResponse,
SquareHoleShapeOptionResponse as SquareHoleWorkShapeOptionResponse,
SquareHoleWorkDetailResponse, SquareHoleWorkMutationResponse,
SquareHoleWorkProfileResponse, SquareHoleWorkSummaryResponse, SquareHoleWorksResponse, SquareHoleWorkProfileResponse, SquareHoleWorkSummaryResponse, SquareHoleWorksResponse,
}, },
}; };
@@ -42,11 +47,11 @@ use spacetime_client::{
SpacetimeClientError, SquareHoleAgentMessageRecord, SquareHoleAgentMessageSubmitRecordInput, SpacetimeClientError, SquareHoleAgentMessageRecord, SquareHoleAgentMessageSubmitRecordInput,
SquareHoleAgentSessionCreateRecordInput, SquareHoleAgentSessionRecord, SquareHoleAgentSessionCreateRecordInput, SquareHoleAgentSessionRecord,
SquareHoleAnchorItemRecord, SquareHoleAnchorPackRecord, SquareHoleCompileDraftRecordInput, SquareHoleAnchorItemRecord, SquareHoleAnchorPackRecord, SquareHoleCompileDraftRecordInput,
SquareHoleCreatorConfigRecord, SquareHoleDropFeedbackRecord, SquareHoleHoleSnapshotRecord, SquareHoleCreatorConfigRecord, SquareHoleDropFeedbackRecord, SquareHoleHoleOptionRecord,
SquareHoleResultDraftRecord, SquareHoleRunDropRecordInput, SquareHoleRunRecord, SquareHoleHoleSnapshotRecord, SquareHoleResultDraftRecord, SquareHoleRunDropRecordInput,
SquareHoleRunRestartRecordInput, SquareHoleRunStartRecordInput, SquareHoleRunStopRecordInput, SquareHoleRunRecord, SquareHoleRunRestartRecordInput, SquareHoleRunStartRecordInput,
SquareHoleRunTimeUpRecordInput, SquareHoleShapeSnapshotRecord, SquareHoleWorkProfileRecord, SquareHoleRunStopRecordInput, SquareHoleRunTimeUpRecordInput, SquareHoleShapeOptionRecord,
SquareHoleWorkUpdateRecordInput, SquareHoleShapeSnapshotRecord, SquareHoleWorkProfileRecord, SquareHoleWorkUpdateRecordInput,
}; };
use crate::{ use crate::{
@@ -54,6 +59,10 @@ use crate::{
api_response::json_success_body, api_response::json_success_body,
auth::AuthenticatedAccessToken, auth::AuthenticatedAccessToken,
http_error::AppError, http_error::AppError,
openai_image_generation::{
build_openai_image_http_client, create_openai_image_generation,
require_openai_image_settings,
},
request_context::RequestContext, request_context::RequestContext,
square_hole_agent_turn::{ square_hole_agent_turn::{
SquareHoleAgentTurnRequest, build_finalize_record_input, run_square_hole_agent_turn, SquareHoleAgentTurnRequest, build_finalize_record_input, run_square_hole_agent_turn,
@@ -77,6 +86,37 @@ struct SquareHoleConfigJson {
twist_rule: String, twist_rule: String,
shape_count: u32, shape_count: u32,
difficulty: u32, difficulty: u32,
#[serde(default)]
shape_options: Vec<SquareHoleConfigShapeOptionJson>,
#[serde(default)]
hole_options: Vec<SquareHoleConfigHoleOptionJson>,
#[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)] #[derive(Clone, Debug, Deserialize)]
@@ -299,25 +339,37 @@ pub async fn execute_square_hole_agent_action(
"sessionId", "sessionId",
)?; )?;
if payload.action.trim() != "square_hole_compile_draft" { let session = match payload.action.trim() {
return Err(square_hole_bad_request( "square_hole_compile_draft" => {
&request_context, compile_square_hole_draft_for_session(
SQUARE_HOLE_AGENT_PROVIDER, &state,
"unknown square hole action", &request_context,
)); &authenticated,
} session_id,
payload.game_name,
let session = compile_square_hole_draft_for_session( payload.summary,
&state, payload.tags,
&request_context, payload.cover_image_src,
&authenticated, )
session_id, .await?
payload.game_name, }
payload.summary, "square_hole_generate_visual_assets" => {
payload.tags, generate_square_hole_visual_assets_for_session(
payload.cover_image_src, &state,
) &request_context,
.await?; &authenticated,
session_id,
)
.await?
}
_ => {
return Err(square_hole_bad_request(
&request_context,
SQUARE_HOLE_AGENT_PROVIDER,
"unknown square hole action",
));
}
};
Ok(json_success_body( Ok(json_success_body(
Some(&request_context), Some(&request_context),
@@ -491,6 +543,18 @@ pub async fn put_square_hole_work(
.clone() .clone()
.filter(|value| !value.trim().is_empty()) .filter(|value| !value.trim().is_empty())
.unwrap_or(existing.theme_text); .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 let item = state
.spacetime_client() .spacetime_client()
.update_square_hole_work(SquareHoleWorkUpdateRecordInput { .update_square_hole_work(SquareHoleWorkUpdateRecordInput {
@@ -502,6 +566,15 @@ pub async fn put_square_hole_work(
summary_text: payload.summary, summary_text: payload.summary,
tags_json: serde_json::to_string(&normalize_tags(payload.tags)).unwrap_or_default(), tags_json: serde_json::to_string(&normalize_tags(payload.tags)).unwrap_or_default(),
cover_image_src: payload.cover_image_src.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, shape_count: payload.shape_count,
difficulty: payload.difficulty, difficulty: payload.difficulty,
updated_at_micros: current_utc_micros(), 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<SquareHoleAgentSessionRecord, Response> {
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<String, AppError> {
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( fn map_square_hole_agent_session_response(
session: SquareHoleAgentSessionRecord, session: SquareHoleAgentSessionRecord,
) -> SquareHoleSessionSnapshotResponse { ) -> SquareHoleSessionSnapshotResponse {
@@ -1084,6 +1382,19 @@ fn map_square_hole_config_response(
twist_rule: config.twist_rule, twist_rule: config.twist_rule,
shape_count: config.shape_count, shape_count: config.shape_count,
difficulty: config.difficulty, 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, twist_rule: draft.twist_rule,
summary: draft.summary, summary: draft.summary,
tags: draft.tags, 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, shape_count: draft.shape_count,
difficulty: draft.difficulty, difficulty: draft.difficulty,
publish_ready: draft.publish_ready, publish_ready: draft.publish_ready,
@@ -1130,6 +1454,18 @@ fn map_square_hole_work_summary_response(
summary: item.summary, summary: item.summary,
tags: item.tags, tags: item.tags,
cover_image_src: item.cover_image_src, 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, shape_count: item.shape_count,
difficulty: item.difficulty, difficulty: item.difficulty,
publication_status: item.publication_status, publication_status: item.publication_status,
@@ -1164,6 +1500,7 @@ fn map_square_hole_run_response(run: SquareHoleRunRecord) -> SquareHoleRunSnapsh
best_combo: run.best_combo, best_combo: run.best_combo,
score: run.score, score: run.score,
rule_label: run.rule_label, rule_label: run.rule_label,
background_image_src: run.background_image_src,
current_shape: run.current_shape.map(map_square_hole_shape_response), current_shape: run.current_shape.map(map_square_hole_shape_response),
holes: run holes: run
.holes .holes
@@ -1182,6 +1519,7 @@ fn map_square_hole_shape_response(
shape_kind: item.shape_kind, shape_kind: item.shape_kind,
label: item.label, label: item.label,
color: item.color, color: item.color,
image_src: item.image_src,
} }
} }
@@ -1194,6 +1532,53 @@ fn map_square_hole_hole_response(
label: slot.label, label: slot.label,
x: slot.x, x: slot.x,
y: slot.y, 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 .difficulty
.unwrap_or(SQUARE_HOLE_DEFAULT_DIFFICULTY) .unwrap_or(SQUARE_HOLE_DEFAULT_DIFFICULTY)
.clamp(1, 10), .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(), twist_rule: config.twist_rule.clone(),
shape_count: config.shape_count.max(1), shape_count: config.shape_count.max(1),
difficulty: config.difficulty.clamp(1, 10), 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 { .unwrap_or_else(|| SquareHoleConfigJson {
theme_text: SQUARE_HOLE_DEFAULT_THEME.to_string(), theme_text: SQUARE_HOLE_DEFAULT_THEME.to_string(),
twist_rule: SQUARE_HOLE_DEFAULT_TWIST_RULE.to_string(), twist_rule: SQUARE_HOLE_DEFAULT_TWIST_RULE.to_string(),
shape_count: SQUARE_HOLE_DEFAULT_SHAPE_COUNT, shape_count: SQUARE_HOLE_DEFAULT_SHAPE_COUNT,
difficulty: SQUARE_HOLE_DEFAULT_DIFFICULTY, 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<String>
serde_json::to_string(config).ok() serde_json::to_string(config).ok()
} }
fn deserialize_optional_string_as_default<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(Option::<String>::deserialize(deserializer)?.unwrap_or_default())
}
fn build_seed_text( fn build_seed_text(
payload: &CreateSquareHoleSessionRequest, payload: &CreateSquareHoleSessionRequest,
config: &SquareHoleConfigJson, config: &SquareHoleConfigJson,
@@ -1291,6 +1716,118 @@ fn normalize_tags(tags: Vec<String>) -> Vec<String> {
result result
} }
fn square_hole_shape_records_to_config_json(
options: Vec<impl Into<SquareHoleConfigShapeOptionJson>>,
) -> Vec<SquareHoleConfigShapeOptionJson> {
options.into_iter().map(Into::into).collect()
}
fn square_hole_hole_records_to_config_json(
options: Vec<impl Into<SquareHoleConfigHoleOptionJson>>,
) -> Vec<SquareHoleConfigHoleOptionJson> {
options.into_iter().map(Into::into).collect()
}
fn square_hole_work_shape_options_to_records(
options: Vec<SquareHoleWorkShapeOptionResponse>,
) -> Vec<SquareHoleShapeOptionRecord> {
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<SquareHoleWorkHoleOptionResponse>,
) -> Vec<SquareHoleHoleOptionRecord> {
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<SquareHoleConfigShapeOptionJson> =
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<SquareHoleConfigHoleOptionJson> =
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::<Vec<_>>()
.join(" ");
if cleaned.is_empty() {
fallback.to_string()
} else {
cleaned.chars().take(180).collect()
}
}
impl From<module_square_hole::SquareHoleShapeOption> 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<SquareHoleShapeOptionRecord> 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<module_square_hole::SquareHoleHoleOption> 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<SquareHoleHoleOptionRecord> 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( fn resolve_author_display_name(
state: &AppState, state: &AppState,
authenticated: &AuthenticatedAccessToken, authenticated: &AuthenticatedAccessToken,

View File

@@ -1,6 +1,7 @@
use module_square_hole::{ use module_square_hole::{
SQUARE_HOLE_MAX_DIFFICULTY, SQUARE_HOLE_MAX_SHAPE_COUNT, SQUARE_HOLE_MIN_DIFFICULTY, 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 platform_llm::LlmClient;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -68,6 +69,34 @@ struct SquareHoleAgentConfigOutput {
twist_rule: String, twist_rule: String,
shape_count: u32, shape_count: u32,
difficulty: u32, difficulty: u32,
shape_options: Vec<SquareHoleAgentShapeOptionOutput>,
hole_options: Vec<SquareHoleAgentHoleOptionOutput>,
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<F>( pub(crate) async fn run_square_hole_agent_turn<F>(
@@ -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 { Ok(SquareHoleAgentConfigOutput {
theme_text: read_text_field(value, "themeText") theme_text,
.unwrap_or_else(|| session.config.theme_text.clone()), twist_rule,
twist_rule: read_text_field(value, "twistRule")
.unwrap_or_else(|| session.config.twist_rule.clone()),
shape_count: read_u32_field(value, "shapeCount") shape_count: read_u32_field(value, "shapeCount")
.unwrap_or(session.config.shape_count) .unwrap_or(session.config.shape_count)
.clamp(SQUARE_HOLE_MIN_SHAPE_COUNT, SQUARE_HOLE_MAX_SHAPE_COUNT), .clamp(SQUARE_HOLE_MIN_SHAPE_COUNT, SQUARE_HOLE_MAX_SHAPE_COUNT),
difficulty: read_u32_field(value, "difficulty") difficulty: read_u32_field(value, "difficulty")
.unwrap_or(session.config.difficulty) .unwrap_or(session.config.difficulty)
.clamp(SQUARE_HOLE_MIN_DIFFICULTY, SQUARE_HOLE_MAX_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<SquareHoleShapeOption> {
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::<Vec<_>>()
})
.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<SquareHoleHoleOption> {
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::<Vec<_>>()
})
.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<String> { fn read_text_field(value: &JsonValue, field_name: &str) -> Option<String> {
value value
.get(field_name) .get(field_name)
@@ -196,6 +341,62 @@ fn read_u32_field(value: &JsonValue, field_name: &str) -> Option<u32> {
.and_then(|number| u32::try_from(number).ok()) .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<SquareHoleShapeOption> 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<SquareHoleHoleOption> 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 { fn resolve_stage(progress_percent: u32) -> String {
if progress_percent >= 100 { if progress_percent >= 100 {
"ReadyToCompile" "ReadyToCompile"
@@ -228,6 +429,11 @@ mod tests {
twist_rule: "方洞万能".to_string(), twist_rule: "方洞万能".to_string(),
shape_count: 12, shape_count: 12,
difficulty: 4, difficulty: 4,
shape_options: Vec::new(),
hole_options: Vec::new(),
background_prompt: "纸箱玩具桌面背景".to_string(),
cover_image_src: None,
background_image_src: None,
}, },
draft: None, draft: None,
messages: Vec::new(), messages: Vec::new(),
@@ -260,7 +466,24 @@ mod tests {
"themeText": "办公室文具", "themeText": "办公室文具",
"twistRule": "所有文具最终都优先进入方洞", "twistRule": "所有文具最终都优先进入方洞",
"shapeCount": 14, "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.twist_rule, "所有文具最终都优先进入方洞");
assert_eq!(output.next_config.shape_count, 14); assert_eq!(output.next_config.shape_count, 14);
assert_eq!(output.next_config.difficulty, 6); 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] #[test]
@@ -287,7 +518,10 @@ mod tests {
"themeText": "霓虹积木", "themeText": "霓虹积木",
"twistRule": "方洞优先", "twistRule": "方洞优先",
"shapeCount": 99, "shapeCount": 99,
"difficulty": 0 "difficulty": 0,
"shapeOptions": [],
"holeOptions": [],
"backgroundPrompt": ""
} }
}); });

View File

@@ -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::commands::{default_tags_for_theme, validate_publish_requirements};
use crate::{ use crate::{
SQUARE_HOLE_DEFAULT_DURATION_LIMIT_MS, SQUARE_HOLE_MAX_DIFFICULTY, SQUARE_HOLE_MIN_DIFFICULTY, SQUARE_HOLE_DEFAULT_DURATION_LIMIT_MS, SQUARE_HOLE_MAX_DIFFICULTY,
SquareHoleCreatorConfig, SquareHoleDropConfirmation, SquareHoleDropFeedback, SQUARE_HOLE_MAX_HOLE_OPTION_COUNT, SQUARE_HOLE_MIN_DIFFICULTY,
SquareHoleDropInput, SquareHoleDropRejectReason, SquareHoleError, SquareHoleHoleSnapshot, SQUARE_HOLE_MIN_HOLE_OPTION_COUNT, SQUARE_HOLE_MIN_SHAPE_OPTION_COUNT, SquareHoleCreatorConfig,
SquareHoleDropConfirmation, SquareHoleDropFeedback, SquareHoleDropInput,
SquareHoleDropRejectReason, SquareHoleError, SquareHoleHoleOption, SquareHoleHoleSnapshot,
SquareHolePublicationStatus, SquareHoleResultDraft, SquareHoleRunSnapshot, SquareHoleRunStatus, SquareHolePublicationStatus, SquareHoleResultDraft, SquareHoleRunSnapshot, SquareHoleRunStatus,
SquareHoleShapeSnapshot, SquareHoleWorkProfile, SquareHoleShapeOption, SquareHoleShapeSnapshot, SquareHoleWorkProfile,
}; };
pub fn compile_result_draft( pub fn compile_result_draft(
@@ -14,6 +16,10 @@ pub fn compile_result_draft(
config: &SquareHoleCreatorConfig, config: &SquareHoleCreatorConfig,
) -> SquareHoleResultDraft { ) -> SquareHoleResultDraft {
let game_name = format!("{}方洞挑战", config.theme_text); 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!( let summary = format!(
"{}主题,{} 个形状,难度 {},真实规则:{}", "{}主题,{} 个形状,难度 {},真实规则:{}",
config.theme_text, config.shape_count, config.difficulty, config.twist_rule 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(), twist_rule: config.twist_rule.clone(),
summary, summary,
tags: default_tags_for_theme(&config.theme_text), 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, shape_count: config.shape_count,
difficulty: config.difficulty, difficulty: config.difficulty,
publish_ready: false, publish_ready: false,
@@ -59,7 +70,11 @@ pub fn create_work_profile(
twist_rule: draft.twist_rule.clone(), twist_rule: draft.twist_rule.clone(),
summary: draft.summary.clone(), summary: draft.summary.clone(),
tags: normalize_string_list(draft.tags.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, shape_count: draft.shape_count,
difficulty: draft.difficulty, difficulty: draft.difficulty,
publication_status: SquareHolePublicationStatus::Draft, publication_status: SquareHolePublicationStatus::Draft,
@@ -99,6 +114,7 @@ pub fn start_run_at(
normalize_required_string(owner_user_id).ok_or(SquareHoleError::MissingOwnerUserId)?; normalize_required_string(owner_user_id).ok_or(SquareHoleError::MissingOwnerUserId)?;
let profile_id = let profile_id =
normalize_required_string(profile_id).ok_or(SquareHoleError::MissingProfileId)?; normalize_required_string(profile_id).ok_or(SquareHoleError::MissingProfileId)?;
let shape_options = normalize_shape_options(config.shape_options.clone(), &config.theme_text);
Ok(SquareHoleRunSnapshot { Ok(SquareHoleRunSnapshot {
run_id, run_id,
@@ -115,8 +131,14 @@ pub fn start_run_at(
best_combo: 0, best_combo: 0,
score: 0, score: 0,
rule_label: config.twist_rule.clone(), rule_label: config.twist_rule.clone(),
current_shape: Some(build_shape_at(0, config.shape_count)), background_image_src: config.background_image_src.clone(),
holes: default_holes(), 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, last_feedback: None,
}) })
} }
@@ -160,14 +182,18 @@ pub fn confirm_drop_at(
next.completed_shape_count = next.completed_shape_count.saturating_add(1); next.completed_shape_count = next.completed_shape_count.saturating_add(1);
next.combo = next.combo.saturating_add(1); next.combo = next.combo.saturating_add(1);
next.best_combo = next.best_combo.max(next.combo); 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.current_shape = if next.completed_shape_count >= next.total_shape_count {
next.status = SquareHoleRunStatus::Won; next.status = SquareHoleRunStatus::Won;
None None
} else { } else {
Some(build_shape_at( Some(build_shape_from_previous_options(
next.completed_shape_count, next.completed_shape_count,
next.total_shape_count, next.total_shape_count,
next.shape_options.as_slice(),
)) ))
}; };
next.snapshot_version = next.snapshot_version.saturating_add(1); next.snapshot_version = next.snapshot_version.saturating_add(1);
@@ -216,7 +242,23 @@ pub fn stop_run_at(run: &SquareHoleRunSnapshot) -> SquareHoleRunSnapshot {
next 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 { let kind = if index + 1 == total {
"square" "square"
} else if index % 4 == 0 { } else if index % 4 == 0 {
@@ -248,6 +290,7 @@ pub fn build_shape_at(index: u32, total: u32) -> SquareHoleShapeSnapshot {
_ => "#c084fc", _ => "#c084fc",
} }
.to_string(), .to_string(),
image_src: None,
} }
} }
@@ -259,6 +302,7 @@ pub fn default_holes() -> Vec<SquareHoleHoleSnapshot> {
label: "方洞".to_string(), label: "方洞".to_string(),
x: 0.5, x: 0.5,
y: 0.28, y: 0.28,
bonus: true,
}, },
SquareHoleHoleSnapshot { SquareHoleHoleSnapshot {
hole_id: "circle-hole".to_string(), hole_id: "circle-hole".to_string(),
@@ -266,6 +310,7 @@ pub fn default_holes() -> Vec<SquareHoleHoleSnapshot> {
label: "圆洞".to_string(), label: "圆洞".to_string(),
x: 0.24, x: 0.24,
y: 0.54, y: 0.54,
bonus: false,
}, },
SquareHoleHoleSnapshot { SquareHoleHoleSnapshot {
hole_id: "triangle-hole".to_string(), hole_id: "triangle-hole".to_string(),
@@ -273,10 +318,240 @@ pub fn default_holes() -> Vec<SquareHoleHoleSnapshot> {
label: "三角洞".to_string(), label: "三角洞".to_string(),
x: 0.76, x: 0.76,
y: 0.54, y: 0.54,
bonus: false,
}, },
] ]
} }
pub fn default_shape_options(theme_text: &str) -> Vec<SquareHoleShapeOption> {
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<SquareHoleHoleOption> {
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<SquareHoleShapeOption>,
theme_text: &str,
) -> Vec<SquareHoleShapeOption> {
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<SquareHoleHoleOption>) -> Vec<SquareHoleHoleOption> {
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<SquareHoleHoleSnapshot> {
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<SquareHoleShapeOption> {
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( fn is_shape_accepted_by_hole(
shape: &SquareHoleShapeSnapshot, shape: &SquareHoleShapeSnapshot,
hole: &SquareHoleHoleSnapshot, hole: &SquareHoleHoleSnapshot,
@@ -315,6 +590,32 @@ mod tests {
build_creator_config("玩具", "方洞万能", shape_count, 4).expect("config should be valid") 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] #[test]
fn draft_is_publishable_with_required_fields() { fn draft_is_publishable_with_required_fields() {
let draft = compile_result_draft("profile-1".to_string(), &test_config(8)); let draft = compile_result_draft("profile-1".to_string(), &test_config(8));
@@ -368,6 +669,33 @@ mod tests {
assert_eq!(result.run.combo, 1); 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] #[test]
fn wrong_non_square_hole_rejects_and_resets_combo() { fn wrong_non_square_hole_rejects_and_resets_combo() {
let mut run = start_run_at( let mut run = start_run_at(
@@ -378,7 +706,7 @@ mod tests {
1_000, 1_000,
) )
.expect("run should start"); .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; run.combo = 2;
let result = confirm_drop_at( let result = confirm_drop_at(
@@ -413,7 +741,7 @@ mod tests {
) )
.expect("run should start"); .expect("run should start");
run.completed_shape_count = 5; 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( let result = confirm_drop_at(
&run, &run,

View File

@@ -3,6 +3,7 @@ use shared_kernel::{normalize_required_string, normalize_string_list};
use crate::{ use crate::{
SQUARE_HOLE_MAX_DIFFICULTY, SQUARE_HOLE_MAX_SHAPE_COUNT, SQUARE_HOLE_MIN_DIFFICULTY, SQUARE_HOLE_MAX_DIFFICULTY, SQUARE_HOLE_MAX_SHAPE_COUNT, SQUARE_HOLE_MIN_DIFFICULTY,
SQUARE_HOLE_MIN_SHAPE_COUNT, SquareHoleCreatorConfig, SquareHoleError, SquareHoleResultDraft, SQUARE_HOLE_MIN_SHAPE_COUNT, SquareHoleCreatorConfig, SquareHoleError, SquareHoleResultDraft,
normalize_hole_options, normalize_shape_options,
}; };
pub fn validate_shape_count(value: u32) -> Result<u32, SquareHoleError> { pub fn validate_shape_count(value: u32) -> Result<u32, SquareHoleError> {
@@ -36,6 +37,11 @@ pub fn build_creator_config(
twist_rule: normalize_required_string(twist_rule).ok_or(SquareHoleError::MissingText)?, twist_rule: normalize_required_string(twist_rule).ok_or(SquareHoleError::MissingText)?,
shape_count: validate_shape_count(shape_count)?, shape_count: validate_shape_count(shape_count)?,
difficulty: validate_difficulty(difficulty)?, 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<Strin
if validate_difficulty(draft.difficulty).is_err() { if validate_difficulty(draft.difficulty).is_err() {
blockers.push("难度必须在 1 到 10 之间".to_string()); blockers.push("难度必须在 1 到 10 之间".to_string());
} }
if draft.shape_options.len() < crate::SQUARE_HOLE_MIN_SHAPE_OPTION_COUNT {
blockers.push("至少需要 6 个形状图片选项".to_string());
}
if draft.hole_options.len() < crate::SQUARE_HOLE_MIN_HOLE_OPTION_COUNT {
blockers.push("至少需要 3 个洞口选项".to_string());
}
blockers blockers
} }
@@ -104,10 +116,15 @@ pub fn build_result_draft(
SquareHoleResultDraft { SquareHoleResultDraft {
profile_id, profile_id,
game_name, game_name,
theme_text, theme_text: theme_text.clone(),
twist_rule, twist_rule,
summary, summary,
tags: build_default_tags("方洞挑战"), tags: build_default_tags("方洞挑战"),
cover_image_src: None,
background_prompt: format!("{theme_text}主题的竖屏游戏背景,舞台中央有多个形状洞口"),
background_image_src: None,
shape_options: normalize_shape_options(Vec::new(), &theme_text),
hole_options: normalize_hole_options(Vec::new()),
shape_count, shape_count,
difficulty, difficulty,
publish_ready: true, publish_ready: true,

View File

@@ -12,6 +12,9 @@ pub const SQUARE_HOLE_MAX_SHAPE_COUNT: u32 = 24;
pub const SQUARE_HOLE_MIN_DIFFICULTY: u32 = 1; pub const SQUARE_HOLE_MIN_DIFFICULTY: u32 = 1;
pub const SQUARE_HOLE_MAX_DIFFICULTY: u32 = 10; pub const SQUARE_HOLE_MAX_DIFFICULTY: u32 = 10;
pub const SQUARE_HOLE_DEFAULT_DURATION_LIMIT_MS: u64 = 60_000; pub const SQUARE_HOLE_DEFAULT_DURATION_LIMIT_MS: u64 = 60_000;
pub const SQUARE_HOLE_MIN_SHAPE_OPTION_COUNT: usize = 6;
pub const SQUARE_HOLE_MIN_HOLE_OPTION_COUNT: usize = 3;
pub const SQUARE_HOLE_MAX_HOLE_OPTION_COUNT: usize = 6;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -55,6 +58,37 @@ pub struct SquareHoleCreatorConfig {
pub twist_rule: String, pub twist_rule: String,
pub shape_count: u32, pub shape_count: u32,
pub difficulty: u32, pub difficulty: u32,
#[serde(default)]
pub shape_options: Vec<SquareHoleShapeOption>,
#[serde(default)]
pub hole_options: Vec<SquareHoleHoleOption>,
#[serde(default)]
pub background_prompt: String,
#[serde(default)]
pub cover_image_src: Option<String>,
#[serde(default)]
pub background_image_src: Option<String>,
}
#[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<String>,
}
#[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))] #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -66,6 +100,16 @@ pub struct SquareHoleResultDraft {
pub twist_rule: String, pub twist_rule: String,
pub summary: String, pub summary: String,
pub tags: Vec<String>, pub tags: Vec<String>,
#[serde(default)]
pub cover_image_src: Option<String>,
#[serde(default)]
pub background_prompt: String,
#[serde(default)]
pub background_image_src: Option<String>,
#[serde(default)]
pub shape_options: Vec<SquareHoleShapeOption>,
#[serde(default)]
pub hole_options: Vec<SquareHoleHoleOption>,
pub shape_count: u32, pub shape_count: u32,
pub difficulty: u32, pub difficulty: u32,
pub publish_ready: bool, pub publish_ready: bool,
@@ -85,6 +129,10 @@ pub struct SquareHoleWorkProfile {
pub summary: String, pub summary: String,
pub tags: Vec<String>, pub tags: Vec<String>,
pub cover_image_src: Option<String>, pub cover_image_src: Option<String>,
pub background_prompt: String,
pub background_image_src: Option<String>,
pub shape_options: Vec<SquareHoleShapeOption>,
pub hole_options: Vec<SquareHoleHoleOption>,
pub shape_count: u32, pub shape_count: u32,
pub difficulty: u32, pub difficulty: u32,
pub publication_status: SquareHolePublicationStatus, pub publication_status: SquareHolePublicationStatus,
@@ -100,6 +148,8 @@ pub struct SquareHoleShapeSnapshot {
pub shape_kind: String, pub shape_kind: String,
pub label: String, pub label: String,
pub color: String, pub color: String,
#[serde(default)]
pub image_src: Option<String>,
} }
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -110,6 +160,8 @@ pub struct SquareHoleHoleSnapshot {
pub label: String, pub label: String,
pub x: f32, pub x: f32,
pub y: f32, pub y: f32,
#[serde(default)]
pub bonus: bool,
} }
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -137,6 +189,10 @@ pub struct SquareHoleRunSnapshot {
pub best_combo: u32, pub best_combo: u32,
pub score: u32, pub score: u32,
pub rule_label: String, pub rule_label: String,
#[serde(default)]
pub background_image_src: Option<String>,
#[serde(default)]
pub shape_options: Vec<SquareHoleShapeOption>,
pub current_shape: Option<SquareHoleShapeSnapshot>, pub current_shape: Option<SquareHoleShapeSnapshot>,
pub holes: Vec<SquareHoleHoleSnapshot>, pub holes: Vec<SquareHoleHoleSnapshot>,
pub last_feedback: Option<SquareHoleDropFeedback>, pub last_feedback: Option<SquareHoleDropFeedback>,

View File

@@ -68,6 +68,7 @@ pub struct LlmTextRequest {
pub max_tokens: Option<u32>, pub max_tokens: Option<u32>,
pub enable_web_search: bool, pub enable_web_search: bool,
pub protocol: LlmTextProtocol, pub protocol: LlmTextProtocol,
pub request_timeout_ms: Option<u64>,
} }
// 文本协议必须由业务请求显式选择,避免全局默认模型把不同场景混到同一上游形态。 // 文本协议必须由业务请求显式选择,避免全局默认模型把不同场景混到同一上游形态。
@@ -421,6 +422,7 @@ impl LlmTextRequest {
max_tokens: None, max_tokens: None,
enable_web_search: false, enable_web_search: false,
protocol: LlmTextProtocol::ChatCompletions, protocol: LlmTextProtocol::ChatCompletions,
request_timeout_ms: None,
} }
} }
@@ -451,6 +453,11 @@ impl LlmTextRequest {
self 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> { fn validate(&self) -> Result<(), LlmError> {
if self.messages.is_empty() { if self.messages.is_empty() {
return Err(LlmError::InvalidRequest( 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(()) Ok(())
} }
@@ -484,6 +499,12 @@ impl LlmTextRequest {
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.unwrap_or(fallback_model) .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 { impl LlmTextProtocol {
@@ -825,7 +846,9 @@ impl LlmClient {
.post(url.as_str()) .post(url.as_str())
.bearer_auth(self.config.api_key()) .bearer_auth(self.config.api_key())
.json(&request_body) .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() .send()
.await; .await;
@@ -1592,6 +1615,48 @@ mod tests {
assert_eq!(response.response_id.as_deref(), Some("resp_retry")); 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] #[tokio::test]
async fn request_text_sends_web_search_options_when_enabled() { async fn request_text_sends_web_search_options_when_enabled() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");

View File

@@ -38,6 +38,26 @@ pub struct ExecuteSquareHoleActionRequest {
pub cover_image_src: Option<String>, pub cover_image_src: Option<String>,
} }
#[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<String>,
}
#[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)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SquareHoleAnchorItemResponse { pub struct SquareHoleAnchorItemResponse {
@@ -63,6 +83,16 @@ pub struct SquareHoleCreatorConfigResponse {
pub twist_rule: String, pub twist_rule: String,
pub shape_count: u32, pub shape_count: u32,
pub difficulty: u32, pub difficulty: u32,
#[serde(default)]
pub shape_options: Vec<SquareHoleShapeOptionResponse>,
#[serde(default)]
pub hole_options: Vec<SquareHoleHoleOptionResponse>,
#[serde(default)]
pub background_prompt: String,
#[serde(default)]
pub cover_image_src: Option<String>,
#[serde(default)]
pub background_image_src: Option<String>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -74,6 +104,16 @@ pub struct SquareHoleResultDraftResponse {
pub twist_rule: String, pub twist_rule: String,
pub summary: String, pub summary: String,
pub tags: Vec<String>, pub tags: Vec<String>,
#[serde(default)]
pub cover_image_src: Option<String>,
#[serde(default)]
pub background_prompt: String,
#[serde(default)]
pub background_image_src: Option<String>,
#[serde(default)]
pub shape_options: Vec<SquareHoleShapeOptionResponse>,
#[serde(default)]
pub hole_options: Vec<SquareHoleHoleOptionResponse>,
pub shape_count: u32, pub shape_count: u32,
pub difficulty: u32, pub difficulty: u32,
pub publish_ready: bool, pub publish_ready: bool,

View File

@@ -30,6 +30,8 @@ pub struct SquareHoleShapeSnapshotResponse {
pub shape_kind: String, pub shape_kind: String,
pub label: String, pub label: String,
pub color: String, pub color: String,
#[serde(default)]
pub image_src: Option<String>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -40,6 +42,7 @@ pub struct SquareHoleHoleSnapshotResponse {
pub label: String, pub label: String,
pub x: f32, pub x: f32,
pub y: f32, pub y: f32,
pub bonus: bool,
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -69,6 +72,8 @@ pub struct SquareHoleRunSnapshotResponse {
pub score: u32, pub score: u32,
pub rule_label: String, pub rule_label: String,
#[serde(default)] #[serde(default)]
pub background_image_src: Option<String>,
#[serde(default)]
pub current_shape: Option<SquareHoleShapeSnapshotResponse>, pub current_shape: Option<SquareHoleShapeSnapshotResponse>,
pub holes: Vec<SquareHoleHoleSnapshotResponse>, pub holes: Vec<SquareHoleHoleSnapshotResponse>,
#[serde(default)] #[serde(default)]

View File

@@ -1,5 +1,25 @@
use serde::{Deserialize, Serialize}; 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<String>,
}
#[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)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct PutSquareHoleWorkRequest { pub struct PutSquareHoleWorkRequest {
@@ -11,6 +31,14 @@ pub struct PutSquareHoleWorkRequest {
pub tags: Vec<String>, pub tags: Vec<String>,
#[serde(default)] #[serde(default)]
pub cover_image_src: Option<String>, pub cover_image_src: Option<String>,
#[serde(default)]
pub background_prompt: Option<String>,
#[serde(default)]
pub background_image_src: Option<String>,
#[serde(default)]
pub shape_options: Option<Vec<SquareHoleShapeOptionResponse>>,
#[serde(default)]
pub hole_options: Option<Vec<SquareHoleHoleOptionResponse>>,
pub shape_count: u32, pub shape_count: u32,
pub difficulty: u32, pub difficulty: u32,
} }
@@ -30,6 +58,14 @@ pub struct SquareHoleWorkSummaryResponse {
pub tags: Vec<String>, pub tags: Vec<String>,
#[serde(default)] #[serde(default)]
pub cover_image_src: Option<String>, pub cover_image_src: Option<String>,
#[serde(default)]
pub background_prompt: String,
#[serde(default)]
pub background_image_src: Option<String>,
#[serde(default)]
pub shape_options: Vec<SquareHoleShapeOptionResponse>,
#[serde(default)]
pub hole_options: Vec<SquareHoleHoleOptionResponse>,
pub shape_count: u32, pub shape_count: u32,
pub difficulty: u32, pub difficulty: u32,
pub publication_status: String, pub publication_status: String,

View File

@@ -55,11 +55,11 @@ pub use mapper::{
SquareHoleAgentMessageSubmitRecordInput, SquareHoleAgentSessionCreateRecordInput, SquareHoleAgentMessageSubmitRecordInput, SquareHoleAgentSessionCreateRecordInput,
SquareHoleAgentSessionRecord, SquareHoleAnchorItemRecord, SquareHoleAnchorPackRecord, SquareHoleAgentSessionRecord, SquareHoleAnchorItemRecord, SquareHoleAnchorPackRecord,
SquareHoleCompileDraftRecordInput, SquareHoleCreatorConfigRecord, SquareHoleCompileDraftRecordInput, SquareHoleCreatorConfigRecord,
SquareHoleDropConfirmationRecord, SquareHoleDropFeedbackRecord, SquareHoleHoleSnapshotRecord, SquareHoleDropConfirmationRecord, SquareHoleDropFeedbackRecord, SquareHoleHoleOptionRecord,
SquareHoleResultDraftRecord, SquareHoleRunDropRecordInput, SquareHoleRunRecord, SquareHoleHoleSnapshotRecord, SquareHoleResultDraftRecord, SquareHoleRunDropRecordInput,
SquareHoleRunRestartRecordInput, SquareHoleRunStartRecordInput, SquareHoleRunStopRecordInput, SquareHoleRunRecord, SquareHoleRunRestartRecordInput, SquareHoleRunStartRecordInput,
SquareHoleRunTimeUpRecordInput, SquareHoleShapeSnapshotRecord, SquareHoleWorkProfileRecord, SquareHoleRunStopRecordInput, SquareHoleRunTimeUpRecordInput, SquareHoleShapeOptionRecord,
SquareHoleWorkUpdateRecordInput, SquareHoleShapeSnapshotRecord, SquareHoleWorkProfileRecord, SquareHoleWorkUpdateRecordInput,
}; };
pub mod ai; pub mod ai;
@@ -144,9 +144,9 @@ use module_puzzle::{
PuzzleWorkProfile as DomainPuzzleWorkProfile, PuzzleWorkProfile as DomainPuzzleWorkProfile,
}; };
use module_runtime::{ use module_runtime::{
RuntimeBrowseHistoryRecord, RuntimePlatformTheme as DomainRuntimePlatformTheme, AnalyticsMetricQueryResponse as DomainAnalyticsMetricQueryResponse, RuntimeBrowseHistoryRecord,
RuntimeProfileDashboardRecord, RuntimeProfileInviteCodeRecord, RuntimeProfilePlayStatsRecord, RuntimePlatformTheme as DomainRuntimePlatformTheme, RuntimeProfileDashboardRecord,
AnalyticsMetricQueryResponse as DomainAnalyticsMetricQueryResponse, RuntimeProfileInviteCodeRecord, RuntimeProfilePlayStatsRecord,
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode,
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
@@ -155,7 +155,7 @@ use module_runtime::{
RuntimeProfileTaskStatus as DomainRuntimeProfileTaskStatus, RuntimeProfileTaskStatus as DomainRuntimeProfileTaskStatus,
RuntimeProfileWalletLedgerEntryRecord, RuntimeReferralInviteCenterRecord, RuntimeProfileWalletLedgerEntryRecord, RuntimeReferralInviteCenterRecord,
RuntimeReferralRedeemRecord, RuntimeSettingsRecord, RuntimeSnapshotRecord, 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_clear_input, build_runtime_browse_history_list_input,
build_runtime_browse_history_record, build_runtime_browse_history_sync_input, build_runtime_browse_history_record, build_runtime_browse_history_sync_input,
build_runtime_profile_dashboard_get_input, build_runtime_profile_dashboard_record, 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_adjustment_input,
build_runtime_profile_wallet_ledger_entry_record, build_runtime_profile_wallet_ledger_entry_record,
build_runtime_profile_wallet_ledger_list_input, build_runtime_referral_invite_center_get_input, 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_invite_center_record, build_runtime_referral_redeem_input,
build_runtime_referral_redeem_record, build_runtime_setting_get_input, build_runtime_referral_redeem_record, build_runtime_setting_get_input,
build_runtime_setting_record, build_runtime_setting_upsert_input, build_runtime_setting_record, build_runtime_setting_upsert_input,

View File

@@ -1585,14 +1585,9 @@ pub(crate) fn map_square_hole_agent_session_procedure_result(
let session_json = result let session_json = result
.session_json .session_json
.ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole agent session 快照"))?; .ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole agent session 快照"))?;
let session = let session = serde_json::from_str::<SquareHoleAgentSessionJsonRecord>(&session_json).map_err(
serde_json::from_str::<SquareHoleAgentSessionJsonRecord>(&session_json).map_err( |error| SpacetimeClientError::Runtime(format!("square hole session_json 非法: {error}")),
|error| { )?;
SpacetimeClientError::Runtime(format!(
"square hole session_json 非法: {error}"
))
},
)?;
Ok(map_square_hole_agent_session_snapshot(session)) 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 let items_json = result
.items_json .items_json
.ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole works 快照"))?; .ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole works 快照"))?;
let items = serde_json::from_str::<Vec<SquareHoleWorkJsonRecord>>(&items_json).map_err( let items =
|error| { serde_json::from_str::<Vec<SquareHoleWorkJsonRecord>>(&items_json).map_err(|error| {
SpacetimeClientError::Runtime(format!("square hole works items_json 非法: {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( 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 let run_json = result
.run_json .run_json
.ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole drop run 快照"))?; .ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole drop run 快照"))?;
let feedback_json = result.feedback_json.ok_or_else(|| { let feedback_json = result
SpacetimeClientError::missing_snapshot("square hole drop feedback 快照") .feedback_json
})?; .ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole drop feedback 快照"))?;
let run = map_square_hole_run_json(run_json)?; let run = map_square_hole_run_json(run_json)?;
let feedback = let feedback = serde_json::from_str::<SquareHoleDropFeedbackJsonRecord>(&feedback_json)
serde_json::from_str::<SquareHoleDropFeedbackJsonRecord>(&feedback_json).map_err( .map_err(|error| {
|error| { SpacetimeClientError::Runtime(format!("square hole feedback_json 非法: {error}"))
SpacetimeClientError::Runtime(format!( })?;
"square hole feedback_json 非法: {error}"
))
},
)?;
Ok(SquareHoleDropConfirmationRecord { Ok(SquareHoleDropConfirmationRecord {
status: result.status, status: result.status,
@@ -2950,6 +2943,19 @@ fn map_square_hole_creator_config(
twist_rule: snapshot.twist_rule, twist_rule: snapshot.twist_rule,
shape_count: snapshot.shape_count, shape_count: snapshot.shape_count,
difficulty: snapshot.difficulty, 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, twist_rule: snapshot.twist_rule,
summary: snapshot.summary_text, summary: snapshot.summary_text,
tags: snapshot.tags, 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, shape_count: snapshot.shape_count,
difficulty: snapshot.difficulty, difficulty: snapshot.difficulty,
publish_ready: false, publish_ready: false,
@@ -2997,6 +3016,18 @@ fn map_square_hole_work_snapshot(
summary: snapshot.summary_text, summary: snapshot.summary_text,
tags: snapshot.tags, tags: snapshot.tags,
cover_image_src: empty_string_to_none(snapshot.cover_image_src), 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, shape_count: snapshot.shape_count,
difficulty: snapshot.difficulty, difficulty: snapshot.difficulty,
publication_status: normalize_square_hole_publication_status(&snapshot.publication_status) 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, best_combo: snapshot.best_combo,
score: snapshot.score, score: snapshot.score,
rule_label: snapshot.rule_label, 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), current_shape: snapshot.current_shape.map(map_square_hole_shape_snapshot),
holes: snapshot holes: snapshot
.holes .holes
@@ -3053,6 +3085,7 @@ fn map_square_hole_shape_snapshot(
shape_kind: snapshot.shape_kind, shape_kind: snapshot.shape_kind,
label: snapshot.label, label: snapshot.label,
color: snapshot.color, 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, label: snapshot.label,
x: snapshot.x, x: snapshot.x,
y: snapshot.y, 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(); let difficulty = config.difficulty.to_string();
SquareHoleAnchorPackRecord { SquareHoleAnchorPackRecord {
theme: build_square_hole_anchor_item("theme", "题材主题", config.theme_text.as_str()), 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()), shape_count: build_square_hole_anchor_item("shapeCount", "形状数量", shape_count.as_str()),
difficulty: build_square_hole_anchor_item("difficulty", "难度", difficulty.as_str()), difficulty: build_square_hole_anchor_item("difficulty", "难度", difficulty.as_str()),
} }
@@ -5992,6 +6053,10 @@ pub struct SquareHoleWorkUpdateRecordInput {
pub summary_text: String, pub summary_text: String,
pub tags_json: String, pub tags_json: String,
pub cover_image_src: 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 shape_count: u32,
pub difficulty: u32, pub difficulty: u32,
pub updated_at_micros: i64, pub updated_at_micros: i64,
@@ -6059,6 +6124,28 @@ pub struct SquareHoleCreatorConfigRecord {
pub twist_rule: String, pub twist_rule: String,
pub shape_count: u32, pub shape_count: u32,
pub difficulty: u32, pub difficulty: u32,
pub shape_options: Vec<SquareHoleShapeOptionRecord>,
pub hole_options: Vec<SquareHoleHoleOptionRecord>,
pub background_prompt: String,
pub cover_image_src: Option<String>,
pub background_image_src: Option<String>,
}
#[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<String>,
}
#[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)] #[derive(Clone, Debug, PartialEq, Eq)]
@@ -6069,6 +6156,11 @@ pub struct SquareHoleResultDraftRecord {
pub twist_rule: String, pub twist_rule: String,
pub summary: String, pub summary: String,
pub tags: Vec<String>, pub tags: Vec<String>,
pub cover_image_src: Option<String>,
pub background_prompt: String,
pub background_image_src: Option<String>,
pub shape_options: Vec<SquareHoleShapeOptionRecord>,
pub hole_options: Vec<SquareHoleHoleOptionRecord>,
pub shape_count: u32, pub shape_count: u32,
pub difficulty: u32, pub difficulty: u32,
pub publish_ready: bool, pub publish_ready: bool,
@@ -6112,6 +6204,10 @@ pub struct SquareHoleWorkProfileRecord {
pub summary: String, pub summary: String,
pub tags: Vec<String>, pub tags: Vec<String>,
pub cover_image_src: Option<String>, pub cover_image_src: Option<String>,
pub background_prompt: String,
pub background_image_src: Option<String>,
pub shape_options: Vec<SquareHoleShapeOptionRecord>,
pub hole_options: Vec<SquareHoleHoleOptionRecord>,
pub shape_count: u32, pub shape_count: u32,
pub difficulty: u32, pub difficulty: u32,
pub publication_status: String, pub publication_status: String,
@@ -6127,6 +6223,7 @@ pub struct SquareHoleShapeSnapshotRecord {
pub shape_kind: String, pub shape_kind: String,
pub label: String, pub label: String,
pub color: String, pub color: String,
pub image_src: Option<String>,
} }
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
@@ -6136,6 +6233,7 @@ pub struct SquareHoleHoleSnapshotRecord {
pub label: String, pub label: String,
pub x: f32, pub x: f32,
pub y: f32, pub y: f32,
pub bonus: bool,
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
@@ -6162,6 +6260,7 @@ pub struct SquareHoleRunRecord {
pub best_combo: u32, pub best_combo: u32,
pub score: u32, pub score: u32,
pub rule_label: String, pub rule_label: String,
pub background_image_src: Option<String>,
pub current_shape: Option<SquareHoleShapeSnapshotRecord>, pub current_shape: Option<SquareHoleShapeSnapshotRecord>,
pub holes: Vec<SquareHoleHoleSnapshotRecord>, pub holes: Vec<SquareHoleHoleSnapshotRecord>,
pub last_feedback: Option<SquareHoleDropFeedbackRecord>, pub last_feedback: Option<SquareHoleDropFeedbackRecord>,
@@ -6185,6 +6284,37 @@ struct SquareHoleCreatorConfigJsonRecord {
twist_rule: String, twist_rule: String,
shape_count: u32, shape_count: u32,
difficulty: u32, difficulty: u32,
#[serde(default)]
shape_options: Vec<SquareHoleShapeOptionJsonRecord>,
#[serde(default)]
hole_options: Vec<SquareHoleHoleOptionJsonRecord>,
#[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)] #[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
@@ -6208,6 +6338,16 @@ struct SquareHoleDraftJsonRecord {
twist_rule: String, twist_rule: String,
summary_text: String, summary_text: String,
tags: Vec<String>, tags: Vec<String>,
#[serde(default)]
cover_image_src: String,
#[serde(default)]
background_prompt: String,
#[serde(default)]
background_image_src: String,
#[serde(default)]
shape_options: Vec<SquareHoleShapeOptionJsonRecord>,
#[serde(default)]
hole_options: Vec<SquareHoleHoleOptionJsonRecord>,
shape_count: u32, shape_count: u32,
difficulty: u32, difficulty: u32,
} }
@@ -6247,6 +6387,14 @@ struct SquareHoleWorkJsonRecord {
summary_text: String, summary_text: String,
tags: Vec<String>, tags: Vec<String>,
cover_image_src: String, cover_image_src: String,
#[serde(default)]
background_prompt: String,
#[serde(default)]
background_image_src: String,
#[serde(default)]
shape_options: Vec<SquareHoleShapeOptionJsonRecord>,
#[serde(default)]
hole_options: Vec<SquareHoleHoleOptionJsonRecord>,
shape_count: u32, shape_count: u32,
difficulty: u32, difficulty: u32,
#[allow(dead_code)] #[allow(dead_code)]
@@ -6265,6 +6413,8 @@ struct SquareHoleShapeJsonRecord {
shape_kind: String, shape_kind: String,
label: String, label: String,
color: String, color: String,
#[serde(default)]
image_src: String,
} }
#[derive(Clone, Debug, PartialEq, serde::Deserialize)] #[derive(Clone, Debug, PartialEq, serde::Deserialize)]
@@ -6275,6 +6425,8 @@ struct SquareHoleHoleJsonRecord {
label: String, label: String,
x: f32, x: f32,
y: f32, y: f32,
#[serde(default)]
bonus: bool,
} }
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
@@ -6303,6 +6455,11 @@ struct SquareHoleRunJsonRecord {
best_combo: u32, best_combo: u32,
score: u32, score: u32,
rule_label: String, rule_label: String,
#[serde(default)]
background_image_src: String,
#[serde(default)]
#[allow(dead_code)]
shape_options: Vec<SquareHoleShapeOptionJsonRecord>,
current_shape: Option<SquareHoleShapeJsonRecord>, current_shape: Option<SquareHoleShapeJsonRecord>,
holes: Vec<SquareHoleHoleJsonRecord>, holes: Vec<SquareHoleHoleJsonRecord>,
last_feedback: Option<SquareHoleDropFeedbackJsonRecord>, last_feedback: Option<SquareHoleDropFeedbackJsonRecord>,

View File

@@ -15,6 +15,10 @@ pub struct SquareHoleWorkUpdateInput {
pub summary_text: String, pub summary_text: String,
pub tags_json: String, pub tags_json: String,
pub cover_image_src: 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 shape_count: u32,
pub difficulty: u32, pub difficulty: u32,
pub updated_at_micros: i64, pub updated_at_micros: i64,

View File

@@ -17,15 +17,14 @@ impl SpacetimeClient {
}; };
self.call_after_connect(move |connection, sender| { self.call_after_connect(move |connection, sender| {
connection.procedures().create_square_hole_agent_session_then( connection
procedure_input, .procedures()
move |_, result| { .create_square_hole_agent_session_then(procedure_input, move |_, result| {
let mapped = result let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_square_hole_agent_session_procedure_result); .and_then(map_square_hole_agent_session_procedure_result);
send_once(&sender, mapped); send_once(&sender, mapped);
}, });
);
}) })
.await .await
} }
@@ -67,15 +66,14 @@ impl SpacetimeClient {
}; };
self.call_after_connect(move |connection, sender| { self.call_after_connect(move |connection, sender| {
connection.procedures().submit_square_hole_agent_message_then( connection
procedure_input, .procedures()
move |_, result| { .submit_square_hole_agent_message_then(procedure_input, move |_, result| {
let mapped = result let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_square_hole_agent_session_procedure_result); .and_then(map_square_hole_agent_session_procedure_result);
send_once(&sender, mapped); send_once(&sender, mapped);
}, });
);
}) })
.await .await
} }
@@ -126,14 +124,15 @@ impl SpacetimeClient {
}; };
self.call_after_connect(move |connection, sender| { self.call_after_connect(move |connection, sender| {
connection connection.procedures().compile_square_hole_draft_then(
.procedures() procedure_input,
.compile_square_hole_draft_then(procedure_input, move |_, result| { move |_, result| {
let mapped = result let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_square_hole_agent_session_procedure_result); .and_then(map_square_hole_agent_session_procedure_result);
send_once(&sender, mapped); send_once(&sender, mapped);
}); },
);
}) })
.await .await
} }
@@ -151,20 +150,25 @@ impl SpacetimeClient {
summary_text: input.summary_text, summary_text: input.summary_text,
tags_json: input.tags_json, tags_json: input.tags_json,
cover_image_src: input.cover_image_src, 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, shape_count: input.shape_count,
difficulty: input.difficulty, difficulty: input.difficulty,
updated_at_micros: input.updated_at_micros, updated_at_micros: input.updated_at_micros,
}; };
self.call_after_connect(move |connection, sender| { self.call_after_connect(move |connection, sender| {
connection connection.procedures().update_square_hole_work_then(
.procedures() procedure_input,
.update_square_hole_work_then(procedure_input, move |_, result| { move |_, result| {
let mapped = result let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_square_hole_work_procedure_result); .and_then(map_square_hole_work_procedure_result);
send_once(&sender, mapped); send_once(&sender, mapped);
}); },
);
}) })
.await .await
} }
@@ -182,14 +186,15 @@ impl SpacetimeClient {
}; };
self.call_after_connect(move |connection, sender| { self.call_after_connect(move |connection, sender| {
connection connection.procedures().publish_square_hole_work_then(
.procedures() procedure_input,
.publish_square_hole_work_then(procedure_input, move |_, result| { move |_, result| {
let mapped = result let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_square_hole_work_procedure_result); .and_then(map_square_hole_work_procedure_result);
send_once(&sender, mapped); send_once(&sender, mapped);
}); },
);
}) })
.await .await
} }
@@ -221,14 +226,15 @@ impl SpacetimeClient {
procedure_input: SquareHoleWorksListInput, procedure_input: SquareHoleWorksListInput,
) -> Result<Vec<SquareHoleWorkProfileRecord>, SpacetimeClientError> { ) -> Result<Vec<SquareHoleWorkProfileRecord>, SpacetimeClientError> {
self.call_after_connect(move |connection, sender| { self.call_after_connect(move |connection, sender| {
connection connection.procedures().list_square_hole_works_then(
.procedures() procedure_input,
.list_square_hole_works_then(procedure_input, move |_, result| { move |_, result| {
let mapped = result let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_square_hole_works_procedure_result); .and_then(map_square_hole_works_procedure_result);
send_once(&sender, mapped); send_once(&sender, mapped);
}); },
);
}) })
.await .await
} }
@@ -244,14 +250,15 @@ impl SpacetimeClient {
}; };
self.call_after_connect(move |connection, sender| { self.call_after_connect(move |connection, sender| {
connection connection.procedures().get_square_hole_work_detail_then(
.procedures() procedure_input,
.get_square_hole_work_detail_then(procedure_input, move |_, result| { move |_, result| {
let mapped = result let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_square_hole_work_procedure_result); .and_then(map_square_hole_work_procedure_result);
send_once(&sender, mapped); send_once(&sender, mapped);
}); },
);
}) })
.await .await
} }
@@ -267,14 +274,15 @@ impl SpacetimeClient {
}; };
self.call_after_connect(move |connection, sender| { self.call_after_connect(move |connection, sender| {
connection connection.procedures().delete_square_hole_work_then(
.procedures() procedure_input,
.delete_square_hole_work_then(procedure_input, move |_, result| { move |_, result| {
let mapped = result let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_square_hole_works_procedure_result); .and_then(map_square_hole_works_procedure_result);
send_once(&sender, mapped); send_once(&sender, mapped);
}); },
);
}) })
.await .await
} }
@@ -291,14 +299,15 @@ impl SpacetimeClient {
}; };
self.call_after_connect(move |connection, sender| { self.call_after_connect(move |connection, sender| {
connection connection.procedures().start_square_hole_run_then(
.procedures() procedure_input,
.start_square_hole_run_then(procedure_input, move |_, result| { move |_, result| {
let mapped = result let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_square_hole_run_procedure_result); .and_then(map_square_hole_run_procedure_result);
send_once(&sender, mapped); send_once(&sender, mapped);
}); },
);
}) })
.await .await
} }
@@ -341,21 +350,21 @@ impl SpacetimeClient {
}; };
self.call_after_connect(move |connection, sender| { self.call_after_connect(move |connection, sender| {
connection connection.procedures().drop_square_hole_shape_then(
.procedures() procedure_input,
.drop_square_hole_shape_then(procedure_input, move |_, result| { move |_, result| {
let mapped = result let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_square_hole_drop_shape_procedure_result) .and_then(map_square_hole_drop_shape_procedure_result)
.map(|mut confirmation| { .map(|mut confirmation| {
if confirmation.accepted { if confirmation.accepted {
confirmation.run.last_confirmed_action_id = confirmation.run.last_confirmed_action_id = Some(client_event_id);
Some(client_event_id);
} }
confirmation confirmation
}); });
send_once(&sender, mapped); send_once(&sender, mapped);
}); },
);
}) })
.await .await
} }
@@ -395,14 +404,15 @@ impl SpacetimeClient {
}; };
self.call_after_connect(move |connection, sender| { self.call_after_connect(move |connection, sender| {
connection connection.procedures().restart_square_hole_run_then(
.procedures() procedure_input,
.restart_square_hole_run_then(procedure_input, move |_, result| { move |_, result| {
let mapped = result let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_square_hole_run_procedure_result); .and_then(map_square_hole_run_procedure_result);
send_once(&sender, mapped); send_once(&sender, mapped);
}); },
);
}) })
.await .await
} }
@@ -418,14 +428,15 @@ impl SpacetimeClient {
}; };
self.call_after_connect(move |connection, sender| { self.call_after_connect(move |connection, sender| {
connection connection.procedures().finish_square_hole_time_up_then(
.procedures() procedure_input,
.finish_square_hole_time_up_then(procedure_input, move |_, result| { move |_, result| {
let mapped = result let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string())) .map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_square_hole_run_procedure_result); .and_then(map_square_hole_run_procedure_result);
send_once(&sender, mapped); send_once(&sender, mapped);
}); },
);
}) })
.await .await
} }

View File

@@ -10,12 +10,17 @@ use module_square_hole::{
SquareHoleDropFeedback as DomainSquareHoleDropFeedback, SquareHoleDropFeedback as DomainSquareHoleDropFeedback,
SquareHoleDropInput as DomainSquareHoleDropInput, SquareHoleDropInput as DomainSquareHoleDropInput,
SquareHoleDropRejectReason as DomainSquareHoleDropRejectReason, SquareHoleDropRejectReason as DomainSquareHoleDropRejectReason,
SquareHoleHoleOption as DomainSquareHoleHoleOption,
SquareHoleHoleSnapshot as DomainSquareHoleHoleSnapshot, SquareHoleHoleSnapshot as DomainSquareHoleHoleSnapshot,
SquareHoleRunSnapshot as DomainSquareHoleRunSnapshot, SquareHoleRunSnapshot as DomainSquareHoleRunSnapshot,
SquareHoleRunStatus as DomainSquareHoleRunStatus, SquareHoleRunStatus as DomainSquareHoleRunStatus,
SquareHoleShapeOption as DomainSquareHoleShapeOption,
SquareHoleShapeSnapshot as DomainSquareHoleShapeSnapshot, SquareHoleShapeSnapshot as DomainSquareHoleShapeSnapshot,
build_creator_config as build_domain_creator_config, build_creator_config as build_domain_creator_config,
compile_result_draft as compile_domain_result_draft, confirm_drop_at as confirm_domain_drop_at, 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, 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, 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.blockers = module_square_hole::validate_publish_requirements(&domain_draft);
domain_draft.publish_ready = domain_draft.blockers.is_empty(); 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 { let draft = SquareHoleDraftSnapshot {
profile_id: input.profile_id.clone(), profile_id: input.profile_id.clone(),
game_name: domain_draft.game_name.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(), twist_rule: domain_draft.twist_rule.clone(),
summary_text: domain_draft.summary.clone(), summary_text: domain_draft.summary.clone(),
tags: domain_draft.tags.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, shape_count: domain_draft.shape_count,
difficulty: domain_draft.difficulty, difficulty: domain_draft.difficulty,
}; };
@@ -454,7 +470,7 @@ fn compile_square_hole_draft_tx(
twist_rule: config.twist_rule.clone(), twist_rule: config.twist_rule.clone(),
summary_text: draft.summary_text.clone(), summary_text: draft.summary_text.clone(),
tags_json: to_json_string(&draft.tags), 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, shape_count: config.shape_count,
difficulty: config.difficulty, difficulty: config.difficulty,
config_json: to_json_string(&config), config_json: to_json_string(&config),
@@ -493,12 +509,31 @@ fn update_square_hole_work_tx(
) -> Result<SquareHoleWorkSnapshot, String> { ) -> Result<SquareHoleWorkSnapshot, String> {
let current = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?; let current = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?;
let tags = parse_tags(&input.tags_json)?; let tags = parse_tags(&input.tags_json)?;
let config = SquareHoleCreatorConfigSnapshot { let current_config = parse_config(&current.config_json)?;
let shape_options = parse_optional_json::<Vec<SquareHoleShapeOptionSnapshot>>(
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::<Vec<SquareHoleHoleOptionSnapshot>>(
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, "玩具"), theme_text: clean_string(&input.theme_text, "玩具"),
twist_rule: clean_string(&input.twist_rule, "方洞万能"), twist_rule: clean_string(&input.twist_rule, "方洞万能"),
shape_count: input.shape_count, shape_count: input.shape_count,
difficulty: input.difficulty, 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)?; validate_config(&config)?;
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
let next = SquareHoleWorkProfileRow { let next = SquareHoleWorkProfileRow {
@@ -828,6 +863,10 @@ fn build_work_snapshot(row: &SquareHoleWorkProfileRow) -> Result<SquareHoleWorkS
summary_text: row.summary_text.clone(), summary_text: row.summary_text.clone(),
tags: parse_tags(&row.tags_json)?, tags: parse_tags(&row.tags_json)?,
cover_image_src: row.cover_image_src.clone(), cover_image_src: row.cover_image_src.clone(),
background_prompt: config.background_prompt.clone(),
background_image_src: config.background_image_src.clone(),
shape_options: config.shape_options.clone(),
hole_options: config.hole_options.clone(),
shape_count: row.shape_count, shape_count: row.shape_count,
difficulty: row.difficulty, difficulty: row.difficulty,
config, config,
@@ -1040,12 +1079,17 @@ fn is_work_publish_ready(row: &SquareHoleWorkProfileRow) -> bool {
} }
fn default_config_from_seed(seed_text: &str) -> SquareHoleCreatorConfigSnapshot { fn default_config_from_seed(seed_text: &str) -> SquareHoleCreatorConfigSnapshot {
SquareHoleCreatorConfigSnapshot { normalize_config(SquareHoleCreatorConfigSnapshot {
theme_text: clean_string(seed_text, "玩具"), theme_text: clean_string(seed_text, "玩具"),
twist_rule: "方洞万能".to_string(), twist_rule: "方洞万能".to_string(),
shape_count: 12, shape_count: 12,
difficulty: 4, 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 { 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<SquareHoleCreatorConfigSnapshot, String> { fn parse_config(value: &str) -> Result<SquareHoleCreatorConfigSnapshot, String> {
parse_json(value, "square_hole config_json").map( parse_json(value, "square_hole config_json").map(normalize_config)
|mut config: SquareHoleCreatorConfigSnapshot| { }
config.theme_text = clean_string(&config.theme_text, "玩具");
config.twist_rule = clean_string(&config.twist_rule, "方洞万能"); fn normalize_config(
config.difficulty = config.difficulty.clamp( mut config: SquareHoleCreatorConfigSnapshot,
module_square_hole::SQUARE_HOLE_MIN_DIFFICULTY, ) -> SquareHoleCreatorConfigSnapshot {
module_square_hole::SQUARE_HOLE_MAX_DIFFICULTY, config.theme_text = clean_string(&config.theme_text, "玩具");
); config.twist_rule = clean_string(&config.twist_rule, "方洞万能");
config 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<Vec<String>, String> { fn parse_tags(value: &str) -> Result<Vec<String>, String> {
@@ -1071,6 +1132,13 @@ fn parse_tags(value: &str) -> Result<Vec<String>, String> {
Ok(normalize_tags(parsed)) Ok(normalize_tags(parsed))
} }
fn parse_optional_json<T: DeserializeOwned>(value: &str, label: &str) -> Result<Option<T>, String> {
if value.trim().is_empty() {
return Ok(None);
}
parse_json(value, label).map(Some)
}
fn normalize_tags(tags: Vec<String>) -> Vec<String> { fn normalize_tags(tags: Vec<String>) -> Vec<String> {
let mut result = Vec::new(); let mut result = Vec::new();
for tag in tags { for tag in tags {
@@ -1097,12 +1165,18 @@ fn normalize_stage(value: &str) -> String {
fn domain_config_from_snapshot( fn domain_config_from_snapshot(
config: &SquareHoleCreatorConfigSnapshot, config: &SquareHoleCreatorConfigSnapshot,
) -> Result<DomainSquareHoleCreatorConfig, module_square_hole::SquareHoleError> { ) -> Result<DomainSquareHoleCreatorConfig, module_square_hole::SquareHoleError> {
build_domain_creator_config( let mut domain = build_domain_creator_config(
&config.theme_text, &config.theme_text,
&config.twist_rule, &config.twist_rule,
config.shape_count, config.shape_count,
config.difficulty, 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( fn snapshot_from_domain(
@@ -1125,6 +1199,8 @@ fn snapshot_from_domain(
best_combo: run.best_combo, best_combo: run.best_combo,
score: run.score, score: run.score,
rule_label: run.rule_label.clone(), 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), current_shape: run.current_shape.as_ref().map(shape_from_domain),
holes: run.holes.iter().map(hole_from_domain).collect(), holes: run.holes.iter().map(hole_from_domain).collect(),
last_feedback: run.last_feedback.as_ref().map(feedback_from_domain), 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, best_combo: snapshot.best_combo,
score: snapshot.score, score: snapshot.score,
rule_label: snapshot.rule_label.clone(), 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: snapshot
.current_shape .current_shape
.as_ref() .as_ref()
@@ -1172,6 +1250,7 @@ fn shape_from_domain(shape: &DomainSquareHoleShapeSnapshot) -> SquareHoleShapeSn
shape_kind: shape.shape_kind.clone(), shape_kind: shape.shape_kind.clone(),
label: shape.label.clone(), label: shape.label.clone(),
color: shape.color.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(), shape_kind: shape.shape_kind.clone(),
label: shape.label.clone(), label: shape.label.clone(),
color: shape.color.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(), label: hole.label.clone(),
x: hole.x, x: hole.x,
y: hole.y, y: hole.y,
bonus: hole.bonus,
} }
} }
@@ -1201,9 +1282,68 @@ fn domain_hole_from_snapshot(hole: &SquareHoleHoleSnapshot) -> DomainSquareHoleH
label: hole.label.clone(), label: hole.label.clone(),
x: hole.x, x: hole.x,
y: hole.y, y: hole.y,
bonus: hole.bonus,
} }
} }
fn shape_options_to_snapshot(
options: &[DomainSquareHoleShapeOption],
) -> Vec<SquareHoleShapeOptionSnapshot> {
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<DomainSquareHoleShapeOption> {
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<SquareHoleHoleOptionSnapshot> {
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<DomainSquareHoleHoleOption> {
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 { fn feedback_from_domain(feedback: &DomainSquareHoleDropFeedback) -> SquareHoleDropFeedbackSnapshot {
SquareHoleDropFeedbackSnapshot { SquareHoleDropFeedbackSnapshot {
accepted: feedback.accepted, accepted: feedback.accepted,

View File

@@ -85,6 +85,10 @@ pub struct SquareHoleWorkUpdateInput {
pub summary_text: String, pub summary_text: String,
pub tags_json: String, pub tags_json: String,
pub cover_image_src: 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 shape_count: u32,
pub difficulty: u32, pub difficulty: u32,
pub updated_at_micros: i64, pub updated_at_micros: i64,
@@ -206,6 +210,37 @@ pub struct SquareHoleCreatorConfigSnapshot {
pub twist_rule: String, pub twist_rule: String,
pub shape_count: u32, pub shape_count: u32,
pub difficulty: u32, pub difficulty: u32,
#[serde(default)]
pub shape_options: Vec<SquareHoleShapeOptionSnapshot>,
#[serde(default)]
pub hole_options: Vec<SquareHoleHoleOptionSnapshot>,
#[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)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -228,6 +263,16 @@ pub struct SquareHoleDraftSnapshot {
pub twist_rule: String, pub twist_rule: String,
pub summary_text: String, pub summary_text: String,
pub tags: Vec<String>, pub tags: Vec<String>,
#[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<SquareHoleShapeOptionSnapshot>,
#[serde(default)]
pub hole_options: Vec<SquareHoleHoleOptionSnapshot>,
pub shape_count: u32, pub shape_count: u32,
pub difficulty: u32, pub difficulty: u32,
} }
@@ -264,6 +309,14 @@ pub struct SquareHoleWorkSnapshot {
pub summary_text: String, pub summary_text: String,
pub tags: Vec<String>, pub tags: Vec<String>,
pub cover_image_src: String, pub cover_image_src: String,
#[serde(default)]
pub background_prompt: String,
#[serde(default)]
pub background_image_src: String,
#[serde(default)]
pub shape_options: Vec<SquareHoleShapeOptionSnapshot>,
#[serde(default)]
pub hole_options: Vec<SquareHoleHoleOptionSnapshot>,
pub shape_count: u32, pub shape_count: u32,
pub difficulty: u32, pub difficulty: u32,
pub config: SquareHoleCreatorConfigSnapshot, pub config: SquareHoleCreatorConfigSnapshot,
@@ -281,6 +334,8 @@ pub struct SquareHoleShapeSnapshot {
pub shape_kind: String, pub shape_kind: String,
pub label: String, pub label: String,
pub color: String, pub color: String,
#[serde(default)]
pub image_src: String,
} }
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -291,6 +346,8 @@ pub struct SquareHoleHoleSnapshot {
pub label: String, pub label: String,
pub x: f32, pub x: f32,
pub y: f32, pub y: f32,
#[serde(default)]
pub bonus: bool,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -319,6 +376,10 @@ pub struct SquareHoleRunSnapshot {
pub best_combo: u32, pub best_combo: u32,
pub score: u32, pub score: u32,
pub rule_label: String, pub rule_label: String,
#[serde(default)]
pub background_image_src: String,
#[serde(default)]
pub shape_options: Vec<SquareHoleShapeOptionSnapshot>,
pub current_shape: Option<SquareHoleShapeSnapshot>, pub current_shape: Option<SquareHoleShapeSnapshot>,
pub holes: Vec<SquareHoleHoleSnapshot>, pub holes: Vec<SquareHoleHoleSnapshot>,
pub last_feedback: Option<SquareHoleDropFeedbackSnapshot>, pub last_feedback: Option<SquareHoleDropFeedbackSnapshot>,

View File

@@ -32,22 +32,6 @@ import type {
Match3DWorkProfile, Match3DWorkProfile,
Match3DWorkSummary, Match3DWorkSummary,
} from '../../../packages/shared/src/contracts/match3dWorks'; } 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 { import type {
PuzzleAgentActionRequest, PuzzleAgentActionRequest,
PuzzleAgentOperationRecord, PuzzleAgentOperationRecord,
@@ -72,6 +56,21 @@ import type {
ProfileSaveArchiveResumeResponse, ProfileSaveArchiveResumeResponse,
ProfileSaveArchiveSummary, ProfileSaveArchiveSummary,
} from '../../../packages/shared/src/contracts/runtime'; } 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 { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import { import {
buildPublicWorkStagePath, buildPublicWorkStagePath,
@@ -123,6 +122,7 @@ import {
buildBigFishGenerationAnchorEntries, buildBigFishGenerationAnchorEntries,
buildMiniGameDraftGenerationProgress, buildMiniGameDraftGenerationProgress,
buildPuzzleGenerationAnchorEntries, buildPuzzleGenerationAnchorEntries,
buildSquareHoleGenerationAnchorEntries,
createMiniGameDraftGenerationState, createMiniGameDraftGenerationState,
type MiniGameDraftGenerationState, type MiniGameDraftGenerationState,
} from '../../services/miniGameDraftGenerationProgress'; } from '../../services/miniGameDraftGenerationProgress';
@@ -552,8 +552,12 @@ function mapPublicWorkDetailToSquareHoleWork(
summary: entry.summaryText, summary: entry.summaryText,
tags: entry.themeTags, tags: entry.themeTags,
coverImageSrc: entry.coverImageSrc, coverImageSrc: entry.coverImageSrc,
shapeCount: 8, backgroundPrompt: entry.backgroundPrompt ?? '方洞挑战运行背景',
difficulty: 4, backgroundImageSrc: entry.backgroundImageSrc ?? null,
shapeOptions: entry.shapeOptions ?? [],
holeOptions: entry.holeOptions ?? [],
shapeCount: entry.shapeCount ?? 8,
difficulty: entry.difficulty ?? 4,
publicationStatus: 'published', publicationStatus: 'published',
playCount: entry.playCount ?? 0, playCount: entry.playCount ?? 0,
updatedAt: entry.updatedAt, updatedAt: entry.updatedAt,
@@ -581,7 +585,11 @@ function buildSquareHoleProfileFromSession(
twistRule: draft.twistRule, twistRule: draft.twistRule,
summary: draft.summary, summary: draft.summary,
tags: draft.tags, 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, shapeCount: draft.shapeCount,
difficulty: draft.difficulty, difficulty: draft.difficulty,
publicationStatus: 'draft', publicationStatus: 'draft',
@@ -608,13 +616,6 @@ function mergeBigFishWorkSummary(
: current; : current;
} }
function mergeSquareHoleWorkSummary(
current: SquareHoleWorkSummary,
updated: SquareHoleWorkSummary,
): SquareHoleWorkSummary {
return current.profileId === updated.profileId ? updated : current;
}
async function resolvePublicWorkAuthorSummary( async function resolvePublicWorkAuthorSummary(
entry: PlatformPublicGalleryCard, entry: PlatformPublicGalleryCard,
): Promise<PublicUserSummary | null> { ): Promise<PublicUserSummary | null> {
@@ -1086,6 +1087,8 @@ export function PlatformEntryFlowShellImpl({
useState<SquareHoleRuntimeReturnStage>('square-hole-result'); useState<SquareHoleRuntimeReturnStage>('square-hole-result');
const [isSquareHoleLoadingLibrary, setIsSquareHoleLoadingLibrary] = const [isSquareHoleLoadingLibrary, setIsSquareHoleLoadingLibrary] =
useState(false); useState(false);
const [squareHoleGenerationState, setSquareHoleGenerationState] =
useState<MiniGameDraftGenerationState | null>(null);
const [bigFishRun, setBigFishRun] = const [bigFishRun, setBigFishRun] =
useState<BigFishRuntimeSnapshotResponse | null>(null); useState<BigFishRuntimeSnapshotResponse | null>(null);
const [bigFishRuntimeShare, setBigFishRuntimeShare] = useState<{ const [bigFishRuntimeShare, setBigFishRuntimeShare] = useState<{
@@ -1817,7 +1820,7 @@ export function PlatformEntryFlowShellImpl({
workspaceStage: 'square-hole-agent-workspace', workspaceStage: 'square-hole-agent-workspace',
resultStage: 'square-hole-result', resultStage: 'square-hole-result',
platformStage: 'platform', platformStage: 'platform',
isCompileAction: (payload) => payload.action === 'square_hole_compile_draft', isCompileAction: () => false,
resolveErrorMessage: resolveSquareHoleErrorMessage, resolveErrorMessage: resolveSquareHoleErrorMessage,
errorMessages: { errorMessages: {
open: '开启方洞挑战共创工作台失败。', open: '开启方洞挑战共创工作台失败。',
@@ -1831,9 +1834,30 @@ export function PlatformEntryFlowShellImpl({
onSessionOpened: () => { onSessionOpened: () => {
setShowCreationTypeModal(false); 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 }) => { onActionComplete: async ({ payload, response, setSession }) => {
setSession(response.session); 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; return;
} }
@@ -1843,12 +1867,79 @@ export function PlatformEntryFlowShellImpl({
return; 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 { try {
const { item } = await getSquareHoleWorkDetail(profileId); const { item } = await getSquareHoleWorkDetail(profileId);
setSquareHoleProfile(item); 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); await refreshSquareHoleShelf().catch(() => undefined);
setSelectionStage('square-hole-result');
} catch { } catch {
setSquareHoleProfile(buildSquareHoleProfileFromSession(response.session)); 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); setSquareHoleProfile(null);
setSquareHoleRun(null); setSquareHoleRun(null);
setSquareHoleError(null); setSquareHoleError(null);
setSquareHoleGenerationState(null);
setSquareHoleRuntimeReturnStage('square-hole-result'); setSquareHoleRuntimeReturnStage('square-hole-result');
setStreamingSquareHoleReplyText(''); setStreamingSquareHoleReplyText('');
setIsStreamingSquareHoleReply(false); setIsStreamingSquareHoleReply(false);
@@ -2162,6 +2254,7 @@ export function PlatformEntryFlowShellImpl({
setSquareHoleGalleryEntries([]); setSquareHoleGalleryEntries([]);
setSquareHoleRun(null); setSquareHoleRun(null);
setSquareHoleRuntimeReturnStage('square-hole-result'); setSquareHoleRuntimeReturnStage('square-hole-result');
setSquareHoleGenerationState(null);
setSquareHoleError(null); setSquareHoleError(null);
setStreamingSquareHoleReplyText(''); setStreamingSquareHoleReplyText('');
setIsStreamingSquareHoleReply(false); setIsStreamingSquareHoleReply(false);
@@ -2291,6 +2384,7 @@ export function PlatformEntryFlowShellImpl({
const leaveSquareHoleFlow = useCallback(() => { const leaveSquareHoleFlow = useCallback(() => {
setSquareHoleRun(null); setSquareHoleRun(null);
setSquareHoleRuntimeReturnStage('square-hole-result'); setSquareHoleRuntimeReturnStage('square-hole-result');
setSquareHoleGenerationState(null);
squareHoleFlow.leaveFlow(); squareHoleFlow.leaveFlow();
}, [squareHoleFlow]); }, [squareHoleFlow]);
@@ -2316,6 +2410,20 @@ export function PlatformEntryFlowShellImpl({
const executeSquareHoleAction = squareHoleFlow.executeAction; 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 executePuzzleAction = puzzleFlow.executeAction;
const retryPuzzleDraftGeneration = useCallback(() => { const retryPuzzleDraftGeneration = useCallback(() => {
@@ -4013,6 +4121,7 @@ export function PlatformEntryFlowShellImpl({
return; return;
} }
setSquareHoleGenerationState(null);
const restoredSession = await squareHoleFlow.restoreDraft( const restoredSession = await squareHoleFlow.restoreDraft(
item.sourceSessionId, item.sourceSessionId,
); );
@@ -5583,6 +5692,50 @@ export function PlatformEntryFlowShellImpl({
</motion.div> </motion.div>
)} )}
{selectionStage === 'square-hole-generating' && (
<motion.div
key="square-hole-generating"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<Suspense
fallback={<LazyPanelFallback label="正在加载方洞挑战生成面板..." />}
>
<CustomWorldGenerationView
settingText={
squareHoleSession?.lastAssistantReply ??
'正在整理当前方洞挑战草稿。'
}
anchorEntries={buildSquareHoleGenerationAnchorEntries(
squareHoleSession,
)}
progress={buildMiniGameDraftGenerationProgress(
squareHoleGenerationState,
)}
isGenerating={isSquareHoleBusy}
error={squareHoleError}
onBack={leaveSquareHoleFlow}
onEditSetting={() => {
setSelectionStage('square-hole-agent-workspace');
}}
onRetry={retrySquareHoleAssetGeneration}
onInterrupt={undefined}
backLabel="返回创作中心"
settingActionLabel={null}
retryLabel="重新生成图片"
settingTitle="当前方洞挑战"
settingDescription={null}
progressTitle="方洞挑战图片生成进度"
activeBadgeLabel="图片生成中"
pausedBadgeLabel="图片生成已暂停"
idleBadgeLabel="等待返回结果页"
/>
</Suspense>
</motion.div>
)}
{selectionStage === 'square-hole-result' && squareHoleSession?.draft && ( {selectionStage === 'square-hole-result' && squareHoleSession?.draft && (
<motion.div <motion.div
key="square-hole-result" key="square-hole-result"

View File

@@ -26,6 +26,7 @@ export type SelectionStage =
| 'match3d-result' | 'match3d-result'
| 'match3d-runtime' | 'match3d-runtime'
| 'square-hole-agent-workspace' | 'square-hole-agent-workspace'
| 'square-hole-generating'
| 'square-hole-result' | 'square-hole-result'
| 'square-hole-runtime' | 'square-hole-runtime'
| 'puzzle-agent-workspace' | 'puzzle-agent-workspace'

View File

@@ -2,11 +2,15 @@ import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft'; import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { import type {
CustomWorldGalleryCard, CustomWorldGalleryCard,
CustomWorldLibraryEntry, CustomWorldLibraryEntry,
} from '../../../packages/shared/src/contracts/runtime'; } from '../../../packages/shared/src/contracts/runtime';
import type {
SquareHoleHoleOption,
SquareHoleShapeOption,
SquareHoleWorkSummary,
} from '../../../packages/shared/src/contracts/squareHoleWorks';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals'; import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
import { import {
@@ -112,6 +116,12 @@ export type PlatformSquareHoleGalleryCard = {
subtitle: string; subtitle: string;
summaryText: string; summaryText: string;
coverImageSrc: string | null; coverImageSrc: string | null;
backgroundPrompt?: string;
backgroundImageSrc?: string | null;
shapeOptions?: SquareHoleShapeOption[];
holeOptions?: SquareHoleHoleOption[];
shapeCount?: number;
difficulty?: number;
themeTags: string[]; themeTags: string[];
playCount?: number; playCount?: number;
remixCount?: number; remixCount?: number;
@@ -224,9 +234,15 @@ export function mapSquareHoleWorkToPlatformGalleryCard(
ownerUserId: work.ownerUserId, ownerUserId: work.ownerUserId,
authorDisplayName: '玩家', authorDisplayName: '玩家',
worldName: work.gameName, worldName: work.gameName,
subtitle: '反直觉形状分拣', subtitle: work.twistRule || '反直觉形状分拣',
summaryText: work.summary, summaryText: work.summary,
coverImageSrc: work.coverImageSrc ?? null, coverImageSrc: work.coverImageSrc ?? null,
backgroundPrompt: work.backgroundPrompt,
backgroundImageSrc: work.backgroundImageSrc ?? null,
shapeOptions: work.shapeOptions,
holeOptions: work.holeOptions,
shapeCount: work.shapeCount,
difficulty: work.difficulty,
themeTags: themeTags:
work.tags.length > 0 ? work.tags : [work.themeText, '方洞挑战'], work.tags.length > 0 ? work.tags : [work.themeText, '方洞挑战'],
playCount: work.playCount ?? 0, playCount: work.playCount ?? 0,

View File

@@ -4,13 +4,17 @@ import {
ImagePlus, ImagePlus,
Loader2, Loader2,
Play, Play,
Plus,
Send, Send,
Trash2,
} from 'lucide-react'; } from 'lucide-react';
import { type ChangeEvent, useEffect, useMemo, useState } from 'react'; import { type ChangeEvent, useEffect, useMemo, useState } from 'react';
import type { SquareHoleResultDraft } from '../../../packages/shared/src/contracts/squareHoleAgent'; import type { SquareHoleResultDraft } from '../../../packages/shared/src/contracts/squareHoleAgent';
import type { import type {
PutSquareHoleWorkRequest, PutSquareHoleWorkRequest,
SquareHoleHoleOption,
SquareHoleShapeOption,
SquareHoleWorkProfile, SquareHoleWorkProfile,
} from '../../../packages/shared/src/contracts/squareHoleWorks'; } from '../../../packages/shared/src/contracts/squareHoleWorks';
import { import {
@@ -37,13 +41,26 @@ type SquareHoleResultEditState = {
summary: string; summary: string;
tagsText: string; tagsText: string;
coverImageSrc: string; coverImageSrc: string;
backgroundPrompt: string;
backgroundImageSrc: string;
themeText: string; themeText: string;
twistRule: string; twistRule: string;
shapeOptions: SquareHoleShapeOption[];
holeOptions: SquareHoleHoleOption[];
shapeCountText: string; shapeCountText: string;
difficultyText: string; difficultyText: string;
}; };
const SQUARE_HOLE_AUTOSAVE_DEBOUNCE_MS = 600; 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) { function normalizeTags(value: string) {
return [ return [
@@ -78,8 +95,12 @@ function createEditState(
summary: profile.summary, summary: profile.summary,
tagsText: profile.tags.join(''), tagsText: profile.tags.join(''),
coverImageSrc: profile.coverImageSrc?.trim() || '', coverImageSrc: profile.coverImageSrc?.trim() || '',
backgroundPrompt: profile.backgroundPrompt || '',
backgroundImageSrc: profile.backgroundImageSrc?.trim() || '',
themeText: profile.themeText, themeText: profile.themeText,
twistRule: profile.twistRule, twistRule: profile.twistRule,
shapeOptions: profile.shapeOptions.map((option) => ({ ...option })),
holeOptions: profile.holeOptions.map((option) => ({ ...option })),
shapeCountText: String(profile.shapeCount), shapeCountText: String(profile.shapeCount),
difficultyText: String(profile.difficulty), difficultyText: String(profile.difficulty),
}; };
@@ -95,6 +116,28 @@ function buildSavePayload(
const twistRule = editState.twistRule.trim(); const twistRule = editState.twistRule.trim();
const summary = editState.summary.trim(); const summary = editState.summary.trim();
const tags = normalizeTags(editState.tagsText); 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 ( if (
!gameName || !gameName ||
@@ -102,6 +145,8 @@ function buildSavePayload(
!twistRule || !twistRule ||
!summary || !summary ||
tags.length === 0 || tags.length === 0 ||
shapeOptions.length === 0 ||
holeOptions.length === 0 ||
!shapeCount || !shapeCount ||
!difficulty !difficulty
) { ) {
@@ -115,6 +160,10 @@ function buildSavePayload(
summary, summary,
tags, tags,
coverImageSrc: editState.coverImageSrc.trim() || null, coverImageSrc: editState.coverImageSrc.trim() || null,
backgroundPrompt: editState.backgroundPrompt.trim(),
backgroundImageSrc: editState.backgroundImageSrc.trim() || null,
shapeOptions,
holeOptions,
shapeCount, shapeCount,
difficulty, difficulty,
}; };
@@ -129,6 +178,21 @@ function buildPublishBlockers(editState: SquareHoleResultEditState) {
...(normalizeTags(editState.tagsText).length > 0 ...(normalizeTags(editState.tagsText).length > 0
? [] ? []
: ['至少需要 1 个标签。']), : ['至少需要 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) ...(normalizeShapeCount(editState.shapeCountText)
? [] ? []
: ['形状数量需要在 6 到 24 之间。']), : ['形状数量需要在 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( function buildPlayableProfile(
profile: SquareHoleWorkProfile, profile: SquareHoleWorkProfile,
editState: SquareHoleResultEditState, editState: SquareHoleResultEditState,
@@ -171,6 +260,10 @@ function buildPlayableProfile(
summary: payload.summary, summary: payload.summary,
tags: payload.tags, tags: payload.tags,
coverImageSrc: payload.coverImageSrc, coverImageSrc: payload.coverImageSrc,
backgroundPrompt: payload.backgroundPrompt ?? profile.backgroundPrompt,
backgroundImageSrc: payload.backgroundImageSrc,
shapeOptions: payload.shapeOptions ?? profile.shapeOptions,
holeOptions: payload.holeOptions ?? profile.holeOptions,
shapeCount: payload.shapeCount, shapeCount: payload.shapeCount,
difficulty: payload.difficulty, difficulty: payload.difficulty,
}; };
@@ -241,7 +334,7 @@ export function SquareHoleResultView({
setEditState(createEditState(profile)); setEditState(createEditState(profile));
setAutoSaveState('idle'); setAutoSaveState('idle');
setLocalError(null); setLocalError(null);
}, [profile.profileId, profile.updatedAt]); }, [profile]);
useEffect(() => { useEffect(() => {
const payload = buildSavePayload(editState); const payload = buildSavePayload(editState);
@@ -250,14 +343,21 @@ export function SquareHoleResultView({
} }
const currentTags = normalizeTags(profile.tags.join('')); const currentTags = normalizeTags(profile.tags.join(''));
const currentShapeOptions = JSON.stringify(profile.shapeOptions);
const currentHoleOptions = JSON.stringify(profile.holeOptions);
const changed = const changed =
payload.gameName !== profile.gameName || payload.gameName !== profile.gameName ||
payload.themeText !== profile.themeText || payload.themeText !== profile.themeText ||
payload.twistRule !== profile.twistRule || payload.twistRule !== profile.twistRule ||
payload.summary !== profile.summary || payload.summary !== profile.summary ||
(payload.coverImageSrc ?? '') !== (profile.coverImageSrc ?? '') || (payload.coverImageSrc ?? '') !== (profile.coverImageSrc ?? '') ||
(payload.backgroundPrompt ?? '') !== (profile.backgroundPrompt ?? '') ||
(payload.backgroundImageSrc ?? '') !==
(profile.backgroundImageSrc ?? '') ||
payload.shapeCount !== profile.shapeCount || payload.shapeCount !== profile.shapeCount ||
payload.difficulty !== profile.difficulty || payload.difficulty !== profile.difficulty ||
JSON.stringify(payload.shapeOptions ?? []) !== currentShapeOptions ||
JSON.stringify(payload.holeOptions ?? []) !== currentHoleOptions ||
payload.tags.length !== currentTags.length || payload.tags.length !== currentTags.length ||
payload.tags.some((tag, index) => tag !== currentTags[index]); payload.tags.some((tag, index) => tag !== currentTags[index]);
@@ -330,6 +430,60 @@ export function SquareHoleResultView({
} }
}; };
const handleBackgroundImageChange = async (
event: ChangeEvent<HTMLInputElement>,
) => {
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<HTMLInputElement>,
) => {
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 () => { const handleStartTestRun = async () => {
if (!canSubmit || isStartingTestRun) { if (!canSubmit || isStartingTestRun) {
setLocalError(blockers[0] ?? null); setLocalError(blockers[0] ?? null);
@@ -422,6 +576,31 @@ export function SquareHoleResultView({
{draft?.publishReady ?? profile.publishReady ? '可发布' : '草稿'} {draft?.publishReady ?? profile.publishReady ? '可发布' : '草稿'}
</div> </div>
</div> </div>
<div className="mt-3 aspect-[16/9] overflow-hidden rounded-[1.1rem] border border-[var(--platform-subpanel-border)] bg-[linear-gradient(135deg,rgba(15,23,42,0.12),rgba(34,197,94,0.16))]">
{editState.backgroundImageSrc ? (
<ResolvedAssetImage
src={editState.backgroundImageSrc}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
) : (
<div className="grid h-full w-full place-items-center text-slate-700">
<ImagePlus className="h-8 w-8" />
</div>
)}
</div>
<label className="platform-button platform-button--ghost mt-3 flex min-h-10 cursor-pointer items-center justify-center gap-2 px-3 py-2 text-sm">
<ImagePlus className="h-4 w-4" />
<input
type="file"
accept="image/*"
className="sr-only"
disabled={busy}
onChange={handleBackgroundImageChange}
/>
</label>
</section> </section>
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5"> <section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5">
@@ -497,6 +676,23 @@ export function SquareHoleResultView({
/> />
</label> </label>
<label className="block sm:col-span-2">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={editState.backgroundPrompt}
disabled={busy}
onChange={(event) =>
setEditState({
...editState,
backgroundPrompt: event.target.value,
})
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="block"> <label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]"> <span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
@@ -534,6 +730,242 @@ export function SquareHoleResultView({
</label> </label>
</div> </div>
</section> </section>
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5 lg:col-span-2">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<button
type="button"
disabled={busy}
onClick={() =>
setEditState((current) => ({
...current,
shapeOptions: [
...current.shapeOptions,
createShapeOption(current.shapeOptions.length),
],
}))
}
className="platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px]"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{editState.shapeOptions.map((option) => (
<div
key={option.optionId}
className="rounded-[1.1rem] border border-[var(--platform-subpanel-border)] bg-white/58 p-3"
>
<div className="mb-3 flex items-start gap-3">
<label className="relative grid h-20 w-20 shrink-0 cursor-pointer place-items-center overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/82 text-slate-600">
{option.imageSrc ? (
<ResolvedAssetImage
src={option.imageSrc}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
) : (
<ImagePlus className="h-6 w-6" />
)}
<input
type="file"
accept="image/*"
className="sr-only"
disabled={busy}
onChange={(event) => {
void handleShapeImageChange(option.optionId, event);
}}
/>
</label>
<div className="min-w-0 flex-1 space-y-2">
<input
value={option.label}
disabled={busy}
onChange={(event) =>
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"
/>
<select
value={option.shapeKind}
disabled={busy}
onChange={(event) =>
setEditState((current) => ({
...current,
shapeOptions: current.shapeOptions.map((entry) =>
entry.optionId === option.optionId
? { ...entry, shapeKind: 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"
>
{SQUARE_HOLE_SHAPE_KIND_OPTIONS.map((kind) => (
<option key={kind} value={kind}>
{kind}
</option>
))}
</select>
</div>
<button
type="button"
disabled={busy || editState.shapeOptions.length <= 1}
onClick={() =>
setEditState((current) => ({
...current,
shapeOptions: current.shapeOptions.filter(
(entry) => entry.optionId !== option.optionId,
),
}))
}
className="rounded-full p-2 text-slate-500 hover:bg-white/72 disabled:opacity-40"
aria-label="删除形状选项"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
<textarea
value={option.imagePrompt}
disabled={busy}
rows={2}
onChange={(event) =>
setEditState((current) => ({
...current,
shapeOptions: current.shapeOptions.map((entry) =>
entry.optionId === option.optionId
? { ...entry, imagePrompt: event.target.value }
: entry,
),
}))
}
className="w-full resize-none rounded-[0.85rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-2 text-sm leading-5 text-[var(--platform-text-strong)] outline-none"
/>
</div>
))}
</div>
</section>
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5 lg:col-span-2">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<button
type="button"
disabled={busy}
onClick={() =>
setEditState((current) => ({
...current,
holeOptions: [
...current.holeOptions,
createHoleOption(current.holeOptions.length),
],
}))
}
className="platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px]"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{editState.holeOptions.map((option) => (
<div
key={option.holeId}
className="rounded-[1.1rem] border border-[var(--platform-subpanel-border)] bg-white/58 p-3"
>
<div className="grid gap-2">
<input
value={option.label}
disabled={busy}
onChange={(event) =>
setEditState((current) => ({
...current,
holeOptions: current.holeOptions.map((entry) =>
entry.holeId === option.holeId
? { ...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"
/>
<select
value={option.holeKind}
disabled={busy}
onChange={(event) =>
setEditState((current) => ({
...current,
holeOptions: current.holeOptions.map((entry) =>
entry.holeId === option.holeId
? { ...entry, holeKind: 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"
>
{SQUARE_HOLE_HOLE_KIND_OPTIONS.map((kind) => (
<option key={kind} value={kind}>
{kind}
</option>
))}
</select>
<div className="flex items-center justify-between gap-3">
<label className="inline-flex min-w-0 items-center gap-2 text-sm font-semibold text-[var(--platform-text-strong)]">
<input
type="checkbox"
checked={option.bonus}
disabled={busy}
onChange={(event) =>
setEditState((current) => ({
...current,
holeOptions: current.holeOptions.map((entry) =>
entry.holeId === option.holeId
? { ...entry, bonus: event.target.checked }
: entry,
),
}))
}
className="h-4 w-4"
/>
</label>
<button
type="button"
disabled={busy || editState.holeOptions.length <= 1}
onClick={() =>
setEditState((current) => ({
...current,
holeOptions: current.holeOptions.filter(
(entry) => entry.holeId !== option.holeId,
),
}))
}
className="rounded-full p-2 text-slate-500 hover:bg-white/72 disabled:opacity-40"
aria-label="删除洞口选项"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
))}
</div>
</section>
</div> </div>
</div> </div>

View File

@@ -15,6 +15,7 @@ import type {
SquareHoleHoleSnapshot, SquareHoleHoleSnapshot,
SquareHoleRunSnapshot, SquareHoleRunSnapshot,
} from '../../../packages/shared/src/contracts/squareHoleRuntime'; } from '../../../packages/shared/src/contracts/squareHoleRuntime';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type SquareHoleRuntimeShellProps = { type SquareHoleRuntimeShellProps = {
run: SquareHoleRunSnapshot | null; run: SquareHoleRunSnapshot | null;
@@ -241,7 +242,17 @@ export function SquareHoleRuntimeShell({
return ( return (
<main className="relative flex min-h-dvh w-full justify-center overflow-hidden bg-[#101827] text-white"> <main className="relative flex min-h-dvh w-full justify-center overflow-hidden bg-[#101827] text-white">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_10%,rgba(125,211,252,0.32),transparent_27%),radial-gradient(circle_at_80%_80%,rgba(248,113,113,0.24),transparent_34%),linear-gradient(180deg,#1f3a5f_0%,#152238_48%,#111827_100%)]" /> {run.backgroundImageSrc ? (
<ResolvedAssetImage
src={run.backgroundImageSrc}
alt=""
aria-hidden="true"
className="absolute inset-0 h-full w-full object-cover"
/>
) : (
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_10%,rgba(125,211,252,0.32),transparent_27%),radial-gradient(circle_at_80%_80%,rgba(248,113,113,0.24),transparent_34%),linear-gradient(180deg,#1f3a5f_0%,#152238_48%,#111827_100%)]" />
)}
<div className="absolute inset-0 bg-slate-950/42" />
<div <div
className="relative flex min-h-dvh min-w-0 flex-col overflow-hidden px-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.8rem)] pt-[calc(env(safe-area-inset-top,0px)+0.65rem)]" className="relative flex min-h-dvh min-w-0 flex-col overflow-hidden px-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.8rem)] pt-[calc(env(safe-area-inset-top,0px)+0.65rem)]"
style={{ style={{
@@ -287,14 +298,14 @@ export function SquareHoleRuntimeShell({
<section className="mt-3 rounded-[1.5rem] border border-white/14 bg-black/18 p-3 shadow-[0_18px_42px_rgba(15,23,42,0.28)] backdrop-blur"> <section className="mt-3 rounded-[1.5rem] border border-white/14 bg-black/18 p-3 shadow-[0_18px_42px_rgba(15,23,42,0.28)] backdrop-blur">
<div className="flex items-center justify-between gap-2 text-xs font-bold text-white/68"> <div className="flex items-center justify-between gap-2 text-xs font-bold text-white/68">
<span>{run.ruleLabel}</span> <span></span>
<span>v{run.snapshotVersion}</span> <span>{progressText}</span>
</div> </div>
<div className="mt-3 flex min-h-[12rem] items-center justify-center rounded-[1.35rem] border border-white/10 bg-white/10"> <div className="mt-3 flex min-h-[12rem] items-center justify-center rounded-[1.35rem] border border-white/10 bg-white/10">
{currentShape ? ( {currentShape ? (
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<div <div
className={`h-24 w-24 shadow-[0_18px_38px_rgba(15,23,42,0.34)] ${getShapePreviewClass( className={`relative h-24 w-24 overflow-hidden shadow-[0_18px_38px_rgba(15,23,42,0.34)] ${getShapePreviewClass(
currentShape.shapeKind, currentShape.shapeKind,
)}`} )}`}
style={{ style={{
@@ -302,7 +313,16 @@ export function SquareHoleRuntimeShell({
currentShape.color || currentShape.color ||
'linear-gradient(135deg,#f8fafc,#38bdf8)', 'linear-gradient(135deg,#f8fafc,#38bdf8)',
}} }}
/> >
{currentShape.imageSrc ? (
<ResolvedAssetImage
src={currentShape.imageSrc}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
) : null}
</div>
<div className="flex items-center gap-2 text-base font-black"> <div className="flex items-center gap-2 text-base font-black">
<Shapes size={18} /> <Shapes size={18} />
<span>{currentShape.label}</span> <span>{currentShape.label}</span>

View File

@@ -7,9 +7,10 @@ import type {
CustomWorldGenerationProgress, CustomWorldGenerationProgress,
CustomWorldGenerationStep, CustomWorldGenerationStep,
} from '../../packages/shared/src/contracts/runtime'; } from '../../packages/shared/src/contracts/runtime';
import type { SquareHoleSessionSnapshot } from '../../packages/shared/src/contracts/squareHoleAgent';
import type { CustomWorldStructuredAnchorEntry } from './customWorldAgentGenerationProgress'; import type { CustomWorldStructuredAnchorEntry } from './customWorldAgentGenerationProgress';
export type MiniGameDraftGenerationKind = 'puzzle' | 'big-fish'; export type MiniGameDraftGenerationKind = 'puzzle' | 'big-fish' | 'square-hole';
export type MiniGameDraftGenerationPhase = export type MiniGameDraftGenerationPhase =
| 'idle' | 'idle'
@@ -17,6 +18,10 @@ export type MiniGameDraftGenerationPhase =
| 'big-fish-draft' | 'big-fish-draft'
| 'big-fish-levels' | 'big-fish-levels'
| 'big-fish-runtime' | 'big-fish-runtime'
| 'square-hole-draft'
| 'square-hole-cover'
| 'square-hole-shapes'
| 'square-hole-ready'
| 'puzzle-images' | 'puzzle-images'
| 'puzzle-select-image' | 'puzzle-select-image'
| 'ready' | 'ready'
@@ -86,12 +91,39 @@ const BIG_FISH_STEPS = [
}, },
] as const satisfies ReadonlyArray<MiniGameStepDefinition>; ] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
const SQUARE_HOLE_STEPS = [
{
id: 'square-hole-draft',
label: '整理玩法草稿',
detail: '收拢题材、形状、洞口与加分选项。',
weight: 28,
},
{
id: 'square-hole-cover',
label: '生成封面与背景',
detail: '生成作品封面和运行背景。',
weight: 32,
},
{
id: 'square-hole-shapes',
label: '生成形状贴图',
detail: '为每个可投放形状生成贴图。',
weight: 40,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
function clampProgress(value: number) { function clampProgress(value: number) {
return Math.max(0, Math.min(100, Math.round(value))); return Math.max(0, Math.min(100, Math.round(value)));
} }
function getStepDefinitions(kind: MiniGameDraftGenerationKind) { function getStepDefinitions(kind: MiniGameDraftGenerationKind) {
return kind === 'puzzle' ? PUZZLE_STEPS : BIG_FISH_STEPS; if (kind === 'puzzle') {
return PUZZLE_STEPS;
}
if (kind === 'square-hole') {
return SQUARE_HOLE_STEPS;
}
return BIG_FISH_STEPS;
} }
function getActiveStepIndex( function getActiveStepIndex(
@@ -132,7 +164,12 @@ export function createMiniGameDraftGenerationState(
): MiniGameDraftGenerationState { ): MiniGameDraftGenerationState {
return { return {
kind, kind,
phase: kind === 'big-fish' ? 'big-fish-draft' : 'compile', phase:
kind === 'big-fish'
? 'big-fish-draft'
: kind === 'square-hole'
? 'square-hole-draft'
: 'compile',
startedAtMs: Date.now(), startedAtMs: Date.now(),
completedAssetCount: 0, completedAssetCount: 0,
totalAssetCount: 0, totalAssetCount: 0,
@@ -152,6 +189,18 @@ function resolveBigFishPhaseByElapsedMs(
return 'big-fish-draft'; return 'big-fish-draft';
} }
function resolveSquareHolePhaseByElapsedMs(
elapsedMs: number,
): MiniGameDraftGenerationPhase {
if (elapsedMs >= 6_500) {
return 'square-hole-shapes';
}
if (elapsedMs >= 2_400) {
return 'square-hole-cover';
}
return 'square-hole-draft';
}
export function buildMiniGameDraftGenerationProgress( export function buildMiniGameDraftGenerationProgress(
state: MiniGameDraftGenerationState | null, state: MiniGameDraftGenerationState | null,
nowMs = Date.now(), nowMs = Date.now(),
@@ -169,6 +218,13 @@ export function buildMiniGameDraftGenerationProgress(
...state, ...state,
phase: resolveBigFishPhaseByElapsedMs(elapsedMs), phase: resolveBigFishPhaseByElapsedMs(elapsedMs),
} }
: state.kind === 'square-hole' &&
state.phase !== 'failed' &&
state.phase !== 'ready'
? {
...state,
phase: resolveSquareHolePhaseByElapsedMs(elapsedMs),
}
: state; : state;
const steps = getStepDefinitions(normalizedState.kind); const steps = getStepDefinitions(normalizedState.kind);
@@ -190,6 +246,8 @@ export function buildMiniGameDraftGenerationProgress(
? 1 ? 1
: normalizedState.kind === 'big-fish' : normalizedState.kind === 'big-fish'
? 0.55 ? 0.55
: normalizedState.kind === 'square-hole'
? 0.42
: 0; : 0;
const overallProgress = const overallProgress =
normalizedState.phase === 'failed' normalizedState.phase === 'failed'
@@ -223,6 +281,8 @@ export function buildMiniGameDraftGenerationProgress(
? 0 ? 0
: normalizedState.kind === 'big-fish' : normalizedState.kind === 'big-fish'
? Math.max(0, 7_000 - elapsedMs) ? Math.max(0, 7_000 - elapsedMs)
: normalizedState.kind === 'square-hole'
? Math.max(0, 12_000 - elapsedMs)
: null, : null,
activeStepIndex, activeStepIndex,
steps: buildMiniGameProgressSteps(steps, activeStepIndex, normalizedState), steps: buildMiniGameProgressSteps(steps, activeStepIndex, normalizedState),
@@ -306,3 +366,46 @@ export function buildBigFishGenerationAnchorEntries(
})) }))
.filter((entry) => entry.value.trim()); .filter((entry) => entry.value.trim());
} }
export function buildSquareHoleGenerationAnchorEntries(
session: SquareHoleSessionSnapshot | null | undefined,
): CustomWorldStructuredAnchorEntry[] {
if (!session) {
return [];
}
const draft = session.draft;
const shapeCount =
draft?.shapeOptions.filter((option) => option.imageSrc?.trim()).length ??
session.config.shapeOptions.filter((option) => option.imageSrc?.trim())
.length;
const totalShapeCount =
draft?.shapeOptions.length || session.config.shapeOptions.length;
const entries: Array<MiniGameAnchorSource | null> = [
{
key: 'square-hole-title',
label: '作品名称',
value: draft?.gameName || `${session.config.themeText}方洞挑战`,
},
{
key: 'square-hole-theme',
label: '题材与规则',
value: `${session.config.themeText}${session.config.twistRule}`,
},
{
key: 'square-hole-options',
label: '选项资产',
value: totalShapeCount > 0 ? `形状贴图 ${shapeCount}/${totalShapeCount}` : '',
},
];
return entries
.filter((entry): entry is MiniGameAnchorSource => Boolean(entry))
.map((entry) => ({
id: entry.key,
label: entry.label,
value: entry.value,
}))
.filter((entry) => entry.value.trim());
}