diff --git a/Cargo.toml b/Cargo.toml index 68d345b..44cc4a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,15 +27,17 @@ anyhow = "1.0.81" chrono = { version = "0.4.37" } clap = { version = "4.5.3", features = ["derive"], optional = true } colored = { version = "2.1.0", optional = false } -libpt = "0.6.0" +libpt = { version = "0.6.0", features = ["cli"] } num_cpus = "1.16.0" rand = "0.8.5" rayon = "1.10.0" regex = "1.10.3" serde = { version = "1.0.197", optional = true, features = ["serde_derive"] } serde_json = { version = "1.0.114", optional = true } +strum = "0.26.3" # serde_with = "3.7.0" thiserror = "1.0.58" +tracing-test = "0.2.5" [[bin]] name = "wordlec" @@ -56,3 +58,6 @@ required-features = ["solve", "cli", "builtin"] name = "wordlebench" path = "src/bin/bench/cli.rs" required-features = ["solve", "cli", "bench", "builtin"] + +[dev-dependencies] +test-log = { version = "0.2.16", default-features = false, features = ["color", "trace"] } diff --git a/src/bench/mod.rs b/src/bench/mod.rs index 1b251d3..be2dea4 100644 --- a/src/bench/mod.rs +++ b/src/bench/mod.rs @@ -45,7 +45,6 @@ where // TODO: add some interface to get reports while the benchmark runs // TODO: make the benchmark optionally multithreaded // NOTE: This is blocking, use start to let it run in another thread - // FIXME: this never stops? Reports just keep getting printed fn bench( &self, n: usize, diff --git a/src/bin/bench/cli.rs b/src/bin/bench/cli.rs index c153eb6..cda7541 100644 --- a/src/bin/bench/cli.rs +++ b/src/bin/bench/cli.rs @@ -2,7 +2,7 @@ // #![warn(missing_docs)] #![warn(missing_debug_implementations)] -use std::thread::sleep_ms; +use std::thread::sleep; use clap::Parser; use libpt::log::*; @@ -10,7 +10,7 @@ use libpt::log::*; use wordle_analyzer::bench::builtin::BuiltinBenchmark; use wordle_analyzer::bench::{Benchmark, DEFAULT_N}; use wordle_analyzer::game::GameBuilder; -use wordle_analyzer::solve::{AnyBuiltinSolver, BuiltinSolverNames, Solver}; +use wordle_analyzer::solve::{AnyBuiltinSolver, BuiltinSolverNames}; use wordle_analyzer::wlist::builtin::BuiltinWList; use wordle_analyzer::{self, game}; @@ -64,7 +64,7 @@ fn main() -> anyhow::Result<()> { bench.start(50, &bench.builder())?; loop { - sleep_ms(1000); + sleep(std::time::Duration::from_secs(1)); println!("{}", bench.report()); if bench.is_finished() { break; diff --git a/src/bin/game/cli.rs b/src/bin/game/cli.rs index 1001e44..e8bbf14 100644 --- a/src/bin/game/cli.rs +++ b/src/bin/game/cli.rs @@ -51,7 +51,7 @@ fn main() -> anyhow::Result<()> { let mut guess: Word; loop { guess = get_word(&cli, game.step())?; - response = match game.guess(guess) { + response = match game.guess(guess, None) { Ok(r) => r, Err(err) => match err { GameError::GuessHasWrongLength(len) => { diff --git a/src/bin/game/tui.rs b/src/bin/game/tui.rs index f2829d1..a1c6413 100644 --- a/src/bin/game/tui.rs +++ b/src/bin/game/tui.rs @@ -1,5 +1,4 @@ #![warn(clippy::all)] -#![warn(missing_docs)] #![warn(missing_debug_implementations)] fn main() { unimplemented!(); diff --git a/src/bin/solve/simple.rs b/src/bin/solve/simple.rs index e235562..19e274a 100644 --- a/src/bin/solve/simple.rs +++ b/src/bin/solve/simple.rs @@ -2,14 +2,20 @@ // #![warn(missing_docs)] #![warn(missing_debug_implementations)] -use clap::Parser; +use clap::{Parser, Subcommand}; +use libpt::cli::console::style; +use libpt::cli::{repl::Repl, strum}; use libpt::log::*; +use strum::EnumIter; +use wordle_analyzer::error::Error; +use wordle_analyzer::game::evaluation::Evaluation; use wordle_analyzer::game::response::GuessResponse; use wordle_analyzer::solve::{BuiltinSolverNames, Solver}; use wordle_analyzer::wlist::builtin::BuiltinWList; use wordle_analyzer::wlist::word::Word; +use wordle_analyzer::wlist::WordList; use wordle_analyzer::{self, game}; #[derive(Parser, Clone, Debug)] @@ -25,36 +31,204 @@ struct Cli { #[arg(short, long, default_value_t = wordle_analyzer::DEFAULT_MAX_STEPS)] max_steps: usize, /// more verbose logs - #[arg(short, long)] - verbose: bool, + #[command(flatten)] + verbose: libpt::cli::args::VerbosityLevel, /// which solver to use - #[arg(short, long, default_value_t = BuiltinSolverNames::default())] + #[arg(long, default_value_t = BuiltinSolverNames::default())] solver: BuiltinSolverNames, + + /// set if the solver should play a full native game without interaction + #[arg(short, long)] + non_interactive: bool, + + // FIXME: line breaks don't work correctly in the cli help + // + /// Solution for the game + /// + /// This will only be used when non-interactive is used. You can use this option to see how the + /// selected solver behaves when trying to guess a specific solution, which can help reproduce + /// behavior. + #[arg(short, long)] + solution: Option, +} + +#[derive(Subcommand, Debug, EnumIter, Clone)] +enum ReplCommand { + /// Let the user input a word and the response for that word + /// + /// Evaluation Format: + /// + /// 'x' means wrong character + /// + /// 'p' means present character + /// + /// 'c' means correct character + /// + /// Example: + /// + /// 'xxxcc' means the first 3 chars are wrong but the second 2 chars are correct + /// + /// 'xppxc' means the first character is wrong, the next two characters are present, the last + /// is correct + Guess { + your_guess: String, + evalutation: String, + }, + /// Let the solver make a guess + Solve, + /// Show the current state of the game + Show, + /// Display data about the wordlist + Wl { + #[command(subcommand)] + cmd: WlCommand, + }, + /// Start a new game + New, + /// Undo the last n operations + Undo { n: usize }, + /// Leave the Repl + Exit, +} + +#[derive(Subcommand, Debug, EnumIter, Clone, Default)] +enum WlCommand { + #[default] + Stats, + Top { + amount: usize, + }, } fn main() -> anyhow::Result<()> { let cli = Cli::parse(); - if cli.verbose { - Logger::builder().set_level(Level::DEBUG).build().unwrap(); - } else { - Logger::builder().set_level(Level::INFO).build().unwrap(); - } - debug!("dumping CLI: {:#?}", cli); + Logger::builder() + .set_level(cli.verbose.level()) + .build() + .unwrap(); + trace!("dumping CLI: {:#?}", cli); + if cli.non_interactive { + play_native_non_interactive(cli)?; + std::process::exit(0); + } + help_guess_interactive(cli) +} + +fn help_guess_interactive(cli: Cli) -> anyhow::Result<()> { let wl = BuiltinWList::default(); - let builder = game::Game::builder(&wl) + let builder = game::GameBuilder::new(&wl, false) .length(cli.length) .max_steps(cli.max_steps) .precompute(cli.precompute); let solver = cli.solver.to_solver(&wl); let mut game = builder.build()?; + let mut repl = libpt::cli::repl::DefaultRepl::::default(); + + debug!("entering the repl"); + loop { + // repl.step() should be at the start of your loop + // It is here that the repl will get the user input, validate it, and so on + match repl.step() { + Ok(c) => c, + Err(e) => { + // if the user requested the help, print in blue, otherwise in red as it's just an + // error + if let libpt::cli::repl::error::Error::Parsing(e) = &e { + if e.kind() == clap::error::ErrorKind::DisplayHelp { + println!("{}", style(e).cyan()); + continue; + } + } + println!("{}", style(e).red().bold()); + continue; + } + }; + + // now we can match our defined commands + // + // only None if the repl has not stepped yet + match repl.command().to_owned().unwrap() { + ReplCommand::Exit => break, + ReplCommand::Wl { cmd } => wlcommand_handler(&cli, &cmd, &wl)?, + ReplCommand::Show => { + println!("{}", game); + } + ReplCommand::Solve => { + let best_guess = solver.guess_for(&game); + if best_guess.is_err() { + eprintln!("{}", style(best_guess.unwrap_err()).red().bold()); + continue; + } + debug!("game state: {game:?}"); + println!("best guess: {}", best_guess.unwrap()); + } + ReplCommand::Guess { + your_guess, + evalutation, + } => { + let evaluation_converted: Evaluation = + Evaluation::build(&your_guess, &evalutation)?; + let guess = game.guess(your_guess, Some(evaluation_converted)); + debug!("your guess: {guess:?}"); + if guess.is_err() { + eprintln!("{}", style(guess.unwrap_err()).red().bold()); + continue; + } + println!("{}", guess.unwrap()); + debug!("game state: {game:#?}"); + } + ReplCommand::New => game = builder.build()?, + ReplCommand::Undo { n } => game.undo(n)?, + } + } + Ok(()) +} + +fn wlcommand_handler(_cli: &Cli, cmd: &WlCommand, wl: &impl WordList) -> anyhow::Result<()> { + match cmd { + WlCommand::Stats => { + println!("{wl}") + } + WlCommand::Top { amount } => { + println!(); + for s in wl.n_most_likely(*amount).iter() { + println!("\t\"{}\":\t{:.08}%", s.0, s.1 * 100.0); + } + } + } + Ok(()) +} + +fn play_native_non_interactive(cli: Cli) -> anyhow::Result<()> { + let wl = BuiltinWList::default(); + let mut builder = game::Game::builder(&wl) + .length(cli.length) + .max_steps(cli.max_steps) + .precompute(cli.precompute); + if cli.solution.is_some() { + let solw: Word = cli.solution.unwrap(); + let sol = wl.get_word(&solw); + if sol.is_none() { + eprintln!("the requested solution \"{solw}\" is not in the wordlist"); + return Err(Error::GameError { + source: wordle_analyzer::error::GameError::WordNotInWordlist(solw), + } + .into()); + } + builder = builder.solution(sol); + } + let solver = cli.solver.to_solver(&wl); + let mut game = builder.build()?; + debug!("{game:#?}"); let mut response: GuessResponse; let mut _guess: Word; loop { response = solver.make_a_move(&mut game)?; + debug!("game state: {game:#?}"); println!("{}. guess: {response}", game.step() - 1); if response.finished() { @@ -66,6 +240,5 @@ fn main() -> anyhow::Result<()> { } else { println!("You lose! The solution was {:?}.", game.solution()); } - Ok(()) } diff --git a/src/error.rs b/src/error.rs index dbed13d..0b10b8c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -13,6 +13,11 @@ pub enum Error { #[from] source: GameError, }, + #[error("Solver Error")] + SolverError { + #[from] + source: SolverError, + }, #[error("Benchmark Error")] BenchError { #[from] @@ -44,8 +49,12 @@ pub enum GameError { GuessHasWrongLength(usize), #[error("The game is finished but a guess is being made")] TryingToPlayAFinishedGame, - #[error("Tried to guess a word that is not in the wordlist ({0})")] + #[error("Tried to guess or use a word that is not in the wordlist ({0})")] WordNotInWordlist(Word), + #[error("Invalid syntax for manual evaluation creation")] + InvalidEvaluationSyntax(String), + #[error("The length of guess and evaluation must be the same")] + GuessAndEvalNotSameLen((String, String)), } #[derive(Debug, Clone, Error)] @@ -53,3 +62,9 @@ pub enum BenchError { #[error("Trying to modify a finished report")] ModifyFinishedReport, } + +#[derive(Debug, Clone, Error)] +pub enum SolverError { + #[error("Wordlist has no matches for the gamestate")] + NoMatches, +} diff --git a/src/game/evaluation.rs b/src/game/evaluation.rs new file mode 100644 index 0000000..fa4b1a0 --- /dev/null +++ b/src/game/evaluation.rs @@ -0,0 +1,92 @@ +use libpt::cli::console::{style, StyledObject}; + +use crate::wlist::word::Word; + +use super::response::Status; +use super::{GameError, WResult}; + +/// the [char] of the guess and the [Status] associated with it +pub type EvaluationUnit = (char, Status); + +/// Basically a [String] with extra information associated with each char +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Evaluation { + inner: Vec, +} + +impl Evaluation { + /// Display the evaluation color coded + pub fn colorized_display(&self) -> Vec> { + let mut buf = Vec::new(); + for e in self.inner.iter() { + let mut c = style(e.0.to_string()); + if e.1 == Status::Matched { + c = c.green(); + } else if e.1 == Status::Exists { + c = c.yellow(); + } + buf.push(c); + } + buf + } + + /// The first string is the word the evaluation is for, The second string defines how the + /// characters of the first string match the solution. + /// + /// + /// ## Evaluation Format: + /// + /// 'x' means wrong character + /// + /// 'p' means present character + /// + /// 'c' means correct character + /// + /// ### Example: + /// + /// 'xxxcc' --- means the first 3 chars are wrong but the second 2 chars are correct + /// + /// 'xppxc' --- means the first character is wrong, the next two characters are present, the last + /// is correct + /// + /// ## Example + /// + /// "wordle xxxcff" --- the guess was wordle, the d is in the correct spot, the solution + /// contains 'l' and 'e', but on another index. + /// + pub fn build(guess: &Word, eval_str: &str) -> WResult { + if guess.len() != eval_str.len() { + return Err(GameError::GuessAndEvalNotSameLen(( + guess.to_string(), + eval_str.to_string(), + )) + .into()); + } + let mut v: Vec = Vec::new(); + for (c, e) in guess.chars().zip(eval_str.chars()) { + v.push((c, Status::from(e))) + } + Ok(v.into()) + } +} + +impl IntoIterator for Evaluation { + type Item = EvaluationUnit; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.inner.into_iter() + } +} + +impl From> for Evaluation { + fn from(value: Vec) -> Self { + Self { inner: value } + } +} + +impl From for Word { + fn from(value: Evaluation) -> Self { + Word::from(value.inner.into_iter().map(|v| v.0).collect::()) + } +} diff --git a/src/game/mod.rs b/src/game/mod.rs index e61f1ce..6873311 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -1,5 +1,8 @@ +use core::panic; +use std::fmt::Display; + use crate::error::*; -use crate::wlist::word::{ManyWordsRef, Word, WordData}; +use crate::wlist::word::{Word, WordData}; use crate::wlist::WordList; use libpt::log::{debug, trace}; @@ -7,8 +10,11 @@ use libpt::log::{debug, trace}; pub mod response; use response::GuessResponse; +pub mod evaluation; + pub mod summary; +use self::evaluation::Evaluation; use self::response::Status; #[derive(Debug, Clone, PartialEq)] @@ -20,9 +26,8 @@ where precompute: bool, max_steps: usize, step: usize, - solution: WordData, + solution: Option, wordlist: &'wl WL, - finished: bool, responses: Vec, // TODO: keep track of the letters the user has tried } @@ -30,7 +35,7 @@ where impl<'wl, WL: WordList> Game<'wl, WL> { /// get a new [`GameBuilder`] pub fn builder(wl: &'wl WL) -> GameBuilder<'wl, WL> { - GameBuilder::new(wl) + GameBuilder::new(wl, true) } /// Create a [Game] of wordle /// @@ -43,44 +48,54 @@ impl<'wl, WL: WordList> Game<'wl, WL> { /// /// # Errors /// - /// This function will return an error if . - pub(crate) fn build( + /// No Errors + pub fn build( length: usize, precompute: bool, max_steps: usize, wlist: &'wl WL, + generate_solution: bool, ) -> GameResult { // TODO: check if the length is in the range bounds of the wordlist - let solution = wlist.rand_solution(); let game: Game<'wl, WL> = Game { length, precompute, max_steps, step: 0, - solution, + solution: if generate_solution { + Some(wlist.rand_solution()) + } else { + None + }, wordlist: wlist, - finished: false, responses: Vec::new(), }; Ok(game) } + /// set a solution, can be used for testing + pub fn set_solution(&mut self, sol: Option) { + self.solution = sol; + } + /// Make a new guess /// /// The word will be evaluated against the [solution](Game::solution) of the [Game]. /// A [GuessResponse] will be formulated, showing us which letters are correctly placed, in the /// solution, or just wrong. /// + /// Note that you do not need to use the [GuessResponse], it is appended to the game state. + /// /// # Errors /// /// This function will return an error if the length of the [Word] is wrong It will also error /// if the game is finished. - pub fn guess(&mut self, guess: Word) -> GameResult { + pub fn guess(&mut self, guess: Word, eval: Option) -> GameResult { if guess.len() != self.length { return Err(GameError::GuessHasWrongLength(guess.len())); } - if self.finished || self.step > self.max_steps { + if self.finished() || self.step > self.max_steps { return Err(GameError::TryingToPlayAFinishedGame); } if self.wordlist.get_word(&guess).is_none() { @@ -88,34 +103,49 @@ impl<'wl, WL: WordList> Game<'wl, WL> { } self.step += 1; - let mut compare_solution = self.solution.0.clone(); + let response; + if eval.is_some() && self.solution.is_none() { + response = GuessResponse::new(&guess, eval.unwrap(), self); + } else if let Some(solution) = self.solution.clone() { + response = GuessResponse::new(&guess, Self::evaluate(solution, &guess), self); + } else { + panic!("there is neither an evaluation nor a predefined solution for this guess"); + } + self.responses.push(response.clone()); + Ok(response) + } + + pub fn evaluate(mut solution: WordData, guess: &Word) -> Evaluation { let mut evaluation = Vec::new(); let mut status: Status; for (idx, c) in guess.chars().enumerate() { - if compare_solution.chars().nth(idx) == Some(c) { + if solution.0.chars().nth(idx) == Some(c) { status = Status::Matched; - compare_solution.replace_range(idx..idx + 1, "_"); - } else if compare_solution.contains(c) { + solution.0.replace_range(idx..idx + 1, "_"); + } else if solution.0.contains(c) { status = Status::Exists; - compare_solution = compare_solution.replacen(c, "_", 1); + solution.0 = solution.0.replacen(c, "_", 1); } else { status = Status::None } evaluation.push((c, status)); } + evaluation.into() + } - let response = GuessResponse::new(guess, evaluation, self); - self.responses.push(response.clone()); - self.finished = response.finished(); - Ok(response) + /// discard the last n responses + pub fn undo(&mut self, n: usize) -> WResult<()> { + self.responses + .drain(self.responses.len() - n..self.responses.len()); + Ok(()) } pub fn length(&self) -> usize { self.length } - pub fn solution(&self) -> &WordData { - &self.solution + pub fn solution(&self) -> Option<&WordData> { + self.solution.as_ref() } pub fn step(&self) -> usize { @@ -123,7 +153,17 @@ impl<'wl, WL: WordList> Game<'wl, WL> { } pub fn finished(&self) -> bool { - self.finished + if self.responses().is_empty() { + return false; + } + self.responses().last().unwrap().finished() + } + + pub fn won(&self) -> bool { + if self.responses().is_empty() { + return false; + } + self.responses().last().unwrap().won() } pub fn max_steps(&self) -> usize { @@ -140,7 +180,7 @@ impl<'wl, WL: WordList> Game<'wl, WL> { self.wordlist } - pub(crate) fn made_guesses(&self) -> ManyWordsRef { + pub(crate) fn made_guesses(&self) -> Vec<&Word> { self.responses.iter().map(|r| r.guess()).collect() } } @@ -160,7 +200,7 @@ impl<'wl, WL: WordList> Game<'wl, WL> { /// # use anyhow::Result; /// # fn main() -> Result<()> { /// let wl = BuiltinWList::default(); -/// let game: Game<_> = GameBuilder::new(&wl) +/// let game: Game<_> = GameBuilder::new(&wl, true) /// .build()?; /// # Ok(()) /// # } @@ -181,32 +221,44 @@ impl<'wl, WL: WordList> Game<'wl, WL> { /// # } /// ``` /// -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, PartialEq)] pub struct GameBuilder<'wl, WL: WordList> { length: usize, precompute: bool, max_steps: usize, wordlist: &'wl WL, + generate_solution: bool, + solution: Option, } impl<'wl, WL: WordList> GameBuilder<'wl, WL> { /// make a new [GameBuilder] /// /// We need a [WordList], so provide one here. - pub fn new(wl: &'wl WL) -> Self { + pub fn new(wl: &'wl WL, generate_solution: bool) -> Self { Self { length: super::DEFAULT_WORD_LENGTH, precompute: false, max_steps: super::DEFAULT_MAX_STEPS, wordlist: wl, + generate_solution, + solution: None, } } /// build a [`Game`] with the stored configuration pub fn build(&'wl self) -> GameResult> { trace!("{:#?}", self); - let game: Game = - Game::build(self.length, self.precompute, self.max_steps, self.wordlist)?; + let mut game: Game = Game::build( + self.length, + self.precompute, + self.max_steps, + self.wordlist, + self.generate_solution, + )?; + if self.solution.is_some() { + game.set_solution(self.solution.clone()) + } Ok(game) } @@ -244,4 +296,45 @@ impl<'wl, WL: WordList> GameBuilder<'wl, WL> { self.wordlist = wl; self } + + /// Set the solution for the games built by the builder + /// + /// If this is [Some], then the solution generated by + /// [generate_solution](Self::generate_solution) will be overwritten (if it + /// is true). + /// + /// If [generate_solution](Self::generate_solution) is false and this method is not used, the + /// game will not have a predetermined solution and will not be able to generate evaluations + /// for guesses, so these will need to be added manually by the user. The intention is that + /// this can be used for use cases where the user plays wordle not within wordle-analyzer but + /// in another program (like their browser). It can also be used to test solvers. + pub fn solution(mut self, solution: Option) -> Self { + self.solution = solution; + self + } +} + +impl<'wl, WL: WordList> Display for Game<'wl, WL> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // TODO: make this actually useful + // TODO: make this actually fancy + write!( + f, + "turn:\t\t{}\nsolution:\t{:?}\nguesses:\t", + self.step(), + self.solution(), + )?; + for s in self + .responses() + .iter() + .map(|v| v.evaluation().to_owned().colorized_display()) + { + write!(f, "\"")?; + for si in s { + write!(f, "{si}")?; + } + write!(f, "\", ")?; + } + Ok(()) + } } diff --git a/src/game/response.rs b/src/game/response.rs index 2395e78..8b4bd00 100644 --- a/src/game/response.rs +++ b/src/game/response.rs @@ -5,7 +5,7 @@ use colored::Colorize; use serde::{Deserialize, Serialize}; use std::fmt::Display; -use super::Game; +use super::{Evaluation, Game}; #[derive(Debug, Clone, PartialEq, Copy, Eq, PartialOrd, Ord)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] @@ -13,15 +13,13 @@ pub struct AtomicEvaluation { char: char, status: Status, } -pub type Evaluation = Vec<(char, Status)>; #[derive(Debug, Clone, PartialEq)] -#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +// #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub struct GuessResponse { guess: Word, evaluation: Evaluation, - finish: bool, - solution: WordData, + solution: Option, step: usize, max_steps: usize, } @@ -34,44 +32,48 @@ pub enum Status { Matched = 2, } +impl From for Status { + fn from(value: char) -> Self { + let value = value.to_ascii_lowercase(); // let's not deal with unicode here, wordle is + // ASCII centric anyway + match value { + 'x' => Self::None, + 'f' | 'e' => Self::Exists, + 'c' | 'm' => Self::Matched, + _ => Self::None, + } + } +} + impl GuessResponse { - pub(crate) fn new( - guess: Word, - status: Vec<(char, Status)>, - game: &Game, - ) -> Self { - let finish: bool = if game.step() > game.max_steps() { - true - } else { - guess == game.solution().0 - }; - Self { - guess, + pub(crate) fn new(guess: &Word, status: Evaluation, game: &Game) -> Self { + let new = Self { + guess: guess.to_owned(), evaluation: status, - finish, - solution: game.solution().clone(), + solution: game.solution().cloned(), step: game.step(), max_steps: game.max_steps(), - } + }; + new } pub fn finished(&self) -> bool { - self.finish + self.step() > self.max_steps() || self.won() } pub fn won(&self) -> bool { - self.guess == self.solution.0 + let mut ok = true; + for i in self.evaluation.clone().into_iter() { + ok &= i.1 == Status::Matched + } + ok } pub fn solution(&self) -> Option { - if self.won() { - Some(self.solution.clone()) - } else { - None - } + self.solution.clone() } - pub fn evaluation(&self) -> &[(char, Status)] { + pub fn evaluation(&self) -> &Evaluation { &self.evaluation } @@ -90,7 +92,7 @@ impl GuessResponse { impl Display for GuessResponse { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - for s in &self.evaluation { + for s in self.evaluation.clone().into_iter() { write!( f, "{}", diff --git a/src/lib.rs b/src/lib.rs index 05b16ec..9893c0c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,5 @@ #![warn(clippy::all)] -#![warn(missing_docs)] +// #![warn(missing_docs)] #![warn(missing_debug_implementations)] /// Default letters of a solution word diff --git a/src/solve/mod.rs b/src/solve/mod.rs index d398f4f..9e34dbc 100644 --- a/src/solve/mod.rs +++ b/src/solve/mod.rs @@ -44,14 +44,14 @@ pub trait Solver<'wl, WL: WordList>: Clone + std::fmt::Debug + Sized + Sync { /// /// Each [Solver] needs to implement this method themselves, many other methods rely on this to /// play the [Game], such as [play](Solver::play) or [solve](Solver::solve). - fn guess_for(&self, game: &Game<'wl, WL>) -> Word; + fn guess_for(&self, game: &Game<'wl, WL>) -> WResult; /// Make a singular step for a [Game] /// /// # Errors /// /// This function will return an error if [guess_for](Solver::guess_for) fails. fn make_a_move(&self, game: &mut Game<'wl, WL>) -> WResult { - Ok(game.guess(self.guess_for(game))?) + Ok(game.guess(self.guess_for(game)?, None)?) } /// Play a [Game] and return the last [GuessResponse]. /// @@ -59,6 +59,7 @@ pub trait Solver<'wl, WL: WordList>: Clone + std::fmt::Debug + Sized + Sync { /// /// This function will return an error if [make_a_move](Solver::make_a_move) fails. fn play(&self, game: &mut Game<'wl, WL>) -> WResult { + // TODO: check if the game is finished already and return an Err if so let mut resp: GuessResponse; loop { resp = self.make_a_move(game)?; @@ -146,10 +147,10 @@ impl<'wl, WL: WordList> Solver<'wl, WL> for AnyBuiltinSolver<'wl, WL> { fn build(wordlist: &'wl WL) -> WResult { Ok(Self::Naive(NaiveSolver::build(wordlist)?)) } - fn guess_for(&self, game: &Game<'wl, WL>) -> Word { - match self { - Self::Naive(solver) => solver.guess_for(game), - Self::Stupid(solver) => solver.guess_for(game), - } + fn guess_for(&self, game: &Game<'wl, WL>) -> WResult { + Ok(match self { + Self::Naive(solver) => solver.guess_for(game)?, + Self::Stupid(solver) => solver.guess_for(game)?, + }) } } diff --git a/src/solve/naive/mod.rs b/src/solve/naive/mod.rs index bfe7625..f8cdee0 100644 --- a/src/solve/naive/mod.rs +++ b/src/solve/naive/mod.rs @@ -1,6 +1,7 @@ use libpt::log::{info, trace}; -use crate::wlist::word::{ManyWordDatas, Word}; +use crate::error::{SolverError, WResult}; +use crate::wlist::word::{Word, WordData}; use crate::wlist::WordList; use super::{AnyBuiltinSolver, Solver, Status}; @@ -15,13 +16,32 @@ impl<'wl, WL: WordList> Solver<'wl, WL> for NaiveSolver<'wl, WL> { info!("using naive solver"); Ok(Self { wl: wordlist }) } - fn guess_for(&self, game: &crate::game::Game) -> Word { + /// Guess a word from the wordlist for the given game + /// + /// ## Algorithm + /// + /// * Look at the evaluation for the last response and keep the correct letters + /// * Get all words that have these letters at the right position + /// * Discard words that have already been tried + /// * Discard all words that don't have the chars that we know from the last guess are in the + /// word, but don't know the position of. + fn guess_for(&self, game: &crate::game::Game) -> WResult { // HACK: hardcoded length let mut pattern: String = String::from("....."); let mut other_chars: Vec = Vec::new(); let response = game.last_response(); + trace!( + "guessing best guess for last response: {response:#?}\n{:#?}", + response.map(|a| a.evaluation()) + ); if response.is_some() { - for (idx, p) in response.unwrap().evaluation().iter().enumerate() { + for (idx, p) in response + .unwrap() + .evaluation() + .clone() + .into_iter() + .enumerate() + { if p.1 == Status::Matched { pattern.replace_range(idx..idx + 1, &p.0.to_string()); } else if p.1 == Status::Exists { @@ -30,10 +50,11 @@ impl<'wl, WL: WordList> Solver<'wl, WL> for NaiveSolver<'wl, WL> { } } trace!("other chars: {:?}", other_chars); - let matches: ManyWordDatas = game - .wordlist() - .get_words_matching(pattern) - .expect("the solution does not exist in the wordlist") + let mut matches: Vec = game.wordlist().get_words_matching(pattern)?; + if matches.is_empty() { + return Err(SolverError::NoMatches.into()); + } + matches = matches .iter() // only words that have not been guessed yet .filter(|p| !game.made_guesses().contains(&&p.0)) @@ -48,7 +69,10 @@ impl<'wl, WL: WordList> Solver<'wl, WL> for NaiveSolver<'wl, WL> { }) .map(|v| v.to_owned()) .collect(); - matches[0].0.to_owned() + if matches.is_empty() { + return Err(SolverError::NoMatches.into()); + } + Ok(matches[0].0.to_owned()) } } diff --git a/src/solve/stupid/mod.rs b/src/solve/stupid/mod.rs index 2aa1d61..e4df9e7 100644 --- a/src/solve/stupid/mod.rs +++ b/src/solve/stupid/mod.rs @@ -1,5 +1,6 @@ use libpt::log::info; +use crate::error::WResult; use crate::wlist::word::Word; use crate::wlist::WordList; @@ -15,8 +16,8 @@ impl<'wl, WL: WordList> Solver<'wl, WL> for StupidSolver<'wl, WL> { info!("using stupid solver"); Ok(Self { wl: wordlist }) } - fn guess_for(&self, game: &crate::game::Game) -> Word { - self.wl.rand_word().0 + fn guess_for(&self, game: &crate::game::Game) -> WResult { + Ok(self.wl.rand_word().0) } } diff --git a/src/wlist/builtin.rs b/src/wlist/builtin.rs index 3204376..116e66f 100644 --- a/src/wlist/builtin.rs +++ b/src/wlist/builtin.rs @@ -1,4 +1,4 @@ -use std::fmt::{write, Debug}; +use std::fmt::{write, Debug, Display}; use serde_json; @@ -54,3 +54,9 @@ impl Debug for BuiltinWList { ) } } + +impl Display for BuiltinWList { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{self:#?}") + } +} diff --git a/src/wlist/mod.rs b/src/wlist/mod.rs index 155ec24..214c082 100644 --- a/src/wlist/mod.rs +++ b/src/wlist/mod.rs @@ -3,6 +3,7 @@ use rand::seq::IteratorRandom; use regex::Regex; use std::collections::HashMap; +use std::fmt::Display; use std::ops::RangeBounds; #[cfg(feature = "builtin")] @@ -14,8 +15,8 @@ use crate::error::WResult; pub type AnyWordlist = Box; -pub trait WordList: Clone + std::fmt::Debug + Default + Sync { - fn solutions(&self) -> ManyWordDatas { +pub trait WordList: Clone + std::fmt::Debug + Default + Sync + Display { + fn solutions(&self) -> Vec { let wmap = self.wordmap().clone(); let threshold = wmap.threshold(); wmap.iter() @@ -41,6 +42,18 @@ pub trait WordList: Clone + std::fmt::Debug + Default + Sync { fn total_freq(&self) -> Frequency { self.wordmap().values().map(|a| a.to_owned()).sum() } + fn sort_likelihood(&self) -> Vec { + let wmap = self.wordmap(); + let mut wpairs: Vec<(_, _)> = wmap.iter().collect(); + wpairs.sort_by(|a, b| a.1.partial_cmp(b.1).unwrap().reverse()); + wpairs + .iter() + .map(|v| (v.0.to_owned(), v.1.to_owned())) + .collect() + } + fn n_most_likely(&self, n: usize) -> Vec { + self.sort_likelihood().into_iter().take(n).collect() + } fn over_threashold(&self) -> WordMap { let wmap = self.wordmap(); let threshold = wmap.threshold(); @@ -75,11 +88,11 @@ pub trait WordList: Clone + std::fmt::Debug + Default + Sync { } buf } - fn get_words_matching(&self, pattern: String) -> WResult { + fn get_words_matching(&self, pattern: String) -> WResult> { let pattern = Regex::new(&pattern)?; let hay = self.raw_wordlist(); let keys = pattern.captures_iter(&hay); - let mut buf = ManyWordDatas::new(); + let mut buf = Vec::new(); for k in keys { let w: WordData = self.wordmap().get(&k[0]).unwrap(); buf.push(w) diff --git a/src/wlist/word.rs b/src/wlist/word.rs index 1defc1f..22d666b 100644 --- a/src/wlist/word.rs +++ b/src/wlist/word.rs @@ -10,8 +10,7 @@ pub type Frequency = f64; // PERF: Hash for String is probably a bottleneck pub type Word = String; pub type WordData = (Word, Frequency); -pub type ManyWordsRef<'a> = Vec<&'a Word>; -pub type ManyWordDatas = Vec<(Word, Frequency)>; +pub type WordDataRef<'wl> = (&'wl Word, &'wl Frequency); #[derive(Clone)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] diff --git a/tests/solver.rs b/tests/solver.rs new file mode 100644 index 0000000..3aa2634 --- /dev/null +++ b/tests/solver.rs @@ -0,0 +1,139 @@ +use test_log::test; // set the log level with an envvar: `RUST_LOG=trace cargo test` + +use wordle_analyzer::game::evaluation::Evaluation; +use wordle_analyzer::game::Game; +use wordle_analyzer::solve::{AnyBuiltinSolver, NaiveSolver, Solver, StupidSolver}; +use wordle_analyzer::wlist::builtin::BuiltinWList; +use wordle_analyzer::wlist::word::{Word, WordData}; +use wordle_analyzer::wlist::WordList; + +fn wordlist() -> impl WordList { + BuiltinWList::default() +} + +#[test] +fn test_build_builtin_solvers() { + let wl = wordlist(); + let _stupid_solver = + AnyBuiltinSolver::Stupid(StupidSolver::build(&wl).expect("could not build naive solver")); + let _naive_solver = + AnyBuiltinSolver::Naive(NaiveSolver::build(&wl).expect("could not build naive solver")); +} + +#[test] +fn test_naive_play_predetermined_game() -> anyhow::Result<()> { + let wl = wordlist(); + let sl = + AnyBuiltinSolver::Naive(NaiveSolver::build(&wl).expect("could not build naive solver")); + let mut game = Game::build(5, false, 6, &wl, false)?; + game.set_solution(Some(("nines".into(), 0.002))); // The accuracy is made up but shouldn't + // matter + sl.make_a_move(&mut game)?; + assert_eq!( + game.responses().last().unwrap().guess(), + &Word::from("which") + ); + sl.make_a_move(&mut game)?; + assert_eq!( + game.responses().last().unwrap().guess(), + &Word::from("their") + ); + sl.make_a_move(&mut game)?; + assert_eq!( + game.responses().last().unwrap().guess(), + &Word::from("being") + ); + sl.make_a_move(&mut game)?; + assert_eq!( + game.responses().last().unwrap().guess(), + &Word::from("since") + ); + sl.make_a_move(&mut game)?; + assert_eq!( + game.responses().last().unwrap().guess(), + &Word::from("lines") + ); + sl.make_a_move(&mut game)?; + assert_eq!( + game.responses().last().unwrap().guess(), + &Word::from("mines") + ); + sl.make_a_move(&mut game)?; + assert_eq!( + game.responses().last().unwrap().guess(), + &Word::from("wines") + ); + + // naive is at the moment too bad to solve "nines" + assert!(game.finished()); + assert!(!game.won()); + + Ok(()) +} + +#[test] +fn test_naive_play_predetermined_game_manually() -> anyhow::Result<()> { + let wl = wordlist(); + let sl = + AnyBuiltinSolver::Naive(NaiveSolver::build(&wl).expect("could not build naive solver")); + // we don't insert the solution yet, + // pretend that a user inputs guesses manually + let mut game = Game::build(5, false, 6, &wl, false)?; + let _actual_solution: Option = Some(("nines".into(), 0.002)); + let mut next_guess: Word; + + next_guess = sl.guess_for(&game)?; + assert_eq!(next_guess, Word::from("which")); + game.guess( + next_guess.clone(), + Some(Evaluation::build(&next_guess, "xxfxx")?), + )?; + + next_guess = sl.guess_for(&game)?; + assert_eq!(next_guess, Word::from("their")); + game.guess( + next_guess.clone(), + Some(Evaluation::build(&next_guess, "xxffx")?), + )?; + + next_guess = sl.guess_for(&game)?; + assert_eq!(next_guess, Word::from("being")); + game.guess( + next_guess.clone(), + Some(Evaluation::build(&next_guess, "xfffx")?), + )?; + + next_guess = sl.guess_for(&game)?; + assert_eq!(next_guess, Word::from("since")); + game.guess( + next_guess.clone(), + Some(Evaluation::build(&next_guess, "fcfxf")?), + )?; + + next_guess = sl.guess_for(&game)?; + assert_eq!(next_guess, Word::from("lines")); + game.guess( + next_guess.clone(), + Some(Evaluation::build(&next_guess, "xcccc")?), + )?; + + next_guess = sl.guess_for(&game)?; + assert_eq!(next_guess, Word::from("mines")); + game.guess( + next_guess.clone(), + Some(Evaluation::build(&next_guess, "xcccc")?), + )?; + + next_guess = sl.guess_for(&game)?; + assert_eq!(next_guess, Word::from("wines")); + game.guess( + next_guess.clone(), + Some(Evaluation::build(&next_guess, "xcccc")?), + )?; + + // naive is at the moment too bad to solve "nines" + assert!(game.finished()); + assert!(!game.won()); + + Ok(()) +}