Files
2026-04-10 21:42:56 +08:00

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
}