diff --git a/Cargo.lock b/Cargo.lock index 33a46ec..f793fea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1833,9 +1833,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.209" +version = "1.0.210" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99fce0ffe7310761ca6bf9faf5115afbc19688edd00171d81b1bb1b116c63e09" +checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" dependencies = [ "serde_derive", ] @@ -1864,9 +1864,9 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.209" +version = "1.0.210" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5831b979fd7b5439637af1752d535ff49f4860c0f341d1baeb6faf0f4242170" +checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" dependencies = [ "proc-macro2", "quote", @@ -2838,6 +2838,7 @@ dependencies = [ "axum 0.7.5", "http 1.1.0", "rand", + "serde", "shuttle-axum", "shuttle-runtime", "tower-http", diff --git a/Cargo.toml b/Cargo.toml index 552ee4c..e91ef38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" axum = "0.7.5" http = "1.1.0" rand = "0.8.5" +serde = { version = "1.0.210", features = ["derive"] } shuttle-axum = "0.47.0" shuttle-runtime = "0.47.0" tower-http = { version = "0.5.2", features = ["compression-br", "compression-gzip", "cors", "fs"] } diff --git a/rustmft.toml b/rustmft.toml new file mode 100644 index 0000000..cc85ea4 --- /dev/null +++ b/rustmft.toml @@ -0,0 +1,4 @@ +edition = "2021" + +group_imports = "StdExternalCrate" +max_width = 80 \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..f5a6d47 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,3 @@ +mod model; + +pub use model::game; diff --git a/src/model/charstatus.rs b/src/model/charstatus.rs new file mode 100644 index 0000000..285f378 --- /dev/null +++ b/src/model/charstatus.rs @@ -0,0 +1,131 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] +pub enum CharStatus { + NotContained(T), + Contained(T), + Match(T), + Unknown, +} + +pub(super) fn compare_strings(s1: &str, s2: &str) -> Vec> { + let mut result: Vec> = Vec::with_capacity(s1.len()); + result.resize_with(s1.len(), || CharStatus::Unknown); + + let mut s1_char_count: HashMap = HashMap::new(); + let mut s2_char_count: HashMap = HashMap::new(); + + for c in s1.chars() { + *s1_char_count.entry(c).or_insert(0) += 1; + } + + for ((c1, c2), res) in s1.chars().zip(s2.chars()).zip(result.iter_mut()) { + if c1 == c2 { + *res = CharStatus::Match(c2.to_string()); + *s2_char_count.entry(c2).or_insert(0) += 1; + } else { + *res = CharStatus::Unknown; + } + } + + for (res, c2) in result.iter_mut().zip(s2.chars()) { + if res == &CharStatus::Unknown { + let c1_count = s1_char_count.get(&c2).unwrap_or(&0); + let c2_count = s2_char_count.get(&c2).unwrap_or(&0); + + if *c1_count > 0 && c1_count > c2_count { + *res = CharStatus::Contained(c2.to_string()); + *s2_char_count.entry(c2).or_insert(0) += 1; + } else { + *res = CharStatus::NotContained(c2.to_string()); + } + } + } + + result +} + +#[cfg(test)] +mod test { + + use super::*; + #[test] + fn test_compare_strings() { + let source = "HALLO"; + + let want = vec![ + CharStatus::NotContained("0".to_owned()), + CharStatus::NotContained("0".to_owned()), + CharStatus::NotContained("0".to_owned()), + CharStatus::NotContained("0".to_owned()), + CharStatus::NotContained("0".to_owned()), + ]; + let input = "00000"; + + let got = compare_strings(source, input); + assert_eq!(want, got); + let source = "HALLO"; + + let want = vec![ + CharStatus::NotContained("L".to_owned()), + CharStatus::NotContained("L".to_owned()), + CharStatus::Match("L".to_owned()), + CharStatus::Match("L".to_owned()), + CharStatus::NotContained("L".to_owned()), + ]; + let input = "LLLLL"; + + let got = compare_strings(source, input); + assert_eq!(want, got); + + let want = vec![ + CharStatus::Match("H".to_owned()), + CharStatus::Match("A".to_owned()), + CharStatus::Match("L".to_owned()), + CharStatus::Match("L".to_owned()), + CharStatus::Match("O".to_owned()), + ]; + let input = "HALLO"; + + let got = compare_strings(source, input); + assert_eq!(want, got); + + let want = vec![ + CharStatus::Match("H".to_owned()), + CharStatus::NotContained("L".to_owned()), + CharStatus::Match("L".to_owned()), + CharStatus::Match("L".to_owned()), + CharStatus::Match("O".to_owned()), + ]; + let input = "HLLLO"; + + let got = compare_strings(source, input); + assert_eq!(want, got); + + let want = vec![ + CharStatus::Match("H".to_owned()), + CharStatus::Contained("L".to_owned()), + CharStatus::Match("L".to_owned()), + CharStatus::NotContained("I".to_owned()), + CharStatus::NotContained("L".to_owned()), + ]; + let input = "HLLIL"; + + let got = compare_strings(source, input); + assert_eq!(want, got); + + let want = vec![ + CharStatus::Contained("L".to_owned()), + CharStatus::NotContained("L".to_owned()), + CharStatus::Match("L".to_owned()), + CharStatus::Contained("A".to_owned()), + CharStatus::Match("O".to_owned()), + ]; + let input = "LLLAO"; + + let got = compare_strings(source, input); + assert_eq!(want, got); + } +} diff --git a/src/model/game.rs b/src/model/game.rs new file mode 100644 index 0000000..ac081e8 --- /dev/null +++ b/src/model/game.rs @@ -0,0 +1,87 @@ +use super::charstatus::{compare_strings, CharStatus}; +use serde::{Deserialize, Serialize}; + +type Tries = usize; + +const MAX_TRIES: Tries = 5; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Game { + pub word: Option, + pub submitted_words: Vec>>, + tries: usize, + status: Status, +} + +impl Game { + #[must_use] + pub const fn new() -> Self { + Self { + word: None, + tries: 0, + submitted_words: Vec::new(), + status: Status::New, + } + } + + pub fn start(&mut self, word: String) { + if self.word.is_none() && self.status == Status::New { + self.status = Status::InProgress; + self.word = Some(word); + } + } + + pub fn submit_answer(&mut self, answer: &[String]) { + if let Some(ref word) = self.word { + let res = compare_strings(word, &answer.join("")); + self.submitted_words.push(res); + self.tries += 1; + self.status = self.current_status(); + } + } + + #[must_use] + pub fn current_status(&self) -> Status { + self.word.as_ref().map_or(Status::New, |_| { + let word_count = self.submitted_words.len(); + match self.tries { + 0 => Status::New, + 1..MAX_TRIES => self + .submitted_words + .last() + .map_or(Status::InProgress, |words| { + if words.iter().all(|v| matches!(v, CharStatus::Match(_))) { + Status::Win(word_count) + } else { + Status::InProgress + } + }), + _ => self + .submitted_words + .last() + .map_or(Status::Lose(word_count), |words| { + if words.iter().all(|v| matches!(v, CharStatus::Match(_))) { + Status::Win(word_count) + } else { + Status::Lose(word_count) + } + }), + } + }) + } +} + +impl Default for Game { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[allow(clippy::module_name_repetitions)] +pub enum Status { + New, + Win(Tries), + Lose(Tries), + InProgress, +} diff --git a/src/model/mod.rs b/src/model/mod.rs new file mode 100644 index 0000000..3e285eb --- /dev/null +++ b/src/model/mod.rs @@ -0,0 +1,2 @@ +mod charstatus; +pub mod game;