merge: 최신 main을 정적 프런트엔드 작업에 통합한다

This commit is contained in:
2026-08-22 09:33:52 +00:00
23 changed files with 1265 additions and 79 deletions
+6
View File
@@ -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 연결이 없지만 영구 보존된
이메일 계정이 있으면 자동 로그인하지 않고 그 계정에 연결할지 묻습니다. 기존
+9 -5
View File
@@ -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,
+47 -2
View File
@@ -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: '<C>●</>첫 기록<br><Y>●</>둘째 기록<br>',
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: '<C>●</>첫 기록<br><Y>●</>둘째 기록<br>',
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: [] },
},
});
@@ -143,7 +143,9 @@ type ActiveGeneral = {
const buildActiveGeneralArchive = (
general: ActiveGeneral,
ranks: Record<string, number>,
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<number, string[]>();
for (const row of resolvedHistoryLogs) {
const battleResultsByGeneral = new Map<number, string[]>();
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<string>();
@@ -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
),
},
@@ -286,24 +286,37 @@ const archiveDeletedGeneral = async (
): Promise<void> => {
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 } },
@@ -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<number, string[]>();
for (const row of generalHistoryRows) {
const battleResultsByGeneral = new Map<number, string[]>();
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({
@@ -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);
@@ -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: '<Y>●</>둘째 기록',
},
{
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
year: 200,
month: 1,
generalId: general.id,
text: '보존하지 않을 개인 기록',
},
{
scope: LogScope.GENERAL,
category: LogCategory.BATTLE_DETAIL,
year: 200,
month: 1,
generalId: general.id,
text: '보존하지 않을 전투 기록',
},
{
scope: LogScope.GENERAL,
category: LogCategory.BATTLE_BRIEF,
year: 200,
month: 1,
generalId: general.id,
text: '보존할 전투 결과',
},
],
});
@@ -150,6 +182,22 @@ integration('general turn lifecycle persistence', () => {
expect(archivedData.history).toEqual(['<Y>●</>둘째 기록', '<C>●</>첫 기록']);
expect(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' } },
@@ -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: '<Y>●</>둘째 기록' }, { text: '<C>●</>첫 기록' }]),
findMany: vi.fn(async () => [
{ category: LogCategory.BATTLE_BRIEF, text: '둘째 전투 결과' },
{ category: LogCategory.HISTORY, text: '<Y>●</>둘째 기록' },
{ category: LogCategory.BATTLE_BRIEF, text: '첫째 전투 결과' },
{ category: LogCategory.HISTORY, text: '<C>●</>첫 기록' },
]),
},
rankData: {
findMany: vi.fn(async () => [
{ type: 'warnum', value: 2 },
{ type: 'killnum', value: 1 },
]),
},
oldGeneral: { upsert },
} 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: ['<Y>●</>둘째 기록', '<C>●</>첫 기록'],
meta: { killturn: 0 },
records: { battleResult: ['둘째 전투 결과', '첫째 전투 결과'] },
availability: { battleResultLogs: true },
meta: { killturn: 0, dex1: 1_000, rank_warnum: 2, rank_killnum: 1 },
}),
}),
})
@@ -1,6 +1,8 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { 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: {
@@ -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,
},
});
});
});
+37
View File
@@ -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,
@@ -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<ScenarioPreview
id: scenarioId,
title: scenario.title,
year: scenario.startYear ?? null,
defaultStatTotal: scenario.config.stat.total,
fiction: scenario.fiction,
npcCount: scenario.generals.length,
npcExCount: scenario.generalsEx.length,
npcNeutralCount: scenario.generalsNeutral.length,
@@ -272,6 +276,8 @@ const buildScenarioPreviewFromGit = async (commitSha: string, scenarioId: number
id: scenarioId,
title: scenario.title,
year: scenario.startYear ?? null,
defaultStatTotal: scenario.config.stat.total,
fiction: scenario.fiction,
npcCount: scenario.generals.length,
npcExCount: scenario.generalsEx.length,
npcNeutralCount: scenario.generalsNeutral.length,
+31
View File
@@ -98,6 +98,7 @@ const buildCaller = (
apiPort: 15003,
status: 'RUNNING' as const,
buildStatus: 'SUCCEEDED' as const,
buildCommitSha: 'HEAD',
meta: {},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -111,6 +112,7 @@ const buildCaller = (
apiPort: 15015,
status: 'RUNNING' as const,
buildStatus: 'SUCCEEDED' as const,
buildCommitSha: 'HEAD',
meta: {},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -268,6 +270,35 @@ const buildCaller = (
};
describe('gateway auth flow', () => {
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({
@@ -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 () => {
@@ -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<string[]> => {
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
);
});
@@ -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,
@@ -64,7 +64,7 @@ onMounted(() => {
&amp;
<a :href="`${appBase}terms.1.html`">이용약관</a>
</p>
<p>© 2023 HideD</p>
<p>© 2026 HideD</p>
<p>크롬, 엣지, 파이어폭스에 최적화되어있습니다.</p>
</footer>
</div>
+6
View File
@@ -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',
@@ -819,6 +819,25 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded shadow-xl overflow-hidden">
<div
class="bg-zinc-800 px-6 py-2 text-center font-bold text-white border-b border-zinc-700 tracking-widest"
>
커 뮤 니 티 도 구
</div>
<div class="p-6 text-center">
<p class="m-0 text-sm text-zinc-400">
시나리오와 빌드 옵션을 확인하고 운영자에게 전달할 오픈 건의 문구를 만들 수 있습니다.
</p>
<RouterLink
to="/open-suggestion"
class="open-suggestion-link mt-4 inline-flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-white px-6 py-2 rounded border border-zinc-700 transition-colors"
>
오픈 건의 양식 작성
</RouterLink>
</div>
</div>
<!-- Account Management -->
<div class="bg-zinc-900 border border-zinc-800 rounded shadow-xl overflow-hidden">
<div
@@ -872,6 +891,16 @@ 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;
@@ -0,0 +1,488 @@
<script setup lang="ts">
import type { AppRouter } from '@sammo-ts/gateway-api';
import type { inferRouterOutputs } from '@trpc/server';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import DefaultLayout from '../layouts/DefaultLayout.vue';
import {
PROFILE_TURN_TERM_MINUTES,
RESET_AUTORUN_LABELS,
RESET_OPTION_COPY,
SYSTEM_PROFILE_RESET_DEFAULTS,
type ResetAutorunOption,
} from '../utils/resetDefaults';
import { directTrpc, trpc } from '../utils/trpc';
type GatewayOutput = inferRouterOutputs<AppRouter>;
type LobbyProfile = GatewayOutput['lobby']['profiles'][number];
type Scenario = GatewayOutput['lobby']['scenarios'][number];
const router = useRouter();
const profiles = ref<LobbyProfile[]>([]);
const scenarios = ref<Scenario[]>([]);
const catalogLoading = ref(false);
const catalogError = ref('');
const copiedMessage = ref('');
const outputElement = ref<HTMLTextAreaElement | null>(null);
let catalogRequestId = 0;
const form = reactive({
profileName: '',
preopenAt: '',
openAt: '',
scenarioId: null as number | null,
turnTermMinutes: 1,
fiction: 1 as 0 | 1,
npcMode: 0 as 0 | 1 | 2,
sync: SYSTEM_PROFILE_RESET_DEFAULTS.sync,
extend: SYSTEM_PROFILE_RESET_DEFAULTS.extend,
blockGeneralCreate: SYSTEM_PROFILE_RESET_DEFAULTS.blockGeneralCreate,
showImgLevel: SYSTEM_PROFILE_RESET_DEFAULTS.showImgLevel,
tournamentTrig: SYSTEM_PROFILE_RESET_DEFAULTS.tournamentTrig,
joinMode: SYSTEM_PROFILE_RESET_DEFAULTS.joinMode,
autorunEnabled: true,
autorunLimitMinutes: 1440,
autorunOptions: RESET_AUTORUN_LABELS.map(({ value }) => value) as ResetAutorunOption[],
});
const selectedProfile = computed(() => profiles.value.find((profile) => profile.profileName === form.profileName));
const selectedScenario = computed(() => scenarios.value.find((scenario) => scenario.id === form.scenarioId));
const dateOrderValid = computed(
() => !form.preopenAt || !form.openAt || new Date(form.preopenAt).getTime() <= new Date(form.openAt).getTime()
);
const canCopy = computed(
() =>
Boolean(selectedProfile.value && selectedScenario.value && form.preopenAt && form.openAt) &&
dateOrderValid.value &&
(!form.autorunEnabled || form.autorunOptions.length > 0)
);
const formatProposalDate = (value: string): string => {
if (!value) return '-';
const normalized = value.replace('T', ' ');
return normalized.length === 16 ? `${normalized}:00` : normalized;
};
const npcModeText = (value: number): string => ['불가', '가능', '선택 생성 가능'][value] ?? '불가';
const fictionText = (value: number): string => (value === 1 ? '가상' : '사실');
const autorunText = computed(() => {
if (!form.autorunEnabled) return '';
const enabled = new Set(form.autorunOptions);
const labels: string[] = [];
if (enabled.has('develop')) labels.push('내정');
if (enabled.has('warp')) labels.push('순간이동');
if (enabled.has('recruit_high')) labels.push('모병');
else if (enabled.has('recruit')) labels.push('징병');
if (enabled.has('train')) labels.push('훈련/사기진작');
if (enabled.has('battle')) labels.push('출병');
if (enabled.has('chief')) labels.push('사령턴');
const limit =
form.autorunLimitMinutes >= 43_200
? '항상 유효'
: form.autorunLimitMinutes % 60 === 0
? `${form.autorunLimitMinutes / 60}시간 유효`
: `${form.autorunLimitMinutes}분 유효`;
labels.push(limit);
return `자율행동[${labels.join(', ')}]`;
});
const additionalSettingsText = computed(() => {
const settings: string[] = [];
if (!form.sync) settings.push('시간동기화 없음');
if (!form.extend) settings.push('확장 NPC 미포함');
if (form.blockGeneralCreate === 1) settings.push('장수 생성 불가');
if (form.blockGeneralCreate === 2) settings.push('장수명 무작위');
if (form.joinMode === 'onlyRandom') settings.push('랜덤 임관');
if (form.showImgLevel !== SYSTEM_PROFILE_RESET_DEFAULTS.showImgLevel) {
settings.push(['이미지 표시 안함', '전콘 표시', '전콘/병종 표시'][form.showImgLevel] ?? '이미지 표시');
}
if (!form.tournamentTrig) settings.push('토너먼트 수동 시작');
if (autorunText.value) settings.push(autorunText.value);
return settings.length > 0 ? settings.join(', ') : '없음';
});
const suggestionText = computed(() => {
const profile = selectedProfile.value;
const scenario = selectedScenario.value;
const serverName = profile ? `${profile.korName}` : '서버';
const scenarioTitle = scenario?.title ?? '시나리오';
const statTotal = scenario?.defaultStatTotal ?? '-';
return `${serverName}<오픈건의>
- 가오픈 일시 : ${formatProposalDate(form.preopenAt)} -
- 오픈 일시 : ${formatProposalDate(form.openAt)} -
${scenarioTitle} ${form.turnTermMinutes}분 턴 서버
(상성 설정:${fictionText(form.fiction)}), (빙의 여부:${npcModeText(form.npcMode)}), (최대 스탯:${statTotal}), (기타 설정:${additionalSettingsText.value})`;
});
const loadScenarios = async (): Promise<void> => {
const profileName = form.profileName;
const requestId = ++catalogRequestId;
scenarios.value = [];
form.scenarioId = null;
catalogError.value = '';
if (!profileName) return;
catalogLoading.value = true;
try {
const result = await directTrpc.lobby.scenarios.query({ profileName });
if (requestId !== catalogRequestId) return;
scenarios.value = result;
form.scenarioId = result[0]?.id ?? null;
} catch (error) {
if (requestId !== catalogRequestId) return;
catalogError.value = error instanceof Error ? error.message : '시나리오 목록을 불러오지 못했습니다.';
} finally {
if (requestId === catalogRequestId) catalogLoading.value = false;
}
};
watch(
() => form.profileName,
() => void loadScenarios()
);
watch(selectedScenario, (scenario) => {
if (scenario?.fiction === 0 || scenario?.fiction === 1) form.fiction = scenario.fiction;
});
const copySuggestion = async (): Promise<void> => {
if (!canCopy.value) return;
copiedMessage.value = '';
try {
await navigator.clipboard.writeText(suggestionText.value);
} catch {
outputElement.value?.focus();
outputElement.value?.select();
document.execCommand('copy');
}
copiedMessage.value = '오픈 건의 양식을 복사했습니다.';
};
onMounted(async () => {
const me = await trpc.me.query().catch(() => null);
if (!me) {
await router.replace('/');
return;
}
profiles.value = await trpc.lobby.profiles.query();
form.profileName = profiles.value[0]?.profileName ?? '';
});
</script>
<template>
<DefaultLayout>
<main class="suggestion-page">
<header class="page-header">
<div>
<p class="eyebrow">GATEWAY COMMUNITY TOOL</p>
<h1>오픈 건의 양식</h1>
<p>현재 서버 빌드의 시나리오와 빌드 옵션을 살펴보고, 운영자에게 전달할 문구를 만듭니다.</p>
</div>
<RouterLink class="back-link" to="/lobby">서버 목록으로</RouterLink>
</header>
<p class="read-only-notice" role="note">
화면은 조회와 문구 작성만 합니다. 서버 설정, 시나리오, 오픈 시각은 변경되지 않습니다.
</p>
<section class="panel" aria-labelledby="proposal-basic-heading">
<h2 id="proposal-basic-heading">기본 정보</h2>
<div class="field-grid">
<label>
<span>대상 서버</span>
<select v-model="form.profileName" data-testid="proposal-profile">
<option v-for="profile in profiles" :key="profile.profileName" :value="profile.profileName">
{{ profile.korName }}
</option>
</select>
</label>
<label>
<span>시나리오</span>
<select
v-model.number="form.scenarioId"
data-testid="proposal-scenario"
:disabled="catalogLoading || scenarios.length === 0"
>
<option v-for="scenario in scenarios" :key="scenario.id" :value="scenario.id">
{{ scenario.title }}
</option>
</select>
</label>
<label>
<span>가오픈 일시</span>
<input v-model="form.preopenAt" type="datetime-local" step="1" data-testid="proposal-preopen" />
</label>
<label>
<span>오픈 일시</span>
<input v-model="form.openAt" type="datetime-local" step="1" data-testid="proposal-open" />
</label>
</div>
<p v-if="catalogLoading" class="field-status" role="status">활성 빌드의 시나리오를 확인하고 있습니다.</p>
<p v-else-if="catalogError" class="field-error" role="alert">
{{ catalogError }}
<button type="button" @click="loadScenarios">다시 확인</button>
</p>
<p v-if="!dateOrderValid" class="field-error" role="alert">가오픈 일시는 오픈 일시보다 늦을 없습니다.</p>
<dl v-if="selectedScenario" class="scenario-summary" data-testid="scenario-summary">
<div><dt>시작 연도</dt><dd>{{ selectedScenario.year ?? '-' }}</dd></div>
<div><dt>최대 스탯</dt><dd>{{ selectedScenario.defaultStatTotal }}</dd></div>
<div><dt>기본 NPC</dt><dd>{{ selectedScenario.npcCount }}</dd></div>
<div><dt>확장 NPC</dt><dd>{{ selectedScenario.npcExCount }}</dd></div>
<div><dt>중립 NPC</dt><dd>{{ selectedScenario.npcNeutralCount }}</dd></div>
<div><dt>국가</dt><dd>{{ selectedScenario.nations.length }}</dd></div>
</dl>
</section>
<section class="panel" aria-labelledby="proposal-options-heading">
<h2 id="proposal-options-heading">빌드 옵션</h2>
<p class="section-help">선택은 아래 미리보기에만 반영됩니다. 설명은 실제 초기화 옵션의 의미입니다.</p>
<div class="field-grid option-grid">
<label>
<span>{{ RESET_OPTION_COPY.turnTerm.label }}</span>
<select v-model.number="form.turnTermMinutes">
<option v-for="minutes in PROFILE_TURN_TERM_MINUTES" :key="minutes" :value="minutes">
{{ minutes }}
</option>
</select>
<small>{{ RESET_OPTION_COPY.turnTerm.help }}</small>
</label>
<label>
<span>{{ RESET_OPTION_COPY.fiction.label }}</span>
<select v-model.number="form.fiction">
<option :value="1">가상</option>
<option :value="0">연의(사실)</option>
</select>
<small>{{ RESET_OPTION_COPY.fiction.help }}</small>
</label>
<label>
<span>{{ RESET_OPTION_COPY.npcMode.label }}</span>
<select v-model.number="form.npcMode">
<option :value="0">불가</option>
<option :value="1">가능</option>
<option :value="2">선택 생성 가능</option>
</select>
<small>{{ RESET_OPTION_COPY.npcMode.help }}</small>
</label>
<label>
<span>{{ RESET_OPTION_COPY.blockGeneralCreate.label }}</span>
<select v-model.number="form.blockGeneralCreate">
<option :value="0">가능</option>
<option :value="2">장수명 무작위</option>
<option :value="1">불가</option>
</select>
<small>{{ RESET_OPTION_COPY.blockGeneralCreate.help }}</small>
</label>
<label>
<span>{{ RESET_OPTION_COPY.joinMode.label }}</span>
<select v-model="form.joinMode">
<option value="full">일반</option>
<option value="onlyRandom">랜덤 임관</option>
</select>
<small>{{ RESET_OPTION_COPY.joinMode.help }}</small>
</label>
<label>
<span>{{ RESET_OPTION_COPY.showImgLevel.label }}</span>
<select v-model.number="form.showImgLevel">
<option :value="0">안함</option>
<option :value="1">전콘</option>
<option :value="2">전콘, 병종</option>
<option :value="3">전콘, 병종, NPC</option>
</select>
<small>{{ RESET_OPTION_COPY.showImgLevel.help }}</small>
</label>
</div>
<div class="toggle-grid">
<label><input v-model="form.sync" type="checkbox" /> 시간 동기화</label>
<label><input v-model="form.extend" type="checkbox" /> 확장 NPC 포함</label>
<label><input v-model="form.tournamentTrig" type="checkbox" /> 토너먼트 자동 시작</label>
<label><input v-model="form.autorunEnabled" type="checkbox" /> 자율행동 사용</label>
</div>
<fieldset v-if="form.autorunEnabled" class="autorun-options">
<legend>자율행동</legend>
<div class="checkbox-list">
<label v-for="option in RESET_AUTORUN_LABELS" :key="option.value">
<input v-model="form.autorunOptions" type="checkbox" :value="option.value" />
{{ option.label }}
</label>
</div>
<label class="autorun-limit">
<span>{{ RESET_OPTION_COPY.autorunLimit.label }}</span>
<select v-model.number="form.autorunLimitMinutes">
<option :value="60">1시간</option>
<option :value="720">12시간</option>
<option :value="1440">24시간</option>
<option :value="2880">48시간</option>
<option :value="4320">72시간</option>
<option :value="43200">항상</option>
</select>
</label>
<p v-if="form.autorunOptions.length === 0" class="field-error" role="alert">
자율행동을 사용하려면 행동을 하나 이상 선택해야 합니다.
</p>
</fieldset>
</section>
<section class="panel output-panel" aria-labelledby="proposal-output-heading">
<div class="output-heading">
<div>
<h2 id="proposal-output-heading">복사할 양식</h2>
<p>기본값과 같은 고급 옵션은 생략하고, 달라진 옵션과 자율행동만 기타 설정에 표시합니다.</p>
</div>
<button type="button" :disabled="!canCopy" data-testid="copy-proposal" @click="copySuggestion">
양식 복사
</button>
</div>
<textarea
ref="outputElement"
class="suggestion-output"
:value="suggestionText"
readonly
rows="6"
data-testid="proposal-output"
></textarea>
<p class="copy-status" role="status">{{ copiedMessage }}</p>
</section>
<details class="panel catalog-panel">
<summary>시나리오 목록 {{ scenarios.length }} 보기</summary>
<div class="catalog-table-frame">
<table>
<thead><tr><th>ID</th><th>시나리오</th><th>시작</th><th>최대 스탯</th><th>NPC</th><th>국가</th></tr></thead>
<tbody>
<tr v-for="scenario in scenarios" :key="scenario.id">
<td>{{ scenario.id }}</td>
<td>{{ scenario.title }}</td>
<td>{{ scenario.year ?? '-' }}</td>
<td>{{ scenario.defaultStatTotal }}</td>
<td>{{ scenario.npcCount + scenario.npcExCount + scenario.npcNeutralCount }}</td>
<td>{{ scenario.nations.length }}</td>
</tr>
</tbody>
</table>
</div>
</details>
</main>
</DefaultLayout>
</template>
<style scoped>
.suggestion-page {
box-sizing: border-box;
width: min(100% - 32px, 960px);
margin: 0 auto;
padding: 108px 0 40px;
color: #e4e4e7;
}
.page-header,
.output-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
}
.page-header h1,
.panel h2 {
margin: 0;
color: #fff;
}
.page-header h1 { font-size: 28px; line-height: 1.25; }
.page-header p:not(.eyebrow), .section-help, .output-heading p { margin: 8px 0 0; color: #a1a1aa; }
.eyebrow { margin: 0 0 6px; color: #fb923c; font-size: 12px; font-weight: 700; letter-spacing: .12em; }
.back-link { flex: 0 0 auto; color: #fdba74; text-underline-offset: 3px; }
.read-only-notice {
margin: 22px 0;
border: 1px solid #3f3f46;
border-left: 4px solid #f97316;
border-radius: 4px;
background: #18181b;
padding: 12px 14px;
color: #fed7aa;
}
.panel {
margin-top: 18px;
border: 1px solid #3f3f46;
border-radius: 6px;
background: #18181b;
padding: 20px;
}
.panel h2 { font-size: 20px; }
.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-top: 18px; }
.field-grid label, .autorun-limit { display: flex; min-width: 0; flex-direction: column; gap: 6px; }
.field-grid label > span, .autorun-limit > span { color: #fafafa; font-size: 14px; font-weight: 700; }
.field-grid small { color: #a1a1aa; font-size: 12px; line-height: 1.45; }
select, input[type='datetime-local'], textarea {
box-sizing: border-box;
width: 100%;
border: 1px solid #52525b;
border-radius: 4px;
background: #09090b;
padding: 10px 11px;
color: #fafafa;
font: inherit;
}
select:focus-visible, input:focus-visible, textarea:focus-visible, button:focus-visible, summary:focus-visible, .back-link:focus-visible {
outline: 2px solid #fb923c;
outline-offset: 2px;
}
.field-status { color: #a1a1aa; }
.field-error { color: #fca5a5; }
.field-error button { border: 0; background: transparent; color: #fdba74; text-decoration: underline; cursor: pointer; }
.scenario-summary { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); margin: 18px 0 0; border: 1px solid #3f3f46; }
.scenario-summary div { min-width: 0; border-right: 1px solid #3f3f46; padding: 10px; text-align: center; }
.scenario-summary div:last-child { border-right: 0; }
.scenario-summary dt { color: #a1a1aa; font-size: 12px; }
.scenario-summary dd { margin: 4px 0 0; color: #fff; font-weight: 700; }
.toggle-grid, .checkbox-list { display: flex; flex-wrap: wrap; gap: 10px 18px; }
.toggle-grid { margin-top: 20px; border-top: 1px solid #3f3f46; padding-top: 16px; }
.toggle-grid label, .checkbox-list label { display: inline-flex; align-items: center; gap: 7px; }
.autorun-options { margin-top: 18px; border: 1px solid #3f3f46; border-radius: 4px; padding: 16px; }
.autorun-options legend { padding: 0 8px; color: #fff; font-weight: 700; }
.autorun-limit { width: min(100%, 260px); margin-top: 16px; }
.output-heading { align-items: center; }
.output-heading h2 { margin: 0; }
.output-heading button {
flex: 0 0 auto;
border: 1px solid #f97316;
border-radius: 4px;
background: #c2410c;
padding: 10px 16px;
color: #fff;
cursor: pointer;
font-weight: 700;
}
.output-heading button:disabled { border-color: #3f3f46; background: #27272a; color: #71717a; cursor: default; }
.suggestion-output { margin-top: 16px; resize: vertical; line-height: 1.6; white-space: pre-wrap; }
.copy-status { min-height: 20px; margin: 8px 0 0; color: #86efac; }
.catalog-panel summary { cursor: pointer; color: #fdba74; font-weight: 700; }
.catalog-table-frame { margin-top: 14px; overflow-x: auto; }
.catalog-table-frame table { width: 100%; min-width: 680px; border-collapse: collapse; }
.catalog-table-frame th, .catalog-table-frame td { border-bottom: 1px solid #3f3f46; padding: 9px; text-align: left; }
.catalog-table-frame th { color: #a1a1aa; font-size: 12px; }
@media (max-width: 700px) {
.suggestion-page { width: min(100% - 24px, 960px); padding-top: 96px; }
.page-header { flex-direction: column; gap: 10px; }
.field-grid, .scenario-summary { grid-template-columns: minmax(0, 1fr); }
.scenario-summary div { display: flex; justify-content: space-between; border-right: 0; border-bottom: 1px solid #3f3f46; text-align: left; }
.scenario-summary div:last-child { border-bottom: 0; }
.scenario-summary dd { margin: 0; }
.panel { padding: 16px; }
.output-heading { align-items: stretch; flex-direction: column; }
.output-heading button { width: 100%; }
}
</style>
@@ -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<ArchivedJsonValue | undefined>): 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')),
},
},
};
@@ -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,
},
});
});
});