feat(go): Port full Ananta private server to Go architecture
This commit is contained in:
1 parent
ca2e80f9b7
commit
c73d744649
28 files changed
+3585
-57
No files matched your search
@@ -0,0 +1,397 @@
|
||||
package gameserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"ananta-ps/internal/config"
|
||||
"ananta-ps/pkg/crypto/chacha8"
|
||||
"ananta-ps/pkg/protocol"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
templatePath string
|
||||
ln net.Listener
|
||||
cancelFunc context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
sessions map[string]*Session
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, templatePath string) *Server {
|
||||
if templatePath == "" {
|
||||
candidates := []string{
|
||||
"data/player_info_template.bin",
|
||||
"../data/player_info_template.bin",
|
||||
}
|
||||
for _, c := range candidates {
|
||||
abs, _ := filepath.Abs(c)
|
||||
templatePath = abs
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
templatePath: templatePath,
|
||||
sessions: make(map[string]*Session),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Start() error {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s.cancelFunc = cancel
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", s.cfg.Network.BindHost, s.cfg.Network.GamePort)
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to listen on game server %s: %w", addr, err)
|
||||
}
|
||||
s.ln = ln
|
||||
log.Printf("[GAME-SERVER] Listening on %s:%d", s.cfg.Network.AdvertisedHost, s.cfg.Network.GamePort)
|
||||
|
||||
s.wg.Add(1)
|
||||
go s.acceptLoop(ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) Stop() error {
|
||||
if s.cancelFunc != nil {
|
||||
s.cancelFunc()
|
||||
}
|
||||
if s.ln != nil {
|
||||
_ = s.ln.Close()
|
||||
}
|
||||
s.wg.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) GetActiveSessions() []*Session {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
list := make([]*Session, 0, len(s.sessions))
|
||||
for _, sess := range s.sessions {
|
||||
list = append(list, sess)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func (s *Server) acceptLoop(ctx context.Context) {
|
||||
defer s.wg.Done()
|
||||
for {
|
||||
conn, err := s.ln.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
log.Printf("[GAME-SERVER] Accept error: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
go s.handleConnection(ctx, conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (sess *Session) SendPacket(packet *protocol.RpcPacket) error {
|
||||
sess.sendMu.Lock()
|
||||
defer sess.sendMu.Unlock()
|
||||
|
||||
var payload []byte
|
||||
switch packet.Kind {
|
||||
case protocol.PacketKindInvoke:
|
||||
payload = protocol.BuildInvoke(packet.MethodID, packet.InvokeID, packet.Body)
|
||||
case protocol.PacketKindReturn:
|
||||
payload = protocol.BuildReturn(packet.MethodID, packet.InvokeID, packet.Error, packet.Body)
|
||||
case protocol.PacketKindNotify:
|
||||
payload = protocol.BuildNotify(packet.MethodID, packet.Body)
|
||||
}
|
||||
|
||||
return protocol.WriteFrame(sess.conn, 0x09, payload, sess.sendCipher)
|
||||
}
|
||||
|
||||
func (sess *Session) ReturnEmptyOk(invokeID int32, methodID uint32) error {
|
||||
return sess.SendPacket(&protocol.RpcPacket{
|
||||
Kind: protocol.PacketKindReturn,
|
||||
InvokeID: invokeID,
|
||||
MethodID: methodID,
|
||||
Error: 0,
|
||||
Body: nil,
|
||||
})
|
||||
}
|
||||
|
||||
func (sess *Session) ReturnData(invokeID int32, methodID uint32, body []byte) error {
|
||||
return sess.SendPacket(&protocol.RpcPacket{
|
||||
Kind: protocol.PacketKindReturn,
|
||||
InvokeID: invokeID,
|
||||
MethodID: methodID,
|
||||
Error: 0,
|
||||
Body: body,
|
||||
})
|
||||
}
|
||||
|
||||
func (sess *Session) NotifyData(methodID uint32, body []byte) error {
|
||||
return sess.SendPacket(&protocol.RpcPacket{
|
||||
Kind: protocol.PacketKindNotify,
|
||||
MethodID: methodID,
|
||||
Body: body,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleConnection(ctx context.Context, conn net.Conn) {
|
||||
defer conn.Close()
|
||||
|
||||
log.Printf("[GAME-SERVER] Client connected from %s", conn.RemoteAddr())
|
||||
|
||||
readCipher, writeCipher, err := protocol.HandleHandshake(conn)
|
||||
if err != nil {
|
||||
log.Printf("[GAME-SERVER] Handshake error from %s: %v", conn.RemoteAddr(), err)
|
||||
return
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.sessions[sess.id] = sess
|
||||
s.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
delete(s.sessions, sess.id)
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
frame, err := protocol.ReadFrame(conn, sess.readCipher)
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
log.Printf("[GAME-SERVER] Connection %s read error: %v", conn.RemoteAddr(), err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Frame mode 0x03 is heartbeat
|
||||
if frame.Mode == 0x03 {
|
||||
_ = protocol.WriteFrame(conn, 0x03, frame.Payload, sess.sendCipher)
|
||||
continue
|
||||
}
|
||||
|
||||
packet := protocol.ParseRpcPacket(frame.Payload)
|
||||
if err := s.dispatch(sess, &packet); err != nil {
|
||||
log.Printf("[GAME-SERVER] Dispatch error for method %d: %v", packet.MethodID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
|
||||
switch pkt.MethodID {
|
||||
case protocol.MethodLoginGame:
|
||||
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
|
||||
}
|
||||
|
||||
// 1. SendServerTimeGame
|
||||
_ = sess.NotifyData(protocol.MethodSendServerTimeGame, protocol.EncodeServerTime(0))
|
||||
|
||||
// 2. SyncPlayerInfo (from template binary)
|
||||
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)
|
||||
} else {
|
||||
log.Printf("[WORLD] Warning: player_info_template.bin load error: %v", err)
|
||||
}
|
||||
|
||||
// 3. SyncEnterScene
|
||||
enterScenePayload := protocol.EncodeEnterScene(
|
||||
sess.pid,
|
||||
sess.activeRaid,
|
||||
sess.activeScene,
|
||||
sess.universeId,
|
||||
sess.unitId,
|
||||
sess.templateId,
|
||||
sess.posX,
|
||||
sess.posY,
|
||||
sess.posZ,
|
||||
sess.facing,
|
||||
)
|
||||
log.Printf("[WORLD-V7] Sending SyncEnterScene raid=%d instance=%d universe=%d unit=%d template=%d pos=(%.2f,%.2f,%.2f)",
|
||||
sess.activeRaid, sess.activeScene, sess.universeId, sess.unitId, sess.templateId, sess.posX, sess.posY, sess.posZ)
|
||||
return sess.NotifyData(protocol.MethodSyncEnterScene, enterScenePayload)
|
||||
|
||||
case protocol.MethodAskLoadSceneCompleted:
|
||||
log.Printf("[WORLD-V7] AskLoadSceneCompleted ack received, committing V7 4-step handoff")
|
||||
if err := sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// V7 4-step Handoff:
|
||||
// Step 1: SyncLogicAgentEnter
|
||||
_ = sess.NotifyData(protocol.MethodSyncLogicAgentEnter, protocol.EncodeLogicAgentEnter(sess.unitId))
|
||||
|
||||
// Step 2: SyncManagedLogicAgent
|
||||
_ = sess.NotifyData(protocol.MethodSyncManagedLogicAgent, protocol.EncodeManagedLogicAgent(sess.unitId, sess.pid, 0))
|
||||
|
||||
// Step 3: SyncRaidBattleUnitSpirit
|
||||
unitProjection := protocol.EncodeRaidBattleUnitSpirit(sess.unitId, sess.templateId, sess.posX, sess.posY, sess.posZ, sess.facing, sess.pid)
|
||||
_ = sess.NotifyData(protocol.MethodSyncRaidBattleUnitSpirit, unitProjection)
|
||||
|
||||
// Step 4: SyncPlayerCurrentSpirit
|
||||
return sess.NotifyData(protocol.MethodSyncPlayerCurrentSpirit, protocol.EncodePlayerCurrentSpirit(sess.pid, sess.templateId, sess.unitId, false))
|
||||
|
||||
case protocol.MethodAskLoadingFinished:
|
||||
log.Printf("[WORLD-V7] AskLoadingFinished received, scene ready")
|
||||
if err := sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 1. SyncSceneLoadCompleted
|
||||
_ = sess.NotifyData(protocol.MethodSyncSceneLoadCompleted, protocol.EncodeSceneLoadCompleted(sess.activeScene))
|
||||
|
||||
// 2. SyncGamePause (false)
|
||||
wPause := protocol.NewUxWriter()
|
||||
wPause.Bool(false)
|
||||
_ = sess.NotifyData(protocol.MethodSyncGamePause, wPause.Bytes())
|
||||
|
||||
// 3. Unlock Map Fog
|
||||
wFog := protocol.NewUxWriter()
|
||||
wFog.U32(uint32(sess.activeScene)).Bool(true)
|
||||
_ = sess.NotifyData(protocol.MethodSyncSceneFogMapAllUnlock, wFog.Bytes())
|
||||
|
||||
// 4. Combat power
|
||||
wPower := protocol.NewUxWriter()
|
||||
wPower.U8(0xFF).Int7(1).U32(sess.templateId).F32(1000.0)
|
||||
_ = sess.NotifyData(protocol.MethodSyncAllSpiritCombatPower, wPower.Bytes())
|
||||
|
||||
// 5. Unlock Diving
|
||||
wDive := protocol.NewUxWriter()
|
||||
wDive.UxString("EnableDive").U8(0xFF).Int7(1).UxString("true")
|
||||
return sess.NotifyData(protocol.MethodSyncRunGmUtils, wDive.Bytes())
|
||||
|
||||
case protocol.MethodAskLoadedInSameScene:
|
||||
w := protocol.NewUxWriter()
|
||||
w.U64(sess.pid).F64(1.0)
|
||||
return sess.NotifyData(protocol.MethodSyncPlayerLoadRate, w.Bytes())
|
||||
|
||||
case protocol.MethodAskMoveStart, protocol.MethodAskMoveStop:
|
||||
return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID)
|
||||
|
||||
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)
|
||||
|
||||
case protocol.MethodAskPreSwitchSpirit:
|
||||
return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID)
|
||||
|
||||
case protocol.MethodAskSwitchSpirit:
|
||||
// Clean DRMK character swap
|
||||
r := protocol.NewUxReader(pkt.Body)
|
||||
newTemplateId := sess.templateId
|
||||
if tid, err := r.U32(); err == nil {
|
||||
newTemplateId = tid
|
||||
}
|
||||
newUnitId := sess.unitId + 1
|
||||
|
||||
log.Printf("[SWITCH] Switching character %d -> %d", sess.templateId, newTemplateId)
|
||||
if err := sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID); err != nil {
|
||||
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))
|
||||
|
||||
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))
|
||||
|
||||
wInSitu := protocol.NewUxWriter()
|
||||
wInSitu.U32(newTemplateId).U64(newUnitId)
|
||||
_ = sess.NotifyData(protocol.MethodSyncSwitchSpiritInSitu, wInSitu.Bytes())
|
||||
|
||||
sess.templateId = newTemplateId
|
||||
sess.unitId = newUnitId
|
||||
return nil
|
||||
|
||||
case protocol.MethodAskSummonVehicle, protocol.MethodAskDriveVehicle, protocol.MethodAskExitVehicle:
|
||||
return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID)
|
||||
|
||||
case protocol.MethodAskPlayerAttack, protocol.MethodAskPlayerCastSkill, protocol.MethodAskUnlockFightStyle:
|
||||
return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID)
|
||||
|
||||
default:
|
||||
if pkt.Kind == protocol.PacketKindInvoke {
|
||||
return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Teleport moves an active session to the specified coordinates.
|
||||
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
|
||||
return sess.NotifyData(protocol.MethodSyncUnitPositionAndFacing, w.Bytes())
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package gameserver
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"ananta-ps/internal/config"
|
||||
"ananta-ps/pkg/crypto/chacha8"
|
||||
"ananta-ps/pkg/protocol"
|
||||
)
|
||||
|
||||
func TestGameServerWorldEntry(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Client: config.ClientConfig{
|
||||
ServerID: 801,
|
||||
Aid: 5944,
|
||||
},
|
||||
Network: config.NetworkConfig{
|
||||
BindHost: "127.0.0.1",
|
||||
AdvertisedHost: "127.0.0.1",
|
||||
GamePort: 15203,
|
||||
},
|
||||
Player: config.PlayerConfig{
|
||||
Pid: 100,
|
||||
InitialUnitId: 100000000000,
|
||||
InitialSpiritTemplateId: 15020967,
|
||||
AccountId: "test_acc",
|
||||
DisplayName: "test_player",
|
||||
},
|
||||
World: config.WorldConfig{
|
||||
RaidId: 23300888,
|
||||
SceneInstanceId: 20001222,
|
||||
UniverseId: 76000888,
|
||||
Spawn: config.Vec3{X: 100, Y: 0, Z: 200},
|
||||
Facing: 0,
|
||||
},
|
||||
}
|
||||
|
||||
srv := New(cfg, "../../data/player_info_template.bin")
|
||||
if err := srv.Start(); err != nil {
|
||||
t.Fatalf("failed to start game server: %v", err)
|
||||
}
|
||||
defer srv.Stop()
|
||||
|
||||
// Connect client
|
||||
conn, err := net.DialTimeout("tcp", "127.0.0.1:15203", 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// 1. Client handshake
|
||||
clientHandshake := make([]byte, 260)
|
||||
aesKey := []byte("0123456789abcdef")
|
||||
copy(clientHandshake[4:20], aesKey)
|
||||
if err := protocol.WriteFrame(conn, 0x01, clientHandshake, nil); err != nil {
|
||||
t.Fatalf("failed to write handshake: %v", err)
|
||||
}
|
||||
|
||||
// 2. Server reply
|
||||
replyFrame, err := protocol.ReadFrame(conn, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read reply: %v", err)
|
||||
}
|
||||
|
||||
aesNonce := replyFrame.Payload[8:20]
|
||||
encryptedHead := replyFrame.Payload[20:96]
|
||||
tag := replyFrame.Payload[96:112]
|
||||
|
||||
cipherBlock, err := aes.NewCipher(aesKey)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create aes cipher: %v", err)
|
||||
}
|
||||
aesGcm, err := cipher.NewGCM(cipherBlock)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create aes gcm: %v", err)
|
||||
}
|
||||
|
||||
fullCipher := append(encryptedHead, tag...)
|
||||
chaChaHead, err := aesGcm.Open(nil, aesNonce, fullCipher, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decrypt chacha head: %v", err)
|
||||
}
|
||||
|
||||
clientSendCipher, _ := chacha8.New(chaChaHead[0:32], chaChaHead[64:76])
|
||||
clientReadCipher, _ := chacha8.New(chaChaHead[32:64], chaChaHead[64:76])
|
||||
|
||||
// 3. Send LoginGame Invoke
|
||||
invokePayload := protocol.BuildInvoke(protocol.MethodLoginGame, 1, nil)
|
||||
if err := protocol.WriteFrame(conn, 0x09, invokePayload, clientSendCipher); err != nil {
|
||||
t.Fatalf("failed to write LoginGame: %v", err)
|
||||
}
|
||||
|
||||
// 4. Read Return frame
|
||||
retFrame, err := protocol.ReadFrame(conn, clientReadCipher)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read return: %v", err)
|
||||
}
|
||||
pkt := protocol.ParseRpcPacket(retFrame.Payload)
|
||||
if pkt.Kind != protocol.PacketKindReturn || pkt.InvokeID != 1 {
|
||||
t.Fatalf("expected return for invoke 1, got %v", pkt)
|
||||
}
|
||||
|
||||
// 5. Read S2C notifications (ServerTime, SyncPlayerInfo, SyncEnterScene)
|
||||
foundEnterScene := false
|
||||
for i := 0; i < 3; i++ {
|
||||
notifyFrame, err := protocol.ReadFrame(conn, clientReadCipher)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read notify frame: %v", err)
|
||||
}
|
||||
nPkt := protocol.ParseRpcPacket(notifyFrame.Payload)
|
||||
if nPkt.MethodID == protocol.MethodSyncEnterScene {
|
||||
foundEnterScene = true
|
||||
}
|
||||
}
|
||||
|
||||
if !foundEnterScene {
|
||||
t.Fatalf("expected SyncEnterScene in notifications")
|
||||
}
|
||||
|
||||
// Verify session tracking
|
||||
sessions := srv.GetActiveSessions()
|
||||
if len(sessions) != 1 {
|
||||
t.Fatalf("expected 1 active session, got %d", len(sessions))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user