merge: PHP 기준 런타임 공통 메뉴 이관

This commit is contained in:
2026-08-19 11:16:01 +00:00
25 changed files with 744 additions and 173 deletions
+1
View File
@@ -199,6 +199,7 @@ pnpm docs:preview
- [아키텍처 개요](docs/architecture/overview.md)
- [런타임 아키텍처](docs/architecture/runtime.md)
- [릴리스 운영 매뉴얼](docs/release-operations.md)
- [Gateway와 게임 공통 메뉴 설정](docs/runtime-navigation.md)
- [차등 검증](docs/architecture/turn-state-differential-testing.md)
- [Caddy prefix 계약](docs/e2e-caddy-routing.md)
- [레거시 DB 이관](docs/legacy-db-migration.md)
+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>
+2
View File
@@ -35,6 +35,8 @@ features:
[시간과 턴](./user/time-and-turns.md)과
[커맨드 목록](./user/command-catalog.generated.md)을 확인해 주세요. Profile과
Gateway 배포는 [릴리스 운영 매뉴얼](./release-operations.md)을 따라 주세요.
[Gateway와 게임 공통 메뉴 설정](./runtime-navigation.md)은 코드 재빌드 없이
상단 링크와 dropdown을 바꾸는 JSON 형식과 복구 경계를 설명합니다.
관리자 화면의 메뉴와 권한·운영 경계는
[관리자 콘솔](./admin-console.md)에서 확인할 수 있습니다.
게임 진행 시각과 운영 벽시계의 경계는
+59
View File
@@ -0,0 +1,59 @@
# Gateway와 게임 공통 메뉴 설정
Gateway 상단 메뉴와 profile 게임 화면의 공통 메뉴는 하나의 JSON 설정을
공유합니다. 저장소 기본값은 `resources/navigation.json`이고 운영 runtime은
`CORE_NAVIGATION_CONFIG_FILE`이 가리키는 파일을 우선합니다. Docker 운영 구성의
기본 경로는 영속 volume 안의 `/srv/data/navigation.json`입니다.
## 반영 경계
`GET /gateway/api/navigation``navigation.get`은 인증 없이 현재 파일을 요청마다
읽고 schema를 검증합니다.
Gateway와 게임 frontend는 화면을 처음 열 때 이를 조회하므로 JSON을 저장한 뒤
브라우저를 새로고침하면 frontend 재빌드나 profile DB 초기화 없이 반영됩니다.
이미 열린 화면을 서버가 강제로 바꾸지는 않습니다.
운영 파일이 아직 없으면 저장소 기본값을 사용합니다. Docker entrypoint는 최초
기동 때만 저장소 기본값을 영속 경로로 복사하고, 이미 존재하는 운영 파일은 배포나
container 재생성 때 덮어쓰지 않습니다. 파일을 읽을 수 없거나 schema가 틀리면
API는 오류를 반환하고 frontend는 빌드에 포함된 안전한 기본 메뉴를 표시합니다.
## 편집 형식
최상위 `version`은 현재 `1`입니다.
- `gateway.brand`: Gateway 브랜드 문구와 내부 `to`
- `gateway.items`: `id`, `label`, `href`, 선택적인 `newTab`
- `game.items`: `link`, `group`, `split` 항목
- `link`: `to`, `href`, `action` 가운데 정확히 하나만 사용
- `divider`: dropdown 구분선이며 고유한 `id`만 사용
- `showWhen: npc-enabled`: NPC 모드에서만 노출
- `highlightWhen: nation-betting|vote`: 해당 실시간 상태일 때 기존 강조색 적용
- `action: show-version`: 현재 지원하는 유일한 로컬 동작으로 버전 정보 dialog 표시
`to`는 profile base path를 보존하는 Vue Router 내부 경로입니다. `/xe`, `/wiki`
같이 Caddy가 소유한 외부 경로는 `href`를 사용합니다. URL은 `/`, `//`,
`https://`, `http://`로 시작하는 값만 허용하며 `javascript:` 같은 실행 URL은
거부합니다. 브라우저 식별과 자동 검증에 쓰이는 `id`는 영문 소문자, 숫자와
하이픈만 사용합니다.
## 운영 변경과 복구
1. `/srv/data/navigation.json`을 별도 위치에 복사해 되돌릴 파일을 확보합니다.
2. 임시 파일에서 편집하고 `jq empty`로 JSON 문법을 확인합니다.
3. 임시 파일을 운영 경로로 같은 filesystem 안에서 교체합니다.
4. `GET /gateway/api/navigation`이 성공하는지 확인합니다.
5. Gateway desktop/mobile과 실제 profile 화면을 새로고침해 순서, 링크,
dropdown과 hover/focus를 확인합니다.
API 검증이 실패하면 직전 복사본을 원래 경로로 되돌립니다. 저장소 기본값으로
완전히 복구하려면 현재 배포 commit의 `resources/navigation.json`을 운영 경로에
복사합니다. 이 작업은 PostgreSQL, Redis, profile release나 현재 시즌을 변경하지
않습니다.
개발 검증은 다음 명령을 사용합니다.
```sh
pnpm --filter @sammo-ts/gateway-api test -- runtimeNavigationConfig.test.ts
pnpm --filter @sammo-ts/gateway-frontend test:e2e:operations --grep 'Gateway 상단 메뉴|Gateway 모바일|JSON 응답'
```
+4
View File
@@ -21,6 +21,10 @@
"./auth/gameSessionTransfer": {
"types": "./dist/auth/gameSessionTransfer.d.ts",
"default": "./dist/auth/gameSessionTransfer.js"
},
"./navigation/menuConfig": {
"types": "./dist/navigation/menuConfig.d.ts",
"default": "./dist/navigation/menuConfig.js"
}
},
"scripts": {
@@ -0,0 +1,57 @@
export type RuntimeNavigationAction = 'show-version';
export type RuntimeNavigationVisibility = 'always' | 'npc-enabled';
export type RuntimeNavigationHighlight = 'nation-betting' | 'vote';
export interface RuntimeNavigationLink {
kind: 'link';
id: string;
label: string;
to?: string;
href?: string;
action?: RuntimeNavigationAction;
newTab?: boolean;
showWhen?: RuntimeNavigationVisibility;
highlightWhen?: RuntimeNavigationHighlight;
}
export interface RuntimeNavigationDivider {
kind: 'divider';
id: string;
}
export interface RuntimeNavigationGroup {
kind: 'group';
id: string;
label: string;
items: Array<RuntimeNavigationLink | RuntimeNavigationDivider>;
}
export interface RuntimeNavigationSplit {
kind: 'split';
id: string;
main: RuntimeNavigationLink;
items: Array<RuntimeNavigationLink | RuntimeNavigationDivider>;
}
export type RuntimeNavigationEntry = RuntimeNavigationLink | RuntimeNavigationGroup | RuntimeNavigationSplit;
export interface GatewayNavigationLink {
id: string;
label: string;
href: string;
newTab?: boolean;
}
export interface RuntimeNavigationConfig {
version: 1;
gateway: {
brand: {
label: string;
to: string;
};
items: GatewayNavigationLink[];
};
game: {
items: RuntimeNavigationEntry[];
};
}
+1
View File
@@ -6,6 +6,7 @@ export default defineConfig({
'auth/gameToken': 'src/auth/gameToken.ts',
'auth/gameSessionTransfer': 'src/auth/gameSessionTransfer.ts',
'auth/sanctions': 'src/auth/sanctions.ts',
'navigation/menuConfig': 'src/navigation/menuConfig.ts',
},
format: 'es',
outDir: 'dist',
+74
View File
@@ -0,0 +1,74 @@
{
"version": 1,
"gateway": {
"brand": {
"label": "삼국지 모의전투 HiDCHe",
"to": "/"
},
"items": [
{ "id": "notice", "label": "공지사항", "href": "/xe/", "newTab": true },
{ "id": "community", "label": "커뮤니티", "href": "/xe/community", "newTab": true },
{ "id": "development", "label": "건의/제안/개발", "href": "/xe/devel", "newTab": true },
{ "id": "report", "label": "신고/문의", "href": "/xe/report", "newTab": true },
{ "id": "faq", "label": "자주 묻는 질문", "href": "/xe/faq", "newTab": true },
{ "id": "patch", "label": "패치 내역", "href": "/wiki/개발/패치_내역", "newTab": true },
{ "id": "repository", "label": "Git Repo.", "href": "//gitea.hided.net/devsam/core", "newTab": true },
{ "id": "wiki", "label": "위키", "href": "/wiki", "newTab": true },
{ "id": "official-chat", "label": "공식 오픈 톡", "href": "https://open.kakao.com/o/gR82obT", "newTab": true },
{ "id": "casual-chat", "label": "잡담 오픈 톡", "href": "https://open.kakao.com/o/g9ZWe5K", "newTab": true }
]
},
"game": {
"items": [
{
"kind": "link",
"id": "nation-betting",
"label": "천통국 베팅",
"to": "/nation-betting",
"highlightWhen": "nation-betting"
},
{ "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": "link", "id": "yearbook", "label": "연감", "to": "/yearbook", "newTab": true },
{
"kind": "group",
"id": "game-info",
"label": "게임 정보",
"items": [
{ "kind": "link", "id": "battle-simulator", "label": "전투 시뮬레이터", "to": "/battle-simulator", "newTab": true },
{ "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": "traffic", "label": "접속량정보", "to": "/traffic", "newTab": true },
{ "kind": "link", "id": "npc-list", "label": "빙의일람", "to": "/npc-list", "newTab": true, "showWhen": "npc-enabled" },
{ "kind": "divider", "id": "game-info-reference-divider" },
{ "kind": "link", "id": "patch", "label": "패치 내역", "href": "/wiki/개발/패치_내역", "newTab": true },
{ "kind": "link", "id": "repository", "label": "코드 저장소", "href": "//storage.hided.net/gitea/devsam/core", "newTab": true },
{ "kind": "divider", "id": "game-info-version-divider" },
{ "kind": "link", "id": "version", "label": "정보", "action": "show-version" }
]
},
{
"kind": "group",
"id": "community",
"label": "커뮤니티",
"items": [
{ "kind": "link", "id": "board-community", "label": "게시판", "href": "/xe/community", "newTab": true },
{ "kind": "link", "id": "board-request", "label": "건의/제안", "href": "/xe/devel", "newTab": true },
{ "kind": "link", "id": "wiki", "label": "위키", "href": "/wiki", "newTab": true },
{ "kind": "divider", "id": "community-chat-divider" },
{ "kind": "link", "id": "official-chat", "label": "공식 오픈 톡", "href": "https://open.kakao.com/o/gR82obT", "newTab": true },
{ "kind": "link", "id": "casual-chat", "label": "잡담 오픈 톡", "href": "https://open.kakao.com/o/g9ZWe5K", "newTab": true }
]
},
{
"kind": "link",
"id": "survey",
"label": "설문조사",
"to": "/survey",
"newTab": true,
"highlightWhen": "vote"
}
]
}
}