854 lines
29 KiB
Rust
854 lines
29 KiB
Rust
use shared_kernel::{normalize_optional_string, normalize_required_string, normalize_string_list};
|
|
|
|
use crate::commands::{default_tags_for_theme, validate_publish_requirements};
|
|
use crate::{
|
|
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,
|
|
SquareHoleShapeOption, SquareHoleShapeSnapshot, SquareHoleWorkProfile,
|
|
};
|
|
|
|
pub fn compile_result_draft(
|
|
profile_id: String,
|
|
config: &SquareHoleCreatorConfig,
|
|
) -> SquareHoleResultDraft {
|
|
let game_name = format!("{}方洞挑战", 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 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
|
|
);
|
|
let mut draft = SquareHoleResultDraft {
|
|
profile_id,
|
|
game_name,
|
|
theme_text: config.theme_text.clone(),
|
|
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,
|
|
blockers: Vec::new(),
|
|
};
|
|
draft.blockers = validate_publish_requirements(&draft);
|
|
draft.publish_ready = draft.blockers.is_empty();
|
|
draft
|
|
}
|
|
|
|
pub fn create_work_profile(
|
|
work_id: String,
|
|
profile_id: String,
|
|
owner_user_id: String,
|
|
source_session_id: Option<String>,
|
|
draft: &SquareHoleResultDraft,
|
|
updated_at_micros: i64,
|
|
) -> Result<SquareHoleWorkProfile, SquareHoleError> {
|
|
let work_id = normalize_required_string(work_id).ok_or(SquareHoleError::MissingText)?;
|
|
let profile_id =
|
|
normalize_required_string(profile_id).ok_or(SquareHoleError::MissingProfileId)?;
|
|
let owner_user_id =
|
|
normalize_required_string(owner_user_id).ok_or(SquareHoleError::MissingOwnerUserId)?;
|
|
|
|
Ok(SquareHoleWorkProfile {
|
|
work_id,
|
|
profile_id,
|
|
owner_user_id,
|
|
source_session_id: normalize_optional_string(source_session_id),
|
|
game_name: draft.game_name.clone(),
|
|
theme_text: draft.theme_text.clone(),
|
|
twist_rule: draft.twist_rule.clone(),
|
|
summary: draft.summary.clone(),
|
|
tags: normalize_string_list(draft.tags.clone()),
|
|
cover_image_src: draft.cover_image_src.clone(),
|
|
background_prompt: draft.background_prompt.clone(),
|
|
background_image_src: draft.background_image_src.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,
|
|
play_count: 0,
|
|
updated_at_micros,
|
|
published_at_micros: None,
|
|
})
|
|
}
|
|
|
|
pub fn publish_work_profile(
|
|
profile: &SquareHoleWorkProfile,
|
|
published_at_micros: i64,
|
|
) -> Result<SquareHoleWorkProfile, SquareHoleError> {
|
|
if profile.shape_count == 0 {
|
|
return Err(SquareHoleError::InvalidShapeCount);
|
|
}
|
|
if !(SQUARE_HOLE_MIN_DIFFICULTY..=SQUARE_HOLE_MAX_DIFFICULTY).contains(&profile.difficulty) {
|
|
return Err(SquareHoleError::InvalidDifficulty);
|
|
}
|
|
|
|
let mut next = profile.clone();
|
|
next.publication_status = SquareHolePublicationStatus::Published;
|
|
next.updated_at_micros = published_at_micros;
|
|
next.published_at_micros = Some(published_at_micros);
|
|
Ok(next)
|
|
}
|
|
|
|
pub fn start_run_at(
|
|
run_id: String,
|
|
owner_user_id: String,
|
|
profile_id: String,
|
|
config: &SquareHoleCreatorConfig,
|
|
started_at_ms: u64,
|
|
) -> Result<SquareHoleRunSnapshot, SquareHoleError> {
|
|
let run_id = normalize_required_string(run_id).ok_or(SquareHoleError::MissingRunId)?;
|
|
let owner_user_id =
|
|
normalize_required_string(owner_user_id).ok_or(SquareHoleError::MissingOwnerUserId)?;
|
|
let profile_id =
|
|
normalize_required_string(profile_id).ok_or(SquareHoleError::MissingProfileId)?;
|
|
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,
|
|
profile_id,
|
|
owner_user_id,
|
|
status: SquareHoleRunStatus::Running,
|
|
snapshot_version: 1,
|
|
started_at_ms,
|
|
duration_limit_ms: SQUARE_HOLE_DEFAULT_DURATION_LIMIT_MS,
|
|
remaining_ms: SQUARE_HOLE_DEFAULT_DURATION_LIMIT_MS,
|
|
total_shape_count: config.shape_count,
|
|
completed_shape_count: 0,
|
|
combo: 0,
|
|
best_combo: 0,
|
|
score: 0,
|
|
rule_label: config.twist_rule.clone(),
|
|
background_image_src: config.background_image_src.clone(),
|
|
current_shape: Some(current_shape),
|
|
shape_options,
|
|
holes: build_holes(hole_options.as_slice()),
|
|
last_feedback: None,
|
|
})
|
|
}
|
|
|
|
pub fn confirm_drop_at(
|
|
run: &SquareHoleRunSnapshot,
|
|
input: &SquareHoleDropInput,
|
|
) -> Result<SquareHoleDropConfirmation, SquareHoleError> {
|
|
let hole_id =
|
|
normalize_required_string(&input.hole_id).ok_or(SquareHoleError::MissingHoleId)?;
|
|
let mut next = resolve_run_timer_at(run, input.dropped_at_ms);
|
|
if next.status != SquareHoleRunStatus::Running {
|
|
return Ok(rejected(next, SquareHoleDropRejectReason::RunNotActive));
|
|
}
|
|
if input.client_snapshot_version != next.snapshot_version {
|
|
return Ok(rejected(
|
|
next,
|
|
SquareHoleDropRejectReason::SnapshotVersionMismatch,
|
|
));
|
|
}
|
|
|
|
let Some(hole) = next.holes.iter().find(|item| item.hole_id == hole_id) else {
|
|
return Ok(rejected(next, SquareHoleDropRejectReason::HoleNotFound));
|
|
};
|
|
let Some(shape) = next.current_shape.clone() else {
|
|
return Ok(rejected(next, SquareHoleDropRejectReason::Incompatible));
|
|
};
|
|
|
|
if !is_shape_accepted_by_hole(&shape, hole) {
|
|
next.combo = 0;
|
|
next.snapshot_version = next.snapshot_version.saturating_add(1);
|
|
return Ok(rejected(next, SquareHoleDropRejectReason::Incompatible));
|
|
}
|
|
|
|
let message = format!("{}进入{}", shape.label, hole.label);
|
|
let feedback = SquareHoleDropFeedback {
|
|
accepted: true,
|
|
reject_reason: None,
|
|
message,
|
|
};
|
|
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);
|
|
next.current_shape = if next.completed_shape_count >= next.total_shape_count {
|
|
next.status = SquareHoleRunStatus::Won;
|
|
None
|
|
} else {
|
|
Some(build_shape_from_previous_options(
|
|
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);
|
|
next.last_feedback = Some(feedback.clone());
|
|
|
|
Ok(SquareHoleDropConfirmation {
|
|
feedback,
|
|
run: next,
|
|
})
|
|
}
|
|
|
|
pub fn resolve_run_timer_at(run: &SquareHoleRunSnapshot, now_ms: u64) -> SquareHoleRunSnapshot {
|
|
let mut next = run.clone();
|
|
if next.status != SquareHoleRunStatus::Running {
|
|
return next;
|
|
}
|
|
let elapsed_ms = now_ms.saturating_sub(next.started_at_ms);
|
|
next.remaining_ms = next.duration_limit_ms.saturating_sub(elapsed_ms);
|
|
if next.remaining_ms == 0 {
|
|
let feedback = SquareHoleDropFeedback {
|
|
accepted: false,
|
|
reject_reason: Some(SquareHoleDropRejectReason::TimeUp),
|
|
message: "时间到".to_string(),
|
|
};
|
|
next.status = SquareHoleRunStatus::Failed;
|
|
next.combo = 0;
|
|
next.current_shape = None;
|
|
next.last_feedback = Some(feedback);
|
|
next.snapshot_version = next.snapshot_version.saturating_add(1);
|
|
}
|
|
next
|
|
}
|
|
|
|
pub fn stop_run_at(run: &SquareHoleRunSnapshot) -> SquareHoleRunSnapshot {
|
|
let mut next = run.clone();
|
|
if next.status == SquareHoleRunStatus::Running {
|
|
next.status = SquareHoleRunStatus::Stopped;
|
|
next.combo = 0;
|
|
next.snapshot_version = next.snapshot_version.saturating_add(1);
|
|
next.last_feedback = Some(SquareHoleDropFeedback {
|
|
accepted: false,
|
|
reject_reason: Some(SquareHoleDropRejectReason::RunNotActive),
|
|
message: "已退出本局".to_string(),
|
|
});
|
|
}
|
|
next
|
|
}
|
|
|
|
pub fn build_shape_at(
|
|
index: u32,
|
|
total: u32,
|
|
options: &[SquareHoleShapeOption],
|
|
run_seed: &str,
|
|
) -> SquareHoleShapeSnapshot {
|
|
if let Some(option) = pick_shape_option(index, options, run_seed) {
|
|
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,
|
|
target_hole_id: option.target_hole_id,
|
|
image_src: option.image_src,
|
|
};
|
|
}
|
|
|
|
let kind = if index + 1 == total {
|
|
"square"
|
|
} else if index % 4 == 0 {
|
|
"circle"
|
|
} else if index % 4 == 1 {
|
|
"triangle"
|
|
} else if index % 4 == 2 {
|
|
"diamond"
|
|
} else {
|
|
"star"
|
|
};
|
|
|
|
SquareHoleShapeSnapshot {
|
|
shape_id: format!("square-hole-shape-{index:03}"),
|
|
shape_kind: kind.to_string(),
|
|
label: match kind {
|
|
"square" => "方块",
|
|
"circle" => "圆块",
|
|
"triangle" => "三角块",
|
|
"diamond" => "菱形块",
|
|
_ => "星形块",
|
|
}
|
|
.to_string(),
|
|
target_hole_id: fallback_target_hole_id(index).to_string(),
|
|
color: match kind {
|
|
"square" => "#facc15",
|
|
"circle" => "#22c55e",
|
|
"triangle" => "#38bdf8",
|
|
"diamond" => "#fb7185",
|
|
_ => "#c084fc",
|
|
}
|
|
.to_string(),
|
|
image_src: None,
|
|
}
|
|
}
|
|
|
|
pub fn default_holes() -> Vec<SquareHoleHoleSnapshot> {
|
|
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, 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", "圆块"),
|
|
("triangle", "三角块"),
|
|
("diamond", "菱形块"),
|
|
("star", "星形块"),
|
|
("arch", "拱形块"),
|
|
]
|
|
.into_iter()
|
|
.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(theme_text: &str) -> Vec<SquareHoleHoleOption> {
|
|
let theme = normalize_required_string(theme_text).unwrap_or_else(|| "玩具".to_string());
|
|
vec![
|
|
SquareHoleHoleOption {
|
|
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: "hole-2".to_string(),
|
|
hole_kind: "hole-2".to_string(),
|
|
label: "洞口 2".to_string(),
|
|
image_prompt: format!("{theme}主题的第二个洞口贴纸图,透明背景,明亮可爱,游戏资产"),
|
|
image_src: None,
|
|
},
|
|
SquareHoleHoleOption {
|
|
hole_id: "hole-3".to_string(),
|
|
hole_kind: "hole-3".to_string(),
|
|
label: "洞口 3".to_string(),
|
|
image_prompt: format!("{theme}主题的第三个洞口贴纸图,透明背景,明亮可爱,游戏资产"),
|
|
image_src: None,
|
|
},
|
|
]
|
|
}
|
|
|
|
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)
|
|
.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 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!(
|
|
"{}主题的{}贴纸图,透明背景,明亮可爱,游戏资产",
|
|
normalize_required_string(theme_text).unwrap_or_else(|| "玩具".to_string()),
|
|
label
|
|
)
|
|
});
|
|
normalized.push(SquareHoleShapeOption {
|
|
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, 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();
|
|
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>,
|
|
theme_text: &str,
|
|
) -> 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(|| format!("hole-{}", index + 1));
|
|
let label = normalize_required_string(&option.label)
|
|
.unwrap_or_else(|| fallback_hole_label(index).to_string());
|
|
let hole_id = normalize_required_string(&option.hole_id)
|
|
.unwrap_or_else(|| format!("hole-{}", index + 1));
|
|
normalized.push(SquareHoleHoleOption {
|
|
hole_id,
|
|
hole_kind,
|
|
label,
|
|
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(theme_text) {
|
|
if normalized.len() >= SQUARE_HOLE_MIN_HOLE_OPTION_COUNT {
|
|
break;
|
|
}
|
|
if !normalized
|
|
.iter()
|
|
.any(|option| option.hole_id == fallback.hole_id)
|
|
{
|
|
normalized.push(fallback);
|
|
}
|
|
}
|
|
|
|
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,
|
|
image_src: option.image_src,
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn build_shape_from_previous_options(
|
|
index: u32,
|
|
total: u32,
|
|
options: &[SquareHoleShapeOption],
|
|
run_seed: &str,
|
|
) -> SquareHoleShapeSnapshot {
|
|
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;
|
|
}
|
|
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 {
|
|
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(index: usize) -> String {
|
|
format!("洞口 {}", index + 1)
|
|
}
|
|
|
|
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,
|
|
) -> bool {
|
|
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(
|
|
mut run: SquareHoleRunSnapshot,
|
|
reject_reason: SquareHoleDropRejectReason,
|
|
) -> SquareHoleDropConfirmation {
|
|
let message = match reject_reason {
|
|
SquareHoleDropRejectReason::RunNotActive => "当前局已结束",
|
|
SquareHoleDropRejectReason::SnapshotVersionMismatch => "操作慢了一步",
|
|
SquareHoleDropRejectReason::HoleNotFound => "洞口不存在",
|
|
SquareHoleDropRejectReason::Incompatible => "这个洞不对",
|
|
SquareHoleDropRejectReason::TimeUp => "时间到",
|
|
}
|
|
.to_string();
|
|
let feedback = SquareHoleDropFeedback {
|
|
accepted: false,
|
|
reject_reason: Some(reject_reason),
|
|
message,
|
|
};
|
|
run.last_feedback = Some(feedback.clone());
|
|
SquareHoleDropConfirmation { feedback, run }
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::commands::build_creator_config;
|
|
|
|
fn test_config(shape_count: u32) -> SquareHoleCreatorConfig {
|
|
build_creator_config("玩具", "方洞万能", shape_count, 4).expect("config should be valid")
|
|
}
|
|
|
|
fn test_config_with_custom_targets(shape_count: u32) -> SquareHoleCreatorConfig {
|
|
SquareHoleCreatorConfig {
|
|
hole_options: vec![
|
|
SquareHoleHoleOption {
|
|
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: "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: "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)
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn draft_is_publishable_with_required_fields() {
|
|
let draft = compile_result_draft("profile-1".to_string(), &test_config(8));
|
|
|
|
assert!(draft.publish_ready);
|
|
assert!(draft.blockers.is_empty());
|
|
assert!(draft.tags.contains(&"方洞挑战".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn run_starts_with_current_shape_and_default_holes() {
|
|
let run = start_run_at(
|
|
"run-1".to_string(),
|
|
"user-1".to_string(),
|
|
"profile-1".to_string(),
|
|
&test_config(8),
|
|
1_000,
|
|
)
|
|
.expect("run should start");
|
|
|
|
assert_eq!(run.status, SquareHoleRunStatus::Running);
|
|
assert!(run.current_shape.is_some());
|
|
assert_eq!(run.holes.len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn target_hole_accepts_current_shape() {
|
|
let run = start_run_at(
|
|
"run-1".to_string(),
|
|
"user-1".to_string(),
|
|
"profile-1".to_string(),
|
|
&test_config(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: 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,
|
|
},
|
|
)
|
|
.expect("drop should resolve");
|
|
|
|
assert!(result.feedback.accepted);
|
|
assert_eq!(result.run.completed_shape_count, 1);
|
|
assert_eq!(result.run.combo, 1);
|
|
}
|
|
|
|
#[test]
|
|
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_custom_targets(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: 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,
|
|
},
|
|
)
|
|
.expect("drop should resolve");
|
|
|
|
assert!(result.feedback.accepted);
|
|
assert_eq!(result.run.score, 110);
|
|
}
|
|
|
|
#[test]
|
|
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_with_custom_targets(8),
|
|
1_000,
|
|
)
|
|
.expect("run should start");
|
|
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: wrong_hole_id,
|
|
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.feedback.reject_reason,
|
|
Some(SquareHoleDropRejectReason::Incompatible)
|
|
);
|
|
assert_eq!(result.run.combo, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn last_shape_win_finishes_run() {
|
|
let mut run = start_run_at(
|
|
"run-1".to_string(),
|
|
"user-1".to_string(),
|
|
"profile-1".to_string(),
|
|
&test_config(6),
|
|
1_000,
|
|
)
|
|
.expect("run should start");
|
|
run.completed_shape_count = 5;
|
|
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: 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,
|
|
},
|
|
)
|
|
.expect("drop should resolve");
|
|
|
|
assert_eq!(result.run.status, SquareHoleRunStatus::Won);
|
|
assert!(result.run.current_shape.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn timer_expiration_fails_running_run() {
|
|
let run = start_run_at(
|
|
"run-1".to_string(),
|
|
"user-1".to_string(),
|
|
"profile-1".to_string(),
|
|
&test_config(8),
|
|
1_000,
|
|
)
|
|
.expect("run should start");
|
|
|
|
let expired = resolve_run_timer_at(&run, 1_000 + SQUARE_HOLE_DEFAULT_DURATION_LIMIT_MS);
|
|
|
|
assert_eq!(expired.status, SquareHoleRunStatus::Failed);
|
|
assert_eq!(
|
|
expired
|
|
.last_feedback
|
|
.and_then(|feedback| feedback.reject_reason),
|
|
Some(SquareHoleDropRejectReason::TimeUp)
|
|
);
|
|
}
|
|
}
|