The companion code to https://worstplans.com/?p=4696 .
#!/usr/bin/env python3 """ A word-guessing game inspired by the game "Wordle" / "Quordle" / "Octordle", etc. Similar to the word-guessing hacking mini-game in the Fallout series, which is also similar to a word version of the game "Mastermind". By default, after you make a guess: * A yellow letter means "this character is present in this word, but not at this position" * A green letter means "this character is present at this exact position." * Anything else means the letter does not appear in that word at all. FYI: for testing, here's a one-liner to pull words out of the dictionary of a particular length: Length=8 --> cat /usr/share/dict/words | perl -ne 'print if /^.{8}$/' | less -S Example command line arguments: SCRIPTNAME -n=4 -l=5 --daily --debug Show a NON-INTERACTIVE demo of a set of guesses for six 12-letter words: SCRIPTNAME -n=6 -l=12 --daily --debug Deterministically always pick the same words: SCRIPTNAME -n=5 -l=4 --seed=1234 Script by Alex Williams, Feb 2022. """ # pylint: disable=unnecessary-pass,useless-return,line-too-long,unused-import import argparse import datetime import random import re from ssl import HAS_TLSv1_1 import sys from enum import Enum from typing import Dict, Iterable, List, Set, Optional class Verdict(Enum): ABSENT = 0 # Letter is not in this word at all ELSEWHERE = 1 # Letter is present but not at this position CORRECT = 2 # Letter is present AND at this position FULL_WORD_CORRECT = 3 # Not a per-letter verdict. Indicates that it's part of a COMPLETE guess. class Redaction(Enum): VISIBLE = 0 # Visibile, no redaction. ASCII = 1 # Ascii text EMOJI = 2 # Redact with emojis RESET_COLOR = "\x1b[0m" # Should reset the terminal to "normal" text color. ANSWER_WORD_COLOR = "\x1b[0;31;40m" BORDER_COLOR = "\x1b[0;34m" GUESS_COUNT_WITHIN_LIMIT_COLOR = "\x1b[0;32;40m" GUESS_COUNT_EXCEEDED_LIMIT_COLOR = "\x1b[0;31;40m" ALREADY_GUESSED_LETTER_COLOR = "\x1b[0;34;40m" # A dim color that isn't too eye-catching NOT_YET_GUESSED_LETTER_COLOR = "\x1b[0;30;42m" REDACT_CHAR_MAPPING: Dict[Verdict, str] = { # Unix color formatting Verdict.ABSENT: " ", Verdict.ELSEWHERE: "-", Verdict.CORRECT: "=", Verdict.FULL_WORD_CORRECT: "#", } REDACT_EMOJI_MAPPING: Dict[Verdict, str] = { # Emoji mapping Verdict.ABSENT: "β¬", Verdict.ELSEWHERE: "π‘", Verdict.CORRECT: "π©", Verdict.FULL_WORD_CORRECT: "πΉ", } COLOR_FORMAT_DEFAULT: Dict[Verdict, str] = { # Unix color formatting Verdict.ABSENT: "\x1b[0;37;40m", # White on black Verdict.ELSEWHERE: "\x1b[0;30;43m", # Black text, yellow background Verdict.CORRECT: "\x1b[0;30;42m", # Black text, green background Verdict.FULL_WORD_CORRECT: "\x1b[1;30;46m", # Note the '1;' for "bold" colors } COLOR_FORMAT_COLORBLIND_1: Dict[Verdict, str] = { # Unix color formatting Verdict.ABSENT: "\x1b[1;37;40m", # White on black Verdict.ELSEWHERE: "\x1b[0;33;41m", # Yellow on red Verdict.CORRECT: "\x1b[0;33;44m", # Yellow on blue Verdict.FULL_WORD_CORRECT: "\x1b[1;30;46m", # Note the '1;' for "bold" colors } def get_color_formatter(name: str = "") -> Dict[Verdict, str]: if name in ("default", ""): return COLOR_FORMAT_DEFAULT elif name in ("colorblind", "colorblind_1", "colorblind1"): return COLOR_FORMAT_COLORBLIND_1 # The set of all possible words you'd find in a dictionary. Note that it's not a *python* dict. WORDS: Set[str] = set() # The actually-picked words. One word per N "boards" ANSWERS: List[str] = list() UNSOLVED_VALUE = -1 # int showing which guess index each answer was found on, starting from index = 0 (first guess). -1 means "not correct yet" SOLVED_AT: List[int] = list() GUESSES: List[str] = list() # your previous guesses def is_guess_valid(guess: str, valid_words: Iterable[str], expected_len: int) -> bool: if len(guess) != expected_len: raise ValueError( f"Your guess must be a word of length {expected_len}, but your guess of `{guess}` was of length {len(guess)}." ) if guess not in valid_words: raise ValueError(f"Guesses must be in the dictionary: your word (`{guess}`) was not.") return True def manually_colorized(word: str, color: str) -> str: return f"""{color}{word}{RESET_COLOR}""" def colorized(letter: str, v: Verdict) -> str: assert len(letter) == 1, "This should be a SINGLE character we are evaluating/colorizing." return f"""{get_color_formatter()[v]}{letter}{RESET_COLOR}""" def evaluate_guess(guess_word: str, answer_word: str) -> List[Verdict]: """Evaluates a guessed WORD versus the answer WORD.""" result: List[Verdict] = list() assert len(guess_word) == len(answer_word), "Programming error: length mismatch!" for i in range(len(guess_word)): if guess_word[i] == answer_word[i]: result.append(Verdict.CORRECT) # Exactly correct elif guess_word[i] in answer_word: result.append(Verdict.ELSEWHERE) # Letter is in the word, but not at this position else: result.append(Verdict.ABSENT) return result def print_keyboard(guesses: Iterable[str]) -> None: char_between_keys: str = " " # Horizontal space to print between keys (a delimiter) xxx: str = "EMPTY" # Just used for layout / spacing h1: str = "HALF" # Half width, for offsetting the keyboard by HALF as much as 'xxx' would. This is how we offset the keyboard to get the 'staggered' row look. h2: str = "HALF_RIGHT" # This is used to make the right border line up, in case it doesn't with the default spacing settings. kb_layout: List[List[str]] = [ # This gets printed out essentially verbatim ["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"], [xxx, xxx, xxx, xxx, xxx, xxx, xxx, xxx, xxx, xxx], # blank line [h1, "a", "s", "d", "f", "g", "h", "j", "k", "l", h2], [xxx, xxx, xxx, xxx, xxx, xxx, xxx, xxx, xxx, xxx], # blank line [xxx, "z", "x", "c", "v", "b", "n", "m", xxx, xxx], ] all_letters_guessed: Set[str] = set("".join(guesses)) # Letters already guessed key_width = 1 # Each character is only one character wide (unless we had something like '[A]' instead of just 'A') l_border = "β " r_border = " β" # How many monospaced characters wide is the keyboard? kb_width: int = (key_width + len(char_between_keys)) * len(kb_layout[0]) + len(l_border) + len(r_border) t_border = "β" + "β" * (kb_width - 2) + "β" + "\n" b_border = "β" + "β" * (kb_width - 2) + "β" + "\n" # per_row_delim = f"\n{l_border}\n" # Two newlines means we get a blank line between rows sys.stdout.write(manually_colorized(t_border, BORDER_COLOR)) row: List[str] for row in kb_layout: sys.stdout.write(manually_colorized(l_border, BORDER_COLOR)) # Left border for the "keyboard" k: str for k in row: if k == xxx: # If it's the special "full spacer" string sys.stdout.write(f" {char_between_keys}") continue if k == h1: # If it's the special "half spacer" string sys.stdout.write(f"{char_between_keys}") # <-- less space than the full spacer continue if k == h2: # If it's the special "half spacer" string sys.stdout.write(f"{char_between_keys}") # <-- less space than the full spacer continue color: str = ( ALREADY_GUESSED_LETTER_COLOR if (k in all_letters_guessed) else NOT_YET_GUESSED_LETTER_COLOR ) sys.stdout.write(f"{color}{k}{RESET_COLOR}{char_between_keys}") # Print this key pass # End of this row. sys.stdout.write( f"{manually_colorized(r_border, BORDER_COLOR)}\n" ) # Just a newline, for the last keyboard row only. pass sys.stdout.write(manually_colorized(b_border, BORDER_COLOR)) return def print_board( guesses: Iterable[str], answers: Iterable[str], max_guesses: int = -1, # <-- Not currently used redact_type: Redaction = Redaction.VISIBLE, toupper: bool = False, show_answers: bool = False, print_guess_num: bool = True, print_in_color: bool = True, ) -> None: n_boards: int = len(answers) ANSWER_HORIZONTAL_DELIM = " " # The delimiter between answers (columns) if n_boards == 0: raise Exception("Zero words? Questionable.") if print_guess_num: N_GUESS_DIGITS: int = 5 LEFT_PAD_BEFORE_GUESS: str = " " else: N_GUESS_DIGITS: int = 0 LEFT_PAD_BEFORE_GUESS: str = "" pass if show_answers: sys.stdout.write(" " * N_GUESS_DIGITS) # Left-padding (guess numbers will go here later sys.stdout.write(LEFT_PAD_BEFORE_GUESS) for i, a in enumerate(answers): if i > 0: sys.stdout.write(ANSWER_HORIZONTAL_DELIM) sys.stdout.write(manually_colorized(a, ANSWER_WORD_COLOR)) sys.stdout.write("\n") pass # Keep track of when we discover that each answer is correct. # Note that this is different from the global variable version, which doesn't actually store the position. guess: str for guess_num, guess in enumerate(guesses): # Each guess is a ROW padded_guess_num = "{0: >{width}}".format(guess_num + 1, width=N_GUESS_DIGITS) guess_color = GUESS_COUNT_WITHIN_LIMIT_COLOR if print_guess_num: sys.stdout.write(manually_colorized(padded_guess_num, color=guess_color)) sys.stdout.write(LEFT_PAD_BEFORE_GUESS) pass answer: str for a_idx, answer in enumerate(answers): # Each answer is a COLUMN. verdict_this_word: List[Verdict] = evaluate_guess(guess, answer) assert len(guess) == len(verdict_this_word) if guess == answer: # Got this entire word totally correct # print(f"Found this word --> {guess} was {answer} at index {a_idx}") SOLVED_AT[a_idx] = guess_num for letter, l_verdict in zip(guess, verdict_this_word): if toupper: letter = letter.capitalize() if (SOLVED_AT[a_idx] != UNSOLVED_VALUE) and (SOLVED_AT[a_idx] < guess_num): # AFTER we've solve the word, print it in a different color from that guess number onward # (SO we print it in 'all solved' color for one line only) l_verdict = Verdict.FULL_WORD_CORRECT letter = " " # Even if we aren't redacting, we still don't keep printing guesses for already-solved words. if redact_type == Redaction.ASCII: # If we're in "redaction" mode, then don't print the guesses letter = REDACT_CHAR_MAPPING[l_verdict] elif redact_type == Redaction.EMOJI: letter = REDACT_EMOJI_MAPPING[l_verdict] pass formatted_letter = colorized(letter=letter, v=l_verdict) if print_in_color else letter sys.stdout.write(formatted_letter) pass # end of printing each letter sys.stdout.write(ANSWER_HORIZONTAL_DELIM) # end of printing all the 'boards' on this row sys.stdout.write("\n") # <-- End of this row. pass return def argErrorAndExit(msg="(No additional information given)"): raise SystemExit("[ERROR] in arguments to this script: " + msg) def main(): parser = argparse.ArgumentParser( description="%(prog)s: Word-guessing game.", epilog="""Example usage: %(prog)s -w 6 -l 5.""", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) parser.add_argument( "-d", "--dict", dest="dictionary", type=str, default="/usr/share/dict/words", metavar="FILE", help="The UNIX dictionary file. Default on the Mac is /usr/share/dict/words.", ) parser.add_argument( "-n", "--n-boards", dest="n_boards", type=int, default=4, help="Number of `boards` you are playing on (one word per board). Anywhere from 1 to 16 is `reasonable`, but more is OK too.", ) parser.add_argument("-l", "--letters", dest="word_len", type=int, default=5, help="Letters per word.") parser.add_argument( "--debug", dest="debug_force_input", action="store_true", help="'Automatically' runs through some guesses. Deterministic, so you may want to also use --seed=####.", ) parser.add_argument( "--no-keyboard", dest="omit_keyboard", action="store_true", help="Don't print the keyboard.", ) parser.add_argument( "-s", "--seed", dest="rand_seed", type=int, default=None, help="Random seed, for deterministic games. Otherwise", ) parser.add_argument( "--daily-challenge", dest="is_daily_challenge", action="store_true", help="Should we use today's date (UTC) as the random seed? The default is just to make a random board.", ) parser.add_argument( "--exclude-upper-case", dest="exclude_upper_case", action="store_false", help="Exclude words with ANY capital letters.", ) parser.add_argument( "--exclude-non-english-alphabet", dest="exclude_non_english_alphabet", action="store_false", help="Exclude words with ANY letters that aren't in [A-Za-z]. E.g. no accent marks or apostrophes.", ) parser.add_argument( "-g", "--num-guesses", dest="n_guesses", type=int, default=4, help="Number of guesses before you 'lose'. Doesn't really mean anything.", ) parser.add_argument( "-q", "--quiet", dest="verbose", action="store_false", help="don't print status messages to stdout" ) parser.add_argument( "remainder", nargs=argparse.REMAINDER ) # get the REMAINING un-parsed arguments (for example, a bunch of filenames) args = parser.parse_args() if not len(args.remainder) == 0: argErrorAndExit("Looks like you have an extra command line argument?") if args.n_boards == 0: argErrorAndExit("You can't have ZERO words to guess. Try 1 or more.") if args.n_boards > 600: argErrorAndExit("The terminal can't currently handle this many boards. Try fewer.") try: with open(args.dictionary, str("r")) as dfile: for linenum, line in enumerate(dfile): word: str = line.strip() # Strip newline if len(word) != args.word_len: continue # Word is not the desired length if args.exclude_upper_case and re.search(r"[A-Z]", word): # print(f'''Line {linenum+1}: skipped word with upper case: {word}''') continue # Skip upper-case word if args.exclude_non_english_alphabet and re.search(r"[^A-Za-z]", word): # Skip this word with a non-A-through-Z letter in it # print(f'''Line {linenum+1}: skipped word non-"English A-Z" char: {word}''') continue # Skip word with non-"A-Z" symbol in it. WORDS.add(word) pass except Exception as e: argErrorAndExit(f"Failed to read from alleged dictionary file `{args.dictionary}: {e}") # print(f"Read this many words of length {args.word_len}: {len(WORDS)}") if len(WORDS) < args.n_boards: argErrorAndExit( f"""Error: you requsted {args.n_boards} words, but the supplied dictionary fileonly has {len(WORDS)} words of a suitable length. Double check the dictionary (at `{args.dictionary}`), or request fewer words.""" ) if args.is_daily_challenge and (args.rand_seed is not None): argErrorAndExit("You cannot specify BOTH `--daily` and `--seed` simultaneously.") seed: Optional[int] if args.is_daily_challenge: # The random seed is "today's" (UTC timezone) days since Jan 1, 2000. seed = (datetime.datetime.utcnow().date() - datetime.date(2000, 1, 1)).days elif args.rand_seed is None: seed = None # Randomize the seed based on the current time print(f"This is a random run, with seed={seed}") else: seed = args.rand_seed print(f"This is a NON-RANDOM run, with seed={args.rand_seed}") random.seed(seed) # Sorted list avoids nondeterminism. set->list is nondeterministic. ANSWERS.extend(random.sample(sorted(list(WORDS)), args.n_boards)) SOLVED_AT.extend([UNSOLVED_VALUE] * len(ANSWERS)) print(f"There are {args.n_boards} word(s) to be solved:") # print("FYI, the answers are: ", " ".join(ANSWERS)) # For testing purposes, '--debug' will force a specific set of automatic TEST_GUESSES. TEST_GUESSES: List[str] = list() if args.debug_force_input: print("DEBUGGING is on: we're generating DETERMINISTIC test input.") print("DEBUG guesses are: N random gueses, 1 correct guess, repeatβ¦") answers_not_yet_guessed: List[str] = random.sample(ANSWERS, len(ANSWERS)) sorted_wordlist = sorted(list(WORDS)) # Set -> list is apparently nondeterministic. while len(answers_not_yet_guessed) > 0: n_rand_guesses_between_correct: int = 2 TEST_GUESSES.extend(random.sample(population=sorted_wordlist, k=n_rand_guesses_between_correct)) TEST_GUESSES.append(answers_not_yet_guessed.pop()) pass pass finished: bool = False while not finished: if TEST_GUESSES: guess = TEST_GUESSES.pop(0) else: guess = input("π’ Guess a word: ") try: _ = is_guess_valid(guess, valid_words=WORDS, expected_len=args.word_len) except ValueError as e: sys.stderr.write(f"π΄ {e}\n") sys.stderr.write("π΄ Try another word...\n") continue GUESSES.append(guess) print(f"Guess #{len(GUESSES)}: β{manually_colorized(guess, color=GUESS_COUNT_WITHIN_LIMIT_COLOR)}β") print_board(guesses=GUESSES, answers=ANSWERS, show_answers=False) if not args.omit_keyboard: print_keyboard(guesses=GUESSES) print("\n") if all([x != UNSOLVED_VALUE for x in SOLVED_AT]): print(f"π’ You got all {len(ANSWERS)} word(s) in this many guesses: {len(GUESSES)}") finished = True pass print() # newline print_board(guesses=GUESSES, answers=ANSWERS, show_answers=True) print("\n\nSpoiler-free text version...\n") print_board(guesses=GUESSES, answers=ANSWERS, redact_type=Redaction.ASCII, show_answers=False) print("\n\nSpoiler-free emoji version:\n") print_board( guesses=GUESSES, answers=ANSWERS, redact_type=Redaction.EMOJI, print_guess_num=False, show_answers=False, print_in_color=False, ) return # end of 'main' if __name__ == "__main__": main() pass