diff --git a/test/load-bot.ts b/test/load-bot.ts index c66661f..2bef8bb 100644 --- a/test/load-bot.ts +++ b/test/load-bot.ts @@ -1,4 +1,9 @@ +/** + * load-bot + * 実際のプレイヤーと同等の通信・ゲームプレイをシミュレートする負荷テスト用ボット + */ import { io } from "socket.io-client"; +import { config as sharedConfig } from "@repo/shared"; import { BOTS, BOT_CAN_MOVE, @@ -10,7 +15,6 @@ MOVE_TICK_MS, BOT_SPEED, BOT_RADIUS, - BOMB_COOLDOWN_MS, BOMB_FUSE_MS, ROOM_ID, SOCKET_PATH, @@ -21,6 +25,8 @@ URL, } from "./load-bot.constants.js"; +const { GAME_CONFIG } = sharedConfig; + type Stats = { connected: number; joined: number; @@ -35,17 +41,54 @@ stop: () => void; }; -type CurrentPlayer = { +// current-players で配信されるプレイヤー情報 +type CurrentPlayerPayload = + | { id: string; name: string; teamId: number } + | { id: string; name: string; teamId: number; x: number; y: number }; + +// game-start で受信するゲーム開始情報 +type GameStartPayload = { + startTime: number; + serverNow: number; + fieldSizePreset: string; + gridCols: number; + gridRows: number; +}; + +// bomb-placed で受信する他プレイヤーの爆弾情報 +type BombPlacedPayload = { + bombId: string; + ownerTeamId: number; + x: number; + y: number; + explodeAtElapsedMs: number; +}; + +// bomb-placed-ack で受信する自分の爆弾確定情報 +type BombPlacedAckPayload = { + bombId: string; + requestId: string; +}; + +// ハリケーン状態 +type HurricaneStatePayload = { id: string; x: number; y: number; + radius: number; + rotationRad: number; }; -type GameStartPayload = { - startTime: number; +// 追跡中の爆弾 +type TrackedBomb = { + bombId: string; + ownerTeamId: number; + x: number; + y: number; + explodeAtElapsedMs: number; }; -// 実行後の簡易サマリ用カウンタ。 +// 実行後の簡易サマリ用カウンタ const stats: Stats = { connected: 0, joined: 0, @@ -57,7 +100,6 @@ }; const bots: Bot[] = []; -// 既にオーナーがいるルームを追跡。 const args = new Set(process.argv.slice(2)); const useDev = args.has("--dev"); @@ -76,14 +118,14 @@ moveTickMs: MOVE_TICK_MS, botSpeed: BOT_SPEED, botRadius: BOT_RADIUS, - bombCooldownMs: BOMB_COOLDOWN_MS, + bombCooldownMs: GAME_CONFIG.BOMB_NORMAL_COOLDOWN_MS, bombFuseMs: BOMB_FUSE_MS, botCanPlaceBomb: BOT_CAN_PLACE_BOMB, maxX: MAX_X, maxY: MAX_Y, }); -// 接続スパイクを避けるため参加タイミングを分散。 +// 接続スパイクを避けるため参加タイミングを分散 for (let i = 0; i < BOTS; i += 1) { const delay = i * JOIN_DELAY_MS; setTimeout(() => { @@ -92,7 +134,7 @@ }, delay); } -// 指定時間後に全ボットを停止。無期限の場合は停止タイマーを設定しない。 +// 指定時間後に全ボットを停止。無期限の場合は停止タイマーを設定しない if (Number.isFinite(DURATION_MS) && DURATION_MS > 0) { setTimeout(() => { console.log("Stopping bots..."); @@ -111,7 +153,7 @@ const playerName = `bot-${index}`; const isOwner = index === 0; - // 固定トランスポートで再接続なしのクライアント接続。 + // 固定トランスポートで再接続なしのクライアント接続 const socket = io(url, { transports: SOCKET_TRANSPORTS, path: SOCKET_PATH, @@ -120,15 +162,63 @@ }); let moveTimer: NodeJS.Timeout | null = null; + let pingTimer: NodeJS.Timeout | null = null; + let bombCheckTimer: NodeJS.Timeout | null = null; + // startTime まで待つカウントダウンタイマー + let gameplayStartTimer: NodeJS.Timeout | null = null; + + // フィールドサイズ(game-start で上書きされる) + let fieldMaxX = MAX_X; + let fieldMaxY = MAX_Y; + let posX = BOT_RADIUS + Math.random() * (MAX_X - BOT_RADIUS * 2); let posY = BOT_RADIUS + Math.random() * (MAX_Y - BOT_RADIUS * 2); + // リスポーン時に戻る初期座標(current-players で確定) + let spawnX = posX; + let spawnY = posY; let dirX = 1; let dirY = 0; + let gameStarted = false; + let gameEnded = false; let readySent = false; + + // 自分のチームID(current-players で確定,友軍撃ちチェックに使用) + let myTeamId: number | null = null; + + // 被弾カウント(5回でリスポーン) + let hitCount = 0; + + // サーバーとのクロックオフセット補正(serverNow - Date.now()) + let clockOffsetMs = 0; + + // ゲーム開始時刻(サーバー時計基準,ms) let gameStartTimeMs: number | null = null; + + // 被弾スタン状態 + let stunUntilMs = 0; + + // 爆弾 let lastBombPlacedElapsedMs = Number.NEGATIVE_INFINITY; let bombRequestSerial = 0; + // 追跡中の爆弾一覧(他プレイヤー設置分) + const trackedBombs = new Map(); + + // ハリケーン状態 + const hurricanes = new Map(); + + const getServerNow = (): number => Date.now() + clockOffsetMs; + + const getElapsedMs = (): number | null => { + if (gameStartTimeMs === null) return null; + return Math.max(0, getServerNow() - gameStartTimeMs); + }; + + const isStunned = (): boolean => Date.now() < stunUntilMs; + + const applyHitStun = (stunMs: number) => { + stunUntilMs = Math.max(stunUntilMs, Date.now() + stunMs); + }; const updateDirection = () => { const angle = Math.random() * Math.PI * 2; @@ -136,11 +226,117 @@ dirY = Math.sin(angle); }; - const tickMove = () => { - if (!BOT_CAN_MOVE) { - return; + // ハリケーン回避: 近いハリケーンがあれば逃げる方向へ調整する + const applyHurricaneAvoidance = () => { + let avoidX = 0; + let avoidY = 0; + let hasNearby = false; + const avoidDist = GAME_CONFIG.HURRICANE_DIAMETER_GRID * 1.5; + + for (const h of hurricanes.values()) { + const dx = posX - h.x; + const dy = posY - h.y; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < avoidDist && dist > 0) { + avoidX += dx / dist; + avoidY += dy / dist; + hasNearby = true; + } } + if (hasNearby) { + const len = Math.sqrt(avoidX * avoidX + avoidY * avoidY); + dirX = avoidX / len; + dirY = avoidY / len; + } + }; + + // 被弾処理(爆弾・ハリケーン共通): カウント管理とリスポーン判定 + const applyDamage = () => { + hitCount += 1; + if (hitCount >= GAME_CONFIG.PLAYER_RESPAWN_HIT_COUNT) { + // リスポーン: 2000ms スタン後に初期座標へ戻る + hitCount = 0; + applyHitStun(GAME_CONFIG.PLAYER_RESPAWN_STUN_MS); + setTimeout(() => { + posX = spawnX; + posY = spawnY; + }, GAME_CONFIG.PLAYER_RESPAWN_STUN_MS); + } else { + applyHitStun(GAME_CONFIG.PLAYER_HIT_STUN_MS); + } + }; + + // 爆弾範囲内チェック + bomb-hit-report 送信 + const checkAndReportBombHits = () => { + const elapsedMs = getElapsedMs(); + if (elapsedMs === null || !gameStarted) return; + + for (const [bombId, bomb] of trackedBombs) { + if (elapsedMs >= bomb.explodeAtElapsedMs) { + // 友軍撃ちは無効(同チームの爆弾には反応しない) + if (myTeamId !== null && bomb.ownerTeamId === myTeamId) { + trackedBombs.delete(bombId); + continue; + } + // 爆弾が爆発したタイミングで範囲内なら被弾報告 + const dx = posX - bomb.x; + const dy = posY - bomb.y; + const dist = Math.sqrt(dx * dx + dy * dy); + const blastRadius = + GAME_CONFIG.BOMB_RADIUS_GRID + GAME_CONFIG.PLAYER_RADIUS; + if (dist <= blastRadius) { + // ローカルで即スタンを適用(実クライアントと同様の即時フィードバック) + // hitCount は server からの player-hit で管理するため applyDamage は呼ばない + applyHitStun(GAME_CONFIG.PLAYER_HIT_STUN_MS); + socket.emit("bomb-hit-report", { bombId }); + } + trackedBombs.delete(bombId); + } + } + }; + + // フィーバー中かどうか(残り60秒未満) + const isInFever = (): boolean => { + const elapsedMs = getElapsedMs(); + if (elapsedMs === null) return false; + const remainingSec = + GAME_CONFIG.GAME_DURATION_SEC - elapsedMs / 1000; + return remainingSec <= GAME_CONFIG.BOMB_FEVER_START_REMAINING_SEC; + }; + + const currentBombCooldownMs = (): number => + isInFever() + ? GAME_CONFIG.BOMB_FEVER_COOLDOWN_MS + : GAME_CONFIG.BOMB_NORMAL_COOLDOWN_MS; + + const tryPlaceBomb = () => { + if (!BOT_CAN_PLACE_BOMB || !gameStarted || isStunned()) return; + + const elapsedMs = getElapsedMs(); + if (elapsedMs === null) return; + + if (elapsedMs - lastBombPlacedElapsedMs < currentBombCooldownMs()) return; + + bombRequestSerial += 1; + const requestId = `${index}-${bombRequestSerial}`; + const explodeAtElapsedMs = elapsedMs + BOMB_FUSE_MS; + + socket.emit("place-bomb", { + requestId, + x: posX, + y: posY, + explodeAtElapsedMs, + }); + lastBombPlacedElapsedMs = elapsedMs; + }; + + const tickMove = () => { + if (!BOT_CAN_MOVE || isStunned()) return; + + // ハリケーンがいれば回避方向へ + applyHurricaneAvoidance(); + const dtSec = MOVE_TICK_MS / 1000; posX += dirX * BOT_SPEED * dtSec; posY += dirY * BOT_SPEED * dtSec; @@ -148,16 +344,16 @@ if (posX < BOT_RADIUS) { posX = BOT_RADIUS; updateDirection(); - } else if (posX > MAX_X - BOT_RADIUS) { - posX = MAX_X - BOT_RADIUS; + } else if (posX > fieldMaxX - BOT_RADIUS) { + posX = fieldMaxX - BOT_RADIUS; updateDirection(); } if (posY < BOT_RADIUS) { posY = BOT_RADIUS; updateDirection(); - } else if (posY > MAX_Y - BOT_RADIUS) { - posY = MAX_Y - BOT_RADIUS; + } else if (posY > fieldMaxY - BOT_RADIUS) { + posY = fieldMaxY - BOT_RADIUS; updateDirection(); } @@ -167,92 +363,211 @@ tryPlaceBomb(); }; - const getElapsedMs = (): number | null => { - if (gameStartTimeMs === null) { - return null; + const startMoveTimer = () => { + if (!moveTimer && BOT_CAN_MOVE && MOVE_TICK_MS > 0) { + updateDirection(); + moveTimer = setInterval(tickMove, MOVE_TICK_MS); } - - return Math.max(0, Date.now() - gameStartTimeMs); }; - const tryPlaceBomb = () => { - if (!BOT_CAN_PLACE_BOMB || !gameStarted) { - return; - } - - const elapsedMs = getElapsedMs(); - if (elapsedMs === null) { - return; - } - - if (elapsedMs - lastBombPlacedElapsedMs < BOMB_COOLDOWN_MS) { - return; - } - - bombRequestSerial += 1; - socket.emit("place-bomb", { - requestId: `${index}-${bombRequestSerial}`, - x: posX, - y: posY, - explodeAtElapsedMs: elapsedMs + BOMB_FUSE_MS, - }); - lastBombPlacedElapsedMs = elapsedMs; + const startPingTimer = () => { + // 5秒ごとに ping を送ってクロックオフセットを更新 + pingTimer = setInterval(() => { + if (!gameEnded) { + socket.emit("ping", Date.now()); + } + }, 5000); + // 初回 ping を即時送信 + socket.emit("ping", Date.now()); }; + const startBombCheckTimer = () => { + bombCheckTimer = setInterval(checkAndReportBombHits, 100); + }; + + const stopAllTimers = () => { + if (gameplayStartTimer) { + clearTimeout(gameplayStartTimer); + gameplayStartTimer = null; + } + if (moveTimer) { + clearInterval(moveTimer); + moveTimer = null; + } + if (pingTimer) { + clearInterval(pingTimer); + pingTimer = null; + } + if (bombCheckTimer) { + clearInterval(bombCheckTimer); + bombCheckTimer = null; + } + }; + + // ---- イベントハンドラ ---- + socket.on("connect", () => { counters.connected += 1; - // 接続直後にルームへ参加。 socket.emit("join-room", { roomId, playerName }); counters.joined += 1; if (START_GAME && isOwner) { - // 他ボットの参加を待つため開始を少し遅らせる。 setTimeout(() => { - socket.emit("start-game"); + socket.emit("start-game", {}); counters.startSent += 1; }, START_DELAY_MS); } + + startPingTimer(); + }); + + // クロックオフセット補正 + socket.on("pong", (payload: { clientTime: number; serverTime: number }) => { + const rtt = Date.now() - payload.clientTime; + clockOffsetMs = payload.serverTime + rtt / 2 - Date.now(); }); socket.on("game-start", (payload?: GameStartPayload) => { counters.gameStarts += 1; - gameStarted = true; - if ( - payload && - typeof payload.startTime === "number" && - Number.isFinite(payload.startTime) - ) { - gameStartTimeMs = payload.startTime; + + if (payload) { + // サーバー時計基準でクロックオフセットを補正 + if (typeof payload.serverNow === "number") { + clockOffsetMs = payload.serverNow - Date.now(); + } + if ( + typeof payload.startTime === "number" && + Number.isFinite(payload.startTime) + ) { + gameStartTimeMs = payload.startTime; + } + // 実際のフィールドサイズを反映 + if ( + typeof payload.gridCols === "number" && + typeof payload.gridRows === "number" + ) { + fieldMaxX = payload.gridCols; + fieldMaxY = payload.gridRows; + // 初期座標がフィールド外なら補正 + posX = Math.min(posX, fieldMaxX - BOT_RADIUS); + posY = Math.min(posY, fieldMaxY - BOT_RADIUS); + } } else if (gameStartTimeMs === null) { - gameStartTimeMs = Date.now(); + gameStartTimeMs = getServerNow(); } if (!readySent) { socket.emit("ready-for-game"); readySent = true; } + + // startTime まで待ってからゲームプレイを開始(移動・爆弾) + const delayMs = Math.max(0, (gameStartTimeMs ?? getServerNow()) - getServerNow()); + gameplayStartTimer = setTimeout(() => { + gameStarted = true; + startMoveTimer(); + startBombCheckTimer(); + }, delayMs); }); - socket.on("current-players", (players: CurrentPlayer[]) => { - const self = players.find((player) => player.id === socket.id); + // 初期プレイヤー一覧(自分の座標・チームIDを同期) + socket.on("current-players", (players: CurrentPlayerPayload[]) => { + const self = players.find((p) => p.id === socket.id); if (self) { - posX = self.x; - posY = self.y; + myTeamId = self.teamId; + if ("x" in self && "y" in self) { + posX = self.x; + posY = self.y; + spawnX = self.x; + spawnY = self.y; + } } - // サーバー状態と同期後に定期的な移動イベントを送信。 - if (gameStarted && BOT_CAN_MOVE && !moveTimer && MOVE_TICK_MS > 0) { - updateDirection(); - moveTimer = setInterval(tickMove, MOVE_TICK_MS); + // 移動タイマーの開始は gameplayStartTimer に委ねる + }); + + // 他プレイヤーの位置差分(update-players は追跡用途のみ,ボット制御には不要) + // socket.on("update-players", ...) は省略(負荷テストでは自分の動きのみ制御) + + // 新規参加プレイヤー通知(追跡不要,受信のみ) + // socket.on("new-player", ...) は省略 + + // プレイヤー退場通知 + socket.on("remove-player", (_playerId: string) => { + // 追跡中プレイヤーの削除が必要な場合はここで処理 + }); + + // マップ更新(追跡不要,受信のみ) + // socket.on("update-map-cells", ...) は省略 + + // ハリケーン全量スナップショット + socket.on( + "current-hurricanes", + (payload: HurricaneStatePayload[]) => { + hurricanes.clear(); + for (const h of payload) { + hurricanes.set(h.id, h); + } + }, + ); + + // ハリケーン差分更新 + socket.on( + "update-hurricanes", + (payload: HurricaneStatePayload[]) => { + for (const h of payload) { + hurricanes.set(h.id, h); + } + }, + ); + + // ハリケーン被弾 + socket.on("hurricane-hit", (payload: { playerId: string }) => { + if (payload.playerId !== socket.id) return; + applyDamage(); + }); + + // 他プレイヤーの爆弾設置通知 + socket.on("bomb-placed", (payload: BombPlacedPayload) => { + trackedBombs.set(payload.bombId, { + bombId: payload.bombId, + ownerTeamId: payload.ownerTeamId, + x: payload.x, + y: payload.y, + explodeAtElapsedMs: payload.explodeAtElapsedMs, + }); + }); + + // 自分の爆弾設置確定(受信のみ) + socket.on("bomb-placed-ack", (_payload: BombPlacedAckPayload) => { + // 自爆はサーバー側で処理しないため追跡不要 + }); + + // 被弾通知(自分または他プレイヤー) + socket.on("player-hit", (payload: { playerId: string }) => { + if (payload.playerId !== socket.id) return; + applyDamage(); + }); + + // ゲーム終了: タイマーを止めて切断 + socket.on("game-end", () => { + gameEnded = true; + stopAllTimers(); + socket.disconnect(); + }); + + // 最終結果受信後も切断(game-end が先に来るが念のため) + socket.on("game-result", () => { + if (!gameEnded) { + gameEnded = true; + stopAllTimers(); + socket.disconnect(); } }); socket.on("disconnect", () => { counters.disconnects += 1; - if (moveTimer) { - clearInterval(moveTimer); - moveTimer = null; - } + stopAllTimers(); }); socket.on("connect_error", () => { @@ -261,10 +576,7 @@ return { stop() { - if (moveTimer) { - clearInterval(moveTimer); - moveTimer = null; - } + stopAllTimers(); socket.disconnect(); }, };