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 @@
/** Shared UI primitives (docs/07 §4). */ /** Shared UI primitives (docs/07 §4). */
import { useEffect, useRef, useState, type ReactNode } from 'react'; import { useEffect, useRef, useState, type ReactNode } from 'react';
import type { PublicUser } from '@bg/shared/lite'; import type { PublicUser } from '@bg/shared/lite';
import { formatRoomCode } from '@bg/shared/lite'; import { formatRoomCode, isBotId } from '@bg/shared/lite';
import { useToasts, toast } from '../store/toast'; import { useToasts, toast } from '../store/toast';
import { useRoom } from '../net/socket'; import { useRoom } from '../net/socket';
import { haptic } from '../lib/feedback'; import { haptic } from '../lib/feedback';
@@ -14,7 +14,7 @@ export function Avatar({ user, size = 40 }: { user: Pick<PublicUser, 'id' | 'nic
const bg = AVATAR_COLORS[h % AVATAR_COLORS.length]; const bg = AVATAR_COLORS[h % AVATAR_COLORS.length];
return ( return (
<span className="avatar" style={{ width: size, height: size, background: bg, fontSize: size * 0.45 }} aria-hidden> <span className="avatar" style={{ width: size, height: size, background: bg, fontSize: size * 0.45 }} aria-hidden>
{user.avatar ? <img src={user.avatar} alt="" loading="lazy" referrerPolicy="no-referrer" /> : [...user.nickname][0]} {user.avatar ? <img src={user.avatar} alt="" loading="lazy" referrerPolicy="no-referrer" /> : isBotId(user.id) ? '🤖' : [...user.nickname][0]}
</span> </span>
); );
} }

View File

