落地方洞挑战图片与运行态交互
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
kdletters
2026-05-06 12:51:28 +08:00
parent 60b667a9d1
commit d06107f2c6
51 changed files with 2590 additions and 989 deletions

View File

@@ -124,8 +124,9 @@ use crate::{
drop_square_hole_shape, execute_square_hole_agent_action, finish_square_hole_time_up,
get_square_hole_agent_session, get_square_hole_run, get_square_hole_work_detail,
get_square_hole_works, list_square_hole_gallery, publish_square_hole_work,
put_square_hole_work, restart_square_hole_run, start_square_hole_run, stop_square_hole_run,
stream_square_hole_agent_message, submit_square_hole_agent_message,
put_square_hole_work, regenerate_square_hole_work_image, restart_square_hole_run,
start_square_hole_run, stop_square_hole_run, stream_square_hole_agent_message,
submit_square_hole_agent_message,
},
state::AppState,
story_battles::{
@@ -904,6 +905,13 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/creation/square-hole/works/{profile_id}/images/regenerate",
post(regenerate_square_hole_work_image).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/square-hole/gallery",
get(list_square_hole_gallery),

View File

@@ -28,8 +28,15 @@ use crate::{
};
// 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。
const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 3] =
["character_visual", "scene_image", "puzzle_cover_image"];
const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 7] = [
"character_visual",
"scene_image",
"puzzle_cover_image",
"square_hole_cover_image",
"square_hole_background_image",
"square_hole_shape_image",
"square_hole_hole_image",
];
pub async fn create_direct_upload_ticket(
State(state): State<AppState>,
@@ -492,6 +499,18 @@ mod tests {
assert!(super::is_supported_asset_history_kind("character_visual"));
assert!(super::is_supported_asset_history_kind("scene_image"));
assert!(super::is_supported_asset_history_kind("puzzle_cover_image"));
assert!(super::is_supported_asset_history_kind(
"square_hole_cover_image"
));
assert!(super::is_supported_asset_history_kind(
"square_hole_background_image"
));
assert!(super::is_supported_asset_history_kind(
"square_hole_shape_image"
));
assert!(super::is_supported_asset_history_kind(
"square_hole_hole_image"
));
assert!(!super::is_supported_asset_history_kind(
"puzzle_preview_image"
));
@@ -501,7 +520,7 @@ mod tests {
fn asset_history_kind_message_lists_all_supported_kinds() {
assert_eq!(
super::supported_asset_history_kind_message(),
"历史素材类型只支持 character_visual、scene_image、puzzle_cover_image"
"历史素材类型只支持 character_visual、scene_image、puzzle_cover_image、square_hole_cover_image、square_hole_background_image、square_hole_shape_image、square_hole_hole_image"
);
}

View File

@@ -24,8 +24,9 @@ pub(crate) const SQUARE_HOLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责
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、文字、水印或解释
9. shapeOptions 至少给 6 个,每个 shapeOptions.targetHoleId 必须指向某个 holeOptions.holeId
10. holeOptions 给 3 到 6 个,每个洞口都要有 imagePrompt
11. imagePrompt 和 backgroundPrompt 必须适合直接生成图片,不要包含 UI、文字、水印或解释
"#;
const SQUARE_HOLE_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输出,不要输出其他文字:
@@ -42,15 +43,16 @@ const SQUARE_HOLE_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输
"optionId": "square-block",
"shapeKind": "square",
"label": "方块",
"targetHoleId": "hole-1",
"imagePrompt": "玩具纸箱主题的方块贴纸图,透明背景,明亮可爱,游戏资产"
}
],
"holeOptions": [
{
"holeId": "square-hole",
"holeKind": "square",
"label": "洞",
"bonus": true
"holeId": "hole-1",
"holeKind": "hole-1",
"label": "洞口 1",
"imagePrompt": "玩具纸箱主题的洞口 1 贴纸图,透明背景,明亮可爱,游戏资产"
}
],
"backgroundPrompt": "玩具桌面上的纸箱洞板背景,中央留出操作空间"
@@ -80,7 +82,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. shapeOptions 必须给出至少 6 个可生成贴图的形状候选,每个 imagePrompt 都围绕主题生成。\n6. holeOptions 必须给出 3 到 6 个洞口,创作者可在结果页继续改;至少一个 bonus=true\n7. backgroundPrompt 用于生成运行态背景,必须描述画面,不要写规则说明。\n8. 用户给出明确方向时优先吸收并推进,不要机械问完所有字段。\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 都围绕主题生成,每个 targetHoleId 指向一个洞口 holeId\n6. holeOptions 必须给出 3 到 6 个洞口,holeId 使用 hole-1、hole-2 这类稳定 IDholeKind 保持同 ID每个洞口都要有 imagePrompt\n7. backgroundPrompt 用于生成运行态背景,必须描述画面,不要写规则说明。\n8. 用户给出明确方向时优先吸收并推进,不要机械问完所有字段。\n\n{contract}",
quick_fill_rules = quick_fill_rules,
turn = session.current_turn.saturating_add(1),
progress = session.progress_percent,
@@ -103,6 +105,7 @@ fn serialize_square_hole_session_config(session: &SquareHoleAgentSessionRecord)
"optionId": option.option_id,
"shapeKind": option.shape_kind,
"label": option.label,
"targetHoleId": option.target_hole_id,
"imagePrompt": option.image_prompt,
"imageSrc": option.image_src,
})
@@ -117,7 +120,8 @@ fn serialize_square_hole_session_config(session: &SquareHoleAgentSessionRecord)
"holeId": option.hole_id,
"holeKind": option.hole_kind,
"label": option.label,
"bonus": option.bonus,
"imagePrompt": option.image_prompt,
"imageSrc": option.image_src,
})
})
.collect();

View File

