From 787016ae1cd59742325430181479036b62998093 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 22 Aug 2026 08:53:40 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EC=A0=80=EC=9E=91=EA=B6=8C=20?= =?UTF-8?q?=ED=91=9C=EA=B8=B0=20=EC=97=B0=EB=8F=84=EB=A5=BC=202026?= =?UTF-8?q?=EB=85=84=EC=9C=BC=EB=A1=9C=20=EA=B0=B1=EC=8B=A0=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/gateway-frontend/src/layouts/DefaultLayout.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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

크롬, 엣지, 파이어폭스에 최적화되어있습니다.

From 1648b6aa84421d94ef4232974c2d7648c3963744 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 22 Aug 2026 09:07:29 +0000 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=EC=9D=BC=EB=B0=98=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EC=98=A4=ED=94=88=20=EA=B1=B4=EC=9D=98=20?= =?UTF-8?q?=EC=96=91=EC=8B=9D=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 활성 profile 빌드의 시나리오 catalog를 세션 기반 읽기 전용 API로 제공한다. Gateway에서 초기화 옵션을 살펴보고 Ref 예약 공지 형식의 제안 문구를 복사하되 서버 mutation은 호출하지 않도록 한다. --- README.md | 6 + app/gateway-api/src/router.ts | 37 ++ .../src/scenario/scenarioCatalog.ts | 6 + app/gateway-api/test/authFlow.test.ts | 31 ++ app/gateway-api/test/scenarioCatalog.test.ts | 4 + .../e2e/open-suggestion.spec.ts | 172 ++++++ .../e2e/playwright.config.mjs | 1 + app/gateway-frontend/src/router/index.ts | 6 + app/gateway-frontend/src/views/LobbyView.vue | 29 ++ .../src/views/OpenSuggestionView.vue | 488 ++++++++++++++++++ 10 files changed, 780 insertions(+) create mode 100644 app/gateway-frontend/e2e/open-suggestion.spec.ts create mode 100644 app/gateway-frontend/src/views/OpenSuggestionView.vue 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/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 @@ + + + + +