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?

ChatGPTでオンライン対戦可能なテキサスホールデムを作った

Posted at

現在開発中のコードクエストというゲーム内のカジノ機能に、オンライ対戦できるテキサスホールデムポーカーをChatGPTと格闘しながら実装しました!

一応動くのができましたが、まだ完璧ではありません。
エラーや実際のポーカーと違う挙動があればぜひご報告おねがいします。

以下ソースコード(htmlとnode.jsです。)

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

<head>
  <meta charset="UTF-8">
  <title>Texas Hold'em Demo</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: 100px;
      margin: 2px;
    }
  </style>
  <script>
    function getCardImageHtml(card) {
      if (card.rank === "??" && card.suit === "??") {
        return `<img src="playingcard/back.svg" alt="back" class="card">`;
      } else {
        let suitLetter = "";
        switch (card.suit) {
          case "": suitLetter = "C"; break;
          case "": suitLetter = "D"; break;
          case "": suitLetter = "H"; break;
          case "": suitLetter = "S"; break;
        }
        return `<img src="playingcard/${card.rank}${suitLetter}.svg" 
                    alt="${card.rank}${suitLetter}" class="card">`;
      }
    }
    function renderHand(hand) {
      return hand.map(card => getCardImageHtml(card)).join("");
    }
  </script>
</head>

