HirotoTMurakami
@HirotoTMurakami

Are you sure you want to delete the question?

If your question is resolved, you may close it.

Leaving a resolved question undeleted may help others!

We hope you find it useful!

オンライン対戦できる本格的なポーカー(テキサスホールデム)を作りたい!

解決したいこと

オンラインで対戦できる本格的なテキサスホールデムを作って公開したい!

大規模な野望ですが、天鳳のポーカー版のようなものを作って無料で公開するのが目標です!厳しいご意見が聞きたく無理を承知でここに投稿させていただきます。

ChatGPTと格闘しながら以下のようなものができましたが、まだ本格的なものは実現できていません。

具体的には現在以下のエラーが出ます。
・ショウダウン時に勝敗判定までいかない
・サイドポットの処理などをデバッグできていない
などなどすぐに直せそうなエラーもたくさんあります

実現したいこと
・天鳳のような段位機能
・VPIPなど各種指標を見れる機能
・ハンド履歴
・GeminiやChatGPTとの対戦機能

現状のソースコード

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;
    }
  </style>
  <script>
    const cardImages = {};
    function preloadCards() {
      const suits = ["C", "D", "H", "S"]; // Clubs, Diamonds, Hearts, Spades
      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;


    // ページロード時にカードをプリロードする
    preloadCards();
    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}`;
      }
      // cardImages はプリロード時にグローバルでセットしておく
      if (window.cardImages && window.cardImages[key]) {
        // 画像が既にプリロードされていればそのキャッシュされた src を利用
        return `<img src="${window.cardImages[key].src}" alt="${key}" class="card">`;
      } else {
        // fallback: プリロードが無い場合は直接画像パスを指定
        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>Texas Hold'em Online</h1>
    <div>
      <input type="text" id="playerName" placeholder="名前">
      <button id="loadRoomListBtn">ルーム一覧を読み込み</button>
      <div id="roomList"></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>
  <script>
    const socket = io("https://socket-io-service-466625290686.us-central1.run.app");
    let myId = null;
    const roomMessages = {};
    const loadRoomListBtn = document.getElementById('loadRoomListBtn');
    const roomListDiv = document.getElementById('roomList');
    const playerNameInput = document.getElementById('playerName');
    socket.on('connect', () => {
      myId = socket.id;
      console.log("Connected with socket id:", myId);
      loadRoomList();
    });
    function loadRoomList() {
      console.log("getRooms emitted");
      socket.emit("getRooms");
    }
    socket.on("roomList", (rooms) => {
      console.log("roomList received:", rooms);
      roomListDiv.innerHTML = "";
      rooms.forEach((r) => {
        const btn = document.createElement("button");
        btn.innerHTML = `ルーム<span style="color:black;">${r.roomId}</span>(${r.playerCount}人: ${r.status})`;
        btn.onclick = () => {
          const name = playerNameInput.value.trim();
          if (!name) {
            alert("名前を入力してください");
            return;
          }
          socket.emit("joinGame", { playerName: name, roomId: r.roomId });
        };
        roomListDiv.appendChild(btn);
        roomListDiv.appendChild(document.createElement("br"));
      });
    });
    loadRoomListBtn.onclick = loadRoomList;
    const joinedRoomsDiv = document.getElementById("joinedRooms");
    function sendActionForRoom(roomId, actionType) {
      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 = ""; }
    }
    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;
      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')">チェック</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");
      }
    });
    socket.on("gameMessage", (data) => {
      if (data.roomId) {
        let roomMsg = document.getElementById("roomMsg-" + data.roomId);
        if (roomMsg) { roomMsg.innerHTML = `<h3>${data.message}</h3>`; }
      } else {
        document.getElementById("messages").innerHTML = data.message;
      }
    });
    socket.on("gameResult", (data) => {
      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) => {
      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);
  }

  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;
  }

  handleAction(playerId, action, raiseAmount = 0) {
    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 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();
    console.log("[DEBUG evaluateShowdown] 勝敗判定結果:", results);

    if (results) {
      // 勝敗結果をクライアントに送信する処理をここに実装します
      // 例: this.sendGameResultToClients(results);
      console.log(
        "[DEBUG evaluateShowdown] 勝敗結果をクライアントに送信する処理は未実装です"
      ); // TODO: 実装
    } else {
      console.error("[ERROR evaluateShowdown] 勝敗判定でエラーが発生しました");
    }
  }

  determineSidePotWinners() {
    const sidePots = this.computeSidePots();
    let results = [];
    console.log("Computed sidePots:", sidePots);

    // 配列の深い比較関数
    function arraysEqual(a, b) {
      if (!Array.isArray(a) || !Array.isArray(b)) {
        console.error("arraysEqual: 引数が配列ではありません", a, b);
        return false;
      }
      if (a.length !== b.length) return false;
      for (let i = 0; i < a.length; i++) {
        if (a[i] !== b[i]) return false;
      }
      return true;
    }

    // 手札評価結果同士を比較する関数
    function compareHands(a, b) {
      // 両方に value 配列が存在するなら、それを用いて比較
      if (Array.isArray(a.value) && Array.isArray(b.value)) {
        return arraysEqual(a.value, b.value);
      }
      // 存在しない場合は、代わりに descr を比較
      return a.descr === b.descr;
    }

    // 各サイドポットについて
    for (const pot of sidePots) {
      console.log("-----");
      console.log("Processing pot:", pot);
      const eligible = pot.eligiblePlayers;

      // 各 eligible プレイヤーについて、手札+コミュニティカードを評価し、player.solvedHand に保存
      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 };
      });

      // Hand.winners() により、勝っている手(同点なら複数)を取得
      const winnersHands = Hand.winners(playerHands.map((ph) => ph.solvedHand));
      console.log("Winners hands from library:", winnersHands);

      // winnersHands の各手と、各 eligible プレイヤーの solvedHand を compareHands 関数で比較し、一致するプレイヤーを抽出
      let winningPlayers = playerHands
        .filter((ph) => {
          return winnersHands.some((wHand) =>
            compareHands(ph.solvedHand, wHand)
          );
        })
        .map((ph) => ph.player);

      // 重複除去(player.id をキーに)
      winningPlayers = [
        ...new Map(winningPlayers.map((p) => [p.id, p])).values(),
      ];
      console.log("Winning players for current pot:", winningPlayers);

      // 各勝者の名前と手役の説明(solvedHand.descr)を winnersInfo として作成
      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 };

socketHandlers.js
// socketHandlers.js
const TexasHoldemGame = require("./texasgame");
const { getPublicGameState } = require("./utils");
const { geminiAIMove } = require("./ai");
const {
  MAX_TABLES,
  GEMINI_ROOM_ID,
  MAX_PLAYERS_PER_TABLE,
  PHASES,
} = require("./config");

// ルーム初期化(通常テーブル)
const rooms = {};
for (let i = 1; i <= MAX_TABLES; i++) {
  rooms[i] = new TexasHoldemGame();
  rooms[i].onShowdown = () => {
    console.log(`[DEBUG] onShowdown triggered for room ${i}`);
    evaluateShowdown(ioRef, i);
  };
}

// Gemini対戦用ルームを3室作成(例:GEMINI_ROOM_ID, GEMINI_ROOM_ID+1, GEMINI_ROOM_ID+2)
const geminiRoomIds = [GEMINI_ROOM_ID, GEMINI_ROOM_ID + 1, GEMINI_ROOM_ID + 2];
geminiRoomIds.forEach((id) => {
  rooms[id] = new TexasHoldemGame();
  // GeminiAIを追加(常にルームに在籍)
  rooms[id].addPlayer({
    id: "GeminiAI",
    name: "Google GeminiAI",
    chips: 1000,
  });
  rooms[id].onShowdown = () => {
    console.log(`[DEBUG] onShowdown triggered for Gemini room ${id}`);
    evaluateShowdown(ioRef, id);
  };
});

// プレイヤーのルーム参加マッピング
const playerRoomMap = {};

function registerSocketHandlers(io) {
  // io をクロージャでキャプチャ
  const ioRef = io;

  // ゲーム状態をブロードキャストする関数
  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];
    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);

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

    setTimeout(() => {
      console.log("[DEBUG] Calling endRoundCleanup for room", roomId);
      endRoundCleanup(roomId);
      broadcastGameState(roomId);
      setTimeout(() => {
        // Gemini対戦ルームは人間プレイヤーが1人以上で新ラウンド開始
        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 evaluateShowdown(roomId) {
    const room = rooms[roomId];
    if (!room || room.phase !== PHASES.SHOWDOWN) return;
    const sidePotResults = room.determineSidePotWinners();
    let resultMessage = "";
    // 各ポットの結果をテキストでまとめる
    sidePotResults.forEach((pot) => {
      resultMessage += pot.winnersInfo.join(", ") + "\n";
    });
    ioRef.to(String(roomId)).emit("gameResult", {
      roomId,
      message: resultMessage,
      pot: room.pot,
    });
  }

  // ラウンド終了時の後処理(プレイヤーの削除・リセット)
  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)
    );
    // GeminiAI のチップが0になっている場合は、次ゲーム開始前にリセット
    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 + game.waitingPlayers.length;
        const status = game.phase === "WAITING" ? "待機中" : "ゲーム中";
        roomList.push({ roomId: i, playerCount, status });
      }
      // Gemini 対戦ルームの情報
      geminiRoomIds.forEach((id) => {
        const geminiGame = rooms[id];
        const geminiStatus =
          geminiGame.phase === "WAITING" ? "待機中" : "ゲーム中";
        roomList.push({
          roomId: id,
          playerCount:
            geminiGame.players.length + geminiGame.waitingPlayers.length,
          status: geminiStatus + " (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];
      // Gemini対戦ルームの場合、既に人間プレイヤーがいるなら参加拒否
      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;
      }
      // Gemini対戦ルームは1室につき最大2(GeminiAI +1人)となるようにチェック
      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;
      }
      // Gemini対戦ルームは、少なくとも1人の人間プレイヤーが必要
      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);
      // Gemini対戦ルームの場合、先頭がGeminiAIなら自動的にgeminiAIMoveを起動
      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;
      }
      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);
      }

      // ショウダウンフェーズに入り、まだ勝敗判定が行われていない場合は自動的に評価
      if (room.phase === PHASES.SHOWDOWN && !room.showdownEvaluated) {
        room.showdownEvaluated = true;
        setTimeout(() => evaluateShowdown(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 };

utils.js
// utils.js
const { Hand } = require("pokersolver");
const { PHASES } = require("./config");

function parseGeminiResponse(text) {
  try {
    const obj = JSON.parse(text);
    if (obj && typeof obj.action === "string") {
      const action = obj.action.toLowerCase();
      if (action === "fold" || action === "call" || action === "check") {
        return { action };
      } else if (action === "raise") {
        const raiseAmount = Number(obj.raiseAmount);
        return {
          action: "raise",
          raiseAmount: isNaN(raiseAmount) ? 0 : raiseAmount,
        };
      }
    }
    return { action: "call" };
  } catch (e) {
    text = text.toLowerCase();
    if (text.includes("fold")) {
      return { action: "fold" };
    } else if (text.includes("check")) {
      return { action: "check" };
    } else if (text.includes("raise")) {
      const match = text.match(/raise.*?(\d+)/);
      const raiseAmount = match ? parseInt(match[1], 10) : 0;
      return { action: "raise", raiseAmount };
    } else if (text.includes("call")) {
      return { action: "call" };
    }
    return { action: "call" };
  }
}

function convertToPokerSolverFormat(card) {
  const rankMap = {
    2: "2",
    3: "3",
    4: "4",
    5: "5",
    6: "6",
    7: "7",
    8: "8",
    9: "9",
    10: "T",
    J: "J",
    Q: "Q",
    K: "K",
    A: "A",
  };
  const suitMap = { "": "s", "": "h", "": "d", "": "c" };
  return rankMap[card.rank] + suitMap[card.suit];
}

function getPositionLabel(game, seatIndex) {
  const n = game.players.length;
  const button = game.dealerButtonIndex;
  const offset = (seatIndex - button + n) % n;
  if (n === 2) {
    return offset === 0 ? "SB(BTN)" : "BB";
  }
  const SIX_MAX_POSITIONS = ["BTN", "SB", "BB", "UTG", "HJ", "CO"];
  if (offset < SIX_MAX_POSITIONS.length) {
    return SIX_MAX_POSITIONS[offset];
  }
  return "";
}

function getPublicGameState(roomId, viewerId, room) {
  return {
    roomId,
    phase: room.phase,
    pot: room.pot,
    currentBet: room.currentBet,
    currentPlayerId: room.players[room.currentPlayerIndex]?.id || null,
    communityCards: room.communityCards,
    players: room.players.map((p, idx) => {
      let displayedHand;
      if (p.id === viewerId) {
        displayedHand = p.hand;
      } else if (p.hasFolded) {
        displayedHand = p.hand.map(() => ({
          rank: "??",
          suit: "??",
          folded: true,
        }));
      } else if (room.phase === PHASES.SHOWDOWN) {
        displayedHand = p.hand;
      } else {
        displayedHand = p.hand.map(() => ({ rank: "??", suit: "??" }));
      }
      let currentRank = "";
      if (
        !p.hasFolded &&
        (p.id === viewerId || room.phase === PHASES.SHOWDOWN) &&
        room.phase !== PHASES.PRE_FLOP &&
        room.communityCards.length >= 3
      ) {
        const combined = [...p.hand, ...room.communityCards];
        const solved = Hand.solve(combined.map(convertToPokerSolverFormat));
        currentRank = solved.descr;
      }
      return {
        id: p.id,
        name: p.name,
        chips: p.chips,
        bet: p.bet,
        hasFolded: p.hasFolded,
        positionLabel: getPositionLabel(room, idx),
        hand: displayedHand,
        rank: currentRank,
      };
    }),
  };
}

module.exports = {
  parseGeminiResponse,
  convertToPokerSolverFormat,
  getPositionLabel,
  getPublicGameState,
};

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,
};

server.js
// server.js
const express = require("express");
const http = require("http");
const { registerSocketHandlers } = require("./socketHandlers");
const app = express();
const server = http.createServer(app);
const io = require("socket.io")(server, {
  cors: {
    origin: "https://codequest-e825e.web.app",
    methods: ["GET", "POST"],
  },
});

app.use(express.static("public"));

registerSocketHandlers(io);

const { PORT } = require("./config");
server.listen(PORT, "0.0.0.0", () => {
  console.log(`Server running on port ${PORT}`);
});

自分で試したこと

・散々ChatGPTと格闘しました(適当ですみません。)

0

何か変更するたびに手動で動作テストするわけにもいかないので、まずはサーバーサイドの単体テストと結合テストを書いてください。 Jest か Vitest あたりを使っておけばいいと思います。 JS のテストフレームワークについて調べれば、導入方法からどのようにテストを書くかまで分かるはずです。

1Like

@uasi さん
ありがとうございます。Jestで単体テストおよび結合テストをすることで今までログを出しながら検証していた手間が省けました。助かります。

他にもなにか試したほうがよいことがあればぜひコメントにて教えて下さい。

0Like

Your answer might help someone💌