mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
Collapse the three env parsers into one generic helper, the duplicated graceful-shutdown block into shutdownServer, and the score cap into pointsFor. Behaviour is unchanged.
823 lines
25 KiB
Go
823 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 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
|
|
}
|
|
}
|