Preserve partial creation replies on stream failure
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
kdletters
2026-05-05 11:31:50 +08:00
parent 100fee7e7a
commit 995661e7cc
299 changed files with 13805 additions and 1429 deletions

View File

@@ -0,0 +1,14 @@
[package]
name = "module-square-hole"
edition.workspace = true
version.workspace = true
license.workspace = true
[features]
default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
shared-kernel = { path = "../shared-kernel" }
spacetimedb = { workspace = true, optional = true }

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

View File

@@ -0,0 +1,116 @@
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,
};
pub fn validate_shape_count(value: u32) -> Result<u32, SquareHoleError> {
if (SQUARE_HOLE_MIN_SHAPE_COUNT..=SQUARE_HOLE_MAX_SHAPE_COUNT).contains(&value) {
Ok(value)
} else {
Err(SquareHoleError::InvalidShapeCount)
}
}
pub fn validate_difficulty(value: u32) -> Result<u32, SquareHoleError> {
if (SQUARE_HOLE_MIN_DIFFICULTY..=SQUARE_HOLE_MAX_DIFFICULTY).contains(&value) {
Ok(value)
} else {
Err(SquareHoleError::InvalidDifficulty)
}
}
pub fn normalize_theme_text(value: impl AsRef<str>) -> Result<String, SquareHoleError> {
normalize_required_string(value).ok_or(SquareHoleError::MissingText)
}
pub fn build_creator_config(
theme_text: &str,
twist_rule: &str,
shape_count: u32,
difficulty: u32,
) -> Result<SquareHoleCreatorConfig, SquareHoleError> {
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)?,
})
}
pub fn build_default_tags(theme_text: &str) -> Vec<String> {
normalize_string_list(vec![
"方洞挑战".to_string(),
theme_text.to_string(),
"反直觉".to_string(),
])
}
pub fn default_tags_for_theme(theme_text: &str) -> Vec<String> {
let mut tags = vec![
"方洞挑战".to_string(),
"反直觉".to_string(),
theme_text.to_string(),
];
tags.sort();
tags.dedup();
tags
}
pub fn validate_publish_requirements(draft: &SquareHoleResultDraft) -> Vec<String> {
let mut blockers = Vec::new();
if normalize_required_string(&draft.game_name).is_none() {
blockers.push("游戏名称不能为空".to_string());
}
if normalize_required_string(&draft.summary).is_none() {
blockers.push("简介不能为空".to_string());
}
if normalize_required_string(&draft.theme_text).is_none() {
blockers.push("题材不能为空".to_string());
}
if normalize_required_string(&draft.twist_rule).is_none() {
blockers.push("反直觉规则不能为空".to_string());
}
if normalize_string_list(draft.tags.clone()).is_empty() {
blockers.push("至少需要 1 个标签".to_string());
}
if validate_shape_count(draft.shape_count).is_err() {
blockers.push(format!(
"形状数量必须在 {}{} 之间",
SQUARE_HOLE_MIN_SHAPE_COUNT, SQUARE_HOLE_MAX_SHAPE_COUNT
));
}
if validate_difficulty(draft.difficulty).is_err() {
blockers.push("难度必须在 1 到 10 之间".to_string());
}
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,
twist_rule,
summary,
tags: build_default_tags("方洞挑战"),
shape_count,
difficulty,
publish_ready: true,
blockers,
}
}

View File

