diff --git a/abc/lib/game.dart b/abc/lib/game.dart deleted file mode 100644 index 3ba2635..0000000 --- a/abc/lib/game.dart +++ /dev/null @@ -1,287 +0,0 @@ -/// 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); - } -} diff --git a/abc/lib/game.dart b/abc/lib/game.dart new file mode 120000 index 0000000..27b2fe2 --- /dev/null +++ b/abc/lib/game.dart @@ -0,0 +1 @@ +../../src/game.dart \ No newline at end of file diff --git a/abc/lib/main.dart b/abc/lib/main.dart deleted file mode 100644 index 0ac50be..0000000 --- a/abc/lib/main.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:flutter/material.dart'; - -void main() { - runApp(const MainApp()); -} - -class MainApp extends StatelessWidget { - const MainApp({super.key}); - - @override - Widget build(BuildContext context) { - return const MaterialApp( - home: Scaffold( - body: Center( - child: Text('Wow, nice, Hello World!'), - ), - ), - ); - } -} diff --git a/abc/lib/main.dart b/abc/lib/main.dart new file mode 120000 index 0000000..832c80a --- /dev/null +++ b/abc/lib/main.dart @@ -0,0 +1 @@ +../../src/main.dart \ No newline at end of file diff --git a/src/game.dart b/src/game.dart new file mode 100644 index 0000000..3ba2635 --- /dev/null +++ b/src/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); + } +} diff --git a/src/main.dart b/src/main.dart new file mode 100644 index 0000000..7d9ca9d --- /dev/null +++ b/src/main.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; + +import 'game.dart'; + +void main() { + runApp(const MainApp()); +} + +class MainApp extends StatelessWidget { + const MainApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: Scaffold( + body: Center( + child: Text('Wow, nice, Hello World!'), + ), + ), + ); + } +} diff --git a/util/run-macos b/util/run-macos new file mode 100644 index 0000000..d5a01f1 --- /dev/null +++ b/util/run-macos @@ -0,0 +1,5 @@ +#!/bin/bash +SDIR=$(cd "$(dirname "$0")" ; pwd -P) + +cd $SDIR/../abc +flutter run -d macos