package common import ( "encoding/json" "fmt" "os" "path/filepath" "time" ) // Only game-facing calendar time changes. Network timeouts, diagnostics, leases // and battle duration measurement continue to use the host's real clock. type GameClockState struct { OffsetMS int64 `json:"offset_ms"` WindowBeginMS int64 `json:"window_begin_ms,omitempty"` WindowEndMS int64 `json:"window_end_ms,omitempty"` } func gameClockPath() (string, error) { cfg, err := LoadRuntimeConfig() if err != nil { return "", err } return cfg.GameClock.StateFile, nil } func ReadGameClock() (GameClockState, error) { path, err := gameClockPath() if err != nil { return GameClockState{}, err } if path == "" { return GameClockState{}, nil } raw, err := os.ReadFile(path) if os.IsNotExist(err) { return GameClockState{}, nil } if err != nil { return GameClockState{}, err } var state GameClockState if err := json.Unmarshal(raw, &state); err != nil { return state, fmt.Errorf("game clock state: %w", err) } if (state.WindowBeginMS != 0 || state.WindowEndMS != 0) && (state.WindowBeginMS < 0 || state.WindowEndMS <= state.WindowBeginMS || state.WindowEndMS > 253402300799999) { return state, fmt.Errorf("invalid game clock window") } return state, nil } func GameTime() (time.Time, error) { s, err := ReadGameClock() if err != nil { return time.Time{}, err } base := time.Now().UnixMilli() if s.OffsetMS < -base || s.OffsetMS > 253402300799999-base { return time.Time{}, fmt.Errorf("game clock outside supported calendar range") } ms := base + s.OffsetMS if s.WindowEndMS != 0 { span := s.WindowEndMS - s.WindowBeginMS delta := (ms - s.WindowBeginMS) % span if delta < 0 { delta += span } ms = s.WindowBeginMS + delta } return time.UnixMilli(ms), nil } func SetGameTime(target *time.Time) (time.Time, error) { now := time.Now() state := GameClockState{} if target != nil { if target.UnixMilli() < 0 || target.Year() > 9999 { return time.Time{}, fmt.Errorf("game time must be between Unix epoch and year 9999") } state.OffsetMS = target.UnixMilli() - now.UnixMilli() } if err := saveGameClockState(state); err != nil { return time.Time{}, err } return time.UnixMilli(now.UnixMilli() + state.OffsetMS), nil } // SetGameTimeWindow keeps the shared game calendar inside an observed Client // interval. It wraps at its end; the host clock and operational timers remain real. func SetGameTimeWindow(begin, end time.Time) (time.Time, error) { start, finish := begin.UnixMilli(), end.UnixMilli() if start < 0 || finish <= start || finish > 253402300799999 { return time.Time{}, fmt.Errorf("invalid game clock window") } now := time.Now() midpoint := start + (finish-start)/2 state := GameClockState{OffsetMS: midpoint - now.UnixMilli(), WindowBeginMS: start, WindowEndMS: finish} if err := saveGameClockState(state); err != nil { return time.Time{}, err } return time.UnixMilli(midpoint), nil } func saveGameClockState(state GameClockState) error { path, err := gameClockPath() if err != nil { return err } if path == "" { return fmt.Errorf("configure game_clock.state_file or GAC_GAME_CLOCK_STATE_FILE first") } if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { return err } raw, err := json.MarshalIndent(state, "", " ") if err != nil { return err } f, err := os.CreateTemp(filepath.Dir(path), ".game-clock-*.tmp") if err != nil { return err } name := f.Name() defer os.Remove(name) if _, err = f.Write(raw); err != nil { f.Close() return err } if err = f.Sync(); err != nil { f.Close() return err } if err = f.Close(); err != nil { return err } if err = os.Rename(name, path); err != nil { return err } return nil }