package engine import ( "fmt" "scrabble-solver/board" "scrabble-solver/rules" "scrabble-solver/scrabble" ) // ActionKind classifies a turn in the move log. type ActionKind uint8 const ( // ActionPlay is a tile placement forming one or more words. ActionPlay ActionKind = iota // ActionPass is a forfeited turn. ActionPass // ActionExchange swaps tiles with the bag. ActionExchange // ActionResign abandons the game. ActionResign // ActionTimeout is the auto-resignation a missed turn becomes; recorded by // the game domain in a later stage, never produced by the engine itself. ActionTimeout ) // String renders the action kind for logs and GCG export. func (a ActionKind) String() string { switch a { case ActionPlay: return "play" case ActionPass: return "pass" case ActionExchange: return "exchange" case ActionResign: return "resign" case ActionTimeout: return "timeout" } return "unknown" } // TileRecord is a single placed tile decoded to a concrete letter, so the move // log is independent of any dictionary and of the solver's internal encoding. type TileRecord struct { Row, Col int // Letter is the concrete character placed; for a blank, the letter it stands // for. Letter string // Blank reports whether the tile was placed from a blank (and so scored 0). Blank bool } // MoveRecord is one turn in the dictionary-independent history. A play carries // the placed tiles, the words it formed and its score; a pass or resignation // carries only the action; an exchange carries the number of tiles swapped. The // game domain adds timestamps and persistence around these values. type MoveRecord struct { Player int Action ActionKind Tiles []TileRecord // ActionPlay only Words []string // ActionPlay only: the main word first, then cross words Count int // ActionExchange only: number of tiles swapped Score int // points scored this turn (0 for non-plays) Total int // the player's running total after this turn } // recordPlay decodes a scored move into a dictionary-independent MoveRecord for // the given player. func (g *Game) recordPlay(player int, m scrabble.Move) MoveRecord { tiles := make([]TileRecord, len(m.Tiles)) for i, p := range m.Tiles { tiles[i] = TileRecord{Row: p.Row, Col: p.Col, Letter: g.letter(p.Letter), Blank: p.Blank} } words := make([]string, 0, 1+len(m.Cross)) words = append(words, g.word(m.Main)) for _, cw := range m.Cross { words = append(words, g.word(cw)) } return MoveRecord{ Player: player, Action: ActionPlay, Tiles: tiles, Words: words, Score: m.Score, Total: g.scores[player], } } // letter decodes one alphabet index to its concrete character via the ruleset's // alphabet. A malformed index yields the empty string. func (g *Game) letter(idx byte) string { s, err := g.rules.Alphabet.Character(idx) if err != nil { return "" } return s } // word decodes a solver word's letters to a concrete string via the ruleset's // alphabet. A malformed word yields the empty string. func (g *Game) word(w scrabble.Word) string { s, err := g.rules.Alphabet.Decode(w.Letters) if err != nil { return "" } return s } // ReplayBoard reconstructs the board the play records produce, on an empty board // for ruleset rs, using only the alphabet and never a dictionary: each recorded // letter is re-indexed and the placements are applied. Non-play records are // ignored. It realises the history invariant in docs/ARCHITECTURE.md §9.1 — an // archived game replays from decoded values plus its variant metadata alone. func ReplayBoard(rs *rules.Ruleset, records []MoveRecord) (*board.Board, error) { b := board.New(rs.Rows, rs.Cols) for _, rec := range records { if rec.Action != ActionPlay { continue } placements := make([]scrabble.Placement, len(rec.Tiles)) for i, t := range rec.Tiles { idx, err := rs.Alphabet.Index(t.Letter) if err != nil { return nil, fmt.Errorf("engine: replay letter %q at (%d,%d): %w", t.Letter, t.Row, t.Col, err) } placements[i] = scrabble.Placement{Row: t.Row, Col: t.Col, Letter: idx, Blank: t.Blank} } scrabble.Apply(b, scrabble.Move{Tiles: placements}) } return b, nil }