diff --git a/backend/internal/robot/strategyfixture_test.go b/backend/internal/robot/strategyfixture_test.go new file mode 100644 index 0000000..78f1fa6 --- /dev/null +++ b/backend/internal/robot/strategyfixture_test.go @@ -0,0 +1,146 @@ +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)) +} diff --git a/ui/src/lib/robot/strategy.parity.test.ts b/ui/src/lib/robot/strategy.parity.test.ts new file mode 100644 index 0000000..aa1effb --- /dev/null +++ b/ui/src/lib/robot/strategy.parity.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { mix, playToWin, deviates, decide, DEFAULT_BAND } from './strategy'; + +// Conformance gate for the ported robot strategy: the client must fold the game seed and +// choose its action exactly as backend/internal/robot/strategy.go does, so a local vs_ai +// game plays the same. Golden fixtures come from the in-package emitter +// (backend/internal/robot/strategyfixture_test.go). + +interface MixCase { + seed: string; + salt: string; + nums: number[]; + value: string; +} +interface DecisionCase { + seed: string; + moveCount: number; + myScore: number; + oppScore: number; + candScores: number[]; + rackLen: number; + bagLen: number; + playToWin: boolean; + win: boolean; + kind: string; + index: number; +} +interface Fixture { + playToWinPercent: number; + mix: MixCase[]; + decisions: DecisionCase[]; +} + +const fx = JSON.parse( + readFileSync(new URL('./testdata/strategy.json', import.meta.url), 'utf8'), +) as Fixture; + +describe('robot strategy parity vs Go', () => { + it('mix folds the seed like Go FNV-1a', () => { + for (const c of fx.mix) { + expect(mix(BigInt(c.seed), c.salt, ...c.nums), `${c.seed}/${c.salt}/${c.nums}`).toBe(BigInt(c.value)); + } + }); + + it('chooses the same action as the Go robot', () => { + for (const c of fx.decisions) { + const seed = BigInt(c.seed); + const cands = c.candScores.map((score, index) => ({ score, index })); + const rack = new Array(c.rackLen).fill('a'); + + expect(playToWin(seed), `playToWin ${c.seed}`).toBe(c.playToWin); + + // the per-turn intent after the occasional off-strategy deviation + let win = playToWin(seed); + if (deviates(seed, c.moveCount, c.bagLen)) win = !win; + expect(win, `win ${c.seed}/${c.moveCount}/${c.bagLen}`).toBe(c.win); + + const dec = decide(seed, c.moveCount, cands, c.myScore, c.oppScore, rack, c.bagLen, DEFAULT_BAND); + expect(dec.kind, `kind ${JSON.stringify(c)}`).toBe(c.kind); + if (dec.kind === 'play') { + expect(dec.move.index, `index ${JSON.stringify(c)}`).toBe(c.index); + } else if (dec.kind === 'exchange') { + expect(dec.exchange.length).toBe(c.rackLen); + } + } + }); +}); diff --git a/ui/src/lib/robot/strategy.ts b/ui/src/lib/robot/strategy.ts new file mode 100644 index 0000000..4c0a724 --- /dev/null +++ b/ui/src/lib/robot/strategy.ts @@ -0,0 +1,180 @@ +// The offline robot's move choice, ported from backend/internal/robot/strategy.go so a +// local vs_ai game plays exactly as the server robot does: it picks the candidate whose +// resulting score margin lands closest to a target band (playing to win ~40% of games and +// to lose the rest), with an occasional off-strategy "wobble" that fades as the bag empties. +// +// Everything derives deterministically from the game's bag seed, folded by FNV-1a (mix), +// so the choice is reproducible. The 64-bit hash is computed with BigInt to stay bit-exact +// with Go's uint64 arithmetic; faithfulness is pinned by strategy.parity.test.ts against +// golden fixtures. Only the move-choice slice of strategy.go is ported — the server's +// think-time, sleep-window and nudge scheduling are irrelevant to on-device play. + +// playToWinPercent is the probability, in percent, that the robot plays to win for a whole +// game; the rest of the time it plays to lose, so the human wins about 60% of games. +const playToWinPercent = 40; + +// deviateMaxProb is the peak probability of a single off-strategy move, held through the +// opening and midgame; it tapers linearly to 0 over the last deviateTaperTiles bag tiles. +const deviateMaxProb = 0.2; +const deviateTaperTiles = 14; + +const FNV_OFFSET = 14695981039346656037n; +const FNV_PRIME = 1099511628211n; +const TWO_POW_64 = 1n << 64n; +const MASK64 = TWO_POW_64 - 1n; + +// u64le returns the 8 little-endian bytes of v reduced mod 2^64, matching Go's +// binary.LittleEndian.PutUint64(uint64(int64(v))) — negatives wrap two's-complement. +function u64le(v: bigint): number[] { + let x = ((v % TWO_POW_64) + TWO_POW_64) % TWO_POW_64; + const out: number[] = []; + for (let i = 0; i < 8; i++) { + out.push(Number(x & 0xffn)); + x >>= 8n; + } + return out; +} + +/** + * mix folds the game seed and a salt (a label plus optional integers such as the move + * index) into a stable 64-bit value, mirroring robot.mix (FNV-1a over the seed's 8 bytes, + * the salt bytes, then each number's 8 bytes). seed is the game's bag seed. + */ +export function mix(seed: bigint, salt: string, ...nums: number[]): bigint { + let h = FNV_OFFSET; + const feed = (b: number): void => { + h = ((h ^ BigInt(b & 0xff)) * FNV_PRIME) & MASK64; + }; + for (const b of u64le(seed)) feed(b); + for (let i = 0; i < salt.length; i++) feed(salt.charCodeAt(i)); + for (const n of nums) for (const b of u64le(BigInt(n))) feed(b); + return h; +} + +/** unitFloat maps a mixed value to a float in [0, 1), mirroring robot.unitFloat. */ +export function unitFloat(v: bigint): number { + return Number(v) / Number(TWO_POW_64); +} + +/** + * playToWin reports the robot's once-per-game decision to play to win, derived from the + * seed so it is fixed for the whole game. Mirrors robot.playToWin. + */ +export function playToWin(seed: bigint): boolean { + return mix(seed, 'win') % 100n < BigInt(playToWinPercent); +} + +// deviateProb is the probability of a single off-strategy move given the tiles left in the +// bag: deviateMaxProb through the midgame, tapering to 0 over the last deviateTaperTiles. +function deviateProb(bagLen: number): number { + if (bagLen <= 0) return 0; + if (bagLen >= deviateTaperTiles) return deviateMaxProb; + return (deviateMaxProb * bagLen) / deviateTaperTiles; +} + +/** + * deviates reports whether the robot plays a single move against its per-game intent at + * moveCount, given the tiles left in the bag — a deterministic per-turn draw, never firing + * once the bag is empty. Mirrors robot.deviates. + */ +export function deviates(seed: bigint, moveCount: number, bagLen: number): boolean { + const p = deviateProb(bagLen); + if (p <= 0) return false; + return unitFloat(mix(seed, 'deviate', moveCount)) < p; +} + +/** MarginBand is an inclusive target range for the resulting score margin (own score after + * the move minus the opponent's). */ +export interface MarginBand { + lo: number; + hi: number; +} + +/** DEFAULT_BAND aims to lead by 1..30 when playing to win (negated when playing to lose), + * matching robot.defaultBand. */ +export const DEFAULT_BAND: MarginBand = { lo: 1, hi: 30 }; + +/** + * Decision is the robot's chosen action: a play (the picked candidate), an exchange of the + * listed tiles, or a pass. Generic over the candidate type (needs only a score) and the + * rack tile type. + */ +export type Decision = + | { kind: 'play'; move: C } + | { kind: 'exchange'; exchange: R[] } + | { kind: 'pass' }; + +// distanceToBand is how far m lies outside [lo, hi], or 0 when inside. +function distanceToBand(m: number, lo: number, hi: number): number { + if (m < lo) return lo - m; + if (m > hi) return m - hi; + return 0; +} + +/** + * selectMove chooses the robot's action from the ranked candidate plays and the current + * scores. With at least one legal play it picks the candidate whose resulting margin + * (myScore + score - oppScore) is closest to the band (negated when not playing to win), + * breaking ties toward the conservative edge (smallest lead when winning, smallest deficit + * when losing). With no legal play it exchanges the whole rack when the bag can refill it, + * else passes. Mirrors robot.selectMove. + */ +export function selectMove( + cands: readonly C[], + myScore: number, + oppScore: number, + win: boolean, + band: MarginBand, + rack: readonly R[], + bagLen: number, +): Decision { + if (cands.length === 0) { + if (rack.length > 0 && bagLen >= rack.length) { + return { kind: 'exchange', exchange: [...rack] }; + } + return { kind: 'pass' }; + } + + let lo = band.lo; + let hi = band.hi; + if (!win) { + lo = -band.hi; + hi = -band.lo; + } + + const margin = (c: C): number => myScore + c.score - oppScore; + let best = 0; + let bestDist = Infinity; + for (let i = 0; i < cands.length; i++) { + const m = margin(cands[i]); + const dist = distanceToBand(m, lo, hi); + if (dist < bestDist) { + best = i; + bestDist = dist; + } else if (dist === bestDist) { + // Conservative tie-break: keep the lead (win) or the deficit (lose) small. + if ((win && m < margin(cands[best])) || (!win && m > margin(cands[best]))) best = i; + } + } + return { kind: 'play', move: cands[best] }; +} + +/** + * decide is the robot's whole per-turn choice: the once-per-game play-to-win intent, flipped + * for this move by the occasional deviation, fed to selectMove. Mirrors the decision slice of + * robot (*Service).act. seed is the game's bag seed; cands are the ranked legal plays. + */ +export function decide( + seed: bigint, + moveCount: number, + cands: readonly C[], + myScore: number, + oppScore: number, + rack: readonly R[], + bagLen: number, + band: MarginBand = DEFAULT_BAND, +): Decision { + let win = playToWin(seed); + if (deviates(seed, moveCount, bagLen)) win = !win; + return selectMove(cands, myScore, oppScore, win, band, rack, bagLen); +} diff --git a/ui/src/lib/robot/testdata/strategy.json b/ui/src/lib/robot/testdata/strategy.json new file mode 100644 index 0000000..a6f10ff --- /dev/null +++ b/ui/src/lib/robot/testdata/strategy.json @@ -0,0 +1,1057 @@ +{ + "playToWinPercent": 40, + "mix": [ + { + "seed": "1", + "salt": "win", + "nums": [], + "value": "3463786966066269450" + }, + { + "seed": "1", + "salt": "deviate", + "nums": [ + 0 + ], + "value": "16739214574173863474" + }, + { + "seed": "1", + "salt": "deviate", + "nums": [ + 7 + ], + "value": "4989416721367080085" + }, + { + "seed": "42", + "salt": "win", + "nums": [], + "value": "11996028682749602919" + }, + { + "seed": "42", + "salt": "deviate", + "nums": [ + 0 + ], + "value": "3199075231987854375" + }, + { + "seed": "42", + "salt": "deviate", + "nums": [ + 7 + ], + "value": "6019611456924280128" + }, + { + "seed": "-7", + "salt": "win", + "nums": [], + "value": "5394446344798824475" + }, + { + "seed": "-7", + "salt": "deviate", + "nums": [ + 0 + ], + "value": "13744638055244324387" + }, + { + "seed": "-7", + "salt": "deviate", + "nums": [ + 7 + ], + "value": "15976953462211913796" + }, + { + "seed": "1000003", + "salt": "win", + "nums": [], + "value": "18263589017178353807" + }, + { + "seed": "1000003", + "salt": "deviate", + "nums": [ + 0 + ], + "value": "7838718956381548031" + }, + { + "seed": "1000003", + "salt": "deviate", + "nums": [ + 7 + ], + "value": "10659255181317973784" + }, + { + "seed": "9999999999", + "salt": "win", + "nums": [], + "value": "6610647830354154516" + }, + { + "seed": "9999999999", + "salt": "deviate", + "nums": [ + 0 + ], + "value": "12675031630059091344" + }, + { + "seed": "9999999999", + "salt": "deviate", + "nums": [ + 7 + ], + "value": "9854495405122665591" + }, + { + "seed": "123456789", + "salt": "win", + "nums": [], + "value": "12237171742041588149" + }, + { + "seed": "123456789", + "salt": "deviate", + "nums": [ + 0 + ], + "value": "16735187508536178449" + }, + { + "seed": "123456789", + "salt": "deviate", + "nums": [ + 7 + ], + "value": "9450020469664573878" + }, + { + "seed": "-123456789", + "salt": "win", + "nums": [], + "value": "11055856957027161150" + }, + { + "seed": "-123456789", + "salt": "deviate", + "nums": [ + 0 + ], + "value": "14082038702359824862" + }, + { + "seed": "-123456789", + "salt": "deviate", + "nums": [ + 7 + ], + "value": "2920461667521877817" + } + ], + "decisions": [ + { + "seed": "1", + "moveCount": 3, + "myScore": 40, + "oppScore": 55, + "candScores": [ + 30, + 12, + 8 + ], + "rackLen": 7, + "bagLen": 20, + "playToWin": false, + "win": false, + "kind": "play", + "index": 1 + }, + { + "seed": "1", + "moveCount": 10, + "myScore": 120, + "oppScore": 90, + "candScores": [ + 25, + 18, + 5 + ], + "rackLen": 7, + "bagLen": 8, + "playToWin": false, + "win": false, + "kind": "play", + "index": 2 + }, + { + "seed": "1", + "moveCount": 1, + "myScore": 0, + "oppScore": 0, + "candScores": [ + 60, + 30, + 15, + 7 + ], + "rackLen": 7, + "bagLen": 30, + "playToWin": false, + "win": true, + "kind": "play", + "index": 3 + }, + { + "seed": "1", + "moveCount": 20, + "myScore": 200, + "oppScore": 40, + "candScores": [ + 50, + 20 + ], + "rackLen": 5, + "bagLen": 2, + "playToWin": false, + "win": false, + "kind": "play", + "index": 1 + }, + { + "seed": "1", + "moveCount": 5, + "myScore": 30, + "oppScore": 30, + "candScores": [ + 20, + 20, + 20 + ], + "rackLen": 7, + "bagLen": 14, + "playToWin": false, + "win": false, + "kind": "play", + "index": 0 + }, + { + "seed": "1", + "moveCount": 8, + "myScore": 50, + "oppScore": 60, + "candScores": [], + "rackLen": 7, + "bagLen": 20, + "playToWin": false, + "win": false, + "kind": "exchange", + "index": -1 + }, + { + "seed": "1", + "moveCount": 8, + "myScore": 50, + "oppScore": 60, + "candScores": [], + "rackLen": 3, + "bagLen": 2, + "playToWin": false, + "win": false, + "kind": "pass", + "index": -1 + }, + { + "seed": "1", + "moveCount": 15, + "myScore": 300, + "oppScore": 10, + "candScores": [ + 80, + 40, + 10 + ], + "rackLen": 7, + "bagLen": 6, + "playToWin": false, + "win": false, + "kind": "play", + "index": 2 + }, + { + "seed": "42", + "moveCount": 3, + "myScore": 40, + "oppScore": 55, + "candScores": [ + 30, + 12, + 8 + ], + "rackLen": 7, + "bagLen": 20, + "playToWin": true, + "win": true, + "kind": "play", + "index": 0 + }, + { + "seed": "42", + "moveCount": 10, + "myScore": 120, + "oppScore": 90, + "candScores": [ + 25, + 18, + 5 + ], + "rackLen": 7, + "bagLen": 8, + "playToWin": true, + "win": true, + "kind": "play", + "index": 2 + }, + { + "seed": "42", + "moveCount": 1, + "myScore": 0, + "oppScore": 0, + "candScores": [ + 60, + 30, + 15, + 7 + ], + "rackLen": 7, + "bagLen": 30, + "playToWin": true, + "win": false, + "kind": "play", + "index": 3 + }, + { + "seed": "42", + "moveCount": 20, + "myScore": 200, + "oppScore": 40, + "candScores": [ + 50, + 20 + ], + "rackLen": 5, + "bagLen": 2, + "playToWin": true, + "win": true, + "kind": "play", + "index": 1 + }, + { + "seed": "42", + "moveCount": 5, + "myScore": 30, + "oppScore": 30, + "candScores": [ + 20, + 20, + 20 + ], + "rackLen": 7, + "bagLen": 14, + "playToWin": true, + "win": true, + "kind": "play", + "index": 0 + }, + { + "seed": "42", + "moveCount": 8, + "myScore": 50, + "oppScore": 60, + "candScores": [], + "rackLen": 7, + "bagLen": 20, + "playToWin": true, + "win": false, + "kind": "exchange", + "index": -1 + }, + { + "seed": "42", + "moveCount": 8, + "myScore": 50, + "oppScore": 60, + "candScores": [], + "rackLen": 3, + "bagLen": 2, + "playToWin": true, + "win": true, + "kind": "pass", + "index": -1 + }, + { + "seed": "42", + "moveCount": 15, + "myScore": 300, + "oppScore": 10, + "candScores": [ + 80, + 40, + 10 + ], + "rackLen": 7, + "bagLen": 6, + "playToWin": true, + "win": true, + "kind": "play", + "index": 2 + }, + { + "seed": "-7", + "moveCount": 3, + "myScore": 40, + "oppScore": 55, + "candScores": [ + 30, + 12, + 8 + ], + "rackLen": 7, + "bagLen": 20, + "playToWin": false, + "win": false, + "kind": "play", + "index": 1 + }, + { + "seed": "-7", + "moveCount": 10, + "myScore": 120, + "oppScore": 90, + "candScores": [ + 25, + 18, + 5 + ], + "rackLen": 7, + "bagLen": 8, + "playToWin": false, + "win": false, + "kind": "play", + "index": 2 + }, + { + "seed": "-7", + "moveCount": 1, + "myScore": 0, + "oppScore": 0, + "candScores": [ + 60, + 30, + 15, + 7 + ], + "rackLen": 7, + "bagLen": 30, + "playToWin": false, + "win": false, + "kind": "play", + "index": 3 + }, + { + "seed": "-7", + "moveCount": 20, + "myScore": 200, + "oppScore": 40, + "candScores": [ + 50, + 20 + ], + "rackLen": 5, + "bagLen": 2, + "playToWin": false, + "win": false, + "kind": "play", + "index": 1 + }, + { + "seed": "-7", + "moveCount": 5, + "myScore": 30, + "oppScore": 30, + "candScores": [ + 20, + 20, + 20 + ], + "rackLen": 7, + "bagLen": 14, + "playToWin": false, + "win": true, + "kind": "play", + "index": 0 + }, + { + "seed": "-7", + "moveCount": 8, + "myScore": 50, + "oppScore": 60, + "candScores": [], + "rackLen": 7, + "bagLen": 20, + "playToWin": false, + "win": false, + "kind": "exchange", + "index": -1 + }, + { + "seed": "-7", + "moveCount": 8, + "myScore": 50, + "oppScore": 60, + "candScores": [], + "rackLen": 3, + "bagLen": 2, + "playToWin": false, + "win": false, + "kind": "pass", + "index": -1 + }, + { + "seed": "-7", + "moveCount": 15, + "myScore": 300, + "oppScore": 10, + "candScores": [ + 80, + 40, + 10 + ], + "rackLen": 7, + "bagLen": 6, + "playToWin": false, + "win": false, + "kind": "play", + "index": 2 + }, + { + "seed": "1000003", + "moveCount": 3, + "myScore": 40, + "oppScore": 55, + "candScores": [ + 30, + 12, + 8 + ], + "rackLen": 7, + "bagLen": 20, + "playToWin": true, + "win": false, + "kind": "play", + "index": 1 + }, + { + "seed": "1000003", + "moveCount": 10, + "myScore": 120, + "oppScore": 90, + "candScores": [ + 25, + 18, + 5 + ], + "rackLen": 7, + "bagLen": 8, + "playToWin": true, + "win": true, + "kind": "play", + "index": 2 + }, + { + "seed": "1000003", + "moveCount": 1, + "myScore": 0, + "oppScore": 0, + "candScores": [ + 60, + 30, + 15, + 7 + ], + "rackLen": 7, + "bagLen": 30, + "playToWin": true, + "win": true, + "kind": "play", + "index": 3 + }, + { + "seed": "1000003", + "moveCount": 20, + "myScore": 200, + "oppScore": 40, + "candScores": [ + 50, + 20 + ], + "rackLen": 5, + "bagLen": 2, + "playToWin": true, + "win": false, + "kind": "play", + "index": 1 + }, + { + "seed": "1000003", + "moveCount": 5, + "myScore": 30, + "oppScore": 30, + "candScores": [ + 20, + 20, + 20 + ], + "rackLen": 7, + "bagLen": 14, + "playToWin": true, + "win": true, + "kind": "play", + "index": 0 + }, + { + "seed": "1000003", + "moveCount": 8, + "myScore": 50, + "oppScore": 60, + "candScores": [], + "rackLen": 7, + "bagLen": 20, + "playToWin": true, + "win": true, + "kind": "exchange", + "index": -1 + }, + { + "seed": "1000003", + "moveCount": 8, + "myScore": 50, + "oppScore": 60, + "candScores": [], + "rackLen": 3, + "bagLen": 2, + "playToWin": true, + "win": true, + "kind": "pass", + "index": -1 + }, + { + "seed": "1000003", + "moveCount": 15, + "myScore": 300, + "oppScore": 10, + "candScores": [ + 80, + 40, + 10 + ], + "rackLen": 7, + "bagLen": 6, + "playToWin": true, + "win": true, + "kind": "play", + "index": 2 + }, + { + "seed": "9999999999", + "moveCount": 3, + "myScore": 40, + "oppScore": 55, + "candScores": [ + 30, + 12, + 8 + ], + "rackLen": 7, + "bagLen": 20, + "playToWin": true, + "win": false, + "kind": "play", + "index": 1 + }, + { + "seed": "9999999999", + "moveCount": 10, + "myScore": 120, + "oppScore": 90, + "candScores": [ + 25, + 18, + 5 + ], + "rackLen": 7, + "bagLen": 8, + "playToWin": true, + "win": true, + "kind": "play", + "index": 2 + }, + { + "seed": "9999999999", + "moveCount": 1, + "myScore": 0, + "oppScore": 0, + "candScores": [ + 60, + 30, + 15, + 7 + ], + "rackLen": 7, + "bagLen": 30, + "playToWin": true, + "win": true, + "kind": "play", + "index": 3 + }, + { + "seed": "9999999999", + "moveCount": 20, + "myScore": 200, + "oppScore": 40, + "candScores": [ + 50, + 20 + ], + "rackLen": 5, + "bagLen": 2, + "playToWin": true, + "win": true, + "kind": "play", + "index": 1 + }, + { + "seed": "9999999999", + "moveCount": 5, + "myScore": 30, + "oppScore": 30, + "candScores": [ + 20, + 20, + 20 + ], + "rackLen": 7, + "bagLen": 14, + "playToWin": true, + "win": true, + "kind": "play", + "index": 0 + }, + { + "seed": "9999999999", + "moveCount": 8, + "myScore": 50, + "oppScore": 60, + "candScores": [], + "rackLen": 7, + "bagLen": 20, + "playToWin": true, + "win": true, + "kind": "exchange", + "index": -1 + }, + { + "seed": "9999999999", + "moveCount": 8, + "myScore": 50, + "oppScore": 60, + "candScores": [], + "rackLen": 3, + "bagLen": 2, + "playToWin": true, + "win": true, + "kind": "pass", + "index": -1 + }, + { + "seed": "9999999999", + "moveCount": 15, + "myScore": 300, + "oppScore": 10, + "candScores": [ + 80, + 40, + 10 + ], + "rackLen": 7, + "bagLen": 6, + "playToWin": true, + "win": true, + "kind": "play", + "index": 2 + }, + { + "seed": "123456789", + "moveCount": 3, + "myScore": 40, + "oppScore": 55, + "candScores": [ + 30, + 12, + 8 + ], + "rackLen": 7, + "bagLen": 20, + "playToWin": false, + "win": true, + "kind": "play", + "index": 0 + }, + { + "seed": "123456789", + "moveCount": 10, + "myScore": 120, + "oppScore": 90, + "candScores": [ + 25, + 18, + 5 + ], + "rackLen": 7, + "bagLen": 8, + "playToWin": false, + "win": false, + "kind": "play", + "index": 2 + }, + { + "seed": "123456789", + "moveCount": 1, + "myScore": 0, + "oppScore": 0, + "candScores": [ + 60, + 30, + 15, + 7 + ], + "rackLen": 7, + "bagLen": 30, + "playToWin": false, + "win": false, + "kind": "play", + "index": 3 + }, + { + "seed": "123456789", + "moveCount": 20, + "myScore": 200, + "oppScore": 40, + "candScores": [ + 50, + 20 + ], + "rackLen": 5, + "bagLen": 2, + "playToWin": false, + "win": false, + "kind": "play", + "index": 1 + }, + { + "seed": "123456789", + "moveCount": 5, + "myScore": 30, + "oppScore": 30, + "candScores": [ + 20, + 20, + 20 + ], + "rackLen": 7, + "bagLen": 14, + "playToWin": false, + "win": false, + "kind": "play", + "index": 0 + }, + { + "seed": "123456789", + "moveCount": 8, + "myScore": 50, + "oppScore": 60, + "candScores": [], + "rackLen": 7, + "bagLen": 20, + "playToWin": false, + "win": false, + "kind": "exchange", + "index": -1 + }, + { + "seed": "123456789", + "moveCount": 8, + "myScore": 50, + "oppScore": 60, + "candScores": [], + "rackLen": 3, + "bagLen": 2, + "playToWin": false, + "win": false, + "kind": "pass", + "index": -1 + }, + { + "seed": "123456789", + "moveCount": 15, + "myScore": 300, + "oppScore": 10, + "candScores": [ + 80, + 40, + 10 + ], + "rackLen": 7, + "bagLen": 6, + "playToWin": false, + "win": false, + "kind": "play", + "index": 2 + }, + { + "seed": "-123456789", + "moveCount": 3, + "myScore": 40, + "oppScore": 55, + "candScores": [ + 30, + 12, + 8 + ], + "rackLen": 7, + "bagLen": 20, + "playToWin": false, + "win": false, + "kind": "play", + "index": 1 + }, + { + "seed": "-123456789", + "moveCount": 10, + "myScore": 120, + "oppScore": 90, + "candScores": [ + 25, + 18, + 5 + ], + "rackLen": 7, + "bagLen": 8, + "playToWin": false, + "win": false, + "kind": "play", + "index": 2 + }, + { + "seed": "-123456789", + "moveCount": 1, + "myScore": 0, + "oppScore": 0, + "candScores": [ + 60, + 30, + 15, + 7 + ], + "rackLen": 7, + "bagLen": 30, + "playToWin": false, + "win": false, + "kind": "play", + "index": 3 + }, + { + "seed": "-123456789", + "moveCount": 20, + "myScore": 200, + "oppScore": 40, + "candScores": [ + 50, + 20 + ], + "rackLen": 5, + "bagLen": 2, + "playToWin": false, + "win": false, + "kind": "play", + "index": 1 + }, + { + "seed": "-123456789", + "moveCount": 5, + "myScore": 30, + "oppScore": 30, + "candScores": [ + 20, + 20, + 20 + ], + "rackLen": 7, + "bagLen": 14, + "playToWin": false, + "win": false, + "kind": "play", + "index": 0 + }, + { + "seed": "-123456789", + "moveCount": 8, + "myScore": 50, + "oppScore": 60, + "candScores": [], + "rackLen": 7, + "bagLen": 20, + "playToWin": false, + "win": false, + "kind": "exchange", + "index": -1 + }, + { + "seed": "-123456789", + "moveCount": 8, + "myScore": 50, + "oppScore": 60, + "candScores": [], + "rackLen": 3, + "bagLen": 2, + "playToWin": false, + "win": false, + "kind": "pass", + "index": -1 + }, + { + "seed": "-123456789", + "moveCount": 15, + "myScore": 300, + "oppScore": 10, + "candScores": [ + 80, + 40, + 10 + ], + "rackLen": 7, + "bagLen": 6, + "playToWin": false, + "win": false, + "kind": "play", + "index": 2 + } + ] +}