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
+12 -5
View File
@@ -67,7 +67,8 @@ func (s *Service) handle(ctx context.Context, rt game.RobotTurn, now time.Time)
// Honest-AI game: the robot moves the instant it is its turn — no sleep window and
// no proactive nudge (chat and nudge are disabled in these games, and the player
// chose an opponent that "moves at once"). It still plays to the same per-game
// strength (playToWin) and margin band as the human-mimicry path.
// strength (playToWin), margin band and occasional off-strategy deviation as the
// human-mimicry path.
if rt.VsAI {
if rt.ToMove == rt.RobotSeat {
return s.act(ctx, rt, now)
@@ -164,9 +165,11 @@ func (s *Service) maybeNudge(ctx context.Context, rt game.RobotTurn, now time.Ti
return nil
}
// act reads the live turn, chooses a move by margin and submits it. State that
// has moved on since the scan (a finished game, a turn that is no longer the
// robot's) surfaces as a benign error and is skipped.
// act reads the live turn, chooses a move by margin — usually toward the robot's
// per-game intent, but with an occasional off-strategy deviation that fades to none
// as the bag empties — and submits it. State that has moved on since the scan (a
// finished game, a turn that is no longer the robot's) surfaces as a benign error
// and is skipped.
func (s *Service) act(ctx context.Context, rt game.RobotTurn, now time.Time) error {
st, err := s.games.GameState(ctx, rt.GameID, rt.RobotID)
if err != nil {
@@ -179,7 +182,11 @@ func (s *Service) act(ctx context.Context, rt game.RobotTurn, now time.Time) err
myScore := st.Game.Seats[st.Seat].Score
oppScore := bestOpponentScore(st.Game.Seats, st.Seat)
d := selectMove(cands, myScore, oppScore, playToWin(rt.Seed), defaultBand, st.Rack, st.BagLen)
win := playToWin(rt.Seed)
if deviates(rt.Seed, rt.MoveCount, st.BagLen) {
win = !win // an occasional off-strategy move; never once the bag is empty
}
d := selectMove(cands, myScore, oppScore, win, defaultBand, st.Rack, st.BagLen)
var res game.MoveResult
switch d.kind {