Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 17 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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枚抜いて戻せば勝ち」の脅威なので高得点)
Expand Down
62 changes: 37 additions & 25 deletions client/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ function RequireAuth({ children }) {
const { user, loading } = useAuth()
const location = useLocation()

// 認証状態の確認中はローディング表示にする
if (loading) {
return (
<div className="flex h-full items-center justify-center">
Expand All @@ -22,6 +23,7 @@ function RequireAuth({ children }) {
)
}

// 未ログインならログイン画面へ遷移させる(遷移元を state に保持する)
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />
}
Expand All @@ -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)
}
Expand All @@ -71,34 +77,40 @@ export default function App() {
[user, loading]
)

// ログイン中のみヘッダーを表示する(未ログイン時は何も描画しない)
let header = null
if (user) {
header = (
<header className="w-full shrink-0 border-b bg-card">
<div className="container mx-auto flex h-14 max-w-6xl items-center justify-between px-4 md:px-8">
<div className="flex items-center gap-2.5">
<img className="h-7 w-7 rounded-sm border" src="/icon.png" alt="ヨンモク アイコン" />
<span className="text-base font-bold tracking-tight text-secondary">ヨンモク</span>
</div>
<div className="flex items-center gap-4">
{/* 名前をそのままアカウント設定への入口にする */}
<Link
to="/settings"
className="hidden font-mono text-xs text-muted-foreground underline decoration-border underline-offset-4 hover:text-foreground hover:decoration-foreground md:inline"
>
{user.nickname || '名無しプレイヤー'}
</Link>
<Button variant="outline" size="sm" asChild>
<Link to="/settings">設定</Link>
</Button>
<Button variant="outline" size="sm" onClick={authValue.logout}>
ログアウト
</Button>
</div>
</div>
</header>
)
}

return (
<AuthContext.Provider value={authValue}>
<div className="flex h-dvh flex-col overflow-hidden font-sans">
{user ? (
<header className="w-full shrink-0 border-b bg-card">
<div className="container mx-auto flex h-14 max-w-6xl items-center justify-between px-4 md:px-8">
<div className="flex items-center gap-2.5">
<img className="h-7 w-7 rounded-sm border" src="/icon.png" alt="ヨンモク アイコン" />
<span className="text-base font-bold tracking-tight text-secondary">ヨンモク</span>
</div>
<div className="flex items-center gap-4">
{/* 名前をそのままアカウント設定への入口にする */}
<Link
to="/settings"
className="hidden font-mono text-xs text-muted-foreground underline decoration-border underline-offset-4 hover:text-foreground hover:decoration-foreground md:inline"
>
{user.nickname || '名無しプレイヤー'}
</Link>
<Button variant="outline" size="sm" asChild>
<Link to="/settings">設定</Link>
</Button>
<Button variant="outline" size="sm" onClick={authValue.logout}>
ログアウト
</Button>
</div>
</div>
</header>
) : null}
{header}
<main className="container mx-auto min-h-0 w-full max-w-6xl flex-1 overflow-auto p-4 md:px-8 md:py-5">
<Routes>
<Route path="/login" element={<LoginPage />} />
Expand Down
24 changes: 23 additions & 1 deletion client/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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',
Expand Down
79 changes: 67 additions & 12 deletions client/src/pages/LobbyPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -57,7 +62,10 @@ export default function LobbyPage() {
* @returns {string} 表示名
*/
const displayName = (seat) => {
if (!seat) return '空席'
// 座席情報が無い場合は空席として表示する
if (!seat) {
return '空席'
}
return seat.nickname || '名無しプレイヤー'
}

Expand All @@ -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)
}
Expand Down Expand Up @@ -107,6 +118,18 @@ export default function LobbyPage() {
// レンダリング
// -------------------------------------------------------------------------

// 読み込み中のみ待機メッセージを表示する
let loadingView = null
if (loading) {
loadingView = <div className="py-16 text-center text-sm text-muted-foreground">ルーム読み込み中...</div>
}

// エラーが発生している場合のみ警告を表示する
let errorView = null
if (error) {
errorView = <div className="rounded-sm border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm font-medium text-destructive">{error}</div>
}

return (
<div className="flex-1 space-y-10 pb-4">
{/* ===== ページヘッダー =====
Expand All @@ -124,18 +147,50 @@ export default function LobbyPage() {
</div>

{/* ===== ローディング/エラー表示 ===== */}
{loading && <div className="py-16 text-center text-sm text-muted-foreground">ルーム読み込み中...</div>}
{error && <div className="rounded-sm border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm font-medium text-destructive">{error}</div>}
{loadingView}
{errorView}

{/* ===== ルーム一覧グリッド =====
カード全体が1つのボタン。上端の色帯で状態を示す:
シアン = 待機中(入れる) / ピンク = 対局中。
12枚並ぶので、色は「帯・状態文字・入室チップ」の3点だけに使う。 */}
<div className="grid gap-px overflow-hidden rounded-sm border bg-border sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{/* ルームごとにカードを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 = <span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-secondary align-middle" />
}

// 黒番の席は着席済みなら駒の色で塗り、空席なら輪郭だけにする
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 (
<button
key={room.id}
Expand All @@ -144,16 +199,16 @@ export default function LobbyPage() {
className="group flex flex-col bg-card text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50"
>
{/* 状態を示す色帯 */}
<div className={cn("h-[3px] w-full", playing ? "bg-secondary" : "bg-primary")} />
<div className={cn("h-[3px] w-full", statusBarClass)} />

<div className="flex flex-1 flex-col p-4">
<div className="flex items-center justify-between gap-2">
<h3 className="truncate text-sm font-bold tracking-tight">{room.name}</h3>
<span className={cn(
"shrink-0 text-[11px] font-semibold",
playing ? "text-secondary" : "text-primary"
statusTextClass
)}>
{playing && <span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-secondary align-middle" />}
{playingDot}
{statusLabel(room.status)}
</span>
</div>
Expand All @@ -164,21 +219,21 @@ export default function LobbyPage() {
<dt className="shrink-0">
<span className={cn(
"block h-4 w-4 rounded-full border",
black ? "border-gray-800 bg-gray-900" : "border-muted-foreground/25 bg-transparent"
blackStoneClass
)} />
</dt>
<dd className={cn("min-w-0 flex-1 truncate font-mono", black ? "text-foreground" : "text-muted-foreground/70")}>
<dd className={cn("min-w-0 flex-1 truncate font-mono", blackNameClass)}>
{displayName(black)}
</dd>
</div>
<div className="flex items-center gap-2">
<dt className="shrink-0">
<span className={cn(
"block h-4 w-4 rounded-full border",
white ? "border-gray-400 bg-white" : "border-muted-foreground/25 bg-transparent"
whiteStoneClass
)} />
</dt>
<dd className={cn("min-w-0 flex-1 truncate font-mono", white ? "text-foreground" : "text-muted-foreground/70")}>
<dd className={cn("min-w-0 flex-1 truncate font-mono", whiteNameClass)}>
{displayName(white)}
</dd>
</div>
Expand Down
Loading