M9: 게임 기록 다시 보기

- 서버: 끝난 게임을 공개된 시드 + room_log 행동으로 다시 돌려 플레이어별 view 장면을 만든다
  (rooms/replay.ts, 장면은 view 차이만 전송: shared/view-patch.ts). 행동 로그의 at = 적용한 정확한 시각,
  start 로그에 stateVersion. GET /api/me/games, GET /api/replays/:gamePk(그 판 플레이어만, no-store),
  RoomView.lastGamePk. 로그가 지워졌거나 규칙 버전이 바뀌었거나 결과가 다르면 한국어 안내(410).
- 웹: 다시 보기 화면(/replay/:gamePk, 게임 Board/Side 읽기 전용, 처음/이전/재생·멈춤/다음/끝, 슬라이더),
  내 정보의 내 게임 기록 목록, 결과 화면의 [처음부터 다시 보기].
- 테스트: 모든 게임을 Room으로 끝까지 둔 뒤 다시 만든 상태·결과·장면이 같은지, 권한, view-patch, e2e/replay.e2e.ts.
- 문서: docs/09 §8, docs/07 §10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-06 13:20:05 +09:00
parent c27cb0a11a
commit c84c1ba95e
19 changed files with 1612 additions and 12 deletions

View File

@@ -1,9 +1,10 @@
/** 계정 페이지 (docs/05 §7). */
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { catalogById, nicknameError, normalizeNickname } from '@bg/shared/lite';
import { Link, useNavigate } from 'react-router';
import { catalogById, nicknameError, normalizeNickname, type MyGameSummary } from '@bg/shared/lite';
import { Avatar, ConfirmSheet, TopBar } from '../components/ui';
import { NicknameForm } from '../components/NicknameForm';
import { gameName } from '../components/GameNotice';
import { api, discordLoginUrl } from '../net/api';
import { useSession } from '../store/session';
import { toast } from '../store/toast';
@@ -152,6 +153,8 @@ export default function MePage() {
)}
</section>
<MyGames />
<section className="stack">
<button className="btn btn-secondary btn-block" onClick={() => setConfirm('logout')}>
로그아웃
@@ -210,3 +213,83 @@ export default function MePage() {
</>
);
}
function resultLabel(g: MyGameSummary): string {
if (g.endReason === 'void') return '무효';
if (g.myRank === null) return '끝남';
if (g.players.length === 1) return '혼자 했어요';
if (g.players.every((p) => p.rank === 1)) return '무승부';
if (g.players.length === 2) return g.myRank === 1 ? '이겼어요' : '졌어요';
return `${g.myRank}등`;
}
/** 내 게임 기록: finished games, newest first, with a 다시 보기 button (docs/09 §8). */
function MyGames() {
const [games, setGames] = useState<MyGameSummary[] | null>(null);
const [next, setNext] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const load = (before: string | null) => {
setLoading(true);
api
.myGames(before)
.then((r) => {
setGames((prev) => [...(before ? (prev ?? []) : []), ...r.games]);
setNext(r.next);
})
.catch((e) => toast((e as Error).message, 'error'))
.finally(() => setLoading(false));
};
useEffect(() => load(null), []);
return (
<section className="card stack" aria-labelledby="my-games-title">
<h2 id="my-games-title">내 게임 기록</h2>
{games === null ? (
<p className="muted">불러오는 중…</p>
) : games.length === 0 ? (
<p className="muted">아직 다시 볼 게임이 없어요.</p>
) : (
<ul className="my-games">
{games.map((g) => {
const label = resultLabel(g);
return (
<li key={g.gamePk} className="my-game">
<div className="stack" style={{ gap: 4, flex: 1, minWidth: 0 }}>
<div className="row" style={{ gap: 8 }}>
<strong>{gameName(g.gameId)}</strong>
<span className={`badge${label === '이겼어요' || label === '1등' ? ' success' : ''}`}>{label}</span>
</div>
<span className="muted small">
{new Date(g.startedAt).toLocaleString('ko-KR', { month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit' })}
</span>
<span className="small">
{g.players.map((p, i) => (
<span key={i}>
{i > 0 && ' · '}
{p.me ? <strong>나</strong> : p.nickname}
</span>
))}
</span>
</div>
{g.replayable ? (
<Link className="btn btn-soft" to={`/replay/${g.gamePk}`} aria-label={`${gameName(g.gameId)} ${label} 다시 보기`}>
▶ 다시 보기
</Link>
) : (
<span className="muted small" style={{ maxWidth: 110, textAlign: 'right' }}>
다시 볼 수 없어요
</span>
)}
</li>
);
})}
</ul>
)}
{next && (
<button className="btn btn-secondary btn-block" disabled={loading} onClick={() => load(next)}>
{loading ? '불러오는 중…' : '더 보기'}
</button>
)}
</section>
);
}