mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
The wsapi server was built on the signal context, so SIGTERM cancelled every room before StartDraining ran and the restart notice never went out. Build it on a background context, release the signal context as soon as it fires, and wait a bounded moment after Shutdown so the notice reaches open sockets. Also: Hard prefers the slower loss in lost positions; resigning out of turn no longer settles a pending dead end on the spot; a self-closing <ref> with a slash in its name no longer swallows definition text; the store enforces builder_version; both listeners get an IdleTimeout; the real-corpus ladder is reproducible from its seed; a -healthcheck flag probes /healthz for the container health check.
300 lines
8.3 KiB
Go
300 lines
8.3 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)
|
|
}
|
|
}
|
|
|
|
// When every move loses inside the search horizon, Hard must play the one that
|
|
// loses later: the opponent may never find the slower refutation, and the
|
|
// faster one hands it over immediately.
|
|
func TestHardPrefersTheSlowerLossWhenEveryLineLoses(t *testing.T) {
|
|
// "s p" loses at once: the only reply "p z" leaves the mover with nothing.
|
|
// "s q" loses three plies later: q r, r t, t u, then "u" starts nothing.
|
|
b := board("s", "s p", "p z", "s q", "q r", "r t", "t u")
|
|
|
|
got, err := mustStrategy(t, Hard).Choose(b)
|
|
if err != nil {
|
|
t.Fatalf("Choose: %v", err)
|
|
}
|
|
if got != "s q" {
|
|
t.Errorf("Hard chose %q, want the slower loss %q", got, "s q")
|
|
}
|
|
}
|