feat: add wooden fish play template
This commit is contained in:
13
server-rs/crates/module-wooden-fish/Cargo.toml
Normal file
13
server-rs/crates/module-wooden-fish/Cargo.toml
Normal 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 }
|
||||
4
server-rs/crates/module-wooden-fish/src/application.rs
Normal file
4
server-rs/crates/module-wooden-fish/src/application.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub use crate::domain::{
|
||||
apply_run_checkpoint, default_floating_words, finish_run, normalize_floating_words,
|
||||
normalize_word_counters,
|
||||
};
|
||||
8
server-rs/crates/module-wooden-fish/src/commands.rs
Normal file
8
server-rs/crates/module-wooden-fish/src/commands.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
281
server-rs/crates/module-wooden-fish/src/domain.rs
Normal file
281
server-rs/crates/module-wooden-fish/src/domain.rs
Normal 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(
|
||||
¤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, "健康");
|
||||
}
|
||||
}
|
||||
23
server-rs/crates/module-wooden-fish/src/errors.rs
Normal file
23
server-rs/crates/module-wooden-fish/src/errors.rs
Normal 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 {}
|
||||
25
server-rs/crates/module-wooden-fish/src/events.rs
Normal file
25
server-rs/crates/module-wooden-fish/src/events.rs
Normal 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,
|
||||
},
|
||||
}
|
||||
11
server-rs/crates/module-wooden-fish/src/lib.rs
Normal file
11
server-rs/crates/module-wooden-fish/src/lib.rs
Normal 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::*;
|
||||
Reference in New Issue
Block a user