2026-10-07 00:21:41 +07:00
|
|
|
package gameserver
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/binary"
|
2026-11-04 15:59:48 +07:00
|
|
|
"strings"
|
2026-10-07 00:21:41 +07:00
|
|
|
"sync"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"google.golang.org/protobuf/proto"
|
|
|
|
|
|
|
|
|
|
kcplib "gac-hkrpg-go/pkg/kcp"
|
|
|
|
|
goproto "gac-hkrpg-go/protocol/proto"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// TestGatewayHandshakeAndLogin simulates a game client: NetOperation
|
|
|
|
|
// handshake -> KCP session -> PlayerGetTokenCsReq -> expect ScRsp back.
|
|
|
|
|
func TestGatewayHandshakeAndLogin(t *testing.T) {
|
|
|
|
|
gw, err := NewGateway("127.0.0.1", 0)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
go func() {
|
|
|
|
|
_ = gw.Listen()
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
client, err := newTestClient(t, gw.conn.LocalAddr().String())
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 1. handshake
|
|
|
|
|
conv, token, err := client.handshake()
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("handshake failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
t.Logf("handshake ok: conv=%d token=%d", conv, token)
|
|
|
|
|
|
2026-11-04 15:59:48 +07:00
|
|
|
// 2. Verify the gateway loaded the player data itself.
|
2026-10-07 00:21:41 +07:00
|
|
|
serverSession := gw.GetSession(conv)
|
|
|
|
|
if serverSession == nil {
|
|
|
|
|
t.Fatal("gateway did not register the session")
|
|
|
|
|
}
|
2026-11-04 15:59:48 +07:00
|
|
|
if serverSession.JSON() == nil {
|
|
|
|
|
t.Fatal("gateway session has no player data")
|
|
|
|
|
}
|
2026-10-07 00:21:41 +07:00
|
|
|
|
|
|
|
|
// 3. send PlayerGetTokenCsReq (cmd 5)
|
|
|
|
|
reqID := uint16(goproto.CmdIDByName["PlayerGetTokenCsReq"])
|
|
|
|
|
rspID := uint16(goproto.CmdIDByName["PlayerGetTokenScRsp"])
|
2026-11-04 15:59:48 +07:00
|
|
|
// Synthetic fields exercise non-empty requests without storing account data.
|
|
|
|
|
tokenRequest := &goproto.PlayerGetTokenCsReq{AccountUid: "52022", Token: strings.Repeat("x", 32), Uid: playerUID, Platform: 3}
|
|
|
|
|
tokenBody, err := proto.Marshal(tokenRequest)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if err := client.sendCmd(reqID, tokenBody); err != nil {
|
2026-10-07 00:21:41 +07:00
|
|
|
t.Fatalf("send cmd: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cmdID, body, err := client.recvCmd(3 * time.Second)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("no response: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cmdID != rspID {
|
|
|
|
|
t.Fatalf("unexpected cmd id: got %d want %d", cmdID, rspID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rsp := &goproto.PlayerGetTokenScRsp{}
|
|
|
|
|
if err := proto.Unmarshal(body, rsp); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if rsp.Retcode != 0 || rsp.Uid != playerUID {
|
|
|
|
|
t.Fatalf("bad token rsp: retcode=%d uid=%d", rsp.Retcode, rsp.Uid)
|
|
|
|
|
}
|
|
|
|
|
t.Logf("login token ok: retcode=%d uid=%d msg=%q", rsp.Retcode, rsp.Uid, rsp.Msg)
|
|
|
|
|
|
2026-11-04 15:59:48 +07:00
|
|
|
// 4. Login must also round-trip through the same UDP/KCP session.
|
|
|
|
|
loginReq := &goproto.PlayerLoginCsReq{LoginRandom: 123456, Signature: strings.Repeat("x", 768)}
|
|
|
|
|
loginBody, err := proto.Marshal(loginReq)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if err := client.sendCmd(uint16(goproto.CmdIDByName["PlayerLoginCsReq"]), loginBody); err != nil {
|
|
|
|
|
t.Fatalf("send player login: %v", err)
|
|
|
|
|
}
|
|
|
|
|
cmdID, body, err = client.recvCmd(3 * time.Second)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("no player login response: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if want := uint16(goproto.CmdIDByName["PlayerLoginScRsp"]); cmdID != want {
|
|
|
|
|
t.Fatalf("login response command: got %d want %d", cmdID, want)
|
|
|
|
|
}
|
|
|
|
|
loginRsp := &goproto.PlayerLoginScRsp{}
|
|
|
|
|
if err := proto.Unmarshal(body, loginRsp); err != nil {
|
|
|
|
|
t.Fatalf("decode login response: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if loginRsp.Retcode != 0 || loginRsp.LoginRandom != loginReq.LoginRandom || loginRsp.BasicInfo == nil {
|
|
|
|
|
t.Fatalf("invalid player login response: %v", loginRsp)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The Android client asks for level rewards before login finish.
|
|
|
|
|
if err := client.sendCmd(uint16(goproto.CmdIDByName["GetLevelRewardTakenListCsReq"]), nil); err != nil {
|
|
|
|
|
t.Fatalf("send level rewards request: %v", err)
|
|
|
|
|
}
|
|
|
|
|
cmdID, body, err = client.recvCmd(3 * time.Second)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("no level rewards response: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if want := uint16(goproto.CmdIDByName["GetLevelRewardTakenListScRsp"]); cmdID != want {
|
|
|
|
|
t.Fatalf("level rewards response command: got %d want %d", cmdID, want)
|
|
|
|
|
}
|
|
|
|
|
levelRsp := &goproto.GetLevelRewardTakenListScRsp{}
|
|
|
|
|
if err := proto.Unmarshal(body, levelRsp); err != nil || levelRsp.Retcode != 0 {
|
|
|
|
|
t.Fatalf("invalid level rewards response: %v / %v", levelRsp, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := client.sendCmd(uint16(goproto.CmdIDByName["PlayerLoginFinishCsReq"]), nil); err != nil {
|
|
|
|
|
t.Fatalf("send login finish: %v", err)
|
|
|
|
|
}
|
|
|
|
|
seenFinish := false
|
|
|
|
|
for i := 0; i < 3 && !seenFinish; i++ {
|
|
|
|
|
cmdID, body, err = client.recvCmd(3 * time.Second)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("no login finish response: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cmdID == uint16(goproto.CmdIDByName["PlayerLoginFinishScRsp"]) {
|
|
|
|
|
finishRsp := &goproto.PlayerLoginFinishScRsp{}
|
|
|
|
|
if err := proto.Unmarshal(body, finishRsp); err != nil || finishRsp.Retcode != 0 {
|
|
|
|
|
t.Fatalf("invalid login finish response: %v / %v", finishRsp, err)
|
|
|
|
|
}
|
|
|
|
|
seenFinish = true
|
|
|
|
|
} else if cmdID != uint16(goproto.CmdIDByName["ContentPackageSyncDataScNotify"]) {
|
|
|
|
|
t.Fatalf("unexpected login finish packet: %d", cmdID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !seenFinish {
|
|
|
|
|
t.Fatal("login finish response missing")
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-07 00:21:41 +07:00
|
|
|
// 4. exercise the SceneCastSkill fix: attack with no real monsters ->
|
|
|
|
|
// must NOT enter battle (no battle_info in the response)
|
|
|
|
|
skillReqID := uint16(goproto.CmdIDByName["SceneCastSkillCsReq"])
|
|
|
|
|
skillRspID := uint16(goproto.CmdIDByName["SceneCastSkillScRsp"])
|
|
|
|
|
skillReq := &goproto.SceneCastSkillCsReq{
|
|
|
|
|
CastEntityId: 1,
|
|
|
|
|
CasterId: 1,
|
|
|
|
|
SkillIndex: 0,
|
|
|
|
|
HitTargetEntityIdList: []uint32{1005, 20010, 3}, // prop / npc / own avatar: none are monsters
|
|
|
|
|
}
|
|
|
|
|
reqBody, _ := proto.Marshal(skillReq)
|
|
|
|
|
if err := client.sendCmd(skillReqID, reqBody); err != nil {
|
|
|
|
|
t.Fatalf("send cast skill: %v", err)
|
|
|
|
|
}
|
|
|
|
|
cmdID, body, err = client.recvCmd(3 * time.Second)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("no cast skill response: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cmdID != skillRspID {
|
|
|
|
|
t.Fatalf("unexpected cast skill cmd id: got %d want %d", cmdID, skillRspID)
|
|
|
|
|
}
|
|
|
|
|
skillRsp := &goproto.SceneCastSkillScRsp{}
|
|
|
|
|
if err := proto.Unmarshal(body, skillRsp); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal cast skill rsp: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if skillRsp.BattleInfo != nil || len(skillRsp.MonsterBattleInfo) != 0 {
|
|
|
|
|
t.Fatalf("camera bug regression: server entered battle on non-monster targets")
|
|
|
|
|
}
|
|
|
|
|
if skillRsp.CastEntityId != 1 || skillRsp.Retcode != 0 {
|
|
|
|
|
t.Fatalf("bad cast skill rsp: %+v", skillRsp)
|
|
|
|
|
}
|
|
|
|
|
t.Log("SceneCastSkill fix verified: no battle triggered for non-monster targets")
|
|
|
|
|
|
|
|
|
|
// 5. attacking a real scene monster DOES start a battle
|
2026-10-31 20:47:36 +07:00
|
|
|
serverSession.MonsterEntities[30001] = 101201
|
2026-10-07 00:21:41 +07:00
|
|
|
skillReq.HitTargetEntityIdList = []uint32{30001}
|
|
|
|
|
reqBody, _ = proto.Marshal(skillReq)
|
|
|
|
|
if err := client.sendCmd(skillReqID, reqBody); err != nil {
|
|
|
|
|
t.Fatalf("send cast skill 2: %v", err)
|
|
|
|
|
}
|
|
|
|
|
cmdID, body, err = client.recvCmd(3 * time.Second)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("no response for monster attack: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cmdID != skillRspID {
|
|
|
|
|
t.Fatalf("unexpected cmd: got %d want %d", cmdID, skillRspID)
|
|
|
|
|
}
|
|
|
|
|
skillRsp = &goproto.SceneCastSkillScRsp{}
|
|
|
|
|
if err := proto.Unmarshal(body, skillRsp); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if skillRsp.BattleInfo == nil || len(skillRsp.MonsterBattleInfo) == 0 {
|
|
|
|
|
t.Fatalf("expected battle to start for a real monster target")
|
|
|
|
|
}
|
|
|
|
|
t.Log("SceneCastSkill verified: battle starts for real monster target")
|
2026-10-07 01:19:56 +07:00
|
|
|
|
|
|
|
|
// 6. GetUnreleasedBlockInfo (4190): Endgame entry gates on this reply
|
|
|
|
|
if err := client.sendCmd(4190, nil); err != nil {
|
|
|
|
|
t.Fatalf("send GetUnreleasedBlockInfo: %v", err)
|
|
|
|
|
}
|
|
|
|
|
cmdID, body, err = client.recvCmd(3 * time.Second)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("no GetUnreleasedBlockInfo response: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cmdID != 4112 {
|
|
|
|
|
t.Fatalf("unexpected GetUnreleasedBlockInfoScRsp id: got %d want 4112", cmdID)
|
|
|
|
|
}
|
2026-10-17 01:05:58 +07:00
|
|
|
blockRsp := &goproto.GetUnreleasedBlockInfoScRsp{}
|
|
|
|
|
if err := proto.Unmarshal(body, blockRsp); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal GetUnreleasedBlockInfo: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if blockRsp.Retcode != 0 {
|
|
|
|
|
t.Fatalf("unexpected UnreleasedBlockInfo retcode: %d", blockRsp.Retcode)
|
2026-10-07 01:19:56 +07:00
|
|
|
}
|
|
|
|
|
t.Log("GetUnreleasedBlockInfo verified: 4190 -> 4112 retcode 0")
|
|
|
|
|
|
|
|
|
|
// 7. SetClientPaused must be acked (pause menu waits for it)
|
|
|
|
|
pauseReq, _ := proto.Marshal(&goproto.SetClientPausedCsReq{Paused: true})
|
|
|
|
|
if err := client.sendCmd(1466, pauseReq); err != nil {
|
|
|
|
|
t.Fatalf("send SetClientPaused: %v", err)
|
|
|
|
|
}
|
|
|
|
|
cmdID, body, err = client.recvCmd(3 * time.Second)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("no SetClientPaused response: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cmdID != 1490 { // SetClientPausedScRsp
|
|
|
|
|
t.Fatalf("unexpected SetClientPausedScRsp id: got %d want 1490", cmdID)
|
|
|
|
|
}
|
|
|
|
|
t.Log("SetClientPaused verified: acked")
|
2026-10-07 00:21:41 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// minimal KCP test client
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
type testClient struct {
|
|
|
|
|
conn *testUDPConn
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
kcp *kcplib.KCP
|
|
|
|
|
now uint32
|
|
|
|
|
conv uint32
|
|
|
|
|
token uint32
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newTestClient(t *testing.T, serverAddr string) (*testClient, error) {
|
|
|
|
|
conn, err := dialUDP(serverAddr)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
c := &testClient{conn: conn}
|
|
|
|
|
return c, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *testClient) handshake() (uint32, uint32, error) {
|
|
|
|
|
// head=0xFF, tail=0xFFFFFFFF, data=client seed
|
|
|
|
|
op := make([]byte, 20)
|
|
|
|
|
binary.BigEndian.PutUint32(op[0:], 0xFF)
|
|
|
|
|
binary.BigEndian.PutUint32(op[12:], 0xABCD1234)
|
|
|
|
|
binary.BigEndian.PutUint32(op[16:], 0xFFFFFFFF)
|
|
|
|
|
if _, err := c.conn.write(op); err != nil {
|
|
|
|
|
return 0, 0, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
c.conn.setReadDeadline(time.Now().Add(3 * time.Second))
|
|
|
|
|
reply, err := c.conn.read()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, 0, err
|
|
|
|
|
}
|
|
|
|
|
rsp, err := DecodeNetOperation(reply)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, 0, err
|
|
|
|
|
}
|
|
|
|
|
if rsp.Head != NetOpAcceptHead || rsp.Tail != NetOpAcceptTail || rsp.Data != 0xABCD1234 {
|
|
|
|
|
return 0, 0, errBadHandshake
|
|
|
|
|
}
|
|
|
|
|
c.conv, c.token = rsp.Param1, rsp.Param2
|
|
|
|
|
|
|
|
|
|
c.kcp = kcplib.New(c.conv, c.token, false, func(data []byte) error {
|
|
|
|
|
_, err := c.conn.write(data)
|
|
|
|
|
return err
|
|
|
|
|
})
|
|
|
|
|
return c.conv, c.token, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *testClient) sendCmd(cmdID uint16, body []byte) error {
|
|
|
|
|
pkt := (&NetPacket{CmdType: cmdID, Body: body}).Encode()
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
defer c.mu.Unlock()
|
|
|
|
|
if _, err := c.kcp.Send(pkt); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if err := c.kcp.Update(c.kcp.Now()); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
return c.kcp.Flush()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *testClient) recvCmd(timeout time.Duration) (uint16, []byte, error) {
|
|
|
|
|
deadline := time.Now().Add(timeout)
|
|
|
|
|
buf := make([]byte, 65536)
|
|
|
|
|
for time.Now().Before(deadline) {
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
now := c.now
|
|
|
|
|
c.now += 50
|
|
|
|
|
if err := c.kcp.Update(now); err != nil {
|
|
|
|
|
c.mu.Unlock()
|
|
|
|
|
return 0, nil, err
|
|
|
|
|
}
|
|
|
|
|
for {
|
|
|
|
|
n, err := c.kcp.Recv(buf)
|
|
|
|
|
if err != nil {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
pkt, err := DecodeNetPacket(buf[:n])
|
|
|
|
|
if err != nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
c.mu.Unlock()
|
|
|
|
|
return pkt.CmdType, pkt.Body, nil
|
|
|
|
|
}
|
|
|
|
|
c.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
c.conn.setReadDeadline(time.Now().Add(10 * time.Millisecond))
|
|
|
|
|
data, err := c.conn.read()
|
|
|
|
|
if err == nil && len(data) >= 28 {
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
_ = c.kcp.Input(data)
|
|
|
|
|
_ = c.kcp.Flush()
|
|
|
|
|
c.mu.Unlock()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return 0, nil, errTimeout
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
errBadHandshake = errString("bad handshake reply")
|
|
|
|
|
errTimeout = errString("timeout")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type errString string
|
|
|
|
|
|
|
|
|
|
func (e errString) Error() string { return string(e) }
|