Baby's first wordle

Hi, I'm reading the rust book and decided I need some practice. I'm pretty sure this is just horrible, so I'd very much appreciate any pointers to what can be improved. Thanks!

// TODO: Handle multiple identical letters correctly.

use colored::Colorize;
use rand::seq::IteratorRandom;
use std::{
    collections::HashSet,
    fs::File,
    io::{self, BufRead, BufReader, Write},
};

const WORD_LENGTH: usize = 5;
const MAX_ATTEMPTS: u32 = 5;
// All ~2300 possible solutions, one word per line
const SOLUTION_FILE: &str = "./word-dict.txt";
// All ~13k possible guesses, one word per line
const GUESS_FILE: &str = "./guess-dict.txt";

#[derive(Debug)]
enum CharGuessResult {
    Correct(char),
    Occuring(char),
    NotOccuring,
}

#[derive(Debug)]
enum GuessResult {
    Correct,
    Incorrect(Vec<CharGuessResult>),
}

fn main() {
    let word = &pick_word();
    // Having 13k * 5 chars in memory sounds better than combing through a file for each guess...
    // right?
    let allowed_guess_words = read_guess_dict();

    println!();
    println!("Welcome to Garble!");
    println!("==================");
    println!();
    game_loop(word, &allowed_guess_words)
}


fn game_loop(word: &str, allow_list: &HashSet<String>) {
    let mut attempt = 1;
    while attempt <= MAX_ATTEMPTS {
        let guess = read_user_guess(attempt, MAX_ATTEMPTS);
        if let Err(msg) = validate_guess(WORD_LENGTH, &guess, allow_list) {
            println!("{}", msg);
            continue;
        }
        let result = evaluate_guess(&guess, word);
        println!(
            "{}",
            stringify_guess_result(&result, word, attempt, MAX_ATTEMPTS)
        );
        if matches!(result, GuessResult::Correct) {
            return;
        }
        attempt += 1;
    }
}

fn pick_word() -> String {
    let f = File::open(SOLUTION_FILE)
        .unwrap_or_else(|e| panic!("(;_;) file not found: {}: {}", SOLUTION_FILE, e));
    let f = BufReader::new(f);
    let lines = f.lines().map(|l| l.expect("Couldn't read line"));
    lines
        .choose(&mut rand::thread_rng())
        .expect("File has no lines?!")
        .to_uppercase()
}

fn read_guess_dict() -> HashSet<String> {
    std::fs::read_to_string(GUESS_FILE)
        .unwrap_or_else(|_| panic!("file not found: {}", GUESS_FILE))
        .lines()
        .map(|x| x.to_uppercase())
        .collect::<HashSet<String>>()
}

fn validate_guess<'a>(
    word_length: usize,
    guess: &'a str,
    allow_list: &HashSet<String>,
) -> Result<&'a str, String> {
    // println!(
    //     "guess: '{}', word_length: {}, guess.len: {}",
    //     guess,
    //     word_length,
    //     guess.len()
    // );
    let is_correct_length = guess.len() == word_length;
    let is_in_list = allow_list.contains(guess);
    if !is_correct_length {
        return Result::Err(format!("Your guess must be {} letters long.", word_length));
    }
    if !is_in_list {
        return Result::Err("Your guess must be an English word.".to_string());
    }
    Result::Ok(guess)
}

fn read_user_guess(attempt: u32, max_attempts: u32) -> String {
    let mut guess = String::new();
    print!(
        "{}{}{} {} ",
        attempt.to_string().blue(),
        "/".blue(),
        max_attempts.to_string().blue(),
        ">?".blue()
    );
    io::stdout()
        .flush()
        .expect("When flushing fails, you're in deep shit.");
    io::stdin()
        .read_line(&mut guess)
        .expect("I don't know what you did, but I can't handle it.");
    guess.trim().to_uppercase()
}

// Cannot change case and get a char back so we get a string instead.
fn stringify_char_result(result: &CharGuessResult) -> String {
    match result {
        CharGuessResult::Correct(c) => c.to_string().to_uppercase().green().to_string(),
        CharGuessResult::Occuring(c) => c.to_string().to_lowercase().yellow().to_string(),
        CharGuessResult::NotOccuring => String::from('_'),
    }
}

