merge: 왕조 이전 기록 profile 경계를 main에 통합

This commit is contained in:
2026-08-20 15:47:23 +00:00
6 changed files with 110 additions and 38 deletions
+13 -4
View File
@@ -4,11 +4,13 @@ import { z } from 'zod';
import { asRecord } from '@sammo-ts/common'; import { asRecord } from '@sammo-ts/common';
import { procedure, router } from '../../trpc.js'; import { procedure, router } from '../../trpc.js';
import type { LegacyEmperorRow } from '../../services/legacyArchiveStore.js';
import { import {
findLegacyEmperor, findLegacyEmperor,
findLegacyEmperors, findLegacyEmperorsByProfile,
findLegacyGeneralsForServer, findLegacyGeneralsForServer,
findLegacyNations, findLegacyNations,
isLegacyArchiveProfile,
} from '../../services/legacyArchiveStore.js'; } from '../../services/legacyArchiveStore.js';
const zDynastyDetailInput = z.object({ const zDynastyDetailInput = z.object({
@@ -65,7 +67,7 @@ const firstText = (...values: unknown[]): string => {
return ''; return '';
}; };
const legacyEmperorListEntry = (row: Awaited<ReturnType<typeof findLegacyEmperors>>[number]) => { const legacyEmperorListEntry = (row: LegacyEmperorRow) => {
const data = asRecord(row.data); const data = asRecord(row.data);
return { return {
id: Number(row.id), id: Number(row.id),
@@ -132,7 +134,9 @@ const formatNationLevel = (level: number | null): string => {
export const dynastyRouter = router({ export const dynastyRouter = router({
getList: procedure.input(zDynastyListInput).query(async ({ ctx, input }) => { getList: procedure.input(zDynastyListInput).query(async ({ ctx, input }) => {
if ((input?.source ?? 'current') === 'legacy') { if ((input?.source ?? 'current') === 'legacy') {
const rows = await findLegacyEmperors(ctx.db); const rows = isLegacyArchiveProfile(ctx.profile.id)
? await findLegacyEmperorsByProfile(ctx.db, ctx.profile.id)
: [];
return { return {
source: 'legacy' as const, source: 'legacy' as const,
current: null, current: null,
@@ -186,7 +190,12 @@ export const dynastyRouter = router({
}), }),
getDetail: procedure.input(zDynastyDetailInput).query(async ({ ctx, input }) => { getDetail: procedure.input(zDynastyDetailInput).query(async ({ ctx, input }) => {
if (input.source === 'legacy') { if (input.source === 'legacy') {
const archived = await findLegacyEmperor(ctx.db, input.emperorId); const archived = isLegacyArchiveProfile(ctx.profile.id)
? await findLegacyEmperor(ctx.db, {
id: input.emperorId,
sourceProfile: ctx.profile.id,
})
: null;
if (!archived) { if (!archived) {
throw new TRPCError({ code: 'NOT_FOUND', message: '이전 서버 왕조 정보를 찾을 수 없습니다.' }); throw new TRPCError({ code: 'NOT_FOUND', message: '이전 서버 왕조 정보를 찾을 수 없습니다.' });
} }
@@ -270,7 +270,26 @@ export const findLegacyEmperors = async (
`); `);
}; };
export const findLegacyEmperor = async (db: LegacyArchiveDatabase, id: number): Promise<LegacyEmperorRow | null> => { export const findLegacyEmperorsByProfile = async (
db: LegacyArchiveDatabase,
sourceProfile: LegacyArchiveProfile
): Promise<LegacyEmperorRow[]> =>
db.$queryRaw<LegacyEmperorRow[]>(GamePrisma.sql`
SELECT
"id",
"source_profile" AS "sourceProfile",
"legacy_id" AS "legacyId",
"server_id" AS "serverId",
"data"
FROM "legacy_archive"."emperor"
WHERE "source_profile" = ${sourceProfile}
ORDER BY "id" DESC
`);
export const findLegacyEmperor = async (
db: LegacyArchiveDatabase,
input: { id: number; sourceProfile: LegacyArchiveProfile }
): Promise<LegacyEmperorRow | null> => {
const rows = await db.$queryRaw<LegacyEmperorRow[]>(GamePrisma.sql` const rows = await db.$queryRaw<LegacyEmperorRow[]>(GamePrisma.sql`
SELECT SELECT
"id", "id",
@@ -279,7 +298,8 @@ export const findLegacyEmperor = async (db: LegacyArchiveDatabase, id: number):
"server_id" AS "serverId", "server_id" AS "serverId",
"data" "data"
FROM "legacy_archive"."emperor" FROM "legacy_archive"."emperor"
WHERE "id" = ${id} WHERE "id" = ${input.id}
AND "source_profile" = ${input.sourceProfile}
LIMIT 1 LIMIT 1
`); `);
return rows[0] ?? null; return rows[0] ?? null;
+43 -17
View File
@@ -120,20 +120,24 @@ const authFor = (userId: string, roles: string[] = []): GameSessionTokenPayload
const buildContext = ( const buildContext = (
auth: GameSessionTokenPayload | null, auth: GameSessionTokenPayload | null,
oldNations: Array<Record<string, unknown>> = [oldNation, deletedOldNation] oldNations: Array<Record<string, unknown>> = [oldNation, deletedOldNation],
profileId = profile.id
): GameApiContext => { ): GameApiContext => {
const selectedProfile = { ...profile, id: profileId, name: `${profileId}:default` };
const db = { const db = {
$queryRaw: async (query: { strings?: readonly string[] }) => { $queryRaw: async (query: { strings?: readonly string[]; values?: unknown[] }) => {
const sql = query.strings?.join(' ') ?? ''; const sql = query.strings?.join(' ') ?? '';
if (sql.includes('legacy_archive"."emperor')) { if (sql.includes('legacy_archive"."emperor')) {
if (!query.values?.includes(selectedProfile.id)) return [];
if (sql.includes('WHERE "id"') && !query.values.includes(101)) return [];
return [ return [
{ {
id: 101n, id: 101n,
sourceProfile: 'hwe', sourceProfile: selectedProfile.id,
legacyId: 7, legacyId: 7,
serverId: emperor.serverId, serverId: emperor.serverId,
data: { data: {
phase: '이전 훼2기', phase: `이전 ${selectedProfile.id.toUpperCase()} 2기`,
nation_count: emperor.nationCount, nation_count: emperor.nationCount,
nation_name: emperor.nationName, nation_name: emperor.nationName,
nation_hist: emperor.nationHist, nation_hist: emperor.nationHist,
@@ -170,9 +174,10 @@ const buildContext = (
]; ];
} }
if (sql.includes('legacy_archive"."nation')) { if (sql.includes('legacy_archive"."nation')) {
if (!query.values?.includes(selectedProfile.id)) return [];
return [ return [
{ {
sourceProfile: 'hwe', sourceProfile: selectedProfile.id,
legacyId: oldNation.id, legacyId: oldNation.id,
serverId: oldNation.serverId, serverId: oldNation.serverId,
nation: oldNation.nation, nation: oldNation.nation,
@@ -182,6 +187,7 @@ const buildContext = (
]; ];
} }
if (sql.includes('legacy_archive"."general')) { if (sql.includes('legacy_archive"."general')) {
if (!query.values?.includes(selectedProfile.id)) return [];
return [ return [
{ generalNo: 11, name: '유비', lastYearMonth: 21504 }, { generalNo: 11, name: '유비', lastYearMonth: 21504 },
{ generalNo: 12, name: '제갈량', lastYearMonth: 21504 }, { generalNo: 12, name: '제갈량', lastYearMonth: 21504 },
@@ -217,13 +223,13 @@ const buildContext = (
db: db as unknown as DatabaseClient, db: db as unknown as DatabaseClient,
turnDaemon: new InMemoryTurnDaemonTransport(), turnDaemon: new InMemoryTurnDaemonTransport(),
battleSim: new InMemoryBattleSimTransport(), battleSim: new InMemoryBattleSimTransport(),
profile, profile: selectedProfile,
auth, auth,
uploadDir: 'uploads', uploadDir: 'uploads',
uploadPath: '/uploads', uploadPath: '/uploads',
uploadPublicUrl: null, uploadPublicUrl: null,
redis, redis,
accessTokenStore: new RedisAccessTokenStore(redis, profile.name), accessTokenStore: new RedisAccessTokenStore(redis, selectedProfile.name),
flushStore: new InMemoryFlushStore(), flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret', gameTokenSecret: 'test-secret',
}; };
@@ -252,27 +258,31 @@ describe('dynasty public read model', () => {
]); ]);
}); });
it('reads previous-server dynasties only when the archive source is selected', async () => { it('scopes previous-server dynasties and detail to the request profile', async () => {
const caller = appRouter.createCaller(buildContext(null)); const cheCaller = appRouter.createCaller(buildContext(null));
const list = await caller.dynasty.getList({ source: 'legacy' }); const cheList = await cheCaller.dynasty.getList({ source: 'legacy' });
expect(list).toMatchObject({ expect(cheList).toMatchObject({
source: 'legacy', source: 'legacy',
current: null, current: null,
entries: [ entries: [
expect.objectContaining({ expect.objectContaining({
id: 101, id: 101,
source: 'legacy', source: 'legacy',
sourceProfile: 'hwe', sourceProfile: 'che',
phase: '이전 2기', phase: '이전 CHE 2기',
}), }),
], ],
}); });
const detail = await caller.dynasty.getDetail({ emperorId: 101, source: 'legacy' }); const staleListInput = { source: 'legacy' as const, sourceProfile: 'hwe' as const };
expect(detail).toMatchObject({ const staleList = await cheCaller.dynasty.getList(staleListInput);
expect(staleList.entries.map((entry) => entry.sourceProfile)).toEqual(['che']);
const cheDetail = await cheCaller.dynasty.getDetail({ emperorId: 101, source: 'legacy' });
expect(cheDetail).toMatchObject({
source: 'legacy', source: 'legacy',
sourceProfile: 'hwe', sourceProfile: 'che',
emperor: expect.objectContaining({ id: 101, phase: '이전 2기', name: '촉' }), emperor: expect.objectContaining({ id: 101, phase: '이전 CHE 2기', name: '촉' }),
nations: [ nations: [
expect.objectContaining({ expect.objectContaining({
name: '촉', name: '촉',
@@ -283,6 +293,22 @@ describe('dynasty public read model', () => {
}), }),
], ],
}); });
const staleDetailInput = { emperorId: 101, source: 'legacy' as const, sourceProfile: 'hwe' as const };
const staleDetail = await cheCaller.dynasty.getDetail(staleDetailInput);
expect(staleDetail.sourceProfile).toBe('che');
const hweCaller = appRouter.createCaller(buildContext(null, undefined, 'hwe'));
const hweList = await hweCaller.dynasty.getList({ source: 'legacy' });
expect(hweList.entries).toEqual([expect.objectContaining({ sourceProfile: 'hwe', phase: '이전 HWE 2기' })]);
const hweDetail = await hweCaller.dynasty.getDetail({ emperorId: 101, source: 'legacy' });
expect(hweDetail.sourceProfile).toBe('hwe');
const developmentCaller = appRouter.createCaller(buildContext(null, undefined, 'development'));
await expect(developmentCaller.dynasty.getList({ source: 'legacy' })).resolves.toMatchObject({ entries: [] });
await expect(developmentCaller.dynasty.getDetail({ emperorId: 101, source: 'legacy' })).rejects.toMatchObject({
code: 'NOT_FOUND',
});
}); });
it('exposes the same public DTO to anonymous, general owners and admins', async () => { it('exposes the same public DTO to anonymous, general owners and admins', async () => {
@@ -11,6 +11,7 @@ const isLegacyRequest = (route: Route): boolean =>
const installArchiveViews = async (page: Page) => { const installArchiveViews = async (page: Page) => {
const hallRequests: string[] = []; const hallRequests: string[] = [];
const dynastyRequests: string[] = [];
await page.addInitScript((profile) => { await page.addInitScript((profile) => {
localStorage.setItem('sammo-game-token', 'ga_archive_views'); localStorage.setItem('sammo-game-token', 'ga_archive_views');
localStorage.setItem('sammo-game-profile', profile); localStorage.setItem('sammo-game-profile', profile);
@@ -21,6 +22,9 @@ const installArchiveViews = async (page: Page) => {
if (operations.some((operation) => operation.startsWith('ranking.getHallOfFame'))) { if (operations.some((operation) => operation.startsWith('ranking.getHallOfFame'))) {
hallRequests.push(decodeURIComponent(`${route.request().url()} ${route.request().postData() ?? ''}`)); hallRequests.push(decodeURIComponent(`${route.request().url()} ${route.request().postData() ?? ''}`));
} }
if (operations.some((operation) => operation.startsWith('dynasty.'))) {
dynastyRequests.push(decodeURIComponent(`${route.request().url()} ${route.request().postData() ?? ''}`));
}
const results = operations.map((operation) => { const results = operations.map((operation) => {
if (operation === 'auth.status') return response({ ok: true }); if (operation === 'auth.status') return response({ ok: true });
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '기록장수' } }); if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '기록장수' } });
@@ -67,8 +71,8 @@ const installArchiveViews = async (page: Page) => {
{ {
id: legacy ? 101 : 1, id: legacy ? 101 : 1,
source: legacy ? 'legacy' : 'current', source: legacy ? 'legacy' : 'current',
sourceProfile: legacy ? 'hwe' : 'che', sourceProfile: 'che',
serverId: legacy ? 'hwe-old-1' : 'che-current-1', serverId: legacy ? 'che-old-1' : 'che-current-1',
phase: legacy ? '이전 1기' : '현재 1기', phase: legacy ? '이전 1기' : '현재 1기',
name: '촉', name: '촉',
year: 215, year: 215,
@@ -93,10 +97,10 @@ const installArchiveViews = async (page: Page) => {
if (operation === 'dynasty.getDetail') { if (operation === 'dynasty.getDetail') {
return response({ return response({
source: 'legacy', source: 'legacy',
sourceProfile: 'hwe', sourceProfile: 'che',
emperor: { emperor: {
id: 101, id: 101,
serverId: 'hwe-old-1', serverId: 'che-old-1',
winnerNationId: 1, winnerNationId: 1,
phase: '이전 1기', phase: '이전 1기',
nationCount: '1 / 2', nationCount: '1 / 2',
@@ -189,7 +193,7 @@ const installArchiveViews = async (page: Page) => {
}); });
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) }); await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
}); });
return { hallRequests }; return { dynastyRequests, hallRequests };
}; };
test('명예의 전당은 현재 profile의 현재·이전 서버 기록만 조회한다', async ({ page }, testInfo) => { test('명예의 전당은 현재 profile의 현재·이전 서버 기록만 조회한다', async ({ page }, testInfo) => {
@@ -217,19 +221,36 @@ test('명예의 전당은 현재 profile의 현재·이전 서버 기록만 조
await page.screenshot({ path: testInfo.outputPath('hall-profile-scope-mobile.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('hall-profile-scope-mobile.png'), fullPage: true });
}); });
test('왕조 일람과 상세는 이전 서버 source와 profile을 유지한다', async ({ page }) => { test('왕조 일람과 상세는 현재 profile의 이전 서버 기록만 조회한다', async ({ page }, testInfo) => {
await installArchiveViews(page); const state = await installArchiveViews(page);
await page.setViewportSize({ width: 1200, height: 800 }); await page.setViewportSize({ width: 1200, height: 800 });
await page.goto('dynasty'); await page.goto('dynasty');
await expect(page.getByText('현재 1기')).toBeVisible(); await expect(page.getByText('현재 1기')).toBeVisible();
await page.getByLabel('기록 구분').focus();
await expect(page.getByLabel('기록 구분')).toBeFocused();
await page.getByLabel('기록 구분').selectOption('legacy'); await page.getByLabel('기록 구분').selectOption('legacy');
await expect(page.getByText(/이전 1기.*HWE 이전 서버/)).toBeVisible(); await expect(page.getByText(/이전 1기.*이전 서버/)).toBeVisible();
await expect(page.getByText(/CHE 이전 서버|HWE 이전 서버/)).toHaveCount(0);
await expect(page.locator('.dynasty-page')).toHaveCSS('width', '1000px');
await expect(page.locator('.dynasty-table')).toHaveCSS('height', '139px');
await expect(page.locator('.dynasty-table .phase-heading')).toHaveCSS('background-color', 'rgb(135, 206, 235)');
await page.screenshot({ path: testInfo.outputPath('dynasty-list-profile-scope-desktop.png'), fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator('.dynasty-page')).toHaveCSS('width', '1000px');
await page.screenshot({ path: testInfo.outputPath('dynasty-list-profile-scope-mobile.png'), fullPage: true });
await page.setViewportSize({ width: 1200, height: 800 });
const detailLink = page.getByRole('link', { name: '자세히' }); const detailLink = page.getByRole('link', { name: '자세히' });
await expect(detailLink).toHaveAttribute('href', /dynasty\/101\?source=legacy$/); await expect(detailLink).toHaveAttribute('href', /dynasty\/101\?source=legacy$/);
await detailLink.click(); await detailLink.click();
await expect(page.getByText(/이전 1기.*HWE 이전 서버/)).toBeVisible(); await expect(page.getByText(/이전 1기.*이전 서버/)).toBeVisible();
await expect(page.getByText(/CHE 이전 서버|HWE 이전 서버/)).toHaveCount(0);
await expect(page.locator('.dynasty-page')).toHaveCSS('width', '1000px'); await expect(page.locator('.dynasty-page')).toHaveCSS('width', '1000px');
expect(state.dynastyRequests.some((request) => request.includes('legacy'))).toBe(true);
expect(state.dynastyRequests.every((request) => !request.includes('sourceProfile'))).toBe(true);
await page.screenshot({ path: testInfo.outputPath('dynasty-detail-profile-scope-desktop.png'), fullPage: true });
}); });
test('연감 국가 라벨은 밝은 배경에 검정, 어두운 배경에 흰 글자를 사용한다', async ({ page }, testInfo) => { test('연감 국가 라벨은 밝은 배경에 검정, 어두운 배경에 흰 글자를 사용한다', async ({ page }, testInfo) => {
@@ -92,9 +92,7 @@ onMounted(loadDetail);
<td class="phase-heading centered" colspan="6"> <td class="phase-heading centered" colspan="6">
<span class="large-text"> <span class="large-text">
{{ data.emperor.phase }} {{ data.emperor.phase }}
<template v-if="data.source === 'legacy'"> <template v-if="data.source === 'legacy'"> [이전 서버] </template>
[{{ data.sourceProfile.toUpperCase() }} 이전 서버]
</template>
</span> </span>
</td> </td>
</tr> </tr>
@@ -98,9 +98,7 @@ watch(selectedSource, loadDynasty);
<td class="phase-heading" colspan="8"> <td class="phase-heading" colspan="8">
<span class="large-text" <span class="large-text"
>{{ entry.phase >{{ entry.phase
}}<template v-if="entry.source === 'legacy'"> }}<template v-if="entry.source === 'legacy'"> [이전 서버]</template></span
[{{ entry.sourceProfile.toUpperCase() }} 이전 서버]</template
></span
> >
<RouterLink <RouterLink
:to="{ :to="{