diff --git a/README.md b/README.md index 53122493..82b48175 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,12 @@ orchestrator가 commit별 worktree와 PM2 process를 조정합니다. Gateway 자체 릴리스는 Gateway 프로세스 밖의 `release-controller`가 별도 `GatewayReleaseOperation` queue를 처리합니다. +로그인한 일반 사용자는 Gateway 로비의 `오픈 건의 양식 작성`에서 대상 profile의 +현재 활성 빌드에 포함된 시나리오와 초기화 옵션의 의미를 조회할 수 있습니다. +이 화면은 `/open-suggestion`에서 복사 가능한 제안 문구만 만들며 RESET, 예약, +오픈 또는 다른 서버 mutation을 호출하지 않습니다. 시나리오 catalog API도 +클라이언트 Git ref를 받지 않고 profile에 저장된 `buildCommitSha`만 읽습니다. + Kakao 계정은 OAuth callback과 일반 비밀번호 로그인 모두에서 Kakao 고유 ID와 현재 인증 이메일을 다시 확인합니다. 로컬 고유 ID 연결이 없지만 영구 보존된 이메일 계정이 있으면 자동 로그인하지 않고 그 계정에 연결할지 묻습니다. 기존 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/app/gateway-api/src/router.ts b/app/gateway-api/src/router.ts index 49376b6e..ddeb1bff 100644 --- a/app/gateway-api/src/router.ts +++ b/app/gateway-api/src/router.ts @@ -21,6 +21,7 @@ import { openPassword, zDisplayName, zPasswordEnvelope, zRegistrationUsername } import { resolveEffectiveAccountIcon } from './auth/accountIconProjection.js'; import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js'; import type { GatewayApiContext } from './context.js'; +import { listScenarioPreviews } from './scenario/scenarioCatalog.js'; import { KakaoVerificationError, mergeRequiredKakaoScopes, @@ -193,6 +194,42 @@ export const appRouter = router({ }) ); }), + scenarios: procedure + .input(z.object({ profileName: z.string().min(1).max(64) })) + .query(async ({ ctx, input }) => { + const provided = ctx.requestHeaders['x-session-token']; + const sessionToken = Array.isArray(provided) ? provided[0] : provided; + if (!sessionToken) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Session token is required.' }); + } + const session = await ctx.sessions.getSession(sessionToken); + const user = session ? await ctx.users.findById(session.userId) : null; + if (!session || !user) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Session is not valid.' }); + } + + const visibleProfiles = await ctx.profileStatus.listLobbyProfiles({ userId: user.id }); + if (!visibleProfiles.some((profile) => profile.profileName === input.profileName)) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' }); + } + const profile = await ctx.profiles.getProfile(input.profileName); + const activeBuildCommit = profile?.buildCommitSha?.trim(); + if (!profile || !activeBuildCommit) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'The profile has no active build commit.', + }); + } + + try { + return await listScenarioPreviews({ gitRef: activeBuildCommit }); + } catch { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'The active build scenario catalog could not be read.', + }); + } + }), }), admin: adminRouter, account: accountRouter, diff --git a/app/gateway-api/src/scenario/scenarioCatalog.ts b/app/gateway-api/src/scenario/scenarioCatalog.ts index dca08c66..68307db8 100644 --- a/app/gateway-api/src/scenario/scenarioCatalog.ts +++ b/app/gateway-api/src/scenario/scenarioCatalog.ts @@ -22,6 +22,8 @@ export interface ScenarioPreview { id: number; title: string; year: number | null; + defaultStatTotal: number; + fiction: number | null; npcCount: number; npcExCount: number; npcNeutralCount: number; @@ -227,6 +229,8 @@ const buildScenarioPreview = async (scenarioId: number): Promise { + it('allows a signed-in regular user to read only the active profile scenario catalog', async () => { + const { caller, sealPassword, setSessionHeader } = buildCaller(); + + await expect(caller.lobby.scenarios({ profileName: 'che:default' })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + + const register = await caller.auth.registerLocal({ + username: 'scenario-reader', + credential: sealPassword('scenario-reader-password'), + displayName: '시나리오조회자', + termsAgreed: true, + privacyAgreed: true, + thirdPartyUse: false, + }); + setSessionHeader(register.sessionToken); + + const scenarios = await caller.lobby.scenarios({ profileName: 'che:default' }); + expect(scenarios.length).toBeGreaterThan(0); + expect(scenarios[0]).toMatchObject({ + id: expect.any(Number), + title: expect.any(String), + defaultStatTotal: expect.any(Number), + }); + await expect(caller.lobby.scenarios({ profileName: 'hidden:default' })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + it('registers a local account first and accepts an encrypted password login', async () => { const { caller, users, sealPassword } = buildCaller(); const register = await caller.auth.registerLocal({ diff --git a/app/gateway-api/test/scenarioCatalog.test.ts b/app/gateway-api/test/scenarioCatalog.test.ts index 2fbabb12..6e10874a 100644 --- a/app/gateway-api/test/scenarioCatalog.test.ts +++ b/app/gateway-api/test/scenarioCatalog.test.ts @@ -14,6 +14,10 @@ describe('scenarioCatalog git ref support', () => { const ids = previews.map((scenario) => scenario.id); const sorted = [...ids].sort((a, b) => a - b); expect(ids).toEqual(sorted); + expect(previews.every((scenario) => scenario.defaultStatTotal > 0)).toBe(true); + expect(previews.every((scenario) => scenario.fiction === null || Number.isInteger(scenario.fiction))).toBe( + true + ); }); it('rejects without crashing when git cannot be spawned', async () => { diff --git a/app/gateway-frontend/e2e/open-suggestion.spec.ts b/app/gateway-frontend/e2e/open-suggestion.spec.ts new file mode 100644 index 00000000..61b448b9 --- /dev/null +++ b/app/gateway-frontend/e2e/open-suggestion.spec.ts @@ -0,0 +1,172 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { writeFile } from 'node:fs/promises'; + +const response = (data: unknown) => ({ result: { data } }); + +const operationNames = (route: Route): string[] => { + const url = new URL(route.request().url()); + return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(','); +}; + +const installFixture = async (page: Page): Promise => { + const operations: string[] = []; + await page.addInitScript(() => { + window.localStorage.setItem('sammo-session-token', 'regular-user-session'); + }); + await page.route('**/gateway/api/trpc/**', async (route) => { + expect(route.request().headers()['x-session-token']).toBe('regular-user-session'); + const results = operationNames(route).map((operation) => { + operations.push(operation); + if (operation === 'me') { + return response({ + id: 'regular-user', + username: 'regular-user', + displayName: '일반유저', + roles: [], + createdAt: '2026-08-22T00:00:00.000Z', + }); + } + if (operation === 'lobby.notice') return response(''); + if (operation === 'lobby.profiles') { + return response([ + { + profileName: 'pya:default', + profile: 'pya', + instanceKey: 'default', + currentScenario: '2701', + scenario: '2701', + status: 'STOPPED', + lifecycle: { + runtimeExpected: false, + userAccessible: false, + turnsRunning: false, + operatorResumable: true, + dataInitialized: true, + }, + apiPort: 15015, + runtime: {}, + korName: '퍄', + color: '#f97316', + localAccountPolicy: null, + }, + ]); + } + if (operation === 'lobby.scenarios') { + return response([ + { + id: 2701, + title: '【가상모드27-b】 아시아 명장전(비급)', + year: 180, + defaultStatTotal: 310, + fiction: 1, + npcCount: 210, + npcExCount: 25, + npcNeutralCount: 12, + nations: [{ id: 1, name: '위', color: '#f00', cities: ['낙양'], generals: 5 }], + }, + { + id: 100, + title: '【가상모드】 기본 시나리오', + year: 184, + defaultStatTotal: 165, + fiction: 0, + npcCount: 100, + npcExCount: 0, + npcNeutralCount: 0, + nations: [], + }, + ]); + } + throw new Error(`Unhandled tRPC operation: ${operation}`); + }); + const batched = new URL(route.request().url()).searchParams.get('batch') === '1'; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(batched ? results : results[0]), + }); + }); + return operations; +}; + +test('lets a regular user inspect active-build scenarios and copy an open suggestion without a mutation', async ({ + page, +}, testInfo) => { + const operations = await installFixture(page); + + await page.goto('lobby'); + const suggestionLink = page.getByRole('link', { name: '오픈 건의 양식 작성' }); + await expect(suggestionLink).toBeVisible(); + await suggestionLink.hover(); + await suggestionLink.focus(); + await expect(suggestionLink).toBeFocused(); + await suggestionLink.click(); + + await expect(page).toHaveURL(/\/gateway\/open-suggestion$/); + await expect(page.getByRole('heading', { name: '오픈 건의 양식' })).toBeVisible(); + await expect(page.getByText('서버 설정, 시나리오, 오픈 시각은 변경되지 않습니다.')).toBeVisible(); + await expect(page.getByTestId('scenario-summary')).toContainText('310'); + + await page.getByTestId('proposal-open').fill('2026-08-18T12:00'); + await page.getByTestId('proposal-preopen').fill('2026-08-18T12:30'); + await expect(page.getByText('가오픈 일시는 오픈 일시보다 늦을 수 없습니다.')).toBeVisible(); + await expect(page.getByTestId('copy-proposal')).toBeDisabled(); + await page.getByTestId('proposal-preopen').fill('2026-08-18T11:30'); + const output = page.getByTestId('proposal-output'); + await expect(output).toHaveValue( + `퍄섭<오픈건의> +- 가오픈 일시 : 2026-08-18 11:30:00 - +- 오픈 일시 : 2026-08-18 12:00:00 - +【가상모드27-b】 아시아 명장전(비급) 1분 턴 서버 +(상성 설정:가상), (빙의 여부:불가), (최대 스탯:310), (기타 설정:자율행동[내정, 순간이동, 모병, 훈련/사기진작, 출병, 사령턴, 24시간 유효])` + ); + + await page.getByTestId('copy-proposal').click(); + await expect(page.getByRole('status').filter({ hasText: '오픈 건의 양식을 복사했습니다.' })).toBeVisible(); + + await page.getByText('시간 동기화', { exact: true }).click(); + await expect(output).toHaveValue(/시간동기화 없음/); + + await page.getByText('시나리오 목록 2개 보기').click(); + await expect(page.getByRole('cell', { name: '【가상모드】 기본 시나리오' })).toBeVisible(); + + const desktopGeometry = await page.locator('.suggestion-page').evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + left: rect.left, + right: rect.right, + width: rect.width, + viewportWidth: window.innerWidth, + documentWidth: document.documentElement.scrollWidth, + }; + }); + expect(desktopGeometry.left).toBeGreaterThanOrEqual(0); + expect(desktopGeometry.right).toBeLessThanOrEqual(desktopGeometry.viewportWidth); + expect(desktopGeometry.documentWidth).toBe(desktopGeometry.viewportWidth); + await page.screenshot({ path: testInfo.outputPath('open-suggestion-desktop.png'), fullPage: true }); + + await page.setViewportSize({ width: 390, height: 844 }); + const mobileGeometry = await page.locator('.suggestion-page').evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + left: rect.left, + right: rect.right, + width: rect.width, + viewportWidth: window.innerWidth, + documentWidth: document.documentElement.scrollWidth, + }; + }); + expect(mobileGeometry.left).toBeGreaterThanOrEqual(0); + expect(mobileGeometry.right).toBeLessThanOrEqual(mobileGeometry.viewportWidth); + expect(mobileGeometry.documentWidth).toBe(mobileGeometry.viewportWidth); + await writeFile( + testInfo.outputPath('open-suggestion-geometry.json'), + JSON.stringify({ desktop: desktopGeometry, mobile: mobileGeometry }, null, 2) + ); + await page.screenshot({ path: testInfo.outputPath('open-suggestion-mobile.png'), fullPage: true }); + + expect(operations).toContain('lobby.scenarios'); + expect(operations.every((operation) => ['me', 'lobby.notice', 'lobby.profiles', 'lobby.scenarios'].includes(operation))).toBe( + true + ); +}); diff --git a/app/gateway-frontend/e2e/playwright.config.mjs b/app/gateway-frontend/e2e/playwright.config.mjs index bd4ec7ef..adfb262c 100644 --- a/app/gateway-frontend/e2e/playwright.config.mjs +++ b/app/gateway-frontend/e2e/playwright.config.mjs @@ -21,6 +21,7 @@ export default defineConfig({ 'kakao-account-recovery.spec.ts', 'public-map-tabs.spec.ts', 'runtime-navigation.spec.ts', + 'open-suggestion.spec.ts', ], fullyParallel: false, workers: 1, diff --git a/app/gateway-frontend/src/layouts/DefaultLayout.vue b/app/gateway-frontend/src/layouts/DefaultLayout.vue index 1c626e7d..616840f6 100644 --- a/app/gateway-frontend/src/layouts/DefaultLayout.vue +++ b/app/gateway-frontend/src/layouts/DefaultLayout.vue @@ -64,7 +64,7 @@ onMounted(() => { & 이용약관

-

© 2023 • HideD

+

© 2026 • HideD

크롬, 엣지, 파이어폭스에 최적화되어있습니다.

diff --git a/app/gateway-frontend/src/router/index.ts b/app/gateway-frontend/src/router/index.ts index 0768fa7d..c3f03239 100644 --- a/app/gateway-frontend/src/router/index.ts +++ b/app/gateway-frontend/src/router/index.ts @@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'; const HomeView = () => import('../views/HomeView.vue'); const LobbyView = () => import('../views/LobbyView.vue'); +const OpenSuggestionView = () => import('../views/OpenSuggestionView.vue'); const AdminOverviewView = () => import('../views/AdminOverviewView.vue'); const AdminView = () => import('../views/AdminView.vue'); const ServerOperationsView = () => import('../views/ServerOperationsView.vue'); @@ -27,6 +28,11 @@ const router = createRouter({ name: 'lobby', component: LobbyView, }, + { + path: '/open-suggestion', + name: 'open-suggestion', + component: OpenSuggestionView, + }, { path: '/admin', name: 'admin', diff --git a/app/gateway-frontend/src/views/LobbyView.vue b/app/gateway-frontend/src/views/LobbyView.vue index a57fa64d..5a4ebf38 100644 --- a/app/gateway-frontend/src/views/LobbyView.vue +++ b/app/gateway-frontend/src/views/LobbyView.vue @@ -819,6 +819,25 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => { +
+
+ 커 뮤 니 티 도 구 +
+
+

+ 시나리오와 빌드 옵션을 확인하고 운영자에게 전달할 오픈 건의 문구를 만들 수 있습니다. +

+ + 오픈 건의 양식 작성 + +
+
+
{ text-align: center; } +.open-suggestion-link { + min-height: 44px; + text-decoration: none; +} + +.open-suggestion-link:focus-visible { + outline: 2px solid #fdba74; + outline-offset: 2px; +} + .legacy-logout-button { box-sizing: border-box; width: 200px; diff --git a/app/gateway-frontend/src/views/OpenSuggestionView.vue b/app/gateway-frontend/src/views/OpenSuggestionView.vue new file mode 100644 index 00000000..f4d74494 --- /dev/null +++ b/app/gateway-frontend/src/views/OpenSuggestionView.vue @@ -0,0 +1,488 @@ + + + + + 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, + }, + }); + }); });