pre cw with probe and a bunch script to read game behavior
This commit is contained in:
1 parent
5120219ae2
commit
da8a32f60d
310 files changed
+219693
-872
No files matched your search
+373
-23
@@ -1,6 +1,7 @@
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -12,10 +13,18 @@ import (
|
||||
"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 (ดันเฮง จ้าวยลจันทรา)",
|
||||
@@ -27,7 +36,7 @@ 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")
|
||||
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
|
||||
@@ -37,9 +46,11 @@ func corsWrapper(next http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
|
||||
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))
|
||||
@@ -48,8 +59,11 @@ func registerAPIRoutes(mux *http.ServeMux) {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -70,16 +84,17 @@ func apiRoot(w http.ResponseWriter, r *http.Request) {
|
||||
"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",
|
||||
"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",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -142,7 +157,7 @@ func apiStatus(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// apiFreesrData handles full read/write of freesr-data.json
|
||||
// apiFreesrData reads data, merges POST updates, and validates complete PUTs.
|
||||
func apiFreesrData(w http.ResponseWriter, r *http.Request) {
|
||||
apiMu.Lock()
|
||||
defer apiMu.Unlock()
|
||||
@@ -177,21 +192,19 @@ func apiFreesrData(w http.ResponseWriter, r *http.Request) {
|
||||
toWrite = wrap.Data
|
||||
}
|
||||
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(toWrite, &parsed); err != nil {
|
||||
writeJSON(w, map[string]any{"error": "invalid json: " + err.Error(), "status": 400})
|
||||
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, _ := json.MarshalIndent(parsed, "", " ")
|
||||
if err := os.WriteFile("freesr-data.json", out, 0o644); err != nil {
|
||||
writeJSON(w, map[string]any{"error": "failed to write freesr-data.json: " + err.Error(), "status": 500})
|
||||
out, err := preparePlayerDataUpdate(existing, toWrite, r.Method == http.MethodPut)
|
||||
if err != nil {
|
||||
writeInventoryError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Reload and sync persistent
|
||||
if d, err := common.LoadFreesrData(); err == nil {
|
||||
d.SavePersistent()
|
||||
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})
|
||||
@@ -201,6 +214,158 @@ func apiFreesrData(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -660,6 +825,19 @@ func apiCommand(w http.ResponseWriter, r *http.Request) {
|
||||
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()
|
||||
@@ -777,6 +955,16 @@ func apiCommand(w http.ResponseWriter, r *http.Request) {
|
||||
// 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{
|
||||
@@ -789,6 +977,8 @@ func apiBattleReport(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]any{
|
||||
"status": 200,
|
||||
"has_report": true,
|
||||
"is_live": false,
|
||||
"revision": report.Timestamp * 1000,
|
||||
"report": report,
|
||||
})
|
||||
return
|
||||
@@ -803,6 +993,7 @@ func apiBattleReport(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
@@ -818,6 +1009,164 @@ func apiBattleReport(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
@@ -828,6 +1177,7 @@ func apiBattleReportReset(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]any{"error": err.Error(), "status": 500})
|
||||
return
|
||||
}
|
||||
clearLiveBattleReport()
|
||||
writeJSON(w, map[string]any{
|
||||
"status": 200,
|
||||
"message": "Battle report cleared",
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gac-hkrpg-go/internal/common"
|
||||
goproto "gac-hkrpg-go/protocol/proto"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func TestLiveBattleReportRoundTripAndExpiry(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "server.json")
|
||||
config := `{"account_name":"fixture","player_uid":52022,"activity_schedule":{"begin_time":0,"end_time":100},"srtool":{"lease_file":"fixture-session.json","lease_seconds":30},"battle_diagnostics":{"live_report_stale_seconds":10},"client_version":"fixture","game_host":"localhost","game_port":32101,"sdk_host":"localhost","sdk_port":32102,"public_game_host":"localhost","public_sdk_url":"http://localhost:32102"}`
|
||||
if err := os.WriteFile(configPath, []byte(config), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("GAC_CONFIG", configPath)
|
||||
t.Setenv("GAC_LIVE_BATTLE_STALE_SECONDS", "10")
|
||||
defer clearLiveBattleReport()
|
||||
|
||||
statistics := &goproto.BattleStatistics{
|
||||
TotalBattleTurns: 2,
|
||||
BattleAvatarList: []*goproto.AvatarBattleInfo{{
|
||||
Id: 1204, StageId: 1052106, TotalDamage: 400,
|
||||
AttackTypeDamage: []*goproto.AttackDamageProperty{{AttackType: "Normal", Damage: 400}},
|
||||
}},
|
||||
MonsterList: []*goproto.MonsterBattleInfo{{MonsterId: 5035011, TotalDamageTaken: 400}},
|
||||
}
|
||||
wire, err := proto.Marshal(statistics)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requestBody, err := json.Marshal(liveBattleReportRequest{
|
||||
Active: true, StatisticsBase64: base64.StdEncoding.EncodeToString(wire),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
post := httptest.NewRequest(http.MethodPost, "/api/battle_report/live", strings.NewReader(string(requestBody)))
|
||||
postResponse := httptest.NewRecorder()
|
||||
apiBattleReportLive(postResponse, post)
|
||||
if postResponse.Code != http.StatusOK {
|
||||
t.Fatalf("live POST status = %d, body = %s", postResponse.Code, postResponse.Body.String())
|
||||
}
|
||||
|
||||
getResponse := httptest.NewRecorder()
|
||||
apiBattleReport(getResponse, httptest.NewRequest(http.MethodGet, "/api/battle_report", nil))
|
||||
var result struct {
|
||||
IsLive bool `json:"is_live"`
|
||||
Revision int64 `json:"revision"`
|
||||
Report common.BattleReport `json:"report"`
|
||||
}
|
||||
if err := json.Unmarshal(getResponse.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.IsLive || result.Report.EndStatus != "LIVE" || result.Report.StageID != 1052106 {
|
||||
t.Fatalf("unexpected live response: %+v", result)
|
||||
}
|
||||
if result.Report.TeamTotalDamage != 400 || result.Report.Avatars[0].DamagePercent != 100 || result.Revision == 0 {
|
||||
t.Fatalf("unexpected live damage values: %+v", result.Report)
|
||||
}
|
||||
|
||||
avatarSnapshots := []*goproto.AvatarBattleInfo{
|
||||
{
|
||||
Id: 1409, StageId: 1052106, TotalDamage: 600, TotalTurns: 2,
|
||||
AttackTypeDamage: []*goproto.AttackDamageProperty{{AttackType: "BPSkill", Damage: 600}},
|
||||
},
|
||||
{
|
||||
Id: 1507, StageId: 1052106, TotalDamage: 400, TotalTurns: 1,
|
||||
AttackTypeDamage: []*goproto.AttackDamageProperty{{AttackType: "Normal", Damage: 400}},
|
||||
},
|
||||
}
|
||||
avatarStatisticsBase64 := make([]string, 0, len(avatarSnapshots))
|
||||
for _, avatar := range avatarSnapshots {
|
||||
wire, err := proto.Marshal(avatar)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
avatarStatisticsBase64 = append(avatarStatisticsBase64, base64.StdEncoding.EncodeToString(wire))
|
||||
}
|
||||
requestBody, err = json.Marshal(liveBattleReportRequest{
|
||||
Active: true,
|
||||
AvatarStatisticsBase64: avatarStatisticsBase64,
|
||||
TotalBattleTurns: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
post = httptest.NewRequest(http.MethodPost, "/api/battle_report/live", strings.NewReader(string(requestBody)))
|
||||
postResponse = httptest.NewRecorder()
|
||||
apiBattleReportLive(postResponse, post)
|
||||
if postResponse.Code != http.StatusOK {
|
||||
t.Fatalf("avatar snapshot POST status = %d, body = %s", postResponse.Code, postResponse.Body.String())
|
||||
}
|
||||
|
||||
getResponse = httptest.NewRecorder()
|
||||
apiBattleReport(getResponse, httptest.NewRequest(http.MethodGet, "/api/battle_report", nil))
|
||||
if err := json.Unmarshal(getResponse.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.IsLive || result.Report.TotalBattleTurns != 3 || result.Report.TeamTotalDamage != 1000 {
|
||||
t.Fatalf("unexpected avatar-only live report: %+v", result)
|
||||
}
|
||||
if len(result.Report.Avatars) != 2 || result.Report.Avatars[0].DamagePercent != 60 || result.Report.Avatars[1].DamagePercent != 40 {
|
||||
t.Fatalf("unexpected avatar-only live shares: %+v", result.Report.Avatars)
|
||||
}
|
||||
if result.Report.Avatars[0].AttackTypeDamage["BPSkill"] != 600 || result.Report.Avatars[1].AttackTypeDamage["Normal"] != 400 {
|
||||
t.Fatalf("unexpected avatar-only live breakdown: %+v", result.Report.Avatars)
|
||||
}
|
||||
servantWire, _ := proto.Marshal(&goproto.BattleAvatarServantInfo{AvatarId: avatarSnapshots[1].Id, EntityId: 88, TotalDamage: 1000})
|
||||
requestBody, _ = json.Marshal(liveBattleReportRequest{
|
||||
Active: true, AvatarStatisticsBase64: avatarStatisticsBase64,
|
||||
ServantStatisticsBase64: []string{base64.StdEncoding.EncodeToString(servantWire)},
|
||||
ServantBreakdowns: []liveAvatarBreakdown{{Damage: map[string]float64{"BPSkill": 900}}},
|
||||
})
|
||||
postResponse = httptest.NewRecorder()
|
||||
apiBattleReportLive(postResponse, httptest.NewRequest(http.MethodPost, "/api/battle_report/live", strings.NewReader(string(requestBody))))
|
||||
if postResponse.Code != http.StatusOK {
|
||||
t.Fatalf("servant POST: %s", postResponse.Body.String())
|
||||
}
|
||||
getResponse = httptest.NewRecorder()
|
||||
apiBattleReport(getResponse, httptest.NewRequest(http.MethodGet, "/api/battle_report", nil))
|
||||
if err := json.Unmarshal(getResponse.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner := result.Report.Avatars[1]
|
||||
if !result.IsLive || result.Report.TeamTotalDamage != 2000 || owner.TotalDamage != 1400 || owner.MemospriteDamage != 1000 ||
|
||||
owner.DamagePercent != 70 || owner.AttackTypeDamage["BPSkill"] != 900 {
|
||||
t.Fatalf("servant data lost through live API: report=%+v owner=%+v", result.Report, owner)
|
||||
}
|
||||
|
||||
liveBattleReportState.Lock()
|
||||
liveBattleReportState.received = time.Now().Add(-11 * time.Second)
|
||||
liveBattleReportState.Unlock()
|
||||
if _, _, live, err := latestLiveBattleReport(); err != nil || live {
|
||||
t.Fatalf("expired live report should be ignored, live=%t err=%v", live, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const clientUnstuckRequestFile = "scripts/client_unstuck_request.lua"
|
||||
|
||||
type clientUnstuckStatus struct {
|
||||
RequestID string `json:"request_id"`
|
||||
State string `json:"state"`
|
||||
Error string `json:"error,omitempty"`
|
||||
QueuedAt int64 `json:"queued_at"`
|
||||
CompletedAt int64 `json:"completed_at,omitempty"`
|
||||
}
|
||||
|
||||
var clientUnstuckState struct {
|
||||
sync.Mutex
|
||||
status clientUnstuckStatus
|
||||
}
|
||||
|
||||
func queueClientUnstuck() (clientUnstuckStatus, error) {
|
||||
clientUnstuckState.Lock()
|
||||
defer clientUnstuckState.Unlock()
|
||||
var nonce [16]byte
|
||||
if _, err := rand.Read(nonce[:]); err != nil {
|
||||
return clientUnstuckStatus{}, err
|
||||
}
|
||||
id := hex.EncodeToString(nonce[:])
|
||||
// Only a generated hexadecimal nonce is interpolated. No user-supplied
|
||||
// command, path, endpoint or Lua source can enter this heartbeat script.
|
||||
script := fmt.Sprintf(clientUnstuckLua, id)
|
||||
if err := os.MkdirAll(filepath.Dir(clientUnstuckRequestFile), 0700); err != nil {
|
||||
return clientUnstuckStatus{}, err
|
||||
}
|
||||
file, err := os.CreateTemp(filepath.Dir(clientUnstuckRequestFile), ".client-unstuck-*.tmp")
|
||||
if err != nil {
|
||||
return clientUnstuckStatus{}, err
|
||||
}
|
||||
defer os.Remove(file.Name())
|
||||
if _, err = file.WriteString(script); err != nil {
|
||||
file.Close()
|
||||
return clientUnstuckStatus{}, err
|
||||
}
|
||||
if err = file.Close(); err != nil {
|
||||
return clientUnstuckStatus{}, err
|
||||
}
|
||||
if err = os.Rename(file.Name(), clientUnstuckRequestFile); err != nil {
|
||||
return clientUnstuckStatus{}, err
|
||||
}
|
||||
status := clientUnstuckStatus{RequestID: id, State: "queued", QueuedAt: time.Now().UnixMilli()}
|
||||
clientUnstuckState.status = status
|
||||
Log("[UNSTUCK] UI recovery queued request=" + id)
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func apiClientUnstuck(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
clientUnstuckState.Lock()
|
||||
status := clientUnstuckState.status
|
||||
clientUnstuckState.Unlock()
|
||||
writeJSON(w, status)
|
||||
case http.MethodPost:
|
||||
status, err := queueClientUnstuck()
|
||||
if err != nil {
|
||||
http.Error(w, "cannot queue UI recovery", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
writeJSON(w, status)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func apiClientUnstuckResult(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var result struct {
|
||||
RequestID string `json:"request_id"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&result); err != nil {
|
||||
http.Error(w, "invalid recovery result", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
clientUnstuckState.Lock()
|
||||
defer clientUnstuckState.Unlock()
|
||||
if result.RequestID == "" || result.RequestID != clientUnstuckState.status.RequestID {
|
||||
http.Error(w, "recovery request is no longer current", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
clientUnstuckState.status.State = "failed"
|
||||
if result.Success {
|
||||
clientUnstuckState.status.State = "completed"
|
||||
}
|
||||
clientUnstuckState.status.Error = result.Error
|
||||
clientUnstuckState.status.CompletedAt = time.Now().UnixMilli()
|
||||
Log("[UNSTUCK] UI recovery " + clientUnstuckState.status.State + " request=" + result.RequestID)
|
||||
writeJSON(w, clientUnstuckState.status)
|
||||
}
|
||||
|
||||
// Named UIManager API observed on the current Client. Clear only network UI
|
||||
// waits: never close a Battle, change a scene, fabricate a successful gameplay
|
||||
// response, erase a run, or reset the Client's loading/phase flags.
|
||||
const clientUnstuckLua = `do
|
||||
local id = %q
|
||||
if _G.__GAC_UNSTUCK_HTTP and _G.__GAC_UNSTUCK_HTTP.isDone then
|
||||
pcall(function() _G.__GAC_UNSTUCK_HTTP:Dispose() end); _G.__GAC_UNSTUCK_HTTP=nil
|
||||
end
|
||||
if _G.__GAC_UNSTUCK_LAST ~= id then
|
||||
_G.__GAC_UNSTUCK_LAST=id
|
||||
local ok,err=pcall(function()
|
||||
local ui=CS.RPG.Client.GlobalVars.s_UIManager
|
||||
if not ui then error('Client UIManager is unavailable') end
|
||||
ui:ClearWaitNetPacketFullScreenBlock()
|
||||
end)
|
||||
CS.UnityEngine.Debug.Log('[GAC_UNSTUCK] request='..id..' success='..tostring(ok)..' '..tostring(err or ''))
|
||||
pcall(function()
|
||||
local url=_G.__GAC_SRTOOL_LIVE_URL
|
||||
if type(url)~='string' or url=='' then return end
|
||||
local body='{"request_id":"'..id..'","success":'..tostring(ok)..',"error":'..CS.Newtonsoft.Json.JsonConvert.SerializeObject(tostring(err or ''))..'}'
|
||||
local req=CS.UnityEngine.Networking.UnityWebRequest(url..'/api/client/unstuck/result','POST')
|
||||
req.uploadHandler=CS.UnityEngine.Networking.UploadHandlerRaw(CS.System.Text.Encoding.UTF8:GetBytes(body))
|
||||
req.downloadHandler=CS.UnityEngine.Networking.DownloadHandlerBuffer()
|
||||
req:SetRequestHeader('Content-Type','application/json');req.timeout=10
|
||||
req:SendWebRequest();_G.__GAC_UNSTUCK_HTTP=req
|
||||
end)
|
||||
end
|
||||
end
|
||||
`
|
||||
@@ -0,0 +1,84 @@
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gac-hkrpg-go/internal/common"
|
||||
pb "gac-hkrpg-go/protocol/proto"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func apiCurrencyWarsCommand(w http.ResponseWriter, command string) {
|
||||
fail := func(err error) { writeJSON(w, map[string]any{"success": false, "output": err.Error()}) }
|
||||
parts := strings.Fields(command)
|
||||
usage := "Usage: /cw status | /cw gold <total> | /cw hp <total>. Changes apply in CW Preparation; adventure inventory is unchanged."
|
||||
if len(parts) < 2 {
|
||||
fail(fmt.Errorf("%s", usage))
|
||||
return
|
||||
}
|
||||
catalog, err := common.LoadCurrencyWarsCatalog()
|
||||
if err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
cfg, err := common.LoadRuntimeConfig()
|
||||
if err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
snapshot, err := common.ReadCurrencyWarsSnapshot(cfg.PlayerUID, catalog.ClientVersion, catalog.Provenance.GameAssemblySHA256)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
if snapshot == nil {
|
||||
fail(fmt.Errorf("no saved CW run"))
|
||||
return
|
||||
}
|
||||
var data pb.GridFightGetDataScRsp
|
||||
if err = proto.Unmarshal(snapshot.WireData, &data); err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
run := data.DHFOFLFCLAH
|
||||
if run == nil {
|
||||
fail(fmt.Errorf("no active CW run"))
|
||||
return
|
||||
}
|
||||
var player *pb.NIHNGPKHEPC
|
||||
for _, c := range run.NAGDJGMMCBE {
|
||||
if v := c.GetFAOCFBOFCJP(); v != nil {
|
||||
player = v
|
||||
}
|
||||
}
|
||||
if player == nil {
|
||||
fail(fmt.Errorf("CW player state missing"))
|
||||
return
|
||||
}
|
||||
field := strings.ToLower(parts[1])
|
||||
if field == "status" && len(parts) == 2 {
|
||||
writeJSON(w, map[string]any{"success": true, "output": fmt.Sprintf("CW run=%d revision=%d gold=%d hp=%d/%d level=%d", run.UniqueId, snapshot.Revision, player.Gold, player.GridFightLineupHp, player.PPKKHHNEOHB, player.EHNJDIDABHD)})
|
||||
return
|
||||
}
|
||||
if len(parts) != 3 || (field != "gold" && field != "hp") {
|
||||
fail(fmt.Errorf("%s", usage))
|
||||
return
|
||||
}
|
||||
value, err := strconv.ParseUint(parts[2], 10, 32)
|
||||
if err != nil {
|
||||
fail(fmt.Errorf("value must be a nonnegative uint32"))
|
||||
return
|
||||
}
|
||||
if field == "hp" && (value == 0 || value > uint64(player.PPKKHHNEOHB)) {
|
||||
fail(fmt.Errorf("HP must be 1..%d", player.PPKKHHNEOHB))
|
||||
return
|
||||
}
|
||||
id, err := common.QueueCurrencyWarsCommand(cfg.PlayerUID, run.UniqueId, field, uint32(value))
|
||||
if err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"success": true, "state": "queued", "request_id": id, "output": fmt.Sprintf("Queued CW %s=%d for run %d. Applies at the next heartbeat in Preparation.", field, value, run.UniqueId)})
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInventoryMaterialsUpdateIsCatalogBoundAndPreservesSave(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Chdir(root)
|
||||
if err := os.Mkdir("resources", 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("resources", "InventoryItemConfig.json"), []byte(`{"item_ids":[101,102,301]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original := `{"avatars":{"1001":{"level":80}},"battle_config":{},"materials":{"101":4}}`
|
||||
if err := os.WriteFile("freesr-data.json", []byte(original), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/inventory/materials", strings.NewReader(`{"updates":[{"item_id":101,"count":0},{"item_id":301,"count":9}]}`))
|
||||
response := httptest.NewRecorder()
|
||||
apiInventoryMaterials(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("POST status = %d, body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
var saved map[string]json.RawMessage
|
||||
raw, err := os.ReadFile("freesr-data.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var avatars map[string]any
|
||||
if err := json.Unmarshal(saved["avatars"], &avatars); err != nil || avatars["1001"] == nil {
|
||||
t.Fatalf("existing avatar data was not preserved: %s", saved["avatars"])
|
||||
}
|
||||
var materials map[string]uint32
|
||||
if err := json.Unmarshal(saved["materials"], &materials); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if materials["101"] != 0 || materials["301"] != 9 {
|
||||
t.Fatalf("saved materials = %#v", materials)
|
||||
}
|
||||
|
||||
reset := httptest.NewRequest(http.MethodPost, "/api/inventory/materials", strings.NewReader(`{"updates":[{"item_id":101,"reset":true}]}`))
|
||||
resetResponse := httptest.NewRecorder()
|
||||
apiInventoryMaterials(resetResponse, reset)
|
||||
if resetResponse.Code != http.StatusOK {
|
||||
t.Fatalf("reset status = %d, body=%s", resetResponse.Code, resetResponse.Body.String())
|
||||
}
|
||||
raw, err = os.ReadFile("freesr-data.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
materials = nil
|
||||
if err := json.Unmarshal(saved["materials"], &materials); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, exists := materials["101"]; exists || materials["301"] != 9 {
|
||||
t.Fatalf("reset did not restore default while preserving other edits: %#v", materials)
|
||||
}
|
||||
|
||||
before := string(raw)
|
||||
bad := httptest.NewRequest(http.MethodPost, "/api/inventory/materials", strings.NewReader(`{"updates":[{"item_id":999,"count":1}]}`))
|
||||
badResponse := httptest.NewRecorder()
|
||||
apiInventoryMaterials(badResponse, bad)
|
||||
if badResponse.Code != http.StatusBadRequest {
|
||||
t.Fatalf("uncatalogued item HTTP status = %d, body=%s", badResponse.Code, badResponse.Body.String())
|
||||
}
|
||||
var errResponse struct {
|
||||
Status int `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(badResponse.Body.Bytes(), &errResponse); err != nil || errResponse.Status != http.StatusBadRequest {
|
||||
t.Fatalf("uncatalogued item response = %s", badResponse.Body.String())
|
||||
}
|
||||
after, err := os.ReadFile("freesr-data.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(after) != before {
|
||||
t.Fatal("uncatalogued item update modified freesr-data.json")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"gac-hkrpg-go/internal/common"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func apiToolSession(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Connected bool `json:"connected"`
|
||||
Client string `json:"client"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || (r.Header.Get("X-GaC-Srtool") != "1" && req.Client != "GaC-Srtool") {
|
||||
http.Error(w, "explicit Srtool connection required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
seconds, err := common.SetSrtoolConnection(req.Connected)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"status": 200, "connected": req.Connected, "lease_seconds": seconds})
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
pb "gac-hkrpg-go/protocol/proto"
|
||||
"math"
|
||||
"sort"
|
||||
)
|
||||
|
||||
type liveAvatarBreakdown struct {
|
||||
Damage map[string]float64 `json:"attack_type_damage"`
|
||||
BreakDamage map[string]float64 `json:"attack_type_break_damage"`
|
||||
MaxDamage map[string]float64 `json:"attack_type_max_damage"`
|
||||
}
|
||||
|
||||
func liveDamageProperties(values map[string]float64) ([]*pb.AttackDamageProperty, error) {
|
||||
if len(values) > 64 {
|
||||
return nil, fmt.Errorf("too many attack types")
|
||||
}
|
||||
keys := make([]string, 0, len(values))
|
||||
for key, value := range values {
|
||||
if key == "" || len(key) > 128 || math.IsNaN(value) || math.IsInf(value, 0) || value < 0 {
|
||||
return nil, fmt.Errorf("invalid native attack-type statistic")
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
list := make([]*pb.AttackDamageProperty, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
list = append(list, &pb.AttackDamageProperty{AttackType: key, Damage: values[key]})
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func applyLiveAvatarBreakdowns(avatars []*pb.AvatarBattleInfo, values []liveAvatarBreakdown) error {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(values) != len(avatars) {
|
||||
return fmt.Errorf("native breakdown count does not match avatars")
|
||||
}
|
||||
for i, value := range values {
|
||||
if avatars[i] == nil {
|
||||
return fmt.Errorf("missing native avatar")
|
||||
}
|
||||
for _, pair := range []struct {
|
||||
values map[string]float64
|
||||
target *[]*pb.AttackDamageProperty
|
||||
}{{value.Damage, &avatars[i].AttackTypeDamage}, {value.BreakDamage, &avatars[i].AttackTypeBreakDamage}, {value.MaxDamage, &avatars[i].AttackTypeMaxDamage}} {
|
||||
if pair.values == nil {
|
||||
continue
|
||||
}
|
||||
properties, err := liveDamageProperties(pair.values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*pair.target = properties
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
pb "gac-hkrpg-go/protocol/proto"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLiveNativeDictionariesSupplyBreakdownWithoutReplacingAggregate(t *testing.T) {
|
||||
avatars := []*pb.AvatarBattleInfo{{Id: 10, TotalDamage: 1200}, {Id: 11}}
|
||||
err := applyLiveAvatarBreakdowns(avatars, []liveAvatarBreakdown{
|
||||
{Damage: map[string]float64{"ElationDamage": 700, "TrueDamage": 300}, MaxDamage: map[string]float64{"ElationDamage": 400}},
|
||||
{Damage: map[string]float64{}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if avatars[0].TotalDamage != 1200 || len(avatars[0].AttackTypeDamage) != 2 || avatars[0].AttackTypeMaxDamage[0].Damage != 400 || len(avatars[1].AttackTypeDamage) != 0 {
|
||||
t.Fatalf("native data was replaced or lost: %v", avatars)
|
||||
}
|
||||
if err := applyLiveAvatarBreakdowns(avatars, []liveAvatarBreakdown{{}}); err == nil {
|
||||
t.Fatal("accepted a mismatched avatar count")
|
||||
}
|
||||
if _, err := liveDamageProperties(map[string]float64{"Normal": -1}); err == nil {
|
||||
t.Fatal("accepted negative damage")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
pb "gac-hkrpg-go/protocol/proto"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func applyLiveServantStatistics(statistics *pb.BattleStatistics, encoded []string, breakdowns []liveAvatarBreakdown) error {
|
||||
if len(encoded) == 0 && len(breakdowns) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(encoded) > 64 || (len(breakdowns) != 0 && len(breakdowns) != len(encoded)) {
|
||||
return fmt.Errorf("invalid servant statistics count")
|
||||
}
|
||||
if statistics.GetIAGKLJEKAOL() != nil && len(statistics.IAGKLJEKAOL.GFOAHHHNBJC) != 0 {
|
||||
return fmt.Errorf("servant statistics must use a single source")
|
||||
}
|
||||
servants := make([]*pb.BattleAvatarServantInfo, 0, len(encoded))
|
||||
totalBytes := 0
|
||||
for i, value := range encoded {
|
||||
wire, err := base64.StdEncoding.Strict().DecodeString(value)
|
||||
if err != nil || len(wire) == 0 || len(wire) > 2<<20-totalBytes {
|
||||
return fmt.Errorf("invalid or oversized servant protobuf")
|
||||
}
|
||||
totalBytes += len(wire)
|
||||
servant := &pb.BattleAvatarServantInfo{}
|
||||
if err := proto.Unmarshal(wire, servant); err != nil || servant.AvatarId == 0 {
|
||||
return fmt.Errorf("invalid BattleAvatarServantInfo protobuf")
|
||||
}
|
||||
if len(breakdowns) != 0 {
|
||||
// Reuse the validation and dictionary hydration used for avatars.
|
||||
temporary := &pb.AvatarBattleInfo{}
|
||||
if err := applyLiveAvatarBreakdowns([]*pb.AvatarBattleInfo{temporary}, breakdowns[i:i+1]); err != nil {
|
||||
return err
|
||||
}
|
||||
if breakdowns[i].Damage != nil {
|
||||
servant.AttackTypeDamage = temporary.AttackTypeDamage
|
||||
}
|
||||
if breakdowns[i].BreakDamage != nil {
|
||||
servant.AttackTypeBreakDamage = temporary.AttackTypeBreakDamage
|
||||
}
|
||||
if breakdowns[i].MaxDamage != nil {
|
||||
servant.AttackTypeMaxDamage = temporary.AttackTypeMaxDamage
|
||||
}
|
||||
}
|
||||
servants = append(servants, servant)
|
||||
}
|
||||
statistics.IAGKLJEKAOL = &pb.OHDHNDMJFAP{GFOAHHHNBJC: servants}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
pb "gac-hkrpg-go/protocol/proto"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLiveServantHydratesNativeCategories(t *testing.T) {
|
||||
wire, _ := proto.Marshal(&pb.BattleAvatarServantInfo{AvatarId: 20, TotalDamage: 1000})
|
||||
encoded := []string{base64.StdEncoding.EncodeToString(wire)}
|
||||
stats := &pb.BattleStatistics{}
|
||||
err := applyLiveServantStatistics(stats, encoded, []liveAvatarBreakdown{{Damage: map[string]float64{"BPSkill": 800}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
servant := stats.IAGKLJEKAOL.GFOAHHHNBJC[0]
|
||||
if servant.AvatarId != 20 || servant.TotalDamage != 1000 || servant.AttackTypeDamage[0].Damage != 800 {
|
||||
t.Fatalf("native servant aggregate/categories lost: %+v", servant)
|
||||
}
|
||||
if applyLiveServantStatistics(stats, encoded, nil) == nil {
|
||||
t.Fatal("accepted two servant statistic sources")
|
||||
}
|
||||
if applyLiveServantStatistics(&pb.BattleStatistics{}, nil, []liveAvatarBreakdown{{}}) == nil {
|
||||
t.Fatal("accepted unmatched breakdowns")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"gac-hkrpg-go/internal/common"
|
||||
)
|
||||
|
||||
// Final packets omit detailed statistics on this Client. Attach the native
|
||||
// snapshot only to an already received result with the same counters and team.
|
||||
// Keep the server's outcome, scene identity and monster analysis.
|
||||
func saveFinalBattleDetails(details *common.BattleReport) error {
|
||||
base, err := common.LoadLatestBattleReport()
|
||||
if err != nil {
|
||||
return fmt.Errorf("completed battle result is not available yet")
|
||||
}
|
||||
if details == nil || details.TotalBattleTurns != base.TotalBattleTurns ||
|
||||
math.Abs(details.CostTime-base.CostTime) > 0.001 ||
|
||||
(details.StageID != 0 && details.StageID != base.StageID) ||
|
||||
(details.BattleID != 0 && details.BattleID != base.BattleID) ||
|
||||
len(details.Avatars) != len(base.Avatars) {
|
||||
return fmt.Errorf("native statistics do not match the completed battle")
|
||||
}
|
||||
ids := make(map[uint32]bool, len(base.Avatars))
|
||||
slots := make(map[uint32]*uint32, len(base.Avatars))
|
||||
for _, a := range base.Avatars {
|
||||
if a != nil {
|
||||
ids[a.AvatarID] = true
|
||||
slots[a.AvatarID] = a.Slot
|
||||
}
|
||||
}
|
||||
for _, a := range details.Avatars {
|
||||
if a == nil || !ids[a.AvatarID] {
|
||||
return fmt.Errorf("native statistics team does not match the completed battle")
|
||||
}
|
||||
delete(ids, a.AvatarID)
|
||||
if a.Slot == nil {
|
||||
a.Slot = slots[a.AvatarID]
|
||||
}
|
||||
}
|
||||
if len(ids) != 0 {
|
||||
return fmt.Errorf("native statistics team is incomplete")
|
||||
}
|
||||
base.Avatars = details.Avatars
|
||||
base.TeamTotalDamage, base.TeamTotalHeal, base.TeamTotalShield = details.TeamTotalDamage, details.TeamTotalHeal, details.TeamTotalShield
|
||||
return common.SaveBattleReport(base)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
"gac-hkrpg-go/internal/common"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFinalDetailsPreserveOutcomeAndMonstersAndRejectAnotherBattle(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
base := &common.BattleReport{BattleID: 1, StageID: 23, EndStatus: "WIN", Timestamp: 123, CostTime: 12.3456789, TotalBattleTurns: 9,
|
||||
Avatars: []*common.BattleAvatarReport{{AvatarID: 10}, {AvatarID: 11}}, Monsters: []*common.BattleMonsterReport{{MonsterID: 12}}}
|
||||
if err := common.SaveBattleReport(base); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
details := &common.BattleReport{CostTime: 12.345679, TotalBattleTurns: 9, TeamTotalDamage: 800,
|
||||
Avatars: []*common.BattleAvatarReport{{AvatarID: 11, TotalDamage: 800, DamagePercent: 100, AttackTypeDamage: map[string]float64{"Normal": 800}}, {AvatarID: 10, TotalHeal: 400}}}
|
||||
if err := saveFinalBattleDetails(details); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := common.LoadLatestBattleReport()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.EndStatus != "WIN" || got.BattleID != 1 || got.StageID != 23 || got.Timestamp != 123 || len(got.Monsters) != 1 || got.TeamTotalDamage != 800 || got.Avatars[0].DamagePercent != 100 || got.Avatars[1].TotalHeal != 400 {
|
||||
t.Fatalf("bad final merge: %+v", got)
|
||||
}
|
||||
details.CostTime = 99
|
||||
if err := saveFinalBattleDetails(details); err == nil {
|
||||
t.Fatal("accepted different battle duration")
|
||||
}
|
||||
details.CostTime = 12.345679
|
||||
details.Avatars[0].AvatarID = 999
|
||||
if err := saveFinalBattleDetails(details); err == nil {
|
||||
t.Fatal("accepted different team")
|
||||
}
|
||||
after, _ := common.LoadLatestBattleReport()
|
||||
if after.Avatars[0].AvatarID != 11 {
|
||||
t.Fatal("rejected request changed saved report")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gac-hkrpg-go/internal/common"
|
||||
)
|
||||
|
||||
// POST patches fields; PUT explicitly replaces a complete player document.
|
||||
// Keep raw JSON values so unrelated IDs and numeric values are not rounded.
|
||||
func preparePlayerDataUpdate(existing, payload []byte, replace bool) ([]byte, error) {
|
||||
var patch map[string]json.RawMessage
|
||||
if err := json.Unmarshal(payload, &patch); err != nil || patch == nil {
|
||||
return nil, fmt.Errorf("player data must be a JSON object")
|
||||
}
|
||||
result := make(map[string]json.RawMessage)
|
||||
if replace {
|
||||
for _, key := range []string{"avatars", "lightcones", "relics", "battle_config"} {
|
||||
if _, present := patch[key]; !present {
|
||||
return nil, fmt.Errorf("full replacement is missing %s; use POST for a partial update", key)
|
||||
}
|
||||
}
|
||||
// FreeSR exports do not necessarily include the inventory editor's
|
||||
// material overrides. Omission preserves them; an explicit materials
|
||||
// object (including zero counts or {}) still replaces that field.
|
||||
if _, present := patch["materials"]; !present && len(existing) != 0 {
|
||||
var current map[string]json.RawMessage
|
||||
if err := json.Unmarshal(existing, ¤t); err != nil || current == nil {
|
||||
return nil, fmt.Errorf("existing player data is invalid; restore it before applying updates")
|
||||
}
|
||||
if value, present := current["materials"]; present {
|
||||
result["materials"] = value
|
||||
}
|
||||
}
|
||||
} else if err := json.Unmarshal(existing, &result); err != nil || result == nil {
|
||||
return nil, fmt.Errorf("existing player data is invalid; restore it before applying updates")
|
||||
}
|
||||
for key, value := range patch {
|
||||
if key == "avatars" && !replace {
|
||||
var incoming map[string]json.RawMessage
|
||||
if err := json.Unmarshal(value, &incoming); err != nil || len(incoming) == 0 {
|
||||
return nil, fmt.Errorf("avatars update must be a non-empty object")
|
||||
}
|
||||
current := make(map[string]json.RawMessage)
|
||||
if raw, found := result[key]; found {
|
||||
if err := json.Unmarshal(raw, ¤t); err != nil || current == nil {
|
||||
return nil, fmt.Errorf("existing avatars data is invalid")
|
||||
}
|
||||
}
|
||||
for id, avatar := range incoming {
|
||||
current[id] = avatar
|
||||
}
|
||||
merged, err := json.Marshal(current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[key] = merged
|
||||
} else {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
out, err := json.MarshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var data common.FreesrData
|
||||
if err := json.Unmarshal(out, &data); err != nil {
|
||||
return nil, fmt.Errorf("invalid player data: %w", err)
|
||||
}
|
||||
if err := common.ValidateAvatarRoster(data.Avatars); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if saved, err := common.ReadPersistent(); err == nil {
|
||||
if err := common.ValidateAdventureLineup(data.Avatars, saved.Lineups); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("cannot validate active lineup: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func writePlayerDataAtomic(data []byte) error {
|
||||
file, err := os.CreateTemp(".", ".gac-freesr-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := file.Name()
|
||||
defer os.Remove(name)
|
||||
if _, err := file.Write(data); err != nil {
|
||||
file.Close()
|
||||
return err
|
||||
}
|
||||
if err := file.Sync(); err != nil {
|
||||
file.Close()
|
||||
return err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(name, "freesr-data.json")
|
||||
}
|
||||
@@ -220,8 +220,10 @@ type accountPayload struct {
|
||||
|
||||
func (a *appState) standardAccount() map[string]any {
|
||||
return map[string]any{
|
||||
"name": a.runtime.AccountName,
|
||||
"account_name": a.runtime.AccountName,
|
||||
"area_code": "**",
|
||||
"email": "ReversedRooms",
|
||||
"email": a.runtime.AccountName,
|
||||
"country": "RU",
|
||||
"is_email_verify": "1",
|
||||
"token": "mostsecuretokenever",
|
||||
@@ -275,13 +277,15 @@ func (a *appState) apnLoginReply(w http.ResponseWriter) {
|
||||
"data": map[string]any{
|
||||
"token": map[string]any{"token": "mostsecuretokenever", "token_type": 1},
|
||||
"user_info": map[string]any{
|
||||
"name": a.runtime.AccountName,
|
||||
"account_name": a.runtime.AccountName,
|
||||
"aid": fmt.Sprint(a.runtime.PlayerUID),
|
||||
"mid": fmt.Sprint(a.runtime.PlayerUID),
|
||||
"is_email_verify": 1,
|
||||
"area_code": "**",
|
||||
"country": "US",
|
||||
"is_adult": 1,
|
||||
"email": "motorized@wheel.chair",
|
||||
"email": a.runtime.AccountName,
|
||||
},
|
||||
},
|
||||
"message": "OK",
|
||||
@@ -294,13 +298,15 @@ func (a *appState) apnVerifyReply(w http.ResponseWriter) {
|
||||
"data": map[string]any{
|
||||
"tokens": []any{map[string]any{"token": "mostsecuretokenever", "token_type": 1}},
|
||||
"user_info": map[string]any{
|
||||
"name": a.runtime.AccountName,
|
||||
"account_name": a.runtime.AccountName,
|
||||
"aid": fmt.Sprint(a.runtime.PlayerUID),
|
||||
"mid": fmt.Sprint(a.runtime.PlayerUID),
|
||||
"is_email_verify": 1,
|
||||
"area_code": "**",
|
||||
"country": "US",
|
||||
"is_adult": 1,
|
||||
"email": "motorized@wheel.chair",
|
||||
"email": a.runtime.AccountName,
|
||||
},
|
||||
},
|
||||
"message": "OK",
|
||||
|
||||
Reference in new issue
Block a user