feat: add wooden fish play template

This commit is contained in:
2026-05-21 23:34:07 +08:00
parent ef09a23c35
commit 5b0f9f3763
121 changed files with 11580 additions and 159 deletions

View File

@@ -0,0 +1,13 @@
[package]
name = "module-wooden-fish"
edition.workspace = true
version.workspace = true
license.workspace = true
[features]
default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { workspace = true }
spacetimedb = { workspace = true, optional = true }

View File

@@ -0,0 +1,4 @@
pub use crate::domain::{
apply_run_checkpoint, default_floating_words, finish_run, normalize_floating_words,
normalize_word_counters,
};

View File

@@ -0,0 +1,8 @@
pub fn normalize_wooden_fish_prompt(value: &str, fallback: &str) -> String {
let value = value.trim();
if value.is_empty() {
fallback.to_string()
} else {
value.to_string()
}
}

View File

@@ -0,0 +1,281 @@
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<WoodenFishWordCounter>,
pub started_at_ms: u64,
pub updated_at_ms: u64,
pub finished_at_ms: Option<u64>,
}
pub fn default_floating_words() -> Vec<String> {
DEFAULT_FLOATING_WORDS
.iter()
.map(|value| (*value).to_string())
.collect()
}
pub fn normalize_floating_words(words: &[String]) -> Vec<String> {
let mut normalized: Vec<String> = 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<WoodenFishWordCounter>,
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<WoodenFishWordCounter>,
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<WoodenFishWordCounter>) -> Vec<WoodenFishWordCounter> {
let mut normalized: Vec<WoodenFishWordCounter> = 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(
&current,
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(
&current,
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, "健康");
}
}

View File

@@ -0,0 +1,23 @@
use std::fmt::{self, Display};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WoodenFishError {
MissingRunId,
MissingProfileId,
MissingOwnerUserId,
RunNotPlaying,
}
impl Display for WoodenFishError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let message = match self {
Self::MissingRunId => "缺少 runId",
Self::MissingProfileId => "缺少 profileId",
Self::MissingOwnerUserId => "owner_user_id 缺失",
Self::RunNotPlaying => "当前运行态不是 playing",
};
write!(f, "{message}")
}
}
impl std::error::Error for WoodenFishError {}

View File

@@ -0,0 +1,25 @@
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum WoodenFishDomainEvent {
DraftCompiled {
profile_id: String,
owner_user_id: String,
occurred_at_micros: i64,
},
WorkPublished {
profile_id: String,
owner_user_id: String,
occurred_at_micros: i64,
},
RunCheckpointed {
run_id: String,
owner_user_id: String,
total_tap_count: u32,
occurred_at_micros: i64,
},
RunFinished {
run_id: String,
owner_user_id: String,
total_tap_count: u32,
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::*;