merge: 최신 main을 Profile 포괄 권한 제거에 통합

This commit is contained in:
2026-08-17 16:11:07 +00:00
58 changed files with 4438 additions and 963 deletions
+442 -139
View File
@@ -1,124 +1,326 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { z } from 'zod'; import { z } from 'zod';
import { asRecord } from '@sammo-ts/common'; import {
asRecord,
normalizeArchivedGeneral,
type ArchivedGeneralSnapshotV1,
type ArchivedJsonValue,
} from '@sammo-ts/common';
import { resolveOfficerLevelName, sanitizeInternalDisplayCode } from '../../services/gameDisplayNames.js'; import {
loadCrewTypeDisplayNames,
loadItemDisplayNames,
resolveDedicationLevelName,
resolveOfficerLevelName,
sanitizeInternalDisplayCode,
} from '../../services/gameDisplayNames.js';
import {
findLegacyEmperors,
findLegacyGeneral,
findLegacyGeneralsByOwner,
findLegacyGames,
findLegacyNations,
LEGACY_ARCHIVE_PROFILES,
type LegacyArchiveProfile,
} from '../../services/legacyArchiveStore.js';
import { readOnlyAuthedProcedure, router } from '../../trpc.js'; import { readOnlyAuthedProcedure, router } from '../../trpc.js';
import { loadTraitNames } from '../nation/shared.js'; import { loadTraitNames } from '../nation/shared.js';
const numberOrNull = (value: unknown): number | null => const numberOrNull = (value: unknown): number | null =>
typeof value === 'number' && Number.isFinite(value) ? value : null; typeof value === 'number' && Number.isFinite(value) ? value : null;
const firstNumber = (record: Record<string, unknown>, ...keys: string[]): number | null => { const textOrNull = (value: unknown): string | null => {
for (const key of keys) { if (typeof value === 'string' && value.trim()) return value;
const value = numberOrNull(record[key]); if (typeof value === 'number' && Number.isFinite(value)) return String(value);
if (value !== null) {
return value;
}
}
return null; return null;
}; };
const displayTextOrNull = (value: unknown): string | null => { const numericText = (value: string | null): number | null => {
if (typeof value === 'string') { if (value === null || value.trim() === '') return null;
return value; const parsed = Number(value);
} return Number.isFinite(parsed) ? Math.trunc(parsed) : null;
return typeof value === 'number' && Number.isFinite(value) ? String(value) : null;
}; };
const parseHistory = (value: unknown): string[] => { const canonicalSnapshot = (value: unknown, fallbackName: string): ArchivedGeneralSnapshotV1 =>
if (Array.isArray(value)) { normalizeArchivedGeneral(value as ArchivedJsonValue, fallbackName).snapshot;
return value.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0);
}
if (typeof value !== 'string') {
return [];
}
return value
.split(/<br\s*\/?>/i)
.map((entry) => entry.trim())
.filter(Boolean);
};
const zPastPlayDetailInput = z.object({ const zPastPlayDetailInput = z.object({
source: z.enum(['current', 'legacy']).default('current'),
sourceProfile: z.enum(LEGACY_ARCHIVE_PROFILES).optional(),
serverId: z.string().trim().min(1).max(64), serverId: z.string().trim().min(1).max(64),
generalNo: z.number().int().positive(), generalNo: z.number().int().positive(),
}); });
type ArchiveSource = 'current' | 'legacy';
interface GeneralArchiveEntry {
source: ArchiveSource;
sourceProfile: string;
serverId: string;
generalNo: number;
name: string;
lastYearMonth: number;
turnTime: Date;
snapshot: ArchivedGeneralSnapshotV1;
}
interface ArchiveNationEntry {
source: ArchiveSource;
sourceProfile: string;
serverId: string;
nation: number;
archivedAt: Date;
data: Record<string, unknown>;
}
const key = (source: ArchiveSource, sourceProfile: string, serverId: string): string =>
`${source}:${sourceProfile}:${serverId}`;
const nationKey = (source: ArchiveSource, sourceProfile: string, serverId: string, nation: number): string =>
`${key(source, sourceProfile, serverId)}:${nation}`;
const resolveNation = (
nations: Map<string, ArchiveNationEntry>,
entry: GeneralArchiveEntry
): { nationId: number; name: string; color: string; level: number | null } => {
const nationId = entry.snapshot.identity.nationId ?? 0;
const archived = nations.get(nationKey(entry.source, entry.sourceProfile, entry.serverId, nationId));
return {
nationId,
name: textOrNull(archived?.data.name) ?? (nationId === 0 ? '재야' : '미상'),
color: textOrNull(archived?.data.color) ?? '#000000',
level: numberOrNull(archived?.data.level),
};
};
const resolveDisplayResources = async (entries: GeneralArchiveEntry[]) => {
const [personalityNames, domesticNames, warNames, itemNames] = await Promise.all([
loadTraitNames(
entries.map((entry) => entry.snapshot.traits.personality),
'personality'
),
loadTraitNames(
entries.map((entry) => entry.snapshot.traits.specialDomestic),
'domestic'
),
loadTraitNames(
entries.map((entry) => entry.snapshot.traits.specialWar),
'war'
),
loadItemDisplayNames(
entries.flatMap((entry) => [
entry.snapshot.items.horse,
entry.snapshot.items.weapon,
entry.snapshot.items.book,
entry.snapshot.items.item,
])
),
]);
const traitName = (code: string | null, names: Awaited<ReturnType<typeof loadTraitNames>>): string =>
code ? (names.get(code)?.name ?? sanitizeInternalDisplayCode(code)) : '-';
const itemName = (code: string | null): string =>
code ? (itemNames.get(code) ?? sanitizeInternalDisplayCode(code)) : '-';
return { personalityNames, domesticNames, warNames, traitName, itemName };
};
const buildGeneralDetail = async (entry: GeneralArchiveEntry, nation: ReturnType<typeof resolveNation>) => {
const snapshot = entry.snapshot;
const display = await resolveDisplayResources([entry]);
const crewTypeId = numericText(snapshot.resources.crewType);
const crewTypeNames = await loadCrewTypeDisplayNames(null, entry.sourceProfile);
const dedicationLevel = snapshot.progression.dedicationLevel ?? 0;
const maxDedicationLevel = 30;
return {
id: entry.generalNo,
name: snapshot.identity.name || entry.name,
picture: snapshot.identity.picture,
imageServer: snapshot.identity.imageServer,
npcState: snapshot.identity.npcState ?? 0,
officerLevel: snapshot.identity.officerLevel ?? 0,
officerLevelText: resolveOfficerLevelName(snapshot.identity.officerLevel ?? 0, nation.level ?? undefined),
stats: {
leadership: snapshot.stats.leadership ?? 0,
strength: snapshot.stats.strength ?? 0,
intelligence: snapshot.stats.intelligence ?? 0,
},
gold: snapshot.resources.gold ?? 0,
rice: snapshot.resources.rice ?? 0,
crew: snapshot.resources.crew ?? 0,
train: snapshot.resources.train ?? 0,
atmos: snapshot.resources.morale ?? 0,
injury: snapshot.resources.injury ?? 0,
experience: snapshot.progression.experience ?? 0,
dedication: snapshot.progression.dedication ?? 0,
...(snapshot.progression.age === null ? {} : { age: snapshot.progression.age }),
turnTime: entry.turnTime.toISOString(),
...(crewTypeId === null ? {} : { crewTypeId }),
crewTypeName: crewTypeId === null ? '-' : (crewTypeNames.get(crewTypeId) ?? '-'),
traits: {
personal: display.traitName(snapshot.traits.personality, display.personalityNames),
specialDomestic: display.traitName(snapshot.traits.specialDomestic, display.domesticNames),
specialWar: display.traitName(snapshot.traits.specialWar, display.warNames),
},
itemNames: {
horse: display.itemName(snapshot.items.horse),
weapon: display.itemName(snapshot.items.weapon),
book: display.itemName(snapshot.items.book),
item: display.itemName(snapshot.items.item),
},
progression: {
experienceLevel: snapshot.progression.experienceLevel ?? 0,
dedicationLevel,
dedicationText: resolveDedicationLevelName(dedicationLevel, maxDedicationLevel),
statExperience: {
leadership: snapshot.stats.leadershipExperience ?? 0,
strength: snapshot.stats.strengthExperience ?? 0,
intelligence: snapshot.stats.intelligenceExperience ?? 0,
},
statUpgradeLimit: 30,
dex: [
snapshot.mastery.infantry,
snapshot.mastery.archery,
snapshot.mastery.cavalry,
snapshot.mastery.special,
snapshot.mastery.siege,
].map((value) => value ?? 0),
},
};
};
export const archiveRouter = router({ export const archiveRouter = router({
myPastPlays: readOnlyAuthedProcedure.query(async ({ ctx }) => { myPastPlays: readOnlyAuthedProcedure.query(async ({ ctx }) => {
const owner = ctx.auth?.user.id; const owner = ctx.auth?.user.id;
if (!owner) { if (!owner) throw new Error('Authenticated archive query is missing its user identity');
throw new Error('Authenticated archive query is missing its user identity');
}
const generals = await ctx.db.oldGeneral.findMany({
where: { owner },
orderBy: [{ lastYearMonth: 'desc' }, { serverId: 'desc' }, { generalNo: 'asc' }],
});
if (!generals.length) {
return { seasons: [] };
}
const serverIds = [...new Set(generals.map((general) => general.serverId))]; const [legacyRows, currentRows] = await Promise.all([
const [games, nations, emperors] = await Promise.all([ findLegacyGeneralsByOwner(ctx.db, owner),
ctx.db.gameHistory.findMany({ ctx.db.oldGeneral.findMany({
where: { serverId: { in: serverIds } }, where: { owner },
}), orderBy: [{ lastYearMonth: 'desc' }, { serverId: 'desc' }, { generalNo: 'asc' }],
ctx.db.oldNation.findMany({
where: { serverId: { in: serverIds } },
orderBy: [{ date: 'desc' }, { id: 'desc' }],
}),
ctx.db.emperor.findMany({
where: { serverId: { in: serverIds } },
orderBy: { id: 'desc' },
select: { id: true, serverId: true },
}), }),
]); ]);
const legacyIdentity = new Set(
legacyRows.map((row) => `${row.sourceProfile}:${row.serverId}:${row.generalNo}`)
);
const entries: GeneralArchiveEntry[] = [
...legacyRows.map((row) => ({
source: 'legacy' as const,
sourceProfile: row.sourceProfile,
serverId: row.serverId,
generalNo: row.generalNo,
name: row.name,
lastYearMonth: row.lastYearMonth,
turnTime: row.turnTime,
snapshot: canonicalSnapshot(row.data, row.name),
})),
...currentRows
.filter((row) => !legacyIdentity.has(`${ctx.profile.id}:${row.serverId}:${row.generalNo}`))
.map((row) => ({
source: 'current' as const,
sourceProfile: ctx.profile.id,
serverId: row.serverId,
generalNo: row.generalNo,
name: row.name,
lastYearMonth: row.lastYearMonth,
turnTime: row.turnTime,
snapshot: canonicalSnapshot(row.data, row.name),
})),
];
if (entries.length === 0) return { seasons: [] };
const gameByServer = new Map(games.map((game) => [game.serverId, game])); const legacyKeys = Array.from(
const emperorByServer = new Map<string, number>(); new Map(
for (const emperor of emperors) { entries
if (emperor.serverId && !emperorByServer.has(emperor.serverId)) { .filter((entry) => entry.source === 'legacy')
emperorByServer.set(emperor.serverId, emperor.id); .map((entry) => [key(entry.source, entry.sourceProfile, entry.serverId), entry])
} ).values()
).map((entry) => ({ sourceProfile: entry.sourceProfile as LegacyArchiveProfile, serverId: entry.serverId }));
const currentServerIds = Array.from(
new Set(entries.filter((entry) => entry.source === 'current').map((entry) => entry.serverId))
);
const [legacyGames, legacyNationRows, legacyEmperors, currentGames, currentNationRows, currentEmperors] =
await Promise.all([
findLegacyGames(ctx.db, legacyKeys),
findLegacyNations(ctx.db, legacyKeys),
findLegacyEmperors(ctx.db, legacyKeys),
currentServerIds.length
? ctx.db.gameHistory.findMany({ where: { serverId: { in: currentServerIds } } })
: [],
currentServerIds.length
? ctx.db.oldNation.findMany({
where: { serverId: { in: currentServerIds } },
orderBy: [{ date: 'desc' }, { id: 'desc' }],
})
: [],
currentServerIds.length
? ctx.db.emperor.findMany({
where: { serverId: { in: currentServerIds } },
orderBy: { id: 'desc' },
select: { id: true, serverId: true },
})
: [],
]);
const games = new Map<string, { openedAt: Date; season: number; scenario: number; scenarioName: string }>();
for (const row of legacyGames) {
games.set(key('legacy', row.sourceProfile, row.serverId), row);
} }
const nationByServerAndId = new Map<string, (typeof nations)[number]>(); for (const row of currentGames) {
for (const nation of nations) { games.set(key('current', ctx.profile.id, row.serverId), {
const key = `${nation.serverId}:${nation.nation}`; openedAt: row.date,
if (!nationByServerAndId.has(key)) { season: row.season,
nationByServerAndId.set(key, nation); scenario: row.scenario,
} scenarioName: row.scenarioName,
});
} }
const archivedRoles = generals.map((general) => { const nations = new Map<string, ArchiveNationEntry>();
const data = asRecord(general.data); for (const row of legacyNationRows) {
const role = asRecord(data.role); const entry: ArchiveNationEntry = {
return { source: 'legacy',
personal: displayTextOrNull(data.personalCode ?? data.personal ?? role.personality), sourceProfile: row.sourceProfile,
special: displayTextOrNull(data.specialCode ?? data.special ?? role.specialDomestic), serverId: row.serverId,
special2: displayTextOrNull(data.special2Code ?? data.special2 ?? role.specialWar), nation: row.nation,
archivedAt: row.archivedAt,
data: asRecord(row.data),
}; };
}); const entryKey = nationKey(entry.source, entry.sourceProfile, entry.serverId, entry.nation);
const [personalityNames, domesticNames, warNames] = await Promise.all([ if (!nations.has(entryKey)) nations.set(entryKey, entry);
loadTraitNames( }
archivedRoles.map((role) => role.personal), for (const row of currentNationRows) {
'personality' const entry: ArchiveNationEntry = {
), source: 'current',
loadTraitNames( sourceProfile: ctx.profile.id,
archivedRoles.map((role) => role.special), serverId: row.serverId,
'domestic' nation: row.nation,
), archivedAt: row.date,
loadTraitNames( data: asRecord(row.data),
archivedRoles.map((role) => role.special2), };
'war' const entryKey = nationKey(entry.source, entry.sourceProfile, entry.serverId, entry.nation);
), if (!nations.has(entryKey)) nations.set(entryKey, entry);
]); }
const displayRole = (value: string | null, names: Awaited<ReturnType<typeof loadTraitNames>>): string | null => const dynastyIds = new Map<string, number>();
value ? (names.get(value)?.name ?? sanitizeInternalDisplayCode(value)) : null; for (const row of legacyEmperors) {
if (row.serverId) {
const entryKey = key('legacy', row.sourceProfile, row.serverId);
if (!dynastyIds.has(entryKey)) dynastyIds.set(entryKey, Number(row.id));
}
}
for (const row of currentEmperors) {
if (row.serverId) {
const entryKey = key('current', ctx.profile.id, row.serverId);
if (!dynastyIds.has(entryKey)) dynastyIds.set(entryKey, row.id);
}
}
const display = await resolveDisplayResources(entries);
const seasons = new Map< const seasons = new Map<
string, string,
{ {
source: ArchiveSource;
sourceProfile: string;
serverId: string; serverId: string;
openedAt: string | null;
date: string | null; date: string | null;
season: number | null; season: number | null;
scenario: number | null; scenario: number | null;
@@ -145,85 +347,186 @@ export const archiveRouter = router({
}>; }>;
} }
>(); >();
for (const entry of entries) {
for (const [generalIndex, general] of generals.entries()) { const entryKey = key(entry.source, entry.sourceProfile, entry.serverId);
const game = gameByServer.get(general.serverId); const game = games.get(entryKey);
let season = seasons.get(general.serverId); let season = seasons.get(entryKey);
if (!season) { if (!season) {
const openedAt = game?.openedAt.toISOString() ?? null;
season = { season = {
serverId: general.serverId, source: entry.source,
date: game?.date.toISOString() ?? null, sourceProfile: entry.sourceProfile,
serverId: entry.serverId,
openedAt,
date: openedAt,
season: game?.season ?? null, season: game?.season ?? null,
scenario: game?.scenario ?? null, scenario: game?.scenario ?? null,
scenarioName: game?.scenarioName ?? null, scenarioName: game?.scenarioName ?? null,
dynastyId: emperorByServer.get(general.serverId) ?? null, dynastyId: dynastyIds.get(entryKey) ?? null,
generals: [], generals: [],
}; };
seasons.set(general.serverId, season); seasons.set(entryKey, season);
} }
const snapshot = entry.snapshot;
const data = asRecord(general.data); const nation = resolveNation(nations, entry);
const stats = asRecord(data.stats); const officerLevel = snapshot.identity.officerLevel;
const nationId = firstNumber(data, 'nationId', 'nation') ?? 0;
const nation = nationByServerAndId.get(`${general.serverId}:${nationId}`);
const nationData = asRecord(nation?.data);
const officerLevel = firstNumber(data, 'officerLevel', 'officer_level');
const nationLevel = firstNumber(nationData, 'level', 'nationLevel');
const archivedRole = archivedRoles[generalIndex]!;
season.generals.push({ season.generals.push({
generalNo: general.generalNo, generalNo: entry.generalNo,
name: general.name, name: snapshot.identity.name || entry.name,
lastYearMonth: general.lastYearMonth, lastYearMonth: entry.lastYearMonth,
nationId, nationId: nation.nationId,
nationName: displayTextOrNull(nationData.name) ?? (nationId === 0 ? '재야' : '미상'), nationName: nation.name,
nationColor: displayTextOrNull(nationData.color) ?? '#000000', nationColor: nation.color,
leadership: firstNumber(data, 'leadership', 'leader') ?? numberOrNull(stats.leadership), leadership: snapshot.stats.leadership,
strength: firstNumber(data, 'strength', 'power') ?? numberOrNull(stats.strength), strength: snapshot.stats.strength,
intel: firstNumber(data, 'intel', 'intelligence') ?? numberOrNull(stats.intelligence), intel: snapshot.stats.intelligence,
experience: numberOrNull(data.experience), experience: snapshot.progression.experience,
dedication: numberOrNull(data.dedication), dedication: snapshot.progression.dedication,
officerLevel, officerLevel,
officerLevelText: officerLevelText:
officerLevel === null officerLevel === null ? null : resolveOfficerLevelName(officerLevel, nation.level ?? undefined),
? null personal: display.traitName(snapshot.traits.personality, display.personalityNames),
: resolveOfficerLevelName(officerLevel, nationLevel === null ? undefined : nationLevel), special: display.traitName(snapshot.traits.specialDomestic, display.domesticNames),
personal: displayRole(archivedRole.personal, personalityNames), special2: display.traitName(snapshot.traits.specialWar, display.warNames),
special: displayRole(archivedRole.special, domesticNames), historyCount: snapshot.history.length,
special2: displayRole(archivedRole.special2, warNames),
historyCount: parseHistory(data.history).length,
}); });
} }
return { return {
seasons: [...seasons.values()].sort((left, right) => { seasons: [...seasons.values()].sort((left, right) => {
const leftTime = left.date ? new Date(left.date).getTime() : 0; const leftTime = left.openedAt ? new Date(left.openedAt).getTime() : 0;
const rightTime = right.date ? new Date(right.date).getTime() : 0; const rightTime = right.openedAt ? new Date(right.openedAt).getTime() : 0;
return rightTime - leftTime || right.serverId.localeCompare(left.serverId); return rightTime - leftTime || right.serverId.localeCompare(left.serverId);
}), }),
}; };
}), }),
myPastPlayDetail: readOnlyAuthedProcedure.input(zPastPlayDetailInput).query(async ({ ctx, input }) => { myPastPlayDetail: readOnlyAuthedProcedure.input(zPastPlayDetailInput).query(async ({ ctx, input }) => {
const owner = ctx.auth?.user.id; const owner = ctx.auth?.user.id;
if (!owner) { if (!owner) throw new Error('Authenticated archive query is missing its user identity');
throw new Error('Authenticated archive query is missing its user identity'); const sourceProfile = input.sourceProfile ?? ctx.profile.id;
} if (input.source === 'current' && sourceProfile !== ctx.profile.id) {
const general = await ctx.db.oldGeneral.findFirst({
where: {
owner,
serverId: input.serverId,
generalNo: input.generalNo,
},
});
if (!general) {
throw new TRPCError({ code: 'NOT_FOUND', message: '지난 장수 기록을 찾을 수 없습니다.' }); throw new TRPCError({ code: 'NOT_FOUND', message: '지난 장수 기록을 찾을 수 없습니다.' });
} }
const data = asRecord(general.data);
let entry: GeneralArchiveEntry | null = null;
let nationRows: ArchiveNationEntry[] = [];
let dynastyId: number | null = null;
if (input.source === 'legacy') {
if (!LEGACY_ARCHIVE_PROFILES.includes(sourceProfile as LegacyArchiveProfile)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이전 서버 프로필입니다.' });
}
const profile = sourceProfile as LegacyArchiveProfile;
const row = await findLegacyGeneral(ctx.db, {
owner,
sourceProfile: profile,
serverId: input.serverId,
generalNo: input.generalNo,
});
if (row) {
entry = {
source: 'legacy',
sourceProfile: row.sourceProfile,
serverId: row.serverId,
generalNo: row.generalNo,
name: row.name,
lastYearMonth: row.lastYearMonth,
turnTime: row.turnTime,
snapshot: canonicalSnapshot(row.data, row.name),
};
const keyInput = [{ sourceProfile: profile, serverId: input.serverId }];
const [nations, emperors] = await Promise.all([
findLegacyNations(ctx.db, keyInput),
findLegacyEmperors(ctx.db, keyInput),
]);
nationRows = nations.map((nation) => ({
source: 'legacy',
sourceProfile: nation.sourceProfile,
serverId: nation.serverId,
nation: nation.nation,
archivedAt: nation.archivedAt,
data: asRecord(nation.data),
}));
dynastyId = Number(emperors[0]?.id ?? 0) || null;
}
} else {
const row = await ctx.db.oldGeneral.findFirst({
where: { owner, serverId: input.serverId, generalNo: input.generalNo },
});
if (row) {
entry = {
source: 'current',
sourceProfile: ctx.profile.id,
serverId: row.serverId,
generalNo: row.generalNo,
name: row.name,
lastYearMonth: row.lastYearMonth,
turnTime: row.turnTime,
snapshot: canonicalSnapshot(row.data, row.name),
};
const [nations, emperor] = await Promise.all([
ctx.db.oldNation.findMany({
where: { serverId: input.serverId },
orderBy: [{ date: 'desc' }, { id: 'desc' }],
}),
ctx.db.emperor.findFirst({ where: { serverId: input.serverId }, orderBy: { id: 'desc' } }),
]);
nationRows = nations.map((nation) => ({
source: 'current',
sourceProfile: ctx.profile.id,
serverId: nation.serverId,
nation: nation.nation,
archivedAt: nation.date,
data: asRecord(nation.data),
}));
dynastyId = emperor?.id ?? null;
}
}
if (!entry) {
throw new TRPCError({ code: 'NOT_FOUND', message: '지난 장수 기록을 찾을 수 없습니다.' });
}
const nationMap = new Map<string, ArchiveNationEntry>();
for (const nation of nationRows) {
const entryKey = nationKey(nation.source, nation.sourceProfile, nation.serverId, nation.nation);
if (!nationMap.has(entryKey)) nationMap.set(entryKey, nation);
}
const nation = resolveNation(nationMap, entry);
const snapshot = entry.snapshot;
const general = await buildGeneralDetail(entry, nation);
const logs = {
generalHistory: {
available: snapshot.availability.history,
entries: snapshot.history.map((text, index) => ({ id: index + 1, text })),
},
battleDetail: { available: snapshot.availability.battleDetailLogs, entries: [] },
battleResult: { available: snapshot.availability.battleResultLogs, entries: [] },
generalAction: { available: false, entries: [] },
};
return { return {
serverId: general.serverId, source: entry.source,
generalNo: general.generalNo, sourceProfile: entry.sourceProfile,
name: general.name, serverId: entry.serverId,
lastYearMonth: general.lastYearMonth, generalNo: entry.generalNo,
history: parseHistory(data.history), sourceFormat: input.source === 'legacy' ? 'normalized-v1' : 'current-archive',
dynastyPath:
dynastyId === null ? null : `/dynasty/${dynastyId}${entry.source === 'legacy' ? '?source=legacy' : ''}`,
nation: { id: nation.nationId, name: nation.name, color: nation.color },
general,
masteryAvailable: snapshot.availability.mastery,
battle: {
available: snapshot.availability.battleAggregates,
warnum: snapshot.battle.battles,
wins: snapshot.battle.wins,
losses: snapshot.battle.losses,
strategies: snapshot.battle.fireSuccesses,
killCrew: snapshot.battle.killedCrew,
deathCrew: snapshot.battle.lostCrew,
winRate: snapshot.battle.winRate,
killRate: snapshot.battle.killRate,
recentWar: snapshot.battle.recentWar,
},
logs,
}; };
}), }),
}); });
+159 -1
View File
@@ -4,11 +4,24 @@ import { z } from 'zod';
import { asRecord } from '@sammo-ts/common'; import { asRecord } from '@sammo-ts/common';
import { procedure, router } from '../../trpc.js'; import { procedure, router } from '../../trpc.js';
import {
findLegacyEmperor,
findLegacyEmperors,
findLegacyGeneralsForServer,
findLegacyNations,
} from '../../services/legacyArchiveStore.js';
const zDynastyDetailInput = z.object({ const zDynastyDetailInput = z.object({
emperorId: z.number().int().positive(), emperorId: z.number().int().positive(),
source: z.enum(['current', 'legacy']).default('current'),
}); });
const zDynastyListInput = z
.object({
source: z.enum(['current', 'legacy']).default('current'),
})
.optional();
const parseNumberArray = (value: unknown): number[] => const parseNumberArray = (value: unknown): number[] =>
Array.isArray(value) Array.isArray(value)
? value.filter((item): item is number => typeof item === 'number' && Number.isFinite(item)) ? value.filter((item): item is number => typeof item === 'number' && Number.isFinite(item))
@@ -44,6 +57,41 @@ const firstFiniteNumber = (...values: unknown[]): number | null => {
return null; return null;
}; };
const firstText = (...values: unknown[]): string => {
for (const value of values) {
if (typeof value === 'string') return value;
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
}
return '';
};
const legacyEmperorListEntry = (row: Awaited<ReturnType<typeof findLegacyEmperors>>[number]) => {
const data = asRecord(row.data);
return {
id: Number(row.id),
source: 'legacy' as const,
sourceProfile: row.sourceProfile,
serverId: row.serverId ?? '',
phase: firstText(data.phase),
name: firstText(data.name),
year: firstFiniteNumber(data.year) ?? 0,
month: firstFiniteNumber(data.month) ?? 0,
color: firstText(data.color) || '#000000',
type: firstText(data.type),
power: firstFiniteNumber(data.power) ?? 0,
gennum: firstFiniteNumber(data.gennum) ?? 0,
citynum: firstFiniteNumber(data.citynum) ?? 0,
l12name: firstText(data.l12name),
l11name: firstText(data.l11name),
l10name: firstText(data.l10name),
l9name: firstText(data.l9name),
l8name: firstText(data.l8name),
l7name: firstText(data.l7name),
l6name: firstText(data.l6name),
l5name: firstText(data.l5name),
};
};
const firstDisplayArray = (...values: unknown[]): Array<string | number> => { const firstDisplayArray = (...values: unknown[]): Array<string | number> => {
for (const value of values) { for (const value of values) {
const parsed = parseDisplayArray(value); const parsed = parseDisplayArray(value);
@@ -82,7 +130,15 @@ const formatNationLevel = (level: number | null): string => {
}; };
export const dynastyRouter = router({ export const dynastyRouter = router({
getList: procedure.query(async ({ ctx }) => { getList: procedure.input(zDynastyListInput).query(async ({ ctx, input }) => {
if ((input?.source ?? 'current') === 'legacy') {
const rows = await findLegacyEmperors(ctx.db);
return {
source: 'legacy' as const,
current: null,
entries: rows.map(legacyEmperorListEntry),
};
}
const [worldState, rows] = await Promise.all([ const [worldState, rows] = await Promise.all([
ctx.db.worldState.findFirst({ ctx.db.worldState.findFirst({
select: { select: {
@@ -96,6 +152,7 @@ export const dynastyRouter = router({
]); ]);
return { return {
source: 'current' as const,
current: worldState current: worldState
? { ? {
year: worldState.currentYear, year: worldState.currentYear,
@@ -104,6 +161,8 @@ export const dynastyRouter = router({
: null, : null,
entries: rows.map((row) => ({ entries: rows.map((row) => ({
id: row.id, id: row.id,
source: 'current' as const,
sourceProfile: ctx.profile.id,
serverId: row.serverId ?? '', serverId: row.serverId ?? '',
phase: row.phase ?? '', phase: row.phase ?? '',
name: row.name ?? '', name: row.name ?? '',
@@ -126,6 +185,103 @@ export const dynastyRouter = router({
}; };
}), }),
getDetail: procedure.input(zDynastyDetailInput).query(async ({ ctx, input }) => { getDetail: procedure.input(zDynastyDetailInput).query(async ({ ctx, input }) => {
if (input.source === 'legacy') {
const archived = await findLegacyEmperor(ctx.db, input.emperorId);
if (!archived) {
throw new TRPCError({ code: 'NOT_FOUND', message: '이전 서버 왕조 정보를 찾을 수 없습니다.' });
}
const emperor = asRecord(archived.data);
const aux = asRecord(emperor.aux);
const winnerNationId = firstFiniteNumber(aux.winnerNationId, aux.winner_nation_id);
const serverId = archived.serverId ?? '';
const oldNationRows = serverId
? await findLegacyNations(ctx.db, [{ sourceProfile: archived.sourceProfile, serverId }])
: [];
const nationEntries = oldNationRows
.map((row) => {
const normalized = normalizeOldNationData(row.data);
const { data } = normalized;
const nationId = row.nation ?? firstFiniteNumber(data.nation) ?? 0;
return {
archiveId: row.legacyId,
nation: nationId,
isWinner: winnerNationId !== null && nationId === winnerNationId,
name: typeof data.name === 'string' ? data.name : nationId === 0 ? '재야' : '미상',
color: typeof data.color === 'string' ? data.color : '#000000',
type: normalized.typeCode,
typeName: formatNationType(normalized.typeCode),
level: firstFiniteNumber(data.level),
tech: normalized.tech,
maxPower: normalized.maxPower,
maxCrew: normalized.maxCrew,
maxCities: normalized.maxCities,
generals: parseNumberArray(data.generals),
history: parseTextArray(data.history),
date: row.archivedAt.toISOString(),
};
})
.filter((entry) => entry.nation !== 0);
const generalIds = Array.from(new Set(nationEntries.flatMap((entry) => entry.generals)));
const generalRows = serverId
? await findLegacyGeneralsForServer(ctx.db, {
sourceProfile: archived.sourceProfile,
serverId,
generalNos: generalIds,
})
: [];
const generalMap = new Map(
generalRows.map((row) => [row.generalNo, { name: row.name, lastYearMonth: row.lastYearMonth }])
);
return {
source: 'legacy' as const,
sourceProfile: archived.sourceProfile,
emperor: {
id: Number(archived.id),
serverId,
winnerNationId,
phase: firstText(emperor.phase),
nationCount: firstText(emperor.nation_count, emperor.nationCount),
nationName: firstText(emperor.nation_name, emperor.nationName),
nationHist: firstText(emperor.nation_hist, emperor.nationHist),
genCount: firstText(emperor.gen_count, emperor.genCount),
personalHist: firstText(emperor.personal_hist, emperor.personalHist),
specialHist: firstText(emperor.special_hist, emperor.specialHist),
name: firstText(emperor.name),
type: firstText(emperor.type),
color: firstText(emperor.color) || '#000000',
year: firstFiniteNumber(emperor.year) ?? 0,
month: firstFiniteNumber(emperor.month) ?? 0,
power: firstFiniteNumber(emperor.power) ?? 0,
gennum: firstFiniteNumber(emperor.gennum) ?? 0,
citynum: firstFiniteNumber(emperor.citynum) ?? 0,
pop: firstText(emperor.pop) || '0',
poprate: firstText(emperor.poprate),
gold: firstFiniteNumber(emperor.gold) ?? 0,
rice: firstFiniteNumber(emperor.rice) ?? 0,
l12name: firstText(emperor.l12name),
l11name: firstText(emperor.l11name),
l10name: firstText(emperor.l10name),
l9name: firstText(emperor.l9name),
l8name: firstText(emperor.l8name),
l7name: firstText(emperor.l7name),
l6name: firstText(emperor.l6name),
l5name: firstText(emperor.l5name),
tiger: firstText(emperor.tiger),
eagle: firstText(emperor.eagle),
gen: firstText(emperor.gen),
history: parseTextArray(emperor.history),
},
nations: nationEntries.map((entry) => ({
...entry,
levelName: formatNationLevel(entry.level),
generalsFull: entry.generals.map((id) => ({
generalNo: id,
name: generalMap.get(id)?.name ?? `#${id}`,
lastYearMonth: generalMap.get(id)?.lastYearMonth ?? null,
})),
})),
};
}
const emperor = await ctx.db.emperor.findUnique({ const emperor = await ctx.db.emperor.findUnique({
where: { id: input.emperorId }, where: { id: input.emperorId },
}); });
@@ -192,6 +348,8 @@ export const dynastyRouter = router({
})); }));
return { return {
source: 'current' as const,
sourceProfile: ctx.profile.id,
emperor: { emperor: {
id: emperor.id, id: emperor.id,
serverId, serverId,
+385 -306
View File
@@ -7,6 +7,11 @@ import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacy
import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import { accessAuthedInputProcedure, accessInputProcedure, procedure, router } from '../../trpc.js'; import { accessAuthedInputProcedure, accessInputProcedure, procedure, router } from '../../trpc.js';
import {
findLegacyHallOptions,
findLegacyHallRows,
LEGACY_ARCHIVE_PROFILES,
} from '../../services/legacyArchiveStore.js';
const DEFAULT_BG_COLOR = '#330000'; const DEFAULT_BG_COLOR = '#330000';
const DEFAULT_FG_COLOR = '#ffffff'; const DEFAULT_FG_COLOR = '#ffffff';
@@ -82,372 +87,446 @@ const loadUniqueItems = () => {
export const rankingRouter = router({ export const rankingRouter = router({
getBestGeneral: accessAuthedInputProcedure( getBestGeneral: accessAuthedInputProcedure(
z z
.object({ .object({
view: z.enum(['user', 'npc']).optional(), view: z.enum(['user', 'npc']).optional(),
}) })
.optional() .optional()
) ).query(async ({ ctx, input }) => {
.query(async ({ ctx, input }) => { const worldState = await ctx.db.worldState.findFirst({
const worldState = await ctx.db.worldState.findFirst({ select: { meta: true, config: true },
select: { meta: true, config: true }, });
}); const meta = asRecord(worldState?.meta);
const meta = asRecord(worldState?.meta); const isUnited = typeof meta.isUnited === 'number' && meta.isUnited !== 0;
const isUnited = typeof meta.isUnited === 'number' && meta.isUnited !== 0;
const view = input?.view ?? 'user'; const view = input?.view ?? 'user';
const npcFilter = view === 'npc' ? { gte: 2 } : { lt: 2 }; const npcFilter = view === 'npc' ? { gte: 2 } : { lt: 2 };
const [nations, generals] = await Promise.all([ const [nations, generals] = await Promise.all([
ctx.db.nation.findMany({ select: { id: true, name: true, color: true } }), ctx.db.nation.findMany({ select: { id: true, name: true, color: true } }),
ctx.db.general.findMany({ ctx.db.general.findMany({
where: { npcState: npcFilter }, where: { npcState: npcFilter },
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
select: { select: {
id: true, id: true,
name: true, name: true,
nationId: true, nationId: true,
userId: true, userId: true,
picture: true, picture: true,
imageServer: true, imageServer: true,
meta: true, meta: true,
experience: true, experience: true,
dedication: true, dedication: true,
horseCode: true, horseCode: true,
weaponCode: true, weaponCode: true,
bookCode: true, bookCode: true,
itemCode: true, itemCode: true,
}, },
}), }),
]); ]);
const nationMap = new Map(nations.map((nation) => [nation.id, nation])); const nationMap = new Map(nations.map((nation) => [nation.id, nation]));
const generalIds = generals.map((general) => general.id); const generalIds = generals.map((general) => general.id);
const rankRows = await ctx.db.rankData.findMany({ const rankRows = await ctx.db.rankData.findMany({
where: { generalId: { in: generalIds } }, where: { generalId: { in: generalIds } },
select: { generalId: true, type: true, value: true }, select: { generalId: true, type: true, value: true },
}); });
const rankMap = new Map<number, Record<string, number>>(); const rankMap = new Map<number, Record<string, number>>();
for (const row of rankRows) { for (const row of rankRows) {
const entry = rankMap.get(row.generalId) ?? {}; const entry = rankMap.get(row.generalId) ?? {};
entry[row.type] = row.value; entry[row.type] = row.value;
rankMap.set(row.generalId, entry); rankMap.set(row.generalId, entry);
} }
const types: Array<[string, 'int' | 'percent', (general: typeof generals[number], ranks: Record<string, number>) => number]> = [ const types: Array<
['명 성', 'int', (g) => g.experience], [string, 'int' | 'percent', (general: (typeof generals)[number], ranks: Record<string, number>) => number]
['계 급', 'int', (g) => g.dedication], > = [
['계 략 성 공', 'int', (_g, r) => r.firenum ?? 0], ['명 성', 'int', (g) => g.experience],
['전 투 횟 수', 'int', (_g, r) => r.warnum ?? 0], ['계 급', 'int', (g) => g.dedication],
['승 리', 'int', (_g, r) => r.killnum ?? 0], ['계 략 성 공', 'int', (_g, r) => r.firenum ?? 0],
['승 률', 'percent', (_g, r) => { ['전 투 횟 수', 'int', (_g, r) => r.warnum ?? 0],
['승 리', 'int', (_g, r) => r.killnum ?? 0],
[
'승 률',
'percent',
(_g, r) => {
const warnum = r.warnum ?? 0; const warnum = r.warnum ?? 0;
if (warnum < 10) { if (warnum < 10) {
return 0; return 0;
} }
return (r.killnum ?? 0) / Math.max(1, warnum); return (r.killnum ?? 0) / Math.max(1, warnum);
}], },
['점 령', 'int', (_g, r) => r.occupied ?? 0], ],
['사 살', 'int', (_g, r) => r.killcrew ?? 0], ['점 령', 'int', (_g, r) => r.occupied ?? 0],
['살 상 률', 'percent', (_g, r) => { ['살', 'int', (_g, r) => r.killcrew ?? 0],
[
'살 상 률',
'percent',
(_g, r) => {
const warnum = r.warnum ?? 0; const warnum = r.warnum ?? 0;
if (warnum < 10) { if (warnum < 10) {
return 0; return 0;
} }
return (r.killcrew ?? 0) / Math.max(1, r.deathcrew ?? 0); return (r.killcrew ?? 0) / Math.max(1, r.deathcrew ?? 0);
}], },
['대 인 사 살', 'int', (_g, r) => r.killcrew_person ?? 0], ],
['대 인 살 상 률', 'percent', (_g, r) => { ['대 인 살', 'int', (_g, r) => r.killcrew_person ?? 0],
[
'대 인 살 상 률',
'percent',
(_g, r) => {
const warnum = r.warnum ?? 0; const warnum = r.warnum ?? 0;
if (warnum < 10) { if (warnum < 10) {
return 0; return 0;
} }
return (r.killcrew_person ?? 0) / Math.max(1, r.deathcrew_person ?? 0); return (r.killcrew_person ?? 0) / Math.max(1, r.deathcrew_person ?? 0);
}], },
['보 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex1)], ],
[' 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex2)], [' 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex1)],
[' 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex3)], [' 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex2)],
[' 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex4)], [' 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex3)],
[' 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex5)], [' 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex4)],
['전 력 전 승 률', 'percent', (_g, r) => { ['차 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex5)],
[
'전 력 전 승 률',
'percent',
(_g, r) => {
const total = (r.ttw ?? 0) + (r.ttd ?? 0) + (r.ttl ?? 0); const total = (r.ttw ?? 0) + (r.ttd ?? 0) + (r.ttl ?? 0);
if (total < 50) { if (total < 50) {
return 0; return 0;
} }
return (r.ttw ?? 0) / Math.max(1, total); return (r.ttw ?? 0) / Math.max(1, total);
}], },
['통 솔 전 승 률', 'percent', (_g, r) => { ],
[
'통 솔 전 승 률',
'percent',
(_g, r) => {
const total = (r.tlw ?? 0) + (r.tld ?? 0) + (r.tll ?? 0); const total = (r.tlw ?? 0) + (r.tld ?? 0) + (r.tll ?? 0);
if (total < 50) { if (total < 50) {
return 0; return 0;
} }
return (r.tlw ?? 0) / Math.max(1, total); return (r.tlw ?? 0) / Math.max(1, total);
}], },
['일 기 토 승 률', 'percent', (_g, r) => { ],
[
'일 기 토 승 률',
'percent',
(_g, r) => {
const total = (r.tsw ?? 0) + (r.tsd ?? 0) + (r.tsl ?? 0); const total = (r.tsw ?? 0) + (r.tsd ?? 0) + (r.tsl ?? 0);
if (total < 50) { if (total < 50) {
return 0; return 0;
} }
return (r.tsw ?? 0) / Math.max(1, total); return (r.tsw ?? 0) / Math.max(1, total);
}], },
['설 전 승 률', 'percent', (_g, r) => { ],
[
'설 전 승 률',
'percent',
(_g, r) => {
const total = (r.tiw ?? 0) + (r.tid ?? 0) + (r.til ?? 0); const total = (r.tiw ?? 0) + (r.tid ?? 0) + (r.til ?? 0);
if (total < 50) { if (total < 50) {
return 0; return 0;
} }
return (r.tiw ?? 0) / Math.max(1, total); return (r.tiw ?? 0) / Math.max(1, total);
}], },
['베 팅 투 자 액', 'int', (_g, r) => r.betgold ?? 0], ],
['베 팅 당 첨', 'int', (_g, r) => r.betwin ?? 0], ['베 팅 투 자 액', 'int', (_g, r) => r.betgold ?? 0],
['베 팅 수 익 금', 'int', (_g, r) => r.betwingold ?? 0], ['베 팅 당 첨', 'int', (_g, r) => r.betwin ?? 0],
['베 팅 수 익 ', 'percent', (_g, r) => { ['베 팅 수 익 ', 'int', (_g, r) => r.betwingold ?? 0],
[
'베 팅 수 익 률',
'percent',
(_g, r) => {
const betgold = r.betgold ?? 0; const betgold = r.betgold ?? 0;
if (betgold < 1000) { if (betgold < 1000) {
return 0; return 0;
} }
return (r.betwingold ?? 0) / Math.max(1, betgold); return (r.betwingold ?? 0) / Math.max(1, betgold);
}], },
['유 산 소 모 량', 'int', (_g, r) => r.inherit_spent ?? 0], ],
['유 산 획 득 량', 'int', (_g, r) => r.inherit_earned ?? 0], ['유 산 소 모 량', 'int', (_g, r) => r.inherit_spent ?? 0],
]; ['유 산 획 득 량', 'int', (_g, r) => r.inherit_earned ?? 0],
];
const sections = types.map(([title, valueType, valueFn]) => { const sections = types.map(([title, valueType, valueFn]) => {
const entries = generals const entries = generals
.map((general) => {
const ranks = rankMap.get(general.id) ?? {};
const value = valueFn(general, ranks);
const nation = nationMap.get(general.nationId) ?? null;
const bgColor = nation?.color ?? (general.nationId === 0 ? NEUTRAL_BG_COLOR : DEFAULT_BG_COLOR);
let display = {
id: general.id,
name: general.name,
ownerName: isUnited ? readOwnerDisplayName(general.meta) : null,
nationName: nation?.name ?? '재야',
bgColor,
fgColor: resolveLegacyTextColor(bgColor),
picture: general.picture ?? null,
imageServer: general.imageServer ?? 0,
value,
printValue: valueType === 'percent' ? percentText(value) : formatLegacyRankingNumber(value),
};
if (
!isUnited &&
(title === '계 략 성 공' || title === '유 산 소 모 량' || title === '유 산 획 득 량')
) {
display = {
...display,
name: '???',
ownerName: null,
nationName: '???',
bgColor: DEFAULT_BG_COLOR,
fgColor: resolveLegacyTextColor(DEFAULT_BG_COLOR),
picture: null,
imageServer: 0,
};
}
return display;
})
.filter((entry) => entry.value > 0)
.sort((a, b) => b.value - a.value)
.slice(0, 10);
return { title, valueType, entries };
});
const uniqueItems = await loadUniqueItems();
const itemRegistry = new Map(uniqueItems.map((item) => [item.key, item]));
const uniqueConfig = resolveUniqueConfig(asRecord(asRecord(worldState?.config).const));
if (Object.keys(uniqueConfig.allItems).length === 0) {
uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry);
}
const activeAuctions = await ctx.db.auction.findMany({
where: {
type: 'UNIQUE_ITEM',
status: { in: ['OPEN', 'FINALIZING'] },
targetCode: { not: null },
},
select: { targetCode: true },
});
const auctionCounts = new Map<string, number>();
for (const auction of activeAuctions) {
if (auction.targetCode) {
auctionCounts.set(auction.targetCode, (auctionCounts.get(auction.targetCode) ?? 0) + 1);
}
}
const slotTitles = {
horse: '명 마',
weapon: '명 검',
book: '명 서',
item: '도 구',
} as const;
const itemEntries = (['horse', 'weapon', 'book', 'item'] as const).map((slot) => {
const configuredItems = Object.entries(uniqueConfig.allItems[slot] ?? {}).reverse();
const entries = configuredItems.flatMap(([itemKey, rawCount]) => {
const item = itemRegistry.get(itemKey);
if (!item || item.buyable) {
return [];
}
const owners = generals
.filter((general) => {
if (slot === 'horse') {
return general.horseCode === itemKey;
}
if (slot === 'weapon') {
return general.weaponCode === itemKey;
}
if (slot === 'book') {
return general.bookCode === itemKey;
}
return general.itemCode === itemKey;
})
.map((general) => { .map((general) => {
const ranks = rankMap.get(general.id) ?? {};
const value = valueFn(general, ranks);
const nation = nationMap.get(general.nationId) ?? null; const nation = nationMap.get(general.nationId) ?? null;
const bgColor = const bgColor = nation?.color ?? (general.nationId === 0 ? NEUTRAL_BG_COLOR : DEFAULT_BG_COLOR);
nation?.color ?? (general.nationId === 0 ? NEUTRAL_BG_COLOR : DEFAULT_BG_COLOR); return {
let display = {
id: general.id, id: general.id,
name: general.name, name: general.name,
ownerName: isUnited ? readOwnerDisplayName(general.meta) : null,
nationName: nation?.name ?? '재야', nationName: nation?.name ?? '재야',
bgColor, bgColor,
fgColor: resolveLegacyTextColor(bgColor), fgColor: resolveLegacyTextColor(bgColor),
picture: general.picture ?? null, picture: general.picture ?? null,
imageServer: general.imageServer ?? 0, imageServer: general.imageServer ?? 0,
value,
printValue:
valueType === 'percent' ? percentText(value) : formatLegacyRankingNumber(value),
}; };
});
if (!isUnited && (title === '계 략 성 공' || title === '유 산 소 모 량' || title === '유 산 획 득 량')) { for (let index = 0; index < (auctionCounts.get(itemKey) ?? 0); index += 1) {
display = { owners.push({
...display, id: 0,
name: '???', name: '경매중',
ownerName: null, nationName: '-',
nationName: '???', bgColor: '#00582c',
bgColor: DEFAULT_BG_COLOR, fgColor: '#ffffff',
fgColor: resolveLegacyTextColor(DEFAULT_BG_COLOR), picture: null,
picture: null, imageServer: 0,
imageServer: 0, });
};
}
return display;
})
.filter((entry) => entry.value > 0)
.sort((a, b) => b.value - a.value)
.slice(0, 10);
return { title, valueType, entries };
});
const uniqueItems = await loadUniqueItems();
const itemRegistry = new Map(uniqueItems.map((item) => [item.key, item]));
const uniqueConfig = resolveUniqueConfig(asRecord(asRecord(worldState?.config).const));
if (Object.keys(uniqueConfig.allItems).length === 0) {
uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry);
}
const activeAuctions = await ctx.db.auction.findMany({
where: {
type: 'UNIQUE_ITEM',
status: { in: ['OPEN', 'FINALIZING'] },
targetCode: { not: null },
},
select: { targetCode: true },
});
const auctionCounts = new Map<string, number>();
for (const auction of activeAuctions) {
if (auction.targetCode) {
auctionCounts.set(auction.targetCode, (auctionCounts.get(auction.targetCode) ?? 0) + 1);
} }
} const count = Math.max(0, Math.floor(rawCount));
const slotTitles = { return Array.from({ length: count }, (_, index) => ({
horse: '명 마', itemKey,
weapon: '명 검', itemName: item.name,
book: '명 서', itemInfo: item.info,
item: '도 구', owner: owners[index] ?? {
} as const; id: 0,
const itemEntries = (['horse', 'weapon', 'book', 'item'] as const).map((slot) => { name: '미발견',
const configuredItems = Object.entries(uniqueConfig.allItems[slot] ?? {}).reverse(); nationName: '-',
const entries = configuredItems.flatMap(([itemKey, rawCount]) => { bgColor: DEFAULT_BG_COLOR,
const item = itemRegistry.get(itemKey); fgColor: resolveLegacyTextColor(DEFAULT_BG_COLOR),
if (!item || item.buyable) { picture: null,
return []; imageServer: 0,
} },
const owners = generals }));
.filter((general) => {
if (slot === 'horse') {
return general.horseCode === itemKey;
}
if (slot === 'weapon') {
return general.weaponCode === itemKey;
}
if (slot === 'book') {
return general.bookCode === itemKey;
}
return general.itemCode === itemKey;
})
.map((general) => {
const nation = nationMap.get(general.nationId) ?? null;
const bgColor =
nation?.color ?? (general.nationId === 0 ? NEUTRAL_BG_COLOR : DEFAULT_BG_COLOR);
return {
id: general.id,
name: general.name,
nationName: nation?.name ?? '재야',
bgColor,
fgColor: resolveLegacyTextColor(bgColor),
picture: general.picture ?? null,
imageServer: general.imageServer ?? 0,
};
});
for (let index = 0; index < (auctionCounts.get(itemKey) ?? 0); index += 1) {
owners.push({
id: 0,
name: '경매중',
nationName: '-',
bgColor: '#00582c',
fgColor: '#ffffff',
picture: null,
imageServer: 0,
});
}
const count = Math.max(0, Math.floor(rawCount));
return Array.from({ length: count }, (_, index) => ({
itemKey,
itemName: item.name,
itemInfo: item.info,
owner: owners[index] ?? {
id: 0,
name: '미발견',
nationName: '-',
bgColor: DEFAULT_BG_COLOR,
fgColor: resolveLegacyTextColor(DEFAULT_BG_COLOR),
picture: null,
imageServer: 0,
},
}));
});
return { title: slotTitles[slot], slot, entries };
}); });
return { title: slotTitles[slot], slot, entries };
return {
isUnited,
sections,
uniqueItems: itemEntries,
};
}),
getHallOfFameOptions: procedure.query(async ({ ctx }) => {
const rows = await ctx.db.gameHistory.findMany({
select: { season: true, scenario: true, scenarioName: true },
orderBy: [{ season: 'desc' }, { scenario: 'asc' }],
}); });
const seasonMap = new Map<number, { season: number; scenarios: Array<{ id: number; name: string; count: number }> }>();
for (const row of rows) { return {
const entry = seasonMap.get(row.season) ?? { season: row.season, scenarios: [] }; isUnited,
const scenario = entry.scenarios.find((item) => item.id === row.scenario); sections,
if (scenario) { uniqueItems: itemEntries,
scenario.count += 1; };
} else {
entry.scenarios.push({ id: row.scenario, name: row.scenarioName, count: 1 });
}
seasonMap.set(row.season, entry);
}
return Array.from(seasonMap.values());
}), }),
getHallOfFame: accessInputProcedure( getHallOfFameOptions: procedure
z.object({ .input(z.object({ source: z.enum(['current', 'legacy']).default('current') }).optional())
season: z.number().int(),
scenario: z.number().int().optional(),
})
)
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const baseWhere = { if ((input?.source ?? 'current') === 'legacy') {
season: input.season, const rows = await findLegacyHallOptions(ctx.db);
...(input.scenario !== undefined ? { scenario: input.scenario } : {}), const optionMap = new Map<
}; string,
{
const types: Array<{ key: HallOfFameType; title: string; type: 'int' | 'percent' }> = [ sourceProfile: (typeof LEGACY_ARCHIVE_PROFILES)[number];
{ key: 'experience', title: '명 성', type: 'int' }, season: number;
{ key: 'dedication', title: '계 급', type: 'int' }, scenarios: Array<{ id: number; name: string; count: number }>;
{ key: 'firenum', title: '계 략 성 공', type: 'int' },
{ key: 'warnum', title: '전 투 횟 수', type: 'int' },
{ key: 'killnum', title: '승 리', type: 'int' },
{ key: 'winrate', title: '승 률', type: 'percent' },
{ key: 'occupied', title: '점 령', type: 'int' },
{ key: 'killcrew', title: '사 살', type: 'int' },
{ key: 'killrate', title: '살 상 률', type: 'percent' },
{ key: 'killcrew_person', title: '대 인 사 살', type: 'int' },
{ key: 'killrate_person', title: '대 인 살 상 률', type: 'percent' },
{ key: 'dex1', title: '보 병 숙 련 도', type: 'int' },
{ key: 'dex2', title: '궁 병 숙 련 도', type: 'int' },
{ key: 'dex3', title: '기 병 숙 련 도', type: 'int' },
{ key: 'dex4', title: '귀 병 숙 련 도', type: 'int' },
{ key: 'dex5', title: '차 병 숙 련 도', type: 'int' },
{ key: 'ttrate', title: '전 력 전 승 률', type: 'percent' },
{ key: 'tlrate', title: '통 솔 전 승 률', type: 'percent' },
{ key: 'tsrate', title: '일 기 토 승 률', type: 'percent' },
{ key: 'tirate', title: '설 전 승 률', type: 'percent' },
{ key: 'betgold', title: '베 팅 투 자 액', type: 'int' },
{ key: 'betwin', title: '베 팅 당 첨', type: 'int' },
{ key: 'betwingold', title: '베 팅 수 익 금', type: 'int' },
{ key: 'betrate', title: '베 팅 수 익 률', type: 'percent' },
];
const allowedTypes = new Set(HALL_OF_FAME_TYPES);
const sections = await Promise.all(
types.map(async (type) => {
if (!allowedTypes.has(type.key)) {
return { title: type.title, valueType: type.type, entries: [] };
} }
const rows = await ctx.db.hallOfFame.findMany({ >();
where: { ...baseWhere, type: type.key }, for (const row of rows) {
orderBy: { value: 'desc' }, const key = `${row.sourceProfile}:${row.season}`;
take: 10, const entry = optionMap.get(key) ?? {
}); sourceProfile: row.sourceProfile,
const entries = rows.map((row) => { season: row.season,
const aux = asRecord(row.aux); scenarios: [],
return { };
generalId: row.generalNo, entry.scenarios.push({ id: row.scenario, name: row.scenarioName, count: Number(row.count) });
name: String(aux.name ?? ''), optionMap.set(key, entry);
ownerName: }
typeof aux.ownerDisplayName === 'string' && aux.ownerDisplayName.length > 0 return Array.from(optionMap.values());
? aux.ownerDisplayName }
: null, const rows = await ctx.db.gameHistory.findMany({
nationName: String(aux.nationName ?? ''), select: { season: true, scenario: true, scenarioName: true },
bgColor: String(aux.bgColor ?? DEFAULT_BG_COLOR), orderBy: [{ season: 'desc' }, { scenario: 'asc' }],
fgColor: String(aux.fgColor ?? DEFAULT_FG_COLOR), });
picture: typeof aux.picture === 'string' ? aux.picture : null, const seasonMap = new Map<
imageServer: readMetaNumber(aux.imgsvr), number,
value: row.value, { sourceProfile: string; season: number; scenarios: Array<{ id: number; name: string; count: number }> }
printValue: >();
type.type === 'percent' for (const row of rows) {
? percentText(row.value) const entry = seasonMap.get(row.season) ?? {
: formatLegacyRankingNumber(row.value), sourceProfile: ctx.profile.id,
serverName: String(aux.serverName ?? ''), season: row.season,
serverIdx: readMetaNumber(aux.serverIdx), scenarios: [],
scenarioName: String(aux.scenarioName ?? ''), };
startTime: typeof aux.startTime === 'string' ? aux.startTime : null, const scenario = entry.scenarios.find((item) => item.id === row.scenario);
unitedTime: typeof aux.unitedTime === 'string' ? aux.unitedTime : null, if (scenario) {
}; scenario.count += 1;
}); } else {
return { title: type.title, valueType: type.type, entries }; entry.scenarios.push({ id: row.scenario, name: row.scenarioName, count: 1 });
}) }
); seasonMap.set(row.season, entry);
}
return { sections }; return Array.from(seasonMap.values());
}), }),
getHallOfFame: accessInputProcedure(
z.object({
source: z.enum(['current', 'legacy']).default('current'),
sourceProfile: z.enum(LEGACY_ARCHIVE_PROFILES).optional(),
season: z.number().int(),
scenario: z.number().int().optional(),
})
).query(async ({ ctx, input }) => {
const baseWhere = {
season: input.season,
...(input.scenario !== undefined ? { scenario: input.scenario } : {}),
};
const types: Array<{ key: HallOfFameType; title: string; type: 'int' | 'percent' }> = [
{ key: 'experience', title: '명 성', type: 'int' },
{ key: 'dedication', title: '계 급', type: 'int' },
{ key: 'firenum', title: '계 략 성 공', type: 'int' },
{ key: 'warnum', title: '전 투 횟 수', type: 'int' },
{ key: 'killnum', title: '승 리', type: 'int' },
{ key: 'winrate', title: '승 률', type: 'percent' },
{ key: 'occupied', title: '점 령', type: 'int' },
{ key: 'killcrew', title: '사 살', type: 'int' },
{ key: 'killrate', title: '살 상 률', type: 'percent' },
{ key: 'killcrew_person', title: '대 인 사 살', type: 'int' },
{ key: 'killrate_person', title: '대 인 살 상 률', type: 'percent' },
{ key: 'dex1', title: '보 병 숙 련 도', type: 'int' },
{ key: 'dex2', title: '궁 병 숙 련 도', type: 'int' },
{ key: 'dex3', title: '기 병 숙 련 도', type: 'int' },
{ key: 'dex4', title: '귀 병 숙 련 도', type: 'int' },
{ key: 'dex5', title: '차 병 숙 련 도', type: 'int' },
{ key: 'ttrate', title: '전 력 전 승 률', type: 'percent' },
{ key: 'tlrate', title: '통 솔 전 승 률', type: 'percent' },
{ key: 'tsrate', title: '일 기 토 승 률', type: 'percent' },
{ key: 'tirate', title: '설 전 승 률', type: 'percent' },
{ key: 'betgold', title: '베 팅 투 자 액', type: 'int' },
{ key: 'betwin', title: '베 팅 당 첨', type: 'int' },
{ key: 'betwingold', title: '베 팅 수 익 금', type: 'int' },
{ key: 'betrate', title: '베 팅 수 익 률', type: 'percent' },
];
const allowedTypes = new Set(HALL_OF_FAME_TYPES);
const sections = await Promise.all(
types.map(async (type) => {
if (!allowedTypes.has(type.key)) {
return { title: type.title, valueType: type.type, entries: [] };
}
const rows =
input.source === 'legacy'
? input.sourceProfile
? await findLegacyHallRows(ctx.db, {
sourceProfile: input.sourceProfile,
season: input.season,
...(input.scenario === undefined ? {} : { scenario: input.scenario }),
type: type.key,
take: 10,
})
: []
: await ctx.db.hallOfFame.findMany({
where: { ...baseWhere, type: type.key },
orderBy: { value: 'desc' },
take: 10,
});
const entries = rows.map((row) => {
const aux = asRecord(row.aux);
return {
generalId: row.generalNo,
name: String(aux.name ?? ''),
ownerName:
typeof aux.ownerDisplayName === 'string' && aux.ownerDisplayName.length > 0
? aux.ownerDisplayName
: null,
nationName: String(aux.nationName ?? ''),
bgColor: String(aux.bgColor ?? DEFAULT_BG_COLOR),
fgColor: String(aux.fgColor ?? DEFAULT_FG_COLOR),
picture: typeof aux.picture === 'string' ? aux.picture : null,
imageServer: readMetaNumber(aux.imgsvr),
value: row.value,
printValue:
type.type === 'percent' ? percentText(row.value) : formatLegacyRankingNumber(row.value),
serverName: String(aux.serverName ?? ''),
serverIdx: readMetaNumber(aux.serverIdx),
scenarioName: String(aux.scenarioName ?? ''),
startTime: typeof aux.startTime === 'string' ? aux.startTime : null,
unitedTime: typeof aux.unitedTime === 'string' ? aux.unitedTime : null,
};
});
return { title: type.title, valueType: type.type, entries };
})
);
return { source: input.source, sourceProfile: input.sourceProfile ?? ctx.profile.id, sections };
}),
}); });
@@ -0,0 +1,264 @@
import { GamePrisma } from '@sammo-ts/infra';
import { isLegacyArchiveProfile, LEGACY_ARCHIVE_PROFILES, type LegacyArchiveProfile } from '@sammo-ts/common';
import type { DatabaseClient } from '../context.js';
export { isLegacyArchiveProfile, LEGACY_ARCHIVE_PROFILES, type LegacyArchiveProfile };
export type LegacyArchiveDatabase = Pick<DatabaseClient, '$queryRaw'>;
export interface LegacyGameHistoryRow {
sourceProfile: LegacyArchiveProfile;
serverId: string;
legacyId: number;
openedAt: Date;
completedAt: Date | null;
legacyDate: Date;
winnerNation: number | null;
map: string | null;
season: number;
scenario: number;
scenarioName: string;
rawEnv: unknown;
}
export interface LegacyGeneralRow {
sourceProfile: LegacyArchiveProfile;
serverId: string;
generalNo: number;
legacyId: number;
owner: string | null;
name: string;
lastYearMonth: number;
turnTime: Date;
schemaVersion: number;
sourceFormat: string;
data: unknown;
}
export interface LegacyNationRow {
sourceProfile: LegacyArchiveProfile;
legacyId: number;
serverId: string;
nation: number;
data: unknown;
archivedAt: Date;
}
export interface LegacyEmperorRow {
id: bigint;
sourceProfile: LegacyArchiveProfile;
legacyId: number;
serverId: string | null;
data: unknown;
}
export interface LegacyHallOptionRow {
sourceProfile: LegacyArchiveProfile;
season: number;
scenario: number;
scenarioName: string;
count: bigint;
}
export interface LegacyHallRow {
sourceProfile: LegacyArchiveProfile;
serverId: string;
generalNo: number;
type: string;
value: number;
owner: string | null;
aux: unknown;
}
export const findLegacyGeneralsByOwner = async (
db: LegacyArchiveDatabase,
owner: string
): Promise<LegacyGeneralRow[]> =>
db.$queryRaw<LegacyGeneralRow[]>(GamePrisma.sql`
SELECT
"source_profile" AS "sourceProfile",
"server_id" AS "serverId",
"general_no" AS "generalNo",
"legacy_id" AS "legacyId",
"owner",
"name",
"last_yearmonth" AS "lastYearMonth",
"turntime" AS "turnTime",
"schema_version" AS "schemaVersion",
"source_format" AS "sourceFormat",
"data"
FROM "legacy_archive"."general"
WHERE "owner" = ${owner}
ORDER BY "last_yearmonth" DESC, "source_profile", "server_id" DESC, "general_no"
`);
export const findLegacyGeneral = async (
db: LegacyArchiveDatabase,
input: { owner: string; sourceProfile: LegacyArchiveProfile; serverId: string; generalNo: number }
): Promise<LegacyGeneralRow | null> => {
const rows = await db.$queryRaw<LegacyGeneralRow[]>(GamePrisma.sql`
SELECT
"source_profile" AS "sourceProfile",
"server_id" AS "serverId",
"general_no" AS "generalNo",
"legacy_id" AS "legacyId",
"owner",
"name",
"last_yearmonth" AS "lastYearMonth",
"turntime" AS "turnTime",
"schema_version" AS "schemaVersion",
"source_format" AS "sourceFormat",
"data"
FROM "legacy_archive"."general"
WHERE "owner" = ${input.owner}
AND "source_profile" = ${input.sourceProfile}
AND "server_id" = ${input.serverId}
AND "general_no" = ${input.generalNo}
LIMIT 1
`);
return rows[0] ?? null;
};
export const findLegacyGeneralsForServer = async (
db: LegacyArchiveDatabase,
input: { sourceProfile: LegacyArchiveProfile; serverId: string; generalNos: number[] }
): Promise<Array<Pick<LegacyGeneralRow, 'generalNo' | 'name' | 'lastYearMonth'>>> => {
if (input.generalNos.length === 0) return [];
return db.$queryRaw<Array<Pick<LegacyGeneralRow, 'generalNo' | 'name' | 'lastYearMonth'>>>(GamePrisma.sql`
SELECT
"general_no" AS "generalNo",
"name",
"last_yearmonth" AS "lastYearMonth"
FROM "legacy_archive"."general"
WHERE "source_profile" = ${input.sourceProfile}
AND "server_id" = ${input.serverId}
AND "general_no" IN (${GamePrisma.join(input.generalNos)})
`);
};
export const findLegacyGames = async (
db: LegacyArchiveDatabase,
keys?: Array<{ sourceProfile: LegacyArchiveProfile; serverId: string }>
): Promise<LegacyGameHistoryRow[]> => {
if (keys && keys.length === 0) return [];
const condition = keys
? GamePrisma.sql`WHERE ("source_profile", "server_id") IN (${GamePrisma.join(
keys.map(({ sourceProfile, serverId }) => GamePrisma.sql`(${sourceProfile}, ${serverId})`)
)})`
: GamePrisma.empty;
return db.$queryRaw<LegacyGameHistoryRow[]>(GamePrisma.sql`
SELECT
"source_profile" AS "sourceProfile",
"server_id" AS "serverId",
"legacy_id" AS "legacyId",
"opened_at" AS "openedAt",
"completed_at" AS "completedAt",
"legacy_date" AS "legacyDate",
"winner_nation" AS "winnerNation",
"map",
"season",
"scenario",
"scenario_name" AS "scenarioName",
"raw_env" AS "rawEnv"
FROM "legacy_archive"."game_history"
${condition}
`);
};
export const findLegacyNations = async (
db: LegacyArchiveDatabase,
keys: Array<{ sourceProfile: LegacyArchiveProfile; serverId: string }>
): Promise<LegacyNationRow[]> => {
if (keys.length === 0) return [];
return db.$queryRaw<LegacyNationRow[]>(GamePrisma.sql`
SELECT
"source_profile" AS "sourceProfile",
"legacy_id" AS "legacyId",
"server_id" AS "serverId",
"nation",
"data",
"archived_at" AS "archivedAt"
FROM "legacy_archive"."nation"
WHERE ("source_profile", "server_id") IN (${GamePrisma.join(
keys.map(({ sourceProfile, serverId }) => GamePrisma.sql`(${sourceProfile}, ${serverId})`)
)})
ORDER BY "archived_at" DESC, "legacy_id" DESC
`);
};
export const findLegacyEmperors = async (
db: LegacyArchiveDatabase,
keys?: Array<{ sourceProfile: LegacyArchiveProfile; serverId: string }>
): Promise<LegacyEmperorRow[]> => {
if (keys && keys.length === 0) return [];
const condition = keys
? GamePrisma.sql`WHERE ("source_profile", "server_id") IN (${GamePrisma.join(
keys.map(({ sourceProfile, serverId }) => GamePrisma.sql`(${sourceProfile}, ${serverId})`)
)})`
: GamePrisma.empty;
return db.$queryRaw<LegacyEmperorRow[]>(GamePrisma.sql`
SELECT
"id",
"source_profile" AS "sourceProfile",
"legacy_id" AS "legacyId",
"server_id" AS "serverId",
"data"
FROM "legacy_archive"."emperor"
${condition}
ORDER BY "id" DESC
`);
};
export const findLegacyEmperor = async (db: LegacyArchiveDatabase, id: number): Promise<LegacyEmperorRow | null> => {
const rows = await db.$queryRaw<LegacyEmperorRow[]>(GamePrisma.sql`
SELECT
"id",
"source_profile" AS "sourceProfile",
"legacy_id" AS "legacyId",
"server_id" AS "serverId",
"data"
FROM "legacy_archive"."emperor"
WHERE "id" = ${id}
LIMIT 1
`);
return rows[0] ?? null;
};
export const findLegacyHallOptions = async (db: LegacyArchiveDatabase): Promise<LegacyHallOptionRow[]> =>
db.$queryRaw<LegacyHallOptionRow[]>(GamePrisma.sql`
SELECT
"source_profile" AS "sourceProfile",
"season",
"scenario",
MAX("scenario_name") AS "scenarioName",
COUNT(*)::bigint AS "count"
FROM "legacy_archive"."game_history"
GROUP BY "source_profile", "season", "scenario"
ORDER BY "season" DESC, "source_profile", "scenario"
`);
export const findLegacyHallRows = async (
db: LegacyArchiveDatabase,
input: { sourceProfile: LegacyArchiveProfile; season: number; scenario?: number; type: string; take: number }
): Promise<LegacyHallRow[]> => {
const scenarioCondition =
input.scenario === undefined ? GamePrisma.empty : GamePrisma.sql`AND "scenario" = ${input.scenario}`;
return db.$queryRaw<LegacyHallRow[]>(GamePrisma.sql`
SELECT
"source_profile" AS "sourceProfile",
"server_id" AS "serverId",
"general_no" AS "generalNo",
"type",
"value",
"owner",
"aux"
FROM "legacy_archive"."hall"
WHERE "source_profile" = ${input.sourceProfile}
AND "season" = ${input.season}
${scenarioCondition}
AND "type" = ${input.type}
ORDER BY "value" DESC
LIMIT ${input.take}
`);
};
+192 -5
View File
@@ -25,8 +25,132 @@ const auth: GameSessionTokenPayload = {
sanctions: {}, sanctions: {},
}; };
const context = (session: GameSessionTokenPayload | null): GameApiContext => { const context = (session: GameSessionTokenPayload | null, includeLegacy = false): GameApiContext => {
const db = { const db = {
$queryRaw: async (query: { strings?: readonly string[] }) => {
if (!includeLegacy) return [];
const sql = query.strings?.join(' ') ?? '';
if (sql.includes('legacy_archive"."general')) {
return [
{
sourceProfile: 'hwe',
serverId: 'hwe_archive_1',
generalNo: 21,
legacyId: 21,
owner: 'user-1',
name: '이전서버장수',
lastYearMonth: 21012,
turnTime: new Date('2020-01-02T00:00:00.000Z'),
schemaVersion: 1,
sourceFormat: 'legacy-flat-v0',
data: {
schemaVersion: 1,
identity: {
name: '이전서버장수',
picture: null,
imageServer: 0,
npcState: 0,
nationId: 2,
cityId: 1,
officerLevel: 7,
officerCity: 1,
},
stats: {
leadership: 91,
strength: 81,
intelligence: 71,
leadershipExperience: 3,
strengthExperience: 4,
intelligenceExperience: 5,
},
progression: {
experience: 900,
experienceLevel: 3,
dedication: 800,
dedicationLevel: 2,
age: 30,
startAge: 20,
bornYear: 180,
deadYear: 250,
},
traits: { personality: null, specialDomestic: null, specialWar: null },
resources: {
gold: 100,
rice: 200,
crew: 300,
crewType: '1',
train: 90,
morale: 80,
injury: 0,
},
items: { horse: null, weapon: null, book: null, item: null },
mastery: { infantry: 1000, archery: 2000, cavalry: 3000, special: 4000, siege: 5000 },
battle: {
battles: 10,
wins: 6,
losses: 4,
fireSuccesses: 2,
kills: 6,
deaths: 4,
killedCrew: 1000,
lostCrew: 500,
winRate: 60,
killRate: 200,
recentWar: null,
tactics: {
total: { wins: null, draws: null, losses: null },
leadership: { wins: null, draws: null, losses: null },
intelligence: { wins: null, draws: null, losses: null },
},
},
history: ['이전 서버 열전'],
availability: {
mastery: true,
battleAggregates: true,
tactics: false,
history: true,
battleDetailLogs: false,
battleResultLogs: false,
},
},
},
];
}
if (sql.includes('legacy_archive"."game_history')) {
return [
{
sourceProfile: 'hwe',
serverId: 'hwe_archive_1',
legacyId: 1,
openedAt: new Date('2019-09-21T00:00:00.000Z'),
completedAt: null,
legacyDate: new Date('2019-09-21T00:00:00.000Z'),
winnerNation: 2,
map: 'legacy',
season: 1,
scenario: 7,
scenarioName: '이전 시나리오',
rawEnv: {},
},
];
}
if (sql.includes('legacy_archive"."nation')) {
return [
{
sourceProfile: 'hwe',
legacyId: 1,
serverId: 'hwe_archive_1',
nation: 2,
data: { name: '이전국', color: '#0000ff', level: 7 },
archivedAt: new Date('2020-01-02T00:00:00.000Z'),
},
];
}
if (sql.includes('legacy_archive"."emperor')) {
return [{ id: 99n, sourceProfile: 'hwe', legacyId: 1, serverId: 'hwe_archive_1', data: {} }];
}
return [];
},
oldGeneral: { oldGeneral: {
findMany: async ({ where }: { where: { owner: string } }) => findMany: async ({ where }: { where: { owner: string } }) =>
where.owner === 'user-1' where.owner === 'user-1'
@@ -82,6 +206,11 @@ const context = (session: GameSessionTokenPayload | null): GameApiContext => {
lastYearMonth: 22012, lastYearMonth: 22012,
turnTime: new Date('2025-01-01T00:00:00.000Z'), turnTime: new Date('2025-01-01T00:00:00.000Z'),
data: { data: {
nation: 3,
leader: 80,
power: 70,
intel: 60,
officer_level: 8,
history: '<C>●</>첫 기록<br><Y>●</>둘째 기록<br>', history: '<C>●</>첫 기록<br><Y>●</>둘째 기록<br>',
}, },
} }
@@ -116,6 +245,7 @@ const context = (session: GameSessionTokenPayload | null): GameApiContext => {
}, },
emperor: { emperor: {
findMany: async () => [{ id: 7, serverId: 'che_legacy_1' }], findMany: async () => [{ id: 7, serverId: 'che_legacy_1' }],
findFirst: async () => ({ id: 7, serverId: 'che_legacy_1' }),
}, },
}; };
const redis = { const redis = {
@@ -146,6 +276,8 @@ describe('archive.myPastPlays', () => {
const result = await appRouter.createCaller(context(auth)).archive.myPastPlays(); const result = await appRouter.createCaller(context(auth)).archive.myPastPlays();
expect(result.seasons).toEqual([ expect(result.seasons).toEqual([
expect.objectContaining({ expect.objectContaining({
source: 'current',
sourceProfile: 'che',
serverId: 'che_legacy_1', serverId: 'che_legacy_1',
scenarioName: '테스트', scenarioName: '테스트',
dynastyId: 7, dynastyId: 7,
@@ -185,12 +317,27 @@ describe('archive.myPastPlays', () => {
}); });
const result = await appRouter.createCaller(context(auth)).archive.myPastPlayDetail(input); const result = await appRouter.createCaller(context(auth)).archive.myPastPlayDetail(input);
expect(result).toEqual({ expect(result).toMatchObject({
source: 'current',
sourceProfile: 'che',
serverId: 'che_legacy_1', serverId: 'che_legacy_1',
generalNo: 10, generalNo: 10,
name: '과거장수', dynastyPath: '/dynasty/7',
lastYearMonth: 22012, nation: { id: 3, name: '촉', color: '#ff0000' },
history: ['<C>●</>첫 기록', '<Y>●</>둘째 기록'], general: expect.objectContaining({ id: 10, name: '과거장수' }),
battle: expect.objectContaining({ available: false }),
logs: {
generalHistory: {
available: true,
entries: [
{ id: 1, text: '<C>●</>첫 기록' },
{ id: 2, text: '<Y>●</>둘째 기록' },
],
},
battleDetail: { available: false, entries: [] },
battleResult: { available: false, entries: [] },
generalAction: { available: false, entries: [] },
},
}); });
const otherUser = { const otherUser = {
@@ -202,4 +349,44 @@ describe('archive.myPastPlays', () => {
code: 'NOT_FOUND', code: 'NOT_FOUND',
}); });
}); });
it('returns normalized previous-server detail from the dedicated archive without exposing raw data', async () => {
const caller = appRouter.createCaller(context(auth, true));
const list = await caller.archive.myPastPlays();
expect(list.seasons).toContainEqual(
expect.objectContaining({
source: 'legacy',
sourceProfile: 'hwe',
serverId: 'hwe_archive_1',
openedAt: '2019-09-21T00:00:00.000Z',
dynastyId: 99,
generals: [expect.objectContaining({ name: '이전서버장수', nationName: '이전국' })],
})
);
const detail = await caller.archive.myPastPlayDetail({
source: 'legacy',
sourceProfile: 'hwe',
serverId: 'hwe_archive_1',
generalNo: 21,
});
expect(detail).toMatchObject({
source: 'legacy',
sourceProfile: 'hwe',
dynastyPath: '/dynasty/99?source=legacy',
nation: { id: 2, name: '이전국', color: '#0000ff' },
general: expect.objectContaining({
id: 21,
name: '이전서버장수',
stats: { leadership: 91, strength: 81, intelligence: 71 },
progression: expect.objectContaining({ dex: [1000, 2000, 3000, 4000, 5000] }),
}),
battle: expect.objectContaining({ available: true, warnum: 10, wins: 6, winRate: 60 }),
logs: expect.objectContaining({
generalHistory: { available: true, entries: [{ id: 1, text: '이전 서버 열전' }] },
battleDetail: { available: false, entries: [] },
}),
});
expect(JSON.stringify(detail)).not.toContain('raw_data');
});
}); });
+99
View File
@@ -123,6 +123,72 @@ const buildContext = (
oldNations: Array<Record<string, unknown>> = [oldNation, deletedOldNation] oldNations: Array<Record<string, unknown>> = [oldNation, deletedOldNation]
): GameApiContext => { ): GameApiContext => {
const db = { const db = {
$queryRaw: async (query: { strings?: readonly string[] }) => {
const sql = query.strings?.join(' ') ?? '';
if (sql.includes('legacy_archive"."emperor')) {
return [
{
id: 101n,
sourceProfile: 'hwe',
legacyId: 7,
serverId: emperor.serverId,
data: {
phase: '이전 훼2기',
nation_count: emperor.nationCount,
nation_name: emperor.nationName,
nation_hist: emperor.nationHist,
gen_count: emperor.genCount,
personal_hist: emperor.personalHist,
special_hist: emperor.specialHist,
name: emperor.name,
type: emperor.type,
color: emperor.color,
year: emperor.year,
month: emperor.month,
power: emperor.power,
gennum: emperor.gennum,
citynum: emperor.citynum,
pop: emperor.pop,
poprate: emperor.poprate,
gold: emperor.gold,
rice: emperor.rice,
l12name: emperor.l12name,
l11name: emperor.l11name,
l10name: emperor.l10name,
l9name: emperor.l9name,
l8name: emperor.l8name,
l7name: emperor.l7name,
l6name: emperor.l6name,
l5name: emperor.l5name,
tiger: emperor.tiger,
eagle: emperor.eagle,
gen: emperor.gen,
history: emperor.history,
aux: emperor.aux,
},
},
];
}
if (sql.includes('legacy_archive"."nation')) {
return [
{
sourceProfile: 'hwe',
legacyId: oldNation.id,
serverId: oldNation.serverId,
nation: oldNation.nation,
data: oldNation.data,
archivedAt: oldNation.date,
},
];
}
if (sql.includes('legacy_archive"."general')) {
return [
{ generalNo: 11, name: '유비', lastYearMonth: 21504 },
{ generalNo: 12, name: '제갈량', lastYearMonth: 21504 },
];
}
return [];
},
worldState: { worldState: {
findFirst: async () => ({ currentYear: 220, currentMonth: 1 }), findFirst: async () => ({ currentYear: 220, currentMonth: 1 }),
}, },
@@ -186,6 +252,39 @@ describe('dynasty public read model', () => {
]); ]);
}); });
it('reads previous-server dynasties only when the archive source is selected', async () => {
const caller = appRouter.createCaller(buildContext(null));
const list = await caller.dynasty.getList({ source: 'legacy' });
expect(list).toMatchObject({
source: 'legacy',
current: null,
entries: [
expect.objectContaining({
id: 101,
source: 'legacy',
sourceProfile: 'hwe',
phase: '이전 훼2기',
}),
],
});
const detail = await caller.dynasty.getDetail({ emperorId: 101, source: 'legacy' });
expect(detail).toMatchObject({
source: 'legacy',
sourceProfile: 'hwe',
emperor: expect.objectContaining({ id: 101, phase: '이전 훼2기', name: '촉' }),
nations: [
expect.objectContaining({
name: '촉',
generalsFull: [
{ generalNo: 11, name: '유비', lastYearMonth: 21504 },
{ generalNo: 12, name: '제갈량', lastYearMonth: 21504 },
],
}),
],
});
});
it('exposes the same public DTO to anonymous, general owners and admins', async () => { it('exposes the same public DTO to anonymous, general owners and admins', async () => {
const anonymous = appRouter.createCaller(buildContext(null)); const anonymous = appRouter.createCaller(buildContext(null));
const owner = appRouter.createCaller(buildContext(authFor('owner-a'))); const owner = appRouter.createCaller(buildContext(authFor('owner-a')));
+77 -6
View File
@@ -110,6 +110,42 @@ const buildContext = (options?: {
}): GameApiContext => { }): GameApiContext => {
const selectedGeneralRows = options?.generals ?? generalRows; const selectedGeneralRows = options?.generals ?? generalRows;
const db = { const db = {
$queryRaw: async (query: { strings?: readonly string[]; values?: unknown[] }) => {
const sql = query.strings?.join(' ') ?? '';
if (sql.includes('legacy_archive"."game_history')) {
return [
{
sourceProfile: 'hwe',
season: 1,
scenario: 7,
scenarioName: '이전 시나리오',
count: 2n,
},
];
}
if (sql.includes('legacy_archive"."hall')) {
return query.values?.includes('experience')
? [
{
sourceProfile: 'hwe',
serverId: 'hwe-old-1',
generalNo: 9,
type: 'experience',
value: 777,
owner: 'private-legacy-owner-id',
aux: {
name: '과거장수',
ownerDisplayName: '과거소유자',
nationName: '과거국',
bgColor: '#330000',
fgColor: '#ffffff',
},
},
]
: [];
}
return [];
},
worldState: { worldState: {
findFirst: async () => ({ findFirst: async () => ({
meta: { isUnited: options?.isUnited ? 1 : 0 }, meta: { isUnited: options?.isUnited ? 1 : 0 },
@@ -289,8 +325,14 @@ describe('ranking.getBestGeneral', () => {
value: value:
type === 'warnum' || type === 'deathcrew' || type === 'deathcrew_person' type === 'warnum' || type === 'deathcrew' || type === 'deathcrew_person'
? 1_000 ? 1_000
: type === 'ttd' || type === 'ttl' || type === 'tld' || type === 'tll' || : type === 'ttd' ||
type === 'tsd' || type === 'tsl' || type === 'tid' || type === 'til' || type === 'ttl' ||
type === 'tld' ||
type === 'tll' ||
type === 'tsd' ||
type === 'tsl' ||
type === 'tid' ||
type === 'til' ||
type === 'betgold' type === 'betgold'
? 1_000 ? 1_000
: general.id * 1_000, : general.id * 1_000,
@@ -304,11 +346,14 @@ describe('ranking.getBestGeneral', () => {
for (const section of result.sections) { for (const section of result.sections) {
expect(section.entries, section.title).toHaveLength(10); expect(section.entries, section.title).toHaveLength(10);
expect(new Set(section.entries.map((entry) => entry.id)).size, section.title).toBe(10); expect(new Set(section.entries.map((entry) => entry.id)).size, section.title).toBe(10);
expect(section.entries.every((entry) => entry.value > 0), section.title).toBe(true); expect(
section.entries.every((entry) => entry.value > 0),
section.title
).toBe(true);
} }
expect(result.sections.find((section) => section.title === '계 략 성 공')?.entries.map((entry) => entry.id)).toEqual([ expect(
12, 11, 10, 9, 8, 7, 6, 5, 4, 3, result.sections.find((section) => section.title === '계 략 성 공')?.entries.map((entry) => entry.id)
]); ).toEqual([12, 11, 10, 9, 8, 7, 6, 5, 4, 3]);
}); });
it('matches PHP number_format rounding and the legacy fixed color table', () => { it('matches PHP number_format rounding and the legacy fixed color table', () => {
@@ -326,12 +371,38 @@ describe('ranking hall of fame', () => {
.ranking.getHallOfFameOptions(); .ranking.getHallOfFameOptions();
expect(options).toEqual([ expect(options).toEqual([
{ {
sourceProfile: 'che',
season: 3, season: 3,
scenarios: [{ id: 22, name: '가상모드22', count: 2 }], scenarios: [{ id: 22, name: '가상모드22', count: 2 }],
}, },
]); ]);
}); });
it('keeps previous-server options and rankings in the dedicated archive source', async () => {
const caller = appRouter.createCaller(buildContext({ authenticated: false }));
await expect(caller.ranking.getHallOfFameOptions({ source: 'legacy' })).resolves.toEqual([
{
sourceProfile: 'hwe',
season: 1,
scenarios: [{ id: 7, name: '이전 시나리오', count: 2 }],
},
]);
const result = await caller.ranking.getHallOfFame({
source: 'legacy',
sourceProfile: 'hwe',
season: 1,
scenario: 7,
});
expect(result.source).toBe('legacy');
expect(result.sourceProfile).toBe('hwe');
expect(result.sections[0]).toMatchObject({
title: '명 성',
entries: [expect.objectContaining({ generalId: 9, name: '과거장수', ownerName: '과거소유자' })],
});
expect(JSON.stringify(result)).not.toContain('private-legacy-owner-id');
});
it('returns an explicit display name but never exposes the stored account identifier', async () => { it('returns an explicit display name but never exposes the stored account identifier', async () => {
const result = await appRouter const result = await appRouter
.createCaller(buildContext({ authenticated: false, includeOwnerDisplayName: true })) .createCaller(buildContext({ authenticated: false, includeOwnerDisplayName: true }))
@@ -0,0 +1,162 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
const response = (data: unknown) => ({ result: { data } });
const operationNames = (route: Route) =>
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
const isLegacyRequest = (route: Route): boolean =>
decodeURIComponent(`${route.request().url()} ${route.request().postData() ?? ''}`).includes('legacy');
const installArchiveViews = async (page: Page) => {
await page.addInitScript((profile) => {
localStorage.setItem('sammo-game-token', 'ga_archive_views');
localStorage.setItem('sammo-game-profile', profile);
}, gameProfile);
await page.route(gameTrpcRoute, async (route) => {
const legacy = isLegacyRequest(route);
const results = operationNames(route).map((operation) => {
if (operation === 'auth.status') return response({ ok: true });
if (operation === 'lobby.info') return response({ myGeneral: null });
if (operation === 'ranking.getHallOfFameOptions') {
return response([
{
sourceProfile: legacy ? 'hwe' : 'che',
season: legacy ? 1 : 2,
scenarios: [{ id: 7, name: legacy ? '이전 시나리오' : '현재 시나리오', count: 1 }],
},
]);
}
if (operation === 'ranking.getHallOfFame') {
return response({
source: legacy ? 'legacy' : 'current',
sourceProfile: legacy ? 'hwe' : 'che',
sections: [
{
title: '명 성',
valueType: 'int',
entries: [
{
generalId: 1,
name: legacy ? '이전장수' : '현재장수',
ownerName: null,
nationName: legacy ? '이전국' : '현재국',
bgColor: '#330000',
fgColor: '#ffffff',
picture: null,
imageServer: 0,
value: 100,
printValue: '100',
},
],
},
],
});
}
if (operation === 'dynasty.getList') {
return response({
source: legacy ? 'legacy' : 'current',
current: legacy ? null : { year: 220, month: 1 },
entries: [
{
id: legacy ? 101 : 1,
source: legacy ? 'legacy' : 'current',
sourceProfile: legacy ? 'hwe' : 'che',
serverId: legacy ? 'hwe-old-1' : 'che-current-1',
phase: legacy ? '이전 1기' : '현재 1기',
name: '촉',
year: 215,
month: 4,
color: '#800000',
type: '병가',
power: 100,
gennum: 5,
citynum: 3,
l12name: '유비',
l11name: '제갈량',
l10name: '관우',
l9name: '방통',
l8name: '장비',
l7name: '법정',
l6name: '조운',
l5name: '마량',
},
],
});
}
if (operation === 'dynasty.getDetail') {
return response({
source: 'legacy',
sourceProfile: 'hwe',
emperor: {
id: 101,
serverId: 'hwe-old-1',
winnerNationId: 1,
phase: '이전 1기',
nationCount: '1 / 2',
nationName: '촉',
nationHist: '병가',
genCount: '5 / 10',
personalHist: '의리',
specialHist: '상재',
name: '촉',
type: '병가',
color: '#800000',
year: 215,
month: 4,
power: 100,
gennum: 5,
citynum: 3,
pop: '1000',
poprate: '100%',
gold: 100,
rice: 100,
l12name: '유비',
l11name: '제갈량',
l10name: '관우',
l9name: '방통',
l8name: '장비',
l7name: '법정',
l6name: '조운',
l5name: '마량',
tiger: '',
eagle: '',
gen: '',
history: [],
},
nations: [],
});
}
return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } };
});
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
});
};
test('명예의 전당은 현재 기록과 이전 서버 기록을 분리해 조회한다', async ({ page }) => {
await installArchiveViews(page);
await page.setViewportSize({ width: 1000, height: 800 });
await page.goto('hall-of-fame');
await expect(page.getByText('현재장수')).toBeVisible();
await page.getByLabel('기록 구분').selectOption('legacy');
await expect(page.getByText('이전장수')).toBeVisible();
await expect(page.getByLabel('시나리오 검색')).toContainText('HWE / 이전 시나리오');
await expect(page.locator('.legacy-hall-page')).toHaveCSS('width', '1000px');
});
test('왕조 일람과 상세는 이전 서버 source와 profile을 유지한다', async ({ page }) => {
await installArchiveViews(page);
await page.setViewportSize({ width: 1200, height: 800 });
await page.goto('dynasty');
await expect(page.getByText('현재 1기')).toBeVisible();
await page.getByLabel('기록 구분').selectOption('legacy');
await expect(page.getByText(/이전 1기.*HWE 이전 서버/)).toBeVisible();
const detailLink = page.getByRole('link', { name: '자세히' });
await expect(detailLink).toHaveAttribute('href', /dynasty\/101\?source=legacy$/);
await detailLink.click();
await expect(page.getByText(/이전 1기.*HWE 이전 서버/)).toBeVisible();
await expect(page.locator('.dynasty-page')).toHaveCSS('width', '1000px');
});
+24 -2
View File
@@ -2224,16 +2224,24 @@ test('mobile bottom controls share Ref pressed geometry while their color bases
await expect await expect
.poll(() => page.locator(manualRefreshSelector).evaluate((element) => getComputedStyle(element).boxShadow)) .poll(() => page.locator(manualRefreshSelector).evaluate((element) => getComputedStyle(element).boxShadow))
.not.toBe('none'); .not.toBe('none');
const callsBeforeManualRefresh = state.generalMeCalls;
await page.locator(manualRefreshSelector).click(); 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({ expect(await buttonStyle(manualRefreshSelector)).toMatchObject({
backgroundColor: 'rgb(33, 37, 41)', backgroundColor: 'rgb(33, 37, 41)',
borderBottomWidth: '4px', borderBottomWidth: '4px',
marginTop: '0px', marginTop: '0px',
height: 45, 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(); await expect(page.locator(manualRefreshSelector)).toBeEnabled();
expect(state.generalMeCalls).toBe(callsBeforeManualRefresh + 1);
await persistArtifact(page, `${basePath.slice(1)}-mobile-bottom-ref-buttons`); 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; const operationsBeforeChangedBurst = state.operations.length;
state.generalName = '부드럽게갱신된장수'; state.generalName = '부드럽게갱신된장수';
state.refreshDelayMs = 1_000;
await page.evaluate(() => { await page.evaluate(() => {
const emit = (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }) const emit = (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void })
.__emitMainRealtime; .__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); 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="general"] .skeleton-line')).toHaveCount(0);
await expect(page.locator('[data-main-target="city"] .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'); await expect(page.getByRole('button', { name: '갱 신' })).toHaveAttribute('aria-busy', 'false');
+122 -11
View File
@@ -8,7 +8,7 @@ const response = (data: unknown) => ({ result: { data } });
const operationNames = (route: Route) => const operationNames = (route: Route) =>
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(','); decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
const installArchive = async (page: Page) => { const installArchive = async (page: Page, options: { battleAvailable?: boolean } = {}) => {
await page.addInitScript((profile) => { await page.addInitScript((profile) => {
localStorage.setItem('sammo-game-token', 'ga_archive'); localStorage.setItem('sammo-game-token', 'ga_archive');
localStorage.setItem('sammo-game-profile', profile); localStorage.setItem('sammo-game-profile', profile);
@@ -21,7 +21,10 @@ const installArchive = async (page: Page) => {
return response({ return response({
seasons: [ seasons: [
{ {
sourceProfile: 'che',
source: 'legacy',
serverId: 'che_2024_01', serverId: 'che_2024_01',
openedAt: '2024-01-31T00:00:00.000Z',
date: '2024-01-31T00:00:00.000Z', date: '2024-01-31T00:00:00.000Z',
season: 51, season: 51,
scenario: 2, scenario: 2,
@@ -54,11 +57,67 @@ const installArchive = async (page: Page) => {
} }
if (operation === 'archive.myPastPlayDetail') { if (operation === 'archive.myPastPlayDetail') {
return response({ return response({
sourceProfile: 'che',
source: 'legacy',
serverId: 'che_2024_01', serverId: 'che_2024_01',
generalNo: 17, generalNo: 17,
name: '관우', dynastyPath: '/dynasty/7?source=legacy',
lastYearMonth: 21403, nation: { name: '촉', color: '#800000' },
history: ['<C>●</>214년 3월: 촉에 임관', '<Y>●</>214년 1월: 성도에서 거병'], general: {
id: 17,
name: '관우',
picture: null,
imageServer: 0,
npcState: 0,
officerLevel: 12,
officerLevelText: '황제',
generalType: '용장',
stats: { leadership: 91, strength: 98, intelligence: 77 },
gold: 12_000,
rice: 8_000,
crew: 7_000,
train: 100,
atmos: 100,
injury: 0,
experience: 23_000,
dedication: 1_200,
crewTypeId: 1,
crewTypeName: '보병',
traits: { personal: '대담', specialDomestic: '상재', specialWar: '신산' },
progression: {
experienceLevel: 12,
dedicationLevel: 8,
dedicationText: '황제',
statExperience: { leadership: 12, strength: 14, intelligence: 8 },
statUpgradeLimit: 30,
dex: [125_000, 250_000, 375_000, 500_000, 625_000],
},
},
masteryAvailable: true,
battle: {
available: options.battleAvailable ?? true,
warnum: 16,
wins: 10,
losses: 6,
strategies: 4,
killCrew: 12_000,
deathCrew: 8_000,
winRate: 62.5,
killRate: 75,
recentWar: '2024-01-30T03:00:00.000Z',
},
logs: {
generalHistory: {
available: true,
entries: [
{ id: 2, text: '<C>●</>214년 3월: 촉에 임관' },
{ id: 1, text: '<Y>●</>214년 1월: 성도에서 거병' },
],
},
battleDetail: { available: false, entries: [] },
battleResult: { available: false, entries: [] },
generalAction: { available: false, entries: [] },
},
}); });
} }
return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } }; return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } };
@@ -76,6 +135,15 @@ test('지난 플레이 관직은 숫자 대신 저장된 Ref 표시명으로 나
await expect(generalRow).not.toContainText('che_'); await expect(generalRow).not.toContainText('che_');
}); });
test('보존되지 않은 과거 전투 집계는 0으로 꾸미지 않고 가용성 경계를 표시한다', async ({ page }) => {
await installArchive(page, { battleAvailable: false });
await page.goto('past-plays');
await page.locator('.detail-toggle').click();
await expect(page.locator('[data-general-battle-summary]')).toHaveText('전투 집계가 보존되지 않았습니다.');
await expect(page.locator('[data-general-battle-summary]')).not.toContainText('승률');
});
test('past plays is available without a current general and preserves desktop interaction geometry', async ({ test('past plays is available without a current general and preserves desktop interaction geometry', async ({
page, page,
}) => { }) => {
@@ -87,19 +155,46 @@ test('past plays is available without a current general and preserves desktop in
await expect(root).toBeVisible(); await expect(root).toBeVisible();
await expect(page.getByRole('heading', { name: '내 지난 플레이 보기' })).toBeVisible(); await expect(page.getByRole('heading', { name: '내 지난 플레이 보기' })).toBeVisible();
await expect(page.getByText('천하쟁패 · 51기')).toBeVisible(); await expect(page.getByText('천하쟁패 · 51기')).toBeVisible();
await expect(page.getByText('이전 서버 기록')).toBeVisible();
await expect(page.getByText('che', { exact: true })).toBeVisible();
await expect(page.getByText(/2024.*개장/)).toBeVisible();
await expect(page.locator('.general-name')).toHaveText('관우'); await expect(page.locator('.general-name')).toHaveText('관우');
await expect(page.locator('tbody tr').filter({ hasText: '관우' })).toContainText('황제'); await expect(page.locator('tbody tr').filter({ hasText: '관우' })).toContainText('황제');
await expect(page.getByRole('link', { name: '이 기수 국가 정보' })).toHaveAttribute('href', gamePath('/dynasty/7')); const detailToggle = page.locator('.detail-toggle');
const historyToggle = page.locator('.history-toggle'); await expect(detailToggle).toHaveText('상세 보기');
await expect(historyToggle).toHaveText('보기 (2)'); await detailToggle.hover();
await historyToggle.click(); const beforePress = await detailToggle.boundingBox();
await page.mouse.down();
expect(await detailToggle.evaluate((element) => getComputedStyle(element).transform)).not.toBe('none');
await page.mouse.up();
expect((await detailToggle.boundingBox())?.y).toBeCloseTo(beforePress?.y ?? 0, 0);
await expect(page.getByText('214년 3월: 촉에 임관')).toBeVisible(); await expect(page.getByText('214년 3월: 촉에 임관')).toBeVisible();
await expect(historyToggle).toHaveAttribute('aria-expanded', 'true'); await expect(detailToggle).toHaveAttribute('aria-expanded', 'true');
await expect(page.locator('.archive-general-card')).toHaveAttribute('data-general-basic-card', '');
await expect(page.locator('.archive-general-card [role="progressbar"]')).toHaveCount(14);
await expect(page.locator('[data-general-battle-summary]')).toContainText('승률62.5%');
await expect(page.locator('[data-general-battle-summary]')).toContainText('살상률75.0%');
await expect(page.locator('[data-log-type="battleDetail"]')).toContainText(
'이 기수에는 전투 기록이 보존되지 않았습니다.'
);
await expect(page.locator('[data-log-type="battleResult"]')).toContainText(
'이 기수에는 전투 결과가 보존되지 않았습니다.'
);
await expect(page.locator('[data-log-type="generalAction"]')).toContainText(
'이 기수에는 개인 기록이 보존되지 않았습니다.'
);
await expect(page.locator('[data-log-type="generalHistory"] C')).toHaveCount(0);
await expect(page.getByRole('link', { name: '이 기수 국가 정보' })).toHaveAttribute(
'href',
`${gamePath('/dynasty/7')}?source=legacy`
);
const geometry = await root.evaluate((element) => { const geometry = await root.evaluate((element) => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
const titleRect = element.querySelector('.title-row')!.getBoundingClientRect(); const titleRect = element.querySelector('.title-row')!.getBoundingClientRect();
const tableRect = element.querySelector('table')!.getBoundingClientRect(); const tableRect = element.querySelector('table')!.getBoundingClientRect();
const detailGrid = element.querySelector('.detail-grid')!;
const card = element.querySelector('[data-general-basic-card]')!.getBoundingClientRect();
const style = getComputedStyle(element); const style = getComputedStyle(element);
return { return {
x: rect.x, x: rect.x,
@@ -107,6 +202,8 @@ test('past plays is available without a current general and preserves desktop in
minHeight: rect.height, minHeight: rect.height,
title: { x: titleRect.x, y: titleRect.y, width: titleRect.width }, title: { x: titleRect.x, y: titleRect.y, width: titleRect.width },
tableWidth: tableRect.width, tableWidth: tableRect.width,
detailColumns: getComputedStyle(detailGrid).gridTemplateColumns,
cardWidth: card.width,
color: style.color, color: style.color,
backgroundColor: style.backgroundColor, backgroundColor: style.backgroundColor,
}; };
@@ -116,13 +213,14 @@ test('past plays is available without a current general and preserves desktop in
width: 1000, width: 1000,
title: { x: 100, y: 0, width: 1000 }, title: { x: 100, y: 0, width: 1000 },
tableWidth: 1000, tableWidth: 1000,
cardWidth: 497,
color: 'rgb(238, 238, 238)', color: 'rgb(238, 238, 238)',
backgroundColor: 'rgb(21, 21, 21)', backgroundColor: 'rgb(48, 32, 22)',
}); });
const refresh = page.getByRole('button', { name: '새로고침' }); const refresh = page.getByRole('button', { name: '새로고침' });
await refresh.hover(); await refresh.hover();
expect(await refresh.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(135, 206, 235)'); await expect(refresh).toHaveCSS('color', 'rgb(135, 206, 235)');
await refresh.focus(); await refresh.focus();
expect(await refresh.evaluate((element) => document.activeElement === element)).toBe(true); expect(await refresh.evaluate((element) => document.activeElement === element)).toBe(true);
@@ -140,6 +238,9 @@ test('past plays keeps the legacy-width table scrollable on a mobile viewport',
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await page.goto('past-plays'); await page.goto('past-plays');
await page.locator('.detail-toggle').click();
await expect(page.locator('.archive-general-card')).toBeVisible();
const scroll = page.locator('.table-scroll'); const scroll = page.locator('.table-scroll');
const metrics = await scroll.evaluate((element) => ({ const metrics = await scroll.evaluate((element) => ({
clientWidth: element.clientWidth, clientWidth: element.clientWidth,
@@ -149,4 +250,14 @@ test('past plays keeps the legacy-width table scrollable on a mobile viewport',
// The shared legacy shell keeps its historical 500 px minimum canvas. // The shared legacy shell keeps its historical 500 px minimum canvas.
expect(metrics).toEqual({ clientWidth: 500, scrollWidth: 940, overflowX: 'auto' }); expect(metrics).toEqual({ clientWidth: 500, scrollWidth: 940, overflowX: 'auto' });
await expect(page.locator('.title-row')).toHaveCSS('flex-direction', 'column'); await expect(page.locator('.title-row')).toHaveCSS('flex-direction', 'column');
await expect(page.locator('.detail-grid')).toHaveCSS('grid-template-columns', '498px');
const detailMetrics = await page.locator('.detail-shell').evaluate((element) => ({
width: element.getBoundingClientRect().width,
scrollWidth: element.scrollWidth,
recordBottom: element.querySelector('[data-general-record-panels]')!.getBoundingClientRect().bottom,
shellBottom: element.getBoundingClientRect().bottom,
}));
expect(detailMetrics.width).toBe(498);
expect(detailMetrics.scrollWidth).toBe(498);
expect(detailMetrics.recordBottom).toBeLessThanOrEqual(detailMetrics.shellBottom);
}); });
@@ -26,6 +26,7 @@ export default defineConfig({
'legacyLogHtml.spec.ts', 'legacyLogHtml.spec.ts',
'directoryLists.spec.ts', 'directoryLists.spec.ts',
'pastPlays.spec.ts', 'pastPlays.spec.ts',
'legacyArchiveViews.spec.ts',
'nationGeneralSecret.spec.ts', 'nationGeneralSecret.spec.ts',
'npcPolicy.spec.ts', 'npcPolicy.spec.ts',
'auction.spec.ts', 'auction.spec.ts',
@@ -0,0 +1,10 @@
export const GENERAL_RECORD_TYPES = ['generalHistory', 'battleDetail', 'battleResult', 'generalAction'] as const;
export type GeneralRecordType = (typeof GENERAL_RECORD_TYPES)[number];
export type GeneralRecordEntry = {
id: number | string;
content: string;
};
export type GeneralRecordCollection = Partial<Record<GeneralRecordType, GeneralRecordEntry[]>>;
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { computed } from 'vue';
import { formatServerDateTime } from '@sammo-ts/common';
export type GeneralBattleSummaryData = {
available?: boolean;
experience?: number | null;
dedicationText?: string | null;
warnum?: number | null;
wins?: number | null;
losses?: number | null;
strategies?: number | null;
killCrew?: number | null;
deathCrew?: number | null;
winRate?: number | null;
killRate?: number | null;
recentWar?: string | null;
};
const props = withDefaults(
defineProps<{
summary: GeneralBattleSummaryData;
showWinRate?: boolean;
rateScale?: 'ratio' | 'percent';
}>(),
{ showWinRate: false, rateScale: 'ratio' }
);
const numberText = (value: number | null | undefined): string =>
typeof value === 'number' && Number.isFinite(value) ? value.toLocaleString('ko-KR') : '-';
const rateText = (value: number): string => `${(props.rateScale === 'percent' ? value : value * 100).toFixed(1)}%`;
const winRate = computed(() => {
if (typeof props.summary.winRate === 'number' && Number.isFinite(props.summary.winRate)) {
return rateText(props.summary.winRate);
}
const battles = props.summary.warnum;
const wins = props.summary.wins;
if (typeof battles !== 'number' || battles <= 0 || typeof wins !== 'number') return '-';
return `${((wins / battles) * 100).toFixed(1)}%`;
});
const killRate = computed(() => {
if (typeof props.summary.killRate === 'number' && Number.isFinite(props.summary.killRate)) {
return rateText(props.summary.killRate);
}
const killed = props.summary.killCrew;
const lost = props.summary.deathCrew;
if (typeof killed !== 'number' || typeof lost !== 'number' || lost <= 0) return '-';
return `${((killed / lost) * 100).toFixed(1)}%`;
});
</script>
<template>
<div class="battle-general-extra" data-general-battle-summary>
<div v-if="summary.available === false" class="battle-summary-unavailable">
전투 집계가 보존되지 않았습니다.
</div>
<template v-else>
<span>명성</span><strong>{{ numberText(summary.experience) }}</strong> <span>계급</span
><strong>{{ summary.dedicationText || '-' }}</strong> <span>전투</span
><strong>{{ numberText(summary.warnum) }}<template v-if="summary.warnum != null"></template></strong>
<span>승리</span><strong>{{ numberText(summary.wins) }}</strong> <span>패배</span
><strong>{{ numberText(summary.losses) }}</strong> <span>계략</span
><strong>{{ numberText(summary.strategies) }}</strong> <span>사살</span
><strong>{{ numberText(summary.killCrew) }}</strong> <span>피살</span
><strong>{{ numberText(summary.deathCrew) }}</strong>
<template v-if="showWinRate">
<span>승률</span><strong>{{ winRate }}</strong> <span>살상률</span><strong>{{ killRate }}</strong>
</template>
<span class="battle-general-extra__recent-label">최근 전투</span>
<strong class="battle-general-extra__recent-value">
{{ formatServerDateTime(summary.recentWar, { format: 'monthDayTime', fallback: '-' }) }}
</strong>
</template>
</div>
</template>
<style scoped>
.battle-general-extra {
display: grid;
grid-template-columns: repeat(6, 1fr);
}
.battle-general-extra > * {
min-height: 24px;
box-sizing: border-box;
border-right: 1px solid #777;
border-bottom: 1px solid #777;
padding: 2px 5px;
}
.battle-general-extra > span {
background-color: rgb(20 75 42 / 70%);
text-align: center;
}
.battle-general-extra > strong {
overflow: hidden;
font-weight: 500;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.battle-general-extra > .battle-general-extra__recent-label {
grid-column: 1;
}
.battle-general-extra > .battle-general-extra__recent-value {
grid-column: 2 / -1;
text-align: left;
}
.battle-summary-unavailable {
grid-column: 1 / -1;
color: #bbb;
text-align: center;
}
</style>
@@ -0,0 +1,100 @@
<script setup lang="ts">
import SkeletonLines from '../ui/SkeletonLines.vue';
import { GENERAL_RECORD_TYPES, type GeneralRecordCollection, type GeneralRecordType } from '../generalRecords';
const props = withDefaults(
defineProps<{
records: GeneralRecordCollection;
loading?: boolean;
trustedHtml?: boolean;
unavailable?: GeneralRecordType[];
}>(),
{
loading: false,
trustedHtml: false,
unavailable: () => [],
}
);
const labels: Record<GeneralRecordType, string> = {
generalHistory: '장수 열전',
battleDetail: '전투 기록',
battleResult: '전투 결과',
generalAction: '개인 기록',
};
const unavailableText: Record<GeneralRecordType, string> = {
generalHistory: '이 기수에는 장수 열전이 보존되지 않았습니다.',
battleDetail: '이 기수에는 전투 기록이 보존되지 않았습니다.',
battleResult: '이 기수에는 전투 결과가 보존되지 않았습니다.',
generalAction: '이 기수에는 개인 기록이 보존되지 않았습니다.',
};
</script>
<template>
<div class="log-grid" data-general-record-panels>
<div v-for="type in GENERAL_RECORD_TYPES" :key="type" class="log-block" :data-log-type="type">
<div class="log-title">{{ labels[type] }}</div>
<SkeletonLines v-if="loading" :lines="3" />
<template v-else-if="props.unavailable.includes(type)">
<div class="empty unavailable">{{ unavailableText[type] }}</div>
</template>
<template v-else>
<div v-if="(records[type]?.length ?? 0) === 0" class="empty">기록이 없습니다.</div>
<template v-for="entry in records[type] ?? []" :key="entry.id">
<!-- Current-season logs have already passed the trusted formatter boundary. -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-if="trustedHtml" class="log-line" v-html="entry.content" />
<div v-else class="log-line">{{ entry.content }}</div>
</template>
</template>
</div>
</div>
</template>
<style scoped>
.log-grid {
display: contents;
}
.log-block {
min-height: 0;
border: 1px solid #666;
padding: 0;
background-color: #302016;
background-image: var(--sammo-texture-walnut);
}
.log-title {
display: flex;
min-height: 34px;
align-items: center;
justify-content: center;
margin: 0;
border-bottom: 1px solid #666;
color: orange;
background-color: #000;
background-image: var(--sammo-texture-green);
font-size: 1.3em;
font-weight: 500;
}
.log-line {
padding: 2px 8px;
border-bottom: 0;
}
.log-line :deep(.hidden_but_copyable) {
color: transparent !important;
font-size: 0;
}
.empty {
padding: 2px 8px;
color: #999;
}
.unavailable {
color: #bbb;
}
</style>
@@ -226,7 +226,6 @@ const onQuick = (item: QuickNavigationItem) => {
data-bottom-menu="manual-refresh" data-bottom-menu="manual-refresh"
aria-label="직접 갱신" aria-label="직접 갱신"
title="직접 갱신" title="직접 갱신"
:disabled="refreshing"
:aria-busy="refreshing" :aria-busy="refreshing"
@click="emit('refresh')" @click="emit('refresh')"
> >
@@ -306,12 +305,6 @@ const onQuick = (item: QuickNavigationItem) => {
line-height: 1; 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']):hover,
.bottom-trigger.legacy-button:not(:disabled, [aria-disabled='true'])[aria-expanded='true'] { .bottom-trigger.legacy-button:not(:disabled, [aria-disabled='true'])[aria-expanded='true'] {
height: 44px; height: 44px;
+31 -118
View File
@@ -3,9 +3,15 @@ import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, reactive, ref, watch } from 'vue'; import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import PanelCard from '../components/ui/PanelCard.vue'; import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue'; import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue'; import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import GeneralBattleSummary from '../components/main/GeneralBattleSummary.vue';
import GeneralRecordPanels from '../components/main/GeneralRecordPanels.vue';
import {
GENERAL_RECORD_TYPES,
type GeneralRecordCollection,
type GeneralRecordType,
} from '../components/generalRecords';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
import { getNpcColor } from '../utils/npcColor'; import { getNpcColor } from '../utils/npcColor';
import { formatLog } from '../utils/formatLog'; import { formatLog } from '../utils/formatLog';
@@ -13,17 +19,10 @@ import { formatLog } from '../utils/formatLog';
type BattleCenterResponse = Awaited<ReturnType<typeof trpc.nation.getBattleCenter.query>>; type BattleCenterResponse = Awaited<ReturnType<typeof trpc.nation.getBattleCenter.query>>;
type GeneralEntry = BattleCenterResponse['generals'][number]; type GeneralEntry = BattleCenterResponse['generals'][number];
type LogType = 'generalHistory' | 'battleResult' | 'battleDetail' | 'generalAction'; type LogType = GeneralRecordType;
type LogLine = { id: number; html: string }; type LogLine = { id: number; html: string };
const logTypes: LogType[] = ['generalHistory', 'battleDetail', 'battleResult', 'generalAction']; const logTypes: LogType[] = [...GENERAL_RECORD_TYPES];
const logLabels: Record<LogType, string> = {
generalHistory: '장수 열전',
battleDetail: '전투 기록',
battleResult: '전투 결과',
generalAction: '개인 기록',
};
const orderOptions = [ const orderOptions = [
{ key: 'recentWar', label: '최근 전투' }, { key: 'recentWar', label: '최근 전투' },
@@ -49,6 +48,12 @@ const logs = reactive<Record<LogType, LogLine[]>>({
generalAction: [], generalAction: [],
}); });
const currentRecords = computed<GeneralRecordCollection>(() =>
Object.fromEntries(
logTypes.map((type) => [type, logs[type].map((entry) => ({ id: entry.id, content: entry.html }))])
)
);
const resolveErrorMessage = (value: unknown): string => { const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) { if (value instanceof Error) {
return value.message; return value.message;
@@ -275,27 +280,20 @@ onMounted(() => {
:nation-color="data?.nation.color" :nation-color="data?.nation.color"
> >
<template v-if="selectedGeneral" #details> <template v-if="selectedGeneral" #details>
<div class="battle-general-extra"> <GeneralBattleSummary
<span>명성</span :summary="{
><strong>{{ selectedGeneral.experience.toLocaleString('ko-KR') }}</strong> available: true,
<span>계급</span><strong>{{ selectedGeneral.progression.dedicationText }}</strong> experience: selectedGeneral.experience,
<span>전투</span><strong>{{ selectedGeneral.warnum }}</strong> <span>승리</span dedicationText: selectedGeneral.progression.dedicationText,
><strong>{{ selectedGeneral.battleStats.kills }}</strong> <span>패배</span warnum: selectedGeneral.warnum,
><strong>{{ selectedGeneral.battleStats.deaths }}</strong> <span>계략</span wins: selectedGeneral.battleStats.kills,
><strong>{{ selectedGeneral.battleStats.fire }}</strong> <span>사살</span losses: selectedGeneral.battleStats.deaths,
><strong>{{ selectedGeneral.battleStats.killCrew.toLocaleString('ko-KR') }}</strong> strategies: selectedGeneral.battleStats.fire,
<span>피살</span killCrew: selectedGeneral.battleStats.killCrew,
><strong>{{ selectedGeneral.battleStats.deathCrew.toLocaleString('ko-KR') }}</strong> deathCrew: selectedGeneral.battleStats.deathCrew,
<span class="battle-general-extra__recent-label">최근 전투</span> recentWar: selectedGeneral.recentWar,
<strong class="battle-general-extra__recent-value"> }"
{{ />
formatServerDateTime(selectedGeneral.recentWar, {
format: 'monthDayTime',
fallback: '-',
})
}}
</strong>
</div>
<LegacyGeneralProgress :general="selectedGeneral" :show-primary="false" /> <LegacyGeneralProgress :general="selectedGeneral" :show-primary="false" />
</template> </template>
</GeneralBasicCard> </GeneralBasicCard>
@@ -304,16 +302,7 @@ onMounted(() => {
<div class="stack"> <div class="stack">
<PanelCard title="장수 기록" subtitle="열전과 전투 기록"> <PanelCard title="장수 기록" subtitle="열전과 전투 기록">
<div class="log-grid"> <GeneralRecordPanels :records="currentRecords" :loading="loading || logLoading" trusted-html />
<div v-for="type in logTypes" :key="type" class="log-block" :data-log-type="type">
<div class="log-title">{{ logLabels[type] }}</div>
<SkeletonLines v-if="loading || logLoading" :lines="3" />
<template v-else>
<div v-if="logs[type].length === 0" class="empty">기록이 없습니다.</div>
<div v-for="entry in logs[type]" :key="entry.id" class="log-line" v-html="entry.html" />
</template>
</div>
</div>
</PanelCard> </PanelCard>
</div> </div>
</section> </section>
@@ -354,81 +343,6 @@ onMounted(() => {
font: inherit; font: inherit;
} }
.battle-general-extra {
display: grid;
grid-template-columns: repeat(6, 1fr);
}
.battle-general-extra > * {
min-height: 24px;
box-sizing: border-box;
border-right: 1px solid #777;
border-bottom: 1px solid #777;
padding: 2px 5px;
}
.battle-general-extra > span {
background-color: rgb(20 75 42 / 70%);
text-align: center;
}
.battle-general-extra > strong {
overflow: hidden;
font-weight: 500;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.battle-general-extra > .battle-general-extra__recent-label {
grid-column: 1;
}
.battle-general-extra > .battle-general-extra__recent-value {
grid-column: 2 / -1;
text-align: left;
}
.log-grid {
display: contents;
}
.log-block {
border: 1px solid #666;
padding: 0;
background-color: #302016;
background-image: var(--sammo-texture-walnut);
min-height: 0;
}
.log-title {
min-height: 34px;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid #666;
color: orange;
background: #000;
font-size: 1.3em;
font-weight: 500;
}
.log-line {
padding: 2px 8px;
border-bottom: 0;
}
.log-line :deep(.hidden_but_copyable) {
color: transparent !important;
font-size: 0;
}
.empty {
padding: 2px 8px;
color: #999;
}
/* PanelCard is retained as a data wrapper, but its presentation follows the /* PanelCard is retained as a data wrapper, but its presentation follows the
flat bootstrap rows used by the reference page. */ flat bootstrap rows used by the reference page. */
:deep(.panel-card) { :deep(.panel-card) {
@@ -466,8 +380,7 @@ onMounted(() => {
font-size: 18px; font-size: 18px;
font-weight: 500; font-weight: 500;
} }
:deep(.panel-header), :deep(.panel-header) {
.log-title {
background-image: var(--sammo-texture-green); background-image: var(--sammo-texture-green);
} }
@@ -14,6 +14,7 @@ const router = useRouter();
const loading = ref(false); const loading = ref(false);
const errorMessage = ref(''); const errorMessage = ref('');
const data = ref<DynastyDetailPayload | null>(null); const data = ref<DynastyDetailPayload | null>(null);
const source = computed<'current' | 'legacy'>(() => (route.query.source === 'legacy' ? 'legacy' : 'current'));
const emperorId = computed(() => { const emperorId = computed(() => {
const idParam = route.params.id; const idParam = route.params.id;
@@ -39,7 +40,7 @@ const loadDetail = async (): Promise<void> => {
loading.value = true; loading.value = true;
errorMessage.value = ''; errorMessage.value = '';
try { try {
data.value = await trpc.dynasty.getDetail.query({ emperorId: emperorId.value }); data.value = await trpc.dynasty.getDetail.query({ emperorId: emperorId.value, source: source.value });
} catch (error) { } catch (error) {
data.value = null; data.value = null;
errorMessage.value = error instanceof Error ? error.message : '왕조 정보를 불러오지 못했습니다.'; errorMessage.value = error instanceof Error ? error.message : '왕조 정보를 불러오지 못했습니다.';
@@ -50,7 +51,7 @@ const loadDetail = async (): Promise<void> => {
const formatArchiveDate = (value: string): string => formatServerDateTime(value); const formatArchiveDate = (value: string): string => formatServerDateTime(value);
watch(emperorId, loadDetail); watch([emperorId, source], loadDetail);
onMounted(loadDetail); onMounted(loadDetail);
</script> </script>
@@ -63,7 +64,8 @@ onMounted(loadDetail);
<br /> <br />
<button class="native-button" type="button" @click="closePage"> 닫기</button> <button class="native-button" type="button" @click="closePage"> 닫기</button>
<span class="all-link"> <span class="all-link">
<RouterLink to="/dynasty" <RouterLink
:to="{ path: '/dynasty', query: source === 'legacy' ? { source: 'legacy' } : {} }"
><button class="native-button" type="button">전체보기</button></RouterLink ><button class="native-button" type="button">전체보기</button></RouterLink
> >
</span> </span>
@@ -88,7 +90,12 @@ onMounted(loadDetail);
<tbody> <tbody>
<tr> <tr>
<td class="phase-heading centered" colspan="6"> <td class="phase-heading centered" colspan="6">
<span class="large-text">{{ data.emperor.phase }}</span> <span class="large-text">
{{ data.emperor.phase }}
<template v-if="data.source === 'legacy'">
[{{ data.sourceProfile.toUpperCase() }} 이전 서버]
</template>
</span>
</td> </td>
</tr> </tr>
<tr> <tr>
+47 -13
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { legacyNationTextColor } from '../utils/legacyNationColor'; import { legacyNationTextColor } from '../utils/legacyNationColor';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
@@ -8,9 +8,11 @@ import { trpc } from '../utils/trpc';
type DynastyListPayload = Awaited<ReturnType<typeof trpc.dynasty.getList.query>>; type DynastyListPayload = Awaited<ReturnType<typeof trpc.dynasty.getList.query>>;
const router = useRouter(); const router = useRouter();
const route = useRoute();
const loading = ref(false); const loading = ref(false);
const errorMessage = ref(''); const errorMessage = ref('');
const data = ref<DynastyListPayload | null>(null); const data = ref<DynastyListPayload | null>(null);
const selectedSource = ref<'current' | 'legacy'>(route.query.source === 'legacy' ? 'legacy' : 'current');
const closePage = async (): Promise<void> => { const closePage = async (): Promise<void> => {
if (window.opener) { if (window.opener) {
@@ -24,7 +26,7 @@ const loadDynasty = async (): Promise<void> => {
loading.value = true; loading.value = true;
errorMessage.value = ''; errorMessage.value = '';
try { try {
data.value = await trpc.dynasty.getList.query(); data.value = await trpc.dynasty.getList.query({ source: selectedSource.value });
} catch (error) { } catch (error) {
errorMessage.value = error instanceof Error ? error.message : '왕조일람을 불러오지 못했습니다.'; errorMessage.value = error instanceof Error ? error.message : '왕조일람을 불러오지 못했습니다.';
} finally { } finally {
@@ -33,6 +35,7 @@ const loadDynasty = async (): Promise<void> => {
}; };
onMounted(loadDynasty); onMounted(loadDynasty);
watch(selectedSource, loadDynasty);
</script> </script>
<template> <template>
@@ -48,6 +51,14 @@ onMounted(loadDynasty);
</tbody> </tbody>
</table> </table>
<div class="record-source legacy-bg0">
기록 구분 :
<select v-model="selectedSource" aria-label="기록 구분">
<option value="current">현재 서버 기록</option>
<option value="legacy">이전 서버 기록</option>
</select>
</div>
<div v-if="errorMessage" class="legacy-message error" role="alert">{{ errorMessage }}</div> <div v-if="errorMessage" class="legacy-message error" role="alert">{{ errorMessage }}</div>
<div v-else-if="loading && !data" class="legacy-message" role="status">불러오는 중...</div> <div v-else-if="loading && !data" class="legacy-message" role="status">불러오는 중...</div>
@@ -57,7 +68,9 @@ onMounted(loadDynasty);
<tr> <tr>
<td class="current-heading" colspan="8"> <td class="current-heading" colspan="8">
<span class="large-text">현재 ({{ data.current.year }} {{ data.current.month }})</span> <span class="large-text">현재 ({{ data.current.year }} {{ data.current.month }})</span>
<RouterLink to="/yearbook"><button class="native-button" type="button">역사 보기</button></RouterLink> <RouterLink to="/yearbook"
><button class="native-button" type="button">역사 보기</button></RouterLink
>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -83,11 +96,24 @@ onMounted(loadDynasty);
<tbody> <tbody>
<tr> <tr>
<td class="phase-heading" colspan="8"> <td class="phase-heading" colspan="8">
<span class="large-text">{{ entry.phase }}</span> <span class="large-text"
<RouterLink :to="`/dynasty/${entry.id}`"> >{{ entry.phase
}}<template v-if="entry.source === 'legacy'">
[{{ entry.sourceProfile.toUpperCase() }} 이전 서버]</template
></span
>
<RouterLink
:to="{
path: `/dynasty/${entry.id}`,
query: entry.source === 'legacy' ? { source: 'legacy' } : {},
}"
>
<button class="native-button" type="button">자세히</button> <button class="native-button" type="button">자세히</button>
</RouterLink> </RouterLink>
<RouterLink v-if="entry.serverId" :to="{ path: '/yearbook', query: { serverID: entry.serverId } }"> <RouterLink
v-if="entry.serverId && entry.source === 'current'"
:to="{ path: '/yearbook', query: { serverID: entry.serverId } }"
>
<button class="native-button" type="button">역사 보기</button> <button class="native-button" type="button">역사 보기</button>
</RouterLink> </RouterLink>
</td> </td>
@@ -138,14 +164,10 @@ onMounted(loadDynasty);
<table class="legacy-table legacy-bg0 footer-table spaced-table"> <table class="legacy-table legacy-bg0 footer-table spaced-table">
<tbody> <tbody>
<tr> <tr>
<td> <td><button class="native-button" type="button" @click="closePage"> 닫기</button><br /></td>
<button class="native-button" type="button" @click="closePage"> 닫기</button><br />
</td>
</tr> </tr>
<tr> <tr>
<td class="banner"> <td class="banner">삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD</td>
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -183,6 +205,18 @@ onMounted(loadDynasty);
margin-top: 10px; margin-top: 10px;
} }
.record-source {
box-sizing: border-box;
width: 1000px;
border: 1px solid gray;
padding: 3px;
text-align: center;
}
.record-source select {
height: 22px;
}
.title-table { .title-table {
height: 47px; height: 47px;
} }
+58 -12
View File
@@ -6,6 +6,7 @@ import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIc
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
type HallOption = { type HallOption = {
sourceProfile: string;
season: number; season: number;
scenarios: Array<{ id: number; name: string; count: number }>; scenarios: Array<{ id: number; name: string; count: number }>;
}; };
@@ -42,6 +43,8 @@ const router = useRouter();
const loading = ref(false); const loading = ref(false);
const errorMessage = ref(''); const errorMessage = ref('');
const options = ref<HallOption[]>([]); const options = ref<HallOption[]>([]);
const selectedSource = ref<'current' | 'legacy'>('current');
const selectedProfile = ref<string | null>(null);
const selectedSeason = ref<number | null>(null); const selectedSeason = ref<number | null>(null);
const selectedScenario = ref<number | null>(null); const selectedScenario = ref<number | null>(null);
const data = ref<HallPayload | null>(null); const data = ref<HallPayload | null>(null);
@@ -51,10 +54,11 @@ const selection = computed({
selectedSeason.value === null selectedSeason.value === null
? '' ? ''
: selectedScenario.value === null : selectedScenario.value === null
? `season:${selectedSeason.value}` ? `season:${selectedProfile.value ?? ''}:${selectedSeason.value}`
: `scenario:${selectedSeason.value}:${selectedScenario.value}`, : `scenario:${selectedProfile.value ?? ''}:${selectedSeason.value}:${selectedScenario.value}`,
set: (value: string) => { set: (value: string) => {
const [kind, season, scenario] = value.split(':'); const [kind, profile, season, scenario] = value.split(':');
selectedProfile.value = profile || null;
selectedSeason.value = Number(season); selectedSeason.value = Number(season);
selectedScenario.value = kind === 'scenario' ? Number(scenario) : null; selectedScenario.value = kind === 'scenario' ? Number(scenario) : null;
}, },
@@ -72,9 +76,17 @@ const closePage = async (): Promise<void> => {
const loadOptions = async (): Promise<void> => { const loadOptions = async (): Promise<void> => {
try { try {
options.value = await trpc.ranking.getHallOfFameOptions.query(); options.value = await trpc.ranking.getHallOfFameOptions.query({ source: selectedSource.value });
if (options.value.length > 0 && selectedSeason.value === null) { const first = options.value[0];
selectedSeason.value = options.value[0]!.season; if (first) {
selectedProfile.value = first.sourceProfile;
selectedSeason.value = first.season;
selectedScenario.value = null;
} else {
selectedProfile.value = null;
selectedSeason.value = null;
selectedScenario.value = null;
data.value = null;
} }
} catch (error) { } catch (error) {
errorMessage.value = error instanceof Error ? error.message : '명예의 전당 옵션을 불러오지 못했습니다.'; errorMessage.value = error instanceof Error ? error.message : '명예의 전당 옵션을 불러오지 못했습니다.';
@@ -90,6 +102,11 @@ const loadHall = async (): Promise<void> => {
errorMessage.value = ''; errorMessage.value = '';
try { try {
data.value = (await trpc.ranking.getHallOfFame.query({ data.value = (await trpc.ranking.getHallOfFame.query({
source: selectedSource.value,
sourceProfile:
selectedSource.value === 'legacy'
? (selectedProfile.value as 'che' | 'kwe' | 'pwe' | 'twe' | 'nya' | 'pya' | 'hwe')
: undefined,
season: selectedSeason.value, season: selectedSeason.value,
scenario: selectedScenario.value ?? undefined, scenario: selectedScenario.value ?? undefined,
})) as HallPayload; })) as HallPayload;
@@ -100,10 +117,14 @@ const loadHall = async (): Promise<void> => {
} }
}; };
watch([selectedSeason, selectedScenario], () => { watch([selectedProfile, selectedSeason, selectedScenario], () => {
void loadHall(); void loadHall();
}); });
watch(selectedSource, () => {
void loadOptions();
});
onMounted(loadOptions); onMounted(loadOptions);
</script> </script>
@@ -114,17 +135,29 @@ onMounted(loadOptions);
<button class="legacy-button" type="button" @click="closePage"> 닫기</button> <button class="legacy-button" type="button" @click="closePage"> 닫기</button>
</div> </div>
<label class="archive-source">
기록 구분 :
<select v-model="selectedSource" aria-label="기록 구분">
<option value="current">현재 서버 기록</option>
<option value="legacy">이전 서버 기록</option>
</select>
</label>
<label class="scenario-search"> <label class="scenario-search">
시나리오 검색 : 시나리오 검색 :
<select v-model="selection" aria-label="시나리오 검색"> <select v-model="selection" aria-label="시나리오 검색">
<template v-for="season in options" :key="season.season"> <template v-for="season in options" :key="`${season.sourceProfile}:${season.season}`">
<option :value="`season:${season.season}`">* 시즌 : {{ season.season }} 종합 *</option> <option :value="`season:${season.sourceProfile}:${season.season}`">
* {{ selectedSource === 'legacy' ? `${season.sourceProfile.toUpperCase()} / ` : '' }}시즌 :
{{ season.season }} 종합 *
</option>
<option <option
v-for="scenario in season.scenarios" v-for="scenario in season.scenarios"
:key="`${season.season}:${scenario.id}`" :key="`${season.sourceProfile}:${season.season}:${scenario.id}`"
:value="`scenario:${season.season}:${scenario.id}`" :value="`scenario:${season.sourceProfile}:${season.season}:${scenario.id}`"
> >
{{ scenario.name }}({{ scenario.count }}) {{ selectedSource === 'legacy' ? `${season.sourceProfile.toUpperCase()} / ` : ''
}}{{ scenario.name }}({{ scenario.count }})
</option> </option>
</template> </template>
</select> </select>
@@ -210,6 +243,19 @@ onMounted(loadOptions);
text-align: center; text-align: center;
} }
.archive-source {
display: block;
padding: 2px 0 0;
text-align: center;
}
.archive-source select {
height: 20px;
border: 1px solid #555;
background: #ddd;
color: #303030;
}
.scenario-search select { .scenario-search select {
width: 189px; width: 189px;
height: 20px; height: 20px;
+12 -3
View File
@@ -20,11 +20,13 @@ import type { QuickNavigationItem } from '../components/main/mainNavigation';
import { formatLog } from '../utils/formatLog'; import { formatLog } from '../utils/formatLog';
import { useSessionStore } from '../stores/session'; import { useSessionStore } from '../stores/session';
import { useMainDashboardStore } from '../stores/mainDashboard'; import { useMainDashboardStore } from '../stores/mainDashboard';
import { useGameFeedback } from '../composables/useGameFeedback';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
import type { CommandPatternEntry } from '../components/command/types'; import type { CommandPatternEntry } from '../components/command/types';
const session = useSessionStore(); const session = useSessionStore();
const dashboard = useMainDashboardStore(); const dashboard = useMainDashboardStore();
const { info: showInfoToast } = useGameFeedback();
const isMobile = useMediaQuery('(max-width: 939.98px)'); const isMobile = useMediaQuery('(max-width: 939.98px)');
const npcMode = ref(0); const npcMode = ref(0);
@@ -115,6 +117,14 @@ const loadMainData = async () => {
npcMode.value = worldState?.config.npcMode ?? 0; npcMode.value = worldState?.config.npcMode ?? 0;
}; };
const requestManualRefresh = () => {
if (refreshing.value) {
showInfoToast('이미 정보를 갱신하고 있습니다.');
return;
}
void loadMainData();
};
const moveLobby = () => { const moveLobby = () => {
window.location.replace(import.meta.env.VITE_GATEWAY_WEB_URL?.trim() || '/gateway/'); window.location.replace(import.meta.env.VITE_GATEWAY_WEB_URL?.trim() || '/gateway/');
}; };
@@ -154,9 +164,8 @@ watch(
<button <button
class="game-shell__action legacy-button legacy-button--navigation" class="game-shell__action legacy-button legacy-button--navigation"
type="button" type="button"
:disabled="refreshing"
:aria-busy="refreshing" :aria-busy="refreshing"
@click="loadMainData" @click="requestManualRefresh"
> >
</button> </button>
@@ -451,7 +460,7 @@ watch(
:npc-mode="npcMode" :npc-mode="npcMode"
:realtime-enabled="realtimeEnabled" :realtime-enabled="realtimeEnabled"
:refreshing="refreshing" :refreshing="refreshing"
@refresh="loadMainData" @refresh="requestManualRefresh"
@toggle-realtime="dashboard.setRealtimeEnabled(!realtimeEnabled)" @toggle-realtime="dashboard.setRealtimeEnabled(!realtimeEnabled)"
@lobby="moveLobby" @lobby="moveLobby"
@quick="moveQuick" @quick="moveQuick"
+312 -162
View File
@@ -1,21 +1,102 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import GeneralBattleSummary, { type GeneralBattleSummaryData } from '../components/main/GeneralBattleSummary.vue';
import GeneralRecordPanels from '../components/main/GeneralRecordPanels.vue';
import {
GENERAL_RECORD_TYPES,
type GeneralRecordCollection,
type GeneralRecordType,
} from '../components/generalRecords';
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
type Archive = Awaited<ReturnType<typeof trpc.archive.myPastPlays.query>>; type Archive = Awaited<ReturnType<typeof trpc.archive.myPastPlays.query>>;
type PastPlayDetail = Awaited<ReturnType<typeof trpc.archive.myPastPlayDetail.query>>; type ArchiveSeason = Archive['seasons'][number] & {
type DetailState = { sourceProfile?: string;
open: boolean; source?: string;
loading: boolean; openedAt?: string | null;
error: string | null; date?: string | null;
detail: PastPlayDetail | null;
}; };
type ArchiveGeneral = {
id: number;
name: string;
picture?: string | null;
imageServer?: number | null;
npcState: number;
officerLevel: number;
officerLevelText: string;
officerCityName?: string | null;
generalType?: string;
leadershipBonus?: number;
stats: { leadership: number; strength: number; intelligence: number };
gold: number;
rice: number;
crew: number;
train: number;
atmos: number;
injury: number;
experience: number;
dedication: number;
age?: number;
retirementYear?: number;
turnTime?: string | null;
crewTypeId?: number;
crewTypeName?: string;
traits?: { personal: string; specialWar: string; specialDomestic: string };
progression: {
experienceLevel: number;
dedicationLevel: number;
dedicationText: string;
statExperience: { leadership: number; strength: number; intelligence: number };
statUpgradeLimit: number;
dex: number[];
};
};
type ArchiveLogChannel = {
available: boolean;
entries: Array<{ id: number | string; text: string }>;
};
type PastPlayDetail = {
sourceProfile: string;
source: string;
serverId: string;
generalNo: number;
dynastyPath: string | null;
nation: { name: string; color: string } | null;
general: ArchiveGeneral;
masteryAvailable: boolean;
battle: GeneralBattleSummaryData & {
winRate?: number | null;
killRate?: number | null;
};
logs: Partial<Record<GeneralRecordType, ArchiveLogChannel>>;
};
type PastPlayDetailInput = {
sourceProfile: string;
source: string;
serverId: string;
generalNo: number;
};
const queryPastPlayDetail = trpc.archive.myPastPlayDetail.query as unknown as (
input: PastPlayDetailInput
) => Promise<PastPlayDetail>;
const archive = ref<Archive | null>(null); const archive = ref<Archive | null>(null);
const loading = ref(false); const loading = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const details = ref<Record<string, DetailState>>({}); const selectedKey = ref<string | null>(null);
const detail = ref<PastPlayDetail | null>(null);
const detailLoading = ref(false);
const detailError = ref<string | null>(null);
const loadArchive = async () => { const loadArchive = async () => {
if (loading.value) return; if (loading.value) return;
@@ -33,43 +114,74 @@ const loadArchive = async () => {
const yearMonth = (value: number): string => `${Math.floor(value / 100)}${value % 100}`; const yearMonth = (value: number): string => `${Math.floor(value / 100)}${value % 100}`;
const valueOrDash = (value: number | string | null): string => (value === null || value === '' ? '-' : String(value)); const valueOrDash = (value: number | string | null): string => (value === null || value === '' ? '-' : String(value));
const plainLog = (value: string): string => value.replace(/<[^>]+>/g, ''); const plainLog = (value: string): string => value.replace(/<[^>]+>/g, '');
const detailKey = (serverId: string, generalNo: number): string => `${serverId}:${generalNo}`; const seasonSourceProfile = (season: ArchiveSeason): string => season.sourceProfile ?? '현재 서버';
const seasonSource = (season: ArchiveSeason): string => season.source ?? 'current';
const detailKey = (season: ArchiveSeason, generalNo: number): string =>
`${seasonSourceProfile(season)}:${seasonSource(season)}:${season.serverId}:${generalNo}`;
const isSelectedSeason = (season: ArchiveSeason): boolean =>
selectedKey.value?.startsWith(`${seasonSourceProfile(season)}:${seasonSource(season)}:${season.serverId}:`) ??
false;
const toggleHistory = async (serverId: string, generalNo: number): Promise<void> => { const formatOpenedAt = (season: ArchiveSeason): string => {
const key = detailKey(serverId, generalNo); const value = season.openedAt ?? season.date;
const current = details.value[key]; if (!value) return '개장일 미상';
if (current?.open) { const date = new Date(value);
details.value = { ...details.value, [key]: { ...current, open: false } }; if (Number.isNaN(date.getTime())) return '개장일 미상';
return; return `${new Intl.DateTimeFormat('ko-KR', { dateStyle: 'medium', timeZone: 'Asia/Seoul' }).format(date)} 개장`;
} };
if (current?.detail) {
details.value = { ...details.value, [key]: { ...current, open: true } }; const selectGeneral = async (season: ArchiveSeason, generalNo: number): Promise<void> => {
const key = detailKey(season, generalNo);
if (selectedKey.value === key) {
selectedKey.value = null;
detail.value = null;
detailError.value = null;
return; return;
} }
details.value = { selectedKey.value = key;
...details.value, detail.value = null;
[key]: { open: true, loading: true, error: null, detail: null }, detailError.value = null;
}; detailLoading.value = true;
try { try {
const detail = await trpc.archive.myPastPlayDetail.query({ serverId, generalNo }); const result = await queryPastPlayDetail({
details.value = { sourceProfile: seasonSourceProfile(season),
...details.value, source: seasonSource(season),
[key]: { open: true, loading: false, error: null, detail }, serverId: season.serverId,
}; generalNo,
});
if (selectedKey.value === key) detail.value = result;
} catch (cause) { } catch (cause) {
details.value = { if (selectedKey.value === key) {
...details.value, detailError.value = cause instanceof Error ? cause.message : '지난 장수 상세 기록을 불러오지 못했습니다.';
[key]: { }
open: true, } finally {
loading: false, if (selectedKey.value === key) detailLoading.value = false;
error: cause instanceof Error ? cause.message : '장수 열전을 불러오지 못했습니다.',
detail: null,
},
};
} }
}; };
const archiveRecords = computed<GeneralRecordCollection>(() => {
const result: GeneralRecordCollection = {};
for (const type of GENERAL_RECORD_TYPES) {
const channel = detail.value?.logs[type];
result[type] = (channel?.entries ?? []).map((entry) => ({ id: entry.id, content: plainLog(entry.text) }));
}
return result;
});
const unavailableRecords = computed<GeneralRecordType[]>(() =>
GENERAL_RECORD_TYPES.filter((type) => {
const channel = detail.value?.logs[type];
return channel ? !channel.available : type !== 'generalHistory';
})
);
const battleSummary = computed<GeneralBattleSummaryData>(() => ({
...detail.value?.battle,
experience: detail.value?.general.experience ?? null,
dedicationText: detail.value?.general.progression.dedicationText ?? null,
}));
onMounted(() => { onMounted(() => {
void loadArchive(); void loadArchive();
}); });
@@ -85,28 +197,27 @@ onMounted(() => {
</nav> </nav>
</header> </header>
<p class="page-note">종료된 기수에 보관된 장수 기록입니다.</p> <p class="page-note">이전 서버에서 종료된 기수에 보관된 장수 기록입니다.</p>
<p v-if="error" class="error-row">{{ error }}</p> <p v-if="error" class="error-row">{{ error }}</p>
<p v-else-if="loading && !archive" class="empty-row">불러오는 중...</p> <p v-else-if="loading && !archive" class="empty-row">불러오는 중...</p>
<p v-else-if="archive?.seasons.length === 0" class="empty-row">보관된 지난 플레이가 없습니다.</p> <p v-else-if="archive?.seasons.length === 0" class="empty-row">보관된 지난 플레이가 없습니다.</p>
<section v-for="season in archive?.seasons ?? []" :key="season.serverId" class="season-card"> <section v-for="season in archive?.seasons ?? []" :key="detailKey(season, 0)" class="season-card">
<div class="season-heading legacy-bg2"> <div class="season-heading legacy-bg2">
<strong>{{ season.serverId }}</strong> <div class="season-identity">
<div class="season-actions"> <strong class="archive-label">이전 서버 기록</strong>
<strong>{{ seasonSourceProfile(season) }}</strong>
<span>{{ season.serverId }}</span>
</div>
<div class="season-meta">
<span>{{ formatOpenedAt(season) }}</span>
<span> <span>
{{ season.scenarioName ?? '시나리오 미상' }} {{ season.scenarioName ?? '시나리오 미상' }}
<template v-if="season.season !== null"> · {{ season.season }}</template> <template v-if="season.season !== null"> · {{ season.season }}</template>
</span> </span>
<RouterLink
v-if="season.dynastyId !== null"
class="legacy-button nation-archive-link"
:to="`/dynasty/${season.dynastyId}`"
>
기수 국가 정보
</RouterLink>
</div> </div>
</div> </div>
<div class="table-scroll"> <div class="table-scroll">
<table> <table>
<thead> <thead>
@@ -121,85 +232,83 @@ onMounted(() => {
<th>성격</th> <th>성격</th>
<th>내정 특기</th> <th>내정 특기</th>
<th>전투 특기</th> <th>전투 특기</th>
<th>장수 열전</th> <th>상세</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<template v-for="general in season.generals" :key="general.generalNo"> <tr v-for="general in season.generals" :key="general.generalNo">
<tr> <td class="general-name">{{ general.name }}</td>
<td class="general-name">{{ general.name }}</td> <td>
<td> <span
<span class="nation-name"
class="nation-name" :style="{ backgroundColor: general.nationColor, color: '#fff' }"
:style="{ backgroundColor: general.nationColor, color: '#ffffff' }" >
> {{ general.nationName }}
{{ general.nationName }} </span>
</span> </td>
</td> <td>{{ yearMonth(general.lastYearMonth) }}</td>
<td>{{ yearMonth(general.lastYearMonth) }}</td> <td>{{ valueOrDash(general.leadership) }}</td>
<td>{{ valueOrDash(general.leadership) }}</td> <td>{{ valueOrDash(general.strength) }}</td>
<td>{{ valueOrDash(general.strength) }}</td> <td>{{ valueOrDash(general.intel) }}</td>
<td>{{ valueOrDash(general.intel) }}</td> <td>{{ valueOrDash(general.officerLevelText) }}</td>
<td>{{ valueOrDash(general.officerLevelText) }}</td> <td>{{ valueOrDash(general.personal) }}</td>
<td>{{ valueOrDash(general.personal) }}</td> <td>{{ valueOrDash(general.special) }}</td>
<td>{{ valueOrDash(general.special) }}</td> <td>{{ valueOrDash(general.special2) }}</td>
<td>{{ valueOrDash(general.special2) }}</td> <td>
<td> <button
<button class="legacy-button detail-toggle"
class="legacy-button history-toggle" type="button"
type="button" :aria-expanded="selectedKey === detailKey(season, general.generalNo)"
:aria-expanded=" @click="selectGeneral(season, general.generalNo)"
details[detailKey(season.serverId, general.generalNo)]?.open ?? false >
" {{ selectedKey === detailKey(season, general.generalNo) ? '접기' : '상세 보기' }}
@click="toggleHistory(season.serverId, general.generalNo)" </button>
> </td>
{{ </tr>
details[detailKey(season.serverId, general.generalNo)]?.open
? '접기'
: `보기 (${general.historyCount})`
}}
</button>
</td>
</tr>
<tr v-if="details[detailKey(season.serverId, general.generalNo)]?.open" class="history-row">
<td colspan="11">
<p
v-if="details[detailKey(season.serverId, general.generalNo)]?.loading"
role="status"
>
장수 열전을 불러오는 ...
</p>
<p
v-else-if="details[detailKey(season.serverId, general.generalNo)]?.error"
class="history-error"
role="alert"
>
{{ details[detailKey(season.serverId, general.generalNo)]?.error }}
</p>
<p
v-else-if="
details[detailKey(season.serverId, general.generalNo)]?.detail?.history
.length === 0
"
>
보관된 장수 열전이 없습니다.
</p>
<ol v-else class="history-list">
<li
v-for="(entry, index) in details[
detailKey(season.serverId, general.generalNo)
]?.detail?.history ?? []"
:key="index"
>
{{ plainLog(entry) }}
</li>
</ol>
</td>
</tr>
</template>
</tbody> </tbody>
</table> </table>
</div> </div>
<div v-if="isSelectedSeason(season)" class="detail-region">
<SkeletonLines v-if="detailLoading" :lines="8" />
<p v-else-if="detailError" class="detail-error" role="alert">{{ detailError }}</p>
<div v-else-if="detail" class="detail-shell">
<div class="detail-source">
<span>{{ detail.sourceProfile }} · {{ detail.source }}</span>
<RouterLink
v-if="detail.dynastyPath"
class="legacy-button nation-archive-link"
:to="detail.dynastyPath"
>
기수 국가 정보
</RouterLink>
</div>
<div class="detail-grid">
<PanelCard title="장수 정보">
<GeneralBasicCard
class="archive-general-card"
:general="detail.general"
:loading="false"
:nation-color="detail.nation?.color"
>
<template #details>
<GeneralBattleSummary :summary="battleSummary" show-win-rate rate-scale="percent" />
<LegacyGeneralProgress
v-if="detail.masteryAvailable"
:general="detail.general"
:show-primary="false"
/>
<div v-else class="archive-unavailable">
기수에는 숙련도 기록이 보존되지 않았습니다.
</div>
</template>
</GeneralBasicCard>
</PanelCard>
<PanelCard title="장수 기록" subtitle="보존된 과거 기록">
<GeneralRecordPanels :records="archiveRecords" :unavailable="unavailableRecords" />
</PanelCard>
</div>
</div>
</div>
</section> </section>
</main> </main>
</template> </template>
@@ -210,13 +319,17 @@ onMounted(() => {
min-height: 100vh; min-height: 100vh;
margin: 0 auto; margin: 0 auto;
color: #eee; color: #eee;
background: #151515; background-color: #302016;
background-image: var(--sammo-texture-walnut);
font-family: var(--sammo-font-sans);
font-size: 14px;
} }
.title-row, .title-row,
.season-heading { .season-heading {
border: 1px solid #555; border: 1px solid #666;
background: #2b2b2b; background-color: #14241b;
background-image: var(--sammo-texture-green);
} }
.title-row { .title-row {
@@ -233,8 +346,12 @@ onMounted(() => {
font-size: 18px; font-size: 18px;
} }
.title-row nav { .title-row nav,
.season-identity,
.season-meta,
.detail-source {
display: flex; display: flex;
align-items: center;
gap: 6px; gap: 6px;
} }
@@ -242,11 +359,12 @@ onMounted(() => {
box-sizing: border-box; box-sizing: border-box;
min-height: 28px; min-height: 28px;
padding: 4px 9px; padding: 4px 9px;
border: 1px solid #777; border: 1px solid #2d5d7f;
border-radius: 0; border-radius: 4px;
color: #eee; color: #fff;
background: #333; background: #315f86;
font: inherit; font: inherit;
font-weight: 700;
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
} }
@@ -257,6 +375,10 @@ onMounted(() => {
color: skyblue; color: skyblue;
} }
.legacy-button:active {
transform: translateY(1px);
}
.legacy-button:disabled { .legacy-button:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.55; opacity: 0.55;
@@ -267,15 +389,17 @@ onMounted(() => {
.error-row { .error-row {
margin: 0; margin: 0;
padding: 12px 10px; padding: 12px 10px;
border-inline: 1px solid #555; border-inline: 1px solid #666;
border-bottom: 1px solid #555; border-bottom: 1px solid #666;
} }
.page-note { .page-note,
.season-heading span {
color: #bbb; color: #bbb;
} }
.error-row { .error-row,
.detail-error {
color: #ff8d8d; color: #ff8d8d;
} }
@@ -294,19 +418,15 @@ onMounted(() => {
color: skyblue; color: skyblue;
} }
.season-heading span { .archive-label {
color: #bbb; padding: 2px 5px;
border: 1px solid #777;
color: #fff !important;
background: #00582c;
} }
.season-actions { .season-meta {
display: flex; justify-content: flex-end;
align-items: center;
gap: 8px;
}
.nation-archive-link {
min-height: 24px;
padding-block: 2px;
} }
.table-scroll { .table-scroll {
@@ -324,7 +444,7 @@ table {
th, th,
td { td {
padding: 6px 7px; padding: 6px 7px;
border: 1px solid #555; border: 1px solid #666;
text-align: center; text-align: center;
white-space: nowrap; white-space: nowrap;
} }
@@ -348,45 +468,75 @@ th {
text-shadow: 0 1px 1px #000; text-shadow: 0 1px 1px #000;
} }
.history-toggle { .detail-toggle,
.nation-archive-link {
min-height: 24px; min-height: 24px;
padding-block: 2px; padding-block: 2px;
} }
.history-row td { .detail-region {
padding: 10px 12px; border: 1px solid #666;
text-align: left;
white-space: normal;
background: #101010; background: #101010;
} }
.history-row p { .detail-error {
margin: 0; margin: 0;
color: #bbb; padding: 10px 12px;
} }
.history-error { .detail-source {
color: #ff8d8d !important; justify-content: space-between;
min-height: 34px;
padding: 4px 8px;
border-bottom: 1px solid #666;
background-color: #14241b;
background-image: var(--sammo-texture-green);
} }
.history-list { .detail-grid {
display: grid; display: grid;
gap: 5px; grid-template-columns: repeat(2, minmax(0, 1fr));
margin: 0; }
padding-left: 26px;
color: #ddd; .detail-grid :deep(.panel-card) {
height: 100%;
border-radius: 0;
box-shadow: none;
}
.detail-grid :deep(.panel-body) {
padding: 0;
}
.detail-grid :deep(.panel-title) {
color: skyblue;
font-size: 18px;
font-weight: 500;
}
.archive-unavailable {
min-height: 28px;
padding: 5px 8px;
border-top: 1px solid #666;
color: #bbb;
text-align: center;
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.title-row, .title-row,
.season-heading { .season-heading,
.season-identity,
.season-meta {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;
} }
.season-actions { .season-meta {
align-items: flex-start; justify-content: flex-start;
flex-direction: column; }
.detail-grid {
grid-template-columns: 1fr;
} }
} }
</style> </style>
@@ -133,6 +133,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
kakaoGraceStartedAt: now.toISOString(), kakaoGraceStartedAt: now.toISOString(),
passwordSalt: password.salt, passwordSalt: password.salt,
passwordHash: password.hash, passwordHash: password.hash,
passwordResetRequired: false,
createdAt: now.toISOString(), createdAt: now.toISOString(),
}; };
usersByName.set(input.username, user); usersByName.set(input.username, user);
@@ -150,6 +151,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
const upgraded = await hasher.hash(password); const upgraded = await hasher.hash(password);
user.passwordSalt = upgraded.salt; user.passwordSalt = upgraded.salt;
user.passwordHash = upgraded.hash; user.passwordHash = upgraded.hash;
user.passwordResetRequired = false;
} }
return verified.ok; return verified.ok;
}, },
@@ -159,6 +161,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
const next = await hasher.hash(password); const next = await hasher.hash(password);
user.passwordSalt = next.salt; user.passwordSalt = next.salt;
user.passwordHash = next.hash; user.passwordHash = next.hash;
user.passwordResetRequired = false;
return; return;
} }
} }
+11 -6
View File
@@ -35,7 +35,9 @@ export const hasActiveSpecialAccountGrant = (
grants: readonly SpecialAccountAccessGrantRecord[], grants: readonly SpecialAccountAccessGrantRecord[],
now: Date = new Date() now: Date = new Date()
): boolean => ): 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 => const appliesToProfile = (grant: SpecialAccountAccessGrantRecord, profile: string, profileName: string): boolean =>
grant.profiles.length === 0 || grant.profiles.includes(profile) || grant.profiles.includes(profileName); 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 selected = active.find((grant) => grant.allowsGeneralCreation) ?? active[0]!;
const expiresAt = active.some((grant) => !grant.expiresAt) const expiresAt = active.some((grant) => !grant.expiresAt)
? null ? null
: active : (active.map((grant) => grant.expiresAt!).sort((left, right) => right.localeCompare(left))[0] ?? null);
.map((grant) => grant.expiresAt!)
.sort((left, right) => right.localeCompare(left))[0] ?? null;
return { return {
kind: selected.kind, kind: selected.kind,
grantId: selected.id, grantId: selected.id,
@@ -98,7 +98,10 @@ export const resolveLocalAccountProfilePolicy = (options: {
'localAccountGeneralCreationGraceDays', 'localAccountGeneralCreationGraceDays',
generalCreationDefault 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 graceStartedAt = new Date(options.user.kakaoGraceStartedAt);
const now = options.now ?? new Date(); const now = options.now ?? new Date();
const specialAccess = resolveSpecialAccess({ const specialAccess = resolveSpecialAccess({
@@ -116,7 +119,9 @@ export const resolveLocalAccountProfilePolicy = (options: {
const generalCreationEndsAt = new Date(graceStartedAt.getTime() + generalCreationGraceDays * DAY_MS); const generalCreationEndsAt = new Date(graceStartedAt.getTime() + generalCreationGraceDays * DAY_MS);
const accessAllowed = kakaoVerified || specialAccess !== null || now < accessEndsAt; const accessAllowed = kakaoVerified || specialAccess !== null || now < accessEndsAt;
const canCreateGeneral = const canCreateGeneral =
kakaoVerified || specialAccess?.allowsGeneralCreation === true || (accessAllowed && now < generalCreationEndsAt); kakaoVerified ||
specialAccess?.allowsGeneralCreation === true ||
(accessAllowed && now < generalCreationEndsAt);
return { return {
requiresKakaoVerification: !kakaoVerified && specialAccess === null, requiresKakaoVerification: !kakaoVerified && specialAccess === null,
+11 -4
View File
@@ -14,7 +14,7 @@ export interface OAuthPendingState {
export interface OAuthSession { export interface OAuthSession {
id: string; id: string;
mode: OAuthMode; mode: OAuthMode;
intent?: 'register' | 'link_existing' | 'rejoin'; intent?: 'register' | 'link_existing' | 'rejoin' | 'password_setup';
targetUserId?: string; targetUserId?: string;
kakaoId: string; kakaoId: string;
email: string; email: string;
@@ -93,6 +93,14 @@ end
return cjson.encode({ status = 'verified', userId = challenge.userId }) 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 { export class RedisOAuthSessionStore implements OAuthSessionStore {
private readonly client: RedisClientLike; private readonly client: RedisClientLike;
private readonly prefix: string; private readonly prefix: string;
@@ -161,12 +169,11 @@ export class RedisOAuthSessionStore implements OAuthSessionStore {
async consumeSession(sessionId: string): Promise<OAuthSession | null> { async consumeSession(sessionId: string): Promise<OAuthSession | null> {
const key = this.sessionKey(sessionId); const key = this.sessionKey(sessionId);
const raw = await this.client.get(key); const raw = await this.client.eval(consumeOnceScript, { keys: [key], arguments: [] });
if (!raw) { if (!raw) {
return null; return null;
} }
await this.client.del(key); return typeof raw === 'string' ? parseJson<OAuthSession>(raw) : null;
return parseJson<OAuthSession>(raw);
} }
async getLoginChallengeForUser(userId: string): Promise<KakaoLoginChallenge | null> { async getLoginChallengeForUser(userId: string): Promise<KakaoLoginChallenge | null> {
@@ -59,6 +59,7 @@ const mapUser = (row: {
displayName: string; displayName: string;
passwordHash: string; passwordHash: string;
passwordSalt: string; passwordSalt: string;
passwordResetRequired: boolean;
roles: GatewayPrisma.JsonValue; roles: GatewayPrisma.JsonValue;
sanctions: GatewayPrisma.JsonValue; sanctions: GatewayPrisma.JsonValue;
oauthType: 'NONE' | 'KAKAO'; oauthType: 'NONE' | 'KAKAO';
@@ -107,6 +108,7 @@ const mapUser = (row: {
deleteAfter: row.deleteAfter?.toISOString(), deleteAfter: row.deleteAfter?.toISOString(),
passwordHash: row.passwordHash, passwordHash: row.passwordHash,
passwordSalt: row.passwordSalt, passwordSalt: row.passwordSalt,
passwordResetRequired: row.passwordResetRequired,
createdAt: row.createdAt.toISOString(), createdAt: row.createdAt.toISOString(),
legacyMemberNo: readLegacyMemberNo(row.legacyData), legacyMemberNo: readLegacyMemberNo(row.legacyData),
legacyGrade: readLegacyGrade(row.legacyData), legacyGrade: readLegacyGrade(row.legacyData),
@@ -250,6 +252,7 @@ export const createPostgresUserRepository = (
displayName: input.displayName ?? input.username, displayName: input.displayName ?? input.username,
passwordHash: password.hash, passwordHash: password.hash,
passwordSalt: password.salt, passwordSalt: password.salt,
passwordResetRequired: false,
roles: ['user'] satisfies GatewayPrisma.JsonArray, roles: ['user'] satisfies GatewayPrisma.JsonArray,
sanctions: {} satisfies GatewayPrisma.JsonObject, sanctions: {} satisfies GatewayPrisma.JsonObject,
oauthType, oauthType,
@@ -274,10 +277,12 @@ export const createPostgresUserRepository = (
data: { data: {
passwordHash: upgraded.hash, passwordHash: upgraded.hash,
passwordSalt: upgraded.salt, passwordSalt: upgraded.salt,
passwordResetRequired: false,
}, },
}); });
user.passwordHash = upgraded.hash; user.passwordHash = upgraded.hash;
user.passwordSalt = upgraded.salt; user.passwordSalt = upgraded.salt;
user.passwordResetRequired = false;
} }
return verified.ok; return verified.ok;
}, },
@@ -288,6 +293,7 @@ export const createPostgresUserRepository = (
data: { data: {
passwordHash: next.hash, passwordHash: next.hash,
passwordSalt: next.salt, passwordSalt: next.salt,
passwordResetRequired: false,
}, },
}); });
}, },
+2 -1
View File
@@ -24,6 +24,7 @@ export interface UserRecord {
deleteAfter?: string; deleteAfter?: string;
passwordHash: string; passwordHash: string;
passwordSalt: string; passwordSalt: string;
passwordResetRequired: boolean;
createdAt: string; createdAt: string;
legacyMemberNo?: number; legacyMemberNo?: number;
legacyGrade?: number; legacyGrade?: number;
@@ -128,7 +129,7 @@ export const toPublicUser = (user: UserRecord): PublicUser => ({
displayName: user.displayName, displayName: user.displayName,
roles: user.roles, roles: user.roles,
picture: user.picture, picture: user.picture,
kakaoVerified: user.oauthType === 'KAKAO' && Boolean(user.kakaoVerifiedAt), kakaoVerified: user.oauthType === 'KAKAO' && Boolean(user.oauthId?.trim()) && Boolean(user.kakaoVerifiedAt),
kakaoGraceStartedAt: user.kakaoGraceStartedAt, kakaoGraceStartedAt: user.kakaoGraceStartedAt,
createdAt: user.createdAt, createdAt: user.createdAt,
}); });
+102 -3
View File
@@ -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({ export const appRouter = router({
health: router({ health: router({
ping: procedure.query(() => ({ ping: procedure.query(() => ({
@@ -348,7 +384,7 @@ export const appRouter = router({
} }
const refreshed = (await ctx.users.findById(verified.id)) ?? verified; const refreshed = (await ctx.users.findById(verified.id)) ?? verified;
await ctx.flushPublisher.publishUserFlush(refreshed.id, 'kakao-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') { if (pending.mode === 'change_pw') {
@@ -415,7 +451,7 @@ export const appRouter = router({
cause: error, cause: error,
}); });
} }
return finishKakaoLogin(ctx, synced, token.accessToken, 'login'); return finishKakaoLoginOrRequestPasswordSetup(ctx, synced, token.accessToken, 'login');
} }
const joinOauthInfo = oauthInfoFromToken(token, tokenIssuedAt); const joinOauthInfo = oauthInfoFromToken(token, tokenIssuedAt);
@@ -562,7 +598,70 @@ export const appRouter = router({
}); });
} }
await ctx.flushPublisher.publishUserFlush(linked.id, 'kakao-account-relinked'); 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 register: procedure
.input( .input(
+114 -2
View File
@@ -631,7 +631,7 @@ describe('gateway auth flow', () => {
}); });
it('asks before relinking a new Kakao identity to the permanently retained email owner', async () => { 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({ const emailOwner = await users.createUser({
username: 'email-owner', username: 'email-owner',
password: 'owner-password', password: 'owner-password',
@@ -642,6 +642,7 @@ describe('gateway auth flow', () => {
info: {}, info: {},
}, },
}); });
emailOwner.passwordResetRequired = true;
await users.markKakaoTalkVerified(emailOwner.id, new Date(Date.now() + 60_000)); await users.markKakaoTalkVerified(emailOwner.id, new Date(Date.now() + 60_000));
kakaoProfile.id = 'different-kakao-id'; kakaoProfile.id = 'different-kakao-id';
@@ -659,7 +660,14 @@ describe('gateway auth flow', () => {
oauthSessionId: recovery.oauthSessionId, oauthSessionId: recovery.oauthSessionId,
action: 'link_existing', 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(sentTalkMessages).toHaveLength(1);
expect(await users.findByOauthId('KAKAO', 'original-kakao-id')).toBeNull(); expect(await users.findByOauthId('KAKAO', 'original-kakao-id')).toBeNull();
expect(await users.findByOauthId('KAKAO', 'different-kakao-id')).toMatchObject({ expect(await users.findByOauthId('KAKAO', 'different-kakao-id')).toMatchObject({
@@ -668,6 +676,108 @@ describe('gateway auth flow', () => {
email: 'tester@example.com', email: 'tester@example.com',
}); });
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'kakao-account-relinked'); 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 () => { 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', username: 'self-service',
password: 'current-password', password: 'current-password',
}); });
user.passwordResetRequired = true;
const session = await sessions.createSession(user); const session = await sessions.createSession(user);
await expect( await expect(
@@ -1165,6 +1276,7 @@ describe('account self service', () => {
const refreshed = await users.findById(user.id); const refreshed = await users.findById(user.id);
expect(refreshed && (await users.verifyPassword(refreshed, 'next-password'))).toBe(true); 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 () => { it('revokes the session and schedules deletion after 30 days', async () => {
@@ -16,6 +16,7 @@ const buildLocalUser = (graceStartedAt: Date): UserRecord => ({
kakaoGraceStartedAt: graceStartedAt.toISOString(), kakaoGraceStartedAt: graceStartedAt.toISOString(),
passwordHash: 'unused', passwordHash: 'unused',
passwordSalt: '', passwordSalt: '',
passwordResetRequired: false,
createdAt: graceStartedAt.toISOString(), 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', () => { it('extends account access with an administrator override without widening general creation grace', () => {
const user = buildLocalUser(new Date('2026-07-20T00:00:00.000Z')); const user = buildLocalUser(new Date('2026-07-20T00:00:00.000Z'));
user.kakaoGraceUntil = '2026-08-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); 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, id: session.id,
intent: 'link_existing', intent: 'link_existing',
targetUserId, targetUserId,
email: 'retained@example.test', email: 'retained@example.test',
}); });
await expect(store.consumeSession(session.id)).resolves.toBeNull();
}); });
it('atomically consumes a successful code once', async () => { it('atomically consumes a successful code once', async () => {
@@ -34,10 +34,12 @@ describe('password credential compatibility', () => {
}); });
user.passwordSalt = 'core-salt'; user.passwordSalt = 'core-salt';
user.passwordHash = createHash('sha256').update('core-salt:current-password').digest('hex'); user.passwordHash = createHash('sha256').update('core-salt:current-password').digest('hex');
user.passwordResetRequired = true;
expect(await users.verifyPassword(user, 'current-password')).toBe(true); expect(await users.verifyPassword(user, 'current-password')).toBe(true);
expect(user.passwordHash.startsWith('$argon2id$')).toBe(true); expect(user.passwordHash.startsWith('$argon2id$')).toBe(true);
expect(user.passwordSalt).toBe(''); expect(user.passwordSalt).toBe('');
expect(user.passwordResetRequired).toBe(false);
}); });
it('upgrades an imported ref double-SHA-512 credential after a successful login', async () => { 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'); const browserHash = createHash('sha512').update(`${globalSalt}current-password${globalSalt}`).digest('hex');
user.passwordSalt = userSalt; user.passwordSalt = userSalt;
user.passwordHash = createHash('sha512').update(`${userSalt}${browserHash}${userSalt}`).digest('hex'); user.passwordHash = createHash('sha512').update(`${userSalt}${browserHash}${userSalt}`).digest('hex');
user.passwordResetRequired = true;
expect(await users.verifyPassword(user, 'current-password')).toBe(true); expect(await users.verifyPassword(user, 'current-password')).toBe(true);
expect(user.passwordHash.startsWith('$argon2id$')).toBe(true); expect(user.passwordHash.startsWith('$argon2id$')).toBe(true);
expect(user.passwordSalt).toBe(''); expect(user.passwordSalt).toBe('');
expect(user.passwordResetRequired).toBe(false);
}); });
it('does not accept an imported ref credential without the matching global salt', async () => { it('does not accept an imported ref credential without the matching global salt', async () => {
+3 -7
View File
@@ -38,8 +38,8 @@ describe('readReleaseManifest', () => {
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
gatewaySchemaHead: '20260817000000_remove_profile_manage_capability', gatewaySchemaHead: '20260817001000_remove_profile_manage_capability',
gameSchemaHead: '20260817000000_add_general_access_batch', 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 () => { it('allows only the explicit controller self-upgrade boundary to cross protocol versions', async () => {
const futureProtocol = RELEASE_CONTROLLER_PROTOCOL + 1; const futureProtocol = RELEASE_CONTROLLER_PROTOCOL + 1;
const workspace = await createWorkspace( const workspace = await createWorkspace('20260801000000_gateway', '20260801000000_game', futureProtocol);
'20260801000000_gateway',
'20260801000000_game',
futureProtocol
);
await expect(readReleaseManifest(workspace)).rejects.toThrow( await expect(readReleaseManifest(workspace)).rejects.toThrow(
`Release requires controller protocol ${futureProtocol}` `Release requires controller protocol ${futureProtocol}`
@@ -1,3 +1,5 @@
import { generateKeyPairSync } from 'node:crypto';
import { expect, test, type Page, type Route } from '@playwright/test'; import { expect, test, type Page, type Route } from '@playwright/test';
const response = (data: unknown) => ({ result: { data } }); 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(','); 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 installFixture = async (page: Page, action: 'link_existing' | 'rejoin') => {
const calls: string[] = []; const calls: string[] = [];
await page.route('**/gateway/api/trpc/**', async (route) => { 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(calls.filter((operation) => operation === 'auth.kakaoResolveAccount')).toHaveLength(1);
expect(geometry.width).toBe(viewport.name === 'desktop' ? 698 : 372); 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(tablist.getByRole('tab', { name: '퀘섭' })).toHaveCount(0);
await expect(panel).toHaveCount(1); await expect(panel).toHaveCount(1);
await expect(cheTab).toHaveAttribute('aria-selected', 'true'); 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 roadLayer = panel.getByTestId('map-preview-road');
const castles = panel.getByTestId('map-preview-castle'); 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 }, largeCastle: { width: 32, height: 24 },
largeNationBackground: { width: 96, height: 72 }, 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) { if (requestedAssets) {
await expect.poll(() => requestedAssets.has('/game/map/che/che_road.png')).toBe(true); await expect.poll(() => requestedAssets.has('/game/map/che/che_road.png')).toBe(true);
await expect.poll(() => requestedAssets.has('/game/cast_8.gif')).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 hweTab.hover();
await expect(hweTab).toHaveAttribute('aria-selected', 'true'); 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 cheTab.click();
await expect(cheTab).toHaveAttribute('aria-selected', 'true'); 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 page.screenshot({ path: testInfo.outputPath('public-map-tabs-desktop.png'), fullPage: true });
await testInfo.attach('public-map-tabs-desktop-geometry', { await testInfo.attach('public-map-tabs-desktop-geometry', {
body: Buffer.from( 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', contentType: 'application/json',
}); });
@@ -344,13 +378,20 @@ test.describe('touch navigation', () => {
await hweTab.tap(); await hweTab.tap();
await expect(hweTab).toHaveAttribute('aria-selected', 'true'); await expect(hweTab).toHaveAttribute('aria-selected', 'true');
const panel = page.getByTestId('public-map-preview-panel'); 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-road')).toBeVisible();
await expect(panel.getByTestId('map-preview-castle')).toHaveCount(2); await expect(panel.getByTestId('map-preview-castle')).toHaveCount(2);
const mapBox = await panel.locator('.map-preview-body').boundingBox(); const mapBox = await panel.locator('.map-preview-body').boundingBox();
expect(mapBox?.width).toBeGreaterThan(280); expect(mapBox?.width).toBeGreaterThan(280);
expect(mapBox?.width).toBeLessThanOrEqual(366); expect(mapBox?.width).toBeLessThanOrEqual(366);
expect(mapBox?.height).toBeCloseTo((mapBox?.width ?? 0) * (5 / 7), 0); 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); const rightCity = panel.getByTestId('map-preview-city').nth(1);
await rightCity.hover(); await rightCity.hover();
const tooltip = panel.getByTestId('map-preview-city-tooltip'); const tooltip = panel.getByTestId('map-preview-city-tooltip');
@@ -276,8 +276,7 @@ const stateClass = (state: number): string => {
<template> <template>
<div class="map-preview" :class="`map-preview-${props.mode}`"> <div class="map-preview" :class="`map-preview-${props.mode}`">
<div class="map-preview-header"> <div class="map-preview-date-bar">
<span class="map-preview-title">{{ props.mapLayout.mapName }}</span>
<span class="map-preview-date">{{ props.mapData.year }} {{ props.mapData.month }}</span> <span class="map-preview-date">{{ props.mapData.year }} {{ props.mapData.month }}</span>
</div> </div>
<div ref="mapBody" class="map-preview-body" :style="{ backgroundImage: `url('${mapBackground}')` }"> <div ref="mapBody" class="map-preview-body" :style="{ backgroundImage: `url('${mapBackground}')` }">
@@ -358,18 +357,22 @@ const stateClass = (state: number): string => {
width: min(100%, 700px); width: min(100%, 700px);
margin-inline: auto; margin-inline: auto;
flex-direction: column; flex-direction: column;
gap: 6px;
} }
.map-preview-header { .map-preview-date-bar {
display: flex; display: flex;
justify-content: space-between; height: 20px;
font-size: 0.7rem; justify-content: center;
color: rgba(232, 221, 196, 0.7); background: #000;
color: rgba(232, 221, 196, 0.82);
} }
.map-preview-title { .map-preview-date {
font-weight: 600; display: block;
width: 160px;
font-size: 14px;
line-height: 20px;
text-align: center;
} }
.map-preview-body { .map-preview-body {
+31 -11
View File
@@ -700,12 +700,6 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
class="map-preview-panel" class="map-preview-panel"
data-testid="public-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"> <div v-if="selectedMapPreview">
<MapPreview <MapPreview
:map-data="selectedMapPreview.mapData" :map-data="selectedMapPreview.mapData"
@@ -714,12 +708,18 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
/> />
<div <div
v-if="profileDetails[selectedMapProfile.profileName]" 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 ?? '-' }} / <span class="map-preview-runtime-status" :style="{ color: selectedMapProfile.color }">
{{ profileDetails[selectedMapProfile.profileName]?.maxUserCnt ?? '-' }} · {{ selectedMapProfile.status }}
{{ profileDetails[selectedMapProfile.profileName]?.nationCnt ?? '-' }}국 · </span>
{{ profileDetails[selectedMapProfile.profileName]?.turnTerm ?? '-' }}분 턴 <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> </div>
<div v-else class="text-xs text-zinc-500 py-8 text-center">지도를 불러오는 중...</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; 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:hover,
.legacy-logout-button:focus, .legacy-logout-button:focus,
.legacy-logout-button:active { .legacy-logout-button:active {
@@ -14,6 +14,8 @@ const submitting = ref(false);
const errorMessage = ref(''); const errorMessage = ref('');
const infoMessage = ref(''); const infoMessage = ref('');
const oauthSessionId = ref(''); const oauthSessionId = ref('');
const passwordSetupSessionId = ref('');
const passwordSetupSuccessStatus = ref<'login' | 'verified'>('login');
const email = ref(''); const email = ref('');
const username = ref(''); const username = ref('');
const password = ref(''); const password = ref('');
@@ -60,6 +62,12 @@ const completeExchange = async (): Promise<void> => {
infoMessage.value = '카카오톡으로 임시 비밀번호를 보냈습니다.'; infoMessage.value = '카카오톡으로 임시 비밀번호를 보냈습니다.';
return; return;
} }
if (result.status === 'password_setup') {
passwordSetupSessionId.value = result.oauthSessionId;
passwordSetupSuccessStatus.value = result.successStatus;
email.value = result.email;
return;
}
if (result.status === 'account_recovery') { if (result.status === 'account_recovery') {
accountRecovery.value = result; accountRecovery.value = result;
email.value = result.email; email.value = result.email;
@@ -94,6 +102,12 @@ const resolveAccount = async (): Promise<void> => {
await router.replace('/lobby'); await router.replace('/lobby');
return; return;
} }
if (result.status === 'password_setup') {
passwordSetupSessionId.value = result.oauthSessionId;
passwordSetupSuccessStatus.value = result.successStatus;
email.value = result.email;
return;
}
oauthSessionId.value = result.oauthSessionId; oauthSessionId.value = result.oauthSessionId;
email.value = result.email; email.value = result.email;
} catch (error) { } 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> => { const register = async (): Promise<void> => {
errorMessage.value = ''; errorMessage.value = '';
if (password.value !== confirmPassword.value) { if (password.value !== confirmPassword.value) {
@@ -156,7 +200,15 @@ onMounted(() => {
<main id="oauth-container"> <main id="oauth-container">
<h1>삼국지 모의전투 HiDCHe</h1> <h1>삼국지 모의전투 HiDCHe</h1>
<section class="oauth-card"> <section class="oauth-card">
<h2>{{ accountRecovery ? '카카오 계정 연결 확인' : '회원가입' }}</h2> <h2>
{{
accountRecovery
? '카카오 계정 연결 확인'
: passwordSetupSessionId
? '새 비밀번호 설정'
: '회원가입'
}}
</h2>
<p v-if="loading" class="oauth-message">카카오 인증을 확인하는 중...</p> <p v-if="loading" class="oauth-message">카카오 인증을 확인하는 중...</p>
<p v-else-if="infoMessage" class="oauth-message" role="status">{{ infoMessage }}</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="카카오 계정 연결 확인"> <div v-else-if="accountRecovery" class="recovery-panel" role="group" aria-label="카카오 계정 연결 확인">
@@ -181,6 +233,46 @@ onMounted(() => {
<RouterLink class="back-link" to="/">취소</RouterLink> <RouterLink class="back-link" to="/">취소</RouterLink>
</div> </div>
</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"> <form v-else-if="oauthSessionId" @submit.prevent="register">
<div class="form-row"> <div class="form-row">
<label for="oauth-email">카카오 이메일</label> <label for="oauth-email">카카오 이메일</label>
+80 -37
View File
@@ -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 uses a stable legacy key with `ON CONFLICT`, so an interrupted run is
repeatable. 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 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 that happens to exist in a dump. Tables outside that schema remain only in the
recovery dump. 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 that UUID, so references such as `ng_old_generals.owner` remain stable even
when an old account was deleted before the dump. 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 `token_valid_until` is copied to `kakao_talk_verified_until`, preserving the
remaining KakaoTalk ownership-proof interval instead of forcing an immediate 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 `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 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 Every imported 128-hex legacy password is marked `password_reset_required`.
`GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT`; a successful login upgrades the stored The dump contains the per-user salt but not Ref's installation-wide salt, so
value to Argon2id. A test-only account can instead be reset with the CLI and a the dump alone cannot validate the old plaintext password. If the original
mode-0600 password file: `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 ```sh
GATEWAY_DATABASE_URL=... pnpm migrate:legacy -- \ GATEWAY_DATABASE_URL=... pnpm migrate:legacy -- \
@@ -50,24 +65,43 @@ The password is never accepted as an argument or printed.
### Game profiles ### Game profiles
| Legacy table | Target | Policy | | Legacy table | Dedicated target | Policy |
| ------------------------------- | ------------------------ | --------------------------------------------------------------------- | | ------------------------------- | ----------------------------- | --------------------------------------------------------------------- |
| `ng_games` | `ng_games` | Preserve completed season metadata | | `ng_games` | `legacy_archive.game_history` | Preserve source profile, opening date, scenario and raw environment |
| `hall` | `hall` | Preserve hall-of-fame rows | | `hall` | `legacy_archive.hall` | Preserve hall-of-fame rows without mixing current records |
| `ng_old_generals` | `ng_old_generals` | Preserve full JSON snapshots and owner | | `ng_old_generals` | `legacy_archive.general` | Preserve canonical V1 plus private raw JSON and owner |
| `ng_old_nations` | `ng_old_nations` | Preserve all versions, including duplicate server/nation pairs | | `ng_old_nations` | `legacy_archive.nation` | Preserve all versions with profile and legacy primary key |
| `emperior` | `emperior` | Preserve dynasty detail and legacy key | | `emperior` | `legacy_archive.emperor` | Preserve dynasty detail under a central archive ID |
| `inheritance_result` | `inheritance_result` | Preserve result JSON/string and legacy key | | `inheritance_result` | `inheritance_result` | Preserve result JSON/string and legacy key |
| `user_record` | `inheritance_log` | Preserve complete long-lived user record | | `user_record` | `inheritance_log` | Preserve complete long-lived user record |
| persistent `storage` namespaces | `legacy_game_storage` | Preserve raw `inheritance_*` and `user_*` rows before projection | | 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: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 | | `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 | | `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 The source contains legitimate duplicate `(server_id, nation)` old-nation rows
and `(server_id, year, month)` history rows. `source_id` is consequently part of 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 their current-schema archive keys, while the dedicated legacy archive uses
rows use the legacy primary key. This avoids a lossy last-row-wins upsert. `(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 Current-season actor/world/queue/lock/message/market/vote state is explicitly
excluded. In particular, `general`, `city`, `nation`, their turn queues, 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 archive owner from the game session and never accepts an owner ID from the
browser. browser.
- `archive.myPastPlays` combines the owner's `ng_old_generals` rows with - `archive.myPastPlays` reads the central legacy archive across profiles and
`ng_games`, the latest matching `ng_old_nations` snapshot and an optional current runtime archives, tags each source, and suppresses a current-schema
`emperior` row. It returns summary fields and a link target for the existing duplicate when the central legacy copy exists.
public dynasty/nation detail. - `archive.myPastPlayDetail(source, sourceProfile, serverId, generalNo)` includes
- `archive.myPastPlayDetail(serverId, generalNo)` includes the session owner in the session owner in the database predicate. A foreign or missing record uses
the database predicate before returning `data.history`. A foreign or missing the same not-found response.
record uses the same not-found response. - The detail DTO feeds the same `GeneralBasicCard`, battle summary,
- Legacy `data.history` may be either an array or a `<br>`-joined string. The API `LegacyGeneralProgress`, and record panels used by My Page/Battle Center.
normalizes both to a newest-first string array, and the frontend renders plain Missing battle/mastery/log channels show an explicit not-preserved state.
text rather than archived markup. - 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 - Runtime death and unification archival writes the current general
`GENERAL/HISTORY` rows into the same `data.history` field, so newly completed `GENERAL/HISTORY` rows into the same `data.history` field, so newly completed
seasons remain compatible with imported rows. 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 ## Cutover procedure
1. Keep the original compressed dumps immutable and restore each source to a 1. Keep the original compressed dumps immutable and restore each source to a
private MariaDB instance. private MariaDB instance.
2. Deploy the gateway and game Prisma migrations to empty staging databases. 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. counts and excluded-table reasons.
4. Compare source counts, malformed JSON checks and duplicate natural-key 4. Compare source counts, malformed JSON checks and duplicate natural-key
counts. Stop on unexplained drift. counts. Stop on unexplained drift.
5. Put the affected target in maintenance mode, take a PostgreSQL backup, then 5. Put the affected target in maintenance mode, take a PostgreSQL backup, then
run the same commands with `--apply`. run the same commands with `--apply`.
6. Repeat each apply. Counts must remain unchanged. 6. Repeat each apply. Counts must remain unchanged; verify the newest
7. Verify Kakao migration timestamps including `kakao_talk_verified_until`, password-hash shapes, archive ownership, `legacy_archive.import_run` is `COMPLETED` and current Gateway credentials
old-nation/history duplicate preservation, `/past-plays` list/detail access, are unchanged.
foreign-owner denial and the dynasty link. 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 8. Retain the MariaDB dumps as rollback evidence. Rollback restores the
pre-cutover PostgreSQL backup; it does not reverse individual importer pre-cutover PostgreSQL backup; it does not reverse individual importer
upserts. upserts.
+1
View File
@@ -25,6 +25,7 @@ export * from './ranking/types.js';
export * from './ranking/legacyColor.js'; export * from './ranking/legacyColor.js';
export * from './auth/accountIconProjection.js'; export * from './auth/accountIconProjection.js';
export * from './logging/formatLegacyLogHtml.js'; export * from './logging/formatLegacyLogHtml.js';
export * from './legacyArchive/ArchivedGeneralSnapshot.js';
export * from './gateway/profileStatus.js'; export * from './gateway/profileStatus.js';
export * from './game/accessPenalty.js'; export * from './game/accessPenalty.js';
export * from './http/trpcTransport.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,
},
},
};
};
@@ -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}$';
+1
View File
@@ -82,6 +82,7 @@ model AppUser {
displayName String @unique @map("display_name") displayName String @unique @map("display_name")
passwordHash String @map("password_hash") passwordHash String @map("password_hash")
passwordSalt String @map("password_salt") passwordSalt String @map("password_salt")
passwordResetRequired Boolean @default(false) @map("password_reset_required")
roles Json @default(dbgenerated("'[]'::jsonb")) roles Json @default(dbgenerated("'[]'::jsonb"))
sanctions Json @default(dbgenerated("'{}'::jsonb")) sanctions Json @default(dbgenerated("'{}'::jsonb"))
oauthType OAuthType @default(NONE) @map("oauth_type") oauthType OAuthType @default(NONE) @map("oauth_type")
@@ -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");
+3
View File
@@ -535,6 +535,9 @@ importers:
tools/legacy-db-migration: tools/legacy-db-migration:
dependencies: dependencies:
'@sammo-ts/common':
specifier: workspace:*
version: link:../../packages/common
mariadb: mariadb:
specifier: 3.5.3 specifier: 3.5.3
version: 3.5.3 version: 3.5.3
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"formatVersion": 1, "formatVersion": 1,
"controllerProtocol": 2, "controllerProtocol": 2,
"gatewaySchemaHead": "20260817000000_remove_profile_manage_capability", "gatewaySchemaHead": "20260817001000_remove_profile_manage_capability",
"gameSchemaHead": "20260817000000_add_general_access_batch", "gameSchemaHead": "20260817001000_add_dedicated_legacy_archive",
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
} }
+20 -8
View File
@@ -5,9 +5,10 @@ into the core2026 PostgreSQL schemas. It is CLI-only; no HTTP or administrator
route invokes it. route invokes it.
The default mode is a read-only dry-run. `--apply` is required before any target 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. PostgreSQL advisory locks prevent two applies for the same target.
write uses a stable legacy key and `ON CONFLICT`, so a completed or interrupted Gateway writes are transactional. Game archive writes and their completed
run can be repeated. `legacy_archive.import_run` record are transactional. Stable legacy keys make
completed or interrupted runs repeatable.
## Source restore ## 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 After reviewing the JSON counts and excluded-table reasons, add
`GATEWAY_DATABASE_URL` or `GAME_DATABASE_URL` and repeat with `--apply`. `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 ### Isolated current-season comparison fixture
`current-season-fixture` is separate from the long-lived archive migration. It `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 comparison, not proof that the two engines consume RNG identically after the
next turn. next turn.
Kakao members retain their OAuth ID, email, and OAuth metadata. Kakao members retain their OAuth ID, email, and OAuth metadata. Only a row with
`kakao_verified_at` and `kakao_grace_started_at` are set to the migration time. 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 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. 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 Imported 128-hex password hashes are marked for reset. They can be upgraded to
the first successful login when Argon2id after the first successful login only when the DB-external
`GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT` is configured in gateway-api. `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 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 in a dump, such as an old root `config` table, are left in the recovery dump and
+1
View File
@@ -15,6 +15,7 @@
"migrate": "tsx src/cli.ts" "migrate": "tsx src/cli.ts"
}, },
"dependencies": { "dependencies": {
"@sammo-ts/common": "workspace:*",
"mariadb": "3.5.3", "mariadb": "3.5.3",
"pg": "^8.16.3" "pg": "^8.16.3"
}, },
+13 -3
View File
@@ -5,7 +5,7 @@ import path from 'node:path';
import process from 'node:process'; import process from 'node:process';
import { createMariaPool, createPostgresPool } from './db.js'; 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 { migrateGateway } from './gateway.js';
import { hashPasswordForReset } from './password.js'; import { hashPasswordForReset } from './password.js';
import { migrateCurrentSeasonFixture } from './currentSeason.js'; import { migrateCurrentSeasonFixture } from './currentSeason.js';
@@ -122,7 +122,10 @@ const resetPassword = async (options: CliOptions): Promise<Record<string, unknow
const hashed = await hashPasswordForReset(password); const hashed = await hashPasswordForReset(password);
await pool.query( await pool.query(
`UPDATE "app_user" `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`, WHERE "id" = $3`,
[hashed.hash, hashed.salt, existing.rows[0]!.id] [hashed.hash, hashed.salt, existing.rows[0]!.id]
); );
@@ -142,7 +145,11 @@ const run = async (): Promise<void> => {
const migratedAt = new Date(); const migratedAt = new Date();
if (options.command === 'gateway') { if (options.command === 'gateway') {
const source = createMariaPool(requireEnvironment('LEGACY_ROOT_DATABASE_URL')); 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 { try {
const summary = await migrateGateway(source, target, options.apply, migratedAt); const summary = await migrateGateway(source, target, options.apply, migratedAt);
console.log(JSON.stringify(summary, null, 2)); 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)) { 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}`); 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 source = createMariaPool(requireEnvironment('LEGACY_GAME_DATABASE_URL'));
const target = const target =
options.apply || options.command === 'current-season-fixture' options.apply || options.command === 'current-season-fixture'
+13 -3
View File
@@ -24,6 +24,14 @@ const quoteIdentifier = (value: string): string => {
return `"${value}"`; 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 createMariaPool = (uri: string): MariaPool => mariadb.createPool(uri);
export const createPostgresPool = (connectionString: string): pg.Pool => { export const createPostgresPool = (connectionString: string): pg.Pool => {
@@ -94,7 +102,8 @@ export const upsertRows = async (
client: PoolClient, client: PoolClient,
table: string, table: string,
rows: readonly TargetRow[], rows: readonly TargetRow[],
conflictColumns: readonly string[] conflictColumns: readonly string[],
options: { preserveOnConflict?: readonly string[] } = {}
): Promise<void> => { ): Promise<void> => {
if (rows.length === 0) { if (rows.length === 0) {
return; return;
@@ -116,12 +125,13 @@ export const upsertRows = async (
}); });
return `(${placeholders.join(', ')})`; return `(${placeholders.join(', ')})`;
}); });
const preserved = new Set(options.preserveOnConflict ?? []);
const updates = columns const updates = columns
.filter((column) => !conflictColumns.includes(column)) .filter((column) => !conflictColumns.includes(column) && !preserved.has(column))
.map((column) => `${quoteIdentifier(column)} = EXCLUDED.${quoteIdentifier(column)}`); .map((column) => `${quoteIdentifier(column)} = EXCLUDED.${quoteIdentifier(column)}`);
const conflictAction = updates.length ? `DO UPDATE SET ${updates.join(', ')}` : 'DO NOTHING'; const conflictAction = updates.length ? `DO UPDATE SET ${updates.join(', ')}` : 'DO NOTHING';
await client.query( await client.query(
`INSERT INTO ${quoteIdentifier(table)} (${columns.map(quoteIdentifier).join(', ')}) `INSERT INTO ${quoteQualifiedIdentifier(table)} (${columns.map(quoteIdentifier).join(', ')})
VALUES ${tuples.join(', ')} VALUES ${tuples.join(', ')}
ON CONFLICT (${conflictColumns.map(quoteIdentifier).join(', ')}) ${conflictAction}`, ON CONFLICT (${conflictColumns.map(quoteIdentifier).join(', ')}) ${conflictAction}`,
values values
+165 -57
View File
@@ -3,6 +3,16 @@ import { createHash } from 'node:crypto';
import type { Pool as MariaPool } from 'mariadb'; import type { Pool as MariaPool } from 'mariadb';
import type { Pool as PgPool, PoolClient } from 'pg'; 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 { import {
paginateSource, paginateSource,
jsonParameter, jsonParameter,
@@ -29,6 +39,12 @@ import {
const batchSize = 250; const batchSize = 250;
interface ArchiveMigrationContext {
profile: LegacyArchiveProfile;
importRunId: string;
sourceFormats: Record<ArchivedGeneralSourceFormat, number>;
}
const parseNullableJson = (value: unknown, fallback: JsonValue, context: string): JsonValue => const parseNullableJson = (value: unknown, fallback: JsonValue, context: string): JsonValue =>
value === null || value === undefined ? fallback : parseJson(value, context); value === null || value === undefined ? fallback : parseJson(value, context);
@@ -43,17 +59,17 @@ const ownerId = (value: unknown): string | null => {
return memberNo > 0 ? legacyUserId(memberNo) : null; return memberNo > 0 ? legacyUserId(memberNo) : null;
}; };
const hashYearbook = (row: TargetRow): string => const hashYearbook = (map: JsonValue, nations: JsonValue, globalHistory: JsonValue, globalAction: JsonValue): string =>
createHash('sha256') createHash('sha256').update(JSON.stringify({ map, nations, globalHistory, globalAction })).digest('hex');
.update(
JSON.stringify({ const asJsonRecord = (value: JsonValue): Record<string, JsonValue> =>
map: row.map, value !== null && !Array.isArray(value) && typeof value === 'object' ? value : {};
nations: row.nations,
globalHistory: row.global_history, export const resolveLegacyGameOpenedAt = (env: JsonValue, legacyDate: Date, context: string): Date => {
globalAction: row.global_action, const record = asJsonRecord(env);
}) const candidate = record.opentime ?? record.starttime;
) return candidate === null || candidate === undefined || candidate === '' ? legacyDate : toDate(candidate, context);
.digest('hex'); };
const migrateSimpleTable = async ( const migrateSimpleTable = async (
source: MariaPool, 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( migrateSimpleTable(
source, source,
target, target,
'hall', 'hall',
'id', 'id',
'hall', 'legacy_archive.hall',
['server_id', 'type', 'general_no'], ['source_profile', 'server_id', 'type', 'general_no'],
(row) => { (row) => {
const sourceId = toNumber(row.id, 'hall.id'); const sourceId = toNumber(row.id, 'hall.id');
return { return {
source_profile: archive.profile,
legacy_id: sourceId,
server_id: toStringValue(row.server_id, `hall.${sourceId}.server_id`), server_id: toStringValue(row.server_id, `hall.${sourceId}.server_id`),
season: toNumber(row.season, `hall.${sourceId}.season`), season: toNumber(row.season, `hall.${sourceId}.season`),
scenario: toNumber(row.scenario, `hall.${sourceId}.scenario`), 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`), value: toFloat(row.value, `hall.${sourceId}.value`),
owner: ownerId(row.owner), owner: ownerId(row.owner),
aux: parseJson(row.aux, `hall.${sourceId}.aux`), aux: parseJson(row.aux, `hall.${sourceId}.aux`),
import_run_id: archive.importRunId,
}; };
}, },
counts 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( migrateSimpleTable(
source, source,
target, target,
'ng_games', 'ng_games',
'id', 'id',
'ng_games', 'legacy_archive.game_history',
['server_id'], ['source_profile', 'server_id'],
(row) => { (row) => {
const sourceId = toNumber(row.id, 'ng_games.id'); 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 { return {
source_profile: archive.profile,
server_id: toStringValue(row.server_id, `ng_games.${sourceId}.server_id`), 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: winner_nation:
row.winner_nation === null row.winner_nation === null
? null ? null
@@ -120,7 +155,8 @@ const migrateGames = (source: MariaPool, target: PoolClient | null, counts: Reco
season: toNumber(row.season, `ng_games.${sourceId}.season`), season: toNumber(row.season, `ng_games.${sourceId}.season`),
scenario: toNumber(row.scenario, `ng_games.${sourceId}.scenario`), scenario: toNumber(row.scenario, `ng_games.${sourceId}.scenario`),
scenario_name: toStringValue(row.scenario_name, `ng_games.${sourceId}.scenario_name`), 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 counts
@@ -129,25 +165,36 @@ const migrateGames = (source: MariaPool, target: PoolClient | null, counts: Reco
const migrateOldGenerals = ( const migrateOldGenerals = (
source: MariaPool, source: MariaPool,
target: PoolClient | null, target: PoolClient | null,
counts: Record<string, number> counts: Record<string, number>,
archive: ArchiveMigrationContext
): Promise<void> => ): Promise<void> =>
migrateSimpleTable( migrateSimpleTable(
source, source,
target, target,
'ng_old_generals', 'ng_old_generals',
'id', 'id',
'ng_old_generals', 'legacy_archive.general',
['server_id', 'general_no'], ['source_profile', 'server_id', 'general_no'],
(row) => { (row) => {
const sourceId = toNumber(row.id, 'ng_old_generals.id'); 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 { return {
source_profile: archive.profile,
server_id: toStringValue(row.server_id, `ng_old_generals.${sourceId}.server_id`), server_id: toStringValue(row.server_id, `ng_old_generals.${sourceId}.server_id`),
general_no: toNumber(row.general_no, `ng_old_generals.${sourceId}.general_no`), general_no: toNumber(row.general_no, `ng_old_generals.${sourceId}.general_no`),
legacy_id: sourceId,
owner: ownerId(row.owner), 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`), last_yearmonth: toNumber(row.last_yearmonth, `ng_old_generals.${sourceId}.last_yearmonth`),
turntime: toDate(row.turntime, `ng_old_generals.${sourceId}.turntime`), 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 counts
@@ -156,41 +203,47 @@ const migrateOldGenerals = (
const migrateOldNations = ( const migrateOldNations = (
source: MariaPool, source: MariaPool,
target: PoolClient | null, target: PoolClient | null,
counts: Record<string, number> counts: Record<string, number>,
archive: ArchiveMigrationContext
): Promise<void> => ): Promise<void> =>
migrateSimpleTable( migrateSimpleTable(
source, source,
target, target,
'ng_old_nations', 'ng_old_nations',
'id', 'id',
'ng_old_nations', 'legacy_archive.nation',
['server_id', 'nation', 'source_id'], ['source_profile', 'legacy_id'],
(row) => { (row) => {
const sourceId = toNumber(row.id, 'ng_old_nations.id'); const sourceId = toNumber(row.id, 'ng_old_nations.id');
return { return {
source_profile: archive.profile,
legacy_id: sourceId,
server_id: toStringValue(row.server_id, `ng_old_nations.${sourceId}.server_id`), server_id: toStringValue(row.server_id, `ng_old_nations.${sourceId}.server_id`),
nation: toNumber(row.nation, `ng_old_nations.${sourceId}.nation`), nation: toNumber(row.nation, `ng_old_nations.${sourceId}.nation`),
source_id: sourceId, data: jsonParameter(parseJson(row.data, `ng_old_nations.${sourceId}.data`)),
data: parseJson(row.data, `ng_old_nations.${sourceId}.data`), archived_at: toDate(row.date, `ng_old_nations.${sourceId}.date`),
date: toDate(row.date, `ng_old_nations.${sourceId}.date`), import_run_id: archive.importRunId,
}; };
}, },
counts 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( migrateSimpleTable(
source, source,
target, target,
'emperior', 'emperior',
'no', 'no',
'emperior', 'legacy_archive.emperor',
['legacy_id'], ['source_profile', 'legacy_id'],
(row) => { (row) => {
const id = toNumber(row.no, 'emperior.no'); const id = toNumber(row.no, 'emperior.no');
return { const data = {
legacy_id: id,
server_id: toNullableString(row.server_id),
phase: toNullableString(row.phase), phase: toNullableString(row.phase),
nation_count: toNullableString(row.nation_count), nation_count: toNullableString(row.nation_count),
nation_name: toNullableString(row.nation_name), 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`), history: parseNullableJson(row.history, [], `emperior.${id}.history`),
aux: parseNullableJson(row.aux, {}, `emperior.${id}.aux`), 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 counts
); );
@@ -295,30 +355,35 @@ const migrateUserRecords = (
const migrateYearbook = async ( const migrateYearbook = async (
source: MariaPool, source: MariaPool,
target: PoolClient | null, target: PoolClient | null,
counts: Record<string, number> counts: Record<string, number>,
archive: ArchiveMigrationContext
): Promise<void> => { ): Promise<void> => {
await migrateSimpleTable( await migrateSimpleTable(
source, source,
target, target,
'ng_history', 'ng_history',
'no', 'no',
'yearbook_history', 'legacy_archive.yearbook',
['profile_name', 'year', 'month', 'source_id'], ['source_profile', 'legacy_id'],
(row) => { (row) => {
const id = toNumber(row.no, 'ng_history.no'); 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 = { const mapped: TargetRow = {
source_profile: archive.profile,
legacy_id: id,
profile_name: toStringValue(row.server_id, `ng_history.${id}.server_id`), profile_name: toStringValue(row.server_id, `ng_history.${id}.server_id`),
source_id: id,
year: toNumber(row.year, `ng_history.${id}.year`), year: toNumber(row.year, `ng_history.${id}.year`),
month: toNumber(row.month, `ng_history.${id}.month`), month: toNumber(row.month, `ng_history.${id}.month`),
map: parseNullableJson(row.map, {}, `ng_history.${id}.map`), map: jsonParameter(map),
nations: parseNullableJson(row.nations, [], `ng_history.${id}.nations`), nations: jsonParameter(nations),
global_history: parseNullableJson(row.global_history, [], `ng_history.${id}.global_history`), global_history: jsonParameter(globalHistory),
global_action: parseNullableJson(row.global_action, [], `ng_history.${id}.global_action`), global_action: jsonParameter(globalAction),
hash: '', content_hash: hashYearbook(map, nations, globalHistory, globalAction),
created_at: new Date(0), import_run_id: archive.importRunId,
}; };
mapped.hash = hashYearbook(mapped);
return mapped; return mapped;
}, },
counts, counts,
@@ -388,7 +453,16 @@ export const migrateGame = async (
apply: boolean, apply: boolean,
profile: string profile: string
): Promise<MigrationSummary> => { ): Promise<MigrationSummary> => {
if (!isLegacyArchiveProfile(profile)) {
throw new Error(`Unsupported legacy archive profile: ${profile}`);
}
const counts: Record<string, number> = {}; 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 = { const excluded = {
general: 'Current-season actor state is intentionally not transferred.', general: 'Current-season actor state is intentionally not transferred.',
city: 'Current-season world 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.', 'storage:season-state': 'Only inheritance_* and user_* long-lived namespaces are archived or projected.',
}; };
const client = apply && targetPool ? await targetPool.connect() : null; const client = apply && targetPool ? await targetPool.connect() : null;
let importRunId: string | null = null;
try { try {
const run = async (): Promise<void> => { const run = async (archive: ArchiveMigrationContext): Promise<void> => {
await migrateGames(source, client, counts); await migrateGames(source, client, counts, archive);
await migrateHall(source, client, counts); await migrateHall(source, client, counts, archive);
await migrateOldGenerals(source, client, counts); await migrateOldGenerals(source, client, counts, archive);
await migrateOldNations(source, client, counts); await migrateOldNations(source, client, counts, archive);
await migrateEmperors(source, client, counts); await migrateEmperors(source, client, counts, archive);
await migrateInheritanceResults(source, client, counts); await migrateInheritanceResults(source, client, counts);
await migrateUserRecords(source, client, counts); await migrateUserRecords(source, client, counts);
await migrateStorage(source, client, counts); await migrateStorage(source, client, counts);
await migrateYearbook(source, client, counts); await migrateYearbook(source, client, counts, archive);
}; };
if (client) { 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 { } else {
await run(); await run({ profile, importRunId: '0', sourceFormats });
} }
} finally { } finally {
client?.release(); client?.release();
} }
return { command: 'game', apply, counts, excluded }; return { command: 'game', apply, counts, excluded, importRunId, sourceFormatSummary: sourceFormats };
}; };
+94 -14
View File
@@ -24,17 +24,83 @@ export interface MigrationSummary {
apply: boolean; apply: boolean;
counts: Record<string, number>; counts: Record<string, number>;
excluded: Record<string, string>; excluded: Record<string, string>;
importRunId?: string | null;
sourceFormatSummary?: Record<string, number>;
} }
const batchSize = 500; 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 memberNo = toNumber(row.NO, 'member.NO');
const grade = toNumber(row.GRADE, `member.${memberNo}.GRADE`); const grade = toNumber(row.GRADE, `member.${memberNo}.GRADE`);
const acl = parseJson(row.acl, `member.${memberNo}.acl`); const acl = parseJson(row.acl, `member.${memberNo}.acl`);
const penalty = parseJson(row.penalty, `member.${memberNo}.penalty`); const penalty = parseJson(row.penalty, `member.${memberNo}.penalty`);
const oauthInfo = parseJson(row.oauth_info, `member.${memberNo}.oauth_info`); const oauthInfo = parseJson(row.oauth_info, `member.${memberNo}.oauth_info`);
const oauthType = row.oauth_type === 'KAKAO' ? 'KAKAO' : 'NONE'; 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 = { const legacyData: JsonValue = {
memberNo, memberNo,
grade, grade,
@@ -49,12 +115,13 @@ const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date | null):
id: legacyUserId(memberNo), id: legacyUserId(memberNo),
login_id: toStringValue(row.ID, `member.${memberNo}.ID`).toLowerCase(), login_id: toStringValue(row.ID, `member.${memberNo}.ID`).toLowerCase(),
display_name: toStringValue(row.NAME, `member.${memberNo}.NAME`), 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_salt: toStringValue(row.salt, `member.${memberNo}.salt`),
password_reset_required: /^[a-f0-9]{128}$/i.test(passwordHash),
roles: jsonParameter(mapLegacyRoles(grade, acl)), roles: jsonParameter(mapLegacyRoles(grade, acl)),
sanctions: jsonParameter(mapLegacySanctions(grade, penalty)), sanctions: jsonParameter(mapLegacySanctions(grade, penalty)),
oauth_type: oauthType, oauth_type: oauthType,
oauth_id: toNullableString(row.oauth_id), oauth_id: oauthId,
email: toNullableString(row.EMAIL)?.toLowerCase() ?? null, email: toNullableString(row.EMAIL)?.toLowerCase() ?? null,
oauth_info: jsonParameter(oauthInfo), oauth_info: jsonParameter(oauthInfo),
picture: toNullableString(row.PICTURE) ?? 'default.jpg', 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, third_party_use: toNumber(row.third_use ?? 0, `member.${memberNo}.third_use`) !== 0,
terms_accepted_at: null, terms_accepted_at: null,
privacy_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: kakao_talk_verified_until:
oauthType === 'KAKAO' oauthType === 'KAKAO' && oauthId
? toNullableDate(row.token_valid_until, `member.${memberNo}.token_valid_until`) ? toNullableDate(row.token_valid_until, `member.${memberNo}.token_valid_until`)
: null, : null,
kakao_grace_started_at: migratedAt, kakao_grace_started_at: migratedAt,
@@ -93,6 +160,7 @@ const loadLastLogins = async (source: MariaPool): Promise<Map<number, Date>> =>
const processMembers = async ( const processMembers = async (
source: MariaPool, source: MariaPool,
target: PoolClient | null, target: PoolClient | null,
apply: boolean,
migratedAt: Date, migratedAt: Date,
counts: Record<string, number> counts: Record<string, number>
): Promise<void> => { ): Promise<void> => {
@@ -103,7 +171,10 @@ const processMembers = async (
return mapMember(row, migratedAt, lastLogins.get(memberNo) ?? null); return mapMember(row, migratedAt, lastLogins.get(memberNo) ?? null);
}); });
if (target) { 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; counts.member = (counts.member ?? 0) + mapped.length;
} }
@@ -206,17 +277,26 @@ export const migrateGateway = async (
login_token: login_token:
'Legacy bearer tokens, IP addresses, and expired sessions are not valid in the Redis session model.', '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 { try {
const run = async (): Promise<void> => { const run = async (): Promise<void> => {
await processMembers(source, client, migratedAt, counts); await processMembers(source, client, apply, migratedAt, counts);
await processMemberLogs(source, client, counts); await processMemberLogs(source, apply ? client : null, counts);
await processBannedMembers(source, client, counts); await processBannedMembers(source, apply ? client : null, counts);
await processRootKeyValues(source, client, counts); await processRootKeyValues(source, apply ? client : null, counts);
await processSystem(source, client, counts); await processSystem(source, apply ? client : null, counts);
}; };
if (client) { if (client && apply) {
await withMigrationLock(client, 'sammo-legacy-gateway-v1', run); 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 { } else {
await run(); 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);
});
});
+24
View File
@@ -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"
}
+146
View File
@@ -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"');
});
});