fix(dynasty): normalize archived nation records

This commit is contained in:
2026-07-31 15:28:04 +00:00
parent 6e8f048876
commit 8a17412fcd
9 changed files with 266 additions and 56 deletions
+54 -14
View File
@@ -25,6 +25,50 @@ const parseDisplayArray = (value: unknown): Array<string | number> =>
) )
: []; : [];
const parseArchiveRecord = (value: unknown): Record<string, unknown> => {
if (typeof value === 'string') {
try {
return asRecord(JSON.parse(value));
} catch {
return {};
}
}
return asRecord(value);
};
const firstFiniteNumber = (...values: unknown[]): number | null => {
for (const value of values) {
const parsed = typeof value === 'string' ? Number(value) : value;
if (typeof parsed === 'number' && Number.isFinite(parsed)) return parsed;
}
return null;
};
const firstDisplayArray = (...values: unknown[]): Array<string | number> => {
for (const value of values) {
const parsed = parseDisplayArray(value);
if (parsed.length > 0 || Array.isArray(value)) return parsed;
}
return [];
};
const normalizeOldNationData = (value: unknown) => {
const data = asRecord(value);
const aux = parseArchiveRecord(data.aux);
const meta = asRecord(data.meta);
const legacyMaxPower = asRecord(meta.max_power);
const typeCode = typeof data.type === 'string' ? data.type : typeof data.typeCode === 'string' ? data.typeCode : '';
return {
data,
typeCode,
tech: firstFiniteNumber(data.tech, meta.tech),
maxPower: firstFiniteNumber(data.maxPower, aux.maxPower, legacyMaxPower.maxPower, data.power),
maxCrew: firstFiniteNumber(data.maxCrew, aux.maxCrew, legacyMaxPower.maxCrew),
maxCities: firstDisplayArray(data.maxCities, aux.maxCities, legacyMaxPower.maxCities),
};
};
const formatNationType = (typeCode: string): string => { const formatNationType = (typeCode: string): string => {
const separator = typeCode.indexOf('_'); const separator = typeCode.indexOf('_');
return separator < 0 ? typeCode : typeCode.slice(separator + 1); return separator < 0 ? typeCode : typeCode.slice(separator + 1);
@@ -94,31 +138,27 @@ export const dynastyRouter = router({
const serverId = emperor.serverId ?? ''; const serverId = emperor.serverId ?? '';
const oldNationRows = await ctx.db.oldNation.findMany({ const oldNationRows = await ctx.db.oldNation.findMany({
where: { serverId }, where: { serverId },
orderBy: { date: 'desc' }, orderBy: [{ date: 'desc' }, { id: 'desc' }],
}); });
const nationEntries = oldNationRows const nationEntries = oldNationRows
.map((row) => { .map((row) => {
const data = asRecord(row.data); const normalized = normalizeOldNationData(row.data);
const { data } = normalized;
const nationId = row.nation ?? (typeof data.nation === 'number' ? data.nation : 0); const nationId = row.nation ?? (typeof data.nation === 'number' ? data.nation : 0);
const typeCode = typeof data.type === 'string' ? data.type : '';
return { return {
archiveId: row.id,
nation: nationId, nation: nationId,
isWinner: winnerNationId !== null && nationId === winnerNationId, isWinner: winnerNationId !== null && nationId === winnerNationId,
name: typeof data.name === 'string' ? data.name : nationId === 0 ? '재야' : '미상', name: typeof data.name === 'string' ? data.name : nationId === 0 ? '재야' : '미상',
color: typeof data.color === 'string' ? data.color : '#000000', color: typeof data.color === 'string' ? data.color : '#000000',
type: typeCode, type: normalized.typeCode,
typeName: formatNationType(typeCode), typeName: formatNationType(normalized.typeCode),
level: typeof data.level === 'number' ? data.level : null, level: typeof data.level === 'number' ? data.level : null,
tech: typeof data.tech === 'number' ? data.tech : null, tech: normalized.tech,
maxPower: maxPower: normalized.maxPower,
typeof data.maxPower === 'number' maxCrew: normalized.maxCrew,
? data.maxPower maxCities: normalized.maxCities,
: typeof data.power === 'number'
? data.power
: null,
maxCrew: typeof data.maxCrew === 'number' ? data.maxCrew : null,
maxCities: parseDisplayArray(data.maxCities),
generals: parseNumberArray(data.generals), generals: parseNumberArray(data.generals),
history: parseTextArray(data.history), history: parseTextArray(data.history),
date: row.date.toISOString(), date: row.date.toISOString(),
+68 -6
View File
@@ -69,12 +69,11 @@ const oldNation = {
nation: 1, nation: 1,
name: '촉', name: '촉',
color: '#FF0000', color: '#FF0000',
type: 'che_병가', typeCode: 'che_병가',
level: 7, level: 7,
tech: 4000, tech: 4000,
power: 34434, power: 12_345,
maxCrew: 120000, aux: { maxPower: 34_434, maxCrew: 120_000, maxCities: ['성도', '한중'] },
maxCities: ['성도', '한중'],
generals: [11, 12], generals: [11, 12],
history: ['<Y>유비</>가 황제로 즉위'], history: ['<Y>유비</>가 황제로 즉위'],
owner: 'not-returned', owner: 'not-returned',
@@ -82,6 +81,28 @@ const oldNation = {
date: new Date('2026-07-25T12:00:00.000Z'), date: new Date('2026-07-25T12:00:00.000Z'),
}; };
const deletedOldNation = {
id: 4,
serverId: emperor.serverId,
nation: 2,
data: {
nation: 2,
name: '위',
color: '#0000FF',
type: 'che_법가',
level: 5,
power: 8_000,
generals: [13],
history: [],
meta: {
tech: 3_000,
max_power: { maxPower: 20_000, maxCrew: 80_000, maxCities: ['허창'] },
privateNote: 'not-returned',
},
},
date: new Date('2026-07-24T12:00:00.000Z'),
};
const authFor = (userId: string, roles: string[] = []): GameSessionTokenPayload => ({ const authFor = (userId: string, roles: string[] = []): GameSessionTokenPayload => ({
version: 1, version: 1,
profile: profile.name, profile: profile.name,
@@ -97,7 +118,10 @@ const authFor = (userId: string, roles: string[] = []): GameSessionTokenPayload
sanctions: {}, sanctions: {},
}); });
const buildContext = (auth: GameSessionTokenPayload | null): GameApiContext => { const buildContext = (
auth: GameSessionTokenPayload | null,
oldNations: Array<Record<string, unknown>> = [oldNation, deletedOldNation]
): GameApiContext => {
const db = { const db = {
worldState: { worldState: {
findFirst: async () => ({ currentYear: 220, currentMonth: 1 }), findFirst: async () => ({ currentYear: 220, currentMonth: 1 }),
@@ -108,12 +132,13 @@ const buildContext = (auth: GameSessionTokenPayload | null): GameApiContext => {
}, },
oldNation: { oldNation: {
findMany: async ({ where }: { where: { serverId: string } }) => findMany: async ({ where }: { where: { serverId: string } }) =>
where.serverId === emperor.serverId ? [oldNation] : [], where.serverId === emperor.serverId ? oldNations : [],
}, },
oldGeneral: { oldGeneral: {
findMany: async () => [ findMany: async () => [
{ generalNo: 11, name: '유비', lastYearMonth: 21504 }, { generalNo: 11, name: '유비', lastYearMonth: 21504 },
{ generalNo: 12, name: '제갈량', lastYearMonth: 21504 }, { generalNo: 12, name: '제갈량', lastYearMonth: 21504 },
{ generalNo: 13, name: '조조', lastYearMonth: 21003 },
], ],
}, },
}; };
@@ -204,16 +229,30 @@ describe('dynasty public read model', () => {
); );
expect(result.nations).toEqual([ expect(result.nations).toEqual([
expect.objectContaining({ expect.objectContaining({
archiveId: 3,
name: '촉', name: '촉',
type: 'che_병가', type: 'che_병가',
typeName: '병가', typeName: '병가',
levelName: '황제', levelName: '황제',
maxPower: 34434, maxPower: 34434,
maxCrew: 120000,
maxCities: ['성도', '한중'],
generalsFull: [ generalsFull: [
{ generalNo: 11, name: '유비', lastYearMonth: 21504 }, { generalNo: 11, name: '유비', lastYearMonth: 21504 },
{ generalNo: 12, name: '제갈량', lastYearMonth: 21504 }, { generalNo: 12, name: '제갈량', lastYearMonth: 21504 },
], ],
}), }),
expect.objectContaining({
archiveId: 4,
name: '위',
type: 'che_법가',
typeName: '법가',
tech: 3000,
maxPower: 20000,
maxCrew: 80000,
maxCities: ['허창'],
generalsFull: [{ generalNo: 13, name: '조조', lastYearMonth: 21003 }],
}),
]); ]);
expect(JSON.stringify(result)).not.toContain('privateNote'); expect(JSON.stringify(result)).not.toContain('privateNote');
expect(JSON.stringify(result)).not.toContain('not-returned'); expect(JSON.stringify(result)).not.toContain('not-returned');
@@ -221,6 +260,29 @@ describe('dynasty public read model', () => {
expect(JSON.stringify(result)).not.toContain('"data"'); expect(JSON.stringify(result)).not.toContain('"data"');
}); });
it('reads legacy JSON-string aux values without exposing malformed archive internals', async () => {
const stringAuxNation = {
...oldNation,
id: 5,
data: {
...oldNation.data,
aux: JSON.stringify({ maxPower: 45_000, maxCrew: 130_000, maxCities: ['낙양'] }),
owner: 'not-returned',
},
};
const result = await appRouter
.createCaller(buildContext(null, [stringAuxNation]))
.dynasty.getDetail({ emperorId: emperor.id });
expect(result.nations[0]).toMatchObject({
archiveId: 5,
maxPower: 45_000,
maxCrew: 130_000,
maxCities: ['낙양'],
});
expect(JSON.stringify(result)).not.toContain('not-returned');
});
it('rejects invalid and missing record identifiers without querying another scope', async () => { it('rejects invalid and missing record identifiers without querying another scope', async () => {
const caller = appRouter.createCaller(buildContext(null)); const caller = appRouter.createCaller(buildContext(null));
+9 -26
View File
@@ -35,6 +35,7 @@ import { calculateNationBettingRewards } from '../betting/nationBettingSettlemen
import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js'; import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js';
import { buildPersistedRankRows } from './rankData.js'; import { buildPersistedRankRows } from './rankData.js';
import { persistUnificationFinalization } from './unificationPersistence.js'; import { persistUnificationFinalization } from './unificationPersistence.js';
import { buildOldNationArchiveData } from './oldNationArchive.js';
import { persistYearbookSnapshot } from './yearbookPersistence.js'; import { persistYearbookSnapshot } from './yearbookPersistence.js';
export interface DatabaseTurnHooks { export interface DatabaseTurnHooks {
@@ -696,40 +697,22 @@ export const createDatabaseTurnHooks = async (
}, },
}, },
update: { update: {
data: { data: buildOldNationArchiveData({
nation: snapshot.nation.id, nation: snapshot.nation,
name: snapshot.nation.name, generalIds: snapshot.generalIds,
color: snapshot.nation.color,
type: snapshot.nation.typeCode,
level: snapshot.nation.level,
gold: snapshot.nation.gold,
rice: snapshot.nation.rice,
power: snapshot.nation.power,
capitalCityId: snapshot.nation.capitalCityId,
generals: snapshot.generalIds,
history: historyMap.get(snapshot.nation.id) ?? [], history: historyMap.get(snapshot.nation.id) ?? [],
meta: snapshot.nation.meta ?? {}, }) as InputJsonValue,
},
date: snapshot.removedAt, date: snapshot.removedAt,
}, },
create: { create: {
serverId, serverId,
nation: snapshot.nation.id, nation: snapshot.nation.id,
sourceId: 0, sourceId: 0,
data: { data: buildOldNationArchiveData({
nation: snapshot.nation.id, nation: snapshot.nation,
name: snapshot.nation.name, generalIds: snapshot.generalIds,
color: snapshot.nation.color,
type: snapshot.nation.typeCode,
level: snapshot.nation.level,
gold: snapshot.nation.gold,
rice: snapshot.nation.rice,
power: snapshot.nation.power,
capitalCityId: snapshot.nation.capitalCityId,
generals: snapshot.generalIds,
history: historyMap.get(snapshot.nation.id) ?? [], history: historyMap.get(snapshot.nation.id) ?? [],
meta: snapshot.nation.meta ?? {}, }) as InputJsonValue,
},
date: snapshot.removedAt, date: snapshot.removedAt,
}, },
}) })
@@ -0,0 +1,38 @@
import { asRecord } from '@sammo-ts/common';
import type { Nation } from '@sammo-ts/logic';
const readNumber = (value: unknown): number => {
const parsed = typeof value === 'string' ? Number(value) : value;
return typeof parsed === 'number' && Number.isFinite(parsed) ? parsed : 0;
};
const readTextArray = (value: unknown): string[] =>
Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
export const buildOldNationArchiveData = (options: {
nation: Nation;
generalIds: readonly number[];
history: readonly string[];
}): Record<string, unknown> => {
const { nation } = options;
const meta = asRecord(nation.meta);
const maxPower = asRecord(meta.max_power);
const aux = {
...asRecord(meta.aux),
...maxPower,
};
const maxCities = readTextArray(maxPower.maxCities);
return {
...nation,
nation: nation.id,
type: nation.typeCode,
tech: readNumber(meta.tech),
maxPower: readNumber(maxPower.maxPower),
maxCrew: readNumber(maxPower.maxCrew),
maxCities,
aux,
generals: [...options.generalIds],
history: [...options.history],
};
};
@@ -4,6 +4,7 @@ import { LogCategory, LogScope, sendMessage, type MessageDraft, type MessageReco
import type { InMemoryTurnWorld } from './inMemoryWorld.js'; import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import { ALL_MERGED_INHERITANCE_KEYS, computeActiveInheritancePoint } from './inheritancePointCalculation.js'; import { ALL_MERGED_INHERITANCE_KEYS, computeActiveInheritancePoint } from './inheritancePointCalculation.js';
import { buildOldNationArchiveData } from './oldNationArchive.js';
import type { PendingUnificationAuctionCancellation, TurnGeneral } from './types.js'; import type { PendingUnificationAuctionCancellation, TurnGeneral } from './types.js';
const UNIFIER_POINT = 2000; const UNIFIER_POINT = 2000;
@@ -490,16 +491,13 @@ export const persistUnificationFinalization = async (
const totalMaxPop = cities.reduce((sum, city) => sum + city.populationMax, 0); const totalMaxPop = cities.reduce((sum, city) => sum + city.populationMax, 0);
const winnerMeta = asRecord(winner.meta); const winnerMeta = asRecord(winner.meta);
const winnerData = { const winnerData = {
...winner, ...buildOldNationArchiveData({
tech: readInteger(winnerMeta.tech), nation: winner,
aux: { generalIds: winnerGenerals.map((general) => general.id),
...asRecord(winnerMeta.aux), history: nationHistory,
...asRecord(winnerMeta.max_power), }),
},
msg: String(asRecord(winnerMeta.nationNotice).msg ?? winnerMeta.msg ?? ''), msg: String(asRecord(winnerMeta.nationNotice).msg ?? winnerMeta.msg ?? ''),
scout_msg: String(winnerMeta.scout_msg ?? ''), scout_msg: String(winnerMeta.scout_msg ?? ''),
generals: winnerGenerals.map((general) => general.id),
history: nationHistory,
generationKey: input.generationKey, generationKey: input.generationKey,
}; };
await transaction.oldNation.upsert({ await transaction.oldNation.upsert({
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import type { Nation } from '@sammo-ts/logic';
import { buildOldNationArchiveData } from '../src/turn/oldNationArchive.js';
describe('old nation archive data', () => {
it('writes one canonical public shape for winner and deleted-nation readers', () => {
const nation: Nation = {
id: 2,
name: '위',
color: '#0000ff',
capitalCityId: 3,
chiefGeneralId: 7,
gold: 1_000,
rice: 2_000,
power: 8_000,
level: 5,
typeCode: 'che_법가',
meta: {
tech: 3_000,
aux: { legacy: 'preserved' },
max_power: { maxPower: 20_000, maxCrew: 80_000, maxCities: ['허창'] },
},
};
expect(buildOldNationArchiveData({ nation, generalIds: [7, 8], history: ['위가 멸망'] })).toMatchObject({
nation: 2,
name: '위',
type: 'che_법가',
typeCode: 'che_법가',
tech: 3_000,
power: 8_000,
maxPower: 20_000,
maxCrew: 80_000,
maxCities: ['허창'],
aux: { legacy: 'preserved', maxPower: 20_000, maxCrew: 80_000, maxCities: ['허창'] },
generals: [7, 8],
history: ['위가 멸망'],
});
});
});
@@ -330,6 +330,26 @@ integration('unification finalization transaction', () => {
expect(await db.inheritanceResult.count({ where: { serverId } })).toBe(1); expect(await db.inheritanceResult.count({ where: { serverId } })).toBe(1);
expect(await db.oldGeneral.count({ where: { serverId } })).toBe(1); expect(await db.oldGeneral.count({ where: { serverId } })).toBe(1);
expect(await db.oldNation.count({ where: { serverId } })).toBe(2); expect(await db.oldNation.count({ where: { serverId } })).toBe(2);
expect(
(
await db.oldNation.findUniqueOrThrow({
where: {
serverId_nation_sourceId: { serverId, nation: fixtureId, sourceId: 0 },
},
})
).data
).toMatchObject({
nation: fixtureId,
name: '원자통일국',
type: 'che_중립',
typeCode: 'che_중립',
tech: 123,
maxPower: 3_500,
maxCrew: 400,
maxCities: ['원자도시'],
aux: { maxPower: 3_500, maxCrew: 400, maxCities: ['원자도시'] },
generals: [fixtureId],
});
expect(await db.emperor.count({ where: { serverId } })).toBe(1); expect(await db.emperor.count({ where: { serverId } })).toBe(1);
expect( expect(
(await db.inheritancePoint.findUniqueOrThrow({ where: { userId_key: { userId, key: 'previous' } } })) (await db.inheritancePoint.findUniqueOrThrow({ where: { userId_key: { userId, key: 'previous' } } }))
@@ -210,7 +210,7 @@ onMounted(loadDetail);
<table <table
v-for="nation in data.nations" v-for="nation in data.nations"
:key="nation.nation" :key="nation.archiveId"
class="legacy-table legacy-bg2 old-nation-table" class="legacy-table legacy-bg2 old-nation-table"
> >
<colgroup> <colgroup>
@@ -84,6 +84,7 @@ const detailPayload = {
}, },
nations: [ nations: [
{ {
archiveId: 3,
nation: 1, nation: 1,
isWinner: true, isWinner: true,
name: '백년01', name: '백년01',
@@ -104,6 +105,25 @@ const detailPayload = {
{ generalNo: 12, name: '료우기시키', lastYearMonth: 21504 }, { generalNo: 12, name: '료우기시키', lastYearMonth: 21504 },
], ],
}, },
{
archiveId: 4,
nation: 2,
isWinner: false,
name: '청해',
color: '#0000FF',
type: 'che_법가',
typeName: '법가',
level: 5,
levelName: '공',
tech: 3000,
maxPower: 20000,
maxCrew: 80000,
maxCities: ['허창'],
generals: [13],
history: ['<Y>조조</>가 나라를 세웠습니다.'],
date: '2026-07-24T12:00:00.000Z',
generalsFull: [{ generalNo: 13, name: '조조', lastYearMonth: 21003 }],
},
], ],
}; };
@@ -241,7 +261,15 @@ test('dynasty detail preserves the legacy fields, old-nation table and error flo
await expect(page.getByText('건 안 칠 자')).toBeVisible(); await expect(page.getByText('건 안 칠 자')).toBeVisible();
await expect(page.getByText('【 백년01 】')).toBeVisible(); await expect(page.getByText('【 백년01 】')).toBeVisible();
await expect(page.getByText('황제', { exact: true })).toBeVisible(); await expect(page.getByText('황제', { exact: true })).toBeVisible();
await expect(page.locator('.old-nation-table')).toHaveCount(1); await expect(page.locator('.old-nation-table')).toHaveCount(2);
await expect(page.locator('.old-nation-table').nth(0)).toContainText('120000명');
await expect(page.locator('.old-nation-table').nth(0)).toContainText('34434');
await expect(page.locator('.old-nation-table').nth(0)).toContainText('낙양, 장안, 성도');
await expect(page.locator('.old-nation-table').nth(1)).toContainText('법가');
await expect(page.locator('.old-nation-table').nth(1)).toContainText('3000');
await expect(page.locator('.old-nation-table').nth(1)).toContainText('80000명');
await expect(page.locator('.old-nation-table').nth(1)).toContainText('20000');
await expect(page.locator('.old-nation-table').nth(1)).toContainText('허창');
const geometry = await page.locator('#dynasty-detail-container').evaluate((container) => { const geometry = await page.locator('#dynasty-detail-container').evaluate((container) => {
const rect = container.getBoundingClientRect(); const rect = container.getBoundingClientRect();