751e74b14f
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.
120 lines
3.7 KiB
Go
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()
|
|
}
|
|
}
|
|
}
|