package robot import ( "encoding/json" "os" "path/filepath" "strconv" "testing" "scrabble/backend/internal/engine" ) // The client-side offline robot (ui/src/lib/robot) reproduces the robot's move choice // so a local vs_ai game plays the same way as the server. These golden fixtures pin that // TS port to the real Go strategy. Being in-package, this emitter can exercise the // unexported mix / playToWin / deviates / selectMove that the port mirrors. type mixCase struct { Seed string `json:"seed"` Salt string `json:"salt"` Nums []int `json:"nums"` Value string `json:"value"` // the mix() uint64, as a decimal string (exceeds JS safe ints) } type decisionCase struct { Seed string `json:"seed"` MoveCount int `json:"moveCount"` MyScore int `json:"myScore"` OppScore int `json:"oppScore"` CandScores []int `json:"candScores"` RackLen int `json:"rackLen"` BagLen int `json:"bagLen"` PlayToWin bool `json:"playToWin"` Win bool `json:"win"` // the per-turn intent after the deviate flip Kind string `json:"kind"` Index int `json:"index"` // chosen candidate index for a play, else -1 } type strategyFixture struct { PlayToWinPercent int `json:"playToWinPercent"` Mix []mixCase `json:"mix"` Decisions []decisionCase `json:"decisions"` } // scenario is one selectMove situation exercised across several seeds. type scenario struct { moveCount, myScore, oppScore, rackLen, bagLen int cands []int } // TestEmitStrategyFixtures regenerates ui/src/lib/robot/testdata/strategy.json. It is // gated by EMIT_STRATEGY_FIXTURES so a normal `go test` skips it; the committed output is // what the TS parity test consumes. Regenerate with: // // EMIT_STRATEGY_FIXTURES=1 go test ./backend/internal/robot -run TestEmitStrategyFixtures func TestEmitStrategyFixtures(t *testing.T) { if os.Getenv("EMIT_STRATEGY_FIXTURES") == "" { t.Skip("set EMIT_STRATEGY_FIXTURES=1 to regenerate ui/src/lib/robot/testdata/strategy.json") } seeds := []int64{1, 42, -7, 1000003, 9999999999, 123456789, -123456789} mixCases := []mixCase{} addMix := func(seed int64, salt string, nums ...int) { mixCases = append(mixCases, mixCase{ Seed: strconv.FormatInt(seed, 10), Salt: salt, Nums: append([]int{}, nums...), Value: strconv.FormatUint(mix(seed, salt, nums...), 10), }) } for _, sd := range seeds { addMix(sd, "win") addMix(sd, "deviate", 0) addMix(sd, "deviate", 7) } scenarios := []scenario{ {3, 40, 55, 7, 20, []int{30, 12, 8}}, // behind, mid-game {10, 120, 90, 7, 8, []int{25, 18, 5}}, // ahead, late {1, 0, 0, 7, 30, []int{60, 30, 15, 7}}, // first move, big spread {20, 200, 40, 5, 2, []int{50, 20}}, // big lead, bag near empty (no deviate) {5, 30, 30, 7, 14, []int{20, 20, 20}}, // equal margins (tie-break) {8, 50, 60, 7, 20, []int{}}, // no play, bag can refill -> exchange {8, 50, 60, 3, 2, []int{}}, // no play, bag can't refill -> pass {15, 300, 10, 7, 6, []int{80, 40, 10}}, // overshoots the band -> nearest to +30 } decisions := []decisionCase{} for _, sd := range seeds { for _, sc := range scenarios { cands := make([]engine.MoveRecord, len(sc.cands)) for i, s := range sc.cands { cands[i] = engine.MoveRecord{Score: s, Player: i} } rack := make([]string, sc.rackLen) for i := range rack { rack[i] = "a" } ptw := playToWin(sd) win := ptw if deviates(sd, sc.moveCount, sc.bagLen) { win = !win } d := selectMove(cands, sc.myScore, sc.oppScore, win, defaultBand, rack, sc.bagLen) kind, index := "pass", -1 switch d.kind { case decidePlay: kind, index = "play", d.move.Player case decideExchange: kind = "exchange" } decisions = append(decisions, decisionCase{ Seed: strconv.FormatInt(sd, 10), MoveCount: sc.moveCount, MyScore: sc.myScore, OppScore: sc.oppScore, CandScores: append([]int{}, sc.cands...), RackLen: sc.rackLen, BagLen: sc.bagLen, PlayToWin: ptw, Win: win, Kind: kind, Index: index, }) } } fx := strategyFixture{PlayToWinPercent: playToWinPercent, Mix: mixCases, Decisions: decisions} dir := filepath.Join("..", "..", "..", "ui", "src", "lib", "robot", "testdata") if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir %s: %v", dir, err) } data, err := json.MarshalIndent(fx, "", " ") if err != nil { t.Fatalf("marshal: %v", err) } path := filepath.Join(dir, "strategy.json") if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { t.Fatalf("write %s: %v", path, err) } t.Logf("wrote %s (%d mix, %d decisions)", path, len(mixCases), len(decisions)) }