merge: 일반 사용자 오픈 건의 양식을 main에 반영한다

This commit is contained in:
2026-08-22 09:13:01 +00:00
10 changed files with 780 additions and 0 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 연결이 없지만 영구 보존된
이메일 계정이 있으면 자동 로그인하지 않고 그 계정에 연결할지 묻습니다. 기존
+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,
+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>