Files
scrabble-game/backend/internal/robot/strategy.go
T
Ilia Denisov acbb2d8254
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 11s
Tests · UI / test (push) Successful in 17s
Stage 8 polish: profile validation, finished-game UI, badge + Safari fixes
Owner-review follow-up on the Stage 8 branch:
- Friend code is copyable (📋 + toast). The lobby notification badge is fixed —
  it had inherited the hamburger-bar style — into a proper round count dot.
- Safari: min-width:0 on flex text inputs (friend code, profile, chat) so they
  shrink instead of pushing the adjacent button off-screen.
- Profile editing is validated on both the UI and the backend: display-name format
  (letters joined by single space/./_ separators, no leading/trailing/adjacent
  separators, <=32 runes), a UTC-offset timezone picker (account.ResolveZone parses
  ±HH:MM or a legacy IANA name), a 10-minute away grid capped at 12h (wrap-aware),
  and email format; Save is disabled and invalid fields red-bordered until valid.
  Language stays in Settings.
- In a game, an "add to friends" menu item flips to a disabled "request sent"; chat
  send/nudge became ⬆️/🛎️ icon buttons.
- A finished game drops its last-word highlight, hides Check word / Drop game,
  disables zoom, and draws an inert (greyed) footer instead of hiding it.

Tests: account validators (name/away/zone), UI profileValidation, e2e for the
finished-game footer/menu and the copy control. Docs (PLAN, ARCHITECTURE,
FUNCTIONAL +ru, UI_DESIGN) updated for the display-name rule, UTC-offset timezone
and the 12h away window.
2026-06-03 22:12:59 +02:00

197 lines
7.0 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
// delayMinMinutes and delayMaxMinutes bound a move delay; delaySkew shapes the
// right-skewed distribution (short delays frequent). With skew 3.5 the median
// is about 10 minutes and the mean about 20, with a tail out to the maximum.
delayMinMinutes = 2.0
delayMaxMinutes = 90.0
delaySkew = 3.5
// nudgeReplyMinMinutes and nudgeReplyMaxMinutes bound how soon the robot
// answers a daytime nudge on its turn.
nudgeReplyMinMinutes = 2.0
nudgeReplyMaxMinutes = 10.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
// proactiveNudgeIdle is how long the robot waits on the human's turn before it
// proactively nudges (subject to the social once-per-hour-per-game limit).
proactiveNudgeIdle = 12 * time.Hour
)
// 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
}
// moveDelay is the robot's think time for the move at moveCount, sampled from the
// right-skewed distribution and bounded to [delayMinMinutes, delayMaxMinutes).
func moveDelay(seed int64, moveCount int) time.Duration {
u := unitFloat(mix(seed, "delay", moveCount))
mins := delayMinMinutes + (delayMaxMinutes-delayMinMinutes)*math.Pow(u, delaySkew)
return time.Duration(mins * float64(time.Minute))
}
// nudgeReplyDelay is how soon after a daytime nudge the robot answers the move at
// moveCount, sampled uniformly from [nudgeReplyMinMinutes, nudgeReplyMaxMinutes).
func nudgeReplyDelay(seed int64, moveCount int) time.Duration {
u := unitFloat(mix(seed, "nudge", moveCount))
mins := nudgeReplyMinMinutes + (nudgeReplyMaxMinutes-nudgeReplyMinMinutes)*u
return time.Duration(mins * float64(time.Minute))
}
// 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
}
}