package engine import ( "fmt" "gitea.iliadenisov.ru/developer/scrabble-solver/board" "gitea.iliadenisov.ru/developer/scrabble-solver/rack" "gitea.iliadenisov.ru/developer/scrabble-solver/rules" "gitea.iliadenisov.ru/developer/scrabble-solver/scrabble" ) // scorelessLimit is the number of consecutive scoreless turns (passes and // exchanges) that ends a game, per docs/ARCHITECTURE.md §6. const scorelessLimit = 6 // EndReason explains why a game finished. type EndReason uint8 const ( // EndNotOver marks a game still in progress. EndNotOver EndReason = iota // EndOutOfTiles fires when the bag is empty and a player empties their rack. EndOutOfTiles // EndScoreless fires after scorelessLimit consecutive passes/exchanges. EndScoreless // EndResign fires when a player resigns. EndResign ) // String renders the end reason for logs and diagnostics. func (r EndReason) String() string { switch r { case EndNotOver: return "not_over" case EndOutOfTiles: return "out_of_tiles" case EndScoreless: return "scoreless" case EndResign: return "resign" } return "unknown" } // DropoutTiles is the per-game disposition of a dropped-out player's rack when // they resign or time out of a game with three or more seats: the tiles are // either removed from play or returned to the bag. It is agreed at game creation // (docs/ARCHITECTURE.md §6) and is irrelevant to a two-player game, which ends on // the first drop-out. In both dispositions the leaver's rack is never revealed to // the remaining players. type DropoutTiles uint8 const ( // DropoutRemove removes the dropped player's tiles from play; this is the // default, so the zero value matches it. DropoutRemove DropoutTiles = iota // DropoutReturn returns the dropped player's tiles to the bag, where the // remaining players may draw them. DropoutReturn ) // String renders the disposition as the stable label the game domain persists. func (d DropoutTiles) String() string { if d == DropoutReturn { return "return" } return "remove" } // ParseDropoutTiles maps a persisted label back to a DropoutTiles, reporting // ErrUnknownDropoutTiles for an unrecognised value. func ParseDropoutTiles(s string) (DropoutTiles, error) { switch s { case "remove": return DropoutRemove, nil case "return": return DropoutReturn, nil } return 0, fmt.Errorf("%w: %q", ErrUnknownDropoutTiles, s) } // Options configures a new game. type Options struct { // Variant selects the rules and dictionary. Variant Variant // Version pins the dictionary version; empty selects the registry's latest. Version string // Players is the number of seats, 2 to 4. Players int // Seed seeds the tile bag, making the game reproducible. Seed int64 // DropoutTiles is the disposition of a dropped-out player's tiles in a game // with three or more seats; the zero value removes them from play. DropoutTiles DropoutTiles } // Game is the in-memory state of a single match and the pure rules engine over // it. It owns the board, the bag, each player's hand, the running scores, whose // turn it is and the decoded move log, and it detects the end of the game. It // performs no scheduling, persistence or I/O and is not safe for concurrent use. type Game struct { solver *scrabble.Solver rules *rules.Ruleset variant Variant version string board *board.Board bag *Bag hands [][]byte // per player, alphabet-index bytes with blankTile for blanks scores []int toMove int scorelessRun int over bool reason EndReason resigned []bool // per seat; a resigned seat is skipped and cannot win dropoutTiles DropoutTiles // disposition of a resigned seat's tiles log []MoveRecord } // New starts a game described by opts over a dictionary from reg. It resolves // the solver (failing with ErrUnknownVariant/ErrUnknownVersion), builds an empty // board and a seeded bag, and deals each player a full rack. func New(reg *Registry, opts Options) (*Game, error) { if opts.Players < 2 || opts.Players > 4 { return nil, fmt.Errorf("engine: players must be between 2 and 4, got %d", opts.Players) } var ( solver *scrabble.Solver version = opts.Version err error ) if version == "" { version, solver, err = reg.Latest(opts.Variant) } else { solver, err = reg.Solver(opts.Variant, version) } if err != nil { return nil, err } 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), resigned: make([]bool, opts.Players), dropoutTiles: opts.DropoutTiles, } for i := range g.hands { g.hands[i] = g.bag.Draw(rs.RackSize) } return g, nil } // Play validates and applies the current player's placement of tiles forming a // word in direction dir. It scores the play, refills the rack from the bag, // advances the turn and may end the game. It returns ErrTilesNotOnRack when the // player does not hold the tiles, ErrIllegalPlay when the solver rejects the // play, and ErrGameOver on a finished game. func (g *Game) Play(dir scrabble.Direction, tiles []scrabble.Placement) (MoveRecord, error) { if g.over { return MoveRecord{}, ErrGameOver } player := g.toMove if err := g.checkHolds(player, placementTiles(tiles)); err != nil { return MoveRecord{}, err } move, err := g.solver.ValidatePlay(g.board, dir, tiles) if err != nil { return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err) } scrabble.Apply(g.board, move) g.removeFromHand(player, placementTiles(tiles)) g.scores[player] += move.Score g.refill(player) g.scorelessRun = 0 rec := g.recordPlay(player, move) g.log = append(g.log, rec) if len(g.hands[player]) == 0 && g.bag.Len() == 0 { g.finish(EndOutOfTiles) } else { g.advance() } return rec, nil } // Pass forfeits the current player's turn. It extends the scoreless run, which // may end the game (EndScoreless), and otherwise advances the turn. func (g *Game) Pass() (MoveRecord, error) { if g.over { return MoveRecord{}, ErrGameOver } player := g.toMove g.scorelessRun++ rec := MoveRecord{Player: player, Action: ActionPass, Total: g.scores[player]} g.log = append(g.log, rec) g.endTurnAfterScoreless() return rec, nil } // Exchange swaps the current player's tiles (alphabet-index bytes, blankTile for // blanks) for fresh ones. It is legal only while the bag holds at least a full // rack. The fresh tiles are drawn before the swapped ones return, so a player // cannot draw back their own tiles. It extends the scoreless run, which may end // the game (EndScoreless). func (g *Game) Exchange(tiles []byte) (MoveRecord, error) { if g.over { return MoveRecord{}, ErrGameOver } if len(tiles) == 0 { return MoveRecord{}, ErrNothingToExchange } if g.bag.Len() < g.rules.RackSize { return MoveRecord{}, ErrNotEnoughTilesToExchange } player := g.toMove if err := g.checkHolds(player, tiles); err != nil { return MoveRecord{}, err } g.removeFromHand(player, tiles) g.hands[player] = append(g.hands[player], g.bag.Draw(len(tiles))...) g.bag.Return(tiles) g.scorelessRun++ rec := MoveRecord{Player: player, Action: ActionExchange, Count: len(tiles), Total: g.scores[player]} g.log = append(g.log, rec) g.endTurnAfterScoreless() return rec, nil } // Resign drops the current player out of the game. The resigner always forfeits // the win and keeps their accumulated score (it is neither zeroed nor docked a // rack adjustment), and their rack is disposed of per the game's DropoutTiles // setting without ever being revealed to the remaining players. In a game with // three or more seats the others play on with the resigned seat skipped, until // one active seat is left (it wins) or the game ends by the ordinary conditions; // the game finishes with EndResign only once a single active seat remains. A // two-player game therefore ends on the first resignation, the other player // winning regardless of score. A missed-turn timeout reuses Resign in the game // domain, so it inherits this win/loss. func (g *Game) Resign() (MoveRecord, error) { if g.over { return MoveRecord{}, ErrGameOver } player := g.toMove g.resigned[player] = true g.disposeHand(player) rec := MoveRecord{Player: player, Action: ActionResign, Total: g.scores[player]} g.log = append(g.log, rec) if g.activeCount() <= 1 { g.finish(EndResign) } else { g.advance() } return rec, nil } // GenerateMoves returns every legal play for the current player's rack, ranked // by descending score. It is empty when the player has no legal play. func (g *Game) GenerateMoves() []scrabble.Move { return g.solver.GenerateMoves(g.board, g.rackOf(g.toMove), scrabble.Both) } // Hint returns the highest-scoring legal play for the current player and true, // or the zero move and false when there is none. It is the top-1 move the // one-per-game hint reveals. func (g *Game) Hint() (scrabble.Move, bool) { moves := g.GenerateMoves() if len(moves) == 0 { return scrabble.Move{}, false } return moves[0], true } // Variant returns the variant the game is played under. func (g *Game) Variant() Variant { return g.variant } // Version returns the pinned dictionary version. func (g *Game) Version() string { return g.version } // Players returns the number of seats in the game. func (g *Game) Players() int { return len(g.hands) } // ToMove returns the index of the player whose turn it is. On a finished game it // is the player who made the final move. func (g *Game) ToMove() int { return g.toMove } // Over reports whether the game has finished. func (g *Game) Over() bool { return g.over } // Reason returns why the game finished, or EndNotOver while it is in progress. func (g *Game) Reason() EndReason { return g.reason } // Score returns the current score of the player at index player. func (g *Game) Score(player int) int { return g.scores[player] } // BagLen returns the number of tiles left in the bag. func (g *Game) BagLen() int { return g.bag.Len() } // BoardClone returns a deep copy of the board, safe for the caller to read or // mutate without affecting the game. func (g *Game) BoardClone() *board.Board { return g.board.Clone() } // Log returns a copy of the dictionary-independent move log. func (g *Game) Log() []MoveRecord { out := make([]MoveRecord, len(g.log)) copy(out, g.log) return out } // Result is the outcome of a finished game. type Result struct { Over bool Reason EndReason Scores []int // Winner is the index of the single highest score, or -1 on a tie or while // the game is unfinished. Winner int } // Result reports the current outcome. Final scores already include the standard // end-game rack adjustment applied when the game finished. func (g *Game) Result() Result { scores := make([]int, len(g.scores)) copy(scores, g.scores) return Result{Over: g.over, Reason: g.reason, Scores: scores, Winner: g.winner()} } // finish marks the game over with reason and applies the end-game rack // adjustment to the scores. func (g *Game) finish(reason EndReason) { g.over = true g.reason = reason g.applyEndAdjustment(reason) } // 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. 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) { switch reason { case EndOutOfTiles: out := g.toMove var bonus int for i := range g.hands { if i == out { continue } v := g.rackValue(i) g.scores[i] -= v bonus += v } g.scores[out] += bonus case EndScoreless: for i := range g.hands { g.scores[i] -= g.rackValue(i) } } } // endTurnAfterScoreless ends the game when the scoreless run reaches the limit, // otherwise advances the turn. Used by Pass and Exchange. func (g *Game) endTurnAfterScoreless() { if g.scorelessRun >= scorelessLimit { g.finish(EndScoreless) return } g.advance() } // advance moves play to the next active (non-resigned) seat. While a game is in // progress at least two seats are active, so a next active seat always exists; // the loop leaves toMove unchanged in the degenerate all-but-one-resigned case, // which Resign turns into a finished game instead. func (g *Game) advance() { n := len(g.hands) for i := 1; i <= n; i++ { next := (g.toMove + i) % n if !g.resigned[next] { g.toMove = next return } } } // activeCount returns the number of seats that have not resigned. func (g *Game) activeCount() int { n := 0 for _, r := range g.resigned { if !r { n++ } } return n } // disposeHand empties a resigned player's rack per the game's DropoutTiles // setting: it returns the tiles to the bag or removes them from play. Either way // the hand is cleared, so the end-game rack adjustment ignores the seat and the // rack is never exposed. func (g *Game) disposeHand(player int) { if g.dropoutTiles == DropoutReturn { g.bag.Return(g.hands[player]) } g.hands[player] = nil } // winner returns the index of the single highest-scoring player, or -1 on a tie // for the lead or while the game is unfinished. Resigned (dropped-out) seats are // always excluded, so a two-player game returns the remaining player even when // the resigner led on score, and a multi-player game never awards the win to a // seat that left. func (g *Game) winner() int { if !g.over { return -1 } best, tie := -1, false for i := range g.scores { if g.resigned[i] { continue } switch { case best == -1 || g.scores[i] > g.scores[best]: best, tie = i, false case g.scores[i] == g.scores[best]: tie = true } } if tie { return -1 } return best } // rackOf builds a generation rack from player's hand. func (g *Game) rackOf(player int) rack.Rack { r := rack.New(g.rules.Size()) for _, t := range g.hands[player] { if t == blankTile { r.AddBlank() } else { r.Add(t) } } return r } // rackValue sums the tile values left on player's hand; blanks count zero. func (g *Game) rackValue(player int) int { var v int for _, t := range g.hands[player] { if t != blankTile { v += g.rules.Values[t] } } return v } // checkHolds reports ErrTilesNotOnRack unless player holds every tile in want. func (g *Game) checkHolds(player int, want []byte) error { avail := tileCounts(g.hands[player]) for tile, n := range tileCounts(want) { if avail[tile] < n { return ErrTilesNotOnRack } } return nil } // removeFromHand takes one tile per entry of used off player's hand. func (g *Game) removeFromHand(player int, used []byte) { hand := g.hands[player] for _, t := range used { for i, h := range hand { if h == t { hand = append(hand[:i], hand[i+1:]...) break } } } g.hands[player] = hand } // refill draws from the bag until player's hand is full or the bag is empty. func (g *Game) refill(player int) { if need := g.rules.RackSize - len(g.hands[player]); need > 0 { g.hands[player] = append(g.hands[player], g.bag.Draw(need)...) } } // placementTiles maps placements to the tiles they consume (blankTile for blanks). func placementTiles(tiles []scrabble.Placement) []byte { out := make([]byte, len(tiles)) for i, p := range tiles { if p.Blank { out[i] = blankTile } else { out[i] = p.Letter } } return out } // tileCounts tallies a multiset of tiles by value. func tileCounts(tiles []byte) map[byte]int { m := make(map[byte]int, len(tiles)) for _, t := range tiles { m[t]++ } return m }