Merge pull request 'feat(offline): robot move-choice strategy in TS (parity-pinned)' (#189) from feature/offline-robot-strategy into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m8s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m42s
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m8s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m42s
This commit was merged in pull request #189.
This commit is contained in:
@@ -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))
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
+1057
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user