diff --git a/README.md b/README.md index cc6042c..e487e60 100644 --- a/README.md +++ b/README.md @@ -250,17 +250,23 @@ GitHub Apps Manager 権限が必要です。 ## 🤖 CPU AI について -CPUは**ミニマックス法**(アルファベータ枝刈り)を使用して最善手を探索します。 - -| 難易度 | 探索深度 | 制限時間 | -|--------|---------|---------| -| Easy | 2 | 120ms | -| Normal | 3 | 240ms | -| Hard | 4 | 420ms | -| Strong | 5 | 700ms | - -上の値は「難易度ごとの上限」で、実際には `CPU_MAX_DEPTH` / `CPU_TIME_LIMIT_MS` で -さらに切り詰められます(無料プランの既定は深さ2 / 8ms)。 +CPUは**ミニマックス法**(アルファベータ枝刈り+反復深化)を使用して最善手を探索します。 + +| 難易度 | 深さの上限 | 1ティックのノード数 | ティック数 | +|--------|-----------|------------------|-----------| +| Easy | 2 | 600 | 1 | +| Normal | 3 | 800 | 2 | +| Hard | 4 | 1100 | 3 | +| Strong | 5 | 1800 | 4 | + +無料プランは 1リクエストあたり CPU 10ms なので、1手の思考を Durable Object の +アラームで複数の「ティック」に分割し、1ティックあたりの探索量をノード数で +頭打ちにしています(時間で測れない理由は `ai.js` のコメント参照)。 + +深さは固定ではなく上限です。浅い深さから順に読み、予算が尽きた時点で読み切れて +いる最良の結果を使うため、上限を上げても読み切れないだけで弱くはなりません。 +上の値は `CPU_MAX_DEPTH` / `CPU_NODE_BUDGET` / `CPU_MAX_TICKS` でさらに +切り詰められます。 評価関数は以下の要素を考慮: - **ラインスコア**: 連続した駒の数(盤上の4目は「1枚抜いて戻せば勝ち」の脅威なので高得点) diff --git a/client/src/App.jsx b/client/src/App.jsx index b66c84d..5a54975 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -14,6 +14,7 @@ function RequireAuth({ children }) { const { user, loading } = useAuth() const location = useLocation() + // 認証状態の確認中はローディング表示にする if (loading) { return (
@@ -22,6 +23,7 @@ function RequireAuth({ children }) { ) } + // 未ログインならログイン画面へ遷移させる(遷移元を state に保持する) if (!user) { return } @@ -35,18 +37,22 @@ export default function App() { useEffect(() => { let active = true + // ログイン中のユーザー情報を取得して認証状態を復元する apiGet('/api/me') .then((data) => { + // アンマウント後の状態更新を避けるため、有効な間だけ反映する if (active) { setUser(data.user) } }) .catch(() => { + // 取得に失敗した場合は未ログイン扱いにする if (active) { setUser(null) } }) .finally(() => { + // 成否にかかわらずローディング表示を終了する if (active) { setLoading(false) } @@ -71,34 +77,40 @@ export default function App() { [user, loading] ) + // ログイン中のみヘッダーを表示する(未ログイン時は何も描画しない) + let header = null + if (user) { + header = ( +
+
+
+ ヨンモク アイコン + ヨンモク +
+
+ {/* 名前をそのままアカウント設定への入口にする */} + + {user.nickname || '名無しプレイヤー'} + + + +
+
+
+ ) + } + return (
- {user ? ( -
-
-
- ヨンモク アイコン - ヨンモク -
-
- {/* 名前をそのままアカウント設定への入口にする */} - - {user.nickname || '名無しプレイヤー'} - - - -
-
-
- ) : null} + {header}
} /> diff --git a/client/src/api.js b/client/src/api.js index 7a5460c..10893a6 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -8,15 +8,24 @@ export async function apiRequest(path, options = {}) { const headers = options.headers || {} const hasBody = options.body !== undefined const url = `${API_BASE_URL}${path}` + + // リクエストボディがある場合のみ JSON の Content-Type を付与する + const baseHeaders = {} + if (hasBody) { + baseHeaders['Content-Type'] = 'application/json' + } + + // APIサーバへリクエストを送信する(Cookieを同送するため credentials: include を指定) const res = await fetch(url, { credentials: 'include', ...options, headers: { - ...(hasBody ? { 'Content-Type': 'application/json' } : {}), + ...baseHeaders, ...headers, }, }) + // エラーステータスの場合はレスポンス本文からエラー情報を取り出して例外を投げる if (!res.ok) { const payload = await res.json().catch(() => ({})) const error = new Error(payload.error || 'request_failed') @@ -31,6 +40,19 @@ export function apiGet(path) { return apiRequest(path) } +/** + * apiRequest が投げた例外からサーバー側のエラーコードを取り出します。 + * @param {*} err - 例外オブジェクト + * @returns {string} エラーコード(取得できない場合は空文字) + */ +export function errorCodeOf(err) { + // 想定外の形の例外はコード無しとして扱い、呼び出し側の既定メッセージを使わせる + if (!err || !err.payload) { + return '' + } + return err.payload.error || '' +} + export function apiPost(path, data) { return apiRequest(path, { method: 'POST', diff --git a/client/src/pages/LobbyPage.jsx b/client/src/pages/LobbyPage.jsx index ac58455..aa170fa 100644 --- a/client/src/pages/LobbyPage.jsx +++ b/client/src/pages/LobbyPage.jsx @@ -46,8 +46,13 @@ export default function LobbyPage() { * @returns {string} 日本語ラベル */ const statusLabel = (status) => { - if (status === 'playing') return '対局中' - if (status === 'waiting') return '待機中' + // 既知のステータスは日本語へ、未知の値はそのまま返す + if (status === 'playing') { + return '対局中' + } + if (status === 'waiting') { + return '待機中' + } return status } @@ -57,7 +62,10 @@ export default function LobbyPage() { * @returns {string} 表示名 */ const displayName = (seat) => { - if (!seat) return '空席' + // 座席情報が無い場合は空席として表示する + if (!seat) { + return '空席' + } return seat.nickname || '名無しプレイヤー' } @@ -70,16 +78,19 @@ export default function LobbyPage() { // 初回ルーム一覧取得 apiGet('/api/rooms') .then((data) => { + // アンマウント後の状態更新を避けるため、有効な間だけ反映する if (active) { setRooms(data.rooms) } }) .catch(() => { + // 取得に失敗した場合はエラーメッセージを表示する if (active) { setError('ルームの取得に失敗しました。') } }) .finally(() => { + // 成否にかかわらずローディング表示を終了する if (active) { setLoading(false) } @@ -107,6 +118,18 @@ export default function LobbyPage() { // レンダリング // ------------------------------------------------------------------------- + // 読み込み中のみ待機メッセージを表示する + let loadingView = null + if (loading) { + loadingView =
ルーム読み込み中...
+ } + + // エラーが発生している場合のみ警告を表示する + let errorView = null + if (error) { + errorView =
{error}
+ } + return (
{/* ===== ページヘッダー ===== @@ -124,18 +147,50 @@ export default function LobbyPage() {
{/* ===== ローディング/エラー表示 ===== */} - {loading &&
ルーム読み込み中...
} - {error &&
{error}
} + {loadingView} + {errorView} {/* ===== ルーム一覧グリッド ===== カード全体が1つのボタン。上端の色帯で状態を示す: シアン = 待機中(入れる) / ピンク = 対局中。 12枚並ぶので、色は「帯・状態文字・入室チップ」の3点だけに使う。 */}
+ {/* ルームごとにカードを1枚描画する */} {rooms.map((room) => { const playing = room.status === 'playing' const black = room.seats.black const white = room.seats.white + + // 対局中はピンク、待機中はシアンで状態を示す + let statusBarClass = "bg-primary" + let statusTextClass = "text-primary" + if (playing) { + statusBarClass = "bg-secondary" + statusTextClass = "text-secondary" + } + + // 対局中のみ状態ラベルの前に点を表示する + let playingDot = null + if (playing) { + playingDot = + } + + // 黒番の席は着席済みなら駒の色で塗り、空席なら輪郭だけにする + let blackStoneClass = "border-muted-foreground/25 bg-transparent" + let blackNameClass = "text-muted-foreground/70" + if (black) { + blackStoneClass = "border-gray-800 bg-gray-900" + blackNameClass = "text-foreground" + } + + // 白番の席も同様に着席状態で見た目を切り替える + let whiteStoneClass = "border-muted-foreground/25 bg-transparent" + let whiteNameClass = "text-muted-foreground/70" + if (white) { + whiteStoneClass = "border-gray-400 bg-white" + whiteNameClass = "text-foreground" + } + return ( @@ -307,7 +348,7 @@ export default function LoginPage() { onClick={handleModeToggle} className="text-xs text-muted-foreground underline decoration-border underline-offset-4 hover:text-foreground hover:decoration-foreground" > - {isRegister ? 'すでにアカウントをお持ちの方はこちら' : 'アカウントをお持ちでない方はこちら'} + {modeToggleLabel}
diff --git a/client/src/pages/RoomPage.jsx b/client/src/pages/RoomPage.jsx index 0b663d5..5a4db7e 100644 --- a/client/src/pages/RoomPage.jsx +++ b/client/src/pages/RoomPage.jsx @@ -24,10 +24,41 @@ const BLACK_POSITIONS = new Set([ const inBounds = (row, col) => row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE +/** + * emit の応答が成功しているかを判定します。 + * @param {Object|null} response - サーバーからの応答 + * @returns {boolean} 成功なら true + */ +const isOkResponse = (response) => { + // 応答自体が無い場合(タイムアウト等)は失敗として扱う + if (!response) { + return false + } + return Boolean(response.ok) +} + +/** + * emit の応答からエラーコードを取り出します。 + * @param {Object|null} response - サーバーからの応答 + * @returns {string} エラーコード(無い場合は空文字) + */ +const responseErrorCode = (response) => { + // 応答もエラーコードも無い場合は空文字を返し、既定のメッセージを使わせる + if (!response || !response.error) { + return '' + } + return response.error +} + const getCellType = (row, col) => { const key = `${row},${col}` - if (NEUTRAL_POSITIONS.has(key)) return 'neutral' - if (BLACK_POSITIONS.has(key)) return 'black' + // マスの色は座標で決まっているため、定義済みの集合から判定する + if (NEUTRAL_POSITIONS.has(key)) { + return 'neutral' + } + if (BLACK_POSITIONS.has(key)) { + return 'black' + } return 'white' } @@ -43,15 +74,21 @@ const getValidMoves = (board, color, from) => { [-1, 1], [-1, -1], ] + // 8方向の隣接マスのうち、空いているマスへは1歩だけ移動できる for (const [dr, dc] of stepDirs) { const row = from.row + dr const col = from.col + dc - if (!inBounds(row, col)) continue + // 盤外は移動先にならない + if (!inBounds(row, col)) { + continue + } + // 空きマスのみ移動先として登録する if (board[row][col] === null) { moves.add(`${row},${col}`) } } + // 自分の色のマスに乗っている駒だけが斜めに滑って動ける if (getCellType(from.row, from.col) !== color) { return moves } @@ -62,10 +99,13 @@ const getValidMoves = (board, color, from) => { [-1, 1], [-1, -1], ] + // 斜め4方向について、自分の色のマスが続く限り進めるマスを集める for (const [dr, dc] of diagDirs) { let row = from.row + dr let col = from.col + dc + // 盤内かつ自分の色のマスが続く間だけ進む while (inBounds(row, col) && getCellType(row, col) === color) { + // 駒が置かれているマスにぶつかったらそこで止まる if (board[row][col] !== null) { break } @@ -94,22 +134,40 @@ export default function RoomPage() { const numericRoomId = useMemo(() => Number(roomId), [roomId]) const statusLabel = (status) => { - if (status === 'playing') return '対局中' - if (status === 'waiting') return '待機中' + // 既知のステータスは日本語へ、未知の値はそのまま返す + if (status === 'playing') { + return '対局中' + } + if (status === 'waiting') { + return '待機中' + } return status } const seatLabel = (color) => { - if (color === 'black') return '黒' - if (color === 'white') return '白' + // 席の色を日本語1文字にする + if (color === 'black') { + return '黒' + } + if (color === 'white') { + return '白' + } return color } const gameStatusLabel = (status) => { - if (status === 'playing') return '進行中' - if (status === 'finished') return '終了' - if (status === 'waiting') return '待機' + // 対局の進行状況を日本語へ変換する + if (status === 'playing') { + return '進行中' + } + if (status === 'finished') { + return '終了' + } + if (status === 'waiting') { + return '待機' + } return status } const errorMessage = (code) => { + // サーバーが返すエラーコードを利用者向けの文言へ変換する switch (code) { case 'game_not_active': return '対局が開始されていません。' @@ -132,7 +190,10 @@ export default function RoomPage() { } } const displayName = (seat) => { - if (!seat) return '空席' + // 座席情報が無い場合は空席として表示する + if (!seat) { + return '空席' + } return seat.nickname || '名無しプレイヤー' } @@ -141,10 +202,12 @@ export default function RoomPage() { const socket = getRoomSocket(numericRoomId) const handleRoomState = (payload) => { - if (!payload || payload.room?.id !== numericRoomId) { + // 別ルームの通知や壊れたペイロードは無視する + if (!payload || !payload.room || payload.room.id !== numericRoomId) { return } setRoom(payload.room) + // 対局情報が同梱されている場合のみ盤面を更新し、選択を解除する if (payload.game) { setGame(payload.game) setSelected(null) @@ -152,25 +215,30 @@ export default function RoomPage() { } const handlePresence = (payload) => { + // 表示中のルームの入室人数だけ反映する if (payload && payload.roomId === numericRoomId) { setPresence(payload.count) } } const handleChatNew = (payload) => { + // 表示中のルームの発言だけ末尾に追加する if (payload && payload.room_id === numericRoomId) { setChat((prev) => [...prev, payload]) } } const handleChatClear = (payload) => { + // 表示中のルームのクリア通知だけ反映する if (payload && payload.roomId === numericRoomId) { setChat([]) } } const handleForfeit = (payload) => { + // 表示中のルームの不戦敗通知だけ反映する if (payload && payload.roomId === numericRoomId) { + // 勝者が決まっている場合はその旨を、決まっていない場合は中断として知らせる if (payload.winnerColor) { setNotice(`${seatLabel(payload.winnerColor)}の勝ち(相手の退出)`) } else { @@ -180,6 +248,7 @@ export default function RoomPage() { } const handleGameState = (payload) => { + // 表示中のルームの盤面更新だけ反映し、選択を解除する if (payload && payload.roomId === numericRoomId) { setGame(payload.game) setSelected(null) @@ -196,9 +265,14 @@ export default function RoomPage() { // 接続完了(再接続を含む)のたびに入室し直して状態を同期する。 // 未接続の間の emit はソケット側でキューされるため取りこぼしはない。 const handleConnect = () => { + // 入室してルームの現在状態を受け取る socket.emit('room:join', { roomId: numericRoomId }, (response) => { - if (!active) return - if (!response || !response.ok) { + // アンマウント後の状態更新を避けるため、有効な間だけ反映する + if (!active) { + return + } + // 入室に失敗した場合はエラーを表示して以降の反映を行わない + if (!isOkResponse(response)) { setError('入室に失敗しました。') return } @@ -209,15 +283,19 @@ export default function RoomPage() { } socket.on('connect', handleConnect) + // WebSocketの接続完了を待たずに描画できるよう、初期状態はHTTPでも取得する apiGet(`/api/rooms/${numericRoomId}`) .then((data) => { + // アンマウント後の状態更新を避けるため、有効な間だけ反映する if (active) { setRoom(data.room) setChat(data.chat) setGame(data.game) } }) - .catch(() => {}) + .catch(() => { + // 失敗しても WebSocket 側の room:join で状態が届くため何もしない + }) return () => { active = false @@ -234,14 +312,20 @@ export default function RoomPage() { }, [numericRoomId]) useEffect(() => { + // 末尾の目印がまだ描画されていない場合はスクロールできないので何もしない + if (!chatEndRef.current) { + return + } // block:'nearest' にしないと親(main)まで巻き込んでスクロールすることがある - chatEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + chatEndRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) }, [chat]) const handleSeat = (color) => { const socket = getRoomSocket(numericRoomId) + // 指定した色の席へ着席を要求する socket.emit('seat:take', { roomId: numericRoomId, color }, (response) => { - if (!response?.ok) { + // 失敗した場合だけエラーを表示し、成功したら表示中のエラーを消す + if (!isOkResponse(response)) { setError('席が埋まっています。') } else { setError('') @@ -251,8 +335,10 @@ export default function RoomPage() { const handleSeatLeave = (color) => { const socket = getRoomSocket(numericRoomId) + // 指定した色の席からの退席を要求する socket.emit('seat:leave', { roomId: numericRoomId, color }, (response) => { - if (!response?.ok) { + // 失敗した場合だけエラーを表示し、成功したら表示中のエラーを消す + if (!isOkResponse(response)) { setError('席の退出に失敗しました。') } else { setError('') @@ -263,14 +349,17 @@ export default function RoomPage() { const handleCpuEnable = (color) => { setCpuError('') const socket = getRoomSocket(numericRoomId) + // 指定した色の席にCPUを着席させる socket.emit( 'cpu:configure', { roomId: numericRoomId, enabled: true, color, level: 'strong' }, (response) => { - if (!response?.ok) { - if (response?.error === 'seat_taken') { + // 失敗した場合のみ、原因に応じたメッセージを表示する + if (!isOkResponse(response)) { + const code = responseErrorCode(response) + if (code === 'seat_taken') { setCpuError('その席は埋まっています。') - } else if (response?.error === 'game_in_progress') { + } else if (code === 'game_in_progress') { setCpuError('対局中はCPU設定を変更できません。') } else { setCpuError('CPU対戦の設定に失敗しました。') @@ -283,12 +372,15 @@ export default function RoomPage() { const handleCpuDisable = () => { setCpuError('') const socket = getRoomSocket(numericRoomId) + // 着席中のCPUを解除する socket.emit( 'cpu:configure', { roomId: numericRoomId, enabled: false }, (response) => { - if (!response?.ok) { - if (response?.error === 'game_in_progress') { + // 失敗した場合のみ、原因に応じたメッセージを表示する + if (!isOkResponse(response)) { + const code = responseErrorCode(response) + if (code === 'game_in_progress') { setCpuError('対局中はCPU設定を変更できません。') } else { setCpuError('CPU対戦の解除に失敗しました。') @@ -300,15 +392,18 @@ export default function RoomPage() { const handleSend = (event) => { event.preventDefault() + // 空文字や空白だけの発言は送信しない if (!message.trim()) { return } const socket = getRoomSocket(numericRoomId) + // 入力されたメッセージをルームへ送信する socket.emit( 'chat:send', { roomId: numericRoomId, message }, (response) => { - if (!response?.ok) { + // 送信できた場合のみ入力欄を空に戻す + if (!isOkResponse(response)) { setError('メッセージの送信に失敗しました。') } else { setMessage('') @@ -318,58 +413,143 @@ export default function RoomPage() { ) } - const mySeat = room?.seats - ? room.seats.black?.userId === user?.id - ? 'black' - : room.seats.white?.userId === user?.id - ? 'white' - : null - : null + // 部屋が未取得の間は席情報を null として扱う + let blackSeat = null + let whiteSeat = null + if (room && room.seats) { + blackSeat = room.seats.black + whiteSeat = room.seats.white + } + + // 自分がどちらの席に着いているかを判定する(着席していなければ null) + let mySeat = null + if (user) { + if (blackSeat && blackSeat.userId === user.id) { + mySeat = 'black' + } else if (whiteSeat && whiteSeat.userId === user.id) { + mySeat = 'white' + } + } + const cpuSeatColor = useMemo(() => { - if (room?.seats?.black?.loginId === 'cpu') return 'black' - if (room?.seats?.white?.loginId === 'cpu') return 'white' + // 部屋が未取得ならCPUは着席していない + if (!room || !room.seats) { + return null + } + // ログインIDが 'cpu' の席をCPUの席とみなす + if (room.seats.black && room.seats.black.loginId === 'cpu') { + return 'black' + } + if (room.seats.white && room.seats.white.loginId === 'cpu') { + return 'white' + } return null }, [room]) + const opponentColor = useMemo(() => { - if (mySeat === 'black') return 'white' - if (mySeat === 'white') return 'black' + // 自分の席の反対側を相手の色とする(観戦中は相手も決まらない) + if (mySeat === 'black') { + return 'white' + } + if (mySeat === 'white') { + return 'black' + } return null }, [mySeat]) + const canReleaseCpu = Boolean(cpuSeatColor && mySeat && mySeat !== cpuSeatColor) - const mySeatText = mySeat ? `着席中(${seatLabel(mySeat)})` : '観戦中' - const board = Array.isArray(game?.board) - ? game.board - : Array.from({ length: 5 }, () => Array(5).fill(null)) - const placed = game?.placed || { black: 0, white: 0 } - const ready = game?.ready || { black: false, white: false } - const myPiecesLeft = mySeat ? Math.max(0, 6 - (placed[mySeat] || 0)) : 0 - const isMyTurn = game?.status === 'playing' && game?.turn === mySeat - const turnLabel = game?.turn ? seatLabel(game.turn) : '-' - const moveTargets = - selected && mySeat && isMyTurn && game?.status === 'playing' - ? getValidMoves(board, mySeat, selected) - : new Set() - const resultLabel = (() => { - if (!game || game.status !== 'finished') return '' - if (game.result === 'draw') return '引き分け(双方とも打つ手がありません)' - if (!game.winner) return '対局終了' - if (game.result === 'four') return `${seatLabel(game.winner)}の勝ち(4目)` - if (game.result === 'five') return `${seatLabel(game.winner)}の勝ち(5目のため負け)` + + // 着席していれば席の色を、していなければ観戦中と表示する + let mySeatText = '観戦中' + if (mySeat) { + mySeatText = `着席中(${seatLabel(mySeat)})` + } + + // 盤面が未取得の間は5x5の空盤で描画を成立させる + let board = Array.from({ length: 5 }, () => Array(5).fill(null)) + if (game && Array.isArray(game.board)) { + board = game.board + } + + // 配置済みの駒数は未取得なら0で埋める + let placed = { black: 0, white: 0 } + if (game && game.placed) { + placed = game.placed + } + + // 準備状態も未取得なら未準備として扱う + let ready = { black: false, white: false } + if (game && game.ready) { + ready = game.ready + } + + // 自分の持ち駒数(6個から配置済みを引いた数)。観戦中は0とする + let myPiecesLeft = 0 + if (mySeat) { + myPiecesLeft = Math.max(0, 6 - (placed[mySeat] || 0)) + } + + // 対局中で手番が自分の席のときだけ操作できる + let isMyTurn = false + if (game && game.status === 'playing' && game.turn === mySeat) { + isMyTurn = true + } + + // 手番が決まっていない間はハイフンを表示する + let turnLabel = '-' + if (game && game.turn) { + turnLabel = seatLabel(game.turn) + } + + // 自分の手番で駒を選択しているときだけ移動可能マスを計算する + let moveTargets = new Set() + if (selected && mySeat && isMyTurn && game && game.status === 'playing') { + moveTargets = getValidMoves(board, mySeat, selected) + } + + /** + * 終局時に表示する結果の文言を組み立てます。 + * @returns {string} 結果ラベル(対局中は空文字) + */ + const buildResultLabel = () => { + // 終局していない間は結果を表示しない + if (!game || game.status !== 'finished') { + return '' + } + if (game.result === 'draw') { + return '引き分け(双方とも打つ手がありません)' + } + // 勝者が決まっていない終局は汎用の文言にする + if (!game.winner) { + return '対局終了' + } + if (game.result === 'four') { + return `${seatLabel(game.winner)}の勝ち(4目)` + } + if (game.result === 'five') { + return `${seatLabel(game.winner)}の勝ち(5目のため負け)` + } if (game.result === 'forfeit') { return `${seatLabel(game.winner)}の勝ち(相手の退出)` } return `${seatLabel(game.winner)}の勝ち` - })() + } + const resultLabel = buildResultLabel() const handleReadyToggle = () => { - if (!mySeat) return + // 着席していない観戦者は準備状態を変更できない + if (!mySeat) { + return + } const socket = getRoomSocket(numericRoomId) + // 現在の準備状態を反転して送信する socket.emit( 'game:ready', { roomId: numericRoomId, ready: !ready[mySeat] }, (response) => { - if (!response?.ok) { - setError(errorMessage(response?.error)) + // 失敗した場合はサーバーのエラーコードに対応する文言を表示する + if (!isOkResponse(response)) { + setError(errorMessage(responseErrorCode(response))) } else { setError('') } @@ -379,40 +559,49 @@ export default function RoomPage() { const handleCellClick = (row, col) => { setNotice('') + // 対局が始まっていなければ盤面を操作できない if (!game || game.status !== 'playing') { setError('対局が開始されていません。') return } + // 観戦者は盤面を操作できない if (!mySeat) { setError('観戦中のため操作できません。') return } + // 自分の手番でなければ操作できない if (!isMyTurn) { setError('あなたの手番ではありません。') return } const cellValue = board[row][col] + // 既に駒を選択している場合は「移動」の操作として扱う if (selected) { + // 選択中のマスをもう一度押したら選択を解除する if (selected.row === row && selected.col === col) { setSelected(null) return } + // 自分の別の駒を押したら選択を そちらへ 移す if (cellValue === mySeat) { setSelected({ row, col }) return } + // 空きマスなら移動先として妥当か確認してから移動を要求する if (cellValue === null) { if (!moveTargets.has(`${row},${col}`)) { setError('そのマスには移動できません。') return } const socket = getRoomSocket(numericRoomId) + // 選択中の駒を押されたマスへ移動させる socket.emit( 'game:move', { roomId: numericRoomId, from: selected, to: { row, col } }, (response) => { - if (!response?.ok) { - setError(errorMessage(response?.error)) + // 成功したときだけ選択を解除する + if (!isOkResponse(response)) { + setError(errorMessage(responseErrorCode(response))) } else { setError('') setSelected(null) @@ -421,26 +610,32 @@ export default function RoomPage() { ) return } + // 相手の駒があるマスへは移動できない setError('そのマスには移動できません。') return } + // 未選択の状態で自分の駒を押したら、その駒を選択する if (cellValue === mySeat) { setSelected({ row, col }) setError('') return } + // 未選択の状態で空きマスを押したら「配置」の操作として扱う if (cellValue === null) { + // 持ち駒が尽きている場合は配置できない if (myPiecesLeft <= 0) { setError('持ち駒がありません。') return } const socket = getRoomSocket(numericRoomId) + // 押されたマスへ持ち駒を1つ配置する socket.emit( 'game:place', { roomId: numericRoomId, row, col }, (response) => { - if (!response?.ok) { - setError(errorMessage(response?.error)) + // 失敗した場合はサーバーのエラーコードに対応する文言を表示する + if (!isOkResponse(response)) { + setError(errorMessage(responseErrorCode(response))) } else { setError('') } @@ -448,14 +643,159 @@ export default function RoomPage() { ) return } + // 相手の駒があるマスには配置できない setError('そのマスには置けません。') } + /** + * 座席パネルに表示する操作ボタンを組み立てます。 + * @param {string} color - 'black' または 'white' + * @param {Object|null} seat - その席の着席情報 + * @returns {JSX.Element|null} 操作ボタン(表示しない場合は null) + */ + const buildSeatActions = (color, seat) => { + // 自分が座っている席では、退席と準備の切り替えを出す + if (mySeat === color) { + // 対局中は準備状態を変更できないのでボタンを出さない + let readyButton = null + if (!game || game.status !== 'playing') { + // 準備済みなら解除、未準備なら開始のラベルにする + let readyLabel = '開始' + if (ready[color]) { + readyLabel = '準備解除' + } + readyButton = ( + + ) + } + return ( +
+ + {readyButton} +
+ ) + } + + // 空席の場合、相手側の席ならCPUを座らせ、それ以外なら自分が着席する + if (!seat) { + if (opponentColor === color) { + return ( + + ) + } + return ( + + ) + } + + // CPUが座っていて、自分が解除できる立場のときだけ解除ボタンを出す + if (seat.loginId === 'cpu' && canReleaseCpu) { + return ( + + ) + } + + return null + } + + // 部屋名は未取得の間、汎用の見出しにする + let roomName = 'ルーム' + if (room) { + roomName = room.name + } + + // エラー・通知はそれぞれ内容がある場合だけ表示する + let errorView = null + if (error) { + errorView =
{error}
+ } + let noticeView = null + if (notice) { + noticeView =
{notice}
+ } + let cpuErrorView = null + if (cpuError) { + cpuErrorView = ( +
+ {cpuError} +
+ ) + } + + // 部屋・対局のステータス表示は、未取得の間は待機として扱う + let roomStatusText = statusLabel('waiting') + if (room && room.status) { + roomStatusText = statusLabel(room.status) + } + let gameStatusText = gameStatusLabel('waiting') + if (game && game.status) { + gameStatusText = gameStatusLabel(game.status) + } + + // 打つ手が無くパスになった場合だけ、その旨を表示する + let passNotice = null + if (game && game.status === 'playing' && game.passed) { + passNotice = ( +
+ {seatLabel(game.passed)}は打つ手がないためパス +
+ ) + } + + // 準備状態は塗り丸(準備済み)と白丸(未準備)で示す + let blackReadyMark = '○' + if (ready.black) { + blackReadyMark = '●' + } + let whiteReadyMark = '○' + if (ready.white) { + whiteReadyMark = '●' + } + + // 終局している場合だけ結果を表示する + let resultView = null + if (resultLabel) { + resultView = ( +
+ {resultLabel} +
+ ) + } + + // チャットは、発言が無ければ案内文を、あれば発言一覧を表示する + let chatView = null + if (chat.length === 0) { + chatView =
まだメッセージはありません。
+ } else { + // 発言ごとに1件のカードを描画する + chatView = chat.map((entry) => ( +
+
+ + {entry.nickname || '名無しプレイヤー'} + + {new Date(entry.created_at).toLocaleTimeString('ja-JP')} +
+
{entry.message}
+
+ )) + } + return (
-

{room ? room.name : 'ルーム'}

+

{roomName}

入室: {presence}人

@@ -478,32 +818,28 @@ export default function RoomPage() {
- {error &&
{error}
} - {notice &&
{notice}
} + {errorView} + {noticeView}
- 状態: {statusLabel(room?.status || 'waiting')} + 状態: {roomStatusText}
あなた: {mySeatText}
- 対局: {gameStatusLabel(game?.status || 'waiting')} + 対局: {gameStatusText}
手番: {turnLabel}
- {game?.status === 'playing' && game?.passed && ( -
- {seatLabel(game.passed)}は打つ手がないためパス -
- )} + {passNotice}
- 準備: 黒 {ready.black ? '●' : '○'} / 白 {ready.white ? '●' : '○'} + 準備: 黒 {blackReadyMark} / 白 {whiteReadyMark}
持ち駒: 黒 {Math.max(0, 6 - (placed.black || 0))} / 白 {Math.max(0, 6 - (placed.white || 0))} @@ -511,11 +847,7 @@ export default function RoomPage() {
- {resultLabel && ( -
- {resultLabel} -
- )} + {resultView} {/* 盤面は「残っている高さ」と「横幅」の狭いほうに合わせて縮む。 @@ -527,11 +859,45 @@ export default function RoomPage() {
盤面
+ {/* 5x5のマスを行ごとに走査して1マスずつボタンを描画する */} {board.map((row, rowIndex) => row.map((cell, colIndex) => { const key = `${rowIndex}-${colIndex}` - const isSelected = selected?.row === rowIndex && selected?.col === colIndex const isMoveable = moveTargets.has(`${rowIndex},${colIndex}`) + + // 現在選択中のマスかどうかを判定する + let isSelected = false + if (selected && selected.row === rowIndex && selected.col === colIndex) { + isSelected = true + } + + // 選択中は枠を強調し、それ以外はホバー時だけ薄く塗る + let highlightClass = "group-hover/cell:bg-primary/10" + if (isSelected) { + highlightClass = "bg-secondary/15 ring-2 ring-secondary" + } + + // 駒があるマスだけ石を描画し、色に応じて見た目を変える + let stone = null + if (cell) { + let stoneColorClass = "bg-gray-100 border border-gray-300" + if (cell === 'black') { + stoneColorClass = "bg-gray-900 border border-gray-800" + } + stone = ( + + ) + } + + // 移動先の目印。塗り円ではなく小さな点にして盤面を汚さない + let moveHint = null + if (isMoveable && !cell) { + moveHint = + } + return ( ) }) @@ -597,33 +953,8 @@ export default function RoomPage() { 黒席
-
{displayName(room?.seats.black)}
- {mySeat === 'black' ? ( -
- - {game?.status !== 'playing' && ( - - )} -
- ) : !room?.seats.black ? ( - opponentColor === 'black' ? ( - - ) : ( - - ) - ) : room?.seats.black?.loginId === 'cpu' && canReleaseCpu ? ( - - ) : null} +
{displayName(blackSeat)}
+ {buildSeatActions('black', blackSeat)}
@@ -631,42 +962,13 @@ export default function RoomPage() { 白席
-
{displayName(room?.seats.white)}
- {mySeat === 'white' ? ( -
- - {game?.status !== 'playing' && ( - - )} -
- ) : !room?.seats.white ? ( - opponentColor === 'white' ? ( - - ) : ( - - ) - ) : room?.seats.white?.loginId === 'cpu' && canReleaseCpu ? ( - - ) : null} +
{displayName(whiteSeat)}
+ {buildSeatActions('white', whiteSeat)}
- {cpuError && ( -
- {cpuError} -
- )} + {cpuErrorView} @@ -675,21 +977,7 @@ export default function RoomPage() {
- {chat.length === 0 ? ( -
まだメッセージはありません。
- ) : ( - chat.map((entry) => ( -
-
- - {entry.nickname || '名無しプレイヤー'} - - {new Date(entry.created_at).toLocaleTimeString('ja-JP')} -
-
{entry.message}
-
- )) - )} + {chatView}
diff --git a/client/src/pages/SettingsPage.jsx b/client/src/pages/SettingsPage.jsx index b64a9b8..9986b93 100644 --- a/client/src/pages/SettingsPage.jsx +++ b/client/src/pages/SettingsPage.jsx @@ -9,7 +9,7 @@ import { useState } from 'react' import { useNavigate } from 'react-router-dom' -import { apiPost } from '../api' +import { apiPost, errorCodeOf } from '../api' import { useAuth } from '../auth' import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -34,7 +34,13 @@ export default function SettingsPage() { const [nicknameDraft, setNicknameDraft] = useState(null) /** 実際に入力欄へ表示する値 */ - const nickname = nicknameDraft ?? (user?.nickname || '') + let nickname = '' + // 下書きがある間はそれを、無い間はログイン中ユーザーの現在の値を表示する + if (nicknameDraft !== null && nicknameDraft !== undefined) { + nickname = nicknameDraft + } else if (user) { + nickname = user.nickname || '' + } /** ニックネーム保存エラー */ const [nicknameError, setNicknameError] = useState('') @@ -68,13 +74,16 @@ export default function SettingsPage() { setNicknameNotice('') try { + // ニックネームをサーバーへ保存し、返ってきたユーザー情報で状態を更新する const data = await apiPost('/api/me/nickname', { nickname }) setUser(data.user) // 保存後は下書きを破棄し、サーバーから返った値を表示する setNicknameDraft(null) setNicknameNotice('保存しました。') } catch (err) { - if (err?.payload?.error === 'nickname_too_long') { + // エラーコードに応じて表示するメッセージを切り替える + const code = errorCodeOf(err) + if (code === 'nickname_too_long') { setNicknameError('ニックネームは20文字以内で入力してください。') } else { setNicknameError('保存に失敗しました。') @@ -91,20 +100,24 @@ export default function SettingsPage() { setPasswordError('') setPasswordNotice('') + // 送信前に文字数を確認し、短すぎる場合はAPIを呼ばずに終える if (newPassword.length < 6) { setPasswordError('新しいパスワードは6文字以上で入力してください。') return } try { + // パスワード変更APIを呼び出し、成功したら入力欄を空に戻す await apiPost('/api/me/password', { currentPassword, newPassword }) setPasswordNotice('パスワードを変更しました。') setCurrentPassword('') setNewPassword('') } catch (err) { - if (err?.payload?.error === 'invalid_current_password') { + // エラーコードに応じて表示するメッセージを切り替える + const code = errorCodeOf(err) + if (code === 'invalid_current_password') { setPasswordError('現在のパスワードが正しくありません。') - } else if (err?.payload?.error === 'password_too_short') { + } else if (code === 'password_too_short') { setPasswordError('新しいパスワードは6文字以上で入力してください。') } else { setPasswordError('パスワード変更に失敗しました。') @@ -116,6 +129,36 @@ export default function SettingsPage() { // レンダリング // ------------------------------------------------------------------------- + // ユーザー情報が未取得の場合に備えてログインIDは空文字にしておく + let loginId = '' + if (user) { + loginId = user.loginId + } + + // ニックネームのエラーは、発生している場合だけ表示する + let nicknameErrorView = null + if (nicknameError) { + nicknameErrorView =

{nicknameError}

+ } + + // ニックネームの保存完了通知も、ある場合だけ表示する + let nicknameNoticeView = null + if (nicknameNotice) { + nicknameNoticeView =

{nicknameNotice}

+ } + + // パスワード変更のエラーは、発生している場合だけ表示する + let passwordErrorView = null + if (passwordError) { + passwordErrorView =

{passwordError}

+ } + + // パスワード変更の完了通知も、ある場合だけ表示する + let passwordNoticeView = null + if (passwordNotice) { + passwordNoticeView =

{passwordNotice}

+ } + return (
{/* ===== ページヘッダー ===== */} @@ -123,7 +166,7 @@ export default function SettingsPage() {

アカウント設定

- ログイン中: {user?.loginId} + ログイン中: {loginId}

20文字以内で入力してください。

- {nicknameError &&

{nicknameError}

} - {nicknameNotice &&

{nicknameNotice}

} + {nicknameErrorView} + {nicknameNoticeView}
@@ -192,8 +235,8 @@ export default function SettingsPage() { />

6文字以上で入力してください。

- {passwordError &&

{passwordError}

} - {passwordNotice &&

{passwordNotice}

} + {passwordErrorView} + {passwordNoticeView}
diff --git a/client/src/socket.js b/client/src/socket.js index 6e6956a..b1962f3 100644 --- a/client/src/socket.js +++ b/client/src/socket.js @@ -33,7 +33,12 @@ const PING_INTERVAL_MS = 30000 function buildUrl(query) { const base = API_BASE_URL || window.location.origin const url = new URL('/ws', base) - url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + // HTTPSで配信されている場合は wss、それ以外は ws で接続する + if (url.protocol === 'https:') { + url.protocol = 'wss:' + } else { + url.protocol = 'ws:' + } url.search = query return url.toString() } @@ -69,7 +74,11 @@ function createSocket(query) { */ const dispatch = (event, payload) => { const handlers = listeners.get(event) - if (!handlers) return + // 登録済みハンドラが無いイベントは配信対象が無いので何もしない + if (!handlers) { + return + } + // ハンドラ内で登録解除されても走査が壊れないよう複製してから呼び出す for (const handler of [...handlers]) { try { handler(payload) @@ -83,9 +92,13 @@ function createSocket(query) { * 送信待ちのメッセージをすべて送ります。 */ const flushQueue = () => { - if (!ws || ws.readyState !== WebSocket.OPEN) return + // 接続が確立していない間は送信できないので何もしない + if (!ws || ws.readyState !== WebSocket.OPEN) { + return + } const items = queue queue = [] + // 溜まっていた送信待ちメッセージを順番に送る for (const item of items) { ws.send(item) } @@ -95,8 +108,12 @@ function createSocket(query) { * 接続を開きます。切断されたら自動で再接続します。 */ const connect = () => { - if (closed) return + // close() 済みの接続は再接続しない + if (closed) { + return + } + // WebSocket接続を開始する ws = new WebSocket(buildUrl(query)) ws.addEventListener('open', () => { @@ -104,6 +121,7 @@ function createSocket(query) { flushQueue() // ハイバネーション中のDurable Objectを起こさない自動応答pingを使う pingTimer = setInterval(() => { + // 接続中のときだけpingを送る if (ws && ws.readyState === WebSocket.OPEN) { ws.send('ping') } @@ -112,18 +130,27 @@ function createSocket(query) { }) ws.addEventListener('message', (event) => { - if (event.data === 'pong') return + // pingへの自動応答は処理不要なので読み飛ばす + if (event.data === 'pong') { + return + } let message try { + // 受信データをプロトコルのJSONとして解釈する message = JSON.parse(event.data) } catch { return } - if (!message || typeof message !== 'object') return + // JSONオブジェクトとして解釈できない内容は無視する + if (!message || typeof message !== 'object') { + return + } + // emitのack応答なら、待機中のリクエストへ結果を返す if (message.t === 'res') { const entry = pending.get(message.id) + // 既にタイムアウト済みの場合は待機情報が無いので何もしない if (entry) { clearTimeout(entry.timer) pending.delete(message.id) @@ -132,6 +159,7 @@ function createSocket(query) { return } + // サーバーからのプッシュイベントなら登録済みハンドラへ配信する if (message.t === 'ev') { dispatch(message.event, message.payload) } @@ -143,7 +171,10 @@ function createSocket(query) { ws = null dispatch('disconnect', undefined) - if (closed) return + // 明示的に閉じた場合は再接続しない + if (closed) { + return + } // 再接続(指数バックオフ) const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)] @@ -168,9 +199,12 @@ function createSocket(query) { * @param {Function} [ack] - サーバーからの応答を受け取るコールバック */ emit(event, payload, ack) { - const id = ack ? nextRequestId++ : null + let id = null + // ackコールバックがある場合のみリクエストIDを採番して応答待ちに登録する if (ack) { + id = nextRequestId++ + // 応答が返らないまま待ち続けないようタイムアウトを仕掛ける const timer = setTimeout(() => { pending.delete(id) ack({ ok: false, error: 'timeout' }) @@ -180,6 +214,7 @@ function createSocket(query) { const message = JSON.stringify({ t: 'req', id, event, payload: payload || {} }) + // 接続中なら即送信し、未接続なら接続後に送るためキューへ積む if (ws && ws.readyState === WebSocket.OPEN) { ws.send(message) } else { @@ -196,14 +231,18 @@ function createSocket(query) { * @param {Function} handler - ハンドラ */ on(event, handler) { + // 初めて登録するイベント名ならハンドラ集合を用意する if (!listeners.has(event)) { listeners.set(event, new Set()) } listeners.get(event).add(handler) + // 接続済みでconnectを登録した場合は取りこぼしを防ぐため一度だけ呼ぶ if (event === 'connect' && ws && ws.readyState === WebSocket.OPEN) { queueMicrotask(() => { - if (listeners.get('connect')?.has(handler)) { + const connectHandlers = listeners.get('connect') + // マイクロタスクが走るまでに解除されていないか確認する + if (connectHandlers && connectHandlers.has(handler)) { handler(undefined) } }) @@ -216,7 +255,11 @@ function createSocket(query) { * @param {Function} handler - 解除するハンドラ */ off(event, handler) { - listeners.get(event)?.delete(handler) + const handlers = listeners.get(event) + // 未登録のイベント名なら解除対象が無いので何もしない + if (handlers) { + handlers.delete(handler) + } }, /** @@ -227,11 +270,13 @@ function createSocket(query) { clearInterval(pingTimer) pingTimer = null queue = [] + // ack待ちのタイムアウトタイマーをすべて止める for (const entry of pending.values()) { clearTimeout(entry.timer) } pending.clear() listeners.clear() + // 接続が残っていれば閉じる if (ws) { ws.close() ws = null @@ -258,6 +303,7 @@ let roomSocketId = null * @returns {Object} 接続オブジェクト */ export function getLobbySocket() { + // アプリ全体で1本を共有するため、未接続のときだけ新規作成する if (!lobbySocket) { lobbySocket = createSocket('lobby=1') } @@ -272,11 +318,13 @@ export function getLobbySocket() { * @returns {Object} 接続オブジェクト */ export function getRoomSocket(roomId) { + // 別のルームに繋がっている場合は張り替えるため既存の接続を閉じる if (roomSocket && roomSocketId !== roomId) { roomSocket.close() roomSocket = null roomSocketId = null } + // 接続が無ければ指定ルームへ新規接続する if (!roomSocket) { roomSocket = createSocket(`roomId=${roomId}`) roomSocketId = roomId @@ -288,6 +336,7 @@ export function getRoomSocket(roomId) { * 現在のルーム接続を閉じます(ルーム画面を離れるとき)。 */ export function closeRoomSocket() { + // ルーム接続が残っていれば閉じる if (roomSocket) { roomSocket.close() roomSocket = null @@ -300,6 +349,7 @@ export function closeRoomSocket() { */ export function resetSocket() { closeRoomSocket() + // ロビー接続が残っていれば閉じる if (lobbySocket) { lobbySocket.close() lobbySocket = null diff --git a/worker/src/ai.js b/worker/src/ai.js index 9b9bbcb..9cd0325 100644 --- a/worker/src/ai.js +++ b/worker/src/ai.js @@ -13,8 +13,19 @@ import { getCellType, getOpponent, applyAction, + normalizeState, } from "./game.js"; +/** + * 探索中の applyAction に渡すオプション。 + * + * 探索が扱う局面は、入口で1度 normalizeState した状態か、その applyAction の + * 結果しかない。つまり常に正規化済みなので、1ノードごとの再正規化と + * 着手時刻の生成を省ける(ここが探索コストのおよそ3割を占めていた)。 + * @type {{trusted: boolean}} + */ +const SEARCH_APPLY = { trusted: true }; + /** * 8方向の移動ベクトル * 縦・横・斜めすべての方向を含む @@ -69,6 +80,7 @@ function listActions(state, color) { * @param {string} key - 重複チェック用のキー */ const addAction = (action, key) => { + // 同じ移動先へ複数の経路がある場合があるため、キーで重複を弾く if (!used.has(key)) { used.add(key); actions.push(action); @@ -78,6 +90,7 @@ function listActions(state, color) { // === 駒を打つアクション === // 持ち駒が残っている場合のみ if (state.placed[color] < MAX_PIECES) { + // 盤面全体を走査し、空きマスをすべて「打つ」手として列挙する for (let row = 0; row < BOARD_SIZE; row += 1) { for (let col = 0; col < BOARD_SIZE; col += 1) { // 空きマスに配置可能 @@ -89,6 +102,7 @@ function listActions(state, color) { } // === 駒を移動するアクション === + // 盤面全体を走査し、自分の駒を1つずつ移動元として扱う for (let row = 0; row < BOARD_SIZE; row += 1) { for (let col = 0; col < BOARD_SIZE; col += 1) { // 自分の駒でなければスキップ @@ -99,6 +113,7 @@ function listActions(state, color) { const from = { row, col }; // --- 1マス移動(8方向) --- + // 隣接8方向それぞれについて、空いていれば移動先にする for (const [dr, dc] of DIRECTIONS) { const toRow = row + dr; const toCol = col + dc; @@ -123,8 +138,10 @@ function listActions(state, color) { continue; } + // 斜め4方向について、自分の色のマスが続く限り滑れる先を集める for (const [dr, dc] of DIAG_DIRECTIONS) { let step = 1; + // 進めなくなる条件(盤外・色違い・駒あり)に当たるまで1マスずつ伸ばす while (true) { const toRow = row + dr * step; const toCol = col + dc * step; @@ -169,6 +186,7 @@ function listActions(state, color) { */ function countPieces(board, color) { let total = 0; + // 盤面全体を走査して該当色のマスを数える for (let row = 0; row < BOARD_SIZE; row += 1) { for (let col = 0; col < BOARD_SIZE; col += 1) { if (board[row][col] === color) { @@ -197,6 +215,7 @@ function lineCounts(board, color) { [-1, 1], // 左下斜め ]; + // 盤面全体を走査し、各マスを起点にラインを数える for (let row = 0; row < BOARD_SIZE; row += 1) { for (let col = 0; col < BOARD_SIZE; col += 1) { // 指定色の駒でなければスキップ @@ -204,6 +223,7 @@ function lineCounts(board, color) { continue; } + // 縦・横・斜め2種の4方向について、そのマスから伸びる長さを測る for (const [dr, dc] of scanDirs) { // ラインの先頭からのみカウント(重複防止) const prevRow = row - dr; @@ -216,6 +236,7 @@ function lineCounts(board, color) { let length = 0; let r = row; let c = col; + // 同じ色が続く限り進み、その長さを数える while (inBounds(r, c) && board[r][c] === color) { length += 1; r += dr; @@ -249,18 +270,22 @@ function countMobility(state, color) { let mobility = 0; let emptyCells = 0; + // 盤面全体を走査し、空きマス数と自分の駒から動ける先の数を数える for (let row = 0; row < BOARD_SIZE; row += 1) { for (let col = 0; col < BOARD_SIZE; col += 1) { const cell = board[row][col]; + // 空きマスは「打つ」手の候補になるので別に数える if (cell === null) { emptyCells += 1; continue; } + // 相手の駒はここでは数えない if (cell !== color) { continue; } + // 自分の駒の隣接8方向のうち、空いている数だけ動ける先がある for (const [dr, dc] of DIRECTIONS) { const nextRow = row + dr; const nextCol = col + dc; @@ -295,6 +320,7 @@ function countMobility(state, color) { function evaluateState(state, color) { // 終了状態の場合は勝敗で決定的なスコアを返す if (state.status === "finished") { + // 勝ち・負け・引き分けで決定的なスコアを返す if (state.winner === color) { return 100000; // 勝利 } @@ -353,19 +379,38 @@ function evaluateState(state, color) { function serializeState(state) { const board = state.board; let code = 0; + // 各マスを2ビットに詰め、盤面全体を1つの整数にまとめる for (let row = 0; row < BOARD_SIZE; row += 1) { const line = board[row]; for (let col = 0; col < BOARD_SIZE; col += 1) { const cell = line[col]; // 空き=0 / 黒=1 / 白=2 - const bits = cell === null ? 0 : cell === "black" ? 1 : 2; + let bits = 0; + if (cell === "black") { + bits = 1; + } else if (cell !== null) { + bits = 2; + } code = code * 4 + bits; } } + + // 手番も1桁で表す(同じ盤面でも手番が違えば別の局面) + let turnBit = 1; + if (state.turn === "black") { + turnBit = 0; + } + // 手番と持ち駒の消費数もキーに含める(同じ盤面でも合法手が変わるため) - return `${code.toString(36)}.${state.turn === "black" ? 0 : 1}${state.placed.black}${state.placed.white}`; + return `${code.toString(36)}.${turnBit}${state.placed.black}${state.placed.white}`; } +/** + * 予算切れで1手も評価できなかったときに、深さ1で読み直すためのノード数。 + * 1局面の子ノードを見るだけなので、これで足りる。 + */ +const RETRY_NODE_BUDGET = 200; + /** トランスポジションテーブルの評価値の種類 */ const TT_EXACT = 0; const TT_LOWER = 1; @@ -389,14 +434,16 @@ function orderingScore(state, action, color) { const opponent = getOpponent(color); let score = 0; - // 相手の駒に隣接する手は挟み(裏返し)につながりやすい + // 着手先の隣接8方向を見て、相手の駒に隣接する手ほど高く評価する for (let i = 0; i < DIRECTIONS.length; i += 1) { const row = to.row + DIRECTIONS[i][0]; const col = to.col + DIRECTIONS[i][1]; + // 盤外は評価対象にならない if (!inBounds(row, col)) { continue; } const cell = board[row][col]; + // 相手の駒に接する手は挟み(裏返し)につながりやすい if (cell === opponent) { score += 4; } else if (cell === color) { @@ -421,14 +468,31 @@ function orderingScore(state, action, color) { * 分割探索では毎回このリストの index で再開するので、 * 同じ局面なら必ず同じ順序になる必要がある(乱数を使わない)。 * + * 反復深化では、1つ浅い深さでの最善手を hint として必ず先頭に置く。 + * これがあるおかげで、深い探索を読み切れずに打ち切っても + * 「前の深さの最善手か、それより良いと分かった手」しか返らない。 + * * @param {Object} state - 現在のゲーム状態 * @param {'black'|'white'} color - 手番の色 + * @param {Object} [hint=null] - 先頭に置く手(1つ浅い深さでの最善手) * @returns {Array} 並べ替え済みのアクション配列 */ -function listRootActions(state, color) { +function listRootActions(state, color, hint = null) { const actions = listActions(state, color); - return actions - .map((action, index) => ({ action, index, score: orderingScore(state, action, color) })) + + // 各手に並べ替え用のスコアを付ける + const scored = actions.map((action, index) => { + // 1つ浅い深さでの最善手は必ず先頭に来るよう最大値にする + let score; + if (hint && sameAction(action, hint)) { + score = Number.POSITIVE_INFINITY; + } else { + score = orderingScore(state, action, color); + } + return { action, index, score }; + }); + + return scored // 同点時は元の順序を保って安定させる .sort((a, b) => b.score - a.score || a.index - b.index) .map((entry) => entry.action); @@ -445,10 +509,12 @@ function listRootActions(state, color) { * * @param {'black'|'white'} color - CPUプレイヤーの色(最大化する側) * @param {number} nodeBudget - 探索するノード数の上限 + * @param {Map} [sharedTable=null] - ティックをまたいで使い回す置換表。 + * 省略時はこの探索専用の表を作る。 * @returns {{evaluate: Function, nodes: Function, aborted: Function}} 探索コンテキスト */ -function createSearchContext(color, nodeBudget) { - const table = new Map(); +function createSearchContext(color, nodeBudget, sharedTable = null) { + const table = sharedTable || new Map(); let nodes = 0; let aborted = false; @@ -462,11 +528,13 @@ function createSearchContext(color, nodeBudget) { */ const evaluate = (current, depth, alpha, beta) => { nodes += 1; + // ノード数の予算を使い切ったらこの探索を打ち切る if (nodes > nodeBudget) { aborted = true; return { score: 0, aborted: true }; } + // 読み切った深さ、または終局した局面は評価関数の値をそのまま返す if (depth === 0 || current.status === "finished") { return { score: evaluateState(current, color), aborted: false }; } @@ -475,21 +543,26 @@ function createSearchContext(color, nodeBudget) { const key = serializeState(current); const cached = table.get(key); + // 同じ局面を同じ深さ以上で読んだ結果が残っていれば再利用する if (cached && cached.depth >= depth) { + // 確定値なら探索せずにそのまま返せる if (cached.flag === TT_EXACT) { return { score: cached.score, aborted: false, bestAction: cached.action }; } + // 上限・下限の記録なら探索窓を狭めるのに使う if (cached.flag === TT_LOWER && cached.score > alpha) { alpha = cached.score; } else if (cached.flag === TT_UPPER && cached.score < beta) { beta = cached.score; } + // 窓が閉じたらこれ以上調べても結果は変わらない if (alpha >= beta) { return { score: cached.score, aborted: false, bestAction: cached.action }; } } const actions = listActions(current, current.turn); + // 合法手が無い局面はそれ以上進められないので評価値を返す if (actions.length === 0) { return { score: evaluateState(current, color), aborted: false }; } @@ -498,78 +571,119 @@ function createSearchContext(color, nodeBudget) { // // 並べ替えは depth>=2 のときだけ行う。葉の直前(depth==1)では子がすべて // 評価関数の呼び出しで終わるため、並べ替えの費用のほうが高くつく。 - const hint = cached ? cached.action : null; - if (actions.length > 1 && depth >= 2) { + // 置換表に最善手が残っていれば並べ替えのヒントとして使う + let hint = null; + if (cached) { + hint = cached.action; + } + const ordered = actions.length > 1 && depth >= 2; + let scores = null; + if (ordered) { const turnColor = current.turn; // スコアは1手につき1回だけ計算する。 // 比較関数の中で計算すると O(n log n) 回呼ばれてしまう。 - const scores = new Array(actions.length); + scores = new Array(actions.length); for (let i = 0; i < actions.length; i += 1) { - scores[i] = hint && sameAction(actions[i], hint) - ? Number.POSITIVE_INFINITY - : orderingScore(current, actions[i], turnColor); - } - // 挿入ソート。手の数はせいぜい数十なので、配列を作り直すより速い。 - for (let i = 1; i < actions.length; i += 1) { - const action = actions[i]; - const score = scores[i]; - let j = i - 1; - while (j >= 0 && scores[j] < score) { - actions[j + 1] = actions[j]; - scores[j + 1] = scores[j]; - j -= 1; + // ヒントと同じ手は最優先で調べる + if (hint && sameAction(actions[i], hint)) { + scores[i] = Number.POSITIVE_INFINITY; + } else { + scores[i] = orderingScore(current, actions[i], turnColor); } - actions[j + 1] = action; - scores[j + 1] = score; } } const maximizing = current.turn === color; - let bestScore = maximizing ? -Infinity : Infinity; + // 最大化側は下限から、最小化側は上限から更新していく + let bestScore = Infinity; + if (maximizing) { + bestScore = -Infinity; + } let bestAction = null; - for (const action of actions) { - const result = applyAction(current, action); + // 手を1つずつ試し、アルファベータ窓が閉じた時点で打ち切る + for (let i = 0; i < actions.length; i += 1) { + if (ordered) { + // 未調査の中から最良の手を i 番目へ持ってくる(選択ソートの1ステップ)。 + // 枝刈りで数手見ただけで抜けることが多いため、 + // 最初に全部並べ替えるより実際に触る回数がずっと少なくて済む。 + let pick = i; + // 未調査の範囲から最もスコアの高い手を探す + for (let j = i + 1; j < actions.length; j += 1) { + if (scores[j] > scores[pick]) { + pick = j; + } + } + // 見つかった手を i 番目と入れ替える + if (pick !== i) { + const swapAction = actions[i]; + actions[i] = actions[pick]; + actions[pick] = swapAction; + const swapScore = scores[i]; + scores[i] = scores[pick]; + scores[pick] = swapScore; + } + } + + const action = actions[i]; + // 手を適用して1手先の局面を作る + const result = applyAction(current, action, SEARCH_APPLY); + // ルール上成立しない手は読み飛ばす if (!result.ok) { continue; } + // 1手先の局面を再帰的に評価する const child = evaluate(result.state, depth - 1, alpha, beta); + // 予算切れならこの探索の結果は使えない if (child.aborted) { return { score: 0, aborted: true }; } + // 手番によって、より大きい値・より小さい値のどちらを選ぶかが変わる if (maximizing) { + // より高い評価の手が見つかったら最善手を差し替える if (child.score > bestScore) { bestScore = child.score; bestAction = action; } + // 最大化側の下限(アルファ)を引き上げる if (bestScore > alpha) { alpha = bestScore; } + // 窓が閉じたら、残りの手を調べても結果は変わらない if (alpha >= beta) { break; } } else { + // より低い評価の手が見つかったら最善手を差し替える if (child.score < bestScore) { bestScore = child.score; bestAction = action; } + // 最小化側の上限(ベータ)を引き下げる if (bestScore < beta) { beta = bestScore; } + // 窓が閉じたら、残りの手を調べても結果は変わらない if (beta <= alpha) { break; } } } + // 1手も成立しなかった場合は評価関数の値をそのまま返す if (bestAction === null) { return { score: evaluateState(current, color), aborted: false }; } - const flag = - bestScore <= alphaOrigin ? TT_UPPER : bestScore >= beta ? TT_LOWER : TT_EXACT; + // 得られた値が確定値か、探索窓による上限・下限かを記録して再利用できるようにする + let flag = TT_EXACT; + if (bestScore <= alphaOrigin) { + flag = TT_UPPER; + } else if (bestScore >= beta) { + flag = TT_LOWER; + } table.set(key, { depth, score: bestScore, flag, action: bestAction }); return { score: bestScore, aborted: false, bestAction }; @@ -598,29 +712,47 @@ function createSearchContext(color, nodeBudget) { * @param {number} [options.nodeBudget=1200] - このバッチで使えるノード数 * @param {number} [options.bestScore=-Infinity] - ここまでの最善評価値 * @param {Object} [options.bestAction=null] - ここまでの最善手 + * @param {Object} [options.hintAction=null] - 最初に調べる手(1つ浅い深さでの最善手) + * @param {Map} [options.table=null] - ティックをまたいで使い回す置換表 * @returns {Object} 進捗と最善手 */ -function searchRootBatch(state, color, options) { +function searchRootBatch(rootState, color, options) { const depth = options.depth; const startIndex = options.startIndex || 0; const nodeBudget = options.nodeBudget || 1200; - const actions = listRootActions(state, color); + // 置換表はティックをまたいで渡された場合のみ使い回す + let table = null; + if (options.table instanceof Map) { + table = options.table; + } + + // 以降は正規化済みであることを前提に探索する(SEARCH_APPLY 参照) + const state = normalizeState(rootState); + const actions = listRootActions(state, color, options.hintAction || null); const total = actions.length; + // 合法手が無ければ探索するものが無い if (total === 0) { return { done: true, nextIndex: 0, total: 0, bestScore: -Infinity, bestAction: null, nodes: 0 }; } - const context = createSearchContext(color, nodeBudget); - let bestScore = typeof options.bestScore === "number" ? options.bestScore : -Infinity; + const context = createSearchContext(color, nodeBudget, table); + + // 前のティックから引き継いだ暫定最善値があればそこから再開する + let bestScore = -Infinity; + if (typeof options.bestScore === "number") { + bestScore = options.bestScore; + } let bestAction = options.bestAction || null; let index = startIndex; let evaluated = 0; + // 根の手を index から順に評価し、予算を使い切ったところで中断する while (index < total) { const action = actions[index]; - const result = applyAction(state, action); + const result = applyAction(state, action, SEARCH_APPLY); + // ルール上成立しない手は読み飛ばす if (!result.ok) { index += 1; continue; @@ -637,8 +769,12 @@ function searchRootBatch(state, color, options) { if (evaluated > 0) { break; } - const shallow = createSearchContext(color, nodeBudget); + // 深さ1の読み直しに必要なのは高々1手ぶんの子ノードなので、予算は小さくてよい。 + // ここに nodeBudget をそのまま渡すと、1ティックのCPU時間が最悪2倍になる。 + const shallow = createSearchContext(color, Math.min(nodeBudget, RETRY_NODE_BUDGET), table); + // 深さ1で読み直して、この手を必ず1つは評価しておく const retry = shallow.evaluate(result.state, 1, -Infinity, Infinity); + // 読み直しが成立し、より良い(または初めての)手なら採用する if (!retry.aborted && (retry.score > bestScore || bestAction === null)) { bestScore = retry.score; bestAction = action; @@ -648,6 +784,7 @@ function searchRootBatch(state, color, options) { break; } + // より良い(または初めての)手が見つかったら最善手を更新する if (child.score > bestScore || bestAction === null) { bestScore = child.score; bestAction = action; @@ -656,6 +793,7 @@ function searchRootBatch(state, color, options) { index += 1; evaluated += 1; + // 予算を使い切ったらこのティックはここまでにする if (context.nodes() >= nodeBudget) { break; } @@ -671,6 +809,115 @@ function searchRootBatch(state, color, options) { }; } +/** + * 置換表のエントリ数の上限。 + * 1手ぶんの探索で使い回すだけなので、これを超えたら丸ごと捨てて作り直す。 + */ +const TABLE_LIMIT = 30000; + +/** + * CPUの1手ぶんの探索の進捗を作ります。 + * + * アラームをまたいで持ち回るため、そのまま構造化クローンできる + * プレーンなオブジェクトにする(置換表は別に持つ)。 + * + * @param {string} signature - 局面の指紋。別の局面の途中結果を捨てるのに使う + * @returns {Object} 進捗オブジェクト + */ +function createCpuSearch(signature) { + return { + signature, + ticks: 0, // 消費したアラームの回数 + depth: 1, // いま読んでいる深さ + index: 0, // その深さで次に調べる根の手 + bestScore: null, // その深さでの暫定最善値(-Infinity は保存できないのでnull) + bestAction: null, + action: null, // 読み切れた最大の深さでの最善手(実際に指す手) + doneDepth: 0, // 読み切れた最大の深さ + }; +} + +/** + * CPUの探索を1ティックぶん進めます。 + * + * 固定の深さで探索すると、予算内に根の手を全部調べ切れなかったときに + * 「最初の数手の中の最善手」を指してしまい、浅く読んだ場合よりはるかに + * 弱くなる。そこで深さ1から順に読み、読み切れた深さの結果を必ず手元に + * 残す(反復深化)。深い探索を打ち切っても、1つ浅い深さの最善手を先頭に + * 調べているので、返るのはその手か、それより良いと分かった手だけになる。 + * + * @param {Object} state - 現在のゲーム状態 + * @param {'black'|'white'} color - CPUプレイヤーの色 + * @param {Object} progress - createCpuSearch() で作った進捗(破壊的に更新される) + * @param {Object} config - 探索設定 + * @param {number} config.maxDepth - 読む深さの上限 + * @param {number} config.nodeBudget - 1ティックで使えるノード数 + * @param {Map} [config.table] - ティックをまたいで使い回す置換表 + * @returns {{done: boolean, action: Object|null}} 打ち切ってよいかと、現時点の着手 + */ +function stepCpuSearch(state, color, progress, config) { + // 置換表は渡された場合のみ使い回す + let table = null; + if (config.table instanceof Map) { + table = config.table; + } + // 肥大化した置換表は丸ごと捨てて作り直す + if (table && table.size > TABLE_LIMIT) { + table.clear(); + } + + // -Infinity は保存に向かないので null で持ち回している。ここで元に戻す + let resumeScore = -Infinity; + if (progress.bestScore !== null) { + resumeScore = progress.bestScore; + } + + // 現在の深さの続きを1ティックぶんだけ進める + const batch = searchRootBatch(state, color, { + depth: progress.depth, + startIndex: progress.index, + nodeBudget: config.nodeBudget, + bestScore: resumeScore, + bestAction: progress.bestAction, + hintAction: progress.action, + table, + }); + + progress.ticks += 1; + + // ±Infinity は保存できないので null に落として持ち回す + if (Number.isFinite(batch.bestScore)) { + progress.bestScore = batch.bestScore; + } else { + progress.bestScore = null; + } + progress.bestAction = batch.bestAction; + + // この深さを読み切れていなければ、次のティックで続きから読む + if (!batch.done) { + // この深さはまだ途中。次のティックで続きから読む + progress.index = batch.nextIndex; + return { done: false, action: batch.bestAction || progress.action }; + } + + // この深さを読み切ったので、その結果を「実際に指す手」として確定させる + if (batch.bestAction) { + progress.action = batch.bestAction; + progress.doneDepth = progress.depth; + } + // 指す手が無い、または深さの上限に達したら思考を終える + if (!batch.bestAction || progress.depth >= config.maxDepth) { + return { done: true, action: progress.action }; + } + + // 次の深さへ + progress.depth += 1; + progress.index = 0; + progress.bestScore = null; + progress.bestAction = null; + return { done: false, action: progress.action }; +} + /** * ミニマックス法(アルファベータ枝刈り)で最善手を探索します。 * 反復深化により、ノード数の予算内で可能な限り深く探索します。 @@ -686,10 +933,13 @@ function searchRootBatch(state, color, options) { * @param {Object} [options.stats] - 探索結果の統計を書き戻すオブジェクト(任意) * @returns {Object|null} 最善手(見つからない場合はnull) */ -function searchBestMove(state, color, options = {}) { +function searchBestMove(rootState, color, options = {}) { const maxDepth = options.maxDepth || 4; const nodeBudget = options.nodeBudget || 1200; + // 以降は正規化済みであることを前提に探索する(SEARCH_APPLY 参照) + const state = normalizeState(rootState); + let best = null; let reachedDepth = 0; let nodes = 0; @@ -697,6 +947,7 @@ function searchBestMove(state, color, options = {}) { // 反復深化: 深度1から徐々に深く探索 for (let depth = 1; depth <= maxDepth; depth += 1) { const remaining = nodeBudget - nodes; + // ノード数の予算を使い切ったらこれ以上深くは読まない if (remaining <= 0) { break; } @@ -704,6 +955,7 @@ function searchBestMove(state, color, options = {}) { const result = searchRootBatch(state, color, { depth, nodeBudget: remaining }); nodes += result.nodes; + // 手が得られていれば、より深い結果で上書きしていく if (result.bestAction) { best = result.bestAction; if (result.done) { @@ -711,20 +963,24 @@ function searchBestMove(state, color, options = {}) { } } + // この深さを読み切れなかった場合、さらに深く読んでも意味がない if (!result.done) { break; } } + // 呼び出し側が統計を求めていれば書き戻す if (options.stats) { options.stats.nodes = nodes; options.stats.depth = reachedDepth; } + // 探索で手が決まっていればそれを指す if (best) { return best; } + // 探索で手が決まらなかった場合は、合法手からランダムに選ぶ const fallback = listActions(state, color); if (fallback.length === 0) { return null; @@ -739,12 +995,15 @@ function searchBestMove(state, color, options = {}) { * @returns {boolean} 同じ手ならtrue */ function sameAction(a, b) { + // 種類が違えば別の手 if (!a || !b || a.type !== b.type) { return false; } + // 着手先が違えば別の手 if (a.to.row !== b.to.row || a.to.col !== b.to.col) { return false; } + // 移動の場合は移動元まで一致して初めて同じ手といえる if (a.type === "move") { return a.from.row === b.from.row && a.from.col === b.from.col; } @@ -758,7 +1017,7 @@ function sameAction(a, b) { /** * CPUの難易度ごとの探索設定。 * - * depth … 読む手数 + * depth … 読む深さの上限(反復深化なので、予算内で届いた深さまでを使う) * nodeBudget … 1リクエストあたりに探索するノード数の上限 * maxTicks … 1手の思考に使うアラームの回数 * @@ -766,13 +1025,17 @@ function sameAction(a, b) { * 探索を複数のアラームに分割するしかない。nodeBudget が1回あたりの * CPU時間を、maxTicks が1手にかける総量を決める。 * + * depth は「固定の深さ」ではなく上限であることに注意。反復深化により + * 浅い深さから順に読み、予算が尽きた時点で読み切れている最良の結果を使う。 + * そのため上限を上げても、届かなければ弱くなるだけということはない。 + * * @type {Object} */ const CPU_LEVELS = { easy: { depth: 2, nodeBudget: 600, maxTicks: 1 }, normal: { depth: 3, nodeBudget: 800, maxTicks: 2 }, - hard: { depth: 3, nodeBudget: 1000, maxTicks: 3 }, - strong: { depth: 3, nodeBudget: 1000, maxTicks: 4 }, + hard: { depth: 4, nodeBudget: 1100, maxTicks: 3 }, + strong: { depth: 5, nodeBudget: 1800, maxTicks: 4 }, }; /** @@ -786,7 +1049,11 @@ const CPU_LEVELS = { * @returns {{level: string, depth: number, nodeBudget: number, maxTicks: number}} 探索設定 */ function resolveCpuLevel(levelName, env = {}) { - const level = CPU_LEVELS[levelName] ? levelName : "strong"; + // 未知の難易度名が来た場合は最も強い設定にフォールバックする + let level = "strong"; + if (CPU_LEVELS[levelName]) { + level = levelName; + } const base = CPU_LEVELS[level]; /** @@ -797,7 +1064,11 @@ function resolveCpuLevel(levelName, env = {}) { */ const cap = (value, raw) => { const limit = Number(raw); - return Number.isFinite(limit) && limit > 0 ? Math.min(value, limit) : value; + // 環境変数で有効な上限が指定されている場合のみ切り詰める + if (Number.isFinite(limit) && limit > 0) { + return Math.min(value, limit); + } + return value; }; return { @@ -815,6 +1086,8 @@ export { evaluateState, searchBestMove, searchRootBatch, + createCpuSearch, + stepCpuSearch, CPU_LEVELS, resolveCpuLevel, }; diff --git a/worker/src/auth.js b/worker/src/auth.js index 77b56d4..27e2a53 100644 --- a/worker/src/auth.js +++ b/worker/src/auth.js @@ -30,8 +30,15 @@ const decoder = new TextDecoder(); * @returns {string} Base64URL文字列 */ function toBase64Url(buffer) { - const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); + // ArrayBuffer で渡された場合はバイト列に変換してから扱う + let bytes; + if (buffer instanceof Uint8Array) { + bytes = buffer; + } else { + bytes = new Uint8Array(buffer); + } let binary = ""; + // btoa に渡せるよう、1バイトずつ文字へ変換して連結する for (const byte of bytes) { binary += String.fromCharCode(byte); } @@ -47,6 +54,7 @@ function fromBase64Url(value) { const padded = value.replace(/-/g, "+").replace(/_/g, "/"); const binary = atob(padded + "=".repeat((4 - (padded.length % 4)) % 4)); const bytes = new Uint8Array(binary.length); + // 復号した文字列を1文字ずつバイトへ書き戻す for (let i = 0; i < binary.length; i += 1) { bytes[i] = binary.charCodeAt(i); } @@ -60,10 +68,12 @@ function fromBase64Url(value) { * @returns {boolean} 一致すればtrue */ function timingSafeEqual(a, b) { + // 長さが違う時点で不一致だが、この判定は内容を漏らさない if (a.length !== b.length) { return false; } let diff = 0; + // 途中で打ち切らずに全バイトを走査し、比較時間を入力に依存させない for (let i = 0; i < a.length; i += 1) { diff |= a[i] ^ b[i]; } @@ -82,6 +92,7 @@ function timingSafeEqual(a, b) { * @returns {Promise} 32バイトの導出鍵 */ async function deriveKey(password, salt, iterations) { + // 平文パスワードをPBKDF2の鍵素材として取り込む const keyMaterial = await crypto.subtle.importKey( "raw", encoder.encode(password), @@ -118,22 +129,26 @@ async function hashPassword(password, iterations = DEFAULT_ITERATIONS) { * @returns {Promise} 一致すればtrue */ async function verifyPassword(password, stored) { + // 保存値が文字列でなければ検証できない if (typeof stored !== "string") { return false; } const parts = stored.split("$"); + // 想定の保存形式(pbkdf2$反復回数$salt$hash)でなければ不一致とする if (parts.length !== 4 || parts[0] !== "pbkdf2") { return false; } const iterations = Number(parts[1]); + // 反復回数が壊れている保存値は検証できない if (!Number.isFinite(iterations) || iterations <= 0) { return false; } const salt = fromBase64Url(parts[2]); const expected = fromBase64Url(parts[3]); + // 保存時と同じ条件で鍵を導出し直して突き合わせる const actual = await deriveKey(password, salt, iterations); return timingSafeEqual(actual, expected); @@ -178,16 +193,19 @@ async function signSession(payload, secret) { * @returns {Promise} 有効ならペイロード、無効ならnull */ async function verifySession(value, secret) { + // `.` の形でなければ検証できない if (typeof value !== "string" || !value.includes(".")) { return null; } const [body, signature] = value.split("."); + // 本体か署名が欠けている場合は無効とする if (!body || !signature) { return null; } const key = await importHmacKey(secret); + // 同じ鍵で署名し直し、改ざんされていないか確認する const expected = await crypto.subtle.sign("HMAC", key, encoder.encode(body)); if (!timingSafeEqual(fromBase64Url(signature), new Uint8Array(expected))) { return null; @@ -195,12 +213,13 @@ async function verifySession(value, secret) { let payload; try { + // 署名が正しければ本体をJSONとして復元する payload = JSON.parse(decoder.decode(fromBase64Url(body))); } catch { return null; } - // 有効期限切れ + // 有効期限切れ、または期限が入っていないペイロードは無効とする if (!payload || typeof payload.exp !== "number" || payload.exp * 1000 < Date.now()) { return null; } @@ -216,15 +235,19 @@ async function verifySession(value, secret) { */ function readCookie(request, name) { const header = request.headers.get("Cookie"); + // Cookieヘッダが無ければ探す対象が無い if (!header) { return null; } + // `名前=値` を「;」区切りで順に調べ、目的の名前を探す for (const part of header.split(";")) { const index = part.indexOf("="); + // 「=」を含まない断片はCookieとして解釈できないので読み飛ばす if (index === -1) { continue; } + // 名前が一致したらURLデコードした値を返す if (part.slice(0, index).trim() === name) { return decodeURIComponent(part.slice(index + 1).trim()); } @@ -241,12 +264,17 @@ function readCookie(request, name) { */ async function getSessionUserId(request, secret) { const cookie = readCookie(request, SESSION_COOKIE); + // セッションCookieが無ければ未ログイン if (!cookie) { return null; } + // 署名と有効期限を検証したうえでユーザーIDを取り出す const payload = await verifySession(cookie, secret); - return payload && typeof payload.uid === "number" ? payload.uid : null; + if (payload && typeof payload.uid === "number") { + return payload.uid; + } + return null; } /** @@ -270,6 +298,7 @@ async function createSessionCookie(userId, secret, secure = true) { "SameSite=Lax", `Max-Age=${SESSION_MAX_AGE}`, ]; + // HTTPS配信時のみ Secure を付ける(ローカルのHTTP開発では付けない) if (secure) { attrs.push("Secure"); } @@ -283,6 +312,7 @@ async function createSessionCookie(userId, secret, secure = true) { */ function clearSessionCookie(secure = true) { const attrs = [`${SESSION_COOKIE}=`, "Path=/", "HttpOnly", "SameSite=Lax", "Max-Age=0"]; + // 発行時と属性を揃えないとCookieを削除できないため、同じ条件でSecureを付ける if (secure) { attrs.push("Secure"); } diff --git a/worker/src/db.js b/worker/src/db.js index 4313307..1e942ae 100644 --- a/worker/src/db.js +++ b/worker/src/db.js @@ -101,11 +101,18 @@ async function updateUserNickname(db, userId, nickname) { * @returns {Promise} 更新できたらtrue */ async function updateUserPassword(db, userId, passwordHash) { + // 対象ユーザーのパスワードハッシュを更新する const result = await db .prepare(`UPDATE users SET password_hash = ? WHERE id = ?`) .bind(passwordHash, userId) .run(); - return (result.meta?.changes ?? 0) > 0; + + // 更新件数が取れない実行結果は0件として扱う + let changes = 0; + if (result.meta && typeof result.meta.changes === "number") { + changes = result.meta.changes; + } + return changes > 0; } export { diff --git a/worker/src/game.js b/worker/src/game.js index 4267363..650c268 100644 --- a/worker/src/game.js +++ b/worker/src/game.js @@ -45,6 +45,35 @@ const BLACK_POSITIONS = new Set([ '4,2', ]); +/** + * 隣接8方向の移動ベクトル。 + * 呼び出しごとに配列を作り直すとCPU対戦の探索でそのコストが効いてくるため、 + * モジュール定数として1度だけ確保する。 + * @type {Array>} + */ +const ALL_DIRECTIONS = [ + [1, 0], // 下 + [-1, 0], // 上 + [0, 1], // 右 + [0, -1], // 左 + [1, 1], // 右下 + [1, -1], // 左下 + [-1, 1], // 右上 + [-1, -1], // 左上 +]; + +/** + * ライン判定に使う4方向(縦・横・斜め2種)。 + * 逆向きは同じラインなので4方向で足りる。 + * @type {Array>} + */ +const LINE_DIRECTIONS = [ + [1, 0], // 縦 + [0, 1], // 横 + [1, 1], // 右下斜め + [-1, 1], // 左下斜め +]; + /** * 座標が盤面内かどうかを判定します。 * @param {number} row - 行番号(0-4) @@ -134,11 +163,14 @@ function normalizeState(state) { // 盤面を正規化 const board = createEmptyBoard(); if (Array.isArray(state.board)) { + // 5x5の範囲だけを走査し、想定外の行や値は捨てる for (let row = 0; row < BOARD_SIZE; row += 1) { const sourceRow = state.board[row]; + // 行が配列でなければその行はすべて空きマスのままにする if (!Array.isArray(sourceRow)) { continue; } + // 行内の各マスを検証しながら書き写す for (let col = 0; col < BOARD_SIZE; col += 1) { const cell = sourceRow[col]; // 有効な値のみコピー @@ -153,13 +185,35 @@ function normalizeState(state) { const placed = state.placed || { black: 0, white: 0 }; const ready = state.ready || { black: false, white: false }; + // 配置済み駒数は数値以外を0に丸める + let placedBlack = 0; + if (Number.isFinite(placed.black)) { + placedBlack = placed.black; + } + let placedWhite = 0; + if (Number.isFinite(placed.white)) { + placedWhite = placed.white; + } + + // 手番は 'white' 以外をすべて黒(先手)として扱う + let turn = 'black'; + if (state.turn === 'white') { + turn = 'white'; + } + + // パスした色は既知の2色のみ受け付ける + let passed = null; + if (state.passed === 'black' || state.passed === 'white') { + passed = state.passed; + } + return { board, placed: { - black: Number.isFinite(placed.black) ? placed.black : 0, - white: Number.isFinite(placed.white) ? placed.white : 0, + black: placedBlack, + white: placedWhite, }, - turn: state.turn === 'white' ? 'white' : 'black', + turn, status: state.status || 'waiting', ready: { black: Boolean(ready.black), @@ -168,7 +222,38 @@ function normalizeState(state) { winner: state.winner || null, result: state.result || null, lastMove: state.lastMove || null, - passed: state.passed === 'black' || state.passed === 'white' ? state.passed : null, + passed, + }; +} + +/** + * 正規化済みのゲーム状態を複製します。 + * + * normalizeState は1マスずつ値を検証しながら盤面を作り直すため、 + * 正しいことが分かっている状態のコピーには重すぎる。CPU対戦の探索では + * 1ノードごとにこのコピーが走るので、検証を省いた複製を使う。 + * + * @param {Object} state - 正規化済みのゲーム状態 + * @returns {Object} 複製された状態 + */ +function cloneState(state) { + const source = state.board; + const board = new Array(BOARD_SIZE); + // 行ごとに浅いコピーを取れば、マスの値は文字列とnullなので複製として十分 + for (let row = 0; row < BOARD_SIZE; row += 1) { + board[row] = source[row].slice(); + } + + return { + board, + placed: { black: state.placed.black, white: state.placed.white }, + turn: state.turn, + status: state.status, + ready: { black: state.ready.black, white: state.ready.white }, + winner: state.winner, + result: state.result, + lastMove: state.lastMove, + passed: state.passed, }; } @@ -178,7 +263,11 @@ function normalizeState(state) { * @returns {'black'|'white'} 相手の色 */ function getOpponent(color) { - return color === 'black' ? 'white' : 'black'; + // 黒の相手は白、それ以外(白)の相手は黒 + if (color === 'black') { + return 'white'; + } + return 'black'; } /** @@ -198,11 +287,7 @@ function hasLegalAction(state, color) { const placed = state.placed || { black: 0, white: 0 }; const canPlace = (placed[color] || 0) < MAX_PIECES; - const stepDirections = [ - [1, 0], [-1, 0], [0, 1], [0, -1], - [1, 1], [1, -1], [-1, 1], [-1, -1], - ]; - + // 盤面全体を走査し、合法手が1つ見つかった時点で打ち切る for (let row = 0; row < BOARD_SIZE; row += 1) { for (let col = 0; col < BOARD_SIZE; col += 1) { const cell = board[row][col]; @@ -214,7 +299,8 @@ function hasLegalAction(state, color) { // 自分の駒の隣に空きがあれば「動かす」が可能 if (cell === color) { - for (const [dr, dc] of stepDirections) { + // 隣接8方向のうち1つでも空いていれば動かせる + for (const [dr, dc] of ALL_DIRECTIONS) { const nextRow = row + dr; const nextCol = col + dc; if (inBounds(nextRow, nextCol) && board[nextRow][nextCol] === null) { @@ -290,6 +376,7 @@ function isValidDiagonalSlide(board, color, from, to) { if (i < distance && board[row][col] !== null) { return false; } + } return true; @@ -306,22 +393,10 @@ function isValidDiagonalSlide(board, color, from, to) { */ function flipSandwiched(board, color, origin) { const opponent = getOpponent(color); - - // 8方向をチェック - const directions = [ - [1, 0], // 下 - [-1, 0], // 上 - [0, 1], // 右 - [0, -1], // 左 - [1, 1], // 右下 - [1, -1], // 左下 - [-1, 1], // 右上 - [-1, -1], // 左上 - ]; - const flipped = []; - for (const [dr, dc] of directions) { + // 8方向それぞれについて、相手の駒を挟んでいるかを調べる + for (const [dr, dc] of ALL_DIRECTIONS) { const candidates = []; let row = origin.row + dr; let col = origin.col + dc; @@ -335,7 +410,7 @@ function flipSandwiched(board, color, origin) { // 相手の駒の後に自分の駒があれば挟んでいる if (candidates.length > 0 && inBounds(row, col) && board[row][col] === color) { - // 挟まれた駒を反転 + // 挟まれた駒をすべて自分の色に変える for (const [r, c] of candidates) { board[r][c] = color; flipped.push([r, c]); @@ -346,59 +421,6 @@ function flipSandwiched(board, color, origin) { return flipped; } -/** - * 指定色の最長ライン(連続した駒の数)を取得します。 - * 縦・横・斜めの4方向で最も長い連続を探します。 - * @param {Array>} board - 盤面 - * @param {'black'|'white'} color - チェックする色 - * @returns {number} 最長ラインの長さ - */ -function getMaxLine(board, color) { - // 縦・横・斜め(右下、左下)の4方向 - const directions = [ - [1, 0], // 縦 - [0, 1], // 横 - [1, 1], // 右下斜め - [-1, 1], // 左下斜め - ]; - - let maxLength = 0; - - for (let row = 0; row < BOARD_SIZE; row += 1) { - for (let col = 0; col < BOARD_SIZE; col += 1) { - // 指定色の駒でなければスキップ - if (board[row][col] !== color) { - continue; - } - - for (const [dr, dc] of directions) { - // ラインの先頭からのみカウント(重複防止) - const prevRow = row - dr; - const prevCol = col - dc; - if (inBounds(prevRow, prevCol) && board[prevRow][prevCol] === color) { - continue; - } - - // ラインの長さをカウント - let length = 0; - let r = row; - let c = col; - while (inBounds(r, c) && board[r][c] === color) { - length += 1; - r += dr; - c += dc; - } - - if (length > maxLength) { - maxLength = length; - } - } - } - } - - return maxLength; -} - /** * 指定したマスを通る、その色の最長ラインの長さを取得します。 * 縦・横・斜めの4方向について、そのマスを含む連続の長さを両方向に数えます。 @@ -408,35 +430,39 @@ function getMaxLine(board, color) { * @returns {number} 最長ラインの長さ */ function getMaxLineThrough(board, color, cells) { - // 縦・横・斜め(右下、左下)の4方向 - const directions = [ - [1, 0], // 縦 - [0, 1], // 横 - [1, 1], // 右下斜め - [-1, 1], // 左下斜め - ]; - let maxLength = 0; + // 変化したマスを1つずつ起点にして、そこを通るラインの長さを測る for (const [row, col] of cells) { + // 自分の色でないマスは起点にならないので読み飛ばす if (!inBounds(row, col) || board[row][col] !== color) { continue; } - for (const [dr, dc] of directions) { - // 対象マス自身を1として、両方向に伸ばす + // 縦・横・斜め2種の4方向について長さを数える + for (const [dr, dc] of LINE_DIRECTIONS) { + // 対象マス自身を1として、正方向と逆方向の両方に伸ばす let length = 1; - for (const sign of [1, -1]) { - let r = row + dr * sign; - let c = col + dc * sign; - while (inBounds(r, c) && board[r][c] === color) { - length += 1; - r += dr * sign; - c += dc * sign; - } + // 正方向へ、同じ色が続く限り数える + let r = row + dr; + let c = col + dc; + while (inBounds(r, c) && board[r][c] === color) { + length += 1; + r += dr; + c += dc; + } + + // 逆方向へも同様に数える + r = row - dr; + c = col - dc; + while (inBounds(r, c) && board[r][c] === color) { + length += 1; + r -= dr; + c -= dc; } + // これまでで最も長いラインを保持する if (length > maxLength) { maxLength = length; } @@ -448,34 +474,35 @@ function getMaxLineThrough(board, color, cells) { /** * 勝敗を評価します。 - * - 5目以上並ぶと負け(打つ・動かすのどちらでも即座に負け) + * - 5目以上並ぶと負け(打つ・動かすのどちらでも) * - 4目並ぶと勝ち。ただし「その手によって4目が成立した」場合のみ。 * 駒を打って4目並べても勝ちにはならず、盤上に既にある4目は、 * 別の駒を動かしても勝ちにはならない(そのラインを崩して組み直す必要がある)。 * - * 「その手で成立した4目」は、その手で自分の色になったマス - * (移動先+反転させたマス)を通るラインだけを見れば判定できる。 - * 駒が減った側のマスではラインは伸びないため。 + * 4目・5目とも、その手で自分の色になったマス(移動先+反転させたマス)を通る + * ラインだけを見れば足りる: + * - 4目は「その手で成立した」ものだけが勝ちなので、定義上そこしか見なくてよい + * - 5目は、直前の局面に5目が無いこと(あれば既に終局している)が前提なので、 + * 新しく5目になり得るのは色が変わったマスを通るラインだけ。駒が減った側の + * マスではラインは伸びない * * @param {Array>} board - 盤面 * @param {'black'|'white'} color - 評価するプレイヤーの色 - * @param {Array>} [createdCells=null] - その手で自分の色になったマス。 - * 勝ちが成立しない手(駒を打つ)では null を渡す。 + * @param {Array>} changedCells - その手で自分の色になったマス + * @param {boolean} canWin - 4目で勝てる手か(動かす手ならtrue、打つ手ならfalse) * @returns {Object} 評価結果 * @returns {'win'|'lose'|null} return.result - 勝敗結果 - * @returns {number} return.maxLine - 最長ラインの長さ + * @returns {number} return.maxLine - 変わったマスを通る最長ラインの長さ */ -function evaluateOutcome(board, color, createdCells = null) { - const maxLine = getMaxLine(board, color); +function evaluateOutcome(board, color, changedCells, canWin) { + const maxLine = getMaxLineThrough(board, color, changedCells); if (maxLine >= 5) { // 5目以上は負け return { result: 'lose', maxLine }; } - - // その手で色が変わったマスを通るラインが4目なら勝ち - if (createdCells && createdCells.length > 0 - && getMaxLineThrough(board, color, createdCells) >= 4) { + if (maxLine >= 4 && canWin) { + // その手で4目が成立した(勝ち) return { result: 'win', maxLine }; } @@ -492,14 +519,25 @@ function evaluateOutcome(board, color, createdCells = null) { * @param {'black'|'white'} action.color - アクションを行うプレイヤーの色 * @param {Object} [action.from] - 移動元座標(moveの場合のみ) * @param {Object} action.to - 移動先座標 + * @param {Object} [options] - 動作オプション + * @param {boolean} [options.trusted=false] - 正規化済みの状態を渡していることが + * 保証されている場合にtrue。正規化と着手時刻の生成を省いて高速化する。 + * CPU対戦の探索専用で、外部入力を扱う経路では絶対に使わないこと。 * @returns {Object} 結果オブジェクト * @returns {boolean} return.ok - 成功したかどうか * @returns {string} [return.error] - 失敗理由 * @returns {Object} [return.state] - 成功時の新しいゲーム状態 */ -function applyAction(state, action) { - // 状態を正規化してコピー - const next = normalizeState(state); +function applyAction(state, action, options) { + const trusted = Boolean(options && options.trusted); + + // 状態をコピー(信頼できない入力はここで正規化も行う) + let next; + if (trusted) { + next = cloneState(state); + } else { + next = normalizeState(state); + } const { type, color } = action; // ゲームが進行中でなければ拒否 @@ -521,6 +559,7 @@ function applyAction(state, action) { let to = null; let flipped = []; + // アクションの種類ごとに、盤面を更新する処理を分ける if (type === 'place') { // === 駒を打つ === to = action.to; @@ -582,11 +621,16 @@ function applyAction(state, action) { return { ok: false, error: 'invalid_action' }; } - // 勝敗判定。4目の勝ちは「その手で4目が成立した」ときだけなので、 - // その手で自分の色になったマス(移動先+反転したマス)を渡す。 - // 駒を打った場合は勝ちにならないため null を渡す。 - const createdCells = type === 'move' ? [[to.row, to.col], ...flipped] : null; - const outcome = evaluateOutcome(next.board, color, createdCells); + // 勝敗判定。その手で自分の色になったマス(移動先+反転したマス)を渡す。 + // 4目の勝ちは動かす手のときだけ成立する。 + const changedCells = [[to.row, to.col]]; + // 反転したマスも「その手で自分の色になったマス」として判定対象に含める + for (const cell of flipped) { + changedCells.push(cell); + } + const outcome = evaluateOutcome(next.board, color, changedCells, type === 'move'); + + // 判定結果に応じて終局処理を行うか、手番を進める if (outcome.result === 'lose') { // 5目並べてしまった(負け) next.status = 'finished'; @@ -616,14 +660,20 @@ function applyAction(state, action) { } } - // 最後の手を記録 + // 着手時刻の文字列化は探索では使わないうえ高価なので、trusted では省く。 + let at = null; + if (!trusted) { + at = new Date().toISOString(); + } + + // 最後の手を記録する next.lastMove = { type, color, from, to, flipped, - at: new Date().toISOString(), + at, }; return { ok: true, state: next }; diff --git a/worker/src/index.js b/worker/src/index.js index 7d18996..99a1ecf 100644 --- a/worker/src/index.js +++ b/worker/src/index.js @@ -57,12 +57,42 @@ function json(data, status = 200, headers = {}) { async function readJson(request) { try { const body = await request.json(); - return body && typeof body === "object" ? body : {}; + // 配列やプリミティブが送られてきた場合は空オブジェクトとして扱う + if (body && typeof body === "object") { + return body; + } + return {}; } catch { return {}; } } +/** + * リクエストボディから文字列項目を取り出します(前後の空白は除去)。 + * @param {*} value - ボディ中の値 + * @returns {string} 文字列(文字列でなければ空文字) + */ +function readTrimmed(value) { + // 文字列以外が送られてきた場合は未入力として扱う + if (typeof value !== "string") { + return ""; + } + return value.trim(); +} + +/** + * リクエストボディからパスワードを取り出します(空白も意味を持つため除去しない)。 + * @param {*} value - ボディ中の値 + * @returns {string} 文字列(文字列でなければ空文字) + */ +function readPassword(value) { + // 文字列以外が送られてきた場合は未入力として扱う + if (typeof value !== "string") { + return ""; + } + return value; +} + /** * 環境変数からセッション署名鍵を取り出します。 * @param {Object} env - 環境変数 @@ -79,7 +109,11 @@ function sessionSecret(env) { */ function pbkdf2Iterations(env) { const value = Number(env.PBKDF2_ITERATIONS); - return Number.isFinite(value) && value > 0 ? Math.floor(value) : DEFAULT_ITERATIONS; + // 環境変数が未設定・不正な場合は既定の反復回数を使う + if (Number.isFinite(value) && value > 0) { + return Math.floor(value); + } + return DEFAULT_ITERATIONS; } /** @@ -98,7 +132,11 @@ function isSecure(url) { */ function roomCount(env) { const count = Number(env.ROOM_COUNT); - return Number.isFinite(count) && count > 0 ? Math.floor(count) : 12; + // 環境変数が未設定・不正な場合は既定の12ルームにする + if (Number.isFinite(count) && count > 0) { + return Math.floor(count); + } + return 12; } /** @@ -127,7 +165,9 @@ function lobbyStub(env) { * @returns {Promise} ユーザーオブジェクト */ async function currentUser(request, env) { + // セッションCookieからユーザーIDを取り出す const userId = await getSessionUserId(request, sessionSecret(env)); + // 未ログイン、または署名が無効ならユーザーは特定できない if (userId === null) { return null; } @@ -148,34 +188,47 @@ async function currentUser(request, env) { async function register(request, env, url) { const body = await readJson(request); - const loginId = typeof body.loginId === "string" ? body.loginId.trim() : ""; - const password = typeof body.password === "string" ? body.password : ""; - const nickname = typeof body.nickname === "string" ? body.nickname.trim() : ""; + const loginId = readTrimmed(body.loginId); + const password = readPassword(body.password); + const nickname = readTrimmed(body.nickname); + // ID・パスワードは必須 if (!loginId || !password) { return json({ error: "missing_fields" }, 400); } + // IDは半角英数字のみ if (!/^[a-zA-Z0-9]+$/.test(loginId)) { return json({ error: "invalid_id" }, 400); } + // IDは3〜20文字 if (loginId.length < 3 || loginId.length > 20) { return json({ error: "invalid_id" }, 400); } + // パスワードは6文字以上 if (password.length < 6) { return json({ error: "password_too_short" }, 400); } + // ニックネームは20文字以内 if (nickname.length > 20) { return json({ error: "nickname_too_long" }, 400); } + // 同じIDが既に登録されていないか確認する if (await getUserByLoginId(env.DB, loginId)) { return json({ error: "id_exists" }, 409); } + // パスワードをハッシュ化して保存する(平文は残さない) const passwordHash = await hashPassword(password, pbkdf2Iterations(env)); - const storedNickname = nickname.length === 0 ? null : nickname; + + // 未入力のニックネームはNULLとして保存する + let storedNickname = null; + if (nickname.length > 0) { + storedNickname = nickname; + } const userId = await createUser(env.DB, loginId, passwordHash, storedNickname); + // 登録と同時にログイン状態にするためセッションCookieを発行する const cookie = await createSessionCookie(userId, sessionSecret(env), isSecure(url)); return json({ user: { id: userId, loginId, nickname: storedNickname } }, 201, { "Set-Cookie": cookie, @@ -192,18 +245,21 @@ async function register(request, env, url) { async function login(request, env, url) { const body = await readJson(request); - const loginId = typeof body.loginId === "string" ? body.loginId.trim() : ""; - const password = typeof body.password === "string" ? body.password : ""; + const loginId = readTrimmed(body.loginId); + const password = readPassword(body.password); + // ID・パスワードは必須 if (!loginId || !password) { return json({ error: "missing_fields" }, 400); } + // ユーザーの存在とパスワードの一致を確認する(どちらが違うかは区別せず返す) const user = await getUserByLoginId(env.DB, loginId); if (!user || !(await verifyPassword(password, user.password_hash))) { return json({ error: "invalid_credentials" }, 401); } + // 認証できたのでセッションCookieを発行する const cookie = await createSessionCookie(user.id, sessionSecret(env), isSecure(url)); return json( { user: { id: user.id, loginId: user.loginId, nickname: user.nickname } }, @@ -225,17 +281,21 @@ async function login(request, env, url) { */ async function updateNickname(request, env, user) { const body = await readJson(request); - const nickname = typeof body.nickname === "string" ? body.nickname.trim() : ""; + const nickname = readTrimmed(body.nickname); + // ニックネームは20文字以内 if (nickname.length > 20) { return json({ error: "nickname_too_long" }, 400); } - const updated = await updateUserNickname( - env.DB, - user.id, - nickname.length === 0 ? null : nickname - ); + // 未入力のニックネームはNULLとして保存する + let storedNickname = null; + if (nickname.length > 0) { + storedNickname = nickname; + } + + // ニックネームを更新し、更新後のユーザー情報を返す + const updated = await updateUserNickname(env.DB, user.id, storedNickname); return json({ user: updated }); } @@ -250,21 +310,26 @@ async function changePassword(request, env, user) { const body = await readJson(request); const { currentPassword, newPassword } = body; + // 現在・新規のパスワードはどちらも必須 if (!currentPassword || !newPassword) { return json({ error: "missing_fields" }, 400); } + // 新しいパスワードは6文字以上 if (typeof newPassword !== "string" || newPassword.length < 6) { return json({ error: "password_too_short" }, 400); } + // 現在のハッシュを取得する(セッションが有効でも削除済みの可能性がある) const stored = await getUserWithPasswordById(env.DB, user.id); if (!stored) { return json({ error: "unauthorized" }, 401); } + // なりすまし防止のため、現在のパスワードの一致を確認する if (!(await verifyPassword(currentPassword, stored.password_hash))) { return json({ error: "invalid_current_password" }, 400); } + // 新しいパスワードをハッシュ化して保存する const newHash = await hashPassword(newPassword, pbkdf2Iterations(env)); await updateUserPassword(env.DB, user.id, newHash); @@ -290,10 +355,12 @@ async function changePassword(request, env, user) { * @returns {Promise} レスポンス */ async function handleWebSocket(request, env, url) { + // Upgrade ヘッダが無いリクエストはWebSocketとして扱えない if (request.headers.get("Upgrade") !== "websocket") { return new Response("expected websocket", { status: 426 }); } + // Durable Object へ渡すユーザー情報をここで確定させる const user = await currentUser(request, env); if (!user) { return new Response("unauthorized", { status: 401 }); @@ -306,6 +373,7 @@ async function handleWebSocket(request, env, url) { // --- 対局ルーム --- const roomId = Number(url.searchParams.get("roomId")); + // 存在しないルーム番号への接続は受け付けない if (!Number.isInteger(roomId) || roomId < 1 || roomId > roomCount(env)) { return new Response("not_found", { status: 404 }); } @@ -315,10 +383,12 @@ async function handleWebSocket(request, env, url) { userId: String(user.id), loginId: user.loginId, }); + // ニックネーム未設定のユーザーはクエリに含めない if (user.nickname) { params.set("nickname", user.nickname); } + // 認証済みの情報を添えて RoomDO へ接続を引き渡す return roomStub(env, roomId).fetch(`https://room/ws?${params}`, request); } @@ -341,6 +411,7 @@ async function handleApi(request, env, url) { const method = request.method; // --- 認証不要 --- + // 経路ごとにメソッドとパスの組み合わせで振り分ける if (method === "POST" && path === "/api/auth/register") { return register(request, env, url); } @@ -357,6 +428,7 @@ async function handleApi(request, env, url) { return json({ error: "unauthorized" }, 401); } + // ログイン中のユーザー情報を返す if (method === "GET" && path === "/api/me") { return json({ user }); } @@ -367,14 +439,17 @@ async function handleApi(request, env, url) { return changePassword(request, env, user); } + // ルーム一覧は LobbyDO が集約しているので取り次ぐ if (method === "GET" && path === "/api/rooms") { const response = await lobbyStub(env).fetch("https://lobby/rooms"); return json(await response.json()); } + // ルーム個別の状態は該当する RoomDO のスナップショットを取り次ぐ const detail = ROOM_DETAIL.exec(path); if (method === "GET" && detail) { const roomId = Number(detail[1]); + // 存在しないルーム番号は404にする if (roomId < 1 || roomId > roomCount(env)) { return json({ error: "not_found" }, 404); } @@ -398,6 +473,7 @@ export default { const url = new URL(request.url); try { + // WebSocketとAPIだけを Worker で処理し、それ以外は静的アセットへ渡す if (url.pathname === "/ws") { return await handleWebSocket(request, env, url); } diff --git a/worker/src/lobby-do.js b/worker/src/lobby-do.js index de7fbce..f1e0fef 100644 --- a/worker/src/lobby-do.js +++ b/worker/src/lobby-do.js @@ -38,7 +38,11 @@ export class LobbyDurableObject extends DurableObject { */ roomCount() { const count = Number(this.env.ROOM_COUNT); - return Number.isFinite(count) && count > 0 ? Math.floor(count) : 12; + // 環境変数が未設定・不正な場合は既定の12ルームにする + if (Number.isFinite(count) && count > 0) { + return Math.floor(count); + } + return 12; } /** @@ -50,17 +54,21 @@ export class LobbyDurableObject extends DurableObject { const total = this.roomCount(); const result = []; + // 1番から順に、既知のサマリが無いルームは既定値で埋める for (let id = 1; id <= total; id += 1) { const known = this.rooms[id]; - result.push( - known || { + // 一度も使われていないルームは待機中・空席として扱う + if (known) { + result.push(known); + } else { + result.push({ id, name: `ルーム ${id}`, status: "waiting", seats: { black: null, white: null }, presence: 0, - } - ); + }); + } } return result; @@ -71,6 +79,7 @@ export class LobbyDurableObject extends DurableObject { */ broadcastRooms() { const message = encodeEvent("rooms:update", this.listRooms()); + // 接続中のロビークライアント全員へ同じ一覧を送る for (const ws of this.ctx.getWebSockets()) { try { ws.send(message); @@ -91,6 +100,7 @@ export class LobbyDurableObject extends DurableObject { // --- RoomDO からのサマリ更新通知 --- if (url.pathname === "/room-update") { const summary = await request.json(); + // ルームIDが取れないサマリは保存せず、配信もしない if (summary && Number.isFinite(summary.id)) { this.rooms[summary.id] = summary; await this.ctx.storage.put("rooms", this.rooms); @@ -105,6 +115,7 @@ export class LobbyDurableObject extends DurableObject { } // --- WebSocket 接続 --- + // Upgrade ヘッダが無いリクエストはWebSocketとして扱えない if (request.headers.get("Upgrade") !== "websocket") { return new Response("expected websocket", { status: 426 }); } @@ -127,15 +138,18 @@ export class LobbyDurableObject extends DurableObject { */ webSocketMessage(ws, raw) { const message = decodeMessage(raw); + // ack要求以外のメッセージはロビーでは扱わない if (!message || message.t !== "req") { return; } + // ルーム一覧の再取得要求にはその場で一覧を返す if (message.event === "rooms:list") { ws.send(JSON.stringify({ t: "res", id: message.id, payload: { ok: true, rooms: this.listRooms() } })); return; } + // 未知のイベントは、ack待ちを解放するためにエラーを返す if (message.id !== undefined && message.id !== null) { ws.send(JSON.stringify({ t: "res", id: message.id, payload: { ok: false, error: "unknown_event" } })); } diff --git a/worker/src/protocol.js b/worker/src/protocol.js index 00e3f68..36e39aa 100644 --- a/worker/src/protocol.js +++ b/worker/src/protocol.js @@ -44,12 +44,17 @@ function encodeResponse(id, payload) { * @returns {Object|null} パースできたメッセージ、失敗時はnull */ function decodeMessage(raw) { + // バイナリフレームはこのプロトコルでは使わないので受け付けない if (typeof raw !== "string") { return null; } try { const parsed = JSON.parse(raw); - return parsed && typeof parsed === "object" ? parsed : null; + // メッセージはオブジェクトである必要があるため、それ以外はnullにする + if (parsed && typeof parsed === "object") { + return parsed; + } + return null; } catch { return null; } diff --git a/worker/src/room-do.js b/worker/src/room-do.js index bc7e88c..8b9b5be 100644 --- a/worker/src/room-do.js +++ b/worker/src/room-do.js @@ -19,7 +19,7 @@ import { normalizeState, applyAction, } from "./game.js"; -import { searchRootBatch, resolveCpuLevel, positionKey } from "./ai.js"; +import { createCpuSearch, stepCpuSearch, resolveCpuLevel, positionKey } from "./ai.js"; import { encodeEvent, encodeResponse, decodeMessage, PING, PONG } from "./protocol.js"; /** CPUプレイヤーを表す擬似ユーザーID(D1には作らない) */ @@ -60,6 +60,11 @@ export class RoomDurableObject extends DurableObject { /** @type {Object} ルームの永続状態 */ this.state = null; + // CPU探索の置換表。1手ぶんのアラームをまたいで使い回すだけのキャッシュなので + // 永続化はしない(消えても探索し直せるだけで、正しさには影響しない)。 + /** @type {Map|null} */ + this.cpuTable = null; + // 起動時(ハイバネーションからの復帰を含む)に状態を復元する。 // blockConcurrencyWhile の間はイベントが配送されないため、 // ハンドラが未初期化の状態を触ることはない。 @@ -123,7 +128,12 @@ export class RoomDurableObject extends DurableObject { */ updateRoomStatus() { const { black, white } = this.state.seats; - this.state.status = black && white ? "playing" : "waiting"; + // 両席が埋まっていれば対局できる状態、片方でも空いていれば待機中 + if (black && white) { + this.state.status = "playing"; + } else { + this.state.status = "waiting"; + } return this.state.status; } @@ -137,6 +147,7 @@ export class RoomDurableObject extends DurableObject { assignSeat(color, userId, userInfo) { const seats = this.state.seats; + // 席の色として想定していない値は受け付けない if (color !== "black" && color !== "white") { return { ok: false, reason: "invalid_seat" }; } @@ -146,8 +157,11 @@ export class RoomDurableObject extends DurableObject { return { ok: false, reason: "taken" }; } - // 同じユーザーが反対側の席に座っている場合は拒否 - const otherColor = color === "black" ? "white" : "black"; + // 同じユーザーが反対側の席に座っている場合は拒否(1人で両席は取れない) + let otherColor = "black"; + if (color === "black") { + otherColor = "white"; + } if (seats[otherColor] && seats[otherColor].userId === userId) { return { ok: false, reason: "already_seated" }; } @@ -170,9 +184,11 @@ export class RoomDurableObject extends DurableObject { releaseSeat(color, userId) { const seats = this.state.seats; + // 席の色として想定していない値は受け付けない if (color !== "black" && color !== "white") { return { ok: false, reason: "invalid_seat" }; } + // 自分が座っている席以外は解放できない if (!seats[color] || seats[color].userId !== userId) { return { ok: false, reason: "not_owner" }; } @@ -192,6 +208,7 @@ export class RoomDurableObject extends DurableObject { releaseSeatsByUser(userId) { const released = []; + // 黒・白の両席を確認し、そのユーザーが座っている席をすべて空ける for (const color of ["black", "white"]) { const seat = this.state.seats[color]; if (seat && seat.userId === userId) { @@ -216,27 +233,50 @@ export class RoomDurableObject extends DurableObject { */ applyCpuReady(game) { const cpu = this.state.cpu; + // CPUが着席していなければ何も変えない if (!cpu) { return game; } + // 対局中の準備状態は結果表示に使うため書き換えない if (game.status === "playing") { return game; } - game.ready = { - black: Boolean(game.ready?.black), - white: Boolean(game.ready?.white), - [cpu.color]: true, - }; + // CPUの席は常に準備完了として扱う + const flags = this.readyFlags(game); + flags[cpu.color] = true; + game.ready = flags; return game; } + /** + * ゲーム状態から準備完了フラグを取り出します。 + * @param {Object} game - ゲーム状態 + * @returns {{black: boolean, white: boolean}} 準備完了フラグ + */ + readyFlags(game) { + // ready を持たない状態でも扱えるよう、両者未準備を既定値にする + if (!game || !game.ready) { + return { black: false, white: false }; + } + return { + black: Boolean(game.ready.black), + white: Boolean(game.ready.white), + }; + } + /** * 現在のゲーム状態を取得します(CPUの準備完了を反映済み)。 * @returns {Object} ゲーム状態 */ getRoomGame() { - const game = this.state.game ? normalizeState(this.state.game) : createWaitingState(); + // 保存された対局が無ければ待機状態から始める + let game; + if (this.state.game) { + game = normalizeState(this.state.game); + } else { + game = createWaitingState(); + } return this.applyCpuReady(game); } @@ -257,6 +297,7 @@ export class RoomDurableObject extends DurableObject { * @returns {Object|null} 開始したゲーム状態、変化なしならnull */ startGameIfReady() { + // 両席が埋まっていない間は開始できない if (this.state.status !== "playing") { return null; } @@ -268,7 +309,8 @@ export class RoomDurableObject extends DurableObject { return game; } - const ready = game.ready || { black: false, white: false }; + // 両者が準備完了していれば新しい対局を開始する + const ready = this.readyFlags(game); if (ready.black && ready.white) { return this.broadcastGame(createNewGameState()); } @@ -283,6 +325,7 @@ export class RoomDurableObject extends DurableObject { */ getPlayerColor(userId) { const seats = this.state.seats; + // 黒・白のどちらの席にそのユーザーが座っているかを調べる if (seats.black && seats.black.userId === userId) { return "black"; } @@ -298,10 +341,11 @@ export class RoomDurableObject extends DurableObject { */ getCpuSeatColor() { const seats = this.state.seats; - if (seats.black?.userId === CPU_USER_ID) { + // CPUの擬似ユーザーIDが入っている席を探す + if (seats.black && seats.black.userId === CPU_USER_ID) { return "black"; } - if (seats.white?.userId === CPU_USER_ID) { + if (seats.white && seats.white.userId === CPU_USER_ID) { return "white"; } return null; @@ -316,16 +360,17 @@ export class RoomDurableObject extends DurableObject { setReady(color, value) { const game = this.getRoomGame(); + // 対局中は準備状態を変更できない if (game.status === "playing") { return { ok: false, error: "game_in_progress" }; } - game.ready = { - black: Boolean(game.ready?.black), - white: Boolean(game.ready?.white), - [color]: Boolean(value), - }; + // 指定された席の準備状態だけを書き換える + const flags = this.readyFlags(game); + flags[color] = Boolean(value); + game.ready = flags; + // 両席が埋まっていて両者とも準備完了なら、この操作で対局を開始する if (this.state.status === "playing" && game.ready.black && game.ready.white) { const next = this.broadcastGame(createNewGameState()); return { ok: true, game: next, started: true }; @@ -348,6 +393,7 @@ export class RoomDurableObject extends DurableObject { // 残っている「人間の」プレイヤーを勝者とする // (離席した本人と、道連れで離席済みのCPUは勝者になれない) let winnerColor = null; + // 両席を確認し、離席者でもCPUでもないプレイヤーが残っていれば勝者にする for (const color of ["black", "white"]) { const seat = this.state.seats[color]; if (seat && seat.userId !== leaverUserId && seat.userId !== CPU_USER_ID) { @@ -356,7 +402,9 @@ export class RoomDurableObject extends DurableObject { } const game = this.getRoomGame(); + // 対局中に離脱された場合のみ、対局の後始末をする if (game.status === "playing") { + // 勝者が決まっていれば不戦勝、決まっていなければ対局を破棄する if (winnerColor) { game.status = "finished"; game.winner = winnerColor; @@ -383,6 +431,7 @@ export class RoomDurableObject extends DurableObject { */ releaseUserSeats(userId) { const released = this.releaseSeatsByUser(userId); + // 座っていなかった(観戦者だった)場合は何もしない if (released.length === 0) { return false; } @@ -398,11 +447,13 @@ export class RoomDurableObject extends DurableObject { const wasPlaying = released.some((seat) => seat.statusBefore === "playing"); let game = this.getRoomGame(); + // 対局が始まっていなかった場合は準備状態だけ初期化する if (!wasPlaying && game.status !== "playing") { game.ready = { black: false, white: false }; this.state.game = game; } + // 対局中の離脱は不戦敗として処理する if (wasPlaying) { this.handleForfeit(userId); } @@ -421,6 +472,7 @@ export class RoomDurableObject extends DurableObject { */ scheduleCpuTurn() { const cpu = this.state.cpu; + // CPUが着席していなければ思考の予定を消す if (!cpu) { this.state.cpuMoveAt = null; this.state.cpuSearch = null; @@ -428,6 +480,7 @@ export class RoomDurableObject extends DurableObject { } const game = this.getRoomGame(); + // CPUの手番でなければ思考の予定を消す if (game.status !== "playing" || game.turn !== cpu.color) { this.state.cpuMoveAt = null; this.state.cpuSearch = null; @@ -448,11 +501,13 @@ export class RoomDurableObject extends DurableObject { */ runCpuTurn() { const cpu = this.state.cpu; + // CPUが着席していなければ指す手は無い if (!cpu) { return; } const game = this.getRoomGame(); + // CPUの手番でなくなっていたら思考をやめる if (game.status !== "playing" || game.turn !== cpu.color) { this.state.cpuMoveAt = null; this.state.cpuSearch = null; @@ -463,46 +518,48 @@ export class RoomDurableObject extends DurableObject { const signature = positionKey(game); let search = this.state.cpuSearch; if (!search || search.signature !== signature) { - search = { signature, index: 0, ticks: 0, bestScore: null, bestAction: null }; + search = createCpuSearch(signature); + this.cpuTable = new Map(); + } + if (!this.cpuTable) { + // ハイバネーションから復帰した直後など。置換表だけ作り直せばよい + this.cpuTable = new Map(); } - const batch = searchRootBatch(game, cpu.color, { - depth: cpu.depth, - startIndex: search.index, + const step = stepCpuSearch(game, cpu.color, search, { + maxDepth: cpu.depth, nodeBudget: cpu.nodeBudget, - // -Infinity は保存に向かないので null で持ち回す - bestScore: search.bestScore === null ? -Infinity : search.bestScore, - bestAction: search.bestAction, + table: this.cpuTable, }); - search.index = batch.nextIndex; - search.ticks += 1; - search.bestScore = Number.isFinite(batch.bestScore) ? batch.bestScore : null; - search.bestAction = batch.bestAction; - // まだ読み残しがあり、回数の上限にも達していないなら続きを次のアラームで読む。 // 1回あたりのCPU時間は nodeBudget で頭打ちになっている。 - const finished = batch.done || search.ticks >= cpu.maxTicks; - if (!finished && batch.bestAction) { + const finished = step.done || search.ticks >= cpu.maxTicks; + if (!finished && step.action) { this.state.cpuSearch = search; this.state.cpuMoveAt = Date.now() + CPU_TICK_MS; return; } this.state.cpuSearch = null; + this.cpuTable = null; - const action = batch.bestAction; + const action = step.action; + // 指す手が見つからなかった場合は何もしない if (!action) { this.state.cpuMoveAt = null; return; } + // 探索で選ばれた手を実際の対局へ反映する const result = applyAction(game, action); + // ルール上成立しない手だった場合は着手しない if (!result.ok) { this.state.cpuMoveAt = null; return; } + // 終局したら次の対局に備えて準備状態を戻す if (result.state.status === "finished") { result.state.ready = { black: false, white: false }; } @@ -548,6 +605,7 @@ export class RoomDurableObject extends DurableObject { */ cleanupExpiredChat() { const expiresAt = this.state.chatExpiresAt; + // 期限が未設定、またはまだ来ていなければ削除しない if (!expiresAt || expiresAt > Date.now()) { return false; } @@ -569,6 +627,7 @@ export class RoomDurableObject extends DurableObject { */ broadcast(event, payload) { const message = encodeEvent(event, payload); + // このルームに繋がっている全ソケットへ同じメッセージを送る for (const ws of this.ctx.getWebSockets()) { try { ws.send(message); @@ -590,10 +649,12 @@ export class RoomDurableObject extends DurableObject { */ presence(exclude = null) { const sockets = this.ctx.getWebSockets(); + // 除外指定が無ければ接続数をそのまま返せる if (!exclude) { return sockets.length; } let count = 0; + // 除外対象のソケットだけを数えずに集計する for (const ws of sockets) { if (ws !== exclude) { count += 1; @@ -609,6 +670,7 @@ export class RoomDurableObject extends DurableObject { * @returns {boolean} 他に接続があればtrue */ hasOtherSocket(userId, exclude) { + // 判定対象以外の接続を走査し、同じユーザーのものがあるか調べる for (const ws of this.ctx.getWebSockets()) { if (ws === exclude) { continue; @@ -630,12 +692,14 @@ export class RoomDurableObject extends DurableObject { (value) => typeof value === "number" && value > 0 ); + // 予定が1つも無ければアラームは不要 if (candidates.length === 0) { return; } const next = Math.min(...candidates); const current = await this.ctx.storage.getAlarm(); + // 既存の予定より早い場合だけ仕掛け直す if (current === null || current > next) { await this.ctx.storage.setAlarm(next); } @@ -648,6 +712,7 @@ export class RoomDurableObject extends DurableObject { async alarm() { const now = Date.now(); + // CPUの着手予定時刻を過ぎていれば1手ぶん思考を進める if (this.state.cpuMoveAt && this.state.cpuMoveAt <= now) { this.state.cpuMoveAt = null; this.runCpuTurn(); @@ -665,6 +730,7 @@ export class RoomDurableObject extends DurableObject { * @returns {Promise} */ async notifyLobby(exclude = null) { + // ルームIDが未確定の間は通知する内容が無い if (this.state.roomId === null) { return; } @@ -703,6 +769,7 @@ export class RoomDurableObject extends DurableObject { // ルームIDと名前は初回アクセス時に確定させる const roomId = Number(url.searchParams.get("roomId")); + // 未設定、または別のIDで来た初回だけルーム情報を確定させる if (Number.isFinite(roomId) && roomId > 0 && this.state.roomId !== roomId) { this.state.roomId = roomId; this.state.name = `ルーム ${roomId}`; @@ -720,6 +787,7 @@ export class RoomDurableObject extends DurableObject { } // --- WebSocket 接続 --- + // Upgrade ヘッダが無いリクエストはWebSocketとして扱えない if (request.headers.get("Upgrade") !== "websocket") { return new Response("expected websocket", { status: 426 }); } @@ -753,16 +821,19 @@ export class RoomDurableObject extends DurableObject { */ async webSocketMessage(ws, raw) { const message = decodeMessage(raw); + // ack要求以外のメッセージはこのルームでは扱わない if (!message || message.t !== "req") { return; } const user = ws.deserializeAttachment(); + // 接続時に紐づけたユーザー情報が無い接続は処理できない if (!user) { return; } const respond = (payload) => { + // ack不要(IDなし)の要求には応答を返さない if (message.id !== undefined && message.id !== null) { try { ws.send(encodeResponse(message.id, payload)); @@ -773,6 +844,7 @@ export class RoomDurableObject extends DurableObject { }; try { + // イベントごとの処理を実行する await this.handleEvent(ws, user, message.event, message.payload || {}, respond); } catch (error) { console.error("room event error:", message.event, error); @@ -799,6 +871,7 @@ export class RoomDurableObject extends DurableObject { const userId = user.userId; const userInfo = { loginId: user.loginId, nickname: user.nickname }; + // イベント名ごとに処理を振り分ける switch (event) { // --------------------------------------------------------------------- case "room:join": { @@ -825,25 +898,25 @@ export class RoomDurableObject extends DurableObject { // --------------------------------------------------------------------- case "seat:take": { const color = payload.color; + // 席の色として想定していない値は受け付けない if (color !== "black" && color !== "white") { respond({ ok: false, error: "invalid_request" }); return; } const result = this.assignSeat(color, userId, userInfo); + // 着席できなかった場合は理由をそのまま返す if (!result.ok) { respond({ ok: false, error: result.reason }); return; } - // 着席時はその席の準備状態をリセット + // 着席時はその席の準備状態をリセット(対局中は書き換えない) const game = this.getRoomGame(); if (game.status !== "playing") { - game.ready = { - black: Boolean(game.ready?.black), - white: Boolean(game.ready?.white), - [color]: false, - }; + const flags = this.readyFlags(game); + flags[color] = false; + game.ready = flags; this.state.game = game; } @@ -856,12 +929,14 @@ export class RoomDurableObject extends DurableObject { // --------------------------------------------------------------------- case "seat:leave": { const color = payload.color; + // 席の色として想定していない値は受け付けない if (color !== "black" && color !== "white") { respond({ ok: false, error: "invalid_request" }); return; } const result = this.releaseSeat(color, userId); + // 離席できなかった場合は理由をそのまま返す if (!result.ok) { respond({ ok: false, error: result.reason }); return; @@ -876,11 +951,13 @@ export class RoomDurableObject extends DurableObject { } let game = this.getRoomGame(); + // 対局が始まっていなかった場合は準備状態だけ初期化する if (result.statusBefore !== "playing" && game.status !== "playing") { game.ready = { black: false, white: false }; this.state.game = game; } + // 対局中の離席は不戦敗として処理する if (result.statusBefore === "playing") { this.handleForfeit(userId); } @@ -894,6 +971,7 @@ export class RoomDurableObject extends DurableObject { // --------------------------------------------------------------------- case "cpu:configure": { const game = this.getRoomGame(); + // 対局中はCPUの設定を変更できない if (game.status === "playing") { respond({ ok: false, error: "game_in_progress" }); return; @@ -902,6 +980,7 @@ export class RoomDurableObject extends DurableObject { // --- CPU解除 --- if (!payload.enabled) { const cpuColor = this.getCpuSeatColor(); + // CPUが座っていればその席を空ける if (cpuColor) { this.releaseSeat(cpuColor, CPU_USER_ID); } @@ -909,12 +988,11 @@ export class RoomDurableObject extends DurableObject { this.state.cpuMoveAt = null; const next = this.getRoomGame(); + // CPUが座っていた席の準備完了も解除する if (cpuColor) { - next.ready = { - black: Boolean(next.ready?.black), - white: Boolean(next.ready?.white), - [cpuColor]: false, - }; + const flags = this.readyFlags(next); + flags[cpuColor] = false; + next.ready = flags; } const broadcasted = this.broadcastGame(next); @@ -925,12 +1003,14 @@ export class RoomDurableObject extends DurableObject { // --- CPU有効化 --- const color = payload.color; + // 席の色として想定していない値は受け付けない if (color !== "black" && color !== "white") { respond({ ok: false, error: "invalid_color" }); return; } const targetSeat = this.state.seats[color]; + // 人が座っている席にはCPUを座らせられない if (targetSeat && targetSeat.userId !== CPU_USER_ID) { respond({ ok: false, error: "seat_taken" }); return; @@ -942,25 +1022,27 @@ export class RoomDurableObject extends DurableObject { this.releaseSeat(existing, CPU_USER_ID); } + // CPUを擬似ユーザーとして着席させる const assigned = this.assignSeat(color, CPU_USER_ID, { loginId: CPU_LOGIN_ID, nickname: CPU_NICKNAME, }); + // 着席できなかった場合は席が埋まっているものとして返す if (!assigned.ok) { respond({ ok: false, error: "seat_taken" }); return; } + // 難易度と環境変数から探索設定を決めて保持する const resolved = resolveCpuLevel(payload.level, this.env); this.state.cpu = { color, ...resolved }; const next = this.getRoomGame(); + // 対局前ならCPUの席を準備完了にしておく if (next.status !== "playing") { - next.ready = { - black: Boolean(next.ready?.black), - white: Boolean(next.ready?.white), - [color]: true, - }; + const flags = this.readyFlags(next); + flags[color] = true; + next.ready = flags; } const broadcasted = this.broadcastGame(next); @@ -978,17 +1060,20 @@ export class RoomDurableObject extends DurableObject { // --------------------------------------------------------------------- case "game:ready": { const color = this.getPlayerColor(userId); + // 着席していない観戦者は準備状態を変更できない if (!color) { respond({ ok: false, error: "not_seated" }); return; } const result = this.setReady(color, Boolean(payload.ready)); + // 変更できなかった場合は理由をそのまま返す if (!result.ok) { respond({ ok: false, error: result.error }); return; } + // この操作で対局が始まった場合は、CPUの手番なら思考を仕掛ける if (result.started) { this.scheduleCpuTurn(); } @@ -1001,17 +1086,24 @@ export class RoomDurableObject extends DurableObject { case "game:place": case "game:move": { const color = this.getPlayerColor(userId); + // 着席していない観戦者は着手できない if (!color) { respond({ ok: false, error: "not_seated" }); return; } - const type = event === "game:place" ? "place" : "move"; + // イベント名から「打つ」か「動かす」かを決める + let type = "move"; + if (event === "game:place") { + type = "place"; + } const action = { type, color }; + // 種類ごとに必要な座標を検証してアクションを組み立てる if (type === "place") { const row = Number(payload.row); const col = Number(payload.col); + // 座標が整数で送られてきていなければ受け付けない if (!Number.isInteger(row) || !Number.isInteger(col)) { respond({ ok: false, error: "invalid_target" }); return; @@ -1020,6 +1112,7 @@ export class RoomDurableObject extends DurableObject { } else { const from = payload.from; const to = payload.to; + // 移動元・移動先の座標がそろっていなければ受け付けない if ( !from || !to || @@ -1035,12 +1128,15 @@ export class RoomDurableObject extends DurableObject { action.to = { row: to.row, col: to.col }; } + // 組み立てたアクションを現在の対局へ適用する const result = applyAction(this.getRoomGame(), action); + // ルール違反の着手は理由を添えて拒否する if (!result.ok) { respond({ ok: false, error: result.error }); return; } + // 終局したら次の対局に備えて準備状態を戻す if (result.state.status === "finished") { result.state.ready = { black: false, white: false }; } @@ -1054,21 +1150,25 @@ export class RoomDurableObject extends DurableObject { // --------------------------------------------------------------------- case "chat:send": { const raw = payload.message; + // 文字列以外の本文は受け付けない if (typeof raw !== "string") { respond({ ok: false, error: "invalid_request" }); return; } const trimmed = raw.trim(); + // 空白だけの発言は送信させない if (!trimmed) { respond({ ok: false, error: "empty" }); return; } + // 長すぎる発言は拒否する if (trimmed.length > CHAT_MAX_LENGTH) { respond({ ok: false, error: "too_long" }); return; } + // 履歴に追加し、ルーム内の全員へ配信する const entry = this.addChatMessage(userId, trimmed, userInfo); this.broadcast("chat:new", entry); respond({ ok: true }); diff --git a/worker/wrangler.jsonc b/worker/wrangler.jsonc index ca86169..63d1ef7 100644 --- a/worker/wrangler.jsonc +++ b/worker/wrangler.jsonc @@ -61,10 +61,14 @@ // 時間による打ち切りは使えない。Workers の Date.now() は「最後のI/Oの // 時刻」を返し、同期処理の途中では進まないため(サイドチャネル対策)。 // - // 実測値(Apple Silicon): 深さ3 / 1000ノードで 1ティック 平均2.3ms・最大2.9ms。 - // 有料プランに切り替えたら CPU_MAX_TICKS を外して深さを上げられる。 - "CPU_MAX_DEPTH": "3", - "CPU_NODE_BUDGET": "1000", + // CPU_MAX_DEPTH は反復深化の深さの上限。届かなければ浅い結果を使うだけなので、 + // 上げても弱くはならない。 + // + // 実測値(Apple Silicon / 実戦局面): 深さ上限5 / 1800ノード / 4ティックで + // 1ティック 平均1.2ms・p95 3.0ms・p99 3.3ms・最大4.5ms。 + // 有料プランに切り替えたら CPU_MAX_TICKS を増やして総量を伸ばせる。 + "CPU_MAX_DEPTH": "5", + "CPU_NODE_BUDGET": "1800", "CPU_MAX_TICKS": "4", // パスワードハッシュの反復回数