0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

オンライン対戦できるテキサスホールデムのWebアプリ開発(アップデート版)

Posted at

オンライン対戦可能なテキサスホールデムを作っています。

最新版では以下の機能追加やバグ改善をしました。

・Geminiとのオンライン対戦機能の強化
・キッカー勝負になったとき正しく勝敗判定をする。
・その他勝敗判定のバグ改善
・UI(ルーム一覧表示機能)の強化

まだまだ追加したい機能が山盛りですが、今日の改善で結構普通に遊べるようになったかな?と思います。

ご意見ご感想などぜひよろしくお願いいたします。

ソースコード

texasholdem.html
<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="UTF-8">
  <title>Texas Hold'em Online</title>
  <style>
    body {
      background: #006400;
      color: #fff;
      font-family: sans-serif;
      padding: 20px;
    }

    .container {
      max-width: 800px;
      margin: auto;
      background: #228B22;
      padding: 20px;
      border-radius: 10px;
    }

    input,
    button {
      margin: 5px;
      padding: 5px 10px;
      border: none;
      border-radius: 4px;
    }

    button {
      cursor: pointer;
    }

    .player {
      margin-bottom: 5px;
      padding: 5px;
    }

    .current-turn {
      background-color: yellow;
      color: black;
      font-weight: bold;
    }

    .card {
      width: 70px;
      margin: 2px;
    }

    #joinedRooms>div {
      border: 1px solid #fff;
      margin: 10px;
      padding: 10px;
    }

    .room-actions {
      margin-top: 10px;
    }

    .room-messages {
      margin-top: 10px;
      color: yellow;
    }

    .community-cards {
      margin-top: 10px;
    }

    .my-turn-room {
      border: 3px solid red !important;
    }

    /* ルーム一覧のテーブルをスタイリング */
    #roomList table {
      width: 100%;
      border-collapse: collapse;
      margin-top: 10px;
      background: #006400;
    }

    #roomList th,
    #roomList td {
      border: 1px solid #fff;
      padding: 8px;
      text-align: center;
    }

    #roomList th {
      background: #228B22;
      cursor: pointer;
    }
  </style>
  <script>
    // --- カード画像のプリロード(既存処理) ---
    const cardImages = {};
    function preloadCards() {
      const suits = ["C", "D", "H", "S"];
      const ranks = ["2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A"];
      for (let suit of suits) {
        for (let rank of ranks) {
          const key = rank + suit;
          const img = new Image();
          img.src = `playingcard/${key}.svg`;
          cardImages[key] = img;
        }
      }
      const backImg = new Image();
      backImg.src = "playingcard/back.svg";
      cardImages["back"] = backImg;

      const backGrayImg = new Image();
      backGrayImg.src = "playingcard/back-gray.svg";
      cardImages["back-gray"] = backGrayImg;

      console.log("Cards preloaded:", Object.keys(cardImages));
    }
    preloadCards();
    window.cardImages = cardImages;

    // --- カード描画(既存処理) ---
    function getCardImageHtml(card) {
      if (!card) return "";
      let key = "";
      if (card.rank === "??" && card.suit === "??") {
        key = card.folded ? "back-gray" : "back";
      } else {
        let suitLetter = "";
        switch (card.suit) {
          case "": suitLetter = "C"; break;
          case "": suitLetter = "D"; break;
          case "": suitLetter = "H"; break;
          case "": suitLetter = "S"; break;
        }
        key = `${card.rank}${suitLetter}`;
      }
      if (window.cardImages && window.cardImages[key]) {
        return `<img src="${window.cardImages[key].src}" alt="${key}" class="card">`;
      } else {
        return `<img src="playingcard/${key}.svg" alt="${key}" class="card">`;
      }
    }

    function renderHand(hand) {
      if (!hand || hand.length === 0) return "No Cards";
      return hand.map(card => getCardImageHtml(card)).join("");
    }
    window.renderHand = renderHand;
  </script>
  <script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
</head>

