merge: 최신 main을 Profile 포괄 권한 제거에 통합
This commit is contained in:
@@ -1,124 +1,326 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import {
|
||||
asRecord,
|
||||
normalizeArchivedGeneral,
|
||||
type ArchivedGeneralSnapshotV1,
|
||||
type ArchivedJsonValue,
|
||||
} from '@sammo-ts/common';
|
||||
|
||||
import { resolveOfficerLevelName, sanitizeInternalDisplayCode } from '../../services/gameDisplayNames.js';
|
||||
import {
|
||||
loadCrewTypeDisplayNames,
|
||||
loadItemDisplayNames,
|
||||
resolveDedicationLevelName,
|
||||
resolveOfficerLevelName,
|
||||
sanitizeInternalDisplayCode,
|
||||
} from '../../services/gameDisplayNames.js';
|
||||
import {
|
||||
findLegacyEmperors,
|
||||
findLegacyGeneral,
|
||||
findLegacyGeneralsByOwner,
|
||||
findLegacyGames,
|
||||
findLegacyNations,
|
||||
LEGACY_ARCHIVE_PROFILES,
|
||||
type LegacyArchiveProfile,
|
||||
} from '../../services/legacyArchiveStore.js';
|
||||
import { readOnlyAuthedProcedure, router } from '../../trpc.js';
|
||||
import { loadTraitNames } from '../nation/shared.js';
|
||||
|
||||
const numberOrNull = (value: unknown): number | null =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? 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;
|
||||
}
|
||||
}
|
||||
const textOrNull = (value: unknown): string | null => {
|
||||
if (typeof value === 'string' && value.trim()) return value;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(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 numericText = (value: string | null): number | null => {
|
||||
if (value === null || value.trim() === '') return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? Math.trunc(parsed) : 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 canonicalSnapshot = (value: unknown, fallbackName: string): ArchivedGeneralSnapshotV1 =>
|
||||
normalizeArchivedGeneral(value as ArchivedJsonValue, fallbackName).snapshot;
|
||||
|
||||
const zPastPlayDetailInput = z.object({
|
||||
source: z.enum(['current', 'legacy']).default('current'),
|
||||
sourceProfile: z.enum(LEGACY_ARCHIVE_PROFILES).optional(),
|
||||
serverId: z.string().trim().min(1).max(64),
|
||||
generalNo: z.number().int().positive(),
|
||||
});
|
||||
|
||||
type ArchiveSource = 'current' | 'legacy';
|
||||
|
||||
interface GeneralArchiveEntry {
|
||||
source: ArchiveSource;
|
||||
sourceProfile: string;
|
||||
serverId: string;
|
||||
generalNo: number;
|
||||
name: string;
|
||||
lastYearMonth: number;
|
||||
turnTime: Date;
|
||||
snapshot: ArchivedGeneralSnapshotV1;
|
||||
}
|
||||
|
||||
interface ArchiveNationEntry {
|
||||
source: ArchiveSource;
|
||||
sourceProfile: string;
|
||||
serverId: string;
|
||||
nation: number;
|
||||
archivedAt: Date;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const key = (source: ArchiveSource, sourceProfile: string, serverId: string): string =>
|
||||
`${source}:${sourceProfile}:${serverId}`;
|
||||
|
||||
const nationKey = (source: ArchiveSource, sourceProfile: string, serverId: string, nation: number): string =>
|
||||
`${key(source, sourceProfile, serverId)}:${nation}`;
|
||||
|
||||
const resolveNation = (
|
||||
nations: Map<string, ArchiveNationEntry>,
|
||||
entry: GeneralArchiveEntry
|
||||
): { nationId: number; name: string; color: string; level: number | null } => {
|
||||
const nationId = entry.snapshot.identity.nationId ?? 0;
|
||||
const archived = nations.get(nationKey(entry.source, entry.sourceProfile, entry.serverId, nationId));
|
||||
return {
|
||||
nationId,
|
||||
name: textOrNull(archived?.data.name) ?? (nationId === 0 ? '재야' : '미상'),
|
||||
color: textOrNull(archived?.data.color) ?? '#000000',
|
||||
level: numberOrNull(archived?.data.level),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveDisplayResources = async (entries: GeneralArchiveEntry[]) => {
|
||||
const [personalityNames, domesticNames, warNames, itemNames] = await Promise.all([
|
||||
loadTraitNames(
|
||||
entries.map((entry) => entry.snapshot.traits.personality),
|
||||
'personality'
|
||||
),
|
||||
loadTraitNames(
|
||||
entries.map((entry) => entry.snapshot.traits.specialDomestic),
|
||||
'domestic'
|
||||
),
|
||||
loadTraitNames(
|
||||
entries.map((entry) => entry.snapshot.traits.specialWar),
|
||||
'war'
|
||||
),
|
||||
loadItemDisplayNames(
|
||||
entries.flatMap((entry) => [
|
||||
entry.snapshot.items.horse,
|
||||
entry.snapshot.items.weapon,
|
||||
entry.snapshot.items.book,
|
||||
entry.snapshot.items.item,
|
||||
])
|
||||
),
|
||||
]);
|
||||
const traitName = (code: string | null, names: Awaited<ReturnType<typeof loadTraitNames>>): string =>
|
||||
code ? (names.get(code)?.name ?? sanitizeInternalDisplayCode(code)) : '-';
|
||||
const itemName = (code: string | null): string =>
|
||||
code ? (itemNames.get(code) ?? sanitizeInternalDisplayCode(code)) : '-';
|
||||
return { personalityNames, domesticNames, warNames, traitName, itemName };
|
||||
};
|
||||
|
||||
const buildGeneralDetail = async (entry: GeneralArchiveEntry, nation: ReturnType<typeof resolveNation>) => {
|
||||
const snapshot = entry.snapshot;
|
||||
const display = await resolveDisplayResources([entry]);
|
||||
const crewTypeId = numericText(snapshot.resources.crewType);
|
||||
const crewTypeNames = await loadCrewTypeDisplayNames(null, entry.sourceProfile);
|
||||
const dedicationLevel = snapshot.progression.dedicationLevel ?? 0;
|
||||
const maxDedicationLevel = 30;
|
||||
return {
|
||||
id: entry.generalNo,
|
||||
name: snapshot.identity.name || entry.name,
|
||||
picture: snapshot.identity.picture,
|
||||
imageServer: snapshot.identity.imageServer,
|
||||
npcState: snapshot.identity.npcState ?? 0,
|
||||
officerLevel: snapshot.identity.officerLevel ?? 0,
|
||||
officerLevelText: resolveOfficerLevelName(snapshot.identity.officerLevel ?? 0, nation.level ?? undefined),
|
||||
stats: {
|
||||
leadership: snapshot.stats.leadership ?? 0,
|
||||
strength: snapshot.stats.strength ?? 0,
|
||||
intelligence: snapshot.stats.intelligence ?? 0,
|
||||
},
|
||||
gold: snapshot.resources.gold ?? 0,
|
||||
rice: snapshot.resources.rice ?? 0,
|
||||
crew: snapshot.resources.crew ?? 0,
|
||||
train: snapshot.resources.train ?? 0,
|
||||
atmos: snapshot.resources.morale ?? 0,
|
||||
injury: snapshot.resources.injury ?? 0,
|
||||
experience: snapshot.progression.experience ?? 0,
|
||||
dedication: snapshot.progression.dedication ?? 0,
|
||||
...(snapshot.progression.age === null ? {} : { age: snapshot.progression.age }),
|
||||
turnTime: entry.turnTime.toISOString(),
|
||||
...(crewTypeId === null ? {} : { crewTypeId }),
|
||||
crewTypeName: crewTypeId === null ? '-' : (crewTypeNames.get(crewTypeId) ?? '-'),
|
||||
traits: {
|
||||
personal: display.traitName(snapshot.traits.personality, display.personalityNames),
|
||||
specialDomestic: display.traitName(snapshot.traits.specialDomestic, display.domesticNames),
|
||||
specialWar: display.traitName(snapshot.traits.specialWar, display.warNames),
|
||||
},
|
||||
itemNames: {
|
||||
horse: display.itemName(snapshot.items.horse),
|
||||
weapon: display.itemName(snapshot.items.weapon),
|
||||
book: display.itemName(snapshot.items.book),
|
||||
item: display.itemName(snapshot.items.item),
|
||||
},
|
||||
progression: {
|
||||
experienceLevel: snapshot.progression.experienceLevel ?? 0,
|
||||
dedicationLevel,
|
||||
dedicationText: resolveDedicationLevelName(dedicationLevel, maxDedicationLevel),
|
||||
statExperience: {
|
||||
leadership: snapshot.stats.leadershipExperience ?? 0,
|
||||
strength: snapshot.stats.strengthExperience ?? 0,
|
||||
intelligence: snapshot.stats.intelligenceExperience ?? 0,
|
||||
},
|
||||
statUpgradeLimit: 30,
|
||||
dex: [
|
||||
snapshot.mastery.infantry,
|
||||
snapshot.mastery.archery,
|
||||
snapshot.mastery.cavalry,
|
||||
snapshot.mastery.special,
|
||||
snapshot.mastery.siege,
|
||||
].map((value) => value ?? 0),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const archiveRouter = router({
|
||||
myPastPlays: readOnlyAuthedProcedure.query(async ({ ctx }) => {
|
||||
const owner = ctx.auth?.user.id;
|
||||
if (!owner) {
|
||||
throw new Error('Authenticated archive query is missing its user identity');
|
||||
}
|
||||
const generals = await ctx.db.oldGeneral.findMany({
|
||||
where: { owner },
|
||||
orderBy: [{ lastYearMonth: 'desc' }, { serverId: 'desc' }, { generalNo: 'asc' }],
|
||||
});
|
||||
if (!generals.length) {
|
||||
return { seasons: [] };
|
||||
}
|
||||
if (!owner) throw new Error('Authenticated archive query is missing its user identity');
|
||||
|
||||
const serverIds = [...new Set(generals.map((general) => general.serverId))];
|
||||
const [games, nations, emperors] = await Promise.all([
|
||||
ctx.db.gameHistory.findMany({
|
||||
where: { serverId: { in: serverIds } },
|
||||
}),
|
||||
ctx.db.oldNation.findMany({
|
||||
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 [legacyRows, currentRows] = await Promise.all([
|
||||
findLegacyGeneralsByOwner(ctx.db, owner),
|
||||
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}`)
|
||||
);
|
||||
const entries: GeneralArchiveEntry[] = [
|
||||
...legacyRows.map((row) => ({
|
||||
source: 'legacy' as const,
|
||||
sourceProfile: row.sourceProfile,
|
||||
serverId: row.serverId,
|
||||
generalNo: row.generalNo,
|
||||
name: row.name,
|
||||
lastYearMonth: row.lastYearMonth,
|
||||
turnTime: row.turnTime,
|
||||
snapshot: canonicalSnapshot(row.data, row.name),
|
||||
})),
|
||||
...currentRows
|
||||
.filter((row) => !legacyIdentity.has(`${ctx.profile.id}:${row.serverId}:${row.generalNo}`))
|
||||
.map((row) => ({
|
||||
source: 'current' as const,
|
||||
sourceProfile: ctx.profile.id,
|
||||
serverId: row.serverId,
|
||||
generalNo: row.generalNo,
|
||||
name: row.name,
|
||||
lastYearMonth: row.lastYearMonth,
|
||||
turnTime: row.turnTime,
|
||||
snapshot: canonicalSnapshot(row.data, row.name),
|
||||
})),
|
||||
];
|
||||
if (entries.length === 0) return { seasons: [] };
|
||||
|
||||
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 legacyKeys = Array.from(
|
||||
new Map(
|
||||
entries
|
||||
.filter((entry) => entry.source === 'legacy')
|
||||
.map((entry) => [key(entry.source, entry.sourceProfile, entry.serverId), entry])
|
||||
).values()
|
||||
).map((entry) => ({ sourceProfile: entry.sourceProfile as LegacyArchiveProfile, serverId: entry.serverId }));
|
||||
const currentServerIds = Array.from(
|
||||
new Set(entries.filter((entry) => entry.source === 'current').map((entry) => entry.serverId))
|
||||
);
|
||||
const [legacyGames, legacyNationRows, legacyEmperors, currentGames, currentNationRows, currentEmperors] =
|
||||
await Promise.all([
|
||||
findLegacyGames(ctx.db, legacyKeys),
|
||||
findLegacyNations(ctx.db, legacyKeys),
|
||||
findLegacyEmperors(ctx.db, legacyKeys),
|
||||
currentServerIds.length
|
||||
? ctx.db.gameHistory.findMany({ where: { serverId: { in: currentServerIds } } })
|
||||
: [],
|
||||
currentServerIds.length
|
||||
? ctx.db.oldNation.findMany({
|
||||
where: { serverId: { in: currentServerIds } },
|
||||
orderBy: [{ date: 'desc' }, { id: 'desc' }],
|
||||
})
|
||||
: [],
|
||||
currentServerIds.length
|
||||
? ctx.db.emperor.findMany({
|
||||
where: { serverId: { in: currentServerIds } },
|
||||
orderBy: { id: 'desc' },
|
||||
select: { id: true, serverId: true },
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
|
||||
const games = new Map<string, { openedAt: Date; season: number; scenario: number; scenarioName: string }>();
|
||||
for (const row of legacyGames) {
|
||||
games.set(key('legacy', row.sourceProfile, row.serverId), row);
|
||||
}
|
||||
const nationByServerAndId = new Map<string, (typeof nations)[number]>();
|
||||
for (const nation of nations) {
|
||||
const key = `${nation.serverId}:${nation.nation}`;
|
||||
if (!nationByServerAndId.has(key)) {
|
||||
nationByServerAndId.set(key, nation);
|
||||
}
|
||||
for (const row of currentGames) {
|
||||
games.set(key('current', ctx.profile.id, row.serverId), {
|
||||
openedAt: row.date,
|
||||
season: row.season,
|
||||
scenario: row.scenario,
|
||||
scenarioName: row.scenarioName,
|
||||
});
|
||||
}
|
||||
const archivedRoles = generals.map((general) => {
|
||||
const data = asRecord(general.data);
|
||||
const role = asRecord(data.role);
|
||||
return {
|
||||
personal: displayTextOrNull(data.personalCode ?? data.personal ?? role.personality),
|
||||
special: displayTextOrNull(data.specialCode ?? data.special ?? role.specialDomestic),
|
||||
special2: displayTextOrNull(data.special2Code ?? data.special2 ?? role.specialWar),
|
||||
const nations = new Map<string, ArchiveNationEntry>();
|
||||
for (const row of legacyNationRows) {
|
||||
const entry: ArchiveNationEntry = {
|
||||
source: 'legacy',
|
||||
sourceProfile: row.sourceProfile,
|
||||
serverId: row.serverId,
|
||||
nation: row.nation,
|
||||
archivedAt: row.archivedAt,
|
||||
data: asRecord(row.data),
|
||||
};
|
||||
});
|
||||
const [personalityNames, domesticNames, warNames] = await Promise.all([
|
||||
loadTraitNames(
|
||||
archivedRoles.map((role) => role.personal),
|
||||
'personality'
|
||||
),
|
||||
loadTraitNames(
|
||||
archivedRoles.map((role) => role.special),
|
||||
'domestic'
|
||||
),
|
||||
loadTraitNames(
|
||||
archivedRoles.map((role) => role.special2),
|
||||
'war'
|
||||
),
|
||||
]);
|
||||
const displayRole = (value: string | null, names: Awaited<ReturnType<typeof loadTraitNames>>): string | null =>
|
||||
value ? (names.get(value)?.name ?? sanitizeInternalDisplayCode(value)) : null;
|
||||
const entryKey = nationKey(entry.source, entry.sourceProfile, entry.serverId, entry.nation);
|
||||
if (!nations.has(entryKey)) nations.set(entryKey, entry);
|
||||
}
|
||||
for (const row of currentNationRows) {
|
||||
const entry: ArchiveNationEntry = {
|
||||
source: 'current',
|
||||
sourceProfile: ctx.profile.id,
|
||||
serverId: row.serverId,
|
||||
nation: row.nation,
|
||||
archivedAt: row.date,
|
||||
data: asRecord(row.data),
|
||||
};
|
||||
const entryKey = nationKey(entry.source, entry.sourceProfile, entry.serverId, entry.nation);
|
||||
if (!nations.has(entryKey)) nations.set(entryKey, entry);
|
||||
}
|
||||
const dynastyIds = new Map<string, number>();
|
||||
for (const row of legacyEmperors) {
|
||||
if (row.serverId) {
|
||||
const entryKey = key('legacy', row.sourceProfile, row.serverId);
|
||||
if (!dynastyIds.has(entryKey)) dynastyIds.set(entryKey, Number(row.id));
|
||||
}
|
||||
}
|
||||
for (const row of currentEmperors) {
|
||||
if (row.serverId) {
|
||||
const entryKey = key('current', ctx.profile.id, row.serverId);
|
||||
if (!dynastyIds.has(entryKey)) dynastyIds.set(entryKey, row.id);
|
||||
}
|
||||
}
|
||||
|
||||
const display = await resolveDisplayResources(entries);
|
||||
const seasons = new Map<
|
||||
string,
|
||||
{
|
||||
source: ArchiveSource;
|
||||
sourceProfile: string;
|
||||
serverId: string;
|
||||
openedAt: string | null;
|
||||
date: string | null;
|
||||
season: number | null;
|
||||
scenario: number | null;
|
||||
@@ -145,85 +347,186 @@ export const archiveRouter = router({
|
||||
}>;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const [generalIndex, general] of generals.entries()) {
|
||||
const game = gameByServer.get(general.serverId);
|
||||
let season = seasons.get(general.serverId);
|
||||
for (const entry of entries) {
|
||||
const entryKey = key(entry.source, entry.sourceProfile, entry.serverId);
|
||||
const game = games.get(entryKey);
|
||||
let season = seasons.get(entryKey);
|
||||
if (!season) {
|
||||
const openedAt = game?.openedAt.toISOString() ?? null;
|
||||
season = {
|
||||
serverId: general.serverId,
|
||||
date: game?.date.toISOString() ?? null,
|
||||
source: entry.source,
|
||||
sourceProfile: entry.sourceProfile,
|
||||
serverId: entry.serverId,
|
||||
openedAt,
|
||||
date: openedAt,
|
||||
season: game?.season ?? null,
|
||||
scenario: game?.scenario ?? null,
|
||||
scenarioName: game?.scenarioName ?? null,
|
||||
dynastyId: emperorByServer.get(general.serverId) ?? null,
|
||||
dynastyId: dynastyIds.get(entryKey) ?? null,
|
||||
generals: [],
|
||||
};
|
||||
seasons.set(general.serverId, season);
|
||||
seasons.set(entryKey, season);
|
||||
}
|
||||
|
||||
const data = asRecord(general.data);
|
||||
const stats = asRecord(data.stats);
|
||||
const nationId = firstNumber(data, 'nationId', 'nation') ?? 0;
|
||||
const nation = nationByServerAndId.get(`${general.serverId}:${nationId}`);
|
||||
const nationData = asRecord(nation?.data);
|
||||
const officerLevel = firstNumber(data, 'officerLevel', 'officer_level');
|
||||
const nationLevel = firstNumber(nationData, 'level', 'nationLevel');
|
||||
const archivedRole = archivedRoles[generalIndex]!;
|
||||
const snapshot = entry.snapshot;
|
||||
const nation = resolveNation(nations, entry);
|
||||
const officerLevel = snapshot.identity.officerLevel;
|
||||
season.generals.push({
|
||||
generalNo: general.generalNo,
|
||||
name: general.name,
|
||||
lastYearMonth: general.lastYearMonth,
|
||||
nationId,
|
||||
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),
|
||||
generalNo: entry.generalNo,
|
||||
name: snapshot.identity.name || entry.name,
|
||||
lastYearMonth: entry.lastYearMonth,
|
||||
nationId: nation.nationId,
|
||||
nationName: nation.name,
|
||||
nationColor: nation.color,
|
||||
leadership: snapshot.stats.leadership,
|
||||
strength: snapshot.stats.strength,
|
||||
intel: snapshot.stats.intelligence,
|
||||
experience: snapshot.progression.experience,
|
||||
dedication: snapshot.progression.dedication,
|
||||
officerLevel,
|
||||
officerLevelText:
|
||||
officerLevel === null
|
||||
? null
|
||||
: resolveOfficerLevelName(officerLevel, nationLevel === null ? undefined : nationLevel),
|
||||
personal: displayRole(archivedRole.personal, personalityNames),
|
||||
special: displayRole(archivedRole.special, domesticNames),
|
||||
special2: displayRole(archivedRole.special2, warNames),
|
||||
historyCount: parseHistory(data.history).length,
|
||||
officerLevel === null ? null : resolveOfficerLevelName(officerLevel, nation.level ?? undefined),
|
||||
personal: display.traitName(snapshot.traits.personality, display.personalityNames),
|
||||
special: display.traitName(snapshot.traits.specialDomestic, display.domesticNames),
|
||||
special2: display.traitName(snapshot.traits.specialWar, display.warNames),
|
||||
historyCount: snapshot.history.length,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
seasons: [...seasons.values()].sort((left, right) => {
|
||||
const leftTime = left.date ? new Date(left.date).getTime() : 0;
|
||||
const rightTime = right.date ? new Date(right.date).getTime() : 0;
|
||||
const leftTime = left.openedAt ? new Date(left.openedAt).getTime() : 0;
|
||||
const rightTime = right.openedAt ? new Date(right.openedAt).getTime() : 0;
|
||||
return rightTime - leftTime || right.serverId.localeCompare(left.serverId);
|
||||
}),
|
||||
};
|
||||
}),
|
||||
|
||||
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) {
|
||||
if (!owner) throw new Error('Authenticated archive query is missing its user identity');
|
||||
const sourceProfile = input.sourceProfile ?? ctx.profile.id;
|
||||
if (input.source === 'current' && sourceProfile !== ctx.profile.id) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '지난 장수 기록을 찾을 수 없습니다.' });
|
||||
}
|
||||
const data = asRecord(general.data);
|
||||
|
||||
let entry: GeneralArchiveEntry | null = null;
|
||||
let nationRows: ArchiveNationEntry[] = [];
|
||||
let dynastyId: number | null = null;
|
||||
if (input.source === 'legacy') {
|
||||
if (!LEGACY_ARCHIVE_PROFILES.includes(sourceProfile as LegacyArchiveProfile)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이전 서버 프로필입니다.' });
|
||||
}
|
||||
const profile = sourceProfile as LegacyArchiveProfile;
|
||||
const row = await findLegacyGeneral(ctx.db, {
|
||||
owner,
|
||||
sourceProfile: profile,
|
||||
serverId: input.serverId,
|
||||
generalNo: input.generalNo,
|
||||
});
|
||||
if (row) {
|
||||
entry = {
|
||||
source: 'legacy',
|
||||
sourceProfile: row.sourceProfile,
|
||||
serverId: row.serverId,
|
||||
generalNo: row.generalNo,
|
||||
name: row.name,
|
||||
lastYearMonth: row.lastYearMonth,
|
||||
turnTime: row.turnTime,
|
||||
snapshot: canonicalSnapshot(row.data, row.name),
|
||||
};
|
||||
const keyInput = [{ sourceProfile: profile, serverId: input.serverId }];
|
||||
const [nations, emperors] = await Promise.all([
|
||||
findLegacyNations(ctx.db, keyInput),
|
||||
findLegacyEmperors(ctx.db, keyInput),
|
||||
]);
|
||||
nationRows = nations.map((nation) => ({
|
||||
source: 'legacy',
|
||||
sourceProfile: nation.sourceProfile,
|
||||
serverId: nation.serverId,
|
||||
nation: nation.nation,
|
||||
archivedAt: nation.archivedAt,
|
||||
data: asRecord(nation.data),
|
||||
}));
|
||||
dynastyId = Number(emperors[0]?.id ?? 0) || null;
|
||||
}
|
||||
} else {
|
||||
const row = await ctx.db.oldGeneral.findFirst({
|
||||
where: { owner, serverId: input.serverId, generalNo: input.generalNo },
|
||||
});
|
||||
if (row) {
|
||||
entry = {
|
||||
source: 'current',
|
||||
sourceProfile: ctx.profile.id,
|
||||
serverId: row.serverId,
|
||||
generalNo: row.generalNo,
|
||||
name: row.name,
|
||||
lastYearMonth: row.lastYearMonth,
|
||||
turnTime: row.turnTime,
|
||||
snapshot: canonicalSnapshot(row.data, row.name),
|
||||
};
|
||||
const [nations, emperor] = await Promise.all([
|
||||
ctx.db.oldNation.findMany({
|
||||
where: { serverId: input.serverId },
|
||||
orderBy: [{ date: 'desc' }, { id: 'desc' }],
|
||||
}),
|
||||
ctx.db.emperor.findFirst({ where: { serverId: input.serverId }, orderBy: { id: 'desc' } }),
|
||||
]);
|
||||
nationRows = nations.map((nation) => ({
|
||||
source: 'current',
|
||||
sourceProfile: ctx.profile.id,
|
||||
serverId: nation.serverId,
|
||||
nation: nation.nation,
|
||||
archivedAt: nation.date,
|
||||
data: asRecord(nation.data),
|
||||
}));
|
||||
dynastyId = emperor?.id ?? null;
|
||||
}
|
||||
}
|
||||
if (!entry) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '지난 장수 기록을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const nationMap = new Map<string, ArchiveNationEntry>();
|
||||
for (const nation of nationRows) {
|
||||
const entryKey = nationKey(nation.source, nation.sourceProfile, nation.serverId, nation.nation);
|
||||
if (!nationMap.has(entryKey)) nationMap.set(entryKey, nation);
|
||||
}
|
||||
const nation = resolveNation(nationMap, entry);
|
||||
const snapshot = entry.snapshot;
|
||||
const general = await buildGeneralDetail(entry, nation);
|
||||
const logs = {
|
||||
generalHistory: {
|
||||
available: snapshot.availability.history,
|
||||
entries: snapshot.history.map((text, index) => ({ id: index + 1, text })),
|
||||
},
|
||||
battleDetail: { available: snapshot.availability.battleDetailLogs, entries: [] },
|
||||
battleResult: { available: snapshot.availability.battleResultLogs, entries: [] },
|
||||
generalAction: { available: false, entries: [] },
|
||||
};
|
||||
return {
|
||||
serverId: general.serverId,
|
||||
generalNo: general.generalNo,
|
||||
name: general.name,
|
||||
lastYearMonth: general.lastYearMonth,
|
||||
history: parseHistory(data.history),
|
||||
source: entry.source,
|
||||
sourceProfile: entry.sourceProfile,
|
||||
serverId: entry.serverId,
|
||||
generalNo: entry.generalNo,
|
||||
sourceFormat: input.source === 'legacy' ? 'normalized-v1' : 'current-archive',
|
||||
dynastyPath:
|
||||
dynastyId === null ? null : `/dynasty/${dynastyId}${entry.source === 'legacy' ? '?source=legacy' : ''}`,
|
||||
nation: { id: nation.nationId, name: nation.name, color: nation.color },
|
||||
general,
|
||||
masteryAvailable: snapshot.availability.mastery,
|
||||
battle: {
|
||||
available: snapshot.availability.battleAggregates,
|
||||
warnum: snapshot.battle.battles,
|
||||
wins: snapshot.battle.wins,
|
||||
losses: snapshot.battle.losses,
|
||||
strategies: snapshot.battle.fireSuccesses,
|
||||
killCrew: snapshot.battle.killedCrew,
|
||||
deathCrew: snapshot.battle.lostCrew,
|
||||
winRate: snapshot.battle.winRate,
|
||||
killRate: snapshot.battle.killRate,
|
||||
recentWar: snapshot.battle.recentWar,
|
||||
},
|
||||
logs,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -4,11 +4,24 @@ import { z } from 'zod';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
|
||||
import { procedure, router } from '../../trpc.js';
|
||||
import {
|
||||
findLegacyEmperor,
|
||||
findLegacyEmperors,
|
||||
findLegacyGeneralsForServer,
|
||||
findLegacyNations,
|
||||
} from '../../services/legacyArchiveStore.js';
|
||||
|
||||
const zDynastyDetailInput = z.object({
|
||||
emperorId: z.number().int().positive(),
|
||||
source: z.enum(['current', 'legacy']).default('current'),
|
||||
});
|
||||
|
||||
const zDynastyListInput = z
|
||||
.object({
|
||||
source: z.enum(['current', 'legacy']).default('current'),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const parseNumberArray = (value: unknown): number[] =>
|
||||
Array.isArray(value)
|
||||
? value.filter((item): item is number => typeof item === 'number' && Number.isFinite(item))
|
||||
@@ -44,6 +57,41 @@ const firstFiniteNumber = (...values: unknown[]): number | null => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const firstText = (...values: unknown[]): string => {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const legacyEmperorListEntry = (row: Awaited<ReturnType<typeof findLegacyEmperors>>[number]) => {
|
||||
const data = asRecord(row.data);
|
||||
return {
|
||||
id: Number(row.id),
|
||||
source: 'legacy' as const,
|
||||
sourceProfile: row.sourceProfile,
|
||||
serverId: row.serverId ?? '',
|
||||
phase: firstText(data.phase),
|
||||
name: firstText(data.name),
|
||||
year: firstFiniteNumber(data.year) ?? 0,
|
||||
month: firstFiniteNumber(data.month) ?? 0,
|
||||
color: firstText(data.color) || '#000000',
|
||||
type: firstText(data.type),
|
||||
power: firstFiniteNumber(data.power) ?? 0,
|
||||
gennum: firstFiniteNumber(data.gennum) ?? 0,
|
||||
citynum: firstFiniteNumber(data.citynum) ?? 0,
|
||||
l12name: firstText(data.l12name),
|
||||
l11name: firstText(data.l11name),
|
||||
l10name: firstText(data.l10name),
|
||||
l9name: firstText(data.l9name),
|
||||
l8name: firstText(data.l8name),
|
||||
l7name: firstText(data.l7name),
|
||||
l6name: firstText(data.l6name),
|
||||
l5name: firstText(data.l5name),
|
||||
};
|
||||
};
|
||||
|
||||
const firstDisplayArray = (...values: unknown[]): Array<string | number> => {
|
||||
for (const value of values) {
|
||||
const parsed = parseDisplayArray(value);
|
||||
@@ -82,7 +130,15 @@ const formatNationLevel = (level: number | null): string => {
|
||||
};
|
||||
|
||||
export const dynastyRouter = router({
|
||||
getList: procedure.query(async ({ ctx }) => {
|
||||
getList: procedure.input(zDynastyListInput).query(async ({ ctx, input }) => {
|
||||
if ((input?.source ?? 'current') === 'legacy') {
|
||||
const rows = await findLegacyEmperors(ctx.db);
|
||||
return {
|
||||
source: 'legacy' as const,
|
||||
current: null,
|
||||
entries: rows.map(legacyEmperorListEntry),
|
||||
};
|
||||
}
|
||||
const [worldState, rows] = await Promise.all([
|
||||
ctx.db.worldState.findFirst({
|
||||
select: {
|
||||
@@ -96,6 +152,7 @@ export const dynastyRouter = router({
|
||||
]);
|
||||
|
||||
return {
|
||||
source: 'current' as const,
|
||||
current: worldState
|
||||
? {
|
||||
year: worldState.currentYear,
|
||||
@@ -104,6 +161,8 @@ export const dynastyRouter = router({
|
||||
: null,
|
||||
entries: rows.map((row) => ({
|
||||
id: row.id,
|
||||
source: 'current' as const,
|
||||
sourceProfile: ctx.profile.id,
|
||||
serverId: row.serverId ?? '',
|
||||
phase: row.phase ?? '',
|
||||
name: row.name ?? '',
|
||||
@@ -126,6 +185,103 @@ export const dynastyRouter = router({
|
||||
};
|
||||
}),
|
||||
getDetail: procedure.input(zDynastyDetailInput).query(async ({ ctx, input }) => {
|
||||
if (input.source === 'legacy') {
|
||||
const archived = await findLegacyEmperor(ctx.db, input.emperorId);
|
||||
if (!archived) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '이전 서버 왕조 정보를 찾을 수 없습니다.' });
|
||||
}
|
||||
const emperor = asRecord(archived.data);
|
||||
const aux = asRecord(emperor.aux);
|
||||
const winnerNationId = firstFiniteNumber(aux.winnerNationId, aux.winner_nation_id);
|
||||
const serverId = archived.serverId ?? '';
|
||||
const oldNationRows = serverId
|
||||
? await findLegacyNations(ctx.db, [{ sourceProfile: archived.sourceProfile, serverId }])
|
||||
: [];
|
||||
const nationEntries = oldNationRows
|
||||
.map((row) => {
|
||||
const normalized = normalizeOldNationData(row.data);
|
||||
const { data } = normalized;
|
||||
const nationId = row.nation ?? firstFiniteNumber(data.nation) ?? 0;
|
||||
return {
|
||||
archiveId: row.legacyId,
|
||||
nation: nationId,
|
||||
isWinner: winnerNationId !== null && nationId === winnerNationId,
|
||||
name: typeof data.name === 'string' ? data.name : nationId === 0 ? '재야' : '미상',
|
||||
color: typeof data.color === 'string' ? data.color : '#000000',
|
||||
type: normalized.typeCode,
|
||||
typeName: formatNationType(normalized.typeCode),
|
||||
level: firstFiniteNumber(data.level),
|
||||
tech: normalized.tech,
|
||||
maxPower: normalized.maxPower,
|
||||
maxCrew: normalized.maxCrew,
|
||||
maxCities: normalized.maxCities,
|
||||
generals: parseNumberArray(data.generals),
|
||||
history: parseTextArray(data.history),
|
||||
date: row.archivedAt.toISOString(),
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.nation !== 0);
|
||||
const generalIds = Array.from(new Set(nationEntries.flatMap((entry) => entry.generals)));
|
||||
const generalRows = serverId
|
||||
? await findLegacyGeneralsForServer(ctx.db, {
|
||||
sourceProfile: archived.sourceProfile,
|
||||
serverId,
|
||||
generalNos: generalIds,
|
||||
})
|
||||
: [];
|
||||
const generalMap = new Map(
|
||||
generalRows.map((row) => [row.generalNo, { name: row.name, lastYearMonth: row.lastYearMonth }])
|
||||
);
|
||||
return {
|
||||
source: 'legacy' as const,
|
||||
sourceProfile: archived.sourceProfile,
|
||||
emperor: {
|
||||
id: Number(archived.id),
|
||||
serverId,
|
||||
winnerNationId,
|
||||
phase: firstText(emperor.phase),
|
||||
nationCount: firstText(emperor.nation_count, emperor.nationCount),
|
||||
nationName: firstText(emperor.nation_name, emperor.nationName),
|
||||
nationHist: firstText(emperor.nation_hist, emperor.nationHist),
|
||||
genCount: firstText(emperor.gen_count, emperor.genCount),
|
||||
personalHist: firstText(emperor.personal_hist, emperor.personalHist),
|
||||
specialHist: firstText(emperor.special_hist, emperor.specialHist),
|
||||
name: firstText(emperor.name),
|
||||
type: firstText(emperor.type),
|
||||
color: firstText(emperor.color) || '#000000',
|
||||
year: firstFiniteNumber(emperor.year) ?? 0,
|
||||
month: firstFiniteNumber(emperor.month) ?? 0,
|
||||
power: firstFiniteNumber(emperor.power) ?? 0,
|
||||
gennum: firstFiniteNumber(emperor.gennum) ?? 0,
|
||||
citynum: firstFiniteNumber(emperor.citynum) ?? 0,
|
||||
pop: firstText(emperor.pop) || '0',
|
||||
poprate: firstText(emperor.poprate),
|
||||
gold: firstFiniteNumber(emperor.gold) ?? 0,
|
||||
rice: firstFiniteNumber(emperor.rice) ?? 0,
|
||||
l12name: firstText(emperor.l12name),
|
||||
l11name: firstText(emperor.l11name),
|
||||
l10name: firstText(emperor.l10name),
|
||||
l9name: firstText(emperor.l9name),
|
||||
l8name: firstText(emperor.l8name),
|
||||
l7name: firstText(emperor.l7name),
|
||||
l6name: firstText(emperor.l6name),
|
||||
l5name: firstText(emperor.l5name),
|
||||
tiger: firstText(emperor.tiger),
|
||||
eagle: firstText(emperor.eagle),
|
||||
gen: firstText(emperor.gen),
|
||||
history: parseTextArray(emperor.history),
|
||||
},
|
||||
nations: nationEntries.map((entry) => ({
|
||||
...entry,
|
||||
levelName: formatNationLevel(entry.level),
|
||||
generalsFull: entry.generals.map((id) => ({
|
||||
generalNo: id,
|
||||
name: generalMap.get(id)?.name ?? `#${id}`,
|
||||
lastYearMonth: generalMap.get(id)?.lastYearMonth ?? null,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
const emperor = await ctx.db.emperor.findUnique({
|
||||
where: { id: input.emperorId },
|
||||
});
|
||||
@@ -192,6 +348,8 @@ export const dynastyRouter = router({
|
||||
}));
|
||||
|
||||
return {
|
||||
source: 'current' as const,
|
||||
sourceProfile: ctx.profile.id,
|
||||
emperor: {
|
||||
id: emperor.id,
|
||||
serverId,
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
import { isLegacyArchiveProfile, LEGACY_ARCHIVE_PROFILES, type LegacyArchiveProfile } from '@sammo-ts/common';
|
||||
|
||||
import type { DatabaseClient } from '../context.js';
|
||||
|
||||
export { isLegacyArchiveProfile, LEGACY_ARCHIVE_PROFILES, type LegacyArchiveProfile };
|
||||
|
||||
export type LegacyArchiveDatabase = Pick<DatabaseClient, '$queryRaw'>;
|
||||
|
||||
export interface LegacyGameHistoryRow {
|
||||
sourceProfile: LegacyArchiveProfile;
|
||||
serverId: string;
|
||||
legacyId: number;
|
||||
openedAt: Date;
|
||||
completedAt: Date | null;
|
||||
legacyDate: Date;
|
||||
winnerNation: number | null;
|
||||
map: string | null;
|
||||
season: number;
|
||||
scenario: number;
|
||||
scenarioName: string;
|
||||
rawEnv: unknown;
|
||||
}
|
||||
|
||||
export interface LegacyGeneralRow {
|
||||
sourceProfile: LegacyArchiveProfile;
|
||||
serverId: string;
|
||||
generalNo: number;
|
||||
legacyId: number;
|
||||
owner: string | null;
|
||||
name: string;
|
||||
lastYearMonth: number;
|
||||
turnTime: Date;
|
||||
schemaVersion: number;
|
||||
sourceFormat: string;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
export interface LegacyNationRow {
|
||||
sourceProfile: LegacyArchiveProfile;
|
||||
legacyId: number;
|
||||
serverId: string;
|
||||
nation: number;
|
||||
data: unknown;
|
||||
archivedAt: Date;
|
||||
}
|
||||
|
||||
export interface LegacyEmperorRow {
|
||||
id: bigint;
|
||||
sourceProfile: LegacyArchiveProfile;
|
||||
legacyId: number;
|
||||
serverId: string | null;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
export interface LegacyHallOptionRow {
|
||||
sourceProfile: LegacyArchiveProfile;
|
||||
season: number;
|
||||
scenario: number;
|
||||
scenarioName: string;
|
||||
count: bigint;
|
||||
}
|
||||
|
||||
export interface LegacyHallRow {
|
||||
sourceProfile: LegacyArchiveProfile;
|
||||
serverId: string;
|
||||
generalNo: number;
|
||||
type: string;
|
||||
value: number;
|
||||
owner: string | null;
|
||||
aux: unknown;
|
||||
}
|
||||
|
||||
export const findLegacyGeneralsByOwner = async (
|
||||
db: LegacyArchiveDatabase,
|
||||
owner: string
|
||||
): Promise<LegacyGeneralRow[]> =>
|
||||
db.$queryRaw<LegacyGeneralRow[]>(GamePrisma.sql`
|
||||
SELECT
|
||||
"source_profile" AS "sourceProfile",
|
||||
"server_id" AS "serverId",
|
||||
"general_no" AS "generalNo",
|
||||
"legacy_id" AS "legacyId",
|
||||
"owner",
|
||||
"name",
|
||||
"last_yearmonth" AS "lastYearMonth",
|
||||
"turntime" AS "turnTime",
|
||||
"schema_version" AS "schemaVersion",
|
||||
"source_format" AS "sourceFormat",
|
||||
"data"
|
||||
FROM "legacy_archive"."general"
|
||||
WHERE "owner" = ${owner}
|
||||
ORDER BY "last_yearmonth" DESC, "source_profile", "server_id" DESC, "general_no"
|
||||
`);
|
||||
|
||||
export const findLegacyGeneral = async (
|
||||
db: LegacyArchiveDatabase,
|
||||
input: { owner: string; sourceProfile: LegacyArchiveProfile; serverId: string; generalNo: number }
|
||||
): Promise<LegacyGeneralRow | null> => {
|
||||
const rows = await db.$queryRaw<LegacyGeneralRow[]>(GamePrisma.sql`
|
||||
SELECT
|
||||
"source_profile" AS "sourceProfile",
|
||||
"server_id" AS "serverId",
|
||||
"general_no" AS "generalNo",
|
||||
"legacy_id" AS "legacyId",
|
||||
"owner",
|
||||
"name",
|
||||
"last_yearmonth" AS "lastYearMonth",
|
||||
"turntime" AS "turnTime",
|
||||
"schema_version" AS "schemaVersion",
|
||||
"source_format" AS "sourceFormat",
|
||||
"data"
|
||||
FROM "legacy_archive"."general"
|
||||
WHERE "owner" = ${input.owner}
|
||||
AND "source_profile" = ${input.sourceProfile}
|
||||
AND "server_id" = ${input.serverId}
|
||||
AND "general_no" = ${input.generalNo}
|
||||
LIMIT 1
|
||||
`);
|
||||
return rows[0] ?? null;
|
||||
};
|
||||
|
||||
export const findLegacyGeneralsForServer = async (
|
||||
db: LegacyArchiveDatabase,
|
||||
input: { sourceProfile: LegacyArchiveProfile; serverId: string; generalNos: number[] }
|
||||
): Promise<Array<Pick<LegacyGeneralRow, 'generalNo' | 'name' | 'lastYearMonth'>>> => {
|
||||
if (input.generalNos.length === 0) return [];
|
||||
return db.$queryRaw<Array<Pick<LegacyGeneralRow, 'generalNo' | 'name' | 'lastYearMonth'>>>(GamePrisma.sql`
|
||||
SELECT
|
||||
"general_no" AS "generalNo",
|
||||
"name",
|
||||
"last_yearmonth" AS "lastYearMonth"
|
||||
FROM "legacy_archive"."general"
|
||||
WHERE "source_profile" = ${input.sourceProfile}
|
||||
AND "server_id" = ${input.serverId}
|
||||
AND "general_no" IN (${GamePrisma.join(input.generalNos)})
|
||||
`);
|
||||
};
|
||||
|
||||
export const findLegacyGames = async (
|
||||
db: LegacyArchiveDatabase,
|
||||
keys?: Array<{ sourceProfile: LegacyArchiveProfile; serverId: string }>
|
||||
): Promise<LegacyGameHistoryRow[]> => {
|
||||
if (keys && keys.length === 0) return [];
|
||||
const condition = keys
|
||||
? GamePrisma.sql`WHERE ("source_profile", "server_id") IN (${GamePrisma.join(
|
||||
keys.map(({ sourceProfile, serverId }) => GamePrisma.sql`(${sourceProfile}, ${serverId})`)
|
||||
)})`
|
||||
: GamePrisma.empty;
|
||||
return db.$queryRaw<LegacyGameHistoryRow[]>(GamePrisma.sql`
|
||||
SELECT
|
||||
"source_profile" AS "sourceProfile",
|
||||
"server_id" AS "serverId",
|
||||
"legacy_id" AS "legacyId",
|
||||
"opened_at" AS "openedAt",
|
||||
"completed_at" AS "completedAt",
|
||||
"legacy_date" AS "legacyDate",
|
||||
"winner_nation" AS "winnerNation",
|
||||
"map",
|
||||
"season",
|
||||
"scenario",
|
||||
"scenario_name" AS "scenarioName",
|
||||
"raw_env" AS "rawEnv"
|
||||
FROM "legacy_archive"."game_history"
|
||||
${condition}
|
||||
`);
|
||||
};
|
||||
|
||||
export const findLegacyNations = async (
|
||||
db: LegacyArchiveDatabase,
|
||||
keys: Array<{ sourceProfile: LegacyArchiveProfile; serverId: string }>
|
||||
): Promise<LegacyNationRow[]> => {
|
||||
if (keys.length === 0) return [];
|
||||
return db.$queryRaw<LegacyNationRow[]>(GamePrisma.sql`
|
||||
SELECT
|
||||
"source_profile" AS "sourceProfile",
|
||||
"legacy_id" AS "legacyId",
|
||||
"server_id" AS "serverId",
|
||||
"nation",
|
||||
"data",
|
||||
"archived_at" AS "archivedAt"
|
||||
FROM "legacy_archive"."nation"
|
||||
WHERE ("source_profile", "server_id") IN (${GamePrisma.join(
|
||||
keys.map(({ sourceProfile, serverId }) => GamePrisma.sql`(${sourceProfile}, ${serverId})`)
|
||||
)})
|
||||
ORDER BY "archived_at" DESC, "legacy_id" DESC
|
||||
`);
|
||||
};
|
||||
|
||||
export const findLegacyEmperors = async (
|
||||
db: LegacyArchiveDatabase,
|
||||
keys?: Array<{ sourceProfile: LegacyArchiveProfile; serverId: string }>
|
||||
): Promise<LegacyEmperorRow[]> => {
|
||||
if (keys && keys.length === 0) return [];
|
||||
const condition = keys
|
||||
? GamePrisma.sql`WHERE ("source_profile", "server_id") IN (${GamePrisma.join(
|
||||
keys.map(({ sourceProfile, serverId }) => GamePrisma.sql`(${sourceProfile}, ${serverId})`)
|
||||
)})`
|
||||
: GamePrisma.empty;
|
||||
return db.$queryRaw<LegacyEmperorRow[]>(GamePrisma.sql`
|
||||
SELECT
|
||||
"id",
|
||||
"source_profile" AS "sourceProfile",
|
||||
"legacy_id" AS "legacyId",
|
||||
"server_id" AS "serverId",
|
||||
"data"
|
||||
FROM "legacy_archive"."emperor"
|
||||
${condition}
|
||||
ORDER BY "id" DESC
|
||||
`);
|
||||
};
|
||||
|
||||
export const findLegacyEmperor = async (db: LegacyArchiveDatabase, id: number): Promise<LegacyEmperorRow | null> => {
|
||||
const rows = await 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 "id" = ${id}
|
||||
LIMIT 1
|
||||
`);
|
||||
return rows[0] ?? null;
|
||||
};
|
||||
|
||||
export const findLegacyHallOptions = async (db: LegacyArchiveDatabase): Promise<LegacyHallOptionRow[]> =>
|
||||
db.$queryRaw<LegacyHallOptionRow[]>(GamePrisma.sql`
|
||||
SELECT
|
||||
"source_profile" AS "sourceProfile",
|
||||
"season",
|
||||
"scenario",
|
||||
MAX("scenario_name") AS "scenarioName",
|
||||
COUNT(*)::bigint AS "count"
|
||||
FROM "legacy_archive"."game_history"
|
||||
GROUP BY "source_profile", "season", "scenario"
|
||||
ORDER BY "season" DESC, "source_profile", "scenario"
|
||||
`);
|
||||
|
||||
export const findLegacyHallRows = async (
|
||||
db: LegacyArchiveDatabase,
|
||||
input: { sourceProfile: LegacyArchiveProfile; season: number; scenario?: number; type: string; take: number }
|
||||
): Promise<LegacyHallRow[]> => {
|
||||
const scenarioCondition =
|
||||
input.scenario === undefined ? GamePrisma.empty : GamePrisma.sql`AND "scenario" = ${input.scenario}`;
|
||||
return db.$queryRaw<LegacyHallRow[]>(GamePrisma.sql`
|
||||
SELECT
|
||||
"source_profile" AS "sourceProfile",
|
||||
"server_id" AS "serverId",
|
||||
"general_no" AS "generalNo",
|
||||
"type",
|
||||
"value",
|
||||
"owner",
|
||||
"aux"
|
||||
FROM "legacy_archive"."hall"
|
||||
WHERE "source_profile" = ${input.sourceProfile}
|
||||
AND "season" = ${input.season}
|
||||
${scenarioCondition}
|
||||
AND "type" = ${input.type}
|
||||
ORDER BY "value" DESC
|
||||
LIMIT ${input.take}
|
||||
`);
|
||||
};
|
||||
@@ -25,8 +25,132 @@ const auth: GameSessionTokenPayload = {
|
||||
sanctions: {},
|
||||
};
|
||||
|
||||
const context = (session: GameSessionTokenPayload | null): GameApiContext => {
|
||||
const context = (session: GameSessionTokenPayload | null, includeLegacy = false): GameApiContext => {
|
||||
const db = {
|
||||
$queryRaw: async (query: { strings?: readonly string[] }) => {
|
||||
if (!includeLegacy) return [];
|
||||
const sql = query.strings?.join(' ') ?? '';
|
||||
if (sql.includes('legacy_archive"."general')) {
|
||||
return [
|
||||
{
|
||||
sourceProfile: 'hwe',
|
||||
serverId: 'hwe_archive_1',
|
||||
generalNo: 21,
|
||||
legacyId: 21,
|
||||
owner: 'user-1',
|
||||
name: '이전서버장수',
|
||||
lastYearMonth: 21012,
|
||||
turnTime: new Date('2020-01-02T00:00:00.000Z'),
|
||||
schemaVersion: 1,
|
||||
sourceFormat: 'legacy-flat-v0',
|
||||
data: {
|
||||
schemaVersion: 1,
|
||||
identity: {
|
||||
name: '이전서버장수',
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
npcState: 0,
|
||||
nationId: 2,
|
||||
cityId: 1,
|
||||
officerLevel: 7,
|
||||
officerCity: 1,
|
||||
},
|
||||
stats: {
|
||||
leadership: 91,
|
||||
strength: 81,
|
||||
intelligence: 71,
|
||||
leadershipExperience: 3,
|
||||
strengthExperience: 4,
|
||||
intelligenceExperience: 5,
|
||||
},
|
||||
progression: {
|
||||
experience: 900,
|
||||
experienceLevel: 3,
|
||||
dedication: 800,
|
||||
dedicationLevel: 2,
|
||||
age: 30,
|
||||
startAge: 20,
|
||||
bornYear: 180,
|
||||
deadYear: 250,
|
||||
},
|
||||
traits: { personality: null, specialDomestic: null, specialWar: null },
|
||||
resources: {
|
||||
gold: 100,
|
||||
rice: 200,
|
||||
crew: 300,
|
||||
crewType: '1',
|
||||
train: 90,
|
||||
morale: 80,
|
||||
injury: 0,
|
||||
},
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
mastery: { infantry: 1000, archery: 2000, cavalry: 3000, special: 4000, siege: 5000 },
|
||||
battle: {
|
||||
battles: 10,
|
||||
wins: 6,
|
||||
losses: 4,
|
||||
fireSuccesses: 2,
|
||||
kills: 6,
|
||||
deaths: 4,
|
||||
killedCrew: 1000,
|
||||
lostCrew: 500,
|
||||
winRate: 60,
|
||||
killRate: 200,
|
||||
recentWar: null,
|
||||
tactics: {
|
||||
total: { wins: null, draws: null, losses: null },
|
||||
leadership: { wins: null, draws: null, losses: null },
|
||||
intelligence: { wins: null, draws: null, losses: null },
|
||||
},
|
||||
},
|
||||
history: ['이전 서버 열전'],
|
||||
availability: {
|
||||
mastery: true,
|
||||
battleAggregates: true,
|
||||
tactics: false,
|
||||
history: true,
|
||||
battleDetailLogs: false,
|
||||
battleResultLogs: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
if (sql.includes('legacy_archive"."game_history')) {
|
||||
return [
|
||||
{
|
||||
sourceProfile: 'hwe',
|
||||
serverId: 'hwe_archive_1',
|
||||
legacyId: 1,
|
||||
openedAt: new Date('2019-09-21T00:00:00.000Z'),
|
||||
completedAt: null,
|
||||
legacyDate: new Date('2019-09-21T00:00:00.000Z'),
|
||||
winnerNation: 2,
|
||||
map: 'legacy',
|
||||
season: 1,
|
||||
scenario: 7,
|
||||
scenarioName: '이전 시나리오',
|
||||
rawEnv: {},
|
||||
},
|
||||
];
|
||||
}
|
||||
if (sql.includes('legacy_archive"."nation')) {
|
||||
return [
|
||||
{
|
||||
sourceProfile: 'hwe',
|
||||
legacyId: 1,
|
||||
serverId: 'hwe_archive_1',
|
||||
nation: 2,
|
||||
data: { name: '이전국', color: '#0000ff', level: 7 },
|
||||
archivedAt: new Date('2020-01-02T00:00:00.000Z'),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (sql.includes('legacy_archive"."emperor')) {
|
||||
return [{ id: 99n, sourceProfile: 'hwe', legacyId: 1, serverId: 'hwe_archive_1', data: {} }];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
oldGeneral: {
|
||||
findMany: async ({ where }: { where: { owner: string } }) =>
|
||||
where.owner === 'user-1'
|
||||
@@ -82,6 +206,11 @@ const context = (session: GameSessionTokenPayload | null): GameApiContext => {
|
||||
lastYearMonth: 22012,
|
||||
turnTime: new Date('2025-01-01T00:00:00.000Z'),
|
||||
data: {
|
||||
nation: 3,
|
||||
leader: 80,
|
||||
power: 70,
|
||||
intel: 60,
|
||||
officer_level: 8,
|
||||
history: '<C>●</>첫 기록<br><Y>●</>둘째 기록<br>',
|
||||
},
|
||||
}
|
||||
@@ -116,6 +245,7 @@ const context = (session: GameSessionTokenPayload | null): GameApiContext => {
|
||||
},
|
||||
emperor: {
|
||||
findMany: async () => [{ id: 7, serverId: 'che_legacy_1' }],
|
||||
findFirst: async () => ({ id: 7, serverId: 'che_legacy_1' }),
|
||||
},
|
||||
};
|
||||
const redis = {
|
||||
@@ -146,6 +276,8 @@ describe('archive.myPastPlays', () => {
|
||||
const result = await appRouter.createCaller(context(auth)).archive.myPastPlays();
|
||||
expect(result.seasons).toEqual([
|
||||
expect.objectContaining({
|
||||
source: 'current',
|
||||
sourceProfile: 'che',
|
||||
serverId: 'che_legacy_1',
|
||||
scenarioName: '테스트',
|
||||
dynastyId: 7,
|
||||
@@ -185,12 +317,27 @@ describe('archive.myPastPlays', () => {
|
||||
});
|
||||
|
||||
const result = await appRouter.createCaller(context(auth)).archive.myPastPlayDetail(input);
|
||||
expect(result).toEqual({
|
||||
expect(result).toMatchObject({
|
||||
source: 'current',
|
||||
sourceProfile: 'che',
|
||||
serverId: 'che_legacy_1',
|
||||
generalNo: 10,
|
||||
name: '과거장수',
|
||||
lastYearMonth: 22012,
|
||||
history: ['<C>●</>첫 기록', '<Y>●</>둘째 기록'],
|
||||
dynastyPath: '/dynasty/7',
|
||||
nation: { id: 3, name: '촉', color: '#ff0000' },
|
||||
general: expect.objectContaining({ id: 10, name: '과거장수' }),
|
||||
battle: expect.objectContaining({ available: false }),
|
||||
logs: {
|
||||
generalHistory: {
|
||||
available: true,
|
||||
entries: [
|
||||
{ id: 1, text: '<C>●</>첫 기록' },
|
||||
{ id: 2, text: '<Y>●</>둘째 기록' },
|
||||
],
|
||||
},
|
||||
battleDetail: { available: false, entries: [] },
|
||||
battleResult: { available: false, entries: [] },
|
||||
generalAction: { available: false, entries: [] },
|
||||
},
|
||||
});
|
||||
|
||||
const otherUser = {
|
||||
@@ -202,4 +349,44 @@ describe('archive.myPastPlays', () => {
|
||||
code: 'NOT_FOUND',
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
expect(list.seasons).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'legacy',
|
||||
sourceProfile: 'hwe',
|
||||
serverId: 'hwe_archive_1',
|
||||
openedAt: '2019-09-21T00:00:00.000Z',
|
||||
dynastyId: 99,
|
||||
generals: [expect.objectContaining({ name: '이전서버장수', nationName: '이전국' })],
|
||||
})
|
||||
);
|
||||
|
||||
const detail = await caller.archive.myPastPlayDetail({
|
||||
source: 'legacy',
|
||||
sourceProfile: 'hwe',
|
||||
serverId: 'hwe_archive_1',
|
||||
generalNo: 21,
|
||||
});
|
||||
expect(detail).toMatchObject({
|
||||
source: 'legacy',
|
||||
sourceProfile: 'hwe',
|
||||
dynastyPath: '/dynasty/99?source=legacy',
|
||||
nation: { id: 2, name: '이전국', color: '#0000ff' },
|
||||
general: expect.objectContaining({
|
||||
id: 21,
|
||||
name: '이전서버장수',
|
||||
stats: { leadership: 91, strength: 81, intelligence: 71 },
|
||||
progression: expect.objectContaining({ dex: [1000, 2000, 3000, 4000, 5000] }),
|
||||
}),
|
||||
battle: expect.objectContaining({ available: true, warnum: 10, wins: 6, winRate: 60 }),
|
||||
logs: expect.objectContaining({
|
||||
generalHistory: { available: true, entries: [{ id: 1, text: '이전 서버 열전' }] },
|
||||
battleDetail: { available: false, entries: [] },
|
||||
}),
|
||||
});
|
||||
expect(JSON.stringify(detail)).not.toContain('raw_data');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,6 +123,72 @@ const buildContext = (
|
||||
oldNations: Array<Record<string, unknown>> = [oldNation, deletedOldNation]
|
||||
): GameApiContext => {
|
||||
const db = {
|
||||
$queryRaw: async (query: { strings?: readonly string[] }) => {
|
||||
const sql = query.strings?.join(' ') ?? '';
|
||||
if (sql.includes('legacy_archive"."emperor')) {
|
||||
return [
|
||||
{
|
||||
id: 101n,
|
||||
sourceProfile: 'hwe',
|
||||
legacyId: 7,
|
||||
serverId: emperor.serverId,
|
||||
data: {
|
||||
phase: '이전 훼2기',
|
||||
nation_count: emperor.nationCount,
|
||||
nation_name: emperor.nationName,
|
||||
nation_hist: emperor.nationHist,
|
||||
gen_count: emperor.genCount,
|
||||
personal_hist: emperor.personalHist,
|
||||
special_hist: emperor.specialHist,
|
||||
name: emperor.name,
|
||||
type: emperor.type,
|
||||
color: emperor.color,
|
||||
year: emperor.year,
|
||||
month: emperor.month,
|
||||
power: emperor.power,
|
||||
gennum: emperor.gennum,
|
||||
citynum: emperor.citynum,
|
||||
pop: emperor.pop,
|
||||
poprate: emperor.poprate,
|
||||
gold: emperor.gold,
|
||||
rice: emperor.rice,
|
||||
l12name: emperor.l12name,
|
||||
l11name: emperor.l11name,
|
||||
l10name: emperor.l10name,
|
||||
l9name: emperor.l9name,
|
||||
l8name: emperor.l8name,
|
||||
l7name: emperor.l7name,
|
||||
l6name: emperor.l6name,
|
||||
l5name: emperor.l5name,
|
||||
tiger: emperor.tiger,
|
||||
eagle: emperor.eagle,
|
||||
gen: emperor.gen,
|
||||
history: emperor.history,
|
||||
aux: emperor.aux,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
if (sql.includes('legacy_archive"."nation')) {
|
||||
return [
|
||||
{
|
||||
sourceProfile: 'hwe',
|
||||
legacyId: oldNation.id,
|
||||
serverId: oldNation.serverId,
|
||||
nation: oldNation.nation,
|
||||
data: oldNation.data,
|
||||
archivedAt: oldNation.date,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (sql.includes('legacy_archive"."general')) {
|
||||
return [
|
||||
{ generalNo: 11, name: '유비', lastYearMonth: 21504 },
|
||||
{ generalNo: 12, name: '제갈량', lastYearMonth: 21504 },
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
worldState: {
|
||||
findFirst: async () => ({ currentYear: 220, currentMonth: 1 }),
|
||||
},
|
||||
@@ -186,6 +252,39 @@ describe('dynasty public read model', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads previous-server dynasties only when the archive source is selected', async () => {
|
||||
const caller = appRouter.createCaller(buildContext(null));
|
||||
const list = await caller.dynasty.getList({ source: 'legacy' });
|
||||
expect(list).toMatchObject({
|
||||
source: 'legacy',
|
||||
current: null,
|
||||
entries: [
|
||||
expect.objectContaining({
|
||||
id: 101,
|
||||
source: 'legacy',
|
||||
sourceProfile: 'hwe',
|
||||
phase: '이전 훼2기',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const detail = await caller.dynasty.getDetail({ emperorId: 101, source: 'legacy' });
|
||||
expect(detail).toMatchObject({
|
||||
source: 'legacy',
|
||||
sourceProfile: 'hwe',
|
||||
emperor: expect.objectContaining({ id: 101, phase: '이전 훼2기', name: '촉' }),
|
||||
nations: [
|
||||
expect.objectContaining({
|
||||
name: '촉',
|
||||
generalsFull: [
|
||||
{ generalNo: 11, name: '유비', lastYearMonth: 21504 },
|
||||
{ generalNo: 12, name: '제갈량', lastYearMonth: 21504 },
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes the same public DTO to anonymous, general owners and admins', async () => {
|
||||
const anonymous = appRouter.createCaller(buildContext(null));
|
||||
const owner = appRouter.createCaller(buildContext(authFor('owner-a')));
|
||||
|
||||
@@ -110,6 +110,42 @@ const buildContext = (options?: {
|
||||
}): GameApiContext => {
|
||||
const selectedGeneralRows = options?.generals ?? generalRows;
|
||||
const db = {
|
||||
$queryRaw: async (query: { strings?: readonly string[]; values?: unknown[] }) => {
|
||||
const sql = query.strings?.join(' ') ?? '';
|
||||
if (sql.includes('legacy_archive"."game_history')) {
|
||||
return [
|
||||
{
|
||||
sourceProfile: 'hwe',
|
||||
season: 1,
|
||||
scenario: 7,
|
||||
scenarioName: '이전 시나리오',
|
||||
count: 2n,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (sql.includes('legacy_archive"."hall')) {
|
||||
return query.values?.includes('experience')
|
||||
? [
|
||||
{
|
||||
sourceProfile: 'hwe',
|
||||
serverId: 'hwe-old-1',
|
||||
generalNo: 9,
|
||||
type: 'experience',
|
||||
value: 777,
|
||||
owner: 'private-legacy-owner-id',
|
||||
aux: {
|
||||
name: '과거장수',
|
||||
ownerDisplayName: '과거소유자',
|
||||
nationName: '과거국',
|
||||
bgColor: '#330000',
|
||||
fgColor: '#ffffff',
|
||||
},
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
worldState: {
|
||||
findFirst: async () => ({
|
||||
meta: { isUnited: options?.isUnited ? 1 : 0 },
|
||||
@@ -289,8 +325,14 @@ describe('ranking.getBestGeneral', () => {
|
||||
value:
|
||||
type === 'warnum' || type === 'deathcrew' || type === 'deathcrew_person'
|
||||
? 1_000
|
||||
: type === 'ttd' || type === 'ttl' || type === 'tld' || type === 'tll' ||
|
||||
type === 'tsd' || type === 'tsl' || type === 'tid' || type === 'til' ||
|
||||
: type === 'ttd' ||
|
||||
type === 'ttl' ||
|
||||
type === 'tld' ||
|
||||
type === 'tll' ||
|
||||
type === 'tsd' ||
|
||||
type === 'tsl' ||
|
||||
type === 'tid' ||
|
||||
type === 'til' ||
|
||||
type === 'betgold'
|
||||
? 1_000
|
||||
: general.id * 1_000,
|
||||
@@ -304,11 +346,14 @@ describe('ranking.getBestGeneral', () => {
|
||||
for (const section of result.sections) {
|
||||
expect(section.entries, section.title).toHaveLength(10);
|
||||
expect(new Set(section.entries.map((entry) => entry.id)).size, section.title).toBe(10);
|
||||
expect(section.entries.every((entry) => entry.value > 0), section.title).toBe(true);
|
||||
expect(
|
||||
section.entries.every((entry) => entry.value > 0),
|
||||
section.title
|
||||
).toBe(true);
|
||||
}
|
||||
expect(result.sections.find((section) => section.title === '계 략 성 공')?.entries.map((entry) => entry.id)).toEqual([
|
||||
12, 11, 10, 9, 8, 7, 6, 5, 4, 3,
|
||||
]);
|
||||
expect(
|
||||
result.sections.find((section) => section.title === '계 략 성 공')?.entries.map((entry) => entry.id)
|
||||
).toEqual([12, 11, 10, 9, 8, 7, 6, 5, 4, 3]);
|
||||
});
|
||||
|
||||
it('matches PHP number_format rounding and the legacy fixed color table', () => {
|
||||
@@ -326,12 +371,38 @@ describe('ranking hall of fame', () => {
|
||||
.ranking.getHallOfFameOptions();
|
||||
expect(options).toEqual([
|
||||
{
|
||||
sourceProfile: 'che',
|
||||
season: 3,
|
||||
scenarios: [{ id: 22, name: '가상모드22', count: 2 }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps previous-server options and rankings in the dedicated archive source', async () => {
|
||||
const caller = appRouter.createCaller(buildContext({ authenticated: false }));
|
||||
await expect(caller.ranking.getHallOfFameOptions({ source: 'legacy' })).resolves.toEqual([
|
||||
{
|
||||
sourceProfile: 'hwe',
|
||||
season: 1,
|
||||
scenarios: [{ id: 7, name: '이전 시나리오', count: 2 }],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await caller.ranking.getHallOfFame({
|
||||
source: 'legacy',
|
||||
sourceProfile: 'hwe',
|
||||
season: 1,
|
||||
scenario: 7,
|
||||
});
|
||||
expect(result.source).toBe('legacy');
|
||||
expect(result.sourceProfile).toBe('hwe');
|
||||
expect(result.sections[0]).toMatchObject({
|
||||
title: '명 성',
|
||||
entries: [expect.objectContaining({ generalId: 9, name: '과거장수', ownerName: '과거소유자' })],
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain('private-legacy-owner-id');
|
||||
});
|
||||
|
||||
it('returns an explicit display name but never exposes the stored account identifier', async () => {
|
||||
const result = await appRouter
|
||||
.createCaller(buildContext({ authenticated: false, includeOwnerDisplayName: true }))
|
||||
|
||||
Reference in New Issue
Block a user