diff --git a/app/game-api/src/router/archive/index.ts b/app/game-api/src/router/archive/index.ts index 275be3b7..49dab2b7 100644 --- a/app/game-api/src/router/archive/index.ts +++ b/app/game-api/src/router/archive/index.ts @@ -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, ...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(//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; +} + +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 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(); - 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(); + for (const row of legacyGames) { + games.set(key('legacy', row.sourceProfile, row.serverId), row); } - const nationByServerAndId = new Map(); - 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(); + 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>): 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(); + 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(); + 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, }; }), }); diff --git a/app/game-api/src/router/dynasty/index.ts b/app/game-api/src/router/dynasty/index.ts index bf96f0bf..89cd8a4c 100644 --- a/app/game-api/src/router/dynasty/index.ts +++ b/app/game-api/src/router/dynasty/index.ts @@ -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>[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 => { 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, diff --git a/app/game-api/src/router/ranking/index.ts b/app/game-api/src/router/ranking/index.ts index 8c8d7522..f4e5917a 100644 --- a/app/game-api/src/router/ranking/index.ts +++ b/app/game-api/src/router/ranking/index.ts @@ -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>(); - 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>(); + 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) => 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) => 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(); + 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(); - 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 }>(); - 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 }; + }), }); diff --git a/app/game-api/src/services/legacyArchiveStore.ts b/app/game-api/src/services/legacyArchiveStore.ts new file mode 100644 index 00000000..b20ef0fa --- /dev/null +++ b/app/game-api/src/services/legacyArchiveStore.ts @@ -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; + +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 => + db.$queryRaw(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 => { + const rows = await db.$queryRaw(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>> => { + if (input.generalNos.length === 0) return []; + return db.$queryRaw>>(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 => { + 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(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 => { + if (keys.length === 0) return []; + return db.$queryRaw(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 => { + 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(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 => { + const rows = await db.$queryRaw(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 => + db.$queryRaw(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 => { + const scenarioCondition = + input.scenario === undefined ? GamePrisma.empty : GamePrisma.sql`AND "scenario" = ${input.scenario}`; + return db.$queryRaw(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} + `); +}; diff --git a/app/game-api/test/archiveRouter.test.ts b/app/game-api/test/archiveRouter.test.ts index 2451600a..a0978383 100644 --- a/app/game-api/test/archiveRouter.test.ts +++ b/app/game-api/test/archiveRouter.test.ts @@ -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: '●첫 기록
●둘째 기록
', }, } @@ -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: ['●첫 기록', '●둘째 기록'], + 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: '●첫 기록' }, + { id: 2, text: '●둘째 기록' }, + ], + }, + 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'); + }); }); diff --git a/app/game-api/test/dynastyRouter.test.ts b/app/game-api/test/dynastyRouter.test.ts index aa833c96..e4cc7f25 100644 --- a/app/game-api/test/dynastyRouter.test.ts +++ b/app/game-api/test/dynastyRouter.test.ts @@ -123,6 +123,72 @@ const buildContext = ( oldNations: Array> = [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'))); diff --git a/app/game-api/test/rankingRouter.test.ts b/app/game-api/test/rankingRouter.test.ts index 18a688b4..59cdb936 100644 --- a/app/game-api/test/rankingRouter.test.ts +++ b/app/game-api/test/rankingRouter.test.ts @@ -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 })) diff --git a/app/game-frontend/e2e/legacyArchiveViews.spec.ts b/app/game-frontend/e2e/legacyArchiveViews.spec.ts new file mode 100644 index 00000000..3fbac055 --- /dev/null +++ b/app/game-frontend/e2e/legacyArchiveViews.spec.ts @@ -0,0 +1,162 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; + +import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; + +const response = (data: unknown) => ({ result: { data } }); +const operationNames = (route: Route) => + decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(','); + +const isLegacyRequest = (route: Route): boolean => + decodeURIComponent(`${route.request().url()} ${route.request().postData() ?? ''}`).includes('legacy'); + +const installArchiveViews = async (page: Page) => { + await page.addInitScript((profile) => { + localStorage.setItem('sammo-game-token', 'ga_archive_views'); + localStorage.setItem('sammo-game-profile', profile); + }, gameProfile); + await page.route(gameTrpcRoute, async (route) => { + const legacy = isLegacyRequest(route); + const results = operationNames(route).map((operation) => { + if (operation === 'auth.status') return response({ ok: true }); + if (operation === 'lobby.info') return response({ myGeneral: null }); + if (operation === 'ranking.getHallOfFameOptions') { + return response([ + { + sourceProfile: legacy ? 'hwe' : 'che', + season: legacy ? 1 : 2, + scenarios: [{ id: 7, name: legacy ? '이전 시나리오' : '현재 시나리오', count: 1 }], + }, + ]); + } + if (operation === 'ranking.getHallOfFame') { + return response({ + source: legacy ? 'legacy' : 'current', + sourceProfile: legacy ? 'hwe' : 'che', + sections: [ + { + title: '명 성', + valueType: 'int', + entries: [ + { + generalId: 1, + name: legacy ? '이전장수' : '현재장수', + ownerName: null, + nationName: legacy ? '이전국' : '현재국', + bgColor: '#330000', + fgColor: '#ffffff', + picture: null, + imageServer: 0, + value: 100, + printValue: '100', + }, + ], + }, + ], + }); + } + if (operation === 'dynasty.getList') { + return response({ + source: legacy ? 'legacy' : 'current', + current: legacy ? null : { year: 220, month: 1 }, + entries: [ + { + id: legacy ? 101 : 1, + source: legacy ? 'legacy' : 'current', + sourceProfile: legacy ? 'hwe' : 'che', + serverId: legacy ? 'hwe-old-1' : 'che-current-1', + phase: legacy ? '이전 1기' : '현재 1기', + name: '촉', + year: 215, + month: 4, + color: '#800000', + type: '병가', + power: 100, + gennum: 5, + citynum: 3, + l12name: '유비', + l11name: '제갈량', + l10name: '관우', + l9name: '방통', + l8name: '장비', + l7name: '법정', + l6name: '조운', + l5name: '마량', + }, + ], + }); + } + if (operation === 'dynasty.getDetail') { + return response({ + source: 'legacy', + sourceProfile: 'hwe', + emperor: { + id: 101, + serverId: 'hwe-old-1', + winnerNationId: 1, + phase: '이전 1기', + nationCount: '1 / 2', + nationName: '촉', + nationHist: '병가', + genCount: '5 / 10', + personalHist: '의리', + specialHist: '상재', + name: '촉', + type: '병가', + color: '#800000', + year: 215, + month: 4, + power: 100, + gennum: 5, + citynum: 3, + pop: '1000', + poprate: '100%', + gold: 100, + rice: 100, + l12name: '유비', + l11name: '제갈량', + l10name: '관우', + l9name: '방통', + l8name: '장비', + l7name: '법정', + l6name: '조운', + l5name: '마량', + tiger: '', + eagle: '', + gen: '', + history: [], + }, + nations: [], + }); + } + return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } }; + }); + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) }); + }); +}; + +test('명예의 전당은 현재 기록과 이전 서버 기록을 분리해 조회한다', async ({ page }) => { + await installArchiveViews(page); + await page.setViewportSize({ width: 1000, height: 800 }); + await page.goto('hall-of-fame'); + + await expect(page.getByText('현재장수')).toBeVisible(); + await page.getByLabel('기록 구분').selectOption('legacy'); + await expect(page.getByText('이전장수')).toBeVisible(); + await expect(page.getByLabel('시나리오 검색')).toContainText('HWE / 이전 시나리오'); + await expect(page.locator('.legacy-hall-page')).toHaveCSS('width', '1000px'); +}); + +test('왕조 일람과 상세는 이전 서버 source와 profile을 유지한다', async ({ page }) => { + await installArchiveViews(page); + await page.setViewportSize({ width: 1200, height: 800 }); + await page.goto('dynasty'); + + await expect(page.getByText('현재 1기')).toBeVisible(); + await page.getByLabel('기록 구분').selectOption('legacy'); + await expect(page.getByText(/이전 1기.*HWE 이전 서버/)).toBeVisible(); + const detailLink = page.getByRole('link', { name: '자세히' }); + await expect(detailLink).toHaveAttribute('href', /dynasty\/101\?source=legacy$/); + await detailLink.click(); + await expect(page.getByText(/이전 1기.*HWE 이전 서버/)).toBeVisible(); + await expect(page.locator('.dynasty-page')).toHaveCSS('width', '1000px'); +}); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index e8075a48..afb2a0ad 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -2224,16 +2224,24 @@ test('mobile bottom controls share Ref pressed geometry while their color bases await expect .poll(() => page.locator(manualRefreshSelector).evaluate((element) => getComputedStyle(element).boxShadow)) .not.toBe('none'); + const callsBeforeManualRefresh = state.generalMeCalls; await page.locator(manualRefreshSelector).click(); - await expect(page.locator(manualRefreshSelector)).toBeDisabled(); + await expect(page.locator(manualRefreshSelector)).toBeEnabled(); + await expect(page.locator(manualRefreshSelector)).toHaveAttribute('aria-busy', 'true'); + await page.locator(manualRefreshSelector).click(); + await expect(page.getByTestId('game-toast')).toContainText('이미 정보를 갱신하고 있습니다.'); + await page.mouse.move(1, 1); expect(await buttonStyle(manualRefreshSelector)).toMatchObject({ backgroundColor: 'rgb(33, 37, 41)', borderBottomWidth: '4px', marginTop: '0px', height: 45, }); - await expect(page.locator(manualRefreshSelector)).toHaveCSS('opacity', '0.55'); + await expect(page.locator(manualRefreshSelector)).toHaveCSS('opacity', '1'); + await page.screenshot({ path: testInfo.outputPath('mobile-refresh-busy-toast.png'), fullPage: true }); + await expect(page.locator(manualRefreshSelector)).toHaveAttribute('aria-busy', 'false'); await expect(page.locator(manualRefreshSelector)).toBeEnabled(); + expect(state.generalMeCalls).toBe(callsBeforeManualRefresh + 1); await persistArtifact(page, `${basePath.slice(1)}-mobile-bottom-ref-buttons`); }); @@ -2802,6 +2810,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl const operationsBeforeChangedBurst = state.operations.length; state.generalName = '부드럽게갱신된장수'; + state.refreshDelayMs = 1_000; await page.evaluate(() => { const emit = (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }) .__emitMainRealtime; @@ -2823,7 +2832,20 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl } }); + const manualRefresh = page.getByRole('button', { name: '갱 신' }); + await expect(manualRefresh).toHaveAttribute('aria-busy', 'true'); + await expect(manualRefresh).toBeEnabled(); + await manualRefresh.click(); + await expect(page.getByTestId('game-toast')).toContainText('이미 정보를 갱신하고 있습니다.'); + if (autoRefreshArtifactRoot) { + await page.screenshot({ + path: resolve(autoRefreshArtifactRoot, 'auto-refresh-busy-feedback.png'), + fullPage: true, + }); + } + await expect.poll(() => state.generalMeCalls, { timeout: 3_000 }).toBe(callsBeforeRefresh + 1); + state.refreshDelayMs = 300; await expect(page.locator('[data-main-target="general"] .skeleton-line')).toHaveCount(0); await expect(page.locator('[data-main-target="city"] .skeleton-line')).toHaveCount(0); await expect(page.getByRole('button', { name: '갱 신' })).toHaveAttribute('aria-busy', 'false'); diff --git a/app/game-frontend/e2e/pastPlays.spec.ts b/app/game-frontend/e2e/pastPlays.spec.ts index c696e4be..d1965ee9 100644 --- a/app/game-frontend/e2e/pastPlays.spec.ts +++ b/app/game-frontend/e2e/pastPlays.spec.ts @@ -8,7 +8,7 @@ const response = (data: unknown) => ({ result: { data } }); const operationNames = (route: Route) => decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(','); -const installArchive = async (page: Page) => { +const installArchive = async (page: Page, options: { battleAvailable?: boolean } = {}) => { await page.addInitScript((profile) => { localStorage.setItem('sammo-game-token', 'ga_archive'); localStorage.setItem('sammo-game-profile', profile); @@ -21,7 +21,10 @@ const installArchive = async (page: Page) => { return response({ seasons: [ { + sourceProfile: 'che', + source: 'legacy', serverId: 'che_2024_01', + openedAt: '2024-01-31T00:00:00.000Z', date: '2024-01-31T00:00:00.000Z', season: 51, scenario: 2, @@ -54,11 +57,67 @@ const installArchive = async (page: Page) => { } if (operation === 'archive.myPastPlayDetail') { return response({ + sourceProfile: 'che', + source: 'legacy', serverId: 'che_2024_01', generalNo: 17, - name: '관우', - lastYearMonth: 21403, - history: ['●214년 3월: 촉에 임관', '●214년 1월: 성도에서 거병'], + dynastyPath: '/dynasty/7?source=legacy', + nation: { name: '촉', color: '#800000' }, + general: { + id: 17, + name: '관우', + picture: null, + imageServer: 0, + npcState: 0, + officerLevel: 12, + officerLevelText: '황제', + generalType: '용장', + stats: { leadership: 91, strength: 98, intelligence: 77 }, + gold: 12_000, + rice: 8_000, + crew: 7_000, + train: 100, + atmos: 100, + injury: 0, + experience: 23_000, + dedication: 1_200, + crewTypeId: 1, + crewTypeName: '보병', + traits: { personal: '대담', specialDomestic: '상재', specialWar: '신산' }, + progression: { + experienceLevel: 12, + dedicationLevel: 8, + dedicationText: '황제', + statExperience: { leadership: 12, strength: 14, intelligence: 8 }, + statUpgradeLimit: 30, + dex: [125_000, 250_000, 375_000, 500_000, 625_000], + }, + }, + masteryAvailable: true, + battle: { + available: options.battleAvailable ?? true, + warnum: 16, + wins: 10, + losses: 6, + strategies: 4, + killCrew: 12_000, + deathCrew: 8_000, + winRate: 62.5, + killRate: 75, + recentWar: '2024-01-30T03:00:00.000Z', + }, + logs: { + generalHistory: { + available: true, + entries: [ + { id: 2, text: '●214년 3월: 촉에 임관' }, + { id: 1, text: '●214년 1월: 성도에서 거병' }, + ], + }, + battleDetail: { available: false, entries: [] }, + battleResult: { available: false, entries: [] }, + generalAction: { available: false, entries: [] }, + }, }); } return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } }; @@ -76,6 +135,15 @@ test('지난 플레이 관직은 숫자 대신 저장된 Ref 표시명으로 나 await expect(generalRow).not.toContainText('che_'); }); +test('보존되지 않은 과거 전투 집계는 0으로 꾸미지 않고 가용성 경계를 표시한다', async ({ page }) => { + await installArchive(page, { battleAvailable: false }); + await page.goto('past-plays'); + await page.locator('.detail-toggle').click(); + + await expect(page.locator('[data-general-battle-summary]')).toHaveText('전투 집계가 보존되지 않았습니다.'); + await expect(page.locator('[data-general-battle-summary]')).not.toContainText('승률'); +}); + test('past plays is available without a current general and preserves desktop interaction geometry', async ({ page, }) => { @@ -87,19 +155,46 @@ test('past plays is available without a current general and preserves desktop in await expect(root).toBeVisible(); await expect(page.getByRole('heading', { name: '내 지난 플레이 보기' })).toBeVisible(); await expect(page.getByText('천하쟁패 · 51기')).toBeVisible(); + await expect(page.getByText('이전 서버 기록')).toBeVisible(); + await expect(page.getByText('che', { exact: true })).toBeVisible(); + await expect(page.getByText(/2024.*개장/)).toBeVisible(); await expect(page.locator('.general-name')).toHaveText('관우'); await expect(page.locator('tbody tr').filter({ hasText: '관우' })).toContainText('황제'); - await expect(page.getByRole('link', { name: '이 기수 국가 정보' })).toHaveAttribute('href', gamePath('/dynasty/7')); - const historyToggle = page.locator('.history-toggle'); - await expect(historyToggle).toHaveText('보기 (2)'); - await historyToggle.click(); + const detailToggle = page.locator('.detail-toggle'); + await expect(detailToggle).toHaveText('상세 보기'); + await detailToggle.hover(); + const beforePress = await detailToggle.boundingBox(); + await page.mouse.down(); + expect(await detailToggle.evaluate((element) => getComputedStyle(element).transform)).not.toBe('none'); + await page.mouse.up(); + expect((await detailToggle.boundingBox())?.y).toBeCloseTo(beforePress?.y ?? 0, 0); await expect(page.getByText('214년 3월: 촉에 임관')).toBeVisible(); - await expect(historyToggle).toHaveAttribute('aria-expanded', 'true'); + await expect(detailToggle).toHaveAttribute('aria-expanded', 'true'); + await expect(page.locator('.archive-general-card')).toHaveAttribute('data-general-basic-card', ''); + await expect(page.locator('.archive-general-card [role="progressbar"]')).toHaveCount(14); + await expect(page.locator('[data-general-battle-summary]')).toContainText('승률62.5%'); + await expect(page.locator('[data-general-battle-summary]')).toContainText('살상률75.0%'); + await expect(page.locator('[data-log-type="battleDetail"]')).toContainText( + '이 기수에는 전투 기록이 보존되지 않았습니다.' + ); + await expect(page.locator('[data-log-type="battleResult"]')).toContainText( + '이 기수에는 전투 결과가 보존되지 않았습니다.' + ); + await expect(page.locator('[data-log-type="generalAction"]')).toContainText( + '이 기수에는 개인 기록이 보존되지 않았습니다.' + ); + await expect(page.locator('[data-log-type="generalHistory"] C')).toHaveCount(0); + await expect(page.getByRole('link', { name: '이 기수 국가 정보' })).toHaveAttribute( + 'href', + `${gamePath('/dynasty/7')}?source=legacy` + ); const geometry = await root.evaluate((element) => { const rect = element.getBoundingClientRect(); const titleRect = element.querySelector('.title-row')!.getBoundingClientRect(); const tableRect = element.querySelector('table')!.getBoundingClientRect(); + const detailGrid = element.querySelector('.detail-grid')!; + const card = element.querySelector('[data-general-basic-card]')!.getBoundingClientRect(); const style = getComputedStyle(element); return { x: rect.x, @@ -107,6 +202,8 @@ test('past plays is available without a current general and preserves desktop in minHeight: rect.height, title: { x: titleRect.x, y: titleRect.y, width: titleRect.width }, tableWidth: tableRect.width, + detailColumns: getComputedStyle(detailGrid).gridTemplateColumns, + cardWidth: card.width, color: style.color, backgroundColor: style.backgroundColor, }; @@ -116,13 +213,14 @@ test('past plays is available without a current general and preserves desktop in width: 1000, title: { x: 100, y: 0, width: 1000 }, tableWidth: 1000, + cardWidth: 497, color: 'rgb(238, 238, 238)', - backgroundColor: 'rgb(21, 21, 21)', + backgroundColor: 'rgb(48, 32, 22)', }); const refresh = page.getByRole('button', { name: '새로고침' }); await refresh.hover(); - expect(await refresh.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(135, 206, 235)'); + await expect(refresh).toHaveCSS('color', 'rgb(135, 206, 235)'); await refresh.focus(); expect(await refresh.evaluate((element) => document.activeElement === element)).toBe(true); @@ -140,6 +238,9 @@ test('past plays keeps the legacy-width table scrollable on a mobile viewport', await page.setViewportSize({ width: 390, height: 844 }); await page.goto('past-plays'); + await page.locator('.detail-toggle').click(); + await expect(page.locator('.archive-general-card')).toBeVisible(); + const scroll = page.locator('.table-scroll'); const metrics = await scroll.evaluate((element) => ({ clientWidth: element.clientWidth, @@ -149,4 +250,14 @@ test('past plays keeps the legacy-width table scrollable on a mobile viewport', // The shared legacy shell keeps its historical 500 px minimum canvas. expect(metrics).toEqual({ clientWidth: 500, scrollWidth: 940, overflowX: 'auto' }); await expect(page.locator('.title-row')).toHaveCSS('flex-direction', 'column'); + await expect(page.locator('.detail-grid')).toHaveCSS('grid-template-columns', '498px'); + const detailMetrics = await page.locator('.detail-shell').evaluate((element) => ({ + width: element.getBoundingClientRect().width, + scrollWidth: element.scrollWidth, + recordBottom: element.querySelector('[data-general-record-panels]')!.getBoundingClientRect().bottom, + shellBottom: element.getBoundingClientRect().bottom, + })); + expect(detailMetrics.width).toBe(498); + expect(detailMetrics.scrollWidth).toBe(498); + expect(detailMetrics.recordBottom).toBeLessThanOrEqual(detailMetrics.shellBottom); }); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index cf19f087..6c766ef7 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -26,6 +26,7 @@ export default defineConfig({ 'legacyLogHtml.spec.ts', 'directoryLists.spec.ts', 'pastPlays.spec.ts', + 'legacyArchiveViews.spec.ts', 'nationGeneralSecret.spec.ts', 'npcPolicy.spec.ts', 'auction.spec.ts', diff --git a/app/game-frontend/src/components/generalRecords.ts b/app/game-frontend/src/components/generalRecords.ts new file mode 100644 index 00000000..0cde138d --- /dev/null +++ b/app/game-frontend/src/components/generalRecords.ts @@ -0,0 +1,10 @@ +export const GENERAL_RECORD_TYPES = ['generalHistory', 'battleDetail', 'battleResult', 'generalAction'] as const; + +export type GeneralRecordType = (typeof GENERAL_RECORD_TYPES)[number]; + +export type GeneralRecordEntry = { + id: number | string; + content: string; +}; + +export type GeneralRecordCollection = Partial>; diff --git a/app/game-frontend/src/components/main/GeneralBattleSummary.vue b/app/game-frontend/src/components/main/GeneralBattleSummary.vue new file mode 100644 index 00000000..d67bb894 --- /dev/null +++ b/app/game-frontend/src/components/main/GeneralBattleSummary.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/app/game-frontend/src/components/main/GeneralRecordPanels.vue b/app/game-frontend/src/components/main/GeneralRecordPanels.vue new file mode 100644 index 00000000..7e756914 --- /dev/null +++ b/app/game-frontend/src/components/main/GeneralRecordPanels.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/app/game-frontend/src/components/main/MainMobileBottomBar.vue b/app/game-frontend/src/components/main/MainMobileBottomBar.vue index 11deec41..f3de99be 100644 --- a/app/game-frontend/src/components/main/MainMobileBottomBar.vue +++ b/app/game-frontend/src/components/main/MainMobileBottomBar.vue @@ -226,7 +226,6 @@ const onQuick = (item: QuickNavigationItem) => { data-bottom-menu="manual-refresh" aria-label="직접 갱신" title="직접 갱신" - :disabled="refreshing" :aria-busy="refreshing" @click="emit('refresh')" > @@ -306,12 +305,6 @@ const onQuick = (item: QuickNavigationItem) => { line-height: 1; } -.manual-refresh-trigger:disabled { - cursor: wait; - filter: grayscale(0.6); - opacity: 0.55; -} - .bottom-trigger.legacy-button:not(:disabled, [aria-disabled='true']):hover, .bottom-trigger.legacy-button:not(:disabled, [aria-disabled='true'])[aria-expanded='true'] { height: 44px; diff --git a/app/game-frontend/src/views/BattleCenterView.vue b/app/game-frontend/src/views/BattleCenterView.vue index 759b5101..41790f26 100644 --- a/app/game-frontend/src/views/BattleCenterView.vue +++ b/app/game-frontend/src/views/BattleCenterView.vue @@ -3,9 +3,15 @@ import { formatServerDateTime } from '@sammo-ts/common'; import { computed, onMounted, reactive, ref, watch } from 'vue'; import { useRoute } from 'vue-router'; import PanelCard from '../components/ui/PanelCard.vue'; -import SkeletonLines from '../components/ui/SkeletonLines.vue'; import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue'; import GeneralBasicCard from '../components/main/GeneralBasicCard.vue'; +import GeneralBattleSummary from '../components/main/GeneralBattleSummary.vue'; +import GeneralRecordPanels from '../components/main/GeneralRecordPanels.vue'; +import { + GENERAL_RECORD_TYPES, + type GeneralRecordCollection, + type GeneralRecordType, +} from '../components/generalRecords'; import { trpc } from '../utils/trpc'; import { getNpcColor } from '../utils/npcColor'; import { formatLog } from '../utils/formatLog'; @@ -13,17 +19,10 @@ import { formatLog } from '../utils/formatLog'; type BattleCenterResponse = Awaited>; type GeneralEntry = BattleCenterResponse['generals'][number]; -type LogType = 'generalHistory' | 'battleResult' | 'battleDetail' | 'generalAction'; +type LogType = GeneralRecordType; type LogLine = { id: number; html: string }; -const logTypes: LogType[] = ['generalHistory', 'battleDetail', 'battleResult', 'generalAction']; - -const logLabels: Record = { - generalHistory: '장수 열전', - battleDetail: '전투 기록', - battleResult: '전투 결과', - generalAction: '개인 기록', -}; +const logTypes: LogType[] = [...GENERAL_RECORD_TYPES]; const orderOptions = [ { key: 'recentWar', label: '최근 전투' }, @@ -49,6 +48,12 @@ const logs = reactive>({ generalAction: [], }); +const currentRecords = computed(() => + Object.fromEntries( + logTypes.map((type) => [type, logs[type].map((entry) => ({ id: entry.id, content: entry.html }))]) + ) +); + const resolveErrorMessage = (value: unknown): string => { if (value instanceof Error) { return value.message; @@ -275,27 +280,20 @@ onMounted(() => { :nation-color="data?.nation.color" > @@ -304,16 +302,7 @@ onMounted(() => {
-
-
-
{{ logLabels[type] }}
- - -
-
+
@@ -354,81 +343,6 @@ onMounted(() => { font: inherit; } -.battle-general-extra { - display: grid; - grid-template-columns: repeat(6, 1fr); -} - -.battle-general-extra > * { - min-height: 24px; - box-sizing: border-box; - border-right: 1px solid #777; - border-bottom: 1px solid #777; - padding: 2px 5px; -} - -.battle-general-extra > span { - background-color: rgb(20 75 42 / 70%); - text-align: center; -} - -.battle-general-extra > strong { - overflow: hidden; - font-weight: 500; - text-align: right; - text-overflow: ellipsis; - white-space: nowrap; -} - -.battle-general-extra > .battle-general-extra__recent-label { - grid-column: 1; -} - -.battle-general-extra > .battle-general-extra__recent-value { - grid-column: 2 / -1; - text-align: left; -} - -.log-grid { - display: contents; -} - -.log-block { - border: 1px solid #666; - padding: 0; - background-color: #302016; - background-image: var(--sammo-texture-walnut); - min-height: 0; -} - -.log-title { - min-height: 34px; - margin: 0; - display: flex; - align-items: center; - justify-content: center; - border-bottom: 1px solid #666; - color: orange; - background: #000; - font-size: 1.3em; - font-weight: 500; -} - -.log-line { - padding: 2px 8px; - border-bottom: 0; -} - -.log-line :deep(.hidden_but_copyable) { - color: transparent !important; - font-size: 0; -} - -.empty { - padding: 2px 8px; - color: #999; -} - /* PanelCard is retained as a data wrapper, but its presentation follows the flat bootstrap rows used by the reference page. */ :deep(.panel-card) { @@ -466,8 +380,7 @@ onMounted(() => { font-size: 18px; font-weight: 500; } -:deep(.panel-header), -.log-title { +:deep(.panel-header) { background-image: var(--sammo-texture-green); } diff --git a/app/game-frontend/src/views/DynastyDetailView.vue b/app/game-frontend/src/views/DynastyDetailView.vue index 50acff83..ce963b3c 100644 --- a/app/game-frontend/src/views/DynastyDetailView.vue +++ b/app/game-frontend/src/views/DynastyDetailView.vue @@ -14,6 +14,7 @@ const router = useRouter(); const loading = ref(false); const errorMessage = ref(''); const data = ref(null); +const source = computed<'current' | 'legacy'>(() => (route.query.source === 'legacy' ? 'legacy' : 'current')); const emperorId = computed(() => { const idParam = route.params.id; @@ -39,7 +40,7 @@ const loadDetail = async (): Promise => { loading.value = true; errorMessage.value = ''; try { - data.value = await trpc.dynasty.getDetail.query({ emperorId: emperorId.value }); + data.value = await trpc.dynasty.getDetail.query({ emperorId: emperorId.value, source: source.value }); } catch (error) { data.value = null; errorMessage.value = error instanceof Error ? error.message : '왕조 정보를 불러오지 못했습니다.'; @@ -50,7 +51,7 @@ const loadDetail = async (): Promise => { const formatArchiveDate = (value: string): string => formatServerDateTime(value); -watch(emperorId, loadDetail); +watch([emperorId, source], loadDetail); onMounted(loadDetail); @@ -63,7 +64,8 @@ onMounted(loadDetail); 역 대 왕 조
- @@ -88,7 +90,12 @@ onMounted(loadDetail); - {{ data.emperor.phase }} + + {{ data.emperor.phase }} + + diff --git a/app/game-frontend/src/views/DynastyListView.vue b/app/game-frontend/src/views/DynastyListView.vue index ff4464ef..cec29466 100644 --- a/app/game-frontend/src/views/DynastyListView.vue +++ b/app/game-frontend/src/views/DynastyListView.vue @@ -1,6 +1,6 @@