<body>
  <div class="container">
    <h1>テキサスホールデムオンライン</h1>

    <!-- 名前入力とルーム一覧ボタン -->
    <div>
      <input type="text" id="playerName" placeholder="名前">
      <button id="loadRoomListBtn">ルーム一覧を読み込み</button>
      <!-- ここにテーブル形式のルーム一覧を表示する -->
      <div id="roomList">
        <!-- テーブルヘッダ部分 -->
        <table>
          <thead>
            <tr>
              <th onclick="sortRooms('tableName')">テーブル名</th>
              <th onclick="sortRooms('stake')">ステーク</th>
              <th onclick="sortRooms('gameType')">ゲーム</th>
              <th onclick="sortRooms('playerCount')">着席者数</th>
              <th onclick="sortRooms('waitingCount')">空席待ち</th>
              <th>参加</th>
            </tr>
          </thead>
          <tbody id="roomListBody">
            <!-- ルームごとの行が動的に入る -->
          </tbody>
        </table>
      </div>
    </div>

    <hr>
    <div id="messages" style="margin-top:10px; color:yellow;"></div>
    <div id="result" style="margin-top:10px;"></div>

    <!-- 参加中のルーム情報を表示 -->
    <div id="joinedRooms"></div>
    <div id="countdownTimer" style="font-size: 24px; text-align: center; margin-bottom: 10px;"></div>
  </div>

  <script>
    //const socket = io("http://localhost:8080");
    const socket = io("https://socket-io-service-466625290686.us-central1.run.app");
    let myId = null;

    // 受け取ったルームデータを保持し、ソートや再描画に利用する
    let roomsData = [];
    let currentSortColumn = null;
    let currentSortDirection = 1; // 1: 昇順, -1: 降順

    const roomMessages = {};
    const loadRoomListBtn = document.getElementById('loadRoomListBtn');
    const roomListDiv = document.getElementById('roomList');
    const roomListBody = document.getElementById('roomListBody');
    const playerNameInput = document.getElementById('playerName');

    // --- ソート用関数 ---
    function sortRooms(column) {
      if (currentSortColumn === column) {
        // 同じ列なら昇順・降順を反転
        currentSortDirection = -currentSortDirection;
      } else {
        currentSortColumn = column;
        currentSortDirection = 1;
      }

      roomsData.sort((a, b) => {
        let cmp = 0;
        if (column === "tableName") {
          // "テーブル"の後ろの数値部分を抽出して数値として比較
          const numA = parseInt(a.tableName.replace("テーブル", ""), 10);
          const numB = parseInt(b.tableName.replace("テーブル", ""), 10);
          cmp = numA - numB;
        } else {
          // 他の列は数値の場合は数値比較、そうでなければ文字列比較
          if (!isNaN(a[column]) && !isNaN(b[column])) {
            cmp = a[column] - b[column];
          } else {
            cmp = a[column].toString().localeCompare(b[column].toString());
          }
        }
        return cmp * currentSortDirection;
      });

      renderRoomsTable();
    }


    // --- ルーム一覧テーブルを描画 ---
    function renderRoomsTable() {
      // tbodyをクリア
      roomListBody.innerHTML = "";

      roomsData.forEach((r) => {
        // それぞれのカラムを表示
        const tr = document.createElement("tr");

        // テーブル名
        const tdName = document.createElement("td");
        tdName.textContent = r.tableName;
        tr.appendChild(tdName);

        // ステーク (SB/BB)
        const tdStake = document.createElement("td");
        tdStake.textContent = r.stake;
        tr.appendChild(tdStake);

        // ゲーム (VS 対人 or VS Gemini)
        const tdGame = document.createElement("td");
        tdGame.textContent = r.gameType;
        tr.appendChild(tdGame);

        // 着席者数
        const tdPlayerCount = document.createElement("td");
        tdPlayerCount.textContent = r.playerCount;
        tr.appendChild(tdPlayerCount);

        // 空席待ち
        const tdWaiting = document.createElement("td");
        tdWaiting.textContent = r.waitingCount;
        tr.appendChild(tdWaiting);

        // 参加ボタン
        const tdJoin = document.createElement("td");
        const joinBtn = document.createElement("button");
        joinBtn.textContent = "参加";
        joinBtn.onclick = () => joinRoom(r.roomId);
        tdJoin.appendChild(joinBtn);
        tr.appendChild(tdJoin);

        roomListBody.appendChild(tr);
      });
    }

    // --- ルームに参加する処理 ---
    function joinRoom(roomId) {
      const name = playerNameInput.value.trim();
      if (!name) {
        alert("名前を入力してください");
        return;
      }
      socket.emit("joinGame", { playerName: name, roomId });
    }

    // --- ルーム一覧をサーバーに要求 ---
    function loadRoomList() {
      console.log("getRooms emitted");
      socket.emit("getRooms");
    }

    loadRoomListBtn.onclick = loadRoomList;

    // --- ソケット接続時 ---
    socket.on('connect', () => {
      myId = socket.id;
      console.log("Connected with socket id:", myId);
      loadRoomList();
    });

    // --- ルーム一覧を受信したとき ---
    socket.on("roomList", (rooms) => {
      console.log("roomList received:", rooms);
      // roomsData を組み立てる
      // 例: rooms[i] に sb, bb, type, playerCount, waitingCount, roomId などの情報があると仮定
      // テーブル名は「テーブル1, テーブル2, ...」として付ける
      // もしサーバーから "tableName" が来るならそれを使ってもOK

      roomsData = rooms.map((r, index) => {
        // SB/BB の文字列を作成 (例: "10/20")
        const stakeStr = `${r.sb}/${r.bb}`;
        // ゲームタイプ
        const gameTypeStr = r.type === "human" ? "VS 対人" : "VS Gemini";

        return {
          roomId: r.roomId,
          tableName: `テーブル${index + 1}`,  // 任意で付与
          stake: stakeStr,
          gameType: gameTypeStr,
          playerCount: r.playerCount || 0,
          waitingCount: r.waitingCount || 0
        };
      });

      // 受け取った時点で描画(デフォルトはそのままの順番)
      renderRoomsTable();
    });

    // --- ゲーム開始やアクション送信など既存処理 ---
    const joinedRoomsDiv = document.getElementById("joinedRooms");
    function sendActionForRoom(roomId, actionType) {
      if (actionType === "fold") {
        stopCountdown();
      }
      const input = document.getElementById('raiseAmount-' + roomId);
      const raiseAmount = input ? parseInt(input.value, 10) || 0 : 0;
      socket.emit("playerAction", { roomId, action: actionType, raiseAmount });
    }
    function startGameForRoom(roomId) {
      socket.emit("startGame", { roomId });
      let roomMsgDiv = document.getElementById("roomMsg-" + roomId);
      if (roomMsgDiv) { roomMsgDiv.innerHTML = ""; }
    }
    // --- カウントダウン用グローバル変数 ---
    let countdownInterval = null;
    let autoActionTimeout = null;

    // カウントダウン表示用関数
    function startCountdown(seconds) {
      const timerEl = document.getElementById("countdownTimer");
      if (!timerEl) return;
      timerEl.textContent = `残り ${seconds} 秒`;
      // 既存のタイマーがあればクリア
      if (countdownInterval) clearInterval(countdownInterval);
      countdownInterval = setInterval(() => {
        seconds--;
        if (seconds <= 0) {
          clearInterval(countdownInterval);
          timerEl.textContent = "";
        } else {
          timerEl.textContent = `残り ${seconds} 秒`;
        }
      }, 1000);
    }

    // タイマーを停止する関数
    function stopCountdown() {
      const timerEl = document.getElementById("countdownTimer");
      if (countdownInterval) clearInterval(countdownInterval);
      if (autoActionTimeout) clearTimeout(autoActionTimeout);
      countdownInterval = null;
      autoActionTimeout = null;
      if (timerEl) timerEl.textContent = "";
    }

    // --- メッセージやゲーム更新のハンドラ(既存) ---
    socket.on("roomMessage", (data) => {
      if (data.roomId) {
        roomMessages[data.roomId] = data.message;
        let roomMsgDiv = document.getElementById("roomMsg-" + data.roomId);
        if (roomMsgDiv) { roomMsgDiv.innerHTML = `<h3>${data.message}</h3>`; }
      } else {
        document.getElementById("messages").innerHTML = data.message;
      }
    });

    socket.on("gameUpdate", (data) => {
      const roomId = data.roomId;
      let roomDiv = document.getElementById("room-" + roomId);
      if (!roomDiv) {
        roomDiv = document.createElement("div");
        roomDiv.id = "room-" + roomId;
        joinedRoomsDiv.appendChild(roomDiv);
      }
      let roomMsgDiv = document.getElementById("roomMsg-" + roomId);
      if (!roomMsgDiv) {
        roomMsgDiv = document.createElement("div");
        roomMsgDiv.id = "roomMsg-" + roomId;
        roomMsgDiv.className = "room-messages";
        roomDiv.appendChild(roomMsgDiv);
      }
      let roomContentDiv = document.getElementById("roomContent-" + roomId);
      if (!roomContentDiv) {
        roomContentDiv = document.createElement("div");
        roomContentDiv.id = "roomContent-" + roomId;
        roomDiv.insertBefore(roomContentDiv, roomMsgDiv);
      }
      const viewer = data.players.find(p => p.id === myId);
      const maxRaise = viewer ? viewer.chips : 0;

      // チェックボタンの活性/非活性を、数値として viewer.bet と data.currentBet を比較
      const viewerBet = Number(viewer ? viewer.bet : 0);
      const tableBet = Number(data.currentBet);
      const disableCheck = (viewerBet !== tableBet);

      let actionsHtml = '';
      if (data.phase === "WAITING") {
        actionsHtml = `
      <div class="room-actions">
        <button onclick="startGameForRoom(${roomId})">ゲーム開始</button>
      </div>
    `;
      } else {
        actionsHtml = `
      <div class="room-actions">
        <button onclick="sendActionForRoom(${roomId}, 'fold')">フォールド</button>
        <button onclick="sendActionForRoom(${roomId}, 'call')">コール</button>
        <button onclick="sendActionForRoom(${roomId}, 'check')" ${disableCheck ? 'disabled' : ''}>チェック</button>
        <div>
          <label for="raiseAmount-${roomId}">レイズ額: </label>
          <input type="range" id="raiseAmount-${roomId}" min="0" max="${maxRaise}" value="0"
                 oninput="document.getElementById('raiseAmountOutput-${roomId}').value = this.value">
          <output id="raiseAmountOutput-${roomId}">0</output>
          <button onclick="sendActionForRoom(${roomId}, 'raise')">レイズ</button>
        </div>
      </div>
    `;
      }

      roomContentDiv.innerHTML = `
    <div class="room-status">
      <h3>ルーム${roomId}</h3>
      <p>フェーズ: ${data.phase} | ポット: ${data.pot} | 現在のベット: ${data.currentBet}</p>
      <p>現在の手番: ${data.currentPlayerId || ""}</p>
      <div class="community-cards">
        <h3>コミュニティカード:</h3>
        ${renderHand(data.communityCards)}
      </div>
      <div>
        ${data.players.map(p => `
          <div class="player ${p.id === data.currentPlayerId ? 'current-turn' : ''}">
            <strong>${p.positionLabel} ${p.name}</strong> - Chips: ${p.chips}, Bet: ${p.bet}, Fold: ${p.hasFolded}
            <br>Hand: ${renderHand(p.hand)} ${p.rank ? `| ${p.rank}` : ''}
          </div>
        `).join('')}
      </div>
    </div>
    ${actionsHtml}
  `;
      if (data.currentPlayerId === myId) {
        roomDiv.classList.add("my-turn-room");
        roomDiv.scrollIntoView({ behavior: "smooth", block: "start" });
      } else {
        roomDiv.classList.remove("my-turn-room");
      }

      // --- カウントダウン制御 ---
      // ゲーム終了やショウダウン、またはフェーズが WAITING の場合は常にタイマーを停止
      if (data.phase === "WAITING" || data.phase === "SHOWDOWN") {
        stopCountdown();
      }
      // それ以外で、自分の手番ならカウントダウン開始
      else if (data.currentPlayerId === myId && viewer && !viewer.hasFolded) {
        startCountdown(10);
        if (autoActionTimeout) clearTimeout(autoActionTimeout);
        autoActionTimeout = setTimeout(() => {
          const updatedViewer = data.players.find(p => p.id === myId);
          const autoAction = (updatedViewer && updatedViewer.bet === data.currentBet) ? "check" : "fold";
          console.log(`[DEBUG] Auto action timeout reached. Emitting auto action: ${autoAction}`);
          sendActionForRoom(data.roomId, autoAction);
          stopCountdown();
        }, 10000);
      }
      else {
        stopCountdown();
      }
    });


    function sendActionForRoom(roomId, actionType) {
      // ※ここではエラーや正常な手動アクション送信時は gameUpdate で再更新されるので、
      // このタイミングで stopCountdown() は呼ばない(不要な場合は、後から gameUpdate で反映される)
      const input = document.getElementById('raiseAmount-' + roomId);
      const raiseAmount = input ? parseInt(input.value, 10) || 0 : 0;
      socket.emit("playerAction", { roomId, action: actionType, raiseAmount });
    }

    socket.on("gameMessage", (data) => {
      // 「チェック可能な状況でフォールドはできません」というエラーの場合はカウントダウンを止めずにエラー表示のみ
      if (data.message.includes("チェック可能な状況でフォールドはできません")) {
        let roomMsg = document.getElementById("roomMsg-" + data.roomId);
        if (roomMsg) {
          roomMsg.innerHTML = `<h3>${data.message}</h3>`;
        }
        // カウントダウンはそのまま継続
      } else {
        // その他の場合は通常どおりカウントダウンを停止する
        stopCountdown();
        let roomMsg = document.getElementById("roomMsg-" + data.roomId);
        if (roomMsg) {
          roomMsg.innerHTML = `<h3>${data.message}</h3>`;
        }
      }
    });

    socket.on("gameResult", (data) => {
      stopCountdown();
      if (data.roomId) {
        let roomMsgDiv = document.getElementById("roomMsg-" + data.roomId);
        if (roomMsgDiv) {
          roomMsgDiv.innerHTML = `<h3 style="white-space: pre-line;">${data.message}</h3>`;
        }
      } else {
        document.getElementById("messages").innerHTML = data.message;
      }
    });

    socket.on("winNotification", (data) => {
      stopCountdown();
      if (data.roomId && data.winnerId && data.winnerId === myId) {
        let roomMsgDiv = document.getElementById("roomMsg-" + data.roomId);
        if (roomMsgDiv) {
          roomMsgDiv.innerHTML += `<h3 style="color:red;">${data.message}</h3>`;
        }
      }
    });

    socket.on("clearRoomMessage", (data) => {
      if (data.roomId) {
        let roomMsgDiv = document.getElementById("roomMsg-" + data.roomId);
        if (roomMsgDiv) { roomMsgDiv.innerHTML = ""; }
      }
    });
  </script>
