Files
noitu/server/internal/bot/simulate_test.go
T
tiennm99 4e2e9e1433 feat(online)!: seat two to four players in a room
A room holds up to four people and needs two to start. Both numbers are
server constants sent to the client in RoomState, so the lobby draws
whatever the server allows and widening a room is a server change alone.

Failing a turn eliminates that player rather than ending the game. The
syllable and the used words survive them, the turn passes to whoever is
next, and the last player standing wins. Two seats is that same rule seen
from close up, which is why there is one implementation of it and not two.

A dead end still costs the first player to face it their own clock, as
before: they get their turn, and lose it. Everyone behind them has already
seen that board, so they go out together rather than each sitting out a turn
limit they cannot use — which leaves the player who closed the position
standing, the same outcome two players get.

A player who is knocked out keeps their seat. They watch the rest of the
game, chat included, with only the word input gone, and everybody lands back
in the same lobby when it ends. The result screen is the whole table, ranked
by who outlasted whom, with each score reported beside the place rather than
deciding it.

The turn clock is deliberately not paused for a seat that has dropped, so a
player who loses their connection on their own turn loses it the way anybody
else would. Their reconnect window decides only whether they are still in the
game afterwards. Any number of windows can be open at once, settled by one
timer armed for the nearest of them.

Starting waits for every guest, not merely the first: a room of four that
began on one yes would have dealt three people a turn they never agreed to.
Kicking names a seat and is still refused on a player who is ready, and on
the owner's own — leaving is what an owner who wants out does, and it hands
the room on. Joining stays a lobby thing: a room with a game running turns a
latecomer away even with seats going spare, because there is no way to hand
somebody a game already in progress.

BREAKING CHANGE: RoomState, TurnUpdate and GameOver lose the fields that
could only ever describe a second player, OpponentLeft is retired in favour
of presence on RoomState, and suggestions move to the new PlayerEliminated —
they describe the position that beat a player, which by the end of a longer
game is nobody else's position. ProtocolVersion goes to 2, so a client built
against 1 is refused with a readable error rather than decoding a frame that
now means something else.
2026-09-08 10:56:14 +07:00

252 lines
7.0 KiB
Go

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 {
// A dead end no longer ends the game on its own: a human keeps the
// turn and loses it to the clock. Two bots have no clock, so this
// is where the position is settled -- exactly as the room does it.
if !e.NoMove(time.Now()) {
t.Fatalf("bot had no move at %q but the engine says the position has one", e.Current())
}
break
}
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)
}
}
}