package engine import ( "fmt" "gitea.iliadenisov.ru/developer/scrabble-solver/board" "gitea.iliadenisov.ru/developer/scrabble-solver/rules" "gitea.iliadenisov.ru/developer/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 Dir Direction // ActionPlay only: orientation of the main word (H/V) MainRow, MainCol int // ActionPlay only: the main word's first-letter coordinate 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, committed move into a dictionary-independent // MoveRecord for the given player, stamping the player and their running total. func (g *Game) recordPlay(player int, m scrabble.Move) MoveRecord { rec := g.decodeMove(m) rec.Player = player rec.Total = g.scores[player] return rec } // decodeMove decodes a scored move's placements and words into a // dictionary-independent MoveRecord, without the player or running total (which // only a committed play has). It backs both recordPlay and the non-committing // previews HintView and EvaluatePlay. func (g *Game) decodeMove(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{ Action: ActionPlay, Dir: fromScrabbleDir(m.Dir), MainRow: m.Main.Row, MainCol: m.Main.Col, Tiles: tiles, Words: words, Score: m.Score, } } // 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 }