@@ -67,6 +67,8 @@ export const api = {
deleteMe: () => call<{ ok: true }>('DELETE', '/api/me', { confirm: '탈퇴' }), deleteMe: () => call<{ ok: true }>('DELETE', '/api/me', { confirm: '탈퇴' }),
stats: () => call<{ stats: { gameId: string; played: number; wins: number; draws: number }[] }>('GET', '/api/me/stats'), stats: () => call<{ stats: { gameId: string; played: number; wins: number; draws: number }[] }>('GET', '/api/me/stats'),
createRoom: (gameId: string, visibility?: 'private' | 'public') => call<{ code: string }>('POST', '/api/rooms', { gameId, visibility }), createRoom: (gameId: string, visibility?: 'private' | 'public') => call<{ code: string }>('POST', '/api/rooms', { gameId, visibility }),
/** 컴퓨터와 연습: a private room with enough computer players to start (docs/06 §12). */
practice: (gameId: string) => call<{ code: string }>('POST', '/api/rooms', { gameId, practice: true }),
quickPlay: (gameId: string) => call<{ code: string }>('POST', `/api/quick/${gameId}`, {}), quickPlay: (gameId: string) => call<{ code: string }>('POST', `/api/quick/${gameId}`, {}),
room: (code: string) => call<RoomPreview>('GET', `/api/rooms/${code}`), room: (code: string) => call<RoomPreview>('GET', `/api/rooms/${code}`),
allPublicRooms: () => call<{ rooms: { code: string; gameId: string; host: string; seated: number; max: number }[] }>('GET', '/api/public-rooms'), allPublicRooms: () => call<{ rooms: { code: string; gameId: string; host: string; seated: number; max: number }[] }>('GET', '/api/public-rooms'),

View File

@@ -1,7 +1,7 @@
/** 게임 소개 + 방 만들기 / 빠른 시작 / 규칙 / 공개 방 목록 (docs/06 §2, §7, §10). */ /** 게임 소개 + 방 만들기 / 빠른 시작 / 규칙 / 공개 방 목록 (docs/06 §2, §7, §10). */
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router'; import { Link, useNavigate, useParams } from 'react-router';
import { catalogById, formatRoomCode } from '@bg/shared/lite'; import { botsAllowed, catalogById, formatRoomCode } from '@bg/shared/lite';
import { Sheet, TopBar } from '../components/ui'; import { Sheet, TopBar } from '../components/ui';
import { NicknameForm } from '../components/NicknameForm'; import { NicknameForm } from '../components/NicknameForm';
import { api } from '../net/api'; import { api } from '../net/api';
@@ -64,6 +64,11 @@ export default function GamePage() {
<button className="btn btn-secondary btn-block" disabled={busy} onClick={() => run(() => api.quickPlay(g.id))}> <button className="btn btn-secondary btn-block" disabled={busy} onClick={() => run(() => api.quickPlay(g.id))}>
⚡ 아무나와 빠른 시작 ⚡ 아무나와 빠른 시작
</button> </button>
{botsAllowed(g.id) && (
<button className="btn btn-secondary btn-block" disabled={busy} onClick={() => run(() => api.practice(g.id))}>
🤖 컴퓨터와 연습하기
</button>
)}
<Link className="btn btn-ghost btn-block" to={`/rules/${g.id}`}> <Link className="btn btn-ghost btn-block" to={`/rules/${g.id}`}>
📖 규칙 보기 📖 규칙 보기
</Link> </Link>

View File

@@ -1,7 +1,7 @@
/** 홈 (PC 기준, docs/07-ui-ux.md §3.3): 큰 행동 줄 + 게임 격자 + 열린 공개 방. */ /** 홈 (PC 기준, docs/07-ui-ux.md §3.3): 큰 행동 줄 + 게임 격자 + 열린 공개 방. */
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router'; import { Link, useNavigate } from 'react-router';
import { CATALOG, formatRoomCode } from '@bg/shared/lite'; import { CATALOG, botsAllowed, formatRoomCode } from '@bg/shared/lite';
import { Sheet } from '../components/ui'; import { Sheet } from '../components/ui';
import { NicknameForm } from '../components/NicknameForm'; import { NicknameForm } from '../components/NicknameForm';
import { CodeInput } from '../components/CodeInput'; import { CodeInput } from '../components/CodeInput';
@@ -16,12 +16,13 @@ export function Home() {
const { me, activeRoom } = useSession(); const { me, activeRoom } = useSession();
const catalog = useSession((s) => (s.catalog.length ? s.catalog : CATALOG)); const catalog = useSession((s) => (s.catalog.length ? s.catalog : CATALOG));
const nav = useNavigate(); const nav = useNavigate();
const [picker, setPicker] = useState(false); const [picker, setPicker] = useState<null | 'room' | 'practice'>(null);
const [needName, setNeedName] = useState<null | (() => void)>(null); const [needName, setNeedName] = useState<null | (() => void)>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [rooms, setRooms] = useState<{ code: string; gameId: string; host: string; seated: number; max: number }[]>([]); const [rooms, setRooms] = useState<{ code: string; gameId: string; host: string; seated: number; max: number }[]>([]);
const [codeKey, setCodeKey] = useState(0); const [codeKey, setCodeKey] = useState(0);
const available = catalog.filter((g) => g.available); const available = catalog.filter((g) => g.available);
const practiceGames = available.filter((g) => botsAllowed(g.id));
useEffect(() => { useEffect(() => {
const load = () => api.allPublicRooms().then((r) => setRooms(r.rooms), () => {}); const load = () => api.allPublicRooms().then((r) => setRooms(r.rooms), () => {});
@@ -31,11 +32,11 @@ export function Home() {
}, []); }, []);
const withName = (fn: () => void) => (me ? fn() : setNeedName(() => fn)); const withName = (fn: () => void) => (me ? fn() : setNeedName(() => fn));
const create = (gameId: string) => const create = (gameId: string, practice = false) =>
withName(async () => { withName(async () => {
setBusy(true); setBusy(true);
try { try {
const { code } = await api.createRoom(gameId); const { code } = await (practice ? api.practice(gameId) : api.createRoom(gameId));
nav(`/r/${code}`); nav(`/r/${code}`);
} catch (e) { } catch (e) {
toast((e as Error).message, 'error'); toast((e as Error).message, 'error');
@@ -70,7 +71,7 @@ export function Home() {
className="btn btn-primary btn-block" className="btn btn-primary btn-block"
style={{ minHeight: 72, fontSize: '1.3rem' }} style={{ minHeight: 72, fontSize: '1.3rem' }}
disabled={busy} disabled={busy}
onClick={() => (available.length === 1 ? create(available[0]!.id) : setPicker(true))} onClick={() => (available.length === 1 ? create(available[0]!.id) : setPicker('room'))}
> >
+ 방 만들기 + 방 만들기
</button> </button>
@@ -80,6 +81,15 @@ export function Home() {
<p className="muted">받은 숫자 6자리를 넣으면 바로 들어가요.</p> <p className="muted">받은 숫자 6자리를 넣으면 바로 들어가요.</p>
<CodeInput key={codeKey} onComplete={joinCode} autoFocus={false} /> <CodeInput key={codeKey} onComplete={joinCode} autoFocus={false} />
</div> </div>
{practiceGames.length > 0 && (
<div className="card stack" style={{ padding: 24, justifyContent: 'center' }}>
<h2>혼자 연습하기</h2>
<p className="muted">컴퓨터와 바로 한 판 해 봐요. 기록에는 남지 않아요.</p>
<button className="btn btn-secondary btn-block" style={{ minHeight: 60 }} disabled={busy} onClick={() => setPicker('practice')}>
🤖 컴퓨터와 연습하기
</button>
</div>
)}
</section> </section>
<div className="two-col" style={{ gridTemplateColumns: undefined }}> <div className="two-col" style={{ gridTemplateColumns: undefined }}>
@@ -111,10 +121,10 @@ export function Home() {
</div> </div>
{picker && ( {picker && (
<Sheet onClose={() => setPicker(false)} label="게임 고르기"> <Sheet onClose={() => setPicker(null)} label="게임 고르기">
<h2>어떤 게임을 할까요?</h2> <h2>{picker === 'practice' ? '컴퓨터와 어떤 게임을 할까요?' : '어떤 게임을 할까요?'}</h2>
{available.map((g) => ( {(picker === 'practice' ? practiceGames : available).map((g) => (
<button key={g.id} className="btn btn-secondary btn-block" onClick={() => (setPicker(false), create(g.id))}> <button key={g.id} className="btn btn-secondary btn-block" onClick={() => (setPicker(null), create(g.id, picker === 'practice'))}>
{GAME_ICON[g.id]} {g.nameKo} {GAME_ICON[g.id]} {g.nameKo}
</button> </button>
))} ))}

View File

@@ -1,7 +1,7 @@
/** 방: 대기실 ↔ 게임 ↔ 결과를 한 주소에서 전환 (docs/06 §5–§9). */ /** 방: 대기실 ↔ 게임 ↔ 결과를 한 주소에서 전환 (docs/06 §5–§9). */
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router'; 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 { Avatar, CodeLabel, ConfirmSheet, CopyButton, ShareButton, Sheet, TopBar } from '../components/ui';
import { NicknameForm } from '../components/NicknameForm'; import { NicknameForm } from '../components/NicknameForm';
import { GameNotice, gameName } from '../components/GameNotice'; 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 shareText = `${host}님이 ${gName} 방에 초대했어요! 코드 ${formatRoomCode(room.code)}`;
const notReady = room.seats.filter((s) => s && !s.ready && s.user.id !== room.hostId).length; const notReady = room.seats.filter((s) => s && !s.ready && s.user.id !== room.hostId).length;
const [askStart, setAskStart] = useState(false); 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 wide = useWide();
const invite = ( const invite = (
@@ -293,7 +298,22 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
</h2> </h2>
<div style={{ display: 'grid', gridTemplateColumns: wide && room.seats.length > 4 ? '1fr 1fr' : '1fr', gap: 10 }}> <div style={{ display: 'grid', gridTemplateColumns: wide && room.seats.length > 4 ? '1fr 1fr' : '1fr', gap: 10 }}>
{room.seats.map((s, i) => {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' }}> <div key={i} className="card row" style={{ gap: 12, flexWrap: 'nowrap' }}>
<Avatar user={s.user} size={44} /> <Avatar user={s.user} size={44} />
<div style={{ flex: 1, minWidth: 0 }}> <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} />} {isHost && s.user.id !== me.id && <HostMenu room={room} target={s.user} />}
</div> </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))}> <div key={i} className="row" style={{ gap: 8, flexWrap: 'nowrap' }}>
{mySeat < 0 ? '+ 여기 앉기' : '+ 빈자리 · 친구 초대하기'} <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))}>
</button> {mySeat < 0 ? '+ 여기 앉기' : '+ 빈자리 · 친구 초대하기'}
</button>
{canAddBot && i === firstEmpty && (
<button className="btn btn-secondary" style={{ minHeight: 76 }} onClick={() => socket.send({ t: 'addBot', seat: i })}>
🤖 컴퓨터 넣기
</button>
)}
</div>
), ),
)} )}
</div> </div>
@@ -339,6 +366,7 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
)} )}
</div> </div>
<p className="muted small">{room.visibility === 'public' ? '공개 방 (누구나 들어올 수 있어요)' : '친구만 (코드·링크로만 들어와요)'}</p> <p className="muted small">{room.visibility === 'public' ? '공개 방 (누구나 들어올 수 있어요)' : '친구만 (코드·링크로만 들어와요)'}</p>
{practice && <p className="small">🤖 연습 게임이에요. 기록에는 남지 않아요.</p>}
{room.lockedOptions.length > 0 && <p className="muted small">🔒 일부 규칙은 관리자가 정해 두어서 바꿀 수 없어요.</p>} {room.lockedOptions.length > 0 && <p className="muted small">🔒 일부 규칙은 관리자가 정해 두어서 바꿀 수 없어요.</p>}
<GameNotice gameId={room.gameId} /> <GameNotice gameId={room.gameId} />
{isHost && wide && <SettingsForm room={room} ui={ui} inline />} {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 style={{ display: 'grid', gridTemplateColumns: inline ? 'repeat(3, minmax(0, 1fr))' : '1fr', gap: 12 }}>
<div className="stack" style={{ gap: 6 }}> <div className="stack" style={{ gap: 6 }}>
<strong>방 공개</strong> <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>
<div className="stack" style={{ gap: 6 }}> <div className="stack" style={{ gap: 6 }}>
<strong>관전</strong> <strong>관전</strong>
@@ -563,8 +595,10 @@ function ResultPanel({ room, me, players, onLeave }: { room: RoomView; me: Publi
const result = useRoom((s) => s.result)!; const result = useRoom((s) => s.result)!;
const [hidden, setHidden] = useState(false); const [hidden, setHidden] = useState(false);
const played = useRef(false); const played = useRef(false);
const seated = room.seats.filter(Boolean).map((s) => s!.user.id); const seatedAll = room.seats.filter(Boolean).map((s) => s!.user.id);
const amSeated = seated.includes(me.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); const voted = room.rematchVotes.includes(me.id);
// Solo games (e.g. 요트 다이스) have one ranked player: neither a draw nor a win. // Solo games (e.g. 요트 다이스) have one ranked player: neither a draw nor a win.
const solo = result.ranking.flat().length === 1; 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="게임 결과"> <Sheet onClose={() => setHidden(true)} label="게임 결과">
<h1 style={{ textAlign: 'center' }}>{allFirst ? '무승부예요' : won ? '🎉 이겼어요!' : lost ? '아쉽게 졌어요' : '게임이 끝났어요'}</h1> <h1 style={{ textAlign: 'center' }}>{allFirst ? '무승부예요' : won ? '🎉 이겼어요!' : lost ? '아쉽게 졌어요' : '게임이 끝났어요'}</h1>
<p style={{ textAlign: 'center' }}>{result.summary}</p> <p style={{ textAlign: 'center' }}>{result.summary}</p>
{seatedAll.length > seated.length && <p className="muted small" style={{ textAlign: 'center' }}>🤖 연습 게임이라 기록에는 남지 않아요.</p>}
<div className="stack"> <div className="stack">
{result.ranking.map((group, i) => {result.ranking.map((group, i) =>
group.map((u) => ( group.map((u) => (

170
e2e/bots.e2e.ts Normal file
View File

@@ -0,0 +1,170 @@
/**
* E2E (docs/06 §12): practice against computer players from the UI.
* 1) PC: 오목 상세 → [컴퓨터와 연습하기] → 대기실(컴퓨터 1) → 시작 → 컴퓨터가 여러 번 받아 둔다.
* 2) 휴대폰: 원카드 연습 → [컴퓨터 넣기]로 2명, 하나 더 넣었다 빼기 → 시작 → 사람·컴퓨터가 여러 턴.
* Run: E2E_PORT=4534 bun e2e/bots.e2e.ts (needs apps/web/dist built)
*/
import { chromium, type Page } from 'playwright-core';
import { mkdirSync, mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { loadConfig } from '../apps/server/src/config';
import { startServer } from '../apps/server/src/server';
const PORT = Number(process.env.E2E_PORT ?? 4534);
const ORIGIN = `http://localhost:${PORT}`;
const SHOTS = process.env.E2E_SHOTS ?? join(import.meta.dir, '../test-results');
mkdirSync(SHOTS, { recursive: true });
const dbDir = mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'bg-e2e-'));
const app = startServer({
config: loadConfig({ PORT: String(PORT), DB_PATH: join(dbDir, 'app.db'), PUBLIC_ORIGIN: ORIGIN }),
log: () => {},
staticDir: join(import.meta.dir, '../apps/web/dist'),
});
const browser = await chromium.launch({ executablePath: process.env.CHROME ?? '/usr/bin/google-chrome', headless: true });
let failed = false;
const step = async (name: string, fn: () => Promise<void>) => {
const t0 = performance.now();
try {
await fn();
console.log(`✓ ${name} (${Math.round(performance.now() - t0)}ms)`);
} catch (e) {
failed = true;
console.log(`✗ ${name}: ${(e as Error).message.split('\n')[0]}`);
throw e;
}
};
const status = (p: Page) => p.locator('.turn-status').innerText().catch(() => '');
const resultShown = async (p: Page) => (await p.getByRole('dialog', { name: '게임 결과' }).count()) > 0;
async function startPractice(p: Page, gameId: string, nick: string) {
await p.goto(`${ORIGIN}/g/${gameId}`);
await p.getByRole('button', { name: '🤖 컴퓨터와 연습하기' }).click();
await p.getByLabel('내 이름(닉네임)').fill(nick);
await p.getByRole('button', { name: '이 이름으로 시작하기' }).click();
await p.waitForURL(/\/r\/\d{6}$/);
await p.getByLabel('자리').getByText('컴퓨터 1').waitFor();
await p.getByText('연습 게임이에요. 기록에는 남지 않아요.').first().waitFor();
}
// ---------------------------------------------------------------- omok (PC)
const PAD = 0.9; // board viewBox padding (coordinate labels)
async function stones(p: Page): Promise<Set<number>> {
const pts = await p.locator('svg[role="grid"] circle[r="0.46"]').evaluateAll((els) =>
els.filter((e) => e.closest('g')?.getAttribute('opacity') !== '0.55').map((e) => [Number(e.getAttribute('cx')), Number(e.getAttribute('cy'))]),
);
return new Set(pts.map(([x, y]) => y! * 15 + x!));
}
async function clickCell(p: Page, x: number, y: number) {
const box = (await p.locator('svg[role="grid"]').boundingBox())!;
const span = 14 + 2 * PAD;
const px = box.x + ((x + PAD) / span) * box.width;
const py = box.y + ((y + PAD) / span) * box.height;
await p.mouse.move(px, py);
await p.mouse.click(px, py);
}
try {
const pc = await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'ko-KR' });
const phone = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true, locale: 'ko-KR' });
const a = await pc.newPage();
const b = await phone.newPage();
const errors: string[] = [];
for (const p of [a, b]) p.on('pageerror', (e) => errors.push(e.message));
await step('오목: 상세 → 컴퓨터와 연습하기 → 대기실', async () => {
await startPractice(a, 'omok', '호랑이');
await a.getByText('🤖 컴퓨터').first().waitFor();
// The host is seated first, the computer next; no public toggle in a practice room.
await a.getByText('컴퓨터가 있는 방은 친구만 들어와요.').waitFor();
await a.screenshot({ path: join(SHOTS, 'bots-1-lobby-pc.png') });
});
await step('오목: 시작 → 컴퓨터가 5번 받아 둔다', async () => {
await a.getByRole('button', { name: '▶ 게임 시작' }).click();
await a.locator('svg[role="grid"]').waitFor();
let answers = 0;
// Cells around the centre, nearest first.
const order = Array.from({ length: 225 }, (_, i) => i).sort((i, j) => Math.hypot((i % 15) - 7, Math.floor(i / 15) - 7) - Math.hypot((j % 15) - 7, Math.floor(j / 15) - 7));
for (let turn = 0; turn < 8 && answers < 5; turn++) {
await a.waitForFunction(() => /내 차례예요|게임이 끝|이겼|졌/.test(document.querySelector('.turn-status')?.textContent ?? ''), null, { timeout: 8000 });
if (await resultShown(a)) break;
const before = await stones(a);
const cell = order.find((i) => !before.has(i) && (i % 15) % 2 === turn % 2)!;
await clickCell(a, cell % 15, Math.floor(cell / 15));
// Our stone, then the computer's answer (it thinks ~1 s).
await a.waitForFunction((n) => document.querySelectorAll('svg[role="grid"] g:not([opacity="0.55"]) > circle[r="0.46"]').length >= n + 2 || !!document.querySelector('[role="dialog"]'), before.size, { timeout: 8000 });
if (await resultShown(a)) break;
answers++;
if (answers === 2) await a.screenshot({ path: join(SHOTS, 'bots-2-omok-pc.png') });
}
if (answers < 3) throw new Error(`the computer answered only ${answers} times`);
console.log(` omok: computer answered ${answers} times`);
});
// ---------------------------------------------------------------- one-card (phone)
await step('원카드: 연습 방 → 컴퓨터 넣기·빼기 → 3명', async () => {
await startPractice(b, 'one-card', '고양이');
const seats = b.getByLabel('자리');
await seats.getByRole('button', { name: '🤖 컴퓨터 넣기' }).click();
await seats.getByText('컴퓨터 2').waitFor();
await seats.getByRole('button', { name: '🤖 컴퓨터 넣기' }).click();
await seats.getByText('컴퓨터 3').waitFor();
await seats.getByRole('button', { name: '컴퓨터 3 빼기' }).click();
await seats.getByText('컴퓨터 3').waitFor({ state: 'detached' });
await b.screenshot({ path: join(SHOTS, 'bots-3-lobby-phone.png'), fullPage: true });
});
await step('원카드: 시작 → 내 차례 4번, 컴퓨터도 둔다', async () => {
await b.getByRole('button', { name: '▶ 게임 시작' }).click();
await b.locator('.turn-status').waitFor();
let mine = 0;
let botTurns = 0;
let last = '';
for (let t = 0; t < 400 && mine < 4; t++) {
if (await resultShown(b)) break;
const one = b.locator('.oc-onebtn');
if ((await one.count()) && (await one.isEnabled().catch(() => false))) await one.click({ timeout: 1000 }).catch(() => {});
const s = await status(b);
if (s !== last && /컴퓨터 \d님 차례예요/.test(s)) botTurns++;
last = s;
if (!s.startsWith('내 차례예요')) {
const next = b.getByRole('button', { name: /다음 판/ });
if (await next.count()) await next.first().click().catch(() => {});
await b.waitForTimeout(150);
continue;
}
const accept = b.getByRole('button', { name: /^받아들이기/ });
const card = b.locator('button.oc-card[data-legal="true"]').first();
if (await accept.count()) await accept.click();
else if (await card.count()) {
await card.click();
await card.click();
const pick = b.locator('.oc-pick button').first();
if (await pick.waitFor({ timeout: 400 }).then(() => true, () => false)) await pick.click();
} else {
const pass = b.getByRole('button', { name: '넘기기' });
if (await pass.count()) await pass.click();
else await b.getByRole('button', { name: /장 먹기$/ }).first().click();
}
await b.waitForFunction((x) => document.querySelector('.turn-status')?.textContent?.trim() !== x, s.trim(), { timeout: 8000 });
mine++;
if (mine === 2) await b.screenshot({ path: join(SHOTS, 'bots-4-one-card-phone.png') });
}
if (mine < 4 && !(await resultShown(b))) throw new Error(`only ${mine} of my turns were played`);
if (botTurns < 2) throw new Error(`computers took only ${botTurns} turns`);
console.log(` one-card: my turns ${mine}, computer turns seen ${botTurns}`);
});
if (errors.length) throw new Error(`page errors: ${errors.join(' | ')}`);
} catch (e) {
failed = true;
console.error(e);
} finally {
await browser.close();
await app.stop();
}
console.log(failed ? 'E2E FAILED' : 'E2E PASSED');
process.exit(failed ? 1 : 0);