feat: 오픈 게임 취소와 유산 정산 경로 추가

별도 최고위험 권한으로 실행되는 원자적 취소 작업을 추가한다. 버려진 게임과 장수 기록의 보존·삭제 옵션, 오픈 원금 전액 환급 및 획득 포인트 보전율, 취소 상태와 관리자·과거기록 UI를 함께 반영한다.
This commit is contained in:
2026-08-18 13:31:49 +00:00
parent a7b11811de
commit 383d173790
36 changed files with 1967 additions and 83 deletions
+46 -4
View File
@@ -8,7 +8,7 @@ const response = (data: unknown) => ({ result: { data } });
const operationNames = (route: Route) =>
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
const installArchive = async (page: Page, options: { battleAvailable?: boolean } = {}) => {
const installArchive = async (page: Page, options: { battleAvailable?: boolean; abandoned?: boolean } = {}) => {
await page.addInitScript((profile) => {
localStorage.setItem('sammo-game-token', 'ga_archive');
localStorage.setItem('sammo-game-profile', profile);
@@ -22,14 +22,17 @@ const installArchive = async (page: Page, options: { battleAvailable?: boolean }
seasons: [
{
sourceProfile: 'che',
source: 'legacy',
source: options.abandoned ? 'current' : 'legacy',
serverId: 'che_2024_01',
openedAt: '2024-01-31T00:00:00.000Z',
date: '2024-01-31T00:00:00.000Z',
season: 51,
season: options.abandoned ? null : 51,
scenario: 2,
scenarioName: '천하쟁패',
dynastyId: 7,
status: options.abandoned ? 'ABANDONED' : 'LEGACY',
cancellationId: options.abandoned ? '12345678-full-id' : null,
cancelledAt: options.abandoned ? '2024-02-01T00:00:00.000Z' : null,
dynastyId: options.abandoned ? null : 7,
generals: [
{
generalNo: 17,
@@ -153,6 +156,45 @@ test('보존되지 않은 과거 전투 집계는 0으로 꾸미지 않고 가
await expect(page.locator('[data-general-battle-summary]')).not.toContainText('승률');
});
test('취소 게임은 정식 기수 번호와 왕조 링크 없이 별도 기록으로 표시된다', async ({ page }, testInfo) => {
await installArchive(page, { abandoned: true });
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('past-plays');
const seasonCard = page.locator('.season-card');
await expect(seasonCard.getByText('취소 게임', { exact: true })).toBeVisible();
await expect(seasonCard.getByText('취소 ID 12345678')).toBeVisible();
await expect(seasonCard).toContainText('천하쟁패');
await expect(seasonCard).not.toContainText('51기');
await expect(seasonCard.getByRole('link', { name: '이 기수 국가 정보' })).toHaveCount(0);
const desktop = await seasonCard.evaluate((element) => {
const rect = element.getBoundingClientRect();
const heading = element.querySelector('.season-heading')!.getBoundingClientRect();
return { x: rect.x, width: rect.width, headingHeight: heading.height };
});
expect(desktop).toMatchObject({ x: 100, width: 1000 });
await page.screenshot({ path: testInfo.outputPath('abandoned-past-play-desktop.png'), fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
const mobile = await seasonCard.evaluate((element) => {
const rect = element.getBoundingClientRect();
return {
x: rect.x,
width: rect.width,
viewportWidth: document.documentElement.clientWidth,
documentScrollWidth: document.documentElement.scrollWidth,
};
});
expect(mobile.x).toBeGreaterThanOrEqual(0);
expect(mobile.x + mobile.width).toBeLessThanOrEqual(mobile.viewportWidth);
expect(mobile.documentScrollWidth).toBeLessThanOrEqual(mobile.viewportWidth);
await writeFile(
testInfo.outputPath('abandoned-past-play-metrics.json'),
JSON.stringify({ desktop, mobile }, null, 2)
);
await page.screenshot({ path: testInfo.outputPath('abandoned-past-play-mobile.png'), fullPage: true });
});
test('past plays is available without a current general and preserves desktop interaction geometry', async ({
page,
}) => {
+22 -4
View File
@@ -20,6 +20,9 @@ type ArchiveSeason = Archive['seasons'][number] & {
source?: string;
openedAt?: string | null;
date?: string | null;
status?: 'OPEN' | 'COMPLETED' | 'ABANDONED' | 'LEGACY';
cancellationId?: string | null;
cancelledAt?: string | null;
};
type ArchiveGeneral = {
@@ -129,6 +132,12 @@ const formatOpenedAt = (season: ArchiveSeason): string => {
if (Number.isNaN(date.getTime())) return '개장일 미상';
return `${new Intl.DateTimeFormat('ko-KR', { dateStyle: 'medium', timeZone: 'Asia/Seoul' }).format(date)} 개장`;
};
const archiveLabel = (season: ArchiveSeason): string =>
season.status === 'ABANDONED' ? '취소 게임' : '이전 서버 기록';
const archiveIdentifier = (season: ArchiveSeason): string =>
season.status === 'ABANDONED' && season.cancellationId
? `취소 ID ${season.cancellationId.slice(0, 8)}`
: season.serverId;
const selectGeneral = async (season: ArchiveSeason, generalNo: number): Promise<void> => {
const key = detailKey(season, generalNo);
@@ -197,7 +206,7 @@ onMounted(() => {
</nav>
</header>
<p class="page-note">이전 서버에서 종료된 기수 보관된 장수 기록입니다.</p>
<p class="page-note">종료된 기수 관리자가 보존한 취소 게임의 장수 기록입니다.</p>
<p v-if="error" class="error-row">{{ error }}</p>
<p v-else-if="loading && !archive" class="empty-row">불러오는 중...</p>
<p v-else-if="archive?.seasons.length === 0" class="empty-row">보관된 지난 플레이가 없습니다.</p>
@@ -205,15 +214,19 @@ onMounted(() => {
<section v-for="season in archive?.seasons ?? []" :key="detailKey(season, 0)" class="season-card">
<div class="season-heading legacy-bg2">
<div class="season-identity">
<strong class="archive-label">이전 서버 기록</strong>
<strong class="archive-label" :class="{ abandoned: season.status === 'ABANDONED' }">
{{ archiveLabel(season) }}
</strong>
<strong>{{ seasonSourceProfile(season) }}</strong>
<span>{{ season.serverId }}</span>
<span>{{ archiveIdentifier(season) }}</span>
</div>
<div class="season-meta">
<span>{{ formatOpenedAt(season) }}</span>
<span>
{{ season.scenarioName ?? '시나리오 미상' }}
<template v-if="season.season !== null"> · {{ season.season }}</template>
<template v-if="season.status !== 'ABANDONED' && season.season !== null">
· {{ season.season }}
</template>
</span>
</div>
</div>
@@ -425,6 +438,11 @@ onMounted(() => {
background: #00582c;
}
.archive-label.abandoned {
border-color: #956f38;
background: #66471f;
}
.season-meta {
justify-content: flex-end;
}