merge: 최신 main을 Profile 포괄 권한 제거에 통합
This commit is contained in:
@@ -1,124 +1,326 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import {
|
||||
asRecord,
|
||||
normalizeArchivedGeneral,
|
||||
type ArchivedGeneralSnapshotV1,
|
||||
type ArchivedJsonValue,
|
||||
} from '@sammo-ts/common';
|
||||
|
||||
import { resolveOfficerLevelName, sanitizeInternalDisplayCode } from '../../services/gameDisplayNames.js';
|
||||
import {
|
||||
loadCrewTypeDisplayNames,
|
||||
loadItemDisplayNames,
|
||||
resolveDedicationLevelName,
|
||||
resolveOfficerLevelName,
|
||||
sanitizeInternalDisplayCode,
|
||||
} from '../../services/gameDisplayNames.js';
|
||||
import {
|
||||
findLegacyEmperors,
|
||||
findLegacyGeneral,
|
||||
findLegacyGeneralsByOwner,
|
||||
findLegacyGames,
|
||||
findLegacyNations,
|
||||
LEGACY_ARCHIVE_PROFILES,
|
||||
type LegacyArchiveProfile,
|
||||
} from '../../services/legacyArchiveStore.js';
|
||||
import { readOnlyAuthedProcedure, router } from '../../trpc.js';
|
||||
import { loadTraitNames } from '../nation/shared.js';
|
||||
|
||||
const numberOrNull = (value: unknown): number | null =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
|
||||
const firstNumber = (record: Record<string, unknown>, ...keys: string[]): number | null => {
|
||||
for (const key of keys) {
|
||||
const value = numberOrNull(record[key]);
|
||||
if (value !== null) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
const textOrNull = (value: unknown): string | null => {
|
||||
if (typeof value === 'string' && value.trim()) return value;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
||||
return null;
|
||||
};
|
||||
|
||||
const displayTextOrNull = (value: unknown): string | null => {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
return typeof value === 'number' && Number.isFinite(value) ? String(value) : null;
|
||||
const numericText = (value: string | null): number | null => {
|
||||
if (value === null || value.trim() === '') return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? Math.trunc(parsed) : null;
|
||||
};
|
||||
|
||||
const parseHistory = (value: unknown): string[] => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0);
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return [];
|
||||
}
|
||||
return value
|
||||
.split(/<br\s*\/?>/i)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
const canonicalSnapshot = (value: unknown, fallbackName: string): ArchivedGeneralSnapshotV1 =>
|
||||
normalizeArchivedGeneral(value as ArchivedJsonValue, fallbackName).snapshot;
|
||||
|
||||
const zPastPlayDetailInput = z.object({
|
||||
source: z.enum(['current', 'legacy']).default('current'),
|
||||
sourceProfile: z.enum(LEGACY_ARCHIVE_PROFILES).optional(),
|
||||
serverId: z.string().trim().min(1).max(64),
|
||||
generalNo: z.number().int().positive(),
|
||||
});
|
||||
|
||||
type ArchiveSource = 'current' | 'legacy';
|
||||
|
||||
interface GeneralArchiveEntry {
|
||||
source: ArchiveSource;
|
||||
sourceProfile: string;
|
||||
serverId: string;
|
||||
generalNo: number;
|
||||
name: string;
|
||||
lastYearMonth: number;
|
||||
turnTime: Date;
|
||||
snapshot: ArchivedGeneralSnapshotV1;
|
||||
}
|
||||
|
||||
interface ArchiveNationEntry {
|
||||
source: ArchiveSource;
|
||||
sourceProfile: string;
|
||||
serverId: string;
|
||||
nation: number;
|
||||
archivedAt: Date;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const key = (source: ArchiveSource, sourceProfile: string, serverId: string): string =>
|
||||
`${source}:${sourceProfile}:${serverId}`;
|
||||
|
||||
const nationKey = (source: ArchiveSource, sourceProfile: string, serverId: string, nation: number): string =>
|
||||
`${key(source, sourceProfile, serverId)}:${nation}`;
|
||||
|
||||
const resolveNation = (
|
||||
nations: Map<string, ArchiveNationEntry>,
|
||||
entry: GeneralArchiveEntry
|
||||
): { nationId: number; name: string; color: string; level: number | null } => {
|
||||
const nationId = entry.snapshot.identity.nationId ?? 0;
|
||||
const archived = nations.get(nationKey(entry.source, entry.sourceProfile, entry.serverId, nationId));
|
||||
return {
|
||||
nationId,
|
||||
name: textOrNull(archived?.data.name) ?? (nationId === 0 ? '재야' : '미상'),
|
||||
color: textOrNull(archived?.data.color) ?? '#000000',
|
||||
level: numberOrNull(archived?.data.level),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveDisplayResources = async (entries: GeneralArchiveEntry[]) => {
|
||||
const [personalityNames, domesticNames, warNames, itemNames] = await Promise.all([
|
||||
loadTraitNames(
|
||||
entries.map((entry) => entry.snapshot.traits.personality),
|
||||
'personality'
|
||||
),
|
||||
loadTraitNames(
|
||||
entries.map((entry) => entry.snapshot.traits.specialDomestic),
|
||||
'domestic'
|
||||
),
|
||||
loadTraitNames(
|
||||
entries.map((entry) => entry.snapshot.traits.specialWar),
|
||||
'war'
|
||||
),
|
||||
loadItemDisplayNames(
|
||||
entries.flatMap((entry) => [
|
||||
entry.snapshot.items.horse,
|
||||
entry.snapshot.items.weapon,
|
||||
entry.snapshot.items.book,
|
||||
entry.snapshot.items.item,
|
||||
])
|
||||
),
|
||||
]);
|
||||
const traitName = (code: string | null, names: Awaited<ReturnType<typeof loadTraitNames>>): string =>
|
||||
code ? (names.get(code)?.name ?? sanitizeInternalDisplayCode(code)) : '-';
|
||||
const itemName = (code: string | null): string =>
|
||||
code ? (itemNames.get(code) ?? sanitizeInternalDisplayCode(code)) : '-';
|
||||
return { personalityNames, domesticNames, warNames, traitName, itemName };
|
||||
};
|
||||
|
||||
const buildGeneralDetail = async (entry: GeneralArchiveEntry, nation: ReturnType<typeof resolveNation>) => {
|
||||
const snapshot = entry.snapshot;
|
||||
const display = await resolveDisplayResources([entry]);
|
||||
const crewTypeId = numericText(snapshot.resources.crewType);
|
||||
const crewTypeNames = await loadCrewTypeDisplayNames(null, entry.sourceProfile);
|
||||
const dedicationLevel = snapshot.progression.dedicationLevel ?? 0;
|
||||
const maxDedicationLevel = 30;
|
||||
return {
|
||||
id: entry.generalNo,
|
||||
name: snapshot.identity.name || entry.name,
|
||||
picture: snapshot.identity.picture,
|
||||
imageServer: snapshot.identity.imageServer,
|
||||
npcState: snapshot.identity.npcState ?? 0,
|
||||
officerLevel: snapshot.identity.officerLevel ?? 0,
|
||||
officerLevelText: resolveOfficerLevelName(snapshot.identity.officerLevel ?? 0, nation.level ?? undefined),
|
||||
stats: {
|
||||
leadership: snapshot.stats.leadership ?? 0,
|
||||
strength: snapshot.stats.strength ?? 0,
|
||||
intelligence: snapshot.stats.intelligence ?? 0,
|
||||
},
|
||||
gold: snapshot.resources.gold ?? 0,
|
||||
rice: snapshot.resources.rice ?? 0,
|
||||
crew: snapshot.resources.crew ?? 0,
|
||||
train: snapshot.resources.train ?? 0,
|
||||
atmos: snapshot.resources.morale ?? 0,
|
||||
injury: snapshot.resources.injury ?? 0,
|
||||
experience: snapshot.progression.experience ?? 0,
|
||||
dedication: snapshot.progression.dedication ?? 0,
|
||||
...(snapshot.progression.age === null ? {} : { age: snapshot.progression.age }),
|
||||
turnTime: entry.turnTime.toISOString(),
|
||||
...(crewTypeId === null ? {} : { crewTypeId }),
|
||||
crewTypeName: crewTypeId === null ? '-' : (crewTypeNames.get(crewTypeId) ?? '-'),
|
||||
traits: {
|
||||
personal: display.traitName(snapshot.traits.personality, display.personalityNames),
|
||||
specialDomestic: display.traitName(snapshot.traits.specialDomestic, display.domesticNames),
|
||||
specialWar: display.traitName(snapshot.traits.specialWar, display.warNames),
|
||||
},
|
||||
itemNames: {
|
||||
horse: display.itemName(snapshot.items.horse),
|
||||
weapon: display.itemName(snapshot.items.weapon),
|
||||
book: display.itemName(snapshot.items.book),
|
||||
item: display.itemName(snapshot.items.item),
|
||||
},
|
||||
progression: {
|
||||
experienceLevel: snapshot.progression.experienceLevel ?? 0,
|
||||
dedicationLevel,
|
||||
dedicationText: resolveDedicationLevelName(dedicationLevel, maxDedicationLevel),
|
||||
statExperience: {
|
||||
leadership: snapshot.stats.leadershipExperience ?? 0,
|
||||
strength: snapshot.stats.strengthExperience ?? 0,
|
||||
intelligence: snapshot.stats.intelligenceExperience ?? 0,
|
||||
},
|
||||
statUpgradeLimit: 30,
|
||||
dex: [
|
||||
snapshot.mastery.infantry,
|
||||
snapshot.mastery.archery,
|
||||
snapshot.mastery.cavalry,
|
||||
snapshot.mastery.special,
|
||||
snapshot.mastery.siege,
|
||||
].map((value) => value ?? 0),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const archiveRouter = router({
|
||||
myPastPlays: readOnlyAuthedProcedure.query(async ({ ctx }) => {
|
||||
const owner = ctx.auth?.user.id;
|
||||
if (!owner) {
|
||||
throw new Error('Authenticated archive query is missing its user identity');
|
||||
}
|
||||
const generals = await ctx.db.oldGeneral.findMany({
|
||||
where: { owner },
|
||||
orderBy: [{ lastYearMonth: 'desc' }, { serverId: 'desc' }, { generalNo: 'asc' }],
|
||||
});
|
||||
if (!generals.length) {
|
||||
return { seasons: [] };
|
||||
}
|
||||
if (!owner) throw new Error('Authenticated archive query is missing its user identity');
|
||||
|
||||
const serverIds = [...new Set(generals.map((general) => general.serverId))];
|
||||
const [games, nations, emperors] = await Promise.all([
|
||||
ctx.db.gameHistory.findMany({
|
||||
where: { serverId: { in: serverIds } },
|
||||
}),
|
||||
ctx.db.oldNation.findMany({
|
||||
where: { serverId: { in: serverIds } },
|
||||
orderBy: [{ date: 'desc' }, { id: 'desc' }],
|
||||
}),
|
||||
ctx.db.emperor.findMany({
|
||||
where: { serverId: { in: serverIds } },
|
||||
orderBy: { id: 'desc' },
|
||||
select: { id: true, serverId: true },
|
||||
const [legacyRows, currentRows] = await Promise.all([
|
||||
findLegacyGeneralsByOwner(ctx.db, owner),
|
||||
ctx.db.oldGeneral.findMany({
|
||||
where: { owner },
|
||||
orderBy: [{ lastYearMonth: 'desc' }, { serverId: 'desc' }, { generalNo: 'asc' }],
|
||||
}),
|
||||
]);
|
||||
const legacyIdentity = new Set(
|
||||
legacyRows.map((row) => `${row.sourceProfile}:${row.serverId}:${row.generalNo}`)
|
||||
);
|
||||
const entries: GeneralArchiveEntry[] = [
|
||||
...legacyRows.map((row) => ({
|
||||
source: 'legacy' as const,
|
||||
sourceProfile: row.sourceProfile,
|
||||
serverId: row.serverId,
|
||||
generalNo: row.generalNo,
|
||||
name: row.name,
|
||||
lastYearMonth: row.lastYearMonth,
|
||||
turnTime: row.turnTime,
|
||||
snapshot: canonicalSnapshot(row.data, row.name),
|
||||
})),
|
||||
...currentRows
|
||||
.filter((row) => !legacyIdentity.has(`${ctx.profile.id}:${row.serverId}:${row.generalNo}`))
|
||||
.map((row) => ({
|
||||
source: 'current' as const,
|
||||
sourceProfile: ctx.profile.id,
|
||||
serverId: row.serverId,
|
||||
generalNo: row.generalNo,
|
||||
name: row.name,
|
||||
lastYearMonth: row.lastYearMonth,
|
||||
turnTime: row.turnTime,
|
||||
snapshot: canonicalSnapshot(row.data, row.name),
|
||||
})),
|
||||
];
|
||||
if (entries.length === 0) return { seasons: [] };
|
||||
|
||||
const gameByServer = new Map(games.map((game) => [game.serverId, game]));
|
||||
const emperorByServer = new Map<string, number>();
|
||||
for (const emperor of emperors) {
|
||||
if (emperor.serverId && !emperorByServer.has(emperor.serverId)) {
|
||||
emperorByServer.set(emperor.serverId, emperor.id);
|
||||
}
|
||||
const legacyKeys = Array.from(
|
||||
new Map(
|
||||
entries
|
||||
.filter((entry) => entry.source === 'legacy')
|
||||
.map((entry) => [key(entry.source, entry.sourceProfile, entry.serverId), entry])
|
||||
).values()
|
||||
).map((entry) => ({ sourceProfile: entry.sourceProfile as LegacyArchiveProfile, serverId: entry.serverId }));
|
||||
const currentServerIds = Array.from(
|
||||
new Set(entries.filter((entry) => entry.source === 'current').map((entry) => entry.serverId))
|
||||
);
|
||||
const [legacyGames, legacyNationRows, legacyEmperors, currentGames, currentNationRows, currentEmperors] =
|
||||
await Promise.all([
|
||||
findLegacyGames(ctx.db, legacyKeys),
|
||||
findLegacyNations(ctx.db, legacyKeys),
|
||||
findLegacyEmperors(ctx.db, legacyKeys),
|
||||
currentServerIds.length
|
||||
? ctx.db.gameHistory.findMany({ where: { serverId: { in: currentServerIds } } })
|
||||
: [],
|
||||
currentServerIds.length
|
||||
? ctx.db.oldNation.findMany({
|
||||
where: { serverId: { in: currentServerIds } },
|
||||
orderBy: [{ date: 'desc' }, { id: 'desc' }],
|
||||
})
|
||||
: [],
|
||||
currentServerIds.length
|
||||
? ctx.db.emperor.findMany({
|
||||
where: { serverId: { in: currentServerIds } },
|
||||
orderBy: { id: 'desc' },
|
||||
select: { id: true, serverId: true },
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
|
||||
const games = new Map<string, { openedAt: Date; season: number; scenario: number; scenarioName: string }>();
|
||||
for (const row of legacyGames) {
|
||||
games.set(key('legacy', row.sourceProfile, row.serverId), row);
|
||||
}
|
||||
const nationByServerAndId = new Map<string, (typeof nations)[number]>();
|
||||
for (const nation of nations) {
|
||||
const key = `${nation.serverId}:${nation.nation}`;
|
||||
if (!nationByServerAndId.has(key)) {
|
||||
nationByServerAndId.set(key, nation);
|
||||
}
|
||||
for (const row of currentGames) {
|
||||
games.set(key('current', ctx.profile.id, row.serverId), {
|
||||
openedAt: row.date,
|
||||
season: row.season,
|
||||
scenario: row.scenario,
|
||||
scenarioName: row.scenarioName,
|
||||
});
|
||||
}
|
||||
const archivedRoles = generals.map((general) => {
|
||||
const data = asRecord(general.data);
|
||||
const role = asRecord(data.role);
|
||||
return {
|
||||
personal: displayTextOrNull(data.personalCode ?? data.personal ?? role.personality),
|
||||
special: displayTextOrNull(data.specialCode ?? data.special ?? role.specialDomestic),
|
||||
special2: displayTextOrNull(data.special2Code ?? data.special2 ?? role.specialWar),
|
||||
const nations = new Map<string, ArchiveNationEntry>();
|
||||
for (const row of legacyNationRows) {
|
||||
const entry: ArchiveNationEntry = {
|
||||
source: 'legacy',
|
||||
sourceProfile: row.sourceProfile,
|
||||
serverId: row.serverId,
|
||||
nation: row.nation,
|
||||
archivedAt: row.archivedAt,
|
||||
data: asRecord(row.data),
|
||||
};
|
||||
});
|
||||
const [personalityNames, domesticNames, warNames] = await Promise.all([
|
||||
loadTraitNames(
|
||||
archivedRoles.map((role) => role.personal),
|
||||
'personality'
|
||||
),
|
||||
loadTraitNames(
|
||||
archivedRoles.map((role) => role.special),
|
||||
'domestic'
|
||||
),
|
||||
loadTraitNames(
|
||||
archivedRoles.map((role) => role.special2),
|
||||
'war'
|
||||
),
|
||||
]);
|
||||
const displayRole = (value: string | null, names: Awaited<ReturnType<typeof loadTraitNames>>): string | null =>
|
||||
value ? (names.get(value)?.name ?? sanitizeInternalDisplayCode(value)) : null;
|
||||
const entryKey = nationKey(entry.source, entry.sourceProfile, entry.serverId, entry.nation);
|
||||
if (!nations.has(entryKey)) nations.set(entryKey, entry);
|
||||
}
|
||||
for (const row of currentNationRows) {
|
||||
const entry: ArchiveNationEntry = {
|
||||
source: 'current',
|
||||
sourceProfile: ctx.profile.id,
|
||||
serverId: row.serverId,
|
||||
nation: row.nation,
|
||||
archivedAt: row.date,
|
||||
data: asRecord(row.data),
|
||||
};
|
||||
const entryKey = nationKey(entry.source, entry.sourceProfile, entry.serverId, entry.nation);
|
||||
if (!nations.has(entryKey)) nations.set(entryKey, entry);
|
||||
}
|
||||
const dynastyIds = new Map<string, number>();
|
||||
for (const row of legacyEmperors) {
|
||||
if (row.serverId) {
|
||||
const entryKey = key('legacy', row.sourceProfile, row.serverId);
|
||||
if (!dynastyIds.has(entryKey)) dynastyIds.set(entryKey, Number(row.id));
|
||||
}
|
||||
}
|
||||
for (const row of currentEmperors) {
|
||||
if (row.serverId) {
|
||||
const entryKey = key('current', ctx.profile.id, row.serverId);
|
||||
if (!dynastyIds.has(entryKey)) dynastyIds.set(entryKey, row.id);
|
||||
}
|
||||
}
|
||||
|
||||
const display = await resolveDisplayResources(entries);
|
||||
const seasons = new Map<
|
||||
string,
|
||||
{
|
||||
source: ArchiveSource;
|
||||
sourceProfile: string;
|
||||
serverId: string;
|
||||
openedAt: string | null;
|
||||
date: string | null;
|
||||
season: number | null;
|
||||
scenario: number | null;
|
||||
@@ -145,85 +347,186 @@ export const archiveRouter = router({
|
||||
}>;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const [generalIndex, general] of generals.entries()) {
|
||||
const game = gameByServer.get(general.serverId);
|
||||
let season = seasons.get(general.serverId);
|
||||
for (const entry of entries) {
|
||||
const entryKey = key(entry.source, entry.sourceProfile, entry.serverId);
|
||||
const game = games.get(entryKey);
|
||||
let season = seasons.get(entryKey);
|
||||
if (!season) {
|
||||
const openedAt = game?.openedAt.toISOString() ?? null;
|
||||
season = {
|
||||
serverId: general.serverId,
|
||||
date: game?.date.toISOString() ?? null,
|
||||
source: entry.source,
|
||||
sourceProfile: entry.sourceProfile,
|
||||
serverId: entry.serverId,
|
||||
openedAt,
|
||||
date: openedAt,
|
||||
season: game?.season ?? null,
|
||||
scenario: game?.scenario ?? null,
|
||||
scenarioName: game?.scenarioName ?? null,
|
||||
dynastyId: emperorByServer.get(general.serverId) ?? null,
|
||||
dynastyId: dynastyIds.get(entryKey) ?? null,
|
||||
generals: [],
|
||||
};
|
||||
seasons.set(general.serverId, season);
|
||||
seasons.set(entryKey, season);
|
||||
}
|
||||
|
||||
const data = asRecord(general.data);
|
||||
const stats = asRecord(data.stats);
|
||||
const nationId = firstNumber(data, 'nationId', 'nation') ?? 0;
|
||||
const nation = nationByServerAndId.get(`${general.serverId}:${nationId}`);
|
||||
const nationData = asRecord(nation?.data);
|
||||
const officerLevel = firstNumber(data, 'officerLevel', 'officer_level');
|
||||
const nationLevel = firstNumber(nationData, 'level', 'nationLevel');
|
||||
const archivedRole = archivedRoles[generalIndex]!;
|
||||
const snapshot = entry.snapshot;
|
||||
const nation = resolveNation(nations, entry);
|
||||
const officerLevel = snapshot.identity.officerLevel;
|
||||
season.generals.push({
|
||||
generalNo: general.generalNo,
|
||||
name: general.name,
|
||||
lastYearMonth: general.lastYearMonth,
|
||||
nationId,
|
||||
nationName: displayTextOrNull(nationData.name) ?? (nationId === 0 ? '재야' : '미상'),
|
||||
nationColor: displayTextOrNull(nationData.color) ?? '#000000',
|
||||
leadership: firstNumber(data, 'leadership', 'leader') ?? numberOrNull(stats.leadership),
|
||||
strength: firstNumber(data, 'strength', 'power') ?? numberOrNull(stats.strength),
|
||||
intel: firstNumber(data, 'intel', 'intelligence') ?? numberOrNull(stats.intelligence),
|
||||
experience: numberOrNull(data.experience),
|
||||
dedication: numberOrNull(data.dedication),
|
||||
generalNo: entry.generalNo,
|
||||
name: snapshot.identity.name || entry.name,
|
||||
lastYearMonth: entry.lastYearMonth,
|
||||
nationId: nation.nationId,
|
||||
nationName: nation.name,
|
||||
nationColor: nation.color,
|
||||
leadership: snapshot.stats.leadership,
|
||||
strength: snapshot.stats.strength,
|
||||
intel: snapshot.stats.intelligence,
|
||||
experience: snapshot.progression.experience,
|
||||
dedication: snapshot.progression.dedication,
|
||||
officerLevel,
|
||||
officerLevelText:
|
||||
officerLevel === null
|
||||
? null
|
||||
: resolveOfficerLevelName(officerLevel, nationLevel === null ? undefined : nationLevel),
|
||||
personal: displayRole(archivedRole.personal, personalityNames),
|
||||
special: displayRole(archivedRole.special, domesticNames),
|
||||
special2: displayRole(archivedRole.special2, warNames),
|
||||
historyCount: parseHistory(data.history).length,
|
||||
officerLevel === null ? null : resolveOfficerLevelName(officerLevel, nation.level ?? undefined),
|
||||
personal: display.traitName(snapshot.traits.personality, display.personalityNames),
|
||||
special: display.traitName(snapshot.traits.specialDomestic, display.domesticNames),
|
||||
special2: display.traitName(snapshot.traits.specialWar, display.warNames),
|
||||
historyCount: snapshot.history.length,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
seasons: [...seasons.values()].sort((left, right) => {
|
||||
const leftTime = left.date ? new Date(left.date).getTime() : 0;
|
||||
const rightTime = right.date ? new Date(right.date).getTime() : 0;
|
||||
const leftTime = left.openedAt ? new Date(left.openedAt).getTime() : 0;
|
||||
const rightTime = right.openedAt ? new Date(right.openedAt).getTime() : 0;
|
||||
return rightTime - leftTime || right.serverId.localeCompare(left.serverId);
|
||||
}),
|
||||
};
|
||||
}),
|
||||
|
||||
myPastPlayDetail: readOnlyAuthedProcedure.input(zPastPlayDetailInput).query(async ({ ctx, input }) => {
|
||||
const owner = ctx.auth?.user.id;
|
||||
if (!owner) {
|
||||
throw new Error('Authenticated archive query is missing its user identity');
|
||||
}
|
||||
const general = await ctx.db.oldGeneral.findFirst({
|
||||
where: {
|
||||
owner,
|
||||
serverId: input.serverId,
|
||||
generalNo: input.generalNo,
|
||||
},
|
||||
});
|
||||
if (!general) {
|
||||
if (!owner) throw new Error('Authenticated archive query is missing its user identity');
|
||||
const sourceProfile = input.sourceProfile ?? ctx.profile.id;
|
||||
if (input.source === 'current' && sourceProfile !== ctx.profile.id) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '지난 장수 기록을 찾을 수 없습니다.' });
|
||||
}
|
||||
const data = asRecord(general.data);
|
||||
|
||||
let entry: GeneralArchiveEntry | null = null;
|
||||
let nationRows: ArchiveNationEntry[] = [];
|
||||
let dynastyId: number | null = null;
|
||||
if (input.source === 'legacy') {
|
||||
if (!LEGACY_ARCHIVE_PROFILES.includes(sourceProfile as LegacyArchiveProfile)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이전 서버 프로필입니다.' });
|
||||
}
|
||||
const profile = sourceProfile as LegacyArchiveProfile;
|
||||
const row = await findLegacyGeneral(ctx.db, {
|
||||
owner,
|
||||
sourceProfile: profile,
|
||||
serverId: input.serverId,
|
||||
generalNo: input.generalNo,
|
||||
});
|
||||
if (row) {
|
||||
entry = {
|
||||
source: 'legacy',
|
||||
sourceProfile: row.sourceProfile,
|
||||
serverId: row.serverId,
|
||||
generalNo: row.generalNo,
|
||||
name: row.name,
|
||||
lastYearMonth: row.lastYearMonth,
|
||||
turnTime: row.turnTime,
|
||||
snapshot: canonicalSnapshot(row.data, row.name),
|
||||
};
|
||||
const keyInput = [{ sourceProfile: profile, serverId: input.serverId }];
|
||||
const [nations, emperors] = await Promise.all([
|
||||
findLegacyNations(ctx.db, keyInput),
|
||||
findLegacyEmperors(ctx.db, keyInput),
|
||||
]);
|
||||
nationRows = nations.map((nation) => ({
|
||||
source: 'legacy',
|
||||
sourceProfile: nation.sourceProfile,
|
||||
serverId: nation.serverId,
|
||||
nation: nation.nation,
|
||||
archivedAt: nation.archivedAt,
|
||||
data: asRecord(nation.data),
|
||||
}));
|
||||
dynastyId = Number(emperors[0]?.id ?? 0) || null;
|
||||
}
|
||||
} else {
|
||||
const row = await ctx.db.oldGeneral.findFirst({
|
||||
where: { owner, serverId: input.serverId, generalNo: input.generalNo },
|
||||
});
|
||||
if (row) {
|
||||
entry = {
|
||||
source: 'current',
|
||||
sourceProfile: ctx.profile.id,
|
||||
serverId: row.serverId,
|
||||
generalNo: row.generalNo,
|
||||
name: row.name,
|
||||
lastYearMonth: row.lastYearMonth,
|
||||
turnTime: row.turnTime,
|
||||
snapshot: canonicalSnapshot(row.data, row.name),
|
||||
};
|
||||
const [nations, emperor] = await Promise.all([
|
||||
ctx.db.oldNation.findMany({
|
||||
where: { serverId: input.serverId },
|
||||
orderBy: [{ date: 'desc' }, { id: 'desc' }],
|
||||
}),
|
||||
ctx.db.emperor.findFirst({ where: { serverId: input.serverId }, orderBy: { id: 'desc' } }),
|
||||
]);
|
||||
nationRows = nations.map((nation) => ({
|
||||
source: 'current',
|
||||
sourceProfile: ctx.profile.id,
|
||||
serverId: nation.serverId,
|
||||
nation: nation.nation,
|
||||
archivedAt: nation.date,
|
||||
data: asRecord(nation.data),
|
||||
}));
|
||||
dynastyId = emperor?.id ?? null;
|
||||
}
|
||||
}
|
||||
if (!entry) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '지난 장수 기록을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const nationMap = new Map<string, ArchiveNationEntry>();
|
||||
for (const nation of nationRows) {
|
||||
const entryKey = nationKey(nation.source, nation.sourceProfile, nation.serverId, nation.nation);
|
||||
if (!nationMap.has(entryKey)) nationMap.set(entryKey, nation);
|
||||
}
|
||||
const nation = resolveNation(nationMap, entry);
|
||||
const snapshot = entry.snapshot;
|
||||
const general = await buildGeneralDetail(entry, nation);
|
||||
const logs = {
|
||||
generalHistory: {
|
||||
available: snapshot.availability.history,
|
||||
entries: snapshot.history.map((text, index) => ({ id: index + 1, text })),
|
||||
},
|
||||
battleDetail: { available: snapshot.availability.battleDetailLogs, entries: [] },
|
||||
battleResult: { available: snapshot.availability.battleResultLogs, entries: [] },
|
||||
generalAction: { available: false, entries: [] },
|
||||
};
|
||||
return {
|
||||
serverId: general.serverId,
|
||||
generalNo: general.generalNo,
|
||||
name: general.name,
|
||||
lastYearMonth: general.lastYearMonth,
|
||||
history: parseHistory(data.history),
|
||||
source: entry.source,
|
||||
sourceProfile: entry.sourceProfile,
|
||||
serverId: entry.serverId,
|
||||
generalNo: entry.generalNo,
|
||||
sourceFormat: input.source === 'legacy' ? 'normalized-v1' : 'current-archive',
|
||||
dynastyPath:
|
||||
dynastyId === null ? null : `/dynasty/${dynastyId}${entry.source === 'legacy' ? '?source=legacy' : ''}`,
|
||||
nation: { id: nation.nationId, name: nation.name, color: nation.color },
|
||||
general,
|
||||
masteryAvailable: snapshot.availability.mastery,
|
||||
battle: {
|
||||
available: snapshot.availability.battleAggregates,
|
||||
warnum: snapshot.battle.battles,
|
||||
wins: snapshot.battle.wins,
|
||||
losses: snapshot.battle.losses,
|
||||
strategies: snapshot.battle.fireSuccesses,
|
||||
killCrew: snapshot.battle.killedCrew,
|
||||
deathCrew: snapshot.battle.lostCrew,
|
||||
winRate: snapshot.battle.winRate,
|
||||
killRate: snapshot.battle.killRate,
|
||||
recentWar: snapshot.battle.recentWar,
|
||||
},
|
||||
logs,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -4,11 +4,24 @@ import { z } from 'zod';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
|
||||
import { procedure, router } from '../../trpc.js';
|
||||
import {
|
||||
findLegacyEmperor,
|
||||
findLegacyEmperors,
|
||||
findLegacyGeneralsForServer,
|
||||
findLegacyNations,
|
||||
} from '../../services/legacyArchiveStore.js';
|
||||
|
||||
const zDynastyDetailInput = z.object({
|
||||
emperorId: z.number().int().positive(),
|
||||
source: z.enum(['current', 'legacy']).default('current'),
|
||||
});
|
||||
|
||||
const zDynastyListInput = z
|
||||
.object({
|
||||
source: z.enum(['current', 'legacy']).default('current'),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const parseNumberArray = (value: unknown): number[] =>
|
||||
Array.isArray(value)
|
||||
? value.filter((item): item is number => typeof item === 'number' && Number.isFinite(item))
|
||||
@@ -44,6 +57,41 @@ const firstFiniteNumber = (...values: unknown[]): number | null => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const firstText = (...values: unknown[]): string => {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const legacyEmperorListEntry = (row: Awaited<ReturnType<typeof findLegacyEmperors>>[number]) => {
|
||||
const data = asRecord(row.data);
|
||||
return {
|
||||
id: Number(row.id),
|
||||
source: 'legacy' as const,
|
||||
sourceProfile: row.sourceProfile,
|
||||
serverId: row.serverId ?? '',
|
||||
phase: firstText(data.phase),
|
||||
name: firstText(data.name),
|
||||
year: firstFiniteNumber(data.year) ?? 0,
|
||||
month: firstFiniteNumber(data.month) ?? 0,
|
||||
color: firstText(data.color) || '#000000',
|
||||
type: firstText(data.type),
|
||||
power: firstFiniteNumber(data.power) ?? 0,
|
||||
gennum: firstFiniteNumber(data.gennum) ?? 0,
|
||||
citynum: firstFiniteNumber(data.citynum) ?? 0,
|
||||
l12name: firstText(data.l12name),
|
||||
l11name: firstText(data.l11name),
|
||||
l10name: firstText(data.l10name),
|
||||
l9name: firstText(data.l9name),
|
||||
l8name: firstText(data.l8name),
|
||||
l7name: firstText(data.l7name),
|
||||
l6name: firstText(data.l6name),
|
||||
l5name: firstText(data.l5name),
|
||||
};
|
||||
};
|
||||
|
||||
const firstDisplayArray = (...values: unknown[]): Array<string | number> => {
|
||||
for (const value of values) {
|
||||
const parsed = parseDisplayArray(value);
|
||||
@@ -82,7 +130,15 @@ const formatNationLevel = (level: number | null): string => {
|
||||
};
|
||||
|
||||
export const dynastyRouter = router({
|
||||
getList: procedure.query(async ({ ctx }) => {
|
||||
getList: procedure.input(zDynastyListInput).query(async ({ ctx, input }) => {
|
||||
if ((input?.source ?? 'current') === 'legacy') {
|
||||
const rows = await findLegacyEmperors(ctx.db);
|
||||
return {
|
||||
source: 'legacy' as const,
|
||||
current: null,
|
||||
entries: rows.map(legacyEmperorListEntry),
|
||||
};
|
||||
}
|
||||
const [worldState, rows] = await Promise.all([
|
||||
ctx.db.worldState.findFirst({
|
||||
select: {
|
||||
@@ -96,6 +152,7 @@ export const dynastyRouter = router({
|
||||
]);
|
||||
|
||||
return {
|
||||
source: 'current' as const,
|
||||
current: worldState
|
||||
? {
|
||||
year: worldState.currentYear,
|
||||
@@ -104,6 +161,8 @@ export const dynastyRouter = router({
|
||||
: null,
|
||||
entries: rows.map((row) => ({
|
||||
id: row.id,
|
||||
source: 'current' as const,
|
||||
sourceProfile: ctx.profile.id,
|
||||
serverId: row.serverId ?? '',
|
||||
phase: row.phase ?? '',
|
||||
name: row.name ?? '',
|
||||
@@ -126,6 +185,103 @@ export const dynastyRouter = router({
|
||||
};
|
||||
}),
|
||||
getDetail: procedure.input(zDynastyDetailInput).query(async ({ ctx, input }) => {
|
||||
if (input.source === 'legacy') {
|
||||
const archived = await findLegacyEmperor(ctx.db, input.emperorId);
|
||||
if (!archived) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '이전 서버 왕조 정보를 찾을 수 없습니다.' });
|
||||
}
|
||||
const emperor = asRecord(archived.data);
|
||||
const aux = asRecord(emperor.aux);
|
||||
const winnerNationId = firstFiniteNumber(aux.winnerNationId, aux.winner_nation_id);
|
||||
const serverId = archived.serverId ?? '';
|
||||
const oldNationRows = serverId
|
||||
? await findLegacyNations(ctx.db, [{ sourceProfile: archived.sourceProfile, serverId }])
|
||||
: [];
|
||||
const nationEntries = oldNationRows
|
||||
.map((row) => {
|
||||
const normalized = normalizeOldNationData(row.data);
|
||||
const { data } = normalized;
|
||||
const nationId = row.nation ?? firstFiniteNumber(data.nation) ?? 0;
|
||||
return {
|
||||
archiveId: row.legacyId,
|
||||
nation: nationId,
|
||||
isWinner: winnerNationId !== null && nationId === winnerNationId,
|
||||
name: typeof data.name === 'string' ? data.name : nationId === 0 ? '재야' : '미상',
|
||||
color: typeof data.color === 'string' ? data.color : '#000000',
|
||||
type: normalized.typeCode,
|
||||
typeName: formatNationType(normalized.typeCode),
|
||||
level: firstFiniteNumber(data.level),
|
||||
tech: normalized.tech,
|
||||
maxPower: normalized.maxPower,
|
||||
maxCrew: normalized.maxCrew,
|
||||
maxCities: normalized.maxCities,
|
||||
generals: parseNumberArray(data.generals),
|
||||
history: parseTextArray(data.history),
|
||||
date: row.archivedAt.toISOString(),
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.nation !== 0);
|
||||
const generalIds = Array.from(new Set(nationEntries.flatMap((entry) => entry.generals)));
|
||||
const generalRows = serverId
|
||||
? await findLegacyGeneralsForServer(ctx.db, {
|
||||
sourceProfile: archived.sourceProfile,
|
||||
serverId,
|
||||
generalNos: generalIds,
|
||||
})
|
||||
: [];
|
||||
const generalMap = new Map(
|
||||
generalRows.map((row) => [row.generalNo, { name: row.name, lastYearMonth: row.lastYearMonth }])
|
||||
);
|
||||
return {
|
||||
source: 'legacy' as const,
|
||||
sourceProfile: archived.sourceProfile,
|
||||
emperor: {
|
||||
id: Number(archived.id),
|
||||
serverId,
|
||||
winnerNationId,
|
||||
phase: firstText(emperor.phase),
|
||||
nationCount: firstText(emperor.nation_count, emperor.nationCount),
|
||||
nationName: firstText(emperor.nation_name, emperor.nationName),
|
||||
nationHist: firstText(emperor.nation_hist, emperor.nationHist),
|
||||
genCount: firstText(emperor.gen_count, emperor.genCount),
|
||||
personalHist: firstText(emperor.personal_hist, emperor.personalHist),
|
||||
specialHist: firstText(emperor.special_hist, emperor.specialHist),
|
||||
name: firstText(emperor.name),
|
||||
type: firstText(emperor.type),
|
||||
color: firstText(emperor.color) || '#000000',
|
||||
year: firstFiniteNumber(emperor.year) ?? 0,
|
||||
month: firstFiniteNumber(emperor.month) ?? 0,
|
||||
power: firstFiniteNumber(emperor.power) ?? 0,
|
||||
gennum: firstFiniteNumber(emperor.gennum) ?? 0,
|
||||
citynum: firstFiniteNumber(emperor.citynum) ?? 0,
|
||||
pop: firstText(emperor.pop) || '0',
|
||||
poprate: firstText(emperor.poprate),
|
||||
gold: firstFiniteNumber(emperor.gold) ?? 0,
|
||||
rice: firstFiniteNumber(emperor.rice) ?? 0,
|
||||
l12name: firstText(emperor.l12name),
|
||||
l11name: firstText(emperor.l11name),
|
||||
l10name: firstText(emperor.l10name),
|
||||
l9name: firstText(emperor.l9name),
|
||||
l8name: firstText(emperor.l8name),
|
||||
l7name: firstText(emperor.l7name),
|
||||
l6name: firstText(emperor.l6name),
|
||||
l5name: firstText(emperor.l5name),
|
||||
tiger: firstText(emperor.tiger),
|
||||
eagle: firstText(emperor.eagle),
|
||||
gen: firstText(emperor.gen),
|
||||
history: parseTextArray(emperor.history),
|
||||
},
|
||||
nations: nationEntries.map((entry) => ({
|
||||
...entry,
|
||||
levelName: formatNationLevel(entry.level),
|
||||
generalsFull: entry.generals.map((id) => ({
|
||||
generalNo: id,
|
||||
name: generalMap.get(id)?.name ?? `#${id}`,
|
||||
lastYearMonth: generalMap.get(id)?.lastYearMonth ?? null,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
const emperor = await ctx.db.emperor.findUnique({
|
||||
where: { id: input.emperorId },
|
||||
});
|
||||
@@ -192,6 +348,8 @@ export const dynastyRouter = router({
|
||||
}));
|
||||
|
||||
return {
|
||||
source: 'current' as const,
|
||||
sourceProfile: ctx.profile.id,
|
||||
emperor: {
|
||||
id: emperor.id,
|
||||
serverId,
|
||||
|
||||
@@ -7,6 +7,11 @@ import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacy
|
||||
import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||
|
||||
import { accessAuthedInputProcedure, accessInputProcedure, procedure, router } from '../../trpc.js';
|
||||
import {
|
||||
findLegacyHallOptions,
|
||||
findLegacyHallRows,
|
||||
LEGACY_ARCHIVE_PROFILES,
|
||||
} from '../../services/legacyArchiveStore.js';
|
||||
|
||||
const DEFAULT_BG_COLOR = '#330000';
|
||||
const DEFAULT_FG_COLOR = '#ffffff';
|
||||
@@ -82,372 +87,446 @@ const loadUniqueItems = () => {
|
||||
|
||||
export const rankingRouter = router({
|
||||
getBestGeneral: accessAuthedInputProcedure(
|
||||
z
|
||||
.object({
|
||||
view: z.enum(['user', 'npc']).optional(),
|
||||
})
|
||||
.optional()
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const worldState = await ctx.db.worldState.findFirst({
|
||||
select: { meta: true, config: true },
|
||||
});
|
||||
const meta = asRecord(worldState?.meta);
|
||||
const isUnited = typeof meta.isUnited === 'number' && meta.isUnited !== 0;
|
||||
z
|
||||
.object({
|
||||
view: z.enum(['user', 'npc']).optional(),
|
||||
})
|
||||
.optional()
|
||||
).query(async ({ ctx, input }) => {
|
||||
const worldState = await ctx.db.worldState.findFirst({
|
||||
select: { meta: true, config: true },
|
||||
});
|
||||
const meta = asRecord(worldState?.meta);
|
||||
const isUnited = typeof meta.isUnited === 'number' && meta.isUnited !== 0;
|
||||
|
||||
const view = input?.view ?? 'user';
|
||||
const npcFilter = view === 'npc' ? { gte: 2 } : { lt: 2 };
|
||||
const view = input?.view ?? 'user';
|
||||
const npcFilter = view === 'npc' ? { gte: 2 } : { lt: 2 };
|
||||
|
||||
const [nations, generals] = await Promise.all([
|
||||
ctx.db.nation.findMany({ select: { id: true, name: true, color: true } }),
|
||||
ctx.db.general.findMany({
|
||||
where: { npcState: npcFilter },
|
||||
orderBy: { id: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
nationId: true,
|
||||
userId: true,
|
||||
picture: true,
|
||||
imageServer: true,
|
||||
meta: true,
|
||||
experience: true,
|
||||
dedication: true,
|
||||
horseCode: true,
|
||||
weaponCode: true,
|
||||
bookCode: true,
|
||||
itemCode: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const [nations, generals] = await Promise.all([
|
||||
ctx.db.nation.findMany({ select: { id: true, name: true, color: true } }),
|
||||
ctx.db.general.findMany({
|
||||
where: { npcState: npcFilter },
|
||||
orderBy: { id: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
nationId: true,
|
||||
userId: true,
|
||||
picture: true,
|
||||
imageServer: true,
|
||||
meta: true,
|
||||
experience: true,
|
||||
dedication: true,
|
||||
horseCode: true,
|
||||
weaponCode: true,
|
||||
bookCode: true,
|
||||
itemCode: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const nationMap = new Map(nations.map((nation) => [nation.id, nation]));
|
||||
const generalIds = generals.map((general) => general.id);
|
||||
const rankRows = await ctx.db.rankData.findMany({
|
||||
where: { generalId: { in: generalIds } },
|
||||
select: { generalId: true, type: true, value: true },
|
||||
});
|
||||
const rankMap = new Map<number, Record<string, number>>();
|
||||
for (const row of rankRows) {
|
||||
const entry = rankMap.get(row.generalId) ?? {};
|
||||
entry[row.type] = row.value;
|
||||
rankMap.set(row.generalId, entry);
|
||||
}
|
||||
const nationMap = new Map(nations.map((nation) => [nation.id, nation]));
|
||||
const generalIds = generals.map((general) => general.id);
|
||||
const rankRows = await ctx.db.rankData.findMany({
|
||||
where: { generalId: { in: generalIds } },
|
||||
select: { generalId: true, type: true, value: true },
|
||||
});
|
||||
const rankMap = new Map<number, Record<string, number>>();
|
||||
for (const row of rankRows) {
|
||||
const entry = rankMap.get(row.generalId) ?? {};
|
||||
entry[row.type] = row.value;
|
||||
rankMap.set(row.generalId, entry);
|
||||
}
|
||||
|
||||
const types: Array<[string, 'int' | 'percent', (general: typeof generals[number], ranks: Record<string, number>) => number]> = [
|
||||
['명 성', 'int', (g) => g.experience],
|
||||
['계 급', 'int', (g) => g.dedication],
|
||||
['계 략 성 공', 'int', (_g, r) => r.firenum ?? 0],
|
||||
['전 투 횟 수', 'int', (_g, r) => r.warnum ?? 0],
|
||||
['승 리', 'int', (_g, r) => r.killnum ?? 0],
|
||||
['승 률', 'percent', (_g, r) => {
|
||||
const types: Array<
|
||||
[string, 'int' | 'percent', (general: (typeof generals)[number], ranks: Record<string, number>) => number]
|
||||
> = [
|
||||
['명 성', 'int', (g) => g.experience],
|
||||
['계 급', 'int', (g) => g.dedication],
|
||||
['계 략 성 공', 'int', (_g, r) => r.firenum ?? 0],
|
||||
['전 투 횟 수', 'int', (_g, r) => r.warnum ?? 0],
|
||||
['승 리', 'int', (_g, r) => r.killnum ?? 0],
|
||||
[
|
||||
'승 률',
|
||||
'percent',
|
||||
(_g, r) => {
|
||||
const warnum = r.warnum ?? 0;
|
||||
if (warnum < 10) {
|
||||
return 0;
|
||||
}
|
||||
return (r.killnum ?? 0) / Math.max(1, warnum);
|
||||
}],
|
||||
['점 령', 'int', (_g, r) => r.occupied ?? 0],
|
||||
['사 살', 'int', (_g, r) => r.killcrew ?? 0],
|
||||
['살 상 률', 'percent', (_g, r) => {
|
||||
},
|
||||
],
|
||||
['점 령', 'int', (_g, r) => r.occupied ?? 0],
|
||||
['사 살', 'int', (_g, r) => r.killcrew ?? 0],
|
||||
[
|
||||
'살 상 률',
|
||||
'percent',
|
||||
(_g, r) => {
|
||||
const warnum = r.warnum ?? 0;
|
||||
if (warnum < 10) {
|
||||
return 0;
|
||||
}
|
||||
return (r.killcrew ?? 0) / Math.max(1, r.deathcrew ?? 0);
|
||||
}],
|
||||
['대 인 사 살', 'int', (_g, r) => r.killcrew_person ?? 0],
|
||||
['대 인 살 상 률', 'percent', (_g, r) => {
|
||||
},
|
||||
],
|
||||
['대 인 사 살', 'int', (_g, r) => r.killcrew_person ?? 0],
|
||||
[
|
||||
'대 인 살 상 률',
|
||||
'percent',
|
||||
(_g, r) => {
|
||||
const warnum = r.warnum ?? 0;
|
||||
if (warnum < 10) {
|
||||
return 0;
|
||||
}
|
||||
return (r.killcrew_person ?? 0) / Math.max(1, r.deathcrew_person ?? 0);
|
||||
}],
|
||||
['보 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex1)],
|
||||
['궁 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex2)],
|
||||
['기 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex3)],
|
||||
['귀 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex4)],
|
||||
['차 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex5)],
|
||||
['전 력 전 승 률', 'percent', (_g, r) => {
|
||||
},
|
||||
],
|
||||
['보 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex1)],
|
||||
['궁 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex2)],
|
||||
['기 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex3)],
|
||||
['귀 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex4)],
|
||||
['차 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex5)],
|
||||
[
|
||||
'전 력 전 승 률',
|
||||
'percent',
|
||||
(_g, r) => {
|
||||
const total = (r.ttw ?? 0) + (r.ttd ?? 0) + (r.ttl ?? 0);
|
||||
if (total < 50) {
|
||||
return 0;
|
||||
}
|
||||
return (r.ttw ?? 0) / Math.max(1, total);
|
||||
}],
|
||||
['통 솔 전 승 률', 'percent', (_g, r) => {
|
||||
},
|
||||
],
|
||||
[
|
||||
'통 솔 전 승 률',
|
||||
'percent',
|
||||
(_g, r) => {
|
||||
const total = (r.tlw ?? 0) + (r.tld ?? 0) + (r.tll ?? 0);
|
||||
if (total < 50) {
|
||||
return 0;
|
||||
}
|
||||
return (r.tlw ?? 0) / Math.max(1, total);
|
||||
}],
|
||||
['일 기 토 승 률', 'percent', (_g, r) => {
|
||||
},
|
||||
],
|
||||
[
|
||||
'일 기 토 승 률',
|
||||
'percent',
|
||||
(_g, r) => {
|
||||
const total = (r.tsw ?? 0) + (r.tsd ?? 0) + (r.tsl ?? 0);
|
||||
if (total < 50) {
|
||||
return 0;
|
||||
}
|
||||
return (r.tsw ?? 0) / Math.max(1, total);
|
||||
}],
|
||||
['설 전 승 률', 'percent', (_g, r) => {
|
||||
},
|
||||
],
|
||||
[
|
||||
'설 전 승 률',
|
||||
'percent',
|
||||
(_g, r) => {
|
||||
const total = (r.tiw ?? 0) + (r.tid ?? 0) + (r.til ?? 0);
|
||||
if (total < 50) {
|
||||
return 0;
|
||||
}
|
||||
return (r.tiw ?? 0) / Math.max(1, total);
|
||||
}],
|
||||
['베 팅 투 자 액', 'int', (_g, r) => r.betgold ?? 0],
|
||||
['베 팅 당 첨', 'int', (_g, r) => r.betwin ?? 0],
|
||||
['베 팅 수 익 금', 'int', (_g, r) => r.betwingold ?? 0],
|
||||
['베 팅 수 익 률', 'percent', (_g, r) => {
|
||||
},
|
||||
],
|
||||
['베 팅 투 자 액', 'int', (_g, r) => r.betgold ?? 0],
|
||||
['베 팅 당 첨', 'int', (_g, r) => r.betwin ?? 0],
|
||||
['베 팅 수 익 금', 'int', (_g, r) => r.betwingold ?? 0],
|
||||
[
|
||||
'베 팅 수 익 률',
|
||||
'percent',
|
||||
(_g, r) => {
|
||||
const betgold = r.betgold ?? 0;
|
||||
if (betgold < 1000) {
|
||||
return 0;
|
||||
}
|
||||
return (r.betwingold ?? 0) / Math.max(1, betgold);
|
||||
}],
|
||||
['유 산 소 모 량', 'int', (_g, r) => r.inherit_spent ?? 0],
|
||||
['유 산 획 득 량', 'int', (_g, r) => r.inherit_earned ?? 0],
|
||||
];
|
||||
},
|
||||
],
|
||||
['유 산 소 모 량', 'int', (_g, r) => r.inherit_spent ?? 0],
|
||||
['유 산 획 득 량', 'int', (_g, r) => r.inherit_earned ?? 0],
|
||||
];
|
||||
|
||||
const sections = types.map(([title, valueType, valueFn]) => {
|
||||
const entries = generals
|
||||
const sections = types.map(([title, valueType, valueFn]) => {
|
||||
const entries = generals
|
||||
.map((general) => {
|
||||
const ranks = rankMap.get(general.id) ?? {};
|
||||
const value = valueFn(general, ranks);
|
||||
const nation = nationMap.get(general.nationId) ?? null;
|
||||
const bgColor = nation?.color ?? (general.nationId === 0 ? NEUTRAL_BG_COLOR : DEFAULT_BG_COLOR);
|
||||
let display = {
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
ownerName: isUnited ? readOwnerDisplayName(general.meta) : null,
|
||||
nationName: nation?.name ?? '재야',
|
||||
bgColor,
|
||||
fgColor: resolveLegacyTextColor(bgColor),
|
||||
picture: general.picture ?? null,
|
||||
imageServer: general.imageServer ?? 0,
|
||||
value,
|
||||
printValue: valueType === 'percent' ? percentText(value) : formatLegacyRankingNumber(value),
|
||||
};
|
||||
|
||||
if (
|
||||
!isUnited &&
|
||||
(title === '계 략 성 공' || title === '유 산 소 모 량' || title === '유 산 획 득 량')
|
||||
) {
|
||||
display = {
|
||||
...display,
|
||||
name: '???',
|
||||
ownerName: null,
|
||||
nationName: '???',
|
||||
bgColor: DEFAULT_BG_COLOR,
|
||||
fgColor: resolveLegacyTextColor(DEFAULT_BG_COLOR),
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
};
|
||||
}
|
||||
return display;
|
||||
})
|
||||
.filter((entry) => entry.value > 0)
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.slice(0, 10);
|
||||
|
||||
return { title, valueType, entries };
|
||||
});
|
||||
|
||||
const uniqueItems = await loadUniqueItems();
|
||||
const itemRegistry = new Map(uniqueItems.map((item) => [item.key, item]));
|
||||
const uniqueConfig = resolveUniqueConfig(asRecord(asRecord(worldState?.config).const));
|
||||
if (Object.keys(uniqueConfig.allItems).length === 0) {
|
||||
uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry);
|
||||
}
|
||||
const activeAuctions = await ctx.db.auction.findMany({
|
||||
where: {
|
||||
type: 'UNIQUE_ITEM',
|
||||
status: { in: ['OPEN', 'FINALIZING'] },
|
||||
targetCode: { not: null },
|
||||
},
|
||||
select: { targetCode: true },
|
||||
});
|
||||
const auctionCounts = new Map<string, number>();
|
||||
for (const auction of activeAuctions) {
|
||||
if (auction.targetCode) {
|
||||
auctionCounts.set(auction.targetCode, (auctionCounts.get(auction.targetCode) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const slotTitles = {
|
||||
horse: '명 마',
|
||||
weapon: '명 검',
|
||||
book: '명 서',
|
||||
item: '도 구',
|
||||
} as const;
|
||||
const itemEntries = (['horse', 'weapon', 'book', 'item'] as const).map((slot) => {
|
||||
const configuredItems = Object.entries(uniqueConfig.allItems[slot] ?? {}).reverse();
|
||||
const entries = configuredItems.flatMap(([itemKey, rawCount]) => {
|
||||
const item = itemRegistry.get(itemKey);
|
||||
if (!item || item.buyable) {
|
||||
return [];
|
||||
}
|
||||
const owners = generals
|
||||
.filter((general) => {
|
||||
if (slot === 'horse') {
|
||||
return general.horseCode === itemKey;
|
||||
}
|
||||
if (slot === 'weapon') {
|
||||
return general.weaponCode === itemKey;
|
||||
}
|
||||
if (slot === 'book') {
|
||||
return general.bookCode === itemKey;
|
||||
}
|
||||
return general.itemCode === itemKey;
|
||||
})
|
||||
.map((general) => {
|
||||
const ranks = rankMap.get(general.id) ?? {};
|
||||
const value = valueFn(general, ranks);
|
||||
const nation = nationMap.get(general.nationId) ?? null;
|
||||
const bgColor =
|
||||
nation?.color ?? (general.nationId === 0 ? NEUTRAL_BG_COLOR : DEFAULT_BG_COLOR);
|
||||
let display = {
|
||||
const bgColor = nation?.color ?? (general.nationId === 0 ? NEUTRAL_BG_COLOR : DEFAULT_BG_COLOR);
|
||||
return {
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
ownerName: isUnited ? readOwnerDisplayName(general.meta) : null,
|
||||
nationName: nation?.name ?? '재야',
|
||||
bgColor,
|
||||
fgColor: resolveLegacyTextColor(bgColor),
|
||||
picture: general.picture ?? null,
|
||||
imageServer: general.imageServer ?? 0,
|
||||
value,
|
||||
printValue:
|
||||
valueType === 'percent' ? percentText(value) : formatLegacyRankingNumber(value),
|
||||
};
|
||||
|
||||
if (!isUnited && (title === '계 략 성 공' || title === '유 산 소 모 량' || title === '유 산 획 득 량')) {
|
||||
display = {
|
||||
...display,
|
||||
name: '???',
|
||||
ownerName: null,
|
||||
nationName: '???',
|
||||
bgColor: DEFAULT_BG_COLOR,
|
||||
fgColor: resolveLegacyTextColor(DEFAULT_BG_COLOR),
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
};
|
||||
}
|
||||
return display;
|
||||
})
|
||||
.filter((entry) => entry.value > 0)
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.slice(0, 10);
|
||||
|
||||
return { title, valueType, entries };
|
||||
});
|
||||
|
||||
const uniqueItems = await loadUniqueItems();
|
||||
const itemRegistry = new Map(uniqueItems.map((item) => [item.key, item]));
|
||||
const uniqueConfig = resolveUniqueConfig(asRecord(asRecord(worldState?.config).const));
|
||||
if (Object.keys(uniqueConfig.allItems).length === 0) {
|
||||
uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry);
|
||||
}
|
||||
const activeAuctions = await ctx.db.auction.findMany({
|
||||
where: {
|
||||
type: 'UNIQUE_ITEM',
|
||||
status: { in: ['OPEN', 'FINALIZING'] },
|
||||
targetCode: { not: null },
|
||||
},
|
||||
select: { targetCode: true },
|
||||
});
|
||||
const auctionCounts = new Map<string, number>();
|
||||
for (const auction of activeAuctions) {
|
||||
if (auction.targetCode) {
|
||||
auctionCounts.set(auction.targetCode, (auctionCounts.get(auction.targetCode) ?? 0) + 1);
|
||||
});
|
||||
for (let index = 0; index < (auctionCounts.get(itemKey) ?? 0); index += 1) {
|
||||
owners.push({
|
||||
id: 0,
|
||||
name: '경매중',
|
||||
nationName: '-',
|
||||
bgColor: '#00582c',
|
||||
fgColor: '#ffffff',
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
const slotTitles = {
|
||||
horse: '명 마',
|
||||
weapon: '명 검',
|
||||
book: '명 서',
|
||||
item: '도 구',
|
||||
} as const;
|
||||
const itemEntries = (['horse', 'weapon', 'book', 'item'] as const).map((slot) => {
|
||||
const configuredItems = Object.entries(uniqueConfig.allItems[slot] ?? {}).reverse();
|
||||
const entries = configuredItems.flatMap(([itemKey, rawCount]) => {
|
||||
const item = itemRegistry.get(itemKey);
|
||||
if (!item || item.buyable) {
|
||||
return [];
|
||||
}
|
||||
const owners = generals
|
||||
.filter((general) => {
|
||||
if (slot === 'horse') {
|
||||
return general.horseCode === itemKey;
|
||||
}
|
||||
if (slot === 'weapon') {
|
||||
return general.weaponCode === itemKey;
|
||||
}
|
||||
if (slot === 'book') {
|
||||
return general.bookCode === itemKey;
|
||||
}
|
||||
return general.itemCode === itemKey;
|
||||
})
|
||||
.map((general) => {
|
||||
const nation = nationMap.get(general.nationId) ?? null;
|
||||
const bgColor =
|
||||
nation?.color ?? (general.nationId === 0 ? NEUTRAL_BG_COLOR : DEFAULT_BG_COLOR);
|
||||
return {
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
nationName: nation?.name ?? '재야',
|
||||
bgColor,
|
||||
fgColor: resolveLegacyTextColor(bgColor),
|
||||
picture: general.picture ?? null,
|
||||
imageServer: general.imageServer ?? 0,
|
||||
};
|
||||
});
|
||||
for (let index = 0; index < (auctionCounts.get(itemKey) ?? 0); index += 1) {
|
||||
owners.push({
|
||||
id: 0,
|
||||
name: '경매중',
|
||||
nationName: '-',
|
||||
bgColor: '#00582c',
|
||||
fgColor: '#ffffff',
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
});
|
||||
}
|
||||
const count = Math.max(0, Math.floor(rawCount));
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
itemKey,
|
||||
itemName: item.name,
|
||||
itemInfo: item.info,
|
||||
owner: owners[index] ?? {
|
||||
id: 0,
|
||||
name: '미발견',
|
||||
nationName: '-',
|
||||
bgColor: DEFAULT_BG_COLOR,
|
||||
fgColor: resolveLegacyTextColor(DEFAULT_BG_COLOR),
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
},
|
||||
}));
|
||||
});
|
||||
return { title: slotTitles[slot], slot, entries };
|
||||
const count = Math.max(0, Math.floor(rawCount));
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
itemKey,
|
||||
itemName: item.name,
|
||||
itemInfo: item.info,
|
||||
owner: owners[index] ?? {
|
||||
id: 0,
|
||||
name: '미발견',
|
||||
nationName: '-',
|
||||
bgColor: DEFAULT_BG_COLOR,
|
||||
fgColor: resolveLegacyTextColor(DEFAULT_BG_COLOR),
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
return {
|
||||
isUnited,
|
||||
sections,
|
||||
uniqueItems: itemEntries,
|
||||
};
|
||||
}),
|
||||
getHallOfFameOptions: procedure.query(async ({ ctx }) => {
|
||||
const rows = await ctx.db.gameHistory.findMany({
|
||||
select: { season: true, scenario: true, scenarioName: true },
|
||||
orderBy: [{ season: 'desc' }, { scenario: 'asc' }],
|
||||
return { title: slotTitles[slot], slot, entries };
|
||||
});
|
||||
const seasonMap = new Map<number, { season: number; scenarios: Array<{ id: number; name: string; count: number }> }>();
|
||||
for (const row of rows) {
|
||||
const entry = seasonMap.get(row.season) ?? { season: row.season, scenarios: [] };
|
||||
const scenario = entry.scenarios.find((item) => item.id === row.scenario);
|
||||
if (scenario) {
|
||||
scenario.count += 1;
|
||||
} else {
|
||||
entry.scenarios.push({ id: row.scenario, name: row.scenarioName, count: 1 });
|
||||
}
|
||||
seasonMap.set(row.season, entry);
|
||||
}
|
||||
return Array.from(seasonMap.values());
|
||||
|
||||
return {
|
||||
isUnited,
|
||||
sections,
|
||||
uniqueItems: itemEntries,
|
||||
};
|
||||
}),
|
||||
getHallOfFame: accessInputProcedure(
|
||||
z.object({
|
||||
season: z.number().int(),
|
||||
scenario: z.number().int().optional(),
|
||||
})
|
||||
)
|
||||
getHallOfFameOptions: procedure
|
||||
.input(z.object({ source: z.enum(['current', 'legacy']).default('current') }).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
const baseWhere = {
|
||||
season: input.season,
|
||||
...(input.scenario !== undefined ? { scenario: input.scenario } : {}),
|
||||
};
|
||||
|
||||
const types: Array<{ key: HallOfFameType; title: string; type: 'int' | 'percent' }> = [
|
||||
{ key: 'experience', title: '명 성', type: 'int' },
|
||||
{ key: 'dedication', title: '계 급', type: 'int' },
|
||||
{ key: 'firenum', title: '계 략 성 공', type: 'int' },
|
||||
{ key: 'warnum', title: '전 투 횟 수', type: 'int' },
|
||||
{ key: 'killnum', title: '승 리', type: 'int' },
|
||||
{ key: 'winrate', title: '승 률', type: 'percent' },
|
||||
{ key: 'occupied', title: '점 령', type: 'int' },
|
||||
{ key: 'killcrew', title: '사 살', type: 'int' },
|
||||
{ key: 'killrate', title: '살 상 률', type: 'percent' },
|
||||
{ key: 'killcrew_person', title: '대 인 사 살', type: 'int' },
|
||||
{ key: 'killrate_person', title: '대 인 살 상 률', type: 'percent' },
|
||||
{ key: 'dex1', title: '보 병 숙 련 도', type: 'int' },
|
||||
{ key: 'dex2', title: '궁 병 숙 련 도', type: 'int' },
|
||||
{ key: 'dex3', title: '기 병 숙 련 도', type: 'int' },
|
||||
{ key: 'dex4', title: '귀 병 숙 련 도', type: 'int' },
|
||||
{ key: 'dex5', title: '차 병 숙 련 도', type: 'int' },
|
||||
{ key: 'ttrate', title: '전 력 전 승 률', type: 'percent' },
|
||||
{ key: 'tlrate', title: '통 솔 전 승 률', type: 'percent' },
|
||||
{ key: 'tsrate', title: '일 기 토 승 률', type: 'percent' },
|
||||
{ key: 'tirate', title: '설 전 승 률', type: 'percent' },
|
||||
{ key: 'betgold', title: '베 팅 투 자 액', type: 'int' },
|
||||
{ key: 'betwin', title: '베 팅 당 첨', type: 'int' },
|
||||
{ key: 'betwingold', title: '베 팅 수 익 금', type: 'int' },
|
||||
{ key: 'betrate', title: '베 팅 수 익 률', type: 'percent' },
|
||||
];
|
||||
const allowedTypes = new Set(HALL_OF_FAME_TYPES);
|
||||
|
||||
const sections = await Promise.all(
|
||||
types.map(async (type) => {
|
||||
if (!allowedTypes.has(type.key)) {
|
||||
return { title: type.title, valueType: type.type, entries: [] };
|
||||
if ((input?.source ?? 'current') === 'legacy') {
|
||||
const rows = await findLegacyHallOptions(ctx.db);
|
||||
const optionMap = new Map<
|
||||
string,
|
||||
{
|
||||
sourceProfile: (typeof LEGACY_ARCHIVE_PROFILES)[number];
|
||||
season: number;
|
||||
scenarios: Array<{ id: number; name: string; count: number }>;
|
||||
}
|
||||
const rows = await ctx.db.hallOfFame.findMany({
|
||||
where: { ...baseWhere, type: type.key },
|
||||
orderBy: { value: 'desc' },
|
||||
take: 10,
|
||||
});
|
||||
const entries = rows.map((row) => {
|
||||
const aux = asRecord(row.aux);
|
||||
return {
|
||||
generalId: row.generalNo,
|
||||
name: String(aux.name ?? ''),
|
||||
ownerName:
|
||||
typeof aux.ownerDisplayName === 'string' && aux.ownerDisplayName.length > 0
|
||||
? aux.ownerDisplayName
|
||||
: null,
|
||||
nationName: String(aux.nationName ?? ''),
|
||||
bgColor: String(aux.bgColor ?? DEFAULT_BG_COLOR),
|
||||
fgColor: String(aux.fgColor ?? DEFAULT_FG_COLOR),
|
||||
picture: typeof aux.picture === 'string' ? aux.picture : null,
|
||||
imageServer: readMetaNumber(aux.imgsvr),
|
||||
value: row.value,
|
||||
printValue:
|
||||
type.type === 'percent'
|
||||
? percentText(row.value)
|
||||
: formatLegacyRankingNumber(row.value),
|
||||
serverName: String(aux.serverName ?? ''),
|
||||
serverIdx: readMetaNumber(aux.serverIdx),
|
||||
scenarioName: String(aux.scenarioName ?? ''),
|
||||
startTime: typeof aux.startTime === 'string' ? aux.startTime : null,
|
||||
unitedTime: typeof aux.unitedTime === 'string' ? aux.unitedTime : null,
|
||||
};
|
||||
});
|
||||
return { title: type.title, valueType: type.type, entries };
|
||||
})
|
||||
);
|
||||
|
||||
return { sections };
|
||||
>();
|
||||
for (const row of rows) {
|
||||
const key = `${row.sourceProfile}:${row.season}`;
|
||||
const entry = optionMap.get(key) ?? {
|
||||
sourceProfile: row.sourceProfile,
|
||||
season: row.season,
|
||||
scenarios: [],
|
||||
};
|
||||
entry.scenarios.push({ id: row.scenario, name: row.scenarioName, count: Number(row.count) });
|
||||
optionMap.set(key, entry);
|
||||
}
|
||||
return Array.from(optionMap.values());
|
||||
}
|
||||
const rows = await ctx.db.gameHistory.findMany({
|
||||
select: { season: true, scenario: true, scenarioName: true },
|
||||
orderBy: [{ season: 'desc' }, { scenario: 'asc' }],
|
||||
});
|
||||
const seasonMap = new Map<
|
||||
number,
|
||||
{ sourceProfile: string; season: number; scenarios: Array<{ id: number; name: string; count: number }> }
|
||||
>();
|
||||
for (const row of rows) {
|
||||
const entry = seasonMap.get(row.season) ?? {
|
||||
sourceProfile: ctx.profile.id,
|
||||
season: row.season,
|
||||
scenarios: [],
|
||||
};
|
||||
const scenario = entry.scenarios.find((item) => item.id === row.scenario);
|
||||
if (scenario) {
|
||||
scenario.count += 1;
|
||||
} else {
|
||||
entry.scenarios.push({ id: row.scenario, name: row.scenarioName, count: 1 });
|
||||
}
|
||||
seasonMap.set(row.season, entry);
|
||||
}
|
||||
return Array.from(seasonMap.values());
|
||||
}),
|
||||
getHallOfFame: accessInputProcedure(
|
||||
z.object({
|
||||
source: z.enum(['current', 'legacy']).default('current'),
|
||||
sourceProfile: z.enum(LEGACY_ARCHIVE_PROFILES).optional(),
|
||||
season: z.number().int(),
|
||||
scenario: z.number().int().optional(),
|
||||
})
|
||||
).query(async ({ ctx, input }) => {
|
||||
const baseWhere = {
|
||||
season: input.season,
|
||||
...(input.scenario !== undefined ? { scenario: input.scenario } : {}),
|
||||
};
|
||||
|
||||
const types: Array<{ key: HallOfFameType; title: string; type: 'int' | 'percent' }> = [
|
||||
{ key: 'experience', title: '명 성', type: 'int' },
|
||||
{ key: 'dedication', title: '계 급', type: 'int' },
|
||||
{ key: 'firenum', title: '계 략 성 공', type: 'int' },
|
||||
{ key: 'warnum', title: '전 투 횟 수', type: 'int' },
|
||||
{ key: 'killnum', title: '승 리', type: 'int' },
|
||||
{ key: 'winrate', title: '승 률', type: 'percent' },
|
||||
{ key: 'occupied', title: '점 령', type: 'int' },
|
||||
{ key: 'killcrew', title: '사 살', type: 'int' },
|
||||
{ key: 'killrate', title: '살 상 률', type: 'percent' },
|
||||
{ key: 'killcrew_person', title: '대 인 사 살', type: 'int' },
|
||||
{ key: 'killrate_person', title: '대 인 살 상 률', type: 'percent' },
|
||||
{ key: 'dex1', title: '보 병 숙 련 도', type: 'int' },
|
||||
{ key: 'dex2', title: '궁 병 숙 련 도', type: 'int' },
|
||||
{ key: 'dex3', title: '기 병 숙 련 도', type: 'int' },
|
||||
{ key: 'dex4', title: '귀 병 숙 련 도', type: 'int' },
|
||||
{ key: 'dex5', title: '차 병 숙 련 도', type: 'int' },
|
||||
{ key: 'ttrate', title: '전 력 전 승 률', type: 'percent' },
|
||||
{ key: 'tlrate', title: '통 솔 전 승 률', type: 'percent' },
|
||||
{ key: 'tsrate', title: '일 기 토 승 률', type: 'percent' },
|
||||
{ key: 'tirate', title: '설 전 승 률', type: 'percent' },
|
||||
{ key: 'betgold', title: '베 팅 투 자 액', type: 'int' },
|
||||
{ key: 'betwin', title: '베 팅 당 첨', type: 'int' },
|
||||
{ key: 'betwingold', title: '베 팅 수 익 금', type: 'int' },
|
||||
{ key: 'betrate', title: '베 팅 수 익 률', type: 'percent' },
|
||||
];
|
||||
const allowedTypes = new Set(HALL_OF_FAME_TYPES);
|
||||
|
||||
const sections = await Promise.all(
|
||||
types.map(async (type) => {
|
||||
if (!allowedTypes.has(type.key)) {
|
||||
return { title: type.title, valueType: type.type, entries: [] };
|
||||
}
|
||||
const rows =
|
||||
input.source === 'legacy'
|
||||
? input.sourceProfile
|
||||
? await findLegacyHallRows(ctx.db, {
|
||||
sourceProfile: input.sourceProfile,
|
||||
season: input.season,
|
||||
...(input.scenario === undefined ? {} : { scenario: input.scenario }),
|
||||
type: type.key,
|
||||
take: 10,
|
||||
})
|
||||
: []
|
||||
: await ctx.db.hallOfFame.findMany({
|
||||
where: { ...baseWhere, type: type.key },
|
||||
orderBy: { value: 'desc' },
|
||||
take: 10,
|
||||
});
|
||||
const entries = rows.map((row) => {
|
||||
const aux = asRecord(row.aux);
|
||||
return {
|
||||
generalId: row.generalNo,
|
||||
name: String(aux.name ?? ''),
|
||||
ownerName:
|
||||
typeof aux.ownerDisplayName === 'string' && aux.ownerDisplayName.length > 0
|
||||
? aux.ownerDisplayName
|
||||
: null,
|
||||
nationName: String(aux.nationName ?? ''),
|
||||
bgColor: String(aux.bgColor ?? DEFAULT_BG_COLOR),
|
||||
fgColor: String(aux.fgColor ?? DEFAULT_FG_COLOR),
|
||||
picture: typeof aux.picture === 'string' ? aux.picture : null,
|
||||
imageServer: readMetaNumber(aux.imgsvr),
|
||||
value: row.value,
|
||||
printValue:
|
||||
type.type === 'percent' ? percentText(row.value) : formatLegacyRankingNumber(row.value),
|
||||
serverName: String(aux.serverName ?? ''),
|
||||
serverIdx: readMetaNumber(aux.serverIdx),
|
||||
scenarioName: String(aux.scenarioName ?? ''),
|
||||
startTime: typeof aux.startTime === 'string' ? aux.startTime : null,
|
||||
unitedTime: typeof aux.unitedTime === 'string' ? aux.unitedTime : null,
|
||||
};
|
||||
});
|
||||
return { title: type.title, valueType: type.type, entries };
|
||||
})
|
||||
);
|
||||
|
||||
return { source: input.source, sourceProfile: input.sourceProfile ?? ctx.profile.id, sections };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
import { isLegacyArchiveProfile, LEGACY_ARCHIVE_PROFILES, type LegacyArchiveProfile } from '@sammo-ts/common';
|
||||
|
||||
import type { DatabaseClient } from '../context.js';
|
||||
|
||||
export { isLegacyArchiveProfile, LEGACY_ARCHIVE_PROFILES, type LegacyArchiveProfile };
|
||||
|
||||
export type LegacyArchiveDatabase = Pick<DatabaseClient, '$queryRaw'>;
|
||||
|
||||
export interface LegacyGameHistoryRow {
|
||||
sourceProfile: LegacyArchiveProfile;
|
||||
serverId: string;
|
||||
legacyId: number;
|
||||
openedAt: Date;
|
||||
completedAt: Date | null;
|
||||
legacyDate: Date;
|
||||
winnerNation: number | null;
|
||||
map: string | null;
|
||||
season: number;
|
||||
scenario: number;
|
||||
scenarioName: string;
|
||||
rawEnv: unknown;
|
||||
}
|
||||
|
||||
export interface LegacyGeneralRow {
|
||||
sourceProfile: LegacyArchiveProfile;
|
||||
serverId: string;
|
||||
generalNo: number;
|
||||
legacyId: number;
|
||||
owner: string | null;
|
||||
name: string;
|
||||
lastYearMonth: number;
|
||||
turnTime: Date;
|
||||
schemaVersion: number;
|
||||
sourceFormat: string;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
export interface LegacyNationRow {
|
||||
sourceProfile: LegacyArchiveProfile;
|
||||
legacyId: number;
|
||||
serverId: string;
|
||||
nation: number;
|
||||
data: unknown;
|
||||
archivedAt: Date;
|
||||
}
|
||||
|
||||
export interface LegacyEmperorRow {
|
||||
id: bigint;
|
||||
sourceProfile: LegacyArchiveProfile;
|
||||
legacyId: number;
|
||||
serverId: string | null;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
export interface LegacyHallOptionRow {
|
||||
sourceProfile: LegacyArchiveProfile;
|
||||
season: number;
|
||||
scenario: number;
|
||||
scenarioName: string;
|
||||
count: bigint;
|
||||
}
|
||||
|
||||
export interface LegacyHallRow {
|
||||
sourceProfile: LegacyArchiveProfile;
|
||||
serverId: string;
|
||||
generalNo: number;
|
||||
type: string;
|
||||
value: number;
|
||||
owner: string | null;
|
||||
aux: unknown;
|
||||
}
|
||||
|
||||
export const findLegacyGeneralsByOwner = async (
|
||||
db: LegacyArchiveDatabase,
|
||||
owner: string
|
||||
): Promise<LegacyGeneralRow[]> =>
|
||||
db.$queryRaw<LegacyGeneralRow[]>(GamePrisma.sql`
|
||||
SELECT
|
||||
"source_profile" AS "sourceProfile",
|
||||
"server_id" AS "serverId",
|
||||
"general_no" AS "generalNo",
|
||||
"legacy_id" AS "legacyId",
|
||||
"owner",
|
||||
"name",
|
||||
"last_yearmonth" AS "lastYearMonth",
|
||||
"turntime" AS "turnTime",
|
||||
"schema_version" AS "schemaVersion",
|
||||
"source_format" AS "sourceFormat",
|
||||
"data"
|
||||
FROM "legacy_archive"."general"
|
||||
WHERE "owner" = ${owner}
|
||||
ORDER BY "last_yearmonth" DESC, "source_profile", "server_id" DESC, "general_no"
|
||||
`);
|
||||
|
||||
export const findLegacyGeneral = async (
|
||||
db: LegacyArchiveDatabase,
|
||||
input: { owner: string; sourceProfile: LegacyArchiveProfile; serverId: string; generalNo: number }
|
||||
): Promise<LegacyGeneralRow | null> => {
|
||||
const rows = await db.$queryRaw<LegacyGeneralRow[]>(GamePrisma.sql`
|
||||
SELECT
|
||||
"source_profile" AS "sourceProfile",
|
||||
"server_id" AS "serverId",
|
||||
"general_no" AS "generalNo",
|
||||
"legacy_id" AS "legacyId",
|
||||
"owner",
|
||||
"name",
|
||||
"last_yearmonth" AS "lastYearMonth",
|
||||
"turntime" AS "turnTime",
|
||||
"schema_version" AS "schemaVersion",
|
||||
"source_format" AS "sourceFormat",
|
||||
"data"
|
||||
FROM "legacy_archive"."general"
|
||||
WHERE "owner" = ${input.owner}
|
||||
AND "source_profile" = ${input.sourceProfile}
|
||||
AND "server_id" = ${input.serverId}
|
||||
AND "general_no" = ${input.generalNo}
|
||||
LIMIT 1
|
||||
`);
|
||||
return rows[0] ?? null;
|
||||
};
|
||||
|
||||
export const findLegacyGeneralsForServer = async (
|
||||
db: LegacyArchiveDatabase,
|
||||
input: { sourceProfile: LegacyArchiveProfile; serverId: string; generalNos: number[] }
|
||||
): Promise<Array<Pick<LegacyGeneralRow, 'generalNo' | 'name' | 'lastYearMonth'>>> => {
|
||||
if (input.generalNos.length === 0) return [];
|
||||
return db.$queryRaw<Array<Pick<LegacyGeneralRow, 'generalNo' | 'name' | 'lastYearMonth'>>>(GamePrisma.sql`
|
||||
SELECT
|
||||
"general_no" AS "generalNo",
|
||||
"name",
|
||||
"last_yearmonth" AS "lastYearMonth"
|
||||
FROM "legacy_archive"."general"
|
||||
WHERE "source_profile" = ${input.sourceProfile}
|
||||
AND "server_id" = ${input.serverId}
|
||||
AND "general_no" IN (${GamePrisma.join(input.generalNos)})
|
||||
`);
|
||||
};
|
||||
|
||||
export const findLegacyGames = async (
|
||||
db: LegacyArchiveDatabase,
|
||||
keys?: Array<{ sourceProfile: LegacyArchiveProfile; serverId: string }>
|
||||
): Promise<LegacyGameHistoryRow[]> => {
|
||||
if (keys && keys.length === 0) return [];
|
||||
const condition = keys
|
||||
? GamePrisma.sql`WHERE ("source_profile", "server_id") IN (${GamePrisma.join(
|
||||
keys.map(({ sourceProfile, serverId }) => GamePrisma.sql`(${sourceProfile}, ${serverId})`)
|
||||
)})`
|
||||
: GamePrisma.empty;
|
||||
return db.$queryRaw<LegacyGameHistoryRow[]>(GamePrisma.sql`
|
||||
SELECT
|
||||
"source_profile" AS "sourceProfile",
|
||||
"server_id" AS "serverId",
|
||||
"legacy_id" AS "legacyId",
|
||||
"opened_at" AS "openedAt",
|
||||
"completed_at" AS "completedAt",
|
||||
"legacy_date" AS "legacyDate",
|
||||
"winner_nation" AS "winnerNation",
|
||||
"map",
|
||||
"season",
|
||||
"scenario",
|
||||
"scenario_name" AS "scenarioName",
|
||||
"raw_env" AS "rawEnv"
|
||||
FROM "legacy_archive"."game_history"
|
||||
${condition}
|
||||
`);
|
||||
};
|
||||
|
||||
export const findLegacyNations = async (
|
||||
db: LegacyArchiveDatabase,
|
||||
keys: Array<{ sourceProfile: LegacyArchiveProfile; serverId: string }>
|
||||
): Promise<LegacyNationRow[]> => {
|
||||
if (keys.length === 0) return [];
|
||||
return db.$queryRaw<LegacyNationRow[]>(GamePrisma.sql`
|
||||
SELECT
|
||||
"source_profile" AS "sourceProfile",
|
||||
"legacy_id" AS "legacyId",
|
||||
"server_id" AS "serverId",
|
||||
"nation",
|
||||
"data",
|
||||
"archived_at" AS "archivedAt"
|
||||
FROM "legacy_archive"."nation"
|
||||
WHERE ("source_profile", "server_id") IN (${GamePrisma.join(
|
||||
keys.map(({ sourceProfile, serverId }) => GamePrisma.sql`(${sourceProfile}, ${serverId})`)
|
||||
)})
|
||||
ORDER BY "archived_at" DESC, "legacy_id" DESC
|
||||
`);
|
||||
};
|
||||
|
||||
export const findLegacyEmperors = async (
|
||||
db: LegacyArchiveDatabase,
|
||||
keys?: Array<{ sourceProfile: LegacyArchiveProfile; serverId: string }>
|
||||
): Promise<LegacyEmperorRow[]> => {
|
||||
if (keys && keys.length === 0) return [];
|
||||
const condition = keys
|
||||
? GamePrisma.sql`WHERE ("source_profile", "server_id") IN (${GamePrisma.join(
|
||||
keys.map(({ sourceProfile, serverId }) => GamePrisma.sql`(${sourceProfile}, ${serverId})`)
|
||||
)})`
|
||||
: GamePrisma.empty;
|
||||
return db.$queryRaw<LegacyEmperorRow[]>(GamePrisma.sql`
|
||||
SELECT
|
||||
"id",
|
||||
"source_profile" AS "sourceProfile",
|
||||
"legacy_id" AS "legacyId",
|
||||
"server_id" AS "serverId",
|
||||
"data"
|
||||
FROM "legacy_archive"."emperor"
|
||||
${condition}
|
||||
ORDER BY "id" DESC
|
||||
`);
|
||||
};
|
||||
|
||||
export const findLegacyEmperor = async (db: LegacyArchiveDatabase, id: number): Promise<LegacyEmperorRow | null> => {
|
||||
const rows = await db.$queryRaw<LegacyEmperorRow[]>(GamePrisma.sql`
|
||||
SELECT
|
||||
"id",
|
||||
"source_profile" AS "sourceProfile",
|
||||
"legacy_id" AS "legacyId",
|
||||
"server_id" AS "serverId",
|
||||
"data"
|
||||
FROM "legacy_archive"."emperor"
|
||||
WHERE "id" = ${id}
|
||||
LIMIT 1
|
||||
`);
|
||||
return rows[0] ?? null;
|
||||
};
|
||||
|
||||
export const findLegacyHallOptions = async (db: LegacyArchiveDatabase): Promise<LegacyHallOptionRow[]> =>
|
||||
db.$queryRaw<LegacyHallOptionRow[]>(GamePrisma.sql`
|
||||
SELECT
|
||||
"source_profile" AS "sourceProfile",
|
||||
"season",
|
||||
"scenario",
|
||||
MAX("scenario_name") AS "scenarioName",
|
||||
COUNT(*)::bigint AS "count"
|
||||
FROM "legacy_archive"."game_history"
|
||||
GROUP BY "source_profile", "season", "scenario"
|
||||
ORDER BY "season" DESC, "source_profile", "scenario"
|
||||
`);
|
||||
|
||||
export const findLegacyHallRows = async (
|
||||
db: LegacyArchiveDatabase,
|
||||
input: { sourceProfile: LegacyArchiveProfile; season: number; scenario?: number; type: string; take: number }
|
||||
): Promise<LegacyHallRow[]> => {
|
||||
const scenarioCondition =
|
||||
input.scenario === undefined ? GamePrisma.empty : GamePrisma.sql`AND "scenario" = ${input.scenario}`;
|
||||
return db.$queryRaw<LegacyHallRow[]>(GamePrisma.sql`
|
||||
SELECT
|
||||
"source_profile" AS "sourceProfile",
|
||||
"server_id" AS "serverId",
|
||||
"general_no" AS "generalNo",
|
||||
"type",
|
||||
"value",
|
||||
"owner",
|
||||
"aux"
|
||||
FROM "legacy_archive"."hall"
|
||||
WHERE "source_profile" = ${input.sourceProfile}
|
||||
AND "season" = ${input.season}
|
||||
${scenarioCondition}
|
||||
AND "type" = ${input.type}
|
||||
ORDER BY "value" DESC
|
||||
LIMIT ${input.take}
|
||||
`);
|
||||
};
|
||||
@@ -25,8 +25,132 @@ const auth: GameSessionTokenPayload = {
|
||||
sanctions: {},
|
||||
};
|
||||
|
||||
const context = (session: GameSessionTokenPayload | null): GameApiContext => {
|
||||
const context = (session: GameSessionTokenPayload | null, includeLegacy = false): GameApiContext => {
|
||||
const db = {
|
||||
$queryRaw: async (query: { strings?: readonly string[] }) => {
|
||||
if (!includeLegacy) return [];
|
||||
const sql = query.strings?.join(' ') ?? '';
|
||||
if (sql.includes('legacy_archive"."general')) {
|
||||
return [
|
||||
{
|
||||
sourceProfile: 'hwe',
|
||||
serverId: 'hwe_archive_1',
|
||||
generalNo: 21,
|
||||
legacyId: 21,
|
||||
owner: 'user-1',
|
||||
name: '이전서버장수',
|
||||
lastYearMonth: 21012,
|
||||
turnTime: new Date('2020-01-02T00:00:00.000Z'),
|
||||
schemaVersion: 1,
|
||||
sourceFormat: 'legacy-flat-v0',
|
||||
data: {
|
||||
schemaVersion: 1,
|
||||
identity: {
|
||||
name: '이전서버장수',
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
npcState: 0,
|
||||
nationId: 2,
|
||||
cityId: 1,
|
||||
officerLevel: 7,
|
||||
officerCity: 1,
|
||||
},
|
||||
stats: {
|
||||
leadership: 91,
|
||||
strength: 81,
|
||||
intelligence: 71,
|
||||
leadershipExperience: 3,
|
||||
strengthExperience: 4,
|
||||
intelligenceExperience: 5,
|
||||
},
|
||||
progression: {
|
||||
experience: 900,
|
||||
experienceLevel: 3,
|
||||
dedication: 800,
|
||||
dedicationLevel: 2,
|
||||
age: 30,
|
||||
startAge: 20,
|
||||
bornYear: 180,
|
||||
deadYear: 250,
|
||||
},
|
||||
traits: { personality: null, specialDomestic: null, specialWar: null },
|
||||
resources: {
|
||||
gold: 100,
|
||||
rice: 200,
|
||||
crew: 300,
|
||||
crewType: '1',
|
||||
train: 90,
|
||||
morale: 80,
|
||||
injury: 0,
|
||||
},
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
mastery: { infantry: 1000, archery: 2000, cavalry: 3000, special: 4000, siege: 5000 },
|
||||
battle: {
|
||||
battles: 10,
|
||||
wins: 6,
|
||||
losses: 4,
|
||||
fireSuccesses: 2,
|
||||
kills: 6,
|
||||
deaths: 4,
|
||||
killedCrew: 1000,
|
||||
lostCrew: 500,
|
||||
winRate: 60,
|
||||
killRate: 200,
|
||||
recentWar: null,
|
||||
tactics: {
|
||||
total: { wins: null, draws: null, losses: null },
|
||||
leadership: { wins: null, draws: null, losses: null },
|
||||
intelligence: { wins: null, draws: null, losses: null },
|
||||
},
|
||||
},
|
||||
history: ['이전 서버 열전'],
|
||||
availability: {
|
||||
mastery: true,
|
||||
battleAggregates: true,
|
||||
tactics: false,
|
||||
history: true,
|
||||
battleDetailLogs: false,
|
||||
battleResultLogs: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
if (sql.includes('legacy_archive"."game_history')) {
|
||||
return [
|
||||
{
|
||||
sourceProfile: 'hwe',
|
||||
serverId: 'hwe_archive_1',
|
||||
legacyId: 1,
|
||||
openedAt: new Date('2019-09-21T00:00:00.000Z'),
|
||||
completedAt: null,
|
||||
legacyDate: new Date('2019-09-21T00:00:00.000Z'),
|
||||
winnerNation: 2,
|
||||
map: 'legacy',
|
||||
season: 1,
|
||||
scenario: 7,
|
||||
scenarioName: '이전 시나리오',
|
||||
rawEnv: {},
|
||||
},
|
||||
];
|
||||
}
|
||||
if (sql.includes('legacy_archive"."nation')) {
|
||||
return [
|
||||
{
|
||||
sourceProfile: 'hwe',
|
||||
legacyId: 1,
|
||||
serverId: 'hwe_archive_1',
|
||||
nation: 2,
|
||||
data: { name: '이전국', color: '#0000ff', level: 7 },
|
||||
archivedAt: new Date('2020-01-02T00:00:00.000Z'),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (sql.includes('legacy_archive"."emperor')) {
|
||||
return [{ id: 99n, sourceProfile: 'hwe', legacyId: 1, serverId: 'hwe_archive_1', data: {} }];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
oldGeneral: {
|
||||
findMany: async ({ where }: { where: { owner: string } }) =>
|
||||
where.owner === 'user-1'
|
||||
@@ -82,6 +206,11 @@ const context = (session: GameSessionTokenPayload | null): GameApiContext => {
|
||||
lastYearMonth: 22012,
|
||||
turnTime: new Date('2025-01-01T00:00:00.000Z'),
|
||||
data: {
|
||||
nation: 3,
|
||||
leader: 80,
|
||||
power: 70,
|
||||
intel: 60,
|
||||
officer_level: 8,
|
||||
history: '<C>●</>첫 기록<br><Y>●</>둘째 기록<br>',
|
||||
},
|
||||
}
|
||||
@@ -116,6 +245,7 @@ const context = (session: GameSessionTokenPayload | null): GameApiContext => {
|
||||
},
|
||||
emperor: {
|
||||
findMany: async () => [{ id: 7, serverId: 'che_legacy_1' }],
|
||||
findFirst: async () => ({ id: 7, serverId: 'che_legacy_1' }),
|
||||
},
|
||||
};
|
||||
const redis = {
|
||||
@@ -146,6 +276,8 @@ describe('archive.myPastPlays', () => {
|
||||
const result = await appRouter.createCaller(context(auth)).archive.myPastPlays();
|
||||
expect(result.seasons).toEqual([
|
||||
expect.objectContaining({
|
||||
source: 'current',
|
||||
sourceProfile: 'che',
|
||||
serverId: 'che_legacy_1',
|
||||
scenarioName: '테스트',
|
||||
dynastyId: 7,
|
||||
@@ -185,12 +317,27 @@ describe('archive.myPastPlays', () => {
|
||||
});
|
||||
|
||||
const result = await appRouter.createCaller(context(auth)).archive.myPastPlayDetail(input);
|
||||
expect(result).toEqual({
|
||||
expect(result).toMatchObject({
|
||||
source: 'current',
|
||||
sourceProfile: 'che',
|
||||
serverId: 'che_legacy_1',
|
||||
generalNo: 10,
|
||||
name: '과거장수',
|
||||
lastYearMonth: 22012,
|
||||
history: ['<C>●</>첫 기록', '<Y>●</>둘째 기록'],
|
||||
dynastyPath: '/dynasty/7',
|
||||
nation: { id: 3, name: '촉', color: '#ff0000' },
|
||||
general: expect.objectContaining({ id: 10, name: '과거장수' }),
|
||||
battle: expect.objectContaining({ available: false }),
|
||||
logs: {
|
||||
generalHistory: {
|
||||
available: true,
|
||||
entries: [
|
||||
{ id: 1, text: '<C>●</>첫 기록' },
|
||||
{ id: 2, text: '<Y>●</>둘째 기록' },
|
||||
],
|
||||
},
|
||||
battleDetail: { available: false, entries: [] },
|
||||
battleResult: { available: false, entries: [] },
|
||||
generalAction: { available: false, entries: [] },
|
||||
},
|
||||
});
|
||||
|
||||
const otherUser = {
|
||||
@@ -202,4 +349,44 @@ describe('archive.myPastPlays', () => {
|
||||
code: 'NOT_FOUND',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns normalized previous-server detail from the dedicated archive without exposing raw data', async () => {
|
||||
const caller = appRouter.createCaller(context(auth, true));
|
||||
const list = await caller.archive.myPastPlays();
|
||||
expect(list.seasons).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'legacy',
|
||||
sourceProfile: 'hwe',
|
||||
serverId: 'hwe_archive_1',
|
||||
openedAt: '2019-09-21T00:00:00.000Z',
|
||||
dynastyId: 99,
|
||||
generals: [expect.objectContaining({ name: '이전서버장수', nationName: '이전국' })],
|
||||
})
|
||||
);
|
||||
|
||||
const detail = await caller.archive.myPastPlayDetail({
|
||||
source: 'legacy',
|
||||
sourceProfile: 'hwe',
|
||||
serverId: 'hwe_archive_1',
|
||||
generalNo: 21,
|
||||
});
|
||||
expect(detail).toMatchObject({
|
||||
source: 'legacy',
|
||||
sourceProfile: 'hwe',
|
||||
dynastyPath: '/dynasty/99?source=legacy',
|
||||
nation: { id: 2, name: '이전국', color: '#0000ff' },
|
||||
general: expect.objectContaining({
|
||||
id: 21,
|
||||
name: '이전서버장수',
|
||||
stats: { leadership: 91, strength: 81, intelligence: 71 },
|
||||
progression: expect.objectContaining({ dex: [1000, 2000, 3000, 4000, 5000] }),
|
||||
}),
|
||||
battle: expect.objectContaining({ available: true, warnum: 10, wins: 6, winRate: 60 }),
|
||||
logs: expect.objectContaining({
|
||||
generalHistory: { available: true, entries: [{ id: 1, text: '이전 서버 열전' }] },
|
||||
battleDetail: { available: false, entries: [] },
|
||||
}),
|
||||
});
|
||||
expect(JSON.stringify(detail)).not.toContain('raw_data');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,6 +123,72 @@ const buildContext = (
|
||||
oldNations: Array<Record<string, unknown>> = [oldNation, deletedOldNation]
|
||||
): GameApiContext => {
|
||||
const db = {
|
||||
$queryRaw: async (query: { strings?: readonly string[] }) => {
|
||||
const sql = query.strings?.join(' ') ?? '';
|
||||
if (sql.includes('legacy_archive"."emperor')) {
|
||||
return [
|
||||
{
|
||||
id: 101n,
|
||||
sourceProfile: 'hwe',
|
||||
legacyId: 7,
|
||||
serverId: emperor.serverId,
|
||||
data: {
|
||||
phase: '이전 훼2기',
|
||||
nation_count: emperor.nationCount,
|
||||
nation_name: emperor.nationName,
|
||||
nation_hist: emperor.nationHist,
|
||||
gen_count: emperor.genCount,
|
||||
personal_hist: emperor.personalHist,
|
||||
special_hist: emperor.specialHist,
|
||||
name: emperor.name,
|
||||
type: emperor.type,
|
||||
color: emperor.color,
|
||||
year: emperor.year,
|
||||
month: emperor.month,
|
||||
power: emperor.power,
|
||||
gennum: emperor.gennum,
|
||||
citynum: emperor.citynum,
|
||||
pop: emperor.pop,
|
||||
poprate: emperor.poprate,
|
||||
gold: emperor.gold,
|
||||
rice: emperor.rice,
|
||||
l12name: emperor.l12name,
|
||||
l11name: emperor.l11name,
|
||||
l10name: emperor.l10name,
|
||||
l9name: emperor.l9name,
|
||||
l8name: emperor.l8name,
|
||||
l7name: emperor.l7name,
|
||||
l6name: emperor.l6name,
|
||||
l5name: emperor.l5name,
|
||||
tiger: emperor.tiger,
|
||||
eagle: emperor.eagle,
|
||||
gen: emperor.gen,
|
||||
history: emperor.history,
|
||||
aux: emperor.aux,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
if (sql.includes('legacy_archive"."nation')) {
|
||||
return [
|
||||
{
|
||||
sourceProfile: 'hwe',
|
||||
legacyId: oldNation.id,
|
||||
serverId: oldNation.serverId,
|
||||
nation: oldNation.nation,
|
||||
data: oldNation.data,
|
||||
archivedAt: oldNation.date,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (sql.includes('legacy_archive"."general')) {
|
||||
return [
|
||||
{ generalNo: 11, name: '유비', lastYearMonth: 21504 },
|
||||
{ generalNo: 12, name: '제갈량', lastYearMonth: 21504 },
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
worldState: {
|
||||
findFirst: async () => ({ currentYear: 220, currentMonth: 1 }),
|
||||
},
|
||||
@@ -186,6 +252,39 @@ describe('dynasty public read model', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads previous-server dynasties only when the archive source is selected', async () => {
|
||||
const caller = appRouter.createCaller(buildContext(null));
|
||||
const list = await caller.dynasty.getList({ source: 'legacy' });
|
||||
expect(list).toMatchObject({
|
||||
source: 'legacy',
|
||||
current: null,
|
||||
entries: [
|
||||
expect.objectContaining({
|
||||
id: 101,
|
||||
source: 'legacy',
|
||||
sourceProfile: 'hwe',
|
||||
phase: '이전 훼2기',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const detail = await caller.dynasty.getDetail({ emperorId: 101, source: 'legacy' });
|
||||
expect(detail).toMatchObject({
|
||||
source: 'legacy',
|
||||
sourceProfile: 'hwe',
|
||||
emperor: expect.objectContaining({ id: 101, phase: '이전 훼2기', name: '촉' }),
|
||||
nations: [
|
||||
expect.objectContaining({
|
||||
name: '촉',
|
||||
generalsFull: [
|
||||
{ generalNo: 11, name: '유비', lastYearMonth: 21504 },
|
||||
{ generalNo: 12, name: '제갈량', lastYearMonth: 21504 },
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes the same public DTO to anonymous, general owners and admins', async () => {
|
||||
const anonymous = appRouter.createCaller(buildContext(null));
|
||||
const owner = appRouter.createCaller(buildContext(authFor('owner-a')));
|
||||
|
||||
@@ -110,6 +110,42 @@ const buildContext = (options?: {
|
||||
}): GameApiContext => {
|
||||
const selectedGeneralRows = options?.generals ?? generalRows;
|
||||
const db = {
|
||||
$queryRaw: async (query: { strings?: readonly string[]; values?: unknown[] }) => {
|
||||
const sql = query.strings?.join(' ') ?? '';
|
||||
if (sql.includes('legacy_archive"."game_history')) {
|
||||
return [
|
||||
{
|
||||
sourceProfile: 'hwe',
|
||||
season: 1,
|
||||
scenario: 7,
|
||||
scenarioName: '이전 시나리오',
|
||||
count: 2n,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (sql.includes('legacy_archive"."hall')) {
|
||||
return query.values?.includes('experience')
|
||||
? [
|
||||
{
|
||||
sourceProfile: 'hwe',
|
||||
serverId: 'hwe-old-1',
|
||||
generalNo: 9,
|
||||
type: 'experience',
|
||||
value: 777,
|
||||
owner: 'private-legacy-owner-id',
|
||||
aux: {
|
||||
name: '과거장수',
|
||||
ownerDisplayName: '과거소유자',
|
||||
nationName: '과거국',
|
||||
bgColor: '#330000',
|
||||
fgColor: '#ffffff',
|
||||
},
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
worldState: {
|
||||
findFirst: async () => ({
|
||||
meta: { isUnited: options?.isUnited ? 1 : 0 },
|
||||
@@ -289,8 +325,14 @@ describe('ranking.getBestGeneral', () => {
|
||||
value:
|
||||
type === 'warnum' || type === 'deathcrew' || type === 'deathcrew_person'
|
||||
? 1_000
|
||||
: type === 'ttd' || type === 'ttl' || type === 'tld' || type === 'tll' ||
|
||||
type === 'tsd' || type === 'tsl' || type === 'tid' || type === 'til' ||
|
||||
: type === 'ttd' ||
|
||||
type === 'ttl' ||
|
||||
type === 'tld' ||
|
||||
type === 'tll' ||
|
||||
type === 'tsd' ||
|
||||
type === 'tsl' ||
|
||||
type === 'tid' ||
|
||||
type === 'til' ||
|
||||
type === 'betgold'
|
||||
? 1_000
|
||||
: general.id * 1_000,
|
||||
@@ -304,11 +346,14 @@ describe('ranking.getBestGeneral', () => {
|
||||
for (const section of result.sections) {
|
||||
expect(section.entries, section.title).toHaveLength(10);
|
||||
expect(new Set(section.entries.map((entry) => entry.id)).size, section.title).toBe(10);
|
||||
expect(section.entries.every((entry) => entry.value > 0), section.title).toBe(true);
|
||||
expect(
|
||||
section.entries.every((entry) => entry.value > 0),
|
||||
section.title
|
||||
).toBe(true);
|
||||
}
|
||||
expect(result.sections.find((section) => section.title === '계 략 성 공')?.entries.map((entry) => entry.id)).toEqual([
|
||||
12, 11, 10, 9, 8, 7, 6, 5, 4, 3,
|
||||
]);
|
||||
expect(
|
||||
result.sections.find((section) => section.title === '계 략 성 공')?.entries.map((entry) => entry.id)
|
||||
).toEqual([12, 11, 10, 9, 8, 7, 6, 5, 4, 3]);
|
||||
});
|
||||
|
||||
it('matches PHP number_format rounding and the legacy fixed color table', () => {
|
||||
@@ -326,12 +371,38 @@ describe('ranking hall of fame', () => {
|
||||
.ranking.getHallOfFameOptions();
|
||||
expect(options).toEqual([
|
||||
{
|
||||
sourceProfile: 'che',
|
||||
season: 3,
|
||||
scenarios: [{ id: 22, name: '가상모드22', count: 2 }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps previous-server options and rankings in the dedicated archive source', async () => {
|
||||
const caller = appRouter.createCaller(buildContext({ authenticated: false }));
|
||||
await expect(caller.ranking.getHallOfFameOptions({ source: 'legacy' })).resolves.toEqual([
|
||||
{
|
||||
sourceProfile: 'hwe',
|
||||
season: 1,
|
||||
scenarios: [{ id: 7, name: '이전 시나리오', count: 2 }],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await caller.ranking.getHallOfFame({
|
||||
source: 'legacy',
|
||||
sourceProfile: 'hwe',
|
||||
season: 1,
|
||||
scenario: 7,
|
||||
});
|
||||
expect(result.source).toBe('legacy');
|
||||
expect(result.sourceProfile).toBe('hwe');
|
||||
expect(result.sections[0]).toMatchObject({
|
||||
title: '명 성',
|
||||
entries: [expect.objectContaining({ generalId: 9, name: '과거장수', ownerName: '과거소유자' })],
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain('private-legacy-owner-id');
|
||||
});
|
||||
|
||||
it('returns an explicit display name but never exposes the stored account identifier', async () => {
|
||||
const result = await appRouter
|
||||
.createCaller(buildContext({ authenticated: false, includeOwnerDisplayName: true }))
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -2224,16 +2224,24 @@ test('mobile bottom controls share Ref pressed geometry while their color bases
|
||||
await expect
|
||||
.poll(() => page.locator(manualRefreshSelector).evaluate((element) => getComputedStyle(element).boxShadow))
|
||||
.not.toBe('none');
|
||||
const callsBeforeManualRefresh = state.generalMeCalls;
|
||||
await page.locator(manualRefreshSelector).click();
|
||||
await expect(page.locator(manualRefreshSelector)).toBeDisabled();
|
||||
await expect(page.locator(manualRefreshSelector)).toBeEnabled();
|
||||
await expect(page.locator(manualRefreshSelector)).toHaveAttribute('aria-busy', 'true');
|
||||
await page.locator(manualRefreshSelector).click();
|
||||
await expect(page.getByTestId('game-toast')).toContainText('이미 정보를 갱신하고 있습니다.');
|
||||
await page.mouse.move(1, 1);
|
||||
expect(await buttonStyle(manualRefreshSelector)).toMatchObject({
|
||||
backgroundColor: 'rgb(33, 37, 41)',
|
||||
borderBottomWidth: '4px',
|
||||
marginTop: '0px',
|
||||
height: 45,
|
||||
});
|
||||
await expect(page.locator(manualRefreshSelector)).toHaveCSS('opacity', '0.55');
|
||||
await expect(page.locator(manualRefreshSelector)).toHaveCSS('opacity', '1');
|
||||
await page.screenshot({ path: testInfo.outputPath('mobile-refresh-busy-toast.png'), fullPage: true });
|
||||
await expect(page.locator(manualRefreshSelector)).toHaveAttribute('aria-busy', 'false');
|
||||
await expect(page.locator(manualRefreshSelector)).toBeEnabled();
|
||||
expect(state.generalMeCalls).toBe(callsBeforeManualRefresh + 1);
|
||||
|
||||
await persistArtifact(page, `${basePath.slice(1)}-mobile-bottom-ref-buttons`);
|
||||
});
|
||||
@@ -2802,6 +2810,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
|
||||
const operationsBeforeChangedBurst = state.operations.length;
|
||||
state.generalName = '부드럽게갱신된장수';
|
||||
state.refreshDelayMs = 1_000;
|
||||
await page.evaluate(() => {
|
||||
const emit = (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void })
|
||||
.__emitMainRealtime;
|
||||
@@ -2823,7 +2832,20 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
}
|
||||
});
|
||||
|
||||
const manualRefresh = page.getByRole('button', { name: '갱 신' });
|
||||
await expect(manualRefresh).toHaveAttribute('aria-busy', 'true');
|
||||
await expect(manualRefresh).toBeEnabled();
|
||||
await manualRefresh.click();
|
||||
await expect(page.getByTestId('game-toast')).toContainText('이미 정보를 갱신하고 있습니다.');
|
||||
if (autoRefreshArtifactRoot) {
|
||||
await page.screenshot({
|
||||
path: resolve(autoRefreshArtifactRoot, 'auto-refresh-busy-feedback.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
|
||||
await expect.poll(() => state.generalMeCalls, { timeout: 3_000 }).toBe(callsBeforeRefresh + 1);
|
||||
state.refreshDelayMs = 300;
|
||||
await expect(page.locator('[data-main-target="general"] .skeleton-line')).toHaveCount(0);
|
||||
await expect(page.locator('[data-main-target="city"] .skeleton-line')).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '갱 신' })).toHaveAttribute('aria-busy', 'false');
|
||||
|
||||
@@ -8,7 +8,7 @@ const response = (data: unknown) => ({ result: { data } });
|
||||
const operationNames = (route: Route) =>
|
||||
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
|
||||
|
||||
const installArchive = async (page: Page) => {
|
||||
const installArchive = async (page: Page, options: { battleAvailable?: boolean } = {}) => {
|
||||
await page.addInitScript((profile) => {
|
||||
localStorage.setItem('sammo-game-token', 'ga_archive');
|
||||
localStorage.setItem('sammo-game-profile', profile);
|
||||
@@ -21,7 +21,10 @@ const installArchive = async (page: Page) => {
|
||||
return response({
|
||||
seasons: [
|
||||
{
|
||||
sourceProfile: 'che',
|
||||
source: 'legacy',
|
||||
serverId: 'che_2024_01',
|
||||
openedAt: '2024-01-31T00:00:00.000Z',
|
||||
date: '2024-01-31T00:00:00.000Z',
|
||||
season: 51,
|
||||
scenario: 2,
|
||||
@@ -54,11 +57,67 @@ const installArchive = async (page: Page) => {
|
||||
}
|
||||
if (operation === 'archive.myPastPlayDetail') {
|
||||
return response({
|
||||
sourceProfile: 'che',
|
||||
source: 'legacy',
|
||||
serverId: 'che_2024_01',
|
||||
generalNo: 17,
|
||||
name: '관우',
|
||||
lastYearMonth: 21403,
|
||||
history: ['<C>●</>214년 3월: 촉에 임관', '<Y>●</>214년 1월: 성도에서 거병'],
|
||||
dynastyPath: '/dynasty/7?source=legacy',
|
||||
nation: { name: '촉', color: '#800000' },
|
||||
general: {
|
||||
id: 17,
|
||||
name: '관우',
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
npcState: 0,
|
||||
officerLevel: 12,
|
||||
officerLevelText: '황제',
|
||||
generalType: '용장',
|
||||
stats: { leadership: 91, strength: 98, intelligence: 77 },
|
||||
gold: 12_000,
|
||||
rice: 8_000,
|
||||
crew: 7_000,
|
||||
train: 100,
|
||||
atmos: 100,
|
||||
injury: 0,
|
||||
experience: 23_000,
|
||||
dedication: 1_200,
|
||||
crewTypeId: 1,
|
||||
crewTypeName: '보병',
|
||||
traits: { personal: '대담', specialDomestic: '상재', specialWar: '신산' },
|
||||
progression: {
|
||||
experienceLevel: 12,
|
||||
dedicationLevel: 8,
|
||||
dedicationText: '황제',
|
||||
statExperience: { leadership: 12, strength: 14, intelligence: 8 },
|
||||
statUpgradeLimit: 30,
|
||||
dex: [125_000, 250_000, 375_000, 500_000, 625_000],
|
||||
},
|
||||
},
|
||||
masteryAvailable: true,
|
||||
battle: {
|
||||
available: options.battleAvailable ?? true,
|
||||
warnum: 16,
|
||||
wins: 10,
|
||||
losses: 6,
|
||||
strategies: 4,
|
||||
killCrew: 12_000,
|
||||
deathCrew: 8_000,
|
||||
winRate: 62.5,
|
||||
killRate: 75,
|
||||
recentWar: '2024-01-30T03:00:00.000Z',
|
||||
},
|
||||
logs: {
|
||||
generalHistory: {
|
||||
available: true,
|
||||
entries: [
|
||||
{ id: 2, text: '<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' } } };
|
||||
@@ -76,6 +135,15 @@ test('지난 플레이 관직은 숫자 대신 저장된 Ref 표시명으로 나
|
||||
await expect(generalRow).not.toContainText('che_');
|
||||
});
|
||||
|
||||
test('보존되지 않은 과거 전투 집계는 0으로 꾸미지 않고 가용성 경계를 표시한다', async ({ page }) => {
|
||||
await installArchive(page, { battleAvailable: false });
|
||||
await page.goto('past-plays');
|
||||
await page.locator('.detail-toggle').click();
|
||||
|
||||
await expect(page.locator('[data-general-battle-summary]')).toHaveText('전투 집계가 보존되지 않았습니다.');
|
||||
await expect(page.locator('[data-general-battle-summary]')).not.toContainText('승률');
|
||||
});
|
||||
|
||||
test('past plays is available without a current general and preserves desktop interaction geometry', async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -87,19 +155,46 @@ test('past plays is available without a current general and preserves desktop in
|
||||
await expect(root).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: '내 지난 플레이 보기' })).toBeVisible();
|
||||
await expect(page.getByText('천하쟁패 · 51기')).toBeVisible();
|
||||
await expect(page.getByText('이전 서버 기록')).toBeVisible();
|
||||
await expect(page.getByText('che', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/2024.*개장/)).toBeVisible();
|
||||
await expect(page.locator('.general-name')).toHaveText('관우');
|
||||
await expect(page.locator('tbody tr').filter({ hasText: '관우' })).toContainText('황제');
|
||||
await expect(page.getByRole('link', { name: '이 기수 국가 정보' })).toHaveAttribute('href', gamePath('/dynasty/7'));
|
||||
const historyToggle = page.locator('.history-toggle');
|
||||
await expect(historyToggle).toHaveText('보기 (2)');
|
||||
await historyToggle.click();
|
||||
const detailToggle = page.locator('.detail-toggle');
|
||||
await expect(detailToggle).toHaveText('상세 보기');
|
||||
await detailToggle.hover();
|
||||
const beforePress = await detailToggle.boundingBox();
|
||||
await page.mouse.down();
|
||||
expect(await detailToggle.evaluate((element) => getComputedStyle(element).transform)).not.toBe('none');
|
||||
await page.mouse.up();
|
||||
expect((await detailToggle.boundingBox())?.y).toBeCloseTo(beforePress?.y ?? 0, 0);
|
||||
await expect(page.getByText('214년 3월: 촉에 임관')).toBeVisible();
|
||||
await expect(historyToggle).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(detailToggle).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(page.locator('.archive-general-card')).toHaveAttribute('data-general-basic-card', '');
|
||||
await expect(page.locator('.archive-general-card [role="progressbar"]')).toHaveCount(14);
|
||||
await expect(page.locator('[data-general-battle-summary]')).toContainText('승률62.5%');
|
||||
await expect(page.locator('[data-general-battle-summary]')).toContainText('살상률75.0%');
|
||||
await expect(page.locator('[data-log-type="battleDetail"]')).toContainText(
|
||||
'이 기수에는 전투 기록이 보존되지 않았습니다.'
|
||||
);
|
||||
await expect(page.locator('[data-log-type="battleResult"]')).toContainText(
|
||||
'이 기수에는 전투 결과가 보존되지 않았습니다.'
|
||||
);
|
||||
await expect(page.locator('[data-log-type="generalAction"]')).toContainText(
|
||||
'이 기수에는 개인 기록이 보존되지 않았습니다.'
|
||||
);
|
||||
await expect(page.locator('[data-log-type="generalHistory"] C')).toHaveCount(0);
|
||||
await expect(page.getByRole('link', { name: '이 기수 국가 정보' })).toHaveAttribute(
|
||||
'href',
|
||||
`${gamePath('/dynasty/7')}?source=legacy`
|
||||
);
|
||||
|
||||
const geometry = await root.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const titleRect = element.querySelector('.title-row')!.getBoundingClientRect();
|
||||
const tableRect = element.querySelector('table')!.getBoundingClientRect();
|
||||
const detailGrid = element.querySelector('.detail-grid')!;
|
||||
const card = element.querySelector('[data-general-basic-card]')!.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
x: rect.x,
|
||||
@@ -107,6 +202,8 @@ test('past plays is available without a current general and preserves desktop in
|
||||
minHeight: rect.height,
|
||||
title: { x: titleRect.x, y: titleRect.y, width: titleRect.width },
|
||||
tableWidth: tableRect.width,
|
||||
detailColumns: getComputedStyle(detailGrid).gridTemplateColumns,
|
||||
cardWidth: card.width,
|
||||
color: style.color,
|
||||
backgroundColor: style.backgroundColor,
|
||||
};
|
||||
@@ -116,13 +213,14 @@ test('past plays is available without a current general and preserves desktop in
|
||||
width: 1000,
|
||||
title: { x: 100, y: 0, width: 1000 },
|
||||
tableWidth: 1000,
|
||||
cardWidth: 497,
|
||||
color: 'rgb(238, 238, 238)',
|
||||
backgroundColor: 'rgb(21, 21, 21)',
|
||||
backgroundColor: 'rgb(48, 32, 22)',
|
||||
});
|
||||
|
||||
const refresh = page.getByRole('button', { name: '새로고침' });
|
||||
await refresh.hover();
|
||||
expect(await refresh.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(135, 206, 235)');
|
||||
await expect(refresh).toHaveCSS('color', 'rgb(135, 206, 235)');
|
||||
await refresh.focus();
|
||||
expect(await refresh.evaluate((element) => document.activeElement === element)).toBe(true);
|
||||
|
||||
@@ -140,6 +238,9 @@ test('past plays keeps the legacy-width table scrollable on a mobile viewport',
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('past-plays');
|
||||
|
||||
await page.locator('.detail-toggle').click();
|
||||
await expect(page.locator('.archive-general-card')).toBeVisible();
|
||||
|
||||
const scroll = page.locator('.table-scroll');
|
||||
const metrics = await scroll.evaluate((element) => ({
|
||||
clientWidth: element.clientWidth,
|
||||
@@ -149,4 +250,14 @@ test('past plays keeps the legacy-width table scrollable on a mobile viewport',
|
||||
// The shared legacy shell keeps its historical 500 px minimum canvas.
|
||||
expect(metrics).toEqual({ clientWidth: 500, scrollWidth: 940, overflowX: 'auto' });
|
||||
await expect(page.locator('.title-row')).toHaveCSS('flex-direction', 'column');
|
||||
await expect(page.locator('.detail-grid')).toHaveCSS('grid-template-columns', '498px');
|
||||
const detailMetrics = await page.locator('.detail-shell').evaluate((element) => ({
|
||||
width: element.getBoundingClientRect().width,
|
||||
scrollWidth: element.scrollWidth,
|
||||
recordBottom: element.querySelector('[data-general-record-panels]')!.getBoundingClientRect().bottom,
|
||||
shellBottom: element.getBoundingClientRect().bottom,
|
||||
}));
|
||||
expect(detailMetrics.width).toBe(498);
|
||||
expect(detailMetrics.scrollWidth).toBe(498);
|
||||
expect(detailMetrics.recordBottom).toBeLessThanOrEqual(detailMetrics.shellBottom);
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ export default defineConfig({
|
||||
'legacyLogHtml.spec.ts',
|
||||
'directoryLists.spec.ts',
|
||||
'pastPlays.spec.ts',
|
||||
'legacyArchiveViews.spec.ts',
|
||||
'nationGeneralSecret.spec.ts',
|
||||
'npcPolicy.spec.ts',
|
||||
'auction.spec.ts',
|
||||
|
||||
@@ -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>
|
||||
@@ -226,7 +226,6 @@ const onQuick = (item: QuickNavigationItem) => {
|
||||
data-bottom-menu="manual-refresh"
|
||||
aria-label="직접 갱신"
|
||||
title="직접 갱신"
|
||||
:disabled="refreshing"
|
||||
:aria-busy="refreshing"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
@@ -306,12 +305,6 @@ const onQuick = (item: QuickNavigationItem) => {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.manual-refresh-trigger:disabled {
|
||||
cursor: wait;
|
||||
filter: grayscale(0.6);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.bottom-trigger.legacy-button:not(:disabled, [aria-disabled='true']):hover,
|
||||
.bottom-trigger.legacy-button:not(:disabled, [aria-disabled='true'])[aria-expanded='true'] {
|
||||
height: 44px;
|
||||
|
||||
@@ -3,9 +3,15 @@ import { formatServerDateTime } from '@sammo-ts/common';
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import PanelCard from '../components/ui/PanelCard.vue';
|
||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
||||
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
|
||||
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
|
||||
import GeneralBattleSummary from '../components/main/GeneralBattleSummary.vue';
|
||||
import GeneralRecordPanels from '../components/main/GeneralRecordPanels.vue';
|
||||
import {
|
||||
GENERAL_RECORD_TYPES,
|
||||
type GeneralRecordCollection,
|
||||
type GeneralRecordType,
|
||||
} from '../components/generalRecords';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { getNpcColor } from '../utils/npcColor';
|
||||
import { formatLog } from '../utils/formatLog';
|
||||
@@ -13,17 +19,10 @@ import { formatLog } from '../utils/formatLog';
|
||||
type BattleCenterResponse = Awaited<ReturnType<typeof trpc.nation.getBattleCenter.query>>;
|
||||
type GeneralEntry = BattleCenterResponse['generals'][number];
|
||||
|
||||
type LogType = 'generalHistory' | 'battleResult' | 'battleDetail' | 'generalAction';
|
||||
type LogType = GeneralRecordType;
|
||||
type LogLine = { id: number; html: string };
|
||||
|
||||
const logTypes: LogType[] = ['generalHistory', 'battleDetail', 'battleResult', 'generalAction'];
|
||||
|
||||
const logLabels: Record<LogType, string> = {
|
||||
generalHistory: '장수 열전',
|
||||
battleDetail: '전투 기록',
|
||||
battleResult: '전투 결과',
|
||||
generalAction: '개인 기록',
|
||||
};
|
||||
const logTypes: LogType[] = [...GENERAL_RECORD_TYPES];
|
||||
|
||||
const orderOptions = [
|
||||
{ key: 'recentWar', label: '최근 전투' },
|
||||
@@ -49,6 +48,12 @@ const logs = reactive<Record<LogType, LogLine[]>>({
|
||||
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 => {
|
||||
if (value instanceof Error) {
|
||||
return value.message;
|
||||
@@ -275,27 +280,20 @@ onMounted(() => {
|
||||
:nation-color="data?.nation.color"
|
||||
>
|
||||
<template v-if="selectedGeneral" #details>
|
||||
<div class="battle-general-extra">
|
||||
<span>명성</span
|
||||
><strong>{{ selectedGeneral.experience.toLocaleString('ko-KR') }}</strong>
|
||||
<span>계급</span><strong>{{ selectedGeneral.progression.dedicationText }}</strong>
|
||||
<span>전투</span><strong>{{ selectedGeneral.warnum }}회</strong> <span>승리</span
|
||||
><strong>{{ selectedGeneral.battleStats.kills }}</strong> <span>패배</span
|
||||
><strong>{{ selectedGeneral.battleStats.deaths }}</strong> <span>계략</span
|
||||
><strong>{{ selectedGeneral.battleStats.fire }}</strong> <span>사살</span
|
||||
><strong>{{ selectedGeneral.battleStats.killCrew.toLocaleString('ko-KR') }}</strong>
|
||||
<span>피살</span
|
||||
><strong>{{ selectedGeneral.battleStats.deathCrew.toLocaleString('ko-KR') }}</strong>
|
||||
<span class="battle-general-extra__recent-label">최근 전투</span>
|
||||
<strong class="battle-general-extra__recent-value">
|
||||
{{
|
||||
formatServerDateTime(selectedGeneral.recentWar, {
|
||||
format: 'monthDayTime',
|
||||
fallback: '-',
|
||||
})
|
||||
}}
|
||||
</strong>
|
||||
</div>
|
||||
<GeneralBattleSummary
|
||||
:summary="{
|
||||
available: true,
|
||||
experience: selectedGeneral.experience,
|
||||
dedicationText: selectedGeneral.progression.dedicationText,
|
||||
warnum: selectedGeneral.warnum,
|
||||
wins: selectedGeneral.battleStats.kills,
|
||||
losses: selectedGeneral.battleStats.deaths,
|
||||
strategies: selectedGeneral.battleStats.fire,
|
||||
killCrew: selectedGeneral.battleStats.killCrew,
|
||||
deathCrew: selectedGeneral.battleStats.deathCrew,
|
||||
recentWar: selectedGeneral.recentWar,
|
||||
}"
|
||||
/>
|
||||
<LegacyGeneralProgress :general="selectedGeneral" :show-primary="false" />
|
||||
</template>
|
||||
</GeneralBasicCard>
|
||||
@@ -304,16 +302,7 @@ onMounted(() => {
|
||||
|
||||
<div class="stack">
|
||||
<PanelCard title="장수 기록" subtitle="열전과 전투 기록">
|
||||
<div class="log-grid">
|
||||
<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>
|
||||
<GeneralRecordPanels :records="currentRecords" :loading="loading || logLoading" trusted-html />
|
||||
</PanelCard>
|
||||
</div>
|
||||
</section>
|
||||
@@ -354,81 +343,6 @@ onMounted(() => {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.battle-general-extra {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
|
||||
.battle-general-extra > * {
|
||||
min-height: 24px;
|
||||
box-sizing: border-box;
|
||||
border-right: 1px solid #777;
|
||||
border-bottom: 1px solid #777;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
|
||||
.battle-general-extra > span {
|
||||
background-color: rgb(20 75 42 / 70%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.battle-general-extra > strong {
|
||||
overflow: hidden;
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.battle-general-extra > .battle-general-extra__recent-label {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.battle-general-extra > .battle-general-extra__recent-value {
|
||||
grid-column: 2 / -1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.log-grid {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.log-block {
|
||||
border: 1px solid #666;
|
||||
padding: 0;
|
||||
background-color: #302016;
|
||||
background-image: var(--sammo-texture-walnut);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.log-title {
|
||||
min-height: 34px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-bottom: 1px solid #666;
|
||||
color: orange;
|
||||
background: #000;
|
||||
font-size: 1.3em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
padding: 2px 8px;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.log-line :deep(.hidden_but_copyable) {
|
||||
color: transparent !important;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 2px 8px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* PanelCard is retained as a data wrapper, but its presentation follows the
|
||||
flat bootstrap rows used by the reference page. */
|
||||
:deep(.panel-card) {
|
||||
@@ -466,8 +380,7 @@ onMounted(() => {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
:deep(.panel-header),
|
||||
.log-title {
|
||||
:deep(.panel-header) {
|
||||
background-image: var(--sammo-texture-green);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const data = ref<DynastyDetailPayload | null>(null);
|
||||
const source = computed<'current' | 'legacy'>(() => (route.query.source === 'legacy' ? 'legacy' : 'current'));
|
||||
|
||||
const emperorId = computed(() => {
|
||||
const idParam = route.params.id;
|
||||
@@ -39,7 +40,7 @@ const loadDetail = async (): Promise<void> => {
|
||||
loading.value = true;
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
data.value = await trpc.dynasty.getDetail.query({ emperorId: emperorId.value });
|
||||
data.value = await trpc.dynasty.getDetail.query({ emperorId: emperorId.value, source: source.value });
|
||||
} catch (error) {
|
||||
data.value = null;
|
||||
errorMessage.value = error instanceof Error ? error.message : '왕조 정보를 불러오지 못했습니다.';
|
||||
@@ -50,7 +51,7 @@ const loadDetail = async (): Promise<void> => {
|
||||
|
||||
const formatArchiveDate = (value: string): string => formatServerDateTime(value);
|
||||
|
||||
watch(emperorId, loadDetail);
|
||||
watch([emperorId, source], loadDetail);
|
||||
onMounted(loadDetail);
|
||||
</script>
|
||||
|
||||
@@ -63,7 +64,8 @@ onMounted(loadDetail);
|
||||
역 대 왕 조<br />
|
||||
<button class="native-button" type="button" @click="closePage">창 닫기</button>
|
||||
<span class="all-link">
|
||||
<RouterLink to="/dynasty"
|
||||
<RouterLink
|
||||
:to="{ path: '/dynasty', query: source === 'legacy' ? { source: 'legacy' } : {} }"
|
||||
><button class="native-button" type="button">전체보기</button></RouterLink
|
||||
>
|
||||
</span>
|
||||
@@ -88,7 +90,12 @@ onMounted(loadDetail);
|
||||
<tbody>
|
||||
<tr>
|
||||
<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>
|
||||
</tr>
|
||||
<tr>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { legacyNationTextColor } from '../utils/legacyNationColor';
|
||||
import { trpc } from '../utils/trpc';
|
||||
@@ -8,9 +8,11 @@ import { trpc } from '../utils/trpc';
|
||||
type DynastyListPayload = Awaited<ReturnType<typeof trpc.dynasty.getList.query>>;
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const data = ref<DynastyListPayload | null>(null);
|
||||
const selectedSource = ref<'current' | 'legacy'>(route.query.source === 'legacy' ? 'legacy' : 'current');
|
||||
|
||||
const closePage = async (): Promise<void> => {
|
||||
if (window.opener) {
|
||||
@@ -24,7 +26,7 @@ const loadDynasty = async (): Promise<void> => {
|
||||
loading.value = true;
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
data.value = await trpc.dynasty.getList.query();
|
||||
data.value = await trpc.dynasty.getList.query({ source: selectedSource.value });
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '왕조일람을 불러오지 못했습니다.';
|
||||
} finally {
|
||||
@@ -33,6 +35,7 @@ const loadDynasty = async (): Promise<void> => {
|
||||
};
|
||||
|
||||
onMounted(loadDynasty);
|
||||
watch(selectedSource, loadDynasty);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -48,6 +51,14 @@ onMounted(loadDynasty);
|
||||
</tbody>
|
||||
</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-else-if="loading && !data" class="legacy-message" role="status">불러오는 중...</div>
|
||||
|
||||
@@ -57,7 +68,9 @@ onMounted(loadDynasty);
|
||||
<tr>
|
||||
<td class="current-heading" colspan="8">
|
||||
<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>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -83,11 +96,24 @@ onMounted(loadDynasty);
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="phase-heading" colspan="8">
|
||||
<span class="large-text">{{ entry.phase }}</span>
|
||||
<RouterLink :to="`/dynasty/${entry.id}`">
|
||||
<span class="large-text"
|
||||
>{{ 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>
|
||||
</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>
|
||||
</RouterLink>
|
||||
</td>
|
||||
@@ -138,14 +164,10 @@ onMounted(loadDynasty);
|
||||
<table class="legacy-table legacy-bg0 footer-table spaced-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<button class="native-button" type="button" @click="closePage">창 닫기</button><br />
|
||||
</td>
|
||||
<td><button class="native-button" type="button" @click="closePage">창 닫기</button><br /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="banner">
|
||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD
|
||||
</td>
|
||||
<td class="banner">삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -183,6 +205,18 @@ onMounted(loadDynasty);
|
||||
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 {
|
||||
height: 47px;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIc
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type HallOption = {
|
||||
sourceProfile: string;
|
||||
season: number;
|
||||
scenarios: Array<{ id: number; name: string; count: number }>;
|
||||
};
|
||||
@@ -42,6 +43,8 @@ const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const options = ref<HallOption[]>([]);
|
||||
const selectedSource = ref<'current' | 'legacy'>('current');
|
||||
const selectedProfile = ref<string | null>(null);
|
||||
const selectedSeason = ref<number | null>(null);
|
||||
const selectedScenario = ref<number | null>(null);
|
||||
const data = ref<HallPayload | null>(null);
|
||||
@@ -51,10 +54,11 @@ const selection = computed({
|
||||
selectedSeason.value === null
|
||||
? ''
|
||||
: selectedScenario.value === null
|
||||
? `season:${selectedSeason.value}`
|
||||
: `scenario:${selectedSeason.value}:${selectedScenario.value}`,
|
||||
? `season:${selectedProfile.value ?? ''}:${selectedSeason.value}`
|
||||
: `scenario:${selectedProfile.value ?? ''}:${selectedSeason.value}:${selectedScenario.value}`,
|
||||
set: (value: string) => {
|
||||
const [kind, season, scenario] = value.split(':');
|
||||
const [kind, profile, season, scenario] = value.split(':');
|
||||
selectedProfile.value = profile || null;
|
||||
selectedSeason.value = Number(season);
|
||||
selectedScenario.value = kind === 'scenario' ? Number(scenario) : null;
|
||||
},
|
||||
@@ -72,9 +76,17 @@ const closePage = async (): Promise<void> => {
|
||||
|
||||
const loadOptions = async (): Promise<void> => {
|
||||
try {
|
||||
options.value = await trpc.ranking.getHallOfFameOptions.query();
|
||||
if (options.value.length > 0 && selectedSeason.value === null) {
|
||||
selectedSeason.value = options.value[0]!.season;
|
||||
options.value = await trpc.ranking.getHallOfFameOptions.query({ source: selectedSource.value });
|
||||
const first = options.value[0];
|
||||
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) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '명예의 전당 옵션을 불러오지 못했습니다.';
|
||||
@@ -90,6 +102,11 @@ const loadHall = async (): Promise<void> => {
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
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,
|
||||
scenario: selectedScenario.value ?? undefined,
|
||||
})) as HallPayload;
|
||||
@@ -100,10 +117,14 @@ const loadHall = async (): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
watch([selectedSeason, selectedScenario], () => {
|
||||
watch([selectedProfile, selectedSeason, selectedScenario], () => {
|
||||
void loadHall();
|
||||
});
|
||||
|
||||
watch(selectedSource, () => {
|
||||
void loadOptions();
|
||||
});
|
||||
|
||||
onMounted(loadOptions);
|
||||
</script>
|
||||
|
||||
@@ -114,17 +135,29 @@ onMounted(loadOptions);
|
||||
<button class="legacy-button" type="button" @click="closePage">창 닫기</button>
|
||||
</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">
|
||||
시나리오 검색 :
|
||||
<select v-model="selection" aria-label="시나리오 검색">
|
||||
<template v-for="season in options" :key="season.season">
|
||||
<option :value="`season:${season.season}`">* 시즌 : {{ season.season }} 종합 *</option>
|
||||
<template v-for="season in options" :key="`${season.sourceProfile}:${season.season}`">
|
||||
<option :value="`season:${season.sourceProfile}:${season.season}`">
|
||||
* {{ selectedSource === 'legacy' ? `${season.sourceProfile.toUpperCase()} / ` : '' }}시즌 :
|
||||
{{ season.season }} 종합 *
|
||||
</option>
|
||||
<option
|
||||
v-for="scenario in season.scenarios"
|
||||
:key="`${season.season}:${scenario.id}`"
|
||||
:value="`scenario:${season.season}:${scenario.id}`"
|
||||
:key="`${season.sourceProfile}:${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>
|
||||
</template>
|
||||
</select>
|
||||
@@ -210,6 +243,19 @@ onMounted(loadOptions);
|
||||
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 {
|
||||
width: 189px;
|
||||
height: 20px;
|
||||
|
||||
@@ -20,11 +20,13 @@ import type { QuickNavigationItem } from '../components/main/mainNavigation';
|
||||
import { formatLog } from '../utils/formatLog';
|
||||
import { useSessionStore } from '../stores/session';
|
||||
import { useMainDashboardStore } from '../stores/mainDashboard';
|
||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import type { CommandPatternEntry } from '../components/command/types';
|
||||
|
||||
const session = useSessionStore();
|
||||
const dashboard = useMainDashboardStore();
|
||||
const { info: showInfoToast } = useGameFeedback();
|
||||
const isMobile = useMediaQuery('(max-width: 939.98px)');
|
||||
|
||||
const npcMode = ref(0);
|
||||
@@ -115,6 +117,14 @@ const loadMainData = async () => {
|
||||
npcMode.value = worldState?.config.npcMode ?? 0;
|
||||
};
|
||||
|
||||
const requestManualRefresh = () => {
|
||||
if (refreshing.value) {
|
||||
showInfoToast('이미 정보를 갱신하고 있습니다.');
|
||||
return;
|
||||
}
|
||||
void loadMainData();
|
||||
};
|
||||
|
||||
const moveLobby = () => {
|
||||
window.location.replace(import.meta.env.VITE_GATEWAY_WEB_URL?.trim() || '/gateway/');
|
||||
};
|
||||
@@ -154,9 +164,8 @@ watch(
|
||||
<button
|
||||
class="game-shell__action legacy-button legacy-button--navigation"
|
||||
type="button"
|
||||
:disabled="refreshing"
|
||||
:aria-busy="refreshing"
|
||||
@click="loadMainData"
|
||||
@click="requestManualRefresh"
|
||||
>
|
||||
갱 신
|
||||
</button>
|
||||
@@ -451,7 +460,7 @@ watch(
|
||||
:npc-mode="npcMode"
|
||||
:realtime-enabled="realtimeEnabled"
|
||||
:refreshing="refreshing"
|
||||
@refresh="loadMainData"
|
||||
@refresh="requestManualRefresh"
|
||||
@toggle-realtime="dashboard.setRealtimeEnabled(!realtimeEnabled)"
|
||||
@lobby="moveLobby"
|
||||
@quick="moveQuick"
|
||||
|
||||
@@ -1,21 +1,102 @@
|
||||
<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';
|
||||
|
||||
type Archive = Awaited<ReturnType<typeof trpc.archive.myPastPlays.query>>;
|
||||
type PastPlayDetail = Awaited<ReturnType<typeof trpc.archive.myPastPlayDetail.query>>;
|
||||
type DetailState = {
|
||||
open: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
detail: PastPlayDetail | null;
|
||||
type ArchiveSeason = Archive['seasons'][number] & {
|
||||
sourceProfile?: string;
|
||||
source?: string;
|
||||
openedAt?: string | null;
|
||||
date?: string | 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 loading = ref(false);
|
||||
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 () => {
|
||||
if (loading.value) return;
|
||||
@@ -33,43 +114,74 @@ const loadArchive = async () => {
|
||||
const yearMonth = (value: number): string => `${Math.floor(value / 100)}년 ${value % 100}월`;
|
||||
const valueOrDash = (value: number | string | null): string => (value === null || value === '' ? '-' : String(value));
|
||||
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 key = detailKey(serverId, generalNo);
|
||||
const current = details.value[key];
|
||||
if (current?.open) {
|
||||
details.value = { ...details.value, [key]: { ...current, open: false } };
|
||||
return;
|
||||
}
|
||||
if (current?.detail) {
|
||||
details.value = { ...details.value, [key]: { ...current, open: true } };
|
||||
const formatOpenedAt = (season: ArchiveSeason): string => {
|
||||
const value = season.openedAt ?? season.date;
|
||||
if (!value) return '개장일 미상';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '개장일 미상';
|
||||
return `${new Intl.DateTimeFormat('ko-KR', { dateStyle: 'medium', timeZone: 'Asia/Seoul' }).format(date)} 개장`;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
details.value = {
|
||||
...details.value,
|
||||
[key]: { open: true, loading: true, error: null, detail: null },
|
||||
};
|
||||
selectedKey.value = key;
|
||||
detail.value = null;
|
||||
detailError.value = null;
|
||||
detailLoading.value = true;
|
||||
try {
|
||||
const detail = await trpc.archive.myPastPlayDetail.query({ serverId, generalNo });
|
||||
details.value = {
|
||||
...details.value,
|
||||
[key]: { open: true, loading: false, error: null, detail },
|
||||
};
|
||||
const result = await queryPastPlayDetail({
|
||||
sourceProfile: seasonSourceProfile(season),
|
||||
source: seasonSource(season),
|
||||
serverId: season.serverId,
|
||||
generalNo,
|
||||
});
|
||||
if (selectedKey.value === key) detail.value = result;
|
||||
} catch (cause) {
|
||||
details.value = {
|
||||
...details.value,
|
||||
[key]: {
|
||||
open: true,
|
||||
loading: false,
|
||||
error: cause instanceof Error ? cause.message : '장수 열전을 불러오지 못했습니다.',
|
||||
detail: null,
|
||||
},
|
||||
};
|
||||
if (selectedKey.value === key) {
|
||||
detailError.value = cause instanceof Error ? cause.message : '지난 장수 상세 기록을 불러오지 못했습니다.';
|
||||
}
|
||||
} finally {
|
||||
if (selectedKey.value === key) detailLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
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(() => {
|
||||
void loadArchive();
|
||||
});
|
||||
@@ -85,28 +197,27 @@ onMounted(() => {
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<p class="page-note">종료된 기수에 보관된 내 장수 기록입니다.</p>
|
||||
<p class="page-note">이전 서버에서 종료된 기수에 보관된 내 장수 기록입니다.</p>
|
||||
<p v-if="error" class="error-row">{{ error }}</p>
|
||||
<p v-else-if="loading && !archive" 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">
|
||||
<strong>{{ season.serverId }}</strong>
|
||||
<div class="season-actions">
|
||||
<div class="season-identity">
|
||||
<strong class="archive-label">이전 서버 기록</strong>
|
||||
<strong>{{ seasonSourceProfile(season) }}</strong>
|
||||
<span>{{ season.serverId }}</span>
|
||||
</div>
|
||||
<div class="season-meta">
|
||||
<span>{{ formatOpenedAt(season) }}</span>
|
||||
<span>
|
||||
{{ season.scenarioName ?? '시나리오 미상' }}
|
||||
<template v-if="season.season !== null"> · {{ season.season }}기</template>
|
||||
</span>
|
||||
<RouterLink
|
||||
v-if="season.dynastyId !== null"
|
||||
class="legacy-button nation-archive-link"
|
||||
:to="`/dynasty/${season.dynastyId}`"
|
||||
>
|
||||
이 기수 국가 정보
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
@@ -121,85 +232,83 @@ onMounted(() => {
|
||||
<th>성격</th>
|
||||
<th>내정 특기</th>
|
||||
<th>전투 특기</th>
|
||||
<th>장수 열전</th>
|
||||
<th>상세</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="general in season.generals" :key="general.generalNo">
|
||||
<tr>
|
||||
<td class="general-name">{{ general.name }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="nation-name"
|
||||
:style="{ backgroundColor: general.nationColor, color: '#ffffff' }"
|
||||
>
|
||||
{{ general.nationName }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ yearMonth(general.lastYearMonth) }}</td>
|
||||
<td>{{ valueOrDash(general.leadership) }}</td>
|
||||
<td>{{ valueOrDash(general.strength) }}</td>
|
||||
<td>{{ valueOrDash(general.intel) }}</td>
|
||||
<td>{{ valueOrDash(general.officerLevelText) }}</td>
|
||||
<td>{{ valueOrDash(general.personal) }}</td>
|
||||
<td>{{ valueOrDash(general.special) }}</td>
|
||||
<td>{{ valueOrDash(general.special2) }}</td>
|
||||
<td>
|
||||
<button
|
||||
class="legacy-button history-toggle"
|
||||
type="button"
|
||||
:aria-expanded="
|
||||
details[detailKey(season.serverId, general.generalNo)]?.open ?? false
|
||||
"
|
||||
@click="toggleHistory(season.serverId, general.generalNo)"
|
||||
>
|
||||
{{
|
||||
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>
|
||||
<tr v-for="general in season.generals" :key="general.generalNo">
|
||||
<td class="general-name">{{ general.name }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="nation-name"
|
||||
:style="{ backgroundColor: general.nationColor, color: '#fff' }"
|
||||
>
|
||||
{{ general.nationName }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ yearMonth(general.lastYearMonth) }}</td>
|
||||
<td>{{ valueOrDash(general.leadership) }}</td>
|
||||
<td>{{ valueOrDash(general.strength) }}</td>
|
||||
<td>{{ valueOrDash(general.intel) }}</td>
|
||||
<td>{{ valueOrDash(general.officerLevelText) }}</td>
|
||||
<td>{{ valueOrDash(general.personal) }}</td>
|
||||
<td>{{ valueOrDash(general.special) }}</td>
|
||||
<td>{{ valueOrDash(general.special2) }}</td>
|
||||
<td>
|
||||
<button
|
||||
class="legacy-button detail-toggle"
|
||||
type="button"
|
||||
:aria-expanded="selectedKey === detailKey(season, general.generalNo)"
|
||||
@click="selectGeneral(season, general.generalNo)"
|
||||
>
|
||||
{{ selectedKey === detailKey(season, general.generalNo) ? '접기' : '상세 보기' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
</main>
|
||||
</template>
|
||||
@@ -210,13 +319,17 @@ onMounted(() => {
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
color: #eee;
|
||||
background: #151515;
|
||||
background-color: #302016;
|
||||
background-image: var(--sammo-texture-walnut);
|
||||
font-family: var(--sammo-font-sans);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.title-row,
|
||||
.season-heading {
|
||||
border: 1px solid #555;
|
||||
background: #2b2b2b;
|
||||
border: 1px solid #666;
|
||||
background-color: #14241b;
|
||||
background-image: var(--sammo-texture-green);
|
||||
}
|
||||
|
||||
.title-row {
|
||||
@@ -233,8 +346,12 @@ onMounted(() => {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.title-row nav {
|
||||
.title-row nav,
|
||||
.season-identity,
|
||||
.season-meta,
|
||||
.detail-source {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@@ -242,11 +359,12 @@ onMounted(() => {
|
||||
box-sizing: border-box;
|
||||
min-height: 28px;
|
||||
padding: 4px 9px;
|
||||
border: 1px solid #777;
|
||||
border-radius: 0;
|
||||
color: #eee;
|
||||
background: #333;
|
||||
border: 1px solid #2d5d7f;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
background: #315f86;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -257,6 +375,10 @@ onMounted(() => {
|
||||
color: skyblue;
|
||||
}
|
||||
|
||||
.legacy-button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.legacy-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
@@ -267,15 +389,17 @@ onMounted(() => {
|
||||
.error-row {
|
||||
margin: 0;
|
||||
padding: 12px 10px;
|
||||
border-inline: 1px solid #555;
|
||||
border-bottom: 1px solid #555;
|
||||
border-inline: 1px solid #666;
|
||||
border-bottom: 1px solid #666;
|
||||
}
|
||||
|
||||
.page-note {
|
||||
.page-note,
|
||||
.season-heading span {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.error-row {
|
||||
.error-row,
|
||||
.detail-error {
|
||||
color: #ff8d8d;
|
||||
}
|
||||
|
||||
@@ -294,19 +418,15 @@ onMounted(() => {
|
||||
color: skyblue;
|
||||
}
|
||||
|
||||
.season-heading span {
|
||||
color: #bbb;
|
||||
.archive-label {
|
||||
padding: 2px 5px;
|
||||
border: 1px solid #777;
|
||||
color: #fff !important;
|
||||
background: #00582c;
|
||||
}
|
||||
|
||||
.season-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nation-archive-link {
|
||||
min-height: 24px;
|
||||
padding-block: 2px;
|
||||
.season-meta {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.table-scroll {
|
||||
@@ -324,7 +444,7 @@ table {
|
||||
th,
|
||||
td {
|
||||
padding: 6px 7px;
|
||||
border: 1px solid #555;
|
||||
border: 1px solid #666;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -348,45 +468,75 @@ th {
|
||||
text-shadow: 0 1px 1px #000;
|
||||
}
|
||||
|
||||
.history-toggle {
|
||||
.detail-toggle,
|
||||
.nation-archive-link {
|
||||
min-height: 24px;
|
||||
padding-block: 2px;
|
||||
}
|
||||
|
||||
.history-row td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
.detail-region {
|
||||
border: 1px solid #666;
|
||||
background: #101010;
|
||||
}
|
||||
|
||||
.history-row p {
|
||||
.detail-error {
|
||||
margin: 0;
|
||||
color: #bbb;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.history-error {
|
||||
color: #ff8d8d !important;
|
||||
.detail-source {
|
||||
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;
|
||||
gap: 5px;
|
||||
margin: 0;
|
||||
padding-left: 26px;
|
||||
color: #ddd;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.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) {
|
||||
.title-row,
|
||||
.season-heading {
|
||||
.season-heading,
|
||||
.season-identity,
|
||||
.season-meta {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.season-actions {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
.season-meta {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -133,6 +133,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
|
||||
kakaoGraceStartedAt: now.toISOString(),
|
||||
passwordSalt: password.salt,
|
||||
passwordHash: password.hash,
|
||||
passwordResetRequired: false,
|
||||
createdAt: now.toISOString(),
|
||||
};
|
||||
usersByName.set(input.username, user);
|
||||
@@ -150,6 +151,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
|
||||
const upgraded = await hasher.hash(password);
|
||||
user.passwordSalt = upgraded.salt;
|
||||
user.passwordHash = upgraded.hash;
|
||||
user.passwordResetRequired = false;
|
||||
}
|
||||
return verified.ok;
|
||||
},
|
||||
@@ -159,6 +161,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
|
||||
const next = await hasher.hash(password);
|
||||
user.passwordSalt = next.salt;
|
||||
user.passwordHash = next.hash;
|
||||
user.passwordResetRequired = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,9 @@ export const hasActiveSpecialAccountGrant = (
|
||||
grants: readonly SpecialAccountAccessGrantRecord[],
|
||||
now: Date = new Date()
|
||||
): boolean =>
|
||||
grants.some((grant) => !grant.revokedAt && (!grant.expiresAt || new Date(grant.expiresAt).getTime() > now.getTime()));
|
||||
grants.some(
|
||||
(grant) => !grant.revokedAt && (!grant.expiresAt || new Date(grant.expiresAt).getTime() > now.getTime())
|
||||
);
|
||||
|
||||
const appliesToProfile = (grant: SpecialAccountAccessGrantRecord, profile: string, profileName: string): boolean =>
|
||||
grant.profiles.length === 0 || grant.profiles.includes(profile) || grant.profiles.includes(profileName);
|
||||
@@ -67,9 +69,7 @@ const resolveSpecialAccess = (options: {
|
||||
const selected = active.find((grant) => grant.allowsGeneralCreation) ?? active[0]!;
|
||||
const expiresAt = active.some((grant) => !grant.expiresAt)
|
||||
? null
|
||||
: active
|
||||
.map((grant) => grant.expiresAt!)
|
||||
.sort((left, right) => right.localeCompare(left))[0] ?? null;
|
||||
: (active.map((grant) => grant.expiresAt!).sort((left, right) => right.localeCompare(left))[0] ?? null);
|
||||
return {
|
||||
kind: selected.kind,
|
||||
grantId: selected.id,
|
||||
@@ -98,7 +98,10 @@ export const resolveLocalAccountProfilePolicy = (options: {
|
||||
'localAccountGeneralCreationGraceDays',
|
||||
generalCreationDefault
|
||||
);
|
||||
const kakaoVerified = options.user.oauthType === 'KAKAO' && Boolean(options.user.kakaoVerifiedAt);
|
||||
const kakaoVerified =
|
||||
options.user.oauthType === 'KAKAO' &&
|
||||
Boolean(options.user.oauthId?.trim()) &&
|
||||
Boolean(options.user.kakaoVerifiedAt);
|
||||
const graceStartedAt = new Date(options.user.kakaoGraceStartedAt);
|
||||
const now = options.now ?? new Date();
|
||||
const specialAccess = resolveSpecialAccess({
|
||||
@@ -116,7 +119,9 @@ export const resolveLocalAccountProfilePolicy = (options: {
|
||||
const generalCreationEndsAt = new Date(graceStartedAt.getTime() + generalCreationGraceDays * DAY_MS);
|
||||
const accessAllowed = kakaoVerified || specialAccess !== null || now < accessEndsAt;
|
||||
const canCreateGeneral =
|
||||
kakaoVerified || specialAccess?.allowsGeneralCreation === true || (accessAllowed && now < generalCreationEndsAt);
|
||||
kakaoVerified ||
|
||||
specialAccess?.allowsGeneralCreation === true ||
|
||||
(accessAllowed && now < generalCreationEndsAt);
|
||||
|
||||
return {
|
||||
requiresKakaoVerification: !kakaoVerified && specialAccess === null,
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface OAuthPendingState {
|
||||
export interface OAuthSession {
|
||||
id: string;
|
||||
mode: OAuthMode;
|
||||
intent?: 'register' | 'link_existing' | 'rejoin';
|
||||
intent?: 'register' | 'link_existing' | 'rejoin' | 'password_setup';
|
||||
targetUserId?: string;
|
||||
kakaoId: string;
|
||||
email: string;
|
||||
@@ -93,6 +93,14 @@ end
|
||||
return cjson.encode({ status = 'verified', userId = challenge.userId })
|
||||
`;
|
||||
|
||||
const consumeOnceScript = `
|
||||
local raw = redis.call('GET', KEYS[1])
|
||||
if raw then
|
||||
redis.call('DEL', KEYS[1])
|
||||
end
|
||||
return raw
|
||||
`;
|
||||
|
||||
export class RedisOAuthSessionStore implements OAuthSessionStore {
|
||||
private readonly client: RedisClientLike;
|
||||
private readonly prefix: string;
|
||||
@@ -161,12 +169,11 @@ export class RedisOAuthSessionStore implements OAuthSessionStore {
|
||||
|
||||
async consumeSession(sessionId: string): Promise<OAuthSession | null> {
|
||||
const key = this.sessionKey(sessionId);
|
||||
const raw = await this.client.get(key);
|
||||
const raw = await this.client.eval(consumeOnceScript, { keys: [key], arguments: [] });
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
await this.client.del(key);
|
||||
return parseJson<OAuthSession>(raw);
|
||||
return typeof raw === 'string' ? parseJson<OAuthSession>(raw) : null;
|
||||
}
|
||||
|
||||
async getLoginChallengeForUser(userId: string): Promise<KakaoLoginChallenge | null> {
|
||||
|
||||
@@ -59,6 +59,7 @@ const mapUser = (row: {
|
||||
displayName: string;
|
||||
passwordHash: string;
|
||||
passwordSalt: string;
|
||||
passwordResetRequired: boolean;
|
||||
roles: GatewayPrisma.JsonValue;
|
||||
sanctions: GatewayPrisma.JsonValue;
|
||||
oauthType: 'NONE' | 'KAKAO';
|
||||
@@ -107,6 +108,7 @@ const mapUser = (row: {
|
||||
deleteAfter: row.deleteAfter?.toISOString(),
|
||||
passwordHash: row.passwordHash,
|
||||
passwordSalt: row.passwordSalt,
|
||||
passwordResetRequired: row.passwordResetRequired,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
legacyMemberNo: readLegacyMemberNo(row.legacyData),
|
||||
legacyGrade: readLegacyGrade(row.legacyData),
|
||||
@@ -250,6 +252,7 @@ export const createPostgresUserRepository = (
|
||||
displayName: input.displayName ?? input.username,
|
||||
passwordHash: password.hash,
|
||||
passwordSalt: password.salt,
|
||||
passwordResetRequired: false,
|
||||
roles: ['user'] satisfies GatewayPrisma.JsonArray,
|
||||
sanctions: {} satisfies GatewayPrisma.JsonObject,
|
||||
oauthType,
|
||||
@@ -274,10 +277,12 @@ export const createPostgresUserRepository = (
|
||||
data: {
|
||||
passwordHash: upgraded.hash,
|
||||
passwordSalt: upgraded.salt,
|
||||
passwordResetRequired: false,
|
||||
},
|
||||
});
|
||||
user.passwordHash = upgraded.hash;
|
||||
user.passwordSalt = upgraded.salt;
|
||||
user.passwordResetRequired = false;
|
||||
}
|
||||
return verified.ok;
|
||||
},
|
||||
@@ -288,6 +293,7 @@ export const createPostgresUserRepository = (
|
||||
data: {
|
||||
passwordHash: next.hash,
|
||||
passwordSalt: next.salt,
|
||||
passwordResetRequired: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface UserRecord {
|
||||
deleteAfter?: string;
|
||||
passwordHash: string;
|
||||
passwordSalt: string;
|
||||
passwordResetRequired: boolean;
|
||||
createdAt: string;
|
||||
legacyMemberNo?: number;
|
||||
legacyGrade?: number;
|
||||
@@ -128,7 +129,7 @@ export const toPublicUser = (user: UserRecord): PublicUser => ({
|
||||
displayName: user.displayName,
|
||||
roles: user.roles,
|
||||
picture: user.picture,
|
||||
kakaoVerified: user.oauthType === 'KAKAO' && Boolean(user.kakaoVerifiedAt),
|
||||
kakaoVerified: user.oauthType === 'KAKAO' && Boolean(user.oauthId?.trim()) && Boolean(user.kakaoVerifiedAt),
|
||||
kakaoGraceStartedAt: user.kakaoGraceStartedAt,
|
||||
createdAt: user.createdAt,
|
||||
});
|
||||
|
||||
@@ -91,6 +91,42 @@ const finishKakaoLogin = async <T extends 'login' | 'verified'>(
|
||||
};
|
||||
};
|
||||
|
||||
const finishKakaoLoginOrRequestPasswordSetup = async <T extends 'login' | 'verified'>(
|
||||
ctx: GatewayApiContext,
|
||||
user: UserRecord,
|
||||
accessToken: string,
|
||||
successStatus: T
|
||||
) => {
|
||||
if (!user.passwordResetRequired) {
|
||||
return finishKakaoLogin(ctx, user, accessToken, successStatus);
|
||||
}
|
||||
if (user.oauthType !== 'KAKAO' || !user.oauthId || !user.email) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '카카오 계정 연결 정보가 올바르지 않아 비밀번호를 설정할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
const oauthInfo = user.oauthInfo ?? {};
|
||||
const passwordSetup = await ctx.oauthSessions.createSession({
|
||||
mode: successStatus === 'verified' ? 'verify' : 'login',
|
||||
intent: 'password_setup',
|
||||
targetUserId: user.id,
|
||||
kakaoId: user.oauthId,
|
||||
email: user.email,
|
||||
accessToken,
|
||||
refreshToken: oauthInfo.refreshToken,
|
||||
accessTokenValidUntil: oauthInfo.accessTokenValidUntil ?? new Date().toISOString(),
|
||||
refreshTokenValidUntil: oauthInfo.refreshTokenValidUntil,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
return {
|
||||
status: 'password_setup' as const,
|
||||
oauthSessionId: passwordSetup.id,
|
||||
email: passwordSetup.email,
|
||||
successStatus,
|
||||
};
|
||||
};
|
||||
|
||||
export const appRouter = router({
|
||||
health: router({
|
||||
ping: procedure.query(() => ({
|
||||
@@ -348,7 +384,7 @@ export const appRouter = router({
|
||||
}
|
||||
const refreshed = (await ctx.users.findById(verified.id)) ?? verified;
|
||||
await ctx.flushPublisher.publishUserFlush(refreshed.id, 'kakao-verified');
|
||||
return finishKakaoLogin(ctx, refreshed, token.accessToken, 'verified');
|
||||
return finishKakaoLoginOrRequestPasswordSetup(ctx, refreshed, token.accessToken, 'verified');
|
||||
}
|
||||
|
||||
if (pending.mode === 'change_pw') {
|
||||
@@ -415,7 +451,7 @@ export const appRouter = router({
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
return finishKakaoLogin(ctx, synced, token.accessToken, 'login');
|
||||
return finishKakaoLoginOrRequestPasswordSetup(ctx, synced, token.accessToken, 'login');
|
||||
}
|
||||
|
||||
const joinOauthInfo = oauthInfoFromToken(token, tokenIssuedAt);
|
||||
@@ -562,7 +598,70 @@ export const appRouter = router({
|
||||
});
|
||||
}
|
||||
await ctx.flushPublisher.publishUserFlush(linked.id, 'kakao-account-relinked');
|
||||
return finishKakaoLogin(ctx, linked, oauthSession.accessToken, 'login');
|
||||
return finishKakaoLoginOrRequestPasswordSetup(ctx, linked, oauthSession.accessToken, 'login');
|
||||
}),
|
||||
kakaoSetPassword: procedure
|
||||
.input(
|
||||
z.object({
|
||||
oauthSessionId: z.string().uuid(),
|
||||
credential: zPasswordEnvelope,
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const password = openPassword(ctx.passwordEnvelope, input.credential);
|
||||
const oauthSession = await ctx.oauthSessions.consumeSession(input.oauthSessionId);
|
||||
if (!oauthSession || oauthSession.intent !== 'password_setup' || !oauthSession.targetUserId) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: '비밀번호 설정 세션이 만료되었습니다. 카카오 로그인을 다시 진행해 주세요.',
|
||||
});
|
||||
}
|
||||
const user = await ctx.users.findById(oauthSession.targetUserId);
|
||||
if (
|
||||
!user ||
|
||||
user.oauthType !== 'KAKAO' ||
|
||||
user.oauthId !== oauthSession.kakaoId ||
|
||||
user.email?.toLowerCase() !== oauthSession.email.toLowerCase() ||
|
||||
!user.passwordResetRequired
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: '카카오 계정 연결 상태가 변경되었습니다. 처음부터 다시 진행해 주세요.',
|
||||
});
|
||||
}
|
||||
if (user.deleteAfter) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Account deletion is pending.' });
|
||||
}
|
||||
if (isLoginBanned(user.sanctions)) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Account login is blocked.' });
|
||||
}
|
||||
let verifiedProfile;
|
||||
try {
|
||||
verifiedProfile = readVerifiedKakaoProfile(await ctx.kakaoClient.getMe(oauthSession.accessToken));
|
||||
} catch (error) {
|
||||
return throwKakaoVerificationError(error);
|
||||
}
|
||||
if (
|
||||
verifiedProfile.kakaoId !== oauthSession.kakaoId ||
|
||||
verifiedProfile.email !== oauthSession.email.toLowerCase()
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: '카카오 계정 정보가 비밀번호 설정 세션과 일치하지 않습니다.',
|
||||
});
|
||||
}
|
||||
await ctx.users.updatePassword(user.id, password);
|
||||
const refreshed = await ctx.users.findById(user.id);
|
||||
if (!refreshed) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '계정을 찾지 못했습니다.' });
|
||||
}
|
||||
await ctx.flushPublisher.publishUserFlush(refreshed.id, 'password-changed');
|
||||
return finishKakaoLogin(
|
||||
ctx,
|
||||
refreshed,
|
||||
oauthSession.accessToken,
|
||||
oauthSession.mode === 'verify' ? 'verified' : 'login'
|
||||
);
|
||||
}),
|
||||
register: procedure
|
||||
.input(
|
||||
|
||||
@@ -631,7 +631,7 @@ describe('gateway auth flow', () => {
|
||||
});
|
||||
|
||||
it('asks before relinking a new Kakao identity to the permanently retained email owner', async () => {
|
||||
const { caller, users, kakaoProfile, sentTalkMessages, flushPublisher } = buildCaller();
|
||||
const { caller, users, kakaoProfile, sealPassword, sentTalkMessages, flushPublisher } = buildCaller();
|
||||
const emailOwner = await users.createUser({
|
||||
username: 'email-owner',
|
||||
password: 'owner-password',
|
||||
@@ -642,6 +642,7 @@ describe('gateway auth flow', () => {
|
||||
info: {},
|
||||
},
|
||||
});
|
||||
emailOwner.passwordResetRequired = true;
|
||||
await users.markKakaoTalkVerified(emailOwner.id, new Date(Date.now() + 60_000));
|
||||
kakaoProfile.id = 'different-kakao-id';
|
||||
|
||||
@@ -659,7 +660,14 @@ describe('gateway auth flow', () => {
|
||||
oauthSessionId: recovery.oauthSessionId,
|
||||
action: 'link_existing',
|
||||
});
|
||||
expect(linked.status).toBe('otp');
|
||||
expect(linked.status).toBe('password_setup');
|
||||
if (linked.status !== 'password_setup') throw new Error('Expected migrated password setup.');
|
||||
expect(sentTalkMessages).toHaveLength(0);
|
||||
const passwordSet = await caller.auth.kakaoSetPassword({
|
||||
oauthSessionId: linked.oauthSessionId,
|
||||
credential: sealPassword('replacement-password'),
|
||||
});
|
||||
expect(passwordSet.status).toBe('otp');
|
||||
expect(sentTalkMessages).toHaveLength(1);
|
||||
expect(await users.findByOauthId('KAKAO', 'original-kakao-id')).toBeNull();
|
||||
expect(await users.findByOauthId('KAKAO', 'different-kakao-id')).toMatchObject({
|
||||
@@ -668,6 +676,108 @@ describe('gateway auth flow', () => {
|
||||
email: 'tester@example.com',
|
||||
});
|
||||
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'kakao-account-relinked');
|
||||
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'password-changed');
|
||||
});
|
||||
|
||||
it('requires a one-time password setup before an imported Kakao account can receive a session', async () => {
|
||||
const { caller, users, sessions, kakaoProfile, sealPassword, sentTalkMessages } = buildCaller({
|
||||
kakaoId: 'imported-kakao-id',
|
||||
kakaoEmail: 'imported@example.com',
|
||||
});
|
||||
const user = await users.createUser({
|
||||
username: 'imported-kakao-user',
|
||||
password: 'legacy-password',
|
||||
oauth: {
|
||||
type: 'KAKAO',
|
||||
id: kakaoProfile.id,
|
||||
email: kakaoProfile.email,
|
||||
info: {},
|
||||
},
|
||||
});
|
||||
user.passwordResetRequired = true;
|
||||
const createSession = vi.spyOn(sessions, 'createSession');
|
||||
|
||||
const start = await caller.auth.kakaoStart({ mode: 'login' });
|
||||
const login = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
|
||||
expect(login).toMatchObject({
|
||||
status: 'password_setup',
|
||||
email: 'imported@example.com',
|
||||
successStatus: 'login',
|
||||
});
|
||||
if (login.status !== 'password_setup') throw new Error('Expected migrated password setup.');
|
||||
expect(login).not.toHaveProperty('sessionToken');
|
||||
expect(createSession).not.toHaveBeenCalled();
|
||||
expect(sentTalkMessages).toHaveLength(0);
|
||||
|
||||
const setup = await caller.auth.kakaoSetPassword({
|
||||
oauthSessionId: login.oauthSessionId,
|
||||
credential: sealPassword('new-imported-password'),
|
||||
});
|
||||
expect(setup.status).toBe('otp');
|
||||
expect((await users.findById(user.id))?.passwordResetRequired).toBe(false);
|
||||
expect(await users.verifyPassword(user, 'new-imported-password')).toBe(true);
|
||||
await expect(
|
||||
caller.auth.kakaoSetPassword({
|
||||
oauthSessionId: login.oauthSessionId,
|
||||
credential: sealPassword('another-password'),
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||
});
|
||||
|
||||
it('rechecks sanctions before consuming a migrated password setup', async () => {
|
||||
const { caller, users, kakaoProfile, sealPassword } = buildCaller({
|
||||
kakaoId: 'sanctioned-setup-id',
|
||||
kakaoEmail: 'sanctioned-setup@example.com',
|
||||
});
|
||||
const user = await users.createUser({
|
||||
username: 'sanctioned-setup-user',
|
||||
password: 'legacy-password',
|
||||
oauth: { type: 'KAKAO', id: kakaoProfile.id, email: kakaoProfile.email, info: {} },
|
||||
});
|
||||
user.passwordResetRequired = true;
|
||||
const start = await caller.auth.kakaoStart({ mode: 'login' });
|
||||
const login = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
|
||||
if (login.status !== 'password_setup') throw new Error('Expected migrated password setup.');
|
||||
await users.updateSanctions(user.id, { bannedUntil: '2099-01-01T00:00:00.000Z' });
|
||||
|
||||
await expect(
|
||||
caller.auth.kakaoSetPassword({
|
||||
oauthSessionId: login.oauthSessionId,
|
||||
credential: sealPassword('blocked-password'),
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
expect((await users.findById(user.id))?.passwordResetRequired).toBe(true);
|
||||
});
|
||||
|
||||
it('consumes password setup when the provider identity changes before submission', async () => {
|
||||
const { caller, users, kakaoProfile, sealPassword } = buildCaller({
|
||||
kakaoId: 'setup-target-id',
|
||||
kakaoEmail: 'setup-target@example.com',
|
||||
});
|
||||
const user = await users.createUser({
|
||||
username: 'setup-target-user',
|
||||
password: 'legacy-password',
|
||||
oauth: { type: 'KAKAO', id: kakaoProfile.id, email: kakaoProfile.email, info: {} },
|
||||
});
|
||||
user.passwordResetRequired = true;
|
||||
const start = await caller.auth.kakaoStart({ mode: 'login' });
|
||||
const login = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
|
||||
if (login.status !== 'password_setup') throw new Error('Expected migrated password setup.');
|
||||
kakaoProfile.id = 'changed-provider-id';
|
||||
|
||||
await expect(
|
||||
caller.auth.kakaoSetPassword({
|
||||
oauthSessionId: login.oauthSessionId,
|
||||
credential: sealPassword('new-target-password'),
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||
expect((await users.findById(user.id))?.passwordResetRequired).toBe(true);
|
||||
await expect(
|
||||
caller.auth.kakaoSetPassword({
|
||||
oauthSessionId: login.oauthSessionId,
|
||||
credential: sealPassword('new-target-password'),
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||
});
|
||||
|
||||
it('asks for rejoin confirmation when Kakao is already registered but no retained email owner exists', async () => {
|
||||
@@ -1147,6 +1257,7 @@ describe('account self service', () => {
|
||||
username: 'self-service',
|
||||
password: 'current-password',
|
||||
});
|
||||
user.passwordResetRequired = true;
|
||||
const session = await sessions.createSession(user);
|
||||
|
||||
await expect(
|
||||
@@ -1165,6 +1276,7 @@ describe('account self service', () => {
|
||||
|
||||
const refreshed = await users.findById(user.id);
|
||||
expect(refreshed && (await users.verifyPassword(refreshed, 'next-password'))).toBe(true);
|
||||
expect(refreshed?.passwordResetRequired).toBe(false);
|
||||
});
|
||||
|
||||
it('revokes the session and schedules deletion after 30 days', async () => {
|
||||
|
||||
@@ -16,6 +16,7 @@ const buildLocalUser = (graceStartedAt: Date): UserRecord => ({
|
||||
kakaoGraceStartedAt: graceStartedAt.toISOString(),
|
||||
passwordHash: 'unused',
|
||||
passwordSalt: '',
|
||||
passwordResetRequired: false,
|
||||
createdAt: graceStartedAt.toISOString(),
|
||||
});
|
||||
|
||||
@@ -90,6 +91,25 @@ describe('local account profile policy', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not trust a migrated Kakao marker without a valid provider ID', () => {
|
||||
const user = buildLocalUser(new Date('2020-01-01T00:00:00.000Z'));
|
||||
user.oauthType = 'KAKAO';
|
||||
user.oauthId = ' ';
|
||||
user.kakaoVerifiedAt = '2026-07-26T00:00:00.000Z';
|
||||
const policy = resolveLocalAccountProfilePolicy({
|
||||
profile: 'che',
|
||||
defaultGraceDays: 0,
|
||||
user,
|
||||
now: new Date('2026-07-26T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(policy).toMatchObject({
|
||||
kakaoVerified: false,
|
||||
requiresKakaoVerification: true,
|
||||
accessAllowed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('extends account access with an administrator override without widening general creation grace', () => {
|
||||
const user = buildLocalUser(new Date('2026-07-20T00:00:00.000Z'));
|
||||
user.kakaoGraceUntil = '2026-08-20T00:00:00.000Z';
|
||||
|
||||
@@ -61,13 +61,15 @@ describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao state', () => {
|
||||
});
|
||||
sessionIds.add(session.id);
|
||||
|
||||
await expect(store.consumeSession(session.id)).resolves.toMatchObject({
|
||||
await expect(client.ttl(`${prefix}:oauth-session:${session.id}`)).resolves.toBeGreaterThan(0);
|
||||
const consumed = await Promise.all([store.consumeSession(session.id), store.consumeSession(session.id)]);
|
||||
expect(consumed.filter((value) => value !== null)).toHaveLength(1);
|
||||
expect(consumed.find((value) => value !== null)).toMatchObject({
|
||||
id: session.id,
|
||||
intent: 'link_existing',
|
||||
targetUserId,
|
||||
email: 'retained@example.test',
|
||||
});
|
||||
await expect(store.consumeSession(session.id)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('atomically consumes a successful code once', async () => {
|
||||
|
||||
@@ -34,10 +34,12 @@ describe('password credential compatibility', () => {
|
||||
});
|
||||
user.passwordSalt = 'core-salt';
|
||||
user.passwordHash = createHash('sha256').update('core-salt:current-password').digest('hex');
|
||||
user.passwordResetRequired = true;
|
||||
|
||||
expect(await users.verifyPassword(user, 'current-password')).toBe(true);
|
||||
expect(user.passwordHash.startsWith('$argon2id$')).toBe(true);
|
||||
expect(user.passwordSalt).toBe('');
|
||||
expect(user.passwordResetRequired).toBe(false);
|
||||
});
|
||||
|
||||
it('upgrades an imported ref double-SHA-512 credential after a successful login', async () => {
|
||||
@@ -52,10 +54,12 @@ describe('password credential compatibility', () => {
|
||||
const browserHash = createHash('sha512').update(`${globalSalt}current-password${globalSalt}`).digest('hex');
|
||||
user.passwordSalt = userSalt;
|
||||
user.passwordHash = createHash('sha512').update(`${userSalt}${browserHash}${userSalt}`).digest('hex');
|
||||
user.passwordResetRequired = true;
|
||||
|
||||
expect(await users.verifyPassword(user, 'current-password')).toBe(true);
|
||||
expect(user.passwordHash.startsWith('$argon2id$')).toBe(true);
|
||||
expect(user.passwordSalt).toBe('');
|
||||
expect(user.passwordResetRequired).toBe(false);
|
||||
});
|
||||
|
||||
it('does not accept an imported ref credential without the matching global salt', async () => {
|
||||
|
||||
@@ -38,8 +38,8 @@ describe('readReleaseManifest', () => {
|
||||
|
||||
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
||||
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
||||
gatewaySchemaHead: '20260817000000_remove_profile_manage_capability',
|
||||
gameSchemaHead: '20260817000000_add_general_access_batch',
|
||||
gatewaySchemaHead: '20260817001000_remove_profile_manage_capability',
|
||||
gameSchemaHead: '20260817001000_add_dedicated_legacy_archive',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,11 +61,7 @@ describe('readReleaseManifest', () => {
|
||||
|
||||
it('allows only the explicit controller self-upgrade boundary to cross protocol versions', async () => {
|
||||
const futureProtocol = RELEASE_CONTROLLER_PROTOCOL + 1;
|
||||
const workspace = await createWorkspace(
|
||||
'20260801000000_gateway',
|
||||
'20260801000000_game',
|
||||
futureProtocol
|
||||
);
|
||||
const workspace = await createWorkspace('20260801000000_gateway', '20260801000000_game', futureProtocol);
|
||||
|
||||
await expect(readReleaseManifest(workspace)).rejects.toThrow(
|
||||
`Release requires controller protocol ${futureProtocol}`
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
@@ -6,6 +8,43 @@ const operationNames = (route: Route): string[] => {
|
||||
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||
};
|
||||
|
||||
const { publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
|
||||
|
||||
const installPasswordSetupFixture = async (page: Page) => {
|
||||
const calls: string[] = [];
|
||||
await page.route('**/gateway/api/trpc/**', async (route) => {
|
||||
const results = operationNames(route).map((operation) => {
|
||||
calls.push(operation);
|
||||
if (operation === 'me') return response(null);
|
||||
if (operation === 'lobby.notice') return response('');
|
||||
if (operation === 'lobby.profiles') return response([]);
|
||||
if (operation === 'auth.passwordKey') {
|
||||
return response({ keyId: 'password-setup-key', publicKeyPem, algorithm: 'RSA-OAEP-256' });
|
||||
}
|
||||
if (operation === 'auth.kakaoExchange') {
|
||||
return response({
|
||||
status: 'password_setup',
|
||||
oauthSessionId: '11111111-1111-4111-8111-111111111112',
|
||||
email: 'migrated@example.test',
|
||||
successStatus: 'login',
|
||||
});
|
||||
}
|
||||
if (operation === 'auth.kakaoSetPassword') {
|
||||
return response({
|
||||
status: 'otp',
|
||||
challengeId: '11111111-1111-4111-8111-111111111111',
|
||||
expiresAt: '2026-08-17T12:03:00.000Z',
|
||||
attemptsRemaining: 3,
|
||||
});
|
||||
}
|
||||
throw new Error(`Unhandled password setup fixture operation: ${operation}`);
|
||||
});
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
|
||||
});
|
||||
return calls;
|
||||
};
|
||||
|
||||
const installFixture = async (page: Page, action: 'link_existing' | 'rejoin') => {
|
||||
const calls: string[] = [];
|
||||
await page.route('**/gateway/api/trpc/**', async (route) => {
|
||||
@@ -108,4 +147,27 @@ for (const viewport of [
|
||||
expect(calls.filter((operation) => operation === 'auth.kakaoResolveAccount')).toHaveLength(1);
|
||||
expect(geometry.width).toBe(viewport.name === 'desktop' ? 698 : 372);
|
||||
});
|
||||
|
||||
test(`sets a migrated password before opening the OTP dialog on ${viewport.name}`, async ({ page }) => {
|
||||
const calls = await installPasswordSetupFixture(page);
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto('/gateway/oauth/callback?code=oauth-code&state=oauth-state');
|
||||
|
||||
const form = page.getByRole('form', { name: '새 비밀번호 설정' });
|
||||
await expect(form).toBeVisible();
|
||||
await expect(form).toContainText('카카오 인증으로 기존 계정을 확인했습니다.');
|
||||
await expect(form.getByLabel('카카오 이메일')).toHaveValue('migrated@example.test');
|
||||
const geometry = await form.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { width: rect.width, right: rect.right };
|
||||
});
|
||||
await form.getByLabel('새 비밀번호').fill('new-password-value');
|
||||
await form.getByLabel('비밀번호 확인').fill('new-password-value');
|
||||
await form.getByRole('button', { name: '새 비밀번호 설정' }).click();
|
||||
|
||||
await expect(page.getByRole('dialog', { name: '인증 코드 필요' })).toBeVisible();
|
||||
expect(calls.filter((operation) => operation === 'auth.kakaoSetPassword')).toHaveLength(1);
|
||||
expect(geometry.width).toBeGreaterThan(300);
|
||||
expect(geometry.right).toBeLessThanOrEqual(viewport.width);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -219,7 +219,13 @@ test('shows one public map panel and switches it by hover, click, and keyboard',
|
||||
await expect(tablist.getByRole('tab', { name: '퀘섭' })).toHaveCount(0);
|
||||
await expect(panel).toHaveCount(1);
|
||||
await expect(cheTab).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(panel).toContainText('유저 11 / 500');
|
||||
await expect(panel.getByText('체섭', { exact: true })).toHaveCount(0);
|
||||
await expect(panel.locator('.map-preview-title')).toHaveCount(0);
|
||||
const dateBar = panel.locator('.map-preview-date-bar');
|
||||
await expect(dateBar).toHaveText('200년 1월');
|
||||
const summary = panel.getByTestId('public-map-preview-summary');
|
||||
await expect(summary).toContainText('RUNNING');
|
||||
await expect(summary).toContainText('유저 11 / 500');
|
||||
|
||||
const roadLayer = panel.getByTestId('map-preview-road');
|
||||
const castles = panel.getByTestId('map-preview-castle');
|
||||
@@ -251,6 +257,32 @@ test('shows one public map panel and switches it by hover, click, and keyboard',
|
||||
largeCastle: { width: 32, height: 24 },
|
||||
largeNationBackground: { width: 96, height: 72 },
|
||||
});
|
||||
const dateGeometry = await dateBar.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const bodyRect = element.nextElementSibling?.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
const textStyle = getComputedStyle(element.firstElementChild!);
|
||||
return {
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
bottom: rect.bottom,
|
||||
bodyTop: bodyRect?.top,
|
||||
backgroundColor: style.backgroundColor,
|
||||
justifyContent: style.justifyContent,
|
||||
fontSize: textStyle.fontSize,
|
||||
lineHeight: textStyle.lineHeight,
|
||||
};
|
||||
});
|
||||
expect(dateGeometry).toEqual({
|
||||
width: 700,
|
||||
height: 20,
|
||||
bottom: dateGeometry.bodyTop,
|
||||
bodyTop: dateGeometry.bodyTop,
|
||||
backgroundColor: 'rgb(0, 0, 0)',
|
||||
justifyContent: 'center',
|
||||
fontSize: '14px',
|
||||
lineHeight: '20px',
|
||||
});
|
||||
if (requestedAssets) {
|
||||
await expect.poll(() => requestedAssets.has('/game/map/che/che_road.png')).toBe(true);
|
||||
await expect.poll(() => requestedAssets.has('/game/cast_8.gif')).toBe(true);
|
||||
@@ -297,7 +329,9 @@ test('shows one public map panel and switches it by hover, click, and keyboard',
|
||||
|
||||
await hweTab.hover();
|
||||
await expect(hweTab).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(panel).toContainText('유저 22 / 500');
|
||||
await expect(panel.getByText('훼섭', { exact: true })).toHaveCount(0);
|
||||
await expect(summary).toContainText('PAUSED');
|
||||
await expect(summary).toContainText('유저 22 / 500');
|
||||
|
||||
await cheTab.click();
|
||||
await expect(cheTab).toHaveAttribute('aria-selected', 'true');
|
||||
@@ -322,7 +356,7 @@ test('shows one public map panel and switches it by hover, click, and keyboard',
|
||||
await page.screenshot({ path: testInfo.outputPath('public-map-tabs-desktop.png'), fullPage: true });
|
||||
await testInfo.attach('public-map-tabs-desktop-geometry', {
|
||||
body: Buffer.from(
|
||||
`${JSON.stringify({ panel: geometry, map: mapGeometry, tooltip: tooltipGeometry }, null, 2)}\n`
|
||||
`${JSON.stringify({ panel: geometry, date: dateGeometry, map: mapGeometry, tooltip: tooltipGeometry }, null, 2)}\n`
|
||||
),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
@@ -344,13 +378,20 @@ test.describe('touch navigation', () => {
|
||||
await hweTab.tap();
|
||||
await expect(hweTab).toHaveAttribute('aria-selected', 'true');
|
||||
const panel = page.getByTestId('public-map-preview-panel');
|
||||
await expect(panel).toContainText('유저 22 / 500');
|
||||
await expect(panel.getByText('훼섭', { exact: true })).toHaveCount(0);
|
||||
await expect(panel.locator('.map-preview-title')).toHaveCount(0);
|
||||
await expect(panel.locator('.map-preview-date-bar')).toHaveText('200년 1월');
|
||||
const summary = panel.getByTestId('public-map-preview-summary');
|
||||
await expect(summary).toContainText('PAUSED');
|
||||
await expect(summary).toContainText('유저 22 / 500');
|
||||
await expect(panel.getByTestId('map-preview-road')).toBeVisible();
|
||||
await expect(panel.getByTestId('map-preview-castle')).toHaveCount(2);
|
||||
const mapBox = await panel.locator('.map-preview-body').boundingBox();
|
||||
expect(mapBox?.width).toBeGreaterThan(280);
|
||||
expect(mapBox?.width).toBeLessThanOrEqual(366);
|
||||
expect(mapBox?.height).toBeCloseTo((mapBox?.width ?? 0) * (5 / 7), 0);
|
||||
const horizontalOverflow = await panel.evaluate((element) => element.scrollWidth - element.clientWidth);
|
||||
expect(horizontalOverflow).toBe(0);
|
||||
const rightCity = panel.getByTestId('map-preview-city').nth(1);
|
||||
await rightCity.hover();
|
||||
const tooltip = panel.getByTestId('map-preview-city-tooltip');
|
||||
|
||||
@@ -276,8 +276,7 @@ const stateClass = (state: number): string => {
|
||||
|
||||
<template>
|
||||
<div class="map-preview" :class="`map-preview-${props.mode}`">
|
||||
<div class="map-preview-header">
|
||||
<span class="map-preview-title">{{ props.mapLayout.mapName }}</span>
|
||||
<div class="map-preview-date-bar">
|
||||
<span class="map-preview-date">{{ props.mapData.year }}년 {{ props.mapData.month }}월</span>
|
||||
</div>
|
||||
<div ref="mapBody" class="map-preview-body" :style="{ backgroundImage: `url('${mapBackground}')` }">
|
||||
@@ -358,18 +357,22 @@ const stateClass = (state: number): string => {
|
||||
width: min(100%, 700px);
|
||||
margin-inline: auto;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.map-preview-header {
|
||||
.map-preview-date-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.7rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
height: 20px;
|
||||
justify-content: center;
|
||||
background: #000;
|
||||
color: rgba(232, 221, 196, 0.82);
|
||||
}
|
||||
|
||||
.map-preview-title {
|
||||
font-weight: 600;
|
||||
.map-preview-date {
|
||||
display: block;
|
||||
width: 160px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.map-preview-body {
|
||||
|
||||
@@ -700,12 +700,6 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
class="map-preview-panel"
|
||||
data-testid="public-map-preview-panel"
|
||||
>
|
||||
<div class="flex items-center justify-between text-xs text-zinc-400 mb-2">
|
||||
<span class="font-semibold" :style="{ color: selectedMapProfile.color }">
|
||||
{{ selectedMapProfile.korName }}섭
|
||||
</span>
|
||||
<span>{{ selectedMapProfile.status }}</span>
|
||||
</div>
|
||||
<div v-if="selectedMapPreview">
|
||||
<MapPreview
|
||||
:map-data="selectedMapPreview.mapData"
|
||||
@@ -714,12 +708,18 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
/>
|
||||
<div
|
||||
v-if="profileDetails[selectedMapProfile.profileName]"
|
||||
class="text-xs text-zinc-400 mt-2"
|
||||
class="map-preview-summary"
|
||||
data-testid="public-map-preview-summary"
|
||||
>
|
||||
유저 {{ profileDetails[selectedMapProfile.profileName]?.userCnt ?? '-' }} /
|
||||
{{ profileDetails[selectedMapProfile.profileName]?.maxUserCnt ?? '-' }} ·
|
||||
{{ profileDetails[selectedMapProfile.profileName]?.nationCnt ?? '-' }}국 ·
|
||||
{{ profileDetails[selectedMapProfile.profileName]?.turnTerm ?? '-' }}분 턴
|
||||
<span class="map-preview-runtime-status" :style="{ color: selectedMapProfile.color }">
|
||||
{{ selectedMapProfile.status }}
|
||||
</span>
|
||||
<span>
|
||||
유저 {{ profileDetails[selectedMapProfile.profileName]?.userCnt ?? '-' }} /
|
||||
{{ profileDetails[selectedMapProfile.profileName]?.maxUserCnt ?? '-' }}
|
||||
</span>
|
||||
<span>{{ profileDetails[selectedMapProfile.profileName]?.nationCnt ?? '-' }}국</span>
|
||||
<span>{{ profileDetails[selectedMapProfile.profileName]?.turnTerm ?? '-' }}분 턴</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-zinc-500 py-8 text-center">지도를 불러오는 중...</div>
|
||||
@@ -832,6 +832,26 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.map-preview-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px 0;
|
||||
margin-top: 8px;
|
||||
color: #a1a1aa;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.map-preview-summary > span + span::before {
|
||||
margin-inline: 6px;
|
||||
color: #52525b;
|
||||
content: '·';
|
||||
}
|
||||
|
||||
.map-preview-runtime-status {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.legacy-logout-button:hover,
|
||||
.legacy-logout-button:focus,
|
||||
.legacy-logout-button:active {
|
||||
|
||||
@@ -14,6 +14,8 @@ const submitting = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const infoMessage = ref('');
|
||||
const oauthSessionId = ref('');
|
||||
const passwordSetupSessionId = ref('');
|
||||
const passwordSetupSuccessStatus = ref<'login' | 'verified'>('login');
|
||||
const email = ref('');
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
@@ -60,6 +62,12 @@ const completeExchange = async (): Promise<void> => {
|
||||
infoMessage.value = '카카오톡으로 임시 비밀번호를 보냈습니다.';
|
||||
return;
|
||||
}
|
||||
if (result.status === 'password_setup') {
|
||||
passwordSetupSessionId.value = result.oauthSessionId;
|
||||
passwordSetupSuccessStatus.value = result.successStatus;
|
||||
email.value = result.email;
|
||||
return;
|
||||
}
|
||||
if (result.status === 'account_recovery') {
|
||||
accountRecovery.value = result;
|
||||
email.value = result.email;
|
||||
@@ -94,6 +102,12 @@ const resolveAccount = async (): Promise<void> => {
|
||||
await router.replace('/lobby');
|
||||
return;
|
||||
}
|
||||
if (result.status === 'password_setup') {
|
||||
passwordSetupSessionId.value = result.oauthSessionId;
|
||||
passwordSetupSuccessStatus.value = result.successStatus;
|
||||
email.value = result.email;
|
||||
return;
|
||||
}
|
||||
oauthSessionId.value = result.oauthSessionId;
|
||||
email.value = result.email;
|
||||
} catch (error) {
|
||||
@@ -103,6 +117,36 @@ const resolveAccount = async (): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
const setMigratedPassword = async (): Promise<void> => {
|
||||
errorMessage.value = '';
|
||||
if (password.value !== confirmPassword.value) {
|
||||
errorMessage.value = '비밀번호 확인이 일치하지 않습니다.';
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const credential = await sealPassword(password.value);
|
||||
const result = await trpc.auth.kakaoSetPassword.mutate({
|
||||
oauthSessionId: passwordSetupSessionId.value,
|
||||
credential,
|
||||
});
|
||||
password.value = '';
|
||||
confirmPassword.value = '';
|
||||
passwordSetupSessionId.value = '';
|
||||
if (result.status === 'otp') {
|
||||
otpChallenge.value = result;
|
||||
otpSuccessStatus.value = passwordSetupSuccessStatus.value;
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem('sammo-session-token', result.sessionToken);
|
||||
await router.replace(result.status === 'verified' ? '/lobby?verified=1' : '/lobby');
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '새 비밀번호를 설정하지 못했습니다.';
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (): Promise<void> => {
|
||||
errorMessage.value = '';
|
||||
if (password.value !== confirmPassword.value) {
|
||||
@@ -156,7 +200,15 @@ onMounted(() => {
|
||||
<main id="oauth-container">
|
||||
<h1>삼국지 모의전투 HiDCHe</h1>
|
||||
<section class="oauth-card">
|
||||
<h2>{{ accountRecovery ? '카카오 계정 연결 확인' : '회원가입' }}</h2>
|
||||
<h2>
|
||||
{{
|
||||
accountRecovery
|
||||
? '카카오 계정 연결 확인'
|
||||
: passwordSetupSessionId
|
||||
? '새 비밀번호 설정'
|
||||
: '회원가입'
|
||||
}}
|
||||
</h2>
|
||||
<p v-if="loading" class="oauth-message">카카오 인증을 확인하는 중...</p>
|
||||
<p v-else-if="infoMessage" class="oauth-message" role="status">{{ infoMessage }}</p>
|
||||
<div v-else-if="accountRecovery" class="recovery-panel" role="group" aria-label="카카오 계정 연결 확인">
|
||||
@@ -181,6 +233,46 @@ onMounted(() => {
|
||||
<RouterLink class="back-link" to="/">취소</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
v-else-if="passwordSetupSessionId"
|
||||
class="password-setup-form"
|
||||
aria-label="새 비밀번호 설정"
|
||||
@submit.prevent="setMigratedPassword"
|
||||
>
|
||||
<p class="oauth-message">
|
||||
카카오 인증으로 기존 계정을 확인했습니다. 앞으로 사용할 새 비밀번호를 설정해 주세요.
|
||||
</p>
|
||||
<div class="form-row">
|
||||
<label for="migrated-password-email">카카오 이메일</label>
|
||||
<input id="migrated-password-email" :value="email" readonly />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="migrated-password">새 비밀번호</label>
|
||||
<input
|
||||
id="migrated-password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
minlength="6"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="migrated-password-confirm">비밀번호 확인</label>
|
||||
<input
|
||||
id="migrated-password-confirm"
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
minlength="6"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button class="register-button" type="submit" :disabled="submitting">
|
||||
{{ submitting ? '설정 중...' : '새 비밀번호 설정' }}
|
||||
</button>
|
||||
<RouterLink class="back-link" to="/">취소</RouterLink>
|
||||
</form>
|
||||
<form v-else-if="oauthSessionId" @submit.prevent="register">
|
||||
<div class="form-row">
|
||||
<label for="oauth-email">카카오 이메일</label>
|
||||
|
||||
+80
-37
@@ -9,6 +9,13 @@ PostgreSQL advisory locks serialize an apply per profile, and every target row
|
||||
uses a stable legacy key with `ON CONFLICT`, so an interrupted run is
|
||||
repeatable.
|
||||
|
||||
Gateway apply is one PostgreSQL transaction. A game apply records a
|
||||
`legacy_archive.import_run`: archive and current-user projection writes commit
|
||||
together with `COMPLETED`, while a rollback leaves a `FAILED` run record. A
|
||||
repeat import updates archive-owned rows but does not replace a live Gateway
|
||||
account's password, reset status, login/display identity, OAuth connection,
|
||||
roles, sanctions, consent, icon or login timestamps.
|
||||
|
||||
The source of truth for eligibility is the checked ref schema, not every table
|
||||
that happens to exist in a dump. Tables outside that schema remain only in the
|
||||
recovery dump.
|
||||
@@ -28,18 +35,26 @@ Legacy member numbers map to deterministic UUIDs. Existing rows are updated by
|
||||
that UUID, so references such as `ng_old_generals.owner` remain stable even
|
||||
when an old account was deleted before the dump.
|
||||
|
||||
Kakao members retain `oauth_id`, email and metadata. A parseable legacy
|
||||
Kakao members retain `oauth_id`, email and metadata. A non-empty provider ID is
|
||||
required before an imported row is marked Kakao-verified. A parseable legacy
|
||||
`token_valid_until` is copied to `kakao_talk_verified_until`, preserving the
|
||||
remaining KakaoTalk ownership-proof interval instead of forcing an immediate
|
||||
message at cutover. Cutover also sets `kakao_verified_at` and
|
||||
message at cutover. Valid provider rows also receive `kakao_verified_at`; all
|
||||
rows receive
|
||||
`kakao_grace_started_at` to the migration time and starts the local-account
|
||||
verification grace period there. Source rows without an OAuth ID retain their
|
||||
metadata, but the importer does not invent a provider identifier.
|
||||
metadata but are not treated as verified, and the importer never invents a
|
||||
provider identifier.
|
||||
|
||||
Legacy password hashes remain usable when gateway-api has
|
||||
`GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT`; a successful login upgrades the stored
|
||||
value to Argon2id. A test-only account can instead be reset with the CLI and a
|
||||
mode-0600 password file:
|
||||
Every imported 128-hex legacy password is marked `password_reset_required`.
|
||||
The dump contains the per-user salt but not Ref's installation-wide salt, so
|
||||
the dump alone cannot validate the old plaintext password. If the original
|
||||
`GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT` is recovered through the runtime secret,
|
||||
a successful password login upgrades the value to Argon2id and clears the
|
||||
flag. Otherwise, a Kakao login (including a confirmed retained-email relink)
|
||||
issues a one-time password-setup challenge before any normal session; the new
|
||||
password is sent in the existing RSA envelope and clears the flag. Accounts
|
||||
without usable Kakao recovery require the CLI and a mode-0600 password file:
|
||||
|
||||
```sh
|
||||
GATEWAY_DATABASE_URL=... pnpm migrate:legacy -- \
|
||||
@@ -50,24 +65,43 @@ The password is never accepted as an argument or printed.
|
||||
|
||||
### Game profiles
|
||||
|
||||
| Legacy table | Target | Policy |
|
||||
| ------------------------------- | ------------------------ | --------------------------------------------------------------------- |
|
||||
| `ng_games` | `ng_games` | Preserve completed season metadata |
|
||||
| `hall` | `hall` | Preserve hall-of-fame rows |
|
||||
| `ng_old_generals` | `ng_old_generals` | Preserve full JSON snapshots and owner |
|
||||
| `ng_old_nations` | `ng_old_nations` | Preserve all versions, including duplicate server/nation pairs |
|
||||
| `emperior` | `emperior` | Preserve dynasty detail and legacy key |
|
||||
| `inheritance_result` | `inheritance_result` | Preserve result JSON/string and legacy key |
|
||||
| `user_record` | `inheritance_log` | Preserve complete long-lived user record |
|
||||
| persistent `storage` namespaces | `legacy_game_storage` | Preserve raw `inheritance_*` and `user_*` rows before projection |
|
||||
| `storage:inheritance_point` | `inheritance_point` | Project the numeric first tuple item; retain the tuple in raw storage |
|
||||
| `storage:user` | `inheritance_user_state` | Project known current inheritance state; retain raw storage |
|
||||
| `ng_history` | `yearbook_history` | Preserve map, nation, global history and global action snapshots |
|
||||
| Legacy table | Dedicated target | Policy |
|
||||
| ------------------------------- | ----------------------------- | --------------------------------------------------------------------- |
|
||||
| `ng_games` | `legacy_archive.game_history` | Preserve source profile, opening date, scenario and raw environment |
|
||||
| `hall` | `legacy_archive.hall` | Preserve hall-of-fame rows without mixing current records |
|
||||
| `ng_old_generals` | `legacy_archive.general` | Preserve canonical V1 plus private raw JSON and owner |
|
||||
| `ng_old_nations` | `legacy_archive.nation` | Preserve all versions with profile and legacy primary key |
|
||||
| `emperior` | `legacy_archive.emperor` | Preserve dynasty detail under a central archive ID |
|
||||
| `inheritance_result` | `inheritance_result` | Preserve result JSON/string and legacy key |
|
||||
| `user_record` | `inheritance_log` | Preserve complete long-lived user record |
|
||||
| persistent `storage` namespaces | `legacy_game_storage` | Preserve raw `inheritance_*` and `user_*` rows before projection |
|
||||
| `storage:inheritance_point` | `inheritance_point` | Project the numeric first tuple item; retain the tuple in raw storage |
|
||||
| `storage:user` | `inheritance_user_state` | Project known current inheritance state; retain raw storage |
|
||||
| `ng_history` | `legacy_archive.yearbook` | Preserve map, nation, global history and global action snapshots |
|
||||
|
||||
The archive schema is shared by all game-profile schemas in the PostgreSQL
|
||||
database. Every natural key contains `source_profile`; the accepted profiles
|
||||
are `che`, `kwe`, `pwe`, `twe`, `nya`, `pya`, and `hwe`. This prevents equal
|
||||
legacy IDs from different servers from colliding while allowing any profile API
|
||||
to read one central archive.
|
||||
|
||||
`ng_games.date` is retained as `legacy_date`. The displayed opening date uses
|
||||
`env.opentime`, then `env.starttime`, then `ng_games.date`. The dumps do not
|
||||
carry a trustworthy completion timestamp, so `completed_at` remains null
|
||||
instead of treating the opening date as completion.
|
||||
|
||||
`ng_old_generals.data` is adapted at import time to
|
||||
`ArchivedGeneralSnapshotV1`. Both old `leader/power` with
|
||||
`dex0/10/20/30/40` and newer `leadership/strength` with `dex1..5` map to one
|
||||
shape. Missing battle aggregates and logs are `null` plus explicit
|
||||
`availability`, never fabricated zeroes. The source JSON remains in
|
||||
`legacy_archive.general.raw_data` for recovery, but no API returns it.
|
||||
|
||||
The source contains legitimate duplicate `(server_id, nation)` old-nation rows
|
||||
and `(server_id, year, month)` history rows. `source_id` is consequently part of
|
||||
the archive unique keys. Runtime-generated rows use `source_id = 0`; migrated
|
||||
rows use the legacy primary key. This avoids a lossy last-row-wins upsert.
|
||||
their current-schema archive keys, while the dedicated legacy archive uses
|
||||
`(source_profile, legacy_id)` from the original primary key. This avoids a lossy
|
||||
last-row-wins upsert and keeps runtime current archives separate.
|
||||
|
||||
Current-season actor/world/queue/lock/message/market/vote state is explicitly
|
||||
excluded. In particular, `general`, `city`, `nation`, their turn queues,
|
||||
@@ -108,35 +142,44 @@ season or as a substitute for the long-lived archive cutover procedure.
|
||||
archive owner from the game session and never accepts an owner ID from the
|
||||
browser.
|
||||
|
||||
- `archive.myPastPlays` combines the owner's `ng_old_generals` rows with
|
||||
`ng_games`, the latest matching `ng_old_nations` snapshot and an optional
|
||||
`emperior` row. It returns summary fields and a link target for the existing
|
||||
public dynasty/nation detail.
|
||||
- `archive.myPastPlayDetail(serverId, generalNo)` includes the session owner in
|
||||
the database predicate before returning `data.history`. A foreign or missing
|
||||
record uses the same not-found response.
|
||||
- Legacy `data.history` may be either an array or a `<br>`-joined string. The API
|
||||
normalizes both to a newest-first string array, and the frontend renders plain
|
||||
text rather than archived markup.
|
||||
- `archive.myPastPlays` reads the central legacy archive across profiles and
|
||||
current runtime archives, tags each source, and suppresses a current-schema
|
||||
duplicate when the central legacy copy exists.
|
||||
- `archive.myPastPlayDetail(source, sourceProfile, serverId, generalNo)` includes
|
||||
the session owner in the database predicate. A foreign or missing record uses
|
||||
the same not-found response.
|
||||
- The detail DTO feeds the same `GeneralBasicCard`, battle summary,
|
||||
`LegacyGeneralProgress`, and record panels used by My Page/Battle Center.
|
||||
Missing battle/mastery/log channels show an explicit not-preserved state.
|
||||
- Legacy `data.history` may be either an array or a `<br>`-joined string. It is
|
||||
normalized to plain-text archive entries; archived markup is never rendered
|
||||
as trusted HTML.
|
||||
- Runtime death and unification archival writes the current general
|
||||
`GENERAL/HISTORY` rows into the same `data.history` field, so newly completed
|
||||
seasons remain compatible with imported rows.
|
||||
|
||||
Hall-of-fame and dynasty APIs and pages take an explicit `current` or `legacy`
|
||||
source. Legacy results use the central archive and show the source profile;
|
||||
they are never merged into the current rankings or current dynasty list.
|
||||
|
||||
## Cutover procedure
|
||||
|
||||
1. Keep the original compressed dumps immutable and restore each source to a
|
||||
private MariaDB instance.
|
||||
2. Deploy the gateway and game Prisma migrations to empty staging databases.
|
||||
3. Run gateway and each non-empty profile without `--apply`; archive the JSON
|
||||
3. Run gateway and each non-empty official profile without `--apply`; archive the JSON
|
||||
counts and excluded-table reasons.
|
||||
4. Compare source counts, malformed JSON checks and duplicate natural-key
|
||||
counts. Stop on unexplained drift.
|
||||
5. Put the affected target in maintenance mode, take a PostgreSQL backup, then
|
||||
run the same commands with `--apply`.
|
||||
6. Repeat each apply. Counts must remain unchanged.
|
||||
7. Verify Kakao migration timestamps including `kakao_talk_verified_until`, password-hash shapes, archive ownership,
|
||||
old-nation/history duplicate preservation, `/past-plays` list/detail access,
|
||||
foreign-owner denial and the dynasty link.
|
||||
6. Repeat each apply. Counts must remain unchanged; verify the newest
|
||||
`legacy_archive.import_run` is `COMPLETED` and current Gateway credentials
|
||||
are unchanged.
|
||||
7. Verify valid/invalid Kakao-ID classification, password-reset-required rows,
|
||||
Kakao password setup, CLI fallback, archive ownership, canonical source
|
||||
format counts, opening dates, `/past-plays`, foreign-owner denial, legacy
|
||||
Hall and legacy Dynasty source switches.
|
||||
8. Retain the MariaDB dumps as rollback evidence. Rollback restores the
|
||||
pre-cutover PostgreSQL backup; it does not reverse individual importer
|
||||
upserts.
|
||||
|
||||
@@ -25,6 +25,7 @@ export * from './ranking/types.js';
|
||||
export * from './ranking/legacyColor.js';
|
||||
export * from './auth/accountIconProjection.js';
|
||||
export * from './logging/formatLegacyLogHtml.js';
|
||||
export * from './legacyArchive/ArchivedGeneralSnapshot.js';
|
||||
export * from './gateway/profileStatus.js';
|
||||
export * from './game/accessPenalty.js';
|
||||
export * from './http/trpcTransport.js';
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
export type ArchivedJsonValue =
|
||||
null | boolean | number | string | ArchivedJsonValue[] | { [key: string]: ArchivedJsonValue };
|
||||
|
||||
export const ARCHIVED_GENERAL_SCHEMA_VERSION = 1 as const;
|
||||
|
||||
export const LEGACY_ARCHIVE_PROFILES = ['che', 'kwe', 'pwe', 'twe', 'nya', 'pya', 'hwe'] as const;
|
||||
export type LegacyArchiveProfile = (typeof LEGACY_ARCHIVE_PROFILES)[number];
|
||||
|
||||
export const isLegacyArchiveProfile = (value: string): value is LegacyArchiveProfile =>
|
||||
(LEGACY_ARCHIVE_PROFILES as readonly string[]).includes(value);
|
||||
|
||||
export type ArchivedGeneralSourceFormat = 'legacy-flat-v0' | 'ref-flat-v1' | 'core-snapshot-v1' | 'unknown';
|
||||
|
||||
export interface ArchivedGeneralSnapshotV1 {
|
||||
schemaVersion: typeof ARCHIVED_GENERAL_SCHEMA_VERSION;
|
||||
identity: {
|
||||
name: string;
|
||||
picture: string | null;
|
||||
imageServer: number | null;
|
||||
npcState: number | null;
|
||||
nationId: number | null;
|
||||
cityId: number | null;
|
||||
officerLevel: number | null;
|
||||
officerCity: number | null;
|
||||
};
|
||||
stats: {
|
||||
leadership: number | null;
|
||||
strength: number | null;
|
||||
intelligence: number | null;
|
||||
leadershipExperience: number | null;
|
||||
strengthExperience: number | null;
|
||||
intelligenceExperience: number | null;
|
||||
};
|
||||
progression: {
|
||||
experience: number | null;
|
||||
experienceLevel: number | null;
|
||||
dedication: number | null;
|
||||
dedicationLevel: number | null;
|
||||
age: number | null;
|
||||
startAge: number | null;
|
||||
bornYear: number | null;
|
||||
deadYear: number | null;
|
||||
};
|
||||
traits: {
|
||||
personality: string | null;
|
||||
specialDomestic: string | null;
|
||||
specialWar: string | null;
|
||||
};
|
||||
resources: {
|
||||
gold: number | null;
|
||||
rice: number | null;
|
||||
crew: number | null;
|
||||
crewType: string | null;
|
||||
train: number | null;
|
||||
morale: number | null;
|
||||
injury: number | null;
|
||||
};
|
||||
items: {
|
||||
horse: string | null;
|
||||
weapon: string | null;
|
||||
book: string | null;
|
||||
item: string | null;
|
||||
};
|
||||
mastery: {
|
||||
infantry: number | null;
|
||||
archery: number | null;
|
||||
cavalry: number | null;
|
||||
special: number | null;
|
||||
siege: number | null;
|
||||
};
|
||||
battle: {
|
||||
battles: number | null;
|
||||
wins: number | null;
|
||||
losses: number | null;
|
||||
fireSuccesses: number | null;
|
||||
kills: number | null;
|
||||
deaths: number | null;
|
||||
killedCrew: number | null;
|
||||
lostCrew: number | null;
|
||||
winRate: number | null;
|
||||
killRate: number | null;
|
||||
recentWar: string | null;
|
||||
tactics: {
|
||||
total: { wins: number | null; draws: number | null; losses: number | null };
|
||||
leadership: { wins: number | null; draws: number | null; losses: number | null };
|
||||
intelligence: { wins: number | null; draws: number | null; losses: number | null };
|
||||
};
|
||||
};
|
||||
history: string[];
|
||||
availability: {
|
||||
mastery: boolean;
|
||||
battleAggregates: boolean;
|
||||
tactics: boolean;
|
||||
history: boolean;
|
||||
battleDetailLogs: false;
|
||||
battleResultLogs: false;
|
||||
};
|
||||
}
|
||||
|
||||
type JsonRecord = Record<string, ArchivedJsonValue | undefined>;
|
||||
|
||||
const asRecord = (value: ArchivedJsonValue | undefined): JsonRecord =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
|
||||
const finiteNumber = (value: ArchivedJsonValue | undefined): number | null => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const firstNumber = (...values: Array<ArchivedJsonValue | undefined>): number | null => {
|
||||
for (const value of values) {
|
||||
const parsed = finiteNumber(value);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const text = (value: ArchivedJsonValue | undefined): string | null => {
|
||||
if (typeof value === 'string') return value.trim() === '' ? null : value;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
||||
return null;
|
||||
};
|
||||
|
||||
const firstText = (...values: Array<ArchivedJsonValue | undefined>): string | null => {
|
||||
for (const value of values) {
|
||||
const parsed = text(value);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const historyLines = (value: ArchivedJsonValue | undefined): string[] => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0);
|
||||
}
|
||||
if (typeof value !== 'string') return [];
|
||||
return value
|
||||
.split(/<br\s*\/?>/iu)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const rate = (numerator: number | null, denominator: number | null): number | null =>
|
||||
numerator === null || denominator === null || denominator <= 0
|
||||
? null
|
||||
: Math.round((numerator / denominator) * 10_000) / 100;
|
||||
|
||||
export const detectArchivedGeneralSourceFormat = (raw: ArchivedJsonValue): ArchivedGeneralSourceFormat => {
|
||||
const data = asRecord(raw);
|
||||
if (Object.keys(asRecord(data.stats)).length > 0 || data.schemaVersion === 1) return 'core-snapshot-v1';
|
||||
if ('leadership' in data || 'strength' in data || 'dex1' in data) return 'ref-flat-v1';
|
||||
if ('leader' in data || 'power' in data || 'dex0' in data) return 'legacy-flat-v0';
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
export const normalizeArchivedGeneral = (
|
||||
raw: ArchivedJsonValue,
|
||||
fallbackName: string
|
||||
): { sourceFormat: ArchivedGeneralSourceFormat; snapshot: ArchivedGeneralSnapshotV1 } => {
|
||||
const data = asRecord(raw);
|
||||
const identity = asRecord(data.identity);
|
||||
const stats = asRecord(data.stats);
|
||||
const role = asRecord(data.role);
|
||||
const items = asRecord(data.items);
|
||||
const progression = asRecord(data.progression);
|
||||
const traits = asRecord(data.traits);
|
||||
const resources = asRecord(data.resources);
|
||||
const nestedMastery = asRecord(data.mastery);
|
||||
const nestedBattle = asRecord(data.battle);
|
||||
const nestedTactics = asRecord(nestedBattle.tactics);
|
||||
const totalTactics = asRecord(nestedTactics.total);
|
||||
const leadershipTactics = asRecord(nestedTactics.leadership);
|
||||
const intelligenceTactics = asRecord(nestedTactics.intelligence);
|
||||
const oldMastery = 'dex0' in data;
|
||||
const masteryValues = oldMastery
|
||||
? [data.dex0, data.dex10, data.dex20, data.dex30, data.dex40]
|
||||
: [
|
||||
data.dex1 ?? nestedMastery.infantry,
|
||||
data.dex2 ?? nestedMastery.archery,
|
||||
data.dex3 ?? nestedMastery.cavalry,
|
||||
data.dex4 ?? nestedMastery.special,
|
||||
data.dex5 ?? nestedMastery.siege,
|
||||
];
|
||||
const mastery = masteryValues.map(finiteNumber);
|
||||
const battles = firstNumber(data.warnum, nestedBattle.battles);
|
||||
const wins = firstNumber(data.killnum, nestedBattle.wins);
|
||||
const losses = firstNumber(data.deathnum, nestedBattle.losses);
|
||||
const killedCrew = firstNumber(data.killcrew, nestedBattle.killedCrew);
|
||||
const lostCrew = firstNumber(data.deathcrew, nestedBattle.lostCrew);
|
||||
const history = historyLines(data.history);
|
||||
const tacticValues = [
|
||||
data.ttw ?? totalTactics.wins,
|
||||
data.ttd ?? totalTactics.draws,
|
||||
data.ttl ?? totalTactics.losses,
|
||||
data.tlw ?? leadershipTactics.wins,
|
||||
data.tld ?? leadershipTactics.draws,
|
||||
data.tll ?? leadershipTactics.losses,
|
||||
data.tiw ?? intelligenceTactics.wins,
|
||||
data.tid ?? intelligenceTactics.draws,
|
||||
data.til ?? intelligenceTactics.losses,
|
||||
];
|
||||
const tacticsAvailable = tacticValues.some((value) => finiteNumber(value) !== null);
|
||||
|
||||
return {
|
||||
sourceFormat: detectArchivedGeneralSourceFormat(raw),
|
||||
snapshot: {
|
||||
schemaVersion: ARCHIVED_GENERAL_SCHEMA_VERSION,
|
||||
identity: {
|
||||
name: firstText(data.name, identity.name) ?? fallbackName,
|
||||
picture: firstText(data.picture, identity.picture),
|
||||
imageServer: firstNumber(data.imageServer, data.imgsvr, identity.imageServer),
|
||||
npcState: firstNumber(data.npcState, data.npc, identity.npcState),
|
||||
nationId: firstNumber(data.nationId, data.nation, identity.nationId),
|
||||
cityId: firstNumber(data.cityId, data.city, identity.cityId),
|
||||
officerLevel: firstNumber(data.officerLevel, data.officer_level, data.level, identity.officerLevel),
|
||||
officerCity: firstNumber(data.officerCity, data.officer_city, identity.officerCity),
|
||||
},
|
||||
stats: {
|
||||
leadership: firstNumber(data.leadership, data.leader, stats.leadership),
|
||||
strength: firstNumber(data.strength, data.power, stats.strength),
|
||||
intelligence: firstNumber(data.intelligence, data.intel, stats.intelligence),
|
||||
leadershipExperience: firstNumber(
|
||||
data.leadershipExperience,
|
||||
data.leadership_exp,
|
||||
stats.leadershipExperience
|
||||
),
|
||||
strengthExperience: firstNumber(data.strengthExperience, data.strength_exp, stats.strengthExperience),
|
||||
intelligenceExperience: firstNumber(
|
||||
data.intelligenceExperience,
|
||||
data.intel_exp,
|
||||
stats.intelligenceExperience
|
||||
),
|
||||
},
|
||||
progression: {
|
||||
experience: firstNumber(data.experience, progression.experience),
|
||||
experienceLevel: firstNumber(data.experienceLevel, data.explevel, progression.experienceLevel),
|
||||
dedication: firstNumber(data.dedication, progression.dedication),
|
||||
dedicationLevel: firstNumber(data.dedicationLevel, data.dedlevel, progression.dedicationLevel),
|
||||
age: firstNumber(data.age, progression.age),
|
||||
startAge: firstNumber(data.startAge, data.startage, progression.startAge),
|
||||
bornYear: firstNumber(data.bornYear, data.bornyear, progression.bornYear),
|
||||
deadYear: firstNumber(data.deadYear, data.deadyear, progression.deadYear),
|
||||
},
|
||||
traits: {
|
||||
personality: firstText(data.personalCode, data.personal, role.personality, traits.personality),
|
||||
specialDomestic: firstText(
|
||||
data.specialCode,
|
||||
data.special,
|
||||
role.specialDomestic,
|
||||
traits.specialDomestic
|
||||
),
|
||||
specialWar: firstText(data.special2Code, data.special2, role.specialWar, traits.specialWar),
|
||||
},
|
||||
resources: {
|
||||
gold: firstNumber(data.gold, resources.gold),
|
||||
rice: firstNumber(data.rice, resources.rice),
|
||||
crew: firstNumber(data.crew, resources.crew),
|
||||
crewType: firstText(data.crewType, data.crewtype, resources.crewType),
|
||||
train: firstNumber(data.train, resources.train),
|
||||
morale: firstNumber(data.morale, data.atmos, resources.morale),
|
||||
injury: firstNumber(data.injury, resources.injury),
|
||||
},
|
||||
items: {
|
||||
horse: firstText(items.horse, data.horse),
|
||||
weapon: firstText(items.weapon, data.weapon, data.weap),
|
||||
book: firstText(items.book, data.book),
|
||||
item: firstText(items.item, data.item),
|
||||
},
|
||||
mastery: {
|
||||
infantry: mastery[0] ?? null,
|
||||
archery: mastery[1] ?? null,
|
||||
cavalry: mastery[2] ?? null,
|
||||
special: mastery[3] ?? null,
|
||||
siege: mastery[4] ?? null,
|
||||
},
|
||||
battle: {
|
||||
battles,
|
||||
wins,
|
||||
losses,
|
||||
fireSuccesses: firstNumber(data.firenum, nestedBattle.fireSuccesses),
|
||||
kills: firstNumber(nestedBattle.kills, wins),
|
||||
deaths: firstNumber(nestedBattle.deaths, losses),
|
||||
killedCrew,
|
||||
lostCrew,
|
||||
winRate: firstNumber(nestedBattle.winRate) ?? rate(wins, battles),
|
||||
killRate: firstNumber(nestedBattle.killRate) ?? rate(killedCrew, lostCrew),
|
||||
recentWar: firstText(data.recentWar, data.recent_war, nestedBattle.recentWar),
|
||||
tactics: {
|
||||
total: {
|
||||
wins: firstNumber(data.ttw, totalTactics.wins),
|
||||
draws: firstNumber(data.ttd, totalTactics.draws),
|
||||
losses: firstNumber(data.ttl, totalTactics.losses),
|
||||
},
|
||||
leadership: {
|
||||
wins: firstNumber(data.tlw, leadershipTactics.wins),
|
||||
draws: firstNumber(data.tld, leadershipTactics.draws),
|
||||
losses: firstNumber(data.tll, leadershipTactics.losses),
|
||||
},
|
||||
intelligence: {
|
||||
wins: firstNumber(data.tiw, intelligenceTactics.wins),
|
||||
draws: firstNumber(data.tid, intelligenceTactics.draws),
|
||||
losses: firstNumber(data.til, intelligenceTactics.losses),
|
||||
},
|
||||
},
|
||||
},
|
||||
history,
|
||||
availability: {
|
||||
mastery: mastery.some((value) => value !== null),
|
||||
battleAggregates: [battles, wins, losses, killedCrew, lostCrew].some((value) => value !== null),
|
||||
tactics: tacticsAvailable,
|
||||
history: history.length > 0,
|
||||
battleDetailLogs: false,
|
||||
battleResultLogs: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE "app_user"
|
||||
ADD COLUMN "password_reset_required" BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
UPDATE "app_user"
|
||||
SET "password_reset_required" = TRUE
|
||||
WHERE "password_hash" ~ '^[[:xdigit:]]{128}$';
|
||||
@@ -82,6 +82,7 @@ model AppUser {
|
||||
displayName String @unique @map("display_name")
|
||||
passwordHash String @map("password_hash")
|
||||
passwordSalt String @map("password_salt")
|
||||
passwordResetRequired Boolean @default(false) @map("password_reset_required")
|
||||
roles Json @default(dbgenerated("'[]'::jsonb"))
|
||||
sanctions Json @default(dbgenerated("'{}'::jsonb"))
|
||||
oauthType OAuthType @default(NONE) @map("oauth_type")
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
CREATE SCHEMA IF NOT EXISTS "legacy_archive";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "legacy_archive"."import_run" (
|
||||
"id" BIGSERIAL PRIMARY KEY,
|
||||
"source_profile" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL,
|
||||
"started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"finished_at" TIMESTAMP(3),
|
||||
"counts" JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
"source_format_summary" JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
"error" TEXT,
|
||||
CONSTRAINT "legacy_archive_import_run_profile_check"
|
||||
CHECK ("source_profile" IN ('che', 'kwe', 'pwe', 'twe', 'nya', 'pya', 'hwe')),
|
||||
CONSTRAINT "legacy_archive_import_run_status_check"
|
||||
CHECK ("status" IN ('RUNNING', 'COMPLETED', 'FAILED'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "legacy_archive_import_run_profile_started"
|
||||
ON "legacy_archive"."import_run" ("source_profile", "started_at" DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "legacy_archive"."game_history" (
|
||||
"source_profile" TEXT NOT NULL,
|
||||
"server_id" TEXT NOT NULL,
|
||||
"legacy_id" INTEGER NOT NULL,
|
||||
"opened_at" TIMESTAMP(3) NOT NULL,
|
||||
"completed_at" TIMESTAMP(3),
|
||||
"legacy_date" TIMESTAMP(3) NOT NULL,
|
||||
"winner_nation" INTEGER,
|
||||
"map" TEXT,
|
||||
"season" INTEGER NOT NULL,
|
||||
"scenario" INTEGER NOT NULL,
|
||||
"scenario_name" TEXT NOT NULL,
|
||||
"raw_env" JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
"import_run_id" BIGINT NOT NULL REFERENCES "legacy_archive"."import_run" ("id"),
|
||||
PRIMARY KEY ("source_profile", "server_id")
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "legacy_archive_game_history_opened"
|
||||
ON "legacy_archive"."game_history" ("source_profile", "opened_at" DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "legacy_archive"."general" (
|
||||
"source_profile" TEXT NOT NULL,
|
||||
"server_id" TEXT NOT NULL,
|
||||
"general_no" INTEGER NOT NULL,
|
||||
"legacy_id" INTEGER NOT NULL,
|
||||
"owner" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"last_yearmonth" INTEGER NOT NULL,
|
||||
"turntime" TIMESTAMP(3) NOT NULL,
|
||||
"schema_version" INTEGER NOT NULL DEFAULT 1,
|
||||
"source_format" TEXT NOT NULL,
|
||||
"data" JSONB NOT NULL,
|
||||
"raw_data" JSONB NOT NULL,
|
||||
"import_run_id" BIGINT NOT NULL REFERENCES "legacy_archive"."import_run" ("id"),
|
||||
PRIMARY KEY ("source_profile", "server_id", "general_no"),
|
||||
CONSTRAINT "legacy_archive_general_schema_version_check" CHECK ("schema_version" = 1)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "legacy_archive_general_owner_opened"
|
||||
ON "legacy_archive"."general" ("owner", "source_profile", "server_id");
|
||||
CREATE INDEX IF NOT EXISTS "legacy_archive_general_name"
|
||||
ON "legacy_archive"."general" ("source_profile", "server_id", "name");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "legacy_archive"."nation" (
|
||||
"source_profile" TEXT NOT NULL,
|
||||
"legacy_id" INTEGER NOT NULL,
|
||||
"server_id" TEXT NOT NULL,
|
||||
"nation" INTEGER NOT NULL,
|
||||
"data" JSONB NOT NULL,
|
||||
"archived_at" TIMESTAMP(3) NOT NULL,
|
||||
"import_run_id" BIGINT NOT NULL REFERENCES "legacy_archive"."import_run" ("id"),
|
||||
PRIMARY KEY ("source_profile", "legacy_id")
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "legacy_archive_nation_server"
|
||||
ON "legacy_archive"."nation" ("source_profile", "server_id", "nation", "archived_at" DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "legacy_archive"."hall" (
|
||||
"source_profile" TEXT NOT NULL,
|
||||
"legacy_id" INTEGER NOT NULL,
|
||||
"server_id" TEXT NOT NULL,
|
||||
"season" INTEGER NOT NULL,
|
||||
"scenario" INTEGER NOT NULL,
|
||||
"general_no" INTEGER NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"value" DOUBLE PRECISION NOT NULL,
|
||||
"owner" TEXT,
|
||||
"aux" JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
"import_run_id" BIGINT NOT NULL REFERENCES "legacy_archive"."import_run" ("id"),
|
||||
PRIMARY KEY ("source_profile", "server_id", "type", "general_no")
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "legacy_archive_hall_scenario"
|
||||
ON "legacy_archive"."hall" ("source_profile", "season", "scenario", "type", "value" DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "legacy_archive"."emperor" (
|
||||
"id" BIGSERIAL PRIMARY KEY,
|
||||
"source_profile" TEXT NOT NULL,
|
||||
"legacy_id" INTEGER NOT NULL,
|
||||
"server_id" TEXT,
|
||||
"data" JSONB NOT NULL,
|
||||
"import_run_id" BIGINT NOT NULL REFERENCES "legacy_archive"."import_run" ("id"),
|
||||
CONSTRAINT "legacy_archive_emperor_source_key" UNIQUE ("source_profile", "legacy_id")
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "legacy_archive_emperor_server"
|
||||
ON "legacy_archive"."emperor" ("source_profile", "server_id", "id" DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "legacy_archive"."yearbook" (
|
||||
"source_profile" TEXT NOT NULL,
|
||||
"legacy_id" INTEGER NOT NULL,
|
||||
"profile_name" TEXT NOT NULL,
|
||||
"year" INTEGER NOT NULL,
|
||||
"month" INTEGER NOT NULL,
|
||||
"map" JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
"nations" JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
"global_history" JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
"global_action" JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
"content_hash" TEXT NOT NULL,
|
||||
"import_run_id" BIGINT NOT NULL REFERENCES "legacy_archive"."import_run" ("id"),
|
||||
PRIMARY KEY ("source_profile", "legacy_id")
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "legacy_archive_yearbook_month"
|
||||
ON "legacy_archive"."yearbook" ("source_profile", "profile_name", "year", "month", "legacy_id");
|
||||
Generated
+3
@@ -535,6 +535,9 @@ importers:
|
||||
|
||||
tools/legacy-db-migration:
|
||||
dependencies:
|
||||
'@sammo-ts/common':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/common
|
||||
mariadb:
|
||||
specifier: 3.5.3
|
||||
version: 3.5.3
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"controllerProtocol": 2,
|
||||
"gatewaySchemaHead": "20260817000000_remove_profile_manage_capability",
|
||||
"gameSchemaHead": "20260817000000_add_general_access_batch",
|
||||
"gatewaySchemaHead": "20260817001000_remove_profile_manage_capability",
|
||||
"gameSchemaHead": "20260817001000_add_dedicated_legacy_archive",
|
||||
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
|
||||
}
|
||||
|
||||
@@ -5,9 +5,10 @@ into the core2026 PostgreSQL schemas. It is CLI-only; no HTTP or administrator
|
||||
route invokes it.
|
||||
|
||||
The default mode is a read-only dry-run. `--apply` is required before any target
|
||||
write. PostgreSQL advisory locks prevent two applies for the same target. Every
|
||||
write uses a stable legacy key and `ON CONFLICT`, so a completed or interrupted
|
||||
run can be repeated.
|
||||
write. PostgreSQL advisory locks prevent two applies for the same target.
|
||||
Gateway writes are transactional. Game archive writes and their completed
|
||||
`legacy_archive.import_run` record are transactional. Stable legacy keys make
|
||||
completed or interrupted runs repeatable.
|
||||
|
||||
## Source restore
|
||||
|
||||
@@ -37,6 +38,13 @@ LEGACY_GAME_DATABASE_URL=... pnpm --filter @sammo-ts/legacy-db-migration migrate
|
||||
After reviewing the JSON counts and excluded-table reasons, add
|
||||
`GATEWAY_DATABASE_URL` or `GAME_DATABASE_URL` and repeat with `--apply`.
|
||||
|
||||
For game archives, `GAME_DATABASE_URL` points at that profile's Core schema.
|
||||
The importer writes completed-history data to the shared
|
||||
`legacy_archive` PostgreSQL schema and writes only inheritance projections to
|
||||
the selected current profile schema. Accepted profiles are
|
||||
`che,kwe,pwe,twe,nya,pya,hwe`; run them separately against the same PostgreSQL
|
||||
database.
|
||||
|
||||
### Isolated current-season comparison fixture
|
||||
|
||||
`current-season-fixture` is separate from the long-lived archive migration. It
|
||||
@@ -77,13 +85,17 @@ listed in the JSON result. This fixture is evidence for persisted-state and GUI
|
||||
comparison, not proof that the two engines consume RNG identically after the
|
||||
next turn.
|
||||
|
||||
Kakao members retain their OAuth ID, email, and OAuth metadata.
|
||||
`kakao_verified_at` and `kakao_grace_started_at` are set to the migration time.
|
||||
Kakao members retain their OAuth ID, email, and OAuth metadata. Only a row with
|
||||
a non-empty OAuth ID receives `kakao_verified_at`.
|
||||
`kakao_grace_started_at` is set to the migration time.
|
||||
The existing `token_valid_until` is copied to `kakao_talk_verified_until` for
|
||||
Kakao rows so a still-current “send to me” proof remains current after cutover.
|
||||
Legacy password hashes and salts are retained and upgraded to Argon2id after
|
||||
the first successful login when
|
||||
`GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT` is configured in gateway-api.
|
||||
Imported 128-hex password hashes are marked for reset. They can be upgraded to
|
||||
Argon2id after the first successful login only when the DB-external
|
||||
`GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT` is safely recovered. Otherwise, a verified
|
||||
Kakao flow requires a new password before session issuance. A non-Kakao account
|
||||
uses the CLI reset below. Reapplying a dump preserves the target account's
|
||||
current credential, OAuth, identity, roles, sanctions, consent, and login state.
|
||||
|
||||
Only tables present in the checked ref schemas are eligible. Extra tables found
|
||||
in a dump, such as an old root `config` table, are left in the recovery dump and
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"migrate": "tsx src/cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sammo-ts/common": "workspace:*",
|
||||
"mariadb": "3.5.3",
|
||||
"pg": "^8.16.3"
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
import { createMariaPool, createPostgresPool } from './db.js';
|
||||
import { migrateGame } from './game.js';
|
||||
import { isLegacyArchiveProfile, LEGACY_ARCHIVE_PROFILES, migrateGame } from './game.js';
|
||||
import { migrateGateway } from './gateway.js';
|
||||
import { hashPasswordForReset } from './password.js';
|
||||
import { migrateCurrentSeasonFixture } from './currentSeason.js';
|
||||
@@ -122,7 +122,10 @@ const resetPassword = async (options: CliOptions): Promise<Record<string, unknow
|
||||
const hashed = await hashPasswordForReset(password);
|
||||
await pool.query(
|
||||
`UPDATE "app_user"
|
||||
SET "password_hash" = $1, "password_salt" = $2, "updated_at" = CURRENT_TIMESTAMP
|
||||
SET "password_hash" = $1,
|
||||
"password_salt" = $2,
|
||||
"password_reset_required" = FALSE,
|
||||
"updated_at" = CURRENT_TIMESTAMP
|
||||
WHERE "id" = $3`,
|
||||
[hashed.hash, hashed.salt, existing.rows[0]!.id]
|
||||
);
|
||||
@@ -142,7 +145,11 @@ const run = async (): Promise<void> => {
|
||||
const migratedAt = new Date();
|
||||
if (options.command === 'gateway') {
|
||||
const source = createMariaPool(requireEnvironment('LEGACY_ROOT_DATABASE_URL'));
|
||||
const target = options.apply ? createPostgresPool(requireEnvironment('GATEWAY_DATABASE_URL')) : null;
|
||||
const targetUrl = process.env.GATEWAY_DATABASE_URL?.trim();
|
||||
if (options.apply && !targetUrl) {
|
||||
throw new Error('GATEWAY_DATABASE_URL is required with --apply');
|
||||
}
|
||||
const target = targetUrl ? createPostgresPool(targetUrl) : null;
|
||||
try {
|
||||
const summary = await migrateGateway(source, target, options.apply, migratedAt);
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
@@ -156,6 +163,9 @@ const run = async (): Promise<void> => {
|
||||
if (!options.profile || !/^[a-z][a-z0-9_-]{1,31}$/.test(options.profile)) {
|
||||
throw new Error(`${options.command} requires a safe --profile value\n\n${usage}`);
|
||||
}
|
||||
if (options.command === 'game' && !isLegacyArchiveProfile(options.profile)) {
|
||||
throw new Error(`game requires --profile ${LEGACY_ARCHIVE_PROFILES.join('|')}\n\n${usage}`);
|
||||
}
|
||||
const source = createMariaPool(requireEnvironment('LEGACY_GAME_DATABASE_URL'));
|
||||
const target =
|
||||
options.apply || options.command === 'current-season-fixture'
|
||||
|
||||
@@ -24,6 +24,14 @@ const quoteIdentifier = (value: string): string => {
|
||||
return `"${value}"`;
|
||||
};
|
||||
|
||||
export const quoteQualifiedIdentifier = (value: string): string => {
|
||||
const parts = value.split('.');
|
||||
if (parts.length < 1 || parts.length > 2 || parts.some((part) => !IDENTIFIER.test(part))) {
|
||||
throw new Error(`Unsafe SQL identifier: ${value}`);
|
||||
}
|
||||
return parts.map((part) => `"${part}"`).join('.');
|
||||
};
|
||||
|
||||
export const createMariaPool = (uri: string): MariaPool => mariadb.createPool(uri);
|
||||
|
||||
export const createPostgresPool = (connectionString: string): pg.Pool => {
|
||||
@@ -94,7 +102,8 @@ export const upsertRows = async (
|
||||
client: PoolClient,
|
||||
table: string,
|
||||
rows: readonly TargetRow[],
|
||||
conflictColumns: readonly string[]
|
||||
conflictColumns: readonly string[],
|
||||
options: { preserveOnConflict?: readonly string[] } = {}
|
||||
): Promise<void> => {
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
@@ -116,12 +125,13 @@ export const upsertRows = async (
|
||||
});
|
||||
return `(${placeholders.join(', ')})`;
|
||||
});
|
||||
const preserved = new Set(options.preserveOnConflict ?? []);
|
||||
const updates = columns
|
||||
.filter((column) => !conflictColumns.includes(column))
|
||||
.filter((column) => !conflictColumns.includes(column) && !preserved.has(column))
|
||||
.map((column) => `${quoteIdentifier(column)} = EXCLUDED.${quoteIdentifier(column)}`);
|
||||
const conflictAction = updates.length ? `DO UPDATE SET ${updates.join(', ')}` : 'DO NOTHING';
|
||||
await client.query(
|
||||
`INSERT INTO ${quoteIdentifier(table)} (${columns.map(quoteIdentifier).join(', ')})
|
||||
`INSERT INTO ${quoteQualifiedIdentifier(table)} (${columns.map(quoteIdentifier).join(', ')})
|
||||
VALUES ${tuples.join(', ')}
|
||||
ON CONFLICT (${conflictColumns.map(quoteIdentifier).join(', ')}) ${conflictAction}`,
|
||||
values
|
||||
|
||||
@@ -3,6 +3,16 @@ import { createHash } from 'node:crypto';
|
||||
import type { Pool as MariaPool } from 'mariadb';
|
||||
import type { Pool as PgPool, PoolClient } from 'pg';
|
||||
|
||||
import {
|
||||
isLegacyArchiveProfile,
|
||||
normalizeArchivedGeneral,
|
||||
type ArchivedGeneralSourceFormat,
|
||||
type ArchivedJsonValue,
|
||||
type LegacyArchiveProfile,
|
||||
} from '@sammo-ts/common';
|
||||
|
||||
export { isLegacyArchiveProfile, LEGACY_ARCHIVE_PROFILES, type LegacyArchiveProfile } from '@sammo-ts/common';
|
||||
|
||||
import {
|
||||
paginateSource,
|
||||
jsonParameter,
|
||||
@@ -29,6 +39,12 @@ import {
|
||||
|
||||
const batchSize = 250;
|
||||
|
||||
interface ArchiveMigrationContext {
|
||||
profile: LegacyArchiveProfile;
|
||||
importRunId: string;
|
||||
sourceFormats: Record<ArchivedGeneralSourceFormat, number>;
|
||||
}
|
||||
|
||||
const parseNullableJson = (value: unknown, fallback: JsonValue, context: string): JsonValue =>
|
||||
value === null || value === undefined ? fallback : parseJson(value, context);
|
||||
|
||||
@@ -43,17 +59,17 @@ const ownerId = (value: unknown): string | null => {
|
||||
return memberNo > 0 ? legacyUserId(memberNo) : null;
|
||||
};
|
||||
|
||||
const hashYearbook = (row: TargetRow): string =>
|
||||
createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify({
|
||||
map: row.map,
|
||||
nations: row.nations,
|
||||
globalHistory: row.global_history,
|
||||
globalAction: row.global_action,
|
||||
})
|
||||
)
|
||||
.digest('hex');
|
||||
const hashYearbook = (map: JsonValue, nations: JsonValue, globalHistory: JsonValue, globalAction: JsonValue): string =>
|
||||
createHash('sha256').update(JSON.stringify({ map, nations, globalHistory, globalAction })).digest('hex');
|
||||
|
||||
const asJsonRecord = (value: JsonValue): Record<string, JsonValue> =>
|
||||
value !== null && !Array.isArray(value) && typeof value === 'object' ? value : {};
|
||||
|
||||
export const resolveLegacyGameOpenedAt = (env: JsonValue, legacyDate: Date, context: string): Date => {
|
||||
const record = asJsonRecord(env);
|
||||
const candidate = record.opentime ?? record.starttime;
|
||||
return candidate === null || candidate === undefined || candidate === '' ? legacyDate : toDate(candidate, context);
|
||||
};
|
||||
|
||||
const migrateSimpleTable = async (
|
||||
source: MariaPool,
|
||||
@@ -75,17 +91,24 @@ const migrateSimpleTable = async (
|
||||
}
|
||||
};
|
||||
|
||||
const migrateHall = (source: MariaPool, target: PoolClient | null, counts: Record<string, number>): Promise<void> =>
|
||||
const migrateHall = (
|
||||
source: MariaPool,
|
||||
target: PoolClient | null,
|
||||
counts: Record<string, number>,
|
||||
archive: ArchiveMigrationContext
|
||||
): Promise<void> =>
|
||||
migrateSimpleTable(
|
||||
source,
|
||||
target,
|
||||
'hall',
|
||||
'id',
|
||||
'hall',
|
||||
['server_id', 'type', 'general_no'],
|
||||
'legacy_archive.hall',
|
||||
['source_profile', 'server_id', 'type', 'general_no'],
|
||||
(row) => {
|
||||
const sourceId = toNumber(row.id, 'hall.id');
|
||||
return {
|
||||
source_profile: archive.profile,
|
||||
legacy_id: sourceId,
|
||||
server_id: toStringValue(row.server_id, `hall.${sourceId}.server_id`),
|
||||
season: toNumber(row.season, `hall.${sourceId}.season`),
|
||||
scenario: toNumber(row.scenario, `hall.${sourceId}.scenario`),
|
||||
@@ -94,24 +117,36 @@ const migrateHall = (source: MariaPool, target: PoolClient | null, counts: Recor
|
||||
value: toFloat(row.value, `hall.${sourceId}.value`),
|
||||
owner: ownerId(row.owner),
|
||||
aux: parseJson(row.aux, `hall.${sourceId}.aux`),
|
||||
import_run_id: archive.importRunId,
|
||||
};
|
||||
},
|
||||
counts
|
||||
);
|
||||
|
||||
const migrateGames = (source: MariaPool, target: PoolClient | null, counts: Record<string, number>): Promise<void> =>
|
||||
const migrateGames = (
|
||||
source: MariaPool,
|
||||
target: PoolClient | null,
|
||||
counts: Record<string, number>,
|
||||
archive: ArchiveMigrationContext
|
||||
): Promise<void> =>
|
||||
migrateSimpleTable(
|
||||
source,
|
||||
target,
|
||||
'ng_games',
|
||||
'id',
|
||||
'ng_games',
|
||||
['server_id'],
|
||||
'legacy_archive.game_history',
|
||||
['source_profile', 'server_id'],
|
||||
(row) => {
|
||||
const sourceId = toNumber(row.id, 'ng_games.id');
|
||||
const legacyDate = toDate(row.date, `ng_games.${sourceId}.date`);
|
||||
const env = parseJson(row.env, `ng_games.${sourceId}.env`);
|
||||
return {
|
||||
source_profile: archive.profile,
|
||||
server_id: toStringValue(row.server_id, `ng_games.${sourceId}.server_id`),
|
||||
date: toDate(row.date, `ng_games.${sourceId}.date`),
|
||||
legacy_id: sourceId,
|
||||
opened_at: resolveLegacyGameOpenedAt(env, legacyDate, `ng_games.${sourceId}.opened_at`),
|
||||
completed_at: null,
|
||||
legacy_date: legacyDate,
|
||||
winner_nation:
|
||||
row.winner_nation === null
|
||||
? null
|
||||
@@ -120,7 +155,8 @@ const migrateGames = (source: MariaPool, target: PoolClient | null, counts: Reco
|
||||
season: toNumber(row.season, `ng_games.${sourceId}.season`),
|
||||
scenario: toNumber(row.scenario, `ng_games.${sourceId}.scenario`),
|
||||
scenario_name: toStringValue(row.scenario_name, `ng_games.${sourceId}.scenario_name`),
|
||||
env: parseJson(row.env, `ng_games.${sourceId}.env`),
|
||||
raw_env: jsonParameter(env),
|
||||
import_run_id: archive.importRunId,
|
||||
};
|
||||
},
|
||||
counts
|
||||
@@ -129,25 +165,36 @@ const migrateGames = (source: MariaPool, target: PoolClient | null, counts: Reco
|
||||
const migrateOldGenerals = (
|
||||
source: MariaPool,
|
||||
target: PoolClient | null,
|
||||
counts: Record<string, number>
|
||||
counts: Record<string, number>,
|
||||
archive: ArchiveMigrationContext
|
||||
): Promise<void> =>
|
||||
migrateSimpleTable(
|
||||
source,
|
||||
target,
|
||||
'ng_old_generals',
|
||||
'id',
|
||||
'ng_old_generals',
|
||||
['server_id', 'general_no'],
|
||||
'legacy_archive.general',
|
||||
['source_profile', 'server_id', 'general_no'],
|
||||
(row) => {
|
||||
const sourceId = toNumber(row.id, 'ng_old_generals.id');
|
||||
const name = toStringValue(row.name, `ng_old_generals.${sourceId}.name`);
|
||||
const rawData = parseJson(row.data, `ng_old_generals.${sourceId}.data`);
|
||||
const normalized = normalizeArchivedGeneral(rawData as ArchivedJsonValue, name);
|
||||
archive.sourceFormats[normalized.sourceFormat] += 1;
|
||||
return {
|
||||
source_profile: archive.profile,
|
||||
server_id: toStringValue(row.server_id, `ng_old_generals.${sourceId}.server_id`),
|
||||
general_no: toNumber(row.general_no, `ng_old_generals.${sourceId}.general_no`),
|
||||
legacy_id: sourceId,
|
||||
owner: ownerId(row.owner),
|
||||
name: toStringValue(row.name, `ng_old_generals.${sourceId}.name`),
|
||||
name,
|
||||
last_yearmonth: toNumber(row.last_yearmonth, `ng_old_generals.${sourceId}.last_yearmonth`),
|
||||
turntime: toDate(row.turntime, `ng_old_generals.${sourceId}.turntime`),
|
||||
data: parseJson(row.data, `ng_old_generals.${sourceId}.data`),
|
||||
schema_version: normalized.snapshot.schemaVersion,
|
||||
source_format: normalized.sourceFormat,
|
||||
data: jsonParameter(normalized.snapshot),
|
||||
raw_data: jsonParameter(rawData),
|
||||
import_run_id: archive.importRunId,
|
||||
};
|
||||
},
|
||||
counts
|
||||
@@ -156,41 +203,47 @@ const migrateOldGenerals = (
|
||||
const migrateOldNations = (
|
||||
source: MariaPool,
|
||||
target: PoolClient | null,
|
||||
counts: Record<string, number>
|
||||
counts: Record<string, number>,
|
||||
archive: ArchiveMigrationContext
|
||||
): Promise<void> =>
|
||||
migrateSimpleTable(
|
||||
source,
|
||||
target,
|
||||
'ng_old_nations',
|
||||
'id',
|
||||
'ng_old_nations',
|
||||
['server_id', 'nation', 'source_id'],
|
||||
'legacy_archive.nation',
|
||||
['source_profile', 'legacy_id'],
|
||||
(row) => {
|
||||
const sourceId = toNumber(row.id, 'ng_old_nations.id');
|
||||
return {
|
||||
source_profile: archive.profile,
|
||||
legacy_id: sourceId,
|
||||
server_id: toStringValue(row.server_id, `ng_old_nations.${sourceId}.server_id`),
|
||||
nation: toNumber(row.nation, `ng_old_nations.${sourceId}.nation`),
|
||||
source_id: sourceId,
|
||||
data: parseJson(row.data, `ng_old_nations.${sourceId}.data`),
|
||||
date: toDate(row.date, `ng_old_nations.${sourceId}.date`),
|
||||
data: jsonParameter(parseJson(row.data, `ng_old_nations.${sourceId}.data`)),
|
||||
archived_at: toDate(row.date, `ng_old_nations.${sourceId}.date`),
|
||||
import_run_id: archive.importRunId,
|
||||
};
|
||||
},
|
||||
counts
|
||||
);
|
||||
|
||||
const migrateEmperors = (source: MariaPool, target: PoolClient | null, counts: Record<string, number>): Promise<void> =>
|
||||
const migrateEmperors = (
|
||||
source: MariaPool,
|
||||
target: PoolClient | null,
|
||||
counts: Record<string, number>,
|
||||
archive: ArchiveMigrationContext
|
||||
): Promise<void> =>
|
||||
migrateSimpleTable(
|
||||
source,
|
||||
target,
|
||||
'emperior',
|
||||
'no',
|
||||
'emperior',
|
||||
['legacy_id'],
|
||||
'legacy_archive.emperor',
|
||||
['source_profile', 'legacy_id'],
|
||||
(row) => {
|
||||
const id = toNumber(row.no, 'emperior.no');
|
||||
return {
|
||||
legacy_id: id,
|
||||
server_id: toNullableString(row.server_id),
|
||||
const data = {
|
||||
phase: toNullableString(row.phase),
|
||||
nation_count: toNullableString(row.nation_count),
|
||||
nation_name: toNullableString(row.nation_name),
|
||||
@@ -232,6 +285,13 @@ const migrateEmperors = (source: MariaPool, target: PoolClient | null, counts: R
|
||||
history: parseNullableJson(row.history, [], `emperior.${id}.history`),
|
||||
aux: parseNullableJson(row.aux, {}, `emperior.${id}.aux`),
|
||||
};
|
||||
return {
|
||||
source_profile: archive.profile,
|
||||
legacy_id: id,
|
||||
server_id: toNullableString(row.server_id),
|
||||
data: jsonParameter(data),
|
||||
import_run_id: archive.importRunId,
|
||||
};
|
||||
},
|
||||
counts
|
||||
);
|
||||
@@ -295,30 +355,35 @@ const migrateUserRecords = (
|
||||
const migrateYearbook = async (
|
||||
source: MariaPool,
|
||||
target: PoolClient | null,
|
||||
counts: Record<string, number>
|
||||
counts: Record<string, number>,
|
||||
archive: ArchiveMigrationContext
|
||||
): Promise<void> => {
|
||||
await migrateSimpleTable(
|
||||
source,
|
||||
target,
|
||||
'ng_history',
|
||||
'no',
|
||||
'yearbook_history',
|
||||
['profile_name', 'year', 'month', 'source_id'],
|
||||
'legacy_archive.yearbook',
|
||||
['source_profile', 'legacy_id'],
|
||||
(row) => {
|
||||
const id = toNumber(row.no, 'ng_history.no');
|
||||
const map = parseNullableJson(row.map, {}, `ng_history.${id}.map`);
|
||||
const nations = parseNullableJson(row.nations, [], `ng_history.${id}.nations`);
|
||||
const globalHistory = parseNullableJson(row.global_history, [], `ng_history.${id}.global_history`);
|
||||
const globalAction = parseNullableJson(row.global_action, [], `ng_history.${id}.global_action`);
|
||||
const mapped: TargetRow = {
|
||||
source_profile: archive.profile,
|
||||
legacy_id: id,
|
||||
profile_name: toStringValue(row.server_id, `ng_history.${id}.server_id`),
|
||||
source_id: id,
|
||||
year: toNumber(row.year, `ng_history.${id}.year`),
|
||||
month: toNumber(row.month, `ng_history.${id}.month`),
|
||||
map: parseNullableJson(row.map, {}, `ng_history.${id}.map`),
|
||||
nations: parseNullableJson(row.nations, [], `ng_history.${id}.nations`),
|
||||
global_history: parseNullableJson(row.global_history, [], `ng_history.${id}.global_history`),
|
||||
global_action: parseNullableJson(row.global_action, [], `ng_history.${id}.global_action`),
|
||||
hash: '',
|
||||
created_at: new Date(0),
|
||||
map: jsonParameter(map),
|
||||
nations: jsonParameter(nations),
|
||||
global_history: jsonParameter(globalHistory),
|
||||
global_action: jsonParameter(globalAction),
|
||||
content_hash: hashYearbook(map, nations, globalHistory, globalAction),
|
||||
import_run_id: archive.importRunId,
|
||||
};
|
||||
mapped.hash = hashYearbook(mapped);
|
||||
return mapped;
|
||||
},
|
||||
counts,
|
||||
@@ -388,7 +453,16 @@ export const migrateGame = async (
|
||||
apply: boolean,
|
||||
profile: string
|
||||
): Promise<MigrationSummary> => {
|
||||
if (!isLegacyArchiveProfile(profile)) {
|
||||
throw new Error(`Unsupported legacy archive profile: ${profile}`);
|
||||
}
|
||||
const counts: Record<string, number> = {};
|
||||
const sourceFormats: Record<ArchivedGeneralSourceFormat, number> = {
|
||||
'legacy-flat-v0': 0,
|
||||
'ref-flat-v1': 0,
|
||||
'core-snapshot-v1': 0,
|
||||
unknown: 0,
|
||||
};
|
||||
const excluded = {
|
||||
general: 'Current-season actor state is intentionally not transferred.',
|
||||
city: 'Current-season world state is intentionally not transferred.',
|
||||
@@ -421,25 +495,59 @@ export const migrateGame = async (
|
||||
'storage:season-state': 'Only inheritance_* and user_* long-lived namespaces are archived or projected.',
|
||||
};
|
||||
const client = apply && targetPool ? await targetPool.connect() : null;
|
||||
let importRunId: string | null = null;
|
||||
try {
|
||||
const run = async (): Promise<void> => {
|
||||
await migrateGames(source, client, counts);
|
||||
await migrateHall(source, client, counts);
|
||||
await migrateOldGenerals(source, client, counts);
|
||||
await migrateOldNations(source, client, counts);
|
||||
await migrateEmperors(source, client, counts);
|
||||
const run = async (archive: ArchiveMigrationContext): Promise<void> => {
|
||||
await migrateGames(source, client, counts, archive);
|
||||
await migrateHall(source, client, counts, archive);
|
||||
await migrateOldGenerals(source, client, counts, archive);
|
||||
await migrateOldNations(source, client, counts, archive);
|
||||
await migrateEmperors(source, client, counts, archive);
|
||||
await migrateInheritanceResults(source, client, counts);
|
||||
await migrateUserRecords(source, client, counts);
|
||||
await migrateStorage(source, client, counts);
|
||||
await migrateYearbook(source, client, counts);
|
||||
await migrateYearbook(source, client, counts, archive);
|
||||
};
|
||||
if (client) {
|
||||
await withMigrationLock(client, `sammo-legacy-game-v1:${profile}`, run);
|
||||
await withMigrationLock(client, `sammo-legacy-archive-v2:${profile}`, async () => {
|
||||
const created = await client.query<{ id: string }>(
|
||||
`INSERT INTO "legacy_archive"."import_run" ("source_profile", "status")
|
||||
VALUES ($1, 'RUNNING') RETURNING "id"`,
|
||||
[profile]
|
||||
);
|
||||
importRunId = created.rows[0]?.id ?? null;
|
||||
if (!importRunId) throw new Error('Failed to create legacy archive import run');
|
||||
const archive: ArchiveMigrationContext = { profile, importRunId, sourceFormats };
|
||||
await client.query('BEGIN');
|
||||
try {
|
||||
await run(archive);
|
||||
await client.query(
|
||||
`UPDATE "legacy_archive"."import_run"
|
||||
SET "status" = 'COMPLETED', "finished_at" = CURRENT_TIMESTAMP,
|
||||
"counts" = $2::jsonb, "source_format_summary" = $3::jsonb
|
||||
WHERE "id" = $1`,
|
||||
[importRunId, JSON.stringify(counts), JSON.stringify(sourceFormats)]
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
const message =
|
||||
error instanceof Error ? error.message.slice(0, 2000) : String(error).slice(0, 2000);
|
||||
await client.query(
|
||||
`UPDATE "legacy_archive"."import_run"
|
||||
SET "status" = 'FAILED', "finished_at" = CURRENT_TIMESTAMP,
|
||||
"counts" = $2::jsonb, "source_format_summary" = $3::jsonb, "error" = $4
|
||||
WHERE "id" = $1`,
|
||||
[importRunId, JSON.stringify(counts), JSON.stringify(sourceFormats), message]
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await run();
|
||||
await run({ profile, importRunId: '0', sourceFormats });
|
||||
}
|
||||
} finally {
|
||||
client?.release();
|
||||
}
|
||||
return { command: 'game', apply, counts, excluded };
|
||||
return { command: 'game', apply, counts, excluded, importRunId, sourceFormatSummary: sourceFormats };
|
||||
};
|
||||
|
||||
@@ -24,17 +24,83 @@ export interface MigrationSummary {
|
||||
apply: boolean;
|
||||
counts: Record<string, number>;
|
||||
excluded: Record<string, string>;
|
||||
importRunId?: string | null;
|
||||
sourceFormatSummary?: Record<string, number>;
|
||||
}
|
||||
|
||||
const batchSize = 500;
|
||||
|
||||
const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date | null): TargetRow => {
|
||||
export const MEMBER_PRESERVED_COLUMNS = [
|
||||
'login_id',
|
||||
'display_name',
|
||||
'password_hash',
|
||||
'password_salt',
|
||||
'password_reset_required',
|
||||
'roles',
|
||||
'sanctions',
|
||||
'oauth_type',
|
||||
'oauth_id',
|
||||
'email',
|
||||
'oauth_info',
|
||||
'picture',
|
||||
'image_server',
|
||||
'icon_updated_at',
|
||||
'third_party_use',
|
||||
'terms_accepted_at',
|
||||
'privacy_accepted_at',
|
||||
'kakao_verified_at',
|
||||
'kakao_talk_verified_until',
|
||||
'kakao_grace_started_at',
|
||||
'delete_after',
|
||||
'updated_at',
|
||||
'last_login_at',
|
||||
'created_at',
|
||||
] as const;
|
||||
|
||||
export const preflightMemberConflicts = async (target: PoolClient, rows: readonly TargetRow[]): Promise<void> => {
|
||||
if (rows.length === 0) return;
|
||||
const ids = rows.map((row) => String(row.id));
|
||||
const loginIds = rows.map((row) => String(row.login_id));
|
||||
const displayNames = rows.map((row) => String(row.display_name));
|
||||
const emails = rows.map((row) => row.email).filter((value): value is string => typeof value === 'string');
|
||||
const existing = await target.query<{
|
||||
id: string;
|
||||
login_id: string;
|
||||
display_name: string;
|
||||
email: string | null;
|
||||
}>(
|
||||
`SELECT "id", "login_id", "display_name", "email"
|
||||
FROM "app_user"
|
||||
WHERE "id" = ANY($1::text[])
|
||||
OR "login_id" = ANY($2::text[])
|
||||
OR "display_name" = ANY($3::text[])
|
||||
OR "email" = ANY($4::text[])`,
|
||||
[ids, loginIds, displayNames, emails]
|
||||
);
|
||||
for (const row of rows) {
|
||||
const id = String(row.id);
|
||||
const collision = existing.rows.find(
|
||||
(candidate) =>
|
||||
candidate.id !== id &&
|
||||
(candidate.login_id === row.login_id ||
|
||||
candidate.display_name === row.display_name ||
|
||||
(row.email !== null && candidate.email === row.email))
|
||||
);
|
||||
if (collision) {
|
||||
throw new Error('Target account identity collision in legacy member batch');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date | null): TargetRow => {
|
||||
const memberNo = toNumber(row.NO, 'member.NO');
|
||||
const grade = toNumber(row.GRADE, `member.${memberNo}.GRADE`);
|
||||
const acl = parseJson(row.acl, `member.${memberNo}.acl`);
|
||||
const penalty = parseJson(row.penalty, `member.${memberNo}.penalty`);
|
||||
const oauthInfo = parseJson(row.oauth_info, `member.${memberNo}.oauth_info`);
|
||||
const oauthType = row.oauth_type === 'KAKAO' ? 'KAKAO' : 'NONE';
|
||||
const oauthId = toNullableString(row.oauth_id)?.trim() || null;
|
||||
const passwordHash = toStringValue(row.PW, `member.${memberNo}.PW`);
|
||||
const legacyData: JsonValue = {
|
||||
memberNo,
|
||||
grade,
|
||||
@@ -49,12 +115,13 @@ const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date | null):
|
||||
id: legacyUserId(memberNo),
|
||||
login_id: toStringValue(row.ID, `member.${memberNo}.ID`).toLowerCase(),
|
||||
display_name: toStringValue(row.NAME, `member.${memberNo}.NAME`),
|
||||
password_hash: toStringValue(row.PW, `member.${memberNo}.PW`),
|
||||
password_hash: passwordHash,
|
||||
password_salt: toStringValue(row.salt, `member.${memberNo}.salt`),
|
||||
password_reset_required: /^[a-f0-9]{128}$/i.test(passwordHash),
|
||||
roles: jsonParameter(mapLegacyRoles(grade, acl)),
|
||||
sanctions: jsonParameter(mapLegacySanctions(grade, penalty)),
|
||||
oauth_type: oauthType,
|
||||
oauth_id: toNullableString(row.oauth_id),
|
||||
oauth_id: oauthId,
|
||||
email: toNullableString(row.EMAIL)?.toLowerCase() ?? null,
|
||||
oauth_info: jsonParameter(oauthInfo),
|
||||
picture: toNullableString(row.PICTURE) ?? 'default.jpg',
|
||||
@@ -63,9 +130,9 @@ const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date | null):
|
||||
third_party_use: toNumber(row.third_use ?? 0, `member.${memberNo}.third_use`) !== 0,
|
||||
terms_accepted_at: null,
|
||||
privacy_accepted_at: null,
|
||||
kakao_verified_at: oauthType === 'KAKAO' ? migratedAt : null,
|
||||
kakao_verified_at: oauthType === 'KAKAO' && oauthId ? migratedAt : null,
|
||||
kakao_talk_verified_until:
|
||||
oauthType === 'KAKAO'
|
||||
oauthType === 'KAKAO' && oauthId
|
||||
? toNullableDate(row.token_valid_until, `member.${memberNo}.token_valid_until`)
|
||||
: null,
|
||||
kakao_grace_started_at: migratedAt,
|
||||
@@ -93,6 +160,7 @@ const loadLastLogins = async (source: MariaPool): Promise<Map<number, Date>> =>
|
||||
const processMembers = async (
|
||||
source: MariaPool,
|
||||
target: PoolClient | null,
|
||||
apply: boolean,
|
||||
migratedAt: Date,
|
||||
counts: Record<string, number>
|
||||
): Promise<void> => {
|
||||
@@ -103,7 +171,10 @@ const processMembers = async (
|
||||
return mapMember(row, migratedAt, lastLogins.get(memberNo) ?? null);
|
||||
});
|
||||
if (target) {
|
||||
await upsertRows(target, 'app_user', mapped, ['id']);
|
||||
await preflightMemberConflicts(target, mapped);
|
||||
}
|
||||
if (target && apply) {
|
||||
await upsertRows(target, 'app_user', mapped, ['id'], { preserveOnConflict: MEMBER_PRESERVED_COLUMNS });
|
||||
}
|
||||
counts.member = (counts.member ?? 0) + mapped.length;
|
||||
}
|
||||
@@ -206,17 +277,26 @@ export const migrateGateway = async (
|
||||
login_token:
|
||||
'Legacy bearer tokens, IP addresses, and expired sessions are not valid in the Redis session model.',
|
||||
};
|
||||
const client = apply && targetPool ? await targetPool.connect() : null;
|
||||
const client = targetPool ? await targetPool.connect() : null;
|
||||
try {
|
||||
const run = async (): Promise<void> => {
|
||||
await processMembers(source, client, migratedAt, counts);
|
||||
await processMemberLogs(source, client, counts);
|
||||
await processBannedMembers(source, client, counts);
|
||||
await processRootKeyValues(source, client, counts);
|
||||
await processSystem(source, client, counts);
|
||||
await processMembers(source, client, apply, migratedAt, counts);
|
||||
await processMemberLogs(source, apply ? client : null, counts);
|
||||
await processBannedMembers(source, apply ? client : null, counts);
|
||||
await processRootKeyValues(source, apply ? client : null, counts);
|
||||
await processSystem(source, apply ? client : null, counts);
|
||||
};
|
||||
if (client) {
|
||||
await withMigrationLock(client, 'sammo-legacy-gateway-v1', run);
|
||||
if (client && apply) {
|
||||
await withMigrationLock(client, 'sammo-legacy-gateway-v1', async () => {
|
||||
await client.query('BEGIN');
|
||||
try {
|
||||
await run();
|
||||
await client.query('COMMIT');
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await run();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { normalizeArchivedGeneral, type ArchivedJsonValue } from '@sammo-ts/common';
|
||||
|
||||
const fixture = async (name: string): Promise<ArchivedJsonValue> =>
|
||||
JSON.parse(await readFile(new URL(`./fixtures/${name}`, import.meta.url), 'utf8')) as ArchivedJsonValue;
|
||||
|
||||
describe('normalizeArchivedGeneral', () => {
|
||||
it('normalizes the sanitized CHE legacy-flat keyset without leaking connection metadata', async () => {
|
||||
const { sourceFormat, snapshot } = normalizeArchivedGeneral(
|
||||
await fixture('che-old-general-legacy-flat-v0.json'),
|
||||
'fallback'
|
||||
);
|
||||
|
||||
expect(sourceFormat).toBe('legacy-flat-v0');
|
||||
expect(snapshot).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
identity: { name: '구형테스트장수', nationId: 3 },
|
||||
stats: { leadership: 81, strength: 73, intelligence: 66 },
|
||||
mastery: { infantry: 101, archery: 202, cavalry: 303, special: 404, siege: 505 },
|
||||
battle: {
|
||||
battles: 20,
|
||||
wins: 12,
|
||||
losses: 8,
|
||||
winRate: 60,
|
||||
killRate: 125,
|
||||
tactics: { total: { wins: 3, draws: 1, losses: 2 } },
|
||||
},
|
||||
history: ['<C>●</>첫 기록', '<Y>●</>둘째 기록'],
|
||||
availability: { mastery: true, battleAggregates: true, tactics: true },
|
||||
});
|
||||
expect(JSON.stringify(snapshot)).not.toMatch(/"(?:ip|lastconnect|refresh)"/iu);
|
||||
});
|
||||
|
||||
it('normalizes the sanitized HWE ref-flat keyset and marks absent battle records unavailable', async () => {
|
||||
const { sourceFormat, snapshot } = normalizeArchivedGeneral(
|
||||
await fixture('hwe-old-general-ref-flat-v1.json'),
|
||||
'fallback'
|
||||
);
|
||||
|
||||
expect(sourceFormat).toBe('ref-flat-v1');
|
||||
expect(snapshot).toMatchObject({
|
||||
identity: { name: '신형테스트장수', officerLevel: 7 },
|
||||
stats: {
|
||||
leadership: 91,
|
||||
strength: 82,
|
||||
intelligence: 74,
|
||||
leadershipExperience: 11,
|
||||
},
|
||||
traits: { personality: 'che_의리', specialDomestic: 'che_상재', specialWar: 'che_신산' },
|
||||
mastery: { infantry: 111, archery: 222, cavalry: 333, special: 444, siege: 555 },
|
||||
battle: { battles: null, wins: null, losses: null, winRate: null, killRate: null },
|
||||
availability: { mastery: true, battleAggregates: false, tactics: false },
|
||||
});
|
||||
expect(snapshot.availability.battleDetailLogs).toBe(false);
|
||||
expect(snapshot.availability.battleResultLogs).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps an already-normalized version 1 snapshot stable', async () => {
|
||||
const first = normalizeArchivedGeneral(await fixture('che-old-general-legacy-flat-v0.json'), 'fallback');
|
||||
const second = normalizeArchivedGeneral(
|
||||
first.snapshot as unknown as ArchivedJsonValue,
|
||||
first.snapshot.identity.name
|
||||
);
|
||||
|
||||
expect(second.sourceFormat).toBe('core-snapshot-v1');
|
||||
expect(second.snapshot).toEqual(first.snapshot);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { PoolClient } from 'pg';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { quoteQualifiedIdentifier, upsertRows } from '../src/db.js';
|
||||
|
||||
describe('qualified archive identifiers', () => {
|
||||
it('quotes a schema-qualified table and still parameterizes values', async () => {
|
||||
const query = vi.fn().mockResolvedValue({});
|
||||
await upsertRows(
|
||||
{ query } as unknown as PoolClient,
|
||||
'legacy_archive.general',
|
||||
[{ id: 1, data: { ok: true } }],
|
||||
['id']
|
||||
);
|
||||
expect(query).toHaveBeenCalledOnce();
|
||||
expect(query.mock.calls[0]?.[0]).toContain('INSERT INTO "legacy_archive"."general"');
|
||||
expect(query.mock.calls[0]?.[1]).toEqual([1, JSON.stringify({ ok: true })]);
|
||||
});
|
||||
|
||||
it.each(['legacy_archive.general.extra', 'legacy-archive.general', 'legacy_archive.General', 'public.;drop'])(
|
||||
'rejects unsafe qualified identifier %s',
|
||||
(value) => expect(() => quoteQualifiedIdentifier(value)).toThrow('Unsafe SQL identifier')
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "구형테스트장수",
|
||||
"leader": 81,
|
||||
"power": 73,
|
||||
"intel": 66,
|
||||
"leader2": 4,
|
||||
"power2": 5,
|
||||
"intel2": 6,
|
||||
"nation": 3,
|
||||
"city": 7,
|
||||
"level": 8,
|
||||
"personal": 2,
|
||||
"special": 4,
|
||||
"special2": 5,
|
||||
"experience": 12345,
|
||||
"explevel": 8,
|
||||
"dedication": 765,
|
||||
"dedlevel": 4,
|
||||
"dex0": 101,
|
||||
"dex10": 202,
|
||||
"dex20": 303,
|
||||
"dex30": 404,
|
||||
"dex40": 505,
|
||||
"warnum": 20,
|
||||
"killnum": 12,
|
||||
"deathnum": 8,
|
||||
"firenum": 7,
|
||||
"killcrew": 2500,
|
||||
"deathcrew": 2000,
|
||||
"ttw": 3,
|
||||
"ttd": 1,
|
||||
"ttl": 2,
|
||||
"tlw": 4,
|
||||
"tld": 0,
|
||||
"tll": 1,
|
||||
"tiw": 5,
|
||||
"tid": 2,
|
||||
"til": 3,
|
||||
"picture": "default.jpg",
|
||||
"imgsvr": 0,
|
||||
"horse": 1,
|
||||
"weap": 2,
|
||||
"book": 3,
|
||||
"item": 4,
|
||||
"history": "<C>●</>첫 기록<br><Y>●</>둘째 기록<br>",
|
||||
"recent_war": "2020-01-02 03:04:05",
|
||||
"ip": "192.0.2.1",
|
||||
"lastconnect": "2020-01-02 03:04:05",
|
||||
"refresh": 10
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "신형테스트장수",
|
||||
"leadership": 91,
|
||||
"strength": 82,
|
||||
"intel": 74,
|
||||
"leadership_exp": 11,
|
||||
"strength_exp": 12,
|
||||
"intel_exp": 13,
|
||||
"nation": 4,
|
||||
"city": 8,
|
||||
"officer_level": 7,
|
||||
"officer_city": 8,
|
||||
"personal": "che_의리",
|
||||
"special": "che_상재",
|
||||
"special2": "che_신산",
|
||||
"experience": 22222,
|
||||
"explevel": 9,
|
||||
"dedication": 999,
|
||||
"dedlevel": 5,
|
||||
"dex1": 111,
|
||||
"dex2": 222,
|
||||
"dex3": 333,
|
||||
"dex4": 444,
|
||||
"dex5": 555,
|
||||
"picture": "default.jpg",
|
||||
"imgsvr": 1,
|
||||
"horse": "che_적토마",
|
||||
"weapon": "che_청룡언월도",
|
||||
"book": null,
|
||||
"item": null,
|
||||
"history": ["<C>●</>최신 기록", "<Y>●</>이전 기록"],
|
||||
"recent_war": null,
|
||||
"aux": "",
|
||||
"ip": "198.51.100.1",
|
||||
"lastconnect": "2026-01-02 03:04:05"
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { Pool as MariaPool } from 'mariadb';
|
||||
import type { Pool as PgPool, PoolClient, QueryResult } from 'pg';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { isLegacyArchiveProfile, migrateGame, resolveLegacyGameOpenedAt } from '../src/game.js';
|
||||
|
||||
const sourceRows = {
|
||||
ng_games: [
|
||||
{
|
||||
id: 1,
|
||||
server_id: 'che_fixture_001',
|
||||
date: new Date('2020-01-01T00:00:00.000Z'),
|
||||
winner_nation: 3,
|
||||
map: 'che',
|
||||
season: 1,
|
||||
scenario: 2,
|
||||
scenario_name: 'fixture',
|
||||
env: JSON.stringify({ opentime: '2020-01-02T00:00:00.000Z', starttime: '2020-01-03T00:00:00.000Z' }),
|
||||
},
|
||||
],
|
||||
ng_old_generals: [
|
||||
{
|
||||
id: 2,
|
||||
server_id: 'che_fixture_001',
|
||||
general_no: 10,
|
||||
owner: 42,
|
||||
name: 'fixture-general',
|
||||
last_yearmonth: 22012,
|
||||
turntime: new Date('2020-02-01T00:00:00.000Z'),
|
||||
data: JSON.stringify({ leader: 80, power: 70, intel: 60, history: 'first<br>second<br>' }),
|
||||
},
|
||||
],
|
||||
} satisfies Record<string, Array<Record<string, unknown>>>;
|
||||
|
||||
const sourcePool = (): MariaPool => {
|
||||
const seen = new Set<string>();
|
||||
return {
|
||||
query: vi.fn(async (sql: string) => {
|
||||
const table = /FROM `([a-z_]+)`/u.exec(sql)?.[1] ?? '';
|
||||
if (seen.has(table)) return [];
|
||||
seen.add(table);
|
||||
return sourceRows[table as keyof typeof sourceRows] ?? [];
|
||||
}),
|
||||
} as unknown as MariaPool;
|
||||
};
|
||||
|
||||
const targetPool = (failPattern?: string) => {
|
||||
const queries: Array<{ sql: string; values: readonly unknown[] }> = [];
|
||||
const query = vi.fn(async (sql: string, values: readonly unknown[] = []) => {
|
||||
queries.push({ sql, values });
|
||||
if (failPattern && sql.includes(failPattern)) {
|
||||
failPattern = undefined;
|
||||
throw new Error('synthetic archive write failure');
|
||||
}
|
||||
if (sql.includes('INSERT INTO "legacy_archive"."import_run"')) {
|
||||
return { rows: [{ id: '77' }], rowCount: 1 } as QueryResult<{ id: string }>;
|
||||
}
|
||||
return { rows: [], rowCount: 0 } as unknown as QueryResult;
|
||||
});
|
||||
const client = { query, release: vi.fn() } as unknown as PoolClient;
|
||||
return {
|
||||
pool: { connect: vi.fn(async () => client) } as unknown as PgPool,
|
||||
queries,
|
||||
};
|
||||
};
|
||||
|
||||
describe('legacy archive game migration', () => {
|
||||
it('uses the official profile allowlist and resolves the best opening timestamp', () => {
|
||||
expect(isLegacyArchiveProfile('che')).toBe(true);
|
||||
expect(isLegacyArchiveProfile('hwe')).toBe(true);
|
||||
expect(isLegacyArchiveProfile('custom')).toBe(false);
|
||||
expect(
|
||||
resolveLegacyGameOpenedAt(
|
||||
{ opentime: '2020-01-02T00:00:00.000Z', starttime: '2020-01-03T00:00:00.000Z' },
|
||||
new Date('2020-01-01T00:00:00.000Z'),
|
||||
'fixture'
|
||||
).toISOString()
|
||||
).toBe('2020-01-02T00:00:00.000Z');
|
||||
expect(
|
||||
resolveLegacyGameOpenedAt(
|
||||
{ starttime: '2020-01-03T00:00:00.000Z' },
|
||||
new Date('2020-01-01T00:00:00.000Z'),
|
||||
'fixture'
|
||||
).toISOString()
|
||||
).toBe('2020-01-03T00:00:00.000Z');
|
||||
expect(resolveLegacyGameOpenedAt({}, new Date('2020-01-01T00:00:00.000Z'), 'fixture').toISOString()).toBe(
|
||||
'2020-01-01T00:00:00.000Z'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps dry-run target-read-only while reporting normalized formats', async () => {
|
||||
const summary = await migrateGame(sourcePool(), null, false, 'che');
|
||||
expect(summary).toMatchObject({
|
||||
apply: false,
|
||||
importRunId: null,
|
||||
counts: { ng_games: 1, ng_old_generals: 1 },
|
||||
sourceFormatSummary: { 'legacy-flat-v0': 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('records a completed import run and writes only archive tables for historical snapshots', async () => {
|
||||
const target = targetPool();
|
||||
const summary = await migrateGame(sourcePool(), target.pool, true, 'che');
|
||||
const sql = target.queries.map((entry) => entry.sql).join('\n');
|
||||
|
||||
expect(summary.importRunId).toBe('77');
|
||||
expect(sql).toContain('INSERT INTO "legacy_archive"."game_history"');
|
||||
expect(sql).toContain('INSERT INTO "legacy_archive"."general"');
|
||||
expect(sql).not.toContain('INSERT INTO "ng_games"');
|
||||
expect(sql).not.toContain('INSERT INTO "ng_old_generals"');
|
||||
expect(sql).toContain(`SET "status" = 'COMPLETED'`);
|
||||
expect(target.queries.some((entry) => entry.sql === 'BEGIN')).toBe(true);
|
||||
expect(target.queries.some((entry) => entry.sql === 'COMMIT')).toBe(true);
|
||||
expect(target.queries.findIndex((entry) => entry.sql.includes(`SET "status" = 'COMPLETED'`))).toBeLessThan(
|
||||
target.queries.findIndex((entry) => entry.sql === 'COMMIT')
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back archive writes and records a failed import run', async () => {
|
||||
const target = targetPool('INSERT INTO "legacy_archive"."general"');
|
||||
await expect(migrateGame(sourcePool(), target.pool, true, 'che')).rejects.toThrow(
|
||||
'synthetic archive write failure'
|
||||
);
|
||||
const sql = target.queries.map((entry) => entry.sql).join('\n');
|
||||
expect(target.queries.some((entry) => entry.sql === 'ROLLBACK')).toBe(true);
|
||||
expect(sql).toContain(`SET "status" = 'FAILED'`);
|
||||
expect(sql).not.toContain(`SET "status" = 'COMPLETED'`);
|
||||
});
|
||||
|
||||
it('rolls back archive writes when completing the import run fails', async () => {
|
||||
const target = targetPool(`SET "status" = 'COMPLETED'`);
|
||||
await expect(migrateGame(sourcePool(), target.pool, true, 'che')).rejects.toThrow(
|
||||
'synthetic archive write failure'
|
||||
);
|
||||
|
||||
expect(target.queries.some((entry) => entry.sql === 'COMMIT')).toBe(false);
|
||||
expect(target.queries.some((entry) => entry.sql === 'ROLLBACK')).toBe(true);
|
||||
expect(target.queries.some((entry) => entry.sql.includes(`SET "status" = 'FAILED'`))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an unsupported profile before reading or writing', async () => {
|
||||
await expect(migrateGame(sourcePool(), null, false, 'custom')).rejects.toThrow(
|
||||
'Unsupported legacy archive profile'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { PoolClient } from 'pg';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { upsertRows } from '../src/db.js';
|
||||
import { mapMember, MEMBER_PRESERVED_COLUMNS, preflightMemberConflicts } from '../src/gateway.js';
|
||||
|
||||
const memberRow = (overrides: Record<string, unknown> = {}) => ({
|
||||
NO: 7,
|
||||
GRADE: 1,
|
||||
acl: '{}',
|
||||
penalty: '{}',
|
||||
oauth_info: '{}',
|
||||
oauth_type: 'KAKAO',
|
||||
oauth_id: null,
|
||||
PW: 'a'.repeat(128),
|
||||
salt: 'member-salt',
|
||||
ID: 'LegacyUser',
|
||||
NAME: '레거시유저',
|
||||
EMAIL: 'USER@EXAMPLE.TEST',
|
||||
PICTURE: 'default.jpg',
|
||||
IMGSVR: 0,
|
||||
third_use: 0,
|
||||
token_valid_until: null,
|
||||
delete_after: null,
|
||||
REG_DATE: '2020-01-01 00:00:00',
|
||||
REG_NUM: 0,
|
||||
BLOCK_NUM: 0,
|
||||
BLOCK_DATE: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('legacy gateway member migration', () => {
|
||||
it('marks imported SHA-512 credentials for reset without trusting a missing Kakao ID', () => {
|
||||
const mapped = mapMember(memberRow(), new Date('2026-08-17T00:00:00.000Z'), null);
|
||||
|
||||
expect(mapped).toMatchObject({
|
||||
login_id: 'legacyuser',
|
||||
email: 'user@example.test',
|
||||
password_reset_required: true,
|
||||
oauth_type: 'KAKAO',
|
||||
oauth_id: null,
|
||||
kakao_verified_at: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves target-owned credentials and OAuth state on a repeated member upsert', async () => {
|
||||
const query = vi.fn(async (_sql: string, _values?: unknown[]) => ({ rows: [], rowCount: 0 }));
|
||||
const client = { query } as unknown as PoolClient;
|
||||
|
||||
await upsertRows(
|
||||
client,
|
||||
'app_user',
|
||||
[
|
||||
{
|
||||
id: 'legacy-id',
|
||||
login_id: 'legacy-user',
|
||||
password_hash: 'legacy-hash',
|
||||
oauth_info: '{}',
|
||||
legacy_data: '{}',
|
||||
},
|
||||
],
|
||||
['id'],
|
||||
{ preserveOnConflict: ['password_hash', 'oauth_info'] }
|
||||
);
|
||||
|
||||
const sql = String(query.mock.calls[0]?.[0]);
|
||||
expect(sql).toContain('"login_id" = EXCLUDED."login_id"');
|
||||
expect(sql).toContain('"legacy_data" = EXCLUDED."legacy_data"');
|
||||
expect(sql).not.toContain('"password_hash" = EXCLUDED."password_hash"');
|
||||
expect(sql).not.toContain('"oauth_info" = EXCLUDED."oauth_info"');
|
||||
});
|
||||
|
||||
it('preserves renamed login and display identities along with every live credential field', () => {
|
||||
expect(MEMBER_PRESERVED_COLUMNS).toEqual(
|
||||
expect.arrayContaining([
|
||||
'login_id',
|
||||
'display_name',
|
||||
'password_hash',
|
||||
'password_salt',
|
||||
'password_reset_required',
|
||||
'roles',
|
||||
'sanctions',
|
||||
'oauth_id',
|
||||
'email',
|
||||
'updated_at',
|
||||
'last_login_at',
|
||||
'created_at',
|
||||
])
|
||||
);
|
||||
expect(MEMBER_PRESERVED_COLUMNS).not.toContain('legacy_data');
|
||||
});
|
||||
|
||||
it('rejects a source member when another target account already owns its identity', async () => {
|
||||
const query = vi.fn(async (..._args: unknown[]) => ({
|
||||
rows: [
|
||||
{
|
||||
id: 'another-target-id',
|
||||
login_id: 'legacyuser',
|
||||
display_name: '다른사용자',
|
||||
email: 'other@example.test',
|
||||
},
|
||||
],
|
||||
rowCount: 1,
|
||||
}));
|
||||
const client = { query } as unknown as PoolClient;
|
||||
const mapped = mapMember(memberRow({ oauth_id: 'stable-kakao-id' }), new Date('2026-08-17T00:00:00Z'), null);
|
||||
|
||||
await expect(preflightMemberConflicts(client, [mapped])).rejects.toThrow(
|
||||
'Target account identity collision in legacy member batch'
|
||||
);
|
||||
expect(String(query.mock.calls[0]?.[0])).toContain('FROM "app_user"');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user