feat(menu): PHP 기준 공통 메뉴를 런타임 설정으로 이관

Gateway와 게임 공통 메뉴를 영속 JSON에서 읽고 다음 페이지 로드에 반영한다. 운영 PHP의 항목과 순서, desktop/mobile geometry 및 정보 동작을 보존하고 검증·복구 문서를 추가한다.
This commit is contained in:
2026-08-19 11:13:57 +00:00
parent b43d7601e7
commit a14282b34d
25 changed files with 744 additions and 173 deletions
+24 -13
View File
@@ -1,8 +1,11 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { expect, test, type Locator, type Page, type Route } from '@playwright/test';
const response = (data: unknown) => ({ result: { data } });
const runtimeNavigation = JSON.parse(
await readFile(new URL('../../../resources/navigation.json', import.meta.url), 'utf8')
) as unknown;
const errorResponse = (path: string, message: string) => ({
error: {
message,
@@ -359,11 +362,16 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
await page.route('**/events**', async (route) => {
await route.abort();
});
await page.route('**/gateway/api/navigation', async (route) => {
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(runtimeNavigation) });
});
await page.route('**/gateway/api/trpc/**', async (route) => {
const operations = operationNames(route);
const results = operations.map((operation) =>
operation === 'me'
? response({ id: 'user-7', username: 'menu-user', displayName: '메뉴 사용자' })
: operation === 'navigation.get'
? response(runtimeNavigation)
: response({ ok: true })
);
await route.fulfill({
@@ -924,7 +932,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
);
await expect(global.locator('[data-navigation-id="nation-list"]')).toHaveAttribute('target', '_blank');
await expect(global.locator('[data-navigation-id="board-community"]')).toHaveAttribute('href', '/xe/community');
await expect(global.locator('[data-navigation-id="official-chat"]')).toHaveAttribute('aria-disabled', 'true');
await expect(global.locator('[data-navigation-id="official-chat"]')).toHaveAttribute('target', '_blank');
await expect(global.locator('[data-navigation-id="survey"]')).toHaveClass(/highlight/);
await expect(page.locator('.main-nation-menu [data-navigation-id="tournament"]')).toHaveClass(/highlight/);
@@ -971,6 +979,14 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
await page.getByRole('heading', { name: '메인 화면 검증 시나리오' }).click();
await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'false');
await gameInfoButton.click();
await global.locator('[data-navigation-id="version"]').click();
const versionDialog = page.getByRole('dialog', { name: '게임 정보' });
await expect(versionDialog).toBeVisible();
await expect(versionDialog).toContainText('메인 화면 검증 시나리오');
await versionDialog.getByRole('button', { name: '닫기' }).click();
await expect(versionDialog).toBeHidden();
const bottomGlobal = page.locator('[data-menu-position="bottom"]');
const bottomGameInfoButton = bottomGlobal.locator('[data-menu-id="game-info"]');
await bottomGameInfoButton.click();
@@ -995,7 +1011,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
});
test('split buttons keep square inner corners and a single divider in every interaction state', async ({
test('nation split buttons keep square inner corners and a single divider in every interaction state', async ({
page,
}, testInfo) => {
const state: NavigationFixture = {
@@ -1062,14 +1078,8 @@ test('split buttons keep square inner corners and a single divider in every inte
for (const width of [1200, 500]) {
await page.setViewportSize({ width, height: 900 });
await waitForMain(page);
const globalSplit = page.locator('.main-global-menu:visible .main-menu-split').first();
const nationSplit = page.locator('.main-nation-menu:visible .nation-menu-split').first();
const pairs: Array<[string, Locator, Locator]> = [
[
'global',
globalSplit.locator('[data-navigation-id="board-community"]'),
globalSplit.locator('[data-menu-id="boards"]'),
],
[
'nation',
nationSplit.locator('[data-navigation-id="auction-resource"]'),
@@ -1129,7 +1139,8 @@ test('the repeated bottom global menu opens upward on the mobile document', asyn
const bottomGlobal = page.locator('[data-menu-position="bottom"]');
const gameInfoButton = bottomGlobal.locator('[data-menu-id="game-info"]');
await gameInfoButton.click();
await gameInfoButton.scrollIntoViewIfNeeded();
await gameInfoButton.evaluate((button) => (button as HTMLElement).click());
await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'true');
await expect(bottomGlobal.locator('#global-menu-game-info')).toBeVisible();
const geometry = await gameInfoButton.evaluate((button) => {
@@ -2418,9 +2429,9 @@ test('all main Lumen button families share the rounded pressed geometry', async
page.locator('.main-global-menu[data-menu-position="top"] [data-navigation-id="nation-betting"]'),
],
[
'게임정보',
'게임 정보',
page.locator('.main-global-menu[data-menu-position="top"]').getByRole('button', {
name: '게임정보',
name: '게임 정보',
exact: true,
}),
],
@@ -2566,7 +2577,7 @@ test('mobile main Lumen button families keep the same state geometry without ove
const controls = [
page.locator('.main-global-menu[data-menu-position="top"] [data-navigation-id="nation-betting"]'),
page.locator('.main-global-menu[data-menu-position="top"]').getByRole('button', {
name: '게임정보',
name: '게임 정보',
exact: true,
}),
page.locator('.layout-mobile [data-navigation-id="meeting"]'),
+2 -1
View File
@@ -12,7 +12,8 @@ const gatewayWebUrl = process.env.PLAYWRIGHT_GATEWAY_WEB_URL ?? '/gateway/';
const useProductionBundle = process.env.PLAYWRIGHT_FRONTEND_MODE === 'production';
const frontendEnv =
`VITE_APP_BASE_PATH=${basePath} VITE_GAME_API_URL=${gameApiUrl} ` +
`VITE_GAME_PROFILE=${gameProfile} VITE_GATEWAY_WEB_URL=${gatewayWebUrl}`;
`VITE_GAME_PROFILE=${gameProfile} VITE_GATEWAY_WEB_URL=${gatewayWebUrl} ` +
'VITE_GATEWAY_API_URL=/gateway/api/trpc';
export default defineConfig({
testDir: '.',
@@ -5,17 +5,23 @@ import {
buildGlobalNavigation,
isNavigationConfigured,
type MainNavigationLink as MainNavigationLinkItem,
type MainNavigationEntry,
} from './mainNavigation';
import { useMenuPopup } from './useMenuPopup';
const props = defineProps<{
npcMode: number;
voteActive: boolean;
entries?: MainNavigationEntry[];
}>();
const entries = computed(() => buildGlobalNavigation(props.npcMode));
const emit = defineEmits<{
action: [action: NonNullable<MainNavigationLinkItem['action']>];
}>();
const entries = computed(() => buildGlobalNavigation(props.npcMode, props.entries));
const { setRoot, openId, close, toggle } = useMenuPopup();
const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props.voteActive;
const isActive = (link: MainNavigationLinkItem) => link.highlightWhen === 'vote' && props.voteActive;
</script>
<template>
@@ -27,6 +33,7 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
:enabled="isNavigationConfigured(entry)"
:active="isActive(entry)"
lumen-variant="navigation"
@action="emit('action', $event)"
/>
<div v-else-if="entry.kind === 'group'" class="main-menu-popup">
<button
@@ -54,6 +61,7 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
:enabled="isNavigationConfigured(item)"
role="menuitem"
@navigate="close()"
@action="emit('action', $event); close()"
/>
</li>
</template>
@@ -65,6 +73,7 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
:enabled="isNavigationConfigured(entry.main)"
:active="isActive(entry.main)"
lumen-variant="navigation"
@action="emit('action', $event)"
/>
<button
class="main-menu-button main-menu-split__toggle legacy-split-button__toggle legacy-button legacy-button--navigation"
@@ -91,6 +100,7 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
:enabled="isNavigationConfigured(item)"
role="menuitem"
@navigate="close()"
@action="emit('action', $event); close()"
/>
</li>
</template>
@@ -9,6 +9,7 @@ import {
nationNavigation,
quickNavigation,
type MainNavigationLink as MainNavigationLinkItem,
type MainNavigationEntry,
type NationNavigationAccess,
type QuickNavigationItem,
} from './mainNavigation';
@@ -21,6 +22,7 @@ const props = defineProps<{
npcMode: number;
realtimeEnabled: boolean;
refreshing: boolean;
entries?: MainNavigationEntry[];
}>();
const emit = defineEmits<{
@@ -28,10 +30,11 @@ const emit = defineEmits<{
toggleRealtime: [];
lobby: [];
quick: [item: QuickNavigationItem];
action: [action: NonNullable<MainNavigationLinkItem['action']>];
}>();
const { setRoot, openId, close, toggle } = useMenuPopup();
const globalEntries = computed(() => buildGlobalNavigation(props.npcMode));
const globalEntries = computed(() => buildGlobalNavigation(props.npcMode, props.entries));
const nationMenuColor = computed(() => props.nationColor || '#000000');
const nationMenuTextColor = computed(() => legacyNationTextColor(nationMenuColor.value));
const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props.tournamentStage;
@@ -40,6 +43,10 @@ const onQuick = (item: QuickNavigationItem) => {
close();
emit('quick', item);
};
const onAction = (action: NonNullable<MainNavigationLinkItem['action']>) => {
close();
emit('action', action);
};
</script>
<template>
@@ -73,6 +80,7 @@ const onQuick = (item: QuickNavigationItem) => {
compact
role="menuitem"
@navigate="close()"
@action="onAction"
/>
</li>
<template v-else-if="entry.kind === 'group'">
@@ -88,6 +96,7 @@ const onQuick = (item: QuickNavigationItem) => {
compact
role="menuitem"
@navigate="close()"
@action="onAction"
/>
</li>
</template>
@@ -100,6 +109,7 @@ const onQuick = (item: QuickNavigationItem) => {
compact
role="menuitem"
@navigate="close()"
@action="onAction"
/>
</li>
<template v-for="item in entry.items" :key="item.id">
@@ -111,6 +121,7 @@ const onQuick = (item: QuickNavigationItem) => {
compact
role="menuitem"
@navigate="close()"
@action="onAction"
/>
</li>
</template>
@@ -20,6 +20,7 @@ const props = withDefaults(
const emit = defineEmits<{
navigate: [];
action: [action: NonNullable<MainNavigationLink['action']>];
}>();
const label = computed(() => (props.compact ? (props.link.compactLabel ?? props.link.label) : props.link.label));
@@ -54,6 +55,16 @@ const lumenClasses = computed(() =>
>
{{ label }}
</a>
<button
v-else-if="enabled && link.action"
class="main-menu-link main-menu-link--button"
:class="[lumenClasses, { highlight: active }]"
type="button"
:data-navigation-id="link.id"
@click="emit('action', link.action)"
>
{{ label }}
</button>
<span
v-else
class="main-menu-link disabled"
@@ -92,6 +103,10 @@ const lumenClasses = computed(() =>
cursor: pointer;
}
.main-menu-link--button {
width: 100%;
}
.main-menu-link:hover,
.main-menu-link:focus-visible,
.main-menu-button:hover,
@@ -1,14 +1,15 @@
import type {
RuntimeNavigationConfig,
RuntimeNavigationEntry,
RuntimeNavigationLink,
} from '@sammo-ts/common/navigation/menuConfig';
import defaultNavigationJson from '../../../../../resources/navigation.json';
export type NationAccessRule =
'always' | 'meeting' | 'secret' | 'nation-member' | 'nation-established' | 'nation-secret';
export type MainNavigationLink = {
kind: 'link';
id: string;
label: string;
to?: string;
href?: string;
export type MainNavigationLink = RuntimeNavigationLink & {
compactLabel?: string;
newTab?: boolean;
access?: NationAccessRule;
highlightStage?: 1 | 6;
unavailableReason?: string;
@@ -49,114 +50,26 @@ export interface QuickNavigationItem {
selector: string;
}
const configuredExternalLink = (
id: string,
label: string,
value: string | undefined,
unavailableReason: string
): MainNavigationLink => {
const href = value?.trim();
return {
kind: 'link',
id,
label,
...(href ? { href } : {}),
newTab: true,
unavailableReason: href ? undefined : unavailableReason,
};
};
const defaultNavigation = defaultNavigationJson as RuntimeNavigationConfig;
export const defaultGlobalNavigation = defaultNavigation.game.items as MainNavigationEntry[];
export const buildGlobalNavigation = (npcMode: number): MainNavigationEntry[] => [
{
kind: 'link',
id: 'nation-betting',
label: '천통국 베팅',
to: '/nation-betting',
},
{
kind: 'group',
id: 'game-info',
label: '게임정보',
items: [
{ kind: 'link', id: 'nation-list', label: '세력일람', to: '/nation-list', newTab: true },
{ kind: 'link', id: 'general-list', label: '장수일람', to: '/general-list', newTab: true },
{ kind: 'link', id: 'best-general', label: '명장일람', to: '/best-general', newTab: true },
{ kind: 'divider', id: 'game-info-divider' },
{ kind: 'link', id: 'hall-of-fame', label: '명예의전당', to: '/hall-of-fame', newTab: true },
{ kind: 'link', id: 'dynasty', label: '왕조일람', to: '/dynasty', newTab: true },
],
},
{ kind: 'link', id: 'yearbook', label: '연감', to: '/yearbook', newTab: true },
{
kind: 'split',
id: 'boards',
main: {
kind: 'link',
id: 'board-community',
label: '게시판',
href: import.meta.env.VITE_BOARD_COMMUNITY_URL?.trim() || '/xe/community',
newTab: true,
},
items: [
configuredExternalLink(
'board-request',
'건의/제안',
import.meta.env.VITE_BOARD_REQUEST_URL,
'건의/제안 게시판 URL이 설정되지 않았습니다.'
),
configuredExternalLink(
'board-tip',
'팁/강좌',
import.meta.env.VITE_BOARD_TIP_URL,
'팁/강좌 게시판 URL이 설정되지 않았습니다.'
),
{ kind: 'divider', id: 'board-divider' },
configuredExternalLink(
'board-patch',
'패치 내역',
import.meta.env.VITE_BOARD_PATCH_URL,
'패치 내역 URL이 설정되지 않았습니다.'
),
],
},
{
kind: 'split',
id: 'open-chat',
main: configuredExternalLink(
'official-chat',
'공식 오픈 톡',
import.meta.env.VITE_OFFICIAL_CHAT_URL,
'공식 오픈톡 URL이 설정되지 않았습니다.'
),
items: [
configuredExternalLink(
'casual-chat',
'잡담 오픈 톡',
import.meta.env.VITE_CASUAL_CHAT_URL,
'잡담 오픈톡 URL이 설정되지 않았습니다.'
),
],
},
{
kind: 'link',
id: 'battle-simulator',
label: '전투 시뮬레이터',
to: '/battle-simulator',
newTab: true,
},
{
kind: 'group',
id: 'other-info',
label: '기타 정보',
items: [
{ kind: 'link', id: 'traffic', label: '접속량정보', to: '/traffic', newTab: true },
...(npcMode > 0
? [{ kind: 'link', id: 'npc-list', label: '빙의일람', to: '/npc-list', newTab: true } as const]
: []),
],
},
{ kind: 'link', id: 'survey', label: '설문조사', to: '/survey', newTab: true },
];
const isVisible = (link: MainNavigationLink, npcMode: number): boolean =>
link.showWhen !== 'npc-enabled' || npcMode > 0;
export const buildGlobalNavigation = (
npcMode: number,
source: RuntimeNavigationEntry[] = defaultGlobalNavigation
): MainNavigationEntry[] =>
source.flatMap((entry): MainNavigationEntry[] => {
if (entry.kind === 'link') return isVisible(entry, npcMode) ? [entry] : [];
const items = entry.items.filter(
(item): item is MainNavigationLink | MainNavigationDivider =>
item.kind === 'divider' || isVisible(item, npcMode)
);
if (entry.kind === 'group') return items.length > 0 ? [{ ...entry, items }] : [];
if (!isVisible(entry.main, npcMode)) return [];
return items.length > 0 ? [{ ...entry, items }] : [entry.main];
});
export const nationNavigation: MainNavigationEntry[] = [
{
@@ -336,7 +249,7 @@ export const quickNavigation: Array<QuickNavigationItem | MainNavigationDivider>
{ id: 'diplomacy-message', label: '외교', tab: 'messages', selector: '[data-message-type="diplomacy"]' },
];
export const isNavigationConfigured = (link: MainNavigationLink): boolean => Boolean(link.to || link.href);
export const isNavigationConfigured = (link: MainNavigationLink): boolean => Boolean(link.to || link.href || link.action);
export const isNationNavigationEnabled = (link: MainNavigationLink, access: NationNavigationAccess): boolean => {
const rule = link.access ?? 'always';
+70 -2
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import type { RuntimeNavigationConfig } from '@sammo-ts/common/navigation/menuConfig';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useMediaQuery } from '@vueuse/core';
@@ -16,7 +17,12 @@ import MainFrontStatus from '../components/main/MainFrontStatus.vue';
import MainGlobalMenu from '../components/main/MainGlobalMenu.vue';
import MainNationMenu from '../components/main/MainNationMenu.vue';
import MainMobileBottomBar from '../components/main/MainMobileBottomBar.vue';
import type { QuickNavigationItem } from '../components/main/mainNavigation';
import {
defaultGlobalNavigation,
type MainNavigationEntry,
type MainNavigationLink,
type QuickNavigationItem,
} from '../components/main/mainNavigation';
import { formatLog } from '../utils/formatLog';
import { useSessionStore } from '../stores/session';
import { useMainDashboardStore } from '../stores/mainDashboard';
@@ -30,6 +36,9 @@ const { info: showInfoToast } = useGameFeedback();
const isMobile = useMediaQuery('(max-width: 939.98px)');
const npcMode = ref(0);
const globalNavigation = ref<MainNavigationEntry[]>(defaultGlobalNavigation);
const versionDialog = ref<HTMLDialogElement | null>(null);
const navigationUrl = (import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc').replace(/\/trpc\/?$/u, '/navigation');
const {
loading,
@@ -95,6 +104,17 @@ onUnmounted(() => {
onMounted(() => {
dashboard.startRealtime();
void fetch(navigationUrl, { headers: { Accept: 'application/json' } })
.then(async (response) => {
if (!response.ok) throw new Error(`메뉴 설정 조회 실패: HTTP ${response.status}`);
return (await response.json()) as RuntimeNavigationConfig;
})
.then((config) => {
globalNavigation.value = config.game.items;
})
.catch((error: unknown) => {
console.warn('운영 메뉴 설정을 불러오지 못해 기본 메뉴를 사용합니다.', error);
});
});
const shiftGeneralTurns = (amount: number) => {
@@ -133,6 +153,10 @@ const moveQuick = (item: QuickNavigationItem) => {
document.querySelector(item.selector)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const handleNavigationAction = (action: NonNullable<MainNavigationLink['action']>) => {
if (action === 'show-version') versionDialog.value?.showModal();
};
watch(
() => [session.isReady, session.hasGeneral],
([ready, hasGeneral]) => {
@@ -146,7 +170,13 @@ watch(
<template>
<main class="game-shell main-page">
<MainGlobalMenu data-menu-position="top" :npc-mode="npcMode" :vote-active="voteActive" />
<MainGlobalMenu
data-menu-position="top"
:npc-mode="npcMode"
:vote-active="voteActive"
:entries="globalNavigation"
@action="handleNavigationAction"
/>
<header class="game-shell__header">
<h1 class="game-shell__title">
@@ -308,6 +338,8 @@ watch(
data-menu-position="middle"
:npc-mode="npcMode"
:vote-active="voteActive"
:entries="globalNavigation"
@action="handleNavigationAction"
/>
<div class="mobile-panel">
@@ -421,6 +453,8 @@ watch(
data-menu-position="middle"
:npc-mode="npcMode"
:vote-active="voteActive"
:entries="globalNavigation"
@action="handleNavigationAction"
/>
<MessagePanel
class="desktop-message-panel"
@@ -449,6 +483,8 @@ watch(
data-menu-position="bottom"
:npc-mode="npcMode"
:vote-active="voteActive"
:entries="globalNavigation"
@action="handleNavigationAction"
/>
</main>
<div v-if="isMobile" class="main-mobile-bottom-spacer" aria-hidden="true"></div>
@@ -460,11 +496,19 @@ watch(
:npc-mode="npcMode"
:realtime-enabled="realtimeEnabled"
:refreshing="refreshing"
:entries="globalNavigation"
@refresh="requestManualRefresh"
@toggle-realtime="dashboard.setRealtimeEnabled(!realtimeEnabled)"
@lobby="moveLobby"
@quick="moveQuick"
@action="handleNavigationAction"
/>
<dialog ref="versionDialog" class="game-version-dialog" aria-labelledby="game-version-title">
<h2 id="game-version-title">게임 정보</h2>
<p>{{ lobbyInfo?.scenarioTitle || 'Core2026' }}</p>
<p>삼국지 모의전투 Core2026</p>
<form method="dialog"><button class="legacy-button legacy-button--navigation" type="submit">닫기</button></form>
</dialog>
</template>
<style scoped>
@@ -475,6 +519,30 @@ button {
color: inherit;
}
.game-version-dialog {
width: min(420px, calc(100vw - 32px));
border: 1px solid #555;
border-radius: 4px;
padding: 18px;
background: #202020;
color: #fff;
text-align: center;
}
.game-version-dialog::backdrop {
background: rgb(0 0 0 / 65%);
}
.game-version-dialog h2,
.game-version-dialog p {
margin: 0 0 12px;
}
.game-version-dialog form {
display: flex;
justify-content: center;
}
/*
* Ref's main document does not clip horizontally; the map panel below manages
* its own overflow.
+7 -3
View File
@@ -34,6 +34,8 @@ export interface GatewayApiConfig {
orchestratorAdminIntervalMs: number;
workspaceRootHint: string;
worktreeRoot: string;
navigationConfigFile: string | null;
defaultNavigationConfigFile: string;
}
export interface GatewayOrchestratorConfig {
@@ -70,6 +72,7 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
const publicBaseUrl = env.GATEWAY_PUBLIC_URL ?? kakaoRedirectUri;
const redisKeyPrefix = env.GATEWAY_REDIS_PREFIX ?? 'sammo:gateway';
const port = parseNumberWithFallback(env.GATEWAY_API_PORT, 13000, 'GATEWAY_API_PORT');
const workspaceRootHint = env.GATEWAY_WORKSPACE_ROOT ?? process.cwd();
return {
host: env.GATEWAY_API_HOST ?? '0.0.0.0',
port,
@@ -129,9 +132,10 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
5000,
'GATEWAY_ORCHESTRATOR_ADMIN_MS'
),
workspaceRootHint: env.GATEWAY_WORKSPACE_ROOT ?? process.cwd(),
worktreeRoot:
env.GATEWAY_WORKTREE_ROOT ?? path.resolve(env.GATEWAY_WORKSPACE_ROOT ?? process.cwd(), '.worktrees'),
workspaceRootHint,
worktreeRoot: env.GATEWAY_WORKTREE_ROOT ?? path.resolve(workspaceRootHint, '.worktrees'),
navigationConfigFile: env.CORE_NAVIGATION_CONFIG_FILE?.trim() || '/srv/data/navigation.json',
defaultNavigationConfigFile: path.resolve(workspaceRootHint, 'resources/navigation.json'),
};
};
+7
View File
@@ -15,6 +15,8 @@ import type { AdminAuthContext } from './adminAuth.js';
import type { PasswordEnvelopeService } from './auth/passwordEnvelope.js';
import { createAdminAuditStore, type AdminAuditStore } from './adminAudit.js';
import type { UserIconUploadStore } from './account/remoteUserIconStore.js';
import path from 'node:path';
import { RuntimeNavigationConfigStore } from './navigation/runtimeNavigationConfig.js';
export interface GatewayApiContext {
users: UserRepository;
@@ -41,6 +43,7 @@ export interface GatewayApiContext {
prisma: GatewayPrismaClient;
adminAudit: AdminAuditStore;
adminAuth?: AdminAuthContext;
navigationConfig: RuntimeNavigationConfigStore;
}
export const createGatewayApiContext = (options: {
@@ -67,6 +70,7 @@ export const createGatewayApiContext = (options: {
requestHeaders?: Record<string, string | string[] | undefined>;
prisma: GatewayPrismaClient;
adminAudit?: AdminAuditStore;
navigationConfig?: RuntimeNavigationConfigStore;
}): GatewayApiContext => ({
users: options.users,
sessions: options.sessions,
@@ -91,4 +95,7 @@ export const createGatewayApiContext = (options: {
requestHeaders: options.requestHeaders ?? {},
prisma: options.prisma,
adminAudit: options.adminAudit ?? createAdminAuditStore(options.prisma),
navigationConfig:
options.navigationConfig ??
new RuntimeNavigationConfigStore(null, path.resolve(process.cwd(), 'resources/navigation.json')),
});
@@ -0,0 +1,126 @@
import fs from 'node:fs/promises';
import type { RuntimeNavigationConfig } from '@sammo-ts/common/navigation/menuConfig';
import { z } from 'zod';
const zId = z.string().min(1).max(80).regex(/^[a-z0-9][a-z0-9-]*$/u);
const zLabel = z.string().min(1).max(80);
const zInternalPath = z
.string()
.min(1)
.max(500)
.refine((value) => value.startsWith('/') && !value.startsWith('//'), '내부 경로는 /로 시작해야 합니다.');
const zExternalHref = z
.string()
.min(1)
.max(1000)
.refine(
(value) =>
(value.startsWith('/') && !value.startsWith('///')) ||
value.startsWith('https://') ||
value.startsWith('http://'),
'링크는 /, //, https:// 또는 http://로 시작해야 합니다.'
);
const zNavigationLink = z
.object({
kind: z.literal('link'),
id: zId,
label: zLabel,
to: zInternalPath.optional(),
href: zExternalHref.optional(),
action: z.literal('show-version').optional(),
newTab: z.boolean().optional(),
showWhen: z.enum(['always', 'npc-enabled']).optional(),
highlightWhen: z.enum(['nation-betting', 'vote']).optional(),
})
.strict()
.superRefine((value, context) => {
const destinations = [value.to, value.href, value.action].filter(Boolean);
if (destinations.length !== 1) {
context.addIssue({
code: 'custom',
message: '메뉴 링크에는 to, href, action 중 하나만 필요합니다.',
});
}
});
const zNavigationDivider = z.object({ kind: z.literal('divider'), id: zId }).strict();
const zNavigationChild = z.union([zNavigationLink, zNavigationDivider]);
const zNavigationEntry = z.union([
zNavigationLink,
z
.object({
kind: z.literal('group'),
id: zId,
label: zLabel,
items: z.array(zNavigationChild).min(1).max(30),
})
.strict(),
z
.object({
kind: z.literal('split'),
id: zId,
main: zNavigationLink,
items: z.array(zNavigationChild).min(1).max(30),
})
.strict(),
]);
export const zRuntimeNavigationConfig: z.ZodType<RuntimeNavigationConfig> = z
.object({
version: z.literal(1),
gateway: z
.object({
brand: z.object({ label: zLabel, to: zInternalPath }).strict(),
items: z
.array(
z
.object({
id: zId,
label: zLabel,
href: zExternalHref,
newTab: z.boolean().optional(),
})
.strict()
)
.max(30),
})
.strict(),
game: z.object({ items: z.array(zNavigationEntry).min(1).max(20) }).strict(),
})
.strict();
export class RuntimeNavigationConfigStore {
constructor(
private readonly overridePath: string | null,
private readonly defaultPath: string
) {}
async get(): Promise<RuntimeNavigationConfig> {
const configPath = await this.resolveConfigPath();
let raw: unknown;
try {
raw = JSON.parse(await fs.readFile(configPath, 'utf8')) as unknown;
} catch (error) {
throw new Error(`메뉴 설정 파일을 읽지 못했습니다: ${configPath}`, { cause: error });
}
const parsed = zRuntimeNavigationConfig.safeParse(raw);
if (!parsed.success) {
throw new Error(`메뉴 설정 파일이 올바르지 않습니다: ${configPath}: ${parsed.error.message}`);
}
return parsed.data;
}
private async resolveConfigPath(): Promise<string> {
if (!this.overridePath) return this.defaultPath;
try {
await fs.access(this.overridePath);
return this.overridePath;
} catch (error) {
const code = error instanceof Error && 'code' in error ? error.code : undefined;
if (code === 'ENOENT') return this.defaultPath;
throw error;
}
}
}
+3
View File
@@ -128,6 +128,9 @@ const finishKakaoLoginOrRequestPasswordSetup = async <T extends 'login' | 'verif
};
export const appRouter = router({
navigation: router({
get: procedure.query(({ ctx }) => ctx.navigationConfig.get()),
}),
health: router({
ping: procedure.query(() => ({
ok: true,
+10
View File
@@ -31,6 +31,7 @@ import { registerProfileStatusInternalRoute } from './lobby/profileStatusInterna
import { installGatewayShutdownController } from './lifecycle/shutdownController.js';
import { RemoteUserIconStore } from './account/remoteUserIconStore.js';
import { gatewayFastifyRouterOptions } from './fastifyOptions.js';
import { RuntimeNavigationConfigStore } from './navigation/runtimeNavigationConfig.js';
export const createGatewayApiServer = async () => {
const config = resolveGatewayApiConfigFromEnv();
@@ -80,6 +81,10 @@ export const createGatewayApiServer = async () => {
);
const releases = createGatewayReleaseRepository(postgres.prisma as GatewayPrismaClient);
const profileStatus = new RepositoryProfileStatusService(profiles, orchestrator);
const navigationConfig = new RuntimeNavigationConfigStore(
config.navigationConfigFile,
config.defaultNavigationConfigFile
);
const app = fastify({
logger: true,
@@ -104,6 +109,10 @@ export const createGatewayApiServer = async () => {
profiles,
secret: config.gameTokenSecret,
});
app.get(config.trpcPath.replace(/\/trpc\/?$/u, '/navigation'), async (_request, reply) => {
void reply.header('Cache-Control', 'no-store');
return navigationConfig.get();
});
await app.register(fastifyTRPCPlugin, {
prefix: config.trpcPath,
@@ -134,6 +143,7 @@ export const createGatewayApiServer = async () => {
profileStatus,
requestHeaders: req.headers,
prisma: postgres.prisma as GatewayPrismaClient,
navigationConfig,
}),
},
});
@@ -0,0 +1,86 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { RuntimeNavigationConfigStore } from '../src/navigation/runtimeNavigationConfig.js';
const temporaryDirectories: string[] = [];
const createTemporaryDirectory = async (): Promise<string> => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-navigation-'));
temporaryDirectories.push(directory);
return directory;
};
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true })));
});
describe('RuntimeNavigationConfigStore', () => {
it('운영 override가 없으면 저장소 기본 메뉴를 읽는다', async () => {
const store = new RuntimeNavigationConfigStore(
'/definitely-missing/navigation.json',
path.resolve(import.meta.dirname, '../../../resources/navigation.json')
);
const config = await store.get();
expect(config.gateway.items.map((item) => item.label)).toEqual([
'공지사항',
'커뮤니티',
'건의/제안/개발',
'신고/문의',
'자주 묻는 질문',
'패치 내역',
'Git Repo.',
'위키',
'공식 오픈 톡',
'잡담 오픈 톡',
]);
expect(config.game.items.map((item) => (item.kind === 'split' ? item.main.label : item.label))).toEqual([
'천통국 베팅',
'세력일람',
'장수일람',
'명장일람',
'연감',
'게임 정보',
'커뮤니티',
'설문조사',
]);
});
it('프로세스를 재시작하지 않아도 운영 JSON 수정이 다음 조회에 반영된다', async () => {
const directory = await createTemporaryDirectory();
const overridePath = path.join(directory, 'navigation.json');
const defaultPath = path.resolve(import.meta.dirname, '../../../resources/navigation.json');
const raw = JSON.parse(await fs.readFile(defaultPath, 'utf8')) as {
gateway: { items: Array<{ label: string }> };
};
await fs.writeFile(overridePath, JSON.stringify(raw));
const store = new RuntimeNavigationConfigStore(overridePath, defaultPath);
expect((await store.get()).gateway.items[0]?.label).toBe('공지사항');
raw.gateway.items[0]!.label = '운영 공지';
await fs.writeFile(overridePath, JSON.stringify(raw));
expect((await store.get()).gateway.items[0]?.label).toBe('운영 공지');
});
it('실행 가능한 스크립트 URL과 목적지가 없는 링크를 거부한다', async () => {
const directory = await createTemporaryDirectory();
const overridePath = path.join(directory, 'navigation.json');
const invalid = {
version: 1,
gateway: {
brand: { label: '삼국지 모의전투 HiDCHe', to: '/' },
items: [{ id: 'unsafe', label: '위험', href: 'javascript:alert(1)' }],
},
game: { items: [{ kind: 'link', id: 'empty', label: '빈 링크' }] },
};
await fs.writeFile(overridePath, JSON.stringify(invalid));
const store = new RuntimeNavigationConfigStore(overridePath, overridePath);
await expect(store.get()).rejects.toThrow('메뉴 설정 파일이 올바르지 않습니다');
});
});
@@ -176,7 +176,7 @@ test('desktop administrator sidebar follows the navbar away and then sticks to t
backgroundColor: string;
}> = [];
for (const scrollY of [0, 20, 55, 56, 120]) {
for (const scrollY of [0, 20, 75, 76, 140]) {
await page.evaluate((top) => window.scrollTo(0, top), scrollY);
await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(scrollY);
@@ -192,16 +192,16 @@ test('desktop administrator sidebar follows the navbar away and then sticks to t
backgroundColor: style.backgroundColor,
};
});
expect(geometry.top).toBeCloseTo(Math.max(0, 56 - scrollY), 0);
expect(geometry.top).toBeCloseTo(Math.max(0, 76 - scrollY), 0);
expect(geometry.position).toBe('sticky');
expect(geometry.backgroundColor).toBe('rgb(17, 17, 19)');
measurements.push({ scrollY, ...geometry });
if (scrollY >= 56) {
if (scrollY >= 76) {
expect(geometry.bottom).toBeCloseTo(geometry.viewportHeight, 0);
}
if (scrollY === 20 || scrollY === 56) {
if (scrollY === 20 || scrollY === 76) {
await page.screenshot({ path: testInfo.outputPath(`admin-sidebar-scroll-${scrollY}.png`) });
}
}
@@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url';
import { defineConfig, devices } from '@playwright/test';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const port = Number(process.env.PLAYWRIGHT_GATEWAY_FRONTEND_PORT ?? 15130);
export default defineConfig({
testDir: '.',
@@ -19,6 +20,7 @@ export default defineConfig({
'kakao-otp.spec.ts',
'kakao-account-recovery.spec.ts',
'public-map-tabs.spec.ts',
'runtime-navigation.spec.ts',
],
fullyParallel: false,
workers: 1,
@@ -29,7 +31,7 @@ export default defineConfig({
reporter: [['list']],
outputDir: resolve(repositoryRoot, 'test-results/server-operations'),
use: {
baseURL: 'http://127.0.0.1:15130/gateway/',
baseURL: `http://127.0.0.1:${port}/gateway/`,
...devices['Desktop Chrome'],
deviceScaleFactor: 1,
colorScheme: 'dark',
@@ -38,9 +40,9 @@ export default defineConfig({
},
webServer: {
command:
"export VITE_APP_BASE_PATH=/gateway VITE_GATEWAY_API_URL=/gateway/api/trpc VITE_GAME_WEB_URL_TEMPLATE='/{profile}/' VITE_GAME_API_URL_TEMPLATE='/{profile}/api/trpc'; pnpm --filter @sammo-ts/gateway-frontend build && pnpm --filter @sammo-ts/gateway-frontend preview --host 127.0.0.1 --port 15130",
`export VITE_APP_BASE_PATH=/gateway VITE_GATEWAY_API_URL=/gateway/api/trpc VITE_GAME_WEB_URL_TEMPLATE='/{profile}/' VITE_GAME_API_URL_TEMPLATE='/{profile}/api/trpc'; pnpm --filter @sammo-ts/gateway-frontend build && pnpm --filter @sammo-ts/gateway-frontend preview --host 127.0.0.1 --port ${port}`,
cwd: repositoryRoot,
url: 'http://127.0.0.1:15130/gateway/',
url: `http://127.0.0.1:${port}/gateway/`,
reuseExistingServer: false,
timeout: 120_000,
},
@@ -0,0 +1,72 @@
import { readFile } from 'node:fs/promises';
import { expect, test, type Page, type Route } from '@playwright/test';
const defaultNavigation = JSON.parse(
await readFile(new URL('../../../resources/navigation.json', import.meta.url), 'utf8')
) as {
gateway: { items: Array<{ id: string; label: string }> };
};
const response = (data: unknown) => ({ result: { data } });
const operationNames = (route: Route) =>
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
const installGatewayFixture = async (page: Page, navigation: unknown = defaultNavigation) => {
await page.route('**/gateway/api/navigation', async (route) => {
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(navigation) });
});
await page.route('**/gateway/api/trpc/**', async (route) => {
const operations = operationNames(route);
const results = operations.map((operation) => {
if (operation === 'navigation.get') return response(navigation);
if (operation === 'me' || operation === 'lobby.notice') return response(null);
if (operation === 'lobby.profiles') return response([]);
return response({ ok: true });
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(operations.length === 1 ? results[0] : results),
});
});
};
test('Gateway 상단 메뉴가 PHP 항목과 desktop geometry를 따른다', async ({ page }) => {
await installGatewayFixture(page);
await page.setViewportSize({ width: 1365, height: 900 });
await page.goto('./');
const navigation = page.locator('#gateway-navigation');
await expect(navigation.locator('a')).toHaveText(defaultNavigation.gateway.items.map((item) => item.label));
await expect(page.locator('.gateway-navbar')).toHaveCSS('height', '76px');
await expect(page.locator('.gateway-navbar')).toHaveCSS('padding', '16px 0px');
await expect(navigation.locator('a').first()).toHaveCSS('font-size', '16px');
await expect(navigation.locator('a').first()).toHaveCSS('padding', '8px');
await navigation.locator('a').first().hover();
await expect(navigation.locator('a').first()).toHaveCSS('color', 'rgb(255, 255, 255)');
});
test('Gateway 모바일 접이식 메뉴가 PHP 40px 행과 전체 너비를 따른다', async ({ page }) => {
await installGatewayFixture(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('./');
await page.getByRole('button', { name: '메뉴 열기' }).click();
const links = page.locator('#gateway-navigation a');
await expect(links).toHaveCount(10);
const first = await links.first().boundingBox();
expect(first).toMatchObject({ x: 1, y: 56, width: 388, height: 40 });
await links.first().focus();
await expect(links.first()).toBeFocused();
await expect(links.first()).toHaveCSS('color', 'rgb(255, 255, 255)');
});
test('JSON 응답을 바꾸면 frontend 재빌드 없이 다음 로드에 반영된다', async ({ page }) => {
const changed = structuredClone(defaultNavigation);
changed.gateway.items[0]!.label = '운영 공지';
await installGatewayFixture(page, changed);
await page.goto('./');
await expect(page.locator('[data-navigation-id="notice"]')).toHaveText('운영 공지');
});
@@ -207,10 +207,10 @@ onMounted(async () => {
.admin-shell {
display: grid;
width: min(1480px, 100%);
min-height: calc(100vh - 56px);
min-height: calc(100vh - 76px);
margin: 0 auto;
grid-template-columns: 244px minmax(0, 1fr);
padding-top: 56px;
padding-top: 76px;
background: #09090b;
}
@@ -392,13 +392,13 @@ onMounted(async () => {
@media (max-width: 860px) {
.admin-shell {
display: block;
padding-top: 72px;
padding-top: 92px;
}
.admin-menu-button {
position: absolute;
z-index: 20;
top: 72px;
top: 92px;
right: 16px;
left: 16px;
display: flex;
@@ -416,7 +416,7 @@ onMounted(async () => {
.admin-sidebar {
position: absolute;
z-index: 19;
top: 124px;
top: 144px;
right: 16px;
left: 16px;
display: none;
@@ -1,15 +1,34 @@
<script setup lang="ts">
import { ref } from 'vue';
import type { RuntimeNavigationConfig } from '@sammo-ts/common/navigation/menuConfig';
import { onMounted, ref } from 'vue';
import defaultNavigationJson from '../../../../resources/navigation.json';
const menuOpen = ref(false);
const appBase = import.meta.env.BASE_URL;
const defaultNavigation = defaultNavigationJson as RuntimeNavigationConfig;
const navigation = ref(defaultNavigation.gateway);
const navigationUrl = (import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc').replace(/\/trpc\/?$/u, '/navigation');
onMounted(() => {
void fetch(navigationUrl, { headers: { Accept: 'application/json' } })
.then(async (response) => {
if (!response.ok) throw new Error(`메뉴 설정 조회 실패: HTTP ${response.status}`);
return (await response.json()) as RuntimeNavigationConfig;
})
.then((config) => {
navigation.value = config.gateway;
})
.catch((error: unknown) => {
console.warn('운영 메뉴 설정을 불러오지 못해 기본 메뉴를 사용합니다.', error);
});
});
</script>
<template>
<div class="gateway-layout">
<header class="gateway-navbar">
<div class="navbar-inner">
<RouterLink class="navbar-brand" to="/">삼국지 모의전투 HiDCHe</RouterLink>
<RouterLink class="navbar-brand" :to="navigation.brand.to">{{ navigation.brand.label }}</RouterLink>
<button
class="navbar-toggler"
type="button"
@@ -21,12 +40,16 @@ const appBase = import.meta.env.BASE_URL;
<span></span><span></span><span></span>
</button>
<nav id="gateway-navigation" :class="{ open: menuOpen }">
<a href="/bbs/board" target="_blank" rel="noreferrer">삼모게시판</a>
<a href="/bbs/tip" target="_blank" rel="noreferrer">/강좌</a>
<a href="/bbs/news" target="_blank" rel="noreferrer">삼국 일보</a>
<a href="/bbs/history2" target="_blank" rel="noreferrer">개인 열전</a>
<a href="/bbs/history3" target="_blank" rel="noreferrer">국가 열전</a>
<a href="/bbs/patch" target="_blank" rel="noreferrer">패치 내역</a>
<a
v-for="item in navigation.items"
:key="item.id"
:href="item.href"
:target="item.newTab ? '_blank' : undefined"
:rel="item.newTab ? 'noopener noreferrer' : undefined"
:data-navigation-id="item.id"
>
{{ item.label }}
</a>
</nav>
</div>
</header>
@@ -66,14 +89,16 @@ const appBase = import.meta.env.BASE_URL;
top: 0;
right: 0;
left: 0;
min-height: 56px;
border-bottom: 1px solid #222;
box-sizing: border-box;
height: 76px;
border: 0;
padding: 16px 0;
background: #303030;
}
.navbar-inner {
display: flex;
min-height: 56px;
width: 100%;
align-items: center;
padding: 0 1px;
}
@@ -90,13 +115,16 @@ const appBase = import.meta.env.BASE_URL;
nav {
display: flex;
flex-grow: 1;
align-items: center;
gap: 16px;
gap: 0;
}
nav a {
color: rgb(255 255 255 / 55%);
font-size: 14px;
padding: 8px;
color: rgb(255 255 255 / 60%);
font-size: 16px;
line-height: 24px;
text-decoration: none;
}
@@ -108,12 +136,12 @@ nav a:focus {
.navbar-toggler {
display: none;
width: 56px;
height: 42px;
height: 40px;
margin-left: auto;
border: 1px solid rgb(255 255 255 / 15%);
border-radius: 6px;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 4px;
background: transparent;
padding: 8px 12px;
padding: 4px 12px;
}
.navbar-toggler span {
@@ -140,10 +168,9 @@ footer a {
color: #666;
}
@media (max-width: 759px) {
@media (max-width: 991.98px) {
.navbar-inner {
flex-wrap: wrap;
padding: 8px 1px;
padding: 0 1px;
}
.navbar-toggler {
@@ -151,12 +178,17 @@ footer a {
}
nav {
position: absolute;
top: 56px;
right: 1px;
left: 1px;
display: none;
width: 100%;
width: auto;
flex-direction: column;
align-items: flex-start;
gap: 0;
padding: 8px 12px;
padding: 0;
background: #303030;
}
nav.open {
@@ -165,7 +197,9 @@ footer a {
nav a {
width: 100%;
padding: 7px 0;
padding: 8px 0;
font-size: 16px;
line-height: 24px;
}
}
</style>