feat(archive): 과거 장수 서버별 조회와 명전 기록 추가

This commit is contained in:
2026-08-19 01:59:17 +00:00
parent add7ab96d1
commit 5e0c26ff3a
5 changed files with 400 additions and 23 deletions
+59 -7
View File
@@ -19,11 +19,13 @@ import {
findLegacyEmperors,
findLegacyGeneral,
findLegacyGeneralBattleResult,
findLegacyGeneralHallRows,
findLegacyGeneralsByOwner,
findLegacyGames,
findLegacyNations,
LEGACY_ARCHIVE_PROFILES,
type LegacyArchiveProfile,
type LegacyGeneralHallRow,
} from '../../services/legacyArchiveStore.js';
import { readOnlyAuthedProcedure, router } from '../../trpc.js';
import { loadTraitNames } from '../nation/shared.js';
@@ -53,6 +55,10 @@ const zPastPlayDetailInput = z.object({
generalNo: z.number().int().positive(),
});
const zPastPlaysInput = z.object({
sourceProfile: z.enum(LEGACY_ARCHIVE_PROFILES),
});
type ArchiveSource = 'current' | 'legacy';
interface GeneralArchiveEntry {
@@ -75,6 +81,42 @@ interface ArchiveNationEntry {
data: Record<string, unknown>;
}
export interface LegacyHallBattleSummary {
available: boolean;
semantics: 'independent-records';
strategies: number | null;
warnum: number | null;
wins: number | null;
winRate: number | null;
occupied: number | null;
killCrew: number | null;
killRate: number | null;
killCrewPerson: number | null;
killRatePerson: number | null;
}
const legacyHallBattleSummary = (rows: LegacyGeneralHallRow[]): LegacyHallBattleSummary => {
const values = new Map(rows.map((row) => [row.type, row.value]));
const value = (type: LegacyGeneralHallRow['type']): number | null => values.get(type) ?? null;
const percent = (type: LegacyGeneralHallRow['type']): number | null => {
const raw = value(type);
return raw === null ? null : raw * 100;
};
return {
available: rows.length > 0,
semantics: 'independent-records',
strategies: value('firenum'),
warnum: value('warnum'),
wins: value('killnum'),
winRate: percent('winrate'),
occupied: value('occupied'),
killCrew: value('killcrew'),
killRate: percent('killrate'),
killCrewPerson: value('killcrew_person'),
killRatePerson: percent('killrate_person'),
};
};
const key = (source: ArchiveSource, sourceProfile: string, serverId: string): string =>
`${source}:${sourceProfile}:${serverId}`;
@@ -190,16 +232,18 @@ const buildGeneralDetail = async (entry: GeneralArchiveEntry, nation: ReturnType
};
export const archiveRouter = router({
myPastPlays: readOnlyAuthedProcedure.query(async ({ ctx }) => {
myPastPlays: readOnlyAuthedProcedure.input(zPastPlaysInput).query(async ({ ctx, input }) => {
const owner = ctx.auth?.user.id;
if (!owner) throw new Error('Authenticated archive query is missing its user identity');
const [legacyRows, currentRows] = await Promise.all([
findLegacyGeneralsByOwner(ctx.db, owner),
ctx.db.oldGeneral.findMany({
where: { owner },
orderBy: [{ lastYearMonth: 'desc' }, { serverId: 'desc' }, { generalNo: 'asc' }],
}),
findLegacyGeneralsByOwner(ctx.db, { owner, sourceProfile: input.sourceProfile }),
input.sourceProfile === ctx.profile.id
? ctx.db.oldGeneral.findMany({
where: { owner },
orderBy: [{ lastYearMonth: 'desc' }, { serverId: 'desc' }, { generalNo: 'asc' }],
})
: [],
]);
const legacyIdentity = new Set(
legacyRows.map((row) => `${row.sourceProfile}:${row.serverId}:${row.generalNo}`)
@@ -454,6 +498,7 @@ export const archiveRouter = router({
let dynastyId: number | null = null;
let battleResultContent: string | null = null;
let battleResultAvailable = false;
let hallBattle = legacyHallBattleSummary([]);
if (input.source === 'legacy') {
if (!LEGACY_ARCHIVE_PROFILES.includes(sourceProfile as LegacyArchiveProfile)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이전 서버 프로필입니다.' });
@@ -477,7 +522,7 @@ export const archiveRouter = router({
snapshot: canonicalSnapshot(row.data, row.name),
};
const keyInput = [{ sourceProfile: profile, serverId: input.serverId }];
const [nations, emperors, battleResult] = await Promise.all([
const [nations, emperors, battleResult, hallRows] = await Promise.all([
findLegacyNations(ctx.db, keyInput),
findLegacyEmperors(ctx.db, keyInput),
findLegacyGeneralBattleResult(ctx.db, {
@@ -485,6 +530,11 @@ export const archiveRouter = router({
serverId: input.serverId,
generalNo: input.generalNo,
}),
findLegacyGeneralHallRows(ctx.db, {
sourceProfile: profile,
serverId: input.serverId,
generalNo: input.generalNo,
}),
]);
nationRows = nations.map((nation) => ({
source: 'legacy',
@@ -497,6 +547,7 @@ export const archiveRouter = router({
dynastyId = Number(emperors[0]?.id ?? 0) || null;
battleResultContent = battleResult?.content ?? null;
battleResultAvailable = battleResult !== null;
hallBattle = legacyHallBattleSummary(hallRows);
}
} else {
const row = await ctx.db.oldGeneral.findFirst({
@@ -580,6 +631,7 @@ export const archiveRouter = router({
killRate: snapshot.battle.killRate,
recentWar: snapshot.battle.recentWar,
},
hallBattle,
logs,
};
}),
@@ -42,6 +42,25 @@ export interface LegacyGeneralBattleResultRow {
contentHash: string;
}
export const LEGACY_GENERAL_HALL_TYPES = [
'firenum',
'warnum',
'killnum',
'winrate',
'occupied',
'killcrew',
'killrate',
'killcrew_person',
'killrate_person',
] as const;
export type LegacyGeneralHallType = (typeof LEGACY_GENERAL_HALL_TYPES)[number];
export interface LegacyGeneralHallRow {
type: LegacyGeneralHallType;
value: number;
}
export interface LegacyNationRow {
sourceProfile: LegacyArchiveProfile;
legacyId: number;
@@ -79,7 +98,7 @@ export interface LegacyHallRow {
export const findLegacyGeneralsByOwner = async (
db: LegacyArchiveDatabase,
owner: string
input: { owner: string; sourceProfile: LegacyArchiveProfile }
): Promise<LegacyGeneralRow[]> =>
db.$queryRaw<LegacyGeneralRow[]>(GamePrisma.sql`
SELECT
@@ -95,8 +114,9 @@ export const findLegacyGeneralsByOwner = async (
"source_format" AS "sourceFormat",
"data"
FROM "legacy_archive"."general"
WHERE "owner" = ${owner}
ORDER BY "last_yearmonth" DESC, "source_profile", "server_id" DESC, "general_no"
WHERE "owner" = ${input.owner}
AND "source_profile" = ${input.sourceProfile}
ORDER BY "last_yearmonth" DESC, "server_id" DESC, "general_no"
`);
export const findLegacyGeneral = async (
@@ -144,6 +164,22 @@ export const findLegacyGeneralBattleResult = async (
return rows[0] ?? null;
};
export const findLegacyGeneralHallRows = async (
db: LegacyArchiveDatabase,
input: { sourceProfile: LegacyArchiveProfile; serverId: string; generalNo: number }
): Promise<LegacyGeneralHallRow[]> =>
db.$queryRaw<LegacyGeneralHallRow[]>(GamePrisma.sql`
SELECT
"type",
"value"
FROM "legacy_archive"."hall"
WHERE "source_profile" = ${input.sourceProfile}
AND "server_id" = ${input.serverId}
AND "general_no" = ${input.generalNo}
AND "type" IN (${GamePrisma.join(LEGACY_GENERAL_HALL_TYPES)})
ORDER BY "type"
`);
export const findLegacyGeneralsForServer = async (
db: LegacyArchiveDatabase,
input: { sourceProfile: LegacyArchiveProfile; serverId: string; generalNos: number[] }
+49 -5
View File
@@ -31,9 +31,22 @@ const context = (
includeCancellation = false
): GameApiContext => {
const db = {
$queryRaw: async (query: { strings?: readonly string[] }) => {
$queryRaw: async (query: { strings?: readonly string[]; values?: readonly unknown[] }) => {
if (!includeLegacy) return [];
const sql = query.strings?.join(' ') ?? '';
if (sql.includes('legacy_archive"."hall')) {
return [
{ type: 'firenum', value: 4 },
{ type: 'warnum', value: 16 },
{ type: 'killnum', value: 10 },
{ type: 'winrate', value: 0.625 },
{ type: 'occupied', value: 3 },
{ type: 'killcrew', value: 12_000 },
{ type: 'killrate', value: 0.75 },
{ type: 'killcrew_person', value: 9_000 },
{ type: 'killrate_person', value: 0.5 },
];
}
if (sql.includes('legacy_archive"."general_battle_result')) {
return [
{
@@ -44,6 +57,7 @@ const context = (
];
}
if (sql.includes('legacy_archive"."general')) {
if (!query.values?.includes('hwe')) return [];
return [
{
sourceProfile: 'hwe',
@@ -300,10 +314,12 @@ const context = (
describe('archive.myPastPlays', () => {
it('requires authentication and returns only the authenticated owner archive', async () => {
await expect(appRouter.createCaller(context(null)).archive.myPastPlays()).rejects.toMatchObject({
await expect(
appRouter.createCaller(context(null)).archive.myPastPlays({ sourceProfile: 'che' })
).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
const result = await appRouter.createCaller(context(auth)).archive.myPastPlays();
const result = await appRouter.createCaller(context(auth)).archive.myPastPlays({ sourceProfile: 'che' });
expect(result.seasons).toEqual([
expect.objectContaining({
source: 'current',
@@ -381,7 +397,9 @@ describe('archive.myPastPlays', () => {
});
it('labels a retained cancellation as an unnumbered abandoned game without a dynasty link', async () => {
const result = await appRouter.createCaller(context(auth, false, true)).archive.myPastPlays();
const result = await appRouter
.createCaller(context(auth, false, true))
.archive.myPastPlays({ sourceProfile: 'che' });
expect(result.seasons).toEqual([
expect.objectContaining({
@@ -397,7 +415,7 @@ describe('archive.myPastPlays', () => {
it('returns normalized previous-server detail from the dedicated archive without exposing raw data', async () => {
const caller = appRouter.createCaller(context(auth, true));
const list = await caller.archive.myPastPlays();
const list = await caller.archive.myPastPlays({ sourceProfile: 'hwe' });
expect(list.seasons).toContainEqual(
expect.objectContaining({
source: 'legacy',
@@ -427,6 +445,19 @@ describe('archive.myPastPlays', () => {
progression: expect.objectContaining({ dex: [1000, 2000, 3000, 4000, 5000] }),
}),
battle: expect.objectContaining({ available: true, warnum: 10, wins: 6, winRate: 60 }),
hallBattle: {
available: true,
semantics: 'independent-records',
strategies: 4,
warnum: 16,
wins: 10,
winRate: 62.5,
occupied: 3,
killCrew: 12_000,
killRate: 75,
killCrewPerson: 9_000,
killRatePerson: 50,
},
logs: expect.objectContaining({
generalHistory: { available: true, entries: [{ id: 1, text: '이전 서버 열전' }] },
battleDetail: { available: false, entries: [] },
@@ -441,4 +472,17 @@ describe('archive.myPastPlays', () => {
});
expect(JSON.stringify(detail)).not.toContain('raw_data');
});
it('limits the archive list to the requested source profile', async () => {
const caller = appRouter.createCaller(context(auth, true));
const che = await caller.archive.myPastPlays({ sourceProfile: 'che' });
expect(che.seasons).toHaveLength(1);
expect(che.seasons[0]?.sourceProfile).toBe('che');
const hwe = await caller.archive.myPastPlays({ sourceProfile: 'hwe' });
expect(hwe.seasons).toHaveLength(1);
expect(hwe.seasons[0]?.sourceProfile).toBe('hwe');
expect(hwe.seasons[0]?.source).toBe('legacy');
});
});