diff --git a/app/game-api/src/battleSim/schema.ts b/app/game-api/src/battleSim/schema.ts index 09fb8d9b..71b092dc 100644 --- a/app/game-api/src/battleSim/schema.ts +++ b/app/game-api/src/battleSim/schema.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; +import { isAvailableNationTraitKey } from '@sammo-ts/logic'; + import type { BattleSimRequestPayload } from './types.js'; const zBattleSimGeneral = z.object({ @@ -71,7 +73,7 @@ const zBattleSimCity = z.object({ }); const zBattleSimNation = z.object({ - type: z.string().min(1), + type: z.string().refine(isAvailableNationTraitKey), tech: z.number().min(0), level: z.number().int().min(0), capital: z.number().int().min(0), diff --git a/app/game-api/src/battleSim/simulatorOptions.ts b/app/game-api/src/battleSim/simulatorOptions.ts index 51b80ddf..0d084147 100644 --- a/app/game-api/src/battleSim/simulatorOptions.ts +++ b/app/game-api/src/battleSim/simulatorOptions.ts @@ -1,4 +1,5 @@ import { + AVAILABLE_NATION_TRAIT_KEYS, ITEM_KEYS, EVENT_DOMESTIC_TRAIT_KEYS, loadEventDomesticTraitModules, @@ -6,7 +7,6 @@ import { loadNationTraitModules, loadPersonalityTraitModules, loadWarTraitModules, - NATION_TRAIT_KEYS, PERSONALITY_TRAIT_KEYS, WAR_TRAIT_KEYS, type ItemModule, @@ -110,7 +110,7 @@ export const loadBattleSimTraitOptions = async (): Promise<{ }> => { if (!cachedTraitOptions) { cachedTraitOptions = Promise.all([ - loadNationTraitModules([...NATION_TRAIT_KEYS]), + loadNationTraitModules([...AVAILABLE_NATION_TRAIT_KEYS]), loadEventDomesticTraitModules([...EVENT_DOMESTIC_TRAIT_KEYS]), loadWarTraitModules([...WAR_TRAIT_KEYS]), loadPersonalityTraitModules([...PERSONALITY_TRAIT_KEYS]), diff --git a/app/game-api/test/battleSimRouter.test.ts b/app/game-api/test/battleSimRouter.test.ts index 3e765fe0..7d60b37c 100644 --- a/app/game-api/test/battleSimRouter.test.ts +++ b/app/game-api/test/battleSimRouter.test.ts @@ -117,7 +117,7 @@ const buildBattleRequest = () => ({ conflict: '{}', }, attackerNation: { - type: 'test', + type: 'che_도적', tech: 1000, level: 1, capital: 1, @@ -193,7 +193,7 @@ const buildBattleRequest = () => ({ conflict: '{}', }, defenderNation: { - type: 'test', + type: 'che_도적', tech: 1000, level: 1, capital: 2, @@ -309,6 +309,27 @@ describe('battle router orchestration', () => { expect(battleSim.simulateCalls).toBe(0); }); + it('rejects the neutral storage nation type before preparing or queuing a simulation', async () => { + const battleSim = new QueuedBattleSimTransport(); + const state: WorldStateRow = { + id: 1, + scenarioCode: 'default', + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + config: {}, + meta: {}, + updatedAt: new Date('2026-01-01T00:00:00Z'), + }; + const caller = appRouter.createCaller(buildContext({ state, battleSim })); + const request = buildBattleRequest(); + request.attackerNation.type = 'che_중립'; + + await expect(caller.battle.prepareSimulation(request)).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + await expect(caller.battle.simulate(request)).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(battleSim.simulateCalls).toBe(0); + }); + it('returns queued then completed results via transport', async () => { const battleSim = new QueuedBattleSimTransport(); const state: WorldStateRow = { diff --git a/app/game-api/test/simulatorOptions.test.ts b/app/game-api/test/simulatorOptions.test.ts new file mode 100644 index 00000000..f2a256fd --- /dev/null +++ b/app/game-api/test/simulatorOptions.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; + +import { AVAILABLE_NATION_TRAIT_KEYS } from '@sammo-ts/logic'; + +import { loadBattleSimTraitOptions } from '../src/battleSim/simulatorOptions.js'; + +describe('selectable trait options', () => { + it('uses the Ref available nation-type list for founding and battle simulation inputs', async () => { + const options = await loadBattleSimTraitOptions(); + + expect(options.nationTypes.map((entry) => entry.key)).toEqual(AVAILABLE_NATION_TRAIT_KEYS); + expect(options.nationTypes).not.toEqual( + expect.arrayContaining([expect.objectContaining({ key: 'che_중립' })]) + ); + }); +}); diff --git a/app/game-engine/src/turn/ai/generalAi/core.ts b/app/game-engine/src/turn/ai/generalAi/core.ts index 17740a19..3b130819 100644 --- a/app/game-engine/src/turn/ai/generalAi/core.ts +++ b/app/game-engine/src/turn/ai/generalAi/core.ts @@ -13,7 +13,10 @@ import type { ConstraintContext } from '@sammo-ts/logic'; import { GAME_TICKS_PER_TURN, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; import { resolveStartYear, resolveTurnTermMinutes } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js'; -import { NATION_TRAIT_KEYS } from '@sammo-ts/logic/actionModules/traits/nation/index.js'; +import { + AVAILABLE_NATION_TRAIT_KEYS, + isAvailableNationTraitKey, +} from '@sammo-ts/logic/actionModules/traits/nation/index.js'; import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js'; import type { ReservedTurnEntry } from '../../reservedTurnStore.js'; @@ -348,8 +351,10 @@ export class GeneralAI { chiefStatMin: this.scenarioConfig.stat.chiefMin, npcMessageFreqByDay: readNumber(constValues.npcMessageFreqByDay, 0), availableNationTypes: Array.isArray(constValues.availableNationType) - ? constValues.availableNationType.filter((value) => typeof value === 'string') - : NATION_TRAIT_KEYS.filter((value) => value !== 'che_중립'), + ? constValues.availableNationType.filter( + (value): value is string => typeof value === 'string' && isAvailableNationTraitKey(value) + ) + : [...AVAILABLE_NATION_TRAIT_KEYS], }; const generalPolicy = new AutorunGeneralPolicy( diff --git a/app/game-engine/src/turn/monthlyRaiseNpcNationAction.ts b/app/game-engine/src/turn/monthlyRaiseNpcNationAction.ts index 5e24209e..2f5beb1e 100644 --- a/app/game-engine/src/turn/monthlyRaiseNpcNationAction.ts +++ b/app/game-engine/src/turn/monthlyRaiseNpcNationAction.ts @@ -3,7 +3,7 @@ import { LogCategory, LogFormat, LogScope, - NATION_TRAIT_KEYS, + AVAILABLE_NATION_TRAIT_KEYS, getCityDistance, type City, type MapDefinition, @@ -54,7 +54,6 @@ const NATION_COLORS = [ '#FFFFFF', '#A9A9A9', ] as const; -const AVAILABLE_NATION_TYPES = NATION_TRAIT_KEYS.filter((key) => key !== 'che_중립'); const NPC_TYPE = 6; const NPC_PREFIX = 'ⓤ'; const STAT_TYPE_WEIGHTS = { 무: 1, 지: 1 } as const; @@ -301,7 +300,7 @@ export const createRaiseNpcNationHandler = (options: { const nationId = world.getNextNationId(); const color = rng.choice([...NATION_COLORS]); - const typeCode = rng.choice([...AVAILABLE_NATION_TYPES]); + const typeCode = rng.choice([...AVAILABLE_NATION_TRAIT_KEYS]); const nation: Nation = { id: nationId, name: `${NPC_PREFIX}${city.name}`, diff --git a/app/game-frontend/e2e/battleSimulator.spec.ts b/app/game-frontend/e2e/battleSimulator.spec.ts index 60b9d3ec..64207b26 100644 --- a/app/game-frontend/e2e/battleSimulator.spec.ts +++ b/app/game-frontend/e2e/battleSimulator.spec.ts @@ -58,7 +58,7 @@ const simulatorOptions = { { id: 200, name: '궁병', armType: 2 }, ], }, - nationTypes: [{ key: 'che_중립', name: '중립', info: '특별한 효과 없음' }], + nationTypes: [{ key: 'che_도적', name: '도적', info: '금 수입 증가, 쌀 수입 감소' }], eventDomesticTraits: [{ key: 'che_event_신산', name: '신산', info: '계략 강화' }], warTraits: [{ key: 'che_필살', name: '필살', info: '필살 확률 증가' }], personalities: [{ key: 'che_대담', name: '대담', info: '공격적인 성격' }], @@ -356,6 +356,10 @@ test('operates independent/game presets, imports my general, and renders battle await page.setViewportSize({ width: 1280, height: 900 }); await gotoSimulator(page); + const nationTypeSelects = page.locator('[data-parity-id="attacker-nation"] select').first(); + await expect(nationTypeSelects).toHaveValue('che_도적'); + await expect(nationTypeSelects.locator('option[value="che_중립"]')).toHaveCount(0); + const notice = page.getByLabel('시뮬레이터 데이터 안내'); const noticeRect = await notice.boundingBox(); expect(noticeRect?.width).toBeLessThan(100); diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index 9c400bc9..b217dd01 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -55,7 +55,7 @@ const inputOptions = { ], crewTypes: [{ value: 1100, label: '보병' }], armTypes: [{ value: 1, label: '보병' }], - nationTypes: [{ value: 'che_중립', label: '중립' }], + nationTypes: [{ value: 'che_도적', label: '도적', description: '금 수입 증가, 쌀 수입 감소' }], colors: [{ value: 0, label: '색상 1', color: '#ff0000' }], items: { horse: [{ value: 'None', label: '판매/해제' }] }, recruitment: { @@ -590,6 +590,61 @@ test('renders and accepts every Ref strategy command at mobile width', async ({ await picker.screenshot({ path: test.info().outputPath('all-strategy-commands-mobile.png') }); }); +test('defaults founding to a Ref-selectable nation trait without exposing the neutral storage trait', async ({ + page, +}) => { + const foundingCommandTable = { + general: [ + { + category: '국가', + values: [ + { + key: 'che_건국', + name: '건국', + reqArg: true, + possible: true, + status: 'needsInput', + inputFields: [ + { key: 'nationName', label: '국가명', kind: 'text', required: true, min: 1, max: 18 }, + { + key: 'nationType', + label: '국가 성향', + kind: 'select', + required: true, + optionSource: 'nationTypes', + }, + { + key: 'colorType', + label: '국기 색상', + kind: 'select', + required: true, + optionSource: 'colors', + }, + ], + }, + ], + }, + ], + nation: [], + inputOptions, + }; + await install(page, false, foundingCommandTable); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('/'); + await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + + const picker = page.getByTestId('command-picker'); + await picker.getByRole('button', { name: '국가', exact: true }).click(); + await picker.getByRole('button', { name: '건국', exact: true }).click(); + const nationType = picker.getByLabel('국가 성향'); + await expect(nationType).toHaveValue('che_도적'); + await expect(nationType.locator('option[value="che_중립"]')).toHaveCount(0); + await expect(nationType.locator('option')).toHaveText(['도적']); + await nationType.focus(); + await expect(nationType).toBeFocused(); + await picker.screenshot({ path: test.info().outputPath('founding-selectable-nation-trait-desktop-1200.png') }); +}); + test('reserves force move, retirement, and resignation from the user command picker', async ({ page }) => { const specialCommandTable = { general: [ diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 05503f87..a7c616b8 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -327,9 +327,27 @@ const install = async (page: Page, state: FixtureState) => { const rawRequestBody: unknown = route.request().postData() ? route.request().postDataJSON() : {}; const requestBody = rawRequestBody && typeof rawRequestBody === 'object' ? (rawRequestBody as Record) : {}; + const queryInputText = new URL(route.request().url()).searchParams.get('input'); + let queryInput: Record = {}; + if (queryInputText) { + try { + const parsed: unknown = JSON.parse(queryInputText); + if (parsed && typeof parsed === 'object') { + queryInput = parsed as Record; + } + } catch { + queryInput = {}; + } + } const results = operations.map((operation, operationIndex) => { const rawPayload = - requestBody[String(operationIndex)] ?? (operations.length === 1 ? requestBody : undefined); + requestBody[String(operationIndex)] ?? + queryInput[String(operationIndex)] ?? + (operations.length === 1 + ? Object.keys(requestBody).length > 0 + ? requestBody + : queryInput + : undefined); const payload = rawPayload && typeof rawPayload === 'object' ? (rawPayload as TrpcRequestPayload) : undefined; const jsonInput = @@ -537,10 +555,12 @@ const install = async (page: Page, state: FixtureState) => { return response(battleCenter(state)); } if (operation === 'nation.getGeneralLog') { - const type = new URL(route.request().url()).searchParams.get('input')?.includes('generalAction') - ? 'generalAction' - : operation; - return response({ type, generalId: 7, logs: [{ id: 1, text: '감찰 기록' }] }); + const type = + typeof jsonInput.type === 'string' && + ['generalHistory', 'battleDetail', 'battleResult', 'generalAction'].includes(jsonInput.type) + ? jsonInput.type + : 'generalAction'; + return response({ type, generalId: 7, logs: [{ id: 1, text: `${type} 감찰 기록` }] }); } return response({ ok: true }); }); @@ -1310,7 +1330,9 @@ test('감찰부 keeps the selector interaction and shows the permission error pa await expect(page.locator('.selector-row select').nth(1)).toHaveValue('8'); await page.getByRole('button', { name: '다음 ▶' }).click(); await expect(page.locator('.selector-row select').nth(1)).toHaveValue('7'); - await expect(page.locator('.battle-general-name')).toContainText('검증장수 【 간의대부 | 건강 】'); + await expect(page.locator('.battle-general-name')).toContainText('검증장수'); + await expect(page.locator('.battle-general-name')).toContainText('간의대부'); + await expect(page.locator('.battle-general-name')).toContainText('건강'); await expect(page.locator('.battle-general-extra')).toContainText('계급29품관'); await expect(page.locator('.battle-general-card')).toContainText('병종보병'); await expect(page.locator('.battle-general-card')).not.toContainText('che_'); @@ -1321,6 +1343,11 @@ test('감찰부 keeps the selector interaction and shows the permission error pa expect(battleImages[1]?.backgroundImage).toContain('/game/crewtype1.png'); await expect(page.locator('.battle-general-card [role="progressbar"]')).toHaveCount(14); await expect(page.locator('.battle-general-card [aria-label*="1,275,975 (EX+)"]')).toHaveCount(5); + await expect(page.locator('.general-meta')).toHaveCount(0); + await expect(page.locator('.battle-general-extra__recent-value')).toHaveText('01-01 00:00'); + await expect(page.locator('.battle-general-extra__recent-value')).not.toContainText('2026'); + await expect(page.locator('.log-block')).toHaveCount(4); + await expect(page.locator('.log-block[data-log-type="battleResult"]')).toContainText('battleResult 감찰 기록'); expect( await page .locator('.battle-general-card [role="progressbar"]') @@ -1330,7 +1357,11 @@ test('감찰부 keeps the selector interaction and shows the permission error pa const geometry = await page.locator('.battle-page').evaluate((element) => { const selector = element.querySelector('.selector-row')!; const controls = [...selector.children].map((child) => (child as HTMLElement).getBoundingClientRect()); - const logBlock = element.querySelector('.log-block')!.getBoundingClientRect(); + const logBlocks = [...element.querySelectorAll('.log-block')]; + const logBlock = logBlocks[0]!.getBoundingClientRect(); + const recentLabel = element.querySelector('.battle-general-extra__recent-label')!; + const recentValue = element.querySelector('.battle-general-extra__recent-value')!; + const previousStat = recentLabel.previousElementSibling as HTMLElement; return { width: element.getBoundingClientRect().width, fontSize: getComputedStyle(element).fontSize, @@ -1341,6 +1372,11 @@ test('감찰부 keeps the selector interaction and shows the permission error pa backgroundImage: getComputedStyle(element).backgroundImage, generalBackgroundImage: getComputedStyle(element.querySelector('.battle-general-card')!) .backgroundImage, + logBackgroundImages: logBlocks.map((block) => getComputedStyle(block).backgroundImage), + recentLabelTop: recentLabel.getBoundingClientRect().top, + recentValueTop: recentValue.getBoundingClientRect().top, + recentValueWidth: recentValue.getBoundingClientRect().width, + previousStatTop: previousStat.getBoundingClientRect().top, }; }); expect(geometry.width).toBe(1000); @@ -1352,16 +1388,41 @@ test('감찰부 keeps the selector interaction and shows the permission error pa expect(geometry.logBlockWidth).toBeCloseTo(500, 0); expect(geometry.backgroundImage).toContain('back_walnut.jpg'); expect(geometry.generalBackgroundImage).toContain('back_blue.jpg'); + expect(geometry.logBackgroundImages).toHaveLength(4); + expect(geometry.logBackgroundImages.every((background) => background.includes('back_walnut.jpg'))).toBe(true); + expect(geometry.recentLabelTop).toBeCloseTo(geometry.recentValueTop, 0); + expect(geometry.recentLabelTop).toBeGreaterThan(geometry.previousStatTop); + expect(geometry.recentValueWidth).toBeGreaterThan(400); await persistParityArtifact(page, 'core-battle-center-desktop', geometry); await page.setViewportSize({ width: 500, height: 900 }); - const mobileGeometry = await page.locator('.selector-row').evaluate((element) => ({ - columns: getComputedStyle(element).gridTemplateColumns, - controlWidths: [...element.children].map((child) => (child as HTMLElement).getBoundingClientRect().width), - })); + const mobileGeometry = await page.locator('.battle-page').evaluate((element) => { + const selector = element.querySelector('.selector-row')!; + const battleResult = element.querySelector('.log-block[data-log-type="battleResult"]')!; + const footer = element.querySelector('.battle-footer')!; + const pageRect = element.getBoundingClientRect(); + const resultRect = battleResult.getBoundingClientRect(); + const footerRect = footer.getBoundingClientRect(); + return { + columns: getComputedStyle(selector).gridTemplateColumns, + controlWidths: [...selector.children].map((child) => (child as HTMLElement).getBoundingClientRect().width), + pageHeight: pageRect.height, + pageOverflow: getComputedStyle(element).overflow, + resultBottomWithinPage: resultRect.bottom <= pageRect.bottom, + footerAfterResult: footerRect.top >= resultRect.bottom, + resultBackgroundImage: getComputedStyle(battleResult).backgroundImage, + }; + }); expect(mobileGeometry.columns.split(' ')).toHaveLength(4); expect(mobileGeometry.controlWidths[0]).toBeCloseTo(83.33, 0); expect(mobileGeometry.controlWidths[1]).toBeCloseTo(125, 0); + expect(mobileGeometry.pageHeight).toBeGreaterThan(0); + expect(mobileGeometry.pageOverflow).toBe('visible'); + expect(mobileGeometry.resultBottomWithinPage).toBe(true); + expect(mobileGeometry.footerAfterResult).toBe(true); + expect(mobileGeometry.resultBackgroundImage).toContain('back_walnut.jpg'); + await page.locator('.log-block[data-log-type="battleResult"]').scrollIntoViewIfNeeded(); + await expect(page.locator('.log-block[data-log-type="battleResult"]')).toBeInViewport(); await persistParityArtifact(page, 'core-battle-center-mobile', mobileGeometry); await page.unrouteAll({ behavior: 'wait' }); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index eac9a843..bf51dec7 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -748,6 +748,7 @@ const persistArtifact = async (page: Page, name: string) => { return { viewport: { width: innerWidth, height: innerHeight }, global: describe('.main-global-menu'), + bottomGlobalPopup: describe('[data-menu-position="bottom"] .main-menu-popup__list'), nation: describe('.main-nation-menu'), bottom: describe('.main-mobile-bottom'), globalPopup: describe('#mobile-global-menu'), @@ -809,6 +810,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await installFixture(page, state); await page.setViewportSize({ width: 1200, height: 900 }); await waitForMain(page); + if (artifactRoot) await mkdir(resolve(artifactRoot), { recursive: true }); await expect(page.locator('.main-global-menu')).toHaveCount(3); expect(await gridColumnCount(page, '.main-global-menu')).toBe(8); @@ -914,6 +916,20 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await gameInfoButton.press('Enter'); await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'true'); await expect(global.locator('#global-menu-game-info')).toBeVisible(); + const topMenuGeometry = await gameInfoButton.evaluate((button) => { + const popup = button.parentElement?.querySelector('.main-menu-popup__list'); + const caret = button.querySelector('.menu-caret'); + if (!popup || !caret) throw new Error('top global menu popup geometry is incomplete'); + return { + trigger: button.getBoundingClientRect().toJSON(), + popup: popup.getBoundingClientRect().toJSON(), + caretBorderTopWidth: getComputedStyle(caret).borderTopWidth, + caretBorderBottomWidth: getComputedStyle(caret).borderBottomWidth, + }; + }); + expect(topMenuGeometry.popup.top).toBeGreaterThanOrEqual(topMenuGeometry.trigger.bottom + 1); + expect(topMenuGeometry.caretBorderTopWidth).toBe('4px'); + expect(topMenuGeometry.caretBorderBottomWidth).toBe('0px'); await page.keyboard.press('Escape'); await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'false'); await expect(gameInfoButton).toBeFocused(); @@ -921,9 +937,72 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await gameInfoButton.click(); await page.getByRole('heading', { name: '메인 화면 검증 시나리오' }).click(); await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'false'); + + const bottomGlobal = page.locator('[data-menu-position="bottom"]'); + const bottomGameInfoButton = bottomGlobal.locator('[data-menu-id="game-info"]'); + await bottomGameInfoButton.click(); + await expect(bottomGameInfoButton).toHaveAttribute('aria-expanded', 'true'); + await expect(bottomGlobal.locator('#global-menu-game-info')).toBeVisible(); + const bottomMenuGeometry = await bottomGameInfoButton.evaluate((button) => { + const popup = button.parentElement?.querySelector('.main-menu-popup__list'); + const caret = button.querySelector('.menu-caret'); + if (!popup || !caret) throw new Error('bottom global menu popup geometry is incomplete'); + return { + trigger: button.getBoundingClientRect().toJSON(), + popup: popup.getBoundingClientRect().toJSON(), + caretBorderTopWidth: getComputedStyle(caret).borderTopWidth, + caretBorderBottomWidth: getComputedStyle(caret).borderBottomWidth, + boxShadow: getComputedStyle(popup).boxShadow, + }; + }); + expect(bottomMenuGeometry.popup.bottom).toBeLessThanOrEqual(bottomMenuGeometry.trigger.top - 1); + expect(bottomMenuGeometry.caretBorderTopWidth).toBe('0px'); + expect(bottomMenuGeometry.caretBorderBottomWidth).toBe('4px'); + expect(bottomMenuGeometry.boxShadow).toContain('0px -8px 18px'); await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`); }); +test('the repeated bottom global menu opens upward on the mobile document', async ({ page }, testInfo) => { + const state: NavigationFixture = { + officerLevel: 5, + permission: 2, + nationLevel: 3, + stage: 1, + npcMode: 1, + scenarioTitle: '하단 메뉴 방향 검증 시나리오', + generalMeCalls: 0, + operations: [], + }; + await installFixture(page, state); + await page.setViewportSize({ width: 500, height: 900 }); + await waitForMain(page); + + const bottomGlobal = page.locator('[data-menu-position="bottom"]'); + const gameInfoButton = bottomGlobal.locator('[data-menu-id="game-info"]'); + await gameInfoButton.click(); + await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'true'); + await expect(bottomGlobal.locator('#global-menu-game-info')).toBeVisible(); + const geometry = await gameInfoButton.evaluate((button) => { + const popup = button.parentElement?.querySelector('.main-menu-popup__list'); + const caret = button.querySelector('.menu-caret'); + if (!popup || !caret) throw new Error('mobile bottom global menu popup geometry is incomplete'); + return { + trigger: button.getBoundingClientRect().toJSON(), + popup: popup.getBoundingClientRect().toJSON(), + caretBorderTopWidth: getComputedStyle(caret).borderTopWidth, + caretBorderBottomWidth: getComputedStyle(caret).borderBottomWidth, + viewportHeight: window.innerHeight, + }; + }); + expect(geometry.popup.bottom).toBeLessThanOrEqual(geometry.trigger.top - 1); + expect(geometry.popup.top).toBeGreaterThanOrEqual(0); + expect(geometry.popup.bottom).toBeLessThanOrEqual(geometry.viewportHeight); + expect(geometry.caretBorderTopWidth).toBe('0px'); + expect(geometry.caretBorderBottomWidth).toBe('4px'); + await bottomGlobal.screenshot({ path: testInfo.outputPath('mobile-bottom-global-dropup.png') }); + await persistArtifact(page, `${basePath.slice(1)}-mobile-bottom-dropup`); +}); + test('main general card uses local turn time and command clock tracks corrected server time', async ({ page }) => { const state: NavigationFixture = { officerLevel: 0, @@ -2160,6 +2239,222 @@ test('nation menu presentation follows the server-derived permission matrix', as ); }); +test('all main Lumen button families share the rounded pressed geometry', async ({ page }) => { + const state: NavigationFixture = { + officerLevel: 5, + permission: 2, + nationLevel: 3, + stage: 0, + npcMode: 1, + generalMeCalls: 0, + operations: [], + nationColor: '#663399', + }; + await installFixture(page, state); + await page.setViewportSize({ width: 1200, height: 900 }); + await waitForMain(page); + + const controls: Array<[string, Locator]> = [ + [ + '천통국 베팅', + page.locator('.main-global-menu[data-menu-position="top"] [data-navigation-id="nation-betting"]'), + ], + [ + '게임정보', + page.locator('.main-global-menu[data-menu-position="top"]').getByRole('button', { + name: '게임정보', + exact: true, + }), + ], + ['회 의 실', page.locator('.layout-desktop [data-navigation-id="meeting"]')], + ['기 밀 실', page.locator('.layout-desktop [data-navigation-id="secret-board"]')], + [ + '당기기', + page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '당기기' }), + ], + [ + '미루기', + page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '미루기' }), + ], + [ + '펼치기', + page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '펼치기' }), + ], + ['실시간 동기화', page.locator('.desktop-action-controls').getByRole('button', { name: /실시간 동기화:/u })], + ['갱 신', page.locator('.desktop-action-controls').getByRole('button', { name: '갱 신' })], + ['로비로', page.locator('.desktop-action-controls').getByRole('button', { name: '로비로' })], + ]; + + const measure = (control: Locator) => + control.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + top: rect.top, + bottom: rect.bottom, + height: rect.height, + marginTop: style.marginTop, + borderTop: style.borderTopWidth, + borderRight: style.borderRightWidth, + borderBottom: style.borderBottomWidth, + borderLeft: style.borderLeftWidth, + radius: style.borderRadius, + background: style.backgroundColor, + filter: style.filter, + }; + }); + + const evidence: Record> = {}; + for (const [index, [label, control]] of controls.entries()) { + await expect(control, `${label} control`).toBeVisible(); + await expect(control).toHaveClass(/legacy-button/u); + await control.scrollIntoViewIfNeeded(); + await page.mouse.move(1195, 895); + const base = await measure(control); + evidence[label] = { default: base }; + if (artifactRoot) { + await control.screenshot({ path: resolve(artifactRoot, `${index + 1}-default.png`) }); + } + expect(base, `${label} default geometry`).toMatchObject({ + marginTop: '0px', + borderTop: '0px', + borderRight: '1px', + borderBottom: '4px', + borderLeft: '1px', + radius: '5.25px', + filter: 'none', + }); + + await control.focus(); + await expect(control, `${label} keyboard focus`).toBeFocused(); + const focused = await measure(control); + evidence[label].focus = focused; + if (artifactRoot) { + await control.screenshot({ path: resolve(artifactRoot, `${index + 1}-focus.png`) }); + } + expect(focused.borderBottom, `${label} focus edge`).toBe('4px'); + expect(focused.marginTop, `${label} focus position`).toBe('0px'); + + await control.hover(); + const hovered = await measure(control); + evidence[label].hover = hovered; + if (artifactRoot) { + await control.screenshot({ path: resolve(artifactRoot, `${index + 1}-hover.png`) }); + } + expect(hovered.borderBottom, `${label} hover edge`).toBe('3px'); + expect(hovered.marginTop, `${label} hover position`).toBe('1px'); + expect(hovered.top, `${label} hover top`).toBeCloseTo(base.top + 1, 2); + expect(hovered.height, `${label} hover height`).toBeCloseTo(base.height - 1, 2); + expect(hovered.bottom, `${label} hover bottom`).toBeCloseTo(base.bottom, 2); + expect(hovered.background, `${label} hover face`).toBe(base.background); + + const box = await control.boundingBox(); + if (!box) throw new Error(`${label} control has no bounding box`); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + const pressed = await measure(control); + evidence[label].pointerDown = pressed; + if (artifactRoot) { + await control.screenshot({ path: resolve(artifactRoot, `${index + 1}-pointer-down.png`) }); + } + expect(pressed.borderBottom, `${label} pressed edge`).toBe('2px'); + expect(pressed.marginTop, `${label} pressed position`).toBe('2px'); + expect(pressed.top, `${label} pressed top`).toBeCloseTo(base.top + 2, 2); + expect(pressed.height, `${label} pressed height`).toBeCloseTo(base.height - 2, 2); + expect(pressed.bottom, `${label} pressed bottom`).toBeCloseTo(base.bottom, 2); + expect(pressed.background, `${label} pressed face`).toBe(base.background); + await page.mouse.move(1195, 895); + await page.mouse.up(); + } + + state.permission = 0; + await page.locator('.desktop-action-controls').getByRole('button', { name: '갱 신' }).click(); + const disabledSecret = page.locator('.layout-desktop [data-navigation-id="secret-board"]'); + await expect(disabledSecret).toHaveAttribute('aria-disabled', 'true'); + await disabledSecret.scrollIntoViewIfNeeded(); + const disabledBase = await measure(disabledSecret); + await disabledSecret.hover({ force: true }); + const disabledHover = await measure(disabledSecret); + evidence['기 밀 실 disabled'] = { default: disabledBase, hover: disabledHover }; + expect(disabledHover.borderBottom).toBe('4px'); + expect(disabledHover.marginTop).toBe('0px'); + expect(disabledHover.top).toBeCloseTo(disabledBase.top, 2); + if (artifactRoot) { + await disabledSecret.screenshot({ path: resolve(artifactRoot, 'disabled-secret-hover.png') }); + await writeFile( + resolve(artifactRoot, 'main-lumen-button-states.json'), + `${JSON.stringify(evidence, null, 2)}\n` + ); + } + + await persistArtifact(page, `${basePath.slice(1)}-main-lumen-button-families`); +}); + +test('mobile main Lumen button families keep the same state geometry without overflow', async ({ page }) => { + const state: NavigationFixture = { + officerLevel: 5, + permission: 2, + nationLevel: 3, + stage: 0, + npcMode: 1, + generalMeCalls: 0, + operations: [], + nationColor: '#663399', + }; + await installFixture(page, state); + await page.setViewportSize({ width: 500, height: 900 }); + await waitForMain(page); + + const controls = [ + page.locator('.main-global-menu[data-menu-position="top"] [data-navigation-id="nation-betting"]'), + page.locator('.main-global-menu[data-menu-position="top"]').getByRole('button', { + name: '게임정보', + exact: true, + }), + page.locator('.layout-mobile [data-navigation-id="meeting"]'), + page.locator('.layout-mobile [data-navigation-id="secret-board"]'), + page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '당기기' }), + page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '미루기' }), + page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '펼치기' }), + page.locator('.desktop-action-controls').getByRole('button', { name: /실시간 동기화:/u }), + page.locator('.desktop-action-controls').getByRole('button', { name: '갱 신' }), + page.locator('.desktop-action-controls').getByRole('button', { name: '로비로' }), + ]; + for (const control of controls) { + await expect(control).toBeVisible(); + await expect(control).toHaveClass(/legacy-button/u); + await expect(control).toHaveCSS('border-radius', '5.25px'); + await expect(control).toHaveCSS('border-bottom-width', '4px'); + } + + for (const control of [controls[0], controls[2], controls[4], controls[7]]) { + if (!control) throw new Error('mobile Lumen control is missing'); + await control.scrollIntoViewIfNeeded(); + await control.focus(); + await expect(control).toBeFocused(); + await expect(control).toHaveCSS('border-bottom-width', '4px'); + await control.hover(); + await expect(control).toHaveCSS('border-bottom-width', '3px'); + await expect(control).toHaveCSS('margin-top', '1px'); + const box = await control.boundingBox(); + if (!box) throw new Error('mobile Lumen control is not measurable'); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await expect(control).toHaveCSS('border-bottom-width', '2px'); + await expect(control).toHaveCSS('margin-top', '2px'); + await page.mouse.move(499, 899); + await page.mouse.up(); + } + + expect( + await page.evaluate(() => ({ + document: document.documentElement.scrollWidth - document.documentElement.clientWidth, + body: document.body.scrollWidth - document.body.clientWidth, + })) + ).toEqual({ document: 0, body: 0 }); + await persistArtifact(page, `${basePath.slice(1)}-mobile-main-lumen-button-families`); +}); + test('mobile single document refreshes once and preserves tokens on lobby return', async ({ page }) => { const state: NavigationFixture = { officerLevel: 5, diff --git a/app/game-frontend/src/assets/styles/game-shell.css b/app/game-frontend/src/assets/styles/game-shell.css index 392ed991..9929c557 100644 --- a/app/game-frontend/src/assets/styles/game-shell.css +++ b/app/game-frontend/src/assets/styles/game-shell.css @@ -48,26 +48,6 @@ cursor: pointer; } -/* - * Ref renders the dashboard reload control with the Lumen navigation family: - * the bottom edge shortens on hover and again while pressed. - */ -.game-shell__action--navigation { - border-color: var(--sammo-button-navigation-border); - border-width: 0 1px 4px; - background: var(--sammo-button-navigation-bg); -} - -.game-shell__action--navigation:not(:disabled):hover { - margin-top: 1px; - border-bottom-width: 3px; -} - -.game-shell__action--navigation:not(:disabled):active { - margin-top: 2px; - border-bottom-width: 2px; -} - .game-feedback--error { color: var(--sammo-color-error); font-size: 0.85rem; diff --git a/app/game-frontend/src/assets/styles/legacy-controls.css b/app/game-frontend/src/assets/styles/legacy-controls.css index 47a9d79a..be50525f 100644 --- a/app/game-frontend/src/assets/styles/legacy-controls.css +++ b/app/game-frontend/src/assets/styles/legacy-controls.css @@ -56,7 +56,6 @@ --legacy-button-bg: var(--sammo-button-primary-bg); --legacy-button-border: var(--sammo-button-primary-border); --legacy-button-color: #fff; - min-height: 35.5px; margin-top: 0; border-color: var(--legacy-button-border); border-style: solid; @@ -65,6 +64,7 @@ padding: 5.25px 10.5px; background: var(--legacy-button-bg); color: var(--legacy-button-color); + filter: none; line-height: 21px; /* Ref's framework baseline for these controls. */ vertical-align: middle; diff --git a/app/game-frontend/src/components/command/ReservedCommandEditor.vue b/app/game-frontend/src/components/command/ReservedCommandEditor.vue index 7258a040..52b42b6b 100644 --- a/app/game-frontend/src/components/command/ReservedCommandEditor.vue +++ b/app/game-frontend/src/components/command/ReservedCommandEditor.vue @@ -665,9 +665,15 @@ const clickOutsideMenu = (event: Event) => {
- - - + + +
@@ -790,7 +796,6 @@ const clickOutsideMenu = (event: Event) => { .control-pad > button, .clock, .legacy-menu > summary, -.bottom-actions button, .select-command { box-sizing: border-box; min-height: 34px; diff --git a/app/game-frontend/src/components/main/MainGlobalMenu.vue b/app/game-frontend/src/components/main/MainGlobalMenu.vue index a149751d..419fe7e5 100644 --- a/app/game-frontend/src/components/main/MainGlobalMenu.vue +++ b/app/game-frontend/src/components/main/MainGlobalMenu.vue @@ -26,10 +26,11 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props :link="entry" :enabled="isNavigationConfigured(entry)" :active="isActive(entry)" + lumen-variant="navigation" />
-
+
{{ logLabels[type] }}