mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-12 16:15:29 +00:00
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.
283 lines
7.6 KiB
Go
283 lines
7.6 KiB
Go
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)
|
|
}
|
|
}
|