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 @@
+
+
+
+
+
+
+
+
+ 이 화면은 조회와 문구 작성만 합니다. 서버 설정, 시나리오, 오픈 시각은 변경되지 않습니다.
+
+
+
+ 기본 정보
+
+
+
+
+
+
+ 활성 빌드의 시나리오를 확인하고 있습니다.
+
+ {{ catalogError }}
+
+
+ 가오픈 일시는 오픈 일시보다 늦을 수 없습니다.
+
+
+ - 시작 연도
- {{ selectedScenario.year ?? '-' }}년
+ - 최대 스탯
- {{ selectedScenario.defaultStatTotal }}
+ - 기본 NPC
- {{ selectedScenario.npcCount }}명
+ - 확장 NPC
- {{ selectedScenario.npcExCount }}명
+ - 중립 NPC
- {{ selectedScenario.npcNeutralCount }}명
+ - 국가
- {{ selectedScenario.nations.length }}개
+
+
+
+
+
+
+
+
+
복사할 양식
+
기본값과 같은 고급 옵션은 생략하고, 달라진 옵션과 자율행동만 기타 설정에 표시합니다.
+
+
+
+
+ {{ copiedMessage }}
+
+
+
+ 시나리오 목록 {{ scenarios.length }}개 보기
+
+
+ | ID | 시나리오 | 시작 | 최대 스탯 | NPC | 국가 |
+
+
+ | {{ scenario.id }} |
+ {{ scenario.title }} |
+ {{ scenario.year ?? '-' }} |
+ {{ scenario.defaultStatTotal }} |
+ {{ scenario.npcCount + scenario.npcExCount + scenario.npcNeutralCount }} |
+ {{ scenario.nations.length }} |
+
+
+
+
+
+
+
+
+
+
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,
+ },
+ });
+ });
});