Extend square-hole creation flow with visual asset timeout guard

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

View File

@@ -2,11 +2,13 @@ use shared_kernel::{normalize_optional_string, normalize_required_string, normal
use crate::commands::{default_tags_for_theme, validate_publish_requirements};
use crate::{
SQUARE_HOLE_DEFAULT_DURATION_LIMIT_MS, SQUARE_HOLE_MAX_DIFFICULTY, SQUARE_HOLE_MIN_DIFFICULTY,
SquareHoleCreatorConfig, SquareHoleDropConfirmation, SquareHoleDropFeedback,
SquareHoleDropInput, SquareHoleDropRejectReason, SquareHoleError, SquareHoleHoleSnapshot,
SQUARE_HOLE_DEFAULT_DURATION_LIMIT_MS, SQUARE_HOLE_MAX_DIFFICULTY,
SQUARE_HOLE_MAX_HOLE_OPTION_COUNT, SQUARE_HOLE_MIN_DIFFICULTY,
SQUARE_HOLE_MIN_HOLE_OPTION_COUNT, SQUARE_HOLE_MIN_SHAPE_OPTION_COUNT, SquareHoleCreatorConfig,
SquareHoleDropConfirmation, SquareHoleDropFeedback, SquareHoleDropInput,
SquareHoleDropRejectReason, SquareHoleError, SquareHoleHoleOption, SquareHoleHoleSnapshot,
SquareHolePublicationStatus, SquareHoleResultDraft, SquareHoleRunSnapshot, SquareHoleRunStatus,
SquareHoleShapeSnapshot, SquareHoleWorkProfile,
SquareHoleShapeOption, SquareHoleShapeSnapshot, SquareHoleWorkProfile,
};
pub fn compile_result_draft(
@@ -14,6 +16,10 @@ pub fn compile_result_draft(
config: &SquareHoleCreatorConfig,
) -> SquareHoleResultDraft {
let game_name = format!("{}方洞挑战", config.theme_text);
let shape_options = normalize_shape_options(config.shape_options.clone(), &config.theme_text);
let hole_options = normalize_hole_options(config.hole_options.clone());
let background_prompt = normalize_required_string(&config.background_prompt)
.unwrap_or_else(|| default_background_prompt(&config.theme_text));
let summary = format!(
"{}主题,{} 个形状,难度 {},真实规则:{}",
config.theme_text, config.shape_count, config.difficulty, config.twist_rule
@@ -25,6 +31,11 @@ pub fn compile_result_draft(
twist_rule: config.twist_rule.clone(),
summary,
tags: default_tags_for_theme(&config.theme_text),
cover_image_src: config.cover_image_src.clone(),
background_prompt,
background_image_src: config.background_image_src.clone(),
shape_options,
hole_options,
shape_count: config.shape_count,
difficulty: config.difficulty,
publish_ready: false,
@@ -59,7 +70,11 @@ pub fn create_work_profile(
twist_rule: draft.twist_rule.clone(),
summary: draft.summary.clone(),
tags: normalize_string_list(draft.tags.clone()),
cover_image_src: None,
cover_image_src: draft.cover_image_src.clone(),
background_prompt: draft.background_prompt.clone(),
background_image_src: draft.background_image_src.clone(),
shape_options: normalize_shape_options(draft.shape_options.clone(), &draft.theme_text),
hole_options: normalize_hole_options(draft.hole_options.clone()),
shape_count: draft.shape_count,
difficulty: draft.difficulty,
publication_status: SquareHolePublicationStatus::Draft,
@@ -99,6 +114,7 @@ pub fn start_run_at(
normalize_required_string(owner_user_id).ok_or(SquareHoleError::MissingOwnerUserId)?;
let profile_id =
normalize_required_string(profile_id).ok_or(SquareHoleError::MissingProfileId)?;
let shape_options = normalize_shape_options(config.shape_options.clone(), &config.theme_text);
Ok(SquareHoleRunSnapshot {
run_id,
@@ -115,8 +131,14 @@ pub fn start_run_at(
best_combo: 0,
score: 0,
rule_label: config.twist_rule.clone(),
current_shape: Some(build_shape_at(0, config.shape_count)),
holes: default_holes(),
background_image_src: config.background_image_src.clone(),
current_shape: Some(build_shape_at(
0,
config.shape_count,
shape_options.as_slice(),
)),
shape_options,
holes: build_holes(config.hole_options.as_slice()),
last_feedback: None,
})
}
@@ -160,14 +182,18 @@ pub fn confirm_drop_at(
next.completed_shape_count = next.completed_shape_count.saturating_add(1);
next.combo = next.combo.saturating_add(1);
next.best_combo = next.best_combo.max(next.combo);
next.score = next.score.saturating_add(100 + next.combo * 10);
let bonus_score = if hole.bonus { 50 } else { 0 };
next.score = next
.score
.saturating_add(100 + next.combo * 10 + bonus_score);
next.current_shape = if next.completed_shape_count >= next.total_shape_count {
next.status = SquareHoleRunStatus::Won;
None
} else {
Some(build_shape_at(
Some(build_shape_from_previous_options(
next.completed_shape_count,
next.total_shape_count,
next.shape_options.as_slice(),
))
};
next.snapshot_version = next.snapshot_version.saturating_add(1);
@@ -216,7 +242,23 @@ pub fn stop_run_at(run: &SquareHoleRunSnapshot) -> SquareHoleRunSnapshot {
next
}
pub fn build_shape_at(index: u32, total: u32) -> SquareHoleShapeSnapshot {
pub fn build_shape_at(
index: u32,
total: u32,
options: &[SquareHoleShapeOption],
) -> SquareHoleShapeSnapshot {
if let Some(option) = pick_shape_option(index, options) {
let shape_kind = option.shape_kind;
let label = option.label;
return SquareHoleShapeSnapshot {
shape_id: format!("square-hole-shape-{index:03}"),
color: fallback_shape_color(&shape_kind).to_string(),
shape_kind,
label,
image_src: option.image_src,
};
}
let kind = if index + 1 == total {
"square"
} else if index % 4 == 0 {
@@ -248,6 +290,7 @@ pub fn build_shape_at(index: u32, total: u32) -> SquareHoleShapeSnapshot {
_ => "#c084fc",
}
.to_string(),
image_src: None,
}
}
@@ -259,6 +302,7 @@ pub fn default_holes() -> Vec<SquareHoleHoleSnapshot> {
label: "方洞".to_string(),
x: 0.5,
y: 0.28,
bonus: true,
},
SquareHoleHoleSnapshot {
hole_id: "circle-hole".to_string(),
@@ -266,6 +310,7 @@ pub fn default_holes() -> Vec<SquareHoleHoleSnapshot> {
label: "圆洞".to_string(),
x: 0.24,
y: 0.54,
bonus: false,
},
SquareHoleHoleSnapshot {
hole_id: "triangle-hole".to_string(),
@@ -273,10 +318,240 @@ pub fn default_holes() -> Vec<SquareHoleHoleSnapshot> {
label: "三角洞".to_string(),
x: 0.76,
y: 0.54,
bonus: false,
},
]
}
pub fn default_shape_options(theme_text: &str) -> Vec<SquareHoleShapeOption> {
let theme = normalize_required_string(theme_text).unwrap_or_else(|| "玩具".to_string());
[
("square", "方块"),
("circle", "圆块"),
("triangle", "三角块"),
("diamond", "菱形块"),
("star", "星形块"),
("arch", "拱形块"),
]
.into_iter()
.map(|(kind, label)| SquareHoleShapeOption {
option_id: format!("{kind}-option"),
shape_kind: kind.to_string(),
label: label.to_string(),
image_prompt: format!("{theme}主题的{label}贴纸图,透明背景,明亮可爱,游戏资产"),
image_src: None,
})
.collect()
}
pub fn default_hole_options() -> Vec<SquareHoleHoleOption> {
vec![
SquareHoleHoleOption {
hole_id: "square-hole".to_string(),
hole_kind: "square".to_string(),
label: "方洞".to_string(),
bonus: true,
},
SquareHoleHoleOption {
hole_id: "circle-hole".to_string(),
hole_kind: "circle".to_string(),
label: "圆洞".to_string(),
bonus: false,
},
SquareHoleHoleOption {
hole_id: "triangle-hole".to_string(),
hole_kind: "triangle".to_string(),
label: "三角洞".to_string(),
bonus: false,
},
]
}
pub fn normalize_shape_options(
options: Vec<SquareHoleShapeOption>,
theme_text: &str,
) -> Vec<SquareHoleShapeOption> {
let mut normalized = Vec::new();
for (index, option) in options.into_iter().enumerate() {
let shape_kind = normalize_required_string(&option.shape_kind)
.unwrap_or_else(|| fallback_shape_kind(index));
let label = normalize_required_string(&option.label)
.unwrap_or_else(|| fallback_shape_label(&shape_kind).to_string());
let option_id = normalize_required_string(&option.option_id)
.unwrap_or_else(|| format!("{shape_kind}-option-{index}"));
let image_prompt = normalize_required_string(&option.image_prompt).unwrap_or_else(|| {
format!(
"{}主题的{}贴纸图,透明背景,明亮可爱,游戏资产",
normalize_required_string(theme_text).unwrap_or_else(|| "玩具".to_string()),
label
)
});
normalized.push(SquareHoleShapeOption {
option_id,
shape_kind,
label,
image_prompt,
image_src: option.image_src.and_then(normalize_required_string),
});
}
let defaults = default_shape_options(theme_text);
let mut default_index = 0;
while normalized.len() < SQUARE_HOLE_MIN_SHAPE_OPTION_COUNT {
let mut fallback = defaults[default_index % defaults.len()].clone();
if normalized
.iter()
.any(|option| option.option_id == fallback.option_id)
{
fallback.option_id = format!("{}-{}", fallback.option_id, normalized.len());
}
normalized.push(fallback);
default_index += 1;
}
normalized
}
pub fn normalize_hole_options(options: Vec<SquareHoleHoleOption>) -> Vec<SquareHoleHoleOption> {
let mut normalized = Vec::new();
for (index, option) in options
.into_iter()
.take(SQUARE_HOLE_MAX_HOLE_OPTION_COUNT)
.enumerate()
{
let hole_kind = normalize_required_string(&option.hole_kind)
.unwrap_or_else(|| fallback_shape_kind(index));
let label = normalize_required_string(&option.label)
.unwrap_or_else(|| fallback_hole_label(&hole_kind).to_string());
let hole_id = normalize_required_string(&option.hole_id)
.unwrap_or_else(|| format!("{hole_kind}-hole-{index}"));
normalized.push(SquareHoleHoleOption {
hole_id,
hole_kind,
label,
bonus: option.bonus,
});
}
for fallback in default_hole_options() {
if normalized.len() >= SQUARE_HOLE_MIN_HOLE_OPTION_COUNT {
break;
}
if !normalized
.iter()
.any(|option| option.hole_id == fallback.hole_id)
{
normalized.push(fallback);
}
}
if normalized.iter().all(|option| !option.bonus)
&& let Some(first) = normalized.first_mut()
{
first.bonus = true;
}
normalized
}
pub fn default_background_prompt(theme_text: &str) -> String {
format!(
"{}主题的竖屏游戏背景,舞台中央有洞口面板,明亮夸张,适合移动端小游戏",
normalize_required_string(theme_text).unwrap_or_else(|| "玩具".to_string())
)
}
fn build_holes(options: &[SquareHoleHoleOption]) -> Vec<SquareHoleHoleSnapshot> {
let normalized = normalize_hole_options(options.to_vec());
let positions = [
(0.5, 0.28),
(0.24, 0.54),
(0.76, 0.54),
(0.24, 0.78),
(0.5, 0.78),
(0.76, 0.78),
];
normalized
.into_iter()
.enumerate()
.map(|(index, option)| {
let (x, y) = positions[index.min(positions.len() - 1)];
SquareHoleHoleSnapshot {
hole_id: option.hole_id,
hole_kind: option.hole_kind,
label: option.label,
x,
y,
bonus: option.bonus,
}
})
.collect()
}
fn build_shape_from_previous_options(
index: u32,
total: u32,
options: &[SquareHoleShapeOption],
) -> SquareHoleShapeSnapshot {
build_shape_at(index, total, options)
}
fn pick_shape_option(
index: u32,
options: &[SquareHoleShapeOption],
) -> Option<SquareHoleShapeOption> {
if options.is_empty() {
return None;
}
options.get(index as usize % options.len()).cloned()
}
fn fallback_shape_kind(index: usize) -> String {
match index % 6 {
0 => "square",
1 => "circle",
2 => "triangle",
3 => "diamond",
4 => "star",
_ => "arch",
}
.to_string()
}
fn fallback_shape_label(kind: &str) -> &'static str {
match kind {
"square" => "方块",
"circle" => "圆块",
"triangle" => "三角块",
"diamond" => "菱形块",
"star" => "星形块",
"arch" => "拱形块",
_ => "形状块",
}
}
fn fallback_hole_label(kind: &str) -> &'static str {
match kind {
"square" => "方洞",
"circle" => "圆洞",
"triangle" => "三角洞",
"diamond" => "菱形洞",
"star" => "星形洞",
"arch" => "拱形洞",
_ => "洞口",
}
}
fn fallback_shape_color(kind: &str) -> &'static str {
match kind {
"square" => "#facc15",
"circle" => "#22c55e",
"triangle" => "#38bdf8",
"diamond" => "#fb7185",
"star" => "#c084fc",
"arch" => "#f97316",
_ => "#f8fafc",
}
}
fn is_shape_accepted_by_hole(
shape: &SquareHoleShapeSnapshot,
hole: &SquareHoleHoleSnapshot,
@@ -315,6 +590,32 @@ mod tests {
build_creator_config("玩具", "方洞万能", shape_count, 4).expect("config should be valid")
}
fn test_config_with_bonus_hole(shape_count: u32) -> SquareHoleCreatorConfig {
SquareHoleCreatorConfig {
hole_options: vec![
SquareHoleHoleOption {
hole_id: "square-hole".to_string(),
hole_kind: "square".to_string(),
label: "方洞".to_string(),
bonus: true,
},
SquareHoleHoleOption {
hole_id: "circle-hole".to_string(),
hole_kind: "circle".to_string(),
label: "圆洞".to_string(),
bonus: false,
},
SquareHoleHoleOption {
hole_id: "triangle-hole".to_string(),
hole_kind: "triangle".to_string(),
label: "三角洞".to_string(),
bonus: false,
},
],
..test_config(shape_count)
}
}
#[test]
fn draft_is_publishable_with_required_fields() {
let draft = compile_result_draft("profile-1".to_string(), &test_config(8));
@@ -368,6 +669,33 @@ mod tests {
assert_eq!(result.run.combo, 1);
}
#[test]
fn bonus_hole_adds_extra_score_when_accepted() {
let run = start_run_at(
"run-1".to_string(),
"user-1".to_string(),
"profile-1".to_string(),
&test_config_with_bonus_hole(8),
1_000,
)
.expect("run should start");
let result = confirm_drop_at(
&run,
&SquareHoleDropInput {
run_id: run.run_id.clone(),
owner_user_id: run.owner_user_id.clone(),
hole_id: "square-hole".to_string(),
client_snapshot_version: run.snapshot_version,
client_event_id: "event-1".to_string(),
dropped_at_ms: 1_100,
},
)
.expect("drop should resolve");
assert!(result.feedback.accepted);
assert_eq!(result.run.score, 160);
}
#[test]
fn wrong_non_square_hole_rejects_and_resets_combo() {
let mut run = start_run_at(
@@ -378,7 +706,7 @@ mod tests {
1_000,
)
.expect("run should start");
run.current_shape = Some(build_shape_at(1, 8));
run.current_shape = Some(build_shape_at(1, 8, &[]));
run.combo = 2;
let result = confirm_drop_at(
@@ -413,7 +741,7 @@ mod tests {
)
.expect("run should start");
run.completed_shape_count = 5;
run.current_shape = Some(build_shape_at(5, 6));
run.current_shape = Some(build_shape_at(5, 6, &[]));
let result = confirm_drop_at(
&run,

View File

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

View File

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