mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-19 06:15:25 +00:00
feat(game): add noi tu rules engine and bot opponent
The engine is transport-free: no sockets, no protobuf, and no clock of its own. Callers pass the current time in and read the deadline back, so every rule is testable without a timer. One goroutine owns a game. Validation resolves the word before checking the chain link. Roughly a third of dictionary aliases move the first syllable, so "sy hai" resolves to "si hai"; checking the link against what the player typed would reject legal moves. The used-word set is keyed on the canonical form, which also stops the same word being played twice under two spellings. An opening whose last syllable starts nothing is refused. It would hand the first player a game already lost, with no move and no reason, that resolves only when the turn timer expires and then reports a timeout. Three bot difficulties, separated by how far they look ahead rather than by how willing they are to win: random, one-ply greedy, and depth-limited negamax with alpha-beta. Strategies see a read-only view of the board, so a bot cannot bypass the same validation a human's move goes through. Simulation on the real corpus, alternating sides across 60 games per pairing: hard beats easy 98%, medium beats easy 87%, hard beats medium 65%. Decisions take at most 19ms against a 150ms budget. Three defects surfaced only under simulation. Withholding the winning move from the middle bot, as first designed, made it lose to the random bot 97% of the time. The search evaluated leaves with an inverted sign, so it hunted for positions where it was about to be trapped. And the rate limit on taking an instant win did nothing, because declining the shortcut let the search rediscover the same move. Games against the hard bot end after about three moves versus fifteen for two random bots: with 1,814 dead-end syllables an instant win is usually available. Tunable, and flagged for playtesting.
This commit is contained in:
1 parent
e8b76cc643
commit
ca01145d06
12 files changed
+2238
-18
No files matched your search
@@ -0,0 +1,141 @@
|
||||
// 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"
|
||||
|
||||
"github.com/tiennm99dev/noitu/server/internal/game"
|
||||
)
|
||||
|
||||
// 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)))
|
||||
}
|
||||
|
||||
// engineBoard adapts an Engine to Board.
|
||||
type engineBoard struct {
|
||||
e *game.Engine
|
||||
dict game.Dictionary
|
||||
}
|
||||
|
||||
// BoardFor wraps an engine so strategies can inspect it.
|
||||
//
|
||||
// The dictionary comes from the engine rather than the caller: handing in a
|
||||
// different one would let the bot search a graph the engine does not validate
|
||||
// against, and that failure surfaces as the engine rejecting its own bot's
|
||||
// move at runtime.
|
||||
func BoardFor(e *game.Engine) Board {
|
||||
return &engineBoard{e: e, dict: e.Dict()}
|
||||
}
|
||||
|
||||
func (b *engineBoard) LegalMoves() []string { return b.e.LegalMoves() }
|
||||
|
||||
func (b *engineBoard) Used(word string) bool { return b.e.Used(word) }
|
||||
|
||||
func (b *engineBoard) WordsStartingWith(syllable string) iter.Seq[string] {
|
||||
return b.dict.WordsStartingWith(syllable)
|
||||
}
|
||||
|
||||
func (b *engineBoard) LastSyllable(word string) (string, bool) {
|
||||
return b.dict.LastSyllable(word)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"iter"
|
||||
"math/rand/v2"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeBoard is a hand-built position. Strategies are judged on boards small
|
||||
// enough to work out the right move by hand.
|
||||
type fakeBoard struct {
|
||||
words map[string][2]string // word -> {first, last}
|
||||
used map[string]bool
|
||||
current string
|
||||
}
|
||||
|
||||
func board(current string, words ...string) *fakeBoard {
|
||||
b := &fakeBoard{words: map[string][2]string{}, used: map[string]bool{}, current: current}
|
||||
for _, w := range words {
|
||||
p := strings.Fields(w)
|
||||
b.words[w] = [2]string{p[0], p[len(p)-1]}
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *fakeBoard) play(words ...string) *fakeBoard {
|
||||
for _, w := range words {
|
||||
b.used[w] = true
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *fakeBoard) LegalMoves() []string {
|
||||
var out []string
|
||||
for w, e := range b.words {
|
||||
if e[0] == b.current && !b.used[w] {
|
||||
out = append(out, w)
|
||||
}
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (b *fakeBoard) Used(word string) bool { return b.used[word] }
|
||||
|
||||
func (b *fakeBoard) WordsStartingWith(syllable string) iter.Seq[string] {
|
||||
var out []string
|
||||
for w, e := range b.words {
|
||||
if e[0] == syllable {
|
||||
out = append(out, w)
|
||||
}
|
||||
}
|
||||
slices.Sort(out)
|
||||
return slices.Values(out)
|
||||
}
|
||||
|
||||
func (b *fakeBoard) LastSyllable(word string) (string, bool) {
|
||||
e, ok := b.words[word]
|
||||
return e[1], ok
|
||||
}
|
||||
|
||||
func seeded() *rand.Rand { return rand.New(rand.NewPCG(1, 2)) }
|
||||
|
||||
func mustStrategy(t *testing.T, d Difficulty) Strategy {
|
||||
t.Helper()
|
||||
s, err := New(d, seeded())
|
||||
if err != nil {
|
||||
t.Fatalf("New(%s): %v", d, err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestNewRejectsUnknownDifficulty(t *testing.T) {
|
||||
if _, err := New(Difficulty(99), seeded()); err == nil {
|
||||
t.Error("New accepted an unknown difficulty")
|
||||
}
|
||||
}
|
||||
|
||||
// Every strategy dereferences the rng, so a nil one must fail at construction
|
||||
// rather than panicking on the first move.
|
||||
func TestNewRejectsNilRNG(t *testing.T) {
|
||||
for _, d := range []Difficulty{Easy, Medium, Hard} {
|
||||
if _, err := New(d, nil); err == nil {
|
||||
t.Errorf("New(%s, nil) succeeded, want error", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every strategy must report no move rather than inventing one.
|
||||
func TestAllStrategiesReportNoMove(t *testing.T) {
|
||||
for _, d := range []Difficulty{Easy, Medium, Hard} {
|
||||
s := mustStrategy(t, d)
|
||||
b := board("lệ", "ngôn ngữ") // nothing starts with "lệ"
|
||||
if _, err := s.Choose(b); err != ErrNoMove {
|
||||
t.Errorf("%s.Choose on a dead end = %v, want ErrNoMove", d, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Whatever a strategy returns must actually be playable.
|
||||
func TestAllStrategiesChooseLegalMoves(t *testing.T) {
|
||||
for _, d := range []Difficulty{Easy, Medium, Hard} {
|
||||
s := mustStrategy(t, d)
|
||||
for i := 0; i < 50; i++ {
|
||||
b := board("ngữ",
|
||||
"ngữ pháp", "ngữ điệu", "ngữ nghĩa",
|
||||
"pháp luật", "điệu bộ", "nghĩa vụ",
|
||||
).play("ngữ nghĩa")
|
||||
|
||||
move, err := s.Choose(b)
|
||||
if err != nil {
|
||||
t.Fatalf("%s.Choose: %v", d, err)
|
||||
}
|
||||
if !slices.Contains(b.LegalMoves(), move) {
|
||||
t.Fatalf("%s chose %q, which is not legal", d, move)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hard must take a move that leaves the opponent with nothing. Rate-limited to
|
||||
// hardKillRate, so this checks it happens most of the time rather than always.
|
||||
func TestHardTakesTheKill(t *testing.T) {
|
||||
kills := 0
|
||||
const runs = 200
|
||||
|
||||
for i := 0; i < runs; i++ {
|
||||
s, err := New(Hard, rand.New(rand.NewPCG(uint64(i), 7)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// "ngữ pháp" hands over "pháp", which starts nothing: an instant win.
|
||||
// "ngữ điệu" hands over "điệu", which still has a reply.
|
||||
b := board("ngữ", "ngữ pháp", "ngữ điệu", "điệu bộ", "bộ phận")
|
||||
move, err := s.Choose(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if move == "ngữ pháp" {
|
||||
kills++
|
||||
}
|
||||
}
|
||||
|
||||
rate := float64(kills) / runs
|
||||
// Band derived from the constant, so changing hardKillRate cannot make
|
||||
// this test lie. Seeds are fixed, so the result is deterministic.
|
||||
if rate < hardKillRate-0.15 || rate > hardKillRate+0.10 {
|
||||
t.Errorf("Hard took the kill %.0f%% of the time, want near %.0f%%", rate*100, hardKillRate*100)
|
||||
}
|
||||
}
|
||||
|
||||
// Medium always takes a win it can see. Withholding it made Medium lose to the
|
||||
// random bot 97% of the time; difficulty comes from lookahead, not from
|
||||
// declining to play well.
|
||||
func TestMediumTakesTheKill(t *testing.T) {
|
||||
for i := 0; i < 100; i++ {
|
||||
s, err := New(Medium, rand.New(rand.NewPCG(uint64(i), 11)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := board("ngữ", "ngữ pháp", "ngữ điệu", "điệu bộ", "bộ phận")
|
||||
move, err := s.Choose(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if move != "ngữ pháp" {
|
||||
t.Fatalf("Medium passed up the instant win, played %q on run %d", move, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// With only a kill available, Medium must still play it rather than fail.
|
||||
func TestMediumPlaysKillWhenItIsTheOnlyMove(t *testing.T) {
|
||||
s := mustStrategy(t, Medium)
|
||||
b := board("ngữ", "ngữ pháp") // "pháp" starts nothing
|
||||
|
||||
move, err := s.Choose(b)
|
||||
if err != nil {
|
||||
t.Fatalf("Choose: %v", err)
|
||||
}
|
||||
if move != "ngữ pháp" {
|
||||
t.Errorf("Choose = %q, want the only legal move", move)
|
||||
}
|
||||
}
|
||||
|
||||
// Medium should prefer the tighter reply when the difference is stark.
|
||||
func TestMediumPrefersTighterReply(t *testing.T) {
|
||||
tight := 0
|
||||
const runs = 100
|
||||
|
||||
for i := 0; i < runs; i++ {
|
||||
s, err := New(Medium, rand.New(rand.NewPCG(uint64(i), 3)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// "ngữ điệu" -> "điệu" has one reply; "ngữ nghĩa" -> "nghĩa" has four.
|
||||
b := board("ngữ",
|
||||
"ngữ điệu", "ngữ nghĩa",
|
||||
"điệu bộ",
|
||||
"nghĩa vụ", "nghĩa quân", "nghĩa trang", "nghĩa hiệp",
|
||||
)
|
||||
move, err := s.Choose(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if move == "ngữ điệu" {
|
||||
tight++
|
||||
}
|
||||
}
|
||||
|
||||
if tight < runs*8/10 {
|
||||
t.Errorf("Medium chose the tighter reply %d/%d times, want most of them", tight, runs)
|
||||
}
|
||||
}
|
||||
|
||||
// remainingOutDegree must count what is actually left, not the dictionary's
|
||||
// static figure: a syllable whose words are all spent is a dead end.
|
||||
func TestRemainingOutDegreeCountsOnlyUnusedWords(t *testing.T) {
|
||||
b := board("ngữ", "ngữ pháp", "pháp luật", "pháp lý")
|
||||
|
||||
if got := remainingOutDegree(b, "pháp", ""); got != 2 {
|
||||
t.Errorf("remainingOutDegree = %d, want 2", got)
|
||||
}
|
||||
|
||||
b.play("pháp luật")
|
||||
if got := remainingOutDegree(b, "pháp", ""); got != 1 {
|
||||
t.Errorf("after one word used, remainingOutDegree = %d, want 1", got)
|
||||
}
|
||||
|
||||
// The move being considered counts as spent too.
|
||||
if got := remainingOutDegree(b, "pháp", "pháp lý"); got != 0 {
|
||||
t.Errorf("excluding the move itself, remainingOutDegree = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThinkingDelayInRange(t *testing.T) {
|
||||
for _, d := range []Difficulty{Easy, Medium, Hard} {
|
||||
s := mustStrategy(t, d)
|
||||
for i := 0; i < 50; i++ {
|
||||
delay := s.ThinkingDelay()
|
||||
if delay < 300*time.Millisecond || delay > 2*time.Second {
|
||||
t.Errorf("%s.ThinkingDelay = %v, outside a human-looking range", d, delay)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDifficultyString(t *testing.T) {
|
||||
for _, d := range []Difficulty{Easy, Medium, Hard} {
|
||||
if s := d.String(); s == "" || s == "unknown" {
|
||||
t.Errorf("Difficulty(%d).String() = %q", d, s)
|
||||
}
|
||||
}
|
||||
if got := Difficulty(99).String(); got != "unknown" {
|
||||
t.Errorf("unknown difficulty string = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A seeded strategy must replay identically, or the simulation below proves
|
||||
// nothing and a reported bug cannot be reproduced.
|
||||
func TestChooseIsDeterministicUnderSeed(t *testing.T) {
|
||||
pick := func() string {
|
||||
s, err := New(Hard, rand.New(rand.NewPCG(42, 42)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := board("ngữ", "ngữ pháp", "ngữ điệu", "ngữ nghĩa",
|
||||
"pháp luật", "điệu bộ", "nghĩa vụ", "luật lệ")
|
||||
move, err := s.Choose(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return move
|
||||
}
|
||||
|
||||
if a, b := pick(), pick(); a != b {
|
||||
t.Errorf("same seed produced %q then %q", a, b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99dev/noitu/server/internal/dictionary"
|
||||
"github.com/tiennm99dev/noitu/server/internal/game"
|
||||
)
|
||||
|
||||
// realDictPath is the derived dictionary. It is a build artifact, not in git,
|
||||
// so every test here skips when it is absent — CI runs without it.
|
||||
const realDictPath = "../../../data/noitu.db"
|
||||
|
||||
func realDict(tb testing.TB) *dictionary.Store {
|
||||
tb.Helper()
|
||||
|
||||
if _, err := os.Stat(realDictPath); err != nil {
|
||||
tb.Skipf("real dictionary not built (run 'make fetch-dict && make dict'): %v", err)
|
||||
}
|
||||
store, err := dictionary.Open(realDictPath)
|
||||
if err != nil {
|
||||
tb.Fatalf("open real dictionary: %v", err)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
// playRealGame runs one bot-vs-bot game on the real corpus and reports the
|
||||
// winner plus how many moves it took.
|
||||
func playRealGame(tb testing.TB, dict game.Dictionary, first, second Strategy, seed uint64) (game.PlayerID, int) {
|
||||
tb.Helper()
|
||||
|
||||
const p1, p2 = game.PlayerID("first"), game.PlayerID("second")
|
||||
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
store := dict.(*dictionary.Store)
|
||||
var e *game.Engine
|
||||
// RandomOpeningWord can still return a word the engine refuses, so retry.
|
||||
for attempt := 0; attempt < 20 && e == nil; attempt++ {
|
||||
opening, err := store.RandomOpeningWord(5)
|
||||
if err != nil {
|
||||
tb.Fatalf("RandomOpeningWord: %v", err)
|
||||
}
|
||||
e, _ = game.New(dict, []game.PlayerID{p1, p2}, opening, time.Minute, now)
|
||||
}
|
||||
if e == nil {
|
||||
tb.Fatal("could not find a playable opening word")
|
||||
}
|
||||
|
||||
board := BoardFor(e)
|
||||
strategies := map[game.PlayerID]Strategy{p1: first, p2: second}
|
||||
|
||||
moves := 0
|
||||
for turn := 0; turn < 2000 && !e.Over(); turn++ {
|
||||
p := e.Turn()
|
||||
move, err := strategies[p].Choose(board)
|
||||
if err == ErrNoMove {
|
||||
tb.Fatalf("bot had no move at %q but the engine had not ended the game", e.Current())
|
||||
}
|
||||
if err != nil {
|
||||
tb.Fatalf("Choose: %v", err)
|
||||
}
|
||||
|
||||
now = now.Add(time.Second)
|
||||
if _, reason := e.Submit(p, move, now); reason != game.ReasonNone {
|
||||
tb.Fatalf("bot %s played %q which the engine rejected: %s", p, move, reason)
|
||||
}
|
||||
moves++
|
||||
}
|
||||
|
||||
if !e.Over() {
|
||||
tb.Fatal("game did not finish within the turn cap")
|
||||
}
|
||||
return e.Winner(), moves
|
||||
}
|
||||
|
||||
// The difficulty ladder that actually matters. The synthetic ladder measures
|
||||
// one hand-made graph, and how much lookahead is worth turns out to be a
|
||||
// property of the graph; this measures the dictionary players will face.
|
||||
func TestDifficultyLadderRealCorpus(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("real-corpus simulation skipped in short mode")
|
||||
}
|
||||
dict := realDict(t)
|
||||
|
||||
rate := func(challenger, defender Difficulty, n int) (float64, float64) {
|
||||
const p1 = game.PlayerID("first")
|
||||
wins, totalMoves := 0, 0
|
||||
for i := 0; i < n; i++ {
|
||||
c, err := New(challenger, rand.New(rand.NewPCG(uint64(i), 1)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d, err := New(defender, rand.New(rand.NewPCG(uint64(i), 2)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Alternate sides: moving first is an advantage in its own right,
|
||||
// and fixed seating reports that advantage as skill.
|
||||
challengerFirst := i%2 == 0
|
||||
var winner game.PlayerID
|
||||
var moves int
|
||||
if challengerFirst {
|
||||
winner, moves = playRealGame(t, dict, c, d, uint64(i))
|
||||
} else {
|
||||
winner, moves = playRealGame(t, dict, d, c, uint64(i))
|
||||
}
|
||||
if (winner == p1) == challengerFirst {
|
||||
wins++
|
||||
}
|
||||
totalMoves += moves
|
||||
}
|
||||
return float64(wins) / float64(n), float64(totalMoves) / float64(n)
|
||||
}
|
||||
|
||||
const games = 60
|
||||
|
||||
hardVsEasy, hardEasyLen := rate(Hard, Easy, games)
|
||||
mediumVsEasy, _ := rate(Medium, Easy, games)
|
||||
hardVsMedium, hardMediumLen := rate(Hard, Medium, games)
|
||||
_, easyLen := rate(Easy, Easy, games)
|
||||
|
||||
t.Logf("real corpus, %d games each: hard-vs-easy %.0f%% (%.1f moves), medium-vs-easy %.0f%%, hard-vs-medium %.0f%% (%.1f moves), easy-vs-easy %.1f moves",
|
||||
games, hardVsEasy*100, hardEasyLen, mediumVsEasy*100, hardVsMedium*100, hardMediumLen, easyLen)
|
||||
|
||||
if hardVsEasy <= 0.70 {
|
||||
t.Errorf("Hard beat Easy %.0f%% of the time, want > 70%%", hardVsEasy*100)
|
||||
}
|
||||
if mediumVsEasy <= 0.50 {
|
||||
t.Errorf("Medium beat Easy %.0f%% of the time, want > 50%%", mediumVsEasy*100)
|
||||
}
|
||||
if hardVsMedium <= 0.50 {
|
||||
t.Errorf("Hard beat Medium %.0f%% of the time, want > 50%%", hardVsMedium*100)
|
||||
}
|
||||
}
|
||||
|
||||
// The success criterion is a decision within 150ms on the real dictionary, so
|
||||
// measure it there rather than on a synthetic graph.
|
||||
func TestHardChooseLatencyRealCorpus(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("latency check skipped in short mode")
|
||||
}
|
||||
dict := realDict(t)
|
||||
|
||||
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
var durations []time.Duration
|
||||
|
||||
for g := 0; g < 40; g++ {
|
||||
opening, err := dict.RandomOpeningWord(20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e, err := game.New(dict, []game.PlayerID{"p1", "p2"}, opening, time.Minute, now)
|
||||
if err != nil {
|
||||
continue // dead-end opening; try another
|
||||
}
|
||||
board := BoardFor(e)
|
||||
hard, _ := New(Hard, rand.New(rand.NewPCG(uint64(g), 1)))
|
||||
easy, _ := New(Easy, rand.New(rand.NewPCG(uint64(g), 2)))
|
||||
|
||||
for turn := 0; turn < 40 && !e.Over(); turn++ {
|
||||
s, timed := hard, true
|
||||
if e.Turn() == "p2" {
|
||||
s, timed = easy, false
|
||||
}
|
||||
start := time.Now()
|
||||
move, err := s.Choose(board)
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if timed {
|
||||
durations = append(durations, elapsed)
|
||||
}
|
||||
now = now.Add(time.Second)
|
||||
if _, r := e.Submit(e.Turn(), move, now); r != game.ReasonNone {
|
||||
t.Fatalf("engine rejected bot move %q: %s", move, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(durations) == 0 {
|
||||
t.Fatal("no Hard decisions were measured")
|
||||
}
|
||||
sort.Slice(durations, func(i, j int) bool { return durations[i] < durations[j] })
|
||||
worst := durations[len(durations)-1]
|
||||
|
||||
t.Logf("Hard decisions on the real corpus: n=%d p50=%v p95=%v max=%v",
|
||||
len(durations), durations[len(durations)/2],
|
||||
durations[int(float64(len(durations))*0.95)], worst)
|
||||
|
||||
if worst > 150*time.Millisecond {
|
||||
t.Errorf("slowest Hard decision %v, want <= 150ms", worst)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkHardChooseRealCorpus is the benchmark the success criteria name.
|
||||
// The synthetic one measures a graph a tenth the size.
|
||||
func BenchmarkHardChooseRealCorpus(b *testing.B) {
|
||||
dict := realDict(b)
|
||||
|
||||
s, err := New(Hard, rand.New(rand.NewPCG(1, 1)))
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
var e *game.Engine
|
||||
for attempt := 0; attempt < 20 && e == nil; attempt++ {
|
||||
opening, err := dict.RandomOpeningWord(50)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
e, _ = game.New(dict, []game.PlayerID{"p1", "p2"}, opening, time.Minute, now)
|
||||
}
|
||||
if e == nil {
|
||||
b.Fatal("could not find a playable opening word")
|
||||
}
|
||||
board := BoardFor(e)
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := s.Choose(board); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"iter"
|
||||
"math/rand/v2"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99dev/noitu/server/internal/game"
|
||||
)
|
||||
|
||||
// simDict is a word graph large enough that skill matters: syllables differ
|
||||
// widely in how many continuations they offer, so choosing well is possible
|
||||
// and choosing badly is punished.
|
||||
type simDict struct {
|
||||
words map[string][2]string
|
||||
}
|
||||
|
||||
// newSimDict builds a seeded pseudo-random word graph.
|
||||
//
|
||||
// Size and irregularity both matter. A small, regular graph makes every game a
|
||||
// race decided by who moves first, and lookahead cannot show an advantage over
|
||||
// greedy play — measured at exactly 50% on a 7-syllable version. Real
|
||||
// dictionaries have many syllables with wildly uneven out-degrees, and that is
|
||||
// where searching ahead starts to pay.
|
||||
func newSimDict() *simDict {
|
||||
d := &simDict{words: map[string][2]string{}}
|
||||
rng := rand.New(rand.NewPCG(20260904, 3))
|
||||
|
||||
const syllableCount = 26
|
||||
syllables := make([]string, syllableCount)
|
||||
for i := range syllables {
|
||||
syllables[i] = string(rune('a' + i))
|
||||
}
|
||||
|
||||
add := func(first, last string) {
|
||||
d.words[first+" "+last] = [2]string{first, last}
|
||||
}
|
||||
|
||||
// Uneven out-degrees: a few hubs, many mid-sized syllables, some dead ends.
|
||||
for i, first := range syllables {
|
||||
var outDegree int
|
||||
switch {
|
||||
case i < 3:
|
||||
outDegree = 8 + rng.IntN(4) // hubs
|
||||
case i < syllableCount-4:
|
||||
outDegree = 1 + rng.IntN(5) // ordinary
|
||||
default:
|
||||
outDegree = rng.IntN(2) // near dead ends
|
||||
}
|
||||
for j := 0; j < outDegree; j++ {
|
||||
add(first, syllables[rng.IntN(syllableCount)])
|
||||
}
|
||||
}
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *simDict) Resolve(word string) (string, bool) {
|
||||
_, ok := d.words[word]
|
||||
return word, ok
|
||||
}
|
||||
|
||||
func (d *simDict) FirstSyllable(word string) (string, bool) {
|
||||
e, ok := d.words[word]
|
||||
return e[0], ok
|
||||
}
|
||||
|
||||
func (d *simDict) LastSyllable(word string) (string, bool) {
|
||||
e, ok := d.words[word]
|
||||
return e[1], ok
|
||||
}
|
||||
|
||||
func (d *simDict) WordsStartingWith(syllable string) iter.Seq[string] {
|
||||
var out []string
|
||||
for w, e := range d.words {
|
||||
if e[0] == syllable {
|
||||
out = append(out, w)
|
||||
}
|
||||
}
|
||||
// Sorted for reproducibility under a fixed seed.
|
||||
slices.Sort(out)
|
||||
return slices.Values(out)
|
||||
}
|
||||
|
||||
func (d *simDict) OutDegree(syllable string) (int, error) {
|
||||
n := 0
|
||||
for _, e := range d.words {
|
||||
if e[0] == syllable {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// playGame runs one headless bot-vs-bot game and reports the winner.
|
||||
// Every move goes through Engine.Submit, so the bots are held to the same
|
||||
// rules a human is.
|
||||
func playGame(t *testing.T, dict game.Dictionary, first, second Strategy) game.PlayerID {
|
||||
t.Helper()
|
||||
|
||||
const p1, p2 = game.PlayerID("first"), game.PlayerID("second")
|
||||
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
e, err := game.New(dict, []game.PlayerID{p1, p2}, "a b", time.Minute, now)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
board := BoardFor(e)
|
||||
|
||||
strategies := map[game.PlayerID]Strategy{p1: first, p2: second}
|
||||
|
||||
// Bounded so a pathological loop cannot hang the suite.
|
||||
for turn := 0; turn < 500 && !e.Over(); turn++ {
|
||||
p := e.Turn()
|
||||
move, err := strategies[p].Choose(board)
|
||||
if err == ErrNoMove {
|
||||
// The engine ends a game on a dead end before the bot is asked, so
|
||||
// this should be unreachable. If it ever fires, the player to move
|
||||
// has lost, so the winner is the opponent -- returning the player
|
||||
// to move would silently invert every ladder number.
|
||||
t.Fatalf("bot had no move at %q but the engine had not ended the game", e.Current())
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Choose: %v", err)
|
||||
}
|
||||
|
||||
now = now.Add(time.Second)
|
||||
if _, reason := e.Submit(p, move, now); reason != game.ReasonNone {
|
||||
t.Fatalf("bot %s played %q which the engine rejected: %s", p, move, reason)
|
||||
}
|
||||
}
|
||||
|
||||
if !e.Over() {
|
||||
t.Fatal("game did not finish within the turn cap")
|
||||
}
|
||||
return e.Winner()
|
||||
}
|
||||
|
||||
// winRate plays n games and reports how often the challenger wins.
|
||||
//
|
||||
// Sides alternate every game. On a small graph moving first is an advantage in
|
||||
// its own right, and a fixed seating order would report that advantage as
|
||||
// skill.
|
||||
func winRate(t *testing.T, challenger, defender Difficulty, n int) float64 {
|
||||
t.Helper()
|
||||
|
||||
const p1 = game.PlayerID("first")
|
||||
dict := newSimDict()
|
||||
wins := 0
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
// Fresh seeded strategies per game: reproducible, but not identical
|
||||
// play every game.
|
||||
c, err := New(challenger, rand.New(rand.NewPCG(uint64(i), 1)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d, err := New(defender, rand.New(rand.NewPCG(uint64(i), 2)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
challengerMovesFirst := i%2 == 0
|
||||
var winner game.PlayerID
|
||||
if challengerMovesFirst {
|
||||
winner = playGame(t, dict, c, d)
|
||||
} else {
|
||||
winner = playGame(t, dict, d, c)
|
||||
}
|
||||
|
||||
if (winner == p1) == challengerMovesFirst {
|
||||
wins++
|
||||
}
|
||||
}
|
||||
return float64(wins) / float64(n)
|
||||
}
|
||||
|
||||
// The whole point of three difficulties is that they are actually different.
|
||||
// If Hard does not beat Easy, the ladder is decoration.
|
||||
func TestDifficultyLadder(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("simulation skipped in short mode")
|
||||
}
|
||||
|
||||
const games = 100
|
||||
|
||||
hardVsEasy := winRate(t, Hard, Easy, games)
|
||||
mediumVsEasy := winRate(t, Medium, Easy, games)
|
||||
hardVsMedium := winRate(t, Hard, Medium, games)
|
||||
|
||||
t.Logf("win rates over %d games: hard-vs-easy %.0f%%, medium-vs-easy %.0f%%, hard-vs-medium %.0f%%",
|
||||
games, hardVsEasy*100, mediumVsEasy*100, hardVsMedium*100)
|
||||
|
||||
if hardVsEasy <= 0.70 {
|
||||
t.Errorf("Hard beat Easy only %.0f%% of the time, want > 70%%", hardVsEasy*100)
|
||||
}
|
||||
if mediumVsEasy <= 0.50 {
|
||||
t.Errorf("Medium beat Easy only %.0f%% of the time, want > 50%%", mediumVsEasy*100)
|
||||
}
|
||||
// Hard vs Medium head to head, not their scores against Easy: both beat a
|
||||
// random opponent nearly always, so those numbers saturate and cannot order
|
||||
// the two.
|
||||
//
|
||||
// The threshold is deliberately loose. This is one synthetic graph, and how
|
||||
// much lookahead is worth is a property of the graph: regenerating it with
|
||||
// a different seed moved this figure between 49% and 78%. The real corpus
|
||||
// is the load-bearing measurement -- see TestDifficultyLadderRealCorpus --
|
||||
// and this test only guards against a gross regression.
|
||||
if hardVsMedium < 0.45 {
|
||||
t.Errorf("Hard beat Medium only %.0f%% of the time, want no worse than parity", hardVsMedium*100)
|
||||
}
|
||||
}
|
||||
|
||||
// Bots must never hand the engine an illegal move; playGame fails the test if
|
||||
// Submit rejects anything, so this is really a rules-conformance check.
|
||||
func TestSimulatedGamesAlwaysTerminate(t *testing.T) {
|
||||
dict := newSimDict()
|
||||
for i := 0; i < 20; i++ {
|
||||
a, _ := New(Hard, rand.New(rand.NewPCG(uint64(i), 5)))
|
||||
b, _ := New(Hard, rand.New(rand.NewPCG(uint64(i), 6)))
|
||||
if got := playGame(t, dict, a, b); got == "" {
|
||||
t.Fatalf("game %d ended with no winner", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkHardChoose(b *testing.B) {
|
||||
dict := newSimDict()
|
||||
s, err := New(Hard, rand.New(rand.NewPCG(1, 1)))
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
e, err := game.New(dict, []game.PlayerID{"p1", "p2"}, "a b", time.Minute, now)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
board := BoardFor(e)
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := s.Choose(board); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"time"
|
||||
)
|
||||
|
||||
// easy plays a uniformly random legal move.
|
||||
//
|
||||
// It does not look at what the move hands the opponent, so it will happily
|
||||
// give away a winning position — which is the point.
|
||||
type easy struct {
|
||||
rng *rand.Rand
|
||||
}
|
||||
|
||||
func (s *easy) Difficulty() Difficulty { return Easy }
|
||||
|
||||
func (s *easy) ThinkingDelay() time.Duration {
|
||||
return thinkingDelay(s.rng, 400*time.Millisecond, 900*time.Millisecond)
|
||||
}
|
||||
|
||||
func (s *easy) Choose(b Board) (string, error) {
|
||||
moves := b.LegalMoves()
|
||||
if len(moves) == 0 {
|
||||
return "", ErrNoMove
|
||||
}
|
||||
return moves[s.rng.IntN(len(moves))], nil
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Tuning. These are constants rather than structure precisely so the bot can be
|
||||
// made easier or harder without touching the search.
|
||||
const (
|
||||
// hardKillRate is how often Hard takes an available instant win. A bot that
|
||||
// always wins is not a game, so it sometimes plays on instead.
|
||||
hardKillRate = 0.85
|
||||
// searchDepth is plies of lookahead. Directed Edge Geography is
|
||||
// PSPACE-complete, so this is a heuristic cutoff, not a solver.
|
||||
searchDepth = 4
|
||||
// nodeCap bounds the search on hub syllables with a large branching
|
||||
// factor, keeping Choose inside its latency budget.
|
||||
nodeCap = 20000
|
||||
// branchCap limits how many candidates are explored per node. Moves are
|
||||
// ordered tightest-first, so the pruned tail is the least interesting.
|
||||
branchCap = 12
|
||||
|
||||
// A win is the negation of a child's loseScore, so only the losing
|
||||
// terminal needs a constant.
|
||||
loseScore = -1000.0
|
||||
)
|
||||
|
||||
// hard looks ahead.
|
||||
//
|
||||
// First it checks for an immediate kill: a move that leaves the opponent with
|
||||
// nothing. Failing that it runs a depth-limited negamax over the remaining
|
||||
// edges, valuing a position by how few replies the opponent has. If the search
|
||||
// finds nothing useful it falls back to Medium's heuristic.
|
||||
type hard struct {
|
||||
rng *rand.Rand
|
||||
}
|
||||
|
||||
func (s *hard) Difficulty() Difficulty { return Hard }
|
||||
|
||||
func (s *hard) ThinkingDelay() time.Duration {
|
||||
return thinkingDelay(s.rng, 600*time.Millisecond, 1500*time.Millisecond)
|
||||
}
|
||||
|
||||
func (s *hard) Choose(b Board) (string, error) {
|
||||
moves := b.LegalMoves()
|
||||
if len(moves) == 0 {
|
||||
return "", ErrNoMove
|
||||
}
|
||||
|
||||
scored := scoreMoves(b, moves)
|
||||
if len(scored) == 0 {
|
||||
return moves[s.rng.IntN(len(moves))], nil
|
||||
}
|
||||
|
||||
// An instant win: the opponent has no reply at all.
|
||||
var kills []string
|
||||
for _, m := range scored {
|
||||
if m.handsOver == 0 {
|
||||
kills = append(kills, m.word)
|
||||
}
|
||||
}
|
||||
if len(kills) > 0 {
|
||||
if s.rng.Float64() < hardKillRate {
|
||||
return kills[s.rng.IntN(len(kills))], nil
|
||||
}
|
||||
// Declined. The killing moves must be taken off the table, not just
|
||||
// skipped here: the search below would otherwise rediscover the same
|
||||
// win and play it anyway, making hardKillRate do nothing at all.
|
||||
var spared []scoredMove
|
||||
for _, m := range scored {
|
||||
if m.handsOver > 0 {
|
||||
spared = append(spared, m)
|
||||
}
|
||||
}
|
||||
if len(spared) > 0 {
|
||||
scored = spared
|
||||
}
|
||||
}
|
||||
|
||||
// Order tightest-first so alpha-beta prunes early.
|
||||
slices.SortStableFunc(scored, func(a, b scoredMove) int { return a.handsOver - b.handsOver })
|
||||
|
||||
st := &search{board: b, used: map[string]bool{}, budget: nodeCap}
|
||||
|
||||
best := ""
|
||||
alpha := math.Inf(-1)
|
||||
for i, m := range scored {
|
||||
if i >= branchCap || st.budget <= 0 {
|
||||
// Out of budget: keep the best move found so far rather than
|
||||
// scoring the rest against an exhausted search.
|
||||
break
|
||||
}
|
||||
last, ok := b.LastSyllable(m.word)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
st.used[m.word] = true
|
||||
// Negamax: the opponent's best outcome, negated, is ours. Alpha carries
|
||||
// across children so later subtrees can be pruned.
|
||||
score := -st.negamax(last, searchDepth-1, math.Inf(-1), -alpha)
|
||||
delete(st.used, m.word)
|
||||
|
||||
if score > alpha {
|
||||
alpha, best = score, m.word
|
||||
}
|
||||
}
|
||||
|
||||
if best == "" {
|
||||
// Nothing scored; fall back to the tightest-first heuristic, which is
|
||||
// Medium's judgement.
|
||||
return scored[0].word, nil
|
||||
}
|
||||
return best, nil
|
||||
}
|
||||
|
||||
// search carries the mutable state of one lookahead. Moves are made and unmade
|
||||
// on `used` rather than copying the position at every node.
|
||||
type search struct {
|
||||
board Board
|
||||
used map[string]bool
|
||||
budget int
|
||||
}
|
||||
|
||||
// spent reports whether a word is unavailable: already played in the real game,
|
||||
// or played earlier in this search line.
|
||||
func (s *search) spent(word string) bool {
|
||||
return s.used[word] || s.board.Used(word)
|
||||
}
|
||||
|
||||
// negamax scores the position for the player to move at `current`.
|
||||
//
|
||||
// Returns a value from that player's point of view, so the caller negates it.
|
||||
func (s *search) negamax(current string, depth int, alpha, beta float64) float64 {
|
||||
if s.budget <= 0 {
|
||||
// Fail soft: return the bound already established rather than a neutral
|
||||
// 0, which reads as "this line is fine" and can push the root toward a
|
||||
// losing move when the budget bites.
|
||||
return alpha
|
||||
}
|
||||
s.budget--
|
||||
|
||||
var candidates []string
|
||||
for word := range s.board.WordsStartingWith(current) {
|
||||
if !s.spent(word) {
|
||||
candidates = append(candidates, word)
|
||||
}
|
||||
}
|
||||
|
||||
// No reply: the player to move has lost.
|
||||
if len(candidates) == 0 {
|
||||
return loseScore
|
||||
}
|
||||
if depth <= 0 {
|
||||
// Negamax evaluates from the perspective of the player to move, so
|
||||
// mobility is a positive: more replies available is better for them.
|
||||
// The caller negates this, which is what turns it into "leave the
|
||||
// opponent with as little as possible". Getting this sign backwards
|
||||
// makes the search prefer positions where it is about to be trapped —
|
||||
// measured at a 29% win rate against the one-ply bot.
|
||||
//
|
||||
// Log rather than the raw count so one hub syllable cannot dominate.
|
||||
return math.Log(1 + float64(len(candidates)))
|
||||
}
|
||||
|
||||
// Order tightest-first: explore the moves most likely to be strong.
|
||||
type cand struct {
|
||||
word string
|
||||
last string
|
||||
handsOver int
|
||||
}
|
||||
ordered := make([]cand, 0, len(candidates))
|
||||
for _, w := range candidates {
|
||||
last, ok := s.board.LastSyllable(w)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
n := 0
|
||||
for reply := range s.board.WordsStartingWith(last) {
|
||||
if reply != w && !s.spent(reply) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
ordered = append(ordered, cand{word: w, last: last, handsOver: n})
|
||||
}
|
||||
slices.SortStableFunc(ordered, func(a, b cand) int { return a.handsOver - b.handsOver })
|
||||
|
||||
// Starts at the losing score: if every candidate is pruned or unresolvable,
|
||||
// this position is no better than lost, which is the honest floor.
|
||||
best := loseScore
|
||||
for i, c := range ordered {
|
||||
if i >= branchCap {
|
||||
break
|
||||
}
|
||||
|
||||
s.used[c.word] = true
|
||||
score := -s.negamax(c.last, depth-1, -beta, -alpha)
|
||||
delete(s.used, c.word)
|
||||
|
||||
if score > best {
|
||||
best = score
|
||||
}
|
||||
if best > alpha {
|
||||
alpha = best
|
||||
}
|
||||
if alpha >= beta {
|
||||
break // the opponent would avoid this line
|
||||
}
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
// medium plays greedily one move deep.
|
||||
//
|
||||
// It scores each legal move by how many continuations remain from the syllable
|
||||
// it hands over, takes an immediate win when it sees one, and otherwise picks
|
||||
// at random from the tightest quartile. Random rather than strictly best, so it
|
||||
// does not play the same game every time.
|
||||
//
|
||||
// What separates it from hard is lookahead, not ruthlessness: medium sees only
|
||||
// the move in front of it, while hard searches several plies. An earlier
|
||||
// version withheld the winning move to feel gentler, and simulation showed it
|
||||
// losing to the random bot 97% of the time — a strategy that declines to win
|
||||
// loses to a coin flip. Difficulty has to come from how well a bot plays, not
|
||||
// from it refusing to.
|
||||
type medium struct {
|
||||
rng *rand.Rand
|
||||
}
|
||||
|
||||
func (s *medium) Difficulty() Difficulty { return Medium }
|
||||
|
||||
func (s *medium) ThinkingDelay() time.Duration {
|
||||
return thinkingDelay(s.rng, 500*time.Millisecond, 1200*time.Millisecond)
|
||||
}
|
||||
|
||||
type scoredMove struct {
|
||||
word string
|
||||
handsOver int // continuations left for the opponent
|
||||
}
|
||||
|
||||
func (s *medium) Choose(b Board) (string, error) {
|
||||
moves := b.LegalMoves()
|
||||
if len(moves) == 0 {
|
||||
return "", ErrNoMove
|
||||
}
|
||||
|
||||
scored := scoreMoves(b, moves)
|
||||
if len(scored) == 0 {
|
||||
return moves[s.rng.IntN(len(moves))], nil
|
||||
}
|
||||
|
||||
slices.SortStableFunc(scored, func(a, b scoredMove) int {
|
||||
return a.handsOver - b.handsOver
|
||||
})
|
||||
|
||||
// A move that leaves the opponent nothing wins immediately. Sorting puts
|
||||
// those first, so this is simply taking the best available move.
|
||||
if scored[0].handsOver == 0 {
|
||||
return scored[0].word, nil
|
||||
}
|
||||
|
||||
// Otherwise pick at random from the tightest quartile.
|
||||
cut := max(len(scored)/4, 1)
|
||||
return scored[s.rng.IntN(cut)].word, nil
|
||||
}
|
||||
|
||||
// scoreMoves annotates each candidate with how many replies it leaves. The
|
||||
// move itself is counted as spent, since the opponent cannot replay it.
|
||||
func scoreMoves(b Board, moves []string) []scoredMove {
|
||||
scored := make([]scoredMove, 0, len(moves))
|
||||
for _, word := range moves {
|
||||
last, ok := b.LastSyllable(word)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
scored = append(scored, scoredMove{
|
||||
word: word,
|
||||
handsOver: remainingOutDegree(b, last, word),
|
||||
})
|
||||
}
|
||||
return scored
|
||||
}
|
||||
Reference in new issue
Block a user