@@ -14,13 +14,14 @@ use module_runtime::{
RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, RuntimeProfileWalletLedgerSourceType,
RuntimeReferralInviteCenterRecord, RuntimeTrackingScopeKind,
};
use serde_json::{Value, json};
use serde::Deserialize;
use serde_json::{Value, json};
use shared_contracts::runtime::{
AdminDisableProfileRedeemCodeRequest, AdminDisableProfileTaskConfigRequest,
AdminUpsertProfileInviteCodeRequest, AdminUpsertProfileRedeemCodeRequest,
AdminUpsertProfileTaskConfigRequest, AnalyticsBucketMetricResponse,
AnalyticsMetricQueryResponse, ClaimProfileTaskRewardResponse,
ANALYTICS_GRANULARITY_DAY, ANALYTICS_GRANULARITY_MONTH, ANALYTICS_GRANULARITY_QUARTER,
ANALYTICS_GRANULARITY_WEEK, ANALYTICS_GRANULARITY_YEAR, AdminDisableProfileRedeemCodeRequest,
AdminDisableProfileTaskConfigRequest, AdminUpsertProfileInviteCodeRequest,
AdminUpsertProfileRedeemCodeRequest, AdminUpsertProfileTaskConfigRequest,
AnalyticsBucketMetricResponse, AnalyticsMetricQueryResponse, ClaimProfileTaskRewardResponse,
CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse,
PROFILE_TASK_CYCLE_DAILY, PROFILE_TASK_STATUS_CLAIMABLE, PROFILE_TASK_STATUS_CLAIMED,
PROFILE_TASK_STATUS_DISABLED, PROFILE_TASK_STATUS_INCOMPLETE,
@@ -33,8 +34,6 @@ use shared_contracts::runtime::{
PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD,
ANALYTICS_GRANULARITY_DAY, ANALYTICS_GRANULARITY_MONTH, ANALYTICS_GRANULARITY_QUARTER,
ANALYTICS_GRANULARITY_WEEK, ANALYTICS_GRANULARITY_YEAR,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse,
ProfileInviteCodeAdminListResponse, ProfileInviteCodeAdminResponse,
ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse,

View File

@@ -1,4 +1,5 @@
use std::{
collections::BTreeMap,
convert::Infallible,
time::{SystemTime, UNIX_EPOCH},
};
@@ -14,11 +15,16 @@ use axum::{
},
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use module_assets::{
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
};
use module_square_hole::{
SQUARE_HOLE_MESSAGE_ID_PREFIX, SQUARE_HOLE_PROFILE_ID_PREFIX, SQUARE_HOLE_RUN_ID_PREFIX,
SQUARE_HOLE_SESSION_ID_PREFIX, default_background_prompt, normalize_hole_options,
normalize_shape_options,
};
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use shared_contracts::{
@@ -36,10 +42,11 @@ use shared_contracts::{
SquareHoleShapeSnapshotResponse, StartSquareHoleRunRequest, StopSquareHoleRunRequest,
},
square_hole_works::{
PutSquareHoleWorkRequest, SquareHoleHoleOptionResponse as SquareHoleWorkHoleOptionResponse,
PutSquareHoleWorkRequest, RegenerateSquareHoleWorkImageRequest,
SquareHoleHoleOptionResponse as SquareHoleWorkHoleOptionResponse,
SquareHoleShapeOptionResponse as SquareHoleWorkShapeOptionResponse,
SquareHoleWorkDetailResponse, SquareHoleWorkMutationResponse,
SquareHoleWorkProfileResponse, SquareHoleWorkSummaryResponse, SquareHoleWorksResponse,
SquareHoleWorkDetailResponse, SquareHoleWorkMutationResponse, SquareHoleWorkProfileResponse,
SquareHoleWorkSummaryResponse, SquareHoleWorksResponse,
},
};
use shared_kernel::build_prefixed_uuid_id;
@@ -60,9 +67,10 @@ use crate::{
auth::AuthenticatedAccessToken,
http_error::AppError,
openai_image_generation::{
build_openai_image_http_client, create_openai_image_generation,
DownloadedOpenAiImage, build_openai_image_http_client, create_openai_image_generation,
require_openai_image_settings,
},
platform_errors::map_oss_error,
request_context::RequestContext,
square_hole_agent_turn::{
SquareHoleAgentTurnRequest, build_finalize_record_input, run_square_hole_agent_turn,
@@ -78,6 +86,11 @@ const SQUARE_HOLE_DEFAULT_TWIST_RULE: &str = "方洞万能";
const SQUARE_HOLE_DEFAULT_SHAPE_COUNT: u32 = 12;
const SQUARE_HOLE_DEFAULT_DIFFICULTY: u32 = 4;
const SQUARE_HOLE_QUESTION_THEME: &str = "你想做什么题材";
const SQUARE_HOLE_ENTITY_KIND: &str = "square_hole_work";
const SQUARE_HOLE_COVER_IMAGE_KIND: &str = "square_hole_cover_image";
const SQUARE_HOLE_BACKGROUND_IMAGE_KIND: &str = "square_hole_background_image";
const SQUARE_HOLE_SHAPE_IMAGE_KIND: &str = "square_hole_shape_image";
const SQUARE_HOLE_HOLE_IMAGE_KIND: &str = "square_hole_hole_image";
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -104,6 +117,8 @@ struct SquareHoleConfigShapeOptionJson {
option_id: String,
shape_kind: String,
label: String,
#[serde(default)]
target_hole_id: String,
image_prompt: String,
#[serde(default, deserialize_with = "deserialize_optional_string_as_default")]
image_src: String,
@@ -116,7 +131,9 @@ struct SquareHoleConfigHoleOptionJson {
hole_kind: String,
label: String,
#[serde(default)]
bonus: bool,
image_prompt: String,
#[serde(default, deserialize_with = "deserialize_optional_string_as_default")]
image_src: String,
}
#[derive(Clone, Debug, Deserialize)]
@@ -359,6 +376,9 @@ pub async fn execute_square_hole_agent_action(
&request_context,
&authenticated,
session_id,
payload.regenerate_visual_assets.unwrap_or(false),
payload.visual_asset_slot,
payload.visual_asset_option_id,
)
.await?
}
@@ -543,18 +563,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
let hole_options = 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 hole_options_json = serialize_square_hole_hole_option_records(&hole_options);
let shape_options = payload
.shape_options
.clone()
.map(|options| square_hole_work_shape_options_to_records(options, hole_options.as_slice()))
.unwrap_or_else(|| existing.shape_options.clone());
let shape_options_json = serialize_square_hole_shape_option_records(&shape_options);
let item = state
.spacetime_client()
.update_square_hole_work(SquareHoleWorkUpdateRecordInput {
@@ -633,6 +653,40 @@ pub async fn publish_square_hole_work(
))
}
pub async fn regenerate_square_hole_work_image(
State(state): State<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<RegenerateSquareHoleWorkImageRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = square_hole_json(payload, &request_context, SQUARE_HOLE_WORKS_PROVIDER)?;
ensure_non_empty(
&request_context,
SQUARE_HOLE_WORKS_PROVIDER,
&profile_id,
"profileId",
)?;
let owner_user_id = authenticated.claims().user_id().to_string();
let item = regenerate_square_hole_visual_asset_for_work(
&state,
&request_context,
owner_user_id,
profile_id,
payload.visual_asset_slot,
payload.visual_asset_option_id,
)
.await?;
Ok(json_success_body(
Some(&request_context),
SquareHoleWorkMutationResponse {
item: map_square_hole_work_profile_response(item),
},
))
}
pub async fn delete_square_hole_work(
State(state): State<AppState>,
Path(profile_id): Path<String>,
@@ -1064,6 +1118,9 @@ async fn generate_square_hole_visual_assets_for_session(
request_context: &RequestContext,
authenticated: &AuthenticatedAccessToken,
session_id: String,
regenerate_visual_assets: bool,
visual_asset_slot: Option<String>,
visual_asset_option_id: Option<String>,
) -> Result<SquareHoleAgentSessionRecord, Response> {
let owner_user_id = authenticated.claims().user_id().to_string();
let session = state
@@ -1100,11 +1157,29 @@ async fn generate_square_hole_visual_assets_for_session(
)
})?;
let requested_slot = normalize_square_hole_visual_asset_slot(
visual_asset_slot.as_deref(),
visual_asset_option_id.as_deref(),
);
let cover_image_src = match work.cover_image_src.clone() {
Some(value) if !value.trim().is_empty() => Some(value),
Some(value)
if !should_generate_square_hole_cover_image(
requested_slot.as_ref(),
regenerate_visual_assets,
value.as_str(),
) =>
{
Some(value)
}
_ => Some(
generate_square_hole_image_data_url(
state,
&owner_user_id,
&session_id,
profile_id.as_str(),
"cover",
SQUARE_HOLE_COVER_IMAGE_KIND,
build_square_hole_cover_prompt(&work).as_str(),
"16:9",
"生成方洞挑战封面图失败",
@@ -1116,10 +1191,23 @@ async fn generate_square_hole_visual_assets_for_session(
),
};
let background_image_src = match work.background_image_src.clone() {
Some(value) if !value.trim().is_empty() => Some(value),
Some(value)
if !should_generate_square_hole_background_image(
requested_slot.as_ref(),
regenerate_visual_assets,
value.as_str(),
) =>
{
Some(value)
}
_ => Some(
generate_square_hole_image_data_url(
state,
&owner_user_id,
&session_id,
profile_id.as_str(),
"background",
SQUARE_HOLE_BACKGROUND_IMAGE_KIND,
build_square_hole_background_prompt(&work).as_str(),
"16:9",
"生成方洞挑战背景图失败",
@@ -1133,18 +1221,21 @@ async fn generate_square_hole_visual_assets_for_session(
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()
{
if !should_generate_square_hole_shape_image(
requested_slot.as_ref(),
regenerate_visual_assets,
option,
) {
continue;
}
option.image_src = Some(
generate_square_hole_image_data_url(
state,
&owner_user_id,
&session_id,
profile_id.as_str(),
option.option_id.as_str(),
SQUARE_HOLE_SHAPE_IMAGE_KIND,
build_square_hole_shape_prompt(&prompt_work, option).as_str(),
"1:1",
"生成方洞挑战形状贴图失败",
@@ -1155,6 +1246,33 @@ async fn generate_square_hole_visual_assets_for_session(
})?,
);
}
let mut hole_options = work.hole_options.clone();
for option in hole_options.iter_mut() {
if !should_generate_square_hole_hole_image(
requested_slot.as_ref(),
regenerate_visual_assets,
option,
) {
continue;
}
option.image_src = Some(
generate_square_hole_image_data_url(
state,
&owner_user_id,
&session_id,
profile_id.as_str(),
option.hole_id.as_str(),
SQUARE_HOLE_HOLE_IMAGE_KIND,
build_square_hole_hole_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()
@@ -1171,7 +1289,7 @@ async fn generate_square_hole_visual_assets_for_session(
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),
hole_options_json: serialize_square_hole_hole_option_records(&hole_options),
shape_count: work.shape_count,
difficulty: work.difficulty,
updated_at_micros: current_utc_micros(),
@@ -1206,8 +1324,180 @@ async fn generate_square_hole_visual_assets_for_session(
Ok(next_session)
}
async fn regenerate_square_hole_visual_asset_for_work(
state: &AppState,
request_context: &RequestContext,
owner_user_id: String,
profile_id: String,
visual_asset_slot: String,
visual_asset_option_id: Option<String>,
) -> Result<SquareHoleWorkProfileRecord, Response> {
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_WORKS_PROVIDER,
map_square_hole_client_error(error),
)
})?;
let requested_slot = normalize_square_hole_visual_asset_slot(
Some(visual_asset_slot.as_str()),
visual_asset_option_id.as_deref(),
)
.ok_or_else(|| {
square_hole_bad_request(
request_context,
SQUARE_HOLE_WORKS_PROVIDER,
"图片槽位不存在",
)
})?;
let synthetic_session_id = work
.source_session_id
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| profile_id.clone());
let prompt_work = work.clone();
match &requested_slot {
SquareHoleVisualAssetSlotRequest::Cover => {
work.cover_image_src = Some(
generate_square_hole_image_data_url(
state,
owner_user_id.as_str(),
synthetic_session_id.as_str(),
profile_id.as_str(),
"cover",
SQUARE_HOLE_COVER_IMAGE_KIND,
build_square_hole_cover_prompt(&prompt_work).as_str(),
"16:9",
"生成方洞挑战封面图失败",
)
.await
.map_err(|error| {
square_hole_error_response(request_context, SQUARE_HOLE_WORKS_PROVIDER, error)
})?,
);
}
SquareHoleVisualAssetSlotRequest::Background => {
work.background_image_src = Some(
generate_square_hole_image_data_url(
state,
owner_user_id.as_str(),
synthetic_session_id.as_str(),
profile_id.as_str(),
"background",
SQUARE_HOLE_BACKGROUND_IMAGE_KIND,
build_square_hole_background_prompt(&prompt_work).as_str(),
"16:9",
"生成方洞挑战背景图失败",
)
.await
.map_err(|error| {
square_hole_error_response(request_context, SQUARE_HOLE_WORKS_PROVIDER, error)
})?,
);
}
SquareHoleVisualAssetSlotRequest::Shape(option_id) => {
let Some(option) = work
.shape_options
.iter_mut()
.find(|option| option.option_id == *option_id)
else {
return Err(square_hole_bad_request(
request_context,
SQUARE_HOLE_WORKS_PROVIDER,
"形状图片槽位不存在",
));
};
option.image_src = Some(
generate_square_hole_image_data_url(
state,
owner_user_id.as_str(),
synthetic_session_id.as_str(),
profile_id.as_str(),
option.option_id.as_str(),
SQUARE_HOLE_SHAPE_IMAGE_KIND,
build_square_hole_shape_prompt(&prompt_work, option).as_str(),
"1:1",
"生成方洞挑战形状贴图失败",
)
.await
.map_err(|error| {
square_hole_error_response(request_context, SQUARE_HOLE_WORKS_PROVIDER, error)
})?,
);
}
SquareHoleVisualAssetSlotRequest::Hole(hole_id) => {
let Some(option) = work
.hole_options
.iter_mut()
.find(|option| option.hole_id == *hole_id)
else {
return Err(square_hole_bad_request(
request_context,
SQUARE_HOLE_WORKS_PROVIDER,
"洞口图片槽位不存在",
));
};
option.image_src = Some(
generate_square_hole_image_data_url(
state,
owner_user_id.as_str(),
synthetic_session_id.as_str(),
profile_id.as_str(),
option.hole_id.as_str(),
SQUARE_HOLE_HOLE_IMAGE_KIND,
build_square_hole_hole_prompt(&prompt_work, option).as_str(),
"1:1",
"生成方洞挑战洞口贴图失败",
)
.await
.map_err(|error| {
square_hole_error_response(request_context, SQUARE_HOLE_WORKS_PROVIDER, error)
})?,
);
}
}
state
.spacetime_client()
.update_square_hole_work(SquareHoleWorkUpdateRecordInput {
profile_id,
owner_user_id,
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: work.cover_image_src.clone().unwrap_or_default(),
background_prompt: work.background_prompt.clone(),
background_image_src: work.background_image_src.clone().unwrap_or_default(),
shape_options_json: serialize_square_hole_shape_option_records(&work.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_WORKS_PROVIDER,
map_square_hole_client_error(error),
)
})
}
async fn generate_square_hole_image_data_url(
state: &AppState,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
slot: &str,
asset_kind: &str,
prompt: &str,
size: &str,
failure_context: &str,
@@ -1232,11 +1522,220 @@ async fn generate_square_hole_image_data_url(
}))
})?;
Ok(format!(
let fallback_data_url = format_square_hole_data_url(&image);
match persist_square_hole_generated_asset(
state,
owner_user_id,
session_id,
profile_id,
slot,
asset_kind,
generated.task_id.as_str(),
image,
current_utc_micros(),
)
.await
{
Ok(image_src) => Ok(image_src),
Err(error) => {
tracing::warn!(
provider = "square-hole-assets",
owner_user_id,
session_id,
profile_id,
slot,
asset_kind,
message = %error.body_text(),
"方洞图片已生成但资产持久化失败,降级回写 Data URL"
);
Ok(fallback_data_url)
}
}
}
fn format_square_hole_data_url(image: &DownloadedOpenAiImage) -> String {
format!(
"data:{};base64,{}",
image.mime_type,
BASE64_STANDARD.encode(image.bytes)
))
BASE64_STANDARD.encode(&image.bytes)
)
}
#[allow(clippy::too_many_arguments)]
async fn persist_square_hole_generated_asset(
state: &AppState,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
slot: &str,
asset_kind: &str,
task_id: &str,
image: DownloadedOpenAiImage,
generated_at_micros: i64,
) -> Result<String, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let http_client = reqwest::Client::new();
let storage_slot = sanitize_square_hole_asset_segment(slot, "slot");
let put_result = oss_client
.put_object(
&http_client,
OssPutObjectRequest {
prefix: LegacyAssetPrefix::SquareHoleAssets,
path_segments: vec![
sanitize_square_hole_asset_segment(session_id, "session"),
sanitize_square_hole_asset_segment(profile_id, "profile"),
sanitize_square_hole_asset_segment(asset_kind, "asset"),
storage_slot.clone(),
format!("asset-{generated_at_micros}"),
],
file_name: format!("image.{}", image.extension),
content_type: Some(image.mime_type.clone()),
access: OssObjectAccess::Private,
metadata: build_square_hole_asset_metadata(
asset_kind,
owner_user_id,
profile_id,
slot,
),
body: image.bytes,
},
)
.await
.map_err(map_square_hole_asset_oss_error)?;
let head = oss_client
.head_object(
&http_client,
OssHeadObjectRequest {
object_key: put_result.object_key.clone(),
},
)
.await
.map_err(map_square_hole_asset_oss_error)?;
match state
.spacetime_client()
.confirm_asset_object(
build_asset_object_upsert_input(
generate_asset_object_id(generated_at_micros),
head.bucket,
head.object_key,
AssetObjectAccessPolicy::Private,
head.content_type.or(Some(image.mime_type)),
head.content_length,
head.etag,
asset_kind.to_string(),
Some(task_id.to_string()),
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
Some(profile_id.to_string()),
generated_at_micros,
)
.map_err(map_square_hole_asset_field_error)?,
)
.await
{
Ok(asset_object) => {
if let Err(error) = state
.spacetime_client()
.bind_asset_object_to_entity(
build_asset_entity_binding_input(
generate_asset_binding_id(generated_at_micros),
asset_object.asset_object_id,
SQUARE_HOLE_ENTITY_KIND.to_string(),
profile_id.to_string(),
slot.to_string(),
asset_kind.to_string(),
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
generated_at_micros,
)
.map_err(map_square_hole_asset_field_error)?,
)
.await
{
tracing::warn!(
provider = "spacetimedb",
owner_user_id,
session_id,
profile_id,
slot,
asset_kind,
error = %error,
"方洞图片资产绑定失败,历史素材索引可能缺少绑定记录"
);
}
}
Err(error) => {
tracing::warn!(
provider = "spacetimedb",
owner_user_id,
session_id,
profile_id,
slot,
asset_kind,
error = %error,
"方洞图片资产对象确认失败,历史素材索引可能缺少本次记录"
);
}
}
Ok(put_result.legacy_public_path)
}
fn build_square_hole_asset_metadata(
asset_kind: &str,
owner_user_id: &str,
profile_id: &str,
slot: &str,
) -> BTreeMap<String, String> {
BTreeMap::from([
("asset_kind".to_string(), asset_kind.to_string()),
("owner_user_id".to_string(), owner_user_id.to_string()),
("profile_id".to_string(), profile_id.to_string()),
(
"entity_kind".to_string(),
SQUARE_HOLE_ENTITY_KIND.to_string(),
),
("entity_id".to_string(), profile_id.to_string()),
("slot".to_string(), slot.to_string()),
])
}
fn map_square_hole_asset_oss_error(error: platform_oss::OssError) -> AppError {
map_oss_error(error, "aliyun-oss")
}
fn map_square_hole_asset_field_error(error: AssetObjectFieldError) -> AppError {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "square-hole-assets",
"message": error.to_string(),
}))
}
fn sanitize_square_hole_asset_segment(value: &str, fallback: &str) -> String {
let sanitized = value
.trim()
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ('\u{4e00}'..='\u{9fff}').contains(&ch) {
ch
} else {
'-'
}
})
.collect::<String>()
.trim_matches('-')
.to_string();
if sanitized.is_empty() {
fallback.to_string()
} else {
sanitized
}
}
fn build_square_hole_cover_prompt(work: &SquareHoleWorkProfileRecord) -> String {
@@ -1280,6 +1779,24 @@ fn build_square_hole_shape_prompt(
)
}
fn build_square_hole_hole_prompt(
work: &SquareHoleWorkProfileRecord,
option: &SquareHoleHoleOptionRecord,
) -> 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()
}
@@ -1518,6 +2035,7 @@ fn map_square_hole_shape_response(
shape_id: item.shape_id,
shape_kind: item.shape_kind,
label: item.label,
target_hole_id: item.target_hole_id,
color: item.color,
image_src: item.image_src,
}
@@ -1532,7 +2050,7 @@ fn map_square_hole_hole_response(
label: slot.label,
x: slot.x,
y: slot.y,
bonus: slot.bonus,
image_src: slot.image_src,
}
}
@@ -1543,6 +2061,7 @@ fn map_square_hole_shape_option_response(
option_id: item.option_id,
shape_kind: item.shape_kind,
label: item.label,
target_hole_id: item.target_hole_id,
image_prompt: item.image_prompt,
image_src: item.image_src,
}
@@ -1555,7 +2074,8 @@ fn map_square_hole_hole_option_response(
hole_id: item.hole_id,
hole_kind: item.hole_kind,
label: item.label,
bonus: item.bonus,
image_prompt: item.image_prompt,
image_src: item.image_src,
}
}
@@ -1566,6 +2086,7 @@ fn map_square_hole_work_shape_option_response(
option_id: item.option_id,
shape_kind: item.shape_kind,
label: item.label,
target_hole_id: item.target_hole_id,
image_prompt: item.image_prompt,
image_src: item.image_src,
}
@@ -1578,7 +2099,8 @@ fn map_square_hole_work_hole_option_response(
hole_id: item.hole_id,
hole_kind: item.hole_kind,
label: item.label,
bonus: item.bonus,
image_prompt: item.image_prompt,
image_src: item.image_src,
}
}
@@ -1595,15 +2117,16 @@ fn map_square_hole_feedback_response(
fn build_config_from_create_request(
payload: &CreateSquareHoleSessionRequest,
) -> SquareHoleConfigJson {
let theme_text = payload
.theme_text
.as_deref()
.or(payload.seed_text.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(SQUARE_HOLE_DEFAULT_THEME);
let hole_options = normalize_hole_options(Vec::new(), theme_text);
SquareHoleConfigJson {
theme_text: payload
.theme_text
.as_deref()
.or(payload.seed_text.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(SQUARE_HOLE_DEFAULT_THEME)
.to_string(),
theme_text: theme_text.to_string(),
twist_rule: payload
.twist_rule
.as_deref()
@@ -1621,20 +2144,11 @@ fn build_config_from_create_request(
.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),
theme_text,
hole_options.as_slice(),
)),
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),
),
hole_options: square_hole_hole_records_to_config_json(hole_options),
background_prompt: default_background_prompt(theme_text),
cover_image_src: String::new(),
background_image_src: String::new(),
}
@@ -1660,12 +2174,17 @@ fn resolve_config_or_default(
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,
)),
shape_options: {
let hole_options = normalize_hole_options(Vec::new(), SQUARE_HOLE_DEFAULT_THEME);
square_hole_shape_records_to_config_json(normalize_shape_options(
Vec::new(),
SQUARE_HOLE_DEFAULT_THEME,
hole_options.as_slice(),
))
},
hole_options: square_hole_hole_records_to_config_json(normalize_hole_options(
Vec::new(),
SQUARE_HOLE_DEFAULT_THEME,
)),
background_prompt: default_background_prompt(SQUARE_HOLE_DEFAULT_THEME),
cover_image_src: String::new(),
@@ -1730,13 +2249,23 @@ fn square_hole_hole_records_to_config_json(
fn square_hole_work_shape_options_to_records(
options: Vec<SquareHoleWorkShapeOptionResponse>,
hole_options: &[SquareHoleHoleOptionRecord],
) -> Vec<SquareHoleShapeOptionRecord> {
let fallback_hole_id = hole_options
.first()
.map(|option| option.hole_id.clone())
.unwrap_or_else(|| "hole-1".to_string());
options
.into_iter()
.map(|option| SquareHoleShapeOptionRecord {
option_id: option.option_id,
shape_kind: option.shape_kind,
label: option.label,
target_hole_id: hole_options
.iter()
.find(|hole| hole.hole_id == option.target_hole_id)
.map(|hole| hole.hole_id.clone())
.unwrap_or_else(|| fallback_hole_id.clone()),
image_prompt: option.image_prompt,
image_src: option.image_src.filter(|value| !value.trim().is_empty()),
})
@@ -1752,7 +2281,8 @@ fn square_hole_work_hole_options_to_records(
hole_id: option.hole_id,
hole_kind: option.hole_kind,
label: option.label,
bonus: option.bonus,
image_prompt: option.image_prompt,
image_src: option.image_src.filter(|value| !value.trim().is_empty()),
})
.collect()
}
@@ -1782,12 +2312,104 @@ fn clean_prompt_text(value: &str, fallback: &str) -> String {
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum SquareHoleVisualAssetSlotRequest {
Cover,
Background,
Shape(String),
Hole(String),
}
fn normalize_square_hole_visual_asset_slot(
slot: Option<&str>,
option_id: Option<&str>,
) -> Option<SquareHoleVisualAssetSlotRequest> {
match slot.map(str::trim).unwrap_or_default() {
"cover" => Some(SquareHoleVisualAssetSlotRequest::Cover),
"background" => Some(SquareHoleVisualAssetSlotRequest::Background),
"shape" => option_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| SquareHoleVisualAssetSlotRequest::Shape(value.to_string())),
"hole" => option_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| SquareHoleVisualAssetSlotRequest::Hole(value.to_string())),
_ => None,
}
}
fn should_generate_square_hole_cover_image(
requested_slot: Option<&SquareHoleVisualAssetSlotRequest>,
regenerate_visual_assets: bool,
current_image_src: &str,
) -> bool {
matches!(
requested_slot,
Some(SquareHoleVisualAssetSlotRequest::Cover)
) || (requested_slot.is_none()
&& (regenerate_visual_assets || current_image_src.trim().is_empty()))
}
fn should_generate_square_hole_background_image(
requested_slot: Option<&SquareHoleVisualAssetSlotRequest>,
regenerate_visual_assets: bool,
current_image_src: &str,
) -> bool {
matches!(
requested_slot,
Some(SquareHoleVisualAssetSlotRequest::Background)
) || (requested_slot.is_none()
&& (regenerate_visual_assets || current_image_src.trim().is_empty()))
}
fn should_generate_square_hole_shape_image(
requested_slot: Option<&SquareHoleVisualAssetSlotRequest>,
regenerate_visual_assets: bool,
option: &SquareHoleShapeOptionRecord,
) -> bool {
match requested_slot {
Some(SquareHoleVisualAssetSlotRequest::Shape(option_id)) => option.option_id == *option_id,
Some(_) => false,
None => {
regenerate_visual_assets
|| option
.image_src
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_none()
}
}
}
fn should_generate_square_hole_hole_image(
requested_slot: Option<&SquareHoleVisualAssetSlotRequest>,
regenerate_visual_assets: bool,
option: &SquareHoleHoleOptionRecord,
) -> bool {
match requested_slot {
Some(SquareHoleVisualAssetSlotRequest::Hole(hole_id)) => option.hole_id == *hole_id,
Some(_) => false,
None => {
regenerate_visual_assets
|| option
.image_src
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_none()
}
}
}
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,
target_hole_id: option.target_hole_id,
image_prompt: option.image_prompt,
image_src: option.image_src.unwrap_or_default(),
}
@@ -1800,6 +2422,7 @@ impl From<SquareHoleShapeOptionRecord> for SquareHoleConfigShapeOptionJson {
option_id: option.option_id,
shape_kind: option.shape_kind,
label: option.label,
target_hole_id: option.target_hole_id,
image_prompt: option.image_prompt,
image_src: option.image_src.unwrap_or_default(),
}
@@ -1812,7 +2435,8 @@ impl From<module_square_hole::SquareHoleHoleOption> for SquareHoleConfigHoleOpti
hole_id: option.hole_id,
hole_kind: option.hole_kind,
label: option.label,
bonus: option.bonus,
image_prompt: option.image_prompt,
image_src: option.image_src.unwrap_or_default(),
}
}
}
@@ -1823,7 +2447,8 @@ impl From<SquareHoleHoleOptionRecord> for SquareHoleConfigHoleOptionJson {
hole_id: option.hole_id,
hole_kind: option.hole_kind,
label: option.label,
bonus: option.bonus,
image_prompt: option.image_prompt,
image_src: option.image_src.unwrap_or_default(),
}
}
}