</body>

</html>
texasgame.js
// game.js
const { PHASES, SMALL_BLIND, BIG_BLIND } = require("./config");
const { convertToPokerSolverFormat } = require("./utils");
const { Hand } = require("pokersolver");

class TexasHoldemGame {
  constructor() {
    this.players = [];
    this.deck = [];
    this.communityCards = [];
    this.pot = 0;
    this.currentBet = 0;
    this.currentPlayerIndex = 0;
    this.phase = PHASES.WAITING;
    this.dealerButtonIndex = 0;
    this.actionStartIndex = 0;
    this.smallBlindIndex = null;
    this.bigBlindIndex = null;
    this.waitingPlayers = [];
    this.showdownEvaluated = false;
    this.onShowdown = this.evaluateShowdown.bind(this);
    this.sendResultCallback = null;
  }

  incorporateWaitingPlayers() {
    console.log(
      "[DEBUG incorporateWaitingPlayers] waitingPlayers:",
      this.waitingPlayers.map((p) => ({
        id: p.id,
        name: p.name,
        chips: p.chips,
        disconnected: p.disconnected,
      }))
    );
    const validWaiters = this.waitingPlayers.filter(
      (p) => p.chips > 0 && !p.disconnected
    );
    console.log(
      `[DEBUG incorporateWaitingPlayers] Valid waiting players: ${validWaiters.length} / ${this.waitingPlayers.length}`
    );
    this.players.push(...validWaiters);
    this.waitingPlayers = [];
    console.log(
      "[DEBUG incorporateWaitingPlayers] players:",
      this.players.map((p) => ({
        id: p.id,
        name: p.name,
        chips: p.chips,
        disconnected: p.disconnected,
      }))
    );
  }

  initializeDeck() {
    const suits = ["", "", "", ""];
    const ranks = [
      "2",
      "3",
      "4",
      "5",
      "6",
      "7",
      "8",
      "9",
      "10",
      "J",
      "Q",
      "K",
      "A",
    ];
    this.deck = [];
    for (let suit of suits) {
      for (let rank of ranks) {
        this.deck.push({ suit, rank });
      }
    }
    // シャッフル
    for (let i = this.deck.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      [this.deck[i], this.deck[j]] = [this.deck[j], this.deck[i]];
    }
  }

  addPlayer({ id, name, chips = 1000 }) {
    if (this.phase === PHASES.WAITING) {
      this.players.push({
        id,
        name,
        chips,
        bet: 0,
        totalBet: 0,
        hasFolded: false,
        isAllIn: false,
        hand: [],
        hasActed: false,
        disconnected: false,
      });
    } else {
      this.waitingPlayers.push({
        id,
        name,
        chips,
        bet: 0,
        totalBet: 0,
        hasFolded: false,
        isAllIn: false,
        hand: [],
        hasActed: false,
      });
    }
  }

  removePlayer(socketId) {
    this.players = this.players.filter((p) => p.id !== socketId);
    this.waitingPlayers = this.waitingPlayers.filter((p) => p.id !== socketId);
  }

  startRound() {
    this.showdownEvaluated = false;
    if (this.phase !== PHASES.WAITING && this.players.length > 1) {
      this.dealerButtonIndex =
        (this.dealerButtonIndex + 1) % this.players.length;
    }
    this.phase = PHASES.PRE_FLOP;
    this.initializeDeck();
    this.communityCards = [];
    this.pot = 0;
    this.currentBet = 0;

    for (let p of this.players) {
      p.bet = 0;
      p.totalBet = 0;
      p.hasFolded = false;
      p.isAllIn = false;
      p.hand = [];
      p.hasActed = false;
    }
    for (let p of this.players) {
      p.hand.push(this.deck.pop(), this.deck.pop());
    }

    if (this.players.length >= 2) {
      if (this.players.length === 2) {
        this.smallBlindIndex = this.dealerButtonIndex;
        this.bigBlindIndex = (this.dealerButtonIndex + 1) % this.players.length;
        this._postBet(this.players[this.smallBlindIndex], SMALL_BLIND);
        this._postBet(this.players[this.bigBlindIndex], BIG_BLIND);
        this.currentBet = BIG_BLIND;
        this.currentPlayerIndex = this.smallBlindIndex;
        this.actionStartIndex = this.currentPlayerIndex;
      } else {
        const sbIndex = (this.dealerButtonIndex + 1) % this.players.length;
        const bbIndex = (this.dealerButtonIndex + 2) % this.players.length;
        this._postBet(this.players[sbIndex], SMALL_BLIND);
        this._postBet(this.players[bbIndex], BIG_BLIND);
        this.currentBet = BIG_BLIND;
        this.currentPlayerIndex =
          (this.dealerButtonIndex + 3) % this.players.length;
        this.actionStartIndex = this.currentPlayerIndex;
        this.smallBlindIndex = sbIndex;
        this.bigBlindIndex = bbIndex;
      }
    }
    console.log(
      `[DEBUG startRound] phase=${this.phase}, dealerButtonIndex=${this.dealerButtonIndex}, smallBlindIndex=${this.smallBlindIndex}, bigBlindIndex=${this.bigBlindIndex}, currentPlayerIndex=${this.currentPlayerIndex}`
    );
  }

  _postBet(player, amount) {
    let toPost = amount;
    if (amount > player.chips) {
      toPost = player.chips;
      player.isAllIn = true;
      player.hasActed = true;
    }
    player.chips -= toPost;
    player.bet += toPost;
    player.totalBet += toPost;
    this.pot += toPost;
  }

  // ここから新規追加
  executePlayerAction(player, action) {
    console.log(
      `[DEBUG] executePlayerAction: Executing action "${action}" for player ${player.id}`
    );
    return this.handleAction(player.id, action);
  }

  nextPlayerTurn() {
    console.log("[DEBUG] nextPlayerTurn: Moving to next player");
    return this.nextPlayer();
  }

