feat(robot): occasional off-strategy deviation, strict in the endgame
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m9s

The robot followed its per-game playToWin/lose intent on every move, which made
the outcome too predictable. It now flips that intent for a single move on ~20%
of opening/midgame turns (a winning robot eases off, a losing one surges ahead),
so the chosen strategy may not pan out — which favours the human. The chance
tapers linearly to 0 over the last 14 tiles in the bag and is 0 once the bag is
empty, so the endgame follows the chosen strategy strictly.

The decision is deterministic from the seed (mix(seed,"deviate",moveCount)) and
applies to both robot paths via the shared selectMove; the per-game play-to-win
intent the admin card shows is unchanged. Adds deviateProb/deviates helpers and
unit tests (taper bounds + monotonicity, never-in-endgame, determinism, ~20%
distribution); bakes the behaviour into ARCHITECTURE §7, FUNCTIONAL (+_ru),
backend/README, PRERELEASE and PLAN Stage 5.
This commit is contained in:
Ilia Denisov
2026-06-15 21:37:23 +02:00
parent 9e6899bb7d
commit 3bceafbc12
9 changed files with 156 additions and 16 deletions
+72
View File
@@ -1,6 +1,7 @@
package robot
import (
"math"
"sort"
"testing"
"time"
@@ -238,6 +239,77 @@ func TestPlayToWinExport(t *testing.T) {
}
}
// TestDeviateProbTaper checks the deviation probability is deviateMaxProb while the
// bag holds at least deviateTaperTiles tiles, halves at the taper midpoint, is 0
// once the bag is empty, and stays within [0, deviateMaxProb] and non-decreasing.
func TestDeviateProbTaper(t *testing.T) {
if p := deviateProb(0); p != 0 {
t.Errorf("deviateProb(0) = %v, want 0 (strict endgame)", p)
}
if p := deviateProb(deviateTaperTiles); p != deviateMaxProb {
t.Errorf("deviateProb(%d) = %v, want %v", deviateTaperTiles, p, deviateMaxProb)
}
if p := deviateProb(deviateTaperTiles + 50); p != deviateMaxProb {
t.Errorf("deviateProb above the taper = %v, want %v (capped)", p, deviateMaxProb)
}
if p := deviateProb(deviateTaperTiles / 2); math.Abs(p-deviateMaxProb/2) > 1e-9 {
t.Errorf("deviateProb at half taper = %v, want ~%v", p, deviateMaxProb/2)
}
prev := -1.0
for bag := 0; bag <= deviateTaperTiles+5; bag++ {
p := deviateProb(bag)
if p < 0 || p > deviateMaxProb {
t.Fatalf("deviateProb(%d) = %v out of [0,%v]", bag, p, deviateMaxProb)
}
if p < prev {
t.Fatalf("deviateProb not non-decreasing: bag %d gives %v after %v", bag, p, prev)
}
prev = p
}
}
// TestDeviatesNeverInEndgame checks the robot never deviates once the bag is empty,
// for every seed and move count, so the endgame follows the chosen strategy strictly.
func TestDeviatesNeverInEndgame(t *testing.T) {
for seed := int64(1); seed <= 5000; seed++ {
for mc := 0; mc < 40; mc++ {
if deviates(seed, mc, 0) {
t.Fatalf("deviates with an empty bag for seed=%d mc=%d", seed, mc)
}
}
}
}
// TestDeviatesDeterministic checks the per-turn deviation draw is reproducible for a
// (seed, moveCount, bagLen), so the driver recomputes the same decision on every scan.
func TestDeviatesDeterministic(t *testing.T) {
for seed := int64(1); seed <= 500; seed++ {
for mc := 0; mc < 30; mc++ {
got := deviates(seed, mc, deviateTaperTiles)
if deviates(seed, mc, deviateTaperTiles) != got {
t.Fatalf("deviates not deterministic for seed=%d mc=%d", seed, mc)
}
}
}
}
// TestDeviatesDistribution checks the deviation rate over many games lands near
// deviateMaxProb while the bag is full (above the taper), at a fixed move count.
func TestDeviatesDistribution(t *testing.T) {
const n = 20000
hits := 0
for seed := int64(1); seed <= n; seed++ {
if deviates(seed, 3, deviateTaperTiles+20) {
hits++
}
}
pct := float64(hits) / float64(n) * 100
want := deviateMaxProb * 100
if pct < want-2 || pct > want+2 {
t.Errorf("deviation rate = %.1f%%, want ~%.0f%% (±2)", pct, want)
}
}
// TestProactiveNudgeGap checks the proactive-nudge schedule: the first gap (refIdle 0) is
// ~60-90 min, every gap stays within [60 min, 6 h] and is deterministic, and the gap lengthens
// as the idle grows (the median at 12 h idle exceeds the median at the start).