Stage 3: game domain (lifecycle, journal+cache, hint, word-check, GCG, stats)
internal/game drives the engine over a single match and owns everything the
engine does not: event-sourced persistence (a games row + an append-only decoded
move journal, with the live engine.Game kept warm in a cache and rebuilt by
replay on a miss), the play/pass/exchange/resign transitions with
validate-at-submit scoring, an unlimited score/legality preview, the hint
(per-game allowance + profile wallet), the word-check tool with complaint
capture, per-player game state, history and GCG export (Poslfit dialect), and a
background turn-timeout sweeper that auto-resigns overdue turns honouring each
player's daily away window. Like Stages 1-2 it is a service/store layer with no
HTTP; the gateway surface lands in Stage 6.
Engine: additive decoded domain API (Direction, SubmitPlay/SubmitExchange/
EvaluatePlay/HintView/Hand, MoveRecord.{Dir,MainRow,MainCol}, Registry.Lookup,
ParseVariant) so internal/game never imports scrabble-solver; and a Resign fix
so the resigner keeps their score yet never wins (the other player wins a
two-player game). Timeout reuses Resign.
Persistence: migration 00002 adds games, game_players, game_moves, complaints,
account_stats and extends accounts with away_start/away_end/hint_balance; go-jet
regenerated. account gained SpendHint. Config adds BACKEND_DICT_DIR (required),
BACKEND_DICT_VERSION, BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL, BACKEND_GAME_CACHE_TTL;
main loads the registry at boot (hard dependency) and starts the sweeper.
Tests: engine resign + decoded-API tests; game unit tests (GCG, away-window
boundaries, hint budget, cache, keyed mutex, payload); inttest integration
(lifecycle, replay equivalence, timeout sweep with away grace, resign stats,
hint policy, word-check/complaint, per-game-lock). Docs (PLAN, ARCHITECTURE,
FUNCTIONAL +_ru, TESTING, README) updated.
This commit is contained in:
@@ -58,18 +58,31 @@ type TileRecord struct {
|
||||
// carries only the action; an exchange carries the number of tiles swapped. The
|
||||
// game domain adds timestamps and persistence around these values.
|
||||
type MoveRecord struct {
|
||||
Player int
|
||||
Action ActionKind
|
||||
Tiles []TileRecord // ActionPlay only
|
||||
Words []string // ActionPlay only: the main word first, then cross words
|
||||
Count int // ActionExchange only: number of tiles swapped
|
||||
Score int // points scored this turn (0 for non-plays)
|
||||
Total int // the player's running total after this turn
|
||||
Player int
|
||||
Action ActionKind
|
||||
Dir Direction // ActionPlay only: orientation of the main word (H/V)
|
||||
MainRow, MainCol int // ActionPlay only: the main word's first-letter coordinate
|
||||
Tiles []TileRecord // ActionPlay only
|
||||
Words []string // ActionPlay only: the main word first, then cross words
|
||||
Count int // ActionExchange only: number of tiles swapped
|
||||
Score int // points scored this turn (0 for non-plays)
|
||||
Total int // the player's running total after this turn
|
||||
}
|
||||
|
||||
// recordPlay decodes a scored move into a dictionary-independent MoveRecord for
|
||||
// the given player.
|
||||
// recordPlay decodes a scored, committed move into a dictionary-independent
|
||||
// MoveRecord for the given player, stamping the player and their running total.
|
||||
func (g *Game) recordPlay(player int, m scrabble.Move) MoveRecord {
|
||||
rec := g.decodeMove(m)
|
||||
rec.Player = player
|
||||
rec.Total = g.scores[player]
|
||||
return rec
|
||||
}
|
||||
|
||||
// decodeMove decodes a scored move's placements and words into a
|
||||
// dictionary-independent MoveRecord, without the player or running total (which
|
||||
// only a committed play has). It backs both recordPlay and the non-committing
|
||||
// previews HintView and EvaluatePlay.
|
||||
func (g *Game) decodeMove(m scrabble.Move) MoveRecord {
|
||||
tiles := make([]TileRecord, len(m.Tiles))
|
||||
for i, p := range m.Tiles {
|
||||
tiles[i] = TileRecord{Row: p.Row, Col: p.Col, Letter: g.letter(p.Letter), Blank: p.Blank}
|
||||
@@ -80,12 +93,13 @@ func (g *Game) recordPlay(player int, m scrabble.Move) MoveRecord {
|
||||
words = append(words, g.word(cw))
|
||||
}
|
||||
return MoveRecord{
|
||||
Player: player,
|
||||
Action: ActionPlay,
|
||||
Tiles: tiles,
|
||||
Words: words,
|
||||
Score: m.Score,
|
||||
Total: g.scores[player],
|
||||
Action: ActionPlay,
|
||||
Dir: fromScrabbleDir(m.Dir),
|
||||
MainRow: m.Main.Row,
|
||||
MainCol: m.Main.Col,
|
||||
Tiles: tiles,
|
||||
Words: words,
|
||||
Score: m.Score,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"scrabble-solver/scrabble"
|
||||
)
|
||||
|
||||
// blankLetter is how a blank tile is written in the decoded, domain-facing API:
|
||||
// in a rack it is an undesignated blank, and in an exchange it is a blank being
|
||||
// swapped. A blank placed on the board is a TileRecord with Blank set and Letter
|
||||
// holding the concrete letter it stands for.
|
||||
const blankLetter = "?"
|
||||
|
||||
// Direction is the orientation of a play as seen by the game domain. It decouples
|
||||
// the domain from the solver's own Direction type: internal/engine is the only
|
||||
// backend package that imports scrabble-solver (see docs/ARCHITECTURE.md §5), so
|
||||
// the engine accepts and returns decoded, solver-free values.
|
||||
type Direction uint8
|
||||
|
||||
const (
|
||||
// Horizontal lays a word left to right along a row.
|
||||
Horizontal Direction = iota
|
||||
// Vertical lays a word top to bottom down a column.
|
||||
Vertical
|
||||
)
|
||||
|
||||
// String renders the direction as "H" or "V", the form the move journal and GCG
|
||||
// export use.
|
||||
func (d Direction) String() string {
|
||||
if d == Vertical {
|
||||
return "V"
|
||||
}
|
||||
return "H"
|
||||
}
|
||||
|
||||
// scrabbleDir maps the domain Direction to the solver's Direction.
|
||||
func (d Direction) scrabbleDir() scrabble.Direction {
|
||||
if d == Vertical {
|
||||
return scrabble.Vertical
|
||||
}
|
||||
return scrabble.Horizontal
|
||||
}
|
||||
|
||||
// fromScrabbleDir maps the solver's Direction to the domain Direction.
|
||||
func fromScrabbleDir(d scrabble.Direction) Direction {
|
||||
if d == scrabble.Vertical {
|
||||
return Vertical
|
||||
}
|
||||
return Horizontal
|
||||
}
|
||||
|
||||
// SubmitPlay validates and applies the current player's play described in decoded
|
||||
// terms: each TileRecord carries a concrete letter (the letter a blank stands for
|
||||
// when Blank is set) and a board coordinate. It encodes the tiles through the
|
||||
// ruleset alphabet and delegates to Play, so it returns the same errors
|
||||
// (ErrTilesNotOnRack, ErrIllegalPlay, ErrGameOver) plus ErrIllegalPlay when a
|
||||
// letter is outside the variant's alphabet.
|
||||
func (g *Game) SubmitPlay(dir Direction, tiles []TileRecord) (MoveRecord, error) {
|
||||
placements, err := g.placements(tiles)
|
||||
if err != nil {
|
||||
return MoveRecord{}, err
|
||||
}
|
||||
return g.Play(dir.scrabbleDir(), placements)
|
||||
}
|
||||
|
||||
// SubmitExchange swaps the current player's tiles, named in decoded terms: a
|
||||
// concrete letter per tile, or "?" for a blank. It encodes them and delegates to
|
||||
// Exchange, returning the same errors plus ErrTilesNotOnRack when a letter is
|
||||
// outside the variant's alphabet.
|
||||
func (g *Game) SubmitExchange(tiles []string) (MoveRecord, error) {
|
||||
raw, err := g.encodeTiles(tiles)
|
||||
if err != nil {
|
||||
return MoveRecord{}, err
|
||||
}
|
||||
return g.Exchange(raw)
|
||||
}
|
||||
|
||||
// EvaluatePlay scores and validates a tentative play without committing it,
|
||||
// backing the unlimited "what would my next move score, and is it legal?" tool.
|
||||
// It returns the decoded move (placed tiles, the words it forms and its score)
|
||||
// or ErrIllegalPlay when the solver rejects it. The board, racks, bag and turn
|
||||
// are left untouched.
|
||||
func (g *Game) EvaluatePlay(dir Direction, tiles []TileRecord) (MoveRecord, error) {
|
||||
if g.over {
|
||||
return MoveRecord{}, ErrGameOver
|
||||
}
|
||||
placements, err := g.placements(tiles)
|
||||
if err != nil {
|
||||
return MoveRecord{}, err
|
||||
}
|
||||
move, err := g.solver.ValidatePlay(g.board, dir.scrabbleDir(), placements)
|
||||
if err != nil {
|
||||
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
|
||||
}
|
||||
return g.decodeMove(move), nil
|
||||
}
|
||||
|
||||
// HintView returns the highest-scoring legal play for the current player as a
|
||||
// decoded MoveRecord and true, or a zero record and false when there is none. It
|
||||
// is the one-per-game hint's top-1 move in domain-facing form.
|
||||
func (g *Game) HintView() (MoveRecord, bool) {
|
||||
move, ok := g.Hint()
|
||||
if !ok {
|
||||
return MoveRecord{}, false
|
||||
}
|
||||
return g.decodeMove(move), true
|
||||
}
|
||||
|
||||
// Hand returns the player's current rack decoded to concrete letters, with "?"
|
||||
// for each undesignated blank. The order mirrors the internal hand. It supplies
|
||||
// the GCG rack field and the per-player game-state view.
|
||||
func (g *Game) Hand(player int) []string {
|
||||
hand := g.hands[player]
|
||||
out := make([]string, len(hand))
|
||||
for i, t := range hand {
|
||||
if t == blankTile {
|
||||
out[i] = blankLetter
|
||||
continue
|
||||
}
|
||||
out[i] = g.letter(t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// placements encodes decoded tiles into solver placements via the ruleset
|
||||
// alphabet, wrapping a bad letter as ErrIllegalPlay.
|
||||
func (g *Game) placements(tiles []TileRecord) ([]scrabble.Placement, error) {
|
||||
out := make([]scrabble.Placement, len(tiles))
|
||||
for i, t := range tiles {
|
||||
idx, err := g.rules.Alphabet.Index(t.Letter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: letter %q at (%d,%d): %v", ErrIllegalPlay, t.Letter, t.Row, t.Col, err)
|
||||
}
|
||||
out[i] = scrabble.Placement{Row: t.Row, Col: t.Col, Letter: idx, Blank: t.Blank}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// encodeTiles encodes decoded exchange tiles ("?" for a blank, otherwise a
|
||||
// concrete letter) into the internal byte form, wrapping a bad letter as
|
||||
// ErrTilesNotOnRack (the caller cannot hold a tile it cannot name).
|
||||
func (g *Game) encodeTiles(tiles []string) ([]byte, error) {
|
||||
raw := make([]byte, len(tiles))
|
||||
for i, t := range tiles {
|
||||
if t == blankLetter {
|
||||
raw[i] = blankTile
|
||||
continue
|
||||
}
|
||||
idx, err := g.rules.Alphabet.Index(t)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: tile %q: %v", ErrTilesNotOnRack, t, err)
|
||||
}
|
||||
raw[i] = idx
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDirectionString covers the H/V rendering used by the journal and GCG.
|
||||
func TestDirectionString(t *testing.T) {
|
||||
if Horizontal.String() != "H" {
|
||||
t.Errorf("Horizontal = %q, want H", Horizontal.String())
|
||||
}
|
||||
if Vertical.String() != "V" {
|
||||
t.Errorf("Vertical = %q, want V", Vertical.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubmitPlayMatchesHint plays the decoded top-1 move through SubmitPlay and
|
||||
// checks it scores and advances exactly like the underlying solver move, proving
|
||||
// the decode→encode round trip.
|
||||
func TestSubmitPlayMatchesHint(t *testing.T) {
|
||||
g := openingGame(t)
|
||||
hint, ok := g.HintView()
|
||||
if !ok {
|
||||
t.Fatal("opening game has no hint")
|
||||
}
|
||||
rec, err := g.SubmitPlay(hint.Dir, hint.Tiles)
|
||||
if err != nil {
|
||||
t.Fatalf("submit play: %v", err)
|
||||
}
|
||||
if rec.Score != hint.Score {
|
||||
t.Errorf("played score = %d, want hint score %d", rec.Score, hint.Score)
|
||||
}
|
||||
if rec.Action != ActionPlay {
|
||||
t.Errorf("action = %v, want play", rec.Action)
|
||||
}
|
||||
if g.Score(0) != hint.Score {
|
||||
t.Errorf("player 0 score = %d, want %d", g.Score(0), hint.Score)
|
||||
}
|
||||
if g.ToMove() != 1 {
|
||||
t.Errorf("to move = %d, want 1 after a play", g.ToMove())
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluatePlayDoesNotCommit checks the preview scores like a real play but
|
||||
// leaves the board, scores, turn and bag untouched.
|
||||
func TestEvaluatePlayDoesNotCommit(t *testing.T) {
|
||||
g := openingGame(t)
|
||||
hint, ok := g.HintView()
|
||||
if !ok {
|
||||
t.Fatal("opening game has no hint")
|
||||
}
|
||||
boardBefore := g.BoardClone()
|
||||
scoreBefore, toMoveBefore, bagBefore := g.Score(0), g.ToMove(), g.BagLen()
|
||||
|
||||
rec, err := g.EvaluatePlay(hint.Dir, hint.Tiles)
|
||||
if err != nil {
|
||||
t.Fatalf("evaluate play: %v", err)
|
||||
}
|
||||
if rec.Score != hint.Score {
|
||||
t.Errorf("evaluated score = %d, want %d", rec.Score, hint.Score)
|
||||
}
|
||||
if !boardsEqual(boardBefore, g.BoardClone()) {
|
||||
t.Error("evaluate must not mutate the board")
|
||||
}
|
||||
if g.Score(0) != scoreBefore || g.ToMove() != toMoveBefore || g.BagLen() != bagBefore {
|
||||
t.Errorf("evaluate mutated state: score %d->%d, toMove %d->%d, bag %d->%d",
|
||||
scoreBefore, g.Score(0), toMoveBefore, g.ToMove(), bagBefore, g.BagLen())
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluatePlayRejectsIllegal reports ErrIllegalPlay for a play the solver
|
||||
// rejects (a single off-centre opening tile) without committing.
|
||||
func TestEvaluatePlayRejectsIllegal(t *testing.T) {
|
||||
g := newEnglishGame(t, 1)
|
||||
letter := g.Hand(0)[0]
|
||||
_, err := g.EvaluatePlay(Horizontal, []TileRecord{{Row: 0, Col: 0, Letter: letter}})
|
||||
if !errors.Is(err, ErrIllegalPlay) {
|
||||
t.Errorf("evaluate off-centre opening = %v, want ErrIllegalPlay", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubmitExchangeWithBlank exchanges a full rack that includes a blank,
|
||||
// exercising the "?" encoding path, and checks the turn advances.
|
||||
func TestSubmitExchangeWithBlank(t *testing.T) {
|
||||
g := gameWithBlankInHand(t)
|
||||
hand := g.Hand(0)
|
||||
if !slices.Contains(hand, blankLetter) {
|
||||
t.Fatalf("hand %v has no blank", hand)
|
||||
}
|
||||
rec, err := g.SubmitExchange(hand)
|
||||
if err != nil {
|
||||
t.Fatalf("submit exchange: %v", err)
|
||||
}
|
||||
if rec.Action != ActionExchange || rec.Count != len(hand) {
|
||||
t.Errorf("exchange record = %+v, want action exchange count %d", rec, len(hand))
|
||||
}
|
||||
if g.ToMove() != 1 {
|
||||
t.Errorf("to move = %d, want 1 after an exchange", g.ToMove())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandDecodesBlank checks Hand returns concrete letters and "?" for a blank,
|
||||
// agreeing with the internal hand.
|
||||
func TestHandDecodesBlank(t *testing.T) {
|
||||
g := gameWithBlankInHand(t)
|
||||
hand := g.Hand(0)
|
||||
if len(hand) != g.rules.RackSize {
|
||||
t.Fatalf("hand size = %d, want %d", len(hand), g.rules.RackSize)
|
||||
}
|
||||
var blanks int
|
||||
for _, s := range hand {
|
||||
if s == "" {
|
||||
t.Errorf("hand %v has an empty letter", hand)
|
||||
}
|
||||
if s == blankLetter {
|
||||
blanks++
|
||||
}
|
||||
}
|
||||
var want int
|
||||
for _, t := range g.hands[0] {
|
||||
if t == blankTile {
|
||||
want++
|
||||
}
|
||||
}
|
||||
if blanks != want {
|
||||
t.Errorf("decoded blanks = %d, want %d", blanks, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistryLookup covers word-check membership and its error taxonomy.
|
||||
func TestRegistryLookup(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
variant Variant
|
||||
word string
|
||||
want bool
|
||||
}{
|
||||
{"english hit", VariantEnglish, "cat", true},
|
||||
{"english miss", VariantEnglish, "zzzz", false},
|
||||
{"russian hit", VariantRussianScrabble, "кот", true},
|
||||
{"erudit hit", VariantErudit, "кот", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := testReg.Lookup(tc.variant, testVersion, tc.word)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("lookup %q = %v, want %v", tc.word, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if _, err := testReg.Lookup(VariantEnglish, "missing", "cat"); !errors.Is(err, ErrUnknownVersion) {
|
||||
t.Errorf("unknown version = %v, want ErrUnknownVersion", err)
|
||||
}
|
||||
if _, err := NewRegistry().Lookup(VariantEnglish, testVersion, "cat"); !errors.Is(err, ErrUnknownVariant) {
|
||||
t.Errorf("empty registry = %v, want ErrUnknownVariant", err)
|
||||
}
|
||||
if _, err := testReg.Lookup(VariantEnglish, testVersion, "кот"); err == nil {
|
||||
t.Error("out-of-alphabet lookup must error")
|
||||
}
|
||||
}
|
||||
|
||||
// gameWithBlankInHand returns a two-player English game whose player 0 holds at
|
||||
// least one blank, searching a deterministic range of seeds.
|
||||
func gameWithBlankInHand(t *testing.T) *Game {
|
||||
t.Helper()
|
||||
for seed := int64(1); seed <= 200; seed++ {
|
||||
g := newEnglishGame(t, seed)
|
||||
if slices.Contains(g.Hand(0), blankLetter) {
|
||||
return g
|
||||
}
|
||||
}
|
||||
t.Fatal("no opening rack with a blank found in seeds 1..200")
|
||||
return nil
|
||||
}
|
||||
@@ -66,6 +66,18 @@ func Variants() []Variant {
|
||||
return []Variant{VariantEnglish, VariantRussianScrabble, VariantErudit}
|
||||
}
|
||||
|
||||
// ParseVariant maps a stable label produced by Variant.String back to its
|
||||
// Variant, or returns ErrUnknownVariant. It is the inverse the game domain uses
|
||||
// to read a persisted variant.
|
||||
func ParseVariant(s string) (Variant, error) {
|
||||
for _, v := range Variants() {
|
||||
if v.String() == s {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("%w: %q", ErrUnknownVariant, s)
|
||||
}
|
||||
|
||||
// Ruleset returns the scrabble-solver ruleset for variant. It needs no
|
||||
// dictionary, so it supports dictionary-independent board replay (see
|
||||
// ReplayBoard) from a finished game's variant metadata alone.
|
||||
|
||||
@@ -72,6 +72,7 @@ type Game struct {
|
||||
scorelessRun int
|
||||
over bool
|
||||
reason EndReason
|
||||
resignedSeat int // seat that resigned, or -1; excludes the resigner from winning
|
||||
log []MoveRecord
|
||||
}
|
||||
|
||||
@@ -98,14 +99,15 @@ func New(reg *Registry, opts Options) (*Game, error) {
|
||||
|
||||
rs := solver.Rules()
|
||||
g := &Game{
|
||||
solver: solver,
|
||||
rules: rs,
|
||||
variant: opts.Variant,
|
||||
version: version,
|
||||
board: board.New(rs.Rows, rs.Cols),
|
||||
bag: NewBag(rs, opts.Seed),
|
||||
hands: make([][]byte, opts.Players),
|
||||
scores: make([]int, opts.Players),
|
||||
solver: solver,
|
||||
rules: rs,
|
||||
variant: opts.Variant,
|
||||
version: version,
|
||||
board: board.New(rs.Rows, rs.Cols),
|
||||
bag: NewBag(rs, opts.Seed),
|
||||
hands: make([][]byte, opts.Players),
|
||||
scores: make([]int, opts.Players),
|
||||
resignedSeat: -1,
|
||||
}
|
||||
for i := range g.hands {
|
||||
g.hands[i] = g.bag.Draw(rs.RackSize)
|
||||
@@ -193,14 +195,19 @@ func (g *Game) Exchange(tiles []byte) (MoveRecord, error) {
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// Resign ends the game on the current player's turn (EndReason EndResign). In a
|
||||
// two-player match this is the only resignation case; richer multi-player
|
||||
// handling belongs to the game domain in a later stage.
|
||||
// Resign ends the game on the current player's turn (EndReason EndResign). The
|
||||
// resigner always forfeits the win and keeps their accumulated score (it is
|
||||
// neither zeroed nor docked a rack adjustment); the win goes to the highest
|
||||
// score among the remaining seats — in a two-player match, unconditionally to
|
||||
// the other player. A missed-turn timeout reuses Resign in the game domain, so
|
||||
// it inherits this win/loss. Richer multi-player drop-out handling belongs to
|
||||
// the game domain in a later stage.
|
||||
func (g *Game) Resign() (MoveRecord, error) {
|
||||
if g.over {
|
||||
return MoveRecord{}, ErrGameOver
|
||||
}
|
||||
player := g.toMove
|
||||
g.resignedSeat = player
|
||||
rec := MoveRecord{Player: player, Action: ActionResign, Total: g.scores[player]}
|
||||
g.log = append(g.log, rec)
|
||||
g.finish(EndResign)
|
||||
@@ -288,10 +295,13 @@ func (g *Game) finish(reason EndReason) {
|
||||
|
||||
// applyEndAdjustment settles the unplayed racks. When a player goes out (bag
|
||||
// empty, rack empty) they gain the sum of every opponent's rack value and each
|
||||
// opponent loses their own; otherwise (scoreless stalemate or resignation) each
|
||||
// player simply forfeits their own rack value.
|
||||
// opponent loses their own. A scoreless stalemate forfeits each player's own
|
||||
// rack value. A resignation freezes the scores: the win is decided by winner
|
||||
// (which excludes the resigner), so no rack adjustment is applied and the
|
||||
// resigner keeps their accumulated score.
|
||||
func (g *Game) applyEndAdjustment(reason EndReason) {
|
||||
if reason == EndOutOfTiles {
|
||||
switch reason {
|
||||
case EndOutOfTiles:
|
||||
out := g.toMove
|
||||
var bonus int
|
||||
for i := range g.hands {
|
||||
@@ -303,10 +313,10 @@ func (g *Game) applyEndAdjustment(reason EndReason) {
|
||||
bonus += v
|
||||
}
|
||||
g.scores[out] += bonus
|
||||
return
|
||||
}
|
||||
for i := range g.hands {
|
||||
g.scores[i] -= g.rackValue(i)
|
||||
case EndScoreless:
|
||||
for i := range g.hands {
|
||||
g.scores[i] -= g.rackValue(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,15 +334,20 @@ func (g *Game) endTurnAfterScoreless() {
|
||||
func (g *Game) advance() { g.toMove = (g.toMove + 1) % len(g.hands) }
|
||||
|
||||
// winner returns the index of the single highest-scoring player, or -1 on a tie
|
||||
// for the lead or while the game is unfinished.
|
||||
// for the lead or while the game is unfinished. After a resignation the resigner
|
||||
// is excluded, so a two-player game returns the remaining player even when the
|
||||
// resigner led on score.
|
||||
func (g *Game) winner() int {
|
||||
if !g.over {
|
||||
return -1
|
||||
}
|
||||
best, tie := 0, false
|
||||
for i := 1; i < len(g.scores); i++ {
|
||||
best, tie := -1, false
|
||||
for i := range g.scores {
|
||||
if g.reason == EndResign && i == g.resignedSeat {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case g.scores[i] > g.scores[best]:
|
||||
case best == -1 || g.scores[i] > g.scores[best]:
|
||||
best, tie = i, false
|
||||
case g.scores[i] == g.scores[best]:
|
||||
tie = true
|
||||
|
||||
@@ -135,6 +135,29 @@ func (r *Registry) Versions(v Variant) []string {
|
||||
return versions
|
||||
}
|
||||
|
||||
// Lookup reports whether word is present in the (variant, version) dictionary,
|
||||
// backing the unlimited word-check tool. It returns ErrUnknownVariant or
|
||||
// ErrUnknownVersion when that dictionary is not resident, and an error when word
|
||||
// contains a character outside the variant's alphabet. The word is matched as
|
||||
// given; callers normalise case to the variant's alphabet first.
|
||||
func (r *Registry) Lookup(v Variant, version, word string) (bool, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
versions, ok := r.entries[v]
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%w: %s", ErrUnknownVariant, v)
|
||||
}
|
||||
e, ok := versions[version]
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%w: %s/%s", ErrUnknownVersion, v, version)
|
||||
}
|
||||
idx, err := e.finder.IndexOf(word)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("engine: lookup %q in %s/%s: %w", word, v, version, err)
|
||||
}
|
||||
return idx >= 0, nil
|
||||
}
|
||||
|
||||
// Close releases every resident dictionary and empties the registry. It is safe
|
||||
// to call more than once; the first close error is returned after all finders
|
||||
// have been closed.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package engine
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestResignLeadingPlayerStillLoses is the core of the resignation fix: a player
|
||||
// who resigns loses even when leading on score, the remaining player wins, and
|
||||
// the resigner's score is frozen (no end-game rack adjustment).
|
||||
func TestResignLeadingPlayerStillLoses(t *testing.T) {
|
||||
g := openingGame(t)
|
||||
|
||||
hint, ok := g.HintView()
|
||||
if !ok {
|
||||
t.Fatal("opening game has no hint")
|
||||
}
|
||||
played, err := g.SubmitPlay(hint.Dir, hint.Tiles)
|
||||
if err != nil {
|
||||
t.Fatalf("player 0 play: %v", err)
|
||||
}
|
||||
if played.Score == 0 {
|
||||
t.Fatal("opening play scored 0; pick a different seed")
|
||||
}
|
||||
|
||||
if _, err := g.Pass(); err != nil { // player 1
|
||||
t.Fatalf("player 1 pass: %v", err)
|
||||
}
|
||||
|
||||
// Player 0 is now on turn and leads 0:played.Score; resigning must still lose.
|
||||
if _, err := g.Resign(); err != nil {
|
||||
t.Fatalf("player 0 resign: %v", err)
|
||||
}
|
||||
|
||||
if !g.Over() || g.Reason() != EndResign {
|
||||
t.Fatalf("game over=%v reason=%v, want over with resign", g.Over(), g.Reason())
|
||||
}
|
||||
res := g.Result()
|
||||
if res.Winner != 1 {
|
||||
t.Errorf("winner = %d, want 1 (the non-resigner) despite the resigner leading", res.Winner)
|
||||
}
|
||||
if g.Score(0) != played.Score {
|
||||
t.Errorf("resigner score = %d, want frozen at %d (no rack adjustment)", g.Score(0), played.Score)
|
||||
}
|
||||
if g.Score(1) != 0 {
|
||||
t.Errorf("opponent score = %d, want 0", g.Score(1))
|
||||
}
|
||||
if g.Score(0) <= g.Score(1) {
|
||||
t.Fatal("test precondition: resigner should lead on raw score")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResignTrailingPlayerLoses covers the ordinary case: the trailing player
|
||||
// resigns and the leader wins.
|
||||
func TestResignTrailingPlayerLoses(t *testing.T) {
|
||||
g := openingGame(t)
|
||||
|
||||
hint, ok := g.HintView()
|
||||
if !ok {
|
||||
t.Fatal("opening game has no hint")
|
||||
}
|
||||
if _, err := g.SubmitPlay(hint.Dir, hint.Tiles); err != nil { // player 0 scores
|
||||
t.Fatalf("player 0 play: %v", err)
|
||||
}
|
||||
|
||||
// Player 1 (trailing 0 points) resigns.
|
||||
if _, err := g.Resign(); err != nil {
|
||||
t.Fatalf("player 1 resign: %v", err)
|
||||
}
|
||||
if res := g.Result(); res.Winner != 0 {
|
||||
t.Errorf("winner = %d, want 0", res.Winner)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResignOnFinishedGame rejects a second transition.
|
||||
func TestResignOnFinishedGame(t *testing.T) {
|
||||
g := newEnglishGame(t, 1)
|
||||
if _, err := g.Resign(); err != nil {
|
||||
t.Fatalf("first resign: %v", err)
|
||||
}
|
||||
if _, err := g.Resign(); err == nil {
|
||||
t.Error("resign on a finished game must error")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user