Add game clock commands, command completions and Cyrene login tooling

This commit is contained in:
CAtlas0784 committed 2026-10-30 15:02:09 +07:00
1 parent 1fa9504176
commit 67a819a4d3
21 files changed
+1194 -7

No files matched your search

+104
View File
@@ -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
}
+58
View File
@@ -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")
}
}
+63 -1
View File
@@ -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")
}
+58 -1
View File
@@ -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")
}
}
@@ -1,7 +1,7 @@
package gameserver
import (
"time"
"gac-hkrpg-go/internal/common"
goproto "gac-hkrpg-go/protocol/proto"
)
@@ -23,7 +23,11 @@ func init() {
rsp.Retcode = 0
rsp.CurTimezone = 8
rsp.LoginRandom = req.LoginRandom
rsp.ServerTimestampMs = uint64(time.Now().UnixMilli())
now, err := common.GameTime()
if err != nil {
return err
}
rsp.ServerTimestampMs = uint64(now.UnixMilli())
rsp.Stamina = 240
rsp.BasicInfo = &goproto.PlayerBasicInfo{
Nickname: playerName,
+5 -1
View File
@@ -42,7 +42,11 @@ func init() {
PacketLog("CW_GM deferred: " + err.Error())
}
rsp.ClientTimeMs = req.ClientTimeMs
rsp.ServerTimeMs = req.ClientTimeMs
now, err := common.GameTime()
if err != nil {
return err
}
rsp.ServerTimeMs = uint64(now.UnixMilli())
var finalData []byte
finalData = append(finalData, heartbeatLua...)
+9 -1
View File
@@ -63,6 +63,7 @@ func registerAPIRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/teleport", corsWrapper(apiTeleport))
mux.HandleFunc("/api/gachabase/fetch", corsWrapper(apiGachabaseFetch))
mux.HandleFunc("/api/command", corsWrapper(apiCommand))
mux.HandleFunc("/api/command-completions", corsWrapper(apiCommandCompletions))
mux.HandleFunc("/api/client/unstuck", corsWrapper(apiClientUnstuck))
mux.HandleFunc("/api/client/unstuck/result", corsWrapper(apiClientUnstuckResult))
mux.HandleFunc("/api/data-in-game", corsWrapper(apiDataInGame))
@@ -803,6 +804,12 @@ func apiCommand(w http.ResponseWriter, r *http.Request) {
apiCurrencyWarsCommand(w, cmd)
return
}
if parts := strings.Fields(cmd); len(parts) > 0 && strings.EqualFold(parts[0], "/faketime") {
apiMu.Lock()
defer apiMu.Unlock()
apiFakeTimeCommand(w, cmd)
return
}
apiMu.Lock()
defer apiMu.Unlock()
@@ -896,7 +903,8 @@ func apiCommand(w http.ResponseWriter, r *http.Request) {
}
case "/help":
output = "Available GM Commands:\n /heal - Fully restore team HP, SP, and Energy\n /max - Set all avatars to Max Lv.80 E6\n /teleport <plane_id> [floor_id] - Teleport to scene\n /avatar <id> [level] [rank] - Modify avatar\n /give <item_id> [count] - Add items to inventory\n /clear lineup - Clear active lineup slots"
output = "/faketime [status|off|Unix seconds|RFC3339 with timezone] - Set game calendar time\n"
output += "Available GM Commands:\n /heal - Fully restore team HP, SP, and Energy\n /max - Set all avatars to Max Lv.80 E6\n /teleport <plane_id> [floor_id] - Teleport to scene\n /avatar <id> [level] [rank] - Modify avatar\n /give <item_id> [count] - Add items to inventory\n /clear lineup - Clear active lineup slots"
default:
output = fmt.Sprintf("[OK] Custom command '%s' dispatched to server.", cmd)
@@ -0,0 +1,97 @@
package sdkserver
import (
"encoding/json"
"fmt"
"gac-hkrpg-go/internal/common"
"net/http"
"sort"
)
type commandCompletion struct {
Value string `json:"value"`
Label string `json:"label"`
Plane uint32 `json:"plane,omitempty"`
Star uint32 `json:"star,omitempty"`
Avatar uint32 `json:"avatar,omitempty"`
}
// Read-only, resource-backed argument choices. No save or live run is changed.
func apiCommandCompletions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
out := map[string][]commandCompletion{}
add := func(kind string, id uint32, label string) {
out[kind] = append(out[kind], commandCompletion{Value: fmt.Sprint(id), Label: label})
}
var issues []string
if raw, err := common.ReadResourceFile("resources/InventoryItemConfig.json"); err == nil {
var items struct {
IDs []uint32 `json:"item_ids"`
}
if err = json.Unmarshal(raw, &items); err == nil {
for _, id := range items.IDs {
add("items", id, fmt.Sprintf("Item %d", id))
}
} else {
issues = append(issues, err.Error())
}
} else {
issues = append(issues, err.Error())
}
if res, err := common.GameRes(); err == nil {
planes := map[uint32]bool{}
floors := map[[2]uint32]bool{}
for _, entry := range res.MapEntrances {
if !planes[entry.PlaneID] {
add("planes", entry.PlaneID, fmt.Sprintf("Plane %d", entry.PlaneID))
planes[entry.PlaneID] = true
}
key := [2]uint32{entry.PlaneID, entry.FloorID}
if !floors[key] {
out["floors"] = append(out["floors"], commandCompletion{Value: fmt.Sprint(entry.FloorID), Label: fmt.Sprintf("Floor %d", entry.FloorID), Plane: entry.PlaneID})
floors[key] = true
}
}
} else {
issues = append(issues, err.Error())
}
if catalog, err := common.LoadCurrencyWarsCatalog(); err == nil {
for _, v := range catalog.CapturedVariants {
out["cw_roles"] = append(out["cw_roles"], commandCompletion{Value: fmt.Sprint(v.Config.ID), Label: fmt.Sprintf("Role %d · %d★", v.Config.ID, v.Star), Star: v.Star, Avatar: v.Config.AvatarID})
}
if equipment, e := common.LoadCurrencyWarsEquipment(catalog); e == nil {
for id, row := range equipment {
add("cw_equip", id, row.AbilityName)
}
} else {
issues = append(issues, e.Error())
}
if rewards, e := common.LoadCurrencyWarsRewards(catalog); e == nil {
for id := range rewards.Orbs {
add("cw_orb", id, fmt.Sprintf("Orb %d", id))
}
for id := range rewards.Consumables {
add("cw_consumable", id, fmt.Sprintf("Consumable %d", id))
}
for id := range rewards.Forges {
add("cw_forge", id, fmt.Sprintf("Forge %d", id))
}
} else {
issues = append(issues, e.Error())
}
} else {
issues = append(issues, err.Error())
}
for key := range out {
sort.Slice(out[key], func(i, j int) bool {
if out[key][i].Value == out[key][j].Value {
return out[key][i].Star < out[key][j].Star
}
return out[key][i].Value < out[key][j].Value
})
}
writeJSON(w, map[string]any{"choices": out, "issues": issues})
}
+48
View File
@@ -0,0 +1,48 @@
package sdkserver
import (
"fmt"
"gac-hkrpg-go/internal/common"
"net/http"
"strconv"
"strings"
"time"
)
func apiFakeTimeCommand(w http.ResponseWriter, cmd string) {
parts := strings.Fields(cmd)
usage := "Usage: /faketime [status | off | <Unix seconds> | <RFC3339 date with timezone>]. Time keeps advancing. Reopen the guide after the next heartbeat; relogin if it caches schedules."
var now time.Time
var err error
arg := strings.TrimSpace(strings.TrimPrefix(cmd, parts[0]))
switch strings.ToLower(arg) {
case "", "status":
now, err = common.GameTime()
case "off", "reset", "real":
now, err = common.SetGameTime(nil)
default:
var target time.Time
if seconds, e := strconv.ParseInt(arg, 10, 64); e == nil {
if seconds < 0 || seconds > 253402300799 {
err = fmt.Errorf("Unix seconds outside supported calendar range")
} else {
target = time.Unix(seconds, 0)
}
} else {
target, err = time.Parse(time.RFC3339, arg)
}
if err == nil {
now, err = common.SetGameTime(&target)
}
}
if err != nil {
writeJSON(w, map[string]any{"success": false, "output": err.Error() + "\n" + usage})
return
}
state, err := common.ReadGameClock()
if err != nil {
writeJSON(w, map[string]any{"success": false, "output": err.Error()})
return
}
writeJSON(w, map[string]any{"success": true, "command": cmd, "game_time": now.UTC().Format(time.RFC3339), "unix_seconds": now.Unix(), "offset_ms": state.OffsetMS, "output": fmt.Sprintf("Game time: %s (offset %d ms). %s", now.UTC().Format(time.RFC3339), state.OffsetMS, usage)})
}
+71
View File
@@ -0,0 +1,71 @@
package sdkserver
import (
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"gac-hkrpg-go/internal/common"
)
// Route name is part of this server's HTTP interface, not a machine URL.
const luaOverlayRoute = "/gacy-lua-overlay"
var luaBundleName = regexp.MustCompile(`^(?:M_LuaV|LuaV_[0-9a-f]{32}|[0-9a-f]{32})\.bytes$`)
// Dispatch outside ServeMux: a subtree registration conflicts with the
// product-name wildcard used by the existing login endpoints in newer Go.
func withLuaOverlay(next http.Handler, cfg common.LuaOverlayConfig) http.Handler {
if !cfg.Enabled {
return next
}
overlay := luaOverlayHandler(cfg)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, luaOverlayRoute+"/") {
overlay(w, r)
return
}
next.ServeHTTP(w, r)
})
}
// Serve only the Lua updater's known flat namespace. Stage files win, then the
// local hotfix and shipping files; no filesystem path or remote URL is derived
// from untrusted input. The original caches remain untouched.
func luaOverlayHandler(cfg common.LuaOverlayConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
path := strings.TrimPrefix(r.URL.Path, luaOverlayRoute+"/")
path = strings.TrimPrefix(path, "client/Windows/")
if !luaBundleName.MatchString(path) {
http.NotFound(w, r)
return
}
for _, root := range []string{cfg.StageDir, cfg.HotfixDir, cfg.StreamingDir} {
file, err := os.Open(filepath.Join(root, path))
if os.IsNotExist(err) {
continue
}
if err != nil {
http.Error(w, "Lua overlay read failed", http.StatusInternalServerError)
return
}
info, err := file.Stat()
if err != nil || !info.Mode().IsRegular() {
file.Close()
http.Error(w, "Lua overlay file invalid", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
http.ServeContent(w, r, path, info.ModTime(), file)
file.Close()
return
}
http.NotFound(w, r)
}
}
+72
View File
@@ -0,0 +1,72 @@
package sdkserver
import (
"encoding/base64"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"gac-hkrpg-go/internal/common"
pb "gac-hkrpg-go/protocol/proto"
"google.golang.org/protobuf/proto"
)
func TestLuaOverlayGatewayAndLookup(t *testing.T) {
base := t.TempDir()
cfg := common.LuaOverlayConfig{Enabled: true, StageDir: filepath.Join(base, "stage"), HotfixDir: filepath.Join(base, "hotfix"), StreamingDir: filepath.Join(base, "streaming")}
for _, dir := range []string{cfg.StageDir, cfg.HotfixDir, cfg.StreamingDir} {
if err := os.Mkdir(dir, 0700); err != nil {
t.Fatal(err)
}
}
for _, data := range []struct{ dir, name, content string }{{cfg.StageDir, "M_LuaV.bytes", "stage"}, {cfg.HotfixDir, "M_LuaV.bytes", "old"}, {cfg.StreamingDir, "0123456789abcdef0123456789abcdef.bytes", "base"}} {
if err := os.WriteFile(filepath.Join(data.dir, data.name), []byte(data.content), 0600); err != nil {
t.Fatal(err)
}
}
h := luaOverlayHandler(cfg)
for _, tt := range []struct {
path, want string
code int
}{{luaOverlayRoute + "/client/Windows/M_LuaV.bytes", "stage", 200}, {luaOverlayRoute + "/0123456789abcdef0123456789abcdef.bytes", "base", 200}, {luaOverlayRoute + "/client/Windows/../../secret", "404 page not found\n", 404}} {
w := httptest.NewRecorder()
h(w, httptest.NewRequest(http.MethodGet, tt.path, nil))
if w.Code != tt.code || w.Body.String() != tt.want {
t.Fatalf("%s: %d %q", tt.path, w.Code, w.Body.String())
}
}
// The existing SDK has product-name wildcard routes; overlay dispatch must
// coexist with them without a ServeMux registration panic.
mux := http.NewServeMux()
mux.HandleFunc("/{product_name}/mdk/shield/api/login", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
wrapped := withLuaOverlay(mux, cfg)
for _, tt := range []struct {
path string
want int
}{{"/game/mdk/shield/api/login", http.StatusNoContent}, {luaOverlayRoute + "/M_LuaV.bytes", http.StatusOK}} {
w := httptest.NewRecorder()
wrapped.ServeHTTP(w, httptest.NewRequest(http.MethodGet, tt.path, nil))
if w.Code != tt.want {
t.Fatalf("%s: got %d want %d", tt.path, w.Code, tt.want)
}
}
state := &appState{runtime: common.RuntimeConfig{PublicSDKURL: "https://sdk.example.invalid", PublicGameHost: "host.example.invalid", GamePort: 1, LuaOverlay: cfg}, hotfixMap: map[string]VersionConfig{"fixture": {LuaURL: "https://upstream.example.invalid"}}}
w := httptest.NewRecorder()
queryGateway(state)(w, httptest.NewRequest(http.MethodGet, "/query_gateway?version=fixture", nil))
wire, err := base64.StdEncoding.DecodeString(w.Body.String())
if err != nil {
t.Fatal(err)
}
var gateway pb.Gateserver
if err := proto.Unmarshal(wire, &gateway); err != nil {
t.Fatal(err)
}
if gateway.LuaUrl != state.runtime.PublicSDKURL+luaOverlayRoute {
t.Fatalf("wrong Lua URL: %q", gateway.LuaUrl)
}
if state.hotfixMap["fixture"].LuaURL != "https://upstream.example.invalid" {
t.Fatal("upstream config was mutated")
}
}
+4 -1
View File
@@ -184,6 +184,9 @@ func queryGateway(state *appState) http.HandlerFunc {
_ = params{}
cfg := state.getOrInsertHotfix(version, seed)
if state.runtime.LuaOverlay.Enabled {
cfg.LuaURL = state.runtime.PublicSDKURL + luaOverlayRoute
}
rsp := &goproto.Gateserver{
Ip: state.runtime.PublicGameHost,
@@ -386,7 +389,7 @@ func Listen() error {
addr := runtime.SDKAddress()
Log("sdkserver is listening at " + addr)
Log(fmt.Sprintf("REST API Gateway is live at %s/ (for GaC-Srtool)", runtime.PublicSDKURL))
return http.ListenAndServe(addr, mux)
return http.ListenAndServe(addr, withLuaOverlay(mux, runtime.LuaOverlay))
}
func Log(msg string) { fmt.Println("[sdkserver]", msg) }