1151 lines
35 KiB
Go
1151 lines
35 KiB
Go
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/aha-help", corsWrapper(apiAhaHelp))
|
|
mux.HandleFunc("/api/camera", corsWrapper(apiCamera))
|
|
mux.HandleFunc("/api/monster-settings/catalog", corsWrapper(apiMonsterSettingsCatalog))
|
|
mux.HandleFunc("/api/monster-settings/aa-round", corsWrapper(apiAARoundSelection))
|
|
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{}{}
|
|
}
|
|
updates := make(map[uint32]*uint32, len(req.Updates))
|
|
for _, update := range req.Updates {
|
|
if update.Reset {
|
|
updates[update.ItemID] = nil
|
|
} else {
|
|
updates[update.ItemID] = update.Count
|
|
}
|
|
}
|
|
materials, err := common.PatchMaterialOverrides(updates)
|
|
if 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]json.RawMessage
|
|
if err := json.Unmarshal(body, &parsed); err != nil {
|
|
writeJSON(w, map[string]any{"error": "invalid json: " + err.Error(), "status": 400})
|
|
return
|
|
}
|
|
|
|
if err := common.PatchPersistentFields(parsed); 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 <plane_id> [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 <avatar_id> [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 <item_id> [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 <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)
|
|
}
|
|
|
|
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",
|
|
})
|
|
}
|