merge: 최신 main을 0.1.0 사전점검에 통합

This commit is contained in:
2026-08-19 11:22:44 +00:00
33 changed files with 1187 additions and 210 deletions
+1
View File
@@ -202,6 +202,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)
+18 -1
View File
@@ -1,6 +1,6 @@
import { TRPCError } from '@trpc/server';
import { asRecord } from '@sammo-ts/common';
import { asNumber, asRecord } from '@sammo-ts/common';
import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
import { isSelectionPoolWorld, resolveSelectionMaxGeneral } from '@sammo-ts/game-engine/turn/selectPoolService.js';
@@ -26,7 +26,14 @@ export const lobbyRouter = router({
const userCnt = await ctx.db.general.count({ where: { npcState: { lt: 2 } } });
const npcCnt = await ctx.db.general.count({ where: { npcState: { gte: 2 } } });
const nationCnt = await ctx.db.nation.count({ where: { level: { gt: 0 } } });
const rawConfig = asRecord(rawWorldState.config);
const scenarioTitle = asRecord(asRecord(rawWorldState.meta).scenarioMeta).title;
const autorunUser = worldState.meta.autorun_user;
const autorunOptions = autorunUser?.options
? Object.entries(autorunUser.options)
.filter(([, enabled]) => enabled)
.map(([option]) => option)
: [];
const gameTime = await loadCurrentGameTime(ctx.db);
let myGeneral = null;
@@ -55,10 +62,20 @@ export const lobbyRouter = router({
fictionMode: worldState.config.fictionMode ?? '사실',
starttime: worldState.meta.starttime ?? '',
opentime: worldState.meta.opentime ?? '',
preopenAt: worldState.meta.preopenAt ?? '',
turntime: worldState.meta.turntime ?? '',
serverTime: gameTime.now.toISOString(),
clockMode: gameTime.mode ?? 'realtime',
otherTextInfo: worldState.meta.otherTextInfo ?? '',
npcMode: worldState.config.npcMode ?? 0,
defaultStatTotal: asNumber(asRecord(rawConfig.stat).total, 165),
autorunUser:
autorunUser?.limit_minutes && autorunUser.limit_minutes > 0 && autorunOptions.length > 0
? {
limitMinutes: autorunUser.limit_minutes,
options: autorunOptions,
}
: null,
isUnited: worldState.meta.isunited ?? worldState.meta.isUnited ?? 0,
selectionPoolEnabled: isSelectionPoolWorld(rawWorldState),
npcPossessionEnabled: worldState.config.npcMode === 1,
+47 -2
View File
@@ -10,7 +10,8 @@ const buildContext = (
tick?: bigint;
mode?: string;
wallAnchor?: Date;
} = {}
} = {},
config: Record<string, unknown> = {}
): GameApiContext =>
({
auth: null,
@@ -22,7 +23,7 @@ const buildContext = (
currentYear: 200,
currentMonth: 1,
tickSeconds: 3_600,
config: {},
config,
meta,
clockBaseTime: clock.baseTime ?? null,
clockTick: clock.tick ?? null,
@@ -67,4 +68,48 @@ describe('lobby season state', () => {
expect(result.serverTime).toBe('2026-08-15T02:00:00.000Z');
expect(result.clockMode).toBe('manual');
});
it('projects the Ref-compatible opening announcement settings without exposing disabled autorun options', async () => {
const result = await appRouter
.createCaller(
buildContext(
{
preopenAt: '2026-08-19 22:00:00',
opentime: '2026-08-19 23:00:00',
scenarioMeta: { title: '【가상모드27-b】 아시아 명장전(비급)' },
autorun_user: {
limit_minutes: 1_440,
options: {
develop: true,
warp: true,
recruit: false,
recruit_high: true,
train: true,
battle: true,
chief: true,
},
},
},
{},
{
fictionMode: '가상',
npcMode: 0,
stat: { total: 310, min: 10, max: 110 },
}
)
)
.lobby.info();
expect(result).toMatchObject({
preopenAt: '2026-08-19 22:00:00',
opentime: '2026-08-19 23:00:00',
scenarioTitle: '【가상모드27-b】 아시아 명장전(비급)',
npcMode: 0,
defaultStatTotal: 310,
autorunUser: {
limitMinutes: 1_440,
options: ['develop', 'warp', 'recruit_high', 'train', 'battle', 'chief'],
},
});
});
});
+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,
}),
},
});
@@ -789,6 +789,30 @@ describe('admin operation API', () => {
).rejects.toBeDefined();
});
it('stores event season zero as the next season number', async () => {
const harness = await buildCaller(
async () => {
throw new Error('not used');
},
{ adminRoles: ['admin.profiles.settings:che:2'], firstUserIsAdmin: false }
);
await harness.caller.admin.profiles.updateMeta({
profileName: 'che:2',
patch: { nextSeasonIdx: 0 },
reason: 'prepare event season',
});
expect(harness.updatedMetas.at(-1)).toMatchObject({ nextSeasonIdx: 0 });
await expect(
harness.caller.admin.profiles.updateMeta({
profileName: 'che:2',
patch: { nextSeasonIdx: -1 },
reason: 'reject negative season',
})
).rejects.toBeDefined();
});
it('does not let a scenario-only operator combine a Git update with reset', async () => {
const harness = await buildCaller(
async () => {
@@ -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`) });
}
}
@@ -51,7 +51,18 @@ type LobbyFixtureOptions = {
isUnited?: number;
starttime?: string;
opentime?: string;
preopenAt?: string;
turntime?: string;
turnTerm?: number;
scenarioTitle?: string;
npcMode?: number;
defaultStatTotal?: number;
korName?: string;
otherTextInfo?: string;
autorunUser?: {
limitMinutes: number;
options: string[];
} | null;
lobbyBundleFailures?: number;
profileStatus?: 'RUNNING' | 'PREOPEN' | 'PAUSED' | 'COMPLETED' | 'STOPPED';
includeStoppedProfile?: boolean;
@@ -78,7 +89,15 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
isUnited = 0,
starttime = '2026-07-30 00:00:00',
opentime = '2026-07-30 00:00:00',
preopenAt = '',
turntime = '2026-07-30 00:05:00',
turnTerm = 5,
scenarioTitle = '',
npcMode = 0,
defaultStatTotal = 165,
korName = 'hwe',
otherTextInfo = '',
autorunUser = null,
lobbyBundleFailures = 0,
profileStatus = 'RUNNING',
includeStoppedProfile = false,
@@ -134,7 +153,7 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
battleSimRunning: true,
tournamentRunning: true,
},
korName: 'hwe',
korName,
color: '#ffffff',
localAccountPolicy: {
accessAllowed: true,
@@ -223,12 +242,17 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
maxUserCnt,
npcCnt: 0,
nationCnt,
turnTerm: 5,
turnTerm,
fictionMode: '가상',
starttime,
opentime,
preopenAt,
turntime,
otherTextInfo: '',
otherTextInfo,
scenarioTitle,
npcMode,
defaultStatTotal,
autorunUser,
isUnited,
selectionPoolEnabled,
npcPossessionEnabled,
@@ -279,6 +303,118 @@ test('exchanges the gateway token before loading authenticated lobby general dat
});
});
test('copies the complete preopen announcement and reveals autorun details without changing layout', async ({
page,
}, testInfo) => {
await installFixture(page, {
roles: ['superuser'],
kakaoVerified: false,
profileStatus: 'PREOPEN',
korName: '훼',
specialAccess: {
kind: 'OPERATOR',
grantId: null,
expiresAt: null,
allowsGeneralCreation: true,
},
preopenAt: '2026-08-19 22:00:00',
opentime: '2026-08-19 23:00:00',
starttime: '2026-08-19 23:00:00',
turnTerm: 1,
scenarioTitle: '【가상모드27-b】 아시아 명장전(비급)',
npcMode: 0,
defaultStatTotal: 310,
autorunUser: {
limitMinutes: 1_440,
options: ['develop', 'warp', 'recruit_high', 'train', 'battle', 'chief'],
},
});
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('lobby');
const row = page.locator('tbody tr').filter({ hasText: '훼섭' });
const serverName = row.locator('.profile-server-cell .font-bold');
const settings = row.locator('.profile-announcement-settings');
const autorun = row.locator('.copyable-autorun');
const detail = row.locator('.copyable-autorun-detail');
await expect(row.getByTestId('profile-preopen-at')).toHaveText('- 가오픈 일시 : 2026-08-19 22:00:00 -');
await expect(row.getByTestId('profile-open-at')).toHaveText('- 오픈 일시 : 2026-08-19 23:00:00 -');
await expect(row.getByTestId('profile-scenario-announcement')).toHaveText(
'【가상모드27-b】 아시아 명장전(비급) 1분 턴 서버'
);
const settingsText = (await settings.textContent())?.replace(/\s+/g, ' ').trim();
expect(settingsText).toBe(
'(상성 설정:가상), (빙의 여부:불가), (최대 스탯:310), ' +
'(기타 설정:자율행동[내정, 순간이동, 모병, 훈련/사기진작, 출병, 사령턴, 24시간 유효])'
);
await expect(detail).toHaveCSS('font-size', '0px');
await expect(detail).toHaveCSS('color', 'rgba(0, 0, 0, 0)');
await expect(page.getByText('특수 접근 · OPERATOR')).toHaveCount(0);
await expect(page.getByText('카카오 인증이 필요합니다.')).toHaveCount(0);
const baseGeometry = await row.evaluate((element) => ({
row: element.getBoundingClientRect().toJSON(),
documentWidth: document.documentElement.scrollWidth,
}));
await autorun.hover();
await expect(detail).toBeVisible();
await expect(detail).toContainText('내정, 순간이동, 모병, 훈련/사기진작, 출병, 사령턴, 24시간 유효');
const hoverGeometry = await row.evaluate((element) => ({
row: element.getBoundingClientRect().toJSON(),
documentWidth: document.documentElement.scrollWidth,
}));
expect(hoverGeometry).toEqual(baseGeometry);
await page.screenshot({ path: testInfo.outputPath('gateway-autorun-announcement-hover.png'), fullPage: true });
await autorun.focus();
await expect(autorun).toBeFocused();
await expect(detail).toBeVisible();
await expect(autorun).toHaveCSS('outline-width', '2px');
await page.mouse.click(8, 8);
const start = await serverName.boundingBox();
const end = await settings.boundingBox();
if (!start || !end) throw new Error('expected announcement selection geometry');
await page.mouse.move(start.x + 1, start.y + start.height / 2);
await page.mouse.down();
await page.mouse.move(end.x + end.width - 1, end.y + end.height / 2, { steps: 24 });
await page.mouse.up();
const selectedText = await page.evaluate(() => window.getSelection()?.toString() ?? '');
const compactSelection = selectedText.replace(/\s+/g, ' ').trim();
expect(compactSelection).toContain(
'훼섭 - 가오픈 일시 : 2026-08-19 22:00:00 - - 오픈 일시 : 2026-08-19 23:00:00 - ' +
'【가상모드27-b】 아시아 명장전(비급) 1분 턴 서버 ' +
'(상성 설정:가상), (빙의 여부:불가), (최대 스탯:310), ' +
'(기타 설정:자율행동[내정, 순간이동, 모병, 훈련/사기진작, 출병, 사령턴, 24시간 유효])'
);
expect(compactSelection).not.toContain('OPERATOR');
await page.evaluate(() => window.getSelection()?.removeAllRanges());
await page.setViewportSize({ width: 390, height: 844 });
await autorun.scrollIntoViewIfNeeded();
const mobileBase = await row.evaluate((element) => ({
row: element.getBoundingClientRect().toJSON(),
documentWidth: document.documentElement.scrollWidth,
}));
await autorun.hover();
await expect(detail).toBeVisible();
const mobileHover = await row.evaluate((element) => {
const tooltip = element.querySelector('.copyable-autorun-detail');
if (!tooltip) throw new Error('expected autorun tooltip');
return {
row: element.getBoundingClientRect().toJSON(),
tooltip: tooltip.getBoundingClientRect().toJSON(),
documentWidth: document.documentElement.scrollWidth,
viewportWidth: window.innerWidth,
};
});
expect(mobileHover.row).toEqual(mobileBase.row);
expect(mobileHover.documentWidth).toBe(mobileHover.viewportWidth);
expect(mobileHover.tooltip.left).toBeGreaterThanOrEqual(8);
expect(mobileHover.tooltip.right).toBeLessThanOrEqual(mobileHover.viewportWidth - 8);
await page.screenshot({ path: testInfo.outputPath('gateway-autorun-announcement-mobile.png'), fullPage: true });
});
test('loads and labels a PAUSED profile whose runtime remains available', async ({ page }, testInfo) => {
const gameOperations = await installFixture(page, { profileStatus: 'PAUSED' });
await page.setViewportSize({ width: 1365, height: 900 });
@@ -484,7 +620,7 @@ test('hides the Kakao verification banner for operator special access', async ({
});
await page.goto('lobby');
await expect(page.getByText('특수 접근 · OPERATOR')).toBeVisible();
await expect(page.getByText('특수 접근 · OPERATOR')).toHaveCount(0);
await expect(page.getByText('카카오 인증이 필요합니다.')).toHaveCount(0);
});
@@ -502,7 +638,7 @@ test('hides the Kakao verification banner when a grant removes the remaining ver
});
await page.goto('lobby');
await expect(page.getByText('특수 접근 · RECOVERY')).toBeVisible();
await expect(page.getByText('특수 접근 · RECOVERY')).toHaveCount(0);
await expect(page.getByText('카카오 인증이 필요합니다.')).toHaveCount(0);
});
@@ -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('운영 공지');
});
@@ -1040,6 +1040,33 @@ test('edits server reset defaults through profile metadata settings', async ({ p
expect(request).toContain('"npcMode":2');
});
test('stores event season zero from server metadata settings', async ({ page }) => {
const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] };
await installFixture(page, state);
await page.goto('admin/servers/che%3Adefault');
const nextSeasonInput = page.getByTestId('next-season-idx');
await nextSeasonInput.fill('0');
await expect(nextSeasonInput).toHaveValue('0');
expect(
await nextSeasonInput.evaluate((element: HTMLInputElement) => ({
valid: element.validity.valid,
valueAsNumber: element.valueAsNumber,
}))
).toEqual({ valid: true, valueAsNumber: 0 });
await page.getByPlaceholder('변경 사유 (필수)').fill('prepare event season');
await page.getByRole('button', { name: '메타 저장' }).click();
await expect
.poll(() => state.requestBodies.find((entry) => entry.operation === 'admin.profiles.updateMeta'))
.toBeTruthy();
const request = JSON.stringify(
state.requestBodies.find((entry) => entry.operation === 'admin.profiles.updateMeta')?.body
);
expect(request).toContain('"nextSeasonIdx":0');
await expect(page.getByTestId('action-toast').filter({ hasText: '메타 저장 완료' })).toBeVisible();
});
test('shows a dismissible error toast when profile metadata persistence fails', async ({ page }, testInfo) => {
const state: FixtureState = {
operations: [],
@@ -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>
+6 -2
View File
@@ -407,7 +407,7 @@ const profileEdits = ref<
color: string;
inGameNotice: string;
profileImageUrl: string;
nextSeasonIdx: string;
nextSeasonIdx: string | number;
localAccountAccessGraceDays: string;
localAccountGeneralCreationGraceDays: string;
resetDefaults: ProfileResetDefaults;
@@ -770,7 +770,10 @@ const updateProfileMeta = async (profileName: string) => {
if (!edit) {
return;
}
const nextSeasonRaw = edit.nextSeasonIdx.trim();
// Vue casts non-empty values from type="number" inputs to numbers even when
// the buffer was initialized with a string. Normalize both runtime shapes so
// event season 0 reaches the metadata mutation instead of throwing on trim().
const nextSeasonRaw = String(edit.nextSeasonIdx).trim();
const nextSeasonIdx = nextSeasonRaw === '' ? null : Number(nextSeasonRaw);
if (nextSeasonIdx !== null && (!Number.isFinite(nextSeasonIdx) || nextSeasonIdx < 0)) {
profileActionStatus.value = {
@@ -2229,6 +2232,7 @@ onMounted(() => {
type="number"
min="0"
step="1"
data-testid="next-season-idx"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="예: 12"
/>
+174 -26
View File
@@ -106,6 +106,34 @@ const handleMapTabKeydown = (event: KeyboardEvent, profileName: string): void =>
const formatGraceEndsAt = (value: string | null | undefined): string => formatServerDateTime(value);
const serverSeasonStatus = (info: LobbyInfo) => resolveServerSeasonStatus(info);
const formatAnnouncementDate = (value: string | null | undefined): string =>
formatServerDateTime(value, { fallback: '-' });
const npcModeText = (mode: number): string => ['불가', '가능', '선택 생성'][mode] ?? '불가';
const autorunDetailText = (info: LobbyInfo): string => {
const autorun = info.autorunUser;
if (!autorun) return '';
const enabled = new Set(autorun.options);
const labels: string[] = [];
if (enabled.has('develop')) labels.push('내정');
if (enabled.has('warp')) labels.push('순간이동');
if (enabled.has('recruit_high')) labels.push('모병');
else if (enabled.has('recruit')) labels.push('징병');
if (enabled.has('train')) labels.push('훈련/사기진작');
if (enabled.has('battle')) labels.push('출병');
if (enabled.has('chief')) labels.push('사령턴');
const limit =
autorun.limitMinutes >= 43_200
? '항상 유효'
: autorun.limitMinutes % 60 === 0
? `${autorun.limitMinutes / 60}시간 유효`
: `${autorun.limitMinutes}분 유효`;
labels.push(limit);
return labels.join(', ');
};
const autorunTooltipId = (profileName: string): string =>
`profile-autorun-${profileName.replaceAll(/[^a-zA-Z0-9_-]/g, '-')}`;
const isProfileRuntimeAvailable = (profile: LobbyProfile): boolean => profile.lifecycle.userAccessible;
const unavailableProfileText = (profile: LobbyProfile): string => {
if (!profile.lifecycle.dataInitialized) return '- DB 초기화 전 · 접근 불가 -';
@@ -455,13 +483,7 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
일시정지 · 조회/예약턴 가능
</div>
<div
v-if="profile.localAccountPolicy?.specialAccess"
class="mt-2 text-xs text-emerald-300"
>
특수 접근 · {{ profile.localAccountPolicy.specialAccess.kind }}
</div>
<div
v-else-if="
v-if="
profile.localAccountPolicy?.requiresKakaoVerification &&
!profile.localAccountPolicy.canCreateGeneral
"
@@ -481,27 +503,91 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
<td class="profile-info-cell px-4 py-4 border-r border-zinc-800">
<template v-if="profileDetails[profile.profileName]">
<div class="space-y-1">
<div>
서기 {{ profileDetails[profile.profileName]?.year }}년
{{ profileDetails[profile.profileName]?.month }}월 (<span
class="text-orange-400"
>{{ profile.scenario }}</span
>)
</div>
<div class="text-zinc-400">
유저 : {{ profileDetails[profile.profileName]?.userCnt }} /
{{ profileDetails[profile.profileName]?.maxUserCnt }}명
<span class="text-cyan-400 ml-2"
>NPC : {{ profileDetails[profile.profileName]?.npcCnt }}명</span
<template v-if="profile.status === 'PREOPEN'">
<div
v-if="profileDetails[profile.profileName]?.preopenAt"
data-testid="profile-preopen-at"
>
<span class="text-green-400 ml-2"
>({{ profileDetails[profile.profileName]?.turnTerm }}분 턴
서버)</span
>
</div>
<div class="text-xs text-zinc-500">
- 가오픈 일시 :
{{
formatAnnouncementDate(
profileDetails[profile.profileName]?.preopenAt
)
}}
-
</div>
<div data-testid="profile-open-at">
- 오픈 일시 :
{{
formatAnnouncementDate(
profileDetails[profile.profileName]?.opentime ||
profileDetails[profile.profileName]?.starttime
)
}}
-
</div>
<div data-testid="profile-scenario-announcement">
<span class="text-orange-400">{{
profileDetails[profile.profileName]?.scenarioTitle ||
profile.scenario
}}</span
>{{ ' ' }}
<span class="text-green-400">
{{ profileDetails[profile.profileName]?.turnTerm }}분 턴 서버
</span>
</div>
</template>
<template v-else>
<div>
서기 {{ profileDetails[profile.profileName]?.year }}년
{{ profileDetails[profile.profileName]?.month }}월 (<span
class="text-orange-400"
>{{ profile.scenario }}</span
>)
</div>
<div class="text-zinc-400">
유저 : {{ profileDetails[profile.profileName]?.userCnt }} /
{{ profileDetails[profile.profileName]?.maxUserCnt }}명
<span class="text-cyan-400 ml-2"
>NPC : {{ profileDetails[profile.profileName]?.npcCnt }}명</span
>
<span class="text-green-400 ml-2"
>({{ profileDetails[profile.profileName]?.turnTerm }}분 턴
서버)</span
>
</div>
</template>
<div class="profile-announcement-settings text-xs text-zinc-500">
(상성 설정:{{ profileDetails[profile.profileName]?.fictionMode }}),
(기타 설정:{{ profileDetails[profile.profileName]?.otherTextInfo }})
<template v-if="profile.status === 'PREOPEN'">
(빙의 여부:{{
npcModeText(profileDetails[profile.profileName]?.npcMode ?? 0)
}}), (최대 스탯:{{
profileDetails[profile.profileName]?.defaultStatTotal
}}),
</template>
(기타 설정:<template
v-if="profileDetails[profile.profileName]?.otherTextInfo"
>{{ profileDetails[profile.profileName]?.otherTextInfo
}}<template v-if="profileDetails[profile.profileName]?.autorunUser"
>,
</template></template
><span
v-if="profileDetails[profile.profileName]?.autorunUser"
class="copyable-autorun"
tabindex="0"
:aria-describedby="autorunTooltipId(profile.profileName)"
>자율행동<span
:id="autorunTooltipId(profile.profileName)"
class="copyable-autorun-detail"
role="tooltip"
><span class="copyable-autorun-bracket">[</span
><span>{{
autorunDetailText(profileDetails[profile.profileName]!)
}}</span
><span class="copyable-autorun-bracket">]</span></span
></span
>)
</div>
</div>
</template>
@@ -793,6 +879,68 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
min-width: 760px;
}
.season-status {
user-select: none;
}
.copyable-autorun {
position: relative;
cursor: help;
text-decoration: underline;
text-underline-offset: 2px;
}
.copyable-autorun-detail {
display: inline;
color: transparent;
font-size: 0;
}
.copyable-autorun-bracket {
color: transparent;
font-size: 0;
}
.copyable-autorun:hover .copyable-autorun-detail,
.copyable-autorun:focus-visible .copyable-autorun-detail {
position: absolute;
z-index: 30;
right: 0;
bottom: calc(100% + 6px);
display: block;
box-sizing: border-box;
width: max-content;
max-width: min(520px, calc(100vw - 32px));
padding: 6px 8px;
border: 1px solid #52525b;
border-radius: 4px;
background: #18181b;
box-shadow: 0 4px 12px rgb(0 0 0 / 45%);
color: #f4f4f5;
font-size: 12px;
line-height: 1.4;
text-align: left;
white-space: normal;
}
.copyable-autorun:focus-visible {
border-radius: 2px;
outline: 2px solid #fdba74;
outline-offset: 2px;
}
@media (max-width: 640px) {
.copyable-autorun:hover .copyable-autorun-detail,
.copyable-autorun:focus-visible .copyable-autorun-detail {
position: fixed;
right: 16px;
bottom: 16px;
left: 16px;
width: auto;
max-width: none;
}
}
.map-preview-tabs {
display: flex;
gap: 4px;
+6 -1
View File
@@ -76,6 +76,11 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
메타가 없거나 유효하지 않으면 기존 시스템 기본값을 사용합니다. 시나리오와
예약·가오픈·정식 오픈 시각은 매 실행마다 선택하므로 서버 기본값에 포함하지
않습니다.
- 서버 상태의 `다음 시즌 번호`는 위 리셋 옵션과 별도의
`GatewayProfile.meta.nextSeasonIdx`이며 0 이상의 정수를 허용합니다. 이벤트
기수는 0을 저장한 뒤 RESET하고, 이벤트 종료 뒤 정상 기수 번호를 다시 저장한
다음 RESET합니다. 빈 값은 강제 번호를 해제하여 기존 게임의 season 또는 신규
기본값 1을 사용한다는 뜻입니다.
- 같은 화면의 `실행 중 게임 옵션`은 리셋 기본값과 별개로 현재 기수 DB의 턴
간격, 장수 생성 제한, 유저 자동턴 제한·동작을 읽어 표시합니다. 세 값은
`admin.profiles.runtime:<name>` 권한과 3자 이상의 사유가 있을 때 하나의
@@ -96,7 +101,7 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
| `admin.profiles.settings:<name>` | 표시 정보·리셋 기본 옵션·Kakao 미인증 접근/장수 생성 유예 |
| `admin.profiles.deploy:<name>` | DB를 유지하는 Git 버전 업데이트, 초기화와 새 버전 결합 |
| `admin.scenarios.reset:<name>` | 현재 배포 버전으로 시나리오 초기화 |
| `admin.games.cancel:<name>` | 진행 게임 취소, 기록 옵션과 유산 포인트 보전율 확정 |
| `admin.games.cancel:<name>` | 진행 게임 취소, 기록 옵션과 유산 포인트 보전율 확정 |
| `admin.reset.schedule:<name>` | 허용된 시나리오 초기화를 미래 시각에 예약 |
| `admin.releases.manage` | profile과 분리된 Gateway control plane 배포·rollback |
+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"
}
]
}
}