Update GaCy-GO

This commit is contained in:
CAtlas0784 committed 2026-10-28 18:54:43 +07:00
1 parent da8a32f60d
commit 0cbe460e49
345 files changed
+98202 -77559

No files matched your search

+10 -45
View File
@@ -49,6 +49,10 @@ func registerAPIRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/tool-session", corsWrapper(apiToolSession))
mux.HandleFunc("/api/status", corsWrapper(apiStatus))
mux.HandleFunc("/api/technique", corsWrapper(apiTechnique))
mux.HandleFunc("/api/aha-help", corsWrapper(apiAhaHelp))
mux.HandleFunc("/api/camera", corsWrapper(apiCamera))
mux.HandleFunc("/api/monster-settings/catalog", corsWrapper(apiMonsterSettingsCatalog))
mux.HandleFunc("/api/monster-settings/aa-round", corsWrapper(apiAARoundSelection))
mux.HandleFunc("/api/freesr", corsWrapper(apiFreesrData))
mux.HandleFunc("/api/inventory/materials", corsWrapper(apiInventoryMaterials))
mux.HandleFunc("/api/freesr/avatars", corsWrapper(apiFreesrAvatars))
@@ -318,45 +322,16 @@ func apiInventoryMaterials(w http.ResponseWriter, r *http.Request) {
}
seen[update.ItemID] = struct{}{}
}
materials, err := readInventory()
if err != nil {
writeInventoryError(w, http.StatusInternalServerError, "failed to read inventory: "+err.Error())
return
}
updates := make(map[uint32]*uint32, len(req.Updates))
for _, update := range req.Updates {
key := strconv.FormatUint(uint64(update.ItemID), 10)
if update.Reset {
delete(materials, key)
updates[update.ItemID] = nil
} else {
materials[key] = *update.Count
updates[update.ItemID] = update.Count
}
}
raw, err := os.ReadFile("freesr-data.json")
materials, err := common.PatchMaterialOverrides(updates)
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
}
@@ -482,23 +457,13 @@ func apiPersistentData(w http.ResponseWriter, r *http.Request) {
return
}
var parsed map[string]any
var parsed map[string]json.RawMessage
if err := json.Unmarshal(body, &parsed); err != nil {
writeJSON(w, map[string]any{"error": "invalid json: " + err.Error(), "status": 400})
return
}
// Merge into existing persistent file so partial updates (e.g. lineups or switches) preserve other settings
existing := make(map[string]any)
if raw, err := os.ReadFile("persistent"); err == nil {
_ = json.Unmarshal(raw, &existing)
}
for k, v := range parsed {
existing[k] = v
}
out, _ := json.MarshalIndent(existing, "", " ")
if err := os.WriteFile("persistent", out, 0o644); err != nil {
if err := common.PatchPersistentFields(parsed); err != nil {
writeJSON(w, map[string]any{"error": "failed to write persistent: " + err.Error(), "status": 500})
return
}
@@ -0,0 +1,45 @@
package sdkserver
import (
"encoding/json"
"net/http"
"gac-hkrpg-go/internal/common"
)
func apiAARoundSelection(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
selection, err := common.ReadAARoundSelection()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{"status": 200, "selection": selection})
case http.MethodPost:
var request struct {
Action string `json:"action"`
PeakGroupID uint32 `json:"peak_group_id"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
if request.Action == "clear" {
if err := common.ClearAARoundSelection(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{"status": 200, "selection": nil})
return
}
selection, err := common.SetAARoundSelection(request.PeakGroupID)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeJSON(w, map[string]any{"status": 200, "selection": selection})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
+47
View File
@@ -0,0 +1,47 @@
package sdkserver
import (
"encoding/json"
"gac-hkrpg-go/internal/common"
"net/http"
)
func apiAhaHelp(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if r.Method == http.MethodPost {
var body map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
raw, ok := body["level"]
var level *uint32
if !ok || json.Unmarshal(raw, &level) != nil {
http.Error(w, "level must be null or an unsigned integer", http.StatusBadRequest)
return
}
if err := common.SaveAhaHelpLevel(level); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
levels, err := common.LoadAhaHelpLevels()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
selected, err := common.ReadAhaHelpLevel()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data, err := common.LoadFreesrData()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{"status": 200, "level": selected, "levels": levels, "legacy_enabled": data.BattleConfig.EnableInvasionSupport})
}
+34
View File
@@ -0,0 +1,34 @@
package sdkserver
import (
"encoding/json"
"gac-hkrpg-go/internal/common"
"net/http"
)
func apiCamera(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
case http.MethodPost:
var settings common.FaceCamSettings
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&settings); err != nil {
http.Error(w, "invalid camera settings", http.StatusBadRequest)
return
}
if err := common.SaveFaceCamSettings(settings); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
settings, err := common.ReadFaceCamSettings()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, settings)
}
+62 -20
View File
@@ -111,6 +111,12 @@ func apiClientUnstuckResult(w http.ResponseWriter, r *http.Request) {
}
clientUnstuckState.status.Error = result.Error
clientUnstuckState.status.CompletedAt = time.Now().UnixMilli()
// Queueing and acknowledgement share the lock, so removing this owned
// request cannot delete a newer recovery. A leftover script still checks
// the live nonce before touching UI, including after an SDK restart.
if err := os.Remove(clientUnstuckRequestFile); err != nil && !os.IsNotExist(err) {
Log("[UNSTUCK] cannot remove completed request: " + err.Error())
}
Log("[UNSTUCK] UI recovery " + clientUnstuckState.status.State + " request=" + result.RequestID)
writeJSON(w, clientUnstuckState.status)
}
@@ -120,27 +126,63 @@ func apiClientUnstuckResult(w http.ResponseWriter, r *http.Request) {
// 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))
local function tick()
local url=_G.__GAC_SRTOOL_LIVE_URL
if type(url)~='string' or url=='' then return end
local s=_G.__GAC_UNSTUCK_WORK
if not s or s.id~=id then
if s and s.request then pcall(function()s.request:Dispose()end) end
s={id=id,phase='check',next=0};_G.__GAC_UNSTUCK_WORK=s
end
if s.phase=='done' or s.phase=='stale' then return end
local now=CS.UnityEngine.Time.unscaledTime
if s.request then
if not s.request.isDone then return end
local request=s.request;s.request=nil
local ok,err=pcall(function()
local code=request.responseCode
if code<200 or code>=300 then error('recovery HTTP '..tostring(code)) end
if s.phase=='check' then
local status=CS.Newtonsoft.Json.Linq.JObject.Parse(request.downloadHandler.text)
local request_id=status:get_Item('request_id')
local phase=status:get_Item('state')
if not request_id or tostring(request_id)~=id or not phase or tostring(phase)~='queued' then
s.phase='stale';return
end
-- Only the current queued SDK request authorizes a UI recovery. Old
-- generated files are inert after reconnect or SDK process restart.
local success,message=pcall(function()
local ui=CS.RPG.Client.GlobalVars.s_UIManager
if not ui then error('Client UIManager is unavailable') end
ui:ClearWaitNetPacketFullScreenBlock()
end)
s.body='{"request_id":"'..id..'","success":'..tostring(success)..',"error":'..CS.Newtonsoft.Json.JsonConvert.SerializeObject(tostring(message or ''))..'}'
s.phase='ack'
CS.UnityEngine.Debug.Log('[GAC_UNSTUCK] request='..id..' success='..tostring(success)..' '..tostring(message or ''))
else
s.phase='done';_G.__GAC_UNSTUCK_LAST=id
end
end)
pcall(function()request:Dispose()end)
if not ok then
s.next=now+5
if s.error~=tostring(err) then CS.UnityEngine.Debug.Log('[GAC_UNSTUCK] '..tostring(err));s.error=tostring(err) end
return
end
end
if s.phase=='done' or s.phase=='stale' or now<s.next then return end
local req
if s.phase=='check' then
req=CS.UnityEngine.Networking.UnityWebRequest.Get(url..'/api/client/unstuck')
else
req=CS.UnityEngine.Networking.UnityWebRequest(url..'/api/client/unstuck/result','POST')
req.uploadHandler=CS.UnityEngine.Networking.UploadHandlerRaw(CS.System.Text.Encoding.UTF8:GetBytes(s.body))
req.downloadHandler=CS.UnityEngine.Networking.DownloadHandlerBuffer()
req:SetRequestHeader('Content-Type','application/json');req.timeout=10
req:SendWebRequest();_G.__GAC_UNSTUCK_HTTP=req
end)
req:SetRequestHeader('Content-Type','application/json')
end
req.timeout=10;s.request=req;req:SendWebRequest()
end
local ok,err=pcall(tick)
if not ok then CS.UnityEngine.Debug.Log('[GAC_UNSTUCK] '..tostring(err)) end
end
`
+153 -4
View File
@@ -6,6 +6,7 @@ import (
pb "gac-hkrpg-go/protocol/proto"
"google.golang.org/protobuf/proto"
"net/http"
"sort"
"strconv"
"strings"
)
@@ -13,7 +14,7 @@ import (
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."
usage := "Usage: /cw status | /cw list role|equip|orb|consumable|forge | /cw gold <total> | /cw hp <total> | /cw exp <gain> | /cw equip|orb|consumable|forge <itemID> [count] | /cw role <roleID> <star> [count]. Changes apply in CW Preparation."
if len(parts) < 2 {
fail(fmt.Errorf("%s", usage))
return
@@ -23,6 +24,79 @@ func apiCurrencyWarsCommand(w http.ResponseWriter, command string) {
fail(err)
return
}
if strings.EqualFold(parts[1], "help") {
writeJSON(w, map[string]any{"success": true, "output": usage})
return
}
if strings.EqualFold(parts[1], "list") {
if len(parts) != 3 {
fail(fmt.Errorf("%s", usage))
return
}
var lines []string
switch strings.ToLower(parts[2]) {
case "role":
for _, v := range catalog.CapturedVariants {
lines = append(lines, fmt.Sprintf("role=%d star=%d avatar=%d rarity=%d", v.Config.ID, v.Star, v.Config.AvatarID, v.Config.Rarity))
}
case "equip":
rows, e := common.LoadCurrencyWarsEquipment(catalog)
if e != nil {
fail(e)
return
}
ids := make([]uint32, 0, len(rows))
for id := range rows {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
for _, id := range ids {
v := rows[id]
lines = append(lines, fmt.Sprintf("equip=%d type=%d category=%d ability=%s", id, v.EquipType, v.EquipCategory, v.AbilityName))
}
case "orb", "consumable", "forge":
rows, e := common.LoadCurrencyWarsRewards(catalog)
if e != nil {
fail(e)
return
}
ids := []uint32{}
kind := strings.ToLower(parts[2])
switch kind {
case "orb":
for id := range rows.Orbs {
ids = append(ids, id)
}
case "consumable":
for id := range rows.Consumables {
ids = append(ids, id)
}
case "forge":
for id := range rows.Forges {
ids = append(ids, id)
}
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
for _, id := range ids {
switch kind {
case "orb":
v := rows.Orbs[id]
lines = append(lines, fmt.Sprintf("orb=%d type=%d bonus=%d", id, v.Type, v.Bonus))
case "consumable":
v := rows.Consumables[id]
lines = append(lines, fmt.Sprintf("consumable=%d rule=%d stack=%t", id, v.ConsumableRule, v.IfStack))
case "forge":
v := rows.Forges[id]
lines = append(lines, fmt.Sprintf("forge=%d function=%d choices=%d parameters=%v", id, v.FuncType, v.EquipNum, v.ParamList))
}
}
default:
fail(fmt.Errorf("available catalogs: role, equip, orb, consumable, forge"))
return
}
writeJSON(w, map[string]any{"success": true, "output": strings.Join(lines, "\n")})
return
}
cfg, err := common.LoadRuntimeConfig()
if err != nil {
fail(err)
@@ -62,7 +136,7 @@ func apiCurrencyWarsCommand(w http.ResponseWriter, command string) {
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") {
if len(parts) < 3 || len(parts) > 5 || (field != "gold" && field != "hp" && field != "exp" && field != "equip" && field != "role" && field != "orb" && field != "consumable" && field != "forge") {
fail(fmt.Errorf("%s", usage))
return
}
@@ -75,10 +149,85 @@ func apiCurrencyWarsCommand(w http.ResponseWriter, command string) {
fail(fmt.Errorf("HP must be 1..%d", player.PPKKHHNEOHB))
return
}
id, err := common.QueueCurrencyWarsCommand(cfg.PlayerUID, run.UniqueId, field, uint32(value))
quantity, star := uint32(1), uint32(0)
parse := func(text string) (uint32, error) {
v, e := strconv.ParseUint(text, 10, 32)
if e != nil || v == 0 {
return 0, fmt.Errorf("star/count must be a positive uint32")
}
return uint32(v), nil
}
switch field {
case "role":
if len(parts) < 4 {
fail(fmt.Errorf("%s", usage))
return
}
star, err = parse(parts[3])
if err == nil && len(parts) == 5 {
quantity, err = parse(parts[4])
}
if err == nil {
_, err = catalog.CapturedRoleVariant(uint32(value), star)
}
case "orb", "consumable", "forge":
if len(parts) > 4 {
fail(fmt.Errorf("%s", usage))
return
}
if len(parts) == 4 {
quantity, err = parse(parts[3])
}
if err == nil {
var rows *common.CurrencyWarsRewards
rows, err = common.LoadCurrencyWarsRewards(catalog)
if err == nil {
if field == "forge" {
if _, ok := rows.Forges[uint32(value)]; !ok {
err = fmt.Errorf("unknown forge %d", value)
}
} else if field == "orb" {
if _, ok := rows.Orbs[uint32(value)]; !ok {
err = fmt.Errorf("unknown Orb %d", value)
}
} else {
if _, ok := rows.Consumables[uint32(value)]; !ok {
err = fmt.Errorf("unknown consumable %d", value)
}
}
}
}
case "equip":
if len(parts) > 4 {
fail(fmt.Errorf("%s", usage))
return
}
if len(parts) == 4 {
quantity, err = parse(parts[3])
}
if err == nil {
var rows map[uint32]common.CurrencyWarsEquipment
rows, err = common.LoadCurrencyWarsEquipment(catalog)
if err == nil {
if _, ok := rows[uint32(value)]; !ok {
err = fmt.Errorf("equipment %d not captured; use /cw list equip", value)
}
}
}
default:
if len(parts) != 3 {
fail(fmt.Errorf("%s", usage))
return
}
}
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)})
id, err := common.QueueCurrencyWarsGrant(cfg.PlayerUID, run.UniqueId, field, uint32(value), quantity, star)
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 star=%d count=%d for run %d. Applies at the next heartbeat in Preparation; check CW_GM log for the applied result.", field, value, star, quantity, run.UniqueId)})
}
+4
View File
@@ -19,6 +19,10 @@ func TestInventoryMaterialsUpdateIsCatalogBoundAndPreservesSave(t *testing.T) {
if err := os.WriteFile(filepath.Join("resources", "InventoryItemConfig.json"), []byte(`{"item_ids":[101,102,301]}`), 0o644); err != nil {
t.Fatal(err)
}
eidolonConfig := `{"avatars":{"1001":{"ranks":[{"cost":[{"ItemID":11001,"ItemNum":1}]}]}},"ex_materials":{}}`
if err := os.WriteFile(filepath.Join("resources", "EidolonConfig.json"), []byte(eidolonConfig), 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)
@@ -0,0 +1,47 @@
package sdkserver
import (
"net/http"
"gac-hkrpg-go/internal/common"
)
func apiMonsterSettingsCatalog(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
catalog, err := common.LoadMonsterSettingsCatalog()
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
writeJSON(w, map[string]any{"error": err.Error(), "status": 500})
return
}
cfg, err := common.LoadRuntimeConfig()
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
writeJSON(w, map[string]any{"error": err.Error(), "status": 500})
return
}
response := map[string]any{
"status": 200,
"modes": catalog.Modes,
"monster_ids": catalog.MonsterIDs,
"monsters": catalog.Monsters,
"levels": catalog.Levels,
"sources": catalog.Sources,
"client_version": cfg.ClientVersion,
}
if metadata, metadataErr := common.LoadNanokaMonsterMetadata(cfg.ClientVersion); metadataErr != nil {
response["metadata_error"] = metadataErr.Error()
response["sources"] = append(catalog.Sources, "Nanoka metadata unavailable")
} else {
catalog.ApplyNanokaMonsterMetadata(metadata)
response["monsters"] = catalog.Monsters
response["modes"] = catalog.Modes
response["sources"] = catalog.Sources
response["metadata_version"] = metadata.Version
response["metadata_source"] = "Nanoka"
}
writeJSON(w, response)
}