feat: migrate legacy long-lived database records

This commit is contained in:
2026-07-27 01:12:00 +00:00
parent d8220a18b3
commit 89013272e1
39 changed files with 2495 additions and 124 deletions
+122
View File
@@ -0,0 +1,122 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { expect, test, type Page, type Route } from '@playwright/test';
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) => {
await page.addInitScript(() => {
localStorage.setItem('sammo-game-token', 'ga_archive');
localStorage.setItem('sammo-game-profile', 'che:default');
});
await page.route('**/che/api/trpc/**', async (route) => {
const results = operationNames(route).map((operation) => {
if (operation === 'lobby.info') return response({ myGeneral: null });
if (operation === 'archive.myPastPlays') {
return response({
seasons: [
{
serverId: 'che_2024_01',
date: '2024-01-31T00:00:00.000Z',
season: 51,
scenario: 2,
scenarioName: '천하쟁패',
generals: [
{
generalNo: 17,
name: '관우',
lastYearMonth: 21403,
nationId: 2,
nationName: '촉',
nationColor: '#800000',
leadership: 91,
strength: 98,
intel: 77,
experience: 23000,
dedication: 1200,
officerLevel: 12,
personal: '대담',
special: '상재',
special2: '신산',
},
],
},
],
});
}
return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } };
});
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
});
};
test('past plays is available without a current general and preserves desktop interaction geometry', async ({
page,
}) => {
await installArchive(page);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('past-plays');
const root = page.locator('.past-plays-page');
await expect(root).toBeVisible();
await expect(page.getByRole('heading', { name: '내 지난 플레이 보기' })).toBeVisible();
await expect(page.getByText('천하쟁패 · 51기')).toBeVisible();
await expect(page.locator('.general-name')).toHaveText('관우');
const geometry = await root.evaluate((element) => {
const rect = element.getBoundingClientRect();
const titleRect = element.querySelector('.title-row')!.getBoundingClientRect();
const tableRect = element.querySelector('table')!.getBoundingClientRect();
const style = getComputedStyle(element);
return {
x: rect.x,
width: rect.width,
minHeight: rect.height,
title: { x: titleRect.x, y: titleRect.y, width: titleRect.width },
tableWidth: tableRect.width,
color: style.color,
backgroundColor: style.backgroundColor,
};
});
expect(geometry).toMatchObject({
x: 100,
width: 1000,
title: { x: 100, y: 0, width: 1000 },
tableWidth: 1000,
color: 'rgb(238, 238, 238)',
backgroundColor: 'rgb(21, 21, 21)',
});
const refresh = page.getByRole('button', { name: '새로고침' });
await refresh.hover();
expect(await refresh.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(135, 206, 235)');
await refresh.focus();
expect(await refresh.evaluate((element) => document.activeElement === element)).toBe(true);
const artifactRoot = process.env.PAST_PLAYS_ARTIFACT_DIR;
if (artifactRoot) {
const output = resolve(artifactRoot);
await mkdir(output, { recursive: true });
await writeFile(resolve(output, 'desktop-computed-dom.json'), `${JSON.stringify(geometry, null, 2)}\n`);
await page.screenshot({ path: resolve(output, 'desktop.png'), fullPage: true });
}
});
test('past plays keeps the legacy-width table scrollable on a mobile viewport', async ({ page }) => {
await installArchive(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('past-plays');
const scroll = page.locator('.table-scroll');
const metrics = await scroll.evaluate((element) => ({
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
overflowX: getComputedStyle(element).overflowX,
}));
// The shared legacy shell keeps its historical 500 px minimum canvas.
expect(metrics).toEqual({ clientWidth: 500, scrollWidth: 820, overflowX: 'auto' });
await expect(page.locator('.title-row')).toHaveCSS('flex-direction', 'column');
});
@@ -16,6 +16,7 @@ export default defineConfig({
'inGameMenus.spec.ts',
'nationOffices.spec.ts',
'directoryLists.spec.ts',
'pastPlays.spec.ts',
'nationGeneralSecret.spec.ts',
'npcPolicy.spec.ts',
'auction.spec.ts',
+9
View File
@@ -35,6 +35,7 @@ import NpcListView from '../views/NpcListView.vue';
import NationListView from '../views/NationListView.vue';
import GeneralListView from '../views/GeneralListView.vue';
import TrafficView from '../views/TrafficView.vue';
import PastPlaysView from '../views/PastPlaysView.vue';
import { useSessionStore } from '../stores/session';
import { trpc } from '../utils/trpc';
@@ -321,6 +322,14 @@ const routes = [
requiresGeneral: true,
},
},
{
path: '/past-plays',
name: 'past-plays',
component: PastPlaysView,
meta: {
requiresAuth: true,
},
},
{
path: '/survey',
name: 'survey',
+1
View File
@@ -281,6 +281,7 @@ onMounted(() => {
<p class="join-subtitle">로그인 완료, 아직 장수가 없는 상태입니다.</p>
</div>
<div class="join-tabs">
<RouterLink class="simulator-link" to="/past-plays"> 지난 플레이</RouterLink>
<RouterLink class="simulator-link" to="/battle-simulator">전투 시뮬레이터</RouterLink>
<button :class="{ active: activeTab === 'create' }" @click="activeTab = 'create'">장수 생성</button>
<button :class="{ active: activeTab === 'possess' }" @click="activeTab = 'possess'">NPC 빙의</button>
+1
View File
@@ -151,6 +151,7 @@ watch(
<a class="ghost" href="/xe/community" target="_blank" rel="noopener">게시판</a>
<RouterLink class="ghost" to="/battle-simulator">전투 시뮬레이터</RouterLink>
<RouterLink class="ghost" to="/my-page"> 정보&amp;설정</RouterLink>
<RouterLink class="ghost" to="/past-plays"> 지난 플레이</RouterLink>
<RouterLink class="ghost" :class="{ highlight: tournamentStage === 1 }" to="/tournament"
>토너먼트</RouterLink
>
@@ -223,6 +223,7 @@ onMounted(() => {
<main id="container" class="legacy-page bg0" :class="`screen-${screenMode}`">
<div class="title-row">
<span> </span>
<RouterLink class="legacy-button" to="/past-plays">지난 플레이</RouterLink>
<RouterLink class="legacy-button" to="/">돌아가기</RouterLink>
<button class="legacy-button" type="button" @click="loadPage">새로고침</button>
</div>
@@ -0,0 +1,239 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { trpc } from '../utils/trpc';
type Archive = Awaited<ReturnType<typeof trpc.archive.myPastPlays.query>>;
const archive = ref<Archive | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const loadArchive = async () => {
if (loading.value) return;
loading.value = true;
error.value = null;
try {
archive.value = await trpc.archive.myPastPlays.query();
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '지난 플레이를 불러오지 못했습니다.';
} finally {
loading.value = false;
}
};
const yearMonth = (value: number): string => `${Math.floor(value / 100)}${value % 100}`;
const valueOrDash = (value: number | string | null): string => (value === null || value === '' ? '-' : String(value));
onMounted(() => {
void loadArchive();
});
</script>
<template>
<main id="container" class="past-plays-page legacy-bg0">
<header class="title-row legacy-bg1">
<h1> 지난 플레이 보기</h1>
<nav>
<RouterLink class="legacy-button" to="/">돌아가기</RouterLink>
<button class="legacy-button" type="button" :disabled="loading" @click="loadArchive">새로고침</button>
</nav>
</header>
<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>
<section v-for="season in archive?.seasons ?? []" :key="season.serverId" class="season-card">
<div class="season-heading legacy-bg2">
<strong>{{ season.serverId }}</strong>
<span>
{{ season.scenarioName ?? '시나리오 미상' }}
<template v-if="season.season !== null"> · {{ season.season }}</template>
</span>
</div>
<div class="table-scroll">
<table>
<thead>
<tr>
<th>장수명</th>
<th>소속</th>
<th>마지막 기록</th>
<th>통솔</th>
<th>무력</th>
<th>지력</th>
<th>관직</th>
<th>성격</th>
<th>내정 특기</th>
<th>전투 특기</th>
</tr>
</thead>
<tbody>
<tr v-for="general in season.generals" :key="general.generalNo">
<td class="general-name">{{ general.name }}</td>
<td>
<span
class="nation-name"
:style="{ backgroundColor: general.nationColor, color: '#ffffff' }"
>
{{ general.nationName }}
</span>
</td>
<td>{{ yearMonth(general.lastYearMonth) }}</td>
<td>{{ valueOrDash(general.leadership) }}</td>
<td>{{ valueOrDash(general.strength) }}</td>
<td>{{ valueOrDash(general.intel) }}</td>
<td>{{ valueOrDash(general.officerLevel) }}</td>
<td>{{ valueOrDash(general.personal) }}</td>
<td>{{ valueOrDash(general.special) }}</td>
<td>{{ valueOrDash(general.special2) }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</main>
</template>
<style scoped>
.past-plays-page {
width: min(1000px, 100%);
min-height: 100vh;
margin: 0 auto;
color: #eee;
background: #151515;
}
.title-row,
.season-heading {
border: 1px solid #555;
background: #2b2b2b;
}
.title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
}
.title-row h1 {
margin: 0;
color: skyblue;
font-size: 18px;
}
.title-row nav {
display: flex;
gap: 6px;
}
.legacy-button {
box-sizing: border-box;
min-height: 28px;
padding: 4px 9px;
border: 1px solid #777;
border-radius: 0;
color: #eee;
background: #333;
font: inherit;
text-decoration: none;
cursor: pointer;
}
.legacy-button:hover,
.legacy-button:focus-visible {
border-color: skyblue;
color: skyblue;
}
.legacy-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.page-note,
.empty-row,
.error-row {
margin: 0;
padding: 12px 10px;
border-inline: 1px solid #555;
border-bottom: 1px solid #555;
}
.page-note {
color: #bbb;
}
.error-row {
color: #ff8d8d;
}
.season-card {
margin-top: 12px;
}
.season-heading {
display: flex;
justify-content: space-between;
gap: 10px;
padding: 7px 9px;
}
.season-heading strong {
color: skyblue;
}
.season-heading span {
color: #bbb;
}
.table-scroll {
overflow-x: auto;
}
table {
width: 100%;
min-width: 820px;
border-collapse: collapse;
table-layout: auto;
background: #191919;
}
th,
td {
padding: 6px 7px;
border: 1px solid #555;
text-align: center;
white-space: nowrap;
}
th {
color: #ddd;
background: #303030;
font-weight: 600;
}
.general-name {
color: skyblue;
font-weight: 600;
}
.nation-name {
display: inline-block;
min-width: 54px;
padding: 2px 5px;
border: 1px solid rgb(255 255 255 / 25%);
text-shadow: 0 1px 1px #000;
}
@media (max-width: 640px) {
.title-row,
.season-heading {
align-items: flex-start;
flex-direction: column;
}
}
</style>