diff --git a/abc/lib/game.dart b/abc/lib/game.dart new file mode 100644 index 0000000..3ba2635 --- /dev/null +++ b/abc/lib/game.dart @@ -0,0 +1,287 @@ +/// Game logic and supporting types for Birdle, +/// a five-letter word-guessing game similar to Wordle. +/// +/// Defines the [Game] state machine and the +/// [Word], [Letter], and [HitType] data model used to +/// represent guesses and their evaluation against a hidden word. +library; + +import 'dart:collection'; +import 'dart:math'; + +/// The result of evaluating a [Letter] of a guess against the hidden word. +enum HitType { + /// The letter hasn't yet been evaluated. + none, + + /// The letter matches the hidden word's letter at the same position. + hit, + + /// The letter is in the hidden word, but at a different position. + partial, + + /// The letter doesn't appear in the hidden word. + miss, +} + +/// A single character paired with its [HitType] against the hidden word. +typedef Letter = ({String char, HitType type}); + +/// Every word that can be legally entered as a guess. +const List allLegalGuesses = [...legalWords, ...legalGuesses]; + +/// Words that can be chosen as the hidden word. +const List legalWords = ['aback', 'abase', 'abate', 'abbey', 'abbot']; + +/// Additional words accepted as guesses beyond those in [legalWords]. +const List legalGuesses = [ + 'aback', + 'abase', + 'abate', + 'abbey', + 'abbot', + 'abhor', + 'abide', + 'abled', + 'abode', + 'abort', +]; + +/// Game state of a single round of Birdle, +/// a five-letter word-guessing game similar to Wordle. +/// +/// Exposes the state and methods a UI needs to +/// evaluate guesses and track progress, +/// but doesn't advance play on its own. +/// +/// Clients drive each round by calling [guess] to submit an attempt and +/// [resetGame] to start over. +class Game { + /// The default maximum number of guesses allowed in a [Game]. + static const int defaultMaxGuesses = 5; + + /// Creates a new game with [maxGuesses] guesses allowed. + /// + /// If [seed] is provided, the hidden word is + /// chosen deterministically from [legalWords], + /// otherwise it is selected at random. + Game({this.maxGuesses = defaultMaxGuesses, this.seed}) + : _wordToGuess = _generateInitialWord(seed), + _guesses = List.filled(maxGuesses, Word.empty()); + + /// The maximum number of guesses allowed in this game. + final int maxGuesses; + + /// The seed used to choose the hidden word, + /// or `null` if it was selected at random. + final int? seed; + + /// The current hidden word, exposed publicly through [hiddenWord]. + Word _wordToGuess; + + /// Backing storage for [guesses]. + /// + /// Holds every guess slot in order, + /// with unfilled slots represented by empty [Word]s. + List _guesses; + + /// The word the player is trying to guess. + Word get hiddenWord => _wordToGuess; + + /// An unmodifiable view of every guess slot, including those still empty. + UnmodifiableListView get guesses => UnmodifiableListView(_guesses); + + /// The most recently submitted guess, + /// or an empty [Word] if no guesses have been made. + Word get previousGuess { + final index = _guesses.lastIndexWhere((word) => word.isNotEmpty); + return index == -1 ? Word.empty() : _guesses[index]; + } + + /// The index of the next empty guess slot, or `-1` if every slot is full. + int get activeIndex => _guesses.indexWhere((word) => word.isEmpty); + + /// The number of guesses still available to the player. + int get guessesRemaining { + if (activeIndex == -1) return 0; + return maxGuesses - activeIndex; + } + + /// Whether the most recent guess matches the hidden word. + bool get didWin { + if (_guesses.first.isEmpty) return false; + + for (final letter in previousGuess) { + if (letter.type != HitType.hit) return false; + } + + return true; + } + + /// Whether all allowed guesses have been used without winning. + bool get didLose => guessesRemaining == 0 && !didWin; + + /// Picks a new hidden word and clears every submitted guess. + void resetGame() { + _wordToGuess = _generateInitialWord(seed); + _guesses = List.filled(maxGuesses, Word.empty()); + } + + /// Evaluates [guess] against the hidden word, + /// records the result in [guesses], and returns it. + /// + /// For finer control, use [isLegalGuess] to validate input or + /// [matchGuessOnly] to evaluate without recording the result. + Word guess(String guess) { + final result = matchGuessOnly(guess); + addGuessToList(result); + return result; + } + + /// Whether [guess] is a legal word to guess. + /// + /// UIs can call this method before [guess] to + /// show players a message when they enter an invalid word. + bool isLegalGuess(String guess) => Word.fromString(guess).isLegalGuess; + + /// Evaluates [guess] against the hidden word without advancing the game. + Word matchGuessOnly(String guess) => + Word.fromString(guess).evaluateGuess(_wordToGuess); + + /// Stores [guess] in the next empty slot of [guesses]. + void addGuessToList(Word guess) { + final guessIndex = activeIndex; + if (guessIndex == -1) { + throw StateError('No guesses remaining.'); + } + + _guesses[guessIndex] = guess; + } + + /// Returns the starting hidden word for a new round. + /// + /// Picks a deterministic word from [legalWords] when [seed] is provided, + /// or one at random otherwise. + static Word _generateInitialWord(int? seed) => + seed == null ? Word.random() : Word.fromSeed(seed); +} + +/// A five-letter word made up of [Letter]s, each tracking its [HitType]. +class Word with IterableMixin { + /// Creates a word backed by the specified list of [Letter]s. + Word(this._letters); + + /// Creates a word with five blank letters of [HitType.none]. + factory Word.empty() => + Word(List.filled(5, (char: '', type: HitType.none))); + + /// Creates a [Word] from [guess]. + /// + /// Each character is lowercased, + /// every [Letter] starts as [HitType.none]. + factory Word.fromString(String guess) { + if (guess.length != 5) { + throw ArgumentError.value( + guess, + 'guess', + 'Must be exactly 5 characters long.', + ); + } + + final letters = guess + .toLowerCase() + .split('') + .map((char) => (char: char, type: HitType.none)) + .toList(); + return Word(letters); + } + + /// Creates a word chosen at random from [legalWords]. + factory Word.random() { + final random = Random(); + final nextWord = legalWords[random.nextInt(legalWords.length)]; + return Word.fromString(nextWord); + } + + /// Creates a word chosen from [legalWords] using [seed] as an index. + factory Word.fromSeed(int seed) => + Word.fromString(legalWords[seed % legalWords.length]); + + /// An unmodifiable list of [Letter]s that make up this word. + final List _letters; + + @override + Iterator get iterator => _letters.iterator; + + /// Whether every [Letter] in this word has no character. + @override + bool get isEmpty => every((letter) => letter.char.isEmpty); + + @override + int get length => _letters.length; + + /// The [Letter] at index [i] in word. + Letter operator [](int i) => _letters[i]; + + @override + String toString() => _letters.map((letter) => letter.char).join().trim(); + + /// Returns a multi-line string showing each [Letter] alongside its [HitType]. + /// + /// Used to play the game from the command line. + String toStringVerbose() => _letters + .map((letter) => '${letter.char} - ${letter.type.name}') + .join('\n'); +} + +/// Validation and guess-evaluation logic on [Word]. +extension WordUtils on Word { + /// Whether this word appears in [allLegalGuesses]. + bool get isLegalGuess => allLegalGuesses.contains(toString()); + + /// Compares this [Word] against the specified [hiddenWord] + /// and returns a new [Word] with the same letters, + /// but where each [Letter] has new a [HitType] of + /// [HitType.hit], [HitType.partial], or [HitType.miss]. + Word evaluateGuess(Word hiddenWord) { + assert(isLegalGuess); + + final result = List.filled(length, (char: '', type: HitType.none)); + // Counts hidden-word letters that can still be claimed as partial matches. + final unmatchedHiddenLetterCounts = {}; + + // Reserve exact matches before scoring partial matches. + for (var i = 0; i < length; i++) { + final guessChar = this[i].char; + final hiddenChar = hiddenWord[i].char; + + if (guessChar == hiddenChar) { + result[i] = (char: guessChar, type: HitType.hit); + } else { + // Track non-hit hidden letters for the partial-match pass. + final unmatchedCount = unmatchedHiddenLetterCounts[hiddenChar] ?? 0; + unmatchedHiddenLetterCounts[hiddenChar] = unmatchedCount + 1; + } + } + + // Spend each remaining hidden letter only once for partial matches. + for (var i = 0; i < length; i++) { + if (result[i].type == HitType.hit) continue; + + final guessChar = this[i].char; + final unmatchedCount = unmatchedHiddenLetterCounts[guessChar] ?? 0; + final isPartial = unmatchedCount > 0; + if (isPartial) { + // Use one available hidden letter for this partial match. + unmatchedHiddenLetterCounts[guessChar] = unmatchedCount - 1; + } + + result[i] = ( + char: guessChar, + type: isPartial ? HitType.partial : HitType.miss, + ); + } + + return Word(result); + } +}