Add game clock commands, command completions and Cyrene login tooling
This commit is contained in:
1 parent
1fa9504176
commit
67a819a4d3
21 files changed
+1194
-7
No files matched your search
@@ -0,0 +1,104 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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
|
||||
return time.UnixMilli(ms), nil
|
||||
}
|
||||
|
||||
func SetGameTime(target *time.Time) (time.Time, error) {
|
||||
path, err := gameClockPath()
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
if path == "" {
|
||||
return time.Time{}, fmt.Errorf("configure game_clock.state_file or GAC_GAME_CLOCK_STATE_FILE first")
|
||||
}
|
||||
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 := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
raw, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
f, err := os.CreateTemp(filepath.Dir(path), ".game-clock-*.tmp")
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
name := f.Name()
|
||||
defer os.Remove(name)
|
||||
if _, err = f.Write(raw); err != nil {
|
||||
f.Close()
|
||||
return time.Time{}, err
|
||||
}
|
||||
if err = f.Sync(); err != nil {
|
||||
f.Close()
|
||||
return time.Time{}, err
|
||||
}
|
||||
if err = f.Close(); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
if err = os.Rename(name, path); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return time.UnixMilli(now.UnixMilli() + state.OffsetMS), nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGameClockSharedStateAndReset(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "server.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var cfg RuntimeConfig
|
||||
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
cfg.GameClock.StateFile = filepath.Join(dir, "clock.json")
|
||||
configPath := filepath.Join(dir, "server.json")
|
||||
raw, _ = json.Marshal(cfg)
|
||||
if err := os.WriteFile(configPath, raw, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("GAC_CONFIG", configPath)
|
||||
t.Setenv("GAC_GAME_CLOCK_STATE_FILE", cfg.GameClock.StateFile)
|
||||
target := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
if _, err := SetGameTime(&target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now, err := GameTime()
|
||||
if err != nil || now.Sub(target) < 0 || now.Sub(target) > time.Second {
|
||||
t.Fatalf("target not preserved: %v %v", now, err)
|
||||
}
|
||||
before, _ := os.ReadFile(cfg.GameClock.StateFile)
|
||||
if _, err := ReadGameClock(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, _ := os.ReadFile(cfg.GameClock.StateFile)
|
||||
if string(before) != string(after) {
|
||||
t.Fatal("read rewrote state")
|
||||
}
|
||||
if _, err := SetGameTime(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now, err = GameTime()
|
||||
if err != nil || time.Since(now).Abs() > time.Second {
|
||||
t.Fatalf("real time not restored: %v %v", now, err)
|
||||
}
|
||||
if err := os.WriteFile(cfg.GameClock.StateFile, []byte(`{"offset_ms":9223372036854775807}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := GameTime(); err == nil {
|
||||
t.Fatal("overflowing offset accepted")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@ package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
@@ -27,6 +30,21 @@ type RuntimeConfig struct {
|
||||
SDKPort uint16 `json:"sdk_port"`
|
||||
PublicGameHost string `json:"public_game_host"`
|
||||
PublicSDKURL string `json:"public_sdk_url"`
|
||||
LuaOverlay LuaOverlayConfig `json:"lua_overlay"`
|
||||
GameClock GameClockConfig `json:"game_clock"`
|
||||
}
|
||||
|
||||
type GameClockConfig struct {
|
||||
StateFile string `json:"state_file"`
|
||||
}
|
||||
|
||||
// Optional Client Lua update mirror. Machine-specific directories must be
|
||||
// supplied by the local config or environment; empty means disabled.
|
||||
type LuaOverlayConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
StageDir string `json:"stage_dir"`
|
||||
HotfixDir string `json:"hotfix_dir"`
|
||||
StreamingDir string `json:"streaming_dir"`
|
||||
}
|
||||
|
||||
// The schedule interval is an explicit operator policy shared by SDK, gateway,
|
||||
@@ -65,12 +83,31 @@ func LoadRuntimeConfig() (RuntimeConfig, error) {
|
||||
"GAC_ACCOUNT_NAME": &cfg.AccountName,
|
||||
"GAC_CLIENT_VERSION": &cfg.ClientVersion, "GAC_GAME_HOST": &cfg.GameHost,
|
||||
"GAC_SDK_HOST": &cfg.SDKHost, "GAC_PUBLIC_GAME_HOST": &cfg.PublicGameHost,
|
||||
"GAC_PUBLIC_SDK_URL": &cfg.PublicSDKURL,
|
||||
"GAC_PUBLIC_SDK_URL": &cfg.PublicSDKURL,
|
||||
"GAC_LUA_OVERLAY_STAGE_DIR": &cfg.LuaOverlay.StageDir,
|
||||
"GAC_LUA_OVERLAY_HOTFIX_DIR": &cfg.LuaOverlay.HotfixDir,
|
||||
"GAC_LUA_OVERLAY_STREAMING_DIR": &cfg.LuaOverlay.StreamingDir,
|
||||
"GAC_GAME_CLOCK_STATE_FILE": &cfg.GameClock.StateFile,
|
||||
} {
|
||||
if value, ok := os.LookupEnv(name); ok {
|
||||
*dest = value
|
||||
}
|
||||
}
|
||||
if value, ok := os.LookupEnv("GAC_LUA_OVERLAY_ENABLED"); ok {
|
||||
enabled, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return cfg, fmt.Errorf("GAC_LUA_OVERLAY_ENABLED must be a boolean")
|
||||
}
|
||||
cfg.LuaOverlay.Enabled = enabled
|
||||
}
|
||||
if cfg.GameClock.StateFile != "" {
|
||||
if strings.TrimSpace(cfg.GameClock.StateFile) == "" {
|
||||
return cfg, fmt.Errorf("game_clock.state_file must be a file path")
|
||||
}
|
||||
if info, err := os.Stat(cfg.GameClock.StateFile); err == nil && info.IsDir() {
|
||||
return cfg, fmt.Errorf("game_clock.state_file must not be a directory")
|
||||
}
|
||||
}
|
||||
for name, dest := range map[string]*uint16{"GAC_GAME_PORT": &cfg.GamePort, "GAC_SDK_PORT": &cfg.SDKPort} {
|
||||
if value, ok := os.LookupEnv(name); ok {
|
||||
n, err := strconv.ParseUint(value, 10, 16)
|
||||
@@ -119,6 +156,31 @@ func LoadRuntimeConfig() (RuntimeConfig, error) {
|
||||
if cfg.PlayerUID == 0 {
|
||||
return cfg, fmt.Errorf("runtime config requires a nonzero player_uid")
|
||||
}
|
||||
if cfg.LuaOverlay.Enabled {
|
||||
for name, directory := range map[string]string{"stage_dir": cfg.LuaOverlay.StageDir, "hotfix_dir": cfg.LuaOverlay.HotfixDir, "streaming_dir": cfg.LuaOverlay.StreamingDir} {
|
||||
if strings.TrimSpace(directory) == "" {
|
||||
return cfg, fmt.Errorf("lua_overlay.%s is required when enabled", name)
|
||||
}
|
||||
info, err := os.Stat(directory)
|
||||
if err != nil || !info.IsDir() {
|
||||
return cfg, fmt.Errorf("lua_overlay.%s must be an existing directory", name)
|
||||
}
|
||||
}
|
||||
selector, err := os.ReadFile(filepath.Join(cfg.LuaOverlay.StageDir, "M_LuaV.bytes"))
|
||||
if err != nil || len(selector) != 66 || string(selector[:4]) != "SRMI" {
|
||||
return cfg, fmt.Errorf("lua_overlay stage selector is invalid")
|
||||
}
|
||||
var indexDigest [16]byte
|
||||
for i := 0; i < 4; i++ {
|
||||
for j := 0; j < 4; j++ {
|
||||
indexDigest[i*4+j] = selector[28+i*4+3-j]
|
||||
}
|
||||
}
|
||||
index, err := os.ReadFile(filepath.Join(cfg.LuaOverlay.StageDir, "LuaV_"+hex.EncodeToString(indexDigest[:])+".bytes"))
|
||||
if err != nil || len(index) != int(binary.LittleEndian.Uint32(selector[44:48])) || md5.Sum(index) != indexDigest {
|
||||
return cfg, fmt.Errorf("lua_overlay staged index is missing or invalid")
|
||||
}
|
||||
}
|
||||
if cfg.GamePort == 0 || cfg.SDKPort == 0 || strings.TrimSpace(cfg.ClientVersion) == "" {
|
||||
return cfg, fmt.Errorf("runtime config requires client_version, game_port and sdk_port")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -10,7 +13,7 @@ import (
|
||||
// deployment defaults. These tests must never bind sockets or start servers.
|
||||
func TestRuntimeConfigOverrides(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
for _, name := range []string{"GAC_CONFIG", "GAC_ACCOUNT_NAME", "GAC_PLAYER_UID", "GAC_ACTIVITY_BEGIN_TIME", "GAC_ACTIVITY_END_TIME", "GAC_CLIENT_VERSION", "GAC_GAME_HOST", "GAC_GAME_PORT", "GAC_SDK_HOST", "GAC_SDK_PORT", "GAC_PUBLIC_GAME_HOST", "GAC_PUBLIC_SDK_URL", "GAC_LIVE_BATTLE_STALE_SECONDS"} {
|
||||
for _, name := range []string{"GAC_CONFIG", "GAC_ACCOUNT_NAME", "GAC_PLAYER_UID", "GAC_ACTIVITY_BEGIN_TIME", "GAC_ACTIVITY_END_TIME", "GAC_CLIENT_VERSION", "GAC_GAME_HOST", "GAC_GAME_PORT", "GAC_SDK_HOST", "GAC_SDK_PORT", "GAC_PUBLIC_GAME_HOST", "GAC_PUBLIC_SDK_URL", "GAC_LIVE_BATTLE_STALE_SECONDS", "GAC_LUA_OVERLAY_ENABLED", "GAC_LUA_OVERLAY_STAGE_DIR", "GAC_LUA_OVERLAY_HOTFIX_DIR", "GAC_LUA_OVERLAY_STREAMING_DIR"} {
|
||||
t.Setenv(name, "")
|
||||
if err := os.Unsetenv(name); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -78,3 +81,57 @@ func TestRuntimeConfigRequiresFile(t *testing.T) {
|
||||
t.Fatal("missing config silently accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuaOverlayConfigRequiresExistingDirectoriesAndAllowsEnvironmentOverride(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
t.Setenv("GAC_CONFIG", "server.json")
|
||||
base := `{"account_name":"fixture","player_uid":1,"activity_schedule":{"begin_time":0,"end_time":100},"srtool":{"lease_file":"lease.json","lease_seconds":1},"battle_diagnostics":{"live_report_stale_seconds":1},"client_version":"fixture","game_host":"localhost","game_port":32101,"sdk_host":"localhost","sdk_port":32102,"public_game_host":"example.invalid","public_sdk_url":"https://example.invalid"}`
|
||||
if err := os.WriteFile("server.json", []byte(base), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("GAC_LUA_OVERLAY_ENABLED", "true")
|
||||
t.Setenv("GAC_LUA_OVERLAY_STAGE_DIR", "missing")
|
||||
t.Setenv("GAC_LUA_OVERLAY_HOTFIX_DIR", "missing")
|
||||
t.Setenv("GAC_LUA_OVERLAY_STREAMING_DIR", "missing")
|
||||
if _, err := LoadRuntimeConfig(); err == nil {
|
||||
t.Fatal("missing directories accepted")
|
||||
}
|
||||
for _, name := range []string{"stage", "hotfix", "streaming"} {
|
||||
if err := os.Mkdir(name, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
index := []byte("fixture index")
|
||||
digest := md5.Sum(index)
|
||||
selector := make([]byte, 66)
|
||||
copy(selector, "SRMI")
|
||||
for i := 0; i < 4; i++ {
|
||||
for j := 0; j < 4; j++ {
|
||||
selector[28+i*4+j] = digest[i*4+3-j]
|
||||
}
|
||||
}
|
||||
binary.LittleEndian.PutUint32(selector[44:48], uint32(len(index)))
|
||||
if err := os.WriteFile(filepath.Join("stage", "M_LuaV.bytes"), selector, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("stage", "LuaV_"+hex.EncodeToString(digest[:])+".bytes"), index, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("GAC_LUA_OVERLAY_STAGE_DIR", "stage")
|
||||
t.Setenv("GAC_LUA_OVERLAY_HOTFIX_DIR", "hotfix")
|
||||
t.Setenv("GAC_LUA_OVERLAY_STREAMING_DIR", "streaming")
|
||||
cfg, err := LoadRuntimeConfig()
|
||||
if err != nil || !cfg.LuaOverlay.Enabled || cfg.LuaOverlay.StageDir != "stage" {
|
||||
t.Fatalf("overlay override: %+v, %v", cfg.LuaOverlay, err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("stage", "LuaV_"+hex.EncodeToString(digest[:])+".bytes"), []byte("corrupt"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadRuntimeConfig(); err == nil {
|
||||
t.Fatal("corrupt staged index accepted")
|
||||
}
|
||||
t.Setenv("GAC_LUA_OVERLAY_ENABLED", "invalid")
|
||||
if _, err := LoadRuntimeConfig(); err == nil {
|
||||
t.Fatal("invalid boolean accepted")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user