docs & feat: add license & attribution notice (Apache-2.0 / GPL-3.0) and core system enhancements
This commit is contained in:
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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user