import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { asRecord, normalizeArchivedGeneral, type ArchivedGeneralSnapshotV1, type ArchivedJsonValue, } from '@sammo-ts/common'; import { loadCrewTypeDisplayNames, loadItemDisplayNames, resolveDedicationLevelName, resolveOfficerLevelName, sanitizeInternalDisplayCode, } from '../../services/gameDisplayNames.js'; import { findLegacyEmperors, findLegacyGeneral, findLegacyGeneralBattleResult, findLegacyGeneralHallRows, findLegacyGeneralsByOwner, findLegacyGames, findLegacyNations, isLegacyArchiveProfile, type LegacyArchiveProfile, type LegacyGeneralHallRow, } 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 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 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 canonicalSnapshot = (value: unknown, fallbackName: string): ArchivedGeneralSnapshotV1 => normalizeArchivedGeneral(value as ArchivedJsonValue, fallbackName).snapshot; const zPastPlayDetailInput = z.object({ source: z.enum(['current', 'legacy']).default('current'), 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; } export interface LegacyHallBattleSummary { available: boolean; semantics: 'independent-records'; strategies: number | null; warnum: number | null; wins: number | null; winRate: number | null; occupied: number | null; killCrew: number | null; killRate: number | null; killCrewPerson: number | null; killRatePerson: number | null; } const legacyHallBattleSummary = (rows: LegacyGeneralHallRow[]): LegacyHallBattleSummary => { const values = new Map(rows.map((row) => [row.type, row.value])); const value = (type: LegacyGeneralHallRow['type']): number | null => values.get(type) ?? null; const percent = (type: LegacyGeneralHallRow['type']): number | null => { const raw = value(type); return raw === null ? null : raw * 100; }; return { available: rows.length > 0, semantics: 'independent-records', strategies: value('firenum'), warnum: value('warnum'), wins: value('killnum'), winRate: percent('winrate'), occupied: value('occupied'), killCrew: value('killcrew'), killRate: percent('killrate'), killCrewPerson: value('killcrew_person'), killRatePerson: percent('killrate_person'), }; }; const key = (source: ArchiveSource, sourceProfile: string, serverId: string): string => `${source}:${sourceProfile}:${serverId}`; const nationKey = (source: ArchiveSource, sourceProfile: string, serverId: string, nation: number): string => `${key(source, sourceProfile, serverId)}:${nation}`; const resolveNation = ( nations: Map, 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>): 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) => { 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 legacyProfile = isLegacyArchiveProfile(ctx.profile.id) ? ctx.profile.id : null; const [legacyRows, currentRows] = await Promise.all([ legacyProfile ? findLegacyGeneralsByOwner(ctx.db, { owner, sourceProfile: legacyProfile }) : [], 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 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, currentCancellations, 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.gameCancellation.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; status?: 'OPEN' | 'COMPLETED' | 'ABANDONED'; cancellationId?: string; cancelledAt?: Date; } >(); for (const row of legacyGames) { games.set(key('legacy', row.sourceProfile, row.serverId), row); } 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, status: row.status, }); } for (const row of currentCancellations) { games.set(key('current', ctx.profile.id, row.serverId), { openedAt: row.openedAt, season: row.originalSeason, scenario: row.scenario, scenarioName: row.scenarioName, status: 'ABANDONED', cancellationId: row.id, cancelledAt: row.cancelledAt, }); } const nations = new Map(); 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 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(); 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; scenarioName: string | null; status: 'OPEN' | 'COMPLETED' | 'ABANDONED' | 'LEGACY'; cancellationId: string | null; cancelledAt: string | null; dynastyId: number | null; generals: Array<{ generalNo: number; name: string; lastYearMonth: number; nationId: number; nationName: string; nationColor: string; leadership: number | null; strength: number | null; intel: number | null; experience: number | null; dedication: number | null; officerLevel: number | null; officerLevelText: string | null; personal: string | null; special: string | null; special2: string | null; historyCount: number; }>; } >(); 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 = { source: entry.source, sourceProfile: entry.sourceProfile, serverId: entry.serverId, openedAt, date: openedAt, season: game?.status === 'ABANDONED' ? null : (game?.season ?? null), scenario: game?.scenario ?? null, scenarioName: game?.scenarioName ?? null, status: entry.source === 'legacy' ? 'LEGACY' : (game?.status ?? 'COMPLETED'), cancellationId: game?.cancellationId ?? null, cancelledAt: game?.cancelledAt?.toISOString() ?? null, dynastyId: game?.status === 'ABANDONED' ? null : (dynastyIds.get(entryKey) ?? null), generals: [], }; seasons.set(entryKey, season); } const snapshot = entry.snapshot; const nation = resolveNation(nations, entry); const officerLevel = snapshot.identity.officerLevel; season.generals.push({ 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, 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.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 sourceProfile = ctx.profile.id; let entry: GeneralArchiveEntry | null = null; let nationRows: ArchiveNationEntry[] = []; let dynastyId: number | null = null; let battleResultContent: string | null = null; let battleResultAvailable = false; let hallBattle = legacyHallBattleSummary([]); if (input.source === 'legacy') { if (!isLegacyArchiveProfile(sourceProfile)) { throw new TRPCError({ code: 'NOT_FOUND', message: '지난 장수 기록을 찾을 수 없습니다.' }); } const profile: LegacyArchiveProfile = sourceProfile; 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, battleResult, hallRows] = await Promise.all([ findLegacyNations(ctx.db, keyInput), findLegacyEmperors(ctx.db, keyInput), findLegacyGeneralBattleResult(ctx.db, { sourceProfile: profile, serverId: input.serverId, generalNo: input.generalNo, }), findLegacyGeneralHallRows(ctx.db, { sourceProfile: profile, serverId: input.serverId, generalNo: input.generalNo, }), ]); 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; battleResultContent = battleResult?.content ?? null; battleResultAvailable = battleResult !== null; hallBattle = legacyHallBattleSummary(hallRows); } } 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(); 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); battleResultAvailable ||= snapshot.availability.battleResultLogs; const battleResultEntries = battleResultContent === null ? snapshot.records.battleResult.map((text, index, rows) => ({ id: rows.length - index, text })) : battleResultContent .split(/\r?\n/u) .map((text, index) => ({ id: index + 1, text })) .filter((item) => item.text.length > 0) .reverse(); 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: battleResultAvailable, entries: battleResultEntries }, generalAction: { available: false, entries: [] }, }; return { 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, }, hallBattle, logs, }; }), });