acbb2d8254
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.
116 lines
3.8 KiB
Go
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()
|
|
}
|
|
}
|
|
}
|