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
@@ -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})
|
||||
}
|
||||
@@ -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)})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
Reference in new issue
Block a user