// 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); }