package robot import ( "encoding/binary" "hash/fnv" "math" "time" "scrabble/backend/internal/account" "scrabble/backend/internal/engine" ) // The robot's per-game and per-turn choices are derived deterministically from // the game's bag seed, so the scheduler keeps no extra state and recomputes the // same behaviour on every tick and after a restart (mirroring how the engine // replays a game from the same seed). The mixing must be stable across process // restarts, so it uses FNV-1a rather than hash/maphash (whose seed is process // random). const ( // playToWinPercent is the probability, in percent, that the robot decides at // game start to play to win; the rest of the time it plays to lose, so the // 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, // delayEarlyHiMinutes] at the first move to [delayLateLoMinutes, delayLateHiMinutes] // by avgGameMoves, then right-skewed by delaySkew (a larger exponent concentrates // delays near the band's floor — an active player). The result is clamped to // [delayHardMinMinutes, delayHardMaxMinutes]. The numbers are deliberate estimates, // to be retuned once real play statistics arrive (docs/ARCHITECTURE.md §7). delayEarlyLoMinutes = 3.0 delayEarlyHiMinutes = 10.0 delayLateLoMinutes = 10.0 delayLateHiMinutes = 90.0 delaySkew = 4.0 avgGameMoves = 28.0 delayHardMinMinutes = 1.0 delayHardMaxMinutes = 90.0 // In a dead-drawn endgame — the two most recent committed moves are both passes, // so the board and the robot's rack are frozen and the robot is bound to pass // again — the robot drops the long late-game think time and answers on a shortened // schedule scaled to the human's own last-move (pass) think time: a uniform sample // from [endgameLoFactor, endgameHiFactor] of it, clamped to [endgameFloorSeconds, // endgameCapMinutes]. A slow human collapses to the cap (the robot never drags out // a decided game), a fast human is tracked, and the floor keeps the robot from // passing suspiciously instantly. The shrink only ever lowers the delay (it is // taken as a min with the normal schedule), so it never makes the robot slower, and // it composes with the sleep window, which is still honoured before any move. endgameLoFactor = 0.8 endgameHiFactor = 1.5 endgameFloorSeconds = 30.0 endgameCapMinutes = 8.0 // nudgeReplySpreadMinutes is the width of the quick window, anchored at the move's // lower band (delayBand's lo), within which the robot answers a daytime nudge on // its turn — so a nudged robot replies near the floor of its think time. nudgeReplySpreadMinutes = 5.0 // sleepStartHour and sleepEndHour bound the robot's nightly sleep in its // (opponent-anchored, drifted) local time: it makes no move and sends no nudge // while the local hour is in [sleepStartHour, sleepEndHour). sleepStartHour = 0 sleepEndHour = 7 // sleepDriftHours is the half-width of the random drift applied to the robot's // sleep window relative to the opponent's timezone, in hours. sleepDriftHours = 3 // The robot proactively nudges the idle human on a sparse, randomized schedule rather than an // hourly stream: every nudge waits a uniform random 9-12 h after its reference point (the turn // start for the first nudge, the previous nudge thereafter), so even a long-neglected turn // collects only a few widely-spaced reminders. The 3 h window width is the random spread; the // gap does not lengthen with idle time. The driver still skips a nudge that would land in the // robot's sleep window, deferring it to the first scan after wake. nudgeGapLoHours = 9.0 nudgeGapHiHours = 12.0 ) // defaultBand is the target resulting score margin after the robot's move: when // playing to win it aims to lead by 1..30 points, when playing to lose it aims to // trail by 1..30 (the band is negated). It picks the candidate closest to the // band rather than the maximum (docs/ARCHITECTURE.md §7). var defaultBand = marginBand{lo: 1, hi: 30} // marginBand is an inclusive target range for the resulting score margin // (own score after the move minus the opponent's). type marginBand struct{ lo, hi int } // decisionKind enumerates the move the robot makes on its turn. type decisionKind int const ( decidePlay decisionKind = iota decideExchange decidePass ) // decision is the robot's chosen action for a turn: a play (Move), an exchange of // the listed tiles, or a pass. type decision struct { kind decisionKind move engine.MoveRecord exchange []string } // mix folds the game seed and a salt (a label plus optional integers such as the // move index) into a stable 64-bit value. It is deterministic across process // restarts. func mix(seed int64, salt string, nums ...int) uint64 { h := fnv.New64a() var b [8]byte binary.LittleEndian.PutUint64(b[:], uint64(seed)) _, _ = h.Write(b[:]) _, _ = h.Write([]byte(salt)) for _, n := range nums { binary.LittleEndian.PutUint64(b[:], uint64(int64(n))) _, _ = h.Write(b[:]) } return h.Sum64() } // unitFloat maps a mixed value to a float in [0, 1). func unitFloat(v uint64) float64 { return float64(v) / (float64(math.MaxUint64) + 1) } // playToWin reports the robot's once-per-game decision to play to win, derived // from the seed so it is fixed for the whole game. 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 // 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 // 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. In a // dead-drawn endgame the robot may pass sooner than this (see endgamePassDelay); NextMoveAt // remains the normal-schedule upper bound. 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 // moves in a long one. func delayBand(moveCount int) (lo, hi float64) { p := float64(moveCount) / avgGameMoves if p > 1 { p = 1 } lo = delayEarlyLoMinutes + (delayLateLoMinutes-delayEarlyLoMinutes)*p hi = delayEarlyHiMinutes + (delayLateHiMinutes-delayEarlyHiMinutes)*p return lo, hi } // moveDelay is the robot's think time for the move at moveCount: a right-skewed // sample from the move's delayBand, clamped to the hard bounds. The skew (delaySkew // > 1) makes short delays frequent and long ones rare, with a tail to the band's top. func moveDelay(seed int64, moveCount int) time.Duration { lo, hi := delayBand(moveCount) u := unitFloat(mix(seed, "delay", moveCount)) return clampMinutes(lo + (hi-lo)*math.Pow(u, delaySkew)) } // endgamePassDelay is the robot's shortened think time for a guaranteed endgame pass // (the two most recent moves are both passes), given the human's last-move think time // oppLast: a uniform sample from [endgameLoFactor, endgameHiFactor] of oppLast, clamped // to [endgameFloorSeconds, endgameCapMinutes]. It is deterministic per (seed, moveCount) // like moveDelay, and oppLast is read from the persisted move journal, so the schedule is // reproducible across restarts. The caller takes it as a min with moveDelay, so it never // slows the robot down. A non-positive oppLast (clock skew) clamps up to the floor. func endgamePassDelay(seed int64, moveCount int, oppLast time.Duration) time.Duration { floor := time.Duration(endgameFloorSeconds * float64(time.Second)) ceil := time.Duration(endgameCapMinutes * float64(time.Minute)) lo := clampDur(time.Duration(float64(oppLast)*endgameLoFactor), floor, ceil) hi := clampDur(time.Duration(float64(oppLast)*endgameHiFactor), floor, ceil) if hi < lo { hi = lo } u := unitFloat(mix(seed, "endgame", moveCount)) return lo + time.Duration(float64(hi-lo)*u) } // nudgeReplyDelay is how soon after a daytime nudge the robot answers the move at // moveCount: a uniform sample from the quick window [lo, lo+nudgeReplySpreadMinutes], // where lo is the move's lower band — so a nudge pulls the move in near the floor of // the robot's think time. func nudgeReplyDelay(seed int64, moveCount int) time.Duration { lo, _ := delayBand(moveCount) u := unitFloat(mix(seed, "nudge", moveCount)) return clampMinutes(lo + nudgeReplySpreadMinutes*u) } // proactiveNudgeGap is the randomized wait before the next proactive nudge, given how long the // human had already been idle at the previous nudge (refIdle; 0 for the first nudge of the turn). // It is a uniform sample in [nudgeGapLoHours, nudgeGapHiHours] hours, deterministic per // (seed, refIdle) so the driver computes the same due time on every scan. refIdle only salts the // draw, so each successive nudge of a still-idle turn waits a fresh 9-12 h rather than lengthening. func proactiveNudgeGap(refIdle time.Duration, seed int64) time.Duration { u := unitFloat(mix(seed, "pnudge", int(refIdle/(30*time.Minute)))) hours := nudgeGapLoHours + (nudgeGapHiHours-nudgeGapLoHours)*u return time.Duration(hours * float64(time.Hour)) } // clampMinutes converts a minute count to a duration, clamping it to the hard delay // bounds so an out-of-range band can never produce an absurd think time. func clampMinutes(mins float64) time.Duration { if mins < delayHardMinMinutes { mins = delayHardMinMinutes } if mins > delayHardMaxMinutes { mins = delayHardMaxMinutes } return time.Duration(mins * float64(time.Minute)) } // clampDur returns d confined to the inclusive range [lo, hi]. func clampDur(d, lo, hi time.Duration) time.Duration { switch { case d < lo: return lo case d > hi: return hi default: return d } } // sleepDrift is the per-game shift of the robot's sleep window relative to the // opponent's timezone, in [-sleepDriftHours, +sleepDriftHours] hours. func sleepDrift(seed int64) time.Duration { span := 2*sleepDriftHours + 1 h := int(mix(seed, "tz")%uint64(span)) - sleepDriftHours return time.Duration(h) * time.Hour } // asleep reports whether the robot is in its nightly sleep window at now. The // window is [sleepStartHour, sleepEndHour) in the opponent's timezone shifted by // drift; an unknown or empty timezone falls back to UTC. func asleep(opponentTZ string, drift time.Duration, now time.Time) bool { local := now.In(loadLocation(opponentTZ)).Add(drift) h := local.Hour() return h >= sleepStartHour && h < sleepEndHour } // loadLocation resolves a stored timezone (an IANA name or a "±HH:MM" offset), // falling back to UTC when it is empty or unknown (so a bad opponent profile never // breaks the driver). It defers to account.ResolveZone. func loadLocation(name string) *time.Location { return account.ResolveZone(name) } // selectMove chooses the robot's action given the ranked candidate plays, the // current scores, the play-to-win decision and the target band. With at least one // legal play it picks the candidate whose resulting margin (myScore + score - // oppScore) is closest to the band, breaking ties toward the conservative edge // (the smallest lead when winning, the smallest deficit when losing). With no // legal play it exchanges the whole rack when the bag can refill it, else passes. func selectMove(cands []engine.MoveRecord, myScore, oppScore int, win bool, band marginBand, rack []string, bagLen int) decision { if len(cands) == 0 { if len(rack) > 0 && bagLen >= len(rack) { return decision{kind: decideExchange, exchange: append([]string(nil), rack...)} } return decision{kind: decidePass} } lo, hi := band.lo, band.hi if !win { lo, hi = -band.hi, -band.lo } margin := func(c engine.MoveRecord) int { return myScore + c.Score - oppScore } best := 0 bestDist := math.MaxInt for i, c := range cands { m := margin(c) dist := distanceToBand(m, lo, hi) switch { case dist < bestDist: best, bestDist = i, dist case dist == bestDist: // Conservative tie-break inside the band: keep the lead (win) or the // deficit (lose) small. if win && m < margin(cands[best]) || !win && m > margin(cands[best]) { best = i } } } return decision{kind: decidePlay, move: cands[best]} } // distanceToBand is how far m lies outside [lo, hi], or 0 when inside. func distanceToBand(m, lo, hi int) int { switch { case m < lo: return lo - m case m > hi: return m - hi default: return 0 } }