diff --git a/app/game-frontend/e2e/board.spec.ts b/app/game-frontend/e2e/board.spec.ts index 599d2e0..eeea3a6 100644 --- a/app/game-frontend/e2e/board.spec.ts +++ b/app/game-frontend/e2e/board.spec.ts @@ -220,7 +220,21 @@ const installApi = async (page: Page, state: BoardFixture) => { } if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] }); if (operation === 'messages.getRecent') return response(emptyMessages); - if (operation === 'turns.reserved.getGeneral') return response([]); + if (operation === 'messages.getContacts') return response({ nation: [] }); + if (operation === 'turns.reserved.getGeneral') return response({ turns: [], revision: 0 }); + if (operation === 'turns.reserved.getNation') return response({ turns: [], revision: 0 }); + if (operation === 'general.getRecentRecords') + return response({ global: [], general: [], history: [] }); + if (operation === 'general.getFrontStatus') + return response({ + onlineUserCount: 1, + onlineNations: '아국(1)', + onlineGenerals: '유비', + nationNotice: '', + lastExecuted: null, + latestVote: null, + }); + if (operation === 'tournament.getState') return response({ stage: 0 }); return errorResponse(operation, `Unhandled board fixture operation: ${operation}`); }); await route.fulfill({ @@ -426,7 +440,7 @@ test('denies direct secret-room rendering and disables its in-game menu for an o state.requests.length = 0; await page.goto(''); await expect(page.getByRole('link', { name: '회의실', exact: true })).toBeVisible(); - await expect(page.locator('.header-actions [aria-disabled="true"]').filter({ hasText: '기밀실' })).toBeVisible(); + await expect(page.locator('.game-shell__actions [aria-disabled="true"]').filter({ hasText: '기밀실' })).toBeVisible(); await expect(page.getByRole('link', { name: '기밀실', exact: true })).toHaveCount(0); }); diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index cad46eb..330217d 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -85,6 +85,22 @@ const generalContext = { }; const turns = (count: number) => Array.from({ length: count }, (_, index) => ({ index, action: '휴식', args: {} })); +const chiefCenter = { + me: { id: 1, officerLevel: 5, nationId: 1 }, + nation: { id: 1, name: '아국', level: 1 }, + currentYear: 200, + currentMonth: 1, + turnTermMinutes: 10, + maxTurns: 12, + chiefs: [12, 10, 8, 6, 11, 9, 7, 5].map((officerLevel) => ({ + officerLevel, + name: officerLevel === 5 ? '장수' : null, + npcState: officerLevel === 5 ? 0 : null, + turnTime: null, + revision: 0, + turns: turns(12), + })), +}; const install = async (page: Page, rejectGeneral = false) => { const requests: unknown[] = []; @@ -115,8 +131,20 @@ const install = async (page: Page, rejectGeneral = false) => { spyList: {}, shownByGeneralList: [], myCity: 1, myNation: 1, }); if (name === 'turns.getCommandTable') return response(commandTable); - if (name === 'turns.reserved.getGeneral') return response(turns(30)); - if (name === 'turns.reserved.getNation') return response(turns(12)); + if (name === 'nation.getChiefCenter') return response(chiefCenter); + if (name === 'turns.reserved.getGeneral') return response({ turns: turns(30), revision: 0 }); + if (name === 'turns.reserved.getNation') return response({ turns: turns(12), revision: 0 }); + if (name === 'general.getRecentRecords') + return response({ global: [], general: [], history: [] }); + if (name === 'general.getFrontStatus') + return response({ + onlineUserCount: 1, + onlineNations: '아국(1)', + onlineGenerals: '장수', + nationNotice: '', + lastExecuted: null, + latestVote: null, + }); if (name === 'messages.getRecent') return response({ private: [], national: [], public: [], diplomacy: [], sequence: -1, hasMore: { private: false, national: false, public: false, diplomacy: false }, @@ -197,6 +225,70 @@ test('keeps the entered command visible and reports a server validation error', await page.locator('.reserved-section').filter({ hasText: '일반 예턴' }) .getByRole('button', { name: '배치' }).first().click(); - await expect(page.locator('.error')).toContainText('대상 도시를 선택할 수 없습니다.'); + await expect(page.getByRole('alert')).toContainText('대상 도시를 선택할 수 없습니다.'); await expect(page.getByTestId('command-argument-form').locator('select')).toHaveValue('2'); }); + +test('keeps the shared main and chief shell geometry and interaction states', async ({ page }) => { + await install(page); + await page.setViewportSize({ width: 1000, height: 900 }); + await page.goto('/'); + await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible(); + + const mainGeometry = await page.locator('.main-page').evaluate((element) => { + const header = element.querySelector('.game-shell__header')!; + const title = element.querySelector('.game-shell__title')!; + const subtitle = element.querySelector('.game-shell__subtitle')!; + const action = element.querySelector('.game-shell__action')!; + return { + width: element.getBoundingClientRect().width, + padding: getComputedStyle(element).padding, + gap: getComputedStyle(element).gap, + headerWidth: header.getBoundingClientRect().width, + headerGap: getComputedStyle(header).gap, + headerBorder: getComputedStyle(header).borderBottomWidth, + headerPadding: getComputedStyle(header).paddingBottom, + titleFontSize: getComputedStyle(title).fontSize, + subtitleFontSize: getComputedStyle(subtitle).fontSize, + actionPadding: getComputedStyle(action).padding, + actionFontSize: getComputedStyle(action).fontSize, + }; + }); + expect(mainGeometry).toEqual({ + width: 1000, + padding: '24px', + gap: '16px', + headerWidth: 952, + headerGap: '12px', + headerBorder: '1px', + headerPadding: '12px', + titleFontSize: '22.4px', + subtitleFontSize: '11.9px', + actionPadding: '6px 12px', + actionFontSize: '11.2px', + }); + + const mainAction = page.getByRole('link', { name: '세력 정보' }); + await mainAction.hover(); + expect(await mainAction.evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer'); + await mainAction.focus(); + expect(await mainAction.evaluate((element) => document.activeElement === element)).toBe(true); + + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('chief-center'); + await expect(page.getByRole('heading', { name: '사령부', exact: true })).toBeVisible(); + const chiefDesktop = await page.locator('.chief-page').evaluate((element) => ({ + width: element.getBoundingClientRect().width, + padding: getComputedStyle(element).padding, + headerWidth: element.querySelector('.game-shell__header')!.getBoundingClientRect().width, + })); + expect(chiefDesktop).toEqual({ width: 1200, padding: '24px', headerWidth: 1152 }); + + await page.setViewportSize({ width: 500, height: 900 }); + const chiefMobile = await page.locator('.chief-page').evaluate((element) => ({ + width: element.getBoundingClientRect().width, + padding: getComputedStyle(element).padding, + headerWidth: element.querySelector('.game-shell__header')!.getBoundingClientRect().width, + })); + expect(chiefMobile).toEqual({ width: 500, padding: '16px', headerWidth: 468 }); +}); diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 6d76291..5f76e19 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -392,5 +392,5 @@ test('감찰부 keeps the selector interaction and shows the permission error pa const member: FixtureState = { permission: 'member', myset: 3, settingMutations: [], accessPages: [] }; await install(page, member); await page.reload(); - await expect(page.locator('.error')).toContainText('권한이 부족합니다.'); + await expect(page.getByRole('alert')).toContainText('권한이 부족합니다.'); }); diff --git a/app/game-frontend/src/assets/main.css b/app/game-frontend/src/assets/main.css index 6e03e3b..071d3f6 100644 --- a/app/game-frontend/src/assets/main.css +++ b/app/game-frontend/src/assets/main.css @@ -1,4 +1,7 @@ @import 'tailwindcss'; +@import './styles/tokens.css'; +@import './styles/game-shell.css'; +@import './styles/ref-shell.css'; @theme { --color-sammo-ink: #000; @@ -15,7 +18,7 @@ body { margin: 0; background: #000; color: #fff; - font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif; + font-family: var(--sammo-font-sans); font-size: 14px; line-height: 1.3; } @@ -29,17 +32,17 @@ textarea { .legacy-bg0 { background-color: #302016; - background-image: url('/image/game/back_walnut.jpg'); + background-image: var(--sammo-texture-walnut); } .legacy-bg1 { background-color: #14241b; - background-image: url('/image/game/back_green.jpg'); + background-image: var(--sammo-texture-green); } .legacy-bg2 { background-color: #172a52; - background-image: url('/image/game/back_blue.jpg'); + background-image: var(--sammo-texture-blue); } .legacy-button { diff --git a/app/game-frontend/src/assets/styles/game-shell.css b/app/game-frontend/src/assets/styles/game-shell.css new file mode 100644 index 0000000..9929c55 --- /dev/null +++ b/app/game-frontend/src/assets/styles/game-shell.css @@ -0,0 +1,54 @@ +/* + * Shared shell for the main dashboard family. + * + * Keep geometry here limited to declarations that are identical on the main, + * chief-center, and public screens. Page-specific grids remain scoped in their + * owning SFC so a generic class cannot silently change a ref measurement. + */ +.game-shell { + min-height: 100vh; + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.game-shell__header { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid var(--sammo-color-border); + padding-bottom: 12px; +} + +.game-shell__title { + font-size: 1.6rem; + font-weight: 600; +} + +.game-shell__subtitle { + font-size: 0.85rem; + color: var(--sammo-color-text-muted); +} + +.game-shell__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.game-shell__action { + border: 1px solid var(--sammo-color-border); + padding: 6px 12px; + background: var(--sammo-color-action-bg); + color: inherit; + text-decoration: none; + font-size: 0.8rem; + cursor: pointer; +} + +.game-feedback--error { + color: var(--sammo-color-error); + font-size: 0.85rem; +} diff --git a/app/game-frontend/src/assets/styles/ref-shell.css b/app/game-frontend/src/assets/styles/ref-shell.css new file mode 100644 index 0000000..335841d --- /dev/null +++ b/app/game-frontend/src/assets/styles/ref-shell.css @@ -0,0 +1,77 @@ +/* + * Fixed ref geometry. This namespace is intentionally separate from + * .game-shell: its 1000/500px contract must win over generic responsive styles. + */ +.ref-shell { + width: 100%; + min-width: 500px; + max-width: 1000px; + min-height: 100vh; + margin: 0 auto; + padding: 0; + display: flex; + flex-direction: column; + gap: 0; + background-color: #302016; + background-image: var(--sammo-texture-walnut); + color: var(--sammo-color-text); + font-family: var(--sammo-font-sans); + font-size: 14px; + line-height: 1.5; +} + +.ref-shell__topbar { + position: relative; + min-height: 32px; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + flex-wrap: wrap; + padding: 0 8px; + border: 1px solid #666; + background-color: #302016; + background-image: var(--sammo-texture-walnut); +} + +.ref-shell__title { + font-size: 17px; + font-weight: 500; +} + +.ref-shell__subtitle { + display: none; +} + +.ref-shell__actions { + position: absolute; + left: 0; + top: 0; + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.ref-shell__control { + min-height: 32px; + border: 1px solid #777; + border-radius: 0; + background: #303030; + color: inherit; + padding: 4px 8px; + font: inherit; + cursor: pointer; +} + +.ref-feedback--error { + padding: 5px 8px; + border: 1px solid #a33; + color: #ff7777; + text-align: center; +} + +@media (max-width: 991px) { + .ref-shell { + width: 500px; + } +} diff --git a/app/game-frontend/src/assets/styles/tokens.css b/app/game-frontend/src/assets/styles/tokens.css new file mode 100644 index 0000000..d99f49b --- /dev/null +++ b/app/game-frontend/src/assets/styles/tokens.css @@ -0,0 +1,12 @@ +:root { + --sammo-font-sans: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif; + --sammo-color-text: #fff; + --sammo-color-text-muted: rgba(232, 221, 196, 0.7); + --sammo-color-accent: #f39c12; + --sammo-color-border: rgba(201, 164, 90, 0.4); + --sammo-color-action-bg: rgba(16, 16, 16, 0.6); + --sammo-color-error: #f5b7b1; + --sammo-texture-walnut: url('/image/game/back_walnut.jpg'); + --sammo-texture-green: url('/image/game/back_green.jpg'); + --sammo-texture-blue: url('/image/game/back_blue.jpg'); +} diff --git a/app/game-frontend/src/components/main/MainFrontStatus.vue b/app/game-frontend/src/components/main/MainFrontStatus.vue index 85b39b5..cb10891 100644 --- a/app/game-frontend/src/components/main/MainFrontStatus.vue +++ b/app/game-frontend/src/components/main/MainFrontStatus.vue @@ -39,7 +39,7 @@ defineProps<{ width: calc(100% + 48px); margin-left: -24px; background-color: #302016; - background-image: url('/image/game/back_walnut.jpg'); + background-image: var(--sammo-texture-walnut); color: #fff; font-size: 14px; font-weight: 400; diff --git a/app/game-frontend/src/components/main/MessagePanel.vue b/app/game-frontend/src/components/main/MessagePanel.vue index 2ab2bd1..226a894 100644 --- a/app/game-frontend/src/components/main/MessagePanel.vue +++ b/app/game-frontend/src/components/main/MessagePanel.vue @@ -266,7 +266,7 @@ const forwardResponse = (messageId: number, response: boolean) => { grid-template-columns: minmax(0, 1fr) minmax(0, 4fr) minmax(0, 1fr); grid-template-areas: 'mailbox input submit'; background-color: #302016; - background-image: url('/image/game/back_walnut.jpg'); + background-image: var(--sammo-texture-walnut); } #mailbox_list-col { @@ -347,7 +347,7 @@ const forwardResponse = (messageId: number, response: boolean) => { align-items: center; outline: 1px solid gray; background-color: #302016; - background-image: url('/image/game/back_walnut.jpg'); + background-image: var(--sammo-texture-walnut); color: #fff; } diff --git a/app/game-frontend/src/components/main/RecordPanel.vue b/app/game-frontend/src/components/main/RecordPanel.vue index b90d672..2da9c0e 100644 --- a/app/game-frontend/src/components/main/RecordPanel.vue +++ b/app/game-frontend/src/components/main/RecordPanel.vue @@ -29,7 +29,7 @@ defineProps<{ border-top: 1px solid gray; border-bottom: 1px solid gray; background-color: #14241b; - background-image: url('/image/game/back_green.jpg'); + background-image: var(--sammo-texture-green); color: #fff; font: inherit; text-align: center; diff --git a/app/game-frontend/src/components/ui/PanelCard.vue b/app/game-frontend/src/components/ui/PanelCard.vue index b8891ce..ac59e85 100644 --- a/app/game-frontend/src/components/ui/PanelCard.vue +++ b/app/game-frontend/src/components/ui/PanelCard.vue @@ -28,7 +28,7 @@ defineProps(); .panel-card { border: 1px solid gray; background-color: #302016; - background-image: url('/image/game/back_walnut.jpg'); + background-image: var(--sammo-texture-walnut); } .panel-header { @@ -38,7 +38,7 @@ defineProps(); gap: 8px; border-bottom: 1px solid gray; background-color: #14241b; - background-image: url('/image/game/back_green.jpg'); + background-image: var(--sammo-texture-green); padding: 3px 6px; } diff --git a/app/game-frontend/src/views/AuctionView.vue b/app/game-frontend/src/views/AuctionView.vue index 7bcf928..d82c77c 100644 --- a/app/game-frontend/src/views/AuctionView.vue +++ b/app/game-frontend/src/views/AuctionView.vue @@ -478,21 +478,21 @@ onMounted(() => { box-sizing: border-box; margin: 0 auto; color: #fff; - font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif; + font-family: var(--sammo-font-sans); font-size: 14px; line-height: 21px; } .bg0 { background-color: #302016; - background-image: url('/image/game/back_walnut.jpg'); + background-image: var(--sammo-texture-walnut); } .bg1 { background-color: #14241b; - background-image: url('/image/game/back_green.jpg'); + background-image: var(--sammo-texture-green); } .bg2 { background-color: #172a52; - background-image: url('/image/game/back_blue.jpg'); + background-image: var(--sammo-texture-blue); } .top-back-bar { width: 100%; diff --git a/app/game-frontend/src/views/BattleCenterView.vue b/app/game-frontend/src/views/BattleCenterView.vue index 8b48183..f94c971 100644 --- a/app/game-frontend/src/views/BattleCenterView.vue +++ b/app/game-frontend/src/views/BattleCenterView.vue @@ -228,26 +228,26 @@ onMounted(() => {