From b77b148bccc3f8b350709fe032a9ed2b86e490c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB=20=D0=9A=D0=B0=D0=BF?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BA=D0=BE?= Date: Mon, 1 Jun 2026 19:06:57 +0300 Subject: [PATCH 02/15] add game.dart --- abc/lib/game.dart | 287 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 abc/lib/game.dart 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); + } +} -- 2.34.1 From 535c4455e59af63232d5a4f14c2d73a66b4d6ad9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB=20=D0=9A=D0=B0=D0=BF?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BA=D0=BE?= Date: Mon, 1 Jun 2026 19:09:52 +0300 Subject: [PATCH 03/15] add util --- abc/lib/game.dart | 288 +--------------------------------------------- abc/lib/main.dart | 21 +--- src/game.dart | 287 +++++++++++++++++++++++++++++++++++++++++++++ src/main.dart | 22 ++++ util/run-macos | 5 + 5 files changed, 316 insertions(+), 307 deletions(-) mode change 100644 => 120000 abc/lib/game.dart mode change 100644 => 120000 abc/lib/main.dart create mode 100644 src/game.dart create mode 100644 src/main.dart create mode 100644 util/run-macos 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 -- 2.34.1 From 471d81bca0f9e1c19380a184169f1257db268d19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB=20=D0=9A=D0=B0=D0=BF?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BA=D0=BE?= Date: Mon, 1 Jun 2026 19:10:01 +0300 Subject: [PATCH 04/15] x --- util/run-macos | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 util/run-macos diff --git a/util/run-macos b/util/run-macos old mode 100644 new mode 100755 -- 2.34.1 From 2bcea6ab154afe1b77cacc106a626a87e356c0ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB=20=D0=9A=D0=B0=D0=BF?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BA=D0=BE?= Date: Mon, 1 Jun 2026 19:37:13 +0300 Subject: [PATCH 05/15] display single tile --- abc/lib/tutFun.dart | 1 + src/main.dart | 52 +++++++++++++++++++++++++++++++++------------ src/tutFun.dart | 17 +++++++++++++++ 3 files changed, 57 insertions(+), 13 deletions(-) create mode 120000 abc/lib/tutFun.dart create mode 100644 src/tutFun.dart diff --git a/abc/lib/tutFun.dart b/abc/lib/tutFun.dart new file mode 120000 index 0000000..aa9d744 --- /dev/null +++ b/abc/lib/tutFun.dart @@ -0,0 +1 @@ +../../src/tutFun.dart \ No newline at end of file diff --git a/src/main.dart b/src/main.dart index 7d9ca9d..c132267 100644 --- a/src/main.dart +++ b/src/main.dart @@ -1,22 +1,48 @@ import 'package:flutter/material.dart'; - import 'game.dart'; +import 'tutFun.dart'; void main() { - runApp(const MainApp()); + runApp(const MainApp()); } class MainApp extends StatelessWidget { - const MainApp({super.key}); + const MainApp({super.key}); - @override - Widget build(BuildContext context) { - return const MaterialApp( - home: Scaffold( - body: Center( - child: Text('Wow, nice, Hello World!'), - ), - ), - ); - } + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: Scaffold( + body: Center( + //child: Text('Wow, nice, Hello World!'), + child: Tile("A", HitType.hit), + ), + ), + ); + } +} + +class Tile extends StatelessWidget { + const Tile(this.letter, this.hitType, {super.key}); + + final String letter; + final HitType hitType; + + @override + Widget build(BuildContext context) { + return Container( + child: Center( + child: Text( + letter.toUpperCase(), + style: Theme.of(context).textTheme.titleLarge, + ), + ), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + color: tutTileColor(hitType), + ), + height: 60, + width: 60, + ); + } } diff --git a/src/tutFun.dart b/src/tutFun.dart new file mode 100644 index 0000000..0f90d09 --- /dev/null +++ b/src/tutFun.dart @@ -0,0 +1,17 @@ +library; + +import 'package:flutter/material.dart'; +import 'game.dart'; + +Color tutTileColor(HitType t) { + if (t == HitType.hit) { + return Colors.green; + } + if (t == HitType.partial) { + return Colors.yellow; + } + if (t == HitType.miss) { + return Colors.grey; + } + return Colors.white; +} -- 2.34.1 From ec37facd9bd80647f1e7dae9166dd077468a4af6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB=20=D0=9A=D0=B0=D0=BF?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BA=D0=BE?= Date: Tue, 2 Jun 2026 18:24:02 +0300 Subject: [PATCH 06/15] weird --- src/main.dart | 2 +- util/run-macos | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main.dart b/src/main.dart index c132267..bb24b62 100644 --- a/src/main.dart +++ b/src/main.dart @@ -15,7 +15,7 @@ class MainApp extends StatelessWidget { home: Scaffold( body: Center( //child: Text('Wow, nice, Hello World!'), - child: Tile("A", HitType.hit), + child: Tile("A", .hit), ), ), ); diff --git a/util/run-macos b/util/run-macos index d5a01f1..d1a46cc 100755 --- a/util/run-macos +++ b/util/run-macos @@ -2,4 +2,4 @@ SDIR=$(cd "$(dirname "$0")" ; pwd -P) cd $SDIR/../abc -flutter run -d macos +flutter run --enable-experiment=dot-shorthands -d macos -- 2.34.1 From 2250fca0046fe117b35df1dd36ffefd99300ad24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB=20=D0=9A=D0=B0=D0=BF?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BA=D0=BE?= Date: Tue, 2 Jun 2026 18:28:13 +0300 Subject: [PATCH 07/15] agents --- AGENTS.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..18df25e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,10 @@ +This is a Flutter project. + +The structure is as follows: + +* `abc/` directory contains usual Flutter application +* `util/` directory contains scripts to run the application +* `src/` directory contains source code files that are symlinked into `abc/lib/` directory so that Flutter builder can find them + + +All new source code files should go into `src/` directory and be symlinked to `abc/lib/`. -- 2.34.1 From f0f229d5667cba0cb407974c96ddfafa351cbef7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB=20=D0=9A=D0=B0=D0=BF?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BA=D0=BE?= Date: Tue, 2 Jun 2026 18:45:07 +0300 Subject: [PATCH 08/15] app bar --- src/main.dart | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main.dart b/src/main.dart index bb24b62..b93d850 100644 --- a/src/main.dart +++ b/src/main.dart @@ -11,11 +11,16 @@ class MainApp extends StatelessWidget { @override Widget build(BuildContext context) { - return const MaterialApp( + return MaterialApp( home: Scaffold( + appBar: AppBar( + title: Align( + alignment: Alignment.centerLeft, + child: Text("Birdle"), + ), + ), body: Center( - //child: Text('Wow, nice, Hello World!'), - child: Tile("A", .hit), + child: Tile("A", HitType.hit), ), ), ); -- 2.34.1 From 52237ab4d827835fdf535f3d4364632e6d269b15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB=20=D0=9A=D0=B0=D0=BF?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BA=D0=BE?= Date: Tue, 2 Jun 2026 18:49:08 +0300 Subject: [PATCH 09/15] game page stub --- src/main.dart | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/main.dart b/src/main.dart index b93d850..2b0f8aa 100644 --- a/src/main.dart +++ b/src/main.dart @@ -6,6 +6,17 @@ void main() { runApp(const MainApp()); } +class GamePage extends StatelessWidget { + GamePage({super.key}); + + final Game _game = Game(); + + @override + Widget build(BuildContext context) { + return Container(); + } +} + class MainApp extends StatelessWidget { const MainApp({super.key}); @@ -19,9 +30,8 @@ class MainApp extends StatelessWidget { child: Text("Birdle"), ), ), - body: Center( - child: Tile("A", HitType.hit), - ), + //body: Center(child: Tile("A", HitType.hit)), + body: Center(child: GamePage()), ), ); } -- 2.34.1 From dd714a455e3b1f58e5676632e8ad7b1d510d558e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB=20=D0=9A=D0=B0=D0=BF?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BA=D0=BE?= Date: Tue, 2 Jun 2026 19:02:12 +0300 Subject: [PATCH 10/15] layout --- src/main.dart | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/main.dart b/src/main.dart index 2b0f8aa..c6dd0ab 100644 --- a/src/main.dart +++ b/src/main.dart @@ -13,7 +13,22 @@ class GamePage extends StatelessWidget { @override Widget build(BuildContext context) { - return Container(); + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + spacing: 5.0, + children: [ + for (final guess in _game.guesses) + Row( + spacing: 5.0, + children: [ + for (final letter in guess) + Tile(letter.char, letter.type) + ], + ) + ], + ), + ); } } @@ -30,7 +45,6 @@ class MainApp extends StatelessWidget { child: Text("Birdle"), ), ), - //body: Center(child: Tile("A", HitType.hit)), body: Center(child: GamePage()), ), ); -- 2.34.1 From a1da6c43bfe8ecf44fdad00f02a042addbfd01dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB=20=D0=9A=D0=B0=D0=BF?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BA=D0=BE?= Date: Tue, 2 Jun 2026 19:50:34 +0300 Subject: [PATCH 11/15] input stub --- src/main.dart | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/main.dart b/src/main.dart index c6dd0ab..2a68e1d 100644 --- a/src/main.dart +++ b/src/main.dart @@ -32,6 +32,33 @@ class GamePage extends StatelessWidget { } } +class GuessInput extends StatelessWidget { + GuessInput({super.key, required this.onSubmitGuess}); + + final void Function(String) onSubmitGuess; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Padding( + child: TextField( + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(35)), + ), + ), + maxLength: 5, + ), + padding: const EdgeInsets.all(8.0), + ), + ) + ] + ); + } +} + class MainApp extends StatelessWidget { const MainApp({super.key}); -- 2.34.1 From dfaead428bb87472c34bbe9eafa4844fe0bc93f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB=20=D0=9A=D0=B0=D0=BF?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BA=D0=BE?= Date: Tue, 2 Jun 2026 20:00:44 +0300 Subject: [PATCH 12/15] input stub --- src/main.dart | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/main.dart b/src/main.dart index 2a68e1d..f2dcad8 100644 --- a/src/main.dart +++ b/src/main.dart @@ -25,7 +25,12 @@ class GamePage extends StatelessWidget { for (final letter in guess) Tile(letter.char, letter.type) ], - ) + ), + GuessInput( + onSubmitGuess: (guess) { + /**/print(guess); + } + ), ], ), ); @@ -37,6 +42,9 @@ class GuessInput extends StatelessWidget { final void Function(String) onSubmitGuess; + final FocusNode _focusNode = FocusNode(); + final TextEditingController _textEditingController = TextEditingController(); + @override Widget build(BuildContext context) { return Row( @@ -44,12 +52,20 @@ class GuessInput extends StatelessWidget { Expanded( child: Padding( child: TextField( + autofocus: true, + controller: _textEditingController, decoration: InputDecoration( border: OutlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(35)), ), ), + focusNode: _focusNode, maxLength: 5, + onSubmitted: (_) { + onSubmitGuess(_textEditingController.text.trim()); + _textEditingController.clear(); + _focusNode.requestFocus(); + }, ), padding: const EdgeInsets.all(8.0), ), -- 2.34.1 From fd45f1a2bb0a5c4ab19da151ccc7aed46266a031 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB=20=D0=9A=D0=B0=D0=BF?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BA=D0=BE?= Date: Wed, 3 Jun 2026 13:13:55 +0300 Subject: [PATCH 13/15] submit btn --- src/main.dart | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/main.dart b/src/main.dart index f2dcad8..7c967a7 100644 --- a/src/main.dart +++ b/src/main.dart @@ -40,9 +40,8 @@ class GamePage extends StatelessWidget { class GuessInput extends StatelessWidget { GuessInput({super.key, required this.onSubmitGuess}); - final void Function(String) onSubmitGuess; - final FocusNode _focusNode = FocusNode(); + final void Function(String) onSubmitGuess; final TextEditingController _textEditingController = TextEditingController(); @override @@ -62,17 +61,28 @@ class GuessInput extends StatelessWidget { focusNode: _focusNode, maxLength: 5, onSubmitted: (_) { - onSubmitGuess(_textEditingController.text.trim()); - _textEditingController.clear(); - _focusNode.requestFocus(); + processSubmit(); }, ), padding: const EdgeInsets.all(8.0), ), - ) + ), + IconButton( + icon: const Icon(Icons.arrow_circle_up), + onPressed: () { + processSubmit(); + }, + padding: EdgeInsets.zero, + ), ] ); } + + void processSubmit() { + onSubmitGuess(_textEditingController.text.trim()); + _textEditingController.clear(); + _focusNode.requestFocus(); + } } class MainApp extends StatelessWidget { -- 2.34.1 From 2e0597d4a416d598af95846ce68af1bf6cb059d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB=20=D0=9A=D0=B0=D0=BF?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BA=D0=BE?= Date: Thu, 4 Jun 2026 18:29:54 +0300 Subject: [PATCH 14/15] set state --- src/game.dart | 2 +- src/main.dart | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/game.dart b/src/game.dart index 3ba2635..c1d7a01 100644 --- a/src/game.dart +++ b/src/game.dart @@ -244,7 +244,7 @@ extension WordUtils on Word { /// but where each [Letter] has new a [HitType] of /// [HitType.hit], [HitType.partial], or [HitType.miss]. Word evaluateGuess(Word hiddenWord) { - assert(isLegalGuess); + //assert(isLegalGuess); final result = List.filled(length, (char: '', type: HitType.none)); // Counts hidden-word letters that can still be claimed as partial matches. diff --git a/src/main.dart b/src/main.dart index 7c967a7..ed4a92c 100644 --- a/src/main.dart +++ b/src/main.dart @@ -6,9 +6,13 @@ void main() { runApp(const MainApp()); } -class GamePage extends StatelessWidget { +class GamePage extends StatefulWidget { GamePage({super.key}); + + @override State createState() => GamePageState(); +} +class GamePageState extends State { final Game _game = Game(); @override @@ -29,6 +33,9 @@ class GamePage extends StatelessWidget { GuessInput( onSubmitGuess: (guess) { /**/print(guess); + setState(() { + _game.guess(guess); + }); } ), ], -- 2.34.1 From 57de3d9b217784e98d041854f0943e71169e7676 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB=20=D0=9A=D0=B0=D0=BF?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BA=D0=BE?= Date: Thu, 4 Jun 2026 18:49:27 +0300 Subject: [PATCH 15/15] anim --- src/main.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main.dart b/src/main.dart index ed4a92c..775a6eb 100644 --- a/src/main.dart +++ b/src/main.dart @@ -119,7 +119,7 @@ class Tile extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( + return AnimatedContainer( child: Center( child: Text( letter.toUpperCase(), @@ -130,6 +130,7 @@ class Tile extends StatelessWidget { border: Border.all(color: Colors.grey.shade300), color: tutTileColor(hitType), ), + duration: Duration(milliseconds: 300), height: 60, width: 60, ); -- 2.34.1