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