package engine import ( "encoding/json" "os" "path/filepath" "testing" "gitea.iliadenisov.ru/developer/scrabble-solver/rules" ) // The offline engine (ui/src/lib/localgame) reproduces the end-of-game rack settlement and the // winner rule so a local game finishes with the same scores as the server. These golden fixtures // pin the ported pure functions (applyEndAdjustment / winner / rackValue) to the real Go engine. // Being in-package, this emitter constructs Game values directly and drives the unexported // end-game math on chosen positions. type endCaseIn struct { Name string `json:"name"` Variant string `json:"variant"` Reason string `json:"reason"` Hands [][]int `json:"hands"` Scores []int `json:"scores"` Resigned []bool `json:"resigned"` ToMove int `json:"toMove"` } type endCaseOut struct { endCaseIn ScoresAfter []int `json:"scoresAfter"` Winner int `json:"winner"` } func rulesetFor(variant string) *rules.Ruleset { switch variant { case "scrabble_ru": return rules.RussianScrabble() case "erudit_ru": return rules.Erudit() default: return rules.English() } } func reasonFor(s string) EndReason { switch s { case "out_of_tiles": return EndOutOfTiles case "scoreless": return EndScoreless case "resign": return EndResign case "aborted": return EndAborted } return EndNotOver } func handsBytes(hands [][]int) [][]byte { out := make([][]byte, len(hands)) for i, h := range hands { b := make([]byte, len(h)) for j, x := range h { b[j] = byte(x) } out[i] = b } return out } // TestEmitEndgameFixtures regenerates ui/src/lib/localgame/testdata/endgame.json. Gated by // EMIT_ENGINE_FIXTURES. Regenerate with: // // EMIT_ENGINE_FIXTURES=1 go test ./backend/internal/engine -run TestEmitEndgameFixtures func TestEmitEndgameFixtures(t *testing.T) { if os.Getenv("EMIT_ENGINE_FIXTURES") == "" { t.Skip("set EMIT_ENGINE_FIXTURES=1 to regenerate ui/src/lib/localgame/testdata/endgame.json") } cases := []endCaseIn{ {"out-basic", "scrabble_en", "out_of_tiles", [][]int{{}, {0, 1, 2}}, []int{50, 40}, []bool{false, false}, 0}, {"out-blank", "scrabble_en", "out_of_tiles", [][]int{{}, {255, 0}}, []int{30, 30}, []bool{false, false}, 0}, {"out-erudit-yo", "erudit_ru", "out_of_tiles", [][]int{{}, {6, 32}}, []int{10, 10}, []bool{false, false}, 0}, {"out-tie", "scrabble_en", "out_of_tiles", [][]int{{}, {}}, []int{30, 30}, []bool{false, false}, 0}, {"out-3p", "scrabble_ru", "out_of_tiles", [][]int{{}, {0, 1}, {2}}, []int{10, 10, 10}, []bool{false, false, false}, 0}, {"scoreless", "scrabble_en", "scoreless", [][]int{{0, 1}, {2, 3}}, []int{20, 20}, []bool{false, false}, 0}, {"resign-2p", "scrabble_en", "resign", [][]int{{}, {0}}, []int{100, 10}, []bool{true, false}, 1}, {"resign-3p", "scrabble_en", "resign", [][]int{{}, {}, {}}, []int{50, 60, 40}, []bool{false, true, false}, 0}, {"aborted", "scrabble_en", "aborted", [][]int{{0}, {1}}, []int{40, 30}, []bool{false, false}, 0}, } out := make([]endCaseOut, 0, len(cases)) for _, c := range cases { rs := rulesetFor(c.Variant) scores := append([]int(nil), c.Scores...) g := &Game{ rules: rs, hands: handsBytes(c.Hands), scores: scores, resigned: c.Resigned, toMove: c.ToMove, } reason := reasonFor(c.Reason) g.over = true g.reason = reason g.applyEndAdjustment(reason) out = append(out, endCaseOut{endCaseIn: c, ScoresAfter: g.scores, Winner: g.winner()}) } dir := filepath.Join("..", "..", "..", "ui", "src", "lib", "localgame", "testdata") if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir %s: %v", dir, err) } data, err := json.MarshalIndent(map[string]any{"cases": out}, "", " ") if err != nil { t.Fatalf("marshal: %v", err) } path := filepath.Join(dir, "endgame.json") if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { t.Fatalf("write %s: %v", path, err) } t.Logf("wrote %s (%d cases)", path, len(out)) }