2026-10-16 18:07:56 +07:00
package sdkserver
import (
2026-10-21 20:40:13 +07:00
"encoding/base64"
2026-10-16 18:07:56 +07:00
"encoding/json"
"fmt"
"io"
"net/http"
"os"
2026-10-17 10:48:11 +07:00
"strconv"
"strings"
2026-10-16 18:07:56 +07:00
"sync"
2026-10-17 03:45:06 +07:00
"time"
2026-10-16 18:07:56 +07:00
"gac-hkrpg-go/internal/common"
2026-10-21 20:40:13 +07:00
goproto "gac-hkrpg-go/protocol/proto"
"google.golang.org/protobuf/proto"
2026-10-16 18:07:56 +07:00
)
var apiMu sync . Mutex
2026-10-21 20:40:13 +07:00
var liveBattleReportState struct {
sync . RWMutex
report * common . BattleReport
received time . Time
}
2026-10-16 18:07:56 +07:00
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" )
2026-10-21 20:40:13 +07:00
w . Header (). Set ( "Access-Control-Allow-Headers" , "Content-Type, Authorization, X-Requested-With, X-GaC-Srtool" )
2026-10-16 18:07:56 +07:00
if r . Method == http . MethodOptions {
w . WriteHeader ( http . StatusOK )
return
}
next ( w , r )
}
}
func registerAPIRoutes ( mux * http . ServeMux ) {
2026-10-21 20:40:13 +07:00
mux . HandleFunc ( "/api/tool-session" , corsWrapper ( apiToolSession ))
2026-10-16 18:07:56 +07:00
mux . HandleFunc ( "/api/status" , corsWrapper ( apiStatus ))
2026-10-17 23:44:36 +07:00
mux . HandleFunc ( "/api/technique" , corsWrapper ( apiTechnique ))
2026-10-28 18:54:43 +07:00
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 ))
2026-10-16 18:07:56 +07:00
mux . HandleFunc ( "/api/freesr" , corsWrapper ( apiFreesrData ))
2026-10-21 20:40:13 +07:00
mux . HandleFunc ( "/api/inventory/materials" , corsWrapper ( apiInventoryMaterials ))
2026-10-16 18:07:56 +07:00
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 ))
2026-10-17 03:45:06 +07:00
mux . HandleFunc ( "/api/gachabase/fetch" , corsWrapper ( apiGachabaseFetch ))
2026-10-17 10:48:11 +07:00
mux . HandleFunc ( "/api/command" , corsWrapper ( apiCommand ))
2026-10-21 20:40:13 +07:00
mux . HandleFunc ( "/api/client/unstuck" , corsWrapper ( apiClientUnstuck ))
mux . HandleFunc ( "/api/client/unstuck/result" , corsWrapper ( apiClientUnstuckResult ))
2026-10-17 11:08:37 +07:00
mux . HandleFunc ( "/api/data-in-game" , corsWrapper ( apiDataInGame ))
2026-10-17 13:08:13 +07:00
mux . HandleFunc ( "/api/battle_report" , corsWrapper ( apiBattleReport ))
2026-10-21 20:40:13 +07:00
mux . HandleFunc ( "/api/battle_report/live" , corsWrapper ( apiBattleReportLive ))
2026-10-17 13:08:13 +07:00
mux . HandleFunc ( "/api/battle_report/reset" , corsWrapper ( apiBattleReportReset ))
2026-10-16 18:07:56 +07:00
}
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
}
2026-10-16 22:19:52 +07:00
cfg , err := common . LoadRuntimeConfig ()
if err != nil {
http . Error ( w , err . Error (), http . StatusInternalServerError )
return
}
2026-10-16 18:07:56 +07:00
writeJSON ( w , map [ string ] any {
"name" : "GaC-hkrpg-go API Gateway" ,
2026-10-16 22:19:52 +07:00
"version" : cfg . ClientVersion ,
2026-10-16 18:07:56 +07:00
"status" : 200 ,
"description" : "Backend connection endpoints for freesr-data, persistent, and GaC-Srtool" ,
"endpoints" : map [ string ] string {
2026-10-21 20:40:13 +07:00
"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" ,
2026-10-16 18:07:56 +07:00
},
})
}
// 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 (),
})
}
2026-10-21 20:40:13 +07:00
// apiFreesrData reads data, merges POST updates, and validates complete PUTs.
2026-10-16 18:07:56 +07:00
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
}
2026-10-21 20:40:13 +07:00
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 ())
2026-10-16 18:07:56 +07:00
return
}
2026-10-21 20:40:13 +07:00
out , err := preparePlayerDataUpdate ( existing , toWrite , r . Method == http . MethodPut )
if err != nil {
writeInventoryError ( w , http . StatusBadRequest , err . Error ())
2026-10-16 18:07:56 +07:00
return
}
2026-10-21 20:40:13 +07:00
if err := writePlayerDataAtomic ( out ); err != nil {
writeInventoryError ( w , http . StatusInternalServerError , "failed to write player data: " + err . Error ())
return
2026-10-16 18:07:56 +07:00
}
writeJSON ( w , map [ string ] any { "message" : "freesr-data.json saved successfully" , "status" : 200 })
default :
http . Error ( w , "method not allowed" , http . StatusMethodNotAllowed )
}
}
2026-10-21 20:40:13 +07:00
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 {}{}
}
2026-10-28 18:54:43 +07:00
updates := make ( map [ uint32 ] * uint32 , len ( req . Updates ))
2026-10-21 20:40:13 +07:00
for _ , update := range req . Updates {
if update . Reset {
2026-10-28 18:54:43 +07:00
updates [ update . ItemID ] = nil
2026-10-21 20:40:13 +07:00
} else {
2026-10-28 18:54:43 +07:00
updates [ update . ItemID ] = update . Count
2026-10-21 20:40:13 +07:00
}
}
2026-10-28 18:54:43 +07:00
materials , err := common . PatchMaterialOverrides ( updates )
2026-10-21 20:40:13 +07:00
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 )
}
}
2026-10-16 18:07:56 +07:00
// 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 , 0 o644 )
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
}
2026-10-28 18:54:43 +07:00
var parsed map [ string ] json . RawMessage
2026-10-16 18:07:56 +07:00
if err := json . Unmarshal ( body , & parsed ); err != nil {
writeJSON ( w , map [ string ] any { "error" : "invalid json: " + err . Error (), "status" : 400 })
return
}
2026-10-28 18:54:43 +07:00
if err := common . PatchPersistentFields ( parsed ); err != nil {
2026-10-16 18:07:56 +07:00
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 )
}
}
2026-10-17 11:08:37 +07:00
// 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 , 0 o644 ); 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 )
}
}
2026-10-16 18:07:56 +07:00
// 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 , 0 o644 )
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 , 0 o644 )
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 })
}
2026-10-17 03:45:06 +07:00
// 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"
}
2026-10-17 10:28:54 +07:00
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 )
}
2026-10-17 03:45:06 +07:00
}
}
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 ,
})
}
2026-10-17 10:48:11 +07:00
// 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
}
2026-10-21 20:40:13 +07:00
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
}
2026-10-17 10:48:11 +07:00
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 , 0 o644 )
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" ),
})
}
2026-10-17 13:08:13 +07:00
// 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 {
2026-10-21 20:40:13 +07:00
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
}
2026-10-17 13:08:13 +07:00
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 ,
2026-10-21 20:40:13 +07:00
"is_live" : false ,
"revision" : report . Timestamp * 1000 ,
2026-10-17 13:08:13 +07:00
"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 ()
}
2026-10-21 20:40:13 +07:00
clearLiveBattleReport ()
2026-10-17 13:08:13 +07:00
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 )
}
2026-10-21 20:40:13 +07:00
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 ()
}
2026-10-17 13:08:13 +07:00
// 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
}
2026-10-21 20:40:13 +07:00
clearLiveBattleReport ()
2026-10-17 13:08:13 +07:00
writeJSON ( w , map [ string ] any {
"status" : 200 ,
"message" : "Battle report cleared" ,
})
}