219 lines
3.9 KiB
Go
219 lines
3.9 KiB
Go
package solver
|
|
|
|
import (
|
|
"bufio"
|
|
"embed"
|
|
"fmt"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
"unicode"
|
|
)
|
|
|
|
//go:embed starter_words.txt
|
|
var starterWords embed.FS
|
|
|
|
var letterScores = map[rune]int{
|
|
'a': 1,
|
|
'b': 4,
|
|
'c': 4,
|
|
'd': 3,
|
|
'e': 1,
|
|
'f': 5,
|
|
'g': 3,
|
|
'h': 3,
|
|
'i': 1,
|
|
'j': 7,
|
|
'k': 6,
|
|
'l': 2,
|
|
'm': 4,
|
|
'n': 2,
|
|
'o': 1,
|
|
'p': 4,
|
|
'q': 8,
|
|
'r': 2,
|
|
's': 1,
|
|
't': 2,
|
|
'u': 2,
|
|
'v': 5,
|
|
'w': 5,
|
|
'x': 7,
|
|
'y': 4,
|
|
'z': 8,
|
|
}
|
|
|
|
type Result struct {
|
|
Word string
|
|
Score int
|
|
Length int
|
|
}
|
|
|
|
type Engine struct {
|
|
dictionary []string
|
|
}
|
|
|
|
func NewEngine(customDictionaryPath string) (*Engine, error) {
|
|
words, err := loadWordSet(customDictionaryPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &Engine{dictionary: words}, nil
|
|
}
|
|
|
|
func (engine *Engine) FindMatches(letters string, minLength int, limit int) ([]Result, error) {
|
|
cleanedLetters := normalizeWord(letters)
|
|
if len(cleanedLetters) < 2 {
|
|
return nil, fmt.Errorf("enter at least 2 letters")
|
|
}
|
|
if minLength < 2 {
|
|
return nil, fmt.Errorf("minimum length must be at least 2")
|
|
}
|
|
if limit < 1 {
|
|
limit = 1
|
|
}
|
|
|
|
available := letterCounts(cleanedLetters)
|
|
results := make([]Result, 0, limit)
|
|
|
|
for _, word := range engine.dictionary {
|
|
if len(word) < minLength || len(word) > len(cleanedLetters) {
|
|
continue
|
|
}
|
|
if !canBuild(word, available) {
|
|
continue
|
|
}
|
|
|
|
results = append(results, Result{
|
|
Word: word,
|
|
Score: wordScore(word),
|
|
Length: len(word),
|
|
})
|
|
}
|
|
|
|
slices.SortFunc(results, func(left Result, right Result) int {
|
|
if left.Score != right.Score {
|
|
return right.Score - left.Score
|
|
}
|
|
if left.Length != right.Length {
|
|
return right.Length - left.Length
|
|
}
|
|
return strings.Compare(left.Word, right.Word)
|
|
})
|
|
|
|
if len(results) > limit {
|
|
results = results[:limit]
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
func loadWordSet(customDictionaryPath string) ([]string, error) {
|
|
entries := map[string]struct{}{}
|
|
|
|
if err := loadWordsFromEmbedded(entries); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := loadWordsFromFile(entries, customDictionaryPath); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
words := make([]string, 0, len(entries))
|
|
for word := range entries {
|
|
words = append(words, word)
|
|
}
|
|
|
|
slices.Sort(words)
|
|
return words, nil
|
|
}
|
|
|
|
func loadWordsFromEmbedded(entries map[string]struct{}) error {
|
|
file, err := starterWords.Open("starter_words.txt")
|
|
if err != nil {
|
|
return fmt.Errorf("open starter dictionary: %w", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
scanner := bufio.NewScanner(file)
|
|
for scanner.Scan() {
|
|
word := normalizeWord(scanner.Text())
|
|
if len(word) >= 2 {
|
|
entries[word] = struct{}{}
|
|
}
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
return fmt.Errorf("read starter dictionary: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func loadWordsFromFile(entries map[string]struct{}, path string) error {
|
|
if path == "" {
|
|
return nil
|
|
}
|
|
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("open custom dictionary: %w", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
scanner := bufio.NewScanner(file)
|
|
for scanner.Scan() {
|
|
word := normalizeWord(scanner.Text())
|
|
if len(word) >= 2 {
|
|
entries[word] = struct{}{}
|
|
}
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
return fmt.Errorf("read custom dictionary: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func normalizeWord(value string) string {
|
|
var builder strings.Builder
|
|
for _, char := range strings.ToLower(value) {
|
|
if unicode.IsLetter(char) {
|
|
builder.WriteRune(char)
|
|
}
|
|
}
|
|
return builder.String()
|
|
}
|
|
|
|
func letterCounts(value string) map[rune]int {
|
|
counts := make(map[rune]int, len(value))
|
|
for _, char := range value {
|
|
counts[char]++
|
|
}
|
|
return counts
|
|
}
|
|
|
|
func canBuild(word string, available map[rune]int) bool {
|
|
used := map[rune]int{}
|
|
for _, char := range word {
|
|
used[char]++
|
|
if used[char] > available[char] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func wordScore(word string) int {
|
|
base := 0
|
|
for _, char := range word {
|
|
base += letterScores[char]
|
|
}
|
|
|
|
lengthBonus := len(word) * len(word)
|
|
return base + lengthBonus
|
|
}
|