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
+37
View File
@@ -23,6 +23,15 @@ const (
// human wins about 60% of games (docs/ARCHITECTURE.md §7).
playToWinPercent = 40
// The robot occasionally plays a single move against its per-game win/lose
// intent (an off-strategy "wobble"), so the chosen strategy may not pan out —
// which favours the human. deviateMaxProb is the peak probability of that, held
// through the opening and midgame; it tapers linearly to 0 over the last
// deviateTaperTiles tiles left in the bag, reaching 0 once the bag is empty, so
// the endgame follows the chosen strategy strictly (docs/ARCHITECTURE.md §7).
deviateMaxProb = 0.20
deviateTaperTiles = 14
// The robot's think time depends on how far the game has progressed: early moves
// are quick and late moves can be long (endgame deliberation). The delay is drawn
// from a band that interpolates with the move count from [delayEarlyLoMinutes,
@@ -129,6 +138,34 @@ func PlayToWin(seed int64) bool { return playToWin(seed) }
// win in any given game (the admin console shows it alongside the per-game decision).
const PlayToWinTargetPercent = playToWinPercent
// deviateProb is the probability that the robot plays a single move against its
// per-game win/lose intent, given the number of tiles left in the bag. It is
// deviateMaxProb through the opening and midgame, then tapers linearly to 0 over
// the last deviateTaperTiles tiles, reaching 0 once the bag is empty so the endgame
// follows the chosen strategy strictly.
func deviateProb(bagLen int) float64 {
switch {
case bagLen <= 0:
return 0
case bagLen >= deviateTaperTiles:
return deviateMaxProb
default:
return deviateMaxProb * float64(bagLen) / float64(deviateTaperTiles)
}
}
// deviates reports whether the robot deviates from its per-game win/lose intent on
// the move at moveCount: a deterministic per-turn draw (mix/unitFloat, like the
// think-time sampling) against deviateProb(bagLen), so it is reproducible across
// restarts and never fires once the bag is empty.
func deviates(seed int64, moveCount, bagLen int) bool {
p := deviateProb(bagLen)
if p <= 0 {
return false
}
return unitFloat(mix(seed, "deviate", moveCount)) < p
}
// NextMoveAt is the deterministic instant the robot is scheduled to play the move at
// moveCount, given when the turn started and the opponent's timezone (which anchors the
// robot's sleep window). It is the sampled think-time delay, deferred to the end of the