  // 自動アクションの処理(修正後)
  startPlayerTurn(player) {
    console.log(
      `[DEBUG] startPlayerTurn: Starting turn for player ${player.id} with a 10-second timer.`
    );
    clearTimeout(player.turnTimer);
    player.turnTimer = setTimeout(() => {
      const autoAction = this.currentBet > 0 ? "fold" : "check";
      console.log(
        `[DEBUG] Auto action timer fired for player ${player.id}: ${autoAction}`
      );
      this.executePlayerAction(player, autoAction);
      player.consecutiveTimeouts = (player.consecutiveTimeouts || 0) + 1;
      if (player.consecutiveTimeouts >= 2) {
        console.log(
          `[DEBUG] Player ${player.id} reached consecutive timeout limit. Kicking player.`
        );
        this.kickPlayer(player);
      }
      this.nextPlayerTurn();
    }, 10000);
  }

  // 手動アクションの処理(修正後)
  onPlayerAction(player, action) {
    console.log(
      `[DEBUG] onPlayerAction: Player ${player.id} performed action "${action}" before timeout.`
    );
    clearTimeout(player.turnTimer);
    this.executePlayerAction(player, action);
    player.consecutiveTimeouts = 0;
    this.nextPlayerTurn();
  }

  handleAction(playerId, action, raiseAmount = 0) {
    if (this.phase === PHASES.SHOWDOWN) {
      console.log("[DEBUG] Action received during SHOWDOWN phase, ignoring.");
      return { error: "ショウダウン中は操作できません" };
    }
    console.log("====== handleAction START ======");
    console.log(
      `[DEBUG] Called by playerId=${playerId}, action=${action}, raiseAmount=${raiseAmount}`
    );
    console.log(
      `[DEBUG] currentPlayerIndex=${this.currentPlayerIndex}, currentBet=${this.currentBet}`
    );

    this.players.forEach((p, i) => {
      console.log(
        `[DEBUG] Player idx=${i}, id=${p.id}, name=${p.name}, folded=${p.hasFolded}, allIn=${p.isAllIn}, hasActed=${p.hasActed}, bet=${p.bet}, chips=${p.chips}`
      );
    });

    const playerIndex = this.players.findIndex((p) => p.id === playerId);
    if (playerIndex !== this.currentPlayerIndex) {
      console.log("[DEBUG] -> Not the correct turn for this player.");
      console.log("====== handleAction END (error) ======");
      return { error: "現在のアクションはあなたの番ではありません" };
    }

    const player = this.players[playerIndex];
    if (player.hasFolded || player.isAllIn) {
      console.log("[DEBUG] -> Player is already folded or all-in.");
      console.log("====== handleAction END (error) ======");
      return { error: "すでにフォールドまたはオールインしています" };
    }

    switch (action) {
      case "fold":
        // チェック可能な状況(自分のベットが currentBet と等しい場合)は、フォールドが意味を持たない
        if (player.bet === this.currentBet) {
          if (player.id === "GeminiAI") {
            console.log(
              "[DEBUG] -> GeminiAI attempted fold in checkable situation; overriding to check."
            );
            player.hasActed = true;
            break;
          } else {
            console.log(
              "[DEBUG] -> Attempted to fold in a checkable situation; fold is not allowed."
            );
            return { error: "チェック可能な状況でフォールドはできません" };
          }
        }
        // それ以外は通常のフォールド処理
        player.hasFolded = true;
        player.hasActed = true;
        console.log(`[DEBUG] -> Player ${player.id} folded.`);
        break;

      case "check":
        if (player.bet !== this.currentBet && this.currentBet !== 0) {
          console.log("[DEBUG] -> Attempted to check, but bet mismatch.");
          console.log("====== handleAction END (error) ======");
          return {
            error: "チェックは現在のベット額と一致している場合のみ可能です",
          };
        }
        player.hasActed = true;
        console.log(`[DEBUG] -> Player ${player.id} checked.`);
        break;
      case "call": {
        const diff = this.currentBet - player.bet;
        console.log(
          `[DEBUG] -> call diff=${diff}, playerChips=${player.chips}`
        );
        if (diff > 0) {
          if (diff >= player.chips) {
            console.log("[DEBUG] -> Player goes all-in (call).");
            this._postBet(player, player.chips);
            player.isAllIn = true;
          } else {
            this._postBet(player, diff);
            console.log(`[DEBUG] -> Player calls. Bet +${diff}.`);
          }
        } else {
          console.log("[DEBUG] -> Already matched or behind.");
        }
        player.hasActed = true;
        break;
      }
      case "raise": {
        const additional = raiseAmount;
        if (additional <= 0) {
          console.log("[DEBUG] -> Invalid raiseAmount (<=0).");
          console.log("====== handleAction END (error) ======");
          return { error: "レイズ額は正の数を入力してください" };
        }
        // 対戦相手の中で、まだアクション可能な(フォールドしていない)プレイヤーを対象にする
        const opponents = this.players.filter(
          (p) => p.id !== player.id && !p.hasFolded
        );
        // すべての対戦相手がオール・イン状態の場合、レイズは許されない
        if (opponents.length > 0 && opponents.every((p) => p.isAllIn)) {
          console.log(
            "[DEBUG] -> Raise not allowed: 全対戦相手がオール・イン状態です。"
          );
          return {
            error:
              "全対戦相手がオールイン状態のため、レイズはできません。コールかフォールドしてください。",
          };
        }

        const finalBet = this.currentBet + additional;
        console.log(
          `[DEBUG] -> finalBet=${finalBet}, currentBet(before)=${this.currentBet}, player.bet=${player.bet}`
        );
        if (finalBet <= this.currentBet) {
          console.log("[DEBUG] -> finalBet <= currentBet => invalid raise.");
          console.log("====== handleAction END (error) ======");
          return {
            error: "レイズ額は現在のベットより大きくなければなりません",
          };
        }
        const diff = finalBet - player.bet;
        if (diff <= 0) {
          console.log("[DEBUG] -> diff <= 0 => already bet enough?");
          console.log("====== handleAction END (error) ======");
          return { error: "既にその額以上をベットしています。" };
        }
        if (diff >= player.chips) {
          console.log("[DEBUG] -> Raise exceeds player's chips => all-in.");
          this._postBet(player, player.chips);
          player.isAllIn = true;
        } else {
          this._postBet(player, diff);
          console.log(`[DEBUG] -> Player raised. Bet +${diff}.`);
        }
        console.log(
          `[DEBUG] -> Updating currentBet from ${this.currentBet} to ${player.bet}`
        );
        this.currentBet = player.bet;
        player.hasActed = true;
        this.players.forEach((p, i) => {
          if (!p.hasFolded && !p.isAllIn && i !== playerIndex) {
            p.hasActed = false;
            console.log(`[DEBUG] -> Reset hasActed for idx=${i}, id=${p.id}`);
          }
        });
        this.actionStartIndex = playerIndex;
        break;
      }

      default:
        console.log("[DEBUG] -> Unknown action.");
        console.log("====== handleAction END (error) ======");
        return { error: "不明なアクションです" };
    }

    console.log("[DEBUG] After action processing:");
    console.log(
      `[DEBUG] currentBet=${this.currentBet}, currentPlayerIndex=${this.currentPlayerIndex}`
    );
    this.players.forEach((p, i) => {
      console.log(
        `[DEBUG] idx=${i}, id=${p.id}, name=${p.name}, folded=${p.hasFolded}, allIn=${p.isAllIn}, hasActed=${p.hasActed}, bet=${p.bet}, chips=${p.chips}`
      );
    });

    const activePlayers = this.players.filter((p) => !p.hasFolded);
    if (activePlayers.length === 1) {
      console.log("[DEBUG] => Only one active player left => termination.");
      console.log("====== handleAction END (termination) ======");
      return { termination: true };
    }

    console.log("[DEBUG] -> Calling nextPlayer()...");
    const oldIndex = this.currentPlayerIndex;
    this.nextPlayer();
    console.log(
      `[DEBUG] -> nextPlayer() done. oldIndex=${oldIndex}, newIndex=${this.currentPlayerIndex}`
    );

    const roundDone = this.isBettingRoundComplete();
    console.log(`[DEBUG] isBettingRoundComplete() => ${roundDone}`);
    if (roundDone) {
      console.log("[DEBUG] -> Betting round ended => advancePhase().");
      this.advancePhase();
    }
    console.log("====== handleAction END (success) ======");
    return { success: true };
  }

