651 lines
19 KiB
Go
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:
|
|
}
|
|
}
|
|
}
|
|
}
|