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

지난 플레이를 현재 장수 카드와 전투·숙련도·기록 컴포넌트로 표시하고, 중앙 archive의 소유자 기반 조회를 연결한다. 명예의 전당과 왕조에 현재/이전 서버 source 분리를 추가한다.
This commit is contained in:
2026-08-17 15:55:43 +00:00
parent fc7de05017
commit d6a25c63b0
18 changed files with 2594 additions and 777 deletions
+442 -139
View File
@@ -1,124 +1,326 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { z } from 'zod'; 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 { readOnlyAuthedProcedure, router } from '../../trpc.js';
import { loadTraitNames } from '../nation/shared.js'; import { loadTraitNames } from '../nation/shared.js';
const numberOrNull = (value: unknown): number | null => const numberOrNull = (value: unknown): number | null =>
typeof value === 'number' && Number.isFinite(value) ? value : null; typeof value === 'number' && Number.isFinite(value) ? value : null;
const firstNumber = (record: Record<string, unknown>, ...keys: string[]): number | null => { const textOrNull = (value: unknown): string | null => {
for (const key of keys) { if (typeof value === 'string' && value.trim()) return value;
const value = numberOrNull(record[key]); if (typeof value === 'number' && Number.isFinite(value)) return String(value);
if (value !== null) {
return value;
}
}
return null; return null;
}; };
const displayTextOrNull = (value: unknown): string | null => { const numericText = (value: string | null): number | null => {
if (typeof value === 'string') { if (value === null || value.trim() === '') return null;
return value; const parsed = Number(value);
} return Number.isFinite(parsed) ? Math.trunc(parsed) : null;
return typeof value === 'number' && Number.isFinite(value) ? String(value) : null;
}; };
const parseHistory = (value: unknown): string[] => { const canonicalSnapshot = (value: unknown, fallbackName: string): ArchivedGeneralSnapshotV1 =>
if (Array.isArray(value)) { normalizeArchivedGeneral(value as ArchivedJsonValue, fallbackName).snapshot;
return value.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0);
}
if (typeof value !== 'string') {
return [];
}
return value
.split(/<br\s*\/?>/i)
.map((entry) => entry.trim())
.filter(Boolean);
};
const zPastPlayDetailInput = z.object({ 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), serverId: z.string().trim().min(1).max(64),
generalNo: z.number().int().positive(), generalNo: z.number().int().positive(),
}); });
type ArchiveSource = 'current' | 'legacy';
interface GeneralArchiveEntry {
source: ArchiveSource;
sourceProfile: string;
serverId: string;
generalNo: number;
name: string;
lastYearMonth: number;
turnTime: Date;
snapshot: ArchivedGeneralSnapshotV1;
}
interface ArchiveNationEntry {
source: ArchiveSource;
sourceProfile: string;
serverId: string;
nation: number;
archivedAt: Date;
data: Record<string, unknown>;
}
const key = (source: ArchiveSource, sourceProfile: string, serverId: string): string =>
`${source}:${sourceProfile}:${serverId}`;
const nationKey = (source: ArchiveSource, sourceProfile: string, serverId: string, nation: number): string =>
`${key(source, sourceProfile, serverId)}:${nation}`;
const resolveNation = (
nations: Map<string, ArchiveNationEntry>,
entry: GeneralArchiveEntry
): { nationId: number; name: string; color: string; level: number | null } => {
const nationId = entry.snapshot.identity.nationId ?? 0;
const archived = nations.get(nationKey(entry.source, entry.sourceProfile, entry.serverId, nationId));
return {
nationId,
name: textOrNull(archived?.data.name) ?? (nationId === 0 ? '재야' : '미상'),
color: textOrNull(archived?.data.color) ?? '#000000',
level: numberOrNull(archived?.data.level),
};
};
const resolveDisplayResources = async (entries: GeneralArchiveEntry[]) => {
const [personalityNames, domesticNames, warNames, itemNames] = await Promise.all([
loadTraitNames(
entries.map((entry) => entry.snapshot.traits.personality),
'personality'
),
loadTraitNames(
entries.map((entry) => entry.snapshot.traits.specialDomestic),
'domestic'
),
loadTraitNames(
entries.map((entry) => entry.snapshot.traits.specialWar),
'war'
),
loadItemDisplayNames(
entries.flatMap((entry) => [
entry.snapshot.items.horse,
entry.snapshot.items.weapon,
entry.snapshot.items.book,
entry.snapshot.items.item,
])
),
]);
const traitName = (code: string | null, names: Awaited<ReturnType<typeof loadTraitNames>>): string =>
code ? (names.get(code)?.name ?? sanitizeInternalDisplayCode(code)) : '-';
const itemName = (code: string | null): string =>
code ? (itemNames.get(code) ?? sanitizeInternalDisplayCode(code)) : '-';
return { personalityNames, domesticNames, warNames, traitName, itemName };
};
const buildGeneralDetail = async (entry: GeneralArchiveEntry, nation: ReturnType<typeof resolveNation>) => {
const snapshot = entry.snapshot;
const display = await resolveDisplayResources([entry]);
const crewTypeId = numericText(snapshot.resources.crewType);
const crewTypeNames = await loadCrewTypeDisplayNames(null, entry.sourceProfile);
const dedicationLevel = snapshot.progression.dedicationLevel ?? 0;
const maxDedicationLevel = 30;
return {
id: entry.generalNo,
name: snapshot.identity.name || entry.name,
picture: snapshot.identity.picture,
imageServer: snapshot.identity.imageServer,
npcState: snapshot.identity.npcState ?? 0,
officerLevel: snapshot.identity.officerLevel ?? 0,
officerLevelText: resolveOfficerLevelName(snapshot.identity.officerLevel ?? 0, nation.level ?? undefined),
stats: {
leadership: snapshot.stats.leadership ?? 0,
strength: snapshot.stats.strength ?? 0,
intelligence: snapshot.stats.intelligence ?? 0,
},
gold: snapshot.resources.gold ?? 0,
rice: snapshot.resources.rice ?? 0,
crew: snapshot.resources.crew ?? 0,
train: snapshot.resources.train ?? 0,
atmos: snapshot.resources.morale ?? 0,
injury: snapshot.resources.injury ?? 0,
experience: snapshot.progression.experience ?? 0,
dedication: snapshot.progression.dedication ?? 0,
...(snapshot.progression.age === null ? {} : { age: snapshot.progression.age }),
turnTime: entry.turnTime.toISOString(),
...(crewTypeId === null ? {} : { crewTypeId }),
crewTypeName: crewTypeId === null ? '-' : (crewTypeNames.get(crewTypeId) ?? '-'),
traits: {
personal: display.traitName(snapshot.traits.personality, display.personalityNames),
specialDomestic: display.traitName(snapshot.traits.specialDomestic, display.domesticNames),
specialWar: display.traitName(snapshot.traits.specialWar, display.warNames),
},
itemNames: {
horse: display.itemName(snapshot.items.horse),
weapon: display.itemName(snapshot.items.weapon),
book: display.itemName(snapshot.items.book),
item: display.itemName(snapshot.items.item),
},
progression: {
experienceLevel: snapshot.progression.experienceLevel ?? 0,
dedicationLevel,
dedicationText: resolveDedicationLevelName(dedicationLevel, maxDedicationLevel),
statExperience: {
leadership: snapshot.stats.leadershipExperience ?? 0,
strength: snapshot.stats.strengthExperience ?? 0,
intelligence: snapshot.stats.intelligenceExperience ?? 0,
},
statUpgradeLimit: 30,
dex: [
snapshot.mastery.infantry,
snapshot.mastery.archery,
snapshot.mastery.cavalry,
snapshot.mastery.special,
snapshot.mastery.siege,
].map((value) => value ?? 0),
},
};
};
export const archiveRouter = router({ export const archiveRouter = router({
myPastPlays: readOnlyAuthedProcedure.query(async ({ ctx }) => { myPastPlays: readOnlyAuthedProcedure.query(async ({ ctx }) => {
const owner = ctx.auth?.user.id; const owner = ctx.auth?.user.id;
if (!owner) { if (!owner) throw new Error('Authenticated archive query is missing its user identity');
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: [] };
}
const serverIds = [...new Set(generals.map((general) => general.serverId))]; const [legacyRows, currentRows] = await Promise.all([
const [games, nations, emperors] = await Promise.all([ findLegacyGeneralsByOwner(ctx.db, owner),
ctx.db.gameHistory.findMany({ ctx.db.oldGeneral.findMany({
where: { serverId: { in: serverIds } }, where: { owner },
}), orderBy: [{ lastYearMonth: 'desc' }, { serverId: 'desc' }, { generalNo: 'asc' }],
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 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 legacyKeys = Array.from(
const emperorByServer = new Map<string, number>(); new Map(
for (const emperor of emperors) { entries
if (emperor.serverId && !emperorByServer.has(emperor.serverId)) { .filter((entry) => entry.source === 'legacy')
emperorByServer.set(emperor.serverId, emperor.id); .map((entry) => [key(entry.source, entry.sourceProfile, entry.serverId), entry])
} ).values()
).map((entry) => ({ sourceProfile: entry.sourceProfile as LegacyArchiveProfile, serverId: entry.serverId }));
const currentServerIds = Array.from(
new Set(entries.filter((entry) => entry.source === 'current').map((entry) => entry.serverId))
);
const [legacyGames, legacyNationRows, legacyEmperors, currentGames, currentNationRows, currentEmperors] =
await Promise.all([
findLegacyGames(ctx.db, legacyKeys),
findLegacyNations(ctx.db, legacyKeys),
findLegacyEmperors(ctx.db, legacyKeys),
currentServerIds.length
? ctx.db.gameHistory.findMany({ where: { serverId: { in: currentServerIds } } })
: [],
currentServerIds.length
? ctx.db.oldNation.findMany({
where: { serverId: { in: currentServerIds } },
orderBy: [{ date: 'desc' }, { id: 'desc' }],
})
: [],
currentServerIds.length
? ctx.db.emperor.findMany({
where: { serverId: { in: currentServerIds } },
orderBy: { id: 'desc' },
select: { id: true, serverId: true },
})
: [],
]);
const games = new Map<string, { openedAt: Date; season: number; scenario: number; scenarioName: string }>();
for (const row of legacyGames) {
games.set(key('legacy', row.sourceProfile, row.serverId), row);
} }
const nationByServerAndId = new Map<string, (typeof nations)[number]>(); for (const row of currentGames) {
for (const nation of nations) { games.set(key('current', ctx.profile.id, row.serverId), {
const key = `${nation.serverId}:${nation.nation}`; openedAt: row.date,
if (!nationByServerAndId.has(key)) { season: row.season,
nationByServerAndId.set(key, nation); scenario: row.scenario,
} scenarioName: row.scenarioName,
});
} }
const archivedRoles = generals.map((general) => { const nations = new Map<string, ArchiveNationEntry>();
const data = asRecord(general.data); for (const row of legacyNationRows) {
const role = asRecord(data.role); const entry: ArchiveNationEntry = {
return { source: 'legacy',
personal: displayTextOrNull(data.personalCode ?? data.personal ?? role.personality), sourceProfile: row.sourceProfile,
special: displayTextOrNull(data.specialCode ?? data.special ?? role.specialDomestic), serverId: row.serverId,
special2: displayTextOrNull(data.special2Code ?? data.special2 ?? role.specialWar), nation: row.nation,
archivedAt: row.archivedAt,
data: asRecord(row.data),
}; };
}); const entryKey = nationKey(entry.source, entry.sourceProfile, entry.serverId, entry.nation);
const [personalityNames, domesticNames, warNames] = await Promise.all([ if (!nations.has(entryKey)) nations.set(entryKey, entry);
loadTraitNames( }
archivedRoles.map((role) => role.personal), for (const row of currentNationRows) {
'personality' const entry: ArchiveNationEntry = {
), source: 'current',
loadTraitNames( sourceProfile: ctx.profile.id,
archivedRoles.map((role) => role.special), serverId: row.serverId,
'domestic' nation: row.nation,
), archivedAt: row.date,
loadTraitNames( data: asRecord(row.data),
archivedRoles.map((role) => role.special2), };
'war' const entryKey = nationKey(entry.source, entry.sourceProfile, entry.serverId, entry.nation);
), if (!nations.has(entryKey)) nations.set(entryKey, entry);
]); }
const displayRole = (value: string | null, names: Awaited<ReturnType<typeof loadTraitNames>>): string | null => const dynastyIds = new Map<string, number>();
value ? (names.get(value)?.name ?? sanitizeInternalDisplayCode(value)) : null; 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< const seasons = new Map<
string, string,
{ {
source: ArchiveSource;
sourceProfile: string;
serverId: string; serverId: string;
openedAt: string | null;
date: string | null; date: string | null;
season: number | null; season: number | null;
scenario: number | null; scenario: number | null;
@@ -145,85 +347,186 @@ export const archiveRouter = router({
}>; }>;
} }
>(); >();
for (const entry of entries) {
for (const [generalIndex, general] of generals.entries()) { const entryKey = key(entry.source, entry.sourceProfile, entry.serverId);
const game = gameByServer.get(general.serverId); const game = games.get(entryKey);
let season = seasons.get(general.serverId); let season = seasons.get(entryKey);
if (!season) { if (!season) {
const openedAt = game?.openedAt.toISOString() ?? null;
season = { season = {
serverId: general.serverId, source: entry.source,
date: game?.date.toISOString() ?? null, sourceProfile: entry.sourceProfile,
serverId: entry.serverId,
openedAt,
date: openedAt,
season: game?.season ?? null, season: game?.season ?? null,
scenario: game?.scenario ?? null, scenario: game?.scenario ?? null,
scenarioName: game?.scenarioName ?? null, scenarioName: game?.scenarioName ?? null,
dynastyId: emperorByServer.get(general.serverId) ?? null, dynastyId: dynastyIds.get(entryKey) ?? null,
generals: [], generals: [],
}; };
seasons.set(general.serverId, season); seasons.set(entryKey, season);
} }
const snapshot = entry.snapshot;
const data = asRecord(general.data); const nation = resolveNation(nations, entry);
const stats = asRecord(data.stats); const officerLevel = snapshot.identity.officerLevel;
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]!;
season.generals.push({ season.generals.push({
generalNo: general.generalNo, generalNo: entry.generalNo,
name: general.name, name: snapshot.identity.name || entry.name,
lastYearMonth: general.lastYearMonth, lastYearMonth: entry.lastYearMonth,
nationId, nationId: nation.nationId,
nationName: displayTextOrNull(nationData.name) ?? (nationId === 0 ? '재야' : '미상'), nationName: nation.name,
nationColor: displayTextOrNull(nationData.color) ?? '#000000', nationColor: nation.color,
leadership: firstNumber(data, 'leadership', 'leader') ?? numberOrNull(stats.leadership), leadership: snapshot.stats.leadership,
strength: firstNumber(data, 'strength', 'power') ?? numberOrNull(stats.strength), strength: snapshot.stats.strength,
intel: firstNumber(data, 'intel', 'intelligence') ?? numberOrNull(stats.intelligence), intel: snapshot.stats.intelligence,
experience: numberOrNull(data.experience), experience: snapshot.progression.experience,
dedication: numberOrNull(data.dedication), dedication: snapshot.progression.dedication,
officerLevel, officerLevel,
officerLevelText: officerLevelText:
officerLevel === null officerLevel === null ? null : resolveOfficerLevelName(officerLevel, nation.level ?? undefined),
? null personal: display.traitName(snapshot.traits.personality, display.personalityNames),
: resolveOfficerLevelName(officerLevel, nationLevel === null ? undefined : nationLevel), special: display.traitName(snapshot.traits.specialDomestic, display.domesticNames),
personal: displayRole(archivedRole.personal, personalityNames), special2: display.traitName(snapshot.traits.specialWar, display.warNames),
special: displayRole(archivedRole.special, domesticNames), historyCount: snapshot.history.length,
special2: displayRole(archivedRole.special2, warNames),
historyCount: parseHistory(data.history).length,
}); });
} }
return { return {
seasons: [...seasons.values()].sort((left, right) => { seasons: [...seasons.values()].sort((left, right) => {
const leftTime = left.date ? new Date(left.date).getTime() : 0; const leftTime = left.openedAt ? new Date(left.openedAt).getTime() : 0;
const rightTime = right.date ? new Date(right.date).getTime() : 0; const rightTime = right.openedAt ? new Date(right.openedAt).getTime() : 0;
return rightTime - leftTime || right.serverId.localeCompare(left.serverId); return rightTime - leftTime || right.serverId.localeCompare(left.serverId);
}), }),
}; };
}), }),
myPastPlayDetail: readOnlyAuthedProcedure.input(zPastPlayDetailInput).query(async ({ ctx, input }) => { myPastPlayDetail: readOnlyAuthedProcedure.input(zPastPlayDetailInput).query(async ({ ctx, input }) => {
const owner = ctx.auth?.user.id; const owner = ctx.auth?.user.id;
if (!owner) { if (!owner) throw new Error('Authenticated archive query is missing its user identity');
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) {
const general = await ctx.db.oldGeneral.findFirst({
where: {
owner,
serverId: input.serverId,
generalNo: input.generalNo,
},
});
if (!general) {
throw new TRPCError({ code: 'NOT_FOUND', message: '지난 장수 기록을 찾을 수 없습니다.' }); throw new TRPCError({ code: 'NOT_FOUND', message: '지난 장수 기록을 찾을 수 없습니다.' });
} }
const data = asRecord(general.data);
let entry: GeneralArchiveEntry | null = null;
let nationRows: ArchiveNationEntry[] = [];
let dynastyId: number | null = null;
if (input.source === 'legacy') {
if (!LEGACY_ARCHIVE_PROFILES.includes(sourceProfile as LegacyArchiveProfile)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이전 서버 프로필입니다.' });
}
const profile = sourceProfile as LegacyArchiveProfile;
const row = await findLegacyGeneral(ctx.db, {
owner,
sourceProfile: profile,
serverId: input.serverId,
generalNo: input.generalNo,
});
if (row) {
entry = {
source: 'legacy',
sourceProfile: row.sourceProfile,
serverId: row.serverId,
generalNo: row.generalNo,
name: row.name,
lastYearMonth: row.lastYearMonth,
turnTime: row.turnTime,
snapshot: canonicalSnapshot(row.data, row.name),
};
const keyInput = [{ sourceProfile: profile, serverId: input.serverId }];
const [nations, emperors] = await Promise.all([
findLegacyNations(ctx.db, keyInput),
findLegacyEmperors(ctx.db, keyInput),
]);
nationRows = nations.map((nation) => ({
source: 'legacy',
sourceProfile: nation.sourceProfile,
serverId: nation.serverId,
nation: nation.nation,
archivedAt: nation.archivedAt,
data: asRecord(nation.data),
}));
dynastyId = Number(emperors[0]?.id ?? 0) || null;
}
} else {
const row = await ctx.db.oldGeneral.findFirst({
where: { owner, serverId: input.serverId, generalNo: input.generalNo },
});
if (row) {
entry = {
source: 'current',
sourceProfile: ctx.profile.id,
serverId: row.serverId,
generalNo: row.generalNo,
name: row.name,
lastYearMonth: row.lastYearMonth,
turnTime: row.turnTime,
snapshot: canonicalSnapshot(row.data, row.name),
};
const [nations, emperor] = await Promise.all([
ctx.db.oldNation.findMany({
where: { serverId: input.serverId },
orderBy: [{ date: 'desc' }, { id: 'desc' }],
}),
ctx.db.emperor.findFirst({ where: { serverId: input.serverId }, orderBy: { id: 'desc' } }),
]);
nationRows = nations.map((nation) => ({
source: 'current',
sourceProfile: ctx.profile.id,
serverId: nation.serverId,
nation: nation.nation,
archivedAt: nation.date,
data: asRecord(nation.data),
}));
dynastyId = emperor?.id ?? null;
}
}
if (!entry) {
throw new TRPCError({ code: 'NOT_FOUND', message: '지난 장수 기록을 찾을 수 없습니다.' });
}
const nationMap = new Map<string, ArchiveNationEntry>();
for (const nation of nationRows) {
const entryKey = nationKey(nation.source, nation.sourceProfile, nation.serverId, nation.nation);
if (!nationMap.has(entryKey)) nationMap.set(entryKey, nation);
}
const nation = resolveNation(nationMap, entry);
const snapshot = entry.snapshot;
const general = await buildGeneralDetail(entry, nation);
const logs = {
generalHistory: {
available: snapshot.availability.history,
entries: snapshot.history.map((text, index) => ({ id: index + 1, text })),
},
battleDetail: { available: snapshot.availability.battleDetailLogs, entries: [] },
battleResult: { available: snapshot.availability.battleResultLogs, entries: [] },
generalAction: { available: false, entries: [] },
};
return { return {
serverId: general.serverId, source: entry.source,
generalNo: general.generalNo, sourceProfile: entry.sourceProfile,
name: general.name, serverId: entry.serverId,
lastYearMonth: general.lastYearMonth, generalNo: entry.generalNo,
history: parseHistory(data.history), 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,
}; };
}), }),
}); });
+159 -1
View File
@@ -4,11 +4,24 @@ import { z } from 'zod';
import { asRecord } from '@sammo-ts/common'; import { asRecord } from '@sammo-ts/common';
import { procedure, router } from '../../trpc.js'; import { procedure, router } from '../../trpc.js';
import {
findLegacyEmperor,
findLegacyEmperors,
findLegacyGeneralsForServer,
findLegacyNations,
} from '../../services/legacyArchiveStore.js';
const zDynastyDetailInput = z.object({ const zDynastyDetailInput = z.object({
emperorId: z.number().int().positive(), 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[] => const parseNumberArray = (value: unknown): number[] =>
Array.isArray(value) Array.isArray(value)
? value.filter((item): item is number => typeof item === 'number' && Number.isFinite(item)) ? value.filter((item): item is number => typeof item === 'number' && Number.isFinite(item))
@@ -44,6 +57,41 @@ const firstFiniteNumber = (...values: unknown[]): number | null => {
return null; return null;
}; };
const firstText = (...values: unknown[]): string => {
for (const value of values) {
if (typeof value === 'string') return value;
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
}
return '';
};
const legacyEmperorListEntry = (row: Awaited<ReturnType<typeof findLegacyEmperors>>[number]) => {
const data = asRecord(row.data);
return {
id: Number(row.id),
source: 'legacy' as const,
sourceProfile: row.sourceProfile,
serverId: row.serverId ?? '',
phase: firstText(data.phase),
name: firstText(data.name),
year: firstFiniteNumber(data.year) ?? 0,
month: firstFiniteNumber(data.month) ?? 0,
color: firstText(data.color) || '#000000',
type: firstText(data.type),
power: firstFiniteNumber(data.power) ?? 0,
gennum: firstFiniteNumber(data.gennum) ?? 0,
citynum: firstFiniteNumber(data.citynum) ?? 0,
l12name: firstText(data.l12name),
l11name: firstText(data.l11name),
l10name: firstText(data.l10name),
l9name: firstText(data.l9name),
l8name: firstText(data.l8name),
l7name: firstText(data.l7name),
l6name: firstText(data.l6name),
l5name: firstText(data.l5name),
};
};
const firstDisplayArray = (...values: unknown[]): Array<string | number> => { const firstDisplayArray = (...values: unknown[]): Array<string | number> => {
for (const value of values) { for (const value of values) {
const parsed = parseDisplayArray(value); const parsed = parseDisplayArray(value);
@@ -82,7 +130,15 @@ const formatNationLevel = (level: number | null): string => {
}; };
export const dynastyRouter = router({ 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([ const [worldState, rows] = await Promise.all([
ctx.db.worldState.findFirst({ ctx.db.worldState.findFirst({
select: { select: {
@@ -96,6 +152,7 @@ export const dynastyRouter = router({
]); ]);
return { return {
source: 'current' as const,
current: worldState current: worldState
? { ? {
year: worldState.currentYear, year: worldState.currentYear,
@@ -104,6 +161,8 @@ export const dynastyRouter = router({
: null, : null,
entries: rows.map((row) => ({ entries: rows.map((row) => ({
id: row.id, id: row.id,
source: 'current' as const,
sourceProfile: ctx.profile.id,
serverId: row.serverId ?? '', serverId: row.serverId ?? '',
phase: row.phase ?? '', phase: row.phase ?? '',
name: row.name ?? '', name: row.name ?? '',
@@ -126,6 +185,103 @@ export const dynastyRouter = router({
}; };
}), }),
getDetail: procedure.input(zDynastyDetailInput).query(async ({ ctx, input }) => { 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({ const emperor = await ctx.db.emperor.findUnique({
where: { id: input.emperorId }, where: { id: input.emperorId },
}); });
@@ -192,6 +348,8 @@ export const dynastyRouter = router({
})); }));
return { return {
source: 'current' as const,
sourceProfile: ctx.profile.id,
emperor: { emperor: {
id: emperor.id, id: emperor.id,
serverId, serverId,
+385 -306
View File
@@ -7,6 +7,11 @@ import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacy
import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import { accessAuthedInputProcedure, accessInputProcedure, procedure, router } from '../../trpc.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_BG_COLOR = '#330000';
const DEFAULT_FG_COLOR = '#ffffff'; const DEFAULT_FG_COLOR = '#ffffff';
@@ -82,372 +87,446 @@ const loadUniqueItems = () => {
export const rankingRouter = router({ export const rankingRouter = router({
getBestGeneral: accessAuthedInputProcedure( getBestGeneral: accessAuthedInputProcedure(
z z
.object({ .object({
view: z.enum(['user', 'npc']).optional(), view: z.enum(['user', 'npc']).optional(),
}) })
.optional() .optional()
) ).query(async ({ ctx, input }) => {
.query(async ({ ctx, input }) => { const worldState = await ctx.db.worldState.findFirst({
const worldState = await ctx.db.worldState.findFirst({ select: { meta: true, config: true },
select: { meta: true, config: true }, });
}); const meta = asRecord(worldState?.meta);
const meta = asRecord(worldState?.meta); const isUnited = typeof meta.isUnited === 'number' && meta.isUnited !== 0;
const isUnited = typeof meta.isUnited === 'number' && meta.isUnited !== 0;
const view = input?.view ?? 'user'; const view = input?.view ?? 'user';
const npcFilter = view === 'npc' ? { gte: 2 } : { lt: 2 }; const npcFilter = view === 'npc' ? { gte: 2 } : { lt: 2 };
const [nations, generals] = await Promise.all([ const [nations, generals] = await Promise.all([
ctx.db.nation.findMany({ select: { id: true, name: true, color: true } }), ctx.db.nation.findMany({ select: { id: true, name: true, color: true } }),
ctx.db.general.findMany({ ctx.db.general.findMany({
where: { npcState: npcFilter }, where: { npcState: npcFilter },
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
select: { select: {
id: true, id: true,
name: true, name: true,
nationId: true, nationId: true,
userId: true, userId: true,
picture: true, picture: true,
imageServer: true, imageServer: true,
meta: true, meta: true,
experience: true, experience: true,
dedication: true, dedication: true,
horseCode: true, horseCode: true,
weaponCode: true, weaponCode: true,
bookCode: true, bookCode: true,
itemCode: true, itemCode: true,
}, },
}), }),
]); ]);
const nationMap = new Map(nations.map((nation) => [nation.id, nation])); const nationMap = new Map(nations.map((nation) => [nation.id, nation]));
const generalIds = generals.map((general) => general.id); const generalIds = generals.map((general) => general.id);
const rankRows = await ctx.db.rankData.findMany({ const rankRows = await ctx.db.rankData.findMany({
where: { generalId: { in: generalIds } }, where: { generalId: { in: generalIds } },
select: { generalId: true, type: true, value: true }, select: { generalId: true, type: true, value: true },
}); });
const rankMap = new Map<number, Record<string, number>>(); const rankMap = new Map<number, Record<string, number>>();
for (const row of rankRows) { for (const row of rankRows) {
const entry = rankMap.get(row.generalId) ?? {}; const entry = rankMap.get(row.generalId) ?? {};
entry[row.type] = row.value; entry[row.type] = row.value;
rankMap.set(row.generalId, entry); rankMap.set(row.generalId, entry);
} }
const types: Array<[string, 'int' | 'percent', (general: typeof generals[number], ranks: Record<string, number>) => number]> = [ const types: Array<
['명 성', 'int', (g) => g.experience], [string, 'int' | 'percent', (general: (typeof generals)[number], ranks: Record<string, number>) => number]
['계 급', 'int', (g) => g.dedication], > = [
['계 략 성 공', 'int', (_g, r) => r.firenum ?? 0], ['명 성', 'int', (g) => g.experience],
['전 투 횟 수', 'int', (_g, r) => r.warnum ?? 0], ['계 급', 'int', (g) => g.dedication],
['승 리', 'int', (_g, r) => r.killnum ?? 0], ['계 략 성 공', 'int', (_g, r) => r.firenum ?? 0],
['승 률', 'percent', (_g, r) => { ['전 투 횟 수', 'int', (_g, r) => r.warnum ?? 0],
['승 리', 'int', (_g, r) => r.killnum ?? 0],
[
'승 률',
'percent',
(_g, r) => {
const warnum = r.warnum ?? 0; const warnum = r.warnum ?? 0;
if (warnum < 10) { if (warnum < 10) {
return 0; return 0;
} }
return (r.killnum ?? 0) / Math.max(1, warnum); return (r.killnum ?? 0) / Math.max(1, warnum);
}], },
['점 령', 'int', (_g, r) => r.occupied ?? 0], ],
['사 살', 'int', (_g, r) => r.killcrew ?? 0], ['점 령', 'int', (_g, r) => r.occupied ?? 0],
['살 상 률', 'percent', (_g, r) => { ['살', 'int', (_g, r) => r.killcrew ?? 0],
[
'살 상 률',
'percent',
(_g, r) => {
const warnum = r.warnum ?? 0; const warnum = r.warnum ?? 0;
if (warnum < 10) { if (warnum < 10) {
return 0; return 0;
} }
return (r.killcrew ?? 0) / Math.max(1, r.deathcrew ?? 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; const warnum = r.warnum ?? 0;
if (warnum < 10) { if (warnum < 10) {
return 0; return 0;
} }
return (r.killcrew_person ?? 0) / Math.max(1, r.deathcrew_person ?? 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).dex1)],
[' 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex3)], [' 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex2)],
[' 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex4)], [' 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex3)],
[' 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex5)], [' 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex4)],
['전 력 전 승 률', 'percent', (_g, r) => { ['차 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex5)],
[
'전 력 전 승 률',
'percent',
(_g, r) => {
const total = (r.ttw ?? 0) + (r.ttd ?? 0) + (r.ttl ?? 0); const total = (r.ttw ?? 0) + (r.ttd ?? 0) + (r.ttl ?? 0);
if (total < 50) { if (total < 50) {
return 0; return 0;
} }
return (r.ttw ?? 0) / Math.max(1, total); 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); const total = (r.tlw ?? 0) + (r.tld ?? 0) + (r.tll ?? 0);
if (total < 50) { if (total < 50) {
return 0; return 0;
} }
return (r.tlw ?? 0) / Math.max(1, total); 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); const total = (r.tsw ?? 0) + (r.tsd ?? 0) + (r.tsl ?? 0);
if (total < 50) { if (total < 50) {
return 0; return 0;
} }
return (r.tsw ?? 0) / Math.max(1, total); 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); const total = (r.tiw ?? 0) + (r.tid ?? 0) + (r.til ?? 0);
if (total < 50) { if (total < 50) {
return 0; return 0;
} }
return (r.tiw ?? 0) / Math.max(1, total); return (r.tiw ?? 0) / Math.max(1, total);
}], },
['베 팅 투 자 액', 'int', (_g, r) => r.betgold ?? 0], ],
['베 팅 당 첨', 'int', (_g, r) => r.betwin ?? 0], ['베 팅 투 자 액', 'int', (_g, r) => r.betgold ?? 0],
['베 팅 수 익 금', 'int', (_g, r) => r.betwingold ?? 0], ['베 팅 당 첨', 'int', (_g, r) => r.betwin ?? 0],
['베 팅 수 익 ', 'percent', (_g, r) => { ['베 팅 수 익 ', 'int', (_g, r) => r.betwingold ?? 0],
[
'베 팅 수 익 률',
'percent',
(_g, r) => {
const betgold = r.betgold ?? 0; const betgold = r.betgold ?? 0;
if (betgold < 1000) { if (betgold < 1000) {
return 0; return 0;
} }
return (r.betwingold ?? 0) / Math.max(1, betgold); 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 sections = types.map(([title, valueType, valueFn]) => {
const entries = generals const entries = generals
.map((general) => {
const ranks = rankMap.get(general.id) ?? {};
const value = valueFn(general, ranks);
const nation = nationMap.get(general.nationId) ?? null;
const bgColor = nation?.color ?? (general.nationId === 0 ? NEUTRAL_BG_COLOR : DEFAULT_BG_COLOR);
let display = {
id: general.id,
name: general.name,
ownerName: isUnited ? readOwnerDisplayName(general.meta) : null,
nationName: nation?.name ?? '재야',
bgColor,
fgColor: resolveLegacyTextColor(bgColor),
picture: general.picture ?? null,
imageServer: general.imageServer ?? 0,
value,
printValue: valueType === 'percent' ? percentText(value) : formatLegacyRankingNumber(value),
};
if (
!isUnited &&
(title === '계 략 성 공' || title === '유 산 소 모 량' || title === '유 산 획 득 량')
) {
display = {
...display,
name: '???',
ownerName: null,
nationName: '???',
bgColor: DEFAULT_BG_COLOR,
fgColor: resolveLegacyTextColor(DEFAULT_BG_COLOR),
picture: null,
imageServer: 0,
};
}
return display;
})
.filter((entry) => entry.value > 0)
.sort((a, b) => b.value - a.value)
.slice(0, 10);
return { title, valueType, entries };
});
const uniqueItems = await loadUniqueItems();
const itemRegistry = new Map(uniqueItems.map((item) => [item.key, item]));
const uniqueConfig = resolveUniqueConfig(asRecord(asRecord(worldState?.config).const));
if (Object.keys(uniqueConfig.allItems).length === 0) {
uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry);
}
const activeAuctions = await ctx.db.auction.findMany({
where: {
type: 'UNIQUE_ITEM',
status: { in: ['OPEN', 'FINALIZING'] },
targetCode: { not: null },
},
select: { targetCode: true },
});
const auctionCounts = new Map<string, number>();
for (const auction of activeAuctions) {
if (auction.targetCode) {
auctionCounts.set(auction.targetCode, (auctionCounts.get(auction.targetCode) ?? 0) + 1);
}
}
const slotTitles = {
horse: '명 마',
weapon: '명 검',
book: '명 서',
item: '도 구',
} as const;
const itemEntries = (['horse', 'weapon', 'book', 'item'] as const).map((slot) => {
const configuredItems = Object.entries(uniqueConfig.allItems[slot] ?? {}).reverse();
const entries = configuredItems.flatMap(([itemKey, rawCount]) => {
const item = itemRegistry.get(itemKey);
if (!item || item.buyable) {
return [];
}
const owners = generals
.filter((general) => {
if (slot === 'horse') {
return general.horseCode === itemKey;
}
if (slot === 'weapon') {
return general.weaponCode === itemKey;
}
if (slot === 'book') {
return general.bookCode === itemKey;
}
return general.itemCode === itemKey;
})
.map((general) => { .map((general) => {
const ranks = rankMap.get(general.id) ?? {};
const value = valueFn(general, ranks);
const nation = nationMap.get(general.nationId) ?? null; const nation = nationMap.get(general.nationId) ?? null;
const bgColor = const bgColor = nation?.color ?? (general.nationId === 0 ? NEUTRAL_BG_COLOR : DEFAULT_BG_COLOR);
nation?.color ?? (general.nationId === 0 ? NEUTRAL_BG_COLOR : DEFAULT_BG_COLOR); return {
let display = {
id: general.id, id: general.id,
name: general.name, name: general.name,
ownerName: isUnited ? readOwnerDisplayName(general.meta) : null,
nationName: nation?.name ?? '재야', nationName: nation?.name ?? '재야',
bgColor, bgColor,
fgColor: resolveLegacyTextColor(bgColor), fgColor: resolveLegacyTextColor(bgColor),
picture: general.picture ?? null, picture: general.picture ?? null,
imageServer: general.imageServer ?? 0, imageServer: general.imageServer ?? 0,
value,
printValue:
valueType === 'percent' ? percentText(value) : formatLegacyRankingNumber(value),
}; };
});
if (!isUnited && (title === '계 략 성 공' || title === '유 산 소 모 량' || title === '유 산 획 득 량')) { for (let index = 0; index < (auctionCounts.get(itemKey) ?? 0); index += 1) {
display = { owners.push({
...display, id: 0,
name: '???', name: '경매중',
ownerName: null, nationName: '-',
nationName: '???', bgColor: '#00582c',
bgColor: DEFAULT_BG_COLOR, fgColor: '#ffffff',
fgColor: resolveLegacyTextColor(DEFAULT_BG_COLOR), picture: null,
picture: null, imageServer: 0,
imageServer: 0, });
};
}
return display;
})
.filter((entry) => entry.value > 0)
.sort((a, b) => b.value - a.value)
.slice(0, 10);
return { title, valueType, entries };
});
const uniqueItems = await loadUniqueItems();
const itemRegistry = new Map(uniqueItems.map((item) => [item.key, item]));
const uniqueConfig = resolveUniqueConfig(asRecord(asRecord(worldState?.config).const));
if (Object.keys(uniqueConfig.allItems).length === 0) {
uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry);
}
const activeAuctions = await ctx.db.auction.findMany({
where: {
type: 'UNIQUE_ITEM',
status: { in: ['OPEN', 'FINALIZING'] },
targetCode: { not: null },
},
select: { targetCode: true },
});
const auctionCounts = new Map<string, number>();
for (const auction of activeAuctions) {
if (auction.targetCode) {
auctionCounts.set(auction.targetCode, (auctionCounts.get(auction.targetCode) ?? 0) + 1);
} }
} const count = Math.max(0, Math.floor(rawCount));
const slotTitles = { return Array.from({ length: count }, (_, index) => ({
horse: '명 마', itemKey,
weapon: '명 검', itemName: item.name,
book: '명 서', itemInfo: item.info,
item: '도 구', owner: owners[index] ?? {
} as const; id: 0,
const itemEntries = (['horse', 'weapon', 'book', 'item'] as const).map((slot) => { name: '미발견',
const configuredItems = Object.entries(uniqueConfig.allItems[slot] ?? {}).reverse(); nationName: '-',
const entries = configuredItems.flatMap(([itemKey, rawCount]) => { bgColor: DEFAULT_BG_COLOR,
const item = itemRegistry.get(itemKey); fgColor: resolveLegacyTextColor(DEFAULT_BG_COLOR),
if (!item || item.buyable) { picture: null,
return []; imageServer: 0,
} },
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 };
}); });
return { title: slotTitles[slot], slot, entries };
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' }],
}); });
const seasonMap = new Map<number, { season: number; scenarios: Array<{ id: number; name: string; count: number }> }>();
for (const row of rows) { return {
const entry = seasonMap.get(row.season) ?? { season: row.season, scenarios: [] }; isUnited,
const scenario = entry.scenarios.find((item) => item.id === row.scenario); sections,
if (scenario) { uniqueItems: itemEntries,
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( getHallOfFameOptions: procedure
z.object({ .input(z.object({ source: z.enum(['current', 'legacy']).default('current') }).optional())
season: z.number().int(),
scenario: z.number().int().optional(),
})
)
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const baseWhere = { if ((input?.source ?? 'current') === 'legacy') {
season: input.season, const rows = await findLegacyHallOptions(ctx.db);
...(input.scenario !== undefined ? { scenario: input.scenario } : {}), const optionMap = new Map<
}; string,
{
const types: Array<{ key: HallOfFameType; title: string; type: 'int' | 'percent' }> = [ sourceProfile: (typeof LEGACY_ARCHIVE_PROFILES)[number];
{ key: 'experience', title: '명 성', type: 'int' }, season: number;
{ key: 'dedication', title: '계 급', type: 'int' }, scenarios: Array<{ id: number; name: string; count: number }>;
{ 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 = await ctx.db.hallOfFame.findMany({ >();
where: { ...baseWhere, type: type.key }, for (const row of rows) {
orderBy: { value: 'desc' }, const key = `${row.sourceProfile}:${row.season}`;
take: 10, const entry = optionMap.get(key) ?? {
}); sourceProfile: row.sourceProfile,
const entries = rows.map((row) => { season: row.season,
const aux = asRecord(row.aux); scenarios: [],
return { };
generalId: row.generalNo, entry.scenarios.push({ id: row.scenario, name: row.scenarioName, count: Number(row.count) });
name: String(aux.name ?? ''), optionMap.set(key, entry);
ownerName: }
typeof aux.ownerDisplayName === 'string' && aux.ownerDisplayName.length > 0 return Array.from(optionMap.values());
? aux.ownerDisplayName }
: null, const rows = await ctx.db.gameHistory.findMany({
nationName: String(aux.nationName ?? ''), select: { season: true, scenario: true, scenarioName: true },
bgColor: String(aux.bgColor ?? DEFAULT_BG_COLOR), orderBy: [{ season: 'desc' }, { scenario: 'asc' }],
fgColor: String(aux.fgColor ?? DEFAULT_FG_COLOR), });
picture: typeof aux.picture === 'string' ? aux.picture : null, const seasonMap = new Map<
imageServer: readMetaNumber(aux.imgsvr), number,
value: row.value, { sourceProfile: string; season: number; scenarios: Array<{ id: number; name: string; count: number }> }
printValue: >();
type.type === 'percent' for (const row of rows) {
? percentText(row.value) const entry = seasonMap.get(row.season) ?? {
: formatLegacyRankingNumber(row.value), sourceProfile: ctx.profile.id,
serverName: String(aux.serverName ?? ''), season: row.season,
serverIdx: readMetaNumber(aux.serverIdx), scenarios: [],
scenarioName: String(aux.scenarioName ?? ''), };
startTime: typeof aux.startTime === 'string' ? aux.startTime : null, const scenario = entry.scenarios.find((item) => item.id === row.scenario);
unitedTime: typeof aux.unitedTime === 'string' ? aux.unitedTime : null, if (scenario) {
}; scenario.count += 1;
}); } else {
return { title: type.title, valueType: type.type, entries }; entry.scenarios.push({ id: row.scenario, name: row.scenarioName, count: 1 });
}) }
); seasonMap.set(row.season, entry);
}
return { sections }; return Array.from(seasonMap.values());
}), }),
getHallOfFame: accessInputProcedure(
z.object({
source: z.enum(['current', 'legacy']).default('current'),
sourceProfile: z.enum(LEGACY_ARCHIVE_PROFILES).optional(),
season: z.number().int(),
scenario: z.number().int().optional(),
})
).query(async ({ ctx, input }) => {
const baseWhere = {
season: input.season,
...(input.scenario !== undefined ? { scenario: input.scenario } : {}),
};
const types: Array<{ key: HallOfFameType; title: string; type: 'int' | 'percent' }> = [
{ key: 'experience', title: '명 성', type: 'int' },
{ key: 'dedication', title: '계 급', type: 'int' },
{ key: 'firenum', title: '계 략 성 공', type: 'int' },
{ key: 'warnum', title: '전 투 횟 수', type: 'int' },
{ key: 'killnum', title: '승 리', type: 'int' },
{ key: 'winrate', title: '승 률', type: 'percent' },
{ key: 'occupied', title: '점 령', type: 'int' },
{ key: 'killcrew', title: '사 살', type: 'int' },
{ key: 'killrate', title: '살 상 률', type: 'percent' },
{ key: 'killcrew_person', title: '대 인 사 살', type: 'int' },
{ key: 'killrate_person', title: '대 인 살 상 률', type: 'percent' },
{ key: 'dex1', title: '보 병 숙 련 도', type: 'int' },
{ key: 'dex2', title: '궁 병 숙 련 도', type: 'int' },
{ key: 'dex3', title: '기 병 숙 련 도', type: 'int' },
{ key: 'dex4', title: '귀 병 숙 련 도', type: 'int' },
{ key: 'dex5', title: '차 병 숙 련 도', type: 'int' },
{ key: 'ttrate', title: '전 력 전 승 률', type: 'percent' },
{ key: 'tlrate', title: '통 솔 전 승 률', type: 'percent' },
{ key: 'tsrate', title: '일 기 토 승 률', type: 'percent' },
{ key: 'tirate', title: '설 전 승 률', type: 'percent' },
{ key: 'betgold', title: '베 팅 투 자 액', type: 'int' },
{ key: 'betwin', title: '베 팅 당 첨', type: 'int' },
{ key: 'betwingold', title: '베 팅 수 익 금', type: 'int' },
{ key: 'betrate', title: '베 팅 수 익 률', type: 'percent' },
];
const allowedTypes = new Set(HALL_OF_FAME_TYPES);
const sections = await Promise.all(
types.map(async (type) => {
if (!allowedTypes.has(type.key)) {
return { title: type.title, valueType: type.type, entries: [] };
}
const rows =
input.source === 'legacy'
? input.sourceProfile
? await findLegacyHallRows(ctx.db, {
sourceProfile: input.sourceProfile,
season: input.season,
...(input.scenario === undefined ? {} : { scenario: input.scenario }),
type: type.key,
take: 10,
})
: []
: await ctx.db.hallOfFame.findMany({
where: { ...baseWhere, type: type.key },
orderBy: { value: 'desc' },
take: 10,
});
const entries = rows.map((row) => {
const aux = asRecord(row.aux);
return {
generalId: row.generalNo,
name: String(aux.name ?? ''),
ownerName:
typeof aux.ownerDisplayName === 'string' && aux.ownerDisplayName.length > 0
? aux.ownerDisplayName
: null,
nationName: String(aux.nationName ?? ''),
bgColor: String(aux.bgColor ?? DEFAULT_BG_COLOR),
fgColor: String(aux.fgColor ?? DEFAULT_FG_COLOR),
picture: typeof aux.picture === 'string' ? aux.picture : null,
imageServer: readMetaNumber(aux.imgsvr),
value: row.value,
printValue:
type.type === 'percent' ? percentText(row.value) : formatLegacyRankingNumber(row.value),
serverName: String(aux.serverName ?? ''),
serverIdx: readMetaNumber(aux.serverIdx),
scenarioName: String(aux.scenarioName ?? ''),
startTime: typeof aux.startTime === 'string' ? aux.startTime : null,
unitedTime: typeof aux.unitedTime === 'string' ? aux.unitedTime : null,
};
});
return { title: type.title, valueType: type.type, entries };
})
);
return { source: input.source, sourceProfile: input.sourceProfile ?? ctx.profile.id, sections };
}),
}); });
@@ -0,0 +1,264 @@
import { GamePrisma } from '@sammo-ts/infra';
import { isLegacyArchiveProfile, LEGACY_ARCHIVE_PROFILES, type LegacyArchiveProfile } from '@sammo-ts/common';
import type { DatabaseClient } from '../context.js';
export { isLegacyArchiveProfile, LEGACY_ARCHIVE_PROFILES, type LegacyArchiveProfile };
export type LegacyArchiveDatabase = Pick<DatabaseClient, '$queryRaw'>;
export interface LegacyGameHistoryRow {
sourceProfile: LegacyArchiveProfile;
serverId: string;
legacyId: number;
openedAt: Date;
completedAt: Date | null;
legacyDate: Date;
winnerNation: number | null;
map: string | null;
season: number;
scenario: number;
scenarioName: string;
rawEnv: unknown;
}
export interface LegacyGeneralRow {
sourceProfile: LegacyArchiveProfile;
serverId: string;
generalNo: number;
legacyId: number;
owner: string | null;
name: string;
lastYearMonth: number;
turnTime: Date;
schemaVersion: number;
sourceFormat: string;
data: unknown;
}
export interface LegacyNationRow {
sourceProfile: LegacyArchiveProfile;
legacyId: number;
serverId: string;
nation: number;
data: unknown;
archivedAt: Date;
}
export interface LegacyEmperorRow {
id: bigint;
sourceProfile: LegacyArchiveProfile;
legacyId: number;
serverId: string | null;
data: unknown;
}
export interface LegacyHallOptionRow {
sourceProfile: LegacyArchiveProfile;
season: number;
scenario: number;
scenarioName: string;
count: bigint;
}
export interface LegacyHallRow {
sourceProfile: LegacyArchiveProfile;
serverId: string;
generalNo: number;
type: string;
value: number;
owner: string | null;
aux: unknown;
}
export const findLegacyGeneralsByOwner = async (
db: LegacyArchiveDatabase,
owner: string
): Promise<LegacyGeneralRow[]> =>
db.$queryRaw<LegacyGeneralRow[]>(GamePrisma.sql`
SELECT
"source_profile" AS "sourceProfile",
"server_id" AS "serverId",
"general_no" AS "generalNo",
"legacy_id" AS "legacyId",
"owner",
"name",
"last_yearmonth" AS "lastYearMonth",
"turntime" AS "turnTime",
"schema_version" AS "schemaVersion",
"source_format" AS "sourceFormat",
"data"
FROM "legacy_archive"."general"
WHERE "owner" = ${owner}
ORDER BY "last_yearmonth" DESC, "source_profile", "server_id" DESC, "general_no"
`);
export const findLegacyGeneral = async (
db: LegacyArchiveDatabase,
input: { owner: string; sourceProfile: LegacyArchiveProfile; serverId: string; generalNo: number }
): Promise<LegacyGeneralRow | null> => {
const rows = await db.$queryRaw<LegacyGeneralRow[]>(GamePrisma.sql`
SELECT
"source_profile" AS "sourceProfile",
"server_id" AS "serverId",
"general_no" AS "generalNo",
"legacy_id" AS "legacyId",
"owner",
"name",
"last_yearmonth" AS "lastYearMonth",
"turntime" AS "turnTime",
"schema_version" AS "schemaVersion",
"source_format" AS "sourceFormat",
"data"
FROM "legacy_archive"."general"
WHERE "owner" = ${input.owner}
AND "source_profile" = ${input.sourceProfile}
AND "server_id" = ${input.serverId}
AND "general_no" = ${input.generalNo}
LIMIT 1
`);
return rows[0] ?? null;
};
export const findLegacyGeneralsForServer = async (
db: LegacyArchiveDatabase,
input: { sourceProfile: LegacyArchiveProfile; serverId: string; generalNos: number[] }
): Promise<Array<Pick<LegacyGeneralRow, 'generalNo' | 'name' | 'lastYearMonth'>>> => {
if (input.generalNos.length === 0) return [];
return db.$queryRaw<Array<Pick<LegacyGeneralRow, 'generalNo' | 'name' | 'lastYearMonth'>>>(GamePrisma.sql`
SELECT
"general_no" AS "generalNo",
"name",
"last_yearmonth" AS "lastYearMonth"
FROM "legacy_archive"."general"
WHERE "source_profile" = ${input.sourceProfile}
AND "server_id" = ${input.serverId}
AND "general_no" IN (${GamePrisma.join(input.generalNos)})
`);
};
export const findLegacyGames = async (
db: LegacyArchiveDatabase,
keys?: Array<{ sourceProfile: LegacyArchiveProfile; serverId: string }>
): Promise<LegacyGameHistoryRow[]> => {
if (keys && keys.length === 0) return [];
const condition = keys
? GamePrisma.sql`WHERE ("source_profile", "server_id") IN (${GamePrisma.join(
keys.map(({ sourceProfile, serverId }) => GamePrisma.sql`(${sourceProfile}, ${serverId})`)
)})`
: GamePrisma.empty;
return db.$queryRaw<LegacyGameHistoryRow[]>(GamePrisma.sql`
SELECT
"source_profile" AS "sourceProfile",
"server_id" AS "serverId",
"legacy_id" AS "legacyId",
"opened_at" AS "openedAt",
"completed_at" AS "completedAt",
"legacy_date" AS "legacyDate",
"winner_nation" AS "winnerNation",
"map",
"season",
"scenario",
"scenario_name" AS "scenarioName",
"raw_env" AS "rawEnv"
FROM "legacy_archive"."game_history"
${condition}
`);
};
export const findLegacyNations = async (
db: LegacyArchiveDatabase,
keys: Array<{ sourceProfile: LegacyArchiveProfile; serverId: string }>
): Promise<LegacyNationRow[]> => {
if (keys.length === 0) return [];
return db.$queryRaw<LegacyNationRow[]>(GamePrisma.sql`
SELECT
"source_profile" AS "sourceProfile",
"legacy_id" AS "legacyId",
"server_id" AS "serverId",
"nation",
"data",
"archived_at" AS "archivedAt"
FROM "legacy_archive"."nation"
WHERE ("source_profile", "server_id") IN (${GamePrisma.join(
keys.map(({ sourceProfile, serverId }) => GamePrisma.sql`(${sourceProfile}, ${serverId})`)
)})
ORDER BY "archived_at" DESC, "legacy_id" DESC
`);
};
export const findLegacyEmperors = async (
db: LegacyArchiveDatabase,
keys?: Array<{ sourceProfile: LegacyArchiveProfile; serverId: string }>
): Promise<LegacyEmperorRow[]> => {
if (keys && keys.length === 0) return [];
const condition = keys
? GamePrisma.sql`WHERE ("source_profile", "server_id") IN (${GamePrisma.join(
keys.map(({ sourceProfile, serverId }) => GamePrisma.sql`(${sourceProfile}, ${serverId})`)
)})`
: GamePrisma.empty;
return db.$queryRaw<LegacyEmperorRow[]>(GamePrisma.sql`
SELECT
"id",
"source_profile" AS "sourceProfile",
"legacy_id" AS "legacyId",
"server_id" AS "serverId",
"data"
FROM "legacy_archive"."emperor"
${condition}
ORDER BY "id" DESC
`);
};
export const findLegacyEmperor = async (db: LegacyArchiveDatabase, id: number): Promise<LegacyEmperorRow | null> => {
const rows = await db.$queryRaw<LegacyEmperorRow[]>(GamePrisma.sql`
SELECT
"id",
"source_profile" AS "sourceProfile",
"legacy_id" AS "legacyId",
"server_id" AS "serverId",
"data"
FROM "legacy_archive"."emperor"
WHERE "id" = ${id}
LIMIT 1
`);
return rows[0] ?? null;
};
export const findLegacyHallOptions = async (db: LegacyArchiveDatabase): Promise<LegacyHallOptionRow[]> =>
db.$queryRaw<LegacyHallOptionRow[]>(GamePrisma.sql`
SELECT
"source_profile" AS "sourceProfile",
"season",
"scenario",
MAX("scenario_name") AS "scenarioName",
COUNT(*)::bigint AS "count"
FROM "legacy_archive"."game_history"
GROUP BY "source_profile", "season", "scenario"
ORDER BY "season" DESC, "source_profile", "scenario"
`);
export const findLegacyHallRows = async (
db: LegacyArchiveDatabase,
input: { sourceProfile: LegacyArchiveProfile; season: number; scenario?: number; type: string; take: number }
): Promise<LegacyHallRow[]> => {
const scenarioCondition =
input.scenario === undefined ? GamePrisma.empty : GamePrisma.sql`AND "scenario" = ${input.scenario}`;
return db.$queryRaw<LegacyHallRow[]>(GamePrisma.sql`
SELECT
"source_profile" AS "sourceProfile",
"server_id" AS "serverId",
"general_no" AS "generalNo",
"type",
"value",
"owner",
"aux"
FROM "legacy_archive"."hall"
WHERE "source_profile" = ${input.sourceProfile}
AND "season" = ${input.season}
${scenarioCondition}
AND "type" = ${input.type}
ORDER BY "value" DESC
LIMIT ${input.take}
`);
};
+192 -5
View File
@@ -25,8 +25,132 @@ const auth: GameSessionTokenPayload = {
sanctions: {}, sanctions: {},
}; };
const context = (session: GameSessionTokenPayload | null): GameApiContext => { const context = (session: GameSessionTokenPayload | null, includeLegacy = false): GameApiContext => {
const db = { 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: { oldGeneral: {
findMany: async ({ where }: { where: { owner: string } }) => findMany: async ({ where }: { where: { owner: string } }) =>
where.owner === 'user-1' where.owner === 'user-1'
@@ -82,6 +206,11 @@ const context = (session: GameSessionTokenPayload | null): GameApiContext => {
lastYearMonth: 22012, lastYearMonth: 22012,
turnTime: new Date('2025-01-01T00:00:00.000Z'), turnTime: new Date('2025-01-01T00:00:00.000Z'),
data: { data: {
nation: 3,
leader: 80,
power: 70,
intel: 60,
officer_level: 8,
history: '<C>●</>첫 기록<br><Y>●</>둘째 기록<br>', history: '<C>●</>첫 기록<br><Y>●</>둘째 기록<br>',
}, },
} }
@@ -116,6 +245,7 @@ const context = (session: GameSessionTokenPayload | null): GameApiContext => {
}, },
emperor: { emperor: {
findMany: async () => [{ id: 7, serverId: 'che_legacy_1' }], findMany: async () => [{ id: 7, serverId: 'che_legacy_1' }],
findFirst: async () => ({ id: 7, serverId: 'che_legacy_1' }),
}, },
}; };
const redis = { const redis = {
@@ -146,6 +276,8 @@ describe('archive.myPastPlays', () => {
const result = await appRouter.createCaller(context(auth)).archive.myPastPlays(); const result = await appRouter.createCaller(context(auth)).archive.myPastPlays();
expect(result.seasons).toEqual([ expect(result.seasons).toEqual([
expect.objectContaining({ expect.objectContaining({
source: 'current',
sourceProfile: 'che',
serverId: 'che_legacy_1', serverId: 'che_legacy_1',
scenarioName: '테스트', scenarioName: '테스트',
dynastyId: 7, dynastyId: 7,
@@ -185,12 +317,27 @@ describe('archive.myPastPlays', () => {
}); });
const result = await appRouter.createCaller(context(auth)).archive.myPastPlayDetail(input); const result = await appRouter.createCaller(context(auth)).archive.myPastPlayDetail(input);
expect(result).toEqual({ expect(result).toMatchObject({
source: 'current',
sourceProfile: 'che',
serverId: 'che_legacy_1', serverId: 'che_legacy_1',
generalNo: 10, generalNo: 10,
name: '과거장수', dynastyPath: '/dynasty/7',
lastYearMonth: 22012, nation: { id: 3, name: '촉', color: '#ff0000' },
history: ['<C>●</>첫 기록', '<Y>●</>둘째 기록'], general: expect.objectContaining({ id: 10, name: '과거장수' }),
battle: expect.objectContaining({ available: false }),
logs: {
generalHistory: {
available: true,
entries: [
{ id: 1, text: '<C>●</>첫 기록' },
{ id: 2, text: '<Y>●</>둘째 기록' },
],
},
battleDetail: { available: false, entries: [] },
battleResult: { available: false, entries: [] },
generalAction: { available: false, entries: [] },
},
}); });
const otherUser = { const otherUser = {
@@ -202,4 +349,44 @@ describe('archive.myPastPlays', () => {
code: 'NOT_FOUND', 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');
});
}); });
+99
View File
@@ -123,6 +123,72 @@ const buildContext = (
oldNations: Array<Record<string, unknown>> = [oldNation, deletedOldNation] oldNations: Array<Record<string, unknown>> = [oldNation, deletedOldNation]
): GameApiContext => { ): GameApiContext => {
const db = { 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: { worldState: {
findFirst: async () => ({ currentYear: 220, currentMonth: 1 }), 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 () => { it('exposes the same public DTO to anonymous, general owners and admins', async () => {
const anonymous = appRouter.createCaller(buildContext(null)); const anonymous = appRouter.createCaller(buildContext(null));
const owner = appRouter.createCaller(buildContext(authFor('owner-a'))); const owner = appRouter.createCaller(buildContext(authFor('owner-a')));
+77 -6
View File
@@ -110,6 +110,42 @@ const buildContext = (options?: {
}): GameApiContext => { }): GameApiContext => {
const selectedGeneralRows = options?.generals ?? generalRows; const selectedGeneralRows = options?.generals ?? generalRows;
const db = { 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: { worldState: {
findFirst: async () => ({ findFirst: async () => ({
meta: { isUnited: options?.isUnited ? 1 : 0 }, meta: { isUnited: options?.isUnited ? 1 : 0 },
@@ -289,8 +325,14 @@ describe('ranking.getBestGeneral', () => {
value: value:
type === 'warnum' || type === 'deathcrew' || type === 'deathcrew_person' type === 'warnum' || type === 'deathcrew' || type === 'deathcrew_person'
? 1_000 ? 1_000
: type === 'ttd' || type === 'ttl' || type === 'tld' || type === 'tll' || : type === 'ttd' ||
type === 'tsd' || type === 'tsl' || type === 'tid' || type === 'til' || type === 'ttl' ||
type === 'tld' ||
type === 'tll' ||
type === 'tsd' ||
type === 'tsl' ||
type === 'tid' ||
type === 'til' ||
type === 'betgold' type === 'betgold'
? 1_000 ? 1_000
: general.id * 1_000, : general.id * 1_000,
@@ -304,11 +346,14 @@ describe('ranking.getBestGeneral', () => {
for (const section of result.sections) { for (const section of result.sections) {
expect(section.entries, section.title).toHaveLength(10); expect(section.entries, section.title).toHaveLength(10);
expect(new Set(section.entries.map((entry) => entry.id)).size, section.title).toBe(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([ expect(
12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 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', () => { it('matches PHP number_format rounding and the legacy fixed color table', () => {
@@ -326,12 +371,38 @@ describe('ranking hall of fame', () => {
.ranking.getHallOfFameOptions(); .ranking.getHallOfFameOptions();
expect(options).toEqual([ expect(options).toEqual([
{ {
sourceProfile: 'che',
season: 3, season: 3,
scenarios: [{ id: 22, name: '가상모드22', count: 2 }], 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 () => { it('returns an explicit display name but never exposes the stored account identifier', async () => {
const result = await appRouter const result = await appRouter
.createCaller(buildContext({ authenticated: false, includeOwnerDisplayName: true })) .createCaller(buildContext({ authenticated: false, includeOwnerDisplayName: true }))
@@ -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');
});
+122 -11
View File
@@ -8,7 +8,7 @@ const response = (data: unknown) => ({ result: { data } });
const operationNames = (route: Route) => const operationNames = (route: Route) =>
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(','); 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) => { await page.addInitScript((profile) => {
localStorage.setItem('sammo-game-token', 'ga_archive'); localStorage.setItem('sammo-game-token', 'ga_archive');
localStorage.setItem('sammo-game-profile', profile); localStorage.setItem('sammo-game-profile', profile);
@@ -21,7 +21,10 @@ const installArchive = async (page: Page) => {
return response({ return response({
seasons: [ seasons: [
{ {
sourceProfile: 'che',
source: 'legacy',
serverId: 'che_2024_01', serverId: 'che_2024_01',
openedAt: '2024-01-31T00:00:00.000Z',
date: '2024-01-31T00:00:00.000Z', date: '2024-01-31T00:00:00.000Z',
season: 51, season: 51,
scenario: 2, scenario: 2,
@@ -54,11 +57,67 @@ const installArchive = async (page: Page) => {
} }
if (operation === 'archive.myPastPlayDetail') { if (operation === 'archive.myPastPlayDetail') {
return response({ return response({
sourceProfile: 'che',
source: 'legacy',
serverId: 'che_2024_01', serverId: 'che_2024_01',
generalNo: 17, generalNo: 17,
name: '관우', dynastyPath: '/dynasty/7?source=legacy',
lastYearMonth: 21403, nation: { name: '촉', color: '#800000' },
history: ['<C>●</>214년 3월: 촉에 임관', '<Y>●</>214년 1월: 성도에서 거병'], 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: '<C>●</>214년 3월: 촉에 임관' },
{ id: 1, text: '<Y>●</>214년 1월: 성도에서 거병' },
],
},
battleDetail: { available: false, entries: [] },
battleResult: { available: false, entries: [] },
generalAction: { available: false, entries: [] },
},
}); });
} }
return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } }; return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } };
@@ -76,6 +135,15 @@ test('지난 플레이 관직은 숫자 대신 저장된 Ref 표시명으로 나
await expect(generalRow).not.toContainText('che_'); 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 ({ test('past plays is available without a current general and preserves desktop interaction geometry', async ({
page, page,
}) => { }) => {
@@ -87,19 +155,46 @@ test('past plays is available without a current general and preserves desktop in
await expect(root).toBeVisible(); await expect(root).toBeVisible();
await expect(page.getByRole('heading', { name: '내 지난 플레이 보기' })).toBeVisible(); await expect(page.getByRole('heading', { name: '내 지난 플레이 보기' })).toBeVisible();
await expect(page.getByText('천하쟁패 · 51기')).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('.general-name')).toHaveText('관우');
await expect(page.locator('tbody tr').filter({ hasText: '관우' })).toContainText('황제'); await expect(page.locator('tbody tr').filter({ hasText: '관우' })).toContainText('황제');
await expect(page.getByRole('link', { name: '이 기수 국가 정보' })).toHaveAttribute('href', gamePath('/dynasty/7')); const detailToggle = page.locator('.detail-toggle');
const historyToggle = page.locator('.history-toggle'); await expect(detailToggle).toHaveText('상세 보기');
await expect(historyToggle).toHaveText('보기 (2)'); await detailToggle.hover();
await historyToggle.click(); 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(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 geometry = await root.evaluate((element) => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
const titleRect = element.querySelector('.title-row')!.getBoundingClientRect(); const titleRect = element.querySelector('.title-row')!.getBoundingClientRect();
const tableRect = element.querySelector('table')!.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); const style = getComputedStyle(element);
return { return {
x: rect.x, x: rect.x,
@@ -107,6 +202,8 @@ test('past plays is available without a current general and preserves desktop in
minHeight: rect.height, minHeight: rect.height,
title: { x: titleRect.x, y: titleRect.y, width: titleRect.width }, title: { x: titleRect.x, y: titleRect.y, width: titleRect.width },
tableWidth: tableRect.width, tableWidth: tableRect.width,
detailColumns: getComputedStyle(detailGrid).gridTemplateColumns,
cardWidth: card.width,
color: style.color, color: style.color,
backgroundColor: style.backgroundColor, backgroundColor: style.backgroundColor,
}; };
@@ -116,13 +213,14 @@ test('past plays is available without a current general and preserves desktop in
width: 1000, width: 1000,
title: { x: 100, y: 0, width: 1000 }, title: { x: 100, y: 0, width: 1000 },
tableWidth: 1000, tableWidth: 1000,
cardWidth: 497,
color: 'rgb(238, 238, 238)', color: 'rgb(238, 238, 238)',
backgroundColor: 'rgb(21, 21, 21)', backgroundColor: 'rgb(48, 32, 22)',
}); });
const refresh = page.getByRole('button', { name: '새로고침' }); const refresh = page.getByRole('button', { name: '새로고침' });
await refresh.hover(); 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(); await refresh.focus();
expect(await refresh.evaluate((element) => document.activeElement === element)).toBe(true); 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.setViewportSize({ width: 390, height: 844 });
await page.goto('past-plays'); 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 scroll = page.locator('.table-scroll');
const metrics = await scroll.evaluate((element) => ({ const metrics = await scroll.evaluate((element) => ({
clientWidth: element.clientWidth, 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. // The shared legacy shell keeps its historical 500 px minimum canvas.
expect(metrics).toEqual({ clientWidth: 500, scrollWidth: 940, overflowX: 'auto' }); expect(metrics).toEqual({ clientWidth: 500, scrollWidth: 940, overflowX: 'auto' });
await expect(page.locator('.title-row')).toHaveCSS('flex-direction', 'column'); 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);
}); });
@@ -26,6 +26,7 @@ export default defineConfig({
'legacyLogHtml.spec.ts', 'legacyLogHtml.spec.ts',
'directoryLists.spec.ts', 'directoryLists.spec.ts',
'pastPlays.spec.ts', 'pastPlays.spec.ts',
'legacyArchiveViews.spec.ts',
'nationGeneralSecret.spec.ts', 'nationGeneralSecret.spec.ts',
'npcPolicy.spec.ts', 'npcPolicy.spec.ts',
'auction.spec.ts', 'auction.spec.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<Record<GeneralRecordType, GeneralRecordEntry[]>>;
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { computed } from 'vue';
import { formatServerDateTime } from '@sammo-ts/common';
export type GeneralBattleSummaryData = {
available?: boolean;
experience?: number | null;
dedicationText?: string | null;
warnum?: number | null;
wins?: number | null;
losses?: number | null;
strategies?: number | null;
killCrew?: number | null;
deathCrew?: number | null;
winRate?: number | null;
killRate?: number | null;
recentWar?: string | null;
};
const props = withDefaults(
defineProps<{
summary: GeneralBattleSummaryData;
showWinRate?: boolean;
rateScale?: 'ratio' | 'percent';
}>(),
{ showWinRate: false, rateScale: 'ratio' }
);
const numberText = (value: number | null | undefined): string =>
typeof value === 'number' && Number.isFinite(value) ? value.toLocaleString('ko-KR') : '-';
const rateText = (value: number): string => `${(props.rateScale === 'percent' ? value : value * 100).toFixed(1)}%`;
const winRate = computed(() => {
if (typeof props.summary.winRate === 'number' && Number.isFinite(props.summary.winRate)) {
return rateText(props.summary.winRate);
}
const battles = props.summary.warnum;
const wins = props.summary.wins;
if (typeof battles !== 'number' || battles <= 0 || typeof wins !== 'number') return '-';
return `${((wins / battles) * 100).toFixed(1)}%`;
});
const killRate = computed(() => {
if (typeof props.summary.killRate === 'number' && Number.isFinite(props.summary.killRate)) {
return rateText(props.summary.killRate);
}
const killed = props.summary.killCrew;
const lost = props.summary.deathCrew;
if (typeof killed !== 'number' || typeof lost !== 'number' || lost <= 0) return '-';
return `${((killed / lost) * 100).toFixed(1)}%`;
});
</script>
<template>
<div class="battle-general-extra" data-general-battle-summary>
<div v-if="summary.available === false" class="battle-summary-unavailable">
전투 집계가 보존되지 않았습니다.
</div>
<template v-else>
<span>명성</span><strong>{{ numberText(summary.experience) }}</strong> <span>계급</span
><strong>{{ summary.dedicationText || '-' }}</strong> <span>전투</span
><strong>{{ numberText(summary.warnum) }}<template v-if="summary.warnum != null"></template></strong>
<span>승리</span><strong>{{ numberText(summary.wins) }}</strong> <span>패배</span
><strong>{{ numberText(summary.losses) }}</strong> <span>계략</span
><strong>{{ numberText(summary.strategies) }}</strong> <span>사살</span
><strong>{{ numberText(summary.killCrew) }}</strong> <span>피살</span
><strong>{{ numberText(summary.deathCrew) }}</strong>
<template v-if="showWinRate">
<span>승률</span><strong>{{ winRate }}</strong> <span>살상률</span><strong>{{ killRate }}</strong>
</template>
<span class="battle-general-extra__recent-label">최근 전투</span>
<strong class="battle-general-extra__recent-value">
{{ formatServerDateTime(summary.recentWar, { format: 'monthDayTime', fallback: '-' }) }}
</strong>
</template>
</div>
</template>
<style scoped>
.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;
}
.battle-summary-unavailable {
grid-column: 1 / -1;
color: #bbb;
text-align: center;
}
</style>
@@ -0,0 +1,100 @@
<script setup lang="ts">
import SkeletonLines from '../ui/SkeletonLines.vue';
import { GENERAL_RECORD_TYPES, type GeneralRecordCollection, type GeneralRecordType } from '../generalRecords';
const props = withDefaults(
defineProps<{
records: GeneralRecordCollection;
loading?: boolean;
trustedHtml?: boolean;
unavailable?: GeneralRecordType[];
}>(),
{
loading: false,
trustedHtml: false,
unavailable: () => [],
}
);
const labels: Record<GeneralRecordType, string> = {
generalHistory: '장수 열전',
battleDetail: '전투 기록',
battleResult: '전투 결과',
generalAction: '개인 기록',
};
const unavailableText: Record<GeneralRecordType, string> = {
generalHistory: '이 기수에는 장수 열전이 보존되지 않았습니다.',
battleDetail: '이 기수에는 전투 기록이 보존되지 않았습니다.',
battleResult: '이 기수에는 전투 결과가 보존되지 않았습니다.',
generalAction: '이 기수에는 개인 기록이 보존되지 않았습니다.',
};
</script>
<template>
<div class="log-grid" data-general-record-panels>
<div v-for="type in GENERAL_RECORD_TYPES" :key="type" class="log-block" :data-log-type="type">
<div class="log-title">{{ labels[type] }}</div>
<SkeletonLines v-if="loading" :lines="3" />
<template v-else-if="props.unavailable.includes(type)">
<div class="empty unavailable">{{ unavailableText[type] }}</div>
</template>
<template v-else>
<div v-if="(records[type]?.length ?? 0) === 0" class="empty">기록이 없습니다.</div>
<template v-for="entry in records[type] ?? []" :key="entry.id">
<!-- Current-season logs have already passed the trusted formatter boundary. -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-if="trustedHtml" class="log-line" v-html="entry.content" />
<div v-else class="log-line">{{ entry.content }}</div>
</template>
</template>
</div>
</div>
</template>
<style scoped>
.log-grid {
display: contents;
}
.log-block {
min-height: 0;
border: 1px solid #666;
padding: 0;
background-color: #302016;
background-image: var(--sammo-texture-walnut);
}
.log-title {
display: flex;
min-height: 34px;
align-items: center;
justify-content: center;
margin: 0;
border-bottom: 1px solid #666;
color: orange;
background-color: #000;
background-image: var(--sammo-texture-green);
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;
}
.unavailable {
color: #bbb;
}
</style>
+31 -118
View File
@@ -3,9 +3,15 @@ import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, reactive, ref, watch } from 'vue'; import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import PanelCard from '../components/ui/PanelCard.vue'; import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue'; import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.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 { trpc } from '../utils/trpc';
import { getNpcColor } from '../utils/npcColor'; import { getNpcColor } from '../utils/npcColor';
import { formatLog } from '../utils/formatLog'; import { formatLog } from '../utils/formatLog';
@@ -13,17 +19,10 @@ import { formatLog } from '../utils/formatLog';
type BattleCenterResponse = Awaited<ReturnType<typeof trpc.nation.getBattleCenter.query>>; type BattleCenterResponse = Awaited<ReturnType<typeof trpc.nation.getBattleCenter.query>>;
type GeneralEntry = BattleCenterResponse['generals'][number]; type GeneralEntry = BattleCenterResponse['generals'][number];
type LogType = 'generalHistory' | 'battleResult' | 'battleDetail' | 'generalAction'; type LogType = GeneralRecordType;
type LogLine = { id: number; html: string }; type LogLine = { id: number; html: string };
const logTypes: LogType[] = ['generalHistory', 'battleDetail', 'battleResult', 'generalAction']; const logTypes: LogType[] = [...GENERAL_RECORD_TYPES];
const logLabels: Record<LogType, string> = {
generalHistory: '장수 열전',
battleDetail: '전투 기록',
battleResult: '전투 결과',
generalAction: '개인 기록',
};
const orderOptions = [ const orderOptions = [
{ key: 'recentWar', label: '최근 전투' }, { key: 'recentWar', label: '최근 전투' },
@@ -49,6 +48,12 @@ const logs = reactive<Record<LogType, LogLine[]>>({
generalAction: [], generalAction: [],
}); });
const currentRecords = computed<GeneralRecordCollection>(() =>
Object.fromEntries(
logTypes.map((type) => [type, logs[type].map((entry) => ({ id: entry.id, content: entry.html }))])
)
);
const resolveErrorMessage = (value: unknown): string => { const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) { if (value instanceof Error) {
return value.message; return value.message;
@@ -275,27 +280,20 @@ onMounted(() => {
:nation-color="data?.nation.color" :nation-color="data?.nation.color"
> >
<template v-if="selectedGeneral" #details> <template v-if="selectedGeneral" #details>
<div class="battle-general-extra"> <GeneralBattleSummary
<span>명성</span :summary="{
><strong>{{ selectedGeneral.experience.toLocaleString('ko-KR') }}</strong> available: true,
<span>계급</span><strong>{{ selectedGeneral.progression.dedicationText }}</strong> experience: selectedGeneral.experience,
<span>전투</span><strong>{{ selectedGeneral.warnum }}</strong> <span>승리</span dedicationText: selectedGeneral.progression.dedicationText,
><strong>{{ selectedGeneral.battleStats.kills }}</strong> <span>패배</span warnum: selectedGeneral.warnum,
><strong>{{ selectedGeneral.battleStats.deaths }}</strong> <span>계략</span wins: selectedGeneral.battleStats.kills,
><strong>{{ selectedGeneral.battleStats.fire }}</strong> <span>사살</span losses: selectedGeneral.battleStats.deaths,
><strong>{{ selectedGeneral.battleStats.killCrew.toLocaleString('ko-KR') }}</strong> strategies: selectedGeneral.battleStats.fire,
<span>피살</span killCrew: selectedGeneral.battleStats.killCrew,
><strong>{{ selectedGeneral.battleStats.deathCrew.toLocaleString('ko-KR') }}</strong> deathCrew: selectedGeneral.battleStats.deathCrew,
<span class="battle-general-extra__recent-label">최근 전투</span> recentWar: selectedGeneral.recentWar,
<strong class="battle-general-extra__recent-value"> }"
{{ />
formatServerDateTime(selectedGeneral.recentWar, {
format: 'monthDayTime',
fallback: '-',
})
}}
</strong>
</div>
<LegacyGeneralProgress :general="selectedGeneral" :show-primary="false" /> <LegacyGeneralProgress :general="selectedGeneral" :show-primary="false" />
</template> </template>
</GeneralBasicCard> </GeneralBasicCard>
@@ -304,16 +302,7 @@ onMounted(() => {
<div class="stack"> <div class="stack">
<PanelCard title="장수 기록" subtitle="열전과 전투 기록"> <PanelCard title="장수 기록" subtitle="열전과 전투 기록">
<div class="log-grid"> <GeneralRecordPanels :records="currentRecords" :loading="loading || logLoading" trusted-html />
<div v-for="type in logTypes" :key="type" class="log-block" :data-log-type="type">
<div class="log-title">{{ logLabels[type] }}</div>
<SkeletonLines v-if="loading || logLoading" :lines="3" />
<template v-else>
<div v-if="logs[type].length === 0" class="empty">기록이 없습니다.</div>
<div v-for="entry in logs[type]" :key="entry.id" class="log-line" v-html="entry.html" />
</template>
</div>
</div>
</PanelCard> </PanelCard>
</div> </div>
</section> </section>
@@ -354,81 +343,6 @@ onMounted(() => {
font: inherit; 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 /* PanelCard is retained as a data wrapper, but its presentation follows the
flat bootstrap rows used by the reference page. */ flat bootstrap rows used by the reference page. */
:deep(.panel-card) { :deep(.panel-card) {
@@ -466,8 +380,7 @@ onMounted(() => {
font-size: 18px; font-size: 18px;
font-weight: 500; font-weight: 500;
} }
:deep(.panel-header), :deep(.panel-header) {
.log-title {
background-image: var(--sammo-texture-green); background-image: var(--sammo-texture-green);
} }
@@ -14,6 +14,7 @@ const router = useRouter();
const loading = ref(false); const loading = ref(false);
const errorMessage = ref(''); const errorMessage = ref('');
const data = ref<DynastyDetailPayload | null>(null); const data = ref<DynastyDetailPayload | null>(null);
const source = computed<'current' | 'legacy'>(() => (route.query.source === 'legacy' ? 'legacy' : 'current'));
const emperorId = computed(() => { const emperorId = computed(() => {
const idParam = route.params.id; const idParam = route.params.id;
@@ -39,7 +40,7 @@ const loadDetail = async (): Promise<void> => {
loading.value = true; loading.value = true;
errorMessage.value = ''; errorMessage.value = '';
try { 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) { } catch (error) {
data.value = null; data.value = null;
errorMessage.value = error instanceof Error ? error.message : '왕조 정보를 불러오지 못했습니다.'; errorMessage.value = error instanceof Error ? error.message : '왕조 정보를 불러오지 못했습니다.';
@@ -50,7 +51,7 @@ const loadDetail = async (): Promise<void> => {
const formatArchiveDate = (value: string): string => formatServerDateTime(value); const formatArchiveDate = (value: string): string => formatServerDateTime(value);
watch(emperorId, loadDetail); watch([emperorId, source], loadDetail);
onMounted(loadDetail); onMounted(loadDetail);
</script> </script>
@@ -63,7 +64,8 @@ onMounted(loadDetail);
<br /> <br />
<button class="native-button" type="button" @click="closePage"> 닫기</button> <button class="native-button" type="button" @click="closePage"> 닫기</button>
<span class="all-link"> <span class="all-link">
<RouterLink to="/dynasty" <RouterLink
:to="{ path: '/dynasty', query: source === 'legacy' ? { source: 'legacy' } : {} }"
><button class="native-button" type="button">전체보기</button></RouterLink ><button class="native-button" type="button">전체보기</button></RouterLink
> >
</span> </span>
@@ -88,7 +90,12 @@ onMounted(loadDetail);
<tbody> <tbody>
<tr> <tr>
<td class="phase-heading centered" colspan="6"> <td class="phase-heading centered" colspan="6">
<span class="large-text">{{ data.emperor.phase }}</span> <span class="large-text">
{{ data.emperor.phase }}
<template v-if="data.source === 'legacy'">
[{{ data.sourceProfile.toUpperCase() }} 이전 서버]
</template>
</span>
</td> </td>
</tr> </tr>
<tr> <tr>
+47 -13
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { legacyNationTextColor } from '../utils/legacyNationColor'; import { legacyNationTextColor } from '../utils/legacyNationColor';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
@@ -8,9 +8,11 @@ import { trpc } from '../utils/trpc';
type DynastyListPayload = Awaited<ReturnType<typeof trpc.dynasty.getList.query>>; type DynastyListPayload = Awaited<ReturnType<typeof trpc.dynasty.getList.query>>;
const router = useRouter(); const router = useRouter();
const route = useRoute();
const loading = ref(false); const loading = ref(false);
const errorMessage = ref(''); const errorMessage = ref('');
const data = ref<DynastyListPayload | null>(null); const data = ref<DynastyListPayload | null>(null);
const selectedSource = ref<'current' | 'legacy'>(route.query.source === 'legacy' ? 'legacy' : 'current');
const closePage = async (): Promise<void> => { const closePage = async (): Promise<void> => {
if (window.opener) { if (window.opener) {
@@ -24,7 +26,7 @@ const loadDynasty = async (): Promise<void> => {
loading.value = true; loading.value = true;
errorMessage.value = ''; errorMessage.value = '';
try { try {
data.value = await trpc.dynasty.getList.query(); data.value = await trpc.dynasty.getList.query({ source: selectedSource.value });
} catch (error) { } catch (error) {
errorMessage.value = error instanceof Error ? error.message : '왕조일람을 불러오지 못했습니다.'; errorMessage.value = error instanceof Error ? error.message : '왕조일람을 불러오지 못했습니다.';
} finally { } finally {
@@ -33,6 +35,7 @@ const loadDynasty = async (): Promise<void> => {
}; };
onMounted(loadDynasty); onMounted(loadDynasty);
watch(selectedSource, loadDynasty);
</script> </script>
<template> <template>
@@ -48,6 +51,14 @@ onMounted(loadDynasty);
</tbody> </tbody>
</table> </table>
<div class="record-source legacy-bg0">
기록 구분 :
<select v-model="selectedSource" aria-label="기록 구분">
<option value="current">현재 서버 기록</option>
<option value="legacy">이전 서버 기록</option>
</select>
</div>
<div v-if="errorMessage" class="legacy-message error" role="alert">{{ errorMessage }}</div> <div v-if="errorMessage" class="legacy-message error" role="alert">{{ errorMessage }}</div>
<div v-else-if="loading && !data" class="legacy-message" role="status">불러오는 중...</div> <div v-else-if="loading && !data" class="legacy-message" role="status">불러오는 중...</div>
@@ -57,7 +68,9 @@ onMounted(loadDynasty);
<tr> <tr>
<td class="current-heading" colspan="8"> <td class="current-heading" colspan="8">
<span class="large-text">현재 ({{ data.current.year }} {{ data.current.month }})</span> <span class="large-text">현재 ({{ data.current.year }} {{ data.current.month }})</span>
<RouterLink to="/yearbook"><button class="native-button" type="button">역사 보기</button></RouterLink> <RouterLink to="/yearbook"
><button class="native-button" type="button">역사 보기</button></RouterLink
>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -83,11 +96,24 @@ onMounted(loadDynasty);
<tbody> <tbody>
<tr> <tr>
<td class="phase-heading" colspan="8"> <td class="phase-heading" colspan="8">
<span class="large-text">{{ entry.phase }}</span> <span class="large-text"
<RouterLink :to="`/dynasty/${entry.id}`"> >{{ entry.phase
}}<template v-if="entry.source === 'legacy'">
[{{ entry.sourceProfile.toUpperCase() }} 이전 서버]</template
></span
>
<RouterLink
:to="{
path: `/dynasty/${entry.id}`,
query: entry.source === 'legacy' ? { source: 'legacy' } : {},
}"
>
<button class="native-button" type="button">자세히</button> <button class="native-button" type="button">자세히</button>
</RouterLink> </RouterLink>
<RouterLink v-if="entry.serverId" :to="{ path: '/yearbook', query: { serverID: entry.serverId } }"> <RouterLink
v-if="entry.serverId && entry.source === 'current'"
:to="{ path: '/yearbook', query: { serverID: entry.serverId } }"
>
<button class="native-button" type="button">역사 보기</button> <button class="native-button" type="button">역사 보기</button>
</RouterLink> </RouterLink>
</td> </td>
@@ -138,14 +164,10 @@ onMounted(loadDynasty);
<table class="legacy-table legacy-bg0 footer-table spaced-table"> <table class="legacy-table legacy-bg0 footer-table spaced-table">
<tbody> <tbody>
<tr> <tr>
<td> <td><button class="native-button" type="button" @click="closePage"> 닫기</button><br /></td>
<button class="native-button" type="button" @click="closePage"> 닫기</button><br />
</td>
</tr> </tr>
<tr> <tr>
<td class="banner"> <td class="banner">삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD</td>
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -183,6 +205,18 @@ onMounted(loadDynasty);
margin-top: 10px; margin-top: 10px;
} }
.record-source {
box-sizing: border-box;
width: 1000px;
border: 1px solid gray;
padding: 3px;
text-align: center;
}
.record-source select {
height: 22px;
}
.title-table { .title-table {
height: 47px; height: 47px;
} }
+58 -12
View File
@@ -6,6 +6,7 @@ import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIc
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
type HallOption = { type HallOption = {
sourceProfile: string;
season: number; season: number;
scenarios: Array<{ id: number; name: string; count: number }>; scenarios: Array<{ id: number; name: string; count: number }>;
}; };
@@ -42,6 +43,8 @@ const router = useRouter();
const loading = ref(false); const loading = ref(false);
const errorMessage = ref(''); const errorMessage = ref('');
const options = ref<HallOption[]>([]); const options = ref<HallOption[]>([]);
const selectedSource = ref<'current' | 'legacy'>('current');
const selectedProfile = ref<string | null>(null);
const selectedSeason = ref<number | null>(null); const selectedSeason = ref<number | null>(null);
const selectedScenario = ref<number | null>(null); const selectedScenario = ref<number | null>(null);
const data = ref<HallPayload | null>(null); const data = ref<HallPayload | null>(null);
@@ -51,10 +54,11 @@ const selection = computed({
selectedSeason.value === null selectedSeason.value === null
? '' ? ''
: selectedScenario.value === null : selectedScenario.value === null
? `season:${selectedSeason.value}` ? `season:${selectedProfile.value ?? ''}:${selectedSeason.value}`
: `scenario:${selectedSeason.value}:${selectedScenario.value}`, : `scenario:${selectedProfile.value ?? ''}:${selectedSeason.value}:${selectedScenario.value}`,
set: (value: string) => { set: (value: string) => {
const [kind, season, scenario] = value.split(':'); const [kind, profile, season, scenario] = value.split(':');
selectedProfile.value = profile || null;
selectedSeason.value = Number(season); selectedSeason.value = Number(season);
selectedScenario.value = kind === 'scenario' ? Number(scenario) : null; selectedScenario.value = kind === 'scenario' ? Number(scenario) : null;
}, },
@@ -72,9 +76,17 @@ const closePage = async (): Promise<void> => {
const loadOptions = async (): Promise<void> => { const loadOptions = async (): Promise<void> => {
try { try {
options.value = await trpc.ranking.getHallOfFameOptions.query(); options.value = await trpc.ranking.getHallOfFameOptions.query({ source: selectedSource.value });
if (options.value.length > 0 && selectedSeason.value === null) { const first = options.value[0];
selectedSeason.value = options.value[0]!.season; if (first) {
selectedProfile.value = first.sourceProfile;
selectedSeason.value = first.season;
selectedScenario.value = null;
} else {
selectedProfile.value = null;
selectedSeason.value = null;
selectedScenario.value = null;
data.value = null;
} }
} catch (error) { } catch (error) {
errorMessage.value = error instanceof Error ? error.message : '명예의 전당 옵션을 불러오지 못했습니다.'; errorMessage.value = error instanceof Error ? error.message : '명예의 전당 옵션을 불러오지 못했습니다.';
@@ -90,6 +102,11 @@ const loadHall = async (): Promise<void> => {
errorMessage.value = ''; errorMessage.value = '';
try { try {
data.value = (await trpc.ranking.getHallOfFame.query({ data.value = (await trpc.ranking.getHallOfFame.query({
source: selectedSource.value,
sourceProfile:
selectedSource.value === 'legacy'
? (selectedProfile.value as 'che' | 'kwe' | 'pwe' | 'twe' | 'nya' | 'pya' | 'hwe')
: undefined,
season: selectedSeason.value, season: selectedSeason.value,
scenario: selectedScenario.value ?? undefined, scenario: selectedScenario.value ?? undefined,
})) as HallPayload; })) as HallPayload;
@@ -100,10 +117,14 @@ const loadHall = async (): Promise<void> => {
} }
}; };
watch([selectedSeason, selectedScenario], () => { watch([selectedProfile, selectedSeason, selectedScenario], () => {
void loadHall(); void loadHall();
}); });
watch(selectedSource, () => {
void loadOptions();
});
onMounted(loadOptions); onMounted(loadOptions);
</script> </script>
@@ -114,17 +135,29 @@ onMounted(loadOptions);
<button class="legacy-button" type="button" @click="closePage"> 닫기</button> <button class="legacy-button" type="button" @click="closePage"> 닫기</button>
</div> </div>
<label class="archive-source">
기록 구분 :
<select v-model="selectedSource" aria-label="기록 구분">
<option value="current">현재 서버 기록</option>
<option value="legacy">이전 서버 기록</option>
</select>
</label>
<label class="scenario-search"> <label class="scenario-search">
시나리오 검색 : 시나리오 검색 :
<select v-model="selection" aria-label="시나리오 검색"> <select v-model="selection" aria-label="시나리오 검색">
<template v-for="season in options" :key="season.season"> <template v-for="season in options" :key="`${season.sourceProfile}:${season.season}`">
<option :value="`season:${season.season}`">* 시즌 : {{ season.season }} 종합 *</option> <option :value="`season:${season.sourceProfile}:${season.season}`">
* {{ selectedSource === 'legacy' ? `${season.sourceProfile.toUpperCase()} / ` : '' }}시즌 :
{{ season.season }} 종합 *
</option>
<option <option
v-for="scenario in season.scenarios" v-for="scenario in season.scenarios"
:key="`${season.season}:${scenario.id}`" :key="`${season.sourceProfile}:${season.season}:${scenario.id}`"
:value="`scenario:${season.season}:${scenario.id}`" :value="`scenario:${season.sourceProfile}:${season.season}:${scenario.id}`"
> >
{{ scenario.name }}({{ scenario.count }}) {{ selectedSource === 'legacy' ? `${season.sourceProfile.toUpperCase()} / ` : ''
}}{{ scenario.name }}({{ scenario.count }})
</option> </option>
</template> </template>
</select> </select>
@@ -210,6 +243,19 @@ onMounted(loadOptions);
text-align: center; text-align: center;
} }
.archive-source {
display: block;
padding: 2px 0 0;
text-align: center;
}
.archive-source select {
height: 20px;
border: 1px solid #555;
background: #ddd;
color: #303030;
}
.scenario-search select { .scenario-search select {
width: 189px; width: 189px;
height: 20px; height: 20px;
+312 -162
View File
@@ -1,21 +1,102 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import GeneralBattleSummary, { type GeneralBattleSummaryData } from '../components/main/GeneralBattleSummary.vue';
import GeneralRecordPanels from '../components/main/GeneralRecordPanels.vue';
import {
GENERAL_RECORD_TYPES,
type GeneralRecordCollection,
type GeneralRecordType,
} from '../components/generalRecords';
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
type Archive = Awaited<ReturnType<typeof trpc.archive.myPastPlays.query>>; type Archive = Awaited<ReturnType<typeof trpc.archive.myPastPlays.query>>;
type PastPlayDetail = Awaited<ReturnType<typeof trpc.archive.myPastPlayDetail.query>>; type ArchiveSeason = Archive['seasons'][number] & {
type DetailState = { sourceProfile?: string;
open: boolean; source?: string;
loading: boolean; openedAt?: string | null;
error: string | null; date?: string | null;
detail: PastPlayDetail | null;
}; };
type ArchiveGeneral = {
id: number;
name: string;
picture?: string | null;
imageServer?: number | null;
npcState: number;
officerLevel: number;
officerLevelText: string;
officerCityName?: string | null;
generalType?: string;
leadershipBonus?: number;
stats: { leadership: number; strength: number; intelligence: number };
gold: number;
rice: number;
crew: number;
train: number;
atmos: number;
injury: number;
experience: number;
dedication: number;
age?: number;
retirementYear?: number;
turnTime?: string | null;
crewTypeId?: number;
crewTypeName?: string;
traits?: { personal: string; specialWar: string; specialDomestic: string };
progression: {
experienceLevel: number;
dedicationLevel: number;
dedicationText: string;
statExperience: { leadership: number; strength: number; intelligence: number };
statUpgradeLimit: number;
dex: number[];
};
};
type ArchiveLogChannel = {
available: boolean;
entries: Array<{ id: number | string; text: string }>;
};
type PastPlayDetail = {
sourceProfile: string;
source: string;
serverId: string;
generalNo: number;
dynastyPath: string | null;
nation: { name: string; color: string } | null;
general: ArchiveGeneral;
masteryAvailable: boolean;
battle: GeneralBattleSummaryData & {
winRate?: number | null;
killRate?: number | null;
};
logs: Partial<Record<GeneralRecordType, ArchiveLogChannel>>;
};
type PastPlayDetailInput = {
sourceProfile: string;
source: string;
serverId: string;
generalNo: number;
};
const queryPastPlayDetail = trpc.archive.myPastPlayDetail.query as unknown as (
input: PastPlayDetailInput
) => Promise<PastPlayDetail>;
const archive = ref<Archive | null>(null); const archive = ref<Archive | null>(null);
const loading = ref(false); const loading = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const details = ref<Record<string, DetailState>>({}); const selectedKey = ref<string | null>(null);
const detail = ref<PastPlayDetail | null>(null);
const detailLoading = ref(false);
const detailError = ref<string | null>(null);
const loadArchive = async () => { const loadArchive = async () => {
if (loading.value) return; if (loading.value) return;
@@ -33,43 +114,74 @@ const loadArchive = async () => {
const yearMonth = (value: number): string => `${Math.floor(value / 100)}${value % 100}`; const yearMonth = (value: number): string => `${Math.floor(value / 100)}${value % 100}`;
const valueOrDash = (value: number | string | null): string => (value === null || value === '' ? '-' : String(value)); const valueOrDash = (value: number | string | null): string => (value === null || value === '' ? '-' : String(value));
const plainLog = (value: string): string => value.replace(/<[^>]+>/g, ''); const plainLog = (value: string): string => value.replace(/<[^>]+>/g, '');
const detailKey = (serverId: string, generalNo: number): string => `${serverId}:${generalNo}`; const seasonSourceProfile = (season: ArchiveSeason): string => season.sourceProfile ?? '현재 서버';
const seasonSource = (season: ArchiveSeason): string => season.source ?? 'current';
const detailKey = (season: ArchiveSeason, generalNo: number): string =>
`${seasonSourceProfile(season)}:${seasonSource(season)}:${season.serverId}:${generalNo}`;
const isSelectedSeason = (season: ArchiveSeason): boolean =>
selectedKey.value?.startsWith(`${seasonSourceProfile(season)}:${seasonSource(season)}:${season.serverId}:`) ??
false;
const toggleHistory = async (serverId: string, generalNo: number): Promise<void> => { const formatOpenedAt = (season: ArchiveSeason): string => {
const key = detailKey(serverId, generalNo); const value = season.openedAt ?? season.date;
const current = details.value[key]; if (!value) return '개장일 미상';
if (current?.open) { const date = new Date(value);
details.value = { ...details.value, [key]: { ...current, open: false } }; if (Number.isNaN(date.getTime())) return '개장일 미상';
return; return `${new Intl.DateTimeFormat('ko-KR', { dateStyle: 'medium', timeZone: 'Asia/Seoul' }).format(date)} 개장`;
} };
if (current?.detail) {
details.value = { ...details.value, [key]: { ...current, open: true } }; const selectGeneral = async (season: ArchiveSeason, generalNo: number): Promise<void> => {
const key = detailKey(season, generalNo);
if (selectedKey.value === key) {
selectedKey.value = null;
detail.value = null;
detailError.value = null;
return; return;
} }
details.value = { selectedKey.value = key;
...details.value, detail.value = null;
[key]: { open: true, loading: true, error: null, detail: null }, detailError.value = null;
}; detailLoading.value = true;
try { try {
const detail = await trpc.archive.myPastPlayDetail.query({ serverId, generalNo }); const result = await queryPastPlayDetail({
details.value = { sourceProfile: seasonSourceProfile(season),
...details.value, source: seasonSource(season),
[key]: { open: true, loading: false, error: null, detail }, serverId: season.serverId,
}; generalNo,
});
if (selectedKey.value === key) detail.value = result;
} catch (cause) { } catch (cause) {
details.value = { if (selectedKey.value === key) {
...details.value, detailError.value = cause instanceof Error ? cause.message : '지난 장수 상세 기록을 불러오지 못했습니다.';
[key]: { }
open: true, } finally {
loading: false, if (selectedKey.value === key) detailLoading.value = false;
error: cause instanceof Error ? cause.message : '장수 열전을 불러오지 못했습니다.',
detail: null,
},
};
} }
}; };
const archiveRecords = computed<GeneralRecordCollection>(() => {
const result: GeneralRecordCollection = {};
for (const type of GENERAL_RECORD_TYPES) {
const channel = detail.value?.logs[type];
result[type] = (channel?.entries ?? []).map((entry) => ({ id: entry.id, content: plainLog(entry.text) }));
}
return result;
});
const unavailableRecords = computed<GeneralRecordType[]>(() =>
GENERAL_RECORD_TYPES.filter((type) => {
const channel = detail.value?.logs[type];
return channel ? !channel.available : type !== 'generalHistory';
})
);
const battleSummary = computed<GeneralBattleSummaryData>(() => ({
...detail.value?.battle,
experience: detail.value?.general.experience ?? null,
dedicationText: detail.value?.general.progression.dedicationText ?? null,
}));
onMounted(() => { onMounted(() => {
void loadArchive(); void loadArchive();
}); });
@@ -85,28 +197,27 @@ onMounted(() => {
</nav> </nav>
</header> </header>
<p class="page-note">종료된 기수에 보관된 장수 기록입니다.</p> <p class="page-note">이전 서버에서 종료된 기수에 보관된 장수 기록입니다.</p>
<p v-if="error" class="error-row">{{ error }}</p> <p v-if="error" class="error-row">{{ error }}</p>
<p v-else-if="loading && !archive" class="empty-row">불러오는 중...</p> <p v-else-if="loading && !archive" class="empty-row">불러오는 중...</p>
<p v-else-if="archive?.seasons.length === 0" class="empty-row">보관된 지난 플레이가 없습니다.</p> <p v-else-if="archive?.seasons.length === 0" class="empty-row">보관된 지난 플레이가 없습니다.</p>
<section v-for="season in archive?.seasons ?? []" :key="season.serverId" class="season-card"> <section v-for="season in archive?.seasons ?? []" :key="detailKey(season, 0)" class="season-card">
<div class="season-heading legacy-bg2"> <div class="season-heading legacy-bg2">
<strong>{{ season.serverId }}</strong> <div class="season-identity">
<div class="season-actions"> <strong class="archive-label">이전 서버 기록</strong>
<strong>{{ seasonSourceProfile(season) }}</strong>
<span>{{ season.serverId }}</span>
</div>
<div class="season-meta">
<span>{{ formatOpenedAt(season) }}</span>
<span> <span>
{{ season.scenarioName ?? '시나리오 미상' }} {{ season.scenarioName ?? '시나리오 미상' }}
<template v-if="season.season !== null"> · {{ season.season }}</template> <template v-if="season.season !== null"> · {{ season.season }}</template>
</span> </span>
<RouterLink
v-if="season.dynastyId !== null"
class="legacy-button nation-archive-link"
:to="`/dynasty/${season.dynastyId}`"
>
기수 국가 정보
</RouterLink>
</div> </div>
</div> </div>
<div class="table-scroll"> <div class="table-scroll">
<table> <table>
<thead> <thead>
@@ -121,85 +232,83 @@ onMounted(() => {
<th>성격</th> <th>성격</th>
<th>내정 특기</th> <th>내정 특기</th>
<th>전투 특기</th> <th>전투 특기</th>
<th>장수 열전</th> <th>상세</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<template v-for="general in season.generals" :key="general.generalNo"> <tr v-for="general in season.generals" :key="general.generalNo">
<tr> <td class="general-name">{{ general.name }}</td>
<td class="general-name">{{ general.name }}</td> <td>
<td> <span
<span class="nation-name"
class="nation-name" :style="{ backgroundColor: general.nationColor, color: '#fff' }"
:style="{ backgroundColor: general.nationColor, color: '#ffffff' }" >
> {{ general.nationName }}
{{ general.nationName }} </span>
</span> </td>
</td> <td>{{ yearMonth(general.lastYearMonth) }}</td>
<td>{{ yearMonth(general.lastYearMonth) }}</td> <td>{{ valueOrDash(general.leadership) }}</td>
<td>{{ valueOrDash(general.leadership) }}</td> <td>{{ valueOrDash(general.strength) }}</td>
<td>{{ valueOrDash(general.strength) }}</td> <td>{{ valueOrDash(general.intel) }}</td>
<td>{{ valueOrDash(general.intel) }}</td> <td>{{ valueOrDash(general.officerLevelText) }}</td>
<td>{{ valueOrDash(general.officerLevelText) }}</td> <td>{{ valueOrDash(general.personal) }}</td>
<td>{{ valueOrDash(general.personal) }}</td> <td>{{ valueOrDash(general.special) }}</td>
<td>{{ valueOrDash(general.special) }}</td> <td>{{ valueOrDash(general.special2) }}</td>
<td>{{ valueOrDash(general.special2) }}</td> <td>
<td> <button
<button class="legacy-button detail-toggle"
class="legacy-button history-toggle" type="button"
type="button" :aria-expanded="selectedKey === detailKey(season, general.generalNo)"
:aria-expanded=" @click="selectGeneral(season, general.generalNo)"
details[detailKey(season.serverId, general.generalNo)]?.open ?? false >
" {{ selectedKey === detailKey(season, general.generalNo) ? '접기' : '상세 보기' }}
@click="toggleHistory(season.serverId, general.generalNo)" </button>
> </td>
{{ </tr>
details[detailKey(season.serverId, general.generalNo)]?.open
? '접기'
: `보기 (${general.historyCount})`
}}
</button>
</td>
</tr>
<tr v-if="details[detailKey(season.serverId, general.generalNo)]?.open" class="history-row">
<td colspan="11">
<p
v-if="details[detailKey(season.serverId, general.generalNo)]?.loading"
role="status"
>
장수 열전을 불러오는 ...
</p>
<p
v-else-if="details[detailKey(season.serverId, general.generalNo)]?.error"
class="history-error"
role="alert"
>
{{ details[detailKey(season.serverId, general.generalNo)]?.error }}
</p>
<p
v-else-if="
details[detailKey(season.serverId, general.generalNo)]?.detail?.history
.length === 0
"
>
보관된 장수 열전이 없습니다.
</p>
<ol v-else class="history-list">
<li
v-for="(entry, index) in details[
detailKey(season.serverId, general.generalNo)
]?.detail?.history ?? []"
:key="index"
>
{{ plainLog(entry) }}
</li>
</ol>
</td>
</tr>
</template>
</tbody> </tbody>
</table> </table>
</div> </div>
<div v-if="isSelectedSeason(season)" class="detail-region">
<SkeletonLines v-if="detailLoading" :lines="8" />
<p v-else-if="detailError" class="detail-error" role="alert">{{ detailError }}</p>
<div v-else-if="detail" class="detail-shell">
<div class="detail-source">
<span>{{ detail.sourceProfile }} · {{ detail.source }}</span>
<RouterLink
v-if="detail.dynastyPath"
class="legacy-button nation-archive-link"
:to="detail.dynastyPath"
>
기수 국가 정보
</RouterLink>
</div>
<div class="detail-grid">
<PanelCard title="장수 정보">
<GeneralBasicCard
class="archive-general-card"
:general="detail.general"
:loading="false"
:nation-color="detail.nation?.color"
>
<template #details>
<GeneralBattleSummary :summary="battleSummary" show-win-rate rate-scale="percent" />
<LegacyGeneralProgress
v-if="detail.masteryAvailable"
:general="detail.general"
:show-primary="false"
/>
<div v-else class="archive-unavailable">
기수에는 숙련도 기록이 보존되지 않았습니다.
</div>
</template>
</GeneralBasicCard>
</PanelCard>
<PanelCard title="장수 기록" subtitle="보존된 과거 기록">
<GeneralRecordPanels :records="archiveRecords" :unavailable="unavailableRecords" />
</PanelCard>
</div>
</div>
</div>
</section> </section>
</main> </main>
</template> </template>
@@ -210,13 +319,17 @@ onMounted(() => {
min-height: 100vh; min-height: 100vh;
margin: 0 auto; margin: 0 auto;
color: #eee; color: #eee;
background: #151515; background-color: #302016;
background-image: var(--sammo-texture-walnut);
font-family: var(--sammo-font-sans);
font-size: 14px;
} }
.title-row, .title-row,
.season-heading { .season-heading {
border: 1px solid #555; border: 1px solid #666;
background: #2b2b2b; background-color: #14241b;
background-image: var(--sammo-texture-green);
} }
.title-row { .title-row {
@@ -233,8 +346,12 @@ onMounted(() => {
font-size: 18px; font-size: 18px;
} }
.title-row nav { .title-row nav,
.season-identity,
.season-meta,
.detail-source {
display: flex; display: flex;
align-items: center;
gap: 6px; gap: 6px;
} }
@@ -242,11 +359,12 @@ onMounted(() => {
box-sizing: border-box; box-sizing: border-box;
min-height: 28px; min-height: 28px;
padding: 4px 9px; padding: 4px 9px;
border: 1px solid #777; border: 1px solid #2d5d7f;
border-radius: 0; border-radius: 4px;
color: #eee; color: #fff;
background: #333; background: #315f86;
font: inherit; font: inherit;
font-weight: 700;
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
} }
@@ -257,6 +375,10 @@ onMounted(() => {
color: skyblue; color: skyblue;
} }
.legacy-button:active {
transform: translateY(1px);
}
.legacy-button:disabled { .legacy-button:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.55; opacity: 0.55;
@@ -267,15 +389,17 @@ onMounted(() => {
.error-row { .error-row {
margin: 0; margin: 0;
padding: 12px 10px; padding: 12px 10px;
border-inline: 1px solid #555; border-inline: 1px solid #666;
border-bottom: 1px solid #555; border-bottom: 1px solid #666;
} }
.page-note { .page-note,
.season-heading span {
color: #bbb; color: #bbb;
} }
.error-row { .error-row,
.detail-error {
color: #ff8d8d; color: #ff8d8d;
} }
@@ -294,19 +418,15 @@ onMounted(() => {
color: skyblue; color: skyblue;
} }
.season-heading span { .archive-label {
color: #bbb; padding: 2px 5px;
border: 1px solid #777;
color: #fff !important;
background: #00582c;
} }
.season-actions { .season-meta {
display: flex; justify-content: flex-end;
align-items: center;
gap: 8px;
}
.nation-archive-link {
min-height: 24px;
padding-block: 2px;
} }
.table-scroll { .table-scroll {
@@ -324,7 +444,7 @@ table {
th, th,
td { td {
padding: 6px 7px; padding: 6px 7px;
border: 1px solid #555; border: 1px solid #666;
text-align: center; text-align: center;
white-space: nowrap; white-space: nowrap;
} }
@@ -348,45 +468,75 @@ th {
text-shadow: 0 1px 1px #000; text-shadow: 0 1px 1px #000;
} }
.history-toggle { .detail-toggle,
.nation-archive-link {
min-height: 24px; min-height: 24px;
padding-block: 2px; padding-block: 2px;
} }
.history-row td { .detail-region {
padding: 10px 12px; border: 1px solid #666;
text-align: left;
white-space: normal;
background: #101010; background: #101010;
} }
.history-row p { .detail-error {
margin: 0; margin: 0;
color: #bbb; padding: 10px 12px;
} }
.history-error { .detail-source {
color: #ff8d8d !important; justify-content: space-between;
min-height: 34px;
padding: 4px 8px;
border-bottom: 1px solid #666;
background-color: #14241b;
background-image: var(--sammo-texture-green);
} }
.history-list { .detail-grid {
display: grid; display: grid;
gap: 5px; grid-template-columns: repeat(2, minmax(0, 1fr));
margin: 0; }
padding-left: 26px;
color: #ddd; .detail-grid :deep(.panel-card) {
height: 100%;
border-radius: 0;
box-shadow: none;
}
.detail-grid :deep(.panel-body) {
padding: 0;
}
.detail-grid :deep(.panel-title) {
color: skyblue;
font-size: 18px;
font-weight: 500;
}
.archive-unavailable {
min-height: 28px;
padding: 5px 8px;
border-top: 1px solid #666;
color: #bbb;
text-align: center;
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.title-row, .title-row,
.season-heading { .season-heading,
.season-identity,
.season-meta {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;
} }
.season-actions { .season-meta {
align-items: flex-start; justify-content: flex-start;
flex-direction: column; }
.detail-grid {
grid-template-columns: 1fr;
} }
} }
</style> </style>