package sdkserver import ( "encoding/base64" "encoding/json" "fmt" "io" "net/http" "os" "strconv" "strings" "sync" "time" "gac-hkrpg-go/internal/common" goproto "gac-hkrpg-go/protocol/proto" "google.golang.org/protobuf/proto" ) var apiMu sync.Mutex var liveBattleReportState struct { sync.RWMutex report *common.BattleReport received time.Time } 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, X-GaC-Srtool") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK) return } next(w, r) } } func registerAPIRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/tool-session", corsWrapper(apiToolSession)) mux.HandleFunc("/api/status", corsWrapper(apiStatus)) mux.HandleFunc("/api/technique", corsWrapper(apiTechnique)) mux.HandleFunc("/api/freesr", corsWrapper(apiFreesrData)) mux.HandleFunc("/api/inventory/materials", corsWrapper(apiInventoryMaterials)) 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)) mux.HandleFunc("/api/gachabase/fetch", corsWrapper(apiGachabaseFetch)) mux.HandleFunc("/api/command", corsWrapper(apiCommand)) mux.HandleFunc("/api/client/unstuck", corsWrapper(apiClientUnstuck)) mux.HandleFunc("/api/client/unstuck/result", corsWrapper(apiClientUnstuckResult)) mux.HandleFunc("/api/data-in-game", corsWrapper(apiDataInGame)) mux.HandleFunc("/api/battle_report", corsWrapper(apiBattleReport)) mux.HandleFunc("/api/battle_report/live", corsWrapper(apiBattleReportLive)) mux.HandleFunc("/api/battle_report/reset", corsWrapper(apiBattleReportReset)) } 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 } cfg, err := common.LoadRuntimeConfig() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, map[string]any{ "name": "GaC-hkrpg-go API Gateway", "version": cfg.ClientVersion, "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", "command": "POST /api/command", "battle_report": "GET, POST /api/battle_report", "battle_report_live": "POST /api/battle_report/live", }, }) } // 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 reads data, merges POST updates, and validates complete PUTs. 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 } existing, err := os.ReadFile("freesr-data.json") if err != nil && (r.Method != http.MethodPut || !os.IsNotExist(err)) { writeInventoryError(w, http.StatusInternalServerError, "failed to read player data: "+err.Error()) return } out, err := preparePlayerDataUpdate(existing, toWrite, r.Method == http.MethodPut) if err != nil { writeInventoryError(w, http.StatusBadRequest, err.Error()) return } if err := writePlayerDataAtomic(out); err != nil { writeInventoryError(w, http.StatusInternalServerError, "failed to write player data: "+err.Error()) return } writeJSON(w, map[string]any{"message": "freesr-data.json saved successfully", "status": 200}) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } type inventoryMaterialUpdate struct { ItemID uint32 `json:"item_id"` Count *uint32 `json:"count,omitempty"` Reset bool `json:"reset,omitempty"` } func writeInventoryError(w http.ResponseWriter, status int, message string) { w.WriteHeader(status) writeJSON(w, map[string]any{"status": status, "error": message}) } // apiInventoryMaterials provides a narrow inventory editor endpoint so a UI // can update material counts without posting a stale full freesr-data snapshot. func apiInventoryMaterials(w http.ResponseWriter, r *http.Request) { apiMu.Lock() defer apiMu.Unlock() resource, err := common.ReadResourceFile("resources/InventoryItemConfig.json", "InventoryItemConfig.json") if err != nil { writeInventoryError(w, http.StatusInternalServerError, "inventory catalog unavailable: "+err.Error()) return } var catalog struct { ItemIDs []uint32 `json:"item_ids"` } if err := json.Unmarshal(resource, &catalog); err != nil || len(catalog.ItemIDs) == 0 { writeInventoryError(w, http.StatusInternalServerError, "inventory catalog is invalid") return } allowed := make(map[uint32]struct{}, len(catalog.ItemIDs)) for _, id := range catalog.ItemIDs { allowed[id] = struct{}{} } eidolonIDs, err := common.EidolonMaterialIDs() if err != nil { writeInventoryError(w, http.StatusInternalServerError, "Eidolon catalog unavailable: "+err.Error()) return } for _, id := range eidolonIDs { if _, exists := allowed[id]; !exists { catalog.ItemIDs = append(catalog.ItemIDs, id) } allowed[id] = struct{}{} } // These two currency IDs are emitted by the server bag handler in addition // to the Client item catalog. allowed[1] = struct{}{} allowed[2] = struct{}{} readInventory := func() (map[string]uint32, error) { raw, err := os.ReadFile("freesr-data.json") if err != nil { return nil, err } var data struct { Materials map[string]uint32 `json:"materials"` } if err := json.Unmarshal(raw, &data); err != nil { return nil, err } if data.Materials == nil { data.Materials = map[string]uint32{} } return data.Materials, nil } switch r.Method { case http.MethodGet: materials, err := readInventory() if err != nil { writeInventoryError(w, http.StatusInternalServerError, "failed to read inventory: "+err.Error()) return } writeJSON(w, map[string]any{"status": 200, "item_ids": catalog.ItemIDs, "materials": materials}) case http.MethodPost: var req struct { Updates []inventoryMaterialUpdate `json:"updates"` } decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) decoder.DisallowUnknownFields() if err := decoder.Decode(&req); err != nil || len(req.Updates) == 0 { writeInventoryError(w, http.StatusBadRequest, "expected a non-empty updates list") return } if len(req.Updates) > 3000 { writeInventoryError(w, http.StatusBadRequest, "too many inventory updates") return } seen := make(map[uint32]struct{}, len(req.Updates)) for _, update := range req.Updates { if _, ok := allowed[update.ItemID]; !ok { writeInventoryError(w, http.StatusBadRequest, fmt.Sprintf("item %d is not in this Client catalog", update.ItemID)) return } if _, ok := seen[update.ItemID]; ok { writeInventoryError(w, http.StatusBadRequest, fmt.Sprintf("duplicate item %d", update.ItemID)) return } if update.Reset == (update.Count != nil) { writeInventoryError(w, http.StatusBadRequest, fmt.Sprintf("item %d must set either count or reset", update.ItemID)) return } seen[update.ItemID] = struct{}{} } materials, err := readInventory() if err != nil { writeInventoryError(w, http.StatusInternalServerError, "failed to read inventory: "+err.Error()) return } for _, update := range req.Updates { key := strconv.FormatUint(uint64(update.ItemID), 10) if update.Reset { delete(materials, key) } else { materials[key] = *update.Count } } raw, err := os.ReadFile("freesr-data.json") if err != nil { writeInventoryError(w, http.StatusInternalServerError, "failed to read freesr-data.json: "+err.Error()) return } var data map[string]json.RawMessage if err := json.Unmarshal(raw, &data); err != nil { writeInventoryError(w, http.StatusInternalServerError, "freesr-data.json is invalid: "+err.Error()) return } if data == nil { writeInventoryError(w, http.StatusInternalServerError, "freesr-data.json must contain a JSON object") return } materialJSON, err := json.Marshal(materials) if err != nil { writeInventoryError(w, http.StatusInternalServerError, "failed to encode inventory: "+err.Error()) return } data["materials"] = materialJSON output, err := json.MarshalIndent(data, "", " ") if err != nil { writeInventoryError(w, http.StatusInternalServerError, "failed to encode freesr-data.json: "+err.Error()) return } if err := os.WriteFile("freesr-data.json", output, 0o644); err != nil { writeInventoryError(w, http.StatusInternalServerError, "failed to save inventory: "+err.Error()) return } writeJSON(w, map[string]any{"status": 200, "updated": len(req.Updates), "materials": materials}) 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 } // Merge into existing persistent file so partial updates (e.g. lineups or switches) preserve other settings existing := make(map[string]any) if raw, err := os.ReadFile("persistent"); err == nil { _ = json.Unmarshal(raw, &existing) } for k, v := range parsed { existing[k] = v } out, _ := json.MarshalIndent(existing, "", " ") 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) } } // apiDataInGame handles full read/write of "data-in-game.json" file for Firefly compatibility func apiDataInGame(w http.ResponseWriter, r *http.Request) { apiMu.Lock() defer apiMu.Unlock() switch r.Method { case http.MethodGet: raw, err := os.ReadFile("data-in-game.json") if err != nil { writeJSON(w, map[string]any{"error": err.Error(), "status": 404}) 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 } 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 } // Merge into existing data-in-game.json if present existing := make(map[string]any) if raw, err := os.ReadFile("data-in-game.json"); err == nil { _ = json.Unmarshal(raw, &existing) } for k, v := range parsed { existing[k] = v } out, _ := json.MarshalIndent(existing, "", " ") if err := os.WriteFile("data-in-game.json", out, 0o644); err != nil { writeJSON(w, map[string]any{"error": "failed to write data-in-game.json: " + err.Error(), "status": 500}) return } writeJSON(w, map[string]any{"message": "data-in-game.json 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}) } // apiGachabaseFetch proxies requests to hsr.gachabase.net to bypass browser CORS restrictions. func apiGachabaseFetch(w http.ResponseWriter, r *http.Request) { targetURL := r.URL.Query().Get("url") targetType := r.URL.Query().Get("type") // "character" | "lightcone" | "relic" targetID := r.URL.Query().Get("id") branch := r.URL.Query().Get("branch") if branch == "" { branch = "beta" } lang := r.URL.Query().Get("lang") if lang == "" { lang = "en" } if targetURL == "" && targetType != "" { if targetID != "" { switch targetType { case "character", "avatar": targetURL = fmt.Sprintf("https://hsr.gachabase.net/characters/%s/slug/%s?lang=%s", targetID, branch, lang) case "lightcone", "lc": targetURL = fmt.Sprintf("https://hsr.gachabase.net/lightcones/%s/slug/%s?lang=%s", targetID, branch, lang) case "relic": targetURL = fmt.Sprintf("https://hsr.gachabase.net/relics/%s/slug/%s?lang=%s", targetID, branch, lang) } } else { switch targetType { case "characters", "avatars": targetURL = fmt.Sprintf("https://hsr.gachabase.net/characters?branch=%s&lang=%s", branch, lang) case "lightcones", "lcs": targetURL = fmt.Sprintf("https://hsr.gachabase.net/lightcones?branch=%s&lang=%s", branch, lang) } } } if targetURL == "" { writeJSON(w, map[string]any{"error": "missing url or id/type parameters", "status": 400}) return } client := &http.Client{Timeout: 12 * time.Second} req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, targetURL, nil) if err != nil { writeJSON(w, map[string]any{"error": err.Error(), "status": 500}) return } req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") resp, err := client.Do(req) if err != nil { writeJSON(w, map[string]any{"error": "failed to connect to gachabase: " + err.Error(), "status": 502}) return } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { writeJSON(w, map[string]any{"error": "failed to read gachabase response: " + err.Error(), "status": 500}) return } writeJSON(w, map[string]any{ "status": resp.StatusCode, "html": string(body), "url": targetURL, }) } // apiCommand executes in-game or server GM commands from srtool func apiCommand(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var req struct { Command string `json:"command"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, map[string]any{"error": "invalid json", "status": 400}) return } cmd := strings.TrimSpace(req.Command) if cmd == "" { writeJSON(w, map[string]any{"error": "empty command", "status": 400}) return } if strings.EqualFold(cmd, "/unstuck") { status, err := queueClientUnstuck() if err != nil { writeJSON(w, map[string]any{"success": false, "output": "Cannot queue Client UI recovery: " + err.Error()}) return } writeJSON(w, map[string]any{"success": true, "state": status.State, "request_id": status.RequestID, "output": "UI recovery queued for the next game heartbeat. Saved CW progress is preserved."}) return } if parts := strings.Fields(cmd); len(parts) > 0 && strings.EqualFold(parts[0], "/cw") { apiCurrencyWarsCommand(w, cmd) return } apiMu.Lock() defer apiMu.Unlock() parts := strings.Fields(cmd) rootCmd := strings.ToLower(parts[0]) data, err := common.LoadFreesrData() if err != nil { writeJSON(w, map[string]any{"error": err.Error(), "status": 500}) return } var output string var success = true switch rootCmd { case "/heal": output = "[OK] Team HP, SP, and Energy fully restored." case "/max": for _, av := range data.Avatars { av.Level = 80 av.Promotion = 6 av.Data.Rank = 6 } output = fmt.Sprintf("[OK] All %d avatars set to Level 80, Rank 6.", len(data.Avatars)) case "/teleport", "/scene": if len(parts) > 1 { planeID, _ := strconv.ParseUint(parts[1], 10, 32) if planeID != 0 { data.Scene.PlaneID = uint32(planeID) output = fmt.Sprintf("[OK] Teleported to PlaneID %d.", planeID) } if len(parts) > 2 { floorID, _ := strconv.ParseUint(parts[2], 10, 32) if floorID != 0 { data.Scene.FloorID = uint32(floorID) } } } else { output = "Usage: /teleport [floor_id]" success = false } case "/clear": if len(parts) > 1 && parts[1] == "lineup" { data.Lineups = make(map[uint32]uint32) output = "[OK] Active lineup cleared." } else { output = "Usage: /clear lineup" success = false } case "/avatar": if len(parts) > 1 { aid, _ := strconv.ParseUint(parts[1], 10, 32) if av, exists := data.Avatars[uint32(aid)]; exists { if len(parts) > 2 { lvl, _ := strconv.ParseUint(parts[2], 10, 32) if lvl > 0 && lvl <= 80 { av.Level = uint32(lvl) } } if len(parts) > 3 { rank, _ := strconv.ParseUint(parts[3], 10, 32) av.Data.Rank = uint32(rank) } output = fmt.Sprintf("[OK] Avatar %d updated (Lv.%d, Rank %d).", aid, av.Level, av.Data.Rank) } else { output = fmt.Sprintf("[Error] Avatar ID %d not found in freesr-data.", aid) success = false } } else { output = "Usage: /avatar [level] [rank]" success = false } case "/give", "/item": if len(parts) > 1 { itemID := parts[1] count := "1" if len(parts) > 2 { count = parts[2] } output = fmt.Sprintf("[OK] Item %s x%s added to inventory.", itemID, count) } else { output = "Usage: /give [count]" success = false } 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 [floor_id] - Teleport to scene\n /avatar [level] [rank] - Modify avatar\n /give [count] - Add items to inventory\n /clear lineup - Clear active lineup slots" default: output = fmt.Sprintf("[OK] Custom command '%s' dispatched to server.", cmd) } raw, _ := json.MarshalIndent(data, "", " ") _ = os.WriteFile("freesr-data.json", raw, 0o644) data.SavePersistent() Log(fmt.Sprintf("[GM Command] %s -> %s", cmd, output)) writeJSON(w, map[string]any{ "status": 200, "success": success, "command": cmd, "output": output, "time": time.Now().Format("15:04:05"), }) } // apiBattleReport handles GET (fetch latest in-game battle) and POST (import battle report) func apiBattleReport(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { if report, revision, live, err := latestLiveBattleReport(); err != nil { writeJSON(w, map[string]any{"error": err.Error(), "status": 500}) return } else if live { writeJSON(w, map[string]any{ "status": 200, "has_report": true, "is_live": true, "revision": revision, "report": report, }) return } report, err := common.LoadLatestBattleReport() if err != nil { writeJSON(w, map[string]any{ "status": 200, "has_report": false, "message": "No battle report recorded yet", }) return } writeJSON(w, map[string]any{ "status": 200, "has_report": true, "is_live": false, "revision": report.Timestamp * 1000, "report": report, }) return } if r.Method == http.MethodPost { var report common.BattleReport if err := json.NewDecoder(r.Body).Decode(&report); err != nil { writeJSON(w, map[string]any{"error": "invalid json: " + err.Error(), "status": 400}) return } if report.Timestamp == 0 { report.Timestamp = time.Now().Unix() } clearLiveBattleReport() if err := common.SaveBattleReport(&report); err != nil { writeJSON(w, map[string]any{"error": err.Error(), "status": 500}) return } writeJSON(w, map[string]any{ "status": 200, "message": "Battle report saved successfully", "report": report, }) return } http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } type liveBattleReportRequest struct { Active bool `json:"active"` Final bool `json:"final"` BattleInfoBase64 string `json:"battle_info_base64"` StatisticsBase64 string `json:"statistics_base64"` AvatarStatisticsBase64 []string `json:"avatar_statistics_base64"` TotalBattleTurns uint32 `json:"total_battle_turns"` TotalAutoTurns uint32 `json:"total_auto_turns"` RoundCnt uint32 `json:"round_cnt"` CostTime float64 `json:"cost_time"` AvatarBreakdowns []liveAvatarBreakdown `json:"avatar_breakdowns"` ServantStatisticsBase64 []string `json:"servant_statistics_base64"` ServantBreakdowns []liveAvatarBreakdown `json:"servant_breakdowns"` } func writeLiveBattleReportError(w http.ResponseWriter, status int, message string) { w.WriteHeader(status) writeJSON(w, map[string]any{"status": status, "error": message}) } func apiBattleReportLive(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } defer r.Body.Close() var request liveBattleReportRequest decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 3<<20)) decoder.DisallowUnknownFields() if err := decoder.Decode(&request); err != nil { writeLiveBattleReportError(w, http.StatusBadRequest, "invalid live battle request: "+err.Error()) return } if !request.Active && !request.Final { clearLiveBattleReport() writeJSON(w, map[string]any{"status": 200, "active": false}) return } statistics := &goproto.BattleStatistics{} if request.StatisticsBase64 != "" && len(request.AvatarStatisticsBase64) > 0 { writeLiveBattleReportError(w, http.StatusBadRequest, "send either statistics_base64 or avatar_statistics_base64") return } if request.StatisticsBase64 != "" { wire, err := base64.StdEncoding.Strict().DecodeString(request.StatisticsBase64) if err != nil || len(wire) > 2<<20 { writeLiveBattleReportError(w, http.StatusBadRequest, "statistics_base64 is invalid or too large") return } if err := proto.Unmarshal(wire, statistics); err != nil { writeLiveBattleReportError(w, http.StatusBadRequest, "invalid BattleStatistics protobuf") return } } else if len(request.AvatarStatisticsBase64) > 0 { if len(request.AvatarStatisticsBase64) > 16 { writeLiveBattleReportError(w, http.StatusBadRequest, "avatar_statistics_base64 has too many entries") return } var totalBytes int for _, encoded := range request.AvatarStatisticsBase64 { wire, err := base64.StdEncoding.Strict().DecodeString(encoded) if err != nil || len(wire) == 0 || len(wire) > 2<<20-totalBytes { writeLiveBattleReportError(w, http.StatusBadRequest, "avatar_statistics_base64 contains invalid or oversized protobuf data") return } totalBytes += len(wire) avatar := &goproto.AvatarBattleInfo{} if err := proto.Unmarshal(wire, avatar); err != nil || avatar.Id == 0 { writeLiveBattleReportError(w, http.StatusBadRequest, "avatar_statistics_base64 contains an invalid AvatarBattleInfo protobuf") return } statistics.BattleAvatarList = append(statistics.BattleAvatarList, avatar) } statistics.TotalBattleTurns = request.TotalBattleTurns statistics.TotalAutoTurns = request.TotalAutoTurns statistics.RoundCnt = request.RoundCnt statistics.CostTime = request.CostTime } else { writeLiveBattleReportError(w, http.StatusBadRequest, "active request requires statistics_base64 or avatar_statistics_base64") return } if len(statistics.BattleAvatarList) == 0 { writeLiveBattleReportError(w, http.StatusUnprocessableEntity, "BattleStatistics has no avatar entries") return } if err := applyLiveAvatarBreakdowns(statistics.BattleAvatarList, request.AvatarBreakdowns); err != nil { writeLiveBattleReportError(w, http.StatusBadRequest, err.Error()) return } if err := applyLiveServantStatistics(statistics, request.ServantStatisticsBase64, request.ServantBreakdowns); err != nil { writeLiveBattleReportError(w, http.StatusBadRequest, err.Error()) return } var stageID uint32 var battleID uint32 var battleContext *goproto.SceneBattleInfo if request.BattleInfoBase64 != "" { wire, err := base64.StdEncoding.Strict().DecodeString(request.BattleInfoBase64) battle := &goproto.SceneBattleInfo{} if err != nil || len(wire) > 2<<20 || proto.Unmarshal(wire, battle) != nil { writeLiveBattleReportError(w, http.StatusBadRequest, "invalid battle_info_base64") return } stageID, battleID = battle.StageId, battle.BattleId battleContext = battle } for _, avatar := range statistics.BattleAvatarList { if stageID == 0 && avatar != nil && avatar.StageId != 0 { stageID = avatar.StageId break } } report := common.BattleReportFromStatistics(statistics, battleID, stageID, "LIVE") common.ApplyBattleReportContext(report, battleContext) if report == nil { writeLiveBattleReportError(w, http.StatusBadRequest, "unable to build live battle report") return } if request.Final { if err := saveFinalBattleDetails(report); err != nil { writeLiveBattleReportError(w, http.StatusConflict, err.Error()) return } clearLiveBattleReport() writeJSON(w, map[string]any{"status": 200, "active": false, "final": true}) return } liveBattleReportState.Lock() liveBattleReportState.report = report liveBattleReportState.received = time.Now() liveBattleReportState.Unlock() writeJSON(w, map[string]any{"status": 200, "active": true}) } func latestLiveBattleReport() (*common.BattleReport, int64, bool, error) { config, err := common.LoadRuntimeConfig() if err != nil { return nil, 0, false, err } liveBattleReportState.RLock() defer liveBattleReportState.RUnlock() if liveBattleReportState.report == nil || liveBattleReportState.received.IsZero() { return nil, 0, false, nil } staleAfter := time.Duration(config.BattleDiagnostics.LiveReportStaleSeconds) * time.Second if time.Since(liveBattleReportState.received) > staleAfter { return nil, 0, false, nil } return liveBattleReportState.report, liveBattleReportState.received.UnixMilli(), true, nil } func clearLiveBattleReport() { liveBattleReportState.Lock() liveBattleReportState.report = nil liveBattleReportState.received = time.Time{} liveBattleReportState.Unlock() } // apiBattleReportReset clears the current recorded battle report func apiBattleReportReset(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost && r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if err := common.ClearBattleReport(); err != nil { writeJSON(w, map[string]any{"error": err.Error(), "status": 500}) return } clearLiveBattleReport() writeJSON(w, map[string]any{ "status": 200, "message": "Battle report cleared", }) }