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.
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user