オンライン対戦可能なテキサスホールデムを作っています。
最新版では以下の機能追加やバグ改善をしました。
・Geminiとのオンライン対戦機能の強化
・キッカー勝負になったとき正しく勝敗判定をする。
・その他勝敗判定のバグ改善
・UI(ルーム一覧表示機能)の強化
まだまだ追加したい機能が山盛りですが、今日の改善で結構普通に遊べるようになったかな?と思います。
ご意見ご感想などぜひよろしくお願いいたします。
ソースコード
texasholdem.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Texas Hold'em Online</title>
<style>
body {
background: #006400;
color: #fff;
font-family: sans-serif;
padding: 20px;
}
.container {
max-width: 800px;
margin: auto;
background: #228B22;
padding: 20px;
border-radius: 10px;
}
input,
button {
margin: 5px;
padding: 5px 10px;
border: none;
border-radius: 4px;
}
button {
cursor: pointer;
}
.player {
margin-bottom: 5px;
padding: 5px;
}
.current-turn {
background-color: yellow;
color: black;
font-weight: bold;
}
.card {
width: 70px;
margin: 2px;
}
#joinedRooms>div {
border: 1px solid #fff;
margin: 10px;
padding: 10px;
}
.room-actions {
margin-top: 10px;
}
.room-messages {
margin-top: 10px;
color: yellow;
}
.community-cards {
margin-top: 10px;
}
.my-turn-room {
border: 3px solid red !important;
}
/* ルーム一覧のテーブルをスタイリング */
#roomList table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
background: #006400;
}
#roomList th,
#roomList td {
border: 1px solid #fff;
padding: 8px;
text-align: center;
}
#roomList th {
background: #228B22;
cursor: pointer;
}
</style>
<script>
// --- カード画像のプリロード(既存処理) ---
const cardImages = {};
function preloadCards() {
const suits = ["C", "D", "H", "S"];
const ranks = ["2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A"];
for (let suit of suits) {
for (let rank of ranks) {
const key = rank + suit;
const img = new Image();
img.src = `playingcard/${key}.svg`;
cardImages[key] = img;
}
}
const backImg = new Image();
backImg.src = "playingcard/back.svg";
cardImages["back"] = backImg;
const backGrayImg = new Image();
backGrayImg.src = "playingcard/back-gray.svg";
cardImages["back-gray"] = backGrayImg;
console.log("Cards preloaded:", Object.keys(cardImages));
}
preloadCards();
window.cardImages = cardImages;
// --- カード描画(既存処理) ---
function getCardImageHtml(card) {
if (!card) return "";
let key = "";
if (card.rank === "??" && card.suit === "??") {
key = card.folded ? "back-gray" : "back";
} else {
let suitLetter = "";
switch (card.suit) {
case "♣": suitLetter = "C"; break;
case "♦": suitLetter = "D"; break;
case "♥": suitLetter = "H"; break;
case "♠": suitLetter = "S"; break;
}
key = `${card.rank}${suitLetter}`;
}
if (window.cardImages && window.cardImages[key]) {
return `<img src="${window.cardImages[key].src}" alt="${key}" class="card">`;
} else {
return `<img src="playingcard/${key}.svg" alt="${key}" class="card">`;
}
}
function renderHand(hand) {
if (!hand || hand.length === 0) return "No Cards";
return hand.map(card => getCardImageHtml(card)).join("");
}
window.renderHand = renderHand;
</script>
<script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
</head>
<body>
<div class="container">
<h1>テキサスホールデムオンライン</h1>
<!-- 名前入力とルーム一覧ボタン -->
<div>
<input type="text" id="playerName" placeholder="名前">
<button id="loadRoomListBtn">ルーム一覧を読み込み</button>
<!-- ここにテーブル形式のルーム一覧を表示する -->
<div id="roomList">
<!-- テーブルヘッダ部分 -->
<table>
<thead>
<tr>
<th onclick="sortRooms('tableName')">テーブル名</th>
<th onclick="sortRooms('stake')">ステーク</th>
<th onclick="sortRooms('gameType')">ゲーム</th>
<th onclick="sortRooms('playerCount')">着席者数</th>
<th onclick="sortRooms('waitingCount')">空席待ち</th>
<th>参加</th>
</tr>
</thead>
<tbody id="roomListBody">
<!-- ルームごとの行が動的に入る -->
</tbody>
</table>
</div>
</div>
<hr>
<div id="messages" style="margin-top:10px; color:yellow;"></div>
<div id="result" style="margin-top:10px;"></div>
<!-- 参加中のルーム情報を表示 -->
<div id="joinedRooms"></div>
<div id="countdownTimer" style="font-size: 24px; text-align: center; margin-bottom: 10px;"></div>
</div>
<script>
//const socket = io("http://localhost:8080");
const socket = io("https://socket-io-service-466625290686.us-central1.run.app");
let myId = null;
// 受け取ったルームデータを保持し、ソートや再描画に利用する
let roomsData = [];
let currentSortColumn = null;
let currentSortDirection = 1; // 1: 昇順, -1: 降順
const roomMessages = {};
const loadRoomListBtn = document.getElementById('loadRoomListBtn');
const roomListDiv = document.getElementById('roomList');
const roomListBody = document.getElementById('roomListBody');
const playerNameInput = document.getElementById('playerName');
// --- ソート用関数 ---
function sortRooms(column) {
if (currentSortColumn === column) {
// 同じ列なら昇順・降順を反転
currentSortDirection = -currentSortDirection;
} else {
currentSortColumn = column;
currentSortDirection = 1;
}
roomsData.sort((a, b) => {
let cmp = 0;
if (column === "tableName") {
// "テーブル"の後ろの数値部分を抽出して数値として比較
const numA = parseInt(a.tableName.replace("テーブル", ""), 10);
const numB = parseInt(b.tableName.replace("テーブル", ""), 10);
cmp = numA - numB;
} else {
// 他の列は数値の場合は数値比較、そうでなければ文字列比較
if (!isNaN(a[column]) && !isNaN(b[column])) {
cmp = a[column] - b[column];
} else {
cmp = a[column].toString().localeCompare(b[column].toString());
}
}
return cmp * currentSortDirection;
});
renderRoomsTable();
}
// --- ルーム一覧テーブルを描画 ---
function renderRoomsTable() {
// tbodyをクリア
roomListBody.innerHTML = "";
roomsData.forEach((r) => {
// それぞれのカラムを表示
const tr = document.createElement("tr");
// テーブル名
const tdName = document.createElement("td");
tdName.textContent = r.tableName;
tr.appendChild(tdName);
// ステーク (SB/BB)
const tdStake = document.createElement("td");
tdStake.textContent = r.stake;
tr.appendChild(tdStake);
// ゲーム (VS 対人 or VS Gemini)
const tdGame = document.createElement("td");
tdGame.textContent = r.gameType;
tr.appendChild(tdGame);
// 着席者数
const tdPlayerCount = document.createElement("td");
tdPlayerCount.textContent = r.playerCount;
tr.appendChild(tdPlayerCount);
// 空席待ち
const tdWaiting = document.createElement("td");
tdWaiting.textContent = r.waitingCount;
tr.appendChild(tdWaiting);
// 参加ボタン
const tdJoin = document.createElement("td");
const joinBtn = document.createElement("button");
joinBtn.textContent = "参加";
joinBtn.onclick = () => joinRoom(r.roomId);
tdJoin.appendChild(joinBtn);
tr.appendChild(tdJoin);
roomListBody.appendChild(tr);
});
}
// --- ルームに参加する処理 ---
function joinRoom(roomId) {
const name = playerNameInput.value.trim();
if (!name) {
alert("名前を入力してください");
return;
}
socket.emit("joinGame", { playerName: name, roomId });
}
// --- ルーム一覧をサーバーに要求 ---
function loadRoomList() {
console.log("getRooms emitted");
socket.emit("getRooms");
}
loadRoomListBtn.onclick = loadRoomList;
// --- ソケット接続時 ---
socket.on('connect', () => {
myId = socket.id;
console.log("Connected with socket id:", myId);
loadRoomList();
});
// --- ルーム一覧を受信したとき ---
socket.on("roomList", (rooms) => {
console.log("roomList received:", rooms);
// roomsData を組み立てる
// 例: rooms[i] に sb, bb, type, playerCount, waitingCount, roomId などの情報があると仮定
// テーブル名は「テーブル1, テーブル2, ...」として付ける
// もしサーバーから "tableName" が来るならそれを使ってもOK
roomsData = rooms.map((r, index) => {
// SB/BB の文字列を作成 (例: "10/20")
const stakeStr = `${r.sb}/${r.bb}`;
// ゲームタイプ
const gameTypeStr = r.type === "human" ? "VS 対人" : "VS Gemini";
return {
roomId: r.roomId,
tableName: `テーブル${index + 1}`, // 任意で付与
stake: stakeStr,
gameType: gameTypeStr,
playerCount: r.playerCount || 0,
waitingCount: r.waitingCount || 0
};
});
// 受け取った時点で描画(デフォルトはそのままの順番)
renderRoomsTable();
});
// --- ゲーム開始やアクション送信など既存処理 ---
const joinedRoomsDiv = document.getElementById("joinedRooms");
function sendActionForRoom(roomId, actionType) {
if (actionType === "fold") {
stopCountdown();
}
const input = document.getElementById('raiseAmount-' + roomId);
const raiseAmount = input ? parseInt(input.value, 10) || 0 : 0;
socket.emit("playerAction", { roomId, action: actionType, raiseAmount });
}
function startGameForRoom(roomId) {
socket.emit("startGame", { roomId });
let roomMsgDiv = document.getElementById("roomMsg-" + roomId);
if (roomMsgDiv) { roomMsgDiv.innerHTML = ""; }
}
// --- カウントダウン用グローバル変数 ---
let countdownInterval = null;
let autoActionTimeout = null;
// カウントダウン表示用関数
function startCountdown(seconds) {
const timerEl = document.getElementById("countdownTimer");
if (!timerEl) return;
timerEl.textContent = `残り ${seconds} 秒`;
// 既存のタイマーがあればクリア
if (countdownInterval) clearInterval(countdownInterval);
countdownInterval = setInterval(() => {
seconds--;
if (seconds <= 0) {
clearInterval(countdownInterval);
timerEl.textContent = "";
} else {
timerEl.textContent = `残り ${seconds} 秒`;
}
}, 1000);
}
// タイマーを停止する関数
function stopCountdown() {
const timerEl = document.getElementById("countdownTimer");
if (countdownInterval) clearInterval(countdownInterval);
if (autoActionTimeout) clearTimeout(autoActionTimeout);
countdownInterval = null;
autoActionTimeout = null;
if (timerEl) timerEl.textContent = "";
}
// --- メッセージやゲーム更新のハンドラ(既存) ---
socket.on("roomMessage", (data) => {
if (data.roomId) {
roomMessages[data.roomId] = data.message;
let roomMsgDiv = document.getElementById("roomMsg-" + data.roomId);
if (roomMsgDiv) { roomMsgDiv.innerHTML = `<h3>${data.message}</h3>`; }
} else {
document.getElementById("messages").innerHTML = data.message;
}
});
socket.on("gameUpdate", (data) => {
const roomId = data.roomId;
let roomDiv = document.getElementById("room-" + roomId);
if (!roomDiv) {
roomDiv = document.createElement("div");
roomDiv.id = "room-" + roomId;
joinedRoomsDiv.appendChild(roomDiv);
}
let roomMsgDiv = document.getElementById("roomMsg-" + roomId);
if (!roomMsgDiv) {
roomMsgDiv = document.createElement("div");
roomMsgDiv.id = "roomMsg-" + roomId;
roomMsgDiv.className = "room-messages";
roomDiv.appendChild(roomMsgDiv);
}
let roomContentDiv = document.getElementById("roomContent-" + roomId);
if (!roomContentDiv) {
roomContentDiv = document.createElement("div");
roomContentDiv.id = "roomContent-" + roomId;
roomDiv.insertBefore(roomContentDiv, roomMsgDiv);
}
const viewer = data.players.find(p => p.id === myId);
const maxRaise = viewer ? viewer.chips : 0;
// チェックボタンの活性/非活性を、数値として viewer.bet と data.currentBet を比較
const viewerBet = Number(viewer ? viewer.bet : 0);
const tableBet = Number(data.currentBet);
const disableCheck = (viewerBet !== tableBet);
let actionsHtml = '';
if (data.phase === "WAITING") {
actionsHtml = `
<div class="room-actions">
<button onclick="startGameForRoom(${roomId})">ゲーム開始</button>
</div>
`;
} else {
actionsHtml = `
<div class="room-actions">
<button onclick="sendActionForRoom(${roomId}, 'fold')">フォールド</button>
<button onclick="sendActionForRoom(${roomId}, 'call')">コール</button>
<button onclick="sendActionForRoom(${roomId}, 'check')" ${disableCheck ? 'disabled' : ''}>チェック</button>
<div>
<label for="raiseAmount-${roomId}">レイズ額: </label>
<input type="range" id="raiseAmount-${roomId}" min="0" max="${maxRaise}" value="0"
oninput="document.getElementById('raiseAmountOutput-${roomId}').value = this.value">
<output id="raiseAmountOutput-${roomId}">0</output>
<button onclick="sendActionForRoom(${roomId}, 'raise')">レイズ</button>
</div>
</div>
`;
}
roomContentDiv.innerHTML = `
<div class="room-status">
<h3>ルーム${roomId}</h3>
<p>フェーズ: ${data.phase} | ポット: ${data.pot} | 現在のベット: ${data.currentBet}</p>
<p>現在の手番: ${data.currentPlayerId || ""}</p>
<div class="community-cards">
<h3>コミュニティカード:</h3>
${renderHand(data.communityCards)}
</div>
<div>
${data.players.map(p => `
<div class="player ${p.id === data.currentPlayerId ? 'current-turn' : ''}">
<strong>${p.positionLabel} ${p.name}</strong> - Chips: ${p.chips}, Bet: ${p.bet}, Fold: ${p.hasFolded}
<br>Hand: ${renderHand(p.hand)} ${p.rank ? `| ${p.rank}` : ''}
</div>
`).join('')}
</div>
</div>
${actionsHtml}
`;
if (data.currentPlayerId === myId) {
roomDiv.classList.add("my-turn-room");
roomDiv.scrollIntoView({ behavior: "smooth", block: "start" });
} else {
roomDiv.classList.remove("my-turn-room");
}
// --- カウントダウン制御 ---
// ゲーム終了やショウダウン、またはフェーズが WAITING の場合は常にタイマーを停止
if (data.phase === "WAITING" || data.phase === "SHOWDOWN") {
stopCountdown();
}
// それ以外で、自分の手番ならカウントダウン開始
else if (data.currentPlayerId === myId && viewer && !viewer.hasFolded) {
startCountdown(10);
if (autoActionTimeout) clearTimeout(autoActionTimeout);
autoActionTimeout = setTimeout(() => {
const updatedViewer = data.players.find(p => p.id === myId);
const autoAction = (updatedViewer && updatedViewer.bet === data.currentBet) ? "check" : "fold";
console.log(`[DEBUG] Auto action timeout reached. Emitting auto action: ${autoAction}`);
sendActionForRoom(data.roomId, autoAction);
stopCountdown();
}, 10000);
}
else {
stopCountdown();
}
});
function sendActionForRoom(roomId, actionType) {
// ※ここではエラーや正常な手動アクション送信時は gameUpdate で再更新されるので、
// このタイミングで stopCountdown() は呼ばない(不要な場合は、後から gameUpdate で反映される)
const input = document.getElementById('raiseAmount-' + roomId);
const raiseAmount = input ? parseInt(input.value, 10) || 0 : 0;
socket.emit("playerAction", { roomId, action: actionType, raiseAmount });
}
socket.on("gameMessage", (data) => {
// 「チェック可能な状況でフォールドはできません」というエラーの場合はカウントダウンを止めずにエラー表示のみ
if (data.message.includes("チェック可能な状況でフォールドはできません")) {
let roomMsg = document.getElementById("roomMsg-" + data.roomId);
if (roomMsg) {
roomMsg.innerHTML = `<h3>${data.message}</h3>`;
}
// カウントダウンはそのまま継続
} else {
// その他の場合は通常どおりカウントダウンを停止する
stopCountdown();
let roomMsg = document.getElementById("roomMsg-" + data.roomId);
if (roomMsg) {
roomMsg.innerHTML = `<h3>${data.message}</h3>`;
}
}
});
socket.on("gameResult", (data) => {
stopCountdown();
if (data.roomId) {
let roomMsgDiv = document.getElementById("roomMsg-" + data.roomId);
if (roomMsgDiv) {
roomMsgDiv.innerHTML = `<h3 style="white-space: pre-line;">${data.message}</h3>`;
}
} else {
document.getElementById("messages").innerHTML = data.message;
}
});
socket.on("winNotification", (data) => {
stopCountdown();
if (data.roomId && data.winnerId && data.winnerId === myId) {
let roomMsgDiv = document.getElementById("roomMsg-" + data.roomId);
if (roomMsgDiv) {
roomMsgDiv.innerHTML += `<h3 style="color:red;">${data.message}</h3>`;
}
}
});
socket.on("clearRoomMessage", (data) => {
if (data.roomId) {
let roomMsgDiv = document.getElementById("roomMsg-" + data.roomId);
if (roomMsgDiv) { roomMsgDiv.innerHTML = ""; }
}
});
</script>
</body>
</html>
texasgame.js
// game.js
const { PHASES, SMALL_BLIND, BIG_BLIND } = require("./config");
const { convertToPokerSolverFormat } = require("./utils");
const { Hand } = require("pokersolver");
class TexasHoldemGame {
constructor() {
this.players = [];
this.deck = [];
this.communityCards = [];
this.pot = 0;
this.currentBet = 0;
this.currentPlayerIndex = 0;
this.phase = PHASES.WAITING;
this.dealerButtonIndex = 0;
this.actionStartIndex = 0;
this.smallBlindIndex = null;
this.bigBlindIndex = null;
this.waitingPlayers = [];
this.showdownEvaluated = false;
this.onShowdown = this.evaluateShowdown.bind(this);
this.sendResultCallback = null;
}
incorporateWaitingPlayers() {
console.log(
"[DEBUG incorporateWaitingPlayers] waitingPlayers:",
this.waitingPlayers.map((p) => ({
id: p.id,
name: p.name,
chips: p.chips,
disconnected: p.disconnected,
}))
);
const validWaiters = this.waitingPlayers.filter(
(p) => p.chips > 0 && !p.disconnected
);
console.log(
`[DEBUG incorporateWaitingPlayers] Valid waiting players: ${validWaiters.length} / ${this.waitingPlayers.length}`
);
this.players.push(...validWaiters);
this.waitingPlayers = [];
console.log(
"[DEBUG incorporateWaitingPlayers] players:",
this.players.map((p) => ({
id: p.id,
name: p.name,
chips: p.chips,
disconnected: p.disconnected,
}))
);
}
initializeDeck() {
const suits = ["♠", "♥", "♦", "♣"];
const ranks = [
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"J",
"Q",
"K",
"A",
];
this.deck = [];
for (let suit of suits) {
for (let rank of ranks) {
this.deck.push({ suit, rank });
}
}
// シャッフル
for (let i = this.deck.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[this.deck[i], this.deck[j]] = [this.deck[j], this.deck[i]];
}
}
addPlayer({ id, name, chips = 1000 }) {
if (this.phase === PHASES.WAITING) {
this.players.push({
id,
name,
chips,
bet: 0,
totalBet: 0,
hasFolded: false,
isAllIn: false,
hand: [],
hasActed: false,
disconnected: false,
});
} else {
this.waitingPlayers.push({
id,
name,
chips,
bet: 0,
totalBet: 0,
hasFolded: false,
isAllIn: false,
hand: [],
hasActed: false,
});
}
}
removePlayer(socketId) {
this.players = this.players.filter((p) => p.id !== socketId);
this.waitingPlayers = this.waitingPlayers.filter((p) => p.id !== socketId);
}
startRound() {
this.showdownEvaluated = false;
if (this.phase !== PHASES.WAITING && this.players.length > 1) {
this.dealerButtonIndex =
(this.dealerButtonIndex + 1) % this.players.length;
}
this.phase = PHASES.PRE_FLOP;
this.initializeDeck();
this.communityCards = [];
this.pot = 0;
this.currentBet = 0;
for (let p of this.players) {
p.bet = 0;
p.totalBet = 0;
p.hasFolded = false;
p.isAllIn = false;
p.hand = [];
p.hasActed = false;
}
for (let p of this.players) {
p.hand.push(this.deck.pop(), this.deck.pop());
}
if (this.players.length >= 2) {
if (this.players.length === 2) {
this.smallBlindIndex = this.dealerButtonIndex;
this.bigBlindIndex = (this.dealerButtonIndex + 1) % this.players.length;
this._postBet(this.players[this.smallBlindIndex], SMALL_BLIND);
this._postBet(this.players[this.bigBlindIndex], BIG_BLIND);
this.currentBet = BIG_BLIND;
this.currentPlayerIndex = this.smallBlindIndex;
this.actionStartIndex = this.currentPlayerIndex;
} else {
const sbIndex = (this.dealerButtonIndex + 1) % this.players.length;
const bbIndex = (this.dealerButtonIndex + 2) % this.players.length;
this._postBet(this.players[sbIndex], SMALL_BLIND);
this._postBet(this.players[bbIndex], BIG_BLIND);
this.currentBet = BIG_BLIND;
this.currentPlayerIndex =
(this.dealerButtonIndex + 3) % this.players.length;
this.actionStartIndex = this.currentPlayerIndex;
this.smallBlindIndex = sbIndex;
this.bigBlindIndex = bbIndex;
}
}
console.log(
`[DEBUG startRound] phase=${this.phase}, dealerButtonIndex=${this.dealerButtonIndex}, smallBlindIndex=${this.smallBlindIndex}, bigBlindIndex=${this.bigBlindIndex}, currentPlayerIndex=${this.currentPlayerIndex}`
);
}
_postBet(player, amount) {
let toPost = amount;
if (amount > player.chips) {
toPost = player.chips;
player.isAllIn = true;
player.hasActed = true;
}
player.chips -= toPost;
player.bet += toPost;
player.totalBet += toPost;
this.pot += toPost;
}
// ここから新規追加
executePlayerAction(player, action) {
console.log(
`[DEBUG] executePlayerAction: Executing action "${action}" for player ${player.id}`
);
return this.handleAction(player.id, action);
}
nextPlayerTurn() {
console.log("[DEBUG] nextPlayerTurn: Moving to next player");
return this.nextPlayer();
}
// 自動アクションの処理(修正後)
startPlayerTurn(player) {
console.log(
`[DEBUG] startPlayerTurn: Starting turn for player ${player.id} with a 10-second timer.`
);
clearTimeout(player.turnTimer);
player.turnTimer = setTimeout(() => {
const autoAction = this.currentBet > 0 ? "fold" : "check";
console.log(
`[DEBUG] Auto action timer fired for player ${player.id}: ${autoAction}`
);
this.executePlayerAction(player, autoAction);
player.consecutiveTimeouts = (player.consecutiveTimeouts || 0) + 1;
if (player.consecutiveTimeouts >= 2) {
console.log(
`[DEBUG] Player ${player.id} reached consecutive timeout limit. Kicking player.`
);
this.kickPlayer(player);
}
this.nextPlayerTurn();
}, 10000);
}
// 手動アクションの処理(修正後)
onPlayerAction(player, action) {
console.log(
`[DEBUG] onPlayerAction: Player ${player.id} performed action "${action}" before timeout.`
);
clearTimeout(player.turnTimer);
this.executePlayerAction(player, action);
player.consecutiveTimeouts = 0;
this.nextPlayerTurn();
}
handleAction(playerId, action, raiseAmount = 0) {
if (this.phase === PHASES.SHOWDOWN) {
console.log("[DEBUG] Action received during SHOWDOWN phase, ignoring.");
return { error: "ショウダウン中は操作できません" };
}
console.log("====== handleAction START ======");
console.log(
`[DEBUG] Called by playerId=${playerId}, action=${action}, raiseAmount=${raiseAmount}`
);
console.log(
`[DEBUG] currentPlayerIndex=${this.currentPlayerIndex}, currentBet=${this.currentBet}`
);
this.players.forEach((p, i) => {
console.log(
`[DEBUG] Player idx=${i}, id=${p.id}, name=${p.name}, folded=${p.hasFolded}, allIn=${p.isAllIn}, hasActed=${p.hasActed}, bet=${p.bet}, chips=${p.chips}`
);
});
const playerIndex = this.players.findIndex((p) => p.id === playerId);
if (playerIndex !== this.currentPlayerIndex) {
console.log("[DEBUG] -> Not the correct turn for this player.");
console.log("====== handleAction END (error) ======");
return { error: "現在のアクションはあなたの番ではありません" };
}
const player = this.players[playerIndex];
if (player.hasFolded || player.isAllIn) {
console.log("[DEBUG] -> Player is already folded or all-in.");
console.log("====== handleAction END (error) ======");
return { error: "すでにフォールドまたはオールインしています" };
}
switch (action) {
case "fold":
// チェック可能な状況(自分のベットが currentBet と等しい場合)は、フォールドが意味を持たない
if (player.bet === this.currentBet) {
if (player.id === "GeminiAI") {
console.log(
"[DEBUG] -> GeminiAI attempted fold in checkable situation; overriding to check."
);
player.hasActed = true;
break;
} else {
console.log(
"[DEBUG] -> Attempted to fold in a checkable situation; fold is not allowed."
);
return { error: "チェック可能な状況でフォールドはできません" };
}
}
// それ以外は通常のフォールド処理
player.hasFolded = true;
player.hasActed = true;
console.log(`[DEBUG] -> Player ${player.id} folded.`);
break;
case "check":
if (player.bet !== this.currentBet && this.currentBet !== 0) {
console.log("[DEBUG] -> Attempted to check, but bet mismatch.");
console.log("====== handleAction END (error) ======");
return {
error: "チェックは現在のベット額と一致している場合のみ可能です",
};
}
player.hasActed = true;
console.log(`[DEBUG] -> Player ${player.id} checked.`);
break;
case "call": {
const diff = this.currentBet - player.bet;
console.log(
`[DEBUG] -> call diff=${diff}, playerChips=${player.chips}`
);
if (diff > 0) {
if (diff >= player.chips) {
console.log("[DEBUG] -> Player goes all-in (call).");
this._postBet(player, player.chips);
player.isAllIn = true;
} else {
this._postBet(player, diff);
console.log(`[DEBUG] -> Player calls. Bet +${diff}.`);
}
} else {
console.log("[DEBUG] -> Already matched or behind.");
}
player.hasActed = true;
break;
}
case "raise": {
const additional = raiseAmount;
if (additional <= 0) {
console.log("[DEBUG] -> Invalid raiseAmount (<=0).");
console.log("====== handleAction END (error) ======");
return { error: "レイズ額は正の数を入力してください" };
}
// 対戦相手の中で、まだアクション可能な(フォールドしていない)プレイヤーを対象にする
const opponents = this.players.filter(
(p) => p.id !== player.id && !p.hasFolded
);
// すべての対戦相手がオール・イン状態の場合、レイズは許されない
if (opponents.length > 0 && opponents.every((p) => p.isAllIn)) {
console.log(
"[DEBUG] -> Raise not allowed: 全対戦相手がオール・イン状態です。"
);
return {
error:
"全対戦相手がオールイン状態のため、レイズはできません。コールかフォールドしてください。",
};
}
const finalBet = this.currentBet + additional;
console.log(
`[DEBUG] -> finalBet=${finalBet}, currentBet(before)=${this.currentBet}, player.bet=${player.bet}`
);
if (finalBet <= this.currentBet) {
console.log("[DEBUG] -> finalBet <= currentBet => invalid raise.");
console.log("====== handleAction END (error) ======");
return {
error: "レイズ額は現在のベットより大きくなければなりません",
};
}
const diff = finalBet - player.bet;
if (diff <= 0) {
console.log("[DEBUG] -> diff <= 0 => already bet enough?");
console.log("====== handleAction END (error) ======");
return { error: "既にその額以上をベットしています。" };
}
if (diff >= player.chips) {
console.log("[DEBUG] -> Raise exceeds player's chips => all-in.");
this._postBet(player, player.chips);
player.isAllIn = true;
} else {
this._postBet(player, diff);
console.log(`[DEBUG] -> Player raised. Bet +${diff}.`);
}
console.log(
`[DEBUG] -> Updating currentBet from ${this.currentBet} to ${player.bet}`
);
this.currentBet = player.bet;
player.hasActed = true;
this.players.forEach((p, i) => {
if (!p.hasFolded && !p.isAllIn && i !== playerIndex) {
p.hasActed = false;
console.log(`[DEBUG] -> Reset hasActed for idx=${i}, id=${p.id}`);
}
});
this.actionStartIndex = playerIndex;
break;
}
default:
console.log("[DEBUG] -> Unknown action.");
console.log("====== handleAction END (error) ======");
return { error: "不明なアクションです" };
}
console.log("[DEBUG] After action processing:");
console.log(
`[DEBUG] currentBet=${this.currentBet}, currentPlayerIndex=${this.currentPlayerIndex}`
);
this.players.forEach((p, i) => {
console.log(
`[DEBUG] idx=${i}, id=${p.id}, name=${p.name}, folded=${p.hasFolded}, allIn=${p.isAllIn}, hasActed=${p.hasActed}, bet=${p.bet}, chips=${p.chips}`
);
});
const activePlayers = this.players.filter((p) => !p.hasFolded);
if (activePlayers.length === 1) {
console.log("[DEBUG] => Only one active player left => termination.");
console.log("====== handleAction END (termination) ======");
return { termination: true };
}
console.log("[DEBUG] -> Calling nextPlayer()...");
const oldIndex = this.currentPlayerIndex;
this.nextPlayer();
console.log(
`[DEBUG] -> nextPlayer() done. oldIndex=${oldIndex}, newIndex=${this.currentPlayerIndex}`
);
const roundDone = this.isBettingRoundComplete();
console.log(`[DEBUG] isBettingRoundComplete() => ${roundDone}`);
if (roundDone) {
console.log("[DEBUG] -> Betting round ended => advancePhase().");
this.advancePhase();
}
console.log("====== handleAction END (success) ======");
return { success: true };
}
isBettingRoundComplete() {
console.log("[DEBUG isBettingRoundComplete] START phase=", this.phase);
const activePlayers = this.players.filter((p) => !p.hasFolded);
if (activePlayers.length <= 1) {
console.log(
"[DEBUG isBettingRoundComplete] => true (only one player remains)"
);
return true;
}
const allAllIn = activePlayers.every((p) => p.isAllIn);
console.log("[DEBUG isBettingRoundComplete] allAllIn =", allAllIn);
if (allAllIn) {
console.log(
"[DEBUG isBettingRoundComplete] => All players all-in, moving to SHOWDOWN."
);
while (this.phase !== PHASES.SHOWDOWN) {
this.advancePhase();
}
return true;
}
const everyoneActed = activePlayers.every((p) => {
if (p.isAllIn || p.hasFolded) return true;
if (this.currentBet === 0) {
return p.hasActed;
}
return p.bet >= this.currentBet && p.hasActed;
});
console.log(
"[DEBUG isBettingRoundComplete] everyoneActed =",
everyoneActed
);
console.log("[DEBUG isBettingRoundComplete] END =>", everyoneActed);
return everyoneActed;
}
nextPlayer() {
console.log(
`[DEBUG nextPlayer] START currentPlayerIndex=${this.currentPlayerIndex}, phase=${this.phase}`
);
if (this.players.length === 2) {
// ヘッズアップの場合、次のプレイヤーがオール・インまたは既にアクション済みであれば、ラウンド終了とみなす
const nextIndex = (this.currentPlayerIndex + 1) % 2;
const nextPlayer = this.players[nextIndex];
if (nextPlayer.isAllIn || nextPlayer.hasActed) {
// ラウンドは完了していると判断する(もしくは強制的に次フェーズへ進む)
this.currentPlayerIndex = nextIndex;
console.log(
`[DEBUG nextPlayer] 2人対戦: next player is all-in or already acted, round complete.`
);
return;
} else {
this.currentPlayerIndex = nextIndex;
// 通常は次のプレイヤーのアクション待ち
this.players[this.currentPlayerIndex].hasActed = false;
console.log(
`[DEBUG nextPlayer] 2人対戦 nextPlayerIndex=${this.currentPlayerIndex}`
);
return;
}
}
// 2人以外の場合は従来のロジック
const total = this.players.length;
let count = 0;
while (count < total) {
this.currentPlayerIndex = (this.currentPlayerIndex + 1) % total;
const curr = this.players[this.currentPlayerIndex];
if (curr.disconnected && !curr.hasFolded && !curr.isAllIn) {
console.log(
`[DEBUG nextPlayer] Skipping disconnected player: ${curr.name}, id=${curr.id}`
);
if (curr.bet < this.currentBet) {
console.log(
`[DEBUG nextPlayer] Disconnected player ${curr.name} auto-folding.`
);
curr.hasFolded = true;
} else {
console.log(
`[DEBUG nextPlayer] Disconnected player ${curr.name} auto-checking.`
);
curr.hasActed = true;
}
continue;
}
if (!curr.hasFolded && !curr.isAllIn && !curr.hasActed) {
console.log(
`[DEBUG nextPlayer] Found next player at index=${this.currentPlayerIndex}, name=${curr.name}, id=${curr.id}`
);
return;
}
count++;
}
if (count >= total) {
console.error("[ERROR] nextPlayer: 無限ループの可能性");
this.currentPlayerIndex = -1;
}
console.log(
`[DEBUG nextPlayer] END currentPlayerIndex=${this.currentPlayerIndex}`
);
}
advancePhase() {
console.log(
`[DEBUG advancePhase] START currentPhase=${this.phase}, currentPlayerIndex=${this.currentPlayerIndex}`
);
console.log("[DEBUG advancePhase] Players state before advancing phase:");
this.players.forEach((p, i) => {
console.log(
`[DEBUG advancePhase] Player idx=${i}, id=${p.id}, name=${p.name}, folded=${p.hasFolded}, allIn=${p.isAllIn}, hasActed=${p.hasActed}, bet=${p.bet}, chips=${p.chips}`
);
});
console.log("[DEBUG advancePhase] Community Cards:", this.communityCards);
console.log(
"[DEBUG advancePhase] Pot:",
this.pot,
"Current Bet:",
this.currentBet
);
console.log(
"[DEBUG advancePhase] Dealer Button Index:",
this.dealerButtonIndex
);
console.log(
"[DEBUG advancePhase] Action Start Index:",
this.actionStartIndex
);
console.log(
"[DEBUG advancePhase] Small Blind Index:",
this.smallBlindIndex,
"Big Blind Index:",
this.bigBlindIndex
);
switch (this.phase) {
case PHASES.PRE_FLOP:
this.phase = PHASES.FLOP;
this.dealFlop();
console.log("[DEBUG advancePhase] -> Moved to FLOP.");
this.resetBetsForNextRound();
break;
case PHASES.FLOP:
this.phase = PHASES.TURN;
this.dealTurn();
console.log("[DEBUG advancePhase] -> Moved to TURN.");
this.resetBetsForNextRound();
break;
case PHASES.TURN:
this.phase = PHASES.RIVER;
this.dealRiver();
console.log("[DEBUG advancePhase] -> Moved to RIVER.");
this.resetBetsForNextRound();
break;
case PHASES.RIVER:
this.phase = PHASES.SHOWDOWN;
console.log("[DEBUG advancePhase] -> Moved to SHOWDOWN.");
// フェーズが SHOWDOWN になったら2秒後に勝敗判定(ショウダウン評価)を実施
setTimeout(() => {
if (typeof this.onShowdown === "function") {
console.log(
"[DEBUG advancePhase] -> Triggering showdown evaluation."
);
this.onShowdown();
} else {
console.log(
"[DEBUG advancePhase] -> onShowdown callback not defined."
);
}
}, 2000);
break;
case PHASES.SHOWDOWN:
console.log("[DEBUG advancePhase] -> Already at SHOWDOWN.");
break;
default:
console.log("[DEBUG advancePhase] -> Unknown phase.");
break;
}
console.log(
`[DEBUG advancePhase] END currentPhase=${this.phase}, currentPlayerIndex=${this.currentPlayerIndex}`
);
}
resetBetsForNextRound() {
console.log(
`[DEBUG resetBetsForNextRound] START phase=${this.phase}, currentPlayerIndex(before)=${this.currentPlayerIndex}`
);
this.currentBet = 0;
for (let p of this.players) {
if (!p.hasFolded && !p.isAllIn) {
p.bet = 0;
p.hasActed = false;
}
}
if (this.phase !== PHASES.PRE_FLOP) {
if (this.players.length === 2) {
this.currentPlayerIndex = this.bigBlindIndex;
} else {
let eligibleFound = false;
let nextIndex = (this.dealerButtonIndex + 1) % this.players.length;
for (let i = 0; i < this.players.length; i++) {
const idx = (nextIndex + i) % this.players.length;
const player = this.players[idx];
if (!player.hasFolded && !player.isAllIn) {
this.currentPlayerIndex = idx;
eligibleFound = true;
break;
}
}
if (!eligibleFound) {
this.currentPlayerIndex = null;
}
}
}
this.actionStartIndex = this.currentPlayerIndex;
console.log(
`[DEBUG resetBetsForNextRound] END currentPlayerIndex=${this.currentPlayerIndex}, actionStartIndex=${this.actionStartIndex}`
);
console.log(
"[DEBUG resetBetsForNextRound] Next round starting with player:",
this.players[this.currentPlayerIndex]?.name,
"at index",
this.currentPlayerIndex
);
}
dealFlop() {
console.log("[DEBUG] dealFlop: dealing three cards...");
this.deck.pop();
this.communityCards.push(this.deck.pop(), this.deck.pop(), this.deck.pop());
}
dealTurn() {
console.log("[DEBUG] dealTurn: dealing one card...");
this.deck.pop();
this.communityCards.push(this.deck.pop());
}
dealRiver() {
console.log("[DEBUG] dealRiver: dealing one card...");
this.deck.pop();
this.communityCards.push(this.deck.pop());
}
computeSidePots() {
const contenders = this.players.filter((p) => !p.hasFolded);
const sorted = [...contenders].sort((a, b) => a.totalBet - b.totalBet);
let sidePots = [];
let previous = 0;
let remaining = contenders.length;
for (let i = 0; i < sorted.length; i++) {
const current = sorted[i].totalBet;
const diff = current - previous;
if (diff > 0) {
sidePots.push({
amount: diff * remaining,
eligiblePlayers: contenders.filter((p) => p.totalBet >= current),
});
previous = current;
}
remaining--;
}
return sidePots;
}
evaluateShowdown() {
console.log("[DEBUG evaluateShowdown] 勝敗判定処理を開始します");
if (this.showdownEvaluated) {
console.log("[DEBUG evaluateShowdown] 勝敗判定は既に実行済みです");
return;
}
this.showdownEvaluated = true;
const results = this.determineSidePotWinners();
this.showdownResults = results; // 勝敗結果を保存
console.log("[DEBUG evaluateShowdown] 勝敗判定結果:", results);
if (results && typeof this.sendResultCallback === "function") {
this.sendResultCallback(results);
} else {
console.error(
"[ERROR evaluateShowdown] 勝敗判定でエラーが発生しました または送信コールバックが未設定"
);
}
// ショウダウン結果送信後、必ずラウンド終了処理を実行する
setTimeout(() => {
if (typeof this.roundEndCallback === "function") {
console.log("[DEBUG evaluateShowdown] ラウンド終了処理を開始します");
this.roundEndCallback();
} else {
console.error(
"[ERROR evaluateShowdown] ラウンド終了コールバックが未設定"
);
}
}, 5000); // 5秒後に終了処理(必要に応じて調整)
}
determineSidePotWinners() {
let sidePots = this.computeSidePots();
// サイドポットが空の場合、全プレイヤーを対象にメインポットを作成する
if (sidePots.length === 0) {
console.log(
"[DEBUG determineSidePotWinners] サイドポットが空なので、メインポットとして全プレイヤーを対象にします"
);
sidePots.push({
amount: this.pot,
eligiblePlayers: this.players.filter((p) => !p.hasFolded),
});
}
let results = [];
console.log("Computed sidePots:", sidePots);
// --- ヘルパー関数 ---
// カードのランク文字列を数値に変換 (A=14, K=13, Q=12, J=11)
function rankToNumber(rank) {
if (rank === "A") return 14;
if (rank === "K") return 13;
if (rank === "Q") return 12;
if (rank === "J") return 11;
return parseInt(rank, 10);
}
// solvedHand.cards からキッカーの数値配列を作成し、降順にソートして比較する
function compareSolvedHands(a, b) {
const cardsA = a.cards
.map((card) => rankToNumber(card.value || card.rank))
.sort((x, y) => y - x);
const cardsB = b.cards
.map((card) => rankToNumber(card.value || card.rank))
.sort((x, y) => y - x);
for (let i = 0; i < 5; i++) {
if (cardsA[i] > cardsB[i]) return 1;
if (cardsA[i] < cardsB[i]) return -1;
}
return 0;
}
// 手札比較:まず手札の種類(solvedHand.rank)を比較し、異なるならその差を返す
// 同じなら、キッカー比較を行う
function compareHandsCustom(a, b) {
if (a.rank !== b.rank) {
return a.rank - b.rank;
}
return compareSolvedHands(a, b);
}
// --- 各サイドポットごとの勝者判定 ---
for (const pot of sidePots) {
console.log("-----");
console.log("Processing pot:", pot);
const eligible = pot.eligiblePlayers;
const playerHands = eligible.map((player) => {
const solverCards = [...player.hand, ...this.communityCards].map(
convertToPokerSolverFormat
);
const solvedHand = Hand.solve(solverCards);
if (!solvedHand || !solvedHand.descr) {
console.error(
`Hand.solve() の結果が不正です。Player: ${player.name}`,
solverCards,
solvedHand
);
}
player.solvedHand = solvedHand;
console.log(`Player: ${player.name}`);
console.log(" Cards for solver:", solverCards);
console.log(" Solved hand:", solvedHand);
console.log(
" Hand rank:",
solvedHand.rank,
"description:",
solvedHand.descr
);
return { player, solvedHand };
});
// 各プレイヤーの手札を compareHandsCustom で比較し、最強の手を決定
let bestHand = null;
for (const ph of playerHands) {
if (
bestHand === null ||
compareHandsCustom(ph.solvedHand, bestHand) > 0
) {
bestHand = ph.solvedHand;
}
}
// 最強手と同じ評価になるプレイヤーを勝者とする
let winningPlayers = playerHands
.filter((ph) => compareHandsCustom(ph.solvedHand, bestHand) === 0)
.map((ph) => ph.player);
// 同じプレイヤーが重複しないように排除
winningPlayers = [
...new Map(winningPlayers.map((p) => [p.id, p])).values(),
];
console.log("Winning players for current pot:", winningPlayers);
const winnersInfo = winningPlayers.map((wp) => {
const ph = playerHands.find((ph) => ph.player.id === wp.id);
return `${wp.name} (${
ph && ph.solvedHand && ph.solvedHand.descr
? ph.solvedHand.descr
: "未評価"
})`;
});
results.push({
potAmount: pot.amount,
winners: winningPlayers,
winnersInfo: winnersInfo,
});
console.log("Current pot results:", results[results.length - 1]);
}
console.log("Final results:", results);
return results;
}
}
module.exports = TexasHoldemGame;
ai.js
// ai.js
const { GoogleGenerativeAI } = require("@google/generative-ai");
const { parseGeminiResponse } = require("./utils");
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
const model = genAI.getGenerativeModel({
model: "gemini-1.5-flash",
generationConfig: { responseMimeType: "application/json" },
});
async function geminiAIMove(room, broadcastGameState, handleTermination) {
const aiPlayer = room.players.find((p) => p.id === "GeminiAI");
if (!aiPlayer) {
console.log("[DEBUG] geminiAIMove: GeminiAI プレイヤーが見つかりません");
return;
}
if (
room.currentPlayerIndex !==
room.players.findIndex((p) => p.id === "GeminiAI")
) {
console.log(
"[DEBUG] geminiAIMove: GeminiAI のターンではありません. currentPlayerIndex=",
room.currentPlayerIndex
);
return;
}
const humanPlayers = room.players
.filter((p) => p.id !== "GeminiAI")
.map((p) => ({ name: p.name, chips: p.chips, bet: p.bet }));
const prompt = `
あなたはポーカーのプロです。以下の状況から、GeminiAIとして最善のアクションを選んでください。
GeminiAIの手札: ${JSON.stringify(aiPlayer.hand)}
コミュニティカード: ${JSON.stringify(room.communityCards)}
ポット: ${room.pot}
現在のベット: ${room.currentBet}
相手プレイヤーの状況: ${JSON.stringify(humanPlayers)}
ゲームフェーズ: ${room.phase}
アクションは "fold", "check", "call", "raise" のいずれかで、レイズの場合は "raiseAmount" も指定してください。
出力は必ず以下の形式の有効な JSON としてください。どのようなロジックでそうしたか説明も加えてください。:
{ "action": "<fold|check|call|raise>", "raiseAmount": <number> ,"解説": "◯◯のため◯◯しました。"}
`.trim();
console.log(
"[DEBUG] geminiAIMove: Gemini API 呼び出し前のプロンプト:",
prompt
);
// API 呼び出しと5秒タイムアウトを競合させる
const responsePromise = model.generateContent(prompt);
const timeoutPromise = new Promise((_, reject) =>
setTimeout(
() =>
reject(new Error("Timeout: Gemini did not respond within 5 seconds")),
5000
)
);
let result;
try {
result = await Promise.race([responsePromise, timeoutPromise]);
} catch (err) {
console.error(
"[DEBUG] geminiAIMove: Gemini API 呼び出しタイムアウトまたはエラー:",
err
);
// タイムアウトなどで失敗した場合、5秒後に再度Geminiのターンを起動する
setTimeout(
() => geminiAIMove(room, broadcastGameState, handleTermination),
5000
);
return;
}
const responseText = result.response.text();
console.log("[DEBUG] geminiAIMove: Gemini API 返答テキスト:", responseText);
const actionObj = parseGeminiResponse(responseText);
console.log(
"[DEBUG] geminiAIMove: 解析されたアクションオブジェクト:",
actionObj
);
const actionResult = await room.handleAction(
"GeminiAI",
actionObj.action,
actionObj.raiseAmount || 0
);
if (actionResult.error) {
console.error("GeminiAI action error:", actionResult.error);
}
if (actionResult.termination) {
handleTermination(room);
} else {
broadcastGameState();
const currentPlayer = room.players[room.currentPlayerIndex];
if (
currentPlayer &&
currentPlayer.id === "GeminiAI" &&
!currentPlayer.hasActed
) {
setTimeout(
() => geminiAIMove(room, broadcastGameState, handleTermination),
2000
);
}
}
}
module.exports = { geminiAIMove };
config.js
// config.js
require("dotenv").config();
module.exports = {
SMALL_BLIND: 10,
BIG_BLIND: 20,
MAX_TABLES: 10,
MAX_PLAYERS_PER_TABLE: 6,
PHASES: {
WAITING: "WAITING",
PRE_FLOP: "PRE_FLOP",
FLOP: "FLOP",
TURN: "TURN",
RIVER: "RIVER",
SHOWDOWN: "SHOWDOWN",
},
GEMINI_ROOM_ID: 11, // MAX_TABLES + 1
PORT: process.env.PORT || 8080,
};
socketHandlers.js
// socketHandlers.js
console.log("socketHandlers.js loaded");
const {
MAX_TABLES,
GEMINI_ROOM_ID,
MAX_PLAYERS_PER_TABLE,
PHASES,
SMALL_BLIND,
BIG_BLIND,
} = require("./config");
const TexasHoldemGame = require("./texasgame");
const { getPublicGameState } = require("./utils");
const { geminiAIMove } = require("./ai");
const rooms = {};
const geminiRoomIds = [GEMINI_ROOM_ID, GEMINI_ROOM_ID + 1, GEMINI_ROOM_ID + 2];
const playerRoomMap = {};
function registerSocketHandlers(io) {
const ioRef = io;
// --- ルーム初期化 ---
// 通常テーブルのルームを生成
for (let i = 1; i <= MAX_TABLES; i++) {
rooms[i] = new TexasHoldemGame();
rooms[i].onShowdown = () => {
console.log(`[DEBUG] onShowdown triggered for room ${i}`);
rooms[i].evaluateShowdown();
};
rooms[i].sendResultCallback = (results) => {
const resultMessage = results
.map((pot) => pot.winnersInfo.join(", "))
.join("\n");
ioRef.to(String(i)).emit("gameResult", {
roomId: i,
message: resultMessage,
pot: rooms[i].pot,
});
};
// roundEndCallback は handleTermination が定義された後に設定
rooms[i].roundEndCallback = () => {
handleTermination(i);
};
}
// Gemini 対戦用ルームの生成
geminiRoomIds.forEach((id) => {
rooms[id] = new TexasHoldemGame();
rooms[id].addPlayer({
id: "GeminiAI",
name: "Google GeminiAI",
chips: 1000,
});
rooms[id].onShowdown = () => {
console.log(`[DEBUG] onShowdown triggered for Gemini room ${id}`);
rooms[id].evaluateShowdown();
};
rooms[id].sendResultCallback = (results) => {
const resultMessage = results
.map((pot) => pot.winnersInfo.join(", "))
.join("\n");
ioRef.to(String(id)).emit("gameResult", {
roomId: id,
message: resultMessage,
pot: rooms[id].pot,
});
};
rooms[id].roundEndCallback = () => {
handleTermination(id);
};
});
// --- ルーム初期化 終了 ---
async function broadcastGameState(roomId) {
const sockets = await ioRef.in(String(roomId)).fetchSockets();
sockets.forEach((socket) => {
socket.emit(
"gameUpdate",
getPublicGameState(roomId, socket.id, rooms[roomId])
);
});
}
function handleTermination(roomId) {
const room = rooms[roomId];
// ショウダウンフェーズの場合は、evaluateShowdown で得た結果を使って終了処理を行う
if (room.phase === PHASES.SHOWDOWN) {
if (room.showdownResults && room.showdownResults.length > 0) {
// ここでは、最初のポットの勝者を対象にポットを均等分割する例
const winners = room.showdownResults[0].winners;
if (winners.length > 0) {
const share = room.pot / winners.length;
winners.forEach((w) => {
w.chips += share;
});
const winnerNames = winners.map((w) => w.name).join(", ");
const resultMessage = `ショウダウンの結果、${winnerNames} の勝利です。`;
ioRef
.to(String(roomId))
.emit("roomMessage", { roomId, message: resultMessage });
winners.forEach((w) => {
ioRef.to(w.id).emit("winNotification", {
roomId,
message: "Win",
winnerId: w.id,
});
});
broadcastGameState(roomId);
}
} else {
console.error(
"[DEBUG handleTermination] ショウダウン結果が得られていません"
);
}
} else {
// 通常の終了処理:フォールド等で1人になった場合
const activePlayers = room.players.filter((p) => !p.hasFolded);
if (activePlayers.length !== 1) return;
const winner = activePlayers[0];
winner.chips += room.pot;
const resultMessage = `全員がフォールドしたため、${winner.name}の勝利です。`;
ioRef
.to(String(roomId))
.emit("roomMessage", { roomId, message: resultMessage });
ioRef.to(String(roomId)).emit("winNotification", {
roomId,
message: "Win",
winnerId: winner.id,
});
broadcastGameState(roomId);
}
// 共通のラウンド終了後の後処理
setTimeout(() => {
console.log("[DEBUG] Calling endRoundCleanup for room", roomId);
endRoundCleanup(roomId);
broadcastGameState(roomId);
setTimeout(() => {
if (geminiRoomIds.includes(roomId)) {
if (room.players.filter((p) => p.id !== "GeminiAI").length >= 1) {
console.log("[DEBUG] Starting new round for Gemini room", roomId);
room.startRound();
} else {
console.log("[DEBUG] Not enough human players in Gemini room.");
room.phase = PHASES.WAITING;
}
} else if (room.players.filter((p) => p.chips > 0).length >= 2) {
console.log("[DEBUG] Starting new round for room", roomId);
room.startRound();
} else {
console.log("[DEBUG] Not enough players to start a new round.");
room.phase = PHASES.WAITING;
}
broadcastGameState(roomId);
ioRef.to(String(roomId)).emit("clearRoomMessage", { roomId });
const newRoom = rooms[roomId];
if (
newRoom.players[newRoom.currentPlayerIndex] &&
newRoom.players[newRoom.currentPlayerIndex].id === "GeminiAI"
) {
setTimeout(() => {
geminiAIMove(
newRoom,
() => broadcastGameState(roomId),
() => handleTermination(roomId)
);
}, 2000);
}
}, 2000);
}, 5000);
}
function endRoundCleanup(roomId) {
console.log(`[DEBUG endRoundCleanup] Called for roomId=${roomId}`);
const room = rooms[roomId];
if (!room) return;
room.incorporateWaitingPlayers();
const beforeCount = room.players.length;
room.players = room.players.filter(
(p) => p.id === "GeminiAI" || (p.chips > 0 && !p.disconnected)
);
room.waitingPlayers = room.waitingPlayers.filter(
(p) => p.id === "GeminiAI" || (p.chips > 0 && !p.disconnected)
);
const gemini = room.players.find((p) => p.id === "GeminiAI");
if (gemini && gemini.chips <= 0) {
gemini.chips = 1000;
}
console.log(
`[DEBUG endRoundCleanup] -> Removed ${
beforeCount - room.players.length
} players from room.players.`
);
if (room.players.length < 2) {
console.log("[DEBUG endRoundCleanup] -> Not enough players => WAITING");
room.phase = PHASES.WAITING;
}
broadcastGameState(roomId);
}
ioRef.on("connection", (socket) => {
console.log("New connection:", socket.id);
socket.on("getRooms", () => {
const roomList = [];
// 通常テーブルの場合
for (let i = 1; i <= MAX_TABLES; i++) {
const game = rooms[i];
const playerCount = game.players.length; // 着席しているプレイヤー数
const waitingCount = game.waitingPlayers.length; // 空席待ちの人数
const status = game.phase === PHASES.WAITING ? "待機中" : "ゲーム中";
roomList.push({
roomId: i,
tableName: `テーブル${i}`, // テーブル名を付与
sb: SMALL_BLIND, // ステーク:SB
bb: BIG_BLIND, // ステーク:BB
type: "human", // ゲーム種別(通常対戦の場合)
playerCount, // 着席者数
waitingCount, // 空席待ち人数
status, // 状態
});
}
// Gemini 対戦用ルームの場合
geminiRoomIds.forEach((id) => {
const geminiGame = rooms[id];
const playerCount = geminiGame.players.length;
const waitingCount = geminiGame.waitingPlayers.length;
const status =
geminiGame.phase === PHASES.WAITING ? "待機中" : "ゲーム中";
roomList.push({
roomId: id,
tableName: `テーブル${id}`, // Gemini ルームもテーブル名として付与
sb: SMALL_BLIND,
bb: BIG_BLIND,
type: "gemini", // ゲーム種別を Gemini として設定
playerCount,
waitingCount,
status: status + " (GeminiAI)",
});
});
socket.emit("roomList", roomList);
});
socket.on("joinGame", (data) => {
let roomId = parseInt(data.roomId, 10);
if (
isNaN(roomId) ||
roomId < 1 ||
(roomId > MAX_TABLES && !geminiRoomIds.includes(roomId))
) {
roomId = 1;
}
const room = rooms[roomId];
if (geminiRoomIds.includes(roomId)) {
const humanCount = room.players.filter(
(p) => p.id !== "GeminiAI"
).length;
if (humanCount >= 1) {
socket.emit("gameMessage", {
message:
"GeminiAIルームは既に対戦中です。別のルームをお選びください。",
});
return;
}
}
const exists =
room.players.some((p) => p.id === socket.id) ||
room.waitingPlayers.some((p) => p.id === socket.id);
if (exists) {
socket.emit("gameMessage", {
message: "既にこのルームに参加しています。",
});
return;
}
if (geminiRoomIds.includes(roomId) && room.players.length >= 2) {
socket.emit("gameMessage", {
message: "GeminiAIルームは満室です。別のルームをお選びください。",
});
return;
} else if (room.players.length >= MAX_PLAYERS_PER_TABLE) {
socket.emit("gameMessage", {
message: `テーブル${roomId}は満員です。別のテーブルを選んでください。`,
});
return;
}
room.addPlayer({ id: socket.id, name: data.playerName });
socket.join(String(roomId));
if (playerRoomMap[socket.id]) {
if (!playerRoomMap[socket.id].includes(roomId)) {
playerRoomMap[socket.id].push(roomId);
}
} else {
playerRoomMap[socket.id] = [roomId];
}
broadcastGameState(roomId);
});
socket.on("startGame", (data) => {
let roomId = data?.roomId;
if (!roomId) {
const arr = playerRoomMap[socket.id];
if (arr && arr.length > 0) {
roomId = arr[0];
} else {
socket.emit("gameMessage", { message: "テーブルに参加していません" });
return;
}
}
const room = rooms[roomId];
if (!room) {
socket.emit("gameMessage", { message: "存在しないテーブルです" });
return;
}
if (geminiRoomIds.includes(roomId)) {
const humanCount = room.players.filter(
(p) => p.id !== "GeminiAI"
).length;
if (humanCount < 1) {
ioRef
.to(String(roomId))
.emit("gameMessage", { message: "対戦相手が必要です" });
return;
}
} else if (room.players.length < 2) {
ioRef
.to(String(roomId))
.emit("gameMessage", { message: "プレイヤーが2人以上必要です" });
return;
}
ioRef.to(String(roomId)).emit("clearRoomMessage", { roomId });
room.startRound();
broadcastGameState(roomId);
if (geminiRoomIds.includes(roomId)) {
const currentPlayer = room.players[room.currentPlayerIndex];
if (currentPlayer && currentPlayer.id === "GeminiAI") {
console.log("[DEBUG] GeminiAIが先頭。geminiAIMoveを起動します。");
setTimeout(() => {
geminiAIMove(
room,
() => broadcastGameState(roomId),
() => handleTermination(roomId)
);
}, 2000);
}
}
});
socket.on("playerAction", async ({ roomId, action, raiseAmount }) => {
const room = rooms[roomId];
if (!room) {
console.error(
`[ERROR] playerAction: ルーム ${roomId} が見つかりません`
);
return;
}
if (room.phase === PHASES.SHOWDOWN) {
socket.emit("gameMessage", {
roomId,
message: "ショウダウン中は操作できません",
});
return;
}
const result = room.handleAction(socket.id, action, raiseAmount);
if (result.error) {
socket.emit("gameMessage", { roomId, message: result.error });
return;
}
if (result.termination) {
handleTermination(roomId);
return;
}
broadcastGameState(roomId);
const currentPlayer = room.players[room.currentPlayerIndex];
if (
currentPlayer &&
currentPlayer.id === "GeminiAI" &&
!currentPlayer.hasActed
) {
setTimeout(() => {
geminiAIMove(
room,
() => broadcastGameState(roomId),
() => handleTermination(roomId)
);
}, 2000);
}
});
socket.on("disconnect", () => {
const roomIds = playerRoomMap[socket.id] || [];
roomIds.forEach((roomId) => {
const room = rooms[roomId];
if (room) {
const target = room.players.find((p) => p.id === socket.id);
if (target) {
target.disconnected = true;
console.log(
`[DEBUG] Player ${socket.id} marked as disconnected in room ${roomId}.`
);
}
room.waitingPlayers = room.waitingPlayers.filter(
(p) => p.id !== socket.id
);
if (
room.players[room.currentPlayerIndex] &&
room.players[room.currentPlayerIndex].id === socket.id
) {
console.log(
`[DEBUG] Current player ${socket.id} is disconnected in room ${roomId}. Auto-folding.`
);
if (target) {
target.hasFolded = true;
target.hasActed = true;
}
room.nextPlayer();
}
const activePlayers = room.players.filter((p) => !p.hasFolded);
if (activePlayers.length === 1) {
console.log(
`[DEBUG] Only one active player left in room ${roomId} after disconnect.`
);
const winner = activePlayers[0];
winner.chips += room.pot;
const resultMessage = `全員がフォールドしたため、${winner.name}の勝利です。`;
ioRef
.to(String(roomId))
.emit("gameResult", { message: resultMessage, pot: room.pot });
ioRef.to(winner.id).emit("winNotification", { message: "Win" });
broadcastGameState(roomId);
setTimeout(() => {
endRoundCleanup(roomId);
}, 5000);
}
broadcastGameState(roomId);
}
});
delete playerRoomMap[socket.id];
console.log("Disconnect:", socket.id);
});
});
}
module.exports = { registerSocketHandlers };