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
@@ -74,7 +74,8 @@
8. 后端初始化单局形状队列、洞口兼容规则和计分状态。
9. 玩家拖拽或点击形状投入洞口。
10. 后端裁决投入结果、连击、扣时、失败、胜利和成绩。
11. 前端只渲染后端快照与即时反馈,不承接正式规则真相
11. 形状贴图、封面图和背景图必须由后端图片生成接口生成或由创作者上传,前端只保存和展示 URL / data URL
12. 前端只渲染后端快照与即时反馈,不承接正式规则真相。
---
@@ -106,7 +107,23 @@ Agent 需要把玩家一句灵感收束为上述锚点,不允许逐项盘问
"themeText": "",
"twistRule": "",
"shapeCount": 12,
"difficulty": 4
"difficulty": 4,
"shapeOptions": [
{
"shapeKind": "circle",
"label": "圆形",
"imagePrompt": "一个圆形办公室印章贴纸"
}
],
"holeOptions": [
{
"holeId": "square-hole",
"holeKind": "square",
"label": "方洞",
"bonus": true
}
],
"backgroundPrompt": "办公室纸箱玩具桌面背景"
}
}
```
@@ -119,6 +136,10 @@ Agent 需要把玩家一句灵感收束为上述锚点,不允许逐项盘问
4. `quickFillRequested=true` 时,模型应直接补齐剩余配置,后端把 `progressPercent` 固定为 `100`
5. 模型不可用或结果无法解析时,接口返回明确错误,不允许用确定性模板伪装成 AI 回复。
6. 非流式消息接口和 SSE 流式消息接口都必须走同一套方洞 Agent turn,SSE 只额外负责把 `replyText` 增量回传。
7. `shapeOptions` 至少包含 `6` 个候选项;缺失时后端用当前题材生成默认候选项。
8. `holeOptions` 至少包含 `3` 个选项,最多 `6` 个选项;创作者可以自定义 label、洞口类型与是否为加分选项。
9. `bonus=true` 只表示“该选项被后端判定为正确时额外加 50 分”,不是公开提示;运行态 UI 不允许直接显示哪个选项是加分选项。
10. `backgroundPrompt` 用于生成运行态背景图;为空时后端用题材和反差规则拼出默认提示词。
---
@@ -147,7 +168,30 @@ Agent 需要把玩家一句灵感收束为上述锚点,不允许逐项盘问
其中 `square_hole_priority` 是参考视频核心反差的首选默认规则。
## 6.3 前端表现
## 6.3 计分规则
首版计分由 `module-square-hole` 统一裁决:
1. 正确投入基础得分:`100 + 当前连击数 * 10`
2. 如果本次投入的洞口是创作者配置的 `bonus=true` 选项,并且本次投入被判定为正确,额外加 `50` 分。
3. 错误投入不扣分,但连击清零。
4. 时间到、主动退出或本局结束不追加结算奖励。
5. 前端只展示后端返回的 `score / combo / bestCombo`,不自行计算分数。
## 6.4 图片资产规则
1. 草稿生成后必须进入生成进度页,后端按草稿配置生成:
- 封面图 `coverImageSrc`
- 背景图 `backgroundImageSrc`
- 每个 `shapeOptions[].imageSrc`
2. 创作者上传入口保留;上传图片可以覆盖生成图片。
3. 图片生成失败时保留草稿和可编辑配置,结果页展示缺失槽位,允许创作者重试生成或上传替代图。
4. 结果页必须展示每个形状选项及其图片、背景图、封面图和洞口选项配置。
5. 运行态当前形状优先显示 `imageSrc`,没有图片时才回退到 CSS 形状。
6. 运行态背景优先显示 `backgroundImageSrc`,没有图片时才回退到默认渐变。
7. 运行态顶部不显示“方洞是唯一解”或等价真实规则提示;只保留时间、进度、分数和连击。
## 6.5 前端表现
1. 竖屏优先,桌面端居中显示游戏台。
2. 当前形状位于屏幕下半区域,洞板位于上半区域。
@@ -207,10 +251,13 @@ Agent 需要把玩家一句灵感收束为上述锚点,不允许逐项盘问
10. `coverImageSrc`
11. `shapeCount`
12. `difficulty`
13. `publicationStatus`
14. `playCount`
15. `updatedAt`
16. `publishedAt`
13. `shapeOptions`
14. `holeOptions`
15. `backgroundImageSrc`
16. `publicationStatus`
17. `playCount`
18. `updatedAt`
19. `publishedAt`
## 8.3 运行态 run snapshot
@@ -228,9 +275,12 @@ Agent 需要把玩家一句灵感收束为上述锚点,不允许逐项盘问
12. `bestCombo`
13. `score`
14. `ruleLabel`
15. `currentShape`
16. `holes`
17. `lastFeedback`
15. `backgroundImageSrc`
16. `currentShape`
17. `holes`
18. `lastFeedback`
运行态 `ruleLabel` 仅保留后端调试兼容字段,前端默认不展示。
---
@@ -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`
@@ -35,6 +35,21 @@ export interface ExecuteSquareHoleActionRequest {
coverImageSrc?: string | null;
}
export interface SquareHoleShapeOption {
optionId: string;
shapeKind: string;
label: string;
imagePrompt: string;
imageSrc?: string | null;
}
export interface SquareHoleHoleOption {
holeId: string;
holeKind: string;
label: string;
bonus: boolean;
}
export interface SquareHoleAnchorItemResponse {
key: string;
label: string;
@@ -54,6 +69,11 @@ export interface SquareHoleCreatorConfig {
twistRule: string;
shapeCount: number;
difficulty: number;
shapeOptions: SquareHoleShapeOption[];
holeOptions: SquareHoleHoleOption[];
backgroundPrompt: string;
coverImageSrc?: string | null;
backgroundImageSrc?: string | null;
}
export interface SquareHoleResultDraft {
@@ -63,6 +83,11 @@ export interface SquareHoleResultDraft {
twistRule: string;
summary: string;
tags: string[];
coverImageSrc?: string | null;
backgroundPrompt: string;
backgroundImageSrc?: string | null;
shapeOptions: SquareHoleShapeOption[];
holeOptions: SquareHoleHoleOption[];
shapeCount: number;
difficulty: number;
publishReady: boolean;
@@ -37,6 +37,7 @@ export interface SquareHoleShapeSnapshot {
shapeKind: SquareHoleShapeKind;
label: string;
color: string;
imageSrc?: string | null;
}
export interface SquareHoleHoleSnapshot {
@@ -45,6 +46,7 @@ export interface SquareHoleHoleSnapshot {
label: string;
x: number;
y: number;
bonus: boolean;
}
export interface SquareHoleRunSnapshot {
@@ -62,6 +64,7 @@ export interface SquareHoleRunSnapshot {
bestCombo: number;
score: number;
ruleLabel: string;
backgroundImageSrc?: string | null;
currentShape?: SquareHoleShapeSnapshot | null;
holes: SquareHoleHoleSnapshot[];
lastFeedback?: SquareHoleDropFeedback | null;
@@ -4,6 +4,21 @@
*/
export type SquareHoleWorkPublicationStatus = 'draft' | 'published' | string;
export interface SquareHoleShapeOption {
optionId: string;
shapeKind: string;
label: string;
imagePrompt: string;
imageSrc?: string | null;
}
export interface SquareHoleHoleOption {
holeId: string;
holeKind: string;
label: string;
bonus: boolean;
}
export interface PutSquareHoleWorkRequest {
gameName: string;
themeText?: string;
@@ -11,6 +26,10 @@ export interface PutSquareHoleWorkRequest {
summary: string;
tags: string[];
coverImageSrc?: string | null;
backgroundPrompt?: string;
backgroundImageSrc?: string | null;
shapeOptions?: SquareHoleShapeOption[];
holeOptions?: SquareHoleHoleOption[];
shapeCount: number;
difficulty: number;
}
@@ -26,6 +45,10 @@ export interface SquareHoleWorkSummary {
summary: string;
tags: string[];
coverImageSrc?: string | null;
backgroundPrompt: string;
backgroundImageSrc?: string | null;
shapeOptions: SquareHoleShapeOption[];
holeOptions: SquareHoleHoleOption[];
shapeCount: number;
difficulty: number;
publicationStatus: SquareHoleWorkPublicationStatus;
+13 -3
View File
@@ -13,9 +13,6 @@ export * from './contracts/puzzleAgentSession';
export * from './contracts/puzzleResultPreview';
export * from './contracts/puzzleRuntimeSession';
export * from './contracts/puzzleWorkSummary';
export * from './contracts/squareHoleAgent';
export * from './contracts/squareHoleRuntime';
export * from './contracts/squareHoleWorks';
export * from './contracts/rpgAgentActions';
export * from './contracts/rpgAgentAnchors';
export * from './contracts/rpgAgentDraft';
@@ -29,6 +26,19 @@ export * from './contracts/rpgRuntimeQuestAssist';
export * from './contracts/rpgRuntimeStoryAction';
export * from './contracts/rpgRuntimeStoryState';
export * from './contracts/runtime';
export * from './contracts/squareHoleAgent';
export * from './contracts/squareHoleRuntime';
export type {
PutSquareHoleWorkRequest,
SquareHoleWorkDetailResponse,
SquareHoleHoleOption as SquareHoleWorkHoleOption,
SquareHoleWorkMutationResponse,
SquareHoleWorkProfile,
SquareHoleWorkPublicationStatus,
SquareHoleShapeOption as SquareHoleWorkShapeOption,
SquareHoleWorksResponse,
SquareHoleWorkSummary,
} from './contracts/squareHoleWorks';
export type * from './contracts/story';
export * from './http';
export * from './llm/narrativeLanguage';
@@ -3,6 +3,8 @@ use serde_json::Value as JsonValue;
use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL;
pub(crate) const CREATION_AGENT_STREAM_REQUEST_TIMEOUT_MS: u64 = 120_000;
#[derive(Clone, Copy, Debug)]
pub(crate) struct CreationAgentLlmTurnErrorMessages<'a> {
pub model_unavailable: &'a str,
@@ -138,6 +140,7 @@ fn build_creation_agent_llm_request(
.with_model(CREATION_TEMPLATE_LLM_MODEL)
.with_responses_api()
.with_web_search(enable_web_search)
.with_request_timeout_ms(CREATION_AGENT_STREAM_REQUEST_TIMEOUT_MS)
}
pub(crate) async fn request_creation_agent_json_turn<E>(
@@ -246,9 +249,9 @@ mod tests {
use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL;
use super::{
CreationAgentLlmTurnErrorMessages, build_creation_agent_llm_request,
extract_reply_text_from_partial_json, is_web_search_tool_unavailable,
parse_json_response_text, stream_creation_agent_json_turn,
CREATION_AGENT_STREAM_REQUEST_TIMEOUT_MS, CreationAgentLlmTurnErrorMessages,
build_creation_agent_llm_request, extract_reply_text_from_partial_json,
is_web_search_tool_unavailable, parse_json_response_text, stream_creation_agent_json_turn,
};
#[test]
@@ -277,6 +280,10 @@ mod tests {
assert_eq!(request.model.as_deref(), Some(CREATION_TEMPLATE_LLM_MODEL));
assert_eq!(request.protocol, platform_llm::LlmTextProtocol::Responses);
assert_eq!(request.messages.len(), 2);
assert_eq!(
request.request_timeout_ms,
Some(CREATION_AGENT_STREAM_REQUEST_TIMEOUT_MS)
);
}
#[test]
+1
View File
@@ -43,6 +43,7 @@ pub async fn proxy_llm_chat_completions(
.collect::<Vec<_>>(),
max_tokens: None,
enable_web_search: false,
request_timeout_ms: None,
};
if payload.stream {
@@ -20,10 +20,12 @@ pub(crate) const SQUARE_HOLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责
2. nextConfig 必须是完整对象,不能只输出 patch
3. replyText 必须是自然中文,不能提“字段”“结构”“JSON”“后端”等内部词
4. replyText 一次最多推进一个最关键问题
5. 如果用户要求自动配置,就直接补齐可发布草稿需要的题材、反差规则、形状数量难度,不要继续提问
5. 如果用户要求自动配置,就直接补齐可发布草稿需要的题材、反差规则、形状数量难度、形状选项、洞口选项和背景提示,不要继续提问
6. 默认核心反差优先使用“方洞万能”或“方洞优先”,但可以根据用户题材包装成更有记忆点的规则
7. progressPercent 范围只能是 0 到 100
8. shapeCount 只能是 6 到 24 的整数,difficulty 只能是 1 到 10 的整数
9. shapeOptions 至少给 6 个,holeOptions 给 3 到 6 个,且至少一个 holeOptions.bonus 为 true
10. imagePrompt 和 backgroundPrompt 必须适合直接生成图片,不要包含 UI、文字、水印或解释
"#;
const SQUARE_HOLE_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输出,不要输出其他文字:
@@ -34,7 +36,24 @@ const SQUARE_HOLE_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输
"themeText": "",
"twistRule": "",
"shapeCount": 12,
"difficulty": 4
"difficulty": 4,
"shapeOptions": [
{
"optionId": "square-block",
"shapeKind": "square",
"label": "方块",
"imagePrompt": "玩具纸箱主题的方块贴纸图,透明背景,明亮可爱,游戏资产"
}
],
"holeOptions": [
{
"holeId": "square-hole",
"holeKind": "square",
"label": "方洞",
"bonus": true
}
],
"backgroundPrompt": "玩具桌面上的纸箱洞板背景,中央留出操作空间"
}
}"#;
@@ -42,8 +61,7 @@ pub(crate) const SQUARE_HOLE_AGENT_JSON_TURN_USER_PROMPT: &str = "请按约定
/// 方洞挑战草稿生成对话提示词脚本。
///
/// 方洞首版只需要四个可写回 SpacetimeDB 的配置项,因此提示词直接围绕配置收束,
/// 不在模型输出层引入额外锚点,避免和当前持久化 schema 产生漂移。
/// 方洞 Agent 负责输出完整玩法配置;后端会继续归一化缺失选项,避免模型偶发漏项导致草稿失败。
pub(crate) fn build_square_hole_agent_prompt(
session: &SquareHoleAgentSessionRecord,
quick_fill_requested: bool,
@@ -53,7 +71,7 @@ pub(crate) fn build_square_hole_agent_prompt(
"\n\n{}",
render_quick_fill_extra_rules(
"当前方洞挑战方向里的题材、反差规则、形状数量和难度",
"不要要求用户再提供洞口、形状、演出或难度信息",
"不要要求用户再提供洞口、形状、背景或难度信息",
"输出完整 nextConfig,直接补齐空缺或仍过于泛化的项",
"生成结果页",
)
@@ -62,7 +80,7 @@ pub(crate) fn build_square_hole_agent_prompt(
String::new()
};
format!(
"模板目标:收束成可试玩、可发布的方洞挑战玩法草稿。{quick_fill_rules}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n是否要求自动配置:{quick_fill_requested_text}\n\n当前配置:\n{current_config}\n\n最近聊天记录:\n{chat_history}\n\n收束要求:\n1. themeText 描述本局的玩具、道具或场景题材,保持短句。\n2. twistRule 描述真实判定规则,优先体现方洞优先或类似反直觉逻辑。\n3. shapeCount 决定单局形状数量,移动端短局建议 8 到 16。\n4. difficulty 决定误导强度和节奏,建议 3 到 7。\n5. 用户给出明确方向时优先吸收并推进,不要机械问完四个问题\n\n{contract}",
"模板目标:收束成可试玩、可发布的方洞挑战玩法草稿。{quick_fill_rules}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n是否要求自动配置:{quick_fill_requested_text}\n\n当前配置:\n{current_config}\n\n最近聊天记录:\n{chat_history}\n\n收束要求:\n1. themeText 描述本局的玩具、道具或场景题材,保持短句。\n2. twistRule 描述真实判定规则,优先体现方洞优先或类似反直觉逻辑。\n3. shapeCount 决定单局形状数量,移动端短局建议 8 到 16。\n4. difficulty 决定误导强度和节奏,建议 3 到 7。\n5. shapeOptions 必须给出至少 6 个可生成贴图的形状候选,每个 imagePrompt 都围绕主题生成。\n6. holeOptions 必须给出 3 到 6 个洞口,创作者可在结果页继续改;至少一个 bonus=true。\n7. backgroundPrompt 用于生成运行态背景,必须描述画面,不要写规则说明。\n8. 用户给出明确方向时优先吸收并推进,不要机械问完所有字段\n\n{contract}",
quick_fill_rules = quick_fill_rules,
turn = session.current_turn.saturating_add(1),
progress = session.progress_percent,
@@ -76,11 +94,42 @@ pub(crate) fn build_square_hole_agent_prompt(
}
fn serialize_square_hole_session_config(session: &SquareHoleAgentSessionRecord) -> String {
let shape_options: Vec<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!({
"themeText": session.config.theme_text,
"twistRule": session.config.twist_rule,
"shapeCount": session.config.shape_count,
"difficulty": session.config.difficulty,
"shapeOptions": shape_options,
"holeOptions": hole_options,
"backgroundPrompt": session.config.background_prompt,
}))
.unwrap_or_else(|_| "{}".to_string())
}
@@ -129,6 +178,11 @@ mod tests {
twist_rule: "方洞万能".to_string(),
shape_count: 12,
difficulty: 4,
shape_options: Vec::new(),
hole_options: Vec::new(),
background_prompt: "积木纸箱桌面背景".to_string(),
cover_image_src: None,
background_image_src: None,
},
draft: None,
messages: vec![message("user", "做成办公室文具版")],
@@ -159,6 +213,9 @@ mod tests {
assert!(prompt.contains("用户刚刚主动要求你自动补充剩余关键字"));
assert!(prompt.contains("不要再继续提问"));
assert!(prompt.contains("nextConfig"));
assert!(prompt.contains("shapeOptions"));
assert!(prompt.contains("holeOptions"));
assert!(prompt.contains("backgroundPrompt"));
assert!(prompt.contains("progressPercent 直接输出为 100"));
}
}
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,7 @@
use module_square_hole::{
SQUARE_HOLE_MAX_DIFFICULTY, SQUARE_HOLE_MAX_SHAPE_COUNT, SQUARE_HOLE_MIN_DIFFICULTY,
SQUARE_HOLE_MIN_SHAPE_COUNT,
SQUARE_HOLE_MIN_SHAPE_COUNT, SquareHoleHoleOption, SquareHoleShapeOption,
default_background_prompt, normalize_hole_options, normalize_shape_options,
};
use platform_llm::LlmClient;
use serde::{Deserialize, Serialize};
@@ -68,6 +69,34 @@ struct SquareHoleAgentConfigOutput {
twist_rule: String,
shape_count: u32,
difficulty: u32,
shape_options: Vec<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>(
@@ -166,20 +195,136 @@ fn parse_model_config(
));
}
let theme_text = read_text_field(value, "themeText")
.unwrap_or_else(|| session.config.theme_text.clone());
let twist_rule = read_text_field(value, "twistRule")
.unwrap_or_else(|| session.config.twist_rule.clone());
let shape_options = parse_shape_options(value, session, &theme_text);
let hole_options = parse_hole_options(value, session);
let background_prompt = read_text_field(value, "backgroundPrompt")
.or_else(|| {
session
.config
.background_prompt
.trim()
.is_empty()
.then(|| default_background_prompt(&theme_text))
})
.unwrap_or_else(|| session.config.background_prompt.clone());
Ok(SquareHoleAgentConfigOutput {
theme_text: read_text_field(value, "themeText")
.unwrap_or_else(|| session.config.theme_text.clone()),
twist_rule: read_text_field(value, "twistRule")
.unwrap_or_else(|| session.config.twist_rule.clone()),
theme_text,
twist_rule,
shape_count: read_u32_field(value, "shapeCount")
.unwrap_or(session.config.shape_count)
.clamp(SQUARE_HOLE_MIN_SHAPE_COUNT, SQUARE_HOLE_MAX_SHAPE_COUNT),
difficulty: read_u32_field(value, "difficulty")
.unwrap_or(session.config.difficulty)
.clamp(SQUARE_HOLE_MIN_DIFFICULTY, SQUARE_HOLE_MAX_DIFFICULTY),
shape_options: shape_options
.into_iter()
.map(SquareHoleAgentShapeOptionOutput::from)
.collect(),
hole_options: hole_options
.into_iter()
.map(SquareHoleAgentHoleOptionOutput::from)
.collect(),
background_prompt,
cover_image_src: session.config.cover_image_src.clone().unwrap_or_default(),
background_image_src: session
.config
.background_image_src
.clone()
.unwrap_or_default(),
})
}
fn parse_shape_options(
value: &JsonValue,
session: &SquareHoleAgentSessionRecord,
theme_text: &str,
) -> Vec<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> {
value
.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())
}
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 {
if progress_percent >= 100 {
"ReadyToCompile"
@@ -228,6 +429,11 @@ mod tests {
twist_rule: "方洞万能".to_string(),
shape_count: 12,
difficulty: 4,
shape_options: Vec::new(),
hole_options: Vec::new(),
background_prompt: "纸箱玩具桌面背景".to_string(),
cover_image_src: None,
background_image_src: None,
},
draft: None,
messages: Vec::new(),
@@ -260,7 +466,24 @@ mod tests {
"themeText": "办公室文具",
"twistRule": "所有文具最终都优先进入方洞",
"shapeCount": 14,
"difficulty": 6
"difficulty": 6,
"shapeOptions": [
{
"optionId": "stamp",
"shapeKind": "circle",
"label": "圆形印章",
"imagePrompt": "办公室圆形印章贴纸"
}
],
"holeOptions": [
{
"holeId": "folder",
"holeKind": "square",
"label": "档案盒方洞",
"bonus": true
}
],
"backgroundPrompt": "办公室桌面纸箱玩具背景"
}
});
@@ -276,6 +499,14 @@ mod tests {
assert_eq!(output.next_config.twist_rule, "所有文具最终都优先进入方洞");
assert_eq!(output.next_config.shape_count, 14);
assert_eq!(output.next_config.difficulty, 6);
assert!(output.next_config.shape_options.len() >= 6);
assert_eq!(output.next_config.shape_options[0].label, "圆形印章");
assert_eq!(output.next_config.hole_options[0].label, "档案盒方洞");
assert!(output.next_config.hole_options[0].bonus);
assert_eq!(
output.next_config.background_prompt,
"办公室桌面纸箱玩具背景"
);
}
#[test]
@@ -287,7 +518,10 @@ mod tests {
"themeText": "霓虹积木",
"twistRule": "方洞优先",
"shapeCount": 99,
"difficulty": 0
"difficulty": 0,
"shapeOptions": [],
"holeOptions": [],
"backgroundPrompt": ""
}
});
@@ -2,11 +2,13 @@ use shared_kernel::{normalize_optional_string, normalize_required_string, normal
use crate::commands::{default_tags_for_theme, validate_publish_requirements};
use crate::{
SQUARE_HOLE_DEFAULT_DURATION_LIMIT_MS, SQUARE_HOLE_MAX_DIFFICULTY, SQUARE_HOLE_MIN_DIFFICULTY,
SquareHoleCreatorConfig, SquareHoleDropConfirmation, SquareHoleDropFeedback,
SquareHoleDropInput, SquareHoleDropRejectReason, SquareHoleError, SquareHoleHoleSnapshot,
SQUARE_HOLE_DEFAULT_DURATION_LIMIT_MS, SQUARE_HOLE_MAX_DIFFICULTY,
SQUARE_HOLE_MAX_HOLE_OPTION_COUNT, SQUARE_HOLE_MIN_DIFFICULTY,
SQUARE_HOLE_MIN_HOLE_OPTION_COUNT, SQUARE_HOLE_MIN_SHAPE_OPTION_COUNT, SquareHoleCreatorConfig,
SquareHoleDropConfirmation, SquareHoleDropFeedback, SquareHoleDropInput,
SquareHoleDropRejectReason, SquareHoleError, SquareHoleHoleOption, SquareHoleHoleSnapshot,
SquareHolePublicationStatus, SquareHoleResultDraft, SquareHoleRunSnapshot, SquareHoleRunStatus,
SquareHoleShapeSnapshot, SquareHoleWorkProfile,
SquareHoleShapeOption, SquareHoleShapeSnapshot, SquareHoleWorkProfile,
};
pub fn compile_result_draft(
@@ -14,6 +16,10 @@ pub fn compile_result_draft(
config: &SquareHoleCreatorConfig,
) -> SquareHoleResultDraft {
let game_name = format!("{}方洞挑战", config.theme_text);
let shape_options = normalize_shape_options(config.shape_options.clone(), &config.theme_text);
let hole_options = normalize_hole_options(config.hole_options.clone());
let background_prompt = normalize_required_string(&config.background_prompt)
.unwrap_or_else(|| default_background_prompt(&config.theme_text));
let summary = format!(
"{}主题,{} 个形状,难度 {},真实规则:{}",
config.theme_text, config.shape_count, config.difficulty, config.twist_rule
@@ -25,6 +31,11 @@ pub fn compile_result_draft(
twist_rule: config.twist_rule.clone(),
summary,
tags: default_tags_for_theme(&config.theme_text),
cover_image_src: config.cover_image_src.clone(),
background_prompt,
background_image_src: config.background_image_src.clone(),
shape_options,
hole_options,
shape_count: config.shape_count,
difficulty: config.difficulty,
publish_ready: false,
@@ -59,7 +70,11 @@ pub fn create_work_profile(
twist_rule: draft.twist_rule.clone(),
summary: draft.summary.clone(),
tags: normalize_string_list(draft.tags.clone()),
cover_image_src: None,
cover_image_src: draft.cover_image_src.clone(),
background_prompt: draft.background_prompt.clone(),
background_image_src: draft.background_image_src.clone(),
shape_options: normalize_shape_options(draft.shape_options.clone(), &draft.theme_text),
hole_options: normalize_hole_options(draft.hole_options.clone()),
shape_count: draft.shape_count,
difficulty: draft.difficulty,
publication_status: SquareHolePublicationStatus::Draft,
@@ -99,6 +114,7 @@ pub fn start_run_at(
normalize_required_string(owner_user_id).ok_or(SquareHoleError::MissingOwnerUserId)?;
let profile_id =
normalize_required_string(profile_id).ok_or(SquareHoleError::MissingProfileId)?;
let shape_options = normalize_shape_options(config.shape_options.clone(), &config.theme_text);
Ok(SquareHoleRunSnapshot {
run_id,
@@ -115,8 +131,14 @@ pub fn start_run_at(
best_combo: 0,
score: 0,
rule_label: config.twist_rule.clone(),
current_shape: Some(build_shape_at(0, config.shape_count)),
holes: default_holes(),
background_image_src: config.background_image_src.clone(),
current_shape: Some(build_shape_at(
0,
config.shape_count,
shape_options.as_slice(),
)),
shape_options,
holes: build_holes(config.hole_options.as_slice()),
last_feedback: None,
})
}
@@ -160,14 +182,18 @@ pub fn confirm_drop_at(
next.completed_shape_count = next.completed_shape_count.saturating_add(1);
next.combo = next.combo.saturating_add(1);
next.best_combo = next.best_combo.max(next.combo);
next.score = next.score.saturating_add(100 + next.combo * 10);
let bonus_score = if hole.bonus { 50 } else { 0 };
next.score = next
.score
.saturating_add(100 + next.combo * 10 + bonus_score);
next.current_shape = if next.completed_shape_count >= next.total_shape_count {
next.status = SquareHoleRunStatus::Won;
None
} else {
Some(build_shape_at(
Some(build_shape_from_previous_options(
next.completed_shape_count,
next.total_shape_count,
next.shape_options.as_slice(),
))
};
next.snapshot_version = next.snapshot_version.saturating_add(1);
@@ -216,7 +242,23 @@ pub fn stop_run_at(run: &SquareHoleRunSnapshot) -> SquareHoleRunSnapshot {
next
}
pub fn build_shape_at(index: u32, total: u32) -> SquareHoleShapeSnapshot {
pub fn build_shape_at(
index: u32,
total: u32,
options: &[SquareHoleShapeOption],
) -> SquareHoleShapeSnapshot {
if let Some(option) = pick_shape_option(index, options) {
let shape_kind = option.shape_kind;
let label = option.label;
return SquareHoleShapeSnapshot {
shape_id: format!("square-hole-shape-{index:03}"),
color: fallback_shape_color(&shape_kind).to_string(),
shape_kind,
label,
image_src: option.image_src,
};
}
let kind = if index + 1 == total {
"square"
} else if index % 4 == 0 {
@@ -248,6 +290,7 @@ pub fn build_shape_at(index: u32, total: u32) -> SquareHoleShapeSnapshot {
_ => "#c084fc",
}
.to_string(),
image_src: None,
}
}
@@ -259,6 +302,7 @@ pub fn default_holes() -> Vec<SquareHoleHoleSnapshot> {
label: "方洞".to_string(),
x: 0.5,
y: 0.28,
bonus: true,
},
SquareHoleHoleSnapshot {
hole_id: "circle-hole".to_string(),
@@ -266,6 +310,7 @@ pub fn default_holes() -> Vec<SquareHoleHoleSnapshot> {
label: "圆洞".to_string(),
x: 0.24,
y: 0.54,
bonus: false,
},
SquareHoleHoleSnapshot {
hole_id: "triangle-hole".to_string(),
@@ -273,10 +318,240 @@ pub fn default_holes() -> Vec<SquareHoleHoleSnapshot> {
label: "三角洞".to_string(),
x: 0.76,
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(
shape: &SquareHoleShapeSnapshot,
hole: &SquareHoleHoleSnapshot,
@@ -315,6 +590,32 @@ mod tests {
build_creator_config("玩具", "方洞万能", shape_count, 4).expect("config should be valid")
}
fn test_config_with_bonus_hole(shape_count: u32) -> SquareHoleCreatorConfig {
SquareHoleCreatorConfig {
hole_options: vec![
SquareHoleHoleOption {
hole_id: "square-hole".to_string(),
hole_kind: "square".to_string(),
label: "方洞".to_string(),
bonus: true,
},
SquareHoleHoleOption {
hole_id: "circle-hole".to_string(),
hole_kind: "circle".to_string(),
label: "圆洞".to_string(),
bonus: false,
},
SquareHoleHoleOption {
hole_id: "triangle-hole".to_string(),
hole_kind: "triangle".to_string(),
label: "三角洞".to_string(),
bonus: false,
},
],
..test_config(shape_count)
}
}
#[test]
fn draft_is_publishable_with_required_fields() {
let draft = compile_result_draft("profile-1".to_string(), &test_config(8));
@@ -368,6 +669,33 @@ mod tests {
assert_eq!(result.run.combo, 1);
}
#[test]
fn bonus_hole_adds_extra_score_when_accepted() {
let run = start_run_at(
"run-1".to_string(),
"user-1".to_string(),
"profile-1".to_string(),
&test_config_with_bonus_hole(8),
1_000,
)
.expect("run should start");
let result = confirm_drop_at(
&run,
&SquareHoleDropInput {
run_id: run.run_id.clone(),
owner_user_id: run.owner_user_id.clone(),
hole_id: "square-hole".to_string(),
client_snapshot_version: run.snapshot_version,
client_event_id: "event-1".to_string(),
dropped_at_ms: 1_100,
},
)
.expect("drop should resolve");
assert!(result.feedback.accepted);
assert_eq!(result.run.score, 160);
}
#[test]
fn wrong_non_square_hole_rejects_and_resets_combo() {
let mut run = start_run_at(
@@ -378,7 +706,7 @@ mod tests {
1_000,
)
.expect("run should start");
run.current_shape = Some(build_shape_at(1, 8));
run.current_shape = Some(build_shape_at(1, 8, &[]));
run.combo = 2;
let result = confirm_drop_at(
@@ -413,7 +741,7 @@ mod tests {
)
.expect("run should start");
run.completed_shape_count = 5;
run.current_shape = Some(build_shape_at(5, 6));
run.current_shape = Some(build_shape_at(5, 6, &[]));
let result = confirm_drop_at(
&run,
@@ -3,6 +3,7 @@ use shared_kernel::{normalize_required_string, normalize_string_list};
use crate::{
SQUARE_HOLE_MAX_DIFFICULTY, SQUARE_HOLE_MAX_SHAPE_COUNT, SQUARE_HOLE_MIN_DIFFICULTY,
SQUARE_HOLE_MIN_SHAPE_COUNT, SquareHoleCreatorConfig, SquareHoleError, SquareHoleResultDraft,
normalize_hole_options, normalize_shape_options,
};
pub fn validate_shape_count(value: u32) -> Result<u32, SquareHoleError> {
@@ -36,6 +37,11 @@ pub fn build_creator_config(
twist_rule: normalize_required_string(twist_rule).ok_or(SquareHoleError::MissingText)?,
shape_count: validate_shape_count(shape_count)?,
difficulty: validate_difficulty(difficulty)?,
shape_options: normalize_shape_options(Vec::new(), theme_text),
hole_options: normalize_hole_options(Vec::new()),
background_prompt: format!("{theme_text}主题的竖屏游戏背景,舞台中央有多个形状洞口"),
cover_image_src: None,
background_image_src: None,
})
}
@@ -84,6 +90,12 @@ pub fn validate_publish_requirements(draft: &SquareHoleResultDraft) -> Vec<Strin
if validate_difficulty(draft.difficulty).is_err() {
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
}
@@ -104,10 +116,15 @@ pub fn build_result_draft(
SquareHoleResultDraft {
profile_id,
game_name,
theme_text,
theme_text: theme_text.clone(),
twist_rule,
summary,
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,
difficulty,
publish_ready: true,
@@ -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_MAX_DIFFICULTY: u32 = 10;
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))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -55,6 +58,37 @@ pub struct SquareHoleCreatorConfig {
pub twist_rule: String,
pub shape_count: 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))]
@@ -66,6 +100,16 @@ pub struct SquareHoleResultDraft {
pub twist_rule: String,
pub summary: 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 difficulty: u32,
pub publish_ready: bool,
@@ -85,6 +129,10 @@ pub struct SquareHoleWorkProfile {
pub summary: 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<SquareHoleShapeOption>,
pub hole_options: Vec<SquareHoleHoleOption>,
pub shape_count: u32,
pub difficulty: u32,
pub publication_status: SquareHolePublicationStatus,
@@ -100,6 +148,8 @@ pub struct SquareHoleShapeSnapshot {
pub shape_kind: String,
pub label: String,
pub color: String,
#[serde(default)]
pub image_src: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -110,6 +160,8 @@ pub struct SquareHoleHoleSnapshot {
pub label: String,
pub x: f32,
pub y: f32,
#[serde(default)]
pub bonus: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -137,6 +189,10 @@ pub struct SquareHoleRunSnapshot {
pub best_combo: u32,
pub score: u32,
pub rule_label: String,
#[serde(default)]
pub background_image_src: Option<String>,
#[serde(default)]
pub shape_options: Vec<SquareHoleShapeOption>,
pub current_shape: Option<SquareHoleShapeSnapshot>,
pub holes: Vec<SquareHoleHoleSnapshot>,
pub last_feedback: Option<SquareHoleDropFeedback>,
+66 -1
View File
@@ -68,6 +68,7 @@ pub struct LlmTextRequest {
pub max_tokens: Option<u32>,
pub enable_web_search: bool,
pub protocol: LlmTextProtocol,
pub request_timeout_ms: Option<u64>,
}
// 文本协议必须由业务请求显式选择,避免全局默认模型把不同场景混到同一上游形态。
@@ -421,6 +422,7 @@ impl LlmTextRequest {
max_tokens: None,
enable_web_search: false,
protocol: LlmTextProtocol::ChatCompletions,
request_timeout_ms: None,
}
}
@@ -451,6 +453,11 @@ impl LlmTextRequest {
self
}
pub fn with_request_timeout_ms(mut self, request_timeout_ms: u64) -> Self {
self.request_timeout_ms = Some(request_timeout_ms);
self
}
fn validate(&self) -> Result<(), LlmError> {
if self.messages.is_empty() {
return Err(LlmError::InvalidRequest(
@@ -474,6 +481,14 @@ impl LlmTextRequest {
));
}
if let Some(request_timeout_ms) = self.request_timeout_ms
&& request_timeout_ms == 0
{
return Err(LlmError::InvalidRequest(
"LLM request_timeout_ms 必须大于 0".to_string(),
));
}
Ok(())
}
@@ -484,6 +499,12 @@ impl LlmTextRequest {
.filter(|value| !value.is_empty())
.unwrap_or(fallback_model)
}
fn resolved_request_timeout_ms(&self, fallback_timeout_ms: u64) -> u64 {
self.request_timeout_ms
.filter(|value| *value > 0)
.unwrap_or(fallback_timeout_ms)
}
}
impl LlmTextProtocol {
@@ -825,7 +846,9 @@ impl LlmClient {
.post(url.as_str())
.bearer_auth(self.config.api_key())
.json(&request_body)
.timeout(Duration::from_millis(self.config.request_timeout_ms()))
.timeout(Duration::from_millis(
request.resolved_request_timeout_ms(self.config.request_timeout_ms()),
))
.send()
.await;
@@ -1592,6 +1615,48 @@ mod tests {
assert_eq!(response.response_id.as_deref(), Some("resp_retry"));
}
#[tokio::test]
async fn request_text_uses_request_level_timeout_override() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let address = listener.local_addr().expect("listener should have addr");
thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("request should connect");
let _ = read_request(&mut stream);
thread::sleep(StdDuration::from_millis(200));
write_response(
&mut stream,
MockResponse {
status_line: "200 OK",
content_type: "application/json; charset=utf-8",
body: r#"{"choices":[{"message":{"content":"too late"},"finish_reason":"stop"}]}"#
.to_string(),
extra_headers: Vec::new(),
},
);
});
let config = LlmConfig::new(
LlmProvider::Ark,
format!("http://{address}"),
"test-key".to_string(),
"test-model".to_string(),
10_000,
0,
1,
)
.expect("config should be valid");
let client = LlmClient::new(config).expect("client should be created");
let error = client
.request_text(
LlmTextRequest::single_turn("系统", "用户").with_request_timeout_ms(20),
)
.await
.expect_err("request override should timeout before the global timeout");
assert_eq!(error, LlmError::Timeout { attempts: 1 });
}
#[tokio::test]
async fn request_text_sends_web_search_options_when_enabled() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
@@ -38,6 +38,26 @@ pub struct ExecuteSquareHoleActionRequest {
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)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleAnchorItemResponse {
@@ -63,6 +83,16 @@ pub struct SquareHoleCreatorConfigResponse {
pub twist_rule: String,
pub shape_count: u32,
pub difficulty: u32,
#[serde(default)]
pub shape_options: Vec<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)]
@@ -74,6 +104,16 @@ pub struct SquareHoleResultDraftResponse {
pub twist_rule: String,
pub summary: 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 difficulty: u32,
pub publish_ready: bool,
@@ -30,6 +30,8 @@ pub struct SquareHoleShapeSnapshotResponse {
pub shape_kind: String,
pub label: String,
pub color: String,
#[serde(default)]
pub image_src: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -40,6 +42,7 @@ pub struct SquareHoleHoleSnapshotResponse {
pub label: String,
pub x: f32,
pub y: f32,
pub bonus: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -69,6 +72,8 @@ pub struct SquareHoleRunSnapshotResponse {
pub score: u32,
pub rule_label: String,
#[serde(default)]
pub background_image_src: Option<String>,
#[serde(default)]
pub current_shape: Option<SquareHoleShapeSnapshotResponse>,
pub holes: Vec<SquareHoleHoleSnapshotResponse>,
#[serde(default)]
@@ -1,5 +1,25 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleShapeOptionResponse {
pub option_id: String,
pub shape_kind: String,
pub label: String,
pub image_prompt: String,
#[serde(default)]
pub image_src: Option<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)]
#[serde(rename_all = "camelCase")]
pub struct PutSquareHoleWorkRequest {
@@ -11,6 +31,14 @@ pub struct PutSquareHoleWorkRequest {
pub tags: Vec<String>,
#[serde(default)]
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 difficulty: u32,
}
@@ -30,6 +58,14 @@ pub struct SquareHoleWorkSummaryResponse {
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 difficulty: u32,
pub publication_status: String,
+9 -10
View File
@@ -55,11 +55,11 @@ pub use mapper::{
SquareHoleAgentMessageSubmitRecordInput, SquareHoleAgentSessionCreateRecordInput,
SquareHoleAgentSessionRecord, SquareHoleAnchorItemRecord, SquareHoleAnchorPackRecord,
SquareHoleCompileDraftRecordInput, SquareHoleCreatorConfigRecord,
SquareHoleDropConfirmationRecord, SquareHoleDropFeedbackRecord, SquareHoleHoleSnapshotRecord,
SquareHoleResultDraftRecord, SquareHoleRunDropRecordInput, SquareHoleRunRecord,
SquareHoleRunRestartRecordInput, SquareHoleRunStartRecordInput, SquareHoleRunStopRecordInput,
SquareHoleRunTimeUpRecordInput, SquareHoleShapeSnapshotRecord, SquareHoleWorkProfileRecord,
SquareHoleWorkUpdateRecordInput,
SquareHoleDropConfirmationRecord, SquareHoleDropFeedbackRecord, SquareHoleHoleOptionRecord,
SquareHoleHoleSnapshotRecord, SquareHoleResultDraftRecord, SquareHoleRunDropRecordInput,
SquareHoleRunRecord, SquareHoleRunRestartRecordInput, SquareHoleRunStartRecordInput,
SquareHoleRunStopRecordInput, SquareHoleRunTimeUpRecordInput, SquareHoleShapeOptionRecord,
SquareHoleShapeSnapshotRecord, SquareHoleWorkProfileRecord, SquareHoleWorkUpdateRecordInput,
};
pub mod ai;
@@ -144,9 +144,9 @@ use module_puzzle::{
PuzzleWorkProfile as DomainPuzzleWorkProfile,
};
use module_runtime::{
RuntimeBrowseHistoryRecord, RuntimePlatformTheme as DomainRuntimePlatformTheme,
RuntimeProfileDashboardRecord, RuntimeProfileInviteCodeRecord, RuntimeProfilePlayStatsRecord,
AnalyticsMetricQueryResponse as DomainAnalyticsMetricQueryResponse,
AnalyticsMetricQueryResponse as DomainAnalyticsMetricQueryResponse, RuntimeBrowseHistoryRecord,
RuntimePlatformTheme as DomainRuntimePlatformTheme, RuntimeProfileDashboardRecord,
RuntimeProfileInviteCodeRecord, RuntimeProfilePlayStatsRecord,
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode,
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
@@ -155,7 +155,7 @@ use module_runtime::{
RuntimeProfileTaskStatus as DomainRuntimeProfileTaskStatus,
RuntimeProfileWalletLedgerEntryRecord, RuntimeReferralInviteCenterRecord,
RuntimeReferralRedeemRecord, RuntimeSettingsRecord, RuntimeSnapshotRecord,
RuntimeTrackingScopeKind as DomainRuntimeTrackingScopeKind,
RuntimeTrackingScopeKind as DomainRuntimeTrackingScopeKind, build_analytics_metric_query_input,
build_runtime_browse_history_clear_input, build_runtime_browse_history_list_input,
build_runtime_browse_history_record, build_runtime_browse_history_sync_input,
build_runtime_profile_dashboard_get_input, build_runtime_profile_dashboard_record,
@@ -178,7 +178,6 @@ use module_runtime::{
build_runtime_profile_wallet_adjustment_input,
build_runtime_profile_wallet_ledger_entry_record,
build_runtime_profile_wallet_ledger_list_input, build_runtime_referral_invite_center_get_input,
build_analytics_metric_query_input,
build_runtime_referral_invite_center_record, build_runtime_referral_redeem_input,
build_runtime_referral_redeem_record, build_runtime_setting_get_input,
build_runtime_setting_record, build_runtime_setting_upsert_input,
+182 -25
View File
@@ -1585,14 +1585,9 @@ pub(crate) fn map_square_hole_agent_session_procedure_result(
let session_json = result
.session_json
.ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole agent session 快照"))?;
let session =
serde_json::from_str::<SquareHoleAgentSessionJsonRecord>(&session_json).map_err(
|error| {
SpacetimeClientError::Runtime(format!(
"square hole session_json 非法: {error}"
))
},
)?;
let session = serde_json::from_str::<SquareHoleAgentSessionJsonRecord>(&session_json).map_err(
|error| SpacetimeClientError::Runtime(format!("square hole session_json 非法: {error}")),
)?;
Ok(map_square_hole_agent_session_snapshot(session))
}
@@ -1624,13 +1619,15 @@ pub(crate) fn map_square_hole_works_procedure_result(
let items_json = result
.items_json
.ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole works 快照"))?;
let items = serde_json::from_str::<Vec<SquareHoleWorkJsonRecord>>(&items_json).map_err(
|error| {
let items =
serde_json::from_str::<Vec<SquareHoleWorkJsonRecord>>(&items_json).map_err(|error| {
SpacetimeClientError::Runtime(format!("square hole works items_json 非法: {error}"))
},
)?;
})?;
Ok(items.into_iter().map(map_square_hole_work_snapshot).collect())
Ok(items
.into_iter()
.map(map_square_hole_work_snapshot)
.collect())
}
pub(crate) fn map_square_hole_run_procedure_result(
@@ -1656,18 +1653,14 @@ pub(crate) fn map_square_hole_drop_shape_procedure_result(
let run_json = result
.run_json
.ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole drop run 快照"))?;
let feedback_json = result.feedback_json.ok_or_else(|| {
SpacetimeClientError::missing_snapshot("square hole drop feedback 快照")
})?;
let feedback_json = result
.feedback_json
.ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole drop feedback 快照"))?;
let run = map_square_hole_run_json(run_json)?;
let feedback =
serde_json::from_str::<SquareHoleDropFeedbackJsonRecord>(&feedback_json).map_err(
|error| {
SpacetimeClientError::Runtime(format!(
"square hole feedback_json 非法: {error}"
))
},
)?;
let feedback = serde_json::from_str::<SquareHoleDropFeedbackJsonRecord>(&feedback_json)
.map_err(|error| {
SpacetimeClientError::Runtime(format!("square hole feedback_json 非法: {error}"))
})?;
Ok(SquareHoleDropConfirmationRecord {
status: result.status,
@@ -2950,6 +2943,19 @@ fn map_square_hole_creator_config(
twist_rule: snapshot.twist_rule,
shape_count: snapshot.shape_count,
difficulty: snapshot.difficulty,
shape_options: snapshot
.shape_options
.into_iter()
.map(map_square_hole_shape_option)
.collect(),
hole_options: snapshot
.hole_options
.into_iter()
.map(map_square_hole_hole_option)
.collect(),
background_prompt: snapshot.background_prompt,
cover_image_src: empty_string_to_none(snapshot.cover_image_src),
background_image_src: empty_string_to_none(snapshot.background_image_src),
}
}
@@ -2963,6 +2969,19 @@ fn map_square_hole_result_draft(
twist_rule: snapshot.twist_rule,
summary: snapshot.summary_text,
tags: snapshot.tags,
cover_image_src: empty_string_to_none(snapshot.cover_image_src),
background_prompt: snapshot.background_prompt,
background_image_src: empty_string_to_none(snapshot.background_image_src),
shape_options: snapshot
.shape_options
.into_iter()
.map(map_square_hole_shape_option)
.collect(),
hole_options: snapshot
.hole_options
.into_iter()
.map(map_square_hole_hole_option)
.collect(),
shape_count: snapshot.shape_count,
difficulty: snapshot.difficulty,
publish_ready: false,
@@ -2997,6 +3016,18 @@ fn map_square_hole_work_snapshot(
summary: snapshot.summary_text,
tags: snapshot.tags,
cover_image_src: empty_string_to_none(snapshot.cover_image_src),
background_prompt: snapshot.background_prompt,
background_image_src: empty_string_to_none(snapshot.background_image_src),
shape_options: snapshot
.shape_options
.into_iter()
.map(map_square_hole_shape_option)
.collect(),
hole_options: snapshot
.hole_options
.into_iter()
.map(map_square_hole_hole_option)
.collect(),
shape_count: snapshot.shape_count,
difficulty: snapshot.difficulty,
publication_status: normalize_square_hole_publication_status(&snapshot.publication_status)
@@ -3032,6 +3063,7 @@ fn map_square_hole_run_snapshot(snapshot: SquareHoleRunJsonRecord) -> SquareHole
best_combo: snapshot.best_combo,
score: snapshot.score,
rule_label: snapshot.rule_label,
background_image_src: empty_string_to_none(snapshot.background_image_src),
current_shape: snapshot.current_shape.map(map_square_hole_shape_snapshot),
holes: snapshot
.holes
@@ -3053,6 +3085,7 @@ fn map_square_hole_shape_snapshot(
shape_kind: snapshot.shape_kind,
label: snapshot.label,
color: snapshot.color,
image_src: empty_string_to_none(snapshot.image_src),
}
}
@@ -3065,6 +3098,30 @@ fn map_square_hole_hole_snapshot(
label: snapshot.label,
x: snapshot.x,
y: snapshot.y,
bonus: snapshot.bonus,
}
}
fn map_square_hole_shape_option(
snapshot: SquareHoleShapeOptionJsonRecord,
) -> SquareHoleShapeOptionRecord {
SquareHoleShapeOptionRecord {
option_id: snapshot.option_id,
shape_kind: snapshot.shape_kind,
label: snapshot.label,
image_prompt: snapshot.image_prompt,
image_src: empty_string_to_none(snapshot.image_src),
}
}
fn map_square_hole_hole_option(
snapshot: SquareHoleHoleOptionJsonRecord,
) -> SquareHoleHoleOptionRecord {
SquareHoleHoleOptionRecord {
hole_id: snapshot.hole_id,
hole_kind: snapshot.hole_kind,
label: snapshot.label,
bonus: snapshot.bonus,
}
}
@@ -3087,7 +3144,11 @@ fn build_square_hole_anchor_pack(
let difficulty = config.difficulty.to_string();
SquareHoleAnchorPackRecord {
theme: build_square_hole_anchor_item("theme", "题材主题", config.theme_text.as_str()),
twist_rule: build_square_hole_anchor_item("twistRule", "反差规则", config.twist_rule.as_str()),
twist_rule: build_square_hole_anchor_item(
"twistRule",
"反差规则",
config.twist_rule.as_str(),
),
shape_count: build_square_hole_anchor_item("shapeCount", "形状数量", shape_count.as_str()),
difficulty: build_square_hole_anchor_item("difficulty", "难度", difficulty.as_str()),
}
@@ -5992,6 +6053,10 @@ pub struct SquareHoleWorkUpdateRecordInput {
pub summary_text: String,
pub tags_json: String,
pub cover_image_src: String,
pub background_prompt: String,
pub background_image_src: String,
pub shape_options_json: String,
pub hole_options_json: String,
pub shape_count: u32,
pub difficulty: u32,
pub updated_at_micros: i64,
@@ -6059,6 +6124,28 @@ pub struct SquareHoleCreatorConfigRecord {
pub twist_rule: String,
pub shape_count: u32,
pub difficulty: u32,
pub shape_options: Vec<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)]
@@ -6069,6 +6156,11 @@ pub struct SquareHoleResultDraftRecord {
pub twist_rule: String,
pub summary: 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 difficulty: u32,
pub publish_ready: bool,
@@ -6112,6 +6204,10 @@ pub struct SquareHoleWorkProfileRecord {
pub summary: 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 difficulty: u32,
pub publication_status: String,
@@ -6127,6 +6223,7 @@ pub struct SquareHoleShapeSnapshotRecord {
pub shape_kind: String,
pub label: String,
pub color: String,
pub image_src: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
@@ -6136,6 +6233,7 @@ pub struct SquareHoleHoleSnapshotRecord {
pub label: String,
pub x: f32,
pub y: f32,
pub bonus: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -6162,6 +6260,7 @@ pub struct SquareHoleRunRecord {
pub best_combo: u32,
pub score: u32,
pub rule_label: String,
pub background_image_src: Option<String>,
pub current_shape: Option<SquareHoleShapeSnapshotRecord>,
pub holes: Vec<SquareHoleHoleSnapshotRecord>,
pub last_feedback: Option<SquareHoleDropFeedbackRecord>,
@@ -6185,6 +6284,37 @@ struct SquareHoleCreatorConfigJsonRecord {
twist_rule: String,
shape_count: 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)]
@@ -6208,6 +6338,16 @@ struct SquareHoleDraftJsonRecord {
twist_rule: String,
summary_text: 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,
difficulty: u32,
}
@@ -6247,6 +6387,14 @@ struct SquareHoleWorkJsonRecord {
summary_text: String,
tags: Vec<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,
difficulty: u32,
#[allow(dead_code)]
@@ -6265,6 +6413,8 @@ struct SquareHoleShapeJsonRecord {
shape_kind: String,
label: String,
color: String,
#[serde(default)]
image_src: String,
}
#[derive(Clone, Debug, PartialEq, serde::Deserialize)]
@@ -6275,6 +6425,8 @@ struct SquareHoleHoleJsonRecord {
label: String,
x: f32,
y: f32,
#[serde(default)]
bonus: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
@@ -6303,6 +6455,11 @@ struct SquareHoleRunJsonRecord {
best_combo: u32,
score: u32,
rule_label: String,
#[serde(default)]
background_image_src: String,
#[serde(default)]
#[allow(dead_code)]
shape_options: Vec<SquareHoleShapeOptionJsonRecord>,
current_shape: Option<SquareHoleShapeJsonRecord>,
holes: Vec<SquareHoleHoleJsonRecord>,
last_feedback: Option<SquareHoleDropFeedbackJsonRecord>,
@@ -15,6 +15,10 @@ pub struct SquareHoleWorkUpdateInput {
pub summary_text: String,
pub tags_json: String,
pub cover_image_src: String,
pub background_prompt: String,
pub background_image_src: String,
pub shape_options_json: String,
pub hole_options_json: String,
pub shape_count: u32,
pub difficulty: u32,
pub updated_at_micros: i64,
@@ -17,15 +17,14 @@ impl SpacetimeClient {
};
self.call_after_connect(move |connection, sender| {
connection.procedures().create_square_hole_agent_session_then(
procedure_input,
move |_, result| {
connection
.procedures()
.create_square_hole_agent_session_then(procedure_input, move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_square_hole_agent_session_procedure_result);
send_once(&sender, mapped);
},
);
});
})
.await
}
@@ -67,15 +66,14 @@ impl SpacetimeClient {
};
self.call_after_connect(move |connection, sender| {
connection.procedures().submit_square_hole_agent_message_then(
procedure_input,
move |_, result| {
connection
.procedures()
.submit_square_hole_agent_message_then(procedure_input, move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_square_hole_agent_session_procedure_result);
send_once(&sender, mapped);
},
);
});
})
.await
}
@@ -126,14 +124,15 @@ impl SpacetimeClient {
};
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.compile_square_hole_draft_then(procedure_input, move |_, result| {
connection.procedures().compile_square_hole_draft_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_square_hole_agent_session_procedure_result);
send_once(&sender, mapped);
});
},
);
})
.await
}
@@ -151,20 +150,25 @@ impl SpacetimeClient {
summary_text: input.summary_text,
tags_json: input.tags_json,
cover_image_src: input.cover_image_src,
background_prompt: input.background_prompt,
background_image_src: input.background_image_src,
shape_options_json: input.shape_options_json,
hole_options_json: input.hole_options_json,
shape_count: input.shape_count,
difficulty: input.difficulty,
updated_at_micros: input.updated_at_micros,
};
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.update_square_hole_work_then(procedure_input, move |_, result| {
connection.procedures().update_square_hole_work_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_square_hole_work_procedure_result);
send_once(&sender, mapped);
});
},
);
})
.await
}
@@ -182,14 +186,15 @@ impl SpacetimeClient {
};
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.publish_square_hole_work_then(procedure_input, move |_, result| {
connection.procedures().publish_square_hole_work_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_square_hole_work_procedure_result);
send_once(&sender, mapped);
});
},
);
})
.await
}
@@ -221,14 +226,15 @@ impl SpacetimeClient {
procedure_input: SquareHoleWorksListInput,
) -> Result<Vec<SquareHoleWorkProfileRecord>, SpacetimeClientError> {
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.list_square_hole_works_then(procedure_input, move |_, result| {
connection.procedures().list_square_hole_works_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_square_hole_works_procedure_result);
send_once(&sender, mapped);
});
},
);
})
.await
}
@@ -244,14 +250,15 @@ impl SpacetimeClient {
};
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.get_square_hole_work_detail_then(procedure_input, move |_, result| {
connection.procedures().get_square_hole_work_detail_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_square_hole_work_procedure_result);
send_once(&sender, mapped);
});
},
);
})
.await
}
@@ -267,14 +274,15 @@ impl SpacetimeClient {
};
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.delete_square_hole_work_then(procedure_input, move |_, result| {
connection.procedures().delete_square_hole_work_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_square_hole_works_procedure_result);
send_once(&sender, mapped);
});
},
);
})
.await
}
@@ -291,14 +299,15 @@ impl SpacetimeClient {
};
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.start_square_hole_run_then(procedure_input, move |_, result| {
connection.procedures().start_square_hole_run_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_square_hole_run_procedure_result);
send_once(&sender, mapped);
});
},
);
})
.await
}
@@ -341,21 +350,21 @@ impl SpacetimeClient {
};
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.drop_square_hole_shape_then(procedure_input, move |_, result| {
connection.procedures().drop_square_hole_shape_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_square_hole_drop_shape_procedure_result)
.map(|mut confirmation| {
if confirmation.accepted {
confirmation.run.last_confirmed_action_id =
Some(client_event_id);
confirmation.run.last_confirmed_action_id = Some(client_event_id);
}
confirmation
});
send_once(&sender, mapped);
});
},
);
})
.await
}
@@ -395,14 +404,15 @@ impl SpacetimeClient {
};
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.restart_square_hole_run_then(procedure_input, move |_, result| {
connection.procedures().restart_square_hole_run_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_square_hole_run_procedure_result);
send_once(&sender, mapped);
});
},
);
})
.await
}
@@ -418,14 +428,15 @@ impl SpacetimeClient {
};
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.finish_square_hole_time_up_then(procedure_input, move |_, result| {
connection.procedures().finish_square_hole_time_up_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_square_hole_run_procedure_result);
send_once(&sender, mapped);
});
},
);
})
.await
}
@@ -10,12 +10,17 @@ use module_square_hole::{
SquareHoleDropFeedback as DomainSquareHoleDropFeedback,
SquareHoleDropInput as DomainSquareHoleDropInput,
SquareHoleDropRejectReason as DomainSquareHoleDropRejectReason,
SquareHoleHoleOption as DomainSquareHoleHoleOption,
SquareHoleHoleSnapshot as DomainSquareHoleHoleSnapshot,
SquareHoleRunSnapshot as DomainSquareHoleRunSnapshot,
SquareHoleRunStatus as DomainSquareHoleRunStatus,
SquareHoleShapeOption as DomainSquareHoleShapeOption,
SquareHoleShapeSnapshot as DomainSquareHoleShapeSnapshot,
build_creator_config as build_domain_creator_config,
compile_result_draft as compile_domain_result_draft, confirm_drop_at as confirm_domain_drop_at,
default_background_prompt as default_domain_background_prompt,
normalize_hole_options as normalize_domain_hole_options,
normalize_shape_options as normalize_domain_shape_options,
resolve_run_timer_at as resolve_domain_run_timer_at, start_run_at as start_domain_run_at,
stop_run_at as stop_domain_run_at,
};
@@ -432,6 +437,9 @@ fn compile_square_hole_draft_tx(
domain_draft.blockers = module_square_hole::validate_publish_requirements(&domain_draft);
domain_draft.publish_ready = domain_draft.blockers.is_empty();
let cover_image_src = clean_optional(&input.cover_image_src)
.or_else(|| clean_optional(&Some(config.cover_image_src.clone())))
.unwrap_or_default();
let draft = SquareHoleDraftSnapshot {
profile_id: input.profile_id.clone(),
game_name: domain_draft.game_name.clone(),
@@ -439,6 +447,14 @@ fn compile_square_hole_draft_tx(
twist_rule: domain_draft.twist_rule.clone(),
summary_text: domain_draft.summary.clone(),
tags: domain_draft.tags.clone(),
cover_image_src: cover_image_src.clone(),
background_prompt: domain_draft.background_prompt.clone(),
background_image_src: domain_draft
.background_image_src
.clone()
.unwrap_or_default(),
shape_options: shape_options_to_snapshot(&domain_draft.shape_options),
hole_options: hole_options_to_snapshot(&domain_draft.hole_options),
shape_count: domain_draft.shape_count,
difficulty: domain_draft.difficulty,
};
@@ -454,7 +470,7 @@ fn compile_square_hole_draft_tx(
twist_rule: config.twist_rule.clone(),
summary_text: draft.summary_text.clone(),
tags_json: to_json_string(&draft.tags),
cover_image_src: clean_optional(&input.cover_image_src).unwrap_or_default(),
cover_image_src,
shape_count: config.shape_count,
difficulty: config.difficulty,
config_json: to_json_string(&config),
@@ -493,12 +509,31 @@ fn update_square_hole_work_tx(
) -> Result<SquareHoleWorkSnapshot, String> {
let current = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?;
let tags = parse_tags(&input.tags_json)?;
let config = SquareHoleCreatorConfigSnapshot {
let current_config = parse_config(&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, "玩具"),
twist_rule: clean_string(&input.twist_rule, "方洞万能"),
shape_count: input.shape_count,
difficulty: input.difficulty,
};
shape_options,
hole_options,
background_prompt: clean_string(
&input.background_prompt,
&default_domain_background_prompt(&input.theme_text),
),
cover_image_src: input.cover_image_src.trim().to_string(),
background_image_src: input.background_image_src.trim().to_string(),
});
validate_config(&config)?;
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
let next = SquareHoleWorkProfileRow {
@@ -828,6 +863,10 @@ fn build_work_snapshot(row: &SquareHoleWorkProfileRow) -> Result<SquareHoleWorkS
summary_text: row.summary_text.clone(),
tags: parse_tags(&row.tags_json)?,
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,
difficulty: row.difficulty,
config,
@@ -1040,12 +1079,17 @@ fn is_work_publish_ready(row: &SquareHoleWorkProfileRow) -> bool {
}
fn default_config_from_seed(seed_text: &str) -> SquareHoleCreatorConfigSnapshot {
SquareHoleCreatorConfigSnapshot {
normalize_config(SquareHoleCreatorConfigSnapshot {
theme_text: clean_string(seed_text, "玩具"),
twist_rule: "方洞万能".to_string(),
shape_count: 12,
difficulty: 4,
}
shape_options: Vec::new(),
hole_options: Vec::new(),
background_prompt: String::new(),
cover_image_src: String::new(),
background_image_src: String::new(),
})
}
fn parse_config_or_default(value: &str) -> SquareHoleCreatorConfigSnapshot {
@@ -1053,17 +1097,34 @@ fn parse_config_or_default(value: &str) -> SquareHoleCreatorConfigSnapshot {
}
fn parse_config(value: &str) -> Result<SquareHoleCreatorConfigSnapshot, String> {
parse_json(value, "square_hole config_json").map(
|mut config: SquareHoleCreatorConfigSnapshot| {
config.theme_text = clean_string(&config.theme_text, "玩具");
config.twist_rule = clean_string(&config.twist_rule, "方洞万能");
config.difficulty = config.difficulty.clamp(
module_square_hole::SQUARE_HOLE_MIN_DIFFICULTY,
module_square_hole::SQUARE_HOLE_MAX_DIFFICULTY,
);
config
},
)
parse_json(value, "square_hole config_json").map(normalize_config)
}
fn normalize_config(
mut config: SquareHoleCreatorConfigSnapshot,
) -> SquareHoleCreatorConfigSnapshot {
config.theme_text = clean_string(&config.theme_text, "玩具");
config.twist_rule = clean_string(&config.twist_rule, "方洞万能");
config.difficulty = config.difficulty.clamp(
module_square_hole::SQUARE_HOLE_MIN_DIFFICULTY,
module_square_hole::SQUARE_HOLE_MAX_DIFFICULTY,
);
config.background_prompt = clean_string(
&config.background_prompt,
&default_domain_background_prompt(&config.theme_text),
);
config.cover_image_src = config.cover_image_src.trim().to_string();
config.background_image_src = config.background_image_src.trim().to_string();
let shape_options = normalize_domain_shape_options(
domain_shape_options_from_snapshot(&config.shape_options),
&config.theme_text,
);
let hole_options =
normalize_domain_hole_options(domain_hole_options_from_snapshot(&config.hole_options));
config.shape_options = shape_options_to_snapshot(&shape_options);
config.hole_options = hole_options_to_snapshot(&hole_options);
config
}
fn parse_tags(value: &str) -> Result<Vec<String>, String> {
@@ -1071,6 +1132,13 @@ fn parse_tags(value: &str) -> Result<Vec<String>, String> {
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> {
let mut result = Vec::new();
for tag in tags {
@@ -1097,12 +1165,18 @@ fn normalize_stage(value: &str) -> String {
fn domain_config_from_snapshot(
config: &SquareHoleCreatorConfigSnapshot,
) -> Result<DomainSquareHoleCreatorConfig, module_square_hole::SquareHoleError> {
build_domain_creator_config(
let mut domain = build_domain_creator_config(
&config.theme_text,
&config.twist_rule,
config.shape_count,
config.difficulty,
)
)?;
domain.shape_options = domain_shape_options_from_snapshot(&config.shape_options);
domain.hole_options = domain_hole_options_from_snapshot(&config.hole_options);
domain.background_prompt = config.background_prompt.clone();
domain.cover_image_src = empty_to_none(&config.cover_image_src);
domain.background_image_src = empty_to_none(&config.background_image_src);
Ok(domain)
}
fn snapshot_from_domain(
@@ -1125,6 +1199,8 @@ fn snapshot_from_domain(
best_combo: run.best_combo,
score: run.score,
rule_label: run.rule_label.clone(),
background_image_src: run.background_image_src.clone().unwrap_or_default(),
shape_options: shape_options_to_snapshot(&run.shape_options),
current_shape: run.current_shape.as_ref().map(shape_from_domain),
holes: run.holes.iter().map(hole_from_domain).collect(),
last_feedback: run.last_feedback.as_ref().map(feedback_from_domain),
@@ -1150,6 +1226,8 @@ fn domain_snapshot_from_snapshot(
best_combo: snapshot.best_combo,
score: snapshot.score,
rule_label: snapshot.rule_label.clone(),
background_image_src: empty_to_none(&snapshot.background_image_src),
shape_options: domain_shape_options_from_snapshot(&snapshot.shape_options),
current_shape: snapshot
.current_shape
.as_ref()
@@ -1172,6 +1250,7 @@ fn shape_from_domain(shape: &DomainSquareHoleShapeSnapshot) -> SquareHoleShapeSn
shape_kind: shape.shape_kind.clone(),
label: shape.label.clone(),
color: shape.color.clone(),
image_src: shape.image_src.clone().unwrap_or_default(),
}
}
@@ -1181,6 +1260,7 @@ fn domain_shape_from_snapshot(shape: &SquareHoleShapeSnapshot) -> DomainSquareHo
shape_kind: shape.shape_kind.clone(),
label: shape.label.clone(),
color: shape.color.clone(),
image_src: empty_to_none(&shape.image_src),
}
}
@@ -1191,6 +1271,7 @@ fn hole_from_domain(hole: &DomainSquareHoleHoleSnapshot) -> SquareHoleHoleSnapsh
label: hole.label.clone(),
x: hole.x,
y: hole.y,
bonus: hole.bonus,
}
}
@@ -1201,9 +1282,68 @@ fn domain_hole_from_snapshot(hole: &SquareHoleHoleSnapshot) -> DomainSquareHoleH
label: hole.label.clone(),
x: hole.x,
y: hole.y,
bonus: hole.bonus,
}
}
fn shape_options_to_snapshot(
options: &[DomainSquareHoleShapeOption],
) -> Vec<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 {
SquareHoleDropFeedbackSnapshot {
accepted: feedback.accepted,
@@ -85,6 +85,10 @@ pub struct SquareHoleWorkUpdateInput {
pub summary_text: String,
pub tags_json: String,
pub cover_image_src: String,
pub background_prompt: String,
pub background_image_src: String,
pub shape_options_json: String,
pub hole_options_json: String,
pub shape_count: u32,
pub difficulty: u32,
pub updated_at_micros: i64,
@@ -206,6 +210,37 @@ pub struct SquareHoleCreatorConfigSnapshot {
pub twist_rule: String,
pub shape_count: u32,
pub difficulty: u32,
#[serde(default)]
pub shape_options: Vec<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)]
@@ -228,6 +263,16 @@ pub struct SquareHoleDraftSnapshot {
pub twist_rule: String,
pub summary_text: 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 difficulty: u32,
}
@@ -264,6 +309,14 @@ pub struct SquareHoleWorkSnapshot {
pub summary_text: String,
pub tags: Vec<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 difficulty: u32,
pub config: SquareHoleCreatorConfigSnapshot,
@@ -281,6 +334,8 @@ pub struct SquareHoleShapeSnapshot {
pub shape_kind: String,
pub label: String,
pub color: String,
#[serde(default)]
pub image_src: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -291,6 +346,8 @@ pub struct SquareHoleHoleSnapshot {
pub label: String,
pub x: f32,
pub y: f32,
#[serde(default)]
pub bonus: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -319,6 +376,10 @@ pub struct SquareHoleRunSnapshot {
pub best_combo: u32,
pub score: u32,
pub rule_label: String,
#[serde(default)]
pub background_image_src: String,
#[serde(default)]
pub shape_options: Vec<SquareHoleShapeOptionSnapshot>,
pub current_shape: Option<SquareHoleShapeSnapshot>,
pub holes: Vec<SquareHoleHoleSnapshot>,
pub last_feedback: Option<SquareHoleDropFeedbackSnapshot>,
@@ -32,22 +32,6 @@ import type {
Match3DWorkProfile,
Match3DWorkSummary,
} from '../../../packages/shared/src/contracts/match3dWorks';
import type {
CreateSquareHoleSessionRequest,
ExecuteSquareHoleActionRequest,
SendSquareHoleMessageRequest,
SquareHoleActionResponse,
SquareHoleSessionResponse,
SquareHoleSessionSnapshot,
} from '../../../packages/shared/src/contracts/squareHoleAgent';
import type {
DropSquareHoleShapeRequest,
SquareHoleRunSnapshot,
} from '../../../packages/shared/src/contracts/squareHoleRuntime';
import type {
SquareHoleWorkProfile,
SquareHoleWorkSummary,
} from '../../../packages/shared/src/contracts/squareHoleWorks';
import type {
PuzzleAgentActionRequest,
PuzzleAgentOperationRecord,
@@ -72,6 +56,21 @@ import type {
ProfileSaveArchiveResumeResponse,
ProfileSaveArchiveSummary,
} from '../../../packages/shared/src/contracts/runtime';
import type {
CreateSquareHoleSessionRequest,
ExecuteSquareHoleActionRequest,
SendSquareHoleMessageRequest,
SquareHoleActionResponse,
SquareHoleSessionResponse,
SquareHoleSessionSnapshot,
} from '../../../packages/shared/src/contracts/squareHoleAgent';
import type {
SquareHoleRunSnapshot,
} from '../../../packages/shared/src/contracts/squareHoleRuntime';
import type {
SquareHoleWorkProfile,
SquareHoleWorkSummary,
} from '../../../packages/shared/src/contracts/squareHoleWorks';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import {
buildPublicWorkStagePath,
@@ -123,6 +122,7 @@ import {
buildBigFishGenerationAnchorEntries,
buildMiniGameDraftGenerationProgress,
buildPuzzleGenerationAnchorEntries,
buildSquareHoleGenerationAnchorEntries,
createMiniGameDraftGenerationState,
type MiniGameDraftGenerationState,
} from '../../services/miniGameDraftGenerationProgress';
@@ -552,8 +552,12 @@ function mapPublicWorkDetailToSquareHoleWork(
summary: entry.summaryText,
tags: entry.themeTags,
coverImageSrc: entry.coverImageSrc,
shapeCount: 8,
difficulty: 4,
backgroundPrompt: entry.backgroundPrompt ?? '方洞挑战运行背景',
backgroundImageSrc: entry.backgroundImageSrc ?? null,
shapeOptions: entry.shapeOptions ?? [],
holeOptions: entry.holeOptions ?? [],
shapeCount: entry.shapeCount ?? 8,
difficulty: entry.difficulty ?? 4,
publicationStatus: 'published',
playCount: entry.playCount ?? 0,
updatedAt: entry.updatedAt,
@@ -581,7 +585,11 @@ function buildSquareHoleProfileFromSession(
twistRule: draft.twistRule,
summary: draft.summary,
tags: draft.tags,
coverImageSrc: null,
coverImageSrc: draft.coverImageSrc ?? null,
backgroundPrompt: draft.backgroundPrompt,
backgroundImageSrc: draft.backgroundImageSrc ?? null,
shapeOptions: draft.shapeOptions,
holeOptions: draft.holeOptions,
shapeCount: draft.shapeCount,
difficulty: draft.difficulty,
publicationStatus: 'draft',
@@ -608,13 +616,6 @@ function mergeBigFishWorkSummary(
: current;
}
function mergeSquareHoleWorkSummary(
current: SquareHoleWorkSummary,
updated: SquareHoleWorkSummary,
): SquareHoleWorkSummary {
return current.profileId === updated.profileId ? updated : current;
}
async function resolvePublicWorkAuthorSummary(
entry: PlatformPublicGalleryCard,
): Promise<PublicUserSummary | null> {
@@ -1086,6 +1087,8 @@ export function PlatformEntryFlowShellImpl({
useState<SquareHoleRuntimeReturnStage>('square-hole-result');
const [isSquareHoleLoadingLibrary, setIsSquareHoleLoadingLibrary] =
useState(false);
const [squareHoleGenerationState, setSquareHoleGenerationState] =
useState<MiniGameDraftGenerationState | null>(null);
const [bigFishRun, setBigFishRun] =
useState<BigFishRuntimeSnapshotResponse | null>(null);
const [bigFishRuntimeShare, setBigFishRuntimeShare] = useState<{
@@ -1817,7 +1820,7 @@ export function PlatformEntryFlowShellImpl({
workspaceStage: 'square-hole-agent-workspace',
resultStage: 'square-hole-result',
platformStage: 'platform',
isCompileAction: (payload) => payload.action === 'square_hole_compile_draft',
isCompileAction: () => false,
resolveErrorMessage: resolveSquareHoleErrorMessage,
errorMessages: {
open: '开启方洞挑战共创工作台失败。',
@@ -1831,9 +1834,30 @@ export function PlatformEntryFlowShellImpl({
onSessionOpened: () => {
setShowCreationTypeModal(false);
},
beforeExecuteAction: ({ payload }) => {
if (payload.action === 'square_hole_compile_draft') {
setSquareHoleGenerationState(
createMiniGameDraftGenerationState('square-hole'),
);
setSelectionStage('square-hole-generating');
}
if (payload.action === 'square_hole_generate_visual_assets') {
setSquareHoleGenerationState((current) => ({
...(current ?? createMiniGameDraftGenerationState('square-hole')),
phase: 'square-hole-cover',
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
}));
setSelectionStage('square-hole-generating');
}
},
onActionComplete: async ({ payload, response, setSession }) => {
setSession(response.session);
if (payload.action !== 'square_hole_compile_draft') {
if (
payload.action !== 'square_hole_compile_draft' &&
payload.action !== 'square_hole_generate_visual_assets'
) {
return;
}
@@ -1843,12 +1867,79 @@ export function PlatformEntryFlowShellImpl({
return;
}
if (payload.action === 'square_hole_compile_draft') {
try {
const assetResponse = await squareHoleCreationClient.executeAction(
response.session.sessionId,
{
action: 'square_hole_generate_visual_assets',
},
);
setSession(assetResponse.session);
const assetProfileId = assetResponse.session.draft?.profileId;
if (!assetProfileId) {
setSquareHoleProfile(
buildSquareHoleProfileFromSession(assetResponse.session),
);
setSelectionStage('square-hole-result');
return;
}
const { item } = await getSquareHoleWorkDetail(assetProfileId);
setSquareHoleProfile(item);
setSquareHoleGenerationState((current) => ({
...(current ?? createMiniGameDraftGenerationState('square-hole')),
phase: 'ready',
completedAssetCount: item.shapeOptions.length + 2,
totalAssetCount: item.shapeOptions.length + 2,
error: null,
}));
await refreshSquareHoleShelf().catch(() => undefined);
setSelectionStage('square-hole-result');
} catch (error) {
const errorMessage = resolveSquareHoleErrorMessage(
error,
'生成方洞挑战图片失败。',
);
setSquareHoleError(errorMessage);
setSquareHoleGenerationState((current) => ({
...(current ?? createMiniGameDraftGenerationState('square-hole')),
phase: 'failed',
error: errorMessage,
}));
setSquareHoleProfile(buildSquareHoleProfileFromSession(response.session));
setSelectionStage('square-hole-generating');
}
return;
}
try {
const { item } = await getSquareHoleWorkDetail(profileId);
setSquareHoleProfile(item);
setSquareHoleGenerationState((current) => ({
...(current ?? createMiniGameDraftGenerationState('square-hole')),
phase: 'ready',
completedAssetCount: item.shapeOptions.length + 2,
totalAssetCount: item.shapeOptions.length + 2,
error: null,
}));
await refreshSquareHoleShelf().catch(() => undefined);
setSelectionStage('square-hole-result');
} catch {
setSquareHoleProfile(buildSquareHoleProfileFromSession(response.session));
setSelectionStage('square-hole-result');
}
},
onActionError: ({ payload, errorMessage }) => {
if (
payload.action === 'square_hole_compile_draft' ||
payload.action === 'square_hole_generate_visual_assets'
) {
setSquareHoleGenerationState((current) => ({
...(current ?? createMiniGameDraftGenerationState('square-hole')),
phase: 'failed',
error: errorMessage,
}));
setSelectionStage('square-hole-generating');
}
},
});
@@ -2047,6 +2138,7 @@ export function PlatformEntryFlowShellImpl({
setSquareHoleProfile(null);
setSquareHoleRun(null);
setSquareHoleError(null);
setSquareHoleGenerationState(null);
setSquareHoleRuntimeReturnStage('square-hole-result');
setStreamingSquareHoleReplyText('');
setIsStreamingSquareHoleReply(false);
@@ -2162,6 +2254,7 @@ export function PlatformEntryFlowShellImpl({
setSquareHoleGalleryEntries([]);
setSquareHoleRun(null);
setSquareHoleRuntimeReturnStage('square-hole-result');
setSquareHoleGenerationState(null);
setSquareHoleError(null);
setStreamingSquareHoleReplyText('');
setIsStreamingSquareHoleReply(false);
@@ -2291,6 +2384,7 @@ export function PlatformEntryFlowShellImpl({
const leaveSquareHoleFlow = useCallback(() => {
setSquareHoleRun(null);
setSquareHoleRuntimeReturnStage('square-hole-result');
setSquareHoleGenerationState(null);
squareHoleFlow.leaveFlow();
}, [squareHoleFlow]);
@@ -2316,6 +2410,20 @@ export function PlatformEntryFlowShellImpl({
const executeSquareHoleAction = squareHoleFlow.executeAction;
const retrySquareHoleAssetGeneration = useCallback(() => {
const session = squareHoleSession;
if (!session?.draft?.profileId) {
void executeSquareHoleAction({
action: 'square_hole_compile_draft',
});
return;
}
void executeSquareHoleAction({
action: 'square_hole_generate_visual_assets',
});
}, [executeSquareHoleAction, squareHoleSession]);
const executePuzzleAction = puzzleFlow.executeAction;
const retryPuzzleDraftGeneration = useCallback(() => {
@@ -4013,6 +4121,7 @@ export function PlatformEntryFlowShellImpl({
return;
}
setSquareHoleGenerationState(null);
const restoredSession = await squareHoleFlow.restoreDraft(
item.sourceSessionId,
);
@@ -5583,6 +5692,50 @@ export function PlatformEntryFlowShellImpl({
</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 && (
<motion.div
key="square-hole-result"

Some files were not shown because too many files have changed in this diff Show More