Files
tiennm99 e29c06d6f0 refactor: move test-only helpers out of production code and drop stale comments
bot.BoardFor and hub.roomCount were reachable from tests only and now live beside them; session.serve drops an always-true nil check (readLoop never returns nil); invisible characters in test literals become escapes; comments no longer cite plan phases; `.dockerignore` keeps the dump out of the build context. No behaviour change; vet, deadcode, staticcheck, go test -race, svelte-check and vitest green.
2026-09-08 23:10:39 +07:00

112 lines
3.2 KiB
Go

// Package bot chooses moves for the computer opponent.
//
// The bot is a player like any other: it picks a word, and that word goes
// through the same Engine.Submit validation a human's would. Nothing here can
// bend the rules, because nothing here applies them.
//
// Choose is pure and fast. The pause that makes the bot feel human is a
// duration this package reports and the caller schedules — sleeping inside
// Choose would make every test wait in real time.
package bot
import (
"errors"
"iter"
"math/rand/v2"
"time"
)
// Difficulty selects a strategy.
type Difficulty int
const (
Easy Difficulty = iota + 1
Medium
Hard
)
func (d Difficulty) String() string {
switch d {
case Easy:
return "easy"
case Medium:
return "medium"
case Hard:
return "hard"
}
return "unknown"
}
// ErrNoMove means the bot has nothing legal to play, so it has lost.
var ErrNoMove = errors.New("bot: no legal move")
// Board is what a strategy may look at. It is deliberately narrower than
// *game.Engine: a strategy can read the position but cannot play a move, so it
// cannot sidestep validation.
//
// Only what the strategies actually use. A dictionary's static out-degree is
// deliberately absent — what matters is how many continuations remain unplayed,
// which the strategies derive from WordsStartingWith and Used.
type Board interface {
LegalMoves() []string
Used(word string) bool
WordsStartingWith(syllable string) iter.Seq[string]
LastSyllable(word string) (string, bool)
}
// Strategy picks a move for the position.
type Strategy interface {
Choose(b Board) (string, error)
// ThinkingDelay is how long the caller should wait before playing the
// chosen move, so the bot does not answer instantly.
ThinkingDelay() time.Duration
Difficulty() Difficulty
}
// New returns the strategy for a difficulty, seeded by rng.
//
// The rng is injected rather than global so a test can replay an identical
// game, and so two concurrent rooms never share a source.
func New(d Difficulty, rng *rand.Rand) (Strategy, error) {
if rng == nil {
// Every strategy dereferences this; failing here beats a nil panic on
// the first move.
return nil, errors.New("bot: nil rng")
}
switch d {
case Easy:
return &easy{rng: rng}, nil
case Medium:
return &medium{rng: rng}, nil
case Hard:
return &hard{rng: rng}, nil
}
return nil, errors.New("bot: unknown difficulty")
}
// thinkingDelay returns a human-looking pause. Faster as difficulty rises,
// which reads as the bot being more sure of itself.
func thinkingDelay(rng *rand.Rand, min, max time.Duration) time.Duration {
if max <= min {
return min
}
return min + time.Duration(rng.Int64N(int64(max-min)))
}
// remainingOutDegree counts the continuations still available from a syllable,
// ignoring `excluding` — the move being considered, which will itself be spent
// once played. Pass "" to exclude nothing.
//
// This is the number that matters, not the dictionary's static out-degree: a
// syllable with fifty words is still a dead end if all fifty are used.
func remainingOutDegree(b Board, syllable, excluding string) int {
n := 0
for word := range b.WordsStartingWith(syllable) {
if word != excluding && !b.Used(word) {
n++
}
}
return n
}