M9: 컴퓨터와 연습하기 화면과 E2E

- 게임 상세·홈 [🤖 컴퓨터와 연습하기], 대기실 [🤖 컴퓨터 넣기]·[✕], 🤖 아바타·배지,
  연습 게임 안내, 한 판 더는 사람만 세기
- e2e/bots.e2e.ts: 오목 연습(컴퓨터가 여러 번 받음), 원카드 컴퓨터 2명 여러 턴

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-06 13:32:28 +09:00
parent a3f81ded37
commit bd9272f4ce
6 changed files with 242 additions and 20 deletions

View File

@@ -1,7 +1,7 @@
/** 방: 대기실 ↔ 게임 ↔ 결과를 한 주소에서 전환 (docs/06 §5–§9). */
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router';
import { formatRoomCode, type PublicUser, type RoomView } from '@bg/shared/lite';
import { botsAllowed, formatRoomCode, isBotId, type PublicUser, type RoomView } from '@bg/shared/lite';
import { Avatar, CodeLabel, ConfirmSheet, CopyButton, ShareButton, Sheet, TopBar } from '../components/ui';
import { NicknameForm } from '../components/NicknameForm';
import { GameNotice, gameName } from '../components/GameNotice';
@@ -268,6 +268,11 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
const shareText = `${host}님이 ${gName} 방에 초대했어요! 코드 ${formatRoomCode(room.code)}`;
const notReady = room.seats.filter((s) => s && !s.ready && s.user.id !== room.hostId).length;
const [askStart, setAskStart] = useState(false);
// Computer players (docs/06 §12): the host fills empty seats; a room with them is a practice room.
const canAddBot = isHost && botsAllowed(room.gameId);
const practice = room.seats.some((s) => s && isBotId(s.user.id));
// Offered on the first empty seat only, so big rooms don't show a column of the same button.
const firstEmpty = room.seats.findIndex((s) => s === null);
const wide = useWide();
const invite = (
@@ -293,7 +298,22 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
</h2>
<div style={{ display: 'grid', gridTemplateColumns: wide && room.seats.length > 4 ? '1fr 1fr' : '1fr', gap: 10 }}>
{room.seats.map((s, i) =>
s ? (
s && isBotId(s.user.id) ? (
<div key={i} className="card row" style={{ gap: 12, flexWrap: 'nowrap' }}>
<Avatar user={s.user} size={44} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700 }}>{s.user.nickname}</div>
<div className="row small" style={{ gap: 6 }}>
<span className="badge">🤖 컴퓨터</span>
</div>
</div>
{isHost && (
<button className="btn btn-ghost" onClick={() => socket.send({ t: 'removeBot', userId: s.user.id })} aria-label={`${s.user.nickname} 빼기`}>
✕
</button>
)}
</div>
) : s ? (
<div key={i} className="card row" style={{ gap: 12, flexWrap: 'nowrap' }}>
<Avatar user={s.user} size={44} />
<div style={{ flex: 1, minWidth: 0 }}>
@@ -311,9 +331,16 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
{isHost && s.user.id !== me.id && <HostMenu room={room} target={s.user} />}
</div>
) : (
<button key={i} className="card row" style={{ border: '2px dashed var(--border)', boxShadow: 'none', cursor: 'pointer', minHeight: 76, justifyContent: 'center', color: 'var(--text-sub)' }} onClick={() => (mySeat < 0 ? socket.send({ t: 'seat', seat: i }) : setQr(true))}>
{mySeat < 0 ? '+ 여기 앉기' : '+ 빈자리 · 친구 초대하기'}
</button>
<div key={i} className="row" style={{ gap: 8, flexWrap: 'nowrap' }}>
<button className="card row" style={{ flex: 1, border: '2px dashed var(--border)', boxShadow: 'none', cursor: 'pointer', minHeight: 76, justifyContent: 'center', color: 'var(--text-sub)' }} onClick={() => (mySeat < 0 ? socket.send({ t: 'seat', seat: i }) : setQr(true))}>
{mySeat < 0 ? '+ 여기 앉기' : '+ 빈자리 · 친구 초대하기'}
</button>
{canAddBot && i === firstEmpty && (
<button className="btn btn-secondary" style={{ minHeight: 76 }} onClick={() => socket.send({ t: 'addBot', seat: i })}>
🤖 컴퓨터 넣기
</button>
)}
</div>
),
)}
</div>
@@ -339,6 +366,7 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
)}
</div>
<p className="muted small">{room.visibility === 'public' ? '공개 방 (누구나 들어올 수 있어요)' : '친구만 (코드·링크로만 들어와요)'}</p>
{practice && <p className="small">🤖 연습 게임이에요. 기록에는 남지 않아요.</p>}
{room.lockedOptions.length > 0 && <p className="muted small">🔒 일부 규칙은 관리자가 정해 두어서 바꿀 수 없어요.</p>}
<GameNotice gameId={room.gameId} />
{isHost && wide && <SettingsForm room={room} ui={ui} inline />}
@@ -478,7 +506,11 @@ function SettingsForm({ room, ui, inline, onDone }: { room: RoomView; ui: GameUI
<div style={{ display: 'grid', gridTemplateColumns: inline ? 'repeat(3, minmax(0, 1fr))' : '1fr', gap: 12 }}>
<div className="stack" style={{ gap: 6 }}>
<strong>방 공개</strong>
{seg(visibility === 'public', (v) => setVisibility(v ? 'public' : 'private'), '누구나', '친구만')}
{room.seats.some((s) => s && isBotId(s.user.id)) ? (
<p className="muted small">컴퓨터가 있는 방은 친구만 들어와요.</p>
) : (
seg(visibility === 'public', (v) => setVisibility(v ? 'public' : 'private'), '누구나', '친구만')
)}
</div>
<div className="stack" style={{ gap: 6 }}>
<strong>관전</strong>
@@ -563,8 +595,10 @@ function ResultPanel({ room, me, players, onLeave }: { room: RoomView; me: Publi
const result = useRoom((s) => s.result)!;
const [hidden, setHidden] = useState(false);
const played = useRef(false);
const seated = room.seats.filter(Boolean).map((s) => s!.user.id);
const amSeated = seated.includes(me.id);
const seatedAll = room.seats.filter(Boolean).map((s) => s!.user.id);
const amSeated = seatedAll.includes(me.id);
// Computer players always agree to a rematch.
const seated = seatedAll.filter((u) => !isBotId(u));
const voted = room.rematchVotes.includes(me.id);
// Solo games (e.g. 요트 다이스) have one ranked player: neither a draw nor a win.
const solo = result.ranking.flat().length === 1;
@@ -592,6 +626,7 @@ function ResultPanel({ room, me, players, onLeave }: { room: RoomView; me: Publi
<Sheet onClose={() => setHidden(true)} label="게임 결과">
<h1 style={{ textAlign: 'center' }}>{allFirst ? '무승부예요' : won ? '🎉 이겼어요!' : lost ? '아쉽게 졌어요' : '게임이 끝났어요'}</h1>
<p style={{ textAlign: 'center' }}>{result.summary}</p>
{seatedAll.length > seated.length && <p className="muted small" style={{ textAlign: 'center' }}>🤖 연습 게임이라 기록에는 남지 않아요.</p>}
<div className="stack">
{result.ranking.map((group, i) =>
group.map((u) => (