Files
scrabble-game/backend/internal/game/timeout.go
T
Ilia Denisov 751e74b14f
Tests · Go / test (push) Successful in 6s
Tests · Integration / integration (push) Successful in 8s
Tests · Go / test (pull_request) Successful in 5s
Tests · Integration / integration (pull_request) Successful in 8s
Stage 3: game domain (lifecycle, journal+cache, hint, word-check, GCG, stats)
internal/game drives the engine over a single match and owns everything the
engine does not: event-sourced persistence (a games row + an append-only decoded
move journal, with the live engine.Game kept warm in a cache and rebuilt by
replay on a miss), the play/pass/exchange/resign transitions with
validate-at-submit scoring, an unlimited score/legality preview, the hint
(per-game allowance + profile wallet), the word-check tool with complaint
capture, per-player game state, history and GCG export (Poslfit dialect), and a
background turn-timeout sweeper that auto-resigns overdue turns honouring each
player's daily away window. Like Stages 1-2 it is a service/store layer with no
HTTP; the gateway surface lands in Stage 6.

Engine: additive decoded domain API (Direction, SubmitPlay/SubmitExchange/
EvaluatePlay/HintView/Hand, MoveRecord.{Dir,MainRow,MainCol}, Registry.Lookup,
ParseVariant) so internal/game never imports scrabble-solver; and a Resign fix
so the resigner keeps their score yet never wins (the other player wins a
two-player game). Timeout reuses Resign.

Persistence: migration 00002 adds games, game_players, game_moves, complaints,
account_stats and extends accounts with away_start/away_end/hint_balance; go-jet
regenerated. account gained SpendHint. Config adds BACKEND_DICT_DIR (required),
BACKEND_DICT_VERSION, BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL, BACKEND_GAME_CACHE_TTL;
main loads the registry at boot (hard dependency) and starts the sweeper.

Tests: engine resign + decoded-API tests; game unit tests (GCG, away-window
boundaries, hint budget, cache, keyed mutex, payload); inttest integration
(lifecycle, replay equivalence, timeout sweep with away grace, resign stats,
hint policy, word-check/complaint, per-game-lock). Docs (PLAN, ARCHITECTURE,
FUNCTIONAL +_ru, TESTING, README) updated.
2026-06-02 17:33:49 +02:00

120 lines
3.7 KiB
Go

package game
import (
"context"
"time"
"go.uber.org/zap"
)
// effectiveDeadline is the instant a turn auto-resigns. It is the raw deadline
// (turn start plus the per-game timeout) unless that instant falls inside the
// acting player's away window (a daily local-time interval in loc), in which case
// it is pushed to the end of that window — so a player is never timed out while
// asleep. awayStartMin and awayEndMin are minutes since local midnight; an empty
// window (start == end) disables the grace. The function is pure and total.
func effectiveDeadline(turnStartedAt time.Time, timeout time.Duration, loc *time.Location, awayStartMin, awayEndMin int) time.Time {
raw := turnStartedAt.Add(timeout)
if awayStartMin == awayEndMin {
return raw
}
local := raw.In(loc)
dlMin := local.Hour()*60 + local.Minute()
in, endToday := inAwayWindow(dlMin, awayStartMin, awayEndMin)
if !in {
return raw
}
y, m, d := local.Date()
end := time.Date(y, m, d, awayEndMin/60, awayEndMin%60, 0, 0, loc)
if !endToday {
end = end.AddDate(0, 0, 1)
}
return end
}
// inAwayWindow reports whether the minute-of-day dlMin lies inside the window
// [start, end) (which may wrap past midnight) and whether the window's end falls
// on the same local day as dlMin.
func inAwayWindow(dlMin, start, end int) (in, endToday bool) {
if start < end {
inside := dlMin >= start && dlMin < end
return inside, inside // a non-wrapping window always ends the same day
}
// Wraps past midnight: [start, 1440) on the evening side, [0, end) on the
// morning side.
switch {
case dlMin >= start:
return true, false // evening: the window ends the next local day
case dlMin < end:
return true, true // morning: the window ends later today
default:
return false, false
}
}
// minutesOfDay returns a time-of-day value's minutes since midnight.
func minutesOfDay(t time.Time) int {
return t.Hour()*60 + t.Minute()
}
// loadLocation resolves an IANA timezone name, falling back to UTC when it is
// empty or unknown (so a bad profile value never breaks the sweeper).
func loadLocation(name string) *time.Location {
if name == "" {
return time.UTC
}
loc, err := time.LoadLocation(name)
if err != nil {
return time.UTC
}
return loc
}
// SweepTimeouts auto-resigns every active game whose current turn has exceeded
// its effective deadline as of now. It cheaply filters games past the raw
// deadline, then defers to timeoutGame, which confirms the away-window-adjusted
// deadline under the per-game lock. It returns the number of games timed out; a
// per-game failure is logged and skipped so one bad game does not stall the
// sweep.
func (svc *Service) SweepTimeouts(ctx context.Context, now time.Time) (int, error) {
games, err := svc.store.ActiveGames(ctx)
if err != nil {
return 0, err
}
var timedOut int
for _, ag := range games {
if now.Before(ag.turnStartedAt.Add(time.Duration(ag.turnTimeoutSecs) * time.Second)) {
continue // not even past the raw deadline
}
did, err := svc.timeoutGame(ctx, ag.gameID, now)
if err != nil {
svc.log.Warn("timeout sweep", zap.String("game", ag.gameID.String()), zap.Error(err))
continue
}
if did {
timedOut++
}
}
return timedOut, nil
}
// RunSweeper drives SweepTimeouts and evicts idle games from the cache on each
// tick until ctx is cancelled. It is started once from main.
func (svc *Service) RunSweeper(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if n, err := svc.SweepTimeouts(ctx, svc.clock()); err != nil {
svc.log.Warn("timeout sweep failed", zap.Error(err))
} else if n > 0 {
svc.log.Info("timed out games", zap.Int("count", n))
}
svc.cache.sweep()
}
}
}