package scrabble import ( "errors" "fmt" "sort" "scrabble-solver/board" "scrabble-solver/internal/encoding" "scrabble-solver/rules" ) // coord maps a line coordinate (fixed, axis) to a board (row, col) for direction dir. // For Horizontal the fixed coordinate is the row and the axis runs along columns; for // Vertical it is the reverse. func coord(dir Direction, fixed, axis int) (row, col int) { if dir == Horizontal { return fixed, axis } return axis, fixed } // fixedAxis is the inverse of coord: it splits a (row, col) into the fixed and axis // coordinates for direction dir. func fixedAxis(dir Direction, row, col int) (fixed, axis int) { if dir == Horizontal { return row, col } return col, row } func perpendicular(d Direction) Direction { if d == Horizontal { return Vertical } return Horizontal } // Evaluate computes the words formed and the score for placing tiles on b in direction // dir under ruleset rs. It validates geometry — the tiles lie on one line, on empty // squares, and form a single contiguous run together with existing tiles — but does not // check the dictionary or board connectivity; ValidatePlay layers those on top. tiles // need not be sorted. func Evaluate(b *board.Board, rs *rules.Ruleset, dir Direction, tiles []Placement) (Move, error) { if len(tiles) == 0 { return Move{}, errors.New("scrabble: empty play") } ts := append([]Placement(nil), tiles...) sort.Slice(ts, func(i, j int) bool { _, ai := fixedAxis(dir, ts[i].Row, ts[i].Col) _, aj := fixedAxis(dir, ts[j].Row, ts[j].Col) return ai < aj }) fixed, _ := fixedAxis(dir, ts[0].Row, ts[0].Col) prevAxis := 0 for i, t := range ts { f, a := fixedAxis(dir, t.Row, t.Col) if f != fixed { return Move{}, errors.New("scrabble: tiles are not on one line") } if !b.InBounds(t.Row, t.Col) { return Move{}, fmt.Errorf("scrabble: tile (%d,%d) off board", t.Row, t.Col) } if !b.Empty(t.Row, t.Col) { return Move{}, fmt.Errorf("scrabble: square (%d,%d) is occupied", t.Row, t.Col) } if i > 0 && a == prevAxis { return Move{}, errors.New("scrabble: two tiles on the same square") } prevAxis = a } main, err := buildMainWord(b, rs, dir, fixed, ts) if err != nil { return Move{}, err } move := Move{Dir: dir, Tiles: ts, Main: main, Score: main.Score} for _, t := range ts { if cw, ok := crossWord(b, rs, dir, t); ok { move.Cross = append(move.Cross, cw) move.Score += cw.Score } } if len(ts) == rs.RackSize { move.Bonus = rs.Bingo move.Score += rs.Bingo } return move, nil } // buildMainWord assembles the word along dir through the (sorted) placements together // with the existing tiles that extend and bridge them, and scores it. New tiles apply // their squares' premiums; existing tiles score at face value. func buildMainWord(b *board.Board, rs *rules.Ruleset, dir Direction, fixed int, ts []Placement) (Word, error) { _, minA := fixedAxis(dir, ts[0].Row, ts[0].Col) _, maxA := fixedAxis(dir, ts[len(ts)-1].Row, ts[len(ts)-1].Col) start := minA for { r, c := coord(dir, fixed, start-1) if !b.Filled(r, c) { break } start-- } end := maxA for { r, c := coord(dir, fixed, end+1) if !b.Filled(r, c) { break } end++ } letters := make([]byte, 0, end-start+1) blanks := make([]bool, 0, end-start+1) letterSum, wordMult := 0, 1 ti := 0 for a := start; a <= end; a++ { r, c := coord(dir, fixed, a) if ti < len(ts) { if _, ta := fixedAxis(dir, ts[ti].Row, ts[ti].Col); ta == a { t := ts[ti] ti++ prem := rs.Premium(r, c) if !t.Blank { letterSum += rs.Values[t.Letter] * prem.LetterMult() } wordMult *= prem.WordMult() letters = append(letters, t.Letter) blanks = append(blanks, t.Blank) continue } } if b.Filled(r, c) { cell := b.At(r, c) l, bl := encoding.Letter(cell), encoding.IsBlank(cell) if !bl { letterSum += rs.Values[l] } letters = append(letters, l) blanks = append(blanks, bl) continue } return Word{}, fmt.Errorf("scrabble: gap in the play at line position %d", a) } wr, wc := coord(dir, fixed, start) return Word{Row: wr, Col: wc, Dir: dir, Letters: letters, Blanks: blanks, Score: letterSum * wordMult}, nil } // crossWord builds the perpendicular word formed by a single new tile, if any. It // returns ok=false when the tile has no perpendicular neighbour. func crossWord(b *board.Board, rs *rules.Ruleset, dir Direction, t Placement) (Word, bool) { cdir := perpendicular(dir) fixed, axis := fixedAxis(cdir, t.Row, t.Col) start := axis for { r, c := coord(cdir, fixed, start-1) if !b.Filled(r, c) { break } start-- } end := axis for { r, c := coord(cdir, fixed, end+1) if !b.Filled(r, c) { break } end++ } if start == end { return Word{}, false } letters := make([]byte, 0, end-start+1) blanks := make([]bool, 0, end-start+1) letterSum, wordMult := 0, 1 for a := start; a <= end; a++ { r, c := coord(cdir, fixed, a) if a == axis { prem := rs.Premium(r, c) if !t.Blank { letterSum += rs.Values[t.Letter] * prem.LetterMult() } wordMult *= prem.WordMult() letters = append(letters, t.Letter) blanks = append(blanks, t.Blank) } else { cell := b.At(r, c) l, bl := encoding.Letter(cell), encoding.IsBlank(cell) if !bl { letterSum += rs.Values[l] } letters = append(letters, l) blanks = append(blanks, bl) } } wr, wc := coord(cdir, fixed, start) return Word{Row: wr, Col: wc, Dir: cdir, Letters: letters, Blanks: blanks, Score: letterSum * wordMult}, true }