feat(offline): port robot move-choice strategy to TS (parity-pinned)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m37s

Phase A (2/2) of PWA offline mode: the offline robot picks its move exactly as the
server does, so a local vs_ai game plays the same. Builds on the move generator
from #188; not wired into a game loop yet (Phase B).

- ui/src/lib/robot/strategy.ts: port of backend/internal/robot/strategy.go's
  move-choice slice — mix (FNV-1a, via BigInt for bit-exact uint64), playToWin
  (~40% play-to-win), deviates (the fading off-strategy wobble) and selectMove
  (pick the candidate whose resulting margin lands closest to the +/-[1,30] band,
  conservative tie-break), composed by decide(). The generator's ranked moves feed
  straight in. Think-time/sleep/nudge scheduling is server-only and not ported.
- backend/internal/robot/strategyfixture_test.go: an in-package, env-gated emitter
  (EMIT_STRATEGY_FIXTURES=1) writing golden fixtures from the real Go strategy — it
  reaches the unexported mix/playToWin/deviates/selectMove.
- strategy.parity.test.ts: 21 mix + 56 decision cases match Go exactly (play/
  exchange/pass, the deviate flip, tie-break, band overshoot).

Pure additive library code; no runtime behavior change (unused at runtime, so the
bundle is unchanged).
This commit is contained in:
Ilia Denisov
2026-07-06 01:54:20 +02:00
parent cedc9ffae1
commit 8c5995c076
4 changed files with 1451 additions and 0 deletions
+68
View File
@@ -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<string>(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);
}
}
});
});
+180
View File
@@ -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<C, R> =
| { 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<C extends { score: number }, R>(
cands: readonly C[],
myScore: number,
oppScore: number,
win: boolean,
band: MarginBand,
rack: readonly R[],
bagLen: number,
): Decision<C, R> {
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<C extends { score: number }, R>(
seed: bigint,
moveCount: number,
cands: readonly C[],
myScore: number,
oppScore: number,
rack: readonly R[],
bagLen: number,
band: MarginBand = DEFAULT_BAND,
): Decision<C, R> {
let win = playToWin(seed);
if (deviates(seed, moveCount, bagLen)) win = !win;
return selectMove(cands, myScore, oppScore, win, band, rack, bagLen);
}
File diff suppressed because it is too large Load Diff