feat: 이전 서버 기록 조회 화면을 통합

지난 플레이를 현재 장수 카드와 전투·숙련도·기록 컴포넌트로 표시하고, 중앙 archive의 소유자 기반 조회를 연결한다. 명예의 전당과 왕조에 현재/이전 서버 source 분리를 추가한다.
This commit is contained in:
2026-08-17 15:55:43 +00:00
parent fc7de05017
commit d6a25c63b0
18 changed files with 2594 additions and 777 deletions
+385 -306
View File
@@ -7,6 +7,11 @@ import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacy
import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import { accessAuthedInputProcedure, accessInputProcedure, procedure, router } from '../../trpc.js';
import {
findLegacyHallOptions,
findLegacyHallRows,
LEGACY_ARCHIVE_PROFILES,
} from '../../services/legacyArchiveStore.js';
const DEFAULT_BG_COLOR = '#330000';
const DEFAULT_FG_COLOR = '#ffffff';
@@ -82,372 +87,446 @@ const loadUniqueItems = () => {
export const rankingRouter = router({
getBestGeneral: accessAuthedInputProcedure(
z
.object({
view: z.enum(['user', 'npc']).optional(),
})
.optional()
)
.query(async ({ ctx, input }) => {
const worldState = await ctx.db.worldState.findFirst({
select: { meta: true, config: true },
});
const meta = asRecord(worldState?.meta);
const isUnited = typeof meta.isUnited === 'number' && meta.isUnited !== 0;
z
.object({
view: z.enum(['user', 'npc']).optional(),
})
.optional()
).query(async ({ ctx, input }) => {
const worldState = await ctx.db.worldState.findFirst({
select: { meta: true, config: true },
});
const meta = asRecord(worldState?.meta);
const isUnited = typeof meta.isUnited === 'number' && meta.isUnited !== 0;
const view = input?.view ?? 'user';
const npcFilter = view === 'npc' ? { gte: 2 } : { lt: 2 };
const view = input?.view ?? 'user';
const npcFilter = view === 'npc' ? { gte: 2 } : { lt: 2 };
const [nations, generals] = await Promise.all([
ctx.db.nation.findMany({ select: { id: true, name: true, color: true } }),
ctx.db.general.findMany({
where: { npcState: npcFilter },
orderBy: { id: 'asc' },
select: {
id: true,
name: true,
nationId: true,
userId: true,
picture: true,
imageServer: true,
meta: true,
experience: true,
dedication: true,
horseCode: true,
weaponCode: true,
bookCode: true,
itemCode: true,
},
}),
]);
const [nations, generals] = await Promise.all([
ctx.db.nation.findMany({ select: { id: true, name: true, color: true } }),
ctx.db.general.findMany({
where: { npcState: npcFilter },
orderBy: { id: 'asc' },
select: {
id: true,
name: true,
nationId: true,
userId: true,
picture: true,
imageServer: true,
meta: true,
experience: true,
dedication: true,
horseCode: true,
weaponCode: true,
bookCode: true,
itemCode: true,
},
}),
]);
const nationMap = new Map(nations.map((nation) => [nation.id, nation]));
const generalIds = generals.map((general) => general.id);
const rankRows = await ctx.db.rankData.findMany({
where: { generalId: { in: generalIds } },
select: { generalId: true, type: true, value: true },
});
const rankMap = new Map<number, Record<string, number>>();
for (const row of rankRows) {
const entry = rankMap.get(row.generalId) ?? {};
entry[row.type] = row.value;
rankMap.set(row.generalId, entry);
}
const nationMap = new Map(nations.map((nation) => [nation.id, nation]));
const generalIds = generals.map((general) => general.id);
const rankRows = await ctx.db.rankData.findMany({
where: { generalId: { in: generalIds } },
select: { generalId: true, type: true, value: true },
});
const rankMap = new Map<number, Record<string, number>>();
for (const row of rankRows) {
const entry = rankMap.get(row.generalId) ?? {};
entry[row.type] = row.value;
rankMap.set(row.generalId, entry);
}
const types: Array<[string, 'int' | 'percent', (general: typeof generals[number], ranks: Record<string, number>) => number]> = [
['명 성', 'int', (g) => g.experience],
['계 급', 'int', (g) => g.dedication],
['계 략 성 공', 'int', (_g, r) => r.firenum ?? 0],
['전 투 횟 수', 'int', (_g, r) => r.warnum ?? 0],
['승 리', 'int', (_g, r) => r.killnum ?? 0],
['승 률', 'percent', (_g, r) => {
const types: Array<
[string, 'int' | 'percent', (general: (typeof generals)[number], ranks: Record<string, number>) => number]
> = [
['명 성', 'int', (g) => g.experience],
['계 급', 'int', (g) => g.dedication],
['계 략 성 공', 'int', (_g, r) => r.firenum ?? 0],
['전 투 횟 수', 'int', (_g, r) => r.warnum ?? 0],
['승 리', 'int', (_g, r) => r.killnum ?? 0],
[
'승 률',
'percent',
(_g, r) => {
const warnum = r.warnum ?? 0;
if (warnum < 10) {
return 0;
}
return (r.killnum ?? 0) / Math.max(1, warnum);
}],
['점 령', 'int', (_g, r) => r.occupied ?? 0],
['사 살', 'int', (_g, r) => r.killcrew ?? 0],
['살 상 률', 'percent', (_g, r) => {
},
],
['점 령', 'int', (_g, r) => r.occupied ?? 0],
['살', 'int', (_g, r) => r.killcrew ?? 0],
[
'살 상 률',
'percent',
(_g, r) => {
const warnum = r.warnum ?? 0;
if (warnum < 10) {
return 0;
}
return (r.killcrew ?? 0) / Math.max(1, r.deathcrew ?? 0);
}],
['대 인 사 살', 'int', (_g, r) => r.killcrew_person ?? 0],
['대 인 살 상 률', 'percent', (_g, r) => {
},
],
['대 인 살', 'int', (_g, r) => r.killcrew_person ?? 0],
[
'대 인 살 상 률',
'percent',
(_g, r) => {
const warnum = r.warnum ?? 0;
if (warnum < 10) {
return 0;
}
return (r.killcrew_person ?? 0) / Math.max(1, r.deathcrew_person ?? 0);
}],
['보 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex1)],
[' 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex2)],
[' 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex3)],
[' 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex4)],
[' 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex5)],
['전 력 전 승 률', 'percent', (_g, r) => {
},
],
[' 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex1)],
[' 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex2)],
[' 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex3)],
[' 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex4)],
['차 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex5)],
[
'전 력 전 승 률',
'percent',
(_g, r) => {
const total = (r.ttw ?? 0) + (r.ttd ?? 0) + (r.ttl ?? 0);
if (total < 50) {
return 0;
}
return (r.ttw ?? 0) / Math.max(1, total);
}],
['통 솔 전 승 률', 'percent', (_g, r) => {
},
],
[
'통 솔 전 승 률',
'percent',
(_g, r) => {
const total = (r.tlw ?? 0) + (r.tld ?? 0) + (r.tll ?? 0);
if (total < 50) {
return 0;
}
return (r.tlw ?? 0) / Math.max(1, total);
}],
['일 기 토 승 률', 'percent', (_g, r) => {
},
],
[
'일 기 토 승 률',
'percent',
(_g, r) => {
const total = (r.tsw ?? 0) + (r.tsd ?? 0) + (r.tsl ?? 0);
if (total < 50) {
return 0;
}
return (r.tsw ?? 0) / Math.max(1, total);
}],
['설 전 승 률', 'percent', (_g, r) => {
},
],
[
'설 전 승 률',
'percent',
(_g, r) => {
const total = (r.tiw ?? 0) + (r.tid ?? 0) + (r.til ?? 0);
if (total < 50) {
return 0;
}
return (r.tiw ?? 0) / Math.max(1, total);
}],
['베 팅 투 자 액', 'int', (_g, r) => r.betgold ?? 0],
['베 팅 당 첨', 'int', (_g, r) => r.betwin ?? 0],
['베 팅 수 익 금', 'int', (_g, r) => r.betwingold ?? 0],
['베 팅 수 익 ', 'percent', (_g, r) => {
},
],
['베 팅 투 자 액', 'int', (_g, r) => r.betgold ?? 0],
['베 팅 당 첨', 'int', (_g, r) => r.betwin ?? 0],
['베 팅 수 익 ', 'int', (_g, r) => r.betwingold ?? 0],
[
'베 팅 수 익 률',
'percent',
(_g, r) => {
const betgold = r.betgold ?? 0;
if (betgold < 1000) {
return 0;
}
return (r.betwingold ?? 0) / Math.max(1, betgold);
}],
['유 산 소 모 량', 'int', (_g, r) => r.inherit_spent ?? 0],
['유 산 획 득 량', 'int', (_g, r) => r.inherit_earned ?? 0],
];
},
],
['유 산 소 모 량', 'int', (_g, r) => r.inherit_spent ?? 0],
['유 산 획 득 량', 'int', (_g, r) => r.inherit_earned ?? 0],
];
const sections = types.map(([title, valueType, valueFn]) => {
const entries = generals
const sections = types.map(([title, valueType, valueFn]) => {
const entries = generals
.map((general) => {
const ranks = rankMap.get(general.id) ?? {};
const value = valueFn(general, ranks);
const nation = nationMap.get(general.nationId) ?? null;
const bgColor = nation?.color ?? (general.nationId === 0 ? NEUTRAL_BG_COLOR : DEFAULT_BG_COLOR);
let display = {
id: general.id,
name: general.name,
ownerName: isUnited ? readOwnerDisplayName(general.meta) : null,
nationName: nation?.name ?? '재야',
bgColor,
fgColor: resolveLegacyTextColor(bgColor),
picture: general.picture ?? null,
imageServer: general.imageServer ?? 0,
value,
printValue: valueType === 'percent' ? percentText(value) : formatLegacyRankingNumber(value),
};
if (
!isUnited &&
(title === '계 략 성 공' || title === '유 산 소 모 량' || title === '유 산 획 득 량')
) {
display = {
...display,
name: '???',
ownerName: null,
nationName: '???',
bgColor: DEFAULT_BG_COLOR,
fgColor: resolveLegacyTextColor(DEFAULT_BG_COLOR),
picture: null,
imageServer: 0,
};
}
return display;
})
.filter((entry) => entry.value > 0)
.sort((a, b) => b.value - a.value)
.slice(0, 10);
return { title, valueType, entries };
});
const uniqueItems = await loadUniqueItems();
const itemRegistry = new Map(uniqueItems.map((item) => [item.key, item]));
const uniqueConfig = resolveUniqueConfig(asRecord(asRecord(worldState?.config).const));
if (Object.keys(uniqueConfig.allItems).length === 0) {
uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry);
}
const activeAuctions = await ctx.db.auction.findMany({
where: {
type: 'UNIQUE_ITEM',
status: { in: ['OPEN', 'FINALIZING'] },
targetCode: { not: null },
},
select: { targetCode: true },
});
const auctionCounts = new Map<string, number>();
for (const auction of activeAuctions) {
if (auction.targetCode) {
auctionCounts.set(auction.targetCode, (auctionCounts.get(auction.targetCode) ?? 0) + 1);
}
}
const slotTitles = {
horse: '명 마',
weapon: '명 검',
book: '명 서',
item: '도 구',
} as const;
const itemEntries = (['horse', 'weapon', 'book', 'item'] as const).map((slot) => {
const configuredItems = Object.entries(uniqueConfig.allItems[slot] ?? {}).reverse();
const entries = configuredItems.flatMap(([itemKey, rawCount]) => {
const item = itemRegistry.get(itemKey);
if (!item || item.buyable) {
return [];
}
const owners = generals
.filter((general) => {
if (slot === 'horse') {
return general.horseCode === itemKey;
}
if (slot === 'weapon') {
return general.weaponCode === itemKey;
}
if (slot === 'book') {
return general.bookCode === itemKey;
}
return general.itemCode === itemKey;
})
.map((general) => {
const ranks = rankMap.get(general.id) ?? {};
const value = valueFn(general, ranks);
const nation = nationMap.get(general.nationId) ?? null;
const bgColor =
nation?.color ?? (general.nationId === 0 ? NEUTRAL_BG_COLOR : DEFAULT_BG_COLOR);
let display = {
const bgColor = nation?.color ?? (general.nationId === 0 ? NEUTRAL_BG_COLOR : DEFAULT_BG_COLOR);
return {
id: general.id,
name: general.name,
ownerName: isUnited ? readOwnerDisplayName(general.meta) : null,
nationName: nation?.name ?? '재야',
bgColor,
fgColor: resolveLegacyTextColor(bgColor),
picture: general.picture ?? null,
imageServer: general.imageServer ?? 0,
value,
printValue:
valueType === 'percent' ? percentText(value) : formatLegacyRankingNumber(value),
};
if (!isUnited && (title === '계 략 성 공' || title === '유 산 소 모 량' || title === '유 산 획 득 량')) {
display = {
...display,
name: '???',
ownerName: null,
nationName: '???',
bgColor: DEFAULT_BG_COLOR,
fgColor: resolveLegacyTextColor(DEFAULT_BG_COLOR),
picture: null,
imageServer: 0,
};
}
return display;
})
.filter((entry) => entry.value > 0)
.sort((a, b) => b.value - a.value)
.slice(0, 10);
return { title, valueType, entries };
});
const uniqueItems = await loadUniqueItems();
const itemRegistry = new Map(uniqueItems.map((item) => [item.key, item]));
const uniqueConfig = resolveUniqueConfig(asRecord(asRecord(worldState?.config).const));
if (Object.keys(uniqueConfig.allItems).length === 0) {
uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry);
}
const activeAuctions = await ctx.db.auction.findMany({
where: {
type: 'UNIQUE_ITEM',
status: { in: ['OPEN', 'FINALIZING'] },
targetCode: { not: null },
},
select: { targetCode: true },
});
const auctionCounts = new Map<string, number>();
for (const auction of activeAuctions) {
if (auction.targetCode) {
auctionCounts.set(auction.targetCode, (auctionCounts.get(auction.targetCode) ?? 0) + 1);
});
for (let index = 0; index < (auctionCounts.get(itemKey) ?? 0); index += 1) {
owners.push({
id: 0,
name: '경매중',
nationName: '-',
bgColor: '#00582c',
fgColor: '#ffffff',
picture: null,
imageServer: 0,
});
}
}
const slotTitles = {
horse: '명 마',
weapon: '명 검',
book: '명 서',
item: '도 구',
} as const;
const itemEntries = (['horse', 'weapon', 'book', 'item'] as const).map((slot) => {
const configuredItems = Object.entries(uniqueConfig.allItems[slot] ?? {}).reverse();
const entries = configuredItems.flatMap(([itemKey, rawCount]) => {
const item = itemRegistry.get(itemKey);
if (!item || item.buyable) {
return [];
}
const owners = generals
.filter((general) => {
if (slot === 'horse') {
return general.horseCode === itemKey;
}
if (slot === 'weapon') {
return general.weaponCode === itemKey;
}
if (slot === 'book') {
return general.bookCode === itemKey;
}
return general.itemCode === itemKey;
})
.map((general) => {
const nation = nationMap.get(general.nationId) ?? null;
const bgColor =
nation?.color ?? (general.nationId === 0 ? NEUTRAL_BG_COLOR : DEFAULT_BG_COLOR);
return {
id: general.id,
name: general.name,
nationName: nation?.name ?? '재야',
bgColor,
fgColor: resolveLegacyTextColor(bgColor),
picture: general.picture ?? null,
imageServer: general.imageServer ?? 0,
};
});
for (let index = 0; index < (auctionCounts.get(itemKey) ?? 0); index += 1) {
owners.push({
id: 0,
name: '경매중',
nationName: '-',
bgColor: '#00582c',
fgColor: '#ffffff',
picture: null,
imageServer: 0,
});
}
const count = Math.max(0, Math.floor(rawCount));
return Array.from({ length: count }, (_, index) => ({
itemKey,
itemName: item.name,
itemInfo: item.info,
owner: owners[index] ?? {
id: 0,
name: '미발견',
nationName: '-',
bgColor: DEFAULT_BG_COLOR,
fgColor: resolveLegacyTextColor(DEFAULT_BG_COLOR),
picture: null,
imageServer: 0,
},
}));
});
return { title: slotTitles[slot], slot, entries };
const count = Math.max(0, Math.floor(rawCount));
return Array.from({ length: count }, (_, index) => ({
itemKey,
itemName: item.name,
itemInfo: item.info,
owner: owners[index] ?? {
id: 0,
name: '미발견',
nationName: '-',
bgColor: DEFAULT_BG_COLOR,
fgColor: resolveLegacyTextColor(DEFAULT_BG_COLOR),
picture: null,
imageServer: 0,
},
}));
});
return {
isUnited,
sections,
uniqueItems: itemEntries,
};
}),
getHallOfFameOptions: procedure.query(async ({ ctx }) => {
const rows = await ctx.db.gameHistory.findMany({
select: { season: true, scenario: true, scenarioName: true },
orderBy: [{ season: 'desc' }, { scenario: 'asc' }],
return { title: slotTitles[slot], slot, entries };
});
const seasonMap = new Map<number, { season: number; scenarios: Array<{ id: number; name: string; count: number }> }>();
for (const row of rows) {
const entry = seasonMap.get(row.season) ?? { season: row.season, scenarios: [] };
const scenario = entry.scenarios.find((item) => item.id === row.scenario);
if (scenario) {
scenario.count += 1;
} else {
entry.scenarios.push({ id: row.scenario, name: row.scenarioName, count: 1 });
}
seasonMap.set(row.season, entry);
}
return Array.from(seasonMap.values());
return {
isUnited,
sections,
uniqueItems: itemEntries,
};
}),
getHallOfFame: accessInputProcedure(
z.object({
season: z.number().int(),
scenario: z.number().int().optional(),
})
)
getHallOfFameOptions: procedure
.input(z.object({ source: z.enum(['current', 'legacy']).default('current') }).optional())
.query(async ({ ctx, input }) => {
const baseWhere = {
season: input.season,
...(input.scenario !== undefined ? { scenario: input.scenario } : {}),
};
const types: Array<{ key: HallOfFameType; title: string; type: 'int' | 'percent' }> = [
{ key: 'experience', title: '명 성', type: 'int' },
{ key: 'dedication', title: '계 급', type: 'int' },
{ key: 'firenum', title: '계 략 성 공', type: 'int' },
{ key: 'warnum', title: '전 투 횟 수', type: 'int' },
{ key: 'killnum', title: '승 리', type: 'int' },
{ key: 'winrate', title: '승 률', type: 'percent' },
{ key: 'occupied', title: '점 령', type: 'int' },
{ key: 'killcrew', title: '사 살', type: 'int' },
{ key: 'killrate', title: '살 상 률', type: 'percent' },
{ key: 'killcrew_person', title: '대 인 사 살', type: 'int' },
{ key: 'killrate_person', title: '대 인 살 상 률', type: 'percent' },
{ key: 'dex1', title: '보 병 숙 련 도', type: 'int' },
{ key: 'dex2', title: '궁 병 숙 련 도', type: 'int' },
{ key: 'dex3', title: '기 병 숙 련 도', type: 'int' },
{ key: 'dex4', title: '귀 병 숙 련 도', type: 'int' },
{ key: 'dex5', title: '차 병 숙 련 도', type: 'int' },
{ key: 'ttrate', title: '전 력 전 승 률', type: 'percent' },
{ key: 'tlrate', title: '통 솔 전 승 률', type: 'percent' },
{ key: 'tsrate', title: '일 기 토 승 률', type: 'percent' },
{ key: 'tirate', title: '설 전 승 률', type: 'percent' },
{ key: 'betgold', title: '베 팅 투 자 액', type: 'int' },
{ key: 'betwin', title: '베 팅 당 첨', type: 'int' },
{ key: 'betwingold', title: '베 팅 수 익 금', type: 'int' },
{ key: 'betrate', title: '베 팅 수 익 률', type: 'percent' },
];
const allowedTypes = new Set(HALL_OF_FAME_TYPES);
const sections = await Promise.all(
types.map(async (type) => {
if (!allowedTypes.has(type.key)) {
return { title: type.title, valueType: type.type, entries: [] };
if ((input?.source ?? 'current') === 'legacy') {
const rows = await findLegacyHallOptions(ctx.db);
const optionMap = new Map<
string,
{
sourceProfile: (typeof LEGACY_ARCHIVE_PROFILES)[number];
season: number;
scenarios: Array<{ id: number; name: string; count: number }>;
}
const rows = await ctx.db.hallOfFame.findMany({
where: { ...baseWhere, type: type.key },
orderBy: { value: 'desc' },
take: 10,
});
const entries = rows.map((row) => {
const aux = asRecord(row.aux);
return {
generalId: row.generalNo,
name: String(aux.name ?? ''),
ownerName:
typeof aux.ownerDisplayName === 'string' && aux.ownerDisplayName.length > 0
? aux.ownerDisplayName
: null,
nationName: String(aux.nationName ?? ''),
bgColor: String(aux.bgColor ?? DEFAULT_BG_COLOR),
fgColor: String(aux.fgColor ?? DEFAULT_FG_COLOR),
picture: typeof aux.picture === 'string' ? aux.picture : null,
imageServer: readMetaNumber(aux.imgsvr),
value: row.value,
printValue:
type.type === 'percent'
? percentText(row.value)
: formatLegacyRankingNumber(row.value),
serverName: String(aux.serverName ?? ''),
serverIdx: readMetaNumber(aux.serverIdx),
scenarioName: String(aux.scenarioName ?? ''),
startTime: typeof aux.startTime === 'string' ? aux.startTime : null,
unitedTime: typeof aux.unitedTime === 'string' ? aux.unitedTime : null,
};
});
return { title: type.title, valueType: type.type, entries };
})
);
return { sections };
>();
for (const row of rows) {
const key = `${row.sourceProfile}:${row.season}`;
const entry = optionMap.get(key) ?? {
sourceProfile: row.sourceProfile,
season: row.season,
scenarios: [],
};
entry.scenarios.push({ id: row.scenario, name: row.scenarioName, count: Number(row.count) });
optionMap.set(key, entry);
}
return Array.from(optionMap.values());
}
const rows = await ctx.db.gameHistory.findMany({
select: { season: true, scenario: true, scenarioName: true },
orderBy: [{ season: 'desc' }, { scenario: 'asc' }],
});
const seasonMap = new Map<
number,
{ sourceProfile: string; season: number; scenarios: Array<{ id: number; name: string; count: number }> }
>();
for (const row of rows) {
const entry = seasonMap.get(row.season) ?? {
sourceProfile: ctx.profile.id,
season: row.season,
scenarios: [],
};
const scenario = entry.scenarios.find((item) => item.id === row.scenario);
if (scenario) {
scenario.count += 1;
} else {
entry.scenarios.push({ id: row.scenario, name: row.scenarioName, count: 1 });
}
seasonMap.set(row.season, entry);
}
return Array.from(seasonMap.values());
}),
getHallOfFame: accessInputProcedure(
z.object({
source: z.enum(['current', 'legacy']).default('current'),
sourceProfile: z.enum(LEGACY_ARCHIVE_PROFILES).optional(),
season: z.number().int(),
scenario: z.number().int().optional(),
})
).query(async ({ ctx, input }) => {
const baseWhere = {
season: input.season,
...(input.scenario !== undefined ? { scenario: input.scenario } : {}),
};
const types: Array<{ key: HallOfFameType; title: string; type: 'int' | 'percent' }> = [
{ key: 'experience', title: '명 성', type: 'int' },
{ key: 'dedication', title: '계 급', type: 'int' },
{ key: 'firenum', title: '계 략 성 공', type: 'int' },
{ key: 'warnum', title: '전 투 횟 수', type: 'int' },
{ key: 'killnum', title: '승 리', type: 'int' },
{ key: 'winrate', title: '승 률', type: 'percent' },
{ key: 'occupied', title: '점 령', type: 'int' },
{ key: 'killcrew', title: '사 살', type: 'int' },
{ key: 'killrate', title: '살 상 률', type: 'percent' },
{ key: 'killcrew_person', title: '대 인 사 살', type: 'int' },
{ key: 'killrate_person', title: '대 인 살 상 률', type: 'percent' },
{ key: 'dex1', title: '보 병 숙 련 도', type: 'int' },
{ key: 'dex2', title: '궁 병 숙 련 도', type: 'int' },
{ key: 'dex3', title: '기 병 숙 련 도', type: 'int' },
{ key: 'dex4', title: '귀 병 숙 련 도', type: 'int' },
{ key: 'dex5', title: '차 병 숙 련 도', type: 'int' },
{ key: 'ttrate', title: '전 력 전 승 률', type: 'percent' },
{ key: 'tlrate', title: '통 솔 전 승 률', type: 'percent' },
{ key: 'tsrate', title: '일 기 토 승 률', type: 'percent' },
{ key: 'tirate', title: '설 전 승 률', type: 'percent' },
{ key: 'betgold', title: '베 팅 투 자 액', type: 'int' },
{ key: 'betwin', title: '베 팅 당 첨', type: 'int' },
{ key: 'betwingold', title: '베 팅 수 익 금', type: 'int' },
{ key: 'betrate', title: '베 팅 수 익 률', type: 'percent' },
];
const allowedTypes = new Set(HALL_OF_FAME_TYPES);
const sections = await Promise.all(
types.map(async (type) => {
if (!allowedTypes.has(type.key)) {
return { title: type.title, valueType: type.type, entries: [] };
}
const rows =
input.source === 'legacy'
? input.sourceProfile
? await findLegacyHallRows(ctx.db, {
sourceProfile: input.sourceProfile,
season: input.season,
...(input.scenario === undefined ? {} : { scenario: input.scenario }),
type: type.key,
take: 10,
})
: []
: await ctx.db.hallOfFame.findMany({
where: { ...baseWhere, type: type.key },
orderBy: { value: 'desc' },
take: 10,
});
const entries = rows.map((row) => {
const aux = asRecord(row.aux);
return {
generalId: row.generalNo,
name: String(aux.name ?? ''),
ownerName:
typeof aux.ownerDisplayName === 'string' && aux.ownerDisplayName.length > 0
? aux.ownerDisplayName
: null,
nationName: String(aux.nationName ?? ''),
bgColor: String(aux.bgColor ?? DEFAULT_BG_COLOR),
fgColor: String(aux.fgColor ?? DEFAULT_FG_COLOR),
picture: typeof aux.picture === 'string' ? aux.picture : null,
imageServer: readMetaNumber(aux.imgsvr),
value: row.value,
printValue:
type.type === 'percent' ? percentText(row.value) : formatLegacyRankingNumber(row.value),
serverName: String(aux.serverName ?? ''),
serverIdx: readMetaNumber(aux.serverIdx),
scenarioName: String(aux.scenarioName ?? ''),
startTime: typeof aux.startTime === 'string' ? aux.startTime : null,
unitedTime: typeof aux.unitedTime === 'string' ? aux.unitedTime : null,
};
});
return { title: type.title, valueType: type.type, entries };
})
);
return { source: input.source, sourceProfile: input.sourceProfile ?? ctx.profile.id, sections };
}),
});