2026-10-30 15:02:09 +07:00
|
|
|
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 {
|
2026-11-04 15:59:48 +07:00
|
|
|
OffsetMS int64 `json:"offset_ms"`
|
|
|
|
|
WindowBeginMS int64 `json:"window_begin_ms,omitempty"`
|
|
|
|
|
WindowEndMS int64 `json:"window_end_ms,omitempty"`
|
2026-10-30 15:02:09 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
2026-11-04 15:59:48 +07:00
|
|
|
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")
|
|
|
|
|
}
|
2026-10-30 15:02:09 +07:00
|
|
|
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
|
2026-11-04 15:59:48 +07:00
|
|
|
if s.WindowEndMS != 0 {
|
|
|
|
|
span := s.WindowEndMS - s.WindowBeginMS
|
|
|
|
|
delta := (ms - s.WindowBeginMS) % span
|
|
|
|
|
if delta < 0 {
|
|
|
|
|
delta += span
|
|
|
|
|
}
|
|
|
|
|
ms = s.WindowBeginMS + delta
|
|
|
|
|
}
|
2026-10-30 15:02:09 +07:00
|
|
|
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()
|
|
|
|
|
}
|
2026-11-04 15:59:48 +07:00
|
|
|
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 {
|
2026-10-30 15:02:09 +07:00
|
|
|
return time.Time{}, err
|
|
|
|
|
}
|
2026-11-04 15:59:48 +07:00
|
|
|
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
|
|
|
|
|
}
|
2026-10-30 15:02:09 +07:00
|
|
|
raw, err := json.MarshalIndent(state, "", " ")
|
|
|
|
|
if err != nil {
|
2026-11-04 15:59:48 +07:00
|
|
|
return err
|
2026-10-30 15:02:09 +07:00
|
|
|
}
|
|
|
|
|
f, err := os.CreateTemp(filepath.Dir(path), ".game-clock-*.tmp")
|
|
|
|
|
if err != nil {
|
2026-11-04 15:59:48 +07:00
|
|
|
return err
|
2026-10-30 15:02:09 +07:00
|
|
|
}
|
|
|
|
|
name := f.Name()
|
|
|
|
|
defer os.Remove(name)
|
|
|
|
|
if _, err = f.Write(raw); err != nil {
|
|
|
|
|
f.Close()
|
2026-11-04 15:59:48 +07:00
|
|
|
return err
|
2026-10-30 15:02:09 +07:00
|
|
|
}
|
|
|
|
|
if err = f.Sync(); err != nil {
|
|
|
|
|
f.Close()
|
2026-11-04 15:59:48 +07:00
|
|
|
return err
|
2026-10-30 15:02:09 +07:00
|
|
|
}
|
|
|
|
|
if err = f.Close(); err != nil {
|
2026-11-04 15:59:48 +07:00
|
|
|
return err
|
2026-10-30 15:02:09 +07:00
|
|
|
}
|
|
|
|
|
if err = os.Rename(name, path); err != nil {
|
2026-11-04 15:59:48 +07:00
|
|
|
return err
|
2026-10-30 15:02:09 +07:00
|
|
|
}
|
2026-11-04 15:59:48 +07:00
|
|
|
return nil
|
2026-10-30 15:02:09 +07:00
|
|
|
}
|