<body>
  <div class="container">
    <h1>Texas Hold'em Online</h1>
    <div>
      <input type="text" id="playerName" placeholder="名前">
      <input type="number" id="roomId" placeholder="テーブル番号 (1~10)" min="1" max="10">
      <button id="joinBtn">参加</button>
      <button id="startBtn">ゲーム開始</button>
    </div>
    <hr>
    <div>
      <h3>フェーズ: <span id="phase"></span></h3>
      <h3>ポット: <span id="pot"></span></h3>
      <h3>テーブル最高ベット: <span id="currentBet"></span></h3>
      <h3>現在アクション中: <span id="currentPlayerId"></span></h3>
    </div>
    <div id="communityCards"></div>
    <div id="players"></div>
    <div>
      <h3>アクション</h3>
      <button id="foldBtn" onclick="sendAction('fold')">フォールド</button>
      <button id="callBtn" onclick="sendAction('call')">コール</button>
      <button id="checkBtn" onclick="sendAction('check')" style="display:none;">チェック</button>
      <input type="number" id="raiseAmount" placeholder="追加レイズ額 (例:10)" style="width:80px;">
      <button id="raiseBtn" onclick="sendAction('raise')">レイズ</button>
    </div>
    <div id="messages" style="margin-top:10px; color:yellow;"></div>
    <div id="result" style="margin-top:10px;"></div>
  </div>

  <script src="/socket.io/socket.io.js"></script>
  <script>
    const socket = io();
    let myId = null;
    socket.on('connect', () => {
      myId = socket.id;
    });

    const joinBtn = document.getElementById('joinBtn');
    const startBtn = document.getElementById('startBtn');
    const foldBtn = document.getElementById('foldBtn');
    const callBtn = document.getElementById('callBtn');
    const checkBtn = document.getElementById('checkBtn');
    const raiseBtn = document.getElementById('raiseBtn');

    const playerNameInput = document.getElementById('playerName');
    const roomIdInput = document.getElementById('roomId');

    const phaseSpan = document.getElementById('phase');
    const potSpan = document.getElementById('pot');
    const currentBetSpan = document.getElementById('currentBet');
    const currentPlayerSpan = document.getElementById('currentPlayerId');
    const communityDiv = document.getElementById('communityCards');
    const playersDiv = document.getElementById('players');
    const messagesDiv = document.getElementById('messages');
    const resultDiv = document.getElementById('result');

    joinBtn.onclick = () => {
      const name = playerNameInput.value.trim();
      const room = roomIdInput.value.trim();
      if (name && room) {
        socket.emit("joinGame", { playerName: name, roomId: room });
      }
    };

    startBtn.onclick = () => {
      socket.emit("startGame");
    };

    function sendAction(actionType) {
      const raiseAmount = parseInt(document.getElementById('raiseAmount').value, 10) || 0;
      socket.emit("playerAction", { action: actionType, raiseAmount });
    }

    socket.on("gameUpdate", (data) => {
      messagesDiv.innerHTML = "";
      // ※ ショウダウンやWAITINGに変わっても、ここではresultをクリアしない

      phaseSpan.textContent = data.phase;
      potSpan.textContent = data.pot;
      currentBetSpan.textContent = data.currentBet;
      currentPlayerSpan.textContent = data.currentPlayerId || "";

      // フェーズがWAITINGならアクション不可
      if (data.phase === "WAITING") {
        foldBtn.disabled = true;
        callBtn.disabled = true;
        checkBtn.disabled = true;
        raiseBtn.disabled = true;
      } else {
        const isMyTurn = (data.currentPlayerId === myId);
        foldBtn.disabled = !isMyTurn;
        raiseBtn.disabled = !isMyTurn;
        // call / check切り替え
        const me = data.players.find(p => p.id === myId);
        if (me) {
          if (me.bet === data.currentBet) {
            checkBtn.style.display = "inline-block";
            callBtn.style.display = "none";
            checkBtn.disabled = !isMyTurn;
          } else {
            checkBtn.style.display = "none";
            callBtn.style.display = "inline-block";
            callBtn.disabled = !isMyTurn;
          }
        }
      }

      // コミュニティカード
      communityDiv.innerHTML = `<h3>コミュニティカード:</h3> ${renderHand(data.communityCards)}`;

      // プレイヤー一覧
      playersDiv.innerHTML = `<h3>プレイヤー:</h3>`;
      data.players.forEach(p => {
        const isCurrent = (p.id === data.currentPlayerId);
        // p.rank には "Pair, Tens" などが格納されている(自分 or SHOWDOWN時)
        // ただし、相手はショウダウン前には "N/A" or 空文字のはず
        playersDiv.innerHTML += `
      <div class="player ${isCurrent ? 'current-turn' : ''}">
        <strong>Name:</strong> ${p.positionLabel} ${p.name} |
        <strong>Chips:</strong> ${p.chips} |
        <strong>Bet:</strong> ${p.bet} |
        <strong>Fold:</strong> ${p.hasFolded} |
        <strong>Hand:</strong> ${renderHand(p.hand)}
       <strong>Rank:</strong> ${p.rank || ""}
      </div>
    `;
      });
    });

    socket.on("gameMessage", (data) => {
      messagesDiv.innerHTML = data.message;
    });

    socket.on("gameResult", (data) => {
      // 結果を表示(複数行になる場合もあるので改行に注意)
      resultDiv.innerHTML = `<h3 style="white-space:pre-line;">${data.message}</h3>`;
    });

    socket.on("winNotification", (data) => {
      // 勝者には "Win" 表示を上書き or 追加
      // 必要があれば、既存の表示に追記しても良い
      resultDiv.innerHTML += `<h3>${data.message}</h3>`;
    });
  </script>
</body>

</html>
server.js
const express = require("express");
const http = require("http");
const socketIo = require("socket.io");
const { Hand } = require("pokersolver"); // 手役判定ライブラリ

// 定数
const SMALL_BLIND = 10;
const BIG_BLIND = 20;
const MAX_TABLES = 10;
const MAX_PLAYERS_PER_TABLE = 6;
const PHASES = {
  WAITING: "WAITING",
  PRE_FLOP: "PRE_FLOP",
  FLOP: "FLOP",
  TURN: "TURN",
  RIVER: "RIVER",
  SHOWDOWN: "SHOWDOWN",
};

//
// TexasHoldemGame クラス(オールイン・サイドポット対応版)
//
class TexasHoldemGame {
  constructor() {
    this.players = []; // { id, name, chips, bet, totalBet, hasFolded, isAllIn, hand: [], hasActed: false }
    this.deck = [];
    this.communityCards = [];
    this.pot = 0;
    this.currentBet = 0;
    this.currentPlayerIndex = 0;
    this.phase = PHASES.WAITING;
    this.dealerButtonIndex = 0;
    this.actionStartIndex = 0;
    // ヘッズアップ用にSBとBBのインデックスを記録
    this.smallBlindIndex = null;
    this.bigBlindIndex = null;
    this.waitingPlayers = [];
  }