  isBettingRoundComplete() {
    console.log("[DEBUG isBettingRoundComplete] START phase=", this.phase);
    const activePlayers = this.players.filter((p) => !p.hasFolded);
    if (activePlayers.length <= 1) {
      console.log(
        "[DEBUG isBettingRoundComplete] => true (only one player remains)"
      );
      return true;
    }
    const allAllIn = activePlayers.every((p) => p.isAllIn);
    console.log("[DEBUG isBettingRoundComplete] allAllIn =", allAllIn);
    if (allAllIn) {
      console.log(
        "[DEBUG isBettingRoundComplete] => All players all-in, moving to SHOWDOWN."
      );
      while (this.phase !== PHASES.SHOWDOWN) {
        this.advancePhase();
      }
      return true;
    }
    const everyoneActed = activePlayers.every((p) => {
      if (p.isAllIn || p.hasFolded) return true;
      if (this.currentBet === 0) {
        return p.hasActed;
      }
      return p.bet >= this.currentBet && p.hasActed;
    });
    console.log(
      "[DEBUG isBettingRoundComplete] everyoneActed =",
      everyoneActed
    );
    console.log("[DEBUG isBettingRoundComplete] END =>", everyoneActed);
    return everyoneActed;
  }

  nextPlayer() {
    console.log(
      `[DEBUG nextPlayer] START currentPlayerIndex=${this.currentPlayerIndex}, phase=${this.phase}`
    );
    if (this.players.length === 2) {
      // ヘッズアップの場合、次のプレイヤーがオール・インまたは既にアクション済みであれば、ラウンド終了とみなす
      const nextIndex = (this.currentPlayerIndex + 1) % 2;
      const nextPlayer = this.players[nextIndex];
      if (nextPlayer.isAllIn || nextPlayer.hasActed) {
        // ラウンドは完了していると判断する(もしくは強制的に次フェーズへ進む)
        this.currentPlayerIndex = nextIndex;
        console.log(
          `[DEBUG nextPlayer] 2人対戦: next player is all-in or already acted, round complete.`
        );
        return;
      } else {
        this.currentPlayerIndex = nextIndex;
        // 通常は次のプレイヤーのアクション待ち
        this.players[this.currentPlayerIndex].hasActed = false;
        console.log(
          `[DEBUG nextPlayer] 2人対戦 nextPlayerIndex=${this.currentPlayerIndex}`
        );
        return;
      }
    }

    // 2人以外の場合は従来のロジック
    const total = this.players.length;
    let count = 0;
    while (count < total) {
      this.currentPlayerIndex = (this.currentPlayerIndex + 1) % total;
      const curr = this.players[this.currentPlayerIndex];
      if (curr.disconnected && !curr.hasFolded && !curr.isAllIn) {
        console.log(
          `[DEBUG nextPlayer] Skipping disconnected player: ${curr.name}, id=${curr.id}`
        );
        if (curr.bet < this.currentBet) {
          console.log(
            `[DEBUG nextPlayer] Disconnected player ${curr.name} auto-folding.`
          );
          curr.hasFolded = true;
        } else {
          console.log(
            `[DEBUG nextPlayer] Disconnected player ${curr.name} auto-checking.`
          );
          curr.hasActed = true;
        }
        continue;
      }
      if (!curr.hasFolded && !curr.isAllIn && !curr.hasActed) {
        console.log(
          `[DEBUG nextPlayer] Found next player at index=${this.currentPlayerIndex}, name=${curr.name}, id=${curr.id}`
        );
        return;
      }
      count++;
    }
    if (count >= total) {
      console.error("[ERROR] nextPlayer: 無限ループの可能性");
      this.currentPlayerIndex = -1;
    }
    console.log(
      `[DEBUG nextPlayer] END currentPlayerIndex=${this.currentPlayerIndex}`
    );
  }

  advancePhase() {
    console.log(
      `[DEBUG advancePhase] START currentPhase=${this.phase}, currentPlayerIndex=${this.currentPlayerIndex}`
    );
    console.log("[DEBUG advancePhase] Players state before advancing phase:");
    this.players.forEach((p, i) => {
      console.log(
        `[DEBUG advancePhase] Player idx=${i}, id=${p.id}, name=${p.name}, folded=${p.hasFolded}, allIn=${p.isAllIn}, hasActed=${p.hasActed}, bet=${p.bet}, chips=${p.chips}`
      );
    });
    console.log("[DEBUG advancePhase] Community Cards:", this.communityCards);
    console.log(
      "[DEBUG advancePhase] Pot:",
      this.pot,
      "Current Bet:",
      this.currentBet
    );
    console.log(
      "[DEBUG advancePhase] Dealer Button Index:",
      this.dealerButtonIndex
    );
    console.log(
      "[DEBUG advancePhase] Action Start Index:",
      this.actionStartIndex
    );
    console.log(
      "[DEBUG advancePhase] Small Blind Index:",
      this.smallBlindIndex,
      "Big Blind Index:",
      this.bigBlindIndex
    );

    switch (this.phase) {
      case PHASES.PRE_FLOP:
        this.phase = PHASES.FLOP;
        this.dealFlop();
        console.log("[DEBUG advancePhase] -> Moved to FLOP.");
        this.resetBetsForNextRound();
        break;
      case PHASES.FLOP:
        this.phase = PHASES.TURN;
        this.dealTurn();
        console.log("[DEBUG advancePhase] -> Moved to TURN.");
        this.resetBetsForNextRound();
        break;
      case PHASES.TURN:
        this.phase = PHASES.RIVER;
        this.dealRiver();
        console.log("[DEBUG advancePhase] -> Moved to RIVER.");
        this.resetBetsForNextRound();
        break;
      case PHASES.RIVER:
        this.phase = PHASES.SHOWDOWN;
        console.log("[DEBUG advancePhase] -> Moved to SHOWDOWN.");
        // フェーズが SHOWDOWN になったら2秒後に勝敗判定(ショウダウン評価)を実施
        setTimeout(() => {
          if (typeof this.onShowdown === "function") {
            console.log(
              "[DEBUG advancePhase] -> Triggering showdown evaluation."
            );
            this.onShowdown();
          } else {
            console.log(
              "[DEBUG advancePhase] -> onShowdown callback not defined."
            );
          }
        }, 2000);
        break;
      case PHASES.SHOWDOWN:
        console.log("[DEBUG advancePhase] -> Already at SHOWDOWN.");
        break;
      default:
        console.log("[DEBUG advancePhase] -> Unknown phase.");
        break;
    }
    console.log(
      `[DEBUG advancePhase] END currentPhase=${this.phase}, currentPlayerIndex=${this.currentPlayerIndex}`
    );
  }

  resetBetsForNextRound() {
    console.log(
      `[DEBUG resetBetsForNextRound] START phase=${this.phase}, currentPlayerIndex(before)=${this.currentPlayerIndex}`
    );
    this.currentBet = 0;
    for (let p of this.players) {
      if (!p.hasFolded && !p.isAllIn) {
        p.bet = 0;
        p.hasActed = false;
      }
    }
    if (this.phase !== PHASES.PRE_FLOP) {
      if (this.players.length === 2) {
        this.currentPlayerIndex = this.bigBlindIndex;
      } else {
        let eligibleFound = false;
        let nextIndex = (this.dealerButtonIndex + 1) % this.players.length;
        for (let i = 0; i < this.players.length; i++) {
          const idx = (nextIndex + i) % this.players.length;
          const player = this.players[idx];
          if (!player.hasFolded && !player.isAllIn) {
            this.currentPlayerIndex = idx;
            eligibleFound = true;
            break;
          }
        }
        if (!eligibleFound) {
          this.currentPlayerIndex = null;
        }
      }
    }
    this.actionStartIndex = this.currentPlayerIndex;
    console.log(
      `[DEBUG resetBetsForNextRound] END currentPlayerIndex=${this.currentPlayerIndex}, actionStartIndex=${this.actionStartIndex}`
    );
    console.log(
      "[DEBUG resetBetsForNextRound] Next round starting with player:",
      this.players[this.currentPlayerIndex]?.name,
      "at index",
      this.currentPlayerIndex
    );
  }

  dealFlop() {
    console.log("[DEBUG] dealFlop: dealing three cards...");
    this.deck.pop();
    this.communityCards.push(this.deck.pop(), this.deck.pop(), this.deck.pop());
  }

  dealTurn() {
    console.log("[DEBUG] dealTurn: dealing one card...");
    this.deck.pop();
    this.communityCards.push(this.deck.pop());
  }

