diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index 23e6d3c1..a09371d8 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -553,6 +553,10 @@ export const getGeneralContext = async (ctx: GameApiContext) => { specialDomestic: resolveTraitDisplayName(general.specialCode, domesticNames), specialWar: resolveTraitDisplayName(general.special2Code, warNames), }, + traitAges: { + specialDomestic: readNumber(metaRecord.specage, 0), + specialWar: readNumber(metaRecord.specage2, 0), + }, traitInfo: { personal: personalityNames.get(general.personalCode)?.info ?? '', specialDomestic: domesticNames.get(general.specialCode)?.info ?? '', diff --git a/app/game-api/test/inGameMenuPermissions.test.ts b/app/game-api/test/inGameMenuPermissions.test.ts index 9cd5752f..32cad590 100644 --- a/app/game-api/test/inGameMenuPermissions.test.ts +++ b/app/game-api/test/inGameMenuPermissions.test.ts @@ -510,6 +510,7 @@ describe('in-game my information ownership', () => { personalCode: 'che_안전', specialCode: 'che_상재', special2Code: 'che_신산', + meta: { specage: 31, specage2: 35 }, }), }); @@ -520,6 +521,10 @@ describe('in-game my information ownership', () => { specialDomestic: '상재', specialWar: '신산', }, + traitAges: { + specialDomestic: 31, + specialWar: 35, + }, traitInfo: { personal: '사기 -5, 징·모병 비용 -20%', specialDomestic: '[내정] 상업 투자 : 기본 보정 +10%, 성공률 +10%p, 비용 -20%', @@ -543,6 +548,31 @@ describe('in-game my information ownership', () => { expect(fixture.db.nation.findUnique).not.toHaveBeenCalled(); }); + it('returns the scheduled acquisition ages when the owned general has no domestic or war trait', async () => { + const fixture = createContext({ + me: buildGeneral({ + age: 30, + specialCode: 'None', + special2Code: 'None', + meta: { specage: 35, specage2: 29 }, + }), + }); + + await expect(appRouter.createCaller(fixture.context).general.me()).resolves.toMatchObject({ + general: { + age: 30, + traits: { + specialDomestic: '-', + specialWar: '-', + }, + traitAges: { + specialDomestic: 35, + specialWar: 29, + }, + }, + }); + }); + it('reads legacy top-level settings and dispatches only the session-owned general', async () => { const requestCommand = vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: 7 })); const fixture = createContext({ requestCommand }); diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 7b51451d..4b7294d0 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -22,6 +22,13 @@ const persistParityArtifact = async (page: Page, name: string, geometry: unknown ]); }; +const waitForVisualAssets = async (page: Page): Promise => { + await page.waitForLoadState('networkidle'); + await page.evaluate(async () => { + await document.fonts.ready; + }); +}; + const readGeneralPanelImages = async (panel: Locator) => panel.evaluate((element) => [...element.querySelectorAll('.general-image')].map((image) => { @@ -77,6 +84,7 @@ type FixtureState = { joinConfig?: Record; createGeneralInputs?: Array>; mainTraits?: { personal: string; specialDomestic: string; specialWar: string }; + mainTraitAges?: { specialDomestic: number; specialWar: number }; richMyInfo?: boolean; hiddenSeedLogText?: string; recentRecords?: { @@ -130,6 +138,7 @@ const myGeneral = (state: FixtureState) => ({ } : null, traits: state.mainTraits ?? { personal: '-', specialDomestic: '-', specialWar: '-' }, + traitAges: state.mainTraitAges ?? { specialDomestic: 31, specialWar: 31 }, traitInfo: state.richMyInfo ? { personal: '부상당할 확률이 감소합니다.', @@ -725,6 +734,47 @@ test('재야 메인은 국가 틀과 성격·특기 표기명을 Chromium에 표 await persistParityArtifact(page, 'main-neutral-trait-display', geometry); }); +test('메인 장수 정보는 없는 내정·전투 특기의 Ref 획득 나이를 Chromium에 표시한다', async ({ page }) => { + const state: FixtureState = { + permission: 'member', + myset: 0, + mainTraits: { personal: '안전', specialDomestic: '-', specialWar: '-' }, + mainTraitAges: { specialDomestic: 35, specialWar: 29 }, + settingMutations: [], + accessPages: [], + }; + await install(page, state); + + for (const viewport of [ + { width: 1000, height: 900 }, + { width: 390, height: 844 }, + ]) { + await page.setViewportSize(viewport); + await page.goto(''); + + const specialValue = page.locator('.general-card .special-value'); + await expect(specialValue).toHaveText(/35세\s*\/\s*31세/u); + await expect(specialValue).toHaveAttribute('aria-label', '35세 / 31세'); + const geometry = await specialValue.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const documentWidth = document.documentElement.scrollWidth; + return { + text: element.textContent?.replace(/\s+/gu, ' ').trim(), + left: rect.left, + right: rect.right, + width: rect.width, + documentWidth, + viewportWidth: window.innerWidth, + }; + }); + expect(geometry.width).toBeGreaterThan(0); + expect(geometry.left).toBeGreaterThanOrEqual(0); + expect(geometry.right).toBeLessThanOrEqual(geometry.documentWidth); + expect(geometry.documentWidth).toBe(Math.max(viewport.width, 500)); + await persistParityArtifact(page, `main-speciality-age-${viewport.width}`, geometry); + } +}); + test('메인 카드의 국가·수도·관직·계급·병종은 Ref 출력명으로 표시된다', async ({ page }) => { const state: FixtureState = { permission: 'head', @@ -1270,6 +1320,9 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide await expect(page.locator('#container')).not.toContainText('che_'); await expect(page.locator('.title-row')).toContainText('내 정 보'); await expect(page.locator('#set_my_setting')).toBeVisible(); + await expect(page.getByRole('radiogroup', { name: '화면 폭 모드' })).toHaveCount(0); + await expect(page.getByRole('button', { name: '순서 바꾸기', exact: true })).toHaveCount(0); + await expect(page.locator('#custom_css')).toHaveCount(0); await expect(page.locator('.general-column [role="progressbar"]')).toHaveCount(14); await expect(page.locator('.general-column [aria-label*="1,275,975 (EX+)"]')).toHaveCount(5); await expect.poll(() => state.generalMeQueries).toBeGreaterThan(0); @@ -1290,7 +1343,6 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide const settings = element.querySelector('.settings-column')!.getBoundingClientRect(); const saveButton = element.querySelector('#set_my_setting')!; const save = saveButton.getBoundingClientRect(); - const customCss = element.querySelector('#custom_css')!.getBoundingClientRect(); const columns = getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns; return { width: rect.width, @@ -1302,8 +1354,6 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide saveWidth: save.width, saveHeight: save.height, saveBackground: getComputedStyle(saveButton).backgroundColor, - customCssWidth: customCss.width, - customCssHeight: customCss.height, backgroundImage: getComputedStyle(element).backgroundImage, sectionBackgroundImage: getComputedStyle(element.querySelector('.section-title')!).backgroundImage, }; @@ -1317,8 +1367,6 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide expect(desktop.saveWidth).toBe(160); expect(desktop.saveHeight).toBe(30); expect(desktop.saveBackground).toBe('rgb(34, 85, 0)'); - expect(desktop.customCssWidth).toBe(420); - expect(desktop.customCssHeight).toBe(150); expect(desktop.backgroundImage).toContain('back_walnut.jpg'); expect(desktop.sectionBackgroundImage).toContain('back_green.jpg'); await expectLumenButtonStates(page, page.locator('#set_my_setting'), 'rgb(34, 85, 0)'); @@ -1583,11 +1631,68 @@ test('내 정보&설정의 지난 플레이는 기본 탐색과 분리되어 오 } }); -test('내 정보&설정에서 모바일 메인 패널을 드래그하거나 버튼으로 재정렬하고 기본 순서로 복원한다', async ({ page }) => { +test('화면 설정에서 화면 폭과 개인 CSS를 저장하고 게임 설정 API는 호출하지 않는다', async ({ page }, testInfo) => { + const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] }; + await install(page, state); + await page.setViewportSize({ width: 1000, height: 900 }); + await page.goto('my-settings'); + await waitForVisualAssets(page); + + await expect(page.locator('.title-row')).toContainText('화 면 설 정'); + await expect( + page.getByText('이 설정은 이 기기의 화면 표시만 바꾸며 게임 상태에는 영향을 주지 않습니다.') + ).toHaveCount(0); + await expect(page.locator('#set_my_setting')).toHaveCount(0); + await expect(page.locator('#custom_css')).toBeVisible(); + expect(state.settingMutations).toHaveLength(0); + + const desktop = await page.locator('#interface-settings').evaluate((element) => { + const rect = element.getBoundingClientRect(); + const css = element.querySelector('#custom_css')!.getBoundingClientRect(); + return { + width: rect.width, + columns: getComputedStyle(element.querySelector('.settings-grid')!).gridTemplateColumns, + cssWidth: css.width, + cssHeight: css.height, + backgroundImage: getComputedStyle(element).backgroundImage, + sectionBackgroundImage: getComputedStyle(element.querySelector('.section-title')!).backgroundImage, + }; + }); + expect(desktop.width).toBe(1000); + expect(desktop.columns.split(' ')).toHaveLength(2); + expect(desktop.cssWidth).toBe(420); + expect(desktop.cssHeight).toBe(150); + expect(desktop.backgroundImage).toContain('back_walnut.jpg'); + expect(desktop.sectionBackgroundImage).toContain('back_green.jpg'); + await page.screenshot({ path: testInfo.outputPath('interface-settings-desktop.png'), fullPage: true }); + + await page.getByRole('radio', { name: '500px' }).check(); + await expect.poll(() => page.evaluate(() => localStorage.getItem('sam.screenMode'))).toBe('500px'); + await expect(page.locator('meta[name="viewport"]')).toHaveAttribute('content', 'width=500'); + + const cssText = '#interface-settings { --ui-settings-e2e: 23px; }'; + await page.getByLabel('개인용 CSS').fill(cssText); + await expect(page.locator('.custom-css span')).toHaveText('(저장 중)'); + await expect.poll(() => page.evaluate(() => localStorage.getItem('sam_customCSS'))).toBe(cssText); + await expect.poll(() => page.locator('#sammo-custom-css').textContent()).toBe(cssText); + await page.reload(); + await expect.poll(() => page.locator('#sammo-custom-css').textContent()).toBe(cssText); + expect(state.settingMutations).toHaveLength(0); + + await page.getByLabel('개인용 CSS').fill(''); + await expect.poll(() => page.evaluate(() => localStorage.getItem('sam_customCSS'))).toBe(''); + await page.getByRole('radio', { name: '자동' }).check(); + await persistParityArtifact(page, 'core-interface-settings-desktop', desktop); +}); + +test('화면 설정에서 모바일 메인 패널을 드래그하거나 버튼으로 재정렬하고 기본 순서로 복원한다', async ({ + page, +}, testInfo) => { const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] }; await install(page, state); await page.setViewportSize({ width: 390, height: 844 }); - await page.goto('my-page'); + await page.goto('my-settings'); + await waitForVisualAssets(page); await page.getByRole('button', { name: '순서 바꾸기', exact: true }).click(); const dialog = page.getByRole('dialog', { name: '모바일 레이아웃 순서 바꾸기' }); @@ -1637,7 +1742,8 @@ test('내 정보&설정에서 모바일 메인 패널을 드래그하거나 버 expect(dialogGeometry.firstItem?.height).toBeGreaterThanOrEqual(44); expect(dialogGeometry.moveButton?.width).toBeGreaterThanOrEqual(36); expect(dialogGeometry.documentWidth).toBe(390); - await persistParityArtifact(page, 'core-my-page-mobile-layout-order-dialog', dialogGeometry); + await dialog.screenshot({ path: testInfo.outputPath('interface-settings-mobile-layout-dialog.png') }); + await persistParityArtifact(page, 'core-interface-settings-mobile-layout-order-dialog', dialogGeometry); await dialog.getByRole('button', { name: '적용', exact: true }).click(); await expect(dialog).toBeHidden(); @@ -1654,7 +1760,7 @@ test('내 정보&설정에서 모바일 메인 패널을 드래그하거나 버 .toEqual(defaultOrder); }); -test('실제 모바일 터치로 메인 패널 순서를 재정렬한다', async ({ browser }, testInfo) => { +test('화면 설정에서 실제 모바일 터치로 메인 패널 순서를 재정렬한다', async ({ browser }, testInfo) => { const configuredBaseUrl = testInfo.project.use.baseURL; if (typeof configuredBaseUrl !== 'string') { throw new Error('Playwright baseURL is required for the mobile touch contract'); @@ -1672,7 +1778,8 @@ test('실제 모바일 터치로 메인 패널 순서를 재정렬한다', async try { const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] }; await install(mobilePage, state); - await mobilePage.goto('my-page'); + await mobilePage.goto('my-settings'); + await waitForVisualAssets(mobilePage); await mobilePage.getByRole('button', { name: '순서 바꾸기', exact: true }).click(); const dialog = mobilePage.getByRole('dialog', { name: '모바일 레이아웃 순서 바꾸기' }); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 9f78ed3f..8ae3476a 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -29,6 +29,8 @@ type NavigationFixture = { permission: number; nationLevel: number; stage: number; + tournamentType?: 0 | 1 | 2 | 3; + tournamentWinnerId?: number; npcMode: number; generalMeCalls: number; operations: string[]; @@ -758,7 +760,16 @@ const installFixture = async (page: Page, state: NavigationFixture) => { canSecret: state.permission >= 2, }); } - if (operation === 'tournament.getState') return response({ stage: state.stage }); + if (operation === 'tournament.getState') { + if (state.stage === 0 && state.tournamentType === undefined && state.tournamentWinnerId === undefined) { + return response(null); + } + return response({ + stage: state.stage, + type: state.tournamentType ?? 0, + winnerId: state.tournamentWinnerId, + }); + } return response({ ok: true }); }); operations.forEach((operation, index) => { @@ -1260,7 +1271,27 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await expect(global.locator('[data-navigation-id="board-community"]')).toHaveAttribute('href', '/xe/community'); 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/); + const nationMenu = page.locator('.main-nation-menu:visible'); + await expect(nationMenu.locator(':scope > *')).toHaveCount(20); + const tournamentMain = nationMenu.locator('[data-navigation-id="tournament"]'); + const tournamentToggle = nationMenu.locator('[data-menu-id="tournament-betting"]'); + await expect(tournamentMain).toHaveClass(/highlight/); + await expect(tournamentMain).toHaveAttribute('href', `${basePath}/tournament`); + await expect(tournamentToggle).toHaveAttribute('aria-expanded', 'false'); + await expect(nationMenu.locator('[data-navigation-id="my-settings"]')).toHaveAttribute( + 'href', + `${basePath}/my-settings` + ); + await tournamentToggle.click(); + await expect(tournamentToggle).toHaveAttribute('aria-expanded', 'true'); + await expect( + nationMenu.locator('#nation-menu-tournament-betting [data-navigation-id="tournament-menu"]') + ).toHaveText('토너먼트'); + await expect(nationMenu.locator('#nation-menu-tournament-betting [data-navigation-id="betting"]')).toHaveAttribute( + 'href', + `${basePath}/betting` + ); + await page.keyboard.press('Escape'); const gameInfoButton = global.locator('[data-menu-id="game-info"]'); const bettingButton = global.locator('[data-navigation-id="nation-betting"]'); @@ -1417,6 +1448,95 @@ test('shows the persisted official game index beside the scenario title without } }); +test('tournament split main action follows recruitment, betting, finals, and tournament type on desktop and mobile', async ({ + page, +}, testInfo) => { + const state: NavigationFixture = { + officerLevel: 5, + permission: 2, + nationLevel: 3, + stage: 1, + tournamentType: 3, + npcMode: 1, + scenarioTitle: '토너먼트 동적 메뉴 검증 시나리오', + generalMeCalls: 0, + operations: [], + }; + await installFixture(page, state); + + const cases = [ + { stage: 1, type: 3 as const, label: '설 전', route: '/tournament' }, + { stage: 5, type: 2 as const, label: '일 기 토', route: '/tournament' }, + { stage: 6, type: 2 as const, label: '베 팅 장', route: '/betting' }, + { stage: 7, type: 2 as const, label: '일 기 토', route: '/tournament' }, + { stage: 10, type: 3 as const, label: '설 전', route: '/tournament' }, + { stage: 0, type: 3 as const, label: '설 전', route: '/tournament', winnerId: 17 }, + ]; + + for (const viewport of [ + { width: 1200, height: 900 }, + { width: 500, height: 900 }, + ]) { + await page.setViewportSize(viewport); + for (const lifecycle of cases) { + state.stage = lifecycle.stage; + state.tournamentType = lifecycle.type; + state.tournamentWinnerId = lifecycle.winnerId; + if (page.url() === 'about:blank') { + await waitForMain(page); + } else { + await page.reload(); + await waitForMain(page); + } + + const nationMenu = page.locator('.main-nation-menu:visible'); + const main = nationMenu.locator('[data-navigation-id="tournament"]'); + await expect(main).toHaveText(lifecycle.label); + await expect(main).toHaveAttribute('href', `${basePath}${lifecycle.route}`); + await expect(main).toHaveClass(/highlight/); + + const toggle = nationMenu.locator('[data-menu-id="tournament-betting"]'); + await toggle.click(); + const tournamentItem = nationMenu.locator( + '#nation-menu-tournament-betting [data-navigation-id="tournament-menu"]' + ); + const bettingItem = nationMenu.locator('#nation-menu-tournament-betting [data-navigation-id="betting"]'); + if (lifecycle.stage === 6) { + await expect(tournamentItem).not.toHaveClass(/highlight/); + await expect(bettingItem).toHaveClass(/highlight/); + } else { + await expect(tournamentItem).toHaveClass(/highlight/); + await expect(bettingItem).not.toHaveClass(/highlight/); + } + await page.keyboard.press('Escape'); + + if (viewport.width === 500) { + const nationTrigger = page.locator('[data-bottom-menu="nation"]'); + await nationTrigger.click(); + const mobileTournament = page.locator('#mobile-nation-menu [data-navigation-id="tournament-menu"]'); + const mobileBetting = page.locator('#mobile-nation-menu [data-navigation-id="betting"]'); + if (lifecycle.stage === 6) { + await expect(mobileTournament).not.toHaveClass(/highlight/); + await expect(mobileBetting).toHaveClass(/highlight/); + } else { + await expect(mobileTournament).toHaveClass(/highlight/); + await expect(mobileBetting).not.toHaveClass(/highlight/); + } + await page.keyboard.press('Escape'); + } + } + + await page + .locator('.main-nation-menu:visible [data-menu-id="tournament-betting"]') + .locator('..') + .screenshot({ + path: artifactRoot + ? resolve(artifactRoot, `tournament-dynamic-main-${viewport.width}.png`) + : testInfo.outputPath(`tournament-dynamic-main-${viewport.width}.png`), + }); + } +}); + test('shows game index zero for a profile whose first game starts at zero', async ({ page }) => { const state: NavigationFixture = { officerLevel: 5, @@ -1455,7 +1575,7 @@ test('nation split buttons keep square inner corners and a single divider in eve officerLevel: 5, permission: 2, nationLevel: 3, - stage: 1, + stage: 6, npcMode: 1, scenarioTitle: '분할 버튼 이음새 검증 시나리오', generalMeCalls: 0, @@ -1515,14 +1635,21 @@ test('nation split buttons keep square inner corners and a single divider in eve for (const width of [1200, 500]) { await page.setViewportSize({ width, height: 900 }); await waitForMain(page); - const nationSplit = page.locator('.main-nation-menu:visible .nation-menu-split').first(); const pairs: Array<[string, Locator, Locator]> = [ [ - 'nation', - nationSplit.locator('[data-navigation-id="auction-resource"]'), - nationSplit.locator('[data-menu-id="auction"]'), + 'tournament', + page.locator('.main-nation-menu:visible [data-navigation-id="tournament"]'), + page.locator('.main-nation-menu:visible [data-menu-id="tournament-betting"]'), + ], + [ + 'auction', + page.locator('.main-nation-menu:visible [data-navigation-id="auction-resource"]'), + page.locator('.main-nation-menu:visible [data-menu-id="auction"]'), ], ]; + await expect(page.locator('.main-nation-menu:visible [data-navigation-id="tournament"]')).toHaveClass( + /highlight/ + ); for (const [label, main, toggle] of pairs) { await expect(main).toBeVisible(); @@ -3959,9 +4086,7 @@ for (const viewport of [ await picker.getByLabel('장비 종류', { exact: true }).selectOption('weapon'); const equipment = picker.getByLabel('장비', { exact: true }); await equipment.selectOption('청룡언월도'); - const optionLabelBeforeRefresh = await equipment - .locator('option[value="청룡언월도"]') - .textContent(); + const optionLabelBeforeRefresh = await equipment.locator('option[value="청룡언월도"]').textContent(); await equipment.evaluate((element) => { const select = element as HTMLSelectElement; const valueDescriptor = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value'); @@ -4091,8 +4216,8 @@ test('keeps an Android Chromium native command select untouched while a turn sig await waitForMain(mobilePage); await expect .poll(() => - mobilePage.evaluate( - () => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime() + mobilePage.evaluate(() => + (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime() ) ) .toBe(true); diff --git a/app/game-frontend/e2e/tournamentBracket.spec.ts b/app/game-frontend/e2e/tournamentBracket.spec.ts index d2eff7e0..1e8b79df 100644 --- a/app/game-frontend/e2e/tournamentBracket.spec.ts +++ b/app/game-frontend/e2e/tournamentBracket.spec.ts @@ -693,6 +693,30 @@ test('tournament and betting pages expose same-row navigation tabs beside close' expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); }); +test('tournament and betting close only their script-opened popup window', async ({ page }, testInfo) => { + const baseURL = testInfo.project.use.baseURL; + expect(typeof baseURL).toBe('string'); + await page.goto('about:blank'); + + for (const route of ['tournament', 'betting'] as const) { + const popupPromise = page.waitForEvent('popup'); + await page.evaluate(() => window.open('about:blank', '_blank', 'noopener')); + const popup = await popupPromise; + await installFixture(popup, { tournamentStage: route === 'betting' ? 6 : 1 }); + await popup.goto(new URL(route, baseURL as string).href); + + await expect(popup.getByRole('button', { name: '창 닫기' }).first()).toBeVisible(); + expect(await popup.evaluate(() => window.opener)).toBeNull(); + + const closed = popup.waitForEvent('close'); + await popup.getByRole('button', { name: '창 닫기' }).first().click(); + await closed; + + expect(page.isClosed()).toBe(false); + expect(page.url()).toBe('about:blank'); + } +}); + test('mobile betting rankings use tabs and keep dedicated icons beside general names', async ({ page }, testInfo) => { await page.setViewportSize({ width: 390, height: 844 }); const { placedBets } = await installFixture(page, { tournamentStage: 6 }); diff --git a/app/game-frontend/src/assets/main.css b/app/game-frontend/src/assets/main.css index 65148076..4912c428 100644 --- a/app/game-frontend/src/assets/main.css +++ b/app/game-frontend/src/assets/main.css @@ -42,6 +42,7 @@ body { /* These redesigned screens own a true handheld layout. */ #app:has(.responsive-settings-page), +#app:has(.interface-settings-page), #app:has(#tournament-container), #app:has(#tournament-betting-container), #app:has(#personnel-container) { diff --git a/app/game-frontend/src/components/main/GeneralBasicCard.vue b/app/game-frontend/src/components/main/GeneralBasicCard.vue index 4fa8125e..64ff9d7c 100644 --- a/app/game-frontend/src/components/main/GeneralBasicCard.vue +++ b/app/game-frontend/src/components/main/GeneralBasicCard.vue @@ -99,6 +99,7 @@ export interface GeneralBasicCardData { crewTypeName?: string; crewTypeInfo?: CrewTypeDisplayInfo | null; traits?: { personal: string; specialWar: string; specialDomestic: string }; + traitAges?: { specialWar: number; specialDomestic: number }; traitInfo?: { personal: string; specialWar: string; specialDomestic: string }; progression?: GeneralProgression; itemNames?: ItemDisplayNames; @@ -226,9 +227,19 @@ const displayDefence = computed(() => { }); const displayKillTurn = computed(() => props.general?.killTurn ?? props.killTurn); const displayRemainingMinutes = computed(() => props.general?.remainingMinutes ?? props.remainingMinutes); +const resolveSpecialDisplayName = (kind: 'specialDomestic' | 'specialWar') => { + const general = props.general; + if (!general) return '-'; + const traitName = general.traits?.[kind]; + if (traitName && traitName !== '-') return traitName; + const scheduledAge = general.traitAges?.[kind]; + if (general.age === undefined || scheduledAge === undefined) return '-'; + return `${Math.max(general.age + 1, scheduledAge)}세`; +}; +const specialDomesticText = computed(() => resolveSpecialDisplayName('specialDomestic')); +const specialWarText = computed(() => resolveSpecialDisplayName('specialWar')); const specialText = computed(() => { - const traits = props.general?.traits; - return traits ? `${traits.specialDomestic || '-'} / ${traits.specialWar || '-'}` : '-'; + return `${specialDomesticText.value} / ${specialWarText.value}`; }); @@ -379,19 +390,19 @@ const specialText = computed(() => { 특기 - {{ props.general.traits?.specialDomestic ?? '-' }} + {{ specialDomesticText }} / - {{ props.general.traits?.specialWar ?? '-' }} + {{ specialWarText }} diff --git a/app/game-frontend/src/components/main/MainMobileBottomBar.vue b/app/game-frontend/src/components/main/MainMobileBottomBar.vue index dc492b03..21559738 100644 --- a/app/game-frontend/src/components/main/MainMobileBottomBar.vue +++ b/app/game-frontend/src/components/main/MainMobileBottomBar.vue @@ -4,9 +4,9 @@ import { legacyNationTextColor } from '../../utils/legacyNationColor'; import MainNavigationLink from './MainNavigationLink.vue'; import { buildGlobalNavigation, + buildNationNavigation, isNationNavigationEnabled, isNavigationConfigured, - nationNavigation, quickNavigation, type MainNavigationLink as MainNavigationLinkItem, type MainNavigationEntry, @@ -18,6 +18,7 @@ import { useMenuPopup } from './useMenuPopup'; const props = defineProps<{ access: NationNavigationAccess; tournamentStage: number; + tournamentType: number | null; nationColor: string; npcMode: number; realtimeEnabled: boolean; @@ -35,9 +36,12 @@ const emit = defineEmits<{ const { setRoot, openId, close, toggle } = useMenuPopup(); const globalEntries = computed(() => buildGlobalNavigation(props.npcMode, props.entries)); +const nationEntries = computed(() => buildNationNavigation(props.tournamentStage, props.tournamentType)); const nationMenuColor = computed(() => props.nationColor || '#000000'); const nationMenuTextColor = computed(() => legacyNationTextColor(nationMenuColor.value)); -const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props.tournamentStage; +const isActive = (link: MainNavigationLinkItem) => + link.highlightStage === props.tournamentStage || + link.highlightStages?.some((stage) => stage === props.tournamentStage) === true; const onQuick = (item: QuickNavigationItem) => { close(); @@ -148,7 +152,7 @@ const onAction = (action: NonNullable) => {