Newer
Older
PixelPaintWar / test / load-bot.mjs
import { io } from "socket.io-client";
import { config } from "@repo/shared";

const { GAME_CONFIG, NETWORK_CONFIG } = config;

const DEFAULT_URL = NETWORK_CONFIG.PROD_SERVER_URL;
const DEFAULT_BOTS = 20;
const DEFAULT_ROOMS = 1;
const DEFAULT_DURATION_MS = 60_000;
const DEFAULT_JOIN_DELAY_MS = 25;
const DEFAULT_MOVE_INTERVAL_MS = 200;
const DEFAULT_START_DELAY_MS = 800;
const DEFAULT_MAX_X = GAME_CONFIG.MAP_WIDTH;
const DEFAULT_MAX_Y = GAME_CONFIG.MAP_HEIGHT;

const SOCKET_PATH = NETWORK_CONFIG.SOCKET_IO_PATH;
const SOCKET_TRANSPORTS = NETWORK_CONFIG.SOCKET_TRANSPORTS;

// CLI引数を先に解析して、環境変数やデフォルトを上書きできるようにする。
const args = parseArgs(process.argv.slice(2));

// 実行時オプションを集約(CLI > 環境変数 > デフォルト)。
const options = {
  url: args.url ?? process.env.TARGET_URL ?? DEFAULT_URL,
  bots: toInt(args.bots ?? process.env.BOTS, DEFAULT_BOTS),
  rooms: toInt(args.rooms ?? process.env.ROOMS, DEFAULT_ROOMS),
  roomPrefix: args.roomPrefix ?? process.env.ROOM_PREFIX ?? "load-room",
  durationMs: toInt(args.durationMs ?? process.env.DURATION_MS, DEFAULT_DURATION_MS),
  joinDelayMs: toInt(args.joinDelayMs ?? process.env.JOIN_DELAY_MS, DEFAULT_JOIN_DELAY_MS),
  startGame: toBool(args.startGame ?? process.env.START_GAME ?? "1"),
  startDelayMs: toInt(args.startDelayMs ?? process.env.START_DELAY_MS, DEFAULT_START_DELAY_MS),
  moveIntervalMs: toInt(args.moveIntervalMs ?? process.env.MOVE_INTERVAL_MS, DEFAULT_MOVE_INTERVAL_MS),
  maxX: toInt(args.maxX ?? process.env.MAX_X, DEFAULT_MAX_X),
  maxY: toInt(args.maxY ?? process.env.MAX_Y, DEFAULT_MAX_Y),
};

// 実行後の簡易サマリ用カウンタ。
const stats = {
  connected: 0,
  joined: 0,
  startSent: 0,
  moveSent: 0,
  disconnects: 0,
  errors: 0,
  gameStarts: 0,
};

const bots = [];
// 既にオーナーがいるルームを追跡。
const perRoomOwnerIndex = new Set();

console.log("Load test starting...", options);

// 接続スパイクを避けるため参加タイミングを分散。
for (let i = 0; i < options.bots; i += 1) {
  const delay = i * options.joinDelayMs;
  setTimeout(() => {
    const bot = createBot(i, options, perRoomOwnerIndex, stats);
    bots.push(bot);
  }, delay);
}

// 指定時間後に全ボットを停止。
setTimeout(() => {
  console.log("Stopping bots...");
  for (const bot of bots) {
    bot.stop();
  }
  setTimeout(() => {
    console.log("Final stats:", stats);
    process.exit(0);
  }, 500);
}, options.durationMs);

function createBot(index, opts, owners, counters) {
  // ルームへラウンドロビンで分散。
  const roomIndex = index % opts.rooms;
  const roomId = `${opts.roomPrefix}-${roomIndex}`;
  const playerName = `bot-${index}`;
  // 各ルームの先頭ボットをオーナー扱いにする。
  const isOwner = index < opts.rooms;

  if (isOwner) {
    owners.add(roomIndex);
  }

  // 固定トランスポートで再接続なしのクライアント接続。
  const socket = io(opts.url, {
    transports: SOCKET_TRANSPORTS,
    path: SOCKET_PATH,
    reconnection: false,
    timeout: 10_000,
  });

  let moveTimer = null;

  socket.on("connect", () => {
    counters.connected += 1;
    // 接続直後にルームへ参加。
    socket.emit("join-room", { roomId, playerName });
    counters.joined += 1;

    if (opts.startGame && isOwner) {
      // 他ボットの参加を待つため開始を少し遅らせる。
      setTimeout(() => {
        socket.emit("start-game");
        counters.startSent += 1;
      }, opts.startDelayMs);
    }
  });

  socket.on("game-start", () => {
    counters.gameStarts += 1;
    // ゲーム開始後に定期的な移動イベントを送信。
    if (!moveTimer && opts.moveIntervalMs > 0) {
      moveTimer = setInterval(() => {
        const x = Math.random() * opts.maxX;
        const y = Math.random() * opts.maxY;
        socket.emit("move", { x, y });
        counters.moveSent += 1;
      }, opts.moveIntervalMs);
    }
  });

  socket.on("disconnect", () => {
    counters.disconnects += 1;
    if (moveTimer) {
      clearInterval(moveTimer);
      moveTimer = null;
    }
  });

  socket.on("connect_error", () => {
    counters.errors += 1;
  });

  return {
    stop() {
      if (moveTimer) {
        clearInterval(moveTimer);
        moveTimer = null;
      }
      socket.disconnect();
    },
  };
}

function parseArgs(argv) {
  const result = {};
  for (let i = 0; i < argv.length; i += 1) {
    const current = argv[i];
    if (!current.startsWith("--")) continue;

    const [rawKey, inlineValue] = current.slice(2).split("=");
    const key = toCamel(rawKey);

    if (inlineValue !== undefined) {
      result[key] = inlineValue;
      continue;
    }

    const next = argv[i + 1];
    if (next && !next.startsWith("--")) {
      result[key] = next;
      i += 1;
    } else {
      result[key] = "true";
    }
  }
  return result;
}

// CLIのkebab-caseをcamelCaseに変換。
function toCamel(value) {
  return value.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
}

// 整数の安全なパース(失敗時はフォールバック)。
function toInt(value, fallback) {
  const num = Number.parseInt(String(value), 10);
  return Number.isFinite(num) ? num : fallback;
}

// フラグの真値として一般的な文字列を許可。
function toBool(value) {
  if (value === true) return true;
  if (value === false) return false;
  const normalized = String(value).toLowerCase();
  return normalized === "1" || normalized === "true" || normalized === "yes";
}