feat: migrate legacy long-lived database records
This commit is contained in:
@@ -24,6 +24,7 @@ import { rankingRouter } from './router/ranking/index.js';
|
||||
import { dynastyRouter } from './router/dynasty/index.js';
|
||||
import { voteRouter } from './router/vote/index.js';
|
||||
import { bettingRouter } from './router/betting/index.js';
|
||||
import { archiveRouter } from './router/archive/index.js';
|
||||
|
||||
export const appRouter = router({
|
||||
health: healthRouter,
|
||||
@@ -50,6 +51,7 @@ export const appRouter = router({
|
||||
dynasty: dynastyRouter,
|
||||
vote: voteRouter,
|
||||
betting: bettingRouter,
|
||||
archive: archiveRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
|
||||
import { readOnlyAuthedProcedure, router } from '../../trpc.js';
|
||||
|
||||
const numberOrNull = (value: unknown): number | null =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
|
||||
const textOrNull = (value: unknown): string | null => (typeof value === 'string' ? value : null);
|
||||
|
||||
export const archiveRouter = router({
|
||||
myPastPlays: readOnlyAuthedProcedure.query(async ({ ctx }) => {
|
||||
const owner = ctx.auth?.user.id;
|
||||
if (!owner) {
|
||||
throw new Error('Authenticated archive query is missing its user identity');
|
||||
}
|
||||
const generals = await ctx.db.oldGeneral.findMany({
|
||||
where: { owner },
|
||||
orderBy: [{ lastYearMonth: 'desc' }, { serverId: 'desc' }, { generalNo: 'asc' }],
|
||||
});
|
||||
if (!generals.length) {
|
||||
return { seasons: [] };
|
||||
}
|
||||
|
||||
const serverIds = [...new Set(generals.map((general) => general.serverId))];
|
||||
const [games, nations] = await Promise.all([
|
||||
ctx.db.gameHistory.findMany({
|
||||
where: { serverId: { in: serverIds } },
|
||||
}),
|
||||
ctx.db.oldNation.findMany({
|
||||
where: { serverId: { in: serverIds } },
|
||||
orderBy: [{ date: 'desc' }, { id: 'desc' }],
|
||||
}),
|
||||
]);
|
||||
|
||||
const gameByServer = new Map(games.map((game) => [game.serverId, game]));
|
||||
const nationByServerAndId = new Map<string, (typeof nations)[number]>();
|
||||
for (const nation of nations) {
|
||||
const key = `${nation.serverId}:${nation.nation}`;
|
||||
if (!nationByServerAndId.has(key)) {
|
||||
nationByServerAndId.set(key, nation);
|
||||
}
|
||||
}
|
||||
|
||||
const seasons = new Map<
|
||||
string,
|
||||
{
|
||||
serverId: string;
|
||||
date: string | null;
|
||||
season: number | null;
|
||||
scenario: number | null;
|
||||
scenarioName: string | null;
|
||||
generals: Array<{
|
||||
generalNo: number;
|
||||
name: string;
|
||||
lastYearMonth: number;
|
||||
nationId: number;
|
||||
nationName: string;
|
||||
nationColor: string;
|
||||
leadership: number | null;
|
||||
strength: number | null;
|
||||
intel: number | null;
|
||||
experience: number | null;
|
||||
dedication: number | null;
|
||||
officerLevel: number | null;
|
||||
personal: string | null;
|
||||
special: string | null;
|
||||
special2: string | null;
|
||||
}>;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const general of generals) {
|
||||
const game = gameByServer.get(general.serverId);
|
||||
let season = seasons.get(general.serverId);
|
||||
if (!season) {
|
||||
season = {
|
||||
serverId: general.serverId,
|
||||
date: game?.date.toISOString() ?? null,
|
||||
season: game?.season ?? null,
|
||||
scenario: game?.scenario ?? null,
|
||||
scenarioName: game?.scenarioName ?? null,
|
||||
generals: [],
|
||||
};
|
||||
seasons.set(general.serverId, season);
|
||||
}
|
||||
|
||||
const data = asRecord(general.data);
|
||||
const nationId = numberOrNull(data.nation) ?? 0;
|
||||
const nation = nationByServerAndId.get(`${general.serverId}:${nationId}`);
|
||||
const nationData = asRecord(nation?.data);
|
||||
season.generals.push({
|
||||
generalNo: general.generalNo,
|
||||
name: general.name,
|
||||
lastYearMonth: general.lastYearMonth,
|
||||
nationId,
|
||||
nationName: textOrNull(nationData.name) ?? (nationId === 0 ? '재야' : '미상'),
|
||||
nationColor: textOrNull(nationData.color) ?? '#000000',
|
||||
leadership: numberOrNull(data.leadership),
|
||||
strength: numberOrNull(data.strength),
|
||||
intel: numberOrNull(data.intel),
|
||||
experience: numberOrNull(data.experience),
|
||||
dedication: numberOrNull(data.dedication),
|
||||
officerLevel: numberOrNull(data.officer_level),
|
||||
personal: textOrNull(data.personal),
|
||||
special: textOrNull(data.special),
|
||||
special2: textOrNull(data.special2),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
seasons: [...seasons.values()].sort((left, right) => {
|
||||
const leftTime = left.date ? new Date(left.date).getTime() : 0;
|
||||
const rightTime = right.date ? new Date(right.date).getTime() : 0;
|
||||
return rightTime - leftTime || right.serverId.localeCompare(left.serverId);
|
||||
}),
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -22,8 +22,10 @@ type YearbookNation = {
|
||||
const joinYearMonth = (year: number, month: number): number => year * 12 + month - 1;
|
||||
const zServerId = z.string().trim().min(1).max(64);
|
||||
|
||||
const computeHash = (payload: unknown): string =>
|
||||
createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
||||
const computeHash = (payload: unknown): string => createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
||||
|
||||
const parseTextArray = (value: unknown): string[] =>
|
||||
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
|
||||
|
||||
const parseYearbookNations = (value: unknown): YearbookNation[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
@@ -103,8 +105,7 @@ const buildNationSnapshot = async (ctx: GameApiContext) => {
|
||||
|
||||
for (const city of cityRows) {
|
||||
const entry = cityStatsByNation.get(city.nationId) ?? { popSum: 0, valueSum: 0, maxSum: 0 };
|
||||
const valueSum =
|
||||
city.population + city.agriculture + city.commerce + city.security + city.wall + city.defence;
|
||||
const valueSum = city.population + city.agriculture + city.commerce + city.security + city.wall + city.defence;
|
||||
const maxSum =
|
||||
city.populationMax +
|
||||
city.agricultureMax +
|
||||
@@ -229,12 +230,8 @@ export const yearbookRouter = router({
|
||||
|
||||
const currentYearMonth = joinYearMonth(worldState.currentYear, worldState.currentMonth);
|
||||
const fallbackYearMonth = currentYearMonth - 1;
|
||||
const firstYearMonth = firstRow
|
||||
? joinYearMonth(firstRow.year, firstRow.month)
|
||||
: fallbackYearMonth;
|
||||
const lastYearMonth = lastRow
|
||||
? joinYearMonth(lastRow.year, lastRow.month)
|
||||
: fallbackYearMonth;
|
||||
const firstYearMonth = firstRow ? joinYearMonth(firstRow.year, firstRow.month) : fallbackYearMonth;
|
||||
const lastYearMonth = lastRow ? joinYearMonth(lastRow.year, lastRow.month) : fallbackYearMonth;
|
||||
const selectedYearMonth = isCurrentProfile ? currentYearMonth : lastYearMonth;
|
||||
|
||||
return {
|
||||
@@ -262,17 +259,10 @@ export const yearbookRouter = router({
|
||||
const isCurrentProfile = targetProfileName === ctx.profile.name;
|
||||
|
||||
const isCurrent =
|
||||
isCurrentProfile &&
|
||||
worldState.currentYear === input.year && worldState.currentMonth === input.month;
|
||||
|
||||
const { globalHistory, globalAction } = isCurrentProfile
|
||||
? await buildLogs(ctx, input.year, input.month)
|
||||
: {
|
||||
globalHistory: [`<C>●</>${input.month}월: 기록 없음`],
|
||||
globalAction: [`<C>●</>${input.month}월: 기록 없음`],
|
||||
};
|
||||
isCurrentProfile && worldState.currentYear === input.year && worldState.currentMonth === input.month;
|
||||
|
||||
if (isCurrent) {
|
||||
const { globalHistory, globalAction } = await buildLogs(ctx, input.year, input.month);
|
||||
const map = await loadPublicMap(ctx, false);
|
||||
if (!map) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World map is not available.' });
|
||||
@@ -299,6 +289,7 @@ export const yearbookRouter = router({
|
||||
year: input.year,
|
||||
month: input.month,
|
||||
},
|
||||
orderBy: [{ sourceId: 'desc' }, { id: 'desc' }],
|
||||
});
|
||||
if (!row) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '연감 데이터를 찾을 수 없습니다.' });
|
||||
@@ -306,6 +297,14 @@ export const yearbookRouter = router({
|
||||
|
||||
const map = asRecord(row.map) as BaseMapResult;
|
||||
const nations = parseYearbookNations(row.nations);
|
||||
const archivedLogs =
|
||||
isCurrentProfile && row.sourceId === 0
|
||||
? await buildLogs(ctx, input.year, input.month)
|
||||
: {
|
||||
globalHistory: parseTextArray(row.globalHistory),
|
||||
globalAction: parseTextArray(row.globalAction),
|
||||
};
|
||||
const { globalHistory, globalAction } = archivedLogs;
|
||||
const data = {
|
||||
year: input.year,
|
||||
month: input.month,
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import type { RedisConnector } from '@sammo-ts/infra';
|
||||
|
||||
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
|
||||
import type { DatabaseClient, GameApiContext } from '../src/context.js';
|
||||
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
|
||||
const auth: GameSessionTokenPayload = {
|
||||
version: 1,
|
||||
profile: 'che:default',
|
||||
issuedAt: '2026-07-27T00:00:00.000Z',
|
||||
expiresAt: '2026-07-28T00:00:00.000Z',
|
||||
sessionId: 'archive-session',
|
||||
user: {
|
||||
id: 'user-1',
|
||||
username: 'user-1',
|
||||
displayName: '테스터',
|
||||
roles: ['user'],
|
||||
},
|
||||
sanctions: {},
|
||||
};
|
||||
|
||||
const context = (session: GameSessionTokenPayload | null): GameApiContext => {
|
||||
const db = {
|
||||
oldGeneral: {
|
||||
findMany: async ({ where }: { where: { owner: string } }) =>
|
||||
where.owner === 'user-1'
|
||||
? [
|
||||
{
|
||||
id: 1,
|
||||
serverId: 'che_legacy_1',
|
||||
generalNo: 10,
|
||||
owner: 'user-1',
|
||||
name: '과거장수',
|
||||
lastYearMonth: 22012,
|
||||
turnTime: new Date('2025-01-01T00:00:00.000Z'),
|
||||
data: {
|
||||
nation: 3,
|
||||
leadership: 80,
|
||||
strength: 70,
|
||||
intel: 60,
|
||||
personal: 'che_의리',
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
gameHistory: {
|
||||
findMany: async () => [
|
||||
{
|
||||
id: 1,
|
||||
serverId: 'che_legacy_1',
|
||||
date: new Date('2025-01-02T00:00:00.000Z'),
|
||||
winnerNation: 3,
|
||||
map: 'scenario',
|
||||
season: 1,
|
||||
scenario: 100,
|
||||
scenarioName: '테스트',
|
||||
env: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
oldNation: {
|
||||
findMany: async () => [
|
||||
{
|
||||
id: 1,
|
||||
serverId: 'che_legacy_1',
|
||||
nation: 3,
|
||||
sourceId: 5,
|
||||
data: { name: '촉', color: '#ff0000' },
|
||||
date: new Date('2025-01-02T00:00:00.000Z'),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const redis = {
|
||||
get: async () => null,
|
||||
set: async () => null,
|
||||
} as unknown as RedisConnector['client'];
|
||||
return {
|
||||
db: db as unknown as DatabaseClient,
|
||||
redis,
|
||||
turnDaemon: new InMemoryTurnDaemonTransport(),
|
||||
battleSim: new InMemoryBattleSimTransport(),
|
||||
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
auth: session,
|
||||
accessTokenStore: new RedisAccessTokenStore(redis, 'che:default'),
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'test-secret',
|
||||
};
|
||||
};
|
||||
|
||||
describe('archive.myPastPlays', () => {
|
||||
it('requires authentication and returns only the authenticated owner archive', async () => {
|
||||
await expect(appRouter.createCaller(context(null)).archive.myPastPlays()).rejects.toMatchObject({
|
||||
code: 'UNAUTHORIZED',
|
||||
});
|
||||
const result = await appRouter.createCaller(context(auth)).archive.myPastPlays();
|
||||
expect(result.seasons).toEqual([
|
||||
expect.objectContaining({
|
||||
serverId: 'che_legacy_1',
|
||||
scenarioName: '테스트',
|
||||
generals: [
|
||||
expect.objectContaining({
|
||||
name: '과거장수',
|
||||
nationName: '촉',
|
||||
leadership: 80,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -21,20 +21,26 @@ const archiveRows = [
|
||||
{
|
||||
id: 1,
|
||||
profileName: archiveServerId,
|
||||
sourceId: 101,
|
||||
year: 200,
|
||||
month: 1,
|
||||
map: { year: 200, month: 1, startYear: 190, cityList: [], nationList: [] },
|
||||
nations: [{ id: 1, name: '촉', color: '#FF0000', level: 7, power: 1000, cities: ['성도'] }],
|
||||
globalHistory: ['<C>●</>1월: 기록 없음'],
|
||||
globalAction: ['<C>●</>1월: 기록 없음'],
|
||||
hash: 'archive-1',
|
||||
createdAt: new Date('2026-07-25T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
profileName: archiveServerId,
|
||||
sourceId: 102,
|
||||
year: 200,
|
||||
month: 2,
|
||||
map: { year: 200, month: 2, startYear: 190, cityList: [], nationList: [] },
|
||||
nations: [{ id: 1, name: '촉', color: '#FF0000', level: 7, power: 1200, cities: ['성도'] }],
|
||||
globalHistory: ['<C>●</>2월: 기록 없음'],
|
||||
globalAction: ['<C>●</>2월: 기록 없음'],
|
||||
hash: 'archive-2',
|
||||
createdAt: new Date('2026-07-25T01:00:00.000Z'),
|
||||
},
|
||||
@@ -55,10 +61,7 @@ const authFor = (userId: string): GameSessionTokenPayload => ({
|
||||
sanctions: {},
|
||||
});
|
||||
|
||||
const buildContext = (
|
||||
auth: GameSessionTokenPayload | null,
|
||||
options: { hasGeneral?: boolean } = {}
|
||||
): GameApiContext => {
|
||||
const buildContext = (auth: GameSessionTokenPayload | null, options: { hasGeneral?: boolean } = {}): GameApiContext => {
|
||||
const db = {
|
||||
general: {
|
||||
findFirst: async ({ where }: { where: { userId: string } }) =>
|
||||
|
||||
@@ -670,9 +670,10 @@ export const createDatabaseTurnHooks = async (
|
||||
deletedNationSnapshots.map((snapshot) =>
|
||||
prisma.oldNation.upsert({
|
||||
where: {
|
||||
serverId_nation: {
|
||||
serverId_nation_sourceId: {
|
||||
serverId,
|
||||
nation: snapshot.nation.id,
|
||||
sourceId: 0,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
@@ -695,6 +696,7 @@ export const createDatabaseTurnHooks = async (
|
||||
create: {
|
||||
serverId,
|
||||
nation: snapshot.nation.id,
|
||||
sourceId: 0,
|
||||
data: {
|
||||
nation: snapshot.nation.id,
|
||||
name: snapshot.nation.name,
|
||||
|
||||
@@ -558,9 +558,10 @@ export const createUnificationHandler = (options: {
|
||||
|
||||
await prisma.oldNation.upsert({
|
||||
where: {
|
||||
serverId_nation: {
|
||||
serverId_nation_sourceId: {
|
||||
serverId,
|
||||
nation: winnerNationId,
|
||||
sourceId: 0,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
@@ -583,6 +584,7 @@ export const createUnificationHandler = (options: {
|
||||
create: {
|
||||
serverId,
|
||||
nation: winnerNationId,
|
||||
sourceId: 0,
|
||||
data: {
|
||||
nation: winnerNationId,
|
||||
name: winnerNation.name,
|
||||
@@ -602,9 +604,10 @@ export const createUnificationHandler = (options: {
|
||||
|
||||
await prisma.oldNation.upsert({
|
||||
where: {
|
||||
serverId_nation: {
|
||||
serverId_nation_sourceId: {
|
||||
serverId,
|
||||
nation: 0,
|
||||
sourceId: 0,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
@@ -627,6 +630,7 @@ export const createUnificationHandler = (options: {
|
||||
create: {
|
||||
serverId,
|
||||
nation: 0,
|
||||
sourceId: 0,
|
||||
data: {
|
||||
nation: 0,
|
||||
name: '재야',
|
||||
|
||||
@@ -160,10 +160,11 @@ export const createYearbookHandler = (options: {
|
||||
|
||||
await connector.prisma.yearbookHistory.upsert({
|
||||
where: {
|
||||
profileName_year_month: {
|
||||
profileName_year_month_sourceId: {
|
||||
profileName: options.profileName,
|
||||
year: context.previousYear,
|
||||
month: context.previousMonth,
|
||||
sourceId: 0,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
@@ -173,6 +174,7 @@ export const createYearbookHandler = (options: {
|
||||
},
|
||||
create: {
|
||||
profileName: options.profileName,
|
||||
sourceId: 0,
|
||||
year: context.previousYear,
|
||||
month: context.previousMonth,
|
||||
map,
|
||||
|
||||
@@ -218,10 +218,11 @@ integration('monthly pre-update persistence', () => {
|
||||
]);
|
||||
const yearbookRow = await db.yearbookHistory.findUniqueOrThrow({
|
||||
where: {
|
||||
profileName_year_month: {
|
||||
profileName_year_month_sourceId: {
|
||||
profileName: yearbookProfile,
|
||||
year: 200,
|
||||
month: 12,
|
||||
sourceId: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -361,7 +361,7 @@ integration('monthly wandering nation persistence', () => {
|
||||
})
|
||||
).toBe(0);
|
||||
const archive = await db.oldNation.findUniqueOrThrow({
|
||||
where: { serverId_nation: { serverId, nation: nationIds[1]! } },
|
||||
where: { serverId_nation_sourceId: { serverId, nation: nationIds[1]!, sourceId: 0 } },
|
||||
});
|
||||
expect(archive.data).toMatchObject({
|
||||
nation: nationIds[1],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">내 정보&설정</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>
|
||||
Reference in New Issue
Block a user