docs & feat: add license & attribution notice (Apache-2.0 / GPL-3.0) and core system enhancements

This commit is contained in:
CAtlas0784 committed 2026-11-01 20:14:36 +07:00
1 parent 507836d367
commit ce6aabfc55
47 files changed
+5674 -724

No files matched your search

@@ -0,0 +1,46 @@
package sdkserver
import (
"encoding/base64"
"net/http/httptest"
"testing"
"gac-hkrpg-go/internal/common"
"gac-hkrpg-go/protocol/proto"
google "google.golang.org/protobuf/proto"
)
func TestAndroidGatewayResolvesBuildSuffix(t *testing.T) {
const version = "OSBETAAndroid4.6.54_D16927089_A16927089_L16927077"
metadata := VersionConfig{AssetBundleURL: "https://example.test/assets", AssetBundleURLB: "https://example.test/backup", ExResourceURL: "https://example.test/data", LuaURL: "https://example.test/lua", IfixURL: "https://example.test/ifix", LuaVersion: "123", IfixVersion: "4"}
state := &appState{runtime: common.RuntimeConfig{PublicGameHost: "127.0.0.1", GamePort: 23301}, hotfixMap: map[string]VersionConfig{
"OSBETAAndroid4.6.54": metadata,
version: {}, // An earlier failed lookup may have cached an empty entry.
}}
rec := httptest.NewRecorder()
queryGateway(state)(rec, httptest.NewRequest("GET", "/query_gateway?version="+version, nil))
if rec.Code != 200 {
t.Fatalf("HTTP status = %d", rec.Code)
}
wire, err := base64.StdEncoding.DecodeString(rec.Body.String())
if err != nil {
t.Fatal(err)
}
var got proto.Gateserver
if err := google.Unmarshal(wire, &got); err != nil {
t.Fatal(err)
}
if got.Ip != "127.0.0.1" || got.Port != 23301 || got.AssetBundleUrl != metadata.AssetBundleURL || got.AssetBundleUrlB != metadata.AssetBundleURLB || got.ExResourceUrl != metadata.ExResourceURL || got.LuaUrl != metadata.LuaURL || got.IfixUrl != metadata.IfixURL || got.LuaVersion != metadata.LuaVersion || got.IfixVersion != metadata.IfixVersion {
t.Fatalf("unexpected gateway response: %v", &got)
}
}
func TestAndroidGatewayPrefersExactBuildMetadata(t *testing.T) {
state := &appState{hotfixMap: map[string]VersionConfig{
"OSBETAAndroid4.6.54": {AssetBundleURL: "release"},
"OSBETAAndroid4.6.54_D1": {AssetBundleURL: "specific"},
}}
if got := state.getOrInsertHotfix("OSBETAAndroid4.6.54_D1", ""); got.AssetBundleURL != "specific" {
t.Fatalf("exact build lost: %+v", got)
}
}
+140 -33
View File
@@ -699,8 +699,11 @@ func apiTeleport(w http.ResponseWriter, r *http.Request) {
data.Position.RotY = *req.RotY
}
data.SavePersistent()
writeJSON(w, map[string]any{"message": "teleported successfully", "status": 200})
if err := data.SavePersistentChecked(); err != nil {
writeJSON(w, map[string]any{"error": "failed to save location: " + err.Error(), "status": 500})
return
}
writeJSON(w, map[string]any{"message": "location saved", "status": 200})
}
// apiGachabaseFetch proxies requests to hsr.gachabase.net to bypass browser CORS restrictions.
@@ -828,7 +831,8 @@ func apiCommand(w http.ResponseWriter, r *http.Request) {
switch rootCmd {
case "/heal":
output = "[OK] Team HP, SP, and Energy fully restored."
output = "Healing through this API is not implemented."
success = false
case "/max":
for _, av := range data.Avatars {
@@ -866,53 +870,156 @@ func apiCommand(w http.ResponseWriter, r *http.Request) {
}
case "/avatar":
if len(parts) > 1 {
aid, _ := strconv.ParseUint(parts[1], 10, 32)
if av, exists := data.Avatars[uint32(aid)]; exists {
if len(parts) > 2 {
lvl, _ := strconv.ParseUint(parts[2], 10, 32)
if lvl > 0 && lvl <= 80 {
av.Level = uint32(lvl)
}
}
if len(parts) > 3 {
rank, _ := strconv.ParseUint(parts[3], 10, 32)
av.Data.Rank = uint32(rank)
}
output = fmt.Sprintf("[OK] Avatar %d updated (Lv.%d, Rank %d).", aid, av.Level, av.Data.Rank)
} else {
output = fmt.Sprintf("[Error] Avatar ID %d not found in freesr-data.", aid)
success = false
if len(parts) < 3 || len(parts) > 4 {
output, success = "Usage: /avatar <avatar_id> <level 1-80> [rank 0-6]", false
break
}
aid, aidErr := strconv.ParseUint(parts[1], 10, 32)
level, levelErr := strconv.ParseUint(parts[2], 10, 32)
if aidErr != nil || aid == 0 || levelErr != nil || level < 1 || level > 80 {
output, success = "Invalid avatar ID or level (1-80 required).", false
break
}
levelValue := uint32(level)
var rankValue *uint32
if len(parts) == 4 {
parsed, err := strconv.ParseUint(parts[3], 10, 32)
if err != nil || parsed > 6 {
output, success = "Invalid rank (0-6 required).", false
break
}
rank := uint32(parsed)
rankValue = &rank
}
if err := common.SaveAvatarProgress(uint32(aid), &levelValue, rankValue); err != nil {
output, success = fmt.Sprintf("Cannot update avatar: %v", err), false
} else {
output = "Usage: /avatar <avatar_id> [level] [rank]"
success = false
output = fmt.Sprintf("Avatar %d saved. Reconnect the game to load the change.", aid)
}
case "/give", "/item":
output = "Giving items through this command is not implemented. Use the inventory editor."
success = false
case "/unlockall", "/ua":
sub := ""
if len(parts) > 1 {
itemID := parts[1]
count := "1"
sub = strings.ToLower(parts[1])
}
switch sub {
case "warp":
arg := "all"
if len(parts) > 2 {
count = parts[2]
arg = strings.ToLower(parts[2])
}
output = fmt.Sprintf("[OK] Item %s x%s added to inventory.", itemID, count)
} else {
output = "Usage: /give <item_id> [count]"
mode := 1
switch arg {
case "all", "on", "1", "true":
mode = 1
case "off", "0", "none", "disable", "false":
mode = 0
case "avatar", "character", "char", "2":
mode = 2
case "lightcone", "lc", "weapon", "3":
mode = 3
default:
output = "Usage: /unlockall warp [all|avatar|lightcone|off]"
success = false
}
if success {
data.WarpUnlockMode = mode
visible, visibilityErr := common.LoadVisibleGachaBanners(mode)
if visibilityErr != nil {
output = visibilityErr.Error()
success = false
break
}
count := len(visible)
desc := "all warps"
if mode == 0 {
desc = "default active warps"
} else if mode == 2 {
desc = "character warps"
} else if mode == 3 {
desc = "lightcone warps"
}
output = fmt.Sprintf("[OK] Set %s (%d banners visible). Reopen Warp to refresh.", desc, count)
}
case "all":
data.WarpUnlockMode = 1
visible, visibilityErr := common.LoadVisibleGachaBanners(1)
if visibilityErr != nil {
output = visibilityErr.Error()
success = false
break
}
count := len(visible)
output = fmt.Sprintf("[OK] Selected all %d verified warps. Reopen Warp to refresh.", count)
case "skin":
output = "Skin unlocking is not implemented by this command."
success = false
default:
output = "Usage: /unlockall [warp|all|skin]\n /unlockall warp [all|avatar|lightcone|off]"
success = false
}
case "/reload":
sub := ""
if len(parts) > 1 {
sub = strings.ToLower(parts[1])
}
switch sub {
case "banner", "banners":
if err := common.ReloadBanners(); err != nil {
output = fmt.Sprintf("[Error] Failed to reload banners: %v", err)
success = false
} else {
visible, visibilityErr := common.LoadVisibleGachaBanners(data.WarpUnlockMode)
if visibilityErr != nil {
output = visibilityErr.Error()
success = false
break
}
count := len(visible)
output = fmt.Sprintf("[OK] Banner configurations reloaded successfully (%d visible in current mode).", count)
}
default:
output = "Usage: /reload [banner]"
success = false
}
case "/help":
output = "/faketime [status|off|Unix seconds|RFC3339 with timezone] - Set game calendar time\n"
output += "Available GM Commands:\n /heal - Fully restore team HP, SP, and Energy\n /max - Set all avatars to Max Lv.80 E6\n /teleport <plane_id> [floor_id] - Teleport to scene\n /avatar <id> [level] [rank] - Modify avatar\n /give <item_id> [count] - Add items to inventory\n /clear lineup - Clear active lineup slots"
output += "Available GM Commands:\n /max - Set all avatars to Max Lv.80 E6\n /teleport <plane_id> [floor_id] - Set scene\n /avatar <id> <level> [rank] - Modify avatar\n /clear lineup - Clear active lineup slots\n /unlockall warp [all|avatar|lightcone|off] - Toggle unlocked gacha banners\n /reload banner - Reload banner configurations"
default:
output = fmt.Sprintf("[OK] Custom command '%s' dispatched to server.", cmd)
output = fmt.Sprintf("Unknown command: %s", cmd)
success = false
}
raw, _ := json.MarshalIndent(data, "", " ")
_ = os.WriteFile("freesr-data.json", raw, 0o644)
data.SavePersistent()
// Banner commands only patch their own persistent setting. Rewriting the
// complete imported roster here can overwrite concurrent inventory updates.
if rootCmd == "/unlockall" || rootCmd == "/ua" || rootCmd == "/reload" {
if success && rootCmd != "/reload" {
mode, _ := json.Marshal(data.WarpUnlockMode)
if err := common.PatchPersistentFields(map[string]json.RawMessage{"warp_unlock_mode": mode}); err != nil {
success = false
output = err.Error()
}
}
} else if success && rootCmd != "/help" && rootCmd != "/avatar" {
raw, err := json.MarshalIndent(data, "", " ")
if err == nil {
err = os.WriteFile("freesr-data.json", raw, 0o644)
}
if err == nil {
err = data.SavePersistentChecked()
}
if err != nil {
success = false
output = err.Error()
}
}
Log(fmt.Sprintf("[GM Command] %s -> %s", cmd, output))
@@ -0,0 +1,98 @@
package sdkserver
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)
func TestAvatarCommandPatchesOnlyRequestedAvatarFields(t *testing.T) {
chdirRepoRootSDK(t)
raw, err := os.ReadFile("freesr-data.json")
if err != nil {
t.Fatal(err)
}
var root map[string]json.RawMessage
if err = json.Unmarshal(raw, &root); err != nil {
t.Fatal(err)
}
var avatars map[string]json.RawMessage
if err = json.Unmarshal(root["avatars"], &avatars); err != nil {
t.Fatal(err)
}
var avatar map[string]json.RawMessage
if err = json.Unmarshal(avatars["1204"], &avatar); err != nil {
t.Fatal(err)
}
avatar["future_field"] = json.RawMessage(`{"value":1234567890123456789}`)
avatars["1204"], _ = json.Marshal(avatar)
root["avatars"], _ = json.Marshal(avatars)
root["future_root"] = json.RawMessage(`{"keep":true}`)
compact := func(value json.RawMessage) string {
if len(value) == 0 {
return ""
}
var b bytes.Buffer
if err := json.Compact(&b, value); err != nil {
t.Fatal(err)
}
return b.String()
}
beforeMaterials := compact(root["materials"])
beforeOtherAvatar := compact(avatars["1213"])
initial, _ := json.Marshal(root)
if err := os.WriteFile("freesr-data.json", initial, 0600); err != nil {
t.Fatal(err)
}
run := func(command string) bool {
body, _ := json.Marshal(map[string]string{"command": command})
w := httptest.NewRecorder()
apiCommand(w, httptest.NewRequest(http.MethodPost, "/api/command", strings.NewReader(string(body))))
var result struct {
Success bool `json:"success"`
}
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
return result.Success
}
if !run("/avatar 1204 42 3") {
t.Fatal("valid avatar update failed")
}
updated, _ := os.ReadFile("freesr-data.json")
if err := json.Unmarshal(updated, &root); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(root["avatars"], &avatars); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(avatars["1204"], &avatar); err != nil {
t.Fatal(err)
}
var data struct {
Rank uint32 `json:"rank"`
}
if err := json.Unmarshal(avatar["data"], &data); err != nil {
t.Fatal(err)
}
var level uint32
if err := json.Unmarshal(avatar["level"], &level); err != nil {
t.Fatal(err)
}
if level != 42 || data.Rank != 3 || compact(avatar["future_field"]) != `{"value":1234567890123456789}` || compact(root["future_root"]) != `{"keep":true}` || compact(root["materials"]) != beforeMaterials || compact(avatars["1213"]) != beforeOtherAvatar {
t.Fatal("avatar update lost or modified unrelated data")
}
for _, invalid := range []string{"/avatar 1204 81", "/avatar 1204 2 7", "/avatar nope 5", "/avatar 999999 5"} {
if run(invalid) {
t.Fatalf("accepted %s", invalid)
}
after, _ := os.ReadFile("freesr-data.json")
if string(after) != string(updated) {
t.Fatalf("invalid command rewrote save: %s", invalid)
}
}
}
@@ -85,6 +85,18 @@ func apiCommandCompletions(w http.ResponseWriter, r *http.Request) {
} else {
issues = append(issues, err.Error())
}
out["unlockall_args"] = []commandCompletion{
{Value: "warp all", Label: "Warp (All Banners)"},
{Value: "warp avatar", Label: "Warp (Character Banners Only)"},
{Value: "warp lightcone", Label: "Warp (Light Cone Banners Only)"},
{Value: "warp off", Label: "Warp (Default Banners Only)"},
{Value: "all", Label: "Unlock All (Warps + Skins)"},
{Value: "skin", Label: "All Skins"},
}
out["reload_args"] = []commandCompletion{
{Value: "banner", Label: "Reload Gacha Banners"},
}
for key := range out {
sort.Slice(out[key], func(i, j int) bool {
if out[key][i].Value == out[key][j].Value {
@@ -0,0 +1,48 @@
package sdkserver
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)
func TestUnsupportedAndReadOnlyCommandsDoNotRewriteSave(t *testing.T) {
chdirRepoRootSDK(t)
beforeData, err := os.ReadFile("freesr-data.json")
if err != nil {
t.Fatal(err)
}
beforePersistent, err := os.ReadFile("persistent")
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
command string
success bool
}{
{"/give 200001 100", false}, {"/heal", false}, {"/not_a_command", false}, {"/help", true},
} {
body, _ := json.Marshal(map[string]string{"command": tc.command})
response := httptest.NewRecorder()
apiCommand(response, httptest.NewRequest(http.MethodPost, "/api/command", strings.NewReader(string(body))))
var result struct {
Success bool `json:"success"`
Output string `json:"output"`
}
if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result.Success != tc.success || result.Output == "" {
t.Fatalf("%s: %+v", tc.command, result)
}
}
afterData, _ := os.ReadFile("freesr-data.json")
afterPersistent, _ := os.ReadFile("persistent")
if !bytes.Equal(beforeData, afterData) || !bytes.Equal(beforePersistent, afterPersistent) {
t.Fatal("read-only or unsupported command rewrote user data")
}
}
@@ -0,0 +1,80 @@
package sdkserver
import (
"bytes"
"encoding/json"
"gac-hkrpg-go/internal/testutil"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func chdirRepoRootSDK(t *testing.T) {
t.Helper()
_, source, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("cannot locate test helper source")
}
root := filepath.Clean(filepath.Join(filepath.Dir(source), "..", ".."))
if _, err := os.Stat(filepath.Join(root, "go.mod")); err != nil {
t.Fatalf("cannot locate repository root from %s: %v", source, err)
}
temporary := t.TempDir()
if err := testutil.CopyRuntime(root, temporary); err != nil {
t.Fatal(err)
}
t.Chdir(temporary)
}
func TestApiCommandUnlockAllWarp(t *testing.T) {
chdirRepoRootSDK(t)
before, err := os.ReadFile("freesr-data.json")
if err != nil {
t.Fatal(err)
}
runCmd := func(command string) (bool, string) {
body, _ := json.Marshal(map[string]string{"command": command})
req := httptest.NewRequest(http.MethodPost, "/api/command", strings.NewReader(string(body)))
rec := httptest.NewRecorder()
apiCommand(rec, req)
var res struct {
Success bool `json:"success"`
Output string `json:"output"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &res)
return res.Success, res.Output
}
// 1. /unlockall warp all
ok, out := runCmd("/unlockall warp all")
if !ok || !strings.Contains(out, "Set all warps") {
t.Fatalf("unexpected output for /unlockall warp all: ok=%v, out=%s", ok, out)
}
// 2. /unlockall warp off
ok, out = runCmd("/unlockall warp off")
if !ok || !strings.Contains(out, "default active warps") {
t.Fatalf("unexpected output for /unlockall warp off: ok=%v, out=%s", ok, out)
}
// 3. /unlockall warp avatar
ok, out = runCmd("/unlockall warp avatar")
if !ok || !strings.Contains(out, "character warps") {
t.Fatalf("unexpected output for /unlockall warp avatar: ok=%v, out=%s", ok, out)
}
// 4. /reload banner
ok, out = runCmd("/reload banner")
if !ok || !strings.Contains(out, "Banner configurations reloaded") {
t.Fatalf("unexpected output for /reload banner: ok=%v, out=%s", ok, out)
}
after, err := os.ReadFile("freesr-data.json")
if err != nil || !bytes.Equal(before, after) {
t.Fatalf("banner commands rewrote avatar inventory: %v", err)
}
}
+57
View File
@@ -0,0 +1,57 @@
package sdkserver
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)
func TestTeleportPersistsLocationAndReportsWriteFailure(t *testing.T) {
chdirRepoRootSDK(t)
body := `{"plane_id":20411,"floor_id":20411001,"entry_id":2041101,"x":123,"y":-456,"z":789,"rot_y":321}`
invoke := func() map[string]any {
t.Helper()
w := httptest.NewRecorder()
apiTeleport(w, httptest.NewRequest(http.MethodPost, "/api/teleport", strings.NewReader(body)))
var result map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
return result
}
if result := invoke(); result["status"] != float64(200) {
t.Fatalf("save response: %v", result)
}
raw, err := os.ReadFile("persistent")
if err != nil {
t.Fatal(err)
}
var saved struct {
Scene struct {
FloorID uint32 `json:"floor_id"`
}
Position struct {
X int32 `json:"x"`
Y int32 `json:"y"`
}
}
if err := json.Unmarshal(raw, &saved); err != nil {
t.Fatal(err)
}
if saved.Scene.FloorID != 20411001 || saved.Position.X != 123 || saved.Position.Y != -456 {
t.Fatalf("saved location: %+v", saved)
}
if err := os.WriteFile("persistent", []byte("invalid json"), 0600); err != nil {
t.Fatal(err)
}
if result := invoke(); result["status"] != float64(500) {
t.Fatalf("expected save failure, got %v", result)
}
raw, _ = os.ReadFile("persistent")
if string(raw) != "invalid json" {
t.Fatalf("invalid save was overwritten: %q", raw)
}
}
+32 -8
View File
@@ -22,10 +22,13 @@ import (
// VersionConfig is one entry of versions.json.
type VersionConfig struct {
AssetBundleURL string `json:"asset_bundle_url"`
ExResourceURL string `json:"ex_resource_url"`
LuaURL string `json:"lua_url"`
IfixURL string `json:"ifix_url"`
AssetBundleURL string `json:"asset_bundle_url"`
AssetBundleURLB string `json:"asset_bundle_url_b"`
ExResourceURL string `json:"ex_resource_url"`
LuaURL string `json:"lua_url"`
IfixURL string `json:"ifix_url"`
LuaVersion string `json:"lua_version"`
IfixVersion string `json:"ifix_version"`
}
type appState struct {
@@ -51,8 +54,19 @@ func (a *appState) getOrInsertHotfix(version, dispatchSeed string) VersionConfig
a.mu.Lock()
defer a.mu.Unlock()
if cfg, ok := a.hotfixMap[version]; ok {
return cfg
exact, hasExact := a.hotfixMap[version]
if hasExact && exact.AssetBundleURL != "" {
return exact
}
// Mobile clients append build identifiers after the release version. Prefer
// an exact entry, then look up the release in the configured metadata.
if release, _, ok := strings.Cut(version, "_"); ok {
if cfg, found := a.hotfixMap[release]; found {
return cfg
}
}
if hasExact {
return exact
}
if cfg, ok := a.hotfixMap["CNBETAWin"+version]; ok {
return cfg
@@ -190,15 +204,25 @@ func queryGateway(state *appState) http.HandlerFunc {
if state.runtime.LuaOverlay.Enabled {
cfg.LuaURL = state.runtime.PublicSDKURL + luaOverlayRoute
}
assetURLB := cfg.AssetBundleURLB
if assetURLB == "" {
assetURLB = cfg.AssetBundleURL
}
ifixVersion := cfg.IfixVersion
if ifixVersion == "" {
ifixVersion = "0"
}
rsp := &goproto.Gateserver{
Ip: state.runtime.PublicGameHost,
Port: uint32(state.runtime.GamePort),
AssetBundleUrl: cfg.AssetBundleURL,
AssetBundleUrlB: cfg.AssetBundleURL,
AssetBundleUrlB: assetURLB,
ExResourceUrl: cfg.ExResourceURL,
LuaUrl: cfg.LuaURL,
IfixVersion: "0",
IfixUrl: cfg.IfixURL,
IfixVersion: ifixVersion,
LuaVersion: cfg.LuaVersion,
Unk1: true,
Unk2: true,
Unk3: true,