fix(go): repair migration wire contracts and runtime lifecycle; document incomplete port
This commit is contained in:
1 parent
e43a310617
commit
32e47014b5
35 files changed
+16826
-438
No files matched your search
@@ -1,81 +1,79 @@
|
||||
package catalogs
|
||||
package catalogs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var _ = json.RawMessage{}
|
||||
var _ = sync.Mutex{}
|
||||
var _ = os.ReadFile
|
||||
var _ = filepath.Join
|
||||
|
||||
// One grantable item row surfaced by the 道具 panel section.
|
||||
type ItemCatalogEntry4229938 struct {
|
||||
Id uint32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Id uint32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Quality uint32 `json:"quality"`
|
||||
SubType uint32 `json:"subType"`
|
||||
}
|
||||
|
||||
//
|
||||
// Build-4229938 backpack item catalog, read from ItemCatalog.json in the configured client-data
|
||||
// directory.
|
||||
// That file is a slim projection of the client's ConsumableConfig.json — the only table in the
|
||||
// config dump whose rows carry the LT.ConfigGen.ICommonItemConfig interface fields, i.e.
|
||||
// the real backpack item table (Id / Name / Quality / SubType). Ids live in 36000000..36999999,
|
||||
// which also contains the three wallet currencies (36920001/2/3); callers that grant "everything"
|
||||
// must skip those so the wallet and the backpack do not disagree.
|
||||
//
|
||||
var (
|
||||
ItemCatalog4229938_cacheLock sync.RWMutex
|
||||
ItemCatalog4229938_cached = false
|
||||
ItemCatalog4229938_entries []any
|
||||
)
|
||||
var itemCatalogState = struct {
|
||||
sync.RWMutex
|
||||
byPath map[string][]ItemCatalogEntry4229938
|
||||
current []ItemCatalogEntry4229938
|
||||
}{byPath: make(map[string][]ItemCatalogEntry4229938)}
|
||||
|
||||
// LoadItemCatalog4229938 loads catalog records from client config directory
|
||||
// LoadItemCatalog4229938 accepts the legacy slim ItemCatalog table or the
|
||||
// ConsumableConfig projection shipped in this Go repository. Cache keys include
|
||||
// the source path, so loading another configuration cannot reuse the first table.
|
||||
func LoadItemCatalog4229938(configDir string) ([]any, error) {
|
||||
ItemCatalog4229938_cacheLock.RLock()
|
||||
if ItemCatalog4229938_cached {
|
||||
defer ItemCatalog4229938_cacheLock.RUnlock()
|
||||
return ItemCatalog4229938_entries, nil
|
||||
}
|
||||
ItemCatalog4229938_cacheLock.RUnlock()
|
||||
|
||||
ItemCatalog4229938_cacheLock.Lock()
|
||||
defer ItemCatalog4229938_cacheLock.Unlock()
|
||||
if ItemCatalog4229938_cached { return ItemCatalog4229938_entries, nil }
|
||||
|
||||
filePath := filepath.Join(configDir, "ItemCatalog.json.json")
|
||||
data, err := os.ReadFile(filePath)
|
||||
path, err := filepath.Abs(filepath.Join(configDir, "ItemCatalog.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var raw struct {
|
||||
Records []json.RawMessage `json:"records"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []any
|
||||
for _, item := range raw.Records {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(item, &m); err == nil {
|
||||
result = append(result, m)
|
||||
itemCatalogState.Lock()
|
||||
defer itemCatalogState.Unlock()
|
||||
entries, ok := itemCatalogState.byPath[path]
|
||||
if !ok {
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
data, err = os.ReadFile(filepath.Join(configDir, "ConsumableConfig.json"))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw struct {
|
||||
Records []ItemCatalogEntry4229938 `json:"records"`
|
||||
}
|
||||
if err = json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if raw.Records == nil {
|
||||
return nil, fmt.Errorf("item catalog missing records array")
|
||||
}
|
||||
entries = make([]ItemCatalogEntry4229938, 0, len(raw.Records))
|
||||
for _, entry := range raw.Records {
|
||||
if entry.Id != 0 {
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
}
|
||||
itemCatalogState.byPath[path] = entries
|
||||
}
|
||||
itemCatalogState.current = entries
|
||||
result := make([]any, len(entries))
|
||||
for i, entry := range entries {
|
||||
result[i] = entry
|
||||
}
|
||||
|
||||
ItemCatalog4229938_entries = result
|
||||
ItemCatalog4229938_cached = true
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func ItemCatalog4229938_Find(id uint32) *ItemCatalogEntry4229938 {
|
||||
// Catalog helper ItemCatalog4229938_Find
|
||||
itemCatalogState.RLock()
|
||||
defer itemCatalogState.RUnlock()
|
||||
for _, entry := range itemCatalogState.current {
|
||||
if entry.Id == id {
|
||||
copy := entry
|
||||
return ©
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package catalogs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestItemCatalogPathsAndLookup(t *testing.T) {
|
||||
for _, file := range []string{"ItemCatalog.json", "ConsumableConfig.json"} {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, file), []byte(`{"records":[{"Id":36000000,"Name":"Test","Quality":2,"SubType":18}]}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, err := LoadItemCatalog4229938(dir)
|
||||
if err != nil || len(entries) != 1 {
|
||||
t.Fatalf("%s: %v %v", file, entries, err)
|
||||
}
|
||||
entry := ItemCatalog4229938_Find(36000000)
|
||||
if entry == nil || entry.Name != "Test" || entry.Quality != 2 {
|
||||
t.Fatalf("bad projection: %+v", entry)
|
||||
}
|
||||
entry.Name = "mutated"
|
||||
if ItemCatalog4229938_Find(36000000).Name != "Test" {
|
||||
t.Fatal("caller mutated cache")
|
||||
}
|
||||
}
|
||||
empty := t.TempDir()
|
||||
if _, err := LoadItemCatalog4229938(empty); err == nil {
|
||||
t.Fatal("reused another directory's cache")
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(empty, "ItemCatalog.json"), []byte(`{"records":[]}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if entries, err := LoadItemCatalog4229938(empty); err != nil || len(entries) != 0 {
|
||||
t.Fatalf("retry after missing file: %v %v", entries, err)
|
||||
}
|
||||
if ItemCatalog4229938_Find(36000000) != nil {
|
||||
t.Fatal("stale current catalog")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShippedItemCatalog(t *testing.T) {
|
||||
entries, err := LoadItemCatalog4229938("../../data/client_data/Configs")
|
||||
if err != nil || len(entries) == 0 {
|
||||
t.Fatalf("shipped consumables not loadable: %v", err)
|
||||
}
|
||||
if entry := ItemCatalog4229938_Find(36000000); entry == nil || entry.SubType != 18 {
|
||||
t.Fatalf("missing known item: %+v", entry)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Login LoginSettings `json:"login"`
|
||||
Client ClientConfig `json:"client"`
|
||||
Network NetworkConfig `json:"network"`
|
||||
Admin AdminConfig `json:"admin"`
|
||||
@@ -117,6 +118,9 @@ func LoadConfig(path string) (*Config, error) {
|
||||
return nil, fmt.Errorf("failed to parse json config from %s: %w", absPath, err)
|
||||
}
|
||||
|
||||
if cfg.Login.RoleListMode == "" {
|
||||
cfg.Login.RoleListMode = "list"
|
||||
}
|
||||
// Apply default values if omitted
|
||||
if cfg.Network.BindHost == "" {
|
||||
cfg.Network.BindHost = "0.0.0.0"
|
||||
|
||||
@@ -3,7 +3,9 @@ package gameserver
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ananta-ps/pkg/protocol"
|
||||
)
|
||||
@@ -48,6 +50,8 @@ func (ab *AdminBridge) ExecuteCommand(cmdLine string) (string, error) {
|
||||
return "", fmt.Errorf("no active players in the game world")
|
||||
}
|
||||
targetSess := sessions[0] // Apply to primary active player
|
||||
targetSess.stateMu.Lock()
|
||||
defer targetSess.stateMu.Unlock()
|
||||
|
||||
switch cmd {
|
||||
case "teleport", "tp":
|
||||
@@ -55,15 +59,28 @@ func (ab *AdminBridge) ExecuteCommand(cmdLine string) (string, error) {
|
||||
return "", fmt.Errorf("usage: teleport <poi_name> OR teleport <x> <y> <z>")
|
||||
}
|
||||
if poi, ok := KnownLandmarks[strings.ToLower(parts[1])]; ok {
|
||||
_ = targetSess.Teleport(poi.X, poi.Y, poi.Z, poi.Facing)
|
||||
if err := targetSess.teleport(poi.X, poi.Y, poi.Z, poi.Facing); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Teleported to %s (%.1f, %.1f, %.1f)", poi.Name, poi.X, poi.Y, poi.Z), nil
|
||||
}
|
||||
if len(parts) >= 4 {
|
||||
var x, y, z float32
|
||||
_, _ = fmt.Sscanf(parts[1], "%f", &x)
|
||||
_, _ = fmt.Sscanf(parts[2], "%f", &y)
|
||||
_, _ = fmt.Sscanf(parts[3], "%f", &z)
|
||||
_ = targetSess.Teleport(x, y, z, 0)
|
||||
if _, err := fmt.Sscanf(parts[1], "%f", &x); err != nil {
|
||||
return "", fmt.Errorf("invalid x coordinate")
|
||||
}
|
||||
if _, err := fmt.Sscanf(parts[2], "%f", &y); err != nil {
|
||||
return "", fmt.Errorf("invalid y coordinate")
|
||||
}
|
||||
if _, err := fmt.Sscanf(parts[3], "%f", &z); err != nil {
|
||||
return "", fmt.Errorf("invalid z coordinate")
|
||||
}
|
||||
if math.IsNaN(float64(x)) || math.IsInf(float64(x), 0) || math.IsNaN(float64(y)) || math.IsInf(float64(y), 0) || math.IsNaN(float64(z)) || math.IsInf(float64(z), 0) {
|
||||
return "", fmt.Errorf("coordinates must be finite")
|
||||
}
|
||||
if err := targetSess.teleport(x, y, z, 0); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Teleported to coordinates (%.1f, %.1f, %.1f)", x, y, z), nil
|
||||
}
|
||||
return "", fmt.Errorf("unknown POI landmark '%s'", parts[1])
|
||||
@@ -73,8 +90,12 @@ func (ab *AdminBridge) ExecuteCommand(cmdLine string) (string, error) {
|
||||
return "", fmt.Errorf("usage: weather <id> (e.g. 1=Sunny, 2=Rain, 3=Fog)")
|
||||
}
|
||||
var wId uint32
|
||||
_, _ = fmt.Sscanf(parts[1], "%d", &wId)
|
||||
ab.server.SetWorldWeather(wId)
|
||||
if _, err := fmt.Sscanf(parts[1], "%d", &wId); err != nil {
|
||||
return "", fmt.Errorf("invalid weather id")
|
||||
}
|
||||
if err := ab.server.SetWorldWeather(wId); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("World weather set to ID %d", wId), nil
|
||||
|
||||
case "time":
|
||||
@@ -82,8 +103,12 @@ func (ab *AdminBridge) ExecuteCommand(cmdLine string) (string, error) {
|
||||
return "", fmt.Errorf("usage: time <hour> (e.g. 12.0 for noon, 0.0 for midnight)")
|
||||
}
|
||||
var hour float32
|
||||
_, _ = fmt.Sscanf(parts[1], "%f", &hour)
|
||||
ab.server.SetWorldTimeOfDay(hour)
|
||||
if _, err := fmt.Sscanf(parts[1], "%f", &hour); err != nil {
|
||||
return "", fmt.Errorf("invalid hour")
|
||||
}
|
||||
if err := ab.server.SetWorldTimeOfDay(hour); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("World time set to %.1f:00", hour), nil
|
||||
|
||||
case "wanted":
|
||||
@@ -95,7 +120,9 @@ func (ab *AdminBridge) ExecuteCommand(cmdLine string) (string, error) {
|
||||
targetSess.wantedLevel = stars
|
||||
w := protocol.NewUxWriter()
|
||||
w.U64(targetSess.pid).U32(stars)
|
||||
_ = targetSess.NotifyData(protocol.MethodGetMethodIDDefault("SyncWantedLevel", 68582860), w.Bytes())
|
||||
if err := targetSess.NotifyData(protocol.MethodGetMethodIDDefault("SyncWantedLevel", 68582860), w.Bytes()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Player wanted level set to %d stars", stars), nil
|
||||
|
||||
case "spawn_vehicle", "veh":
|
||||
@@ -103,11 +130,13 @@ func (ab *AdminBridge) ExecuteCommand(cmdLine string) (string, error) {
|
||||
if len(parts) >= 2 {
|
||||
_, _ = fmt.Sscanf(parts[1], "%d", &templateId)
|
||||
}
|
||||
vehUnitId := uint64(0x6000000000000000) | uint64(len(ab.server.sessions)+1)
|
||||
vehUnitId := uint64(0x6000000000000000) | uint64(time.Now().UnixNano())
|
||||
targetSess.activeVehicleId = vehUnitId
|
||||
w := protocol.NewUxWriter()
|
||||
w.U64(vehUnitId).U32(templateId).F32(targetSess.posX + 3.0).F32(targetSess.posY).F32(targetSess.posZ + 3.0).F32(targetSess.facing)
|
||||
_ = targetSess.NotifyData(protocol.MethodGetMethodIDDefault("SyncSpawnVehicle", 68582851), w.Bytes())
|
||||
if err := targetSess.NotifyData(protocol.MethodGetMethodIDDefault("SyncSpawnVehicle", 68582851), w.Bytes()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Spawned vehicle template %d (unit %d)", templateId, vehUnitId), nil
|
||||
|
||||
case "spawn_enemy":
|
||||
@@ -119,15 +148,30 @@ func (ab *AdminBridge) ExecuteCommand(cmdLine string) (string, error) {
|
||||
return "", fmt.Errorf("usage: switch <spirit_template_id>")
|
||||
}
|
||||
var newId uint32
|
||||
_, _ = fmt.Sscanf(parts[1], "%d", &newId)
|
||||
if _, err := fmt.Sscanf(parts[1], "%d", &newId); err != nil || newId == 0 {
|
||||
return "", fmt.Errorf("invalid spirit template")
|
||||
}
|
||||
// Perform clean character swap
|
||||
newUnitId := targetSess.unitId + 1
|
||||
_ = targetSess.NotifyData(protocol.MethodSyncLogicAgentLeave, protocol.EncodeLogicAgentLeave(targetSess.unitId))
|
||||
_ = targetSess.NotifyData(protocol.MethodSyncLogicAgentEnter, protocol.EncodeLogicAgentEnter(newUnitId))
|
||||
_ = targetSess.NotifyData(protocol.MethodSyncManagedLogicAgent, protocol.EncodeManagedLogicAgent(newUnitId, targetSess.pid, 0))
|
||||
if err := targetSess.NotifyData(protocol.MethodSyncLogicAgentLeave, protocol.EncodeLogicAgentLeave(targetSess.unitId)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := targetSess.NotifyData(protocol.MethodSyncPlayerCurrentSpirit, protocol.EncodePlayerCurrentSpirit(targetSess.pid, newId, newUnitId, true)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := targetSess.NotifyData(protocol.MethodSyncLogicAgentEnter, protocol.EncodeLogicAgentEnter(newUnitId)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := targetSess.NotifyData(protocol.MethodSyncManagedLogicAgent, protocol.EncodeManagedLogicAgent(newUnitId, targetSess.pid, 0)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
newProj := protocol.EncodeRaidBattleUnitSpirit(newUnitId, newId, targetSess.posX, targetSess.posY, targetSess.posZ, targetSess.facing, targetSess.pid)
|
||||
_ = targetSess.NotifyData(protocol.MethodSyncRaidBattleUnitSpirit, newProj)
|
||||
_ = targetSess.NotifyData(protocol.MethodSyncPlayerCurrentSpirit, protocol.EncodePlayerCurrentSpirit(targetSess.pid, newId, newUnitId, true))
|
||||
if err := targetSess.NotifyData(protocol.MethodSyncRaidBattleUnitSpirit, newProj); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := targetSess.NotifyData(protocol.MethodSyncPlayerCurrentSpirit, protocol.EncodePlayerCurrentSpirit(targetSess.pid, newId, newUnitId, true)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
targetSess.templateId = newId
|
||||
targetSess.unitId = newUnitId
|
||||
return fmt.Sprintf("Switched player character to template %d", newId), nil
|
||||
@@ -135,13 +179,17 @@ func (ab *AdminBridge) ExecuteCommand(cmdLine string) (string, error) {
|
||||
case "fog_unlock":
|
||||
wFog := protocol.NewUxWriter()
|
||||
wFog.U32(uint32(targetSess.activeScene)).Bool(true)
|
||||
_ = targetSess.NotifyData(protocol.MethodSyncSceneFogMapAllUnlock, wFog.Bytes())
|
||||
if err := targetSess.NotifyData(protocol.MethodSyncSceneFogMapAllUnlock, wFog.Bytes()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "All map fog unlocked", nil
|
||||
|
||||
case "dive_unlock":
|
||||
wDive := protocol.NewUxWriter()
|
||||
wDive.UxString("EnableDive").U8(0xFF).Int7(1).UxString("true")
|
||||
_ = targetSess.NotifyData(protocol.MethodSyncRunGmUtils, wDive.Bytes())
|
||||
if err := targetSess.NotifyData(protocol.MethodSyncRunGmUtils, wDive.Bytes()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "Diving mechanics enabled", nil
|
||||
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package gameserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"ananta-ps/internal/config"
|
||||
"ananta-ps/pkg/protocol"
|
||||
)
|
||||
|
||||
func TestWeatherAndTimeUseActualClientContracts(t *testing.T) {
|
||||
srv := New(&config.Config{}, "")
|
||||
conn := &recordingConn{}
|
||||
srv.sessions["test"] = &Session{conn: conn}
|
||||
if err := srv.SetWorldWeather(3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err := protocol.ReadFrame(conn, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pkt := protocol.ParseRpcPacket(f.Payload)
|
||||
if pkt.MethodID != 68712141 || !bytes.Equal(pkt.Body, []byte{3, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0}) {
|
||||
t.Fatalf("weather contract: %+v", pkt)
|
||||
}
|
||||
if err := srv.SetWorldTimeOfDay(12.5); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err = protocol.ReadFrame(conn, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pkt = protocol.ParseRpcPacket(f.Payload)
|
||||
if pkt.MethodID != 68337673 || len(pkt.Body) != 22 || binary.LittleEndian.Uint32(pkt.Body[4:8]) != 45000 || pkt.Body[8] != 1 {
|
||||
t.Fatalf("time contract: %+v", pkt)
|
||||
}
|
||||
for _, hour := range []float32{-1, 24, float32(math.NaN()), float32(math.Inf(1))} {
|
||||
if err := srv.SetWorldTimeOfDay(hour); err == nil {
|
||||
t.Fatalf("invalid hour: %v", hour)
|
||||
}
|
||||
}
|
||||
if conn.Len() != 0 {
|
||||
t.Fatal("invalid time emitted a packet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRejectsInvalidTeleport(t *testing.T) {
|
||||
srv := New(&config.Config{}, "")
|
||||
conn := &recordingConn{}
|
||||
srv.sessions["test"] = &Session{conn: conn, posX: 123}
|
||||
for _, cmd := range []string{"tp garbage 0 0", "tp NaN 0 0", "tp 0 +Inf 0"} {
|
||||
if _, err := srv.GetAdminBridge().ExecuteCommand(cmd); err == nil {
|
||||
t.Fatalf("accepted %s", cmd)
|
||||
}
|
||||
}
|
||||
if conn.Len() != 0 || srv.sessions["test"].posX != 123 {
|
||||
t.Fatal("invalid command changed position")
|
||||
}
|
||||
}
|
||||
|
||||
type recordingConn struct{ bytes.Buffer }
|
||||
|
||||
func (c *recordingConn) Close() error { return nil }
|
||||
func (c *recordingConn) LocalAddr() net.Addr { return &net.TCPAddr{} }
|
||||
func (c *recordingConn) RemoteAddr() net.Addr { return &net.TCPAddr{} }
|
||||
func (c *recordingConn) SetDeadline(time.Time) error { return nil }
|
||||
func (c *recordingConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (c *recordingConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
|
||||
func TestDispatchContractFallbackAndPacketKinds(t *testing.T) {
|
||||
srv := New(&config.Config{}, "")
|
||||
conn := &recordingConn{}
|
||||
sess := &Session{conn: conn}
|
||||
// An unimplemented RPC returning bool needs one byte, not an empty OK.
|
||||
if err := srv.dispatch(sess, &protocol.RpcPacket{Kind: protocol.PacketKindInvoke, MethodID: 67709054, InvokeID: 12}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err := protocol.ReadFrame(conn, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pkt := protocol.ParseRpcPacket(f.Payload)
|
||||
if pkt.Kind != protocol.PacketKindReturn || pkt.InvokeID != 12 || !bytes.Equal(pkt.Body, []byte{0}) {
|
||||
t.Fatalf("bad bool fallback: %+v", pkt)
|
||||
}
|
||||
for _, kind := range []protocol.RpcPacketKind{protocol.PacketKindReturn, protocol.PacketKindUnknown} {
|
||||
if err := srv.dispatch(sess, &protocol.RpcPacket{Kind: kind, MethodID: protocol.MethodAskSwitchSpirit}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if conn.Len() != 0 {
|
||||
t.Fatal("dispatched a return or malformed packet as a request")
|
||||
}
|
||||
}
|
||||
if err := sess.ReturnEmptyOk(0, protocol.MethodLoginGame); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if conn.Len() != 0 {
|
||||
t.Fatal("unsolicited return for a notification")
|
||||
}
|
||||
// Fabricated old AskMoveStart collided with a real client ID; it must not move state.
|
||||
if err := srv.dispatch(sess, &protocol.RpcPacket{Kind: protocol.PacketKindNotify, MethodID: 68412852}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if conn.Len() != 0 {
|
||||
t.Fatal("invented route still active")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHangupAndActualMovementContract(t *testing.T) {
|
||||
srv := New(&config.Config{}, "")
|
||||
sess := &Session{conn: &recordingConn{}, unitId: 9}
|
||||
for _, tc := range []struct {
|
||||
id uint32
|
||||
paused bool
|
||||
}{{63842476, true}, {63910160, false}} {
|
||||
if err := srv.dispatch(sess, &protocol.RpcPacket{Kind: protocol.PacketKindInvoke, MethodID: tc.id, InvokeID: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sess.hangup.Load() != tc.paused {
|
||||
t.Fatal("hangup state not updated")
|
||||
}
|
||||
}
|
||||
body := protocol.NewUxWriter().U8(0xff).Int7(1).U64(9).F32(1).F32(2).F32(3).F32(0).F32(90).F32(0).Bytes()
|
||||
if err := srv.dispatch(sess, &protocol.RpcPacket{Kind: protocol.PacketKindNotify, MethodID: 67543012, Body: body}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sess.posX != 1 || sess.posY != 2 || sess.posZ != 3 || sess.facing != 90 {
|
||||
t.Fatal("actual movement contract not applied")
|
||||
}
|
||||
if err := srv.dispatch(sess, &protocol.RpcPacket{Kind: protocol.PacketKindNotify, MethodID: 67543012, Body: body[:15]}); err == nil {
|
||||
t.Fatal("truncated position accepted")
|
||||
}
|
||||
if sess.posX != 1 || sess.posY != 2 {
|
||||
t.Fatal("partial position update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeleportIncludesNullableTail(t *testing.T) {
|
||||
conn := &recordingConn{}
|
||||
sess := &Session{conn: conn, unitId: 9}
|
||||
if err := sess.Teleport(1, 2, 3, 90); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err := protocol.ReadFrame(conn, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pkt := protocol.ParseRpcPacket(f.Payload)
|
||||
if pkt.MethodID != 68030312 || len(pkt.Body) != 29 || !bytes.Equal(pkt.Body[24:], []byte{0, 0, 6, 0, 0}) {
|
||||
t.Fatalf("invalid position contract: %+v", pkt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopClosesIdleHandshakeConnections(t *testing.T) {
|
||||
srv := New(&config.Config{Network: config.NetworkConfig{BindHost: "127.0.0.1"}}, "")
|
||||
if err := srv.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn, err := net.DialTimeout("tcp", srv.ln.Addr().String(), time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
// Wait for the server to own the socket before stopping.
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
srv.mu.RLock()
|
||||
n := len(srv.connections)
|
||||
srv.mu.RUnlock()
|
||||
if n == 1 {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("connection not accepted")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() { srv.Stop(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Stop blocked on idle handshake")
|
||||
}
|
||||
conn.SetReadDeadline(time.Now().Add(time.Second))
|
||||
if _, err := conn.Read(make([]byte, 1)); err == nil {
|
||||
t.Fatal("idle connection survived Stop")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package gameserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"ananta-ps/pkg/protocol"
|
||||
)
|
||||
|
||||
// Both movement RPCs carry a List7Bit of inline structs. LogicAgentSyncData
|
||||
// has no MoveId; SimpleMoveActionData adds one byte after the two vectors.
|
||||
func (s *Server) handleMovementReport(sess *Session, pkt *protocol.RpcPacket, simple bool) error {
|
||||
r := protocol.NewUxReader(pkt.Body)
|
||||
mark, err := r.U8()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if mark == 0 && r.Remaining() == 0 {
|
||||
return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID)
|
||||
}
|
||||
if mark != 0xff {
|
||||
return fmt.Errorf("invalid movement list marker")
|
||||
}
|
||||
count, err := r.Int7()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
size := 32
|
||||
if simple {
|
||||
size++
|
||||
}
|
||||
if count > r.Remaining()/size || count*size != r.Remaining() {
|
||||
return fmt.Errorf("invalid movement sample count")
|
||||
}
|
||||
x, y, z, yaw := sess.posX, sess.posY, sess.posZ, sess.facing
|
||||
for i := 0; i < count; i++ {
|
||||
id, _ := r.U64()
|
||||
var v [6]float32
|
||||
for j := range v {
|
||||
v[j], err = r.F32()
|
||||
if err != nil || math.IsNaN(float64(v[j])) || math.IsInf(float64(v[j]), 0) {
|
||||
return fmt.Errorf("invalid movement coordinate")
|
||||
}
|
||||
}
|
||||
if simple {
|
||||
_, _ = r.U8()
|
||||
}
|
||||
if id == sess.unitId {
|
||||
x, y, z, yaw = v[0], v[1], v[2], v[4]
|
||||
}
|
||||
}
|
||||
sess.posX, sess.posY, sess.posZ, sess.facing = x, y, z, yaw
|
||||
return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID)
|
||||
}
|
||||
+200
-122
@@ -6,8 +6,11 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"ananta-ps/internal/config"
|
||||
"ananta-ps/pkg/crypto/chacha8"
|
||||
@@ -20,6 +23,7 @@ type Server struct {
|
||||
ln net.Listener
|
||||
cancelFunc context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
connections map[net.Conn]struct{}
|
||||
sessions map[string]*Session
|
||||
mu sync.RWMutex
|
||||
trafficEngine *CityTrafficEngine
|
||||
@@ -28,28 +32,34 @@ type Server struct {
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
id string
|
||||
conn net.Conn
|
||||
readCipher *chacha8.UxChaCha8
|
||||
sendCipher *chacha8.UxChaCha8
|
||||
sendMu sync.Mutex
|
||||
pid uint64
|
||||
unitId uint64
|
||||
templateId uint32
|
||||
posX float32
|
||||
posY float32
|
||||
posZ float32
|
||||
facing float32
|
||||
activeScene uint64
|
||||
activeRaid uint32
|
||||
universeId uint32
|
||||
lastShopExitMs int64
|
||||
pendingTeleport *PreTeleportOption
|
||||
activeVehicleId uint64
|
||||
isDriving bool
|
||||
wantedLevel uint32
|
||||
balloonScore uint32
|
||||
racing *RacingSession
|
||||
replyKind protocol.RpcPacketKind
|
||||
stateMu sync.Mutex
|
||||
ready atomic.Bool
|
||||
hangup atomic.Bool
|
||||
initialStateSent bool
|
||||
writeTimeout time.Duration
|
||||
id string
|
||||
conn net.Conn
|
||||
readCipher *chacha8.UxChaCha8
|
||||
sendCipher *chacha8.UxChaCha8
|
||||
sendMu sync.Mutex
|
||||
pid uint64
|
||||
unitId uint64
|
||||
templateId uint32
|
||||
posX float32
|
||||
posY float32
|
||||
posZ float32
|
||||
facing float32
|
||||
activeScene uint64
|
||||
activeRaid uint32
|
||||
universeId uint32
|
||||
lastShopExitMs int64
|
||||
pendingTeleport *PreTeleportOption
|
||||
activeVehicleId uint64
|
||||
isDriving bool
|
||||
wantedLevel uint32
|
||||
balloonScore uint32
|
||||
racing *RacingSession
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, templatePath string) *Server {
|
||||
@@ -57,11 +67,13 @@ func New(cfg *config.Config, templatePath string) *Server {
|
||||
candidates := []string{
|
||||
"data/player_info_template.bin",
|
||||
"../data/player_info_template.bin",
|
||||
"../../data/player_info_template.bin",
|
||||
}
|
||||
for _, c := range candidates {
|
||||
abs, _ := filepath.Abs(c)
|
||||
templatePath = abs
|
||||
break
|
||||
if _, err := os.Stat(c); err == nil {
|
||||
templatePath, _ = filepath.Abs(c)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +81,7 @@ func New(cfg *config.Config, templatePath string) *Server {
|
||||
cfg: cfg,
|
||||
templatePath: templatePath,
|
||||
sessions: make(map[string]*Session),
|
||||
connections: make(map[net.Conn]struct{}),
|
||||
}
|
||||
srv.trafficEngine = NewCityTrafficEngine(srv)
|
||||
srv.crowdEngine = NewUrbanCrowdEngine(srv)
|
||||
@@ -84,16 +97,15 @@ func (s *Server) Start() error {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s.cancelFunc = cancel
|
||||
|
||||
// Load Road Network for city traffic simulation
|
||||
_ = s.trafficEngine.LoadRoadNetwork("data/client_data/World/RoadNetwork.json")
|
||||
s.trafficEngine.Start(ctx)
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", s.cfg.Network.BindHost, s.cfg.Network.GamePort)
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return fmt.Errorf("failed to listen on game server %s: %w", addr, err)
|
||||
}
|
||||
s.ln = ln
|
||||
_ = s.trafficEngine.LoadRoadNetwork("data/client_data/World/RoadNetwork.json")
|
||||
s.trafficEngine.Start(ctx)
|
||||
log.Printf("[GAME-SERVER] Listening on %s:%d", s.cfg.Network.AdvertisedHost, s.cfg.Network.GamePort)
|
||||
|
||||
s.wg.Add(1)
|
||||
@@ -103,15 +115,20 @@ func (s *Server) Start() error {
|
||||
}
|
||||
|
||||
func (s *Server) Stop() error {
|
||||
if s.trafficEngine != nil {
|
||||
s.trafficEngine.Stop()
|
||||
}
|
||||
if s.cancelFunc != nil {
|
||||
s.cancelFunc()
|
||||
}
|
||||
if s.ln != nil {
|
||||
_ = s.ln.Close()
|
||||
}
|
||||
s.mu.Lock()
|
||||
for conn := range s.connections {
|
||||
_ = conn.Close()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if s.trafficEngine != nil {
|
||||
s.trafficEngine.Stop()
|
||||
}
|
||||
s.wg.Wait()
|
||||
return nil
|
||||
}
|
||||
@@ -140,7 +157,20 @@ func (s *Server) acceptLoop(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
go s.handleConnection(ctx, conn)
|
||||
s.mu.Lock()
|
||||
if ctx.Err() != nil {
|
||||
s.mu.Unlock()
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
s.connections[conn] = struct{}{}
|
||||
s.wg.Add(1)
|
||||
s.mu.Unlock()
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
defer func() { s.mu.Lock(); delete(s.connections, conn); s.mu.Unlock() }()
|
||||
s.handleConnection(ctx, conn)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,10 +188,29 @@ func (sess *Session) SendPacket(packet *protocol.RpcPacket) error {
|
||||
payload = protocol.BuildNotify(packet.MethodID, packet.Body)
|
||||
}
|
||||
|
||||
return protocol.WriteFrame(sess.conn, 0x09, payload, sess.sendCipher)
|
||||
return sess.writeFrameLocked(0x09, payload)
|
||||
}
|
||||
|
||||
func (sess *Session) writeFrameLocked(mode byte, payload []byte) error {
|
||||
timeout := sess.writeTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 15 * time.Second
|
||||
}
|
||||
if err := sess.conn.SetWriteDeadline(time.Now().Add(timeout)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := protocol.WriteFrame(sess.conn, mode, payload, sess.sendCipher); err != nil {
|
||||
// The cipher has advanced; this stream cannot be reused after a failed write.
|
||||
sess.conn.Close()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sess *Session) ReturnEmptyOk(invokeID int32, methodID uint32) error {
|
||||
if sess.replyKind != protocol.PacketKindInvoke {
|
||||
return nil
|
||||
}
|
||||
return sess.SendPacket(&protocol.RpcPacket{
|
||||
Kind: protocol.PacketKindReturn,
|
||||
InvokeID: invokeID,
|
||||
@@ -172,6 +221,9 @@ func (sess *Session) ReturnEmptyOk(invokeID int32, methodID uint32) error {
|
||||
}
|
||||
|
||||
func (sess *Session) ReturnData(invokeID int32, methodID uint32, body []byte) error {
|
||||
if sess.replyKind != protocol.PacketKindInvoke {
|
||||
return nil
|
||||
}
|
||||
return sess.SendPacket(&protocol.RpcPacket{
|
||||
Kind: protocol.PacketKindReturn,
|
||||
InvokeID: invokeID,
|
||||
@@ -182,6 +234,9 @@ func (sess *Session) ReturnData(invokeID int32, methodID uint32, body []byte) er
|
||||
}
|
||||
|
||||
func (sess *Session) NotifyData(methodID uint32, body []byte) error {
|
||||
if methodID == 0 {
|
||||
return fmt.Errorf("unverified notification method")
|
||||
}
|
||||
return sess.SendPacket(&protocol.RpcPacket{
|
||||
Kind: protocol.PacketKindNotify,
|
||||
MethodID: methodID,
|
||||
@@ -201,20 +256,21 @@ func (s *Server) handleConnection(ctx context.Context, conn net.Conn) {
|
||||
}
|
||||
|
||||
sess := &Session{
|
||||
id: conn.RemoteAddr().String(),
|
||||
conn: conn,
|
||||
readCipher: readCipher,
|
||||
sendCipher: writeCipher,
|
||||
pid: s.cfg.Player.Pid,
|
||||
unitId: s.cfg.Player.InitialUnitId,
|
||||
templateId: s.cfg.Player.InitialSpiritTemplateId,
|
||||
posX: float32(s.cfg.World.Spawn.X),
|
||||
posY: float32(s.cfg.World.Spawn.Y),
|
||||
posZ: float32(s.cfg.World.Spawn.Z),
|
||||
facing: s.cfg.World.Facing,
|
||||
activeRaid: s.cfg.World.RaidId,
|
||||
activeScene: s.cfg.World.SceneInstanceId,
|
||||
universeId: s.cfg.World.UniverseId,
|
||||
id: conn.RemoteAddr().String(),
|
||||
writeTimeout: time.Duration(s.cfg.Network.WriteTimeoutMs) * time.Millisecond,
|
||||
conn: conn,
|
||||
readCipher: readCipher,
|
||||
sendCipher: writeCipher,
|
||||
pid: s.cfg.Player.Pid,
|
||||
unitId: s.cfg.Player.InitialUnitId,
|
||||
templateId: s.cfg.Player.InitialSpiritTemplateId,
|
||||
posX: float32(s.cfg.World.Spawn.X),
|
||||
posY: float32(s.cfg.World.Spawn.Y),
|
||||
posZ: float32(s.cfg.World.Spawn.Z),
|
||||
facing: s.cfg.World.Facing,
|
||||
activeRaid: s.cfg.World.RaidId,
|
||||
activeScene: s.cfg.World.SceneInstanceId,
|
||||
universeId: s.cfg.World.UniverseId,
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
@@ -243,10 +299,18 @@ func (s *Server) handleConnection(ctx context.Context, conn net.Conn) {
|
||||
}
|
||||
|
||||
if frame.Mode == 0x03 {
|
||||
_ = protocol.WriteFrame(conn, 0x03, frame.Payload, sess.sendCipher)
|
||||
sess.sendMu.Lock()
|
||||
err = sess.writeFrameLocked(0x03, frame.Payload)
|
||||
sess.sendMu.Unlock()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if frame.Mode != 0x09 {
|
||||
continue
|
||||
}
|
||||
packet := protocol.ParseRpcPacket(frame.Payload)
|
||||
if err := s.dispatch(sess, &packet); err != nil {
|
||||
log.Printf("[GAME-SERVER] Dispatch error for method %d (%s): %v",
|
||||
@@ -256,19 +320,34 @@ func (s *Server) handleConnection(ctx context.Context, conn net.Conn) {
|
||||
}
|
||||
|
||||
func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
|
||||
if pkt.Kind != protocol.PacketKindInvoke && pkt.Kind != protocol.PacketKindNotify {
|
||||
return nil
|
||||
}
|
||||
sess.stateMu.Lock()
|
||||
defer sess.stateMu.Unlock()
|
||||
sess.replyKind = pkt.Kind
|
||||
defer func() { sess.replyKind = protocol.PacketKindUnknown }()
|
||||
switch pkt.MethodID {
|
||||
// ---------------- Login / World Entry ----------------
|
||||
case protocol.MethodLoginGame:
|
||||
if sess.initialStateSent {
|
||||
return nil
|
||||
}
|
||||
sess.initialStateSent = true
|
||||
log.Printf("[WORLD] LoginGame received, preparing world state for player %d", sess.pid)
|
||||
if err := sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = sess.NotifyData(protocol.MethodSendServerTimeGame, protocol.EncodeServerTime(0))
|
||||
if err := sess.NotifyData(protocol.MethodSendServerTimeGame, protocol.EncodeServerTime(0)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
playerInfoBytes, err := protocol.EncodePlayerInfo(s.templatePath, s.cfg.Client.Aid, sess.pid, s.cfg.Player.AccountId, s.cfg.Player.DisplayName)
|
||||
if err == nil {
|
||||
_ = sess.NotifyData(protocol.MethodSyncPlayerInfo, playerInfoBytes)
|
||||
if err := sess.NotifyData(protocol.MethodSyncPlayerInfo, playerInfoBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
log.Printf("[WORLD] Warning: player_info_template.bin load error: %v", err)
|
||||
}
|
||||
@@ -295,10 +374,16 @@ func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = sess.NotifyData(protocol.MethodSyncLogicAgentEnter, protocol.EncodeLogicAgentEnter(sess.unitId))
|
||||
_ = sess.NotifyData(protocol.MethodSyncManagedLogicAgent, protocol.EncodeManagedLogicAgent(sess.unitId, sess.pid, 0))
|
||||
if err := sess.NotifyData(protocol.MethodSyncLogicAgentEnter, protocol.EncodeLogicAgentEnter(sess.unitId)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := sess.NotifyData(protocol.MethodSyncManagedLogicAgent, protocol.EncodeManagedLogicAgent(sess.unitId, sess.pid, 0)); err != nil {
|
||||
return err
|
||||
}
|
||||
unitProjection := protocol.EncodeRaidBattleUnitSpirit(sess.unitId, sess.templateId, sess.posX, sess.posY, sess.posZ, sess.facing, sess.pid)
|
||||
_ = sess.NotifyData(protocol.MethodSyncRaidBattleUnitSpirit, unitProjection)
|
||||
if err := sess.NotifyData(protocol.MethodSyncRaidBattleUnitSpirit, unitProjection); err != nil {
|
||||
return err
|
||||
}
|
||||
return sess.NotifyData(protocol.MethodSyncPlayerCurrentSpirit, protocol.EncodePlayerCurrentSpirit(sess.pid, sess.templateId, sess.unitId, false))
|
||||
|
||||
case protocol.MethodAskLoadingFinished:
|
||||
@@ -307,19 +392,28 @@ func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = sess.NotifyData(protocol.MethodSyncSceneLoadCompleted, protocol.EncodeSceneLoadCompleted(sess.activeScene))
|
||||
if err := sess.NotifyData(protocol.MethodSyncSceneLoadCompleted, protocol.EncodeSceneLoadCompleted(sess.activeScene)); err != nil {
|
||||
return err
|
||||
}
|
||||
sess.ready.Store(true)
|
||||
|
||||
wPause := protocol.NewUxWriter()
|
||||
wPause.Bool(false)
|
||||
_ = sess.NotifyData(protocol.MethodSyncGamePause, wPause.Bytes())
|
||||
if err := sess.NotifyData(protocol.MethodSyncGamePause, wPause.Bytes()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
wFog := protocol.NewUxWriter()
|
||||
wFog.U32(uint32(sess.activeScene)).Bool(true)
|
||||
_ = sess.NotifyData(protocol.MethodSyncSceneFogMapAllUnlock, wFog.Bytes())
|
||||
if err := sess.NotifyData(protocol.MethodSyncSceneFogMapAllUnlock, wFog.Bytes()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
wPower := protocol.NewUxWriter()
|
||||
wPower.U8(0xFF).Int7(1).U32(sess.templateId).F32(1000.0)
|
||||
_ = sess.NotifyData(protocol.MethodSyncAllSpiritCombatPower, wPower.Bytes())
|
||||
if err := sess.NotifyData(protocol.MethodSyncAllSpiritCombatPower, wPower.Bytes()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
wDive := protocol.NewUxWriter()
|
||||
wDive.UxString("EnableDive").U8(0xFF).Int7(1).UxString("true")
|
||||
@@ -330,32 +424,29 @@ func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
|
||||
w.U64(sess.pid).F64(1.0)
|
||||
return sess.NotifyData(protocol.MethodSyncPlayerLoadRate, w.Bytes())
|
||||
|
||||
// ---------------- Movement & Position ----------------
|
||||
case protocol.MethodAskMoveStart, protocol.MethodAskMoveStop:
|
||||
case protocol.MethodRequestPlayerStartHangup:
|
||||
sess.hangup.Store(true)
|
||||
return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID)
|
||||
case protocol.MethodRequestPlayerStopHangup:
|
||||
sess.hangup.Store(false)
|
||||
return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID)
|
||||
case protocol.MethodAskReportLogicAgentSyncData:
|
||||
return s.handleMovementReport(sess, pkt, false)
|
||||
case protocol.MethodAskUnitMoveActionSimple:
|
||||
return s.handleMovementReport(sess, pkt, true)
|
||||
|
||||
case protocol.MethodAskUpdatePlayerPosition:
|
||||
r := protocol.NewUxReader(pkt.Body)
|
||||
if x, err := r.F32(); err == nil {
|
||||
sess.posX = x
|
||||
}
|
||||
if y, err := r.F32(); err == nil {
|
||||
sess.posY = y
|
||||
}
|
||||
if z, err := r.F32(); err == nil {
|
||||
sess.posZ = z
|
||||
}
|
||||
return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID)
|
||||
// ---------------- Movement & Position ----------------
|
||||
|
||||
// ---------------- Character Switching ----------------
|
||||
case protocol.MethodAskPreSwitchSpirit:
|
||||
return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID)
|
||||
|
||||
case protocol.MethodAskSwitchSpirit:
|
||||
r := protocol.NewUxReader(pkt.Body)
|
||||
newTemplateId := sess.templateId
|
||||
if tid, err := r.U32(); err == nil {
|
||||
newTemplateId = tid
|
||||
newTemplateId, err := r.U32()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if newTemplateId == 0 {
|
||||
return fmt.Errorf("spirit template must be nonzero")
|
||||
}
|
||||
newUnitId := sess.unitId + 1
|
||||
|
||||
@@ -364,17 +455,32 @@ func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = sess.NotifyData(protocol.MethodSyncLogicAgentLeave, protocol.EncodeLogicAgentLeave(sess.unitId))
|
||||
_ = sess.NotifyData(protocol.MethodSyncLogicAgentEnter, protocol.EncodeLogicAgentEnter(newUnitId))
|
||||
_ = sess.NotifyData(protocol.MethodSyncManagedLogicAgent, protocol.EncodeManagedLogicAgent(newUnitId, sess.pid, 0))
|
||||
if err := sess.NotifyData(protocol.MethodSyncLogicAgentLeave, protocol.EncodeLogicAgentLeave(sess.unitId)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := sess.NotifyData(protocol.MethodSyncPlayerCurrentSpirit, protocol.EncodePlayerCurrentSpirit(sess.pid, newTemplateId, newUnitId, true)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := sess.NotifyData(protocol.MethodSyncLogicAgentEnter, protocol.EncodeLogicAgentEnter(newUnitId)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := sess.NotifyData(protocol.MethodSyncManagedLogicAgent, protocol.EncodeManagedLogicAgent(newUnitId, sess.pid, 0)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newProj := protocol.EncodeRaidBattleUnitSpirit(newUnitId, newTemplateId, sess.posX, sess.posY, sess.posZ, sess.facing, sess.pid)
|
||||
_ = sess.NotifyData(protocol.MethodSyncRaidBattleUnitSpirit, newProj)
|
||||
_ = sess.NotifyData(protocol.MethodSyncPlayerCurrentSpirit, protocol.EncodePlayerCurrentSpirit(sess.pid, newTemplateId, newUnitId, true))
|
||||
if err := sess.NotifyData(protocol.MethodSyncRaidBattleUnitSpirit, newProj); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := sess.NotifyData(protocol.MethodSyncPlayerCurrentSpirit, protocol.EncodePlayerCurrentSpirit(sess.pid, newTemplateId, newUnitId, true)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
wInSitu := protocol.NewUxWriter()
|
||||
wInSitu.U32(newTemplateId).U64(newUnitId)
|
||||
_ = sess.NotifyData(protocol.MethodSyncSwitchSpiritInSitu, wInSitu.Bytes())
|
||||
if err := sess.NotifyData(protocol.MethodSyncSwitchSpiritInSitu, wInSitu.Bytes()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sess.templateId = newTemplateId
|
||||
sess.unitId = newUnitId
|
||||
@@ -397,12 +503,6 @@ func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
|
||||
case protocol.MethodAskSummonVehicle:
|
||||
return s.handleAskSummonVehicle(sess, pkt)
|
||||
|
||||
case protocol.MethodAskDriveVehicle:
|
||||
return s.handleAskDriveVehicle(sess, pkt)
|
||||
|
||||
case protocol.MethodAskExitVehicle:
|
||||
return s.handleAskExitVehicle(sess, pkt)
|
||||
|
||||
case protocol.MethodAskVehicleMove:
|
||||
return s.handleAskVehicleMove(sess, pkt)
|
||||
|
||||
@@ -413,20 +513,6 @@ func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
|
||||
return s.handleAskVehicleSendGamePlaySignal(sess, pkt)
|
||||
|
||||
// ---------------- Police & Crime ----------------
|
||||
case protocol.MethodAskSetWantedLevel:
|
||||
return s.handleAskSetWantedLevel(sess, pkt)
|
||||
|
||||
case protocol.MethodAskPoliceArrest:
|
||||
return s.handleAskPoliceArrest(sess, pkt)
|
||||
|
||||
case protocol.MethodReportPoliceArrestFinish:
|
||||
return s.handleReportPoliceArrestFinish(sess, pkt)
|
||||
|
||||
case protocol.MethodAskPayFine:
|
||||
return s.handleAskPayFine(sess, pkt)
|
||||
|
||||
case protocol.MethodAskReleaseFromPrison:
|
||||
return s.handleAskReleaseFromPrison(sess, pkt)
|
||||
|
||||
// ---------------- Dialog & NPC & Crowd ----------------
|
||||
case protocol.MethodAskStartDialog:
|
||||
@@ -447,9 +533,6 @@ func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
|
||||
case protocol.MethodAskDoNpcAction:
|
||||
return s.handleAskDoNpcAction(sess, pkt)
|
||||
|
||||
case protocol.MethodAskSetCrowdDensity:
|
||||
return s.handleAskSetCrowdDensity(sess, pkt)
|
||||
|
||||
case protocol.MethodAskPhoneAddCallRecord:
|
||||
return s.handleAskPhoneAddCallRecord(sess, pkt)
|
||||
|
||||
@@ -484,21 +567,7 @@ func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
|
||||
case protocol.MethodAskControlPowerHoldEnemyFallGrou:
|
||||
return s.handleAskControlPowerHoldEnemyFallGrou(sess, pkt)
|
||||
|
||||
case protocol.MethodAskPlayerAttack:
|
||||
return s.handleAskPlayerAttack(sess, pkt)
|
||||
|
||||
case protocol.MethodAskPlayerCastSkill:
|
||||
return s.handleAskPlayerCastSkill(sess, pkt)
|
||||
|
||||
case protocol.MethodAskUnlockFightStyle:
|
||||
return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID)
|
||||
|
||||
// ---------------- Mini-games ----------------
|
||||
case protocol.MethodAskChineseChessMove:
|
||||
return s.handleAskChineseChessMove(sess, pkt)
|
||||
|
||||
case protocol.MethodAskMahjongAction:
|
||||
return s.handleAskMahjongAction(sess, pkt)
|
||||
|
||||
case protocol.MethodEnterBalloonZone:
|
||||
return s.handleEnterBalloonZone(sess, pkt)
|
||||
@@ -554,7 +623,10 @@ func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
|
||||
default:
|
||||
// Safe neutral typed fallback for unrouted RPC invokes
|
||||
if pkt.Kind == protocol.PacketKindInvoke {
|
||||
return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID)
|
||||
if body, ok := protocol.DefaultReturnBody(pkt.MethodID); ok {
|
||||
return sess.ReturnData(pkt.InvokeID, pkt.MethodID, body)
|
||||
}
|
||||
return sess.SendPacket(&protocol.RpcPacket{Kind: protocol.PacketKindReturn, InvokeID: pkt.InvokeID, MethodID: pkt.MethodID, Error: -1})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -563,12 +635,18 @@ func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
|
||||
|
||||
// Teleport moves an active session to the specified coordinates.
|
||||
func (sess *Session) Teleport(x, y, z, facing float32) error {
|
||||
sess.stateMu.Lock()
|
||||
defer sess.stateMu.Unlock()
|
||||
return sess.teleport(x, y, z, facing)
|
||||
}
|
||||
|
||||
func (sess *Session) teleport(x, y, z, facing float32) error {
|
||||
sess.posX = x
|
||||
sess.posY = y
|
||||
sess.posZ = z
|
||||
sess.facing = facing
|
||||
|
||||
w := protocol.NewUxWriter()
|
||||
w.U64(sess.unitId).F32(x).F32(y).F32(z).F32(facing).U8(0).Bool(false).U8(6) // SetPositionType.Teleport = 6
|
||||
w.U64(sess.unitId).F32(x).F32(y).F32(z).F32(facing).U8(0).Bool(false).U8(6).U8(0).U8(0) // nullable moveGroundInfo and loadingInfo
|
||||
return sess.NotifyData(protocol.MethodSyncUnitPositionAndFacing, w.Bytes())
|
||||
}
|
||||
@@ -133,7 +133,7 @@ func (s *Server) handleReportPreTeleportFinish(sess *Session, pkt *protocol.RpcP
|
||||
if opt != nil {
|
||||
dest := opt.Position
|
||||
if dest.X != 0 || dest.Y != 0 || dest.Z != 0 {
|
||||
_ = sess.Teleport(dest.X, dest.Y, dest.Z, opt.Facing)
|
||||
_ = sess.teleport(dest.X, dest.Y, dest.Z, opt.Facing)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -30,6 +30,7 @@ type RoadNetworkData struct {
|
||||
}
|
||||
|
||||
type CityTrafficEngine struct {
|
||||
wg sync.WaitGroup
|
||||
mu sync.RWMutex
|
||||
vehicles map[uint64]*TrafficVehicle
|
||||
lanes []RoadLane
|
||||
@@ -97,23 +98,29 @@ func (te *CityTrafficEngine) Start(ctx context.Context) {
|
||||
LaneLength: lane.Length,
|
||||
}
|
||||
}
|
||||
count := len(te.vehicles)
|
||||
te.wg.Add(1)
|
||||
te.mu.Unlock()
|
||||
|
||||
log.Printf("[TRAFFIC] CityTrafficEngine started with %d simulated AI vehicles", len(te.vehicles))
|
||||
log.Printf("[TRAFFIC] CityTrafficEngine started with %d simulated AI vehicles", count)
|
||||
go te.runLoop(ctx)
|
||||
}
|
||||
|
||||
func (te *CityTrafficEngine) Stop() {
|
||||
te.mu.Lock()
|
||||
defer te.mu.Unlock()
|
||||
if te.cancel != nil {
|
||||
te.cancel()
|
||||
}
|
||||
te.isRunning = false
|
||||
te.mu.Unlock()
|
||||
te.wg.Wait()
|
||||
te.mu.Lock()
|
||||
te.vehicles = make(map[uint64]*TrafficVehicle)
|
||||
te.mu.Unlock()
|
||||
}
|
||||
|
||||
func (te *CityTrafficEngine) runLoop(ctx context.Context) {
|
||||
defer te.wg.Done()
|
||||
ticker := time.NewTicker(150 * time.Millisecond) // ~6.7 Hz downsampled traffic lane updates
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -151,6 +158,9 @@ func (te *CityTrafficEngine) tick(dt float32) {
|
||||
methodID := protocol.MethodGetMethodIDDefault("SyncAetherAIVehicleLaneDatas", 68582850)
|
||||
sessions := te.server.GetActiveSessions()
|
||||
for _, sess := range sessions {
|
||||
if !sess.ready.Load() || sess.hangup.Load() {
|
||||
continue
|
||||
}
|
||||
_ = sess.NotifyData(methodID, payload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,45 @@
|
||||
package gameserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"ananta-ps/pkg/protocol"
|
||||
)
|
||||
|
||||
func (s *Server) SetWorldWeather(weatherId uint32) {
|
||||
func (s *Server) SetWorldWeather(weatherId uint32) error {
|
||||
log.Printf("[WEATHER] Setting world weather to %d", weatherId)
|
||||
w := protocol.NewUxWriter()
|
||||
w.U32(weatherId)
|
||||
w.U32(weatherId).U32(weatherId).U32(0)
|
||||
payload := w.Bytes()
|
||||
|
||||
methodID := protocol.MethodGetMethodIDDefault("SyncTimeWeather", 68582910)
|
||||
methodID := protocol.MethodGetMethodIDDefault("SyncPlayerWeather", 68712141)
|
||||
for _, sess := range s.GetActiveSessions() {
|
||||
_ = sess.NotifyData(methodID, payload)
|
||||
if err := sess.NotifyData(methodID, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) SetWorldTimeOfDay(hour float32) {
|
||||
func (s *Server) SetWorldTimeOfDay(hour float32) error {
|
||||
if math.IsNaN(float64(hour)) || math.IsInf(float64(hour), 0) || hour < 0 || hour >= 24 {
|
||||
return fmt.Errorf("hour must be in [0,24)")
|
||||
}
|
||||
log.Printf("[WEATHER] Setting world time of day to %.1f:00", hour)
|
||||
w := protocol.NewUxWriter()
|
||||
w.F32(hour)
|
||||
w.U32(uint32(time.Now().Unix())).U32(uint32(hour * 3600)).Bool(true).Bool(false).U32(0).I32(0).I32(0)
|
||||
payload := w.Bytes()
|
||||
|
||||
methodID := protocol.MethodGetMethodIDDefault("SyncTimeOfDay", 68582911)
|
||||
methodID := protocol.MethodGetMethodIDDefault("SyncPlayerCurrentTime", 68337673)
|
||||
for _, sess := range s.GetActiveSessions() {
|
||||
_ = sess.NotifyData(methodID, payload)
|
||||
if err := sess.NotifyData(methodID, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleGmUnlockAllFogMap(sess *Session, pkt *protocol.RpcPacket) error {
|
||||
|
||||
+30
-69
@@ -60,80 +60,41 @@ func New(cfg *config.Config, publicDir, certDir string) *Gateway {
|
||||
}
|
||||
|
||||
func (g *Gateway) Start() error {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
g.cancelFunc = cancel
|
||||
|
||||
cert, err := tls.LoadX509KeyPair(filepath.Join(g.certDir, "cert.pem"), filepath.Join(g.certDir, "key.pem"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("load gateway TLS certificate: %w", err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", g.handleDispatch)
|
||||
|
||||
// 1. Port 5801: LoginList & Mobile Dispatch Server
|
||||
listAddr := fmt.Sprintf("%s:%d", g.cfg.Network.BindHost, g.cfg.Network.Proxy.LoginListPort)
|
||||
g.listSrv = &http.Server{
|
||||
Addr: listAddr,
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("[GATEWAY] LoginList & Mobile Dispatch listening on http://%s (Port %d)", g.cfg.Network.AdvertisedHost, g.cfg.Network.Proxy.LoginListPort)
|
||||
if err := g.listSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("[GATEWAY] LoginList server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// 2. Port 80: HTTP Gateway
|
||||
httpAddr := fmt.Sprintf("%s:%d", g.cfg.Network.BindHost, g.cfg.Network.Proxy.HttpPort)
|
||||
g.httpSrv = &http.Server{
|
||||
Addr: httpAddr,
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("[GATEWAY] HTTP Proxy listening on port %d", g.cfg.Network.Proxy.HttpPort)
|
||||
if err := g.httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("[GATEWAY] HTTP Proxy error (might need Admin privileges for port 80): %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// 3. Port 443: HTTPS Proxy with TLS SNI
|
||||
certFile := filepath.Join(g.certDir, "cert.pem")
|
||||
keyFile := filepath.Join(g.certDir, "key.pem")
|
||||
if _, err := os.Stat(certFile); err == nil {
|
||||
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err == nil {
|
||||
tlsConfig := &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
GetCertificate: func(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
return &cert, nil
|
||||
},
|
||||
addr := func(port int) string { return net.JoinHostPort(g.cfg.Network.BindHost, fmt.Sprint(port)) }
|
||||
g.listSrv = &http.Server{Addr: addr(g.cfg.Network.Proxy.LoginListPort), Handler: mux}
|
||||
g.httpSrv = &http.Server{Addr: addr(g.cfg.Network.Proxy.HttpPort), Handler: mux}
|
||||
g.httpsSrv = &http.Server{Addr: addr(g.cfg.Network.Proxy.HttpsPort), Handler: mux,
|
||||
TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}}
|
||||
servers := []*http.Server{g.listSrv, g.httpSrv, g.httpsSrv}
|
||||
listeners := make([]net.Listener, 0, len(servers))
|
||||
for _, srv := range servers {
|
||||
ln, err := net.Listen("tcp", srv.Addr)
|
||||
if err != nil {
|
||||
for _, opened := range listeners {
|
||||
opened.Close()
|
||||
}
|
||||
|
||||
httpsAddr := fmt.Sprintf("%s:%d", g.cfg.Network.BindHost, g.cfg.Network.Proxy.HttpsPort)
|
||||
g.httpsSrv = &http.Server{
|
||||
Addr: httpsAddr,
|
||||
Handler: mux,
|
||||
TLSConfig: tlsConfig,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("[GATEWAY] HTTPS SNI Proxy listening on port %d", g.cfg.Network.Proxy.HttpsPort)
|
||||
ln, err := net.Listen("tcp", httpsAddr)
|
||||
if err != nil {
|
||||
log.Printf("[GATEWAY] HTTPS Proxy listen error (port 443): %v", err)
|
||||
return
|
||||
}
|
||||
tlsLn := tls.NewListener(ln, tlsConfig)
|
||||
if err := g.httpsSrv.Serve(tlsLn); err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("[GATEWAY] HTTPS Proxy error: %v", err)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
log.Printf("[GATEWAY] Warning: failed to load TLS certificate %s: %v", certFile, err)
|
||||
return fmt.Errorf("listen gateway %s: %w", srv.Addr, err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[GATEWAY] Warning: cert.pem not found in %s; HTTPS port 443 disabled", g.certDir)
|
||||
listeners = append(listeners, ln)
|
||||
}
|
||||
for i, srv := range servers {
|
||||
ln := listeners[i]
|
||||
if srv == g.httpsSrv {
|
||||
ln = tls.NewListener(ln, srv.TLSConfig)
|
||||
}
|
||||
log.Printf("[GATEWAY] Listening on %s", ln.Addr())
|
||||
go func() {
|
||||
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("[GATEWAY] Serve error: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
_ = ctx
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -9,6 +11,41 @@ import (
|
||||
"ananta-ps/internal/config"
|
||||
)
|
||||
|
||||
func TestGatewayReportsBindFailureAndRollsBack(t *testing.T) {
|
||||
blocker, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer blocker.Close()
|
||||
probe, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listPort := probe.Addr().(*net.TCPAddr).Port
|
||||
probe.Close()
|
||||
cfg := &config.Config{Network: config.NetworkConfig{BindHost: "127.0.0.1", Proxy: config.ProxyConfig{
|
||||
LoginListPort: listPort, HttpPort: blocker.Addr().(*net.TCPAddr).Port,
|
||||
}}}
|
||||
gw := New(cfg, "../../public", "../../certs")
|
||||
if err := gw.Start(); err == nil {
|
||||
gw.Stop()
|
||||
t.Fatal("occupied HTTP port reported as ready")
|
||||
}
|
||||
ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", fmt.Sprint(listPort)))
|
||||
if err != nil {
|
||||
t.Fatalf("partial startup leaked login-list listener: %v", err)
|
||||
}
|
||||
ln.Close()
|
||||
}
|
||||
|
||||
func TestGatewayRequiresUsableTLSCertificate(t *testing.T) {
|
||||
gw := New(&config.Config{}, t.TempDir(), t.TempDir())
|
||||
if err := gw.Start(); err == nil {
|
||||
gw.Stop()
|
||||
t.Fatal("missing certificate silently disabled HTTPS")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayDispatch(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Client: config.ClientConfig{
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package logingate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"ananta-ps/internal/config"
|
||||
"ananta-ps/pkg/protocol"
|
||||
)
|
||||
|
||||
type recordingConn struct{ bytes.Buffer }
|
||||
|
||||
func (c *recordingConn) Close() error { return nil }
|
||||
func (c *recordingConn) LocalAddr() net.Addr { return &net.TCPAddr{} }
|
||||
func (c *recordingConn) RemoteAddr() net.Addr { return &net.TCPAddr{} }
|
||||
func (c *recordingConn) SetDeadline(time.Time) error { return nil }
|
||||
func (c *recordingConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (c *recordingConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
|
||||
func TestColdLoginRoleList(t *testing.T) {
|
||||
cfg := &config.Config{Login: config.LoginSettings{RoleListMode: "list"}, Player: config.PlayerConfig{Pid: 100}}
|
||||
srv := New(cfg)
|
||||
conn := &recordingConn{}
|
||||
sess := &Session{conn: conn}
|
||||
// Invoke ID zero is valid: packet kind, rather than ID, controls replies.
|
||||
if err := srv.dispatch(sess, &protocol.RpcPacket{Kind: protocol.PacketKindInvoke, MethodID: 34529582}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err := protocol.ReadFrame(conn, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pkt := protocol.ParseRpcPacket(f.Payload); pkt.Kind != protocol.PacketKindReturn || pkt.InvokeID != 0 {
|
||||
t.Fatalf("missing invoke response: %+v", pkt)
|
||||
}
|
||||
f, err = protocol.ReadFrame(conn, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pkt := protocol.ParseRpcPacket(f.Payload)
|
||||
if pkt.MethodID != 35167428 || !bytes.Equal(pkt.Body, []byte{0xff, 1}) {
|
||||
t.Fatalf("malformed role list: %+v", pkt)
|
||||
}
|
||||
if err := srv.dispatch(sess, &protocol.RpcPacket{Kind: protocol.PacketKindNotify, MethodID: 34700853}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if conn.Len() != 0 {
|
||||
t.Fatal("reply emitted for notify")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopClosesIdleHandshake(t *testing.T) {
|
||||
srv := New(&config.Config{Network: config.NetworkConfig{BindHost: "127.0.0.1", LoginPorts: []int{0}}})
|
||||
if err := srv.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn, err := net.DialTimeout("tcp", srv.listeners[0].Addr().String(), time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
srv.mu.Lock()
|
||||
n := len(srv.connections)
|
||||
srv.mu.Unlock()
|
||||
if n == 1 {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("connection not accepted")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() { srv.Stop(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Stop blocked")
|
||||
}
|
||||
conn.SetReadDeadline(time.Now().Add(time.Second))
|
||||
if _, err := conn.Read(make([]byte, 1)); err == nil {
|
||||
t.Fatal("idle socket survived Stop")
|
||||
}
|
||||
}
|
||||
+101
-22
@@ -7,6 +7,7 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"ananta-ps/internal/config"
|
||||
"ananta-ps/pkg/crypto/chacha8"
|
||||
@@ -14,15 +15,18 @@ import (
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
listeners []net.Listener
|
||||
cancelFunc context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
cfg *config.Config
|
||||
listeners []net.Listener
|
||||
cancelFunc context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
connections map[net.Conn]struct{}
|
||||
}
|
||||
|
||||
func New(cfg *config.Config) *Server {
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
cfg: cfg,
|
||||
connections: make(map[net.Conn]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +58,11 @@ func (s *Server) Stop() error {
|
||||
for _, ln := range s.listeners {
|
||||
_ = ln.Close()
|
||||
}
|
||||
s.mu.Lock()
|
||||
for conn := range s.connections {
|
||||
_ = conn.Close()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
s.wg.Wait()
|
||||
return nil
|
||||
}
|
||||
@@ -72,15 +81,30 @@ func (s *Server) acceptLoop(ctx context.Context, ln net.Listener, port int) {
|
||||
}
|
||||
}
|
||||
|
||||
go s.handleConnection(ctx, conn, port)
|
||||
s.mu.Lock()
|
||||
if ctx.Err() != nil {
|
||||
s.mu.Unlock()
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
s.connections[conn] = struct{}{}
|
||||
s.wg.Add(1)
|
||||
s.mu.Unlock()
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
defer func() { s.mu.Lock(); delete(s.connections, conn); s.mu.Unlock() }()
|
||||
s.handleConnection(ctx, conn, port)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
conn net.Conn
|
||||
readCipher *chacha8.UxChaCha8
|
||||
sendCipher *chacha8.UxChaCha8
|
||||
mu sync.Mutex
|
||||
replyKind protocol.RpcPacketKind
|
||||
writeTimeout time.Duration
|
||||
conn net.Conn
|
||||
readCipher *chacha8.UxChaCha8
|
||||
sendCipher *chacha8.UxChaCha8
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (sess *Session) SendPacket(packet *protocol.RpcPacket) error {
|
||||
@@ -97,10 +121,28 @@ func (sess *Session) SendPacket(packet *protocol.RpcPacket) error {
|
||||
payload = protocol.BuildNotify(packet.MethodID, packet.Body)
|
||||
}
|
||||
|
||||
return protocol.WriteFrame(sess.conn, 0x09, payload, sess.sendCipher)
|
||||
return sess.writeFrameLocked(0x09, payload)
|
||||
}
|
||||
|
||||
func (sess *Session) writeFrameLocked(mode byte, payload []byte) error {
|
||||
timeout := sess.writeTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 15 * time.Second
|
||||
}
|
||||
if err := sess.conn.SetWriteDeadline(time.Now().Add(timeout)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := protocol.WriteFrame(sess.conn, mode, payload, sess.sendCipher); err != nil {
|
||||
sess.conn.Close()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sess *Session) ReturnEmptyOk(invokeID int32, methodID uint32) error {
|
||||
if sess.replyKind != protocol.PacketKindInvoke {
|
||||
return nil
|
||||
}
|
||||
return sess.SendPacket(&protocol.RpcPacket{
|
||||
Kind: protocol.PacketKindReturn,
|
||||
InvokeID: invokeID,
|
||||
@@ -111,6 +153,9 @@ func (sess *Session) ReturnEmptyOk(invokeID int32, methodID uint32) error {
|
||||
}
|
||||
|
||||
func (sess *Session) ReturnEmpty(invokeID int32, methodID uint32, errCode int32) error {
|
||||
if sess.replyKind != protocol.PacketKindInvoke {
|
||||
return nil
|
||||
}
|
||||
return sess.SendPacket(&protocol.RpcPacket{
|
||||
Kind: protocol.PacketKindReturn,
|
||||
InvokeID: invokeID,
|
||||
@@ -121,6 +166,9 @@ func (sess *Session) ReturnEmpty(invokeID int32, methodID uint32, errCode int32)
|
||||
}
|
||||
|
||||
func (sess *Session) ReturnData(invokeID int32, methodID uint32, body []byte) error {
|
||||
if sess.replyKind != protocol.PacketKindInvoke {
|
||||
return nil
|
||||
}
|
||||
return sess.SendPacket(&protocol.RpcPacket{
|
||||
Kind: protocol.PacketKindReturn,
|
||||
InvokeID: invokeID,
|
||||
@@ -131,6 +179,9 @@ func (sess *Session) ReturnData(invokeID int32, methodID uint32, body []byte) er
|
||||
}
|
||||
|
||||
func (sess *Session) NotifyData(methodID uint32, body []byte) error {
|
||||
if methodID == 0 {
|
||||
return fmt.Errorf("unverified notification method")
|
||||
}
|
||||
return sess.SendPacket(&protocol.RpcPacket{
|
||||
Kind: protocol.PacketKindNotify,
|
||||
MethodID: methodID,
|
||||
@@ -150,9 +201,10 @@ func (s *Server) handleConnection(ctx context.Context, conn net.Conn, port int)
|
||||
}
|
||||
|
||||
sess := &Session{
|
||||
conn: conn,
|
||||
readCipher: readCipher,
|
||||
sendCipher: writeCipher,
|
||||
conn: conn,
|
||||
writeTimeout: time.Duration(s.cfg.Network.WriteTimeoutMs) * time.Millisecond,
|
||||
readCipher: readCipher,
|
||||
sendCipher: writeCipher,
|
||||
}
|
||||
|
||||
for {
|
||||
@@ -173,10 +225,18 @@ func (s *Server) handleConnection(ctx context.Context, conn net.Conn, port int)
|
||||
// Frame mode 0x03 is heartbeat
|
||||
if frame.Mode == 0x03 {
|
||||
// Echo heartbeat back
|
||||
_ = protocol.WriteFrame(conn, 0x03, frame.Payload, sess.sendCipher)
|
||||
sess.mu.Lock()
|
||||
err = sess.writeFrameLocked(0x03, frame.Payload)
|
||||
sess.mu.Unlock()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if frame.Mode != 0x09 {
|
||||
continue
|
||||
}
|
||||
packet := protocol.ParseRpcPacket(frame.Payload)
|
||||
if err := s.dispatch(sess, &packet); err != nil {
|
||||
log.Printf("[LOGIN-GATE] Dispatch error for method %d: %v", packet.MethodID, err)
|
||||
@@ -185,6 +245,11 @@ func (s *Server) handleConnection(ctx context.Context, conn net.Conn, port int)
|
||||
}
|
||||
|
||||
func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
|
||||
if pkt.Kind != protocol.PacketKindInvoke && pkt.Kind != protocol.PacketKindNotify {
|
||||
return nil
|
||||
}
|
||||
sess.replyKind = pkt.Kind
|
||||
defer func() { sess.replyKind = protocol.PacketKindUnknown }()
|
||||
switch pkt.MethodID {
|
||||
case protocol.MethodCheckVersion, protocol.MethodHasOnlinePlayer:
|
||||
return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID)
|
||||
@@ -213,6 +278,7 @@ func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
|
||||
s.cfg.Player.AccountId,
|
||||
s.cfg.Player.UserName,
|
||||
s.cfg.Player.LoginToken,
|
||||
s.cfg.Player.DisplayName,
|
||||
s.cfg.Client.Aid,
|
||||
s.cfg.Client.ServerID,
|
||||
s.cfg.Player.Pid,
|
||||
@@ -223,10 +289,7 @@ func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
|
||||
if err := sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID); err != nil {
|
||||
return err
|
||||
}
|
||||
// Send SyncRoleList (35167428)
|
||||
w := protocol.NewUxWriter()
|
||||
w.U64(s.cfg.Player.Pid)
|
||||
return sess.NotifyData(protocol.MethodSyncRoleList, w.Bytes())
|
||||
return s.syncRoleList(sess)
|
||||
|
||||
case protocol.MethodRequestCreateRoleEx:
|
||||
w := protocol.NewUxWriter()
|
||||
@@ -234,7 +297,7 @@ func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
|
||||
if err := sess.ReturnData(pkt.InvokeID, pkt.MethodID, w.Bytes()); err != nil {
|
||||
return err
|
||||
}
|
||||
return sess.NotifyData(protocol.MethodSyncRoleList, w.Bytes())
|
||||
return s.syncRoleList(sess)
|
||||
|
||||
case protocol.MethodRequestEnterGame, protocol.MethodDebugRequestEnterGame:
|
||||
body := protocol.EncodeEnterGame(
|
||||
@@ -294,10 +357,26 @@ func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
|
||||
|
||||
default:
|
||||
if pkt.Kind == protocol.PacketKindInvoke {
|
||||
log.Printf("[LOGIN-GATE] Unhandled Invoke MethodID %d, returning empty OK", pkt.MethodID)
|
||||
return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID)
|
||||
log.Printf("[LOGIN-GATE] Unhandled Invoke MethodID %d, using contract fallback", pkt.MethodID)
|
||||
if body, ok := protocol.DefaultReturnBody(pkt.MethodID); ok {
|
||||
return sess.ReturnData(pkt.InvokeID, pkt.MethodID, body)
|
||||
}
|
||||
return sess.SendPacket(&protocol.RpcPacket{Kind: protocol.PacketKindReturn, InvokeID: pkt.InvokeID, MethodID: pkt.MethodID, Error: -1})
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Match the original server: unsolicited role lists are opt-in because the legacy pid
|
||||
// payload is not a list and can stall a cold login.
|
||||
func (s *Server) syncRoleList(sess *Session) error {
|
||||
switch s.cfg.Login.RoleListMode {
|
||||
case "pid":
|
||||
return sess.NotifyData(protocol.MethodSyncRoleList, protocol.NewUxWriter().U64(s.cfg.Player.Pid).Bytes())
|
||||
case "list":
|
||||
return sess.NotifyData(protocol.MethodSyncRoleList, protocol.EncodeEmptyList())
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
+12
-2
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -43,15 +44,24 @@ func (a *AdminServer) Start() error {
|
||||
mux.HandleFunc("/api/command", a.handleCommand)
|
||||
mux.HandleFunc("/api/sessions", a.handleSessions)
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", a.cfg.Network.BindHost, a.cfg.Admin.Port)
|
||||
host := a.cfg.Admin.Host
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
addr := net.JoinHostPort(host, fmt.Sprint(a.cfg.Admin.Port))
|
||||
a.httpSrv = &http.Server{
|
||||
Addr: addr,
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
log.Printf("[ADMIN-GM] Admin Web Panel running at http://%s:%d/", a.cfg.Network.AdvertisedHost, a.cfg.Admin.Port)
|
||||
if err := a.httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
if err := a.httpSrv.Serve(ln); err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("[ADMIN-GM] Web server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -40,9 +41,14 @@ func (d *DebugServer) Start() error {
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
log.Printf("[DEBUG-API] Debug WebUI panel at http://%s:5809/", d.cfg.Network.AdvertisedHost)
|
||||
if err := d.httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
if err := d.httpSrv.Serve(ln); err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("[DEBUG-API] Debug server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"ananta-ps/internal/config"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAdminHonorsConfiguredHost(t *testing.T) {
|
||||
cfg := &config.Config{Network: config.NetworkConfig{BindHost: "0.0.0.0"}, Admin: config.AdminConfig{Enabled: true, Host: "127.0.0.1"}}
|
||||
a := NewAdmin(cfg, nil)
|
||||
if err := a.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer a.Stop()
|
||||
if a.httpSrv.Addr != "127.0.0.1:0" {
|
||||
t.Fatalf("admin bind ignored: %s", a.httpSrv.Addr)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user