Stage 17 round 5 (L2): robot play-to-win intent + next-move ETA in the admin game card
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 29s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m14s

The admin game detail now shows, per robot seat, the game's deterministic play-to-win
decision (from the bag seed) and — while it is that robot's turn — its scheduled next-move
ETA (sampled think-time delay, deferred past the sleep window), plus a caption with the
~40% global target. Wiring: robot.PlayToWin/NextMoveAt/PlayToWinTargetPercent exports,
account.IsRobot, game RobotSchedule (seed + turn-start). Tests: NextMoveAt invariants
(never early, never in the sleep window), PlayToWin export, and an admin render integration
test asserting the intent + ETA + target appear.
This commit is contained in:
Ilia Denisov
2026-06-07 09:42:23 +02:00
parent 29d1193a0a
commit f916d5e0ca
9 changed files with 198 additions and 4 deletions
+34
View File
@@ -114,6 +114,40 @@ func playToWin(seed int64) bool {
return mix(seed, "win")%100 < playToWinPercent
}
// PlayToWin exposes the once-per-game play-to-win decision for a game's bag seed, for the
// admin console (it is deterministic and fixed for the whole game).
func PlayToWin(seed int64) bool { return playToWin(seed) }
// PlayToWinTargetPercent is the configured probability, in percent, that a robot plays to
// win in any given game (the admin console shows it alongside the per-game decision).
const PlayToWinTargetPercent = playToWinPercent
// 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
// sleep window when it would otherwise land while the robot is asleep. The driver acts on
// a scan tick, so the real move lands at the first scan at or after this instant. It is
// meaningful only on the robot's own turn; the admin console surfaces it as an ETA.
func NextMoveAt(seed int64, moveCount int, turnStartedAt time.Time, opponentTZ string) time.Time {
t := turnStartedAt.Add(moveDelay(seed, moveCount))
drift := sleepDrift(seed)
if asleep(opponentTZ, drift, t) {
t = wakeAfter(opponentTZ, drift, t)
}
return t
}
// wakeAfter returns the first instant at or after t when the robot is awake — the local
// hour reaches sleepEndHour in the opponent's drifted timezone — converted back to UTC.
func wakeAfter(opponentTZ string, drift time.Duration, t time.Time) time.Time {
local := t.In(loadLocation(opponentTZ)).Add(drift)
wake := time.Date(local.Year(), local.Month(), local.Day(), sleepEndHour, 0, 0, 0, local.Location())
if !wake.After(local) {
wake = wake.Add(24 * time.Hour)
}
return wake.Add(-drift).UTC()
}
// delayBand returns the lower and upper bounds, in minutes, of the move-delay band
// for the move at moveCount. It interpolates linearly with game progress (the move
// count over avgGameMoves, capped at 1): early moves sit in a short band and late