merge: 최신 main을 정적 프런트엔드 작업에 통합한다
This commit is contained in:
@@ -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 연결이 없지만 영구 보존된
|
||||
이메일 계정이 있으면 자동 로그인하지 않고 그 계정에 연결할지 묻습니다. 기존
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
&
|
||||
<a :href="`${appBase}terms.1.html`">이용약관</a>
|
||||
</p>
|
||||
<p>© 2023 • HideD</p>
|
||||
<p>© 2026 • HideD</p>
|
||||
<p>크롬, 엣지, 파이어폭스에 최적화되어있습니다.</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user