From 9aab62414f0076a16880965e7bcd5f0ada2fa62b Mon Sep 17 00:00:00 2001 From: CAtlas0784 Date: Fri, 16 Oct 2026 18:07:56 +0700 Subject: [PATCH] feat: replace local web UI with REST API gateway for freesr and persistent integration (GaC-Srtool) --- internal/sdkserver/api.go | 494 +++++++++++++++++++++++++++++++++++ internal/sdkserver/server.go | 29 +- 2 files changed, 514 insertions(+), 9 deletions(-) create mode 100644 internal/sdkserver/api.go diff --git a/internal/sdkserver/api.go b/internal/sdkserver/api.go new file mode 100644 index 0000000..3221185 --- /dev/null +++ b/internal/sdkserver/api.go @@ -0,0 +1,494 @@ +package sdkserver + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "sync" + + "gac-hkrpg-go/internal/common" +) + +var apiMu sync.Mutex + +var exRankAvatarNames = map[uint32]string{ + 1204: "Jing Yuan (จิ่งหยวน)", + 1213: "Dan Heng • Imbibitor Lunae (ดันเฮง จ้าวยลจันทรา)", + 1308: "Acheron (อาเคอรอน)", +} + +// corsWrapper adds standard CORS headers so external tools (e.g. GaC-Srtool) can communicate cleanly. +func corsWrapper(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + next(w, r) + } +} + +func registerAPIRoutes(mux *http.ServeMux) { + mux.HandleFunc("/api/status", corsWrapper(apiStatus)) + mux.HandleFunc("/api/freesr", corsWrapper(apiFreesrData)) + mux.HandleFunc("/api/freesr/avatars", corsWrapper(apiFreesrAvatars)) + mux.HandleFunc("/api/persistent", corsWrapper(apiPersistentData)) + mux.HandleFunc("/api/exrank/toggle", corsWrapper(apiExRankToggle)) + mux.HandleFunc("/api/avatar/rank", corsWrapper(apiAvatarRank)) + mux.HandleFunc("/api/avatar/max_all", corsWrapper(apiMaxAll)) + mux.HandleFunc("/api/teleport", corsWrapper(apiTeleport)) +} + +func apiRoot(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + writeJSON(w, map[string]any{ + "name": "GaC-hkrpg-go API Gateway", + "version": "4.6.53", + "status": 200, + "description": "Backend connection endpoints for freesr-data, persistent, and GaC-Srtool", + "endpoints": map[string]string{ + "status": "GET /api/status", + "freesr": "GET, POST /api/freesr", + "avatars": "GET, POST /api/freesr/avatars", + "persistent": "GET, POST /api/persistent", + "exrank_toggle": "POST /api/exrank/toggle", + "avatar_rank": "POST /api/avatar/rank", + "avatar_max_all": "POST /api/avatar/max_all", + "teleport": "POST /api/teleport", + }, + }) +} + +// apiStatus returns server health, statistics, ex-ranks, and basic persistent overview. +func apiStatus(w http.ResponseWriter, r *http.Request) { + apiMu.Lock() + defer apiMu.Unlock() + + data, err := common.LoadFreesrData() + if err != nil { + writeJSON(w, map[string]any{"error": err.Error(), "status": 500}) + return + } + + type ExRankInfo struct { + AvatarID uint32 `json:"avatar_id"` + Name string `json:"name"` + ExRankID uint32 `json:"ex_rank_id"` + Unlocked bool `json:"unlocked"` + Rank uint32 `json:"rank"` + Level uint32 `json:"level"` + } + + var exList []ExRankInfo + for aid, exID := range common.AvatarExRankMap { + name := exRankAvatarNames[aid] + if name == "" { + name = fmt.Sprintf("Avatar %d", aid) + } + unlocked := false + rank := uint32(0) + level := uint32(80) + if av, ok := data.Avatars[aid]; ok { + unlocked = av.ExRankUnlocked + rank = av.Data.Rank + level = av.Level + } + exList = append(exList, ExRankInfo{ + AvatarID: aid, + Name: name, + ExRankID: exID, + Unlocked: unlocked, + Rank: rank, + Level: level, + }) + } + + writeJSON(w, map[string]any{ + "status": 200, + "ex_ranks": exList, + "avatar_count": len(data.Avatars), + "lightcone_count": len(data.Lightcones), + "relic_count": len(data.Relics), + "lineups": data.Lineups, + "scene": data.Scene, + "position": data.Position, + "main_character": data.MainCharacter.String(), + "march_type": data.MarchType.String(), + }) +} + +// apiFreesrData handles full read/write of freesr-data.json +func apiFreesrData(w http.ResponseWriter, r *http.Request) { + apiMu.Lock() + defer apiMu.Unlock() + + switch r.Method { + case http.MethodGet: + raw, err := os.ReadFile("freesr-data.json") + if err != nil { + writeJSON(w, map[string]any{"error": err.Error(), "status": 500}) + return + } + var parsed any + if err := json.Unmarshal(raw, &parsed); err != nil { + writeJSON(w, map[string]any{"error": err.Error(), "status": 500}) + return + } + writeJSON(w, map[string]any{"status": 200, "data": parsed}) + + case http.MethodPost, http.MethodPut: + body, err := io.ReadAll(r.Body) + if err != nil || len(body) == 0 { + writeJSON(w, map[string]any{"error": "empty body", "status": 400}) + return + } + + // Support both { "data": {...} } and direct {...} payload + var wrap struct { + Data json.RawMessage `json:"data"` + } + toWrite := body + if err := json.Unmarshal(body, &wrap); err == nil && len(wrap.Data) > 0 { + toWrite = wrap.Data + } + + var parsed map[string]any + if err := json.Unmarshal(toWrite, &parsed); err != nil { + writeJSON(w, map[string]any{"error": "invalid json: " + err.Error(), "status": 400}) + return + } + + out, _ := json.MarshalIndent(parsed, "", " ") + if err := os.WriteFile("freesr-data.json", out, 0o644); err != nil { + writeJSON(w, map[string]any{"error": "failed to write freesr-data.json: " + err.Error(), "status": 500}) + return + } + + // Reload and sync persistent + if d, err := common.LoadFreesrData(); err == nil { + d.SavePersistent() + } + + writeJSON(w, map[string]any{"message": "freesr-data.json saved successfully", "status": 200}) + + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +// apiFreesrAvatars handles querying and modifying avatar details +func apiFreesrAvatars(w http.ResponseWriter, r *http.Request) { + apiMu.Lock() + defer apiMu.Unlock() + + data, err := common.LoadFreesrData() + if err != nil { + writeJSON(w, map[string]any{"error": err.Error(), "status": 500}) + return + } + + switch r.Method { + case http.MethodGet: + type AvatarItem struct { + AvatarID uint32 `json:"avatar_id"` + Name string `json:"name,omitempty"` + Level uint32 `json:"level"` + Promotion uint32 `json:"promotion"` + Rank uint32 `json:"rank"` + ExRankUnlocked bool `json:"ex_rank_unlocked"` + HasExRank bool `json:"has_ex_rank"` + } + var list []AvatarItem + for _, aid := range common.BaseAvatarIDs { + if av, ok := data.Avatars[aid]; ok { + _, hasEx := common.AvatarExRankMap[aid] + name := exRankAvatarNames[aid] + list = append(list, AvatarItem{ + AvatarID: aid, + Name: name, + Level: av.Level, + Promotion: av.Promotion, + Rank: av.Data.Rank, + ExRankUnlocked: av.ExRankUnlocked, + HasExRank: hasEx, + }) + } + } + writeJSON(w, map[string]any{ + "status": 200, + "avatars": list, + }) + + case http.MethodPost, http.MethodPut: + var req struct { + AvatarID uint32 `json:"avatar_id"` + Level *uint32 `json:"level"` + Promotion *uint32 `json:"promotion"` + Rank *uint32 `json:"rank"` + ExRankUnlocked *bool `json:"ex_rank_unlocked"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.AvatarID == 0 { + writeJSON(w, map[string]any{"error": "invalid payload or missing avatar_id", "status": 400}) + return + } + + av, ok := data.Avatars[req.AvatarID] + if !ok { + writeJSON(w, map[string]any{"error": fmt.Sprintf("avatar %d not found", req.AvatarID), "status": 404}) + return + } + + if req.Level != nil { + av.Level = *req.Level + } + if req.Promotion != nil { + av.Promotion = *req.Promotion + } + if req.Rank != nil && *req.Rank <= 6 { + av.Data.Rank = *req.Rank + } + if req.ExRankUnlocked != nil { + av.ExRankUnlocked = *req.ExRankUnlocked + } + + raw, _ := json.MarshalIndent(data, "", " ") + _ = os.WriteFile("freesr-data.json", raw, 0o644) + data.SavePersistent() + + writeJSON(w, map[string]any{ + "message": fmt.Sprintf("avatar %d updated", req.AvatarID), + "status": 200, + "avatar_id": req.AvatarID, + }) + + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +// apiPersistentData handles full read/write of "persistent" file +func apiPersistentData(w http.ResponseWriter, r *http.Request) { + apiMu.Lock() + defer apiMu.Unlock() + + switch r.Method { + case http.MethodGet: + raw, err := os.ReadFile("persistent") + if err != nil { + writeJSON(w, map[string]any{"error": err.Error(), "status": 500}) + return + } + var parsed any + if err := json.Unmarshal(raw, &parsed); err != nil { + writeJSON(w, map[string]any{"error": err.Error(), "status": 500}) + return + } + writeJSON(w, map[string]any{"status": 200, "persistent": parsed}) + + case http.MethodPost, http.MethodPut: + body, err := io.ReadAll(r.Body) + if err != nil || len(body) == 0 { + writeJSON(w, map[string]any{"error": "empty body", "status": 400}) + return + } + + var parsed map[string]any + if err := json.Unmarshal(body, &parsed); err != nil { + writeJSON(w, map[string]any{"error": "invalid json: " + err.Error(), "status": 400}) + return + } + + out, _ := json.MarshalIndent(parsed, "", " ") + if err := os.WriteFile("persistent", out, 0o644); err != nil { + writeJSON(w, map[string]any{"error": "failed to write persistent: " + err.Error(), "status": 500}) + return + } + + writeJSON(w, map[string]any{"message": "persistent saved successfully", "status": 200}) + + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +// apiExRankToggle toggles Ex-Rank state for characters +func apiExRankToggle(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + AvatarID uint32 `json:"avatar_id"` + Unlocked *bool `json:"unlocked"` + All bool `json:"all"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, map[string]any{"error": "invalid json", "status": 400}) + return + } + + apiMu.Lock() + defer apiMu.Unlock() + + data, err := common.LoadFreesrData() + if err != nil { + writeJSON(w, map[string]any{"error": err.Error(), "status": 500}) + return + } + + if req.All { + setVal := false + if req.Unlocked != nil { + setVal = *req.Unlocked + } + for aid := range common.AvatarExRankMap { + if av, ok := data.Avatars[aid]; ok { + av.ExRankUnlocked = setVal + } + } + } else if req.AvatarID != 0 { + if av, ok := data.Avatars[req.AvatarID]; ok { + if req.Unlocked != nil { + av.ExRankUnlocked = *req.Unlocked + } else { + av.ExRankUnlocked = !av.ExRankUnlocked + } + } + } + + data.SavePersistent() + writeJSON(w, map[string]any{"message": "OK", "status": 200}) +} + +// apiAvatarRank modifies an avatar's eidolon/rank +func apiAvatarRank(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + AvatarID uint32 `json:"avatar_id"` + Rank uint32 `json:"rank"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, map[string]any{"error": "invalid json", "status": 400}) + return + } + + apiMu.Lock() + defer apiMu.Unlock() + + data, err := common.LoadFreesrData() + if err != nil { + writeJSON(w, map[string]any{"error": err.Error(), "status": 500}) + return + } + + if av, ok := data.Avatars[req.AvatarID]; ok { + if req.Rank <= 6 { + av.Data.Rank = req.Rank + } + } + + raw, _ := json.MarshalIndent(data, "", " ") + _ = os.WriteFile("freesr-data.json", raw, 0o644) + data.SavePersistent() + + writeJSON(w, map[string]any{"message": "OK", "status": 200}) +} + +// apiMaxAll sets all characters to max level 80 and rank 6 +func apiMaxAll(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + apiMu.Lock() + defer apiMu.Unlock() + + data, err := common.LoadFreesrData() + if err != nil { + writeJSON(w, map[string]any{"error": err.Error(), "status": 500}) + return + } + + for _, av := range data.Avatars { + av.Level = 80 + av.Promotion = 6 + av.Data.Rank = 6 + } + + raw, _ := json.MarshalIndent(data, "", " ") + _ = os.WriteFile("freesr-data.json", raw, 0o644) + data.SavePersistent() + + writeJSON(w, map[string]any{"message": "OK", "status": 200}) +} + +// apiTeleport updates scene and position in persistent +func apiTeleport(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + PlaneID uint32 `json:"plane_id"` + FloorID uint32 `json:"floor_id"` + EntryID uint32 `json:"entry_id"` + X *int32 `json:"x"` + Y *int32 `json:"y"` + Z *int32 `json:"z"` + RotY *int32 `json:"rot_y"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, map[string]any{"error": "invalid json", "status": 400}) + return + } + + apiMu.Lock() + defer apiMu.Unlock() + + data, err := common.LoadFreesrData() + if err != nil { + writeJSON(w, map[string]any{"error": err.Error(), "status": 500}) + return + } + + if req.PlaneID != 0 { + data.Scene.PlaneID = req.PlaneID + } + if req.FloorID != 0 { + data.Scene.FloorID = req.FloorID + } + if req.EntryID != 0 { + data.Scene.EntryID = req.EntryID + } + if req.X != nil { + data.Position.X = *req.X + } + if req.Y != nil { + data.Position.Y = *req.Y + } + if req.Z != nil { + data.Position.Z = *req.Z + } + if req.RotY != nil { + data.Position.RotY = *req.RotY + } + + data.SavePersistent() + writeJSON(w, map[string]any{"message": "teleported successfully", "status": 200}) +} diff --git a/internal/sdkserver/server.go b/internal/sdkserver/server.go index 98147b6..8e66ce3 100644 --- a/internal/sdkserver/server.go +++ b/internal/sdkserver/server.go @@ -32,15 +32,12 @@ type appState struct { } func loadHotfix() map[string]VersionConfig { - const path = "versions.json" - raw, err := os.ReadFile(path) - if err != nil { - return map[string]VersionConfig{} - } out := map[string]VersionConfig{} - if err := json.Unmarshal(raw, &out); err != nil { - Log("malformed versions.json: " + err.Error()) - return map[string]VersionConfig{} + if raw, err := os.ReadFile("hotfix.json"); err == nil { + _ = json.Unmarshal(raw, &out) + } + if raw, err := os.ReadFile("versions.json"); err == nil { + _ = json.Unmarshal(raw, &out) } return out } @@ -54,6 +51,12 @@ func (a *appState) getOrInsertHotfix(version, dispatchSeed string) VersionConfig if cfg, ok := a.hotfixMap[version]; ok { return cfg } + if cfg, ok := a.hotfixMap["CNBETAWin"+version]; ok { + return cfg + } + if cfg, ok := a.hotfixMap["OSBETAWin"+version]; ok { + return cfg + } Log(fmt.Sprintf("trying to fetch hotfix for version %s with dispatch seed %s", version, dispatchSeed)) @@ -354,10 +357,18 @@ func Listen() error { mux.HandleFunc("/account/ma-cn-passport/app/loginByPassword", func(w http.ResponseWriter, r *http.Request) { apnLoginReply(w) }) mux.HandleFunc("/account/ma-cn-session/app/verify", func(w http.ResponseWriter, r *http.Request) { apnVerifyReply(w) }) mux.HandleFunc("/srtools", srToolsSave) - mux.HandleFunc("/", notFound) + registerAPIRoutes(mux) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" || r.URL.Path == "/dashboard" { + apiRoot(w, r) + return + } + notFound(w, r) + }) addr := fmt.Sprintf("0.0.0.0:%d", Port) Log("sdkserver is listening at " + addr) + Log(fmt.Sprintf("REST API Gateway is live at http://localhost:%d/ (for GaC-Srtool)", Port)) return http.ListenAndServe(addr, mux) }