fix(api): resolve yearbooks by generation server id
This commit is contained in:
@@ -63,6 +63,27 @@ const parseYearbookNations = (value: unknown): YearbookNation[] => {
|
|||||||
return output;
|
return output;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveArchiveTarget = (
|
||||||
|
worldMeta: unknown,
|
||||||
|
profileName: string,
|
||||||
|
requestedServerId?: string
|
||||||
|
): { archiveKey: string; legacyAlias: string | null; isCurrentProfile: boolean } => {
|
||||||
|
const rawServerId = asRecord(worldMeta).serverId;
|
||||||
|
const canonicalServerId = typeof rawServerId === 'string' && rawServerId.trim() ? rawServerId.trim() : profileName;
|
||||||
|
const requested = requestedServerId ?? profileName;
|
||||||
|
const isCurrentProfile = requested === profileName || requested === canonicalServerId;
|
||||||
|
return {
|
||||||
|
archiveKey: isCurrentProfile ? canonicalServerId : requested,
|
||||||
|
legacyAlias: isCurrentProfile && canonicalServerId !== profileName ? profileName : null,
|
||||||
|
isCurrentProfile,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeArchivedLogs = (value: unknown, month: number): string[] => {
|
||||||
|
const logs = parseTextArray(value);
|
||||||
|
return logs.length ? logs : [`<C>●</>${month}월: 기록 없음`];
|
||||||
|
};
|
||||||
|
|
||||||
const buildNationSnapshot = async (ctx: GameApiContext) => {
|
const buildNationSnapshot = async (ctx: GameApiContext) => {
|
||||||
const [nationRows, cityRows, generalRows] = await Promise.all([
|
const [nationRows, cityRows, generalRows] = await Promise.all([
|
||||||
ctx.db.nation.findMany({
|
ctx.db.nation.findMany({
|
||||||
@@ -220,22 +241,27 @@ export const yearbookRouter = router({
|
|||||||
message: 'World state is not initialized.',
|
message: 'World state is not initialized.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const targetProfileName = input?.serverID ?? ctx.profile.name;
|
const target = resolveArchiveTarget(worldState.meta, ctx.profile.name, input?.serverID);
|
||||||
const isCurrentProfile = targetProfileName === ctx.profile.name;
|
|
||||||
|
|
||||||
const firstRow = await ctx.db.yearbookHistory.findFirst({
|
const findRange = async (profileName: string) =>
|
||||||
where: { profileName: targetProfileName },
|
Promise.all([
|
||||||
select: { year: true, month: true },
|
ctx.db.yearbookHistory.findFirst({
|
||||||
orderBy: [{ year: 'asc' }, { month: 'asc' }],
|
where: { profileName },
|
||||||
});
|
select: { year: true, month: true },
|
||||||
|
orderBy: [{ year: 'asc' as const }, { month: 'asc' as const }],
|
||||||
|
}),
|
||||||
|
ctx.db.yearbookHistory.findFirst({
|
||||||
|
where: { profileName },
|
||||||
|
select: { year: true, month: true },
|
||||||
|
orderBy: [{ year: 'desc' as const }, { month: 'desc' as const }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
let [firstRow, lastRow] = await findRange(target.archiveKey);
|
||||||
|
if ((!firstRow || !lastRow) && target.legacyAlias) {
|
||||||
|
[firstRow, lastRow] = await findRange(target.legacyAlias);
|
||||||
|
}
|
||||||
|
|
||||||
const lastRow = await ctx.db.yearbookHistory.findFirst({
|
if (!target.isCurrentProfile && (!firstRow || !lastRow)) {
|
||||||
where: { profileName: targetProfileName },
|
|
||||||
select: { year: true, month: true },
|
|
||||||
orderBy: [{ year: 'desc' }, { month: 'desc' }],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!isCurrentProfile && (!firstRow || !lastRow)) {
|
|
||||||
throw new TRPCError({ code: 'NOT_FOUND', message: '연감 범위를 찾을 수 없습니다.' });
|
throw new TRPCError({ code: 'NOT_FOUND', message: '연감 범위를 찾을 수 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +269,7 @@ export const yearbookRouter = router({
|
|||||||
const fallbackYearMonth = currentYearMonth - 1;
|
const fallbackYearMonth = currentYearMonth - 1;
|
||||||
const firstYearMonth = firstRow ? joinYearMonth(firstRow.year, firstRow.month) : fallbackYearMonth;
|
const firstYearMonth = firstRow ? joinYearMonth(firstRow.year, firstRow.month) : fallbackYearMonth;
|
||||||
const lastYearMonth = lastRow ? joinYearMonth(lastRow.year, lastRow.month) : fallbackYearMonth;
|
const lastYearMonth = lastRow ? joinYearMonth(lastRow.year, lastRow.month) : fallbackYearMonth;
|
||||||
const selectedYearMonth = isCurrentProfile ? currentYearMonth : lastYearMonth;
|
const selectedYearMonth = target.isCurrentProfile ? currentYearMonth : lastYearMonth;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
firstYearMonth,
|
firstYearMonth,
|
||||||
@@ -266,15 +292,16 @@ export const yearbookRouter = router({
|
|||||||
if (!worldState) {
|
if (!worldState) {
|
||||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' });
|
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' });
|
||||||
}
|
}
|
||||||
const targetProfileName = input.serverID ?? ctx.profile.name;
|
const target = resolveArchiveTarget(worldState.meta, ctx.profile.name, input.serverID);
|
||||||
const isCurrentProfile = targetProfileName === ctx.profile.name;
|
const shouldRecordAfterHashCheck = target.isCurrentProfile && Boolean(input.hash);
|
||||||
const shouldRecordAfterHashCheck = isCurrentProfile && Boolean(input.hash);
|
if (target.isCurrentProfile && !shouldRecordAfterHashCheck) {
|
||||||
if (isCurrentProfile && !shouldRecordAfterHashCheck) {
|
|
||||||
await recordHistoryAccess(ctx);
|
await recordHistoryAccess(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCurrent =
|
const isCurrent =
|
||||||
isCurrentProfile && worldState.currentYear === input.year && worldState.currentMonth === input.month;
|
target.isCurrentProfile &&
|
||||||
|
worldState.currentYear === input.year &&
|
||||||
|
worldState.currentMonth === input.month;
|
||||||
|
|
||||||
if (isCurrent) {
|
if (isCurrent) {
|
||||||
const { globalHistory, globalAction } = await buildLogs(ctx, input.year, input.month);
|
const { globalHistory, globalAction } = await buildLogs(ctx, input.year, input.month);
|
||||||
@@ -301,28 +328,23 @@ export const yearbookRouter = router({
|
|||||||
return { notModified: false, hash, data };
|
return { notModified: false, hash, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
const row = await ctx.db.yearbookHistory.findFirst({
|
const findArchivedRow = (profileName: string) =>
|
||||||
where: {
|
ctx.db.yearbookHistory.findFirst({
|
||||||
profileName: targetProfileName,
|
where: { profileName, year: input.year, month: input.month },
|
||||||
year: input.year,
|
orderBy: [{ sourceId: 'desc' as const }, { id: 'desc' as const }],
|
||||||
month: input.month,
|
});
|
||||||
},
|
let row = await findArchivedRow(target.archiveKey);
|
||||||
orderBy: [{ sourceId: 'desc' }, { id: 'desc' }],
|
if (!row && target.legacyAlias) {
|
||||||
});
|
row = await findArchivedRow(target.legacyAlias);
|
||||||
|
}
|
||||||
if (!row) {
|
if (!row) {
|
||||||
throw new TRPCError({ code: 'NOT_FOUND', message: '연감 데이터를 찾을 수 없습니다.' });
|
throw new TRPCError({ code: 'NOT_FOUND', message: '연감 데이터를 찾을 수 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const map = asRecord(row.map) as BaseMapResult;
|
const map = asRecord(row.map) as BaseMapResult;
|
||||||
const nations = parseYearbookNations(row.nations);
|
const nations = parseYearbookNations(row.nations);
|
||||||
const archivedLogs =
|
const globalHistory = normalizeArchivedLogs(row.globalHistory, input.month);
|
||||||
isCurrentProfile && row.sourceId === 0
|
const globalAction = normalizeArchivedLogs(row.globalAction, input.month);
|
||||||
? await buildLogs(ctx, input.year, input.month)
|
|
||||||
: {
|
|
||||||
globalHistory: parseTextArray(row.globalHistory),
|
|
||||||
globalAction: parseTextArray(row.globalAction),
|
|
||||||
};
|
|
||||||
const { globalHistory, globalAction } = archivedLogs;
|
|
||||||
const data = {
|
const data = {
|
||||||
year: input.year,
|
year: input.year,
|
||||||
month: input.month,
|
month: input.month,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const profile: GameProfile = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const archiveServerId = 'hwe_260725_archive';
|
const archiveServerId = 'hwe_260725_archive';
|
||||||
|
const currentServerId = 'che_260731_runtime';
|
||||||
const archiveRows = [
|
const archiveRows = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -44,6 +45,19 @@ const archiveRows = [
|
|||||||
hash: 'archive-2',
|
hash: 'archive-2',
|
||||||
createdAt: new Date('2026-07-25T01:00:00.000Z'),
|
createdAt: new Date('2026-07-25T01:00:00.000Z'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
profileName: currentServerId,
|
||||||
|
sourceId: 0,
|
||||||
|
year: 219,
|
||||||
|
month: 12,
|
||||||
|
map: { year: 219, month: 12, startYear: 190, cityList: [], nationList: [] },
|
||||||
|
nations: [{ id: 1, name: '현재기수국', color: '#00FF00', level: 7, power: 1300, cities: ['낙양'] }],
|
||||||
|
globalHistory: ['저장된 현재 기수 과거 기록'],
|
||||||
|
globalAction: ['저장된 현재 기수 과거 행동'],
|
||||||
|
hash: 'current-archive',
|
||||||
|
createdAt: new Date('2026-07-31T00:00:00.000Z'),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const authFor = (userId: string): GameSessionTokenPayload => ({
|
const authFor = (userId: string): GameSessionTokenPayload => ({
|
||||||
@@ -68,7 +82,7 @@ const buildContext = (auth: GameSessionTokenPayload | null, options: { hasGenera
|
|||||||
options.hasGeneral === false ? null : { id: where.userId === 'owner-a' ? 1 : 2, userId: where.userId },
|
options.hasGeneral === false ? null : { id: where.userId === 'owner-a' ? 1 : 2, userId: where.userId },
|
||||||
},
|
},
|
||||||
worldState: {
|
worldState: {
|
||||||
findFirst: async () => ({ currentYear: 220, currentMonth: 1 }),
|
findFirst: async () => ({ currentYear: 220, currentMonth: 1, meta: { serverId: currentServerId } }),
|
||||||
},
|
},
|
||||||
yearbookHistory: {
|
yearbookHistory: {
|
||||||
findFirst: async (args: {
|
findFirst: async (args: {
|
||||||
@@ -165,4 +179,40 @@ describe('historical yearbook access from dynasty', () => {
|
|||||||
message: '연감 범위를 찾을 수 없습니다.',
|
message: '연감 범위를 찾을 수 없습니다.',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('treats the canonical server ID and profile alias as the same live generation', async () => {
|
||||||
|
const caller = appRouter.createCaller(buildContext(authFor('owner-a')));
|
||||||
|
|
||||||
|
const [canonical, alias, omitted] = await Promise.all([
|
||||||
|
caller.yearbook.getRange({ serverID: currentServerId }),
|
||||||
|
caller.yearbook.getRange({ serverID: profile.name }),
|
||||||
|
caller.yearbook.getRange(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(canonical).toEqual(alias);
|
||||||
|
expect(alias).toEqual(omitted);
|
||||||
|
expect(canonical).toEqual({
|
||||||
|
firstYearMonth: 219 * 12 + 11,
|
||||||
|
lastYearMonth: 219 * 12 + 11,
|
||||||
|
currentYearMonth: 220 * 12,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses stored logs for a past month of the current generation', async () => {
|
||||||
|
const caller = appRouter.createCaller(buildContext(authFor('owner-a')));
|
||||||
|
|
||||||
|
const result = await caller.yearbook.getHistory({
|
||||||
|
serverID: currentServerId,
|
||||||
|
year: 219,
|
||||||
|
month: 12,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
notModified: false,
|
||||||
|
data: {
|
||||||
|
globalHistory: ['저장된 현재 기수 과거 기록'],
|
||||||
|
globalAction: ['저장된 현재 기수 과거 행동'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -259,6 +259,7 @@ const installAuthenticatedGameFixture = async (page: Page): Promise<void> => {
|
|||||||
);
|
);
|
||||||
await page.route('**/che/api/trpc/**', async (route) => {
|
await page.route('**/che/api/trpc/**', async (route) => {
|
||||||
await fulfillOperations(route, (operation) => {
|
await fulfillOperations(route, (operation) => {
|
||||||
|
if (operation === 'auth.status') return { userId: 'frontend-legacy-fixture-user' };
|
||||||
if (operation === 'lobby.info') {
|
if (operation === 'lobby.info') {
|
||||||
return { ...fixture.game.lobby, myGeneral: fixture.game.session.general };
|
return { ...fixture.game.lobby, myGeneral: fixture.game.session.general };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user