feat: replace local web UI with REST API gateway for freesr and persistent integration (GaC-Srtool)
This commit is contained in:
1 parent
5b33715c1b
commit
9aab62414f
2 files changed
+514
-9
No files matched your search
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user