Extend square-hole creation flow with visual asset timeout guard
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,11 @@ use axum::{
|
||||
sse::{Event, Sse},
|
||||
},
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use module_square_hole::{
|
||||
SQUARE_HOLE_MESSAGE_ID_PREFIX, SQUARE_HOLE_PROFILE_ID_PREFIX, SQUARE_HOLE_RUN_ID_PREFIX,
|
||||
SQUARE_HOLE_SESSION_ID_PREFIX,
|
||||
SQUARE_HOLE_SESSION_ID_PREFIX, default_background_prompt, normalize_hole_options,
|
||||
normalize_shape_options,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
@@ -24,8 +26,9 @@ use shared_contracts::{
|
||||
CreateSquareHoleSessionRequest, ExecuteSquareHoleActionRequest,
|
||||
SendSquareHoleMessageRequest, SquareHoleActionResponse, SquareHoleAgentMessageResponse,
|
||||
SquareHoleAnchorItemResponse, SquareHoleAnchorPackResponse,
|
||||
SquareHoleCreatorConfigResponse, SquareHoleResultDraftResponse, SquareHoleSessionResponse,
|
||||
SquareHoleSessionSnapshotResponse,
|
||||
SquareHoleCreatorConfigResponse, SquareHoleHoleOptionResponse,
|
||||
SquareHoleResultDraftResponse, SquareHoleSessionResponse,
|
||||
SquareHoleSessionSnapshotResponse, SquareHoleShapeOptionResponse,
|
||||
},
|
||||
square_hole_runtime::{
|
||||
DropSquareHoleShapeRequest, SquareHoleDropFeedbackResponse, SquareHoleDropResponse,
|
||||
@@ -33,7 +36,9 @@ use shared_contracts::{
|
||||
SquareHoleShapeSnapshotResponse, StartSquareHoleRunRequest, StopSquareHoleRunRequest,
|
||||
},
|
||||
square_hole_works::{
|
||||
PutSquareHoleWorkRequest, SquareHoleWorkDetailResponse, SquareHoleWorkMutationResponse,
|
||||
PutSquareHoleWorkRequest, SquareHoleHoleOptionResponse as SquareHoleWorkHoleOptionResponse,
|
||||
SquareHoleShapeOptionResponse as SquareHoleWorkShapeOptionResponse,
|
||||
SquareHoleWorkDetailResponse, SquareHoleWorkMutationResponse,
|
||||
SquareHoleWorkProfileResponse, SquareHoleWorkSummaryResponse, SquareHoleWorksResponse,
|
||||
},
|
||||
};
|
||||
@@ -42,11 +47,11 @@ use spacetime_client::{
|
||||
SpacetimeClientError, SquareHoleAgentMessageRecord, SquareHoleAgentMessageSubmitRecordInput,
|
||||
SquareHoleAgentSessionCreateRecordInput, SquareHoleAgentSessionRecord,
|
||||
SquareHoleAnchorItemRecord, SquareHoleAnchorPackRecord, SquareHoleCompileDraftRecordInput,
|
||||
SquareHoleCreatorConfigRecord, SquareHoleDropFeedbackRecord, SquareHoleHoleSnapshotRecord,
|
||||
SquareHoleResultDraftRecord, SquareHoleRunDropRecordInput, SquareHoleRunRecord,
|
||||
SquareHoleRunRestartRecordInput, SquareHoleRunStartRecordInput, SquareHoleRunStopRecordInput,
|
||||
SquareHoleRunTimeUpRecordInput, SquareHoleShapeSnapshotRecord, SquareHoleWorkProfileRecord,
|
||||
SquareHoleWorkUpdateRecordInput,
|
||||
SquareHoleCreatorConfigRecord, SquareHoleDropFeedbackRecord, SquareHoleHoleOptionRecord,
|
||||
SquareHoleHoleSnapshotRecord, SquareHoleResultDraftRecord, SquareHoleRunDropRecordInput,
|
||||
SquareHoleRunRecord, SquareHoleRunRestartRecordInput, SquareHoleRunStartRecordInput,
|
||||
SquareHoleRunStopRecordInput, SquareHoleRunTimeUpRecordInput, SquareHoleShapeOptionRecord,
|
||||
SquareHoleShapeSnapshotRecord, SquareHoleWorkProfileRecord, SquareHoleWorkUpdateRecordInput,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
@@ -54,6 +59,10 @@ use crate::{
|
||||
api_response::json_success_body,
|
||||
auth::AuthenticatedAccessToken,
|
||||
http_error::AppError,
|
||||
openai_image_generation::{
|
||||
build_openai_image_http_client, create_openai_image_generation,
|
||||
require_openai_image_settings,
|
||||
},
|
||||
request_context::RequestContext,
|
||||
square_hole_agent_turn::{
|
||||
SquareHoleAgentTurnRequest, build_finalize_record_input, run_square_hole_agent_turn,
|
||||
@@ -77,6 +86,37 @@ struct SquareHoleConfigJson {
|
||||
twist_rule: String,
|
||||
shape_count: u32,
|
||||
difficulty: u32,
|
||||
#[serde(default)]
|
||||
shape_options: Vec<SquareHoleConfigShapeOptionJson>,
|
||||
#[serde(default)]
|
||||
hole_options: Vec<SquareHoleConfigHoleOptionJson>,
|
||||
#[serde(default)]
|
||||
background_prompt: String,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_string_as_default")]
|
||||
cover_image_src: String,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_string_as_default")]
|
||||
background_image_src: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SquareHoleConfigShapeOptionJson {
|
||||
option_id: String,
|
||||
shape_kind: String,
|
||||
label: String,
|
||||
image_prompt: String,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_string_as_default")]
|
||||
image_src: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SquareHoleConfigHoleOptionJson {
|
||||
hole_id: String,
|
||||
hole_kind: String,
|
||||
label: String,
|
||||
#[serde(default)]
|
||||
bonus: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -299,25 +339,37 @@ pub async fn execute_square_hole_agent_action(
|
||||
"sessionId",
|
||||
)?;
|
||||
|
||||
if payload.action.trim() != "square_hole_compile_draft" {
|
||||
return Err(square_hole_bad_request(
|
||||
&request_context,
|
||||
SQUARE_HOLE_AGENT_PROVIDER,
|
||||
"unknown square hole action",
|
||||
));
|
||||
}
|
||||
|
||||
let session = compile_square_hole_draft_for_session(
|
||||
&state,
|
||||
&request_context,
|
||||
&authenticated,
|
||||
session_id,
|
||||
payload.game_name,
|
||||
payload.summary,
|
||||
payload.tags,
|
||||
payload.cover_image_src,
|
||||
)
|
||||
.await?;
|
||||
let session = match payload.action.trim() {
|
||||
"square_hole_compile_draft" => {
|
||||
compile_square_hole_draft_for_session(
|
||||
&state,
|
||||
&request_context,
|
||||
&authenticated,
|
||||
session_id,
|
||||
payload.game_name,
|
||||
payload.summary,
|
||||
payload.tags,
|
||||
payload.cover_image_src,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
"square_hole_generate_visual_assets" => {
|
||||
generate_square_hole_visual_assets_for_session(
|
||||
&state,
|
||||
&request_context,
|
||||
&authenticated,
|
||||
session_id,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
_ => {
|
||||
return Err(square_hole_bad_request(
|
||||
&request_context,
|
||||
SQUARE_HOLE_AGENT_PROVIDER,
|
||||
"unknown square hole action",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -491,6 +543,18 @@ pub async fn put_square_hole_work(
|
||||
.clone()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or(existing.theme_text);
|
||||
let shape_options_json = payload
|
||||
.shape_options
|
||||
.clone()
|
||||
.map(square_hole_work_shape_options_to_records)
|
||||
.unwrap_or_else(|| existing.shape_options.clone());
|
||||
let shape_options_json = serialize_square_hole_shape_option_records(&shape_options_json);
|
||||
let hole_options_json = payload
|
||||
.hole_options
|
||||
.clone()
|
||||
.map(square_hole_work_hole_options_to_records)
|
||||
.unwrap_or_else(|| existing.hole_options.clone());
|
||||
let hole_options_json = serialize_square_hole_hole_option_records(&hole_options_json);
|
||||
let item = state
|
||||
.spacetime_client()
|
||||
.update_square_hole_work(SquareHoleWorkUpdateRecordInput {
|
||||
@@ -502,6 +566,15 @@ pub async fn put_square_hole_work(
|
||||
summary_text: payload.summary,
|
||||
tags_json: serde_json::to_string(&normalize_tags(payload.tags)).unwrap_or_default(),
|
||||
cover_image_src: payload.cover_image_src.unwrap_or_default(),
|
||||
background_prompt: payload
|
||||
.background_prompt
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or(existing.background_prompt),
|
||||
background_image_src: payload
|
||||
.background_image_src
|
||||
.unwrap_or(existing.background_image_src.unwrap_or_default()),
|
||||
shape_options_json,
|
||||
hole_options_json,
|
||||
shape_count: payload.shape_count,
|
||||
difficulty: payload.difficulty,
|
||||
updated_at_micros: current_utc_micros(),
|
||||
@@ -986,6 +1059,231 @@ async fn compile_square_hole_draft_for_session(
|
||||
})
|
||||
}
|
||||
|
||||
async fn generate_square_hole_visual_assets_for_session(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
authenticated: &AuthenticatedAccessToken,
|
||||
session_id: String,
|
||||
) -> Result<SquareHoleAgentSessionRecord, Response> {
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
let session = state
|
||||
.spacetime_client()
|
||||
.get_square_hole_agent_session(session_id.clone(), owner_user_id.clone())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
square_hole_error_response(
|
||||
request_context,
|
||||
SQUARE_HOLE_AGENT_PROVIDER,
|
||||
map_square_hole_client_error(error),
|
||||
)
|
||||
})?;
|
||||
let profile_id = session
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|draft| draft.profile_id.clone())
|
||||
.ok_or_else(|| {
|
||||
square_hole_bad_request(
|
||||
request_context,
|
||||
SQUARE_HOLE_AGENT_PROVIDER,
|
||||
"square hole 草稿尚未编译,不能生成图片资产",
|
||||
)
|
||||
})?;
|
||||
let mut work = state
|
||||
.spacetime_client()
|
||||
.get_square_hole_work_detail(profile_id.clone(), owner_user_id.clone())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
square_hole_error_response(
|
||||
request_context,
|
||||
SQUARE_HOLE_AGENT_PROVIDER,
|
||||
map_square_hole_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
let cover_image_src = match work.cover_image_src.clone() {
|
||||
Some(value) if !value.trim().is_empty() => Some(value),
|
||||
_ => Some(
|
||||
generate_square_hole_image_data_url(
|
||||
state,
|
||||
build_square_hole_cover_prompt(&work).as_str(),
|
||||
"16:9",
|
||||
"生成方洞挑战封面图失败",
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
square_hole_error_response(request_context, SQUARE_HOLE_AGENT_PROVIDER, error)
|
||||
})?,
|
||||
),
|
||||
};
|
||||
let background_image_src = match work.background_image_src.clone() {
|
||||
Some(value) if !value.trim().is_empty() => Some(value),
|
||||
_ => Some(
|
||||
generate_square_hole_image_data_url(
|
||||
state,
|
||||
build_square_hole_background_prompt(&work).as_str(),
|
||||
"16:9",
|
||||
"生成方洞挑战背景图失败",
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
square_hole_error_response(request_context, SQUARE_HOLE_AGENT_PROVIDER, error)
|
||||
})?,
|
||||
),
|
||||
};
|
||||
let mut shape_options = work.shape_options.clone();
|
||||
let prompt_work = work.clone();
|
||||
for option in shape_options.iter_mut() {
|
||||
if option
|
||||
.image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
option.image_src = Some(
|
||||
generate_square_hole_image_data_url(
|
||||
state,
|
||||
build_square_hole_shape_prompt(&prompt_work, option).as_str(),
|
||||
"1:1",
|
||||
"生成方洞挑战形状贴图失败",
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
square_hole_error_response(request_context, SQUARE_HOLE_AGENT_PROVIDER, error)
|
||||
})?,
|
||||
);
|
||||
}
|
||||
|
||||
work = state
|
||||
.spacetime_client()
|
||||
.update_square_hole_work(SquareHoleWorkUpdateRecordInput {
|
||||
profile_id,
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
game_name: work.game_name.clone(),
|
||||
theme_text: work.theme_text.clone(),
|
||||
twist_rule: work.twist_rule.clone(),
|
||||
summary_text: work.summary.clone(),
|
||||
tags_json: serde_json::to_string(&normalize_tags(work.tags.clone()))
|
||||
.unwrap_or_default(),
|
||||
cover_image_src: cover_image_src.clone().unwrap_or_default(),
|
||||
background_prompt: work.background_prompt.clone(),
|
||||
background_image_src: background_image_src.clone().unwrap_or_default(),
|
||||
shape_options_json: serialize_square_hole_shape_option_records(&shape_options),
|
||||
hole_options_json: serialize_square_hole_hole_option_records(&work.hole_options),
|
||||
shape_count: work.shape_count,
|
||||
difficulty: work.difficulty,
|
||||
updated_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
square_hole_error_response(
|
||||
request_context,
|
||||
SQUARE_HOLE_AGENT_PROVIDER,
|
||||
map_square_hole_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut next_session = state
|
||||
.spacetime_client()
|
||||
.get_square_hole_agent_session(session_id, owner_user_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
square_hole_error_response(
|
||||
request_context,
|
||||
SQUARE_HOLE_AGENT_PROVIDER,
|
||||
map_square_hole_client_error(error),
|
||||
)
|
||||
})?;
|
||||
if let Some(draft) = next_session.draft.as_mut() {
|
||||
draft.cover_image_src = work.cover_image_src.clone();
|
||||
draft.background_image_src = work.background_image_src.clone();
|
||||
draft.background_prompt = work.background_prompt.clone();
|
||||
draft.shape_options = work.shape_options.clone();
|
||||
draft.hole_options = work.hole_options.clone();
|
||||
}
|
||||
Ok(next_session)
|
||||
}
|
||||
|
||||
async fn generate_square_hole_image_data_url(
|
||||
state: &AppState,
|
||||
prompt: &str,
|
||||
size: &str,
|
||||
failure_context: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let settings = require_openai_image_settings(state)?;
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
let generated = create_openai_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
prompt,
|
||||
Some(build_square_hole_negative_prompt().as_str()),
|
||||
size,
|
||||
1,
|
||||
&[],
|
||||
failure_context,
|
||||
)
|
||||
.await?;
|
||||
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "apimart",
|
||||
"message": format!("{failure_context}:上游未返回图片"),
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(format!(
|
||||
"data:{};base64,{}",
|
||||
image.mime_type,
|
||||
BASE64_STANDARD.encode(image.bytes)
|
||||
))
|
||||
}
|
||||
|
||||
fn build_square_hole_cover_prompt(work: &SquareHoleWorkProfileRecord) -> String {
|
||||
format!(
|
||||
"移动端休闲游戏封面图。主题:{}。玩法反差:{}。画面主体是贴着主题图案的几何形状正在靠近不同洞口,视觉清晰、色彩明快、偏游戏资产质感。不要文字、不要 UI、不要水印。",
|
||||
clean_prompt_text(&work.theme_text, "奇怪形状"),
|
||||
clean_prompt_text(&work.twist_rule, "反直觉分拣")
|
||||
)
|
||||
}
|
||||
|
||||
fn build_square_hole_background_prompt(work: &SquareHoleWorkProfileRecord) -> String {
|
||||
let custom_prompt = work.background_prompt.trim();
|
||||
if !custom_prompt.is_empty() {
|
||||
return format!(
|
||||
"移动端休闲游戏运行背景。{}。画面中央预留清晰操作空间,边缘可有主题装饰,低噪声,不要文字、不要 UI、不要水印。",
|
||||
custom_prompt
|
||||
);
|
||||
}
|
||||
|
||||
format!(
|
||||
"移动端休闲游戏运行背景。主题:{}。柔和纵深、玩具盒或舞台感,中央预留清晰操作空间,不要文字、不要 UI、不要水印。",
|
||||
clean_prompt_text(&work.theme_text, "奇怪形状")
|
||||
)
|
||||
}
|
||||
|
||||
fn build_square_hole_shape_prompt(
|
||||
work: &SquareHoleWorkProfileRecord,
|
||||
option: &SquareHoleShapeOptionRecord,
|
||||
) -> String {
|
||||
let image_prompt = option.image_prompt.trim();
|
||||
let option_prompt = if image_prompt.is_empty() {
|
||||
format!("{} 主题的 {}", work.theme_text, option.label)
|
||||
} else {
|
||||
image_prompt.to_string()
|
||||
};
|
||||
|
||||
format!(
|
||||
"单个游戏道具贴图,透明或干净浅色背景。几何形状:{}。主题贴图:{}。要求主体居中、边缘清晰、适合贴在可拖拽形状上,不要文字、不要 UI、不要水印。",
|
||||
clean_prompt_text(&option.label, "形状"),
|
||||
clean_prompt_text(&option_prompt, "主题图案")
|
||||
)
|
||||
}
|
||||
|
||||
fn build_square_hole_negative_prompt() -> String {
|
||||
"文字、水印、复杂 UI、真实人物、恐怖血腥、低清晰度、过度模糊、主体被裁切、多个主体".to_string()
|
||||
}
|
||||
|
||||
fn map_square_hole_agent_session_response(
|
||||
session: SquareHoleAgentSessionRecord,
|
||||
) -> SquareHoleSessionSnapshotResponse {
|
||||
@@ -1084,6 +1382,19 @@ fn map_square_hole_config_response(
|
||||
twist_rule: config.twist_rule,
|
||||
shape_count: config.shape_count,
|
||||
difficulty: config.difficulty,
|
||||
shape_options: config
|
||||
.shape_options
|
||||
.into_iter()
|
||||
.map(map_square_hole_shape_option_response)
|
||||
.collect(),
|
||||
hole_options: config
|
||||
.hole_options
|
||||
.into_iter()
|
||||
.map(map_square_hole_hole_option_response)
|
||||
.collect(),
|
||||
background_prompt: config.background_prompt,
|
||||
cover_image_src: config.cover_image_src,
|
||||
background_image_src: config.background_image_src,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1097,6 +1408,19 @@ fn map_square_hole_draft_response(
|
||||
twist_rule: draft.twist_rule,
|
||||
summary: draft.summary,
|
||||
tags: draft.tags,
|
||||
cover_image_src: draft.cover_image_src,
|
||||
background_prompt: draft.background_prompt,
|
||||
background_image_src: draft.background_image_src,
|
||||
shape_options: draft
|
||||
.shape_options
|
||||
.into_iter()
|
||||
.map(map_square_hole_shape_option_response)
|
||||
.collect(),
|
||||
hole_options: draft
|
||||
.hole_options
|
||||
.into_iter()
|
||||
.map(map_square_hole_hole_option_response)
|
||||
.collect(),
|
||||
shape_count: draft.shape_count,
|
||||
difficulty: draft.difficulty,
|
||||
publish_ready: draft.publish_ready,
|
||||
@@ -1130,6 +1454,18 @@ fn map_square_hole_work_summary_response(
|
||||
summary: item.summary,
|
||||
tags: item.tags,
|
||||
cover_image_src: item.cover_image_src,
|
||||
background_prompt: item.background_prompt,
|
||||
background_image_src: item.background_image_src,
|
||||
shape_options: item
|
||||
.shape_options
|
||||
.into_iter()
|
||||
.map(map_square_hole_work_shape_option_response)
|
||||
.collect(),
|
||||
hole_options: item
|
||||
.hole_options
|
||||
.into_iter()
|
||||
.map(map_square_hole_work_hole_option_response)
|
||||
.collect(),
|
||||
shape_count: item.shape_count,
|
||||
difficulty: item.difficulty,
|
||||
publication_status: item.publication_status,
|
||||
@@ -1164,6 +1500,7 @@ fn map_square_hole_run_response(run: SquareHoleRunRecord) -> SquareHoleRunSnapsh
|
||||
best_combo: run.best_combo,
|
||||
score: run.score,
|
||||
rule_label: run.rule_label,
|
||||
background_image_src: run.background_image_src,
|
||||
current_shape: run.current_shape.map(map_square_hole_shape_response),
|
||||
holes: run
|
||||
.holes
|
||||
@@ -1182,6 +1519,7 @@ fn map_square_hole_shape_response(
|
||||
shape_kind: item.shape_kind,
|
||||
label: item.label,
|
||||
color: item.color,
|
||||
image_src: item.image_src,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1194,6 +1532,53 @@ fn map_square_hole_hole_response(
|
||||
label: slot.label,
|
||||
x: slot.x,
|
||||
y: slot.y,
|
||||
bonus: slot.bonus,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_square_hole_shape_option_response(
|
||||
item: SquareHoleShapeOptionRecord,
|
||||
) -> SquareHoleShapeOptionResponse {
|
||||
SquareHoleShapeOptionResponse {
|
||||
option_id: item.option_id,
|
||||
shape_kind: item.shape_kind,
|
||||
label: item.label,
|
||||
image_prompt: item.image_prompt,
|
||||
image_src: item.image_src,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_square_hole_hole_option_response(
|
||||
item: SquareHoleHoleOptionRecord,
|
||||
) -> SquareHoleHoleOptionResponse {
|
||||
SquareHoleHoleOptionResponse {
|
||||
hole_id: item.hole_id,
|
||||
hole_kind: item.hole_kind,
|
||||
label: item.label,
|
||||
bonus: item.bonus,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_square_hole_work_shape_option_response(
|
||||
item: SquareHoleShapeOptionRecord,
|
||||
) -> SquareHoleWorkShapeOptionResponse {
|
||||
SquareHoleWorkShapeOptionResponse {
|
||||
option_id: item.option_id,
|
||||
shape_kind: item.shape_kind,
|
||||
label: item.label,
|
||||
image_prompt: item.image_prompt,
|
||||
image_src: item.image_src,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_square_hole_work_hole_option_response(
|
||||
item: SquareHoleHoleOptionRecord,
|
||||
) -> SquareHoleWorkHoleOptionResponse {
|
||||
SquareHoleWorkHoleOptionResponse {
|
||||
hole_id: item.hole_id,
|
||||
hole_kind: item.hole_kind,
|
||||
label: item.label,
|
||||
bonus: item.bonus,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1234,6 +1619,24 @@ fn build_config_from_create_request(
|
||||
.difficulty
|
||||
.unwrap_or(SQUARE_HOLE_DEFAULT_DIFFICULTY)
|
||||
.clamp(1, 10),
|
||||
shape_options: square_hole_shape_records_to_config_json(normalize_shape_options(
|
||||
Vec::new(),
|
||||
payload
|
||||
.theme_text
|
||||
.as_deref()
|
||||
.or(payload.seed_text.as_deref())
|
||||
.unwrap_or(SQUARE_HOLE_DEFAULT_THEME),
|
||||
)),
|
||||
hole_options: square_hole_hole_records_to_config_json(normalize_hole_options(Vec::new())),
|
||||
background_prompt: default_background_prompt(
|
||||
payload
|
||||
.theme_text
|
||||
.as_deref()
|
||||
.or(payload.seed_text.as_deref())
|
||||
.unwrap_or(SQUARE_HOLE_DEFAULT_THEME),
|
||||
),
|
||||
cover_image_src: String::new(),
|
||||
background_image_src: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1246,12 +1649,27 @@ fn resolve_config_or_default(
|
||||
twist_rule: config.twist_rule.clone(),
|
||||
shape_count: config.shape_count.max(1),
|
||||
difficulty: config.difficulty.clamp(1, 10),
|
||||
shape_options: square_hole_shape_records_to_config_json(config.shape_options.clone()),
|
||||
hole_options: square_hole_hole_records_to_config_json(config.hole_options.clone()),
|
||||
background_prompt: config.background_prompt.clone(),
|
||||
cover_image_src: config.cover_image_src.clone().unwrap_or_default(),
|
||||
background_image_src: config.background_image_src.clone().unwrap_or_default(),
|
||||
})
|
||||
.unwrap_or_else(|| SquareHoleConfigJson {
|
||||
theme_text: SQUARE_HOLE_DEFAULT_THEME.to_string(),
|
||||
twist_rule: SQUARE_HOLE_DEFAULT_TWIST_RULE.to_string(),
|
||||
shape_count: SQUARE_HOLE_DEFAULT_SHAPE_COUNT,
|
||||
difficulty: SQUARE_HOLE_DEFAULT_DIFFICULTY,
|
||||
shape_options: square_hole_shape_records_to_config_json(normalize_shape_options(
|
||||
Vec::new(),
|
||||
SQUARE_HOLE_DEFAULT_THEME,
|
||||
)),
|
||||
hole_options: square_hole_hole_records_to_config_json(normalize_hole_options(
|
||||
Vec::new(),
|
||||
)),
|
||||
background_prompt: default_background_prompt(SQUARE_HOLE_DEFAULT_THEME),
|
||||
cover_image_src: String::new(),
|
||||
background_image_src: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1259,6 +1677,13 @@ fn serialize_square_hole_config(config: &SquareHoleConfigJson) -> Option<String>
|
||||
serde_json::to_string(config).ok()
|
||||
}
|
||||
|
||||
fn deserialize_optional_string_as_default<'de, D>(deserializer: D) -> Result<String, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
Ok(Option::<String>::deserialize(deserializer)?.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn build_seed_text(
|
||||
payload: &CreateSquareHoleSessionRequest,
|
||||
config: &SquareHoleConfigJson,
|
||||
@@ -1291,6 +1716,118 @@ fn normalize_tags(tags: Vec<String>) -> Vec<String> {
|
||||
result
|
||||
}
|
||||
|
||||
fn square_hole_shape_records_to_config_json(
|
||||
options: Vec<impl Into<SquareHoleConfigShapeOptionJson>>,
|
||||
) -> Vec<SquareHoleConfigShapeOptionJson> {
|
||||
options.into_iter().map(Into::into).collect()
|
||||
}
|
||||
|
||||
fn square_hole_hole_records_to_config_json(
|
||||
options: Vec<impl Into<SquareHoleConfigHoleOptionJson>>,
|
||||
) -> Vec<SquareHoleConfigHoleOptionJson> {
|
||||
options.into_iter().map(Into::into).collect()
|
||||
}
|
||||
|
||||
fn square_hole_work_shape_options_to_records(
|
||||
options: Vec<SquareHoleWorkShapeOptionResponse>,
|
||||
) -> Vec<SquareHoleShapeOptionRecord> {
|
||||
options
|
||||
.into_iter()
|
||||
.map(|option| SquareHoleShapeOptionRecord {
|
||||
option_id: option.option_id,
|
||||
shape_kind: option.shape_kind,
|
||||
label: option.label,
|
||||
image_prompt: option.image_prompt,
|
||||
image_src: option.image_src.filter(|value| !value.trim().is_empty()),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn square_hole_work_hole_options_to_records(
|
||||
options: Vec<SquareHoleWorkHoleOptionResponse>,
|
||||
) -> Vec<SquareHoleHoleOptionRecord> {
|
||||
options
|
||||
.into_iter()
|
||||
.map(|option| SquareHoleHoleOptionRecord {
|
||||
hole_id: option.hole_id,
|
||||
hole_kind: option.hole_kind,
|
||||
label: option.label,
|
||||
bonus: option.bonus,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn serialize_square_hole_shape_option_records(options: &[SquareHoleShapeOptionRecord]) -> String {
|
||||
let json_options: Vec<SquareHoleConfigShapeOptionJson> =
|
||||
options.iter().cloned().map(Into::into).collect();
|
||||
serde_json::to_string(&json_options).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn serialize_square_hole_hole_option_records(options: &[SquareHoleHoleOptionRecord]) -> String {
|
||||
let json_options: Vec<SquareHoleConfigHoleOptionJson> =
|
||||
options.iter().cloned().map(Into::into).collect();
|
||||
serde_json::to_string(&json_options).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn clean_prompt_text(value: &str, fallback: &str) -> String {
|
||||
let cleaned = value
|
||||
.trim()
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
if cleaned.is_empty() {
|
||||
fallback.to_string()
|
||||
} else {
|
||||
cleaned.chars().take(180).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<module_square_hole::SquareHoleShapeOption> for SquareHoleConfigShapeOptionJson {
|
||||
fn from(option: module_square_hole::SquareHoleShapeOption) -> Self {
|
||||
Self {
|
||||
option_id: option.option_id,
|
||||
shape_kind: option.shape_kind,
|
||||
label: option.label,
|
||||
image_prompt: option.image_prompt,
|
||||
image_src: option.image_src.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SquareHoleShapeOptionRecord> for SquareHoleConfigShapeOptionJson {
|
||||
fn from(option: SquareHoleShapeOptionRecord) -> Self {
|
||||
Self {
|
||||
option_id: option.option_id,
|
||||
shape_kind: option.shape_kind,
|
||||
label: option.label,
|
||||
image_prompt: option.image_prompt,
|
||||
image_src: option.image_src.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<module_square_hole::SquareHoleHoleOption> for SquareHoleConfigHoleOptionJson {
|
||||
fn from(option: module_square_hole::SquareHoleHoleOption) -> Self {
|
||||
Self {
|
||||
hole_id: option.hole_id,
|
||||
hole_kind: option.hole_kind,
|
||||
label: option.label,
|
||||
bonus: option.bonus,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SquareHoleHoleOptionRecord> for SquareHoleConfigHoleOptionJson {
|
||||
fn from(option: SquareHoleHoleOptionRecord) -> Self {
|
||||
Self {
|
||||
hole_id: option.hole_id,
|
||||
hole_kind: option.hole_kind,
|
||||
label: option.label,
|
||||
bonus: option.bonus,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_author_display_name(
|
||||
state: &AppState,
|
||||
authenticated: &AuthenticatedAccessToken,
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user