View File

@@ -84,6 +84,7 @@ struct SquareHoleAgentShapeOptionOutput {
option_id: String,
shape_kind: String,
label: String,
target_hole_id: String,
image_prompt: String,
#[serde(default)]
image_src: String,
@@ -95,8 +96,9 @@ struct SquareHoleAgentHoleOptionOutput {
hole_id: String,
hole_kind: String,
label: String,
image_prompt: String,
#[serde(default)]
bonus: bool,
image_src: String,
}
pub(crate) async fn run_square_hole_agent_turn<F>(
@@ -195,12 +197,12 @@ 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 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 hole_options = parse_hole_options(value, session, &theme_text);
let shape_options = parse_shape_options(value, session, &theme_text, hole_options.as_slice());
let background_prompt = read_text_field(value, "backgroundPrompt")
.or_else(|| {
session
@@ -243,6 +245,7 @@ fn parse_shape_options(
value: &JsonValue,
session: &SquareHoleAgentSessionRecord,
theme_text: &str,
hole_options: &[SquareHoleHoleOption],
) -> Vec<SquareHoleShapeOption> {
let parsed = value
.get("shapeOptions")
@@ -258,8 +261,19 @@ fn parse_shape_options(
.unwrap_or_else(|| fallback_shape_kind(index).to_string()),
label: read_text_field(item, "label")
.unwrap_or_else(|| fallback_shape_label(index).to_string()),
target_hole_id: read_text_field(item, "targetHoleId")
.filter(|value| hole_options.iter().any(|option| option.hole_id == *value))
.unwrap_or_else(|| {
hole_options
.get(index % hole_options.len().max(1))
.map(|option| option.hole_id.clone())
.unwrap_or_else(|| fallback_target_hole_id(index).to_string())
}),
image_prompt: read_text_field(item, "imagePrompt").unwrap_or_else(|| {
format!("{theme_text}主题的{}贴纸图,透明背景,明亮游戏资产", fallback_shape_label(index))
format!(
"{theme_text}主题的{}贴纸图,透明背景,明亮游戏资产",
fallback_shape_label(index)
)
}),
image_src: read_text_field(item, "imageSrc"),
})
@@ -274,18 +288,20 @@ fn parse_shape_options(
option_id: option.option_id.clone(),
shape_kind: option.shape_kind.clone(),
label: option.label.clone(),
target_hole_id: option.target_hole_id.clone(),
image_prompt: option.image_prompt.clone(),
image_src: option.image_src.clone(),
})
.collect()
});
normalize_shape_options(parsed, theme_text)
normalize_shape_options(parsed, theme_text, hole_options)
}
fn parse_hole_options(
value: &JsonValue,
session: &SquareHoleAgentSessionRecord,
theme_text: &str,
) -> Vec<SquareHoleHoleOption> {
let parsed = value
.get("holeOptions")
@@ -298,13 +314,16 @@ fn parse_hole_options(
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()),
.unwrap_or_else(|| format!("hole-{}", index + 1)),
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),
image_prompt: read_text_field(item, "imagePrompt").unwrap_or_else(|| {
format!(
"{theme_text}主题的{}贴纸图,透明背景,明亮游戏资产",
fallback_hole_label(index)
)
}),
image_src: read_text_field(item, "imageSrc"),
})
.collect::<Vec<_>>()
})
@@ -317,12 +336,13 @@ fn parse_hole_options(
hole_id: option.hole_id.clone(),
hole_kind: option.hole_kind.clone(),
label: option.label.clone(),
bonus: option.bonus,
image_prompt: option.image_prompt.clone(),
image_src: option.image_src.clone(),
})
.collect()
});
normalize_hole_options(parsed)
normalize_hole_options(parsed, theme_text)
}
fn read_text_field(value: &JsonValue, field_name: &str) -> Option<String> {
@@ -363,14 +383,15 @@ fn fallback_shape_label(index: usize) -> &'static str {
}
}
fn fallback_hole_label(index: usize) -> &'static str {
match fallback_shape_kind(index) {
"square" => "方洞",
"circle" => "圆洞",
"triangle" => "三角洞",
"diamond" => "菱形洞",
"star" => "星形洞",
_ => "拱形洞",
fn fallback_hole_label(index: usize) -> String {
format!("洞口 {}", index + 1)
}
fn fallback_target_hole_id(index: usize) -> &'static str {
match index % 3 {
0 => "hole-1",
1 => "hole-2",
_ => "hole-3",
}
}
@@ -380,6 +401,7 @@ impl From<SquareHoleShapeOption> for SquareHoleAgentShapeOptionOutput {
option_id: option.option_id,
shape_kind: option.shape_kind,
label: option.label,
target_hole_id: option.target_hole_id,
image_prompt: option.image_prompt,
image_src: option.image_src.unwrap_or_default(),
}
@@ -392,7 +414,8 @@ impl From<SquareHoleHoleOption> for SquareHoleAgentHoleOptionOutput {
hole_id: option.hole_id,
hole_kind: option.hole_kind,
label: option.label,
bonus: option.bonus,
image_prompt: option.image_prompt,
image_src: option.image_src.unwrap_or_default(),
}
}
}
@@ -472,15 +495,16 @@ mod tests {
"optionId": "stamp",
"shapeKind": "circle",
"label": "圆形印章",
"targetHoleId": "folder",
"imagePrompt": "办公室圆形印章贴纸"
}
],
"holeOptions": [
{
"holeId": "folder",
"holeKind": "square",
"holeKind": "folder",
"label": "档案盒方洞",
"bonus": true
"imagePrompt": "办公室档案盒洞口贴纸"
}
],
"backgroundPrompt": "办公室桌面纸箱玩具背景"
@@ -501,8 +525,12 @@ mod tests {
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.shape_options[0].target_hole_id, "folder");
assert_eq!(output.next_config.hole_options[0].label, "档案盒方洞");
assert!(output.next_config.hole_options[0].bonus);
assert_eq!(
output.next_config.hole_options[0].image_prompt,
"办公室档案盒洞口贴纸"
);
assert_eq!(
output.next_config.background_prompt,
"办公室桌面纸箱玩具背景"

View File

@@ -16,8 +16,12 @@ 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 hole_options = normalize_hole_options(config.hole_options.clone(), &config.theme_text);
let shape_options = normalize_shape_options(
config.shape_options.clone(),
&config.theme_text,
hole_options.as_slice(),
);
let background_prompt = normalize_required_string(&config.background_prompt)
.unwrap_or_else(|| default_background_prompt(&config.theme_text));
let summary = format!(
@@ -73,8 +77,12 @@ pub fn create_work_profile(
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()),
hole_options: { normalize_hole_options(draft.hole_options.clone(), &draft.theme_text) },
shape_options: normalize_shape_options(
draft.shape_options.clone(),
&draft.theme_text,
normalize_hole_options(draft.hole_options.clone(), &draft.theme_text).as_slice(),
),
shape_count: draft.shape_count,
difficulty: draft.difficulty,
publication_status: SquareHolePublicationStatus::Draft,
@@ -114,7 +122,13 @@ 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);
let hole_options = normalize_hole_options(config.hole_options.clone(), &config.theme_text);
let shape_options = normalize_shape_options(
config.shape_options.clone(),
&config.theme_text,
hole_options.as_slice(),
);
let current_shape = build_shape_at(0, config.shape_count, shape_options.as_slice(), &run_id);
Ok(SquareHoleRunSnapshot {
run_id,
@@ -132,13 +146,9 @@ pub fn start_run_at(
score: 0,
rule_label: config.twist_rule.clone(),
background_image_src: config.background_image_src.clone(),
current_shape: Some(build_shape_at(
0,
config.shape_count,
shape_options.as_slice(),
)),
current_shape: Some(current_shape),
shape_options,
holes: build_holes(config.hole_options.as_slice()),
holes: build_holes(hole_options.as_slice()),
last_feedback: None,
})
}
@@ -182,10 +192,7 @@ 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);
let bonus_score = if hole.bonus { 50 } else { 0 };
next.score = next
.score
.saturating_add(100 + next.combo * 10 + bonus_score);
next.score = next.score.saturating_add(100 + next.combo * 10);
next.current_shape = if next.completed_shape_count >= next.total_shape_count {
next.status = SquareHoleRunStatus::Won;
None
@@ -194,6 +201,7 @@ pub fn confirm_drop_at(
next.completed_shape_count,
next.total_shape_count,
next.shape_options.as_slice(),
next.run_id.as_str(),
))
};
next.snapshot_version = next.snapshot_version.saturating_add(1);
@@ -246,8 +254,9 @@ pub fn build_shape_at(
index: u32,
total: u32,
options: &[SquareHoleShapeOption],
run_seed: &str,
) -> SquareHoleShapeSnapshot {
if let Some(option) = pick_shape_option(index, options) {
if let Some(option) = pick_shape_option(index, options, run_seed) {
let shape_kind = option.shape_kind;
let label = option.label;
return SquareHoleShapeSnapshot {
@@ -255,6 +264,7 @@ pub fn build_shape_at(
color: fallback_shape_color(&shape_kind).to_string(),
shape_kind,
label,
target_hole_id: option.target_hole_id,
image_src: option.image_src,
};
}
@@ -282,6 +292,7 @@ pub fn build_shape_at(
_ => "星形块",
}
.to_string(),
target_hole_id: fallback_target_hole_id(index).to_string(),
color: match kind {
"square" => "#facc15",
"circle" => "#22c55e",
@@ -295,36 +306,34 @@ pub fn build_shape_at(
}
pub fn default_holes() -> Vec<SquareHoleHoleSnapshot> {
vec![
SquareHoleHoleSnapshot {
hole_id: "square-hole".to_string(),
hole_kind: "square".to_string(),
label: "方洞".to_string(),
x: 0.5,
y: 0.28,
bonus: true,
},
SquareHoleHoleSnapshot {
hole_id: "circle-hole".to_string(),
hole_kind: "circle".to_string(),
label: "圆洞".to_string(),
x: 0.24,
y: 0.54,
bonus: false,
},
SquareHoleHoleSnapshot {
hole_id: "triangle-hole".to_string(),
hole_kind: "triangle".to_string(),
label: "三角洞".to_string(),
x: 0.76,
y: 0.54,
bonus: false,
},
]
default_hole_options("玩具")
.into_iter()
.enumerate()
.map(|(index, option)| {
let positions = [(0.5, 0.28), (0.24, 0.54), (0.76, 0.54)];
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,
image_src: option.image_src,
}
})
.collect()
}
pub fn default_shape_options(theme_text: &str) -> Vec<SquareHoleShapeOption> {
pub fn default_shape_options(theme_text: &str, hole_ids: &[String]) -> Vec<SquareHoleShapeOption> {
let theme = normalize_required_string(theme_text).unwrap_or_else(|| "玩具".to_string());
let default_hole_ids = if hole_ids.is_empty() {
default_hole_options(theme_text)
.into_iter()
.map(|option| option.hole_id)
.collect::<Vec<_>>()
} else {
hole_ids.to_vec()
};
[
("square", "方块"),
("circle", "圆块"),
@@ -334,35 +343,41 @@ pub fn default_shape_options(theme_text: &str) -> Vec<SquareHoleShapeOption> {
("arch", "拱形块"),
]
.into_iter()
.map(|(kind, label)| SquareHoleShapeOption {
.enumerate()
.map(|(index, (kind, label))| SquareHoleShapeOption {
option_id: format!("{kind}-option"),
shape_kind: kind.to_string(),
label: label.to_string(),
target_hole_id: default_hole_ids[index % default_hole_ids.len()].clone(),
image_prompt: format!("{theme}主题的{label}贴纸图,透明背景,明亮可爱,游戏资产"),
image_src: None,
})
.collect()
}
pub fn default_hole_options() -> Vec<SquareHoleHoleOption> {
pub fn default_hole_options(theme_text: &str) -> Vec<SquareHoleHoleOption> {
let theme = normalize_required_string(theme_text).unwrap_or_else(|| "玩具".to_string());
vec![
SquareHoleHoleOption {
hole_id: "square-hole".to_string(),
hole_kind: "square".to_string(),
label: "".to_string(),
bonus: true,
hole_id: "hole-1".to_string(),
hole_kind: "hole-1".to_string(),
label: "口 1".to_string(),
image_prompt: format!("{theme}主题的第一个洞口贴纸图,透明背景,明亮可爱,游戏资产"),
image_src: None,
},
SquareHoleHoleOption {
hole_id: "circle-hole".to_string(),
hole_kind: "circle".to_string(),
label: "".to_string(),
bonus: false,
hole_id: "hole-2".to_string(),
hole_kind: "hole-2".to_string(),
label: "口 2".to_string(),
image_prompt: format!("{theme}主题的第二个洞口贴纸图,透明背景,明亮可爱,游戏资产"),
image_src: None,
},
SquareHoleHoleOption {
hole_id: "triangle-hole".to_string(),
hole_kind: "triangle".to_string(),
label: "三角".to_string(),
bonus: false,
hole_id: "hole-3".to_string(),
hole_kind: "hole-3".to_string(),
label: "口 3".to_string(),
image_prompt: format!("{theme}主题的第三个洞口贴纸图,透明背景,明亮可爱,游戏资产"),
image_src: None,
},
]
}
@@ -370,7 +385,19 @@ pub fn default_hole_options() -> Vec<SquareHoleHoleOption> {
pub fn normalize_shape_options(
options: Vec<SquareHoleShapeOption>,
theme_text: &str,
hole_options: &[SquareHoleHoleOption],
) -> Vec<SquareHoleShapeOption> {
let hole_ids = if hole_options.is_empty() {
default_hole_options(theme_text)
.into_iter()
.map(|option| option.hole_id)
.collect::<Vec<_>>()
} else {
hole_options
.iter()
.map(|option| option.hole_id.clone())
.collect::<Vec<_>>()
};
let mut normalized = Vec::new();
for (index, option) in options.into_iter().enumerate() {
let shape_kind = normalize_required_string(&option.shape_kind)
@@ -379,6 +406,9 @@ pub fn normalize_shape_options(
.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 target_hole_id = normalize_required_string(&option.target_hole_id)
.filter(|value| hole_ids.iter().any(|hole_id| hole_id == value))
.unwrap_or_else(|| hole_ids[index % hole_ids.len()].clone());
let image_prompt = normalize_required_string(&option.image_prompt).unwrap_or_else(|| {
format!(
"{}主题的{}贴纸图,透明背景,明亮可爱,游戏资产",
@@ -390,12 +420,13 @@ pub fn normalize_shape_options(
option_id,
shape_kind,
label,
target_hole_id,
image_prompt,
image_src: option.image_src.and_then(normalize_required_string),
});
}
let defaults = default_shape_options(theme_text);
let defaults = default_shape_options(theme_text, hole_ids.as_slice());
let mut default_index = 0;
while normalized.len() < SQUARE_HOLE_MIN_SHAPE_OPTION_COUNT {
let mut fallback = defaults[default_index % defaults.len()].clone();
@@ -411,7 +442,10 @@ pub fn normalize_shape_options(
normalized
}
pub fn normalize_hole_options(options: Vec<SquareHoleHoleOption>) -> Vec<SquareHoleHoleOption> {
pub fn normalize_hole_options(
options: Vec<SquareHoleHoleOption>,
theme_text: &str,
) -> Vec<SquareHoleHoleOption> {
let mut normalized = Vec::new();
for (index, option) in options
.into_iter()
@@ -419,20 +453,27 @@ pub fn normalize_hole_options(options: Vec<SquareHoleHoleOption>) -> Vec<SquareH
.enumerate()
{
let hole_kind = normalize_required_string(&option.hole_kind)
.unwrap_or_else(|| fallback_shape_kind(index));
.unwrap_or_else(|| format!("hole-{}", index + 1));
let label = normalize_required_string(&option.label)
.unwrap_or_else(|| fallback_hole_label(&hole_kind).to_string());
.unwrap_or_else(|| fallback_hole_label(index).to_string());
let hole_id = normalize_required_string(&option.hole_id)
.unwrap_or_else(|| format!("{hole_kind}-hole-{index}"));
.unwrap_or_else(|| format!("hole-{}", index + 1));
normalized.push(SquareHoleHoleOption {
hole_id,
hole_kind,
label,
bonus: option.bonus,
image_prompt: normalize_required_string(&option.image_prompt).unwrap_or_else(|| {
format!(
"{}主题的{}贴纸图,透明背景,明亮可爱,游戏资产",
normalize_required_string(theme_text).unwrap_or_else(|| "玩具".to_string()),
fallback_hole_label(index)
)
}),
image_src: option.image_src.and_then(normalize_required_string),
});
}
for fallback in default_hole_options() {
for fallback in default_hole_options(theme_text) {
if normalized.len() >= SQUARE_HOLE_MIN_HOLE_OPTION_COUNT {
break;
}
@@ -444,11 +485,6 @@ pub fn normalize_hole_options(options: Vec<SquareHoleHoleOption>) -> Vec<SquareH
}
}
if normalized.iter().all(|option| !option.bonus)
&& let Some(first) = normalized.first_mut()
{
first.bonus = true;
}
normalized
}
@@ -460,7 +496,7 @@ pub fn default_background_prompt(theme_text: &str) -> String {
}
fn build_holes(options: &[SquareHoleHoleOption]) -> Vec<SquareHoleHoleSnapshot> {
let normalized = normalize_hole_options(options.to_vec());
let normalized = normalize_hole_options(options.to_vec(), "玩具");
let positions = [
(0.5, 0.28),
(0.24, 0.54),
@@ -480,7 +516,7 @@ fn build_holes(options: &[SquareHoleHoleOption]) -> Vec<SquareHoleHoleSnapshot>
label: option.label,
x,
y,
bonus: option.bonus,
image_src: option.image_src,
}
})
.collect()
@@ -490,18 +526,34 @@ fn build_shape_from_previous_options(
index: u32,
total: u32,
options: &[SquareHoleShapeOption],
run_seed: &str,
) -> SquareHoleShapeSnapshot {
build_shape_at(index, total, options)
build_shape_at(index, total, options, run_seed)
}
fn pick_shape_option(
index: u32,
options: &[SquareHoleShapeOption],
run_seed: &str,
) -> Option<SquareHoleShapeOption> {
if options.is_empty() {
return None;
}
options.get(index as usize % options.len()).cloned()
let base_seed = run_seed.as_bytes().iter().fold(index, |current, byte| {
current.wrapping_mul(31).wrapping_add(u32::from(*byte))
});
let seed = options
.iter()
.enumerate()
.map(|(option_index, option)| {
let mut hash = base_seed.wrapping_add(option_index as u32).wrapping_mul(97);
for byte in option.option_id.as_bytes() {
hash = hash.wrapping_mul(33).wrapping_add(u32::from(*byte));
}
hash
})
.fold(0u32, u32::wrapping_add);
options.get((seed as usize) % options.len()).cloned()
}
fn fallback_shape_kind(index: usize) -> String {
@@ -528,16 +580,8 @@ fn fallback_shape_label(kind: &str) -> &'static str {
}
}
fn fallback_hole_label(kind: &str) -> &'static str {
match kind {
"square" => "方洞",
"circle" => "圆洞",
"triangle" => "三角洞",
"diamond" => "菱形洞",
"star" => "星形洞",
"arch" => "拱形洞",
_ => "洞口",
}
fn fallback_hole_label(index: usize) -> String {
format!("洞口 {}", index + 1)
}
fn fallback_shape_color(kind: &str) -> &'static str {
@@ -556,8 +600,15 @@ fn is_shape_accepted_by_hole(
shape: &SquareHoleShapeSnapshot,
hole: &SquareHoleHoleSnapshot,
) -> bool {
// 中文注释:首版核心反差固定为“方洞万能”,保留同形状洞口兼容便于后续扩展规则。
hole.hole_kind == "square" || hole.hole_kind == shape.shape_kind
shape.target_hole_id == hole.hole_id
}
fn fallback_target_hole_id(index: u32) -> &'static str {
match index % 3 {
0 => "hole-1",
1 => "hole-2",
_ => "hole-3",
}
}
fn rejected(
@@ -590,28 +641,39 @@ mod tests {
build_creator_config("玩具", "方洞万能", shape_count, 4).expect("config should be valid")
}
fn test_config_with_bonus_hole(shape_count: u32) -> SquareHoleCreatorConfig {
fn test_config_with_custom_targets(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,
hole_id: "hole-alpha".to_string(),
hole_kind: "hole-alpha".to_string(),
label: "口 Alpha".to_string(),
image_prompt: "玩具主题的 Alpha 洞口贴纸图".to_string(),
image_src: None,
},
SquareHoleHoleOption {
hole_id: "circle-hole".to_string(),
hole_kind: "circle".to_string(),
label: "".to_string(),
bonus: false,
hole_id: "hole-beta".to_string(),
hole_kind: "hole-beta".to_string(),
label: "口 Beta".to_string(),
image_prompt: "玩具主题的 Beta 洞口贴纸图".to_string(),
image_src: None,
},
SquareHoleHoleOption {
hole_id: "triangle-hole".to_string(),
hole_kind: "triangle".to_string(),
label: "三角".to_string(),
bonus: false,
hole_id: "hole-gamma".to_string(),
hole_kind: "hole-gamma".to_string(),
label: "口 Gamma".to_string(),
image_prompt: "玩具主题的 Gamma 洞口贴纸图".to_string(),
image_src: None,
},
],
shape_options: vec![SquareHoleShapeOption {
option_id: "shape-alpha".to_string(),
shape_kind: "square".to_string(),
label: "Alpha 形状".to_string(),
target_hole_id: "hole-alpha".to_string(),
image_prompt: "玩具主题的 Alpha 形状贴纸图".to_string(),
image_src: None,
}],
..test_config(shape_count)
}
}
@@ -642,7 +704,7 @@ mod tests {
}
#[test]
fn square_hole_accepts_non_square_shape() {
fn target_hole_accepts_current_shape() {
let run = start_run_at(
"run-1".to_string(),
"user-1".to_string(),
@@ -656,7 +718,7 @@ mod tests {
&SquareHoleDropInput {
run_id: run.run_id.clone(),
owner_user_id: run.owner_user_id.clone(),
hole_id: "square-hole".to_string(),
hole_id: run.current_shape.as_ref().unwrap().target_hole_id.clone(),
client_snapshot_version: run.snapshot_version,
client_event_id: "event-1".to_string(),
dropped_at_ms: 1_100,
@@ -670,12 +732,12 @@ mod tests {
}
#[test]
fn bonus_hole_adds_extra_score_when_accepted() {
fn accepted_drop_uses_base_combo_score() {
let run = start_run_at(
"run-1".to_string(),
"user-1".to_string(),
"profile-1".to_string(),
&test_config_with_bonus_hole(8),
&test_config_with_custom_targets(8),
1_000,
)
.expect("run should start");
@@ -684,7 +746,7 @@ mod tests {
&SquareHoleDropInput {
run_id: run.run_id.clone(),
owner_user_id: run.owner_user_id.clone(),
hole_id: "square-hole".to_string(),
hole_id: run.current_shape.as_ref().unwrap().target_hole_id.clone(),
client_snapshot_version: run.snapshot_version,
client_event_id: "event-1".to_string(),
dropped_at_ms: 1_100,
@@ -693,28 +755,35 @@ mod tests {
.expect("drop should resolve");
assert!(result.feedback.accepted);
assert_eq!(result.run.score, 160);
assert_eq!(result.run.score, 110);
}
#[test]
fn wrong_non_square_hole_rejects_and_resets_combo() {
fn wrong_target_hole_rejects_and_resets_combo() {
let mut run = start_run_at(
"run-1".to_string(),
"user-1".to_string(),
"profile-1".to_string(),
&test_config(8),
&test_config_with_custom_targets(8),
1_000,
)
.expect("run should start");
run.current_shape = Some(build_shape_at(1, 8, &[]));
run.combo = 2;
let target_hole_id = run.current_shape.as_ref().unwrap().target_hole_id.clone();
let wrong_hole_id = run
.holes
.iter()
.find(|hole| hole.hole_id != target_hole_id)
.expect("test run should have a non-target hole")
.hole_id
.clone();
let result = confirm_drop_at(
&run,
&SquareHoleDropInput {
run_id: run.run_id.clone(),
owner_user_id: run.owner_user_id.clone(),
hole_id: "circle-hole".to_string(),
hole_id: wrong_hole_id,
client_snapshot_version: run.snapshot_version,
client_event_id: "event-1".to_string(),
dropped_at_ms: 1_100,
@@ -741,14 +810,14 @@ 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, &[], run.run_id.as_str()));
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(),
hole_id: run.current_shape.as_ref().unwrap().target_hole_id.clone(),
client_snapshot_version: run.snapshot_version,
client_event_id: "event-1".to_string(),
dropped_at_ms: 1_100,

View File

@@ -32,13 +32,14 @@ pub fn build_creator_config(
shape_count: u32,
difficulty: u32,
) -> Result<SquareHoleCreatorConfig, SquareHoleError> {
let hole_options = normalize_hole_options(Vec::new(), theme_text);
Ok(SquareHoleCreatorConfig {
theme_text: normalize_theme_text(theme_text)?,
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()),
shape_options: normalize_shape_options(Vec::new(), theme_text, hole_options.as_slice()),
hole_options,
background_prompt: format!("{theme_text}主题的竖屏游戏背景,舞台中央有多个形状洞口"),
cover_image_src: None,
background_image_src: None,
@@ -98,36 +99,3 @@ pub fn validate_publish_requirements(draft: &SquareHoleResultDraft) -> Vec<Strin
}
blockers
}
#[deprecated(note = "请使用 compile_result_draft(profile_id, &config)")]
pub fn build_result_draft(
profile_id: String,
theme_text: String,
twist_rule: String,
shape_count: u32,
difficulty: u32,
) -> SquareHoleResultDraft {
let game_name = format!("{theme_text}方洞挑战");
let summary = format!(
"{theme_text}主题,{} 个形状,难度 {},规则:{twist_rule}",
shape_count, difficulty
);
let blockers = Vec::new();
SquareHoleResultDraft {
profile_id,
game_name,
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,
blockers,
}
}

View File

@@ -76,6 +76,8 @@ pub struct SquareHoleShapeOption {
pub option_id: String,
pub shape_kind: String,
pub label: String,
#[serde(default)]
pub target_hole_id: String,
pub image_prompt: String,
#[serde(default)]
pub image_src: Option<String>,
@@ -88,7 +90,9 @@ pub struct SquareHoleHoleOption {
pub hole_kind: String,
pub label: String,
#[serde(default)]
pub bonus: bool,
pub image_prompt: String,
#[serde(default)]
pub image_src: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -147,6 +151,8 @@ pub struct SquareHoleShapeSnapshot {
pub shape_id: String,
pub shape_kind: String,
pub label: String,
#[serde(default)]
pub target_hole_id: String,
pub color: String,
#[serde(default)]
pub image_src: Option<String>,
@@ -161,7 +167,7 @@ pub struct SquareHoleHoleSnapshot {
pub x: f32,
pub y: f32,
#[serde(default)]
pub bonus: bool,
pub image_src: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]

View File

@@ -17,11 +17,12 @@ pub const DEFAULT_POST_MAX_SIZE_BYTES: u64 = 20 * 1024 * 1024;
pub const DEFAULT_SUCCESS_ACTION_STATUS: u16 = 200;
pub const DEFAULT_METADATA_TOTAL_BYTES_LIMIT: usize = 8 * 1024;
pub const LEGACY_PUBLIC_PREFIXES: [&str; 7] = [
pub const LEGACY_PUBLIC_PREFIXES: [&str; 8] = [
"generated-character-drafts",
"generated-characters",
"generated-animations",
"generated-big-fish-assets",
"generated-square-hole-assets",
"generated-custom-world-scenes",
"generated-custom-world-covers",
"generated-qwen-sprites",
@@ -40,6 +41,7 @@ pub enum LegacyAssetPrefix {
Characters,
Animations,
BigFishAssets,
SquareHoleAssets,
PuzzleAssets,
CustomWorldScenes,
CustomWorldCovers,
@@ -221,6 +223,7 @@ impl LegacyAssetPrefix {
"generated-characters" => Some(Self::Characters),
"generated-animations" => Some(Self::Animations),
"generated-big-fish-assets" => Some(Self::BigFishAssets),
"generated-square-hole-assets" => Some(Self::SquareHoleAssets),
"generated-puzzle-assets" => Some(Self::PuzzleAssets),
"generated-custom-world-scenes" => Some(Self::CustomWorldScenes),
"generated-custom-world-covers" => Some(Self::CustomWorldCovers),
@@ -235,6 +238,7 @@ impl LegacyAssetPrefix {
Self::Characters => "generated-characters",
Self::Animations => "generated-animations",
Self::BigFishAssets => "generated-big-fish-assets",
Self::SquareHoleAssets => "generated-square-hole-assets",
Self::PuzzleAssets => "generated-puzzle-assets",
Self::CustomWorldScenes => "generated-custom-world-scenes",
Self::CustomWorldCovers => "generated-custom-world-covers",

View File

@@ -36,6 +36,12 @@ pub struct ExecuteSquareHoleActionRequest {
pub tags: Option<Vec<String>>,
#[serde(default)]
pub cover_image_src: Option<String>,
#[serde(default)]
pub regenerate_visual_assets: Option<bool>,
#[serde(default)]
pub visual_asset_slot: Option<String>,
#[serde(default)]
pub visual_asset_option_id: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -44,6 +50,7 @@ pub struct SquareHoleShapeOptionResponse {
pub option_id: String,
pub shape_kind: String,
pub label: String,
pub target_hole_id: String,
pub image_prompt: String,
#[serde(default)]
pub image_src: Option<String>,
@@ -55,7 +62,9 @@ pub struct SquareHoleHoleOptionResponse {
pub hole_id: String,
pub hole_kind: String,
pub label: String,
pub bonus: bool,
pub image_prompt: String,
#[serde(default)]
pub image_src: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]

View File

@@ -29,6 +29,7 @@ pub struct SquareHoleShapeSnapshotResponse {
pub shape_id: String,
pub shape_kind: String,
pub label: String,
pub target_hole_id: String,
pub color: String,
#[serde(default)]
pub image_src: Option<String>,
@@ -42,7 +43,8 @@ pub struct SquareHoleHoleSnapshotResponse {
pub label: String,
pub x: f32,
pub y: f32,
pub bonus: bool,
#[serde(default)]
pub image_src: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]

View File

@@ -6,6 +6,7 @@ pub struct SquareHoleShapeOptionResponse {
pub option_id: String,
pub shape_kind: String,
pub label: String,
pub target_hole_id: String,
pub image_prompt: String,
#[serde(default)]
pub image_src: Option<String>,
@@ -17,7 +18,9 @@ pub struct SquareHoleHoleOptionResponse {
pub hole_id: String,
pub hole_kind: String,
pub label: String,
pub bonus: bool,
pub image_prompt: String,
#[serde(default)]
pub image_src: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -43,6 +46,14 @@ pub struct PutSquareHoleWorkRequest {
pub difficulty: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RegenerateSquareHoleWorkImageRequest {
pub visual_asset_slot: String,
#[serde(default)]
pub visual_asset_option_id: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleWorkSummaryResponse {

View File

@@ -3084,6 +3084,7 @@ fn map_square_hole_shape_snapshot(
shape_id: snapshot.shape_id,
shape_kind: snapshot.shape_kind,
label: snapshot.label,
target_hole_id: snapshot.target_hole_id,
color: snapshot.color,
image_src: empty_string_to_none(snapshot.image_src),
}
@@ -3098,7 +3099,7 @@ fn map_square_hole_hole_snapshot(
label: snapshot.label,
x: snapshot.x,
y: snapshot.y,
bonus: snapshot.bonus,
image_src: empty_string_to_none(snapshot.image_src),
}
}
@@ -3109,6 +3110,7 @@ fn map_square_hole_shape_option(
option_id: snapshot.option_id,
shape_kind: snapshot.shape_kind,
label: snapshot.label,
target_hole_id: snapshot.target_hole_id,
image_prompt: snapshot.image_prompt,
image_src: empty_string_to_none(snapshot.image_src),
}
@@ -3121,7 +3123,8 @@ fn map_square_hole_hole_option(
hole_id: snapshot.hole_id,
hole_kind: snapshot.hole_kind,
label: snapshot.label,
bonus: snapshot.bonus,
image_prompt: snapshot.image_prompt,
image_src: empty_string_to_none(snapshot.image_src),
}
}
@@ -6136,6 +6139,7 @@ pub struct SquareHoleShapeOptionRecord {
pub option_id: String,
pub shape_kind: String,
pub label: String,
pub target_hole_id: String,
pub image_prompt: String,
pub image_src: Option<String>,
}
@@ -6145,7 +6149,8 @@ pub struct SquareHoleHoleOptionRecord {
pub hole_id: String,
pub hole_kind: String,
pub label: String,
pub bonus: bool,
pub image_prompt: String,
pub image_src: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -6222,6 +6227,7 @@ pub struct SquareHoleShapeSnapshotRecord {
pub shape_id: String,
pub shape_kind: String,
pub label: String,
pub target_hole_id: String,
pub color: String,
pub image_src: Option<String>,
}
@@ -6233,7 +6239,7 @@ pub struct SquareHoleHoleSnapshotRecord {
pub label: String,
pub x: f32,
pub y: f32,
pub bonus: bool,
pub image_src: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -6302,6 +6308,8 @@ struct SquareHoleShapeOptionJsonRecord {
option_id: String,
shape_kind: String,
label: String,
#[serde(default)]
target_hole_id: String,
image_prompt: String,
#[serde(default)]
image_src: String,
@@ -6314,6 +6322,10 @@ struct SquareHoleHoleOptionJsonRecord {
hole_kind: String,
label: String,
#[serde(default)]
image_prompt: String,
#[serde(default)]
image_src: String,
#[serde(default)]
bonus: bool,
}
@@ -6412,6 +6424,8 @@ struct SquareHoleShapeJsonRecord {
shape_id: String,
shape_kind: String,
label: String,
#[serde(default)]
target_hole_id: String,
color: String,
#[serde(default)]
image_src: String,
@@ -6426,6 +6440,8 @@ struct SquareHoleHoleJsonRecord {
x: f32,
y: f32,
#[serde(default)]
image_src: String,
#[serde(default)]
bonus: bool,
}

View File

@@ -4,6 +4,10 @@ const ASSET_HISTORY_MAX_LIMIT: usize = 120;
const ASSET_HISTORY_CHARACTER_VISUAL_KIND: &str = "character_visual";
const ASSET_HISTORY_SCENE_IMAGE_KIND: &str = "scene_image";
const ASSET_HISTORY_PUZZLE_COVER_IMAGE_KIND: &str = "puzzle_cover_image";
const ASSET_HISTORY_SQUARE_HOLE_COVER_IMAGE_KIND: &str = "square_hole_cover_image";
const ASSET_HISTORY_SQUARE_HOLE_BACKGROUND_IMAGE_KIND: &str = "square_hole_background_image";
const ASSET_HISTORY_SQUARE_HOLE_SHAPE_IMAGE_KIND: &str = "square_hole_shape_image";
const ASSET_HISTORY_SQUARE_HOLE_HOLE_IMAGE_KIND: &str = "square_hole_hole_image";
/// 资产事件类型。
///
@@ -204,9 +208,13 @@ fn list_asset_history(
if asset_kind != ASSET_HISTORY_CHARACTER_VISUAL_KIND
&& asset_kind != ASSET_HISTORY_SCENE_IMAGE_KIND
&& asset_kind != ASSET_HISTORY_PUZZLE_COVER_IMAGE_KIND
&& asset_kind != ASSET_HISTORY_SQUARE_HOLE_COVER_IMAGE_KIND
&& asset_kind != ASSET_HISTORY_SQUARE_HOLE_BACKGROUND_IMAGE_KIND
&& asset_kind != ASSET_HISTORY_SQUARE_HOLE_SHAPE_IMAGE_KIND
&& asset_kind != ASSET_HISTORY_SQUARE_HOLE_HOLE_IMAGE_KIND
{
return Err(
"历史素材类型只支持 character_visual、scene_imagepuzzle_cover_image".to_string(),
"历史素材类型只支持 character_visual、scene_imagepuzzle_cover_image、square_hole_cover_image、square_hole_background_image、square_hole_shape_image 或 square_hole_hole_image".to_string(),
);
}

View File

@@ -471,7 +471,6 @@ pub fn list_profile_wallet_ledger(
}
}
// analytics metric 查询直接聚合 tracking_daily_stat避免 API 层订阅全量表后自行汇总。
#[spacetimedb::procedure]
pub fn query_analytics_metric(
@@ -2747,7 +2746,6 @@ fn build_profile_task_center_snapshot(
})
}
fn query_analytics_metric_buckets(
ctx: &ReducerContext,
input: AnalyticsMetricQueryInput,

View File

@@ -1116,12 +1116,15 @@ fn normalize_config(
config.cover_image_src = config.cover_image_src.trim().to_string();
config.background_image_src = config.background_image_src.trim().to_string();
let hole_options = normalize_domain_hole_options(
domain_hole_options_from_snapshot(&config.hole_options),
&config.theme_text,
);
let shape_options = normalize_domain_shape_options(
domain_shape_options_from_snapshot(&config.shape_options),
&config.theme_text,
hole_options.as_slice(),
);
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
@@ -1249,6 +1252,7 @@ fn shape_from_domain(shape: &DomainSquareHoleShapeSnapshot) -> SquareHoleShapeSn
shape_id: shape.shape_id.clone(),
shape_kind: shape.shape_kind.clone(),
label: shape.label.clone(),
target_hole_id: shape.target_hole_id.clone(),
color: shape.color.clone(),
image_src: shape.image_src.clone().unwrap_or_default(),
}
@@ -1259,6 +1263,7 @@ fn domain_shape_from_snapshot(shape: &SquareHoleShapeSnapshot) -> DomainSquareHo
shape_id: shape.shape_id.clone(),
shape_kind: shape.shape_kind.clone(),
label: shape.label.clone(),
target_hole_id: shape.target_hole_id.clone(),
color: shape.color.clone(),
image_src: empty_to_none(&shape.image_src),
}
@@ -1271,7 +1276,7 @@ fn hole_from_domain(hole: &DomainSquareHoleHoleSnapshot) -> SquareHoleHoleSnapsh
label: hole.label.clone(),
x: hole.x,
y: hole.y,
bonus: hole.bonus,
image_src: hole.image_src.clone().unwrap_or_default(),
}
}
@@ -1282,7 +1287,7 @@ fn domain_hole_from_snapshot(hole: &SquareHoleHoleSnapshot) -> DomainSquareHoleH
label: hole.label.clone(),
x: hole.x,
y: hole.y,
bonus: hole.bonus,
image_src: empty_to_none(&hole.image_src),
}
}
@@ -1295,6 +1300,7 @@ fn shape_options_to_snapshot(
option_id: option.option_id.clone(),
shape_kind: option.shape_kind.clone(),
label: option.label.clone(),
target_hole_id: option.target_hole_id.clone(),
image_prompt: option.image_prompt.clone(),
image_src: option.image_src.clone().unwrap_or_default(),
})
@@ -1310,6 +1316,7 @@ fn domain_shape_options_from_snapshot(
option_id: option.option_id.clone(),
shape_kind: option.shape_kind.clone(),
label: option.label.clone(),
target_hole_id: option.target_hole_id.clone(),
image_prompt: option.image_prompt.clone(),
image_src: empty_to_none(&option.image_src),
})
@@ -1325,7 +1332,8 @@ fn hole_options_to_snapshot(
hole_id: option.hole_id.clone(),
hole_kind: option.hole_kind.clone(),
label: option.label.clone(),
bonus: option.bonus,
image_prompt: option.image_prompt.clone(),
image_src: option.image_src.clone().unwrap_or_default(),
})
.collect()
}
@@ -1339,7 +1347,8 @@ fn domain_hole_options_from_snapshot(
hole_id: option.hole_id.clone(),
hole_kind: option.hole_kind.clone(),
label: option.label.clone(),
bonus: option.bonus,
image_prompt: option.image_prompt.clone(),
image_src: empty_to_none(&option.image_src),
})
.collect()
}

View File

@@ -228,6 +228,8 @@ pub struct SquareHoleShapeOptionSnapshot {
pub option_id: String,
pub shape_kind: String,
pub label: String,
#[serde(default)]
pub target_hole_id: String,
pub image_prompt: String,
#[serde(default)]
pub image_src: String,
@@ -240,7 +242,9 @@ pub struct SquareHoleHoleOptionSnapshot {
pub hole_kind: String,
pub label: String,
#[serde(default)]
pub bonus: bool,
pub image_prompt: String,
#[serde(default)]
pub image_src: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -333,6 +337,8 @@ pub struct SquareHoleShapeSnapshot {
pub shape_id: String,
pub shape_kind: String,
pub label: String,
#[serde(default)]
pub target_hole_id: String,
pub color: String,
#[serde(default)]
pub image_src: String,
@@ -347,7 +353,7 @@ pub struct SquareHoleHoleSnapshot {
pub x: f32,
pub y: f32,
#[serde(default)]
pub bonus: bool,
pub image_src: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]

View File

@@ -8,7 +8,7 @@
当前首版只放无业务规则的 smoke/HTTP 通用断言:
1. Maincloud healthz 默认地址常量
1. api-server healthz 默认地址常量
2. smoke URL 空值与尾斜杠归一化
3. HTTP 2xx 状态码断言
4. healthz 非空响应体断言

View File

@@ -1,6 +1,6 @@
use std::fmt;
pub const DEFAULT_MAINCLOUD_HEALTHZ_URL: &str = "http://127.0.0.1:3100/healthz";
pub const DEFAULT_API_SERVER_HEALTHZ_URL: &str = "http://127.0.0.1:3100/healthz";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SmokeAssertionError {
@@ -32,7 +32,7 @@ impl std::error::Error for SmokeAssertionError {}
pub fn normalize_smoke_url(input: impl AsRef<str>) -> String {
let trimmed = input.as_ref().trim();
if trimmed.is_empty() {
return DEFAULT_MAINCLOUD_HEALTHZ_URL.to_string();
return DEFAULT_API_SERVER_HEALTHZ_URL.to_string();
}
trimmed.trim_end_matches('/').to_string()
@@ -63,10 +63,10 @@ mod tests {
use super::*;
#[test]
fn normalize_smoke_url_uses_maincloud_healthz_when_empty() {
fn normalize_smoke_url_uses_api_server_healthz_when_empty() {
assert_eq!(
normalize_smoke_url(" "),
DEFAULT_MAINCLOUD_HEALTHZ_URL.to_string()
DEFAULT_API_SERVER_HEALTHZ_URL.to_string()
);
}