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 @@ + + + + + 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 @@