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