Files
Genarrative/server-rs/crates/module-square-hole/src/application.rs
kdletters d06107f2c6
Some checks failed
CI / verify (push) Has been cancelled
落地方洞挑战图片与运行态交互
2026-05-06 12:52:47 +08:00

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)
);
}
}