package engine import ( "fmt" "gitea.iliadenisov.ru/developer/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 } // Candidates returns every legal play for the current player as decoded // MoveRecords, ranked by descending score (so the first entry equals HintView's // move). It is empty when the player has no legal play. The robot opponent picks // from these by margin without importing the solver; each record carries the // move's score, so a caller can choose by resulting score difference rather than // always taking the maximum. func (g *Game) Candidates() []MoveRecord { moves := g.GenerateMoves() out := make([]MoveRecord, len(moves)) for i, m := range moves { out[i] = g.decodeMove(m) } return out } // 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 }