fn stringify_guess_result(
    result: &GuessResult,
    word: &str,
    attempts: u32,
    max_attempts: u32,
) -> String {
    match result {
        GuessResult::Correct => format!("Correct! You did it in {attempts} attempts."),
        GuessResult::Incorrect(chars) if attempts < max_attempts => chars
            .iter()
            .map(stringify_char_result)
            .collect::<Vec<String>>()
            .join(" ")
            .to_string(),
        _ => format!("The correct word was {word}, better luck next time."),
    }
}

fn evaluate_guess(guess: &str, word: &str) -> GuessResult {
    let zipped = guess.chars().zip(word.chars());
    // apparently iter.all below consumes the iter and that's a mutation.
    // So I create a Vec instead and then an iter from that in the next line.
    let char_results: Vec<CharGuessResult> =
        zipped.map(|(a, b)| evaluate_char(a, b, word)).collect();
    let is_correct = char_results
        .iter()
        .all(|result| matches!(result, CharGuessResult::Correct(_)));
    if is_correct {
        GuessResult::Correct
    } else {
        GuessResult::Incorrect(char_results)
    }
}

fn evaluate_char(guess_char: char, word_char: char, word: &str) -> CharGuessResult {
    if guess_char == word_char {
        CharGuessResult::Correct(guess_char)
    } else if word.contains(guess_char) {
        CharGuessResult::Occuring(guess_char)
    } else {
        CharGuessResult::NotOccuring
    }
}

Output:

3 Likes

Some pointers, in no particular order:

  • Rather than reading the allowed solutions and guesses from text files (which, as currently coded, always need to be in the current working directory when the program is run), it would be better to place the text files inside your src/ directory and read them into the source code with include_str!(). In addition, for the guess dict, if everything in the file is already uppercase, you can use a HashSet<&'static str> instead of a HashSet<String>.

  • You're not using the value returned in Ok from validate_guess(). You might as well just return Ok(()) on success.

  • You don't need to write Result::Err and Result::Ok. Just Err and Ok will do.

  • If you add Eq and PartialEq to the derives for GuessResult, then matches!(result, GuessResult::Correct) can be simplified to just result == GuessResult::Correct.

  • As currently coded, if the user's last attempt is incorrect, they will get the "Better luck next time" message without getting to see just how accurate their guess was. I'm not sure if that's what you want. I would recommend moving the "Better luck next time" message to after the while loop in game_loop() and always displaying the stringified result for incorrect guesses.

3 Likes

Thank you very much, very helpful suggestions. I was wondering how to add string data to the executable, that macro is fantastic.

You might want to next spend some time learning how to nicely handle errors, in particular you may find the "stock" error handling libraries anyhow or thiserror handy, they provide what are effectively shorthands for common good error handling practice that would have saved me some time if I knew about them earlier, like easily adding context or giving human readable messages to a list of error values.

Regardless, this looks quite good for a first project!

3 Likes

Do you have it on github? I'd love to play this.

Yes, I'm not exactly 'handling' the errors right now. Thanks for the suggestion.

I do now: GitHub - lincore81/garble-rs: A legally distinct word guessing game written in an obscure language

2 Likes

Thanks :slight_smile:
Btw, why do you call it garble?

One more question, just out of curiosity. Why are guesses and solutions two separat files? Are there more possible guesses then there are solutions? If so why?

That’s the way that the original Wordle works: It lets people with a large vocabulary guess with any word they know (eg apres), but ensures that the actual answer will be a word that most people will be familiar with (eg start).

2 Likes

Names are hard. I just wanted something that's not a trademark violation, but sounds similar-ish.

There are ~3k possible solutions and about ~13k allowed guesses.
Like @2e71828 already said, this is how the game works. The solution should usually be trivial enough that you never think the game presented you an impossible task (the original authors only picked words they knew as possible solutions). On the other hand, having arbitrary restrictions on which words are acceptable guesses is not intuitive. Having virtually all 5 letter English words available also offers more tactical options and thus rewards players who have a large vocabulary.

2 Likes
1 Like

wurdle - sounds vaguely german

1 Like

Wördel.

5 Likes

that's öwsome!

2 Likes

But then you have to recompile every time you change the dictionary. I think programs and data should be separated.

This might introduce an extra number of instructions and binary size, although it would probably be optimized in release complies. Normally I would go for matches!, especially if comparisons are not frequent.

1 Like

Your code assumes that changing the case of a word does not change it's length, which is famously not true for the german sharp s (ß). You might want to check that the guess only contains ascii letters.

Thanks for pointing that out. Since I have no plans of handling anything but English, I suppose I dodged a bullet :slight_smile: If I ever deal with i18n in Rust, I will gladly use a library that abstracts all of these details far away from me.