  // [追加] ゲーム終了時に呼び出して、待機中プレイヤーを本参加に編入する
  incorporateWaitingPlayers() {
    console.log(
      "[DEBUG incorporateWaitingPlayers] start. waitingPlayers:",
      this.waitingPlayers.map((p) => ({
        id: p.id,
        name: p.name,
        chips: p.chips,
        disconnected: p.disconnected,
      }))
    );

    // 0チップ or disconnected な人は合流させない
    const validWaiters = this.waitingPlayers.filter(
      (p) => p.chips > 0 && !p.disconnected
    );
    console.log(
      `[DEBUG incorporateWaitingPlayers] from ${this.waitingPlayers.length} to ${validWaiters.length} valid players.`
    );

    // 合流
    this.players.push(...validWaiters);
    // waitingPlayers クリア
    this.waitingPlayers = [];

    console.log(
      "[DEBUG incorporateWaitingPlayers] end. 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 }) {
    // もし現在フェーズが WAITING (次ゲーム待ち) ならすぐ参加可能
    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);
  }

  startRound() {
    // すでに動いているテーブルでもディーラーボタンを進める(複数人時)など
    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;
    }
    // 2枚配る
    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) {
        // ヘッズアップ
        // dealerButtonIndex が SB(ボタン)を担当
        this.smallBlindIndex = this.dealerButtonIndex;
        this.bigBlindIndex = (this.dealerButtonIndex + 1) % this.players.length;

        // SBとBBのブラインドを支払う
        this._postBet(this.players[this.smallBlindIndex], SMALL_BLIND);
        this._postBet(this.players[this.bigBlindIndex], BIG_BLIND);
        this.currentBet = BIG_BLIND;

        // プリフロップはSBが先にアクション
        this.currentPlayerIndex = this.smallBlindIndex;
        this.actionStartIndex = this.smallBlindIndex;
      } else {
        // 3人以上
        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;

        // 3人以上はBBの次(ディーラーから見て3つ先)が最初にアクション
        this.currentPlayerIndex =
          (this.dealerButtonIndex + 3) % this.players.length;
        this.actionStartIndex = this.currentPlayerIndex;

        // 必要に応じて smallBlindIndex, bigBlindIndex を記録
        this.smallBlindIndex = sbIndex;
        this.bigBlindIndex = bbIndex;
      }
    }
  }

  // プレイヤーが金額をベットする(オールイン含む)
  _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}`
      );
    });

    // ---------- [1] ターンチェック ----------
    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: "すでにフォールドまたはオールインしています" };
    }

    // ---------- [2] アクション別処理 ----------
    switch (action) {
      case "fold": {
        if (player.bet === this.currentBet) {
          console.log(
            "[DEBUG] -> Attempted to fold when check is possible. Disallowed by current logic."
          );
          console.log("====== handleAction END (error) ======");
          return { error: "チェック可能な状況でフォールドはできません" };
        }
        player.hasFolded = true;
        player.hasActed = true;
        console.log(`[DEBUG] -> Player ${player.id} folded.`);
        break;
      }
      case "check": {
        // currentBetとの差があればチェック不可
        if (player.bet !== this.currentBet) {
          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": {
        // 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] -> diff <= 0 => Already matched or behind. No additional bet needed."
          );
        }
        player.hasActed = true;
        break;
      }
      case "raise": {
        // ここでは raiseAmount を「currentBet にいくら上乗せするか」と解釈
        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}.`);
        }

        // テーブルのcurrentBetを更新
        console.log(
          `[DEBUG] -> Updating currentBet from ${this.currentBet} to ${player.bet}`
        );
        this.currentBet = player.bet;
        player.hasActed = true;

        // レイズしたので他アクティブプレイヤーのhasActedをfalseに戻す
        this.players.forEach((p, i) => {
          if (!p.hasFolded && !p.isAllIn && i !== playerIndex) {
            p.hasActed = false;
            console.log(
              `[DEBUG] -> Reset hasActed=false for idx=${i}, id=${p.id}`
            );
          }
        });

        // レイズしたプレイヤーをactionStartIndexに
        this.actionStartIndex = playerIndex;
        break;
      }
      default: {
        console.log("[DEBUG] -> Unknown action.");
        console.log("====== handleAction END (error) ======");
        return { error: "不明なアクションです" };
      }
    }

    // ---------- [3] アクション処理後の状態をデバッグ出力 ----------
    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}`
      );
    });

    // ---------- [4] フォールドによる1人残りチェック ----------
    const activePlayers = this.players.filter((p) => !p.hasFolded);
    if (activePlayers.length === 1) {
      console.log(
        "[DEBUG] => Only one active player left => immediate termination (fold)."
      );
      console.log("====== handleAction END (termination) ======");
      return { termination: true };
    }

    // ---------- [5] 次のプレイヤーへ ----------
    console.log("[DEBUG] -> Calling nextPlayer()...");
    const oldIndex = this.currentPlayerIndex;
    this.nextPlayer(); // 内部で isAllIn や hasFolded のプレイヤーをスキップする実装が必要

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

    // ---------- [6] ベッティングラウンド終了判定 ----------
    //   - isBettingRoundComplete() は内部で「全員オールイン or 全員hasActed」をチェック
    //   - ここでtrueならthis.advancePhase() → フェーズ移行
    const roundDone = this.isBettingRoundComplete();
    console.log(`[DEBUG] isBettingRoundComplete() => ${roundDone}`);

    if (roundDone || this.currentPlayerIndex === this.actionStartIndex) {
      // 「1周したら終了」判定を残す場合は this.currentPlayerIndex === this.actionStartIndex も考慮
      console.log("[DEBUG] -> Betting round ended => advancePhase().");
      this.advancePhase();
    }

    console.log("====== handleAction END (success) ======");
    return { success: true };
  }

  isBettingRoundComplete() {
    // フォールドしていないプレイヤー
    const activePlayers = this.players.filter((p) => !p.hasFolded);

    // デバッグログ:呼び出しの都度、状況を確認
    console.log(
      "[DEBUG isBettingRoundComplete] activePlayers.length=",
      activePlayers.length
    );

    // 1人以下なら既に終わり(フォールド総取りなど)
    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, deal remaining cards and go SHOWDOWN."
      );

      // フロップ→ターン→リバーを自動的にdealしてSHOWDOWNへ
      while (this.phase !== PHASES.SHOWDOWN) {
        this.advancePhase();
      }
      // ここでショウダウン処理(サイドポット計算など)が走る想定
      return true;
    }

    // ここに来た時点で「複数のアクティブプレイヤーがいて、全員オールインではない」状態
    // => 全員の hasActed が終わったか(またはフォールドorオールイン)をチェック
    const everyoneActed = activePlayers.every((p) => p.hasActed || p.isAllIn);

    console.log("[DEBUG isBettingRoundComplete] =>", everyoneActed);
    return everyoneActed;
  }

  // 次のプレイヤー選出
  nextPlayer() {
    const total = this.players.length;
    let count = 0;
    while (count < total) {
      this.currentPlayerIndex = (this.currentPlayerIndex + 1) % total;
      const curr = this.players[this.currentPlayerIndex];

      // --- [1] もしこのプレイヤーがまだアクション必要な状態 && 切断済みなら自動アクション ---
      if (
        curr.disconnected &&
        !curr.hasFolded &&
        !curr.isAllIn &&
        !curr.hasActed
      ) {
        // チップが足りていれば(=すでにcurrentBetに達していれば)チェック扱い、
        // そうでなければフォールドにする
        if (curr.bet < this.currentBet) {
          // コール or レイズする必要があるが、切断中なのでフォールド
          curr.hasFolded = true;
          curr.hasActed = true;
        } else {
          // すでに currentBet に追いついてる => チェック扱い
          curr.hasActed = true;
        }
      }

      // --- [2] このプレイヤーが「まだ行動していない、フォールドしていない、オールインでもない」なら手番終了 ---
      if (!curr.hasFolded && !curr.isAllIn && !curr.hasActed) {
        return;
      }
      count++;
    }
    // 全員がアクション完了 or フォールド状態なら、ベットラウンド終了
  }

  // フェーズ進行
  advancePhase() {
    console.log(`[DEBUG] advancePhase: currentPhase=${this.phase}`);

    switch (this.phase) {
      case PHASES.PRE_FLOP:
        this.phase = PHASES.FLOP;
        this.dealFlop();
        console.log("[DEBUG] -> Moved to FLOP. Dealt flop cards.");
        this.resetBetsForNextRound(); // ★フロップに移るタイミングでリセット
        break;

      case PHASES.FLOP:
        this.phase = PHASES.TURN;
        this.dealTurn();
        console.log("[DEBUG] -> Moved to TURN. Dealt turn card.");
        this.resetBetsForNextRound(); // ★ターンに移るタイミングでリセット
        break;

      case PHASES.TURN:
        this.phase = PHASES.RIVER;
        this.dealRiver();
        console.log("[DEBUG] -> Moved to RIVER. Dealt river card.");
        this.resetBetsForNextRound(); // ★リバーに移るタイミングでリセット
        break;

      case PHASES.RIVER:
        this.phase = PHASES.SHOWDOWN;
        console.log("[DEBUG] -> Moved to SHOWDOWN.");
        // ここではSHOWDOWNに入るので、resetBetsForNextRound()は不要
        break;

      case PHASES.SHOWDOWN:
        console.log("[DEBUG] -> Already at SHOWDOWN. No further advance.");
        break;

      default:
        console.log("[DEBUG] -> Unknown phase. Doing nothing.");
        break;
    }

    console.log(`[DEBUG] advancePhase end: currentPhase=${this.phase}`);
  }

  resetBetsForNextRound() {
    this.currentBet = 0;
    for (let p of this.players) {
      if (!p.hasFolded && !p.isAllIn) {
        p.bet = 0;
        p.hasActed = false;
      }
    }

    // 今までは「前のベッティングラウンド終了時の currentPlayerIndex」を開始位置にしていたが
    // フロップ以降は「ボタンの左隣」(ヘッズアップはBB)が先にアクションするよう修正

    if (this.phase !== PHASES.PRE_FLOP) {
      // フロップ,ターン,リバーの場合
      if (this.players.length === 2) {
        // ヘッズアップはBBが先
        this.currentPlayerIndex = this.bigBlindIndex;
      } else {
        // 3人以上はボタンの左隣= (dealerButtonIndex + 1) からスタート
        let nextIndex = (this.dealerButtonIndex + 1) % this.players.length;

        // SBがフォールドしていたり、オールインならさらに左隣を探す
        while (
          this.players[nextIndex].hasFolded ||
          this.players[nextIndex].isAllIn
        ) {
          nextIndex = (nextIndex + 1) % this.players.length;
        }
        this.currentPlayerIndex = nextIndex;
      }
    }
    // プリフロップ時(phase===PRE_FLOP)は既存のロジックどおりでOK
    this.actionStartIndex = 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);

    // totalBet が小さい順にソート
    const sorted = [...contenders].sort((a, b) => a.totalBet - b.totalBet);

    let sidePots = [];
    let previous = 0; // 直前の totalBet
    let remaining = contenders.length; // 残りのプレイヤー数

    for (let i = 0; i < sorted.length; i++) {
      const current = sorted[i].totalBet;
      const diff = current - previous;
      if (diff > 0) {
        // diff * remaining が、このポットの総チップ量になる
        sidePots.push({
          amount: diff * remaining,
          // このポットに参加できるのは totalBet >= current の人
          eligiblePlayers: contenders.filter((p) => p.totalBet >= current),
        });
        previous = current;
      }
      remaining--;
    }
    return sidePots;
  }

  // pokersolverからHandをimportしている想定
  // const { Hand } = require('pokersolver');

  determineSidePotWinners() {
    const sidePots = this.computeSidePots();
    let results = [];

    for (const pot of sidePots) {
      const eligible = pot.eligiblePlayers;
      const playerHands = eligible.map((player) => {
        const solverCards = [...player.hand, ...this.communityCards].map(
          convertToPokerSolverFormat
        );
        const solvedHand = Hand.solve(solverCards);
        return { player, solvedHand };
      });

      const winnersHands = Hand.winners(playerHands.map((ph) => ph.solvedHand));
      // winnersHandsは複数の要素が含まれる場合があります(完全に同じ強さでタイのとき)

      // winnersHands に対応するプレイヤーを取得
      const winningPlayers = winnersHands
        .map((wHand) => {
          return (
            playerHands.find((ph) => ph.solvedHand === wHand)?.player || null
          );
        })
        .filter(Boolean);

      // ---- サイドポットを勝者で分配 ----
      const share = Math.floor(pot.amount / winningPlayers.length);
      let leftover = pot.amount % winningPlayers.length;

      winningPlayers.forEach((w) => {
        w.chips += share;
      });

      // leftover配分時に、"ボタン+1" から順にプレイヤーを回す例
      if (leftover > 0) {
        // 現在のプレイヤーリストを「ボタンからスタートして左回り」の並びに並べ替え
        const reorder = [];
        for (let i = 0; i < this.players.length; i++) {
          reorder.push(
            this.players[(this.dealerButtonIndex + 1 + i) % this.players.length]
          );
        }
        // reorderの順序に従って、winners だけ取り出す
        const sortedWinners = reorder.filter((p) => winningPlayers.includes(p));

        // これで button+1 の座席から順に leftover を1チップずつ加算
        let i = 0;
        while (leftover > 0 && sortedWinners.length > 0) {
          sortedWinners[i].chips += 1;
          leftover--;
          i = (i + 1) % sortedWinners.length;
        }
      }

      // ---- 勝者の手役(descr)を表示したい場合 ----
      // 同じポットを勝った複数のプレイヤーがいる場合、それぞれ手役をまとめる
      const winnersInfo = winningPlayers.map((wp) => {
        // 自分がどの solvedHand だったか探す
        const ph = playerHands.find((ph) => ph.player === wp);
        // ph.solvedHand.name => 例 "Full House"
        // ph.solvedHand.descr => 例 "Full House, Aces full of Kings"
        return `${wp.name} (${ph.solvedHand.descr})`;
      });

      results.push({
        potAmount: pot.amount,
        winners: winningPlayers,
        winnersInfo, // 追加: 勝者たちの役情報も保持
      });
    }
    return results;
  }
}

// pokersolver用のカードフォーマット変換
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];
}

//
// ルーム(テーブル)管理
//
const rooms = {};
for (let i = 1; i <= MAX_TABLES; i++) {
  rooms[i] = new TexasHoldemGame();
}
const playerRoomMap = {};

//
// サーバーセットアップ
//
const app = express();
const server = http.createServer(app);
const io = socketIo(server);
app.use(express.static("public"));

// ゲーム状態を各ソケットごとに送信
async function broadcastGameState(roomId) {
  const sockets = await io.in(String(roomId)).fetchSockets();
  sockets.forEach((socket) => {
    socket.emit("gameUpdate", getPublicGameState(roomId, socket.id));
  });
}

function getPublicGameState(roomId, viewerId) {
  const room = rooms[roomId];
  return {
    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) => {
      // SHOWDOWNかつフォールドしていなければ全員のカード公開
      // それ以外のフェーズでは、自分のカードだけ公開
      const showCards =
        p.id === viewerId || (room.phase === PHASES.SHOWDOWN && !p.hasFolded);

      // 手札は公開条件を満たしていればそのまま表示、そうでなければ裏表示にする
      const displayedHand = showCards
        ? p.hand
        : p.hand.map(() => ({ rank: "??", suit: "??" }));

      // --- ここで現在の役を計算するロジックを追加 ---
      // (1) デフォルトでは空文字(または null)
      let currentRank = "";

      // (2) まだフォールドしていない & フロップ以降 & 自分だけが見られる場合
      //     という条件を満たせば、pokersolverで手役を計算
      if (
        !p.hasFolded &&
        showCards &&
        room.phase !== PHASES.PRE_FLOP &&
        room.communityCards.length >= 3
      ) {
        // 現在公開されているコミュニティカードを合算
        const combined = [...p.hand, ...room.communityCards];
        const solved = Hand.solve(combined.map(convertToPokerSolverFormat));
        // 例: solved.name => 'Pair', solved.descr => 'Pair, Tens'
        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,
      };
    }),
  };
}

// ソケットイベント
io.on("connection", (socket) => {
  console.log("New connection:", socket.id);

  socket.on("joinGame", (data) => {
    let roomId = parseInt(data.roomId, 10);
    if (isNaN(roomId) || roomId < 1 || roomId > MAX_TABLES) {
      roomId = 1;
    }
    const room = rooms[roomId];
    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));
    playerRoomMap[socket.id] = roomId;
    broadcastGameState(roomId);
  });

  socket.on("startGame", () => {
    const roomId = playerRoomMap[socket.id];
    if (!roomId) {
      socket.emit("gameMessage", { message: "テーブルに参加していません" });
      return;
    }
    const room = rooms[roomId];

    console.log(`[DEBUG startGame] StartGame called by ${socket.id}.`);
    console.log(
      "[DEBUG startGame] Current players:",
      room.players.map((p) => ({ id: p.id, name: p.name, chips: p.chips }))
    );

    if (room.players.length < 2) {
      io.to(String(roomId)).emit("gameMessage", {
        message: "プレイヤーが2人以上必要です",
      });
      return;
    }

    // ここで room.startRound() など
    room.startRound();
    broadcastGameState(roomId);
  });

  socket.on("playerAction", ({ action, raiseAmount }) => {
    const roomId = playerRoomMap[socket.id];
    if (!roomId) {
      socket.emit("gameMessage", { message: "テーブルに参加していません" });
      return;
    }
    const room = rooms[roomId];
    const result = room.handleAction(socket.id, action, raiseAmount);

    if (result.error) {
      socket.emit("gameMessage", { message: result.error });
      return;
    }

    // 誰か以外全員フォールドの終了処理
    if (result.termination) {
      const activePlayers = room.players.filter((p) => !p.hasFolded);
      const winner = activePlayers[0];
      winner.chips += room.pot;
      const resultMessage = `全員がフォールドしたため、${winner.name}の勝利です。`;
      io.to(String(roomId)).emit("gameResult", {
        message: resultMessage,
        pot: room.pot,
      });
      io.to(winner.id).emit("winNotification", { message: "Win" });
      broadcastGameState(roomId);

      // 5秒待ってから endRoundCleanup() → さらに2秒後に startRound()
      setTimeout(() => {
        endRoundCleanup(roomId);
        broadcastGameState(roomId);

        setTimeout(() => {
          const room = rooms[roomId];
          if (room && room.players.filter((p) => p.chips > 0).length >= 2) {
            room.startRound();
          } else {
            room.phase = PHASES.WAITING;
          }
          broadcastGameState(roomId);
        }, 2000);
      }, 5000);

      return;
    }

    // 通常の更新
    broadcastGameState(roomId);

    // ショウダウンに突入したらサイドポット計算
    if (room.phase === PHASES.SHOWDOWN) {
      const sidePotResults = room.determineSidePotWinners();
      let resultMessage = "";
      const winnersSet = new Set();
      sidePotResults.forEach((pot, idx) => {
        // winnersInfo を直接使って「○○(Four of a Kind)」のように表示
        const winnersText = pot.winnersInfo.join(", ");
        resultMessage += `サイドポット${idx + 1} (${
          pot.potAmount
        }): ${winnersText}\n`;

        const share = Math.floor(pot.potAmount / pot.winners.length);
        pot.winners.forEach((w) => {
          w.chips += share;
          winnersSet.add(w);
        });
      });

      io.to(String(roomId)).emit("gameResult", {
        message: resultMessage,
        pot: room.pot,
      });
      // 勝者にWin通知
      winnersSet.forEach((w) => {
        io.to(w.id).emit("winNotification", { message: "Win" });
      });

      // ショウダウン画面を十分表示するため、数秒待ってから次へ
      // (ここでは8秒)
      setTimeout(() => {
        room.phase = PHASES.WAITING;
        room.incorporateWaitingPlayers();
        room.players = room.players.filter((p) => !p.disconnected);
        endRoundCleanup(roomId);
        broadcastGameState(roomId);
        setTimeout(() => {
          if (room.players.filter((p) => p.chips > 0).length >= 2) {
            room.startRound();
          } else {
            room.phase = PHASES.WAITING;
          }
          broadcastGameState(roomId);
        }, 2000); // WAITINGにしてからさらに2秒後に次スタート(計10秒)
      }, 8000);
    }
  });

  socket.on("disconnect", () => {
    const roomId = playerRoomMap[socket.id];
    if (roomId) {
      const room = rooms[roomId];

      // プレイヤーを即座に remove せず、disconnected=true にする
      const target = room.players.find((p) => p.id === socket.id);
      if (target) {
        target.disconnected = true;
      }

      // ただし waitingPlayers にいる場合は取り除いてOK(まだハンドに参加していないので)
      room.waitingPlayers = room.waitingPlayers.filter(
        (p) => p.id !== socket.id
      );

      // [注意] ここでは room.players からは削除しない
      // room.players = room.players.filter((p) => p.id !== socket.id);

      delete playerRoomMap[socket.id];

      broadcastGameState(roomId);
    }
    console.log("Disconnect:", socket.id);
  });
});

// 6max用のポジション名配列
const SIX_MAX_POSITIONS = ["BTN", "SB", "BB", "UTG", "HJ", "CO"];

function getPositionLabel(game, seatIndex) {
  const n = game.players.length;
  // ディーラーボタンの位置
  const button = game.dealerButtonIndex;
  // offsetを計算 (seatIndexがボタンから何番目にいるか)
  const offset = (seatIndex - button + n) % n;

  // ヘッズアップ(2人)なら特別処理
  if (n === 2) {
    // offset=0 => BTN/SB, offset=1 => BB
    return offset === 0 ? "SB(BTN)" : "BB";
  }
  // 3~6人の場合: offsetに従ってSIX_MAX_POSITIONSを割り当て
  if (offset < SIX_MAX_POSITIONS.length) {
    return SIX_MAX_POSITIONS[offset];
  }
  // 念のため
  return "";
}

// ラウンド終了処理の例
function endRoundCleanup(roomId) {
  console.log(`[DEBUG endRoundCleanup] Called for roomId=${roomId}`);
  const room = rooms[roomId];
  if (!room) return;

  // (1) 待機者合流
  if (typeof room.incorporateWaitingPlayers === "function") {
    console.log(
      "[DEBUG endRoundCleanup] -> Calling incorporateWaitingPlayers()"
    );
    room.incorporateWaitingPlayers();
  }

  // (2) 0チップ or disconnected をplayers配列から除外
  const beforeCount = room.players.length;
  room.players = room.players.filter((p) => p.chips > 0 && !p.disconnected);
  const removedPlayers = beforeCount - room.players.length;
  console.log(
    `[DEBUG endRoundCleanup] -> Removed ${removedPlayers} players from room.players.`
  );

  // waitingPlayers も同様に除外 (不要なら省略可)
  const beforeWait = room.waitingPlayers.length;
  room.waitingPlayers = room.waitingPlayers.filter(
    (p) => p.chips > 0 && !p.disconnected
  );
  console.log(
    `[DEBUG endRoundCleanup] -> Removed ${
      beforeWait - room.waitingPlayers.length
    } from waitingPlayers.`
  );

  // ログ出し
  console.log(
    "[DEBUG endRoundCleanup] final players:",
    room.players.map((p) => ({
      id: p.id,
      name: p.name,
      chips: p.chips,
    }))
  );
  console.log(
    "[DEBUG endRoundCleanup] final waitingPlayers:",
    room.waitingPlayers.map((p) => ({
      id: p.id,
      name: p.name,
      chips: p.chips,
    }))
  );

  // 2人未満ならWAITINGにしておく
  if (room.players.length < 2) {
    console.log("[DEBUG endRoundCleanup] -> Not enough players => WAITING");
    room.phase = PHASES.WAITING;
  }

  // (4) 最後に broadcast
  console.log("[DEBUG endRoundCleanup] -> broadcastGameState(roomId)");
  broadcastGameState(roomId);
  console.log("[DEBUG endRoundCleanup] Done.");
}

server.listen(process.env.PORT || 8080, () => {
  console.log(`Server running on port ${process.env.PORT || 8080}`);
});

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?