10412fee8e
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Failing after 12s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Failing after 1s
CI / deploy (pull_request) Has been skipped
- Resign on the opponent's turn: engine ResignSeat(seat) resigns a specific seat (not just toMove); game.Resign bypasses the turn check and forfeits the actor's seat. - Quick-match cancel was a UI no-op (only stopped polling): add the full path (REST /lobby/cancel -> gateway lobby.cancel -> client) and clear the matchmaker's pending result on Cancel, so a cancelled search is dequeued (no 'already queued', no later robot-substituted game). NewGame dequeues on cancel and on abandon. - Lobby win/loss: result.ts ranked by score, so a 0-0 resignation read as a win. The winner now takes rank 1 and the viewer is placed from rank 2 — matching the game-detail screen. - Friend request to a robot: robots no longer block requests; the request stays pending and expires (friendRequestTTL), mirroring a human who ignores it. - Nudge cooldown: ErrNudgeTooSoon now maps to a distinct nudge_too_soon code with a correct message; the chat nudge button disables during the hourly cooldown; the nudge note reads 'Waiting for your move!' (button keeps the Nudge action label). Tests: engine/service off-turn resign, matchmaker cancel-clears-result, friend-to-robot inttest, result.ts 0-0 resignation, nudge_too_soon mapping.
536 lines
16 KiB
Go
536 lines
16 KiB
Go
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) {
|
|
return g.ResignSeat(g.toMove)
|
|
}
|
|
|
|
// ResignSeat resigns a specific seat regardless of whose turn it is, so a player
|
|
// may forfeit on the opponent's turn. The resigning seat always loses (winner()
|
|
// skips resigned seats). The turn cursor only advances when the seat that resigned
|
|
// was the one to move; resigning an off-turn seat leaves the current player's turn
|
|
// intact. It returns ErrGameOver on a finished game or for an out-of-range or
|
|
// already-resigned seat.
|
|
func (g *Game) ResignSeat(seat int) (MoveRecord, error) {
|
|
if g.over {
|
|
return MoveRecord{}, ErrGameOver
|
|
}
|
|
if seat < 0 || seat >= len(g.hands) || g.resigned[seat] {
|
|
return MoveRecord{}, ErrGameOver
|
|
}
|
|
g.resigned[seat] = true
|
|
g.disposeHand(seat)
|
|
rec := MoveRecord{Player: seat, Action: ActionResign, Total: g.scores[seat]}
|
|
g.log = append(g.log, rec)
|
|
if g.activeCount() <= 1 {
|
|
g.finish(EndResign)
|
|
} else if seat == g.toMove {
|
|
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
|
|
}
|