feat: expose archived general histories
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
|
||||
import { readOnlyAuthedProcedure, router } from '../../trpc.js';
|
||||
@@ -5,7 +8,40 @@ 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);
|
||||
const firstNumber = (record: Record<string, unknown>, ...keys: string[]): number | null => {
|
||||
for (const key of keys) {
|
||||
const value = numberOrNull(record[key]);
|
||||
if (value !== null) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const displayTextOrNull = (value: unknown): string | null => {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
return typeof value === 'number' && Number.isFinite(value) ? String(value) : null;
|
||||
};
|
||||
|
||||
const parseHistory = (value: unknown): string[] => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0);
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return [];
|
||||
}
|
||||
return value
|
||||
.split(/<br\s*\/?>/i)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const zPastPlayDetailInput = z.object({
|
||||
serverId: z.string().trim().min(1).max(64),
|
||||
generalNo: z.number().int().positive(),
|
||||
});
|
||||
|
||||
export const archiveRouter = router({
|
||||
myPastPlays: readOnlyAuthedProcedure.query(async ({ ctx }) => {
|
||||
@@ -22,7 +58,7 @@ export const archiveRouter = router({
|
||||
}
|
||||
|
||||
const serverIds = [...new Set(generals.map((general) => general.serverId))];
|
||||
const [games, nations] = await Promise.all([
|
||||
const [games, nations, emperors] = await Promise.all([
|
||||
ctx.db.gameHistory.findMany({
|
||||
where: { serverId: { in: serverIds } },
|
||||
}),
|
||||
@@ -30,9 +66,20 @@ export const archiveRouter = router({
|
||||
where: { serverId: { in: serverIds } },
|
||||
orderBy: [{ date: 'desc' }, { id: 'desc' }],
|
||||
}),
|
||||
ctx.db.emperor.findMany({
|
||||
where: { serverId: { in: serverIds } },
|
||||
orderBy: { id: 'desc' },
|
||||
select: { id: true, serverId: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const gameByServer = new Map(games.map((game) => [game.serverId, game]));
|
||||
const emperorByServer = new Map<string, number>();
|
||||
for (const emperor of emperors) {
|
||||
if (emperor.serverId && !emperorByServer.has(emperor.serverId)) {
|
||||
emperorByServer.set(emperor.serverId, emperor.id);
|
||||
}
|
||||
}
|
||||
const nationByServerAndId = new Map<string, (typeof nations)[number]>();
|
||||
for (const nation of nations) {
|
||||
const key = `${nation.serverId}:${nation.nation}`;
|
||||
@@ -49,6 +96,7 @@ export const archiveRouter = router({
|
||||
season: number | null;
|
||||
scenario: number | null;
|
||||
scenarioName: string | null;
|
||||
dynastyId: number | null;
|
||||
generals: Array<{
|
||||
generalNo: number;
|
||||
name: string;
|
||||
@@ -65,6 +113,7 @@ export const archiveRouter = router({
|
||||
personal: string | null;
|
||||
special: string | null;
|
||||
special2: string | null;
|
||||
historyCount: number;
|
||||
}>;
|
||||
}
|
||||
>();
|
||||
@@ -79,13 +128,16 @@ export const archiveRouter = router({
|
||||
season: game?.season ?? null,
|
||||
scenario: game?.scenario ?? null,
|
||||
scenarioName: game?.scenarioName ?? null,
|
||||
dynastyId: emperorByServer.get(general.serverId) ?? null,
|
||||
generals: [],
|
||||
};
|
||||
seasons.set(general.serverId, season);
|
||||
}
|
||||
|
||||
const data = asRecord(general.data);
|
||||
const nationId = numberOrNull(data.nation) ?? 0;
|
||||
const stats = asRecord(data.stats);
|
||||
const role = asRecord(data.role);
|
||||
const nationId = firstNumber(data, 'nationId', 'nation') ?? 0;
|
||||
const nation = nationByServerAndId.get(`${general.serverId}:${nationId}`);
|
||||
const nationData = asRecord(nation?.data);
|
||||
season.generals.push({
|
||||
@@ -93,17 +145,18 @@ export const archiveRouter = router({
|
||||
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),
|
||||
nationName: displayTextOrNull(nationData.name) ?? (nationId === 0 ? '재야' : '미상'),
|
||||
nationColor: displayTextOrNull(nationData.color) ?? '#000000',
|
||||
leadership: firstNumber(data, 'leadership', 'leader') ?? numberOrNull(stats.leadership),
|
||||
strength: firstNumber(data, 'strength', 'power') ?? numberOrNull(stats.strength),
|
||||
intel: firstNumber(data, 'intel', 'intelligence') ?? numberOrNull(stats.intelligence),
|
||||
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),
|
||||
officerLevel: firstNumber(data, 'officerLevel', 'officer_level'),
|
||||
personal: displayTextOrNull(data.personalCode ?? data.personal ?? role.personality),
|
||||
special: displayTextOrNull(data.specialCode ?? data.special ?? role.specialDomestic),
|
||||
special2: displayTextOrNull(data.special2Code ?? data.special2 ?? role.specialWar),
|
||||
historyCount: parseHistory(data.history).length,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -115,4 +168,28 @@ export const archiveRouter = router({
|
||||
}),
|
||||
};
|
||||
}),
|
||||
myPastPlayDetail: readOnlyAuthedProcedure.input(zPastPlayDetailInput).query(async ({ ctx, input }) => {
|
||||
const owner = ctx.auth?.user.id;
|
||||
if (!owner) {
|
||||
throw new Error('Authenticated archive query is missing its user identity');
|
||||
}
|
||||
const general = await ctx.db.oldGeneral.findFirst({
|
||||
where: {
|
||||
owner,
|
||||
serverId: input.serverId,
|
||||
generalNo: input.generalNo,
|
||||
},
|
||||
});
|
||||
if (!general) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '지난 장수 기록을 찾을 수 없습니다.' });
|
||||
}
|
||||
const data = asRecord(general.data);
|
||||
return {
|
||||
serverId: general.serverId,
|
||||
generalNo: general.generalNo,
|
||||
name: general.name,
|
||||
lastYearMonth: general.lastYearMonth,
|
||||
history: parseHistory(data.history),
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -41,14 +41,51 @@ const context = (session: GameSessionTokenPayload | null): GameApiContext => {
|
||||
turnTime: new Date('2025-01-01T00:00:00.000Z'),
|
||||
data: {
|
||||
nation: 3,
|
||||
leadership: 80,
|
||||
strength: 70,
|
||||
leader: 80,
|
||||
power: 70,
|
||||
intel: 60,
|
||||
personal: 'che_의리',
|
||||
officer_level: 8,
|
||||
personal: 3,
|
||||
history: '<C>●</>첫 기록<br><Y>●</>둘째 기록<br>',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
serverId: 'che_legacy_1',
|
||||
generalNo: 11,
|
||||
owner: 'user-1',
|
||||
name: '현재형장수',
|
||||
lastYearMonth: 22012,
|
||||
turnTime: new Date('2025-01-01T00:00:00.000Z'),
|
||||
data: {
|
||||
nationId: 3,
|
||||
stats: { leadership: 81, strength: 71, intelligence: 61 },
|
||||
officerLevel: 7,
|
||||
role: {
|
||||
personality: 'che_의리',
|
||||
specialDomestic: 'che_상재',
|
||||
specialWar: 'che_신산',
|
||||
},
|
||||
history: ['<C>●</>현재형 기록'],
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
findFirst: async ({ where }: { where: { owner: string; serverId: string; generalNo: number } }) =>
|
||||
where.owner === 'user-1' && where.serverId === 'che_legacy_1' && where.generalNo === 10
|
||||
? {
|
||||
id: 1,
|
||||
serverId: 'che_legacy_1',
|
||||
generalNo: 10,
|
||||
owner: 'user-1',
|
||||
name: '과거장수',
|
||||
lastYearMonth: 22012,
|
||||
turnTime: new Date('2025-01-01T00:00:00.000Z'),
|
||||
data: {
|
||||
history: '<C>●</>첫 기록<br><Y>●</>둘째 기록<br>',
|
||||
},
|
||||
}
|
||||
: null,
|
||||
},
|
||||
gameHistory: {
|
||||
findMany: async () => [
|
||||
@@ -77,6 +114,9 @@ const context = (session: GameSessionTokenPayload | null): GameApiContext => {
|
||||
},
|
||||
],
|
||||
},
|
||||
emperor: {
|
||||
findMany: async () => [{ id: 7, serverId: 'che_legacy_1' }],
|
||||
},
|
||||
};
|
||||
const redis = {
|
||||
get: async () => null,
|
||||
@@ -108,14 +148,56 @@ describe('archive.myPastPlays', () => {
|
||||
expect.objectContaining({
|
||||
serverId: 'che_legacy_1',
|
||||
scenarioName: '테스트',
|
||||
dynastyId: 7,
|
||||
generals: [
|
||||
expect.objectContaining({
|
||||
name: '과거장수',
|
||||
nationName: '촉',
|
||||
leadership: 80,
|
||||
strength: 70,
|
||||
officerLevel: 8,
|
||||
personal: '3',
|
||||
historyCount: 2,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: '현재형장수',
|
||||
nationName: '촉',
|
||||
leadership: 81,
|
||||
strength: 71,
|
||||
intel: 61,
|
||||
officerLevel: 7,
|
||||
personal: 'che_의리',
|
||||
special: 'che_상재',
|
||||
special2: 'che_신산',
|
||||
historyCount: 1,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('loads archived history only for a general owned by the authenticated session', async () => {
|
||||
const input = { serverId: 'che_legacy_1', generalNo: 10 };
|
||||
await expect(appRouter.createCaller(context(null)).archive.myPastPlayDetail(input)).rejects.toMatchObject({
|
||||
code: 'UNAUTHORIZED',
|
||||
});
|
||||
|
||||
const result = await appRouter.createCaller(context(auth)).archive.myPastPlayDetail(input);
|
||||
expect(result).toEqual({
|
||||
serverId: 'che_legacy_1',
|
||||
generalNo: 10,
|
||||
name: '과거장수',
|
||||
lastYearMonth: 22012,
|
||||
history: ['<C>●</>첫 기록', '<Y>●</>둘째 기록'],
|
||||
});
|
||||
|
||||
const otherUser = {
|
||||
...auth,
|
||||
sessionId: 'other-session',
|
||||
user: { ...auth.user, id: 'user-2', username: 'user-2' },
|
||||
};
|
||||
await expect(appRouter.createCaller(context(otherUser)).archive.myPastPlayDetail(input)).rejects.toMatchObject({
|
||||
code: 'NOT_FOUND',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { asRecord, HALL_OF_FAME_TYPES, type HallOfFameType } from '@sammo-ts/common';
|
||||
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
|
||||
import { LogCategory, LogScope } from '@sammo-ts/logic';
|
||||
|
||||
import type { GeneralLifecycleEvent } from './inMemoryWorld.js';
|
||||
|
||||
@@ -260,10 +261,20 @@ const archiveDeletedGeneral = async (
|
||||
): Promise<void> => {
|
||||
const serverId =
|
||||
typeof worldMeta.serverId === 'string' && worldMeta.serverId.trim() ? worldMeta.serverId.trim() : 'default';
|
||||
const history = await prisma.logEntry.findMany({
|
||||
where: {
|
||||
generalId: event.generalId,
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.HISTORY,
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
select: { text: true },
|
||||
});
|
||||
const data = {
|
||||
...event.before,
|
||||
turnTime: event.before.turnTime.toISOString(),
|
||||
recentWarTime: event.before.recentWarTime?.toISOString() ?? null,
|
||||
history: history.map((entry) => entry.text),
|
||||
};
|
||||
await prisma.oldGeneral.upsert({
|
||||
where: { by_no: { serverId, generalNo: event.generalId } },
|
||||
|
||||
@@ -651,6 +651,26 @@ export const createUnificationHandler = (options: {
|
||||
const oldGeneralTargets = generalRows.filter(
|
||||
(general) => general.nationId === 0 || general.nationId === winnerNationId
|
||||
);
|
||||
const generalHistoryRows = oldGeneralTargets.length
|
||||
? await prisma.logEntry.findMany({
|
||||
where: {
|
||||
generalId: { in: oldGeneralTargets.map((general) => general.id) },
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.HISTORY,
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
select: { generalId: true, text: true },
|
||||
})
|
||||
: [];
|
||||
const historyByGeneral = new Map<number, string[]>();
|
||||
for (const row of generalHistoryRows) {
|
||||
if (row.generalId === null) {
|
||||
continue;
|
||||
}
|
||||
const history = historyByGeneral.get(row.generalId) ?? [];
|
||||
history.push(row.text);
|
||||
historyByGeneral.set(row.generalId, history);
|
||||
}
|
||||
await Promise.all(
|
||||
oldGeneralTargets.map((general) =>
|
||||
((snapshot) =>
|
||||
@@ -680,6 +700,7 @@ export const createUnificationHandler = (options: {
|
||||
}))({
|
||||
...general,
|
||||
turnTime: general.turnTime.toISOString(),
|
||||
history: historyByGeneral.get(general.id) ?? [],
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
import { LogCategory, LogScope } from '@sammo-ts/logic';
|
||||
|
||||
import type { GeneralLifecycleEvent } from '../src/turn/inMemoryWorld.js';
|
||||
import { persistGeneralLifecycleEvents } from '../src/turn/generalTurnLifecyclePersistence.js';
|
||||
@@ -72,6 +74,7 @@ integration('general turn lifecycle persistence', () => {
|
||||
let close: (() => Promise<void>) | undefined;
|
||||
|
||||
const cleanup = async () => {
|
||||
await db.logEntry.deleteMany({ where: { generalId: { in: generalIds } } });
|
||||
await db.generalAccessLog.deleteMany({ where: { generalId: { in: generalIds } } });
|
||||
await db.rankData.deleteMany({ where: { generalId: { in: generalIds } } });
|
||||
await db.oldGeneral.deleteMany({ where: { serverId, generalNo: { in: generalIds } } });
|
||||
@@ -116,6 +119,26 @@ integration('general turn lifecycle persistence', () => {
|
||||
{ generalId: general.id, nationId: 0, type: 'firenum', value: 1 },
|
||||
],
|
||||
});
|
||||
await db.logEntry.createMany({
|
||||
data: [
|
||||
{
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.HISTORY,
|
||||
year: 199,
|
||||
month: 12,
|
||||
generalId: general.id,
|
||||
text: '<C>●</>첫 기록',
|
||||
},
|
||||
{
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.HISTORY,
|
||||
year: 200,
|
||||
month: 1,
|
||||
generalId: general.id,
|
||||
text: '<Y>●</>둘째 기록',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await db.$transaction((tx) =>
|
||||
persistGeneralLifecycleEvents(
|
||||
@@ -127,7 +150,10 @@ integration('general turn lifecycle persistence', () => {
|
||||
);
|
||||
|
||||
expect(await db.generalAccessLog.findUnique({ where: { generalId: general.id } })).toBeNull();
|
||||
expect(await db.oldGeneral.findUnique({ where: { by_no: { serverId, generalNo: general.id } } })).not.toBeNull();
|
||||
const archived = await db.oldGeneral.findUniqueOrThrow({
|
||||
where: { by_no: { serverId, generalNo: general.id } },
|
||||
});
|
||||
expect(asRecord(archived.data).history).toEqual(['<Y>●</>둘째 기록', '<C>●</>첫 기록']);
|
||||
expect(
|
||||
await db.inheritancePoint.findUnique({
|
||||
where: { userId_key: { userId: general.userId!, key: 'previous' } },
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GamePrisma } from '@sammo-ts/infra';
|
||||
import { LogCategory, LogScope } from '@sammo-ts/logic';
|
||||
|
||||
import { persistGeneralLifecycleEvents } from '../src/turn/generalTurnLifecyclePersistence.js';
|
||||
import type { GeneralLifecycleEvent } from '../src/turn/inMemoryWorld.js';
|
||||
import type { TurnGeneral } from '../src/turn/types.js';
|
||||
|
||||
const archivedGeneral = (): TurnGeneral => ({
|
||||
id: 91,
|
||||
userId: null,
|
||||
name: '기록장수',
|
||||
nationId: 0,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
stats: { leadership: 80, strength: 70, intelligence: 60 },
|
||||
experience: 1_000,
|
||||
dedication: 500,
|
||||
officerLevel: 0,
|
||||
role: {
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
injury: 0,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
crew: 0,
|
||||
crewTypeId: 1,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
age: 70,
|
||||
npcState: 2,
|
||||
bornYear: 170,
|
||||
deadYear: 240,
|
||||
affinity: 50,
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { killturn: 0 },
|
||||
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
describe('general lifecycle archive history', () => {
|
||||
it('stores the general history in descending log order when a general is deleted', async () => {
|
||||
const general = archivedGeneral();
|
||||
const upsert = vi.fn(async () => undefined);
|
||||
const prisma = {
|
||||
generalAccessLog: {
|
||||
updateMany: vi.fn(async () => ({ count: 1 })),
|
||||
deleteMany: vi.fn(async () => ({ count: 1 })),
|
||||
},
|
||||
logEntry: {
|
||||
findMany: vi.fn(async () => [{ text: '<Y>●</>둘째 기록' }, { text: '<C>●</>첫 기록' }]),
|
||||
},
|
||||
oldGeneral: { upsert },
|
||||
} as unknown as GamePrisma.TransactionClient;
|
||||
const event: GeneralLifecycleEvent = {
|
||||
generalId: general.id,
|
||||
outcome: 'deleted',
|
||||
before: general,
|
||||
year: 200,
|
||||
month: 1,
|
||||
};
|
||||
|
||||
await persistGeneralLifecycleEvents(prisma, [event], { serverId: 'archive-fixture' }, {});
|
||||
|
||||
expect(prisma.logEntry.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
generalId: general.id,
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.HISTORY,
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
select: { text: true },
|
||||
});
|
||||
expect(upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
create: expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
history: ['<Y>●</>둘째 기록', '<C>●</>첫 기록'],
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ const installArchive = async (page: Page) => {
|
||||
season: 51,
|
||||
scenario: 2,
|
||||
scenarioName: '천하쟁패',
|
||||
dynastyId: 7,
|
||||
generals: [
|
||||
{
|
||||
generalNo: 17,
|
||||
@@ -41,12 +42,22 @@ const installArchive = async (page: Page) => {
|
||||
personal: '대담',
|
||||
special: '상재',
|
||||
special2: '신산',
|
||||
historyCount: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (operation === 'archive.myPastPlayDetail') {
|
||||
return response({
|
||||
serverId: 'che_2024_01',
|
||||
generalNo: 17,
|
||||
name: '관우',
|
||||
lastYearMonth: 21403,
|
||||
history: ['<C>●</>214년 3월: 촉에 임관', '<Y>●</>214년 1월: 성도에서 거병'],
|
||||
});
|
||||
}
|
||||
return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } };
|
||||
});
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
|
||||
@@ -65,6 +76,12 @@ test('past plays is available without a current general and preserves desktop in
|
||||
await expect(page.getByRole('heading', { name: '내 지난 플레이 보기' })).toBeVisible();
|
||||
await expect(page.getByText('천하쟁패 · 51기')).toBeVisible();
|
||||
await expect(page.locator('.general-name')).toHaveText('관우');
|
||||
await expect(page.getByRole('link', { name: '이 기수 국가 정보' })).toHaveAttribute('href', '/che/dynasty/7');
|
||||
const historyToggle = page.locator('.history-toggle');
|
||||
await expect(historyToggle).toHaveText('보기 (2)');
|
||||
await historyToggle.click();
|
||||
await expect(page.getByText('214년 3월: 촉에 임관')).toBeVisible();
|
||||
await expect(historyToggle).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
const geometry = await root.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
@@ -117,6 +134,6 @@ test('past plays keeps the legacy-width table scrollable on a mobile viewport',
|
||||
overflowX: getComputedStyle(element).overflowX,
|
||||
}));
|
||||
// The shared legacy shell keeps its historical 500 px minimum canvas.
|
||||
expect(metrics).toEqual({ clientWidth: 500, scrollWidth: 820, overflowX: 'auto' });
|
||||
expect(metrics).toEqual({ clientWidth: 500, scrollWidth: 940, overflowX: 'auto' });
|
||||
await expect(page.locator('.title-row')).toHaveCSS('flex-direction', 'column');
|
||||
});
|
||||
|
||||
@@ -4,10 +4,18 @@ import { onMounted, ref } from 'vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type Archive = Awaited<ReturnType<typeof trpc.archive.myPastPlays.query>>;
|
||||
type PastPlayDetail = Awaited<ReturnType<typeof trpc.archive.myPastPlayDetail.query>>;
|
||||
type DetailState = {
|
||||
open: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
detail: PastPlayDetail | null;
|
||||
};
|
||||
|
||||
const archive = ref<Archive | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const details = ref<Record<string, DetailState>>({});
|
||||
|
||||
const loadArchive = async () => {
|
||||
if (loading.value) return;
|
||||
@@ -24,6 +32,43 @@ const loadArchive = async () => {
|
||||
|
||||
const yearMonth = (value: number): string => `${Math.floor(value / 100)}년 ${value % 100}월`;
|
||||
const valueOrDash = (value: number | string | null): string => (value === null || value === '' ? '-' : String(value));
|
||||
const plainLog = (value: string): string => value.replace(/<[^>]+>/g, '');
|
||||
const detailKey = (serverId: string, generalNo: number): string => `${serverId}:${generalNo}`;
|
||||
|
||||
const toggleHistory = async (serverId: string, generalNo: number): Promise<void> => {
|
||||
const key = detailKey(serverId, generalNo);
|
||||
const current = details.value[key];
|
||||
if (current?.open) {
|
||||
details.value = { ...details.value, [key]: { ...current, open: false } };
|
||||
return;
|
||||
}
|
||||
if (current?.detail) {
|
||||
details.value = { ...details.value, [key]: { ...current, open: true } };
|
||||
return;
|
||||
}
|
||||
|
||||
details.value = {
|
||||
...details.value,
|
||||
[key]: { open: true, loading: true, error: null, detail: null },
|
||||
};
|
||||
try {
|
||||
const detail = await trpc.archive.myPastPlayDetail.query({ serverId, generalNo });
|
||||
details.value = {
|
||||
...details.value,
|
||||
[key]: { open: true, loading: false, error: null, detail },
|
||||
};
|
||||
} catch (cause) {
|
||||
details.value = {
|
||||
...details.value,
|
||||
[key]: {
|
||||
open: true,
|
||||
loading: false,
|
||||
error: cause instanceof Error ? cause.message : '장수 열전을 불러오지 못했습니다.',
|
||||
detail: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
void loadArchive();
|
||||
@@ -48,10 +93,19 @@ onMounted(() => {
|
||||
<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 class="season-actions">
|
||||
<span>
|
||||
{{ season.scenarioName ?? '시나리오 미상' }}
|
||||
<template v-if="season.season !== null"> · {{ season.season }}기</template>
|
||||
</span>
|
||||
<RouterLink
|
||||
v-if="season.dynastyId !== null"
|
||||
class="legacy-button nation-archive-link"
|
||||
:to="`/dynasty/${season.dynastyId}`"
|
||||
>
|
||||
이 기수 국가 정보
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
@@ -67,28 +121,82 @@ onMounted(() => {
|
||||
<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>
|
||||
<template v-for="general in season.generals" :key="general.generalNo">
|
||||
<tr>
|
||||
<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>
|
||||
<td>
|
||||
<button
|
||||
class="legacy-button history-toggle"
|
||||
type="button"
|
||||
:aria-expanded="
|
||||
details[detailKey(season.serverId, general.generalNo)]?.open ?? false
|
||||
"
|
||||
@click="toggleHistory(season.serverId, general.generalNo)"
|
||||
>
|
||||
{{
|
||||
details[detailKey(season.serverId, general.generalNo)]?.open
|
||||
? '접기'
|
||||
: `보기 (${general.historyCount})`
|
||||
}}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="details[detailKey(season.serverId, general.generalNo)]?.open" class="history-row">
|
||||
<td colspan="11">
|
||||
<p
|
||||
v-if="details[detailKey(season.serverId, general.generalNo)]?.loading"
|
||||
role="status"
|
||||
>
|
||||
장수 열전을 불러오는 중...
|
||||
</p>
|
||||
<p
|
||||
v-else-if="details[detailKey(season.serverId, general.generalNo)]?.error"
|
||||
class="history-error"
|
||||
role="alert"
|
||||
>
|
||||
{{ details[detailKey(season.serverId, general.generalNo)]?.error }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="
|
||||
details[detailKey(season.serverId, general.generalNo)]?.detail?.history
|
||||
.length === 0
|
||||
"
|
||||
>
|
||||
보관된 장수 열전이 없습니다.
|
||||
</p>
|
||||
<ol v-else class="history-list">
|
||||
<li
|
||||
v-for="(entry, index) in details[
|
||||
detailKey(season.serverId, general.generalNo)
|
||||
]?.detail?.history ?? []"
|
||||
:key="index"
|
||||
>
|
||||
{{ plainLog(entry) }}
|
||||
</li>
|
||||
</ol>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -190,13 +298,24 @@ onMounted(() => {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.season-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nation-archive-link {
|
||||
min-height: 24px;
|
||||
padding-block: 2px;
|
||||
}
|
||||
|
||||
.table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
min-width: 820px;
|
||||
min-width: 940px;
|
||||
border-collapse: collapse;
|
||||
table-layout: auto;
|
||||
background: #191919;
|
||||
@@ -229,11 +348,45 @@ th {
|
||||
text-shadow: 0 1px 1px #000;
|
||||
}
|
||||
|
||||
.history-toggle {
|
||||
min-height: 24px;
|
||||
padding-block: 2px;
|
||||
}
|
||||
|
||||
.history-row td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
background: #101010;
|
||||
}
|
||||
|
||||
.history-row p {
|
||||
margin: 0;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.history-error {
|
||||
color: #ff8d8d !important;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
margin: 0;
|
||||
padding-left: 26px;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.title-row,
|
||||
.season-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.season-actions {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -77,6 +77,26 @@ excluded. In particular, `general`, `city`, `nation`, their turn queues,
|
||||
`ng_betting`, `reserved_open`, `select_pool`, `select_npc_token` and `plock`
|
||||
must not be used to reconstruct a running season.
|
||||
|
||||
## Archived play read model
|
||||
|
||||
`/past-plays` is an authenticated, read-only projection. The server derives the
|
||||
archive owner from the game session and never accepts an owner ID from the
|
||||
browser.
|
||||
|
||||
- `archive.myPastPlays` combines the owner's `ng_old_generals` rows with
|
||||
`ng_games`, the latest matching `ng_old_nations` snapshot and an optional
|
||||
`emperior` row. It returns summary fields and a link target for the existing
|
||||
public dynasty/nation detail.
|
||||
- `archive.myPastPlayDetail(serverId, generalNo)` includes the session owner in
|
||||
the database predicate before returning `data.history`. A foreign or missing
|
||||
record uses the same not-found response.
|
||||
- Legacy `data.history` may be either an array or a `<br>`-joined string. The API
|
||||
normalizes both to a newest-first string array, and the frontend renders plain
|
||||
text rather than archived markup.
|
||||
- Runtime death and unification archival writes the current general
|
||||
`GENERAL/HISTORY` rows into the same `data.history` field, so newly completed
|
||||
seasons remain compatible with imported rows.
|
||||
|
||||
## Cutover procedure
|
||||
|
||||
1. Keep the original compressed dumps immutable and restore each source to a
|
||||
@@ -90,8 +110,8 @@ must not be used to reconstruct a running season.
|
||||
run the same commands with `--apply`.
|
||||
6. Repeat each apply. Counts must remain unchanged.
|
||||
7. Verify Kakao migration timestamps, password-hash shapes, archive ownership,
|
||||
old-nation/history duplicate preservation and the `/past-plays` authenticated
|
||||
read path.
|
||||
old-nation/history duplicate preservation, `/past-plays` list/detail access,
|
||||
foreign-owner denial and the dynasty link.
|
||||
8. Retain the MariaDB dumps as rollback evidence. Rollback restores the
|
||||
pre-cutover PostgreSQL backup; it does not reverse individual importer
|
||||
upserts.
|
||||
|
||||
Reference in New Issue
Block a user