d7337d24ea
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m48s
Russian "Эрудит" treats a word laid on the board as belonging to the game: it cannot be laid again. Neither the solver, the backend nor the offline JS port knew the rule, so a player (and the robot) could replay a word freely. Official Scrabble places no such restriction, so both Scrabble variants keep playing unrestricted. The rule applies in two ways. A play whose main word is already on the board is illegal, and is neither accepted nor generated. A play whose perpendicular cross-word is already there stands — that word is incidental to laying the main word — but scores nothing. The set of played words is the game's own move journal, main words and cross-words alike, compared decoded, so a word spelled with a blank is the same word. It lives in the game layer, not the solver: only a game knows its history, and the solver stays stateless and standard-rules. The backend applies it at submit, at the move preview and over generated moves (filtering and re-ranking them, so neither the robot nor the hint can offer a play the engine would then refuse); the client port does the same for the offline engine and for the on-device preview of an online game. The rule is pinned per game (games.no_repeat_words, set from the variant at creation) rather than keyed on the variant, because a game is replayed from its journal on every open. Applied retroactively it would make an already-played repeat illegal — closing that game as a draw — and would rescore a play whose cross-word repeats an earlier word, shifting a live game's totals. Games created before the rule keep playing without it. The flag rides the wire as a trailing field because the client's preview must score the way the server does, and offline games pin the same answer in their own record.
582 lines
18 KiB
Go
582 lines
18 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
|
|
// EndAborted fires when a committed game can no longer be reconstructed from its
|
|
// journal — a recorded move became illegal under tightened rules — and is closed as a
|
|
// draw rather than left unopenable. See (*Game).Abort.
|
|
EndAborted
|
|
)
|
|
|
|
// 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"
|
|
case EndAborted:
|
|
return "aborted"
|
|
}
|
|
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
|
|
// MultipleWordsPerTurn selects standard Scrabble when true: every cross-word a
|
|
// play forms must be a valid word and is scored. When false the game uses the
|
|
// "single word per turn" rule — only the main word is validated and scored and
|
|
// perpendicular cross-words are ignored. Callers always set this explicitly; the
|
|
// zero value (false) is the single-word rule.
|
|
MultipleWordsPerTurn bool
|
|
// NoRepeatWords enables the "Эрудит" rule under which a word already laid on the
|
|
// board cannot be laid again (see norepeat.go). It is pinned per game rather than
|
|
// derived from the variant, so a game started before the rule existed replays and
|
|
// scores unchanged; the zero value (false) is the unrestricted rule.
|
|
NoRepeatWords bool
|
|
}
|
|
|
|
// 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
|
|
multipleWords bool // false = single-word rule (perpendicular cross-words ignored)
|
|
noRepeatWords bool // true = a word already on the board cannot be laid again
|
|
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,
|
|
multipleWords: opts.MultipleWordsPerTurn,
|
|
noRepeatWords: opts.NoRepeatWords,
|
|
}
|
|
for i := range g.hands {
|
|
g.hands[i] = g.bag.Draw(rs.RackSize)
|
|
}
|
|
return g, nil
|
|
}
|
|
|
|
// playOpts returns the solver play options for this game's rules. Under the single-word
|
|
// rule (multipleWords false) the solver ignores perpendicular cross-words: only the main
|
|
// word is validated and scored, and move generation is not constrained by cross-words.
|
|
func (g *Game) playOpts() scrabble.PlayOptions {
|
|
return scrabble.PlayOptions{IgnoreCrossWords: !g.multipleWords}
|
|
}
|
|
|
|
// 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.ValidatePlayOpts(g.board, dir, tiles, g.playOpts())
|
|
if err != nil {
|
|
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
|
|
}
|
|
move, err = g.noRepeat(move, g.playedWords())
|
|
if err != nil {
|
|
return MoveRecord{}, 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.noRepeatFilter(g.solver.GenerateMovesOpts(g.board, g.rackOf(g.toMove), scrabble.Both, g.playOpts()))
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// Abort closes a still-running game as a draw with EndAborted and no rack adjustment. The
|
|
// service calls it when a committed game can no longer be reconstructed from its journal —
|
|
// a recorded move became illegal under tightened rules — so the game ends gracefully
|
|
// instead of being left unopenable. It is a no-op on an already-finished game.
|
|
func (g *Game) Abort() {
|
|
if g.over {
|
|
return
|
|
}
|
|
g.finish(EndAborted)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
if g.reason == EndAborted {
|
|
return -1 // an aborted game is a draw regardless of the running scores
|
|
}
|
|
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
|
|
}
|