8c5995c076
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).
69 lines
2.2 KiB
TypeScript
69 lines
2.2 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
});
|
|
});
|