// 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: } } } }