fix: 지난 플레이 전투 자료를 보존한다

통일·장수 삭제·게임 취소 archive에 전투 집계와 숙련도, 전투 결과를 저장한다. 개인 기록과 전투 상세는 보존 대상에서 제외하고 기존 Core snapshot도 정규화한다.
This commit is contained in:
2026-08-22 09:15:45 +00:00
parent d74a8e48bb
commit d3d89cf003
12 changed files with 484 additions and 78 deletions
+9 -5
View File
@@ -585,11 +585,15 @@ export const archiveRouter = router({
const nation = resolveNation(nationMap, entry); const nation = resolveNation(nationMap, entry);
const snapshot = entry.snapshot; const snapshot = entry.snapshot;
const general = await buildGeneralDetail(entry, nation); const general = await buildGeneralDetail(entry, nation);
const battleResultEntries = (battleResultContent ?? '') battleResultAvailable ||= snapshot.availability.battleResultLogs;
.split(/\r?\n/u) const battleResultEntries =
.map((text, index) => ({ id: index + 1, text })) battleResultContent === null
.filter((item) => item.text.length > 0) ? snapshot.records.battleResult.map((text, index, rows) => ({ id: rows.length - index, text }))
.reverse(); : battleResultContent
.split(/\r?\n/u)
.map((text, index) => ({ id: index + 1, text }))
.filter((item) => item.text.length > 0)
.reverse();
const logs = { const logs = {
generalHistory: { generalHistory: {
available: snapshot.availability.history, available: snapshot.availability.history,
+47 -2
View File
@@ -197,7 +197,22 @@ const context = (
intel: 60, intel: 60,
officer_level: 8, officer_level: 8,
personal: 3, 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: '<C>●</>첫 기록<br><Y>●</>둘째 기록<br>', history: '<C>●</>첫 기록<br><Y>●</>둘째 기록<br>',
records: { battleResult: ['둘째 전투 결과', '첫째 전투 결과'] },
availability: { battleResultLogs: true },
}, },
}, },
{ {
@@ -238,7 +253,22 @@ const context = (
power: 70, power: 70,
intel: 60, intel: 60,
officer_level: 8, 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: '<C>●</>첫 기록<br><Y>●</>둘째 기록<br>', history: '<C>●</>첫 기록<br><Y>●</>둘째 기록<br>',
records: { battleResult: ['둘째 전투 결과', '첫째 전투 결과'] },
availability: { battleResultLogs: true },
}, },
} }
: null, : null,
@@ -369,7 +399,16 @@ describe('archive.myPastPlays', () => {
dynastyPath: '/dynasty/7', dynastyPath: '/dynasty/7',
nation: { id: 3, name: '촉', color: '#ff0000' }, nation: { id: 3, name: '촉', color: '#ff0000' },
general: expect.objectContaining({ id: 10, name: '과거장수' }), 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: { logs: {
generalHistory: { generalHistory: {
available: true, available: true,
@@ -379,7 +418,13 @@ describe('archive.myPastPlays', () => {
], ],
}, },
battleDetail: { available: false, entries: [] }, battleDetail: { available: false, entries: [] },
battleResult: { available: false, entries: [] }, battleResult: {
available: true,
entries: [
{ id: 2, text: '둘째 전투 결과' },
{ id: 1, text: '첫째 전투 결과' },
],
},
generalAction: { available: false, entries: [] }, generalAction: { available: false, entries: [] },
}, },
}); });
@@ -143,7 +143,9 @@ type ActiveGeneral = {
const buildActiveGeneralArchive = ( const buildActiveGeneralArchive = (
general: ActiveGeneral, general: ActiveGeneral,
ranks: Record<string, number>,
history: string[], history: string[],
battleResults: string[],
cancellation: { id: string; at: Date; reason: string } cancellation: { id: string; at: Date; reason: string }
): InputJsonValue => ): InputJsonValue =>
asJson({ asJson({
@@ -190,9 +192,14 @@ const buildActiveGeneralArchive = (
}, },
}, },
lastTurn: general.lastTurn, lastTurn: general.lastTurn,
meta: general.meta, meta: {
...asRecord(general.meta),
...Object.fromEntries(Object.entries(ranks).map(([key, value]) => [`rank_${key}`, value])),
},
penalty: general.penalty, penalty: general.penalty,
history, history,
records: { battleResult: battleResults },
availability: { battleResultLogs: true },
abandonedGame: { abandonedGame: {
cancellationId: cancellation.id, cancellationId: cancellation.id,
cancelledAt: cancellation.at.toISOString(), cancelledAt: cancellation.at.toISOString(),
@@ -266,14 +273,14 @@ const cancelGameInTransaction = async (
]); ]);
const activeIds = activeGenerals.map((general) => general.id); 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.rankData.findMany({ where: { generalId: { in: activeIds } } }) : [],
activeIds.length activeIds.length
? prisma.logEntry.findMany({ ? prisma.logEntry.findMany({
where: { where: {
generalId: { in: activeIds }, generalId: { in: activeIds },
scope: LogScope.GENERAL, scope: LogScope.GENERAL,
category: LogCategory.HISTORY, category: { in: [LogCategory.HISTORY, LogCategory.BATTLE_BRIEF] },
}, },
orderBy: { id: 'desc' }, orderBy: { id: 'desc' },
}) })
@@ -293,11 +300,19 @@ const cancelGameInTransaction = async (
ranksByGeneral.set(row.generalId, ranks); ranksByGeneral.set(row.generalId, ranks);
} }
const logsByGeneral = new Map<number, string[]>(); const logsByGeneral = new Map<number, string[]>();
for (const row of resolvedHistoryLogs) { const battleResultsByGeneral = new Map<number, string[]>();
for (const row of resolvedRecordLogs) {
if (row.generalId === null) continue; 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); logs.push(row.text);
logsByGeneral.set(row.generalId, logs); target.set(row.generalId, logs);
} }
const participantUsers = new Set<string>(); const participantUsers = new Set<string>();
@@ -464,7 +479,9 @@ const cancelGameInTransaction = async (
turnTime: general.turnTime, turnTime: general.turnTime,
data: buildActiveGeneralArchive( data: buildActiveGeneralArchive(
general as ActiveGeneral, general as ActiveGeneral,
ranksByGeneral.get(general.id) ?? {},
logsByGeneral.get(general.id) ?? [], logsByGeneral.get(general.id) ?? [],
battleResultsByGeneral.get(general.id) ?? [],
abandonment abandonment
), ),
}, },
@@ -477,7 +494,9 @@ const cancelGameInTransaction = async (
turnTime: general.turnTime, turnTime: general.turnTime,
data: buildActiveGeneralArchive( data: buildActiveGeneralArchive(
general as ActiveGeneral, general as ActiveGeneral,
ranksByGeneral.get(general.id) ?? {},
logsByGeneral.get(general.id) ?? [], logsByGeneral.get(general.id) ?? [],
battleResultsByGeneral.get(general.id) ?? [],
abandonment abandonment
), ),
}, },
@@ -286,24 +286,37 @@ const archiveDeletedGeneral = async (
): Promise<void> => { ): Promise<void> => {
const serverId = const serverId =
typeof worldMeta.serverId === 'string' && worldMeta.serverId.trim() ? worldMeta.serverId.trim() : 'default'; typeof worldMeta.serverId === 'string' && worldMeta.serverId.trim() ? worldMeta.serverId.trim() : 'default';
const history = await prisma.logEntry.findMany({ const [recordRows, rankRows] = await Promise.all([
where: { prisma.logEntry.findMany({
generalId: event.generalId, where: {
scope: LogScope.GENERAL, generalId: event.generalId,
category: LogCategory.HISTORY, scope: LogScope.GENERAL,
}, category: { in: [LogCategory.HISTORY, LogCategory.BATTLE_BRIEF] },
orderBy: { id: 'desc' }, },
select: { text: true }, orderBy: { id: 'desc' },
}); select: { category: true, text: true },
const archivedMeta = { ...asRecord(event.before.meta) }; }),
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.inheritRandomUnique;
delete archivedMeta.inheritSpecificSpecialWar; 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 = { const data = {
...event.before, ...event.before,
meta: archivedMeta, meta: archivedMeta,
turnTime: event.before.turnTime.toISOString(), turnTime: event.before.turnTime.toISOString(),
recentWarTime: event.before.recentWarTime?.toISOString() ?? null, recentWarTime: event.before.recentWarTime?.toISOString() ?? null,
history: history.map((entry) => entry.text), history,
records: { battleResult: battleResults },
availability: { battleResultLogs: true },
}; };
await prisma.oldGeneral.upsert({ await prisma.oldGeneral.upsert({
where: { by_no: { serverId, generalNo: event.generalId } }, where: { by_no: { serverId, generalNo: event.generalId } },
@@ -531,29 +531,44 @@ export const persistUnificationFinalization = async (
}); });
const archiveGenerals = [...neutralGenerals, ...winnerGenerals]; const archiveGenerals = [...neutralGenerals, ...winnerGenerals];
const generalHistoryRows = archiveGenerals.length const generalRecordRows = archiveGenerals.length
? await transaction.logEntry.findMany({ ? await transaction.logEntry.findMany({
where: { where: {
generalId: { in: archiveGenerals.map((general) => general.id) }, generalId: { in: archiveGenerals.map((general) => general.id) },
scope: LogScope.GENERAL, scope: LogScope.GENERAL,
category: LogCategory.HISTORY, category: { in: [LogCategory.HISTORY, LogCategory.BATTLE_BRIEF] },
}, },
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
select: { generalId: true, text: true }, select: { generalId: true, category: true, text: true },
}) })
: []; : [];
const historyByGeneral = new Map<number, string[]>(); const historyByGeneral = new Map<number, string[]>();
for (const row of generalHistoryRows) { const battleResultsByGeneral = new Map<number, string[]>();
for (const row of generalRecordRows) {
if (row.generalId === null) continue; if (row.generalId === null) continue;
const history = historyByGeneral.get(row.generalId) ?? []; const target =
history.push(row.text); row.category === LogCategory.HISTORY
historyByGeneral.set(row.generalId, 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) { for (const general of archiveGenerals) {
const ranks = ranksByGeneral.get(general.id) ?? {};
const data = { const data = {
...general, ...general,
meta: {
...asRecord(general.meta),
...Object.fromEntries(Object.entries(ranks).map(([key, value]) => [`rank_${key}`, value])),
},
turnTime: general.turnTime.toISOString(), turnTime: general.turnTime.toISOString(),
history: historyByGeneral.get(general.id) ?? [], history: historyByGeneral.get(general.id) ?? [],
records: { battleResult: [...(battleResultsByGeneral.get(general.id) ?? [])].reverse() },
availability: { battleResultLogs: true },
generationKey: input.generationKey, generationKey: input.generationKey,
}; };
await transaction.oldGeneral.upsert({ await transaction.oldGeneral.upsert({
@@ -1,6 +1,8 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; 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 { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { LogCategory, LogScope } from '@sammo-ts/logic';
import { cancelGame } from '../src/scenario/gameCancellation.js'; import { cancelGame } from '../src/scenario/gameCancellation.js';
@@ -62,13 +64,46 @@ integration('game cancellation transaction', () => {
userId, userId,
name: '취소장수', name: '취소장수',
turnTime: openedAt, 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({ await db.rankData.createMany({
data: [ data: [
{ generalId, nationId: 0, type: 'inherit_spent_dyn', value: 4_500 }, { generalId, nationId: 0, type: 'inherit_spent_dyn', value: 4_500 },
{ generalId, nationId: 0, type: 'warnum', value: 10 }, { 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({ await db.inheritancePoint.createMany({
@@ -180,7 +215,7 @@ integration('game cancellation transaction', () => {
[userId]: { [userId]: {
openingPoint: 10_000, openingPoint: 10_000,
currentPoint: 7_000, currentPoint: 7_000,
earnedPoint: 1_750, earnedPoint: 1_750.005,
retainedEarnedPoint: 700, retainedEarnedPoint: 700,
finalPoint: 10_700, finalPoint: 10_700,
baselineSource: 'OPENING', baselineSource: 'OPENING',
@@ -197,6 +232,23 @@ integration('game cancellation transaction', () => {
const archived = await db.oldGeneral.findMany({ where: { serverId }, orderBy: { generalNo: 'asc' } }); const archived = await db.oldGeneral.findMany({ where: { serverId }, orderBy: { generalNo: 'asc' } });
expect(archived).toHaveLength(2); expect(archived).toHaveLength(2);
expect(archived.every((row) => JSON.stringify(row.data).includes(request.cancellationId))).toBe(true); 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.hallOfFame.count({ where: { serverId } })).resolves.toBe(0);
await expect(db.oldNation.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); await expect(db.emperor.count({ where: { serverId } })).resolves.toBe(0);
@@ -1,5 +1,5 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; 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 { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { LogCategory, LogScope } from '@sammo-ts/logic'; import { LogCategory, LogScope } from '@sammo-ts/logic';
@@ -98,6 +98,10 @@ integration('general turn lifecycle persistence', () => {
inherit_active_action: 2, inherit_active_action: 2,
inheritRandomUnique: true, inheritRandomUnique: true,
dex1: 1_000, dex1: 1_000,
dex2: 1,
dex3: 1,
dex4: 1,
dex5: 1,
}, },
}); });
await db.generalAccessLog.create({ await db.generalAccessLog.create({
@@ -110,6 +114,10 @@ integration('general turn lifecycle persistence', () => {
data: [ data: [
{ generalId: general.id, nationId: 0, type: 'warnum', value: 2 }, { generalId: general.id, nationId: 0, type: 'warnum', value: 2 },
{ generalId: general.id, nationId: 0, type: 'firenum', value: 1 }, { 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({ await db.logEntry.createMany({
@@ -130,6 +138,30 @@ integration('general turn lifecycle persistence', () => {
generalId: general.id, generalId: general.id,
text: '<Y>●</>둘째 기록', text: '<Y>●</>둘째 기록',
}, },
{
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(['<Y>●</>둘째 기록', '<C>●</>첫 기록']); expect(archivedData.history).toEqual(['<Y>●</>둘째 기록', '<C>●</>첫 기록']);
expect(asRecord(archivedData.meta)).not.toHaveProperty('inheritRandomUnique'); expect(asRecord(archivedData.meta)).not.toHaveProperty('inheritRandomUnique');
expect(asRecord(archivedData.meta)).not.toHaveProperty('inheritSpecificSpecialWar'); 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( expect(
await db.inheritancePoint.findUnique({ await db.inheritancePoint.findUnique({
where: { userId_key: { userId: general.userId!, key: 'previous' } }, where: { userId_key: { userId: general.userId!, key: 'previous' } },
@@ -39,6 +39,7 @@ const archivedGeneral = (): TurnGeneral => ({
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { meta: {
killturn: 0, killturn: 0,
dex1: 1_000,
inheritRandomUnique: true, inheritRandomUnique: true,
inheritSpecificSpecialWar: true, inheritSpecificSpecialWar: true,
}, },
@@ -55,7 +56,18 @@ describe('general lifecycle archive history', () => {
deleteMany: vi.fn(async () => ({ count: 1 })), deleteMany: vi.fn(async () => ({ count: 1 })),
}, },
logEntry: { logEntry: {
findMany: vi.fn(async () => [{ text: '<Y>●</>둘째 기록' }, { text: '<C>●</>첫 기록' }]), findMany: vi.fn(async () => [
{ category: LogCategory.BATTLE_BRIEF, text: '둘째 전투 결과' },
{ category: LogCategory.HISTORY, text: '<Y>●</>둘째 기록' },
{ category: LogCategory.BATTLE_BRIEF, text: '첫째 전투 결과' },
{ category: LogCategory.HISTORY, text: '<C>●</>첫 기록' },
]),
},
rankData: {
findMany: vi.fn(async () => [
{ type: 'warnum', value: 2 },
{ type: 'killnum', value: 1 },
]),
}, },
oldGeneral: { upsert }, oldGeneral: { upsert },
} as unknown as GamePrisma.TransactionClient; } as unknown as GamePrisma.TransactionClient;
@@ -73,17 +85,19 @@ describe('general lifecycle archive history', () => {
where: { where: {
generalId: general.id, generalId: general.id,
scope: LogScope.GENERAL, scope: LogScope.GENERAL,
category: LogCategory.HISTORY, category: { in: [LogCategory.HISTORY, LogCategory.BATTLE_BRIEF] },
}, },
orderBy: { id: 'desc' }, orderBy: { id: 'desc' },
select: { text: true }, select: { category: true, text: true },
}); });
expect(upsert).toHaveBeenCalledWith( expect(upsert).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
create: expect.objectContaining({ create: expect.objectContaining({
data: expect.objectContaining({ data: expect.objectContaining({
history: ['<Y>●</>둘째 기록', '<C>●</>첫 기록'], history: ['<Y>●</>둘째 기록', '<C>●</>첫 기록'],
meta: { killturn: 0 }, records: { battleResult: ['둘째 전투 결과', '첫째 전투 결과'] },
availability: { battleResultLogs: true },
meta: { killturn: 0, dex1: 1_000, rank_warnum: 2, rank_killnum: 1 },
}), }),
}), }),
}) })
@@ -1,6 +1,8 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; 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 { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { LogCategory, LogScope } from '@sammo-ts/logic';
import { createAuctionBidder } from '../src/auction/bidder.js'; import { createAuctionBidder } from '../src/auction/bidder.js';
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js'; import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js';
@@ -39,7 +41,9 @@ integration('unification finalization transaction', () => {
await db.inheritanceLog.deleteMany({ where: { userId } }); await db.inheritanceLog.deleteMany({ where: { userId } });
await db.inheritancePoint.deleteMany({ where: { userId } }); await db.inheritancePoint.deleteMany({ where: { userId } });
await db.gameHistory.deleteMany({ where: { serverId } }); 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.rankData.deleteMany({ where: { generalId: fixtureId } });
await db.general.deleteMany({ where: { id: fixtureId } }); await db.general.deleteMany({ where: { id: fixtureId } });
await db.city.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({ await db.inheritancePoint.createMany({
data: [ data: [
{ userId, key: 'previous', value: 100 }, { userId, key: 'previous', value: 100 },
@@ -413,6 +463,28 @@ integration('unification finalization transaction', () => {
where: { generalId_type: { generalId: fixtureId, type: 'inherit_earned' } }, where: { generalId_type: { generalId: fixtureId, type: 'inherit_earned' } },
}) })
).resolves.toMatchObject({ value: 2_160 }); ).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); expect((await db.gameHistory.findUniqueOrThrow({ where: { serverId } })).winnerNation).toBe(fixtureId);
const yearbook = await db.yearbookHistory.findUniqueOrThrow({ const yearbook = await db.yearbookHistory.findUniqueOrThrow({
where: { where: {
@@ -1,7 +1,8 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { normalizeArchivedGeneral, type ArchivedJsonValue } from '@sammo-ts/common';
import type { GamePrisma } from '@sammo-ts/infra'; 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 { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { persistUnificationFinalization, resolveStoredInheritancePoint } from '../src/turn/unificationPersistence.js'; import { persistUnificationFinalization, resolveStoredInheritancePoint } from '../src/turn/unificationPersistence.js';
@@ -187,6 +188,7 @@ describe('persistUnificationFinalization', () => {
const hallCreate = vi.fn().mockResolvedValue({}); const hallCreate = vi.fn().mockResolvedValue({});
const gameHistoryUpdate = vi.fn().mockResolvedValue({}); const gameHistoryUpdate = vi.fn().mockResolvedValue({});
const emperorCreate = vi.fn().mockResolvedValue({}); const emperorCreate = vi.fn().mockResolvedValue({});
const oldGeneralUpsert = vi.fn().mockResolvedValue({});
const transaction = Object.assign({} as GamePrisma.TransactionClient, { const transaction = Object.assign({} as GamePrisma.TransactionClient, {
$executeRaw: vi.fn().mockResolvedValue(1), $executeRaw: vi.fn().mockResolvedValue(1),
$queryRaw: vi.fn().mockResolvedValue([]), $queryRaw: vi.fn().mockResolvedValue([]),
@@ -204,19 +206,41 @@ describe('persistUnificationFinalization', () => {
}, },
inheritanceResult: { create: inheritanceResultCreate }, inheritanceResult: { create: inheritanceResultCreate },
inheritanceLog: { create: inheritanceLogCreate }, 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 }, gameHistory: { count: vi.fn().mockResolvedValue(1), update: gameHistoryUpdate },
hallOfFame: { hallOfFame: {
findFirst: vi.fn().mockResolvedValue(null), findFirst: vi.fn().mockResolvedValue(null),
create: hallCreate, create: hallCreate,
update: vi.fn().mockResolvedValue({}), 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: { oldNation: {
upsert: vi.fn().mockResolvedValue({}), upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]), findMany: vi.fn().mockResolvedValue([]),
}, },
oldGeneral: { upsert: vi.fn().mockResolvedValue({}) }, oldGeneral: { upsert: oldGeneralUpsert },
emperor: { create: emperorCreate }, emperor: { create: emperorCreate },
}); });
await expect(persistUnificationFinalization(transaction, input, buildWorld())).resolves.toEqual({ await expect(persistUnificationFinalization(transaction, input, buildWorld())).resolves.toEqual({
@@ -252,5 +276,28 @@ describe('persistUnificationFinalization', () => {
data: expect.objectContaining({ aux: { winnerNationId: 1, generationKey: input.generationKey } }), 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,
},
});
}); });
}); });
@@ -87,13 +87,17 @@ export interface ArchivedGeneralSnapshotV1 {
}; };
}; };
history: string[]; history: string[];
records: {
/** Newest first, matching the live battle-result panel. */
battleResult: string[];
};
availability: { availability: {
mastery: boolean; mastery: boolean;
battleAggregates: boolean; battleAggregates: boolean;
tactics: boolean; tactics: boolean;
history: boolean; history: boolean;
battleDetailLogs: false; battleDetailLogs: false;
battleResultLogs: false; battleResultLogs: boolean;
}; };
} }
@@ -144,6 +148,11 @@ const historyLines = (value: ArchivedJsonValue | undefined): string[] => {
.filter(Boolean); .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 => const rate = (numerator: number | null, denominator: number | null): number | null =>
numerator === null || denominator === null || denominator <= 0 numerator === null || denominator === null || denominator <= 0
? null ? null
@@ -169,8 +178,11 @@ export const normalizeArchivedGeneral = (
const progression = asRecord(data.progression); const progression = asRecord(data.progression);
const traits = asRecord(data.traits); const traits = asRecord(data.traits);
const resources = asRecord(data.resources); const resources = asRecord(data.resources);
const meta = asRecord(data.meta);
const nestedMastery = asRecord(data.mastery); const nestedMastery = asRecord(data.mastery);
const nestedBattle = asRecord(data.battle); const nestedBattle = asRecord(data.battle);
const nestedRecords = asRecord(data.records);
const storedAvailability = asRecord(data.availability);
const nestedTactics = asRecord(nestedBattle.tactics); const nestedTactics = asRecord(nestedBattle.tactics);
const totalTactics = asRecord(nestedTactics.total); const totalTactics = asRecord(nestedTactics.total);
const leadershipTactics = asRecord(nestedTactics.leadership); const leadershipTactics = asRecord(nestedTactics.leadership);
@@ -179,29 +191,32 @@ export const normalizeArchivedGeneral = (
const masteryValues = oldMastery const masteryValues = oldMastery
? [data.dex0, data.dex10, data.dex20, data.dex30, data.dex40] ? [data.dex0, data.dex10, data.dex20, data.dex30, data.dex40]
: [ : [
data.dex1 ?? nestedMastery.infantry, data.dex1 ?? nestedMastery.infantry ?? meta.dex1,
data.dex2 ?? nestedMastery.archery, data.dex2 ?? nestedMastery.archery ?? meta.dex2,
data.dex3 ?? nestedMastery.cavalry, data.dex3 ?? nestedMastery.cavalry ?? meta.dex3,
data.dex4 ?? nestedMastery.special, data.dex4 ?? nestedMastery.special ?? meta.dex4,
data.dex5 ?? nestedMastery.siege, data.dex5 ?? nestedMastery.siege ?? meta.dex5,
]; ];
const mastery = masteryValues.map(finiteNumber); const mastery = masteryValues.map(finiteNumber);
const battles = firstNumber(data.warnum, nestedBattle.battles); const rankValue = (key: string, ...values: Array<ArchivedJsonValue | undefined>): number | null =>
const wins = firstNumber(data.killnum, nestedBattle.wins); firstNumber(...values, data[`rank_${key}`], meta[`rank_${key}`], meta[key]);
const losses = firstNumber(data.deathnum, nestedBattle.losses); const battles = rankValue('warnum', data.warnum, nestedBattle.battles);
const killedCrew = firstNumber(data.killcrew, nestedBattle.killedCrew); const wins = rankValue('killnum', data.killnum, nestedBattle.wins);
const lostCrew = firstNumber(data.deathcrew, nestedBattle.lostCrew); 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 history = historyLines(data.history);
const battleResultRecords = recordLines(nestedRecords.battleResult ?? data.battleResultRecords);
const tacticValues = [ const tacticValues = [
data.ttw ?? totalTactics.wins, rankValue('ttw', data.ttw, totalTactics.wins),
data.ttd ?? totalTactics.draws, rankValue('ttd', data.ttd, totalTactics.draws),
data.ttl ?? totalTactics.losses, rankValue('ttl', data.ttl, totalTactics.losses),
data.tlw ?? leadershipTactics.wins, rankValue('tlw', data.tlw, leadershipTactics.wins),
data.tld ?? leadershipTactics.draws, rankValue('tld', data.tld, leadershipTactics.draws),
data.tll ?? leadershipTactics.losses, rankValue('tll', data.tll, leadershipTactics.losses),
data.tiw ?? intelligenceTactics.wins, rankValue('tiw', data.tiw, intelligenceTactics.wins),
data.tid ?? intelligenceTactics.draws, rankValue('tid', data.tid, intelligenceTactics.draws),
data.til ?? intelligenceTactics.losses, rankValue('til', data.til, intelligenceTactics.losses),
]; ];
const tacticsAvailable = tacticValues.some((value) => finiteNumber(value) !== null); const tacticsAvailable = tacticValues.some((value) => finiteNumber(value) !== null);
@@ -226,13 +241,20 @@ export const normalizeArchivedGeneral = (
leadershipExperience: firstNumber( leadershipExperience: firstNumber(
data.leadershipExperience, data.leadershipExperience,
data.leadership_exp, 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( intelligenceExperience: firstNumber(
data.intelligenceExperience, data.intelligenceExperience,
data.intel_exp, data.intel_exp,
stats.intelligenceExperience stats.intelligenceExperience,
meta.intel_exp
), ),
}, },
progression: { progression: {
@@ -281,40 +303,44 @@ export const normalizeArchivedGeneral = (
battles, battles,
wins, wins,
losses, losses,
fireSuccesses: firstNumber(data.firenum, nestedBattle.fireSuccesses), fireSuccesses: rankValue('firenum', data.firenum, nestedBattle.fireSuccesses),
kills: firstNumber(nestedBattle.kills, wins), kills: firstNumber(nestedBattle.kills, wins),
deaths: firstNumber(nestedBattle.deaths, losses), deaths: firstNumber(nestedBattle.deaths, losses),
killedCrew, killedCrew,
lostCrew, lostCrew,
winRate: firstNumber(nestedBattle.winRate) ?? rate(wins, battles), winRate: firstNumber(nestedBattle.winRate) ?? rate(wins, battles),
killRate: firstNumber(nestedBattle.killRate) ?? rate(killedCrew, lostCrew), 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: { tactics: {
total: { total: {
wins: firstNumber(data.ttw, totalTactics.wins), wins: rankValue('ttw', data.ttw, totalTactics.wins),
draws: firstNumber(data.ttd, totalTactics.draws), draws: rankValue('ttd', data.ttd, totalTactics.draws),
losses: firstNumber(data.ttl, totalTactics.losses), losses: rankValue('ttl', data.ttl, totalTactics.losses),
}, },
leadership: { leadership: {
wins: firstNumber(data.tlw, leadershipTactics.wins), wins: rankValue('tlw', data.tlw, leadershipTactics.wins),
draws: firstNumber(data.tld, leadershipTactics.draws), draws: rankValue('tld', data.tld, leadershipTactics.draws),
losses: firstNumber(data.tll, leadershipTactics.losses), losses: rankValue('tll', data.tll, leadershipTactics.losses),
}, },
intelligence: { intelligence: {
wins: firstNumber(data.tiw, intelligenceTactics.wins), wins: rankValue('tiw', data.tiw, intelligenceTactics.wins),
draws: firstNumber(data.tid, intelligenceTactics.draws), draws: rankValue('tid', data.tid, intelligenceTactics.draws),
losses: firstNumber(data.til, intelligenceTactics.losses), losses: rankValue('til', data.til, intelligenceTactics.losses),
}, },
}, },
}, },
history, history,
records: { battleResult: battleResultRecords },
availability: { availability: {
mastery: mastery.some((value) => value !== null), mastery: mastery.some((value) => value !== null),
battleAggregates: [battles, wins, losses, killedCrew, lostCrew].some((value) => value !== null), battleAggregates: [battles, wins, losses, killedCrew, lostCrew].some((value) => value !== null),
tactics: tacticsAvailable, tactics: tacticsAvailable,
history: history.length > 0, history: history.length > 0,
battleDetailLogs: false, battleDetailLogs: false,
battleResultLogs: false, battleResultLogs:
storedAvailability.battleResultLogs === true ||
(storedAvailability.battleResultLogs !== false &&
Object.prototype.hasOwnProperty.call(nestedRecords, 'battleResult')),
}, },
}, },
}; };
@@ -68,4 +68,55 @@ describe('normalizeArchivedGeneral', () => {
expect(second.sourceFormat).toBe('core-snapshot-v1'); expect(second.sourceFormat).toBe('core-snapshot-v1');
expect(second.snapshot).toEqual(first.snapshot); 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,
},
});
});
}); });