pre cw with probe and a bunch script to read game behavior

This commit is contained in:
CAtlas0784 committed 2026-10-21 20:40:13 +07:00
1 parent 5120219ae2
commit da8a32f60d
310 files changed
+219693 -872

No files matched your search

+373 -23
View File
@@ -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)
}
}
+146
View File
@@ -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
`
+84
View File
@@ -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)})
}
+94
View File
@@ -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")
}
}
+28
View File
@@ -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")
}
}
+104
View File
@@ -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, &current); 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, &current); 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")
}
+9 -3
View File
@@ -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",