  dealRiver() {
    console.log("[DEBUG] dealRiver: dealing one card...");
    this.deck.pop();
    this.communityCards.push(this.deck.pop());
  }

  computeSidePots() {
    const contenders = this.players.filter((p) => !p.hasFolded);
    const sorted = [...contenders].sort((a, b) => a.totalBet - b.totalBet);
    let sidePots = [];
    let previous = 0;
    let remaining = contenders.length;
    for (let i = 0; i < sorted.length; i++) {
      const current = sorted[i].totalBet;
      const diff = current - previous;
      if (diff > 0) {
        sidePots.push({
          amount: diff * remaining,
          eligiblePlayers: contenders.filter((p) => p.totalBet >= current),
        });
        previous = current;
      }
      remaining--;
    }
    return sidePots;
  }

  evaluateShowdown() {
    console.log("[DEBUG evaluateShowdown] 勝敗判定処理を開始します");
    if (this.showdownEvaluated) {
      console.log("[DEBUG evaluateShowdown] 勝敗判定は既に実行済みです");
      return;
    }
    this.showdownEvaluated = true;
    const results = this.determineSidePotWinners();
    this.showdownResults = results; // 勝敗結果を保存
    console.log("[DEBUG evaluateShowdown] 勝敗判定結果:", results);

    if (results && typeof this.sendResultCallback === "function") {
      this.sendResultCallback(results);
    } else {
      console.error(
        "[ERROR evaluateShowdown] 勝敗判定でエラーが発生しました または送信コールバックが未設定"
      );
    }

    // ショウダウン結果送信後、必ずラウンド終了処理を実行する
    setTimeout(() => {
      if (typeof this.roundEndCallback === "function") {
        console.log("[DEBUG evaluateShowdown] ラウンド終了処理を開始します");
        this.roundEndCallback();
      } else {
        console.error(
          "[ERROR evaluateShowdown] ラウンド終了コールバックが未設定"
        );
      }
    }, 5000); // 5秒後に終了処理(必要に応じて調整)
  }

  determineSidePotWinners() {
    let sidePots = this.computeSidePots();

    // サイドポットが空の場合、全プレイヤーを対象にメインポットを作成する
    if (sidePots.length === 0) {
      console.log(
        "[DEBUG determineSidePotWinners] サイドポットが空なので、メインポットとして全プレイヤーを対象にします"
      );
      sidePots.push({
        amount: this.pot,
        eligiblePlayers: this.players.filter((p) => !p.hasFolded),
      });
    }

    let results = [];
    console.log("Computed sidePots:", sidePots);

    // --- ヘルパー関数 ---

    // カードのランク文字列を数値に変換 (A=14, K=13, Q=12, J=11)
    function rankToNumber(rank) {
      if (rank === "A") return 14;
      if (rank === "K") return 13;
      if (rank === "Q") return 12;
      if (rank === "J") return 11;
      return parseInt(rank, 10);
    }

    // solvedHand.cards からキッカーの数値配列を作成し、降順にソートして比較する
    function compareSolvedHands(a, b) {
      const cardsA = a.cards
        .map((card) => rankToNumber(card.value || card.rank))
        .sort((x, y) => y - x);
      const cardsB = b.cards
        .map((card) => rankToNumber(card.value || card.rank))
        .sort((x, y) => y - x);
      for (let i = 0; i < 5; i++) {
        if (cardsA[i] > cardsB[i]) return 1;
        if (cardsA[i] < cardsB[i]) return -1;
      }
      return 0;
    }

    // 手札比較:まず手札の種類(solvedHand.rank)を比較し、異なるならその差を返す
    // 同じなら、キッカー比較を行う
    function compareHandsCustom(a, b) {
      if (a.rank !== b.rank) {
        return a.rank - b.rank;
      }
      return compareSolvedHands(a, b);
    }

    // --- 各サイドポットごとの勝者判定 ---
    for (const pot of sidePots) {
      console.log("-----");
      console.log("Processing pot:", pot);
      const eligible = pot.eligiblePlayers;
      const playerHands = eligible.map((player) => {
        const solverCards = [...player.hand, ...this.communityCards].map(
          convertToPokerSolverFormat
        );
        const solvedHand = Hand.solve(solverCards);
        if (!solvedHand || !solvedHand.descr) {
          console.error(
            `Hand.solve() の結果が不正です。Player: ${player.name}`,
            solverCards,
            solvedHand
          );
        }
        player.solvedHand = solvedHand;
        console.log(`Player: ${player.name}`);
        console.log("  Cards for solver:", solverCards);
        console.log("  Solved hand:", solvedHand);
        console.log(
          "  Hand rank:",
          solvedHand.rank,
          "description:",
          solvedHand.descr
        );
        return { player, solvedHand };
      });

      // 各プレイヤーの手札を compareHandsCustom で比較し、最強の手を決定
      let bestHand = null;
      for (const ph of playerHands) {
        if (
          bestHand === null ||
          compareHandsCustom(ph.solvedHand, bestHand) > 0
        ) {
          bestHand = ph.solvedHand;
        }
      }

      // 最強手と同じ評価になるプレイヤーを勝者とする
      let winningPlayers = playerHands
        .filter((ph) => compareHandsCustom(ph.solvedHand, bestHand) === 0)
        .map((ph) => ph.player);
      // 同じプレイヤーが重複しないように排除
      winningPlayers = [
        ...new Map(winningPlayers.map((p) => [p.id, p])).values(),
      ];
      console.log("Winning players for current pot:", winningPlayers);

      const winnersInfo = winningPlayers.map((wp) => {
        const ph = playerHands.find((ph) => ph.player.id === wp.id);
        return `${wp.name} (${
          ph && ph.solvedHand && ph.solvedHand.descr
            ? ph.solvedHand.descr
            : "未評価"
        })`;
      });

      results.push({
        potAmount: pot.amount,
        winners: winningPlayers,
        winnersInfo: winnersInfo,
      });
      console.log("Current pot results:", results[results.length - 1]);
    }
    console.log("Final results:", results);
    return results;
  }
}

module.exports = TexasHoldemGame;

ai.js
// ai.js
const { GoogleGenerativeAI } = require("@google/generative-ai");
const { parseGeminiResponse } = require("./utils");

const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
const model = genAI.getGenerativeModel({
  model: "gemini-1.5-flash",
  generationConfig: { responseMimeType: "application/json" },
});

async function geminiAIMove(room, broadcastGameState, handleTermination) {
  const aiPlayer = room.players.find((p) => p.id === "GeminiAI");
  if (!aiPlayer) {
    console.log("[DEBUG] geminiAIMove: GeminiAI プレイヤーが見つかりません");
    return;
  }
  if (
    room.currentPlayerIndex !==
    room.players.findIndex((p) => p.id === "GeminiAI")
  ) {
    console.log(
      "[DEBUG] geminiAIMove: GeminiAI のターンではありません. currentPlayerIndex=",
      room.currentPlayerIndex
    );
    return;
  }

  const humanPlayers = room.players
    .filter((p) => p.id !== "GeminiAI")
    .map((p) => ({ name: p.name, chips: p.chips, bet: p.bet }));

  const prompt = `
あなたはポーカーのプロです。以下の状況から、GeminiAIとして最善のアクションを選んでください。
GeminiAIの手札: ${JSON.stringify(aiPlayer.hand)}
コミュニティカード: ${JSON.stringify(room.communityCards)}
ポット: ${room.pot}
現在のベット: ${room.currentBet}
相手プレイヤーの状況: ${JSON.stringify(humanPlayers)}
ゲームフェーズ: ${room.phase}
アクションは "fold", "check", "call", "raise" のいずれかで、レイズの場合は "raiseAmount" も指定してください。
出力は必ず以下の形式の有効な JSON としてください。どのようなロジックでそうしたか説明も加えてください。:
{ "action": "<fold|check|call|raise>", "raiseAmount": <number> ,"解説": "◯◯のため◯◯しました。"}
  `.trim();

  console.log(
    "[DEBUG] geminiAIMove: Gemini API 呼び出し前のプロンプト:",
    prompt
  );

  // API 呼び出しと5秒タイムアウトを競合させる
  const responsePromise = model.generateContent(prompt);
  const timeoutPromise = new Promise((_, reject) =>
    setTimeout(
      () =>
        reject(new Error("Timeout: Gemini did not respond within 5 seconds")),
      5000
    )
  );

  let result;
  try {
    result = await Promise.race([responsePromise, timeoutPromise]);
  } catch (err) {
    console.error(
      "[DEBUG] geminiAIMove: Gemini API 呼び出しタイムアウトまたはエラー:",
      err
    );
    // タイムアウトなどで失敗した場合、5秒後に再度Geminiのターンを起動する
    setTimeout(
      () => geminiAIMove(room, broadcastGameState, handleTermination),
      5000
    );
    return;
  }

  const responseText = result.response.text();
  console.log("[DEBUG] geminiAIMove: Gemini API 返答テキスト:", responseText);
  const actionObj = parseGeminiResponse(responseText);
  console.log(
    "[DEBUG] geminiAIMove: 解析されたアクションオブジェクト:",
    actionObj
  );
  const actionResult = await room.handleAction(
    "GeminiAI",
    actionObj.action,
    actionObj.raiseAmount || 0
  );
  if (actionResult.error) {
    console.error("GeminiAI action error:", actionResult.error);
  }
  if (actionResult.termination) {
    handleTermination(room);
  } else {
    broadcastGameState();
    const currentPlayer = room.players[room.currentPlayerIndex];
    if (
      currentPlayer &&
      currentPlayer.id === "GeminiAI" &&
      !currentPlayer.hasActed
    ) {
      setTimeout(
        () => geminiAIMove(room, broadcastGameState, handleTermination),
        2000
      );
    }
  }
}

