Files
scrabble-game/backend/internal/robot/strategy.go
T
Ilia Denisov 6e77de4c1e
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) Successful in 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m26s
feat: sparser robot nudges, typed unread badge, lobby unread bump
Three owner-requested polish changes:

- robot: replace the lengthening 60-90 min -> 6 h proactive-nudge ramp with a
  flat uniform 9-12 h wait before every nudge; the existing sleep-window gate
  still skips and defers a nudge that would land in the robot's night.
- ui: colour the lobby/in-game unread dot by type -- the regular danger colour
  when a chat message is unread, a softer amber (--warn) when only nudges are.
  Adds a per-viewer unread_messages flag (chat_messages.kind='message') across
  the backend DTO, FlatBuffers wire, gateway transcode and the UI store.
- ui: float games with any unread notification to the top of the lobby's
  your-turn and opponent-turn sections (finished keeps its order), reusing the
  existing unread_chat flag.

Docs (ARCHITECTURE 7, FUNCTIONAL + _ru) updated. No DB migration; the new wire
field is backward-compatible.
2026-06-19 16:50:48 +02:00

371 lines
15 KiB
Go

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
}
}