Preserve partial creation replies on stream failure
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
456
server-rs/crates/module-square-hole/src/application.rs
Normal file
456
server-rs/crates/module-square-hole/src/application.rs
Normal file
@@ -0,0 +1,456 @@
|
||||
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_MIN_DIFFICULTY,
|
||||
SquareHoleCreatorConfig, SquareHoleDropConfirmation, SquareHoleDropFeedback,
|
||||
SquareHoleDropInput, SquareHoleDropRejectReason, SquareHoleError, SquareHoleHoleSnapshot,
|
||||
SquareHolePublicationStatus, SquareHoleResultDraft, SquareHoleRunSnapshot, SquareHoleRunStatus,
|
||||
SquareHoleShapeSnapshot, SquareHoleWorkProfile,
|
||||
};
|
||||
|
||||
pub fn compile_result_draft(
|
||||
profile_id: String,
|
||||
config: &SquareHoleCreatorConfig,
|
||||
) -> SquareHoleResultDraft {
|
||||
let game_name = format!("{}方洞挑战", 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),
|
||||
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: None,
|
||||
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)?;
|
||||
|
||||
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(),
|
||||
current_shape: Some(build_shape_at(0, config.shape_count)),
|
||||
holes: default_holes(),
|
||||
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_at(
|
||||
next.completed_shape_count,
|
||||
next.total_shape_count,
|
||||
))
|
||||
};
|
||||
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) -> SquareHoleShapeSnapshot {
|
||||
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(),
|
||||
color: match kind {
|
||||
"square" => "#facc15",
|
||||
"circle" => "#22c55e",
|
||||
"triangle" => "#38bdf8",
|
||||
"diamond" => "#fb7185",
|
||||
_ => "#c084fc",
|
||||
}
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
SquareHoleHoleSnapshot {
|
||||
hole_id: "circle-hole".to_string(),
|
||||
hole_kind: "circle".to_string(),
|
||||
label: "圆洞".to_string(),
|
||||
x: 0.24,
|
||||
y: 0.54,
|
||||
},
|
||||
SquareHoleHoleSnapshot {
|
||||
hole_id: "triangle-hole".to_string(),
|
||||
hole_kind: "triangle".to_string(),
|
||||
label: "三角洞".to_string(),
|
||||
x: 0.76,
|
||||
y: 0.54,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn is_shape_accepted_by_hole(
|
||||
shape: &SquareHoleShapeSnapshot,
|
||||
hole: &SquareHoleHoleSnapshot,
|
||||
) -> bool {
|
||||
// 中文注释:首版核心反差固定为“方洞万能”,保留同形状洞口兼容便于后续扩展规则。
|
||||
hole.hole_kind == "square" || hole.hole_kind == shape.shape_kind
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
#[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 square_hole_accepts_non_square_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: "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.completed_shape_count, 1);
|
||||
assert_eq!(result.run.combo, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_non_square_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),
|
||||
1_000,
|
||||
)
|
||||
.expect("run should start");
|
||||
run.current_shape = Some(build_shape_at(1, 8));
|
||||
run.combo = 2;
|
||||
|
||||
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(),
|
||||
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));
|
||||
|
||||
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_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)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user