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
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:
@@ -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
|
||||
|
||||
@@ -207,6 +207,37 @@ func TestMixDeterministic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNextMoveAt checks the exported schedule used by the admin ETA: the instant is never
|
||||
// earlier than the sampled think-time delay, and it never lands while the robot is asleep
|
||||
// (a delay that would fall in the sleep window is deferred to the wake time).
|
||||
func TestNextMoveAt(t *testing.T) {
|
||||
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
for seed := int64(1); seed <= 500; seed++ {
|
||||
for _, h := range []int{0, 2, 6, 9, 14, 23} { // turn starts across the day
|
||||
start := base.Add(time.Duration(h) * time.Hour)
|
||||
at := NextMoveAt(seed, 3, start, "UTC")
|
||||
if at.Before(start.Add(moveDelay(seed, 3))) {
|
||||
t.Fatalf("seed %d h %d: ETA %s earlier than the scheduled delay", seed, h, at)
|
||||
}
|
||||
if asleep("UTC", sleepDrift(seed), at) {
|
||||
t.Fatalf("seed %d h %d: ETA %s lands in the sleep window", seed, h, at)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlayToWinExport checks the exported decision matches the internal one and the target.
|
||||
func TestPlayToWinExport(t *testing.T) {
|
||||
for seed := int64(1); seed <= 200; seed++ {
|
||||
if PlayToWin(seed) != playToWin(seed) {
|
||||
t.Fatalf("PlayToWin(%d) != playToWin", seed)
|
||||
}
|
||||
}
|
||||
if PlayToWinTargetPercent != playToWinPercent {
|
||||
t.Errorf("PlayToWinTargetPercent = %d, want %d", PlayToWinTargetPercent, playToWinPercent)
|
||||
}
|
||||
}
|
||||
|
||||
// plays builds candidate plays carrying only the given scores (ranked as passed).
|
||||
func plays(scores ...int) []engine.MoveRecord {
|
||||
out := make([]engine.MoveRecord, len(scores))
|
||||
|
||||
Reference in New Issue
Block a user