feat(archive): 과거 장수 서버별 조회와 명전 기록 추가
This commit is contained in:
@@ -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[] }
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user