diff --git a/game.go b/game/game.go similarity index 70% rename from game.go rename to game/game.go index 39d855d..4a4e995 100644 --- a/game.go +++ b/game/game.go @@ -1,9 +1,10 @@ -package main +package game import ( "strings" ) +// CellStatus represents whether a cell is in a state of empty, correct, incorrect or wrong type CellStatus int const ( @@ -13,17 +14,26 @@ const ( STATUS_WRONG CellStatus = 3 ) +// GridCell represents a cell within a game grid type GridCell struct { Letter string Status CellStatus } -type Grid = [][]GridCell +// Grid represents a game grid +type Grid [][]GridCell +// Game represents a game that can be played type Game interface { + // Play plays a word in the game Play(word string) (bool, error) + // HasEnded returns whether the game has ended, whether with success or failure HasEnded() bool + // GetScore gets the running or final score for the game GetScore() (int, int) + // GetLastPlay returns the result of the last play + GetLastPlay() []GridCell + // OutputForConsole returns a string representation of the game for the command line OutputForConsole() string } @@ -71,6 +81,10 @@ func (g *game) GetScore() (int, int) { return g.attempts, len(g.grid) } +func (g *game) GetLastPlay() []GridCell { + return g.grid[g.attempts-1] +} + func (g *game) OutputForConsole() string { str := "\n" + strings.Repeat("-", len(g.answer)+2) + "\n" for _, row := range g.grid { @@ -95,8 +109,9 @@ func (g *game) OutputForConsole() string { return str } -// TODO include valid entries +// CreateGame creates a game for the given answer and number of allowed tries func CreateGame(answer string, tries int) Game { + // TODO include valid entries grid := make([][]GridCell, tries) return &game{false, 0, answer, grid} diff --git a/game/helpers.go b/game/helpers.go new file mode 100644 index 0000000..3e6fa99 --- /dev/null +++ b/game/helpers.go @@ -0,0 +1,19 @@ +package game + +const ( + NUM_LETTERS = 5 + NUM_ATTEMPTS = 6 + + COLOUR_RESET = "\033[0m" + COLOUR_GREEN = "\033[32m" + COLOUR_YELLOW = "\033[33m" +) + +func stringInSlice(a string, list []string) bool { + for _, b := range list { + if b == a { + return true + } + } + return false +} diff --git a/helpers.go b/helpers.go deleted file mode 100644 index 48918f9..0000000 --- a/helpers.go +++ /dev/null @@ -1,10 +0,0 @@ -package main - -func stringInSlice(a string, list []string) bool { - for _, b := range list { - if b == a { - return true - } - } - return false -} diff --git a/main.go b/main.go index e4c54ac..fd0ec3d 100644 --- a/main.go +++ b/main.go @@ -6,41 +6,31 @@ import ( "fmt" "math/rand" "os" - "sort" - "strconv" "strings" "time" + + "github.com/archy-bold/wordle-go/game" + "github.com/archy-bold/wordle-go/strategy" ) const ( NUM_LETTERS = 5 NUM_ATTEMPTS = 6 - - COLOUR_RESET = "\033[0m" - COLOUR_GREEN = "\033[32m" - COLOUR_YELLOW = "\033[33m" ) -type HistogramEntry struct { - Occurences int - OccurrencesInPosition []int -} - -var letters = map[string]bool{"a": true, "b": true, "c": true, "d": true, "e": true, "f": true, "g": true, "h": true, "i": true, "j": true, "k": true, "l": true, "m": true, "n": true, "o": true, "p": true, "q": true, "r": true, "s": true, "t": true, "u": true, "v": true, "w": true, "x": true, "y": true, "z": true} +var letters = []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"} var validWords = []string{} var dictionary = map[string]int{} -var histogram = map[string]HistogramEntry{} -var rankedWords PairList -var answersCorrect []string -var answersIncorrect [][]string -var answersIncorrectAll []string var board []string func main() { wordPtr := flag.String("word", "", "The game's answer") playPtr := flag.Bool("play", false, "Whether to play the game") + autoPtr := flag.Bool("auto", false, "Play the game automatically") flag.Parse() + auto := *autoPtr + // Read the valid words fmt.Println("Reading words...") err := readValidWords() @@ -49,22 +39,38 @@ func main() { reader := bufio.NewReader(os.Stdin) + var strat strategy.Strategy + if auto { + strat = strategy.NewCharFrequencyStrategy(NUM_LETTERS, letters, &validWords) + } + if *playPtr { // If no answer given in the word flag, choose answer := *wordPtr + if answer == "" { rand.Seed(time.Now().Unix()) answer = validWords[rand.Intn(len(validWords))] } - g := CreateGame(answer, NUM_ATTEMPTS) + g := game.CreateGame(answer, NUM_ATTEMPTS) for { - fmt.Print("Enter your guess: ") - input, _ := reader.ReadString('\n') - word := strings.TrimSpace(input) + // Play based on whether a strategy is provided + var word string + if strat != nil { + word = strat.GetNextMove() + } else { + fmt.Print("Enter your guess: ") + input, _ := reader.ReadString('\n') + word = strings.TrimSpace(input) + } success, _ := g.Play(word) + if strat != nil { + strat.SetMoveOutcome(g.GetLastPlay()) + } + fmt.Println(g.OutputForConsole()) if success { @@ -78,80 +84,70 @@ func main() { } } - answersCorrect = make([]string, NUM_LETTERS) - answersIncorrect = make([][]string, NUM_LETTERS) - for { - // Generate histogram - fmt.Println("Building histogram...") - buildHistogram() - - // Rank words based on frequency - fmt.Println("Ranking words...") - rankWords() - - // Print the top 10 answers - fmt.Println("Top answers:") - ln := 10 - if len(rankedWords) < ln { - ln = len(rankedWords) - } - for i := 0; i < ln; i++ { - rank := rankedWords[i] - if rank.Key != "" { - fmt.Printf(" %d: %s (%d)\n", i+1, rank.Key, rank.Value) - } - } - - // Read the entered word from stdin - answersIncorrectAll = make([]string, 0) - // TODO handle errors such as wrong sized word, wrong pattern for response - fmt.Print("Enter number of entered word, or word itself: ") - word, _ := reader.ReadString('\n') - word = strings.TrimSpace(word) - if idx, err := strconv.Atoi(word); err == nil && idx <= len(rankedWords) { - word = rankedWords[idx-1].Key - } - wordParts := strings.Split(word, "") - - fmt.Print("Enter the result, where x is incorrect, o is wrong position, y is correct eg yxxox: ") - input, _ := reader.ReadString('\n') - parts := strings.Split(strings.TrimSpace((input)), "") - boardRow := "" - numCorrect := 0 - rejected := make([]bool, NUM_LETTERS) - for i, chr := range parts { - if chr == "x" { - // If this letter has shown up before but not rejected, don't eliminate - shouldEliminate := true - for j := 0; j < i; j++ { - if chr == parts[j] && !rejected[j] { - shouldEliminate = false - } - } - if shouldEliminate { - letters[wordParts[i]] = false - } - rejected[i] = true - } else if chr == "y" { - boardRow += COLOUR_GREEN - answersCorrect[i] = wordParts[i] - numCorrect++ - } else if chr == "o" { - boardRow += COLOUR_YELLOW - answersIncorrect[i] = append(answersIncorrect[i], wordParts[i]) - answersIncorrectAll = append(answersIncorrectAll, wordParts[i]) - } - boardRow += wordParts[i] + COLOUR_RESET - } - board = append(board, boardRow) - - outputBoard() - if numCorrect == NUM_LETTERS { - fmt.Printf("Hooray! (%d/%d)\n", len(board), NUM_ATTEMPTS) - return - } + // // Print the top 10 answers + // fmt.Println("Top answers:") + // ln := 10 + // if len(rankedWords) < ln { + // ln = len(rankedWords) + // } + // for i := 0; i < ln; i++ { + // rank := rankedWords[i] + // if rank.Key != "" { + // fmt.Printf(" %d: %s (%d)\n", i+1, rank.Key, rank.Value) + // } + // } + // + // // Read the entered word from stdin + // answersIncorrectAll = make([]string, 0) + // // TODO handle errors such as wrong sized word, wrong pattern for response + // fmt.Print("Enter number of entered word, or word itself: ") + // word, _ := reader.ReadString('\n') + // word = strings.TrimSpace(word) + // if idx, err := strconv.Atoi(word); err == nil && idx <= len(rankedWords) { + // word = rankedWords[idx-1].Key + // } + // wordParts := strings.Split(word, "") + // + // fmt.Print("Enter the result, where x is incorrect, o is wrong position, y is correct eg yxxox: ") + // input, _ := reader.ReadString('\n') + // parts := strings.Split(strings.TrimSpace((input)), "") + // boardRow := "" + // numCorrect := 0 + // rejected := make([]bool, NUM_LETTERS) + // for i, chr := range parts { + // if chr == "x" { + // // If this letter has shown up before but not rejected, don't eliminate + // shouldEliminate := true + // for j := 0; j < i; j++ { + // if chr == parts[j] && !rejected[j] { + // shouldEliminate = false + // } + // } + // if shouldEliminate { + // letters[wordParts[i]] = false + // } + // rejected[i] = true + // } else if chr == "y" { + // boardRow += COLOUR_GREEN + // answersCorrect[i] = wordParts[i] + // numCorrect++ + // } else if chr == "o" { + // boardRow += COLOUR_YELLOW + // answersIncorrect[i] = append(answersIncorrect[i], wordParts[i]) + // answersIncorrectAll = append(answersIncorrectAll, wordParts[i]) + // } + // boardRow += wordParts[i] + COLOUR_RESET + // } + // board = append(board, boardRow) + // + // outputBoard() + // + // if numCorrect == NUM_LETTERS { + // fmt.Printf("Hooray! (%d/%d)\n", len(board), NUM_ATTEMPTS) + // return + // } } } @@ -161,21 +157,6 @@ func check(e error) { } } -// difference returns the elements in `a` that aren't in `b`. -func difference(a, b []string) []string { - mb := make(map[string]struct{}, len(b)) - for _, x := range b { - mb[x] = struct{}{} - } - var diff []string - for _, x := range a { - if _, found := mb[x]; !found { - diff = append(diff, x) - } - } - return diff -} - func readValidWords() error { file, err := os.Open("./solutions.txt") if err != nil { @@ -199,102 +180,6 @@ func readValidWords() error { return nil } -func buildHistogram() { - histogram = make(map[string]HistogramEntry, len(letters)) - for l := range letters { - histogram[l] = HistogramEntry{0, make([]int, NUM_LETTERS)} - } - - // Loop through each word and check which unique letters are in the word - for _, word := range validWords { - chrs := strings.Split(word, "") - - // Loop through each char and update the histogram - checkedChars := map[string]bool{} - for i, chr := range chrs { - // Ignore removed letters - if !letters[chr] { - continue - } - // If we've not already processed this letter - if _, ok := checkedChars[chr]; !ok { - checkedChars[chr] = true - if entry, ok2 := histogram[chr]; ok2 { - entry.Occurences++ - entry.OccurrencesInPosition[i]++ - histogram[chr] = entry - } - } - } - } -} - -func rankWords() { - rankedWords = make(PairList, len(validWords)) - -word: - for _, word := range validWords { - chrs := strings.Split(word, "") - // First set the score based on the letters that exist - // TODO score based on letter position too - checkedChars := map[string]bool{} - score := 0 - - // Check if any of the incorrect answers don't appear in the word - if len(answersIncorrectAll) > 0 { - diff := difference(answersIncorrectAll, chrs) - if len(diff) > 0 { - rankedWords = append(rankedWords, Pair{word, 0}) - continue word - } - } - - for i, chr := range chrs { - // If this is an eliminated letter, score down - if !letters[chr] { - rankedWords = append(rankedWords, Pair{word, 0}) - continue word - } - - // If there is an answer in this position, we can disregard words that don't have that letter in that position - if answersCorrect[i] != "" && answersCorrect[i] != chr { - rankedWords = append(rankedWords, Pair{word, 0}) - continue word - } - - // Also check if there's an incorrect answer in this position - if len(answersIncorrect[i]) > 0 { - for _, ai := range answersIncorrect[i] { - if ai == chr { - rankedWords = append(rankedWords, Pair{word, 0}) - continue word - } - } - } - - if _, ok := checkedChars[chr]; !ok { - // Score based on occurences and occurences in the position - scoreToAdd := histogram[chr].Occurences + histogram[chr].OccurrencesInPosition[i] - // Increase score for incorrectly placed letters - for _, aiChr := range answersIncorrectAll { - if chr == aiChr { - scoreToAdd *= 2 - break - } - } - score += scoreToAdd - checkedChars[chr] = true - } - } - - // Add to the ranked list - rankedWords = append(rankedWords, Pair{word, score}) - } - - // Sort the - sort.Sort(sort.Reverse(rankedWords)) -} - func outputBoard() { fmt.Println("") fmt.Println(strings.Repeat("-", NUM_LETTERS+2)) diff --git a/strategy/charfrequency.go b/strategy/charfrequency.go new file mode 100644 index 0000000..641c049 --- /dev/null +++ b/strategy/charfrequency.go @@ -0,0 +1,182 @@ +package strategy + +import ( + "sort" + "strings" + + "github.com/archy-bold/wordle-go/game" +) + +// HistogramEntry represents an entry in the character frequency histogram +type HistogramEntry struct { + Occurences int + OccurrencesInPosition []int +} + +// CharFrequencyStrategy a strategy that plays based on the frequency of characters in the solutions list +type CharFrequencyStrategy struct { + wordLength int + letters map[string]bool + validWords *[]string + histogram map[string]HistogramEntry + rankedWords PairList + answersCorrect []string + answersIncorrect [][]string + answersIncorrectAll []string +} + +func (s *CharFrequencyStrategy) buildHistogram() { + s.histogram = make(map[string]HistogramEntry, len(s.letters)) + for l := range s.letters { + s.histogram[l] = HistogramEntry{0, make([]int, s.wordLength)} + } + + // Loop through each word and check which unique letters are in the word + for _, word := range *s.validWords { + chrs := strings.Split(word, "") + + // Loop through each char and update the histogram + checkedChars := map[string]bool{} + for i, chr := range chrs { + // Ignore removed letters + if !s.letters[chr] { + continue + } + // If we've not already processed this letter + if _, ok := checkedChars[chr]; !ok { + checkedChars[chr] = true + if entry, ok2 := s.histogram[chr]; ok2 { + entry.Occurences++ + entry.OccurrencesInPosition[i]++ + s.histogram[chr] = entry + } + } + } + } +} + +func (s *CharFrequencyStrategy) rankWords() { + s.rankedWords = make(PairList, len(*s.validWords)) + +word: + for _, word := range *s.validWords { + chrs := strings.Split(word, "") + // First set the score based on the letters that exist + // TODO score based on letter position too + checkedChars := map[string]bool{} + score := 0 + + // Check if any of the incorrect answers don't appear in the word + if len(s.answersIncorrectAll) > 0 { + diff := difference(s.answersIncorrectAll, chrs) + if len(diff) > 0 { + s.rankedWords = append(s.rankedWords, Pair{word, 0}) + continue word + } + } + + for i, chr := range chrs { + // If this is an eliminated letter, score down + if !s.letters[chr] { + s.rankedWords = append(s.rankedWords, Pair{word, 0}) + continue word + } + + // If there is an answer in this position, we can disregard words that don't have that letter in that position + if s.answersCorrect[i] != "" && s.answersCorrect[i] != chr { + s.rankedWords = append(s.rankedWords, Pair{word, 0}) + continue word + } + + // Also check if there's an incorrect answer in this position + if len(s.answersIncorrect[i]) > 0 { + for _, ai := range s.answersIncorrect[i] { + if ai == chr { + s.rankedWords = append(s.rankedWords, Pair{word, 0}) + continue word + } + } + } + + if _, ok := checkedChars[chr]; !ok { + // Score based on occurences and occurences in the position + scoreToAdd := s.histogram[chr].Occurences + s.histogram[chr].OccurrencesInPosition[i] + // Increase score for incorrectly placed letters + for _, aiChr := range s.answersIncorrectAll { + if chr == aiChr { + scoreToAdd *= 2 + break + } + } + score += scoreToAdd + checkedChars[chr] = true + } + } + + // Add to the ranked list + s.rankedWords = append(s.rankedWords, Pair{word, score}) + } + + // Sort the + sort.Sort(sort.Reverse(s.rankedWords)) +} + +// GetNextMove simply returns the top-ranked word +func (s *CharFrequencyStrategy) GetNextMove() string { + return s.rankedWords[0].Key +} + +func (s *CharFrequencyStrategy) SetMoveOutcome(row []game.GridCell) { + // Update the internal state for the row + numCorrect := 0 + rejected := make([]bool, s.wordLength) + for i, cell := range row { + switch cell.Status { + case game.STATUS_WRONG: + // If this letter has shown up before but not rejected, don't eliminate + shouldEliminate := true + for j := 0; j < i; j++ { + if cell.Letter == row[j].Letter && !rejected[j] { + shouldEliminate = false + } + } + if shouldEliminate { + s.letters[cell.Letter] = false + } + rejected[i] = true + case game.STATUS_INCORRECT: + s.answersIncorrect[i] = append(s.answersIncorrect[i], cell.Letter) + s.answersIncorrectAll = append(s.answersIncorrectAll, cell.Letter) + case game.STATUS_CORRECT: + s.answersCorrect[i] = cell.Letter + numCorrect++ + } + } + + // Rebuild the histogram and ranking + s.buildHistogram() + s.rankWords() +} + +// NewCharFrequencyStrategy create a char frequency-based strategy given the word list and letters list +func NewCharFrequencyStrategy(wordLength int, letters []string, validWords *[]string) Strategy { + lettersMap := map[string]bool{} + for _, l := range letters { + lettersMap[l] = true + } + + s := &CharFrequencyStrategy{ + wordLength: wordLength, + letters: lettersMap, + validWords: validWords, + answersCorrect: make([]string, wordLength), + answersIncorrect: make([][]string, wordLength), + } + + // Initialise the histogram + s.buildHistogram() + // Rank the words + s.rankWords() + + return s +} diff --git a/strategy/helpers.go b/strategy/helpers.go new file mode 100644 index 0000000..a66b7ee --- /dev/null +++ b/strategy/helpers.go @@ -0,0 +1,16 @@ +package strategy + +// difference returns the elements in `a` that aren't in `b`. +func difference(a, b []string) []string { + mb := make(map[string]struct{}, len(b)) + for _, x := range b { + mb[x] = struct{}{} + } + var diff []string + for _, x := range a { + if _, found := mb[x]; !found { + diff = append(diff, x) + } + } + return diff +} diff --git a/pair.go b/strategy/pair.go similarity index 93% rename from pair.go rename to strategy/pair.go index 794eb1b..6bf673a 100644 --- a/pair.go +++ b/strategy/pair.go @@ -1,4 +1,4 @@ -package main +package strategy type Pair struct { Key string diff --git a/strategy/strategy.go b/strategy/strategy.go new file mode 100644 index 0000000..7b842e8 --- /dev/null +++ b/strategy/strategy.go @@ -0,0 +1,11 @@ +package strategy + +import "github.com/archy-bold/wordle-go/game" + +// Strategy represents an object that determine optimal next moves for the strategy +type Strategy interface { + // GetNextMove will get the next move for the given strategy + GetNextMove() string + // SetMoveOutcome is to tell the strategy the outcome of the last move + SetMoveOutcome(row []game.GridCell) +} diff --git a/wordle b/wordle index 0e76673..5e1a4d6 100755 Binary files a/wordle and b/wordle differ