From 8a17412fcd8e38b7726b1d27673f4b489d45d120 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 31 Jul 2026 15:28:04 +0000 Subject: [PATCH] fix(dynasty): normalize archived nation records --- app/game-api/src/router/dynasty/index.ts | 68 +++++++++++++---- app/game-api/test/dynastyRouter.test.ts | 74 +++++++++++++++++-- app/game-engine/src/turn/databaseHooks.ts | 35 +++------ app/game-engine/src/turn/oldNationArchive.ts | 38 ++++++++++ .../src/turn/unificationPersistence.ts | 14 ++-- app/game-engine/test/oldNationArchive.test.ts | 41 ++++++++++ ...nificationFinalization.integration.test.ts | 20 +++++ .../src/views/DynastyDetailView.vue | 2 +- .../dynasty-parity.spec.ts | 30 +++++++- 9 files changed, 266 insertions(+), 56 deletions(-) create mode 100644 app/game-engine/src/turn/oldNationArchive.ts create mode 100644 app/game-engine/test/oldNationArchive.test.ts diff --git a/app/game-api/src/router/dynasty/index.ts b/app/game-api/src/router/dynasty/index.ts index 2bc2e0c..bf96f0b 100644 --- a/app/game-api/src/router/dynasty/index.ts +++ b/app/game-api/src/router/dynasty/index.ts @@ -25,6 +25,50 @@ const parseDisplayArray = (value: unknown): Array => ) : []; +const parseArchiveRecord = (value: unknown): Record => { + 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 => { + 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 separator = typeCode.indexOf('_'); return separator < 0 ? typeCode : typeCode.slice(separator + 1); @@ -94,31 +138,27 @@ export const dynastyRouter = router({ const serverId = emperor.serverId ?? ''; const oldNationRows = await ctx.db.oldNation.findMany({ where: { serverId }, - orderBy: { date: 'desc' }, + orderBy: [{ date: 'desc' }, { id: 'desc' }], }); const nationEntries = oldNationRows .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 typeCode = typeof data.type === 'string' ? data.type : ''; return { + archiveId: row.id, 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: typeCode, - typeName: formatNationType(typeCode), + type: normalized.typeCode, + typeName: formatNationType(normalized.typeCode), level: typeof data.level === 'number' ? data.level : null, - tech: typeof data.tech === 'number' ? data.tech : null, - maxPower: - typeof data.maxPower === 'number' - ? data.maxPower - : typeof data.power === 'number' - ? data.power - : null, - maxCrew: typeof data.maxCrew === 'number' ? data.maxCrew : null, - maxCities: parseDisplayArray(data.maxCities), + tech: normalized.tech, + maxPower: normalized.maxPower, + maxCrew: normalized.maxCrew, + maxCities: normalized.maxCities, generals: parseNumberArray(data.generals), history: parseTextArray(data.history), date: row.date.toISOString(), diff --git a/app/game-api/test/dynastyRouter.test.ts b/app/game-api/test/dynastyRouter.test.ts index 608671f..aa833c9 100644 --- a/app/game-api/test/dynastyRouter.test.ts +++ b/app/game-api/test/dynastyRouter.test.ts @@ -69,12 +69,11 @@ const oldNation = { nation: 1, name: '촉', color: '#FF0000', - type: 'che_병가', + typeCode: 'che_병가', level: 7, tech: 4000, - power: 34434, - maxCrew: 120000, - maxCities: ['성도', '한중'], + power: 12_345, + aux: { maxPower: 34_434, maxCrew: 120_000, maxCities: ['성도', '한중'] }, generals: [11, 12], history: ['유비가 황제로 즉위'], owner: 'not-returned', @@ -82,6 +81,28 @@ const oldNation = { 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 => ({ version: 1, profile: profile.name, @@ -97,7 +118,10 @@ const authFor = (userId: string, roles: string[] = []): GameSessionTokenPayload sanctions: {}, }); -const buildContext = (auth: GameSessionTokenPayload | null): GameApiContext => { +const buildContext = ( + auth: GameSessionTokenPayload | null, + oldNations: Array> = [oldNation, deletedOldNation] +): GameApiContext => { const db = { worldState: { findFirst: async () => ({ currentYear: 220, currentMonth: 1 }), @@ -108,12 +132,13 @@ const buildContext = (auth: GameSessionTokenPayload | null): GameApiContext => { }, oldNation: { findMany: async ({ where }: { where: { serverId: string } }) => - where.serverId === emperor.serverId ? [oldNation] : [], + where.serverId === emperor.serverId ? oldNations : [], }, oldGeneral: { findMany: async () => [ { generalNo: 11, 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.objectContaining({ + archiveId: 3, name: '촉', type: 'che_병가', typeName: '병가', levelName: '황제', maxPower: 34434, + maxCrew: 120000, + maxCities: ['성도', '한중'], generalsFull: [ { generalNo: 11, 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('not-returned'); @@ -221,6 +260,29 @@ describe('dynasty public read model', () => { 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 () => { const caller = appRouter.createCaller(buildContext(null)); diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 4145a54..fe26274 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -35,6 +35,7 @@ import { calculateNationBettingRewards } from '../betting/nationBettingSettlemen import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js'; import { buildPersistedRankRows } from './rankData.js'; import { persistUnificationFinalization } from './unificationPersistence.js'; +import { buildOldNationArchiveData } from './oldNationArchive.js'; import { persistYearbookSnapshot } from './yearbookPersistence.js'; export interface DatabaseTurnHooks { @@ -696,40 +697,22 @@ export const createDatabaseTurnHooks = async ( }, }, update: { - data: { - nation: snapshot.nation.id, - name: snapshot.nation.name, - 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, + data: buildOldNationArchiveData({ + nation: snapshot.nation, + generalIds: snapshot.generalIds, history: historyMap.get(snapshot.nation.id) ?? [], - meta: snapshot.nation.meta ?? {}, - }, + }) as InputJsonValue, date: snapshot.removedAt, }, create: { serverId, nation: snapshot.nation.id, sourceId: 0, - data: { - nation: snapshot.nation.id, - name: snapshot.nation.name, - 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, + data: buildOldNationArchiveData({ + nation: snapshot.nation, + generalIds: snapshot.generalIds, history: historyMap.get(snapshot.nation.id) ?? [], - meta: snapshot.nation.meta ?? {}, - }, + }) as InputJsonValue, date: snapshot.removedAt, }, }) diff --git a/app/game-engine/src/turn/oldNationArchive.ts b/app/game-engine/src/turn/oldNationArchive.ts new file mode 100644 index 0000000..6a96a93 --- /dev/null +++ b/app/game-engine/src/turn/oldNationArchive.ts @@ -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 => { + 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], + }; +}; diff --git a/app/game-engine/src/turn/unificationPersistence.ts b/app/game-engine/src/turn/unificationPersistence.ts index 05fc5bf..fa0b877 100644 --- a/app/game-engine/src/turn/unificationPersistence.ts +++ b/app/game-engine/src/turn/unificationPersistence.ts @@ -4,6 +4,7 @@ import { LogCategory, LogScope, sendMessage, type MessageDraft, type MessageReco import type { InMemoryTurnWorld } from './inMemoryWorld.js'; import { ALL_MERGED_INHERITANCE_KEYS, computeActiveInheritancePoint } from './inheritancePointCalculation.js'; +import { buildOldNationArchiveData } from './oldNationArchive.js'; import type { PendingUnificationAuctionCancellation, TurnGeneral } from './types.js'; const UNIFIER_POINT = 2000; @@ -490,16 +491,13 @@ export const persistUnificationFinalization = async ( const totalMaxPop = cities.reduce((sum, city) => sum + city.populationMax, 0); const winnerMeta = asRecord(winner.meta); const winnerData = { - ...winner, - tech: readInteger(winnerMeta.tech), - aux: { - ...asRecord(winnerMeta.aux), - ...asRecord(winnerMeta.max_power), - }, + ...buildOldNationArchiveData({ + nation: winner, + generalIds: winnerGenerals.map((general) => general.id), + history: nationHistory, + }), msg: String(asRecord(winnerMeta.nationNotice).msg ?? winnerMeta.msg ?? ''), scout_msg: String(winnerMeta.scout_msg ?? ''), - generals: winnerGenerals.map((general) => general.id), - history: nationHistory, generationKey: input.generationKey, }; await transaction.oldNation.upsert({ diff --git a/app/game-engine/test/oldNationArchive.test.ts b/app/game-engine/test/oldNationArchive.test.ts new file mode 100644 index 0000000..b739e6d --- /dev/null +++ b/app/game-engine/test/oldNationArchive.test.ts @@ -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: ['위가 멸망'], + }); + }); +}); diff --git a/app/game-engine/test/unificationFinalization.integration.test.ts b/app/game-engine/test/unificationFinalization.integration.test.ts index 6799cd9..a3ed85c 100644 --- a/app/game-engine/test/unificationFinalization.integration.test.ts +++ b/app/game-engine/test/unificationFinalization.integration.test.ts @@ -330,6 +330,26 @@ integration('unification finalization transaction', () => { expect(await db.inheritanceResult.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.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.inheritancePoint.findUniqueOrThrow({ where: { userId_key: { userId, key: 'previous' } } })) diff --git a/app/game-frontend/src/views/DynastyDetailView.vue b/app/game-frontend/src/views/DynastyDetailView.vue index 6438e23..6e9f18f 100644 --- a/app/game-frontend/src/views/DynastyDetailView.vue +++ b/app/game-frontend/src/views/DynastyDetailView.vue @@ -210,7 +210,7 @@ onMounted(loadDetail); diff --git a/tools/frontend-legacy-parity/dynasty-parity.spec.ts b/tools/frontend-legacy-parity/dynasty-parity.spec.ts index c73b8d1..d26e192 100644 --- a/tools/frontend-legacy-parity/dynasty-parity.spec.ts +++ b/tools/frontend-legacy-parity/dynasty-parity.spec.ts @@ -84,6 +84,7 @@ const detailPayload = { }, nations: [ { + archiveId: 3, nation: 1, isWinner: true, name: '백년01', @@ -104,6 +105,25 @@ const detailPayload = { { 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: ['조조가 나라를 세웠습니다.'], + 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('【 백년01 】')).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 rect = container.getBoundingClientRect();