module.exports = { geminiAIMove };

config.js
// config.js
require("dotenv").config();

module.exports = {
  SMALL_BLIND: 10,
  BIG_BLIND: 20,
  MAX_TABLES: 10,
  MAX_PLAYERS_PER_TABLE: 6,
  PHASES: {
    WAITING: "WAITING",
    PRE_FLOP: "PRE_FLOP",
    FLOP: "FLOP",
    TURN: "TURN",
    RIVER: "RIVER",
    SHOWDOWN: "SHOWDOWN",
  },
  GEMINI_ROOM_ID: 11, // MAX_TABLES + 1
  PORT: process.env.PORT || 8080,
};

socketHandlers.js
// socketHandlers.js
console.log("socketHandlers.js loaded");
const {
  MAX_TABLES,
  GEMINI_ROOM_ID,
  MAX_PLAYERS_PER_TABLE,
  PHASES,
  SMALL_BLIND,
  BIG_BLIND,
} = require("./config");

const TexasHoldemGame = require("./texasgame");
const { getPublicGameState } = require("./utils");
const { geminiAIMove } = require("./ai");

const rooms = {};
const geminiRoomIds = [GEMINI_ROOM_ID, GEMINI_ROOM_ID + 1, GEMINI_ROOM_ID + 2];
const playerRoomMap = {};

