Files
a301_game_server/cmd/testclient/main.go
2026-02-26 17:52:48 +09:00

651 lines
19 KiB
Go

// testclient: 서버와 WebSocket 연결해서 전체 게임 흐름을 테스트하는 CLI 클라이언트.
//
// 사용법:
//
// go run ./cmd/testclient # 기본 (ws://localhost:8080/ws, user1/pass1)
// go run ./cmd/testclient -url ws://host:8080/ws # 서버 주소 지정
// go run ./cmd/testclient -user alice -pass secret
// go run ./cmd/testclient -clients 5 # 5명 동시 접속 부하 테스트
// go run ./cmd/testclient -scenario combat # 전투 시나리오만 실행
package main
import (
"encoding/binary"
"flag"
"fmt"
"log"
"math/rand"
"net/url"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/gorilla/websocket"
pb "a301_game_server/proto/gen/pb"
"google.golang.org/protobuf/proto"
)
// ─── 메시지 타입 ID ────────────────────────────────────────────────
const (
MsgLoginRequest uint16 = 0x0001
MsgLoginResponse uint16 = 0x0002
MsgEnterWorldRequest uint16 = 0x0003
MsgEnterWorldResponse uint16 = 0x0004
MsgMoveRequest uint16 = 0x0010
MsgStateUpdate uint16 = 0x0011
MsgSpawnEntity uint16 = 0x0012
MsgDespawnEntity uint16 = 0x0013
MsgZoneTransferNotify uint16 = 0x0014
MsgPing uint16 = 0x0020
MsgPong uint16 = 0x0021
MsgUseSkillRequest uint16 = 0x0040
MsgUseSkillResponse uint16 = 0x0041
MsgCombatEvent uint16 = 0x0042
MsgBuffApplied uint16 = 0x0043
MsgBuffRemoved uint16 = 0x0044
MsgRespawnRequest uint16 = 0x0045
MsgRespawnResponse uint16 = 0x0046
MsgAOIToggleRequest uint16 = 0x0030
MsgAOIToggleResponse uint16 = 0x0031
MsgMetricsRequest uint16 = 0x0032
MsgServerMetrics uint16 = 0x0033
)
var msgTypeNames = map[uint16]string{
MsgLoginRequest: "LoginRequest", MsgLoginResponse: "LoginResponse",
MsgEnterWorldRequest: "EnterWorldRequest", MsgEnterWorldResponse: "EnterWorldResponse",
MsgMoveRequest: "MoveRequest", MsgStateUpdate: "StateUpdate",
MsgSpawnEntity: "SpawnEntity", MsgDespawnEntity: "DespawnEntity",
MsgZoneTransferNotify: "ZoneTransferNotify",
MsgPing: "Ping", MsgPong: "Pong",
MsgUseSkillRequest: "UseSkillRequest", MsgUseSkillResponse: "UseSkillResponse",
MsgCombatEvent: "CombatEvent", MsgBuffApplied: "BuffApplied",
MsgBuffRemoved: "BuffRemoved",
MsgRespawnRequest: "RespawnRequest", MsgRespawnResponse: "RespawnResponse",
MsgAOIToggleRequest: "AOIToggleRequest", MsgAOIToggleResponse: "AOIToggleResponse",
MsgMetricsRequest: "MetricsRequest", MsgServerMetrics: "ServerMetrics",
}
// ─── 클라이언트 ────────────────────────────────────────────────────
type Client struct {
id int
username string
password string
conn *websocket.Conn
logger *log.Logger
sessionToken string
playerID uint64
zoneID uint32
done chan struct{}
recvCh chan recvMsg
mu sync.Mutex
entities map[uint64]*pb.EntityState
}
type recvMsg struct {
msgType uint16
data []byte
}
func newClient(id int, username, password string) *Client {
return &Client{
id: id,
username: username,
password: password,
logger: log.New(os.Stdout, fmt.Sprintf("[client-%d] ", id), log.Ltime|log.Lmsgprefix),
done: make(chan struct{}),
recvCh: make(chan recvMsg, 64),
entities: make(map[uint64]*pb.EntityState),
}
}
func (c *Client) connect(serverURL string) error {
u, err := url.Parse(serverURL)
if err != nil {
return err
}
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
if err != nil {
return fmt.Errorf("dial failed: %w", err)
}
c.conn = conn
c.logger.Printf("연결 성공: %s", serverURL)
// 수신 루프
go c.readLoop()
return nil
}
func (c *Client) readLoop() {
defer close(c.done)
for {
_, data, err := c.conn.ReadMessage()
if err != nil {
c.logger.Printf("연결 종료: %v", err)
return
}
if len(data) < 2 {
c.logger.Printf("패킷 너무 짧음 (%d bytes)", len(data))
continue
}
msgType := binary.BigEndian.Uint16(data[:2])
payload := data[2:]
// Ping에는 자동으로 Pong 응답
if msgType == MsgPing {
var ping pb.Ping
if err := proto.Unmarshal(payload, &ping); err == nil {
_ = c.send(MsgPong, &pb.Pong{ClientTime: ping.ClientTime})
}
continue
}
select {
case c.recvCh <- recvMsg{msgType, payload}:
default:
c.logger.Printf("recvCh 버퍼 풀 - 메시지 드롭: 0x%04X", msgType)
}
}
}
func (c *Client) send(msgType uint16, msg proto.Message) error {
body, err := proto.Marshal(msg)
if err != nil {
return err
}
packet := make([]byte, 2+len(body))
binary.BigEndian.PutUint16(packet[:2], msgType)
copy(packet[2:], body)
return c.conn.WriteMessage(websocket.BinaryMessage, packet)
}
// waitFor: 특정 메시지 타입이 올 때까지 대기 (timeout 적용)
func (c *Client) waitFor(msgType uint16, timeout time.Duration) ([]byte, error) {
deadline := time.After(timeout)
for {
select {
case msg := <-c.recvCh:
name, ok := msgTypeNames[msg.msgType]
if !ok {
name = fmt.Sprintf("0x%04X", msg.msgType)
}
if msg.msgType == msgType {
c.logger.Printf(" <- %s (받음)", name)
return msg.data, nil
}
// 기다리는 타입이 아닌 것은 즉시 처리
c.handleAsync(msg)
case <-deadline:
return nil, fmt.Errorf("타임아웃: 0x%04X 메시지 대기 중 %v 초과", msgType, timeout)
case <-c.done:
return nil, fmt.Errorf("연결 끊김")
}
}
}
// handleAsync: waitFor 중 받은 다른 메시지를 처리
func (c *Client) handleAsync(msg recvMsg) {
name, ok := msgTypeNames[msg.msgType]
if !ok {
name = fmt.Sprintf("0x%04X", msg.msgType)
}
switch msg.msgType {
case MsgStateUpdate:
var upd pb.StateUpdate
if err := proto.Unmarshal(msg.data, &upd); err == nil {
c.mu.Lock()
for _, e := range upd.Entities {
c.entities[e.EntityId] = e
}
c.mu.Unlock()
}
case MsgSpawnEntity:
var sp pb.SpawnEntity
if err := proto.Unmarshal(msg.data, &sp); err == nil {
c.mu.Lock()
c.entities[sp.Entity.EntityId] = sp.Entity
c.mu.Unlock()
c.logger.Printf(" <- SpawnEntity: [%d] %s (HP:%d/%d) @ (%.1f,%.1f)",
sp.Entity.EntityId, sp.Entity.Name, sp.Entity.Hp, sp.Entity.MaxHp,
sp.Entity.Position.GetX(), sp.Entity.Position.GetZ())
}
case MsgDespawnEntity:
var dp pb.DespawnEntity
if err := proto.Unmarshal(msg.data, &dp); err == nil {
c.mu.Lock()
delete(c.entities, dp.EntityId)
c.mu.Unlock()
c.logger.Printf(" <- DespawnEntity: [%d]", dp.EntityId)
}
case MsgCombatEvent:
var evt pb.CombatEvent
if err := proto.Unmarshal(msg.data, &evt); err == nil {
switch evt.EventType {
case pb.CombatEventType_COMBAT_EVENT_DAMAGE:
crit := ""
if evt.IsCritical {
crit = " [CRIT]"
}
c.logger.Printf(" <- CombatEvent: [%d] -> [%d] 데미지 %d%s (남은HP: %d/%d)",
evt.CasterId, evt.TargetId, evt.Damage, crit, evt.TargetHp, evt.TargetMaxHp)
case pb.CombatEventType_COMBAT_EVENT_HEAL:
c.logger.Printf(" <- CombatEvent: [%d] 힐 +%d (HP: %d/%d)",
evt.TargetId, evt.Heal, evt.TargetHp, evt.TargetMaxHp)
case pb.CombatEventType_COMBAT_EVENT_DEATH:
c.logger.Printf(" <- CombatEvent: [%d] 사망", evt.TargetId)
case pb.CombatEventType_COMBAT_EVENT_RESPAWN:
c.logger.Printf(" <- CombatEvent: [%d] 리스폰 (HP: %d/%d)",
evt.TargetId, evt.TargetHp, evt.TargetMaxHp)
}
}
case MsgBuffApplied:
var b pb.BuffApplied
if err := proto.Unmarshal(msg.data, &b); err == nil {
debuff := ""
if b.IsDebuff {
debuff = "[디버프]"
}
c.logger.Printf(" <- BuffApplied%s: [%d] %s (%.1fs)", debuff, b.TargetId, b.BuffName, b.Duration)
}
case MsgBuffRemoved:
var b pb.BuffRemoved
if err := proto.Unmarshal(msg.data, &b); err == nil {
c.logger.Printf(" <- BuffRemoved: [%d] buff#%d", b.TargetId, b.BuffId)
}
case MsgZoneTransferNotify:
var z pb.ZoneTransferNotify
if err := proto.Unmarshal(msg.data, &z); err == nil {
c.zoneID = z.NewZoneId
c.logger.Printf(" <- ZoneTransfer: 새 존 %d (주변 엔티티 %d개)", z.NewZoneId, len(z.NearbyEntities))
}
default:
c.logger.Printf(" <- %s (비동기 수신)", name)
}
}
// ─── 시나리오 ──────────────────────────────────────────────────────
// stepAuth: 로그인 → 월드 입장
func (c *Client) stepAuth() error {
// 1. 로그인
c.logger.Printf("→ LoginRequest (user=%s)", c.username)
if err := c.send(MsgLoginRequest, &pb.LoginRequest{
Username: c.username,
Password: c.password,
}); err != nil {
return err
}
data, err := c.waitFor(MsgLoginResponse, 5*time.Second)
if err != nil {
return err
}
var resp pb.LoginResponse
if err := proto.Unmarshal(data, &resp); err != nil {
return err
}
if !resp.Success {
return fmt.Errorf("로그인 실패: %s", resp.ErrorMessage)
}
c.sessionToken = resp.SessionToken
c.playerID = resp.PlayerId
c.logger.Printf(" 로그인 성공: playerID=%d, token=%s…", resp.PlayerId, resp.SessionToken[:8])
// 2. 월드 입장
c.logger.Printf("→ EnterWorldRequest")
if err := c.send(MsgEnterWorldRequest, &pb.EnterWorldRequest{
SessionToken: c.sessionToken,
}); err != nil {
return err
}
data, err = c.waitFor(MsgEnterWorldResponse, 5*time.Second)
if err != nil {
return err
}
var ewResp pb.EnterWorldResponse
if err := proto.Unmarshal(data, &ewResp); err != nil {
return err
}
if !ewResp.Success {
return fmt.Errorf("월드 입장 실패: %s", ewResp.ErrorMessage)
}
c.zoneID = ewResp.ZoneId
c.logger.Printf(" 월드 입장 성공: zone=%d, pos=(%.1f, %.1f, %.1f)",
ewResp.ZoneId,
ewResp.Self.Position.GetX(),
ewResp.Self.Position.GetY(),
ewResp.Self.Position.GetZ())
c.logger.Printf(" 플레이어 정보: name=%s, HP=%d/%d, level=%d",
ewResp.Self.Name, ewResp.Self.Hp, ewResp.Self.MaxHp, ewResp.Self.Level)
return nil
}
// stepMove: 위치 이동 전송 후 StateUpdate 수신 확인
func (c *Client) stepMove() error {
c.logger.Printf("─── 이동 테스트 ───")
positions := [][2]float32{{5, 5}, {10, 10}, {15, 5}, {10, 0}}
for _, pos := range positions {
c.logger.Printf("→ MoveRequest (%.1f, 0, %.1f)", pos[0], pos[1])
if err := c.send(MsgMoveRequest, &pb.MoveRequest{
Position: &pb.Vector3{X: pos[0], Y: 0, Z: pos[1]},
Velocity: &pb.Vector3{X: 1, Y: 0, Z: 0},
Rotation: 0,
}); err != nil {
return err
}
time.Sleep(100 * time.Millisecond)
}
// StateUpdate 수신 대기 (이동 후 서버 틱 안에 옴)
c.logger.Printf("StateUpdate 대기 중…")
_, err := c.waitFor(MsgStateUpdate, 3*time.Second)
if err != nil {
c.logger.Printf(" 경고: StateUpdate 없음 (혼자 접속 중이면 정상)")
return nil
}
c.mu.Lock()
c.logger.Printf(" 현재 시야 내 엔티티 수: %d", len(c.entities))
c.mu.Unlock()
return nil
}
// stepCombat: 주변 몹 타겟팅 후 스킬 사용
func (c *Client) stepCombat() error {
c.logger.Printf("─── 전투 테스트 ───")
// 주변 몹 찾기 (SpawnEntity로 받은 것들 중 mob 타입)
var mobID uint64
c.mu.Lock()
for id, e := range c.entities {
if e.EntityType == pb.EntityType_ENTITY_TYPE_MOB {
mobID = id
c.logger.Printf(" 타겟 몹 발견: [%d] %s (HP:%d/%d)", id, e.Name, e.Hp, e.MaxHp)
break
}
}
c.mu.Unlock()
if mobID == 0 {
c.logger.Printf(" 주변에 몹이 없음 - 스킬 테스트 스킵")
return nil
}
skills := []struct {
id uint32
name string
}{
{1, "Basic Attack"},
{2, "Fireball"},
}
for _, skill := range skills {
c.logger.Printf("→ UseSkillRequest: %s (skillID=%d) → 타겟 [%d]", skill.name, skill.id, mobID)
if err := c.send(MsgUseSkillRequest, &pb.UseSkillRequest{
SkillId: skill.id,
TargetId: mobID,
}); err != nil {
return err
}
data, err := c.waitFor(MsgUseSkillResponse, 3*time.Second)
if err != nil {
return err
}
var resp pb.UseSkillResponse
if err := proto.Unmarshal(data, &resp); err != nil {
return err
}
if resp.Success {
c.logger.Printf(" 스킬 성공")
} else {
c.logger.Printf(" 스킬 실패: %s", resp.ErrorMessage)
}
time.Sleep(200 * time.Millisecond) // 쿨다운 대기
}
return nil
}
// stepHeal: 자신 힐 스킬
func (c *Client) stepHeal() error {
c.logger.Printf("─── 힐 테스트 ───")
c.logger.Printf("→ UseSkillRequest: Heal (skillID=3, 셀프)")
if err := c.send(MsgUseSkillRequest, &pb.UseSkillRequest{SkillId: 3}); err != nil {
return err
}
data, err := c.waitFor(MsgUseSkillResponse, 3*time.Second)
if err != nil {
return err
}
var resp pb.UseSkillResponse
if err := proto.Unmarshal(data, &resp); err != nil {
return err
}
if resp.Success {
c.logger.Printf(" 힐 성공")
} else {
c.logger.Printf(" 힐 실패: %s", resp.ErrorMessage)
}
return nil
}
// stepMetrics: 서버 메트릭 요청
func (c *Client) stepMetrics() error {
c.logger.Printf("─── 서버 메트릭 ───")
c.logger.Printf("→ MetricsRequest")
if err := c.send(MsgMetricsRequest, &pb.MetricsRequest{}); err != nil {
return err
}
data, err := c.waitFor(MsgServerMetrics, 3*time.Second)
if err != nil {
return err
}
var m pb.ServerMetrics
if err := proto.Unmarshal(data, &m); err != nil {
return err
}
c.logger.Printf(" 온라인 플레이어: %d", m.OnlinePlayers)
c.logger.Printf(" 총 엔티티: %d", m.TotalEntities)
c.logger.Printf(" 틱 시간: %d µs", m.TickDurationUs)
c.logger.Printf(" AOI 활성화: %v", m.AoiEnabled)
return nil
}
// stepAOIToggle: AOI on/off 토글 테스트
func (c *Client) stepAOIToggle(enabled bool) error {
c.logger.Printf("─── AOI 토글 (enabled=%v) ───", enabled)
if err := c.send(MsgAOIToggleRequest, &pb.AOIToggleRequest{Enabled: enabled}); err != nil {
return err
}
data, err := c.waitFor(MsgAOIToggleResponse, 3*time.Second)
if err != nil {
return err
}
var resp pb.AOIToggleResponse
if err := proto.Unmarshal(data, &resp); err != nil {
return err
}
c.logger.Printf(" AOI 토글 결과: %s", resp.Message)
return nil
}
// runScenario: 전체 또는 특정 시나리오 실행
func (c *Client) runScenario(scenario string) {
// 공통: 인증
if err := c.stepAuth(); err != nil {
c.logger.Printf("[FAIL] 인증: %v", err)
return
}
switch scenario {
case "auth":
c.logger.Printf("[OK] 인증 시나리오 완료")
case "move":
if err := c.stepMove(); err != nil {
c.logger.Printf("[FAIL] 이동: %v", err)
return
}
c.logger.Printf("[OK] 이동 시나리오 완료")
case "combat":
// 이동해서 몹 시야 안으로
time.Sleep(500 * time.Millisecond) // SpawnEntity 수신 대기
if err := c.stepCombat(); err != nil {
c.logger.Printf("[FAIL] 전투: %v", err)
return
}
if err := c.stepHeal(); err != nil {
c.logger.Printf("[FAIL] 힐: %v", err)
return
}
c.logger.Printf("[OK] 전투 시나리오 완료")
case "metrics":
if err := c.stepMetrics(); err != nil {
c.logger.Printf("[FAIL] 메트릭: %v", err)
return
}
if err := c.stepAOIToggle(false); err != nil {
c.logger.Printf("[FAIL] AOI 토글: %v", err)
return
}
if err := c.stepAOIToggle(true); err != nil {
c.logger.Printf("[FAIL] AOI 토글: %v", err)
return
}
c.logger.Printf("[OK] 메트릭 시나리오 완료")
default: // "all"
time.Sleep(500 * time.Millisecond) // SpawnEntity 수신 대기
if err := c.stepMove(); err != nil {
c.logger.Printf("[FAIL] 이동: %v", err)
}
if err := c.stepCombat(); err != nil {
c.logger.Printf("[FAIL] 전투: %v", err)
}
if err := c.stepHeal(); err != nil {
c.logger.Printf("[FAIL] 힐: %v", err)
}
if err := c.stepMetrics(); err != nil {
c.logger.Printf("[FAIL] 메트릭: %v", err)
}
c.logger.Printf("[OK] 전체 시나리오 완료")
}
}
// ─── 부하 테스트 ──────────────────────────────────────────────────
func loadTest(serverURL string, numClients int, duration time.Duration) {
fmt.Printf("부하 테스트: %d 클라이언트, %v 동안\n", numClients, duration)
var wg sync.WaitGroup
start := time.Now()
for i := 0; i < numClients; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
username := fmt.Sprintf("loadtest_%d_%d", id, rand.Intn(9999))
c := newClient(id, username, "testpass")
if err := c.connect(serverURL); err != nil {
c.logger.Printf("[FAIL] 연결: %v", err)
return
}
defer c.conn.Close()
if err := c.stepAuth(); err != nil {
c.logger.Printf("[FAIL] 인증: %v", err)
return
}
// duration 동안 이동 패킷 전송
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
timeout := time.After(duration)
x, z := float32(0), float32(0)
for {
select {
case <-ticker.C:
x += rand.Float32()*2 - 1
z += rand.Float32()*2 - 1
_ = c.send(MsgMoveRequest, &pb.MoveRequest{
Position: &pb.Vector3{X: x, Y: 0, Z: z},
Velocity: &pb.Vector3{X: 1, Y: 0, Z: 0},
})
case <-timeout:
c.logger.Printf("완료 (%.1fs)", time.Since(start).Seconds())
return
case <-c.done:
return
}
}
}(i)
time.Sleep(10 * time.Millisecond) // 동시 접속 분산
}
wg.Wait()
fmt.Printf("부하 테스트 완료: %.1fs\n", time.Since(start).Seconds())
}
// ─── main ─────────────────────────────────────────────────────────
func main() {
serverURL := flag.String("url", "ws://localhost:8080/ws", "WebSocket 서버 주소")
username := flag.String("user", "testuser1", "사용자 이름")
password := flag.String("pass", "password123", "비밀번호")
scenario := flag.String("scenario", "all", "실행할 시나리오: all | auth | move | combat | metrics")
numClients := flag.Int("clients", 1, "동시 접속 클라이언트 수 (부하 테스트)")
loadDuration := flag.Duration("duration", 10*time.Second, "부하 테스트 지속 시간")
flag.Parse()
// 부하 테스트 모드
if *numClients > 1 {
loadTest(*serverURL, *numClients, *loadDuration)
return
}
// 단일 클라이언트 시나리오
c := newClient(1, *username, *password)
if err := c.connect(*serverURL); err != nil {
log.Fatalf("서버 연결 실패: %v\n서버가 실행 중인지 확인하세요: make run", err)
}
defer c.conn.Close()
c.runScenario(*scenario)
// Ctrl+C 대기 (실시간 메시지 관찰용)
if *scenario == "all" {
fmt.Println("\n[Ctrl+C로 종료] 서버 메시지 수신 대기 중...")
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
loop:
for {
select {
case <-sig:
break loop
case <-c.done:
break loop
case msg := <-c.recvCh:
c.handleAsync(msg)
case <-ticker.C:
}
}
}
}