use serde::{Deserialize, Serialize}; #[cfg(feature = "spacetime-types")] use spacetimedb::SpacetimeType; pub const WOODEN_FISH_SESSION_ID_PREFIX: &str = "wooden-fish-session-"; pub const WOODEN_FISH_PROFILE_ID_PREFIX: &str = "wooden-fish-profile-"; pub const WOODEN_FISH_WORK_ID_PREFIX: &str = "wooden-fish-work-"; pub const WOODEN_FISH_RUN_ID_PREFIX: &str = "wooden-fish-run-"; pub const DEFAULT_FLOATING_WORDS: [&str; 8] = [ "幸运", "健康", "财富", "姻缘", "幸福", "事业", "成功", "功德", ]; #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum WoodenFishRunStatus { Playing, Finished, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct WoodenFishWordCounter { pub text: String, pub count: u32, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct WoodenFishRunSnapshot { pub run_id: String, pub profile_id: String, pub owner_user_id: String, pub status: WoodenFishRunStatus, pub total_tap_count: u32, pub word_counters: Vec, pub started_at_ms: u64, pub updated_at_ms: u64, pub finished_at_ms: Option, } pub fn default_floating_words() -> Vec { DEFAULT_FLOATING_WORDS .iter() .map(|value| (*value).to_string()) .collect() } pub fn normalize_floating_words(words: &[String]) -> Vec { let mut normalized: Vec = Vec::new(); for word in words { let value = normalize_floating_word(word); if !value.is_empty() && !normalized.iter().any(|item| item == &value) { normalized.push(value); } if normalized.len() == 8 { break; } } if normalized.is_empty() { default_floating_words() } else { normalized } } fn normalize_floating_word(word: &str) -> String { word.trim() .trim_end_matches(|ch: char| ch == '1' || ch.is_whitespace()) .trim_end_matches(['+', '+']) .trim() .to_string() } pub fn apply_run_checkpoint( current: &WoodenFishRunSnapshot, total_tap_count: u32, word_counters: Vec, updated_at_ms: u64, ) -> WoodenFishRunSnapshot { WoodenFishRunSnapshot { total_tap_count, word_counters: normalize_word_counters(word_counters), updated_at_ms, ..current.clone() } } pub fn finish_run( current: &WoodenFishRunSnapshot, total_tap_count: u32, word_counters: Vec, finished_at_ms: u64, ) -> WoodenFishRunSnapshot { WoodenFishRunSnapshot { status: WoodenFishRunStatus::Finished, total_tap_count, word_counters: normalize_word_counters(word_counters), updated_at_ms: finished_at_ms, finished_at_ms: Some(finished_at_ms), ..current.clone() } } pub fn normalize_word_counters(counters: Vec) -> Vec { let mut normalized: Vec = Vec::new(); for counter in counters { let text = normalize_floating_word(&counter.text); if text.is_empty() || counter.count == 0 { continue; } if let Some(existing) = normalized.iter_mut().find(|item| item.text == text) { existing.count = existing.count.saturating_add(counter.count); } else { normalized.push(WoodenFishWordCounter { text, count: counter.count, }); } if normalized.len() == 8 { break; } } normalized } #[cfg(test)] mod tests { use super::*; #[test] fn floating_words_default_to_eight_blessing_entries() { assert_eq!( default_floating_words(), vec![ "幸运".to_string(), "健康".to_string(), "财富".to_string(), "姻缘".to_string(), "幸福".to_string(), "事业".to_string(), "成功".to_string(), "功德".to_string(), ] ); } #[test] fn floating_words_are_trimmed_deduped_and_capped_at_eight() { let words = vec![ " 幸运+1 ".to_string(), "幸运".to_string(), "健康".to_string(), "财富".to_string(), "姻缘".to_string(), "幸福".to_string(), "事业".to_string(), "成功".to_string(), "功德".to_string(), "快乐".to_string(), ]; assert_eq!(normalize_floating_words(&words).len(), 8); assert_eq!(normalize_floating_words(&words)[0], "幸运"); assert_eq!(normalize_floating_words(&words)[7], "功德"); } #[test] fn checkpoint_replaces_single_run_counter_snapshot() { let current = WoodenFishRunSnapshot { run_id: "wooden-fish-run-1".to_string(), profile_id: "wooden-fish-profile-1".to_string(), owner_user_id: "user-1".to_string(), status: WoodenFishRunStatus::Playing, total_tap_count: 0, word_counters: Vec::new(), started_at_ms: 100, updated_at_ms: 100, finished_at_ms: None, }; let next = apply_run_checkpoint( ¤t, 2, vec![WoodenFishWordCounter { text: "功德".to_string(), count: 2, }], 160, ); assert_eq!(next.total_tap_count, 2); assert_eq!(next.word_counters[0].text, "功德"); assert_eq!(next.updated_at_ms, 160); assert_eq!(next.started_at_ms, 100); } #[test] fn word_counters_are_trimmed_deduped_and_capped_at_eight() { let counters = vec![ WoodenFishWordCounter { text: " 幸运+1 ".to_string(), count: 1, }, WoodenFishWordCounter { text: "幸运+1".to_string(), count: 2, }, WoodenFishWordCounter { text: "健康+1".to_string(), count: 1, }, WoodenFishWordCounter { text: "财富+1".to_string(), count: 1, }, WoodenFishWordCounter { text: "姻缘+1".to_string(), count: 1, }, WoodenFishWordCounter { text: "幸福+1".to_string(), count: 1, }, WoodenFishWordCounter { text: "事业+1".to_string(), count: 1, }, WoodenFishWordCounter { text: "成功+1".to_string(), count: 1, }, WoodenFishWordCounter { text: "功德+1".to_string(), count: 1, }, WoodenFishWordCounter { text: "快乐+1".to_string(), count: 1, }, ]; let normalized = normalize_word_counters(counters); assert_eq!(normalized.len(), 8); assert_eq!(normalized[0].text, "幸运"); assert_eq!(normalized[0].count, 3); assert_eq!(normalized[7].text, "功德"); } #[test] fn finish_run_sets_status_and_finished_timestamp() { let current = WoodenFishRunSnapshot { run_id: "wooden-fish-run-1".to_string(), profile_id: "wooden-fish-profile-1".to_string(), owner_user_id: "user-1".to_string(), status: WoodenFishRunStatus::Playing, total_tap_count: 3, word_counters: Vec::new(), started_at_ms: 100, updated_at_ms: 130, finished_at_ms: None, }; let next = finish_run( ¤t, 4, vec![WoodenFishWordCounter { text: "健康".to_string(), count: 4, }], 220, ); assert_eq!(next.status, WoodenFishRunStatus::Finished); assert_eq!(next.total_tap_count, 4); assert_eq!(next.updated_at_ms, 220); assert_eq!(next.finished_at_ms, Some(220)); assert_eq!(next.word_counters[0].text, "健康"); } }