function registerSocketHandlers(io) {
  const ioRef = io;

  // --- ルーム初期化 ---
  // 通常テーブルのルームを生成
  for (let i = 1; i <= MAX_TABLES; i++) {
    rooms[i] = new TexasHoldemGame();
    rooms[i].onShowdown = () => {
      console.log(`[DEBUG] onShowdown triggered for room ${i}`);
      rooms[i].evaluateShowdown();
    };
    rooms[i].sendResultCallback = (results) => {
      const resultMessage = results
        .map((pot) => pot.winnersInfo.join(", "))
        .join("\n");
      ioRef.to(String(i)).emit("gameResult", {
        roomId: i,
        message: resultMessage,
        pot: rooms[i].pot,
      });
    };
    // roundEndCallback は handleTermination が定義された後に設定
    rooms[i].roundEndCallback = () => {
      handleTermination(i);
    };
  }
  // Gemini 対戦用ルームの生成
  geminiRoomIds.forEach((id) => {
    rooms[id] = new TexasHoldemGame();
    rooms[id].addPlayer({
      id: "GeminiAI",
      name: "Google GeminiAI",
      chips: 1000,
    });
    rooms[id].onShowdown = () => {
      console.log(`[DEBUG] onShowdown triggered for Gemini room ${id}`);
      rooms[id].evaluateShowdown();
    };
    rooms[id].sendResultCallback = (results) => {
      const resultMessage = results
        .map((pot) => pot.winnersInfo.join(", "))
        .join("\n");
      ioRef.to(String(id)).emit("gameResult", {
        roomId: id,
        message: resultMessage,
        pot: rooms[id].pot,
      });
    };
    rooms[id].roundEndCallback = () => {
      handleTermination(id);
    };
  });
  // --- ルーム初期化 終了 ---

  async function broadcastGameState(roomId) {
    const sockets = await ioRef.in(String(roomId)).fetchSockets();
    sockets.forEach((socket) => {
      socket.emit(
        "gameUpdate",
        getPublicGameState(roomId, socket.id, rooms[roomId])
      );
    });
  }

  function handleTermination(roomId) {
    const room = rooms[roomId];
    // ショウダウンフェーズの場合は、evaluateShowdown で得た結果を使って終了処理を行う
    if (room.phase === PHASES.SHOWDOWN) {
      if (room.showdownResults && room.showdownResults.length > 0) {
        // ここでは、最初のポットの勝者を対象にポットを均等分割する例
        const winners = room.showdownResults[0].winners;
        if (winners.length > 0) {
          const share = room.pot / winners.length;
          winners.forEach((w) => {
            w.chips += share;
          });
          const winnerNames = winners.map((w) => w.name).join(", ");
          const resultMessage = `ショウダウンの結果、${winnerNames} の勝利です。`;
          ioRef
            .to(String(roomId))
            .emit("roomMessage", { roomId, message: resultMessage });
          winners.forEach((w) => {
            ioRef.to(w.id).emit("winNotification", {
              roomId,
              message: "Win",
              winnerId: w.id,
            });
          });
          broadcastGameState(roomId);
        }
      } else {
        console.error(
          "[DEBUG handleTermination] ショウダウン結果が得られていません"
        );
      }
    } else {
      // 通常の終了処理:フォールド等で1人になった場合
      const activePlayers = room.players.filter((p) => !p.hasFolded);
      if (activePlayers.length !== 1) return;
      const winner = activePlayers[0];
      winner.chips += room.pot;
      const resultMessage = `全員がフォールドしたため、${winner.name}の勝利です。`;
      ioRef
        .to(String(roomId))
        .emit("roomMessage", { roomId, message: resultMessage });
      ioRef.to(String(roomId)).emit("winNotification", {
        roomId,
        message: "Win",
        winnerId: winner.id,
      });
      broadcastGameState(roomId);
    }

    // 共通のラウンド終了後の後処理
    setTimeout(() => {
      console.log("[DEBUG] Calling endRoundCleanup for room", roomId);
      endRoundCleanup(roomId);
      broadcastGameState(roomId);
      setTimeout(() => {
        if (geminiRoomIds.includes(roomId)) {
          if (room.players.filter((p) => p.id !== "GeminiAI").length >= 1) {
            console.log("[DEBUG] Starting new round for Gemini room", roomId);
            room.startRound();
          } else {
            console.log("[DEBUG] Not enough human players in Gemini room.");
            room.phase = PHASES.WAITING;
          }
        } else if (room.players.filter((p) => p.chips > 0).length >= 2) {
          console.log("[DEBUG] Starting new round for room", roomId);
          room.startRound();
        } else {
          console.log("[DEBUG] Not enough players to start a new round.");
          room.phase = PHASES.WAITING;
        }
        broadcastGameState(roomId);
        ioRef.to(String(roomId)).emit("clearRoomMessage", { roomId });
        const newRoom = rooms[roomId];
        if (
          newRoom.players[newRoom.currentPlayerIndex] &&
          newRoom.players[newRoom.currentPlayerIndex].id === "GeminiAI"
        ) {
          setTimeout(() => {
            geminiAIMove(
              newRoom,
              () => broadcastGameState(roomId),
              () => handleTermination(roomId)
            );
          }, 2000);
        }
      }, 2000);
    }, 5000);
  }

  function endRoundCleanup(roomId) {
    console.log(`[DEBUG endRoundCleanup] Called for roomId=${roomId}`);
    const room = rooms[roomId];
    if (!room) return;
    room.incorporateWaitingPlayers();
    const beforeCount = room.players.length;
    room.players = room.players.filter(
      (p) => p.id === "GeminiAI" || (p.chips > 0 && !p.disconnected)
    );
    room.waitingPlayers = room.waitingPlayers.filter(
      (p) => p.id === "GeminiAI" || (p.chips > 0 && !p.disconnected)
    );
    const gemini = room.players.find((p) => p.id === "GeminiAI");
    if (gemini && gemini.chips <= 0) {
      gemini.chips = 1000;
    }
    console.log(
      `[DEBUG endRoundCleanup] -> Removed ${
        beforeCount - room.players.length
      } players from room.players.`
    );
    if (room.players.length < 2) {
      console.log("[DEBUG endRoundCleanup] -> Not enough players => WAITING");
      room.phase = PHASES.WAITING;
    }
    broadcastGameState(roomId);
  }

  ioRef.on("connection", (socket) => {
    console.log("New connection:", socket.id);

    socket.on("getRooms", () => {
      const roomList = [];
      // 通常テーブルの場合
      for (let i = 1; i <= MAX_TABLES; i++) {
        const game = rooms[i];
        const playerCount = game.players.length; // 着席しているプレイヤー数
        const waitingCount = game.waitingPlayers.length; // 空席待ちの人数
        const status = game.phase === PHASES.WAITING ? "待機中" : "ゲーム中";
        roomList.push({
          roomId: i,
          tableName: `テーブル${i}`, // テーブル名を付与
          sb: SMALL_BLIND, // ステーク:SB
          bb: BIG_BLIND, // ステーク:BB
          type: "human", // ゲーム種別(通常対戦の場合)
          playerCount, // 着席者数
          waitingCount, // 空席待ち人数
          status, // 状態
        });
      }
      // Gemini 対戦用ルームの場合
      geminiRoomIds.forEach((id) => {
        const geminiGame = rooms[id];
        const playerCount = geminiGame.players.length;
        const waitingCount = geminiGame.waitingPlayers.length;
        const status =
          geminiGame.phase === PHASES.WAITING ? "待機中" : "ゲーム中";
        roomList.push({
          roomId: id,
          tableName: `テーブル${id}`, // Gemini ルームもテーブル名として付与
          sb: SMALL_BLIND,
          bb: BIG_BLIND,
          type: "gemini", // ゲーム種別を Gemini として設定
          playerCount,
          waitingCount,
          status: status + " (GeminiAI)",
        });
      });
      socket.emit("roomList", roomList);
    });

    socket.on("joinGame", (data) => {
      let roomId = parseInt(data.roomId, 10);
      if (
        isNaN(roomId) ||
        roomId < 1 ||
        (roomId > MAX_TABLES && !geminiRoomIds.includes(roomId))
      ) {
        roomId = 1;
      }
      const room = rooms[roomId];
      if (geminiRoomIds.includes(roomId)) {
        const humanCount = room.players.filter(
          (p) => p.id !== "GeminiAI"
        ).length;
        if (humanCount >= 1) {
          socket.emit("gameMessage", {
            message:
              "GeminiAIルームは既に対戦中です。別のルームをお選びください。",
          });
          return;
        }
      }
      const exists =
        room.players.some((p) => p.id === socket.id) ||
        room.waitingPlayers.some((p) => p.id === socket.id);
      if (exists) {
        socket.emit("gameMessage", {
          message: "既にこのルームに参加しています。",
        });
        return;
      }
      if (geminiRoomIds.includes(roomId) && room.players.length >= 2) {
        socket.emit("gameMessage", {
          message: "GeminiAIルームは満室です。別のルームをお選びください。",
        });
        return;
      } else if (room.players.length >= MAX_PLAYERS_PER_TABLE) {
        socket.emit("gameMessage", {
          message: `テーブル${roomId}は満員です。別のテーブルを選んでください。`,
        });
        return;
      }
      room.addPlayer({ id: socket.id, name: data.playerName });
      socket.join(String(roomId));
      if (playerRoomMap[socket.id]) {
        if (!playerRoomMap[socket.id].includes(roomId)) {
          playerRoomMap[socket.id].push(roomId);
        }
      } else {
        playerRoomMap[socket.id] = [roomId];
      }
      broadcastGameState(roomId);
    });

    socket.on("startGame", (data) => {
      let roomId = data?.roomId;
      if (!roomId) {
        const arr = playerRoomMap[socket.id];
        if (arr && arr.length > 0) {
          roomId = arr[0];
        } else {
          socket.emit("gameMessage", { message: "テーブルに参加していません" });
          return;
        }
      }
      const room = rooms[roomId];
      if (!room) {
        socket.emit("gameMessage", { message: "存在しないテーブルです" });
        return;
      }
      if (geminiRoomIds.includes(roomId)) {
        const humanCount = room.players.filter(
          (p) => p.id !== "GeminiAI"
        ).length;
        if (humanCount < 1) {
          ioRef
            .to(String(roomId))
            .emit("gameMessage", { message: "対戦相手が必要です" });
          return;
        }
      } else if (room.players.length < 2) {
        ioRef
          .to(String(roomId))
          .emit("gameMessage", { message: "プレイヤーが2人以上必要です" });
        return;
      }
      ioRef.to(String(roomId)).emit("clearRoomMessage", { roomId });
      room.startRound();
      broadcastGameState(roomId);
      if (geminiRoomIds.includes(roomId)) {
        const currentPlayer = room.players[room.currentPlayerIndex];
        if (currentPlayer && currentPlayer.id === "GeminiAI") {
          console.log("[DEBUG] GeminiAIが先頭。geminiAIMoveを起動します。");
          setTimeout(() => {
            geminiAIMove(
              room,
              () => broadcastGameState(roomId),
              () => handleTermination(roomId)
            );
          }, 2000);
        }
      }
    });

    socket.on("playerAction", async ({ roomId, action, raiseAmount }) => {
      const room = rooms[roomId];
      if (!room) {
        console.error(
          `[ERROR] playerAction: ルーム ${roomId} が見つかりません`
        );
        return;
      }
      if (room.phase === PHASES.SHOWDOWN) {
        socket.emit("gameMessage", {
          roomId,
          message: "ショウダウン中は操作できません",
        });
        return;
      }
      const result = room.handleAction(socket.id, action, raiseAmount);
      if (result.error) {
        socket.emit("gameMessage", { roomId, message: result.error });
        return;
      }
      if (result.termination) {
        handleTermination(roomId);
        return;
      }
      broadcastGameState(roomId);

      const currentPlayer = room.players[room.currentPlayerIndex];
      if (
        currentPlayer &&
        currentPlayer.id === "GeminiAI" &&
        !currentPlayer.hasActed
      ) {
        setTimeout(() => {
          geminiAIMove(
            room,
            () => broadcastGameState(roomId),
            () => handleTermination(roomId)
          );
        }, 2000);
      }
    });

    socket.on("disconnect", () => {
      const roomIds = playerRoomMap[socket.id] || [];
      roomIds.forEach((roomId) => {
        const room = rooms[roomId];
        if (room) {
          const target = room.players.find((p) => p.id === socket.id);
          if (target) {
            target.disconnected = true;
            console.log(
              `[DEBUG] Player ${socket.id} marked as disconnected in room ${roomId}.`
            );
          }
          room.waitingPlayers = room.waitingPlayers.filter(
            (p) => p.id !== socket.id
          );
          if (
            room.players[room.currentPlayerIndex] &&
            room.players[room.currentPlayerIndex].id === socket.id
          ) {
            console.log(
              `[DEBUG] Current player ${socket.id} is disconnected in room ${roomId}. Auto-folding.`
            );
            if (target) {
              target.hasFolded = true;
              target.hasActed = true;
            }
            room.nextPlayer();
          }
          const activePlayers = room.players.filter((p) => !p.hasFolded);
          if (activePlayers.length === 1) {
            console.log(
              `[DEBUG] Only one active player left in room ${roomId} after disconnect.`
            );
            const winner = activePlayers[0];
            winner.chips += room.pot;
            const resultMessage = `全員がフォールドしたため、${winner.name}の勝利です。`;
            ioRef
              .to(String(roomId))
              .emit("gameResult", { message: resultMessage, pot: room.pot });
            ioRef.to(winner.id).emit("winNotification", { message: "Win" });
            broadcastGameState(roomId);
            setTimeout(() => {
              endRoundCleanup(roomId);
            }, 5000);
          }
          broadcastGameState(roomId);
        }
      });
      delete playerRoomMap[socket.id];
      console.log("Disconnect:", socket.id);
    });
  });
}

module.exports = { registerSocketHandlers };

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?