diff --git a/.env.example b/.env.example
index 4335b6e..46353e7 100644
--- a/.env.example
+++ b/.env.example
@@ -48,3 +48,14 @@ PROFILE=hwe
SCENARIO=default
DAEMON_REQUEST_TIMEOUT_MS=5000
TRPC_PATH=/trpc
+
+# Frontend public URLs
+# These values are public and become part of the browser bundle.
+VITE_GATEWAY_WEB_URL=/gateway/
+VITE_BOARD_COMMUNITY_URL=/xe/community
+# Set the remaining links only after their public routes are confirmed.
+VITE_BOARD_REQUEST_URL=
+VITE_BOARD_TIP_URL=
+VITE_BOARD_PATCH_URL=
+VITE_OFFICIAL_CHAT_URL=
+VITE_CASUAL_CHAT_URL=
diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts
new file mode 100644
index 0000000..9b654f6
--- /dev/null
+++ b/app/game-frontend/e2e/mainNavigation.spec.ts
@@ -0,0 +1,495 @@
+import { mkdir, writeFile } from 'node:fs/promises';
+import { resolve } from 'node:path';
+import { expect, test, type Page, type Route } from '@playwright/test';
+
+const response = (data: unknown) => ({ result: { data } });
+const artifactRoot = process.env.MAIN_NAVIGATION_ARTIFACT_DIR;
+const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`;
+const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'che:default';
+const operationNames = (route: Route) =>
+ decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
+
+type NavigationFixture = {
+ officerLevel: number;
+ permission: number;
+ nationLevel: number;
+ stage: number;
+ npcMode: number;
+ generalMeCalls: number;
+ operations: string[];
+};
+
+const emptyMessages = (permission: number) => ({
+ private: [],
+ national: [],
+ public: [],
+ diplomacy: [],
+ permission,
+ sequence: -1,
+ hasMore: { private: false, national: false, public: false, diplomacy: false },
+ latestRead: { private: 0, national: 0, public: 0, diplomacy: 0 },
+ canRespondDiplomacy: false,
+});
+
+const generalContext = (state: NavigationFixture) => ({
+ general: {
+ id: 7,
+ name: '메뉴검증장수',
+ nationId: 1,
+ cityId: 1,
+ troopId: 0,
+ npcState: 0,
+ officerLevel: state.officerLevel,
+ picture: null,
+ imageServer: 0,
+ stats: { leadership: 70, strength: 60, intelligence: 50 },
+ gold: 1_000,
+ rice: 2_000,
+ crew: 300,
+ train: 80,
+ atmos: 90,
+ injury: 0,
+ experience: 100,
+ dedication: 200,
+ items: { horse: null, weapon: null, book: null, item: null },
+ },
+ city: {
+ id: 1,
+ name: '업',
+ level: 8,
+ nationId: 1,
+ region: 1,
+ population: 100_000,
+ populationMax: 200_000,
+ agriculture: 1_000,
+ agricultureMax: 2_000,
+ commerce: 1_000,
+ commerceMax: 2_000,
+ security: 1_000,
+ securityMax: 2_000,
+ trust: 80,
+ trade: 100,
+ defence: 1_000,
+ defenceMax: 2_000,
+ wall: 1_000,
+ wallMax: 2_000,
+ supplyState: 1,
+ frontState: 0,
+ },
+ nation: {
+ id: 1,
+ name: '위',
+ color: '#008000',
+ level: state.nationLevel,
+ gold: 10_000,
+ rice: 20_000,
+ tech: 100,
+ rate: 20,
+ bill: 100,
+ capitalCityId: 1,
+ typeCode: 'che_유가',
+ },
+ settings: {},
+ penalties: {},
+});
+
+const installFixture = async (page: Page, state: NavigationFixture) => {
+ await page.addInitScript(
+ ({ profile }) => {
+ localStorage.setItem('sammo-game-token', 'ga_navigation');
+ localStorage.setItem('sammo-game-profile', profile);
+ },
+ { profile: gameProfile }
+ );
+
+ await page.route('**/image/**', async (route) => {
+ await route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') });
+ });
+ await page.route('**/events**', async (route) => {
+ await route.abort();
+ });
+ 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: '메뉴 사용자' })
+ : response({ ok: true })
+ );
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify(operations.length === 1 ? results[0] : results),
+ });
+ });
+ await page.route(`**${basePath}/api/trpc/**`, async (route) => {
+ const operations = operationNames(route);
+ state.operations.push(...operations);
+ const results = operations.map((operation) => {
+ if (operation === 'lobby.info') {
+ return response({ myGeneral: { id: 7, name: '메뉴검증장수' }, year: 185, month: 1, turnTerm: 10 });
+ }
+ if (operation === 'general.me') {
+ state.generalMeCalls += 1;
+ return response(generalContext(state));
+ }
+ if (operation === 'world.getState') {
+ return response({
+ currentYear: 185,
+ currentMonth: 1,
+ tickSeconds: 600,
+ config: { npcMode: state.npcMode, const: {}, environment: {} },
+ meta: {},
+ });
+ }
+ if (operation === 'world.getMapLayout') {
+ return response({
+ mapName: 'che',
+ cityList: [{ id: 1, name: '업', level: 8, region: 1, x: 200, y: 120, path: [] }],
+ regionMap: { 1: '하북' },
+ levelMap: { 8: '특' },
+ });
+ }
+ if (operation === 'world.getMap') {
+ return response({
+ result: true,
+ version: 0,
+ startYear: 180,
+ year: 185,
+ month: 1,
+ cityList: [[1, 8, 0, 1, 1, 1]],
+ nationList: [[1, '위', '#008000', 1]],
+ spyList: {},
+ shownByGeneralList: [],
+ myCity: 1,
+ myNation: 1,
+ });
+ }
+ if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] });
+ if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') {
+ return response({ turns: [], revision: 0 });
+ }
+ if (operation === 'messages.getRecent') return response(emptyMessages(state.permission));
+ if (operation === 'messages.getContacts') return response({ nation: [] });
+ if (operation === 'general.getRecentRecords') {
+ return response({
+ global: [{ id: 3, text: '장수 동향 기록' }],
+ general: [{ id: 2, text: '개인 기록' }],
+ history: [{ id: 1, text: '중원 정세 기록' }],
+ });
+ }
+ if (operation === 'general.getFrontStatus') {
+ return response({
+ onlineUserCount: 1,
+ onlineNations: '위(1)',
+ onlineGenerals: '메뉴검증장수',
+ nationNotice: '
국가 방침
',
+ lastExecuted: null,
+ latestVote: { id: 9, title: '메뉴 설문', hasVoted: false },
+ });
+ }
+ if (operation === 'board.getAccess') {
+ return response({
+ permission: state.permission,
+ canMeeting: state.officerLevel >= 1,
+ canSecret: state.permission >= 2,
+ });
+ }
+ if (operation === 'tournament.getState') return response({ stage: state.stage });
+ return response({ ok: true });
+ });
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify(operations.length === 1 ? results[0] : results),
+ });
+ });
+};
+
+const waitForMain = async (page: Page) => {
+ await page.goto('./');
+ await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
+ await expect(page.locator('.main-global-menu').first()).toBeVisible();
+ await expect(page.locator('.main-nation-menu')).toBeVisible();
+ await expect(page.locator('[data-navigation-id="npc-list"]')).toHaveCount(4);
+};
+
+const gridColumnCount = async (page: Page, selector: string) =>
+ page
+ .locator(selector)
+ .first()
+ .evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(' ').length);
+
+const persistArtifact = async (page: Page, name: string) => {
+ if (!artifactRoot) return;
+ const target = resolve(artifactRoot);
+ await mkdir(target, { recursive: true });
+ const geometry = await page.evaluate(() => {
+ const describe = (selector: string) => {
+ const element = document.querySelector(selector);
+ if (!element) return null;
+ const rect = element.getBoundingClientRect();
+ const style = getComputedStyle(element);
+ return {
+ rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
+ display: style.display,
+ position: style.position,
+ zIndex: style.zIndex,
+ gridTemplateColumns: style.gridTemplateColumns,
+ columnCount: style.columnCount,
+ gap: style.gap,
+ fontSize: style.fontSize,
+ lineHeight: style.lineHeight,
+ color: style.color,
+ backgroundColor: style.backgroundColor,
+ backgroundImage: style.backgroundImage,
+ borderColor: style.borderColor,
+ padding: style.padding,
+ boxShadow: style.boxShadow,
+ overflowY: style.overflowY,
+ maxHeight: style.maxHeight,
+ cursor: style.cursor,
+ opacity: style.opacity,
+ visibility: style.visibility,
+ };
+ };
+ return {
+ viewport: { width: innerWidth, height: innerHeight },
+ global: describe('.main-global-menu'),
+ nation: describe('.main-nation-menu'),
+ bottom: describe('.main-mobile-bottom'),
+ globalPopup: describe('#mobile-global-menu'),
+ nationPopup: describe('#mobile-nation-menu'),
+ quickPopup: describe('#mobile-quick-menu'),
+ };
+ });
+ await Promise.all([
+ page.screenshot({ path: resolve(target, `${name}.png`), fullPage: true }),
+ writeFile(resolve(target, `${name}.json`), `${JSON.stringify(geometry, null, 2)}\n`),
+ ]);
+};
+
+test('desktop menus preserve ref columns, prefix-safe routes, and controlled dropdown behavior', async ({ page }) => {
+ const state: NavigationFixture = {
+ officerLevel: 5,
+ permission: 2,
+ nationLevel: 3,
+ stage: 1,
+ npcMode: 1,
+ generalMeCalls: 0,
+ operations: [],
+ };
+ await installFixture(page, state);
+ await page.setViewportSize({ width: 1200, height: 900 });
+ await waitForMain(page);
+
+ await expect(page.locator('.main-global-menu')).toHaveCount(3);
+ expect(await gridColumnCount(page, '.main-global-menu')).toBe(8);
+ expect(await gridColumnCount(page, '.main-nation-menu')).toBe(10);
+ await expect(page.locator('.main-mobile-bottom')).toBeHidden();
+ await expect(page.locator('.layout-desktop')).toBeVisible();
+ await expect(page.locator('.layout-mobile')).toHaveCount(0);
+
+ const global = page.locator('.main-global-menu').first();
+ await expect(global.locator('[data-navigation-id="nation-betting"]')).toHaveAttribute(
+ 'href',
+ `${basePath}/nation-betting`
+ );
+ await expect(global.locator('[data-navigation-id="nation-list"]')).toHaveAttribute(
+ 'href',
+ `${basePath}/nation-list`
+ );
+ 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="survey"]')).toHaveClass(/highlight/);
+ await expect(page.locator('.main-nation-menu [data-navigation-id="tournament"]')).toHaveClass(/highlight/);
+
+ const gameInfoButton = global.locator('[data-menu-id="game-info"]');
+ await gameInfoButton.focus();
+ await gameInfoButton.press('Enter');
+ await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'true');
+ await expect(global.locator('#global-menu-game-info')).toBeVisible();
+ await page.keyboard.press('Escape');
+ await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'false');
+ await expect(gameInfoButton).toBeFocused();
+
+ await gameInfoButton.click();
+ await page.getByRole('heading', { name: '전장 현황' }).click();
+ await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'false');
+ await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
+});
+
+test('the 939/940 boundary and 500px bottom bar match the ref responsive contract', async ({ page }) => {
+ const state: NavigationFixture = {
+ officerLevel: 5,
+ permission: 2,
+ nationLevel: 3,
+ stage: 6,
+ npcMode: 1,
+ generalMeCalls: 0,
+ operations: [],
+ };
+ await installFixture(page, state);
+ await page.setViewportSize({ width: 940, height: 900 });
+ await waitForMain(page);
+ expect(await gridColumnCount(page, '.main-global-menu')).toBe(8);
+ expect(await gridColumnCount(page, '.main-nation-menu')).toBe(10);
+ await expect(page.locator('.main-mobile-bottom')).toBeHidden();
+ await expect(page.locator('.layout-desktop')).toBeVisible();
+
+ await page.setViewportSize({ width: 939, height: 900 });
+ await expect(page.locator('.layout-mobile')).toBeVisible();
+ expect(await gridColumnCount(page, '.main-global-menu')).toBe(4);
+ expect(await gridColumnCount(page, '.main-nation-menu')).toBe(5);
+ await expect(page.locator('.main-mobile-bottom')).toBeVisible();
+
+ await page.setViewportSize({ width: 500, height: 900 });
+ const bottomGeometry = await page.locator('.main-mobile-bottom').evaluate((element) => {
+ const rect = element.getBoundingClientRect();
+ return {
+ x: rect.x,
+ width: rect.width,
+ height: rect.height,
+ bottom: innerHeight - rect.bottom,
+ buttons: [...element.children].map((child) => child.getBoundingClientRect().width),
+ position: getComputedStyle(element).position,
+ zIndex: getComputedStyle(element).zIndex,
+ };
+ });
+ expect(bottomGeometry).toEqual({
+ x: 0,
+ width: 500,
+ height: 45,
+ bottom: 0,
+ buttons: [125, 125, 125, 125],
+ position: 'fixed',
+ zIndex: '99',
+ });
+
+ const externalTrigger = page.locator('[data-bottom-menu="global"]');
+ await externalTrigger.focus();
+ await externalTrigger.press('Enter');
+ await expect(externalTrigger).toHaveAttribute('aria-expanded', 'true');
+ const popupStyle = await page.locator('#mobile-global-menu').evaluate((element) => {
+ const style = getComputedStyle(element);
+ return {
+ columns: style.columnCount,
+ overflowY: style.overflowY,
+ maxHeight: style.maxHeight,
+ bottom: style.bottom,
+ };
+ });
+ expect(popupStyle.columns).toBe('3');
+ expect(popupStyle.overflowY).toBe('auto');
+ expect(popupStyle.maxHeight).toBe('850px');
+ expect(popupStyle.bottom).toBe('47px');
+ await page.keyboard.press('Escape');
+ await expect(externalTrigger).toBeFocused();
+ await persistArtifact(page, `${basePath.slice(1)}-mobile-500`);
+});
+
+test('nation menu presentation follows the server-derived permission matrix', async ({ page }) => {
+ const state: NavigationFixture = {
+ officerLevel: 1,
+ permission: 0,
+ nationLevel: 1,
+ stage: 0,
+ npcMode: 1,
+ generalMeCalls: 0,
+ operations: [],
+ };
+ await installFixture(page, state);
+ await page.setViewportSize({ width: 1200, height: 900 });
+ await waitForMain(page);
+ const nationMenu = page.locator('.main-nation-menu');
+
+ await expect(nationMenu.locator('[data-navigation-id="meeting"]')).toHaveAttribute('href', `${basePath}/board`);
+ await expect(nationMenu.locator('[data-navigation-id="secret-board"]')).toHaveAttribute('aria-disabled', 'true');
+ await expect(nationMenu.locator('[data-navigation-id="diplomacy"]')).toHaveAttribute('aria-disabled', 'true');
+ await expect(nationMenu.locator('[data-navigation-id="nation-info"]')).toHaveAttribute(
+ 'href',
+ `${basePath}/nation/info`
+ );
+
+ state.officerLevel = 2;
+ const callsBeforeRefresh = state.generalMeCalls;
+ await page.getByRole('button', { name: '갱 신' }).click();
+ await expect.poll(() => state.generalMeCalls).toBeGreaterThan(callsBeforeRefresh);
+ await expect(nationMenu.locator('[data-navigation-id="diplomacy"]')).toHaveAttribute(
+ 'href',
+ `${basePath}/diplomacy`
+ );
+ await expect(nationMenu.locator('[data-navigation-id="nation-secret"]')).toHaveAttribute(
+ 'href',
+ `${basePath}/nation/secret`
+ );
+});
+
+test('mobile quick navigation changes tabs before scrolling, refreshes once, and preserves tokens on lobby return', async ({
+ page,
+}) => {
+ const state: NavigationFixture = {
+ officerLevel: 5,
+ permission: 2,
+ nationLevel: 3,
+ stage: 0,
+ npcMode: 1,
+ generalMeCalls: 0,
+ operations: [],
+ };
+ await installFixture(page, state);
+ await page.setViewportSize({ width: 500, height: 900 });
+ await waitForMain(page);
+
+ const cases = [
+ ['policy', 'map', '[data-main-target="policy"]'],
+ ['commands', 'commands', '[data-main-target="commands"]'],
+ ['nation', 'status', '[data-main-target="nation"]'],
+ ['general', 'status', '[data-main-target="general"]'],
+ ['city', 'status', '[data-main-target="city"]'],
+ ['map', 'map', '[data-main-target="map"]'],
+ ['global-records', 'world', '[data-main-target="global-records"]'],
+ ['general-records', 'world', '[data-main-target="general-records"]'],
+ ['world-history', 'world', '[data-main-target="world-history"]'],
+ ['public-message', 'messages', '[data-message-type="public"]'],
+ ['national-message', 'messages', '[data-message-type="national"]'],
+ ['private-message', 'messages', '[data-message-type="private"]'],
+ ['diplomacy-message', 'messages', '[data-message-type="diplomacy"]'],
+ ] as const;
+
+ for (const [id, tab, selector] of cases) {
+ await page.locator('[data-bottom-menu="quick"]').click();
+ await page.locator(`[data-quick-id="${id}"]`).click();
+ await expect(page.locator('.mobile-tabs button.active')).toHaveText(
+ { map: '지도', commands: '명령', status: '상태', world: '동향', messages: '메시지' }[tab]
+ );
+ await expect(page.locator(selector)).toBeVisible();
+ const targetTop = await page.locator(selector).evaluate((element) => element.getBoundingClientRect().top);
+ expect(targetTop).toBeLessThan(855);
+ }
+
+ const callsBeforeRefresh = state.generalMeCalls;
+ await page.locator('[data-bottom-menu="refresh"]').click();
+ await expect.poll(() => state.generalMeCalls).toBeGreaterThan(callsBeforeRefresh);
+
+ await page.evaluate(() => {
+ localStorage.setItem('sammo-session-token', 'session_navigation');
+ });
+ await page.route('**/gateway/', async (route) => {
+ await route.fulfill({ status: 200, contentType: 'text/html', body: 'gateway' });
+ });
+ await page.locator('[data-bottom-menu="quick"]').click();
+ await Promise.all([page.waitForURL('**/gateway/'), page.getByRole('menuitem', { name: '로비로' }).click()]);
+ expect(
+ await page.evaluate(() => ({
+ session: localStorage.getItem('sammo-session-token'),
+ game: localStorage.getItem('sammo-game-token'),
+ profile: localStorage.getItem('sammo-game-profile'),
+ }))
+ ).toEqual({
+ session: 'session_navigation',
+ game: 'ga_navigation',
+ profile: gameProfile,
+ });
+ expect(state.operations).not.toContain('auth.logout');
+});
diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs
index 31ce453..5344295 100644
--- a/app/game-frontend/e2e/playwright.config.mjs
+++ b/app/game-frontend/e2e/playwright.config.mjs
@@ -4,8 +4,11 @@ import { defineConfig, devices } from '@playwright/test';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const port = Number(process.env.PLAYWRIGHT_FRONTEND_PORT ?? 15120);
-const baseURL = `http://127.0.0.1:${port}/che/`;
-const gameApiUrl = process.env.PLAYWRIGHT_GAME_API_URL ?? '/che/api/trpc';
+const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`;
+const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'che:default';
+const baseURL = `http://127.0.0.1:${port}${basePath}/`;
+const gameApiUrl = process.env.PLAYWRIGHT_GAME_API_URL ?? `${basePath}/api/trpc`;
+const gatewayWebUrl = process.env.PLAYWRIGHT_GATEWAY_WEB_URL ?? '/gateway/';
export default defineConfig({
testDir: '.',
@@ -24,6 +27,7 @@ export default defineConfig({
'battleSimulatorRef.spec.ts',
'commandArguments.spec.ts',
'commandArgumentsLive.spec.ts',
+ 'mainNavigation.spec.ts',
],
fullyParallel: false,
workers: 1,
@@ -45,7 +49,7 @@ export default defineConfig({
screenshot: 'only-on-failure',
},
webServer: {
- command: `VITE_APP_BASE_PATH=/che VITE_GAME_API_URL=${gameApiUrl} pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port ${port}`,
+ command: `VITE_APP_BASE_PATH=${basePath} VITE_GAME_API_URL=${gameApiUrl} VITE_GAME_PROFILE=${gameProfile} VITE_GATEWAY_WEB_URL=${gatewayWebUrl} pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port ${port}`,
cwd: repositoryRoot,
url: baseURL,
reuseExistingServer: false,
diff --git a/app/game-frontend/src/components/main/MainGlobalMenu.vue b/app/game-frontend/src/components/main/MainGlobalMenu.vue
new file mode 100644
index 0000000..2f37254
--- /dev/null
+++ b/app/game-frontend/src/components/main/MainGlobalMenu.vue
@@ -0,0 +1,185 @@
+
+
+
+
+
+
+
diff --git a/app/game-frontend/src/components/main/MainMobileBottomBar.vue b/app/game-frontend/src/components/main/MainMobileBottomBar.vue
new file mode 100644
index 0000000..19e8d32
--- /dev/null
+++ b/app/game-frontend/src/components/main/MainMobileBottomBar.vue
@@ -0,0 +1,351 @@
+
+
+
+
+
+
+
diff --git a/app/game-frontend/src/components/main/MainNationMenu.vue b/app/game-frontend/src/components/main/MainNationMenu.vue
new file mode 100644
index 0000000..329eb58
--- /dev/null
+++ b/app/game-frontend/src/components/main/MainNationMenu.vue
@@ -0,0 +1,142 @@
+
+
+
+
+
+
+
diff --git a/app/game-frontend/src/components/main/MainNavigationLink.vue b/app/game-frontend/src/components/main/MainNavigationLink.vue
new file mode 100644
index 0000000..2a1195b
--- /dev/null
+++ b/app/game-frontend/src/components/main/MainNavigationLink.vue
@@ -0,0 +1,117 @@
+
+
+
+
+ {{ label }}
+
+
+ {{ label }}
+
+
+ {{ label }}
+
+
+
+
diff --git a/app/game-frontend/src/components/main/mainNavigation.ts b/app/game-frontend/src/components/main/mainNavigation.ts
new file mode 100644
index 0000000..70a5ea4
--- /dev/null
+++ b/app/game-frontend/src/components/main/mainNavigation.ts
@@ -0,0 +1,357 @@
+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;
+ compactLabel?: string;
+ newTab?: boolean;
+ access?: NationAccessRule;
+ highlightStage?: 1 | 6;
+ unavailableReason?: string;
+};
+
+export interface MainNavigationDivider {
+ kind: 'divider';
+ id: string;
+}
+
+export interface MainNavigationGroup {
+ kind: 'group';
+ id: string;
+ label: string;
+ items: Array;
+}
+
+export interface MainNavigationSplit {
+ kind: 'split';
+ id: string;
+ main: MainNavigationLink;
+ items: Array;
+}
+
+export type MainNavigationEntry = MainNavigationLink | MainNavigationGroup | MainNavigationSplit;
+
+export interface NationNavigationAccess {
+ permission: number;
+ officerLevel: number;
+ nationLevel: number;
+}
+
+export interface QuickNavigationItem {
+ id: string;
+ label: string;
+ tab: 'map' | 'commands' | 'status' | 'world' | 'messages';
+ 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,
+ };
+};
+
+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 },
+];
+
+export const nationNavigation: MainNavigationEntry[] = [
+ {
+ kind: 'link',
+ id: 'meeting',
+ label: '회 의 실',
+ compactLabel: '회의실',
+ to: '/board',
+ access: 'meeting',
+ },
+ {
+ kind: 'link',
+ id: 'secret-board',
+ label: '기 밀 실',
+ compactLabel: '기밀실',
+ to: '/board/secret',
+ access: 'secret',
+ },
+ {
+ kind: 'link',
+ id: 'troop',
+ label: '부대 편성',
+ to: '/troop',
+ access: 'nation-established',
+ },
+ {
+ kind: 'link',
+ id: 'diplomacy',
+ label: '외 교 부',
+ compactLabel: '외교부',
+ to: '/diplomacy',
+ access: 'nation-secret',
+ },
+ {
+ kind: 'link',
+ id: 'personnel',
+ label: '인 사 부',
+ compactLabel: '인사부',
+ to: '/nation/personnel',
+ access: 'nation-member',
+ },
+ {
+ kind: 'link',
+ id: 'finance',
+ label: '내 무 부',
+ compactLabel: '내무부',
+ to: '/nation/finance',
+ access: 'nation-secret',
+ },
+ {
+ kind: 'link',
+ id: 'chief-center',
+ label: '사 령 부',
+ compactLabel: '사령부',
+ to: '/chief-center',
+ access: 'nation-secret',
+ },
+ {
+ kind: 'link',
+ id: 'npc-control',
+ label: 'NPC 정책',
+ to: '/npc-control',
+ access: 'nation-secret',
+ },
+ {
+ kind: 'link',
+ id: 'nation-secret',
+ label: '암 행 부',
+ compactLabel: '암행부',
+ to: '/nation/secret',
+ newTab: true,
+ access: 'nation-secret',
+ },
+ {
+ kind: 'link',
+ id: 'tournament',
+ label: '토 너 먼 트',
+ compactLabel: '토너먼트',
+ to: '/tournament',
+ newTab: true,
+ access: 'always',
+ highlightStage: 1,
+ },
+ {
+ kind: 'link',
+ id: 'nation-info',
+ label: '세력 정보',
+ to: '/nation/info',
+ access: 'nation-member',
+ },
+ {
+ kind: 'link',
+ id: 'nation-cities',
+ label: '세력 도시',
+ to: '/nation/cities',
+ access: 'nation-established',
+ },
+ {
+ kind: 'link',
+ id: 'nation-generals',
+ label: '세력 장수',
+ to: '/nation/generals',
+ access: 'nation-member',
+ },
+ { kind: 'link', id: 'global-info', label: '중원 정보', to: '/global-info', access: 'always' },
+ { kind: 'link', id: 'current-city', label: '현재 도시', to: '/current-city', access: 'always' },
+ {
+ kind: 'link',
+ id: 'battle-center',
+ label: '감 찰 부',
+ compactLabel: '감찰부',
+ to: '/battle-center',
+ newTab: true,
+ access: 'nation-secret',
+ },
+ { kind: 'link', id: 'inherit', label: '유산 관리', to: '/inherit', access: 'always' },
+ { kind: 'link', id: 'my-page', label: '내 정보&설정', to: '/my-page', access: 'always' },
+ {
+ kind: 'split',
+ id: 'auction',
+ main: {
+ kind: 'link',
+ id: 'auction-resource',
+ label: '경 매 장',
+ compactLabel: '금/쌀 경매장',
+ to: '/auction',
+ newTab: true,
+ access: 'always',
+ },
+ items: [
+ {
+ kind: 'link',
+ id: 'auction-resource-menu',
+ label: '금/쌀 경매장',
+ to: '/auction',
+ newTab: true,
+ access: 'always',
+ },
+ {
+ kind: 'link',
+ id: 'auction-unique',
+ label: '유니크 경매장',
+ to: '/auction?type=unique',
+ newTab: true,
+ access: 'always',
+ },
+ ],
+ },
+ {
+ kind: 'link',
+ id: 'betting',
+ label: '베 팅 장',
+ compactLabel: '베팅장',
+ to: '/betting',
+ newTab: true,
+ access: 'always',
+ highlightStage: 6,
+ },
+];
+
+export const quickNavigation: Array = [
+ { kind: 'divider', id: 'quick-nation-heading' },
+ { id: 'policy', label: '방침', tab: 'map', selector: '[data-main-target="policy"]' },
+ { id: 'commands', label: '명령', tab: 'commands', selector: '[data-main-target="commands"]' },
+ { id: 'nation', label: '국가', tab: 'status', selector: '[data-main-target="nation"]' },
+ { id: 'general', label: '장수', tab: 'status', selector: '[data-main-target="general"]' },
+ { id: 'city', label: '도시', tab: 'status', selector: '[data-main-target="city"]' },
+ { kind: 'divider', id: 'quick-record-heading' },
+ { id: 'map', label: '지도', tab: 'map', selector: '[data-main-target="map"]' },
+ { id: 'global-records', label: '동향', tab: 'world', selector: '[data-main-target="global-records"]' },
+ { id: 'general-records', label: '개인', tab: 'world', selector: '[data-main-target="general-records"]' },
+ { id: 'world-history', label: '정세', tab: 'world', selector: '[data-main-target="world-history"]' },
+ { kind: 'divider', id: 'quick-message-heading' },
+ { id: 'public-message', label: '전체', tab: 'messages', selector: '[data-message-type="public"]' },
+ { id: 'national-message', label: '국가', tab: 'messages', selector: '[data-message-type="national"]' },
+ { id: 'private-message', label: '개인', tab: 'messages', selector: '[data-message-type="private"]' },
+ { id: 'diplomacy-message', label: '외교', tab: 'messages', selector: '[data-message-type="diplomacy"]' },
+];
+
+export const isNavigationConfigured = (link: MainNavigationLink): boolean => Boolean(link.to || link.href);
+
+export const isNationNavigationEnabled = (link: MainNavigationLink, access: NationNavigationAccess): boolean => {
+ const rule = link.access ?? 'always';
+ const showSecret = access.permission >= 1 || access.officerLevel >= 2;
+ switch (rule) {
+ case 'meeting':
+ return access.officerLevel >= 1;
+ case 'secret':
+ return access.permission >= 2;
+ case 'nation-member':
+ return access.officerLevel >= 1;
+ case 'nation-established':
+ return access.officerLevel >= 1 && access.nationLevel >= 1;
+ case 'nation-secret':
+ return showSecret;
+ case 'always':
+ return true;
+ }
+};
diff --git a/app/game-frontend/src/components/main/useMenuPopup.ts b/app/game-frontend/src/components/main/useMenuPopup.ts
new file mode 100644
index 0000000..c0f1bfe
--- /dev/null
+++ b/app/game-frontend/src/components/main/useMenuPopup.ts
@@ -0,0 +1,50 @@
+import { nextTick, onMounted, onUnmounted, ref } from 'vue';
+
+export const useMenuPopup = () => {
+ const root = ref(null);
+ const openId = ref(null);
+ let trigger: HTMLElement | null = null;
+
+ const close = (restoreFocus = false) => {
+ openId.value = null;
+ if (restoreFocus && trigger) {
+ void nextTick(() => trigger?.focus());
+ }
+ };
+
+ const toggle = (id: string, event: MouseEvent) => {
+ const nextOpen = openId.value === id ? null : id;
+ trigger = nextOpen ? (event.currentTarget as HTMLElement) : null;
+ openId.value = nextOpen;
+ };
+
+ const setRoot = (element: unknown) => {
+ root.value = element instanceof HTMLElement ? element : null;
+ };
+
+ const onPointerDown = (event: PointerEvent) => {
+ const target = event.target;
+ if (target instanceof Node && !root.value?.contains(target)) {
+ close();
+ }
+ };
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape' && openId.value) {
+ event.preventDefault();
+ close(true);
+ }
+ };
+
+ onMounted(() => {
+ document.addEventListener('pointerdown', onPointerDown);
+ document.addEventListener('keydown', onKeyDown);
+ });
+
+ onUnmounted(() => {
+ document.removeEventListener('pointerdown', onPointerDown);
+ document.removeEventListener('keydown', onKeyDown);
+ });
+
+ return { setRoot, openId, close, toggle };
+};
diff --git a/app/game-frontend/src/env.d.ts b/app/game-frontend/src/env.d.ts
index 2818d84..9075980 100644
--- a/app/game-frontend/src/env.d.ts
+++ b/app/game-frontend/src/env.d.ts
@@ -14,6 +14,12 @@ interface ImportMetaEnv {
readonly VITE_GAME_ASSET_URL?: string;
readonly VITE_GAME_PROFILE?: string;
readonly VITE_GATEWAY_WEB_URL?: string;
+ readonly VITE_BOARD_COMMUNITY_URL?: string;
+ readonly VITE_BOARD_REQUEST_URL?: string;
+ readonly VITE_BOARD_TIP_URL?: string;
+ readonly VITE_BOARD_PATCH_URL?: string;
+ readonly VITE_OFFICIAL_CHAT_URL?: string;
+ readonly VITE_CASUAL_CHAT_URL?: string;
}
interface ImportMeta {
diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue
index 139d886..d1e6c11 100644
--- a/app/game-frontend/src/views/MainView.vue
+++ b/app/game-frontend/src/views/MainView.vue
@@ -1,5 +1,5 @@