Files
scrabble-game/backend/internal/game/timeout.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

116 lines
3.8 KiB
Go

package game
import (
"context"
"time"
"go.uber.org/zap"
"scrabble/backend/internal/account"
)
// 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 a stored timezone (an IANA name or a "±HH:MM" offset),
// falling back to UTC when it is empty or unknown (so a bad profile value never
// breaks the sweeper). It defers to account.ResolveZone, the single source of truth.
func loadLocation(name string) *time.Location {
return account.ResolveZone(name)
}
// 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()
}
}
}