@@ -0,0 +1,204 @@
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const SQUARE_HOLE_SESSION_ID_PREFIX: &str = "square-hole-session-";
pub const SQUARE_HOLE_MESSAGE_ID_PREFIX: &str = "square-hole-message-";
pub const SQUARE_HOLE_PROFILE_ID_PREFIX: &str = "square-hole-profile-";
pub const SQUARE_HOLE_WORK_ID_PREFIX: &str = "square-hole-work-";
pub const SQUARE_HOLE_RUN_ID_PREFIX: &str = "square-hole-run-";
pub const SQUARE_HOLE_MIN_SHAPE_COUNT: u32 = 6;
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;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum SquareHoleCreationStage {
CollectingConfig,
DraftReady,
ReadyToPublish,
Published,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum SquareHolePublicationStatus {
Draft,
Published,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum SquareHoleRunStatus {
Running,
Won,
Failed,
Stopped,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum SquareHoleDropRejectReason {
RunNotActive,
SnapshotVersionMismatch,
HoleNotFound,
Incompatible,
TimeUp,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SquareHoleCreatorConfig {
pub theme_text: String,
pub twist_rule: String,
pub shape_count: u32,
pub difficulty: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SquareHoleResultDraft {
pub profile_id: String,
pub game_name: String,
pub theme_text: String,
pub twist_rule: String,
pub summary: String,
pub tags: Vec<String>,
pub shape_count: u32,
pub difficulty: u32,
pub publish_ready: bool,
pub blockers: Vec<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SquareHoleWorkProfile {
pub work_id: String,
pub profile_id: String,
pub owner_user_id: String,
pub source_session_id: Option<String>,
pub game_name: String,
pub theme_text: String,
pub twist_rule: String,
pub summary: String,
pub tags: Vec<String>,
pub cover_image_src: Option<String>,
pub shape_count: u32,
pub difficulty: u32,
pub publication_status: SquareHolePublicationStatus,
pub play_count: u32,
pub updated_at_micros: i64,
pub published_at_micros: Option<i64>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SquareHoleShapeSnapshot {
pub shape_id: String,
pub shape_kind: String,
pub label: String,
pub color: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SquareHoleHoleSnapshot {
pub hole_id: String,
pub hole_kind: String,
pub label: String,
pub x: f32,
pub y: f32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SquareHoleDropFeedback {
pub accepted: bool,
pub reject_reason: Option<SquareHoleDropRejectReason>,
pub message: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SquareHoleRunSnapshot {
pub run_id: String,
pub profile_id: String,
pub owner_user_id: String,
pub status: SquareHoleRunStatus,
pub snapshot_version: u64,
pub started_at_ms: u64,
pub duration_limit_ms: u64,
pub remaining_ms: u64,
pub total_shape_count: u32,
pub completed_shape_count: u32,
pub combo: u32,
pub best_combo: u32,
pub score: u32,
pub rule_label: String,
pub current_shape: Option<SquareHoleShapeSnapshot>,
pub holes: Vec<SquareHoleHoleSnapshot>,
pub last_feedback: Option<SquareHoleDropFeedback>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SquareHoleDropInput {
pub run_id: String,
pub owner_user_id: String,
pub hole_id: String,
pub client_snapshot_version: u64,
pub client_event_id: String,
pub dropped_at_ms: u64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SquareHoleDropConfirmation {
pub feedback: SquareHoleDropFeedback,
pub run: SquareHoleRunSnapshot,
}
impl SquareHoleCreationStage {
pub fn as_str(self) -> &'static str {
match self {
Self::CollectingConfig => "collecting_config",
Self::DraftReady => "draft_ready",
Self::ReadyToPublish => "ready_to_publish",
Self::Published => "published",
}
}
}
impl SquareHolePublicationStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Draft => "draft",
Self::Published => "published",
}
}
}
impl SquareHoleRunStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Running => "running",
Self::Won => "won",
Self::Failed => "failed",
Self::Stopped => "stopped",
}
}
}
impl SquareHoleDropRejectReason {
pub fn as_str(self) -> &'static str {
match self {
Self::RunNotActive => "run_not_active",
Self::SnapshotVersionMismatch => "snapshot_version_mismatch",
Self::HoleNotFound => "hole_not_found",
Self::Incompatible => "incompatible",
Self::TimeUp => "time_up",
}
}
}

View File

@@ -0,0 +1,37 @@
use std::fmt::{self, Display};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SquareHoleError {
MissingText,
MissingOwnerUserId,
InvalidShapeCount,
InvalidDifficulty,
MissingProfileId,
MissingRunId,
MissingHoleId,
RunNotActive,
SnapshotVersionMismatch,
HoleNotFound,
Incompatible,
}
impl Display for SquareHoleError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let message = match self {
Self::MissingText => "文本不能为空",
Self::MissingOwnerUserId => "owner_user_id 缺失",
Self::InvalidShapeCount => "形状数量必须在 6 到 24 之间",
Self::InvalidDifficulty => "难度必须在 1 到 10 之间",
Self::MissingProfileId => "缺少 profileId",
Self::MissingRunId => "缺少 runId",
Self::MissingHoleId => "缺少 holeId",
Self::RunNotActive => "当前运行态未激活",
Self::SnapshotVersionMismatch => "快照版本不一致",
Self::HoleNotFound => "洞口不存在",
Self::Incompatible => "当前形状不能投入这个洞口",
};
write!(f, "{message}")
}
}
impl std::error::Error for SquareHoleError {}

View File

@@ -0,0 +1,25 @@
//! 方洞挑战领域事件。
//!
//! 事件只表达已经发生的领域事实,是否持久化、投影或通知前端由
//! SpacetimeDB adapter 与 BFF 编排层决定。
/// 方洞挑战领域事件。
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SquareHoleDomainEvent {
DraftCompiled {
profile_id: String,
owner_user_id: String,
occurred_at_micros: i64,
},
WorkPublished {
profile_id: String,
owner_user_id: String,
occurred_at_micros: i64,
},
RunSettled {
run_id: String,
owner_user_id: String,
status: String,
occurred_at_micros: i64,
},
}

View File

@@ -0,0 +1,11 @@
mod application;
mod commands;
mod domain;
mod errors;
mod events;
pub use application::*;
pub use commands::*;
pub use domain::*;
pub use errors::*;
pub use events::*;