From d3d89cf003d6fb3910cf6e0797c178ba5ad628b7 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 22 Aug 2026 09:15:45 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=A7=80=EB=82=9C=20=ED=94=8C=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=20=EC=A0=84=ED=88=AC=20=EC=9E=90=EB=A3=8C=EB=A5=BC=20?= =?UTF-8?q?=EB=B3=B4=EC=A1=B4=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 통일·장수 삭제·게임 취소 archive에 전투 집계와 숙련도, 전투 결과를 저장한다. 개인 기록과 전투 상세는 보존 대상에서 제외하고 기존 Core snapshot도 정규화한다. --- app/game-api/src/router/archive/index.ts | 14 ++- app/game-api/test/archiveRouter.test.ts | 49 +++++++++- .../src/scenario/gameCancellation.ts | 31 ++++-- .../turn/generalTurnLifecyclePersistence.ts | 35 ++++--- .../src/turn/unificationPersistence.ts | 29 ++++-- .../test/gameCancellation.integration.test.ts | 56 ++++++++++- ...rnLifecyclePersistence.integration.test.ts | 50 +++++++++- .../generalTurnLifecyclePersistence.test.ts | 22 ++++- ...nificationFinalization.integration.test.ts | 74 +++++++++++++- .../test/unificationPersistence.test.ts | 55 ++++++++++- .../legacyArchive/ArchivedGeneralSnapshot.ts | 96 ++++++++++++------- .../test/archiveGeneral.test.ts | 51 ++++++++++ 12 files changed, 484 insertions(+), 78 deletions(-) diff --git a/app/game-api/src/router/archive/index.ts b/app/game-api/src/router/archive/index.ts index 1b5de0a8..d2713b72 100644 --- a/app/game-api/src/router/archive/index.ts +++ b/app/game-api/src/router/archive/index.ts @@ -585,11 +585,15 @@ export const archiveRouter = router({ const nation = resolveNation(nationMap, entry); const snapshot = entry.snapshot; const general = await buildGeneralDetail(entry, nation); - const battleResultEntries = (battleResultContent ?? '') - .split(/\r?\n/u) - .map((text, index) => ({ id: index + 1, text })) - .filter((item) => item.text.length > 0) - .reverse(); + battleResultAvailable ||= snapshot.availability.battleResultLogs; + const battleResultEntries = + battleResultContent === null + ? snapshot.records.battleResult.map((text, index, rows) => ({ id: rows.length - index, text })) + : battleResultContent + .split(/\r?\n/u) + .map((text, index) => ({ id: index + 1, text })) + .filter((item) => item.text.length > 0) + .reverse(); const logs = { generalHistory: { available: snapshot.availability.history, diff --git a/app/game-api/test/archiveRouter.test.ts b/app/game-api/test/archiveRouter.test.ts index 443aeb19..351f6734 100644 --- a/app/game-api/test/archiveRouter.test.ts +++ b/app/game-api/test/archiveRouter.test.ts @@ -197,7 +197,22 @@ const context = ( intel: 60, officer_level: 8, personal: 3, + meta: { + dex1: 100, + dex2: 200, + dex3: 300, + dex4: 400, + dex5: 500, + rank_warnum: 10, + rank_killnum: 6, + rank_deathnum: 4, + rank_firenum: 2, + rank_killcrew: 1_000, + rank_deathcrew: 500, + }, history: '●첫 기록
●둘째 기록
', + records: { battleResult: ['둘째 전투 결과', '첫째 전투 결과'] }, + availability: { battleResultLogs: true }, }, }, { @@ -238,7 +253,22 @@ const context = ( power: 70, intel: 60, officer_level: 8, + meta: { + dex1: 100, + dex2: 200, + dex3: 300, + dex4: 400, + dex5: 500, + rank_warnum: 10, + rank_killnum: 6, + rank_deathnum: 4, + rank_firenum: 2, + rank_killcrew: 1_000, + rank_deathcrew: 500, + }, history: '●첫 기록
●둘째 기록
', + records: { battleResult: ['둘째 전투 결과', '첫째 전투 결과'] }, + availability: { battleResultLogs: true }, }, } : null, @@ -369,7 +399,16 @@ describe('archive.myPastPlays', () => { dynastyPath: '/dynasty/7', nation: { id: 3, name: '촉', color: '#ff0000' }, general: expect.objectContaining({ id: 10, name: '과거장수' }), - battle: expect.objectContaining({ available: false }), + masteryAvailable: true, + battle: expect.objectContaining({ + available: true, + warnum: 10, + wins: 6, + losses: 4, + strategies: 2, + killCrew: 1_000, + deathCrew: 500, + }), logs: { generalHistory: { available: true, @@ -379,7 +418,13 @@ describe('archive.myPastPlays', () => { ], }, battleDetail: { available: false, entries: [] }, - battleResult: { available: false, entries: [] }, + battleResult: { + available: true, + entries: [ + { id: 2, text: '둘째 전투 결과' }, + { id: 1, text: '첫째 전투 결과' }, + ], + }, generalAction: { available: false, entries: [] }, }, }); diff --git a/app/game-engine/src/scenario/gameCancellation.ts b/app/game-engine/src/scenario/gameCancellation.ts index b767a14f..00ae04fe 100644 --- a/app/game-engine/src/scenario/gameCancellation.ts +++ b/app/game-engine/src/scenario/gameCancellation.ts @@ -143,7 +143,9 @@ type ActiveGeneral = { const buildActiveGeneralArchive = ( general: ActiveGeneral, + ranks: Record, history: string[], + battleResults: string[], cancellation: { id: string; at: Date; reason: string } ): InputJsonValue => asJson({ @@ -190,9 +192,14 @@ const buildActiveGeneralArchive = ( }, }, lastTurn: general.lastTurn, - meta: general.meta, + meta: { + ...asRecord(general.meta), + ...Object.fromEntries(Object.entries(ranks).map(([key, value]) => [`rank_${key}`, value])), + }, penalty: general.penalty, history, + records: { battleResult: battleResults }, + availability: { battleResultLogs: true }, abandonedGame: { cancellationId: cancellation.id, cancelledAt: cancellation.at.toISOString(), @@ -266,14 +273,14 @@ const cancelGameInTransaction = async ( ]); const activeIds = activeGenerals.map((general) => general.id); - const [resolvedRankRows, resolvedHistoryLogs] = await Promise.all([ + const [resolvedRankRows, resolvedRecordLogs] = await Promise.all([ activeIds.length ? prisma.rankData.findMany({ where: { generalId: { in: activeIds } } }) : [], activeIds.length ? prisma.logEntry.findMany({ where: { generalId: { in: activeIds }, scope: LogScope.GENERAL, - category: LogCategory.HISTORY, + category: { in: [LogCategory.HISTORY, LogCategory.BATTLE_BRIEF] }, }, orderBy: { id: 'desc' }, }) @@ -293,11 +300,19 @@ const cancelGameInTransaction = async ( ranksByGeneral.set(row.generalId, ranks); } const logsByGeneral = new Map(); - for (const row of resolvedHistoryLogs) { + const battleResultsByGeneral = new Map(); + for (const row of resolvedRecordLogs) { if (row.generalId === null) continue; - const logs = logsByGeneral.get(row.generalId) ?? []; + const target = + row.category === LogCategory.HISTORY + ? logsByGeneral + : row.category === LogCategory.BATTLE_BRIEF + ? battleResultsByGeneral + : null; + if (!target) continue; + const logs = target.get(row.generalId) ?? []; logs.push(row.text); - logsByGeneral.set(row.generalId, logs); + target.set(row.generalId, logs); } const participantUsers = new Set(); @@ -464,7 +479,9 @@ const cancelGameInTransaction = async ( turnTime: general.turnTime, data: buildActiveGeneralArchive( general as ActiveGeneral, + ranksByGeneral.get(general.id) ?? {}, logsByGeneral.get(general.id) ?? [], + battleResultsByGeneral.get(general.id) ?? [], abandonment ), }, @@ -477,7 +494,9 @@ const cancelGameInTransaction = async ( turnTime: general.turnTime, data: buildActiveGeneralArchive( general as ActiveGeneral, + ranksByGeneral.get(general.id) ?? {}, logsByGeneral.get(general.id) ?? [], + battleResultsByGeneral.get(general.id) ?? [], abandonment ), }, diff --git a/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts b/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts index c072c580..2a134e20 100644 --- a/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts +++ b/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts @@ -286,24 +286,37 @@ const archiveDeletedGeneral = async ( ): Promise => { const serverId = typeof worldMeta.serverId === 'string' && worldMeta.serverId.trim() ? worldMeta.serverId.trim() : 'default'; - const history = await prisma.logEntry.findMany({ - where: { - generalId: event.generalId, - scope: LogScope.GENERAL, - category: LogCategory.HISTORY, - }, - orderBy: { id: 'desc' }, - select: { text: true }, - }); - const archivedMeta = { ...asRecord(event.before.meta) }; + const [recordRows, rankRows] = await Promise.all([ + prisma.logEntry.findMany({ + where: { + generalId: event.generalId, + scope: LogScope.GENERAL, + category: { in: [LogCategory.HISTORY, LogCategory.BATTLE_BRIEF] }, + }, + orderBy: { id: 'desc' }, + select: { category: true, text: true }, + }), + prisma.rankData.findMany({ + where: { generalId: event.generalId }, + select: { type: true, value: true }, + }), + ]); + const archivedMeta = { + ...asRecord(event.before.meta), + ...Object.fromEntries(rankRows.map((row) => [`rank_${row.type}`, row.value])), + }; delete archivedMeta.inheritRandomUnique; delete archivedMeta.inheritSpecificSpecialWar; + const history = recordRows.filter((row) => row.category === LogCategory.HISTORY).map((row) => row.text); + const battleResults = recordRows.filter((row) => row.category === LogCategory.BATTLE_BRIEF).map((row) => row.text); const data = { ...event.before, meta: archivedMeta, turnTime: event.before.turnTime.toISOString(), recentWarTime: event.before.recentWarTime?.toISOString() ?? null, - history: history.map((entry) => entry.text), + history, + records: { battleResult: battleResults }, + availability: { battleResultLogs: true }, }; await prisma.oldGeneral.upsert({ where: { by_no: { serverId, generalNo: event.generalId } }, diff --git a/app/game-engine/src/turn/unificationPersistence.ts b/app/game-engine/src/turn/unificationPersistence.ts index 5a845dd3..62315d46 100644 --- a/app/game-engine/src/turn/unificationPersistence.ts +++ b/app/game-engine/src/turn/unificationPersistence.ts @@ -531,29 +531,44 @@ export const persistUnificationFinalization = async ( }); const archiveGenerals = [...neutralGenerals, ...winnerGenerals]; - const generalHistoryRows = archiveGenerals.length + const generalRecordRows = archiveGenerals.length ? await transaction.logEntry.findMany({ where: { generalId: { in: archiveGenerals.map((general) => general.id) }, scope: LogScope.GENERAL, - category: LogCategory.HISTORY, + category: { in: [LogCategory.HISTORY, LogCategory.BATTLE_BRIEF] }, }, orderBy: { id: 'asc' }, - select: { generalId: true, text: true }, + select: { generalId: true, category: true, text: true }, }) : []; const historyByGeneral = new Map(); - for (const row of generalHistoryRows) { + const battleResultsByGeneral = new Map(); + for (const row of generalRecordRows) { if (row.generalId === null) continue; - const history = historyByGeneral.get(row.generalId) ?? []; - history.push(row.text); - historyByGeneral.set(row.generalId, history); + const target = + row.category === LogCategory.HISTORY + ? historyByGeneral + : row.category === LogCategory.BATTLE_BRIEF + ? battleResultsByGeneral + : null; + if (!target) continue; + const records = target.get(row.generalId) ?? []; + records.push(row.text); + target.set(row.generalId, records); } for (const general of archiveGenerals) { + const ranks = ranksByGeneral.get(general.id) ?? {}; const data = { ...general, + meta: { + ...asRecord(general.meta), + ...Object.fromEntries(Object.entries(ranks).map(([key, value]) => [`rank_${key}`, value])), + }, turnTime: general.turnTime.toISOString(), history: historyByGeneral.get(general.id) ?? [], + records: { battleResult: [...(battleResultsByGeneral.get(general.id) ?? [])].reverse() }, + availability: { battleResultLogs: true }, generationKey: input.generationKey, }; await transaction.oldGeneral.upsert({ diff --git a/app/game-engine/test/gameCancellation.integration.test.ts b/app/game-engine/test/gameCancellation.integration.test.ts index af47422f..574dc37f 100644 --- a/app/game-engine/test/gameCancellation.integration.test.ts +++ b/app/game-engine/test/gameCancellation.integration.test.ts @@ -1,6 +1,8 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { normalizeArchivedGeneral, type ArchivedJsonValue } from '@sammo-ts/common'; import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; +import { LogCategory, LogScope } from '@sammo-ts/logic'; import { cancelGame } from '../src/scenario/gameCancellation.js'; @@ -62,13 +64,46 @@ integration('game cancellation transaction', () => { userId, name: '취소장수', turnTime: openedAt, - meta: { inherit_spent_dyn: 4_500 }, + meta: { inherit_spent_dyn: 4_500, dex1: 1, dex2: 1, dex3: 1, dex4: 1, dex5: 1 }, }, }); await db.rankData.createMany({ data: [ { generalId, nationId: 0, type: 'inherit_spent_dyn', value: 4_500 }, { generalId, nationId: 0, type: 'warnum', value: 10 }, + { generalId, nationId: 0, type: 'killnum', value: 6 }, + { generalId, nationId: 0, type: 'deathnum', value: 4 }, + { generalId, nationId: 0, type: 'firenum', value: 2 }, + { generalId, nationId: 0, type: 'killcrew', value: 1_000 }, + { generalId, nationId: 0, type: 'deathcrew', value: 500 }, + ], + }); + await db.logEntry.createMany({ + data: [ + { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId, + year: 190, + month: 7, + text: '보존하지 않을 개인 기록', + }, + { + scope: LogScope.GENERAL, + category: LogCategory.BATTLE_DETAIL, + generalId, + year: 190, + month: 7, + text: '보존하지 않을 전투 기록', + }, + { + scope: LogScope.GENERAL, + category: LogCategory.BATTLE_BRIEF, + generalId, + year: 190, + month: 7, + text: '보존할 전투 결과', + }, ], }); await db.inheritancePoint.createMany({ @@ -180,7 +215,7 @@ integration('game cancellation transaction', () => { [userId]: { openingPoint: 10_000, currentPoint: 7_000, - earnedPoint: 1_750, + earnedPoint: 1_750.005, retainedEarnedPoint: 700, finalPoint: 10_700, baselineSource: 'OPENING', @@ -197,6 +232,23 @@ integration('game cancellation transaction', () => { const archived = await db.oldGeneral.findMany({ where: { serverId }, orderBy: { generalNo: 'asc' } }); expect(archived).toHaveLength(2); expect(archived.every((row) => JSON.stringify(row.data).includes(request.cancellationId))).toBe(true); + const activeArchive = archived.find((row) => row.generalNo === generalId)!; + const snapshot = normalizeArchivedGeneral(activeArchive.data as ArchivedJsonValue, activeArchive.name).snapshot; + expect(snapshot).toMatchObject({ + mastery: { infantry: 1, archery: 1, cavalry: 1, special: 1, siege: 1 }, + battle: { + battles: 10, + wins: 6, + losses: 4, + fireSuccesses: 2, + killedCrew: 1_000, + lostCrew: 500, + }, + records: { battleResult: ['보존할 전투 결과'] }, + availability: { battleResultLogs: true, battleDetailLogs: false }, + }); + expect(JSON.stringify(activeArchive.data)).not.toContain('보존하지 않을 개인 기록'); + expect(JSON.stringify(activeArchive.data)).not.toContain('보존하지 않을 전투 기록'); await expect(db.hallOfFame.count({ where: { serverId } })).resolves.toBe(0); await expect(db.oldNation.count({ where: { serverId } })).resolves.toBe(0); await expect(db.emperor.count({ where: { serverId } })).resolves.toBe(0); diff --git a/app/game-engine/test/generalTurnLifecyclePersistence.integration.test.ts b/app/game-engine/test/generalTurnLifecyclePersistence.integration.test.ts index 3e0cd345..f6123637 100644 --- a/app/game-engine/test/generalTurnLifecyclePersistence.integration.test.ts +++ b/app/game-engine/test/generalTurnLifecyclePersistence.integration.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { asRecord } from '@sammo-ts/common'; +import { asRecord, normalizeArchivedGeneral, type ArchivedJsonValue } from '@sammo-ts/common'; import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; import { LogCategory, LogScope } from '@sammo-ts/logic'; @@ -98,6 +98,10 @@ integration('general turn lifecycle persistence', () => { inherit_active_action: 2, inheritRandomUnique: true, dex1: 1_000, + dex2: 1, + dex3: 1, + dex4: 1, + dex5: 1, }, }); await db.generalAccessLog.create({ @@ -110,6 +114,10 @@ integration('general turn lifecycle persistence', () => { data: [ { generalId: general.id, nationId: 0, type: 'warnum', value: 2 }, { generalId: general.id, nationId: 0, type: 'firenum', value: 1 }, + { generalId: general.id, nationId: 0, type: 'killnum', value: 1 }, + { generalId: general.id, nationId: 0, type: 'deathnum', value: 1 }, + { generalId: general.id, nationId: 0, type: 'killcrew', value: 400 }, + { generalId: general.id, nationId: 0, type: 'deathcrew', value: 300 }, ], }); await db.logEntry.createMany({ @@ -130,6 +138,30 @@ integration('general turn lifecycle persistence', () => { generalId: general.id, text: '●둘째 기록', }, + { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + year: 200, + month: 1, + generalId: general.id, + text: '보존하지 않을 개인 기록', + }, + { + scope: LogScope.GENERAL, + category: LogCategory.BATTLE_DETAIL, + year: 200, + month: 1, + generalId: general.id, + text: '보존하지 않을 전투 기록', + }, + { + scope: LogScope.GENERAL, + category: LogCategory.BATTLE_BRIEF, + year: 200, + month: 1, + generalId: general.id, + text: '보존할 전투 결과', + }, ], }); @@ -150,6 +182,22 @@ integration('general turn lifecycle persistence', () => { expect(archivedData.history).toEqual(['●둘째 기록', '●첫 기록']); expect(asRecord(archivedData.meta)).not.toHaveProperty('inheritRandomUnique'); expect(asRecord(archivedData.meta)).not.toHaveProperty('inheritSpecificSpecialWar'); + const snapshot = normalizeArchivedGeneral(archived.data as ArchivedJsonValue, archived.name).snapshot; + expect(snapshot).toMatchObject({ + mastery: { infantry: 1_000, archery: 1, cavalry: 1, special: 1, siege: 1 }, + battle: { + battles: 2, + wins: 1, + losses: 1, + fireSuccesses: 1, + killedCrew: 400, + lostCrew: 300, + }, + records: { battleResult: ['보존할 전투 결과'] }, + availability: { battleResultLogs: true, battleDetailLogs: false }, + }); + expect(JSON.stringify(archived.data)).not.toContain('보존하지 않을 개인 기록'); + expect(JSON.stringify(archived.data)).not.toContain('보존하지 않을 전투 기록'); expect( await db.inheritancePoint.findUnique({ where: { userId_key: { userId: general.userId!, key: 'previous' } }, diff --git a/app/game-engine/test/generalTurnLifecyclePersistence.test.ts b/app/game-engine/test/generalTurnLifecyclePersistence.test.ts index b46f9967..0d144dd8 100644 --- a/app/game-engine/test/generalTurnLifecyclePersistence.test.ts +++ b/app/game-engine/test/generalTurnLifecyclePersistence.test.ts @@ -39,6 +39,7 @@ const archivedGeneral = (): TurnGeneral => ({ triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, meta: { killturn: 0, + dex1: 1_000, inheritRandomUnique: true, inheritSpecificSpecialWar: true, }, @@ -55,7 +56,18 @@ describe('general lifecycle archive history', () => { deleteMany: vi.fn(async () => ({ count: 1 })), }, logEntry: { - findMany: vi.fn(async () => [{ text: '●둘째 기록' }, { text: '●첫 기록' }]), + findMany: vi.fn(async () => [ + { category: LogCategory.BATTLE_BRIEF, text: '둘째 전투 결과' }, + { category: LogCategory.HISTORY, text: '●둘째 기록' }, + { category: LogCategory.BATTLE_BRIEF, text: '첫째 전투 결과' }, + { category: LogCategory.HISTORY, text: '●첫 기록' }, + ]), + }, + rankData: { + findMany: vi.fn(async () => [ + { type: 'warnum', value: 2 }, + { type: 'killnum', value: 1 }, + ]), }, oldGeneral: { upsert }, } as unknown as GamePrisma.TransactionClient; @@ -73,17 +85,19 @@ describe('general lifecycle archive history', () => { where: { generalId: general.id, scope: LogScope.GENERAL, - category: LogCategory.HISTORY, + category: { in: [LogCategory.HISTORY, LogCategory.BATTLE_BRIEF] }, }, orderBy: { id: 'desc' }, - select: { text: true }, + select: { category: true, text: true }, }); expect(upsert).toHaveBeenCalledWith( expect.objectContaining({ create: expect.objectContaining({ data: expect.objectContaining({ history: ['●둘째 기록', '●첫 기록'], - meta: { killturn: 0 }, + records: { battleResult: ['둘째 전투 결과', '첫째 전투 결과'] }, + availability: { battleResultLogs: true }, + meta: { killturn: 0, dex1: 1_000, rank_warnum: 2, rank_killnum: 1 }, }), }), }) diff --git a/app/game-engine/test/unificationFinalization.integration.test.ts b/app/game-engine/test/unificationFinalization.integration.test.ts index d02d1f18..6b3308b3 100644 --- a/app/game-engine/test/unificationFinalization.integration.test.ts +++ b/app/game-engine/test/unificationFinalization.integration.test.ts @@ -1,6 +1,8 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { normalizeArchivedGeneral, type ArchivedJsonValue } from '@sammo-ts/common'; import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; +import { LogCategory, LogScope } from '@sammo-ts/logic'; import { createAuctionBidder } from '../src/auction/bidder.js'; import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js'; @@ -39,7 +41,9 @@ integration('unification finalization transaction', () => { await db.inheritanceLog.deleteMany({ where: { userId } }); await db.inheritancePoint.deleteMany({ where: { userId } }); await db.gameHistory.deleteMany({ where: { serverId } }); - await db.logEntry.deleteMany({ where: { year: 190, month: 7 } }); + await db.logEntry.deleteMany({ + where: { OR: [{ generalId: fixtureId }, { year: 190, month: 7 }] }, + }); await db.rankData.deleteMany({ where: { generalId: fixtureId } }); await db.general.deleteMany({ where: { id: fixtureId } }); await db.city.deleteMany({ where: { id: fixtureId } }); @@ -139,6 +143,52 @@ integration('unification finalization transaction', () => { }, }, }); + await db.rankData.createMany({ + data: [ + { generalId: fixtureId, nationId: fixtureId, type: 'warnum', value: 4 }, + { generalId: fixtureId, nationId: fixtureId, type: 'killnum', value: 3 }, + { generalId: fixtureId, nationId: fixtureId, type: 'deathnum', value: 1 }, + { generalId: fixtureId, nationId: fixtureId, type: 'firenum', value: 2 }, + { generalId: fixtureId, nationId: fixtureId, type: 'killcrew', value: 1_200 }, + { generalId: fixtureId, nationId: fixtureId, type: 'deathcrew', value: 800 }, + ], + }); + await db.logEntry.createMany({ + data: [ + { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: fixtureId, + year: 190, + month: 6, + text: '보존하지 않을 개인 기록', + }, + { + scope: LogScope.GENERAL, + category: LogCategory.BATTLE_DETAIL, + generalId: fixtureId, + year: 190, + month: 6, + text: '보존하지 않을 전투 기록', + }, + { + scope: LogScope.GENERAL, + category: LogCategory.BATTLE_BRIEF, + generalId: fixtureId, + year: 190, + month: 5, + text: '먼저 보존할 전투 결과', + }, + { + scope: LogScope.GENERAL, + category: LogCategory.BATTLE_BRIEF, + generalId: fixtureId, + year: 190, + month: 6, + text: '보존할 전투 결과', + }, + ], + }); await db.inheritancePoint.createMany({ data: [ { userId, key: 'previous', value: 100 }, @@ -413,6 +463,28 @@ integration('unification finalization transaction', () => { where: { generalId_type: { generalId: fixtureId, type: 'inherit_earned' } }, }) ).resolves.toMatchObject({ value: 2_160 }); + const archivedGeneral = await db.oldGeneral.findUniqueOrThrow({ + where: { by_no: { serverId, generalNo: fixtureId } }, + }); + const archivedSnapshot = normalizeArchivedGeneral( + archivedGeneral.data as ArchivedJsonValue, + archivedGeneral.name + ).snapshot; + expect(archivedSnapshot).toMatchObject({ + mastery: { infantry: 100 }, + battle: { + battles: 4, + wins: 3, + losses: 1, + fireSuccesses: 2, + killedCrew: 1_200, + lostCrew: 800, + }, + records: { battleResult: ['보존할 전투 결과', '먼저 보존할 전투 결과'] }, + availability: { battleResultLogs: true, battleDetailLogs: false }, + }); + expect(JSON.stringify(archivedGeneral.data)).not.toContain('보존하지 않을 개인 기록'); + expect(JSON.stringify(archivedGeneral.data)).not.toContain('보존하지 않을 전투 기록'); expect((await db.gameHistory.findUniqueOrThrow({ where: { serverId } })).winnerNation).toBe(fixtureId); const yearbook = await db.yearbookHistory.findUniqueOrThrow({ where: { diff --git a/app/game-engine/test/unificationPersistence.test.ts b/app/game-engine/test/unificationPersistence.test.ts index 7bd58307..f19e886d 100644 --- a/app/game-engine/test/unificationPersistence.test.ts +++ b/app/game-engine/test/unificationPersistence.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; +import { normalizeArchivedGeneral, type ArchivedJsonValue } from '@sammo-ts/common'; import type { GamePrisma } from '@sammo-ts/infra'; -import type { City, Nation } from '@sammo-ts/logic'; +import { LogCategory, LogScope, type City, type Nation } from '@sammo-ts/logic'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; import { persistUnificationFinalization, resolveStoredInheritancePoint } from '../src/turn/unificationPersistence.js'; @@ -187,6 +188,7 @@ describe('persistUnificationFinalization', () => { const hallCreate = vi.fn().mockResolvedValue({}); const gameHistoryUpdate = vi.fn().mockResolvedValue({}); const emperorCreate = vi.fn().mockResolvedValue({}); + const oldGeneralUpsert = vi.fn().mockResolvedValue({}); const transaction = Object.assign({} as GamePrisma.TransactionClient, { $executeRaw: vi.fn().mockResolvedValue(1), $queryRaw: vi.fn().mockResolvedValue([]), @@ -204,19 +206,41 @@ describe('persistUnificationFinalization', () => { }, inheritanceResult: { create: inheritanceResultCreate }, inheritanceLog: { create: inheritanceLogCreate }, - rankData: { findMany: vi.fn().mockResolvedValue([]) }, + rankData: { + findMany: vi.fn().mockResolvedValue([ + { generalId: 1, type: 'warnum', value: 15 }, + { generalId: 1, type: 'killnum', value: 9 }, + { generalId: 1, type: 'deathnum', value: 6 }, + { generalId: 1, type: 'firenum', value: 4 }, + { generalId: 1, type: 'killcrew', value: 1_200 }, + { generalId: 1, type: 'deathcrew', value: 800 }, + { generalId: 1, type: 'ttw', value: 3 }, + { generalId: 1, type: 'ttd', value: 2 }, + { generalId: 1, type: 'ttl', value: 1 }, + ]), + }, gameHistory: { count: vi.fn().mockResolvedValue(1), update: gameHistoryUpdate }, hallOfFame: { findFirst: vi.fn().mockResolvedValue(null), create: hallCreate, update: vi.fn().mockResolvedValue({}), }, - logEntry: { findMany: vi.fn().mockResolvedValue([]) }, + logEntry: { + findMany: vi.fn().mockImplementation(({ where }: { where: { scope: string } }) => + where.scope === LogScope.GENERAL + ? [ + { generalId: 1, category: LogCategory.BATTLE_BRIEF, text: '이전 전투 결과' }, + { generalId: 1, category: LogCategory.HISTORY, text: '통일 장수 열전' }, + { generalId: 1, category: LogCategory.BATTLE_BRIEF, text: '최신 전투 결과' }, + ] + : [] + ), + }, oldNation: { upsert: vi.fn().mockResolvedValue({}), findMany: vi.fn().mockResolvedValue([]), }, - oldGeneral: { upsert: vi.fn().mockResolvedValue({}) }, + oldGeneral: { upsert: oldGeneralUpsert }, emperor: { create: emperorCreate }, }); await expect(persistUnificationFinalization(transaction, input, buildWorld())).resolves.toEqual({ @@ -252,5 +276,28 @@ describe('persistUnificationFinalization', () => { data: expect.objectContaining({ aux: { winnerNationId: 1, generationKey: input.generationKey } }), }) ); + const archiveWrite = oldGeneralUpsert.mock.calls[0]?.[0] as { + create: { data: ArchivedJsonValue }; + }; + const snapshot = normalizeArchivedGeneral(archiveWrite.create.data, '통일장수').snapshot; + expect(snapshot).toMatchObject({ + mastery: { infantry: 100 }, + battle: { + battles: 15, + wins: 9, + losses: 6, + fireSuccesses: 4, + killedCrew: 1_200, + lostCrew: 800, + tactics: { total: { wins: 3, draws: 2, losses: 1 } }, + }, + records: { battleResult: ['최신 전투 결과', '이전 전투 결과'] }, + availability: { + mastery: true, + battleAggregates: true, + battleResultLogs: true, + battleDetailLogs: false, + }, + }); }); }); diff --git a/packages/common/src/legacyArchive/ArchivedGeneralSnapshot.ts b/packages/common/src/legacyArchive/ArchivedGeneralSnapshot.ts index dab9e2b0..4735f5d9 100644 --- a/packages/common/src/legacyArchive/ArchivedGeneralSnapshot.ts +++ b/packages/common/src/legacyArchive/ArchivedGeneralSnapshot.ts @@ -87,13 +87,17 @@ export interface ArchivedGeneralSnapshotV1 { }; }; history: string[]; + records: { + /** Newest first, matching the live battle-result panel. */ + battleResult: string[]; + }; availability: { mastery: boolean; battleAggregates: boolean; tactics: boolean; history: boolean; battleDetailLogs: false; - battleResultLogs: false; + battleResultLogs: boolean; }; } @@ -144,6 +148,11 @@ const historyLines = (value: ArchivedJsonValue | undefined): string[] => { .filter(Boolean); }; +const recordLines = (value: ArchivedJsonValue | undefined): string[] => { + if (!Array.isArray(value)) return []; + return value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0); +}; + const rate = (numerator: number | null, denominator: number | null): number | null => numerator === null || denominator === null || denominator <= 0 ? null @@ -169,8 +178,11 @@ export const normalizeArchivedGeneral = ( const progression = asRecord(data.progression); const traits = asRecord(data.traits); const resources = asRecord(data.resources); + const meta = asRecord(data.meta); const nestedMastery = asRecord(data.mastery); const nestedBattle = asRecord(data.battle); + const nestedRecords = asRecord(data.records); + const storedAvailability = asRecord(data.availability); const nestedTactics = asRecord(nestedBattle.tactics); const totalTactics = asRecord(nestedTactics.total); const leadershipTactics = asRecord(nestedTactics.leadership); @@ -179,29 +191,32 @@ export const normalizeArchivedGeneral = ( 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, + data.dex1 ?? nestedMastery.infantry ?? meta.dex1, + data.dex2 ?? nestedMastery.archery ?? meta.dex2, + data.dex3 ?? nestedMastery.cavalry ?? meta.dex3, + data.dex4 ?? nestedMastery.special ?? meta.dex4, + data.dex5 ?? nestedMastery.siege ?? meta.dex5, ]; 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 rankValue = (key: string, ...values: Array): number | null => + firstNumber(...values, data[`rank_${key}`], meta[`rank_${key}`], meta[key]); + const battles = rankValue('warnum', data.warnum, nestedBattle.battles); + const wins = rankValue('killnum', data.killnum, nestedBattle.wins); + const losses = rankValue('deathnum', data.deathnum, nestedBattle.losses); + const killedCrew = rankValue('killcrew', data.killcrew, nestedBattle.killedCrew); + const lostCrew = rankValue('deathcrew', data.deathcrew, nestedBattle.lostCrew); const history = historyLines(data.history); + const battleResultRecords = recordLines(nestedRecords.battleResult ?? data.battleResultRecords); 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, + rankValue('ttw', data.ttw, totalTactics.wins), + rankValue('ttd', data.ttd, totalTactics.draws), + rankValue('ttl', data.ttl, totalTactics.losses), + rankValue('tlw', data.tlw, leadershipTactics.wins), + rankValue('tld', data.tld, leadershipTactics.draws), + rankValue('tll', data.tll, leadershipTactics.losses), + rankValue('tiw', data.tiw, intelligenceTactics.wins), + rankValue('tid', data.tid, intelligenceTactics.draws), + rankValue('til', data.til, intelligenceTactics.losses), ]; const tacticsAvailable = tacticValues.some((value) => finiteNumber(value) !== null); @@ -226,13 +241,20 @@ export const normalizeArchivedGeneral = ( leadershipExperience: firstNumber( data.leadershipExperience, data.leadership_exp, - stats.leadershipExperience + stats.leadershipExperience, + meta.leadership_exp + ), + strengthExperience: firstNumber( + data.strengthExperience, + data.strength_exp, + stats.strengthExperience, + meta.strength_exp ), - strengthExperience: firstNumber(data.strengthExperience, data.strength_exp, stats.strengthExperience), intelligenceExperience: firstNumber( data.intelligenceExperience, data.intel_exp, - stats.intelligenceExperience + stats.intelligenceExperience, + meta.intel_exp ), }, progression: { @@ -281,40 +303,44 @@ export const normalizeArchivedGeneral = ( battles, wins, losses, - fireSuccesses: firstNumber(data.firenum, nestedBattle.fireSuccesses), + fireSuccesses: rankValue('firenum', 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), + recentWar: firstText(data.recentWar, data.recent_war, data.recentWarTime, nestedBattle.recentWar), tactics: { total: { - wins: firstNumber(data.ttw, totalTactics.wins), - draws: firstNumber(data.ttd, totalTactics.draws), - losses: firstNumber(data.ttl, totalTactics.losses), + wins: rankValue('ttw', data.ttw, totalTactics.wins), + draws: rankValue('ttd', data.ttd, totalTactics.draws), + losses: rankValue('ttl', data.ttl, totalTactics.losses), }, leadership: { - wins: firstNumber(data.tlw, leadershipTactics.wins), - draws: firstNumber(data.tld, leadershipTactics.draws), - losses: firstNumber(data.tll, leadershipTactics.losses), + wins: rankValue('tlw', data.tlw, leadershipTactics.wins), + draws: rankValue('tld', data.tld, leadershipTactics.draws), + losses: rankValue('tll', data.tll, leadershipTactics.losses), }, intelligence: { - wins: firstNumber(data.tiw, intelligenceTactics.wins), - draws: firstNumber(data.tid, intelligenceTactics.draws), - losses: firstNumber(data.til, intelligenceTactics.losses), + wins: rankValue('tiw', data.tiw, intelligenceTactics.wins), + draws: rankValue('tid', data.tid, intelligenceTactics.draws), + losses: rankValue('til', data.til, intelligenceTactics.losses), }, }, }, history, + records: { battleResult: battleResultRecords }, 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, + battleResultLogs: + storedAvailability.battleResultLogs === true || + (storedAvailability.battleResultLogs !== false && + Object.prototype.hasOwnProperty.call(nestedRecords, 'battleResult')), }, }, }; diff --git a/tools/legacy-db-migration/test/archiveGeneral.test.ts b/tools/legacy-db-migration/test/archiveGeneral.test.ts index b77e838e..cd59bbce 100644 --- a/tools/legacy-db-migration/test/archiveGeneral.test.ts +++ b/tools/legacy-db-migration/test/archiveGeneral.test.ts @@ -68,4 +68,55 @@ describe('normalizeArchivedGeneral', () => { expect(second.sourceFormat).toBe('core-snapshot-v1'); expect(second.snapshot).toEqual(first.snapshot); }); + + it('recovers Core rank/mastery projections and preserved battle results from a raw past-play snapshot', () => { + const { sourceFormat, snapshot } = normalizeArchivedGeneral( + { + name: '현재기수장수', + stats: { leadership: 88, strength: 77, intelligence: 66 }, + meta: { + dex1: 1_100, + dex2: 2_200, + dex3: 3_300, + dex4: 4_400, + dex5: 5_500, + rank_warnum: 15, + rank_killnum: 9, + rank_deathnum: 6, + rank_firenum: 4, + rank_killcrew: 12_345, + rank_deathcrew: 6_789, + rank_ttw: 3, + rank_ttd: 2, + rank_ttl: 1, + }, + records: { battleResult: ['둘째 전투', '첫째 전투'] }, + availability: { battleResultLogs: true }, + }, + 'fallback' + ); + + expect(sourceFormat).toBe('core-snapshot-v1'); + expect(snapshot).toMatchObject({ + mastery: { infantry: 1_100, archery: 2_200, cavalry: 3_300, special: 4_400, siege: 5_500 }, + battle: { + battles: 15, + wins: 9, + losses: 6, + fireSuccesses: 4, + killedCrew: 12_345, + lostCrew: 6_789, + winRate: 60, + tactics: { total: { wins: 3, draws: 2, losses: 1 } }, + }, + records: { battleResult: ['둘째 전투', '첫째 전투'] }, + availability: { + mastery: true, + battleAggregates: true, + tactics: true, + battleDetailLogs: false, + battleResultLogs: true, + }, + }); + }); });