Files
noitu/server/internal/game/engine_test.go
T
tiennm99 8b3e8f7e67 fix(wsapi): guard room seating races, refuse games during drain, and drop the history copy
A session can tear down between the hub handing a room its seating
message and the room goroutine draining it off the queue. Nothing else
ever learns that, since leaveRoom only notifies a room the session had
already attached to. Left seated as connected, allConnected() could
report true and let quick match auto-start a game against a dead
socket. handleCreate, handleJoin, handleStartBot and handleResume now
check the connection's context and either reopen the grace window the
disconnect would have, or cancel the room when there is no lobby left
to hold one open.

beginGame could also raise the live-game count after the drain
decision: an existing lobby's StartGame, and quick match's own
auto-start, now refuse with server_restarting once the hub is
draining, matching the refusal newRegisteredRoom already gives a
brand-new room.

Engine.Snapshot() copied the whole move history on every broadcast for
two callers that only ever wanted the last move or the played-word
set. Engine.LastMove and Engine.UsedWords answer both directly off the
engine's own state, State.History is gone, and Standings is only
computed once the game is actually over, which is the only time it is
meaningful.
2026-09-21 16:13:52 +07:00

831 lines
25 KiB
Go

package game
import (
"fmt"
"iter"
"slices"
"strings"
"testing"
"time"
)
// fakeDict is a hand-built word graph. Tests state the exact edges they need,
// so a rule can be exercised on a board small enough to reason about — no
// SQLite, no real corpus.
type fakeDict struct {
words map[string][2]string // canonical -> {first, last}
aliases map[string]string
}
func newDict(words ...string) *fakeDict {
d := &fakeDict{words: map[string][2]string{}, aliases: map[string]string{}}
for _, w := range words {
parts := strings.Fields(w)
d.words[w] = [2]string{parts[0], parts[len(parts)-1]}
}
return d
}
func (d *fakeDict) alias(variant, canonical string) *fakeDict {
d.aliases[variant] = canonical
return d
}
func (d *fakeDict) Resolve(word string) (string, bool) {
if _, ok := d.words[word]; ok {
return word, true
}
c, ok := d.aliases[word]
return c, ok
}
func (d *fakeDict) FirstSyllable(word string) (string, bool) {
e, ok := d.words[word]
return e[0], ok
}
func (d *fakeDict) LastSyllable(word string) (string, bool) {
e, ok := d.words[word]
return e[1], ok
}
func (d *fakeDict) WordsStartingWith(syllable string) iter.Seq[string] {
var out []string
for w, e := range d.words {
if e[0] == syllable {
out = append(out, w)
}
}
slices.Sort(out)
return slices.Values(out)
}
func (d *fakeDict) OutDegree(syllable string) (int, error) {
n := 0
for _, e := range d.words {
if e[0] == syllable {
n++
}
}
return n, nil
}
const (
alice = PlayerID("alice")
bob = PlayerID("bob")
)
var t0 = time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
// standardDict gives both players room to move for several turns.
func standardDict() *fakeDict {
return newDict(
"ngôn ngữ", // ngôn -> ngữ
"ngữ pháp", // ngữ -> pháp
"ngữ điệu", // ngữ -> điệu
"pháp luật", // pháp -> luật
"pháp lý", // pháp -> lý
"luật lệ", // luật -> lệ
"lý do", // lý -> do
"vô tuyến điện",
)
}
func newGame(t *testing.T, d Dictionary, opening string) *Engine {
t.Helper()
e, err := New(d, []PlayerID{alice, bob}, opening, 20*time.Second, t0)
if err != nil {
t.Fatalf("New: %v", err)
}
return e
}
func TestNewSeedsStateFromOpening(t *testing.T) {
e := newGame(t, standardDict(), "ngôn ngữ")
if got := e.Current(); got != "ngữ" {
t.Errorf("Current = %q, want %q", got, "ngữ")
}
if got := e.Turn(); got != alice {
t.Errorf("Turn = %q, want %q", got, alice)
}
if !e.Used("ngôn ngữ") {
t.Error("opening word is not marked used")
}
if got := e.ChainLength(); got != 1 {
t.Errorf("ChainLength = %d, want 1", got)
}
}
func TestNewRejectsBadInput(t *testing.T) {
d := standardDict()
if _, err := New(nil, []PlayerID{alice, bob}, "ngôn ngữ", time.Second, t0); err == nil {
t.Error("New accepted a nil dictionary")
}
if _, err := New(d, []PlayerID{alice}, "ngôn ngữ", time.Second, t0); err == nil {
t.Error("New accepted a single player")
}
if _, err := New(d, []PlayerID{alice, bob}, "ngôn ngữ", 0, t0); err == nil {
t.Error("New accepted a zero turn limit")
}
if _, err := New(d, []PlayerID{alice, bob}, "không tồn tại", time.Second, t0); err == nil {
t.Error("New accepted an opening word outside the dictionary")
}
}
func TestSubmitAcceptsLegalMove(t *testing.T) {
e := newGame(t, standardDict(), "ngôn ngữ")
move, reason := e.Submit(alice, "ngữ pháp", t0)
if reason != ReasonNone {
t.Fatalf("Submit rejected a legal move: %s", reason)
}
if move.Word != "ngữ pháp" || move.First != "ngữ" || move.Last != "pháp" {
t.Errorf("move = %+v, want ngữ pháp / ngữ / pháp", move)
}
if got := e.Current(); got != "pháp" {
t.Errorf("Current = %q, want %q", got, "pháp")
}
if got := e.Turn(); got != bob {
t.Errorf("Turn = %q, want %q", got, bob)
}
if !e.Used("ngữ pháp") {
t.Error("accepted word is not marked used")
}
}
// One test per rejection reason: a boolean would not tell a player what to fix.
func TestSubmitRejectionReasons(t *testing.T) {
tests := []struct {
name string
setup func(*Engine)
who PlayerID
word string
when time.Time
want RejectReason
}{
{
name: "not your turn",
who: bob, word: "ngữ pháp", when: t0,
want: ReasonNotYourTurn,
},
{
name: "single syllable",
who: alice, word: "ngữ", when: t0,
want: ReasonTooFewSyllables,
},
{
name: "empty input",
who: alice, word: " ", when: t0,
want: ReasonTooFewSyllables,
},
{
name: "unknown word",
who: alice, word: "ngữ xyzzy", when: t0,
want: ReasonNotInDictionary,
},
{
name: "wrong link",
who: alice, word: "luật lệ", when: t0,
want: ReasonWrongLink,
},
{
name: "turn expired",
who: alice, word: "ngữ pháp", when: t0.Add(21 * time.Second),
want: ReasonTimeout,
},
}
// ReasonAlreadyUsed needs a played-out board, so it has its own tests:
// TestSubmitBlocksReuseAcrossPlayers and TestSubmitBlocksReuseViaAlias.
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
e := newGame(t, standardDict(), "ngôn ngữ")
if tc.setup != nil {
tc.setup(e)
}
if _, got := e.Submit(tc.who, tc.word, tc.when); got != tc.want {
t.Errorf("Submit = %s, want %s", got, tc.want)
}
})
}
}
// A word is spent for the whole game, not per player.
func TestSubmitBlocksReuseAcrossPlayers(t *testing.T) {
d := newDict(
"ngôn ngữ",
"ngữ pháp",
"pháp ngữ", // lets play return to "ngữ"
"ngữ điệu",
)
e := newGame(t, d, "ngôn ngữ")
if _, r := e.Submit(alice, "ngữ pháp", t0); r != ReasonNone {
t.Fatalf("alice's move rejected: %s", r)
}
if _, r := e.Submit(bob, "pháp ngữ", t0); r != ReasonNone {
t.Fatalf("bob's move rejected: %s", r)
}
// Back on "ngữ": alice cannot replay the word she already used.
if _, r := e.Submit(alice, "ngữ pháp", t0); r != ReasonAlreadyUsed {
t.Errorf("replaying a used word = %s, want %s", r, ReasonAlreadyUsed)
}
}
// Canonicalization can move the first syllable, so the link must be checked
// against the canonical form. Checking the typed form rejects a legal move.
func TestSubmitAcceptsAliasWithFirstSyllableDrift(t *testing.T) {
d := newDict("ngôn ngữ", "ngữ pháp").alias("ngử pháp", "ngữ pháp")
e := newGame(t, d, "ngôn ngữ")
move, reason := e.Submit(alice, "ngử pháp", t0)
if reason != ReasonNone {
t.Fatalf("alias with first-syllable drift rejected: %s", reason)
}
if move.Word != "ngữ pháp" {
t.Errorf("move.Word = %q, want the canonical %q", move.Word, "ngữ pháp")
}
if move.Typed != "ngử pháp" {
t.Errorf("move.Typed = %q, want what the player typed", move.Typed)
}
// The chain must continue from the canonical's last syllable.
if got := e.Current(); got != "pháp" {
t.Errorf("Current = %q, want %q", got, "pháp")
}
}
// An alias must be spent along with its canonical: the same word cannot be
// played twice under two spellings.
func TestSubmitBlocksReuseViaAlias(t *testing.T) {
// "ngữ điệu" keeps a move available after play returns to "ngữ", so the
// game does not end on a dead end before the reuse can be attempted.
d := newDict("ngôn ngữ", "ngữ pháp", "pháp ngữ", "ngữ điệu").alias("ngử pháp", "ngữ pháp")
e := newGame(t, d, "ngôn ngữ")
e.Submit(alice, "ngữ pháp", t0)
e.Submit(bob, "pháp ngữ", t0)
if _, r := e.Submit(alice, "ngử pháp", t0); r != ReasonAlreadyUsed {
t.Errorf("replaying via an alias = %s, want %s", r, ReasonAlreadyUsed)
}
}
func TestSubmitScoring(t *testing.T) {
d := newDict("ngôn ngữ", "ngữ pháp", "pháp vô tuyến điện")
e := newGame(t, d, "ngôn ngữ")
// Spec: 10 + 2*chainLength + 5*(syllables-2) + speed + rarity, where
// chainLength counts the words already down, opening word included, speed
// is 10 for an answer with the whole turn still on the clock, and rarity
// is 15 for a syllable this dictionary answers with one word. Asserted as
// literals so the test pins the specified formula rather than whatever the
// code computes.
two, _ := e.Submit(alice, "ngữ pháp", t0)
if want := 37; two.Points != want { // 10 + 2*1 + 5*0 + 10 + 15
t.Errorf("two-syllable word at chain 1 scored %d, want %d", two.Points, want)
}
four, r := e.Submit(bob, "pháp vô tuyến điện", t0)
if r != ReasonNone {
t.Fatalf("four-syllable word rejected: %s", r)
}
if want := 49; four.Points != want { // 10 + 2*2 + 5*2 + 10 + 15
t.Errorf("four-syllable word at chain 2 scored %d, want %d", four.Points, want)
}
if four.Points <= two.Points {
t.Error("longer word did not score more than a shorter one")
}
}
// The speed term falls off linearly across the turn and is gone by the
// deadline, which is the last instant a submission is still in time.
func TestSubmitScoringPaysForTimeLeft(t *testing.T) {
half := t0.Add(10 * time.Second) // half of the 20-second turn newGame gives
buzzer := t0.Add(20 * time.Second)
instant, _ := newGame(t, newDict("ngôn ngữ", "ngữ pháp"), "ngôn ngữ").Submit(alice, "ngữ pháp", t0)
halfway, _ := newGame(t, newDict("ngôn ngữ", "ngữ pháp"), "ngôn ngữ").Submit(alice, "ngữ pháp", half)
late, r := newGame(t, newDict("ngôn ngữ", "ngữ pháp"), "ngôn ngữ").Submit(alice, "ngữ pháp", buzzer)
if r != ReasonNone {
t.Fatalf("word played on the deadline rejected: %s", r)
}
if want := 37; instant.Points != want { // ... + speed 10
t.Errorf("instant answer scored %d, want %d", instant.Points, want)
}
if want := 32; halfway.Points != want { // ... + speed 5
t.Errorf("answer with half the turn left scored %d, want %d", halfway.Points, want)
}
if want := 27; late.Points != want { // ... + speed 0
t.Errorf("answer on the buzzer scored %d, want %d", late.Points, want)
}
}
// The rarity term pays for the syllable being answered, not for the word: the
// same word is worth more when the dictionary offers little else on that link,
// and it loses 3 points per doubling of the answers rather than falling in a
// straight line, so a crowded link pays nothing at all.
func TestSubmitScoringPaysForARareLink(t *testing.T) {
// A dictionary where "ngữ" is answered by options words, one of which is
// the word the test plays.
link := func(options int) *fakeDict {
words := []string{"ngôn ngữ", "ngữ pháp"}
for i := 1; i < options; i++ {
words = append(words, fmt.Sprintf("ngữ từ%d", i))
}
return newDict(words...)
}
// 10 base + 2 chain + 0 syllables + 10 speed = 22 before the rarity term.
tests := []struct {
options int
want int
}{
{1, 22 + 15},
{2, 22 + 12},
{3, 22 + 12},
{4, 22 + 9},
{8, 22 + 6},
{16, 22 + 3},
{32, 22},
{64, 22},
}
for _, tc := range tests {
e := newGame(t, link(tc.options), "ngôn ngữ")
m, r := e.Submit(alice, "ngữ pháp", t0)
if r != ReasonNone {
t.Fatalf("word on a %d-answer link rejected: %s", tc.options, r)
}
if m.Points != tc.want {
t.Errorf("word on a %d-answer link scored %d, want %d", tc.options, m.Points, tc.want)
}
}
}
// The chain term stops growing at chainBonusWords. Beyond it a word is priced
// by the move — its length, its speed, its link — and not by how long the game
// happened to have been running.
func TestSubmitScoringStopsPayingForChainLength(t *testing.T) {
// A single line of two-syllable words, "a0 a1" -> "a1 a2" -> ..., so every
// link has exactly one answer and only the chain term varies.
const words = chainBonusWords + 3
var line []string
for i := range words {
line = append(line, fmt.Sprintf("a%d a%d", i, i+1))
}
e := newGame(t, newDict(line...), line[0])
// 10 base + 0 syllables + 10 speed + 15 rarity, plus the chain term.
var points []int
for i := 1; i < words; i++ {
m, r := e.Submit(e.Turn(), line[i], t0)
if r != ReasonNone {
t.Fatalf("word %d of the line rejected: %s", i, r)
}
points = append(points, m.Points)
}
for i, got := range points {
chain := i + 1 // the words already down when this one was played
want := 35 + 2*min(chain, chainBonusWords)
if got != want {
t.Errorf("word at chain %d scored %d, want %d", chain, got, want)
}
}
if first, last := points[chainBonusWords-1], points[len(points)-1]; first != last {
t.Errorf("chain term kept growing past %d words: %d then %d", chainBonusWords, first, last)
}
}
// Parts must name every term the total is made of, with nothing left over and
// nothing zero: a term that did not contribute has nothing to show a player.
func TestSubmitScoringPartsMatchTheTotal(t *testing.T) {
d := newDict("ngôn ngữ", "ngữ pháp", "pháp vô tuyến điện")
e := newGame(t, d, "ngôn ngữ")
two, _ := e.Submit(alice, "ngữ pháp", t0)
if want := []PointPart{
{Kind: PointKindBase, Value: 10},
{Kind: PointKindChain, Value: 2},
{Kind: PointKindSpeed, Value: 10},
{Kind: PointKindRarity, Value: 15},
}; !slices.Equal(two.Parts, want) {
t.Errorf("two-syllable word parts = %+v, want %+v", two.Parts, want)
}
if sum := sumParts(two.Parts); sum != two.Points {
t.Errorf("parts sum to %d, want Points %d", sum, two.Points)
}
// Four syllables is two past the minimum, so this word's syllable term is
// the one difference from the case above and must appear rather than be
// silently folded into the total.
four, r := e.Submit(bob, "pháp vô tuyến điện", t0)
if r != ReasonNone {
t.Fatalf("four-syllable word rejected: %s", r)
}
if want := []PointPart{
{Kind: PointKindBase, Value: 10},
{Kind: PointKindChain, Value: 4},
{Kind: PointKindSyllables, Value: 10},
{Kind: PointKindSpeed, Value: 10},
{Kind: PointKindRarity, Value: 15},
}; !slices.Equal(four.Parts, want) {
t.Errorf("four-syllable word parts = %+v, want %+v", four.Parts, want)
}
if sum := sumParts(four.Parts); sum != four.Points {
t.Errorf("parts sum to %d, want Points %d", sum, four.Points)
}
}
// When the naive sum overruns maxPointsPerWord, the parts must still sum to
// the capped total exactly — trimmed from the end (rarity, then speed, then
// syllables) rather than the total being clamped while the breakdown still
// claims the uncapped numbers.
func TestSubmitScoringPartsAreTrimmedAtTheCap(t *testing.T) {
// A 20-syllable word answering "ngữ", the only continuation the dictionary
// offers there, so rarity pays its maximum: base 10 + chain 2 + syllables
// 5*18=90 + speed 10 + rarity 15 = 127 raw, 27 over the cap.
tokens := []string{"ngữ"}
for i := range 18 {
tokens = append(tokens, fmt.Sprintf("s%d", i))
}
tokens = append(tokens, "z")
long := strings.Join(tokens, " ")
e := newGame(t, newDict("ngôn ngữ", long), "ngôn ngữ")
move, r := e.Submit(alice, long, t0)
if r != ReasonNone {
t.Fatalf("long word rejected: %s", r)
}
if move.Points != maxPointsPerWord {
t.Fatalf("Points = %d, want the cap %d", move.Points, maxPointsPerWord)
}
if sum := sumParts(move.Parts); sum != maxPointsPerWord {
t.Errorf("parts sum to %d, want the cap %d", sum, maxPointsPerWord)
}
// Rarity and speed are trimmed away entirely (15 + 10 = 25 of the 27
// overflow) before syllables gives up the remaining 2, so base and chain
// are untouched and syllables lands at 88 rather than its raw 90.
if want := []PointPart{
{Kind: PointKindBase, Value: 10},
{Kind: PointKindChain, Value: 2},
{Kind: PointKindSyllables, Value: 88},
}; !slices.Equal(move.Parts, want) {
t.Errorf("trimmed parts = %+v, want %+v", move.Parts, want)
}
}
func sumParts(parts []PointPart) int {
sum := 0
for _, p := range parts {
sum += p.Value
}
return sum
}
func TestPointKindStrings(t *testing.T) {
seen := map[string]bool{}
for k := PointKind(0); k < NumPointKinds; k++ {
s := k.String()
if s == "" || s == "unknown" {
t.Errorf("PointKind(%d).String() = %q", k, s)
}
if seen[s] {
t.Errorf("duplicate description %q", s)
}
seen[s] = true
}
if got := PointKind(99).String(); got != "unknown" {
t.Errorf("unknown PointKind string = %q", got)
}
}
func TestLegalMovesAndHasLegalMove(t *testing.T) {
e := newGame(t, standardDict(), "ngôn ngữ")
got := e.LegalMoves()
want := []string{"ngữ pháp", "ngữ điệu"}
slices.Sort(got)
slices.Sort(want)
if !slices.Equal(got, want) {
t.Errorf("LegalMoves = %v, want %v", got, want)
}
if !e.HasLegalMove() {
t.Error("HasLegalMove = false with moves available")
}
// Once both continuations are spent there is nothing left from "ngữ".
e.Submit(alice, "ngữ pháp", t0)
if got := e.Current(); got != "pháp" {
t.Fatalf("Current = %q, want pháp", got)
}
}
// A dead end does not end the game by itself. The player who walked into one
// keeps the turn they were given, and it is the clock that takes it from them.
func TestDeadEndLeavesTheTurnWithThePlayer(t *testing.T) {
e := deadEndGame(t)
if e.Over() {
t.Fatal("game ended on the move into a dead end instead of leaving the turn to be played")
}
if e.Turn() != alice {
t.Errorf("Turn = %q, want %q", e.Turn(), alice)
}
if e.HasLegalMove() {
t.Error("HasLegalMove = true in a position with nothing to play")
}
}
// Losing a turn nobody could have answered is reported as the dead end it was,
// not as time spent thinking.
func TestDeadEndEndsOnTheClockAsNoLegalMove(t *testing.T) {
e := deadEndGame(t)
if !e.Timeout(e.Deadline().Add(time.Nanosecond)) {
t.Fatal("Timeout did not end an expired turn")
}
if e.Winner() != bob {
t.Errorf("Winner = %q, want %q (alice had no move)", e.Winner(), bob)
}
if got := e.Snapshot().EndReason; got != EndNoLegalMove {
t.Errorf("EndReason = %s, want %s", got, EndNoLegalMove)
}
}
// NoMove settles a dead end without a clock, which is what the room does for
// the bot: it has nothing to wait for.
func TestNoMoveEndsADeadEndImmediately(t *testing.T) {
e := deadEndGame(t)
if !e.NoMove(t0) {
t.Fatal("NoMove = false in a position with nothing to play")
}
if e.Winner() != bob {
t.Errorf("Winner = %q, want %q", e.Winner(), bob)
}
if got := e.Snapshot().EndReason; got != EndNoLegalMove {
t.Errorf("EndReason = %s, want %s", got, EndNoLegalMove)
}
if e.NoMove(t0) {
t.Error("NoMove ended an already finished game a second time")
}
}
// NoMove is not a resignation button: a player who still has words to play
// cannot use it to end the game.
func TestNoMoveRefusesAPlayablePosition(t *testing.T) {
e := newGame(t, standardDict(), "ngôn ngữ")
if e.NoMove(t0) {
t.Error("NoMove = true with legal moves available")
}
if e.Over() {
t.Error("game ended from a playable position")
}
}
// deadEndGame leaves alice on turn with nothing to play: "lệ" starts no word.
func deadEndGame(t *testing.T) *Engine {
t.Helper()
d := newDict("ngôn ngữ", "ngữ luật", "luật lệ")
e := newGame(t, d, "ngôn ngữ")
if _, r := e.Submit(alice, "ngữ luật", t0); r != ReasonNone {
t.Fatalf("alice's move rejected: %s", r)
}
if _, r := e.Submit(bob, "luật lệ", t0); r != ReasonNone {
t.Fatalf("bob's move rejected: %s", r)
}
return e
}
// What a losing player is shown: a few of the words the position still had,
// and nothing at all when it had none.
func TestSuggestions(t *testing.T) {
e := newGame(t, standardDict(), "ngôn ngữ")
if got, want := e.Suggestions(3), []string{"ngữ pháp", "ngữ điệu"}; !slices.Equal(got, want) {
t.Errorf("Suggestions(3) = %v, want %v", got, want)
}
// Capped, and stable: the same position answers the same way every time.
if got := e.Suggestions(1); !slices.Equal(got, []string{"ngữ pháp"}) {
t.Errorf("Suggestions(1) = %v, want [ngữ pháp]", got)
}
if got := e.Suggestions(0); got != nil {
t.Errorf("Suggestions(0) = %v, want nil", got)
}
// A played word is no longer a suggestion.
e.Submit(alice, "ngữ pháp", t0)
for _, word := range e.Suggestions(3) {
if word == "ngữ pháp" {
t.Error("Suggestions offered a word that had already been played")
}
}
if got := deadEndGame(t).Suggestions(3); len(got) != 0 {
t.Errorf("Suggestions in a dead end = %v, want none", got)
}
}
func TestIsExpiredBoundary(t *testing.T) {
e := newGame(t, standardDict(), "ngôn ngữ")
deadline := e.Deadline()
if e.IsExpired(deadline.Add(-time.Nanosecond)) {
t.Error("expired just before the deadline")
}
if e.IsExpired(deadline) {
t.Error("expired exactly on the deadline")
}
if !e.IsExpired(deadline.Add(time.Nanosecond)) {
t.Error("not expired just after the deadline")
}
}
func TestTimeoutAwardsOpponent(t *testing.T) {
e := newGame(t, standardDict(), "ngôn ngữ")
if e.Timeout(t0) {
t.Error("Timeout fired before the deadline")
}
if !e.Timeout(t0.Add(21 * time.Second)) {
t.Fatal("Timeout did not fire after the deadline")
}
if e.Winner() != bob {
t.Errorf("Winner = %q, want %q (alice ran out of time)", e.Winner(), bob)
}
if e.Timeout(t0.Add(30 * time.Second)) {
t.Error("Timeout fired twice")
}
}
func TestResign(t *testing.T) {
e := newGame(t, standardDict(), "ngôn ngữ")
if !e.Resign(alice, t0) {
t.Fatal("Resign returned false")
}
if e.Winner() != bob {
t.Errorf("Winner = %q, want %q", e.Winner(), bob)
}
if e.Snapshot().EndReason != EndResigned {
t.Errorf("EndReason = %s, want %s", e.Snapshot().EndReason, EndResigned)
}
if e.Resign(bob, t0) {
t.Error("Resign succeeded on a finished game")
}
}
// A finished game is not a turn-order problem, and the distinction reaches the
// player as copy.
func TestSubmitAfterGameOver(t *testing.T) {
e := newGame(t, standardDict(), "ngôn ngữ")
e.Resign(alice, t0)
if _, r := e.Submit(bob, "ngữ pháp", t0); r != ReasonGameOver {
t.Errorf("Submit after the game ended = %s, want %s", r, ReasonGameOver)
}
}
// An opening whose last syllable starts nothing hands the first player a game
// they have already lost, with no move and no reason -- it would resolve only
// on the turn timer, reported as a timeout. New must refuse it.
func TestNewRejectsDeadEndOpening(t *testing.T) {
// "lệ" starts no word.
d := newDict("luật lệ", "ngôn ngữ", "ngữ pháp")
_, err := New(d, []PlayerID{alice, bob}, "luật lệ", 20*time.Second, t0)
if err == nil {
t.Fatal("New accepted an opening that leaves the first player no move")
}
if !strings.Contains(err.Error(), "starts no other word") {
t.Errorf("error %q does not explain the dead end", err)
}
}
func TestNewRejectsDuplicatePlayers(t *testing.T) {
if _, err := New(standardDict(), []PlayerID{alice, alice}, "ngôn ngữ", time.Second, t0); err == nil {
t.Error("New accepted the same player twice")
}
}
func TestEndReasonStrings(t *testing.T) {
seen := map[string]bool{}
for _, r := range []EndReason{EndNone, EndTimeout, EndNoLegalMove, EndResigned} {
s := r.String()
if s == "" || s == "unknown" {
t.Errorf("EndReason(%d).String() = %q", r, s)
}
if seen[s] {
t.Errorf("duplicate description %q", s)
}
seen[s] = true
}
if got := EndReason(99).String(); got != "unknown" {
t.Errorf("unknown EndReason string = %q", got)
}
}
// Move.Typed must carry what the player actually sent, so the UI can show that
// a correction happened.
func TestMoveRecordsRawInput(t *testing.T) {
e := newGame(t, standardDict(), "ngôn ngữ")
const raw = " NGỮ Pháp "
move, r := e.Submit(alice, raw, t0)
if r != ReasonNone {
t.Fatalf("Submit rejected %q: %s", raw, r)
}
if move.Typed != raw {
t.Errorf("move.Typed = %q, want the raw input %q", move.Typed, raw)
}
if move.Word != "ngữ pháp" {
t.Errorf("move.Word = %q, want the canonical form", move.Word)
}
}
// Snapshot must not hand out engine state.
func TestSnapshotIsACopy(t *testing.T) {
e := newGame(t, standardDict(), "ngôn ngữ")
e.Submit(alice, "ngữ pháp", t0)
snap := e.Snapshot()
snap.Scores[alice] = 9999
fresh := e.Snapshot()
if fresh.Scores[alice] == 9999 {
t.Error("mutating a snapshot's scores changed engine state")
}
}
// LastMove is the resume replay's whole reason to exist: the tail of the
// chain without copying the rest of it.
func TestLastMove(t *testing.T) {
e := newGame(t, standardDict(), "ngôn ngữ")
if _, ok := e.LastMove(); ok {
t.Fatal("LastMove reported a move before any word was played")
}
if _, r := e.Submit(alice, "ngữ pháp", t0); r != ReasonNone {
t.Fatalf("Submit: %s", r)
}
move, ok := e.LastMove()
if !ok {
t.Fatal("LastMove reported none after a word was played")
}
if move.Word != "ngữ pháp" {
t.Errorf("LastMove = %q, want %q", move.Word, "ngữ pháp")
}
if _, r := e.Submit(bob, "pháp luật", t0); r != ReasonNone {
t.Fatalf("Submit: %s", r)
}
move, ok = e.LastMove()
if !ok || move.Word != "pháp luật" {
t.Errorf("LastMove after a second word = %+v, %v, want %q", move, ok, "pháp luật")
}
}
// UsedWords is the bot's search boundary: the opening word and everything
// played since, with nothing else in it.
func TestUsedWords(t *testing.T) {
e := newGame(t, standardDict(), "ngôn ngữ")
if _, r := e.Submit(alice, "ngữ pháp", t0); r != ReasonNone {
t.Fatalf("Submit: %s", r)
}
used := map[string]bool{}
for w := range e.UsedWords() {
used[w] = true
}
if !used["ngôn ngữ"] || !used["ngữ pháp"] || len(used) != 2 {
t.Errorf("UsedWords = %v, want exactly the opening word and the move played", used)
}
}
func TestRejectReasonStrings(t *testing.T) {
// Every reason needs a distinct, non-empty description: these become
// player-facing messages.
seen := map[string]bool{}
for _, r := range []RejectReason{
ReasonNone, ReasonNotYourTurn, ReasonTooFewSyllables,
ReasonNotInDictionary, ReasonWrongLink, ReasonAlreadyUsed, ReasonTimeout,
} {
s := r.String()
if s == "" || s == "unknown" {
t.Errorf("RejectReason(%d).String() = %q", r, s)
}
if seen[s] {
t.Errorf("duplicate description %q", s)
}
seen[s] = true
}
}