diff --git a/app/game-frontend/e2e/nationOffices.spec.ts b/app/game-frontend/e2e/nationOffices.spec.ts index faa014f0..727a3ff4 100644 --- a/app/game-frontend/e2e/nationOffices.spec.ts +++ b/app/game-frontend/e2e/nationOffices.spec.ts @@ -15,6 +15,7 @@ type FixtureState = { appointedGeneralId?: number; appointedCityId?: number; appointedOfficerLevel?: number; + kickedGeneralId?: number; permissionMutationInput?: { isAmbassador: boolean; targetGeneralIds: number[] }; noticeMutationInput?: string; scoutMutationInput?: string; @@ -101,7 +102,7 @@ const personnelFixture = (state: FixtureState) => { general(3, '하후돈', 4), general(4, '곽가', 3), general(5, '정욱', 2), - general(6, '장료', 1), + general(6, '장료', 1, { npcState: 2 }), general(7, '허저', 1, { permission: 'ambassador' }), general(8, '가후', 1, { permission: 'auditor' }), general(9, '전위', 1), @@ -256,7 +257,10 @@ const installFixture = async (page: Page, state: FixtureState) => { }; return response({ ok: true }); } - if (operation === 'nation.kick') return response({ ok: true }); + if (operation === 'nation.kick') { + state.kickedGeneralId = Number(jsonInput.destGeneralId ?? 0); + return response({ ok: true }); + } if (operation === 'nation.setRate') { if (state.failNextRate) { state.failNextRate = false; @@ -425,6 +429,16 @@ test('personnel selects an informed general and reports the JosaUtil-composed re const picker = page.getByTestId('personnel-selection-dialog'); await expect(picker).toBeVisible(); await expect(picker.getByRole('heading', { name: '주부 임명 대상 선택' })).toBeVisible(); + await expect(picker.locator('.personnel-picker-card:not(.vacancy-card)')).toHaveText([ + /가후/, + /곽가/, + /순욱/, + /전위/, + /정욱/, + /하후돈/, + /허저/, + /장료/, + ]); await picker.getByPlaceholder('장수명·도시·관직·특성 검색').fill('장료'); const candidate = picker.getByRole('button', { name: /장료/ }); await expect(candidate).toContainText('허창 · 일반 장수'); @@ -476,14 +490,7 @@ test('personnel reflows row-level appointments at 500px and 390px without gradie expect(rowGeometry.officerWidth).toBeLessThan(141); expect(rowGeometry.gradientCount).toBe(0); await expect(page.getByRole('combobox', { name: '외교권자' })).toHaveCount(0); - await expect(page.getByRole('combobox', { name: '추방 대상 장수' })).toBeVisible(); - await expect(page.getByRole('combobox', { name: '추방 대상 장수' }).locator('option')).toHaveText([ - '장수 선택', - '하후돈 (70/70/70)', - '곽가 (70/70/70)', - '정욱 (70/70/70)', - '장료 (70/70/70)', - ]); + await expect(page.getByRole('button', { name: '추방 대상 선택하기' })).toBeVisible(); await page.getByRole('button', { name: '허창 태수 변경하기', exact: true }).click(); const picker = page.getByTestId('personnel-selection-dialog'); @@ -561,6 +568,53 @@ test('personnel reflows row-level appointments at 500px and 390px without gradie await screenshot(page, 'core-personnel-mobile-rows.png'); }); +test('personnel chooses a kick target in the informed picker and double-confirms a user general', async ({ page }) => { + const state: FixtureState = { role: 'head', rate: 20 }; + await installFixture(page, state); + await page.setViewportSize({ width: 390, height: 844 }); + await gotoOffice(page, 'nation/personnel'); + + const kickLabel = page.locator('.kick-label'); + await expect(kickLabel).toHaveText('대상 장수'); + expect(await kickLabel.evaluate((element) => getComputedStyle(element).whiteSpace)).toBe('nowrap'); + + await page.getByRole('button', { name: '추방 대상 선택하기' }).click(); + const picker = page.getByTestId('personnel-selection-dialog'); + await expect(picker.getByRole('heading', { name: '추방 대상 선택' })).toBeVisible(); + await expect(picker.locator('.vacancy-card')).toHaveCount(0); + await expect(picker.locator('.personnel-picker-card')).toHaveText([ + /가후/, + /곽가/, + /전위/, + /정욱/, + /하후돈/, + /장료/, + ]); + await picker.getByRole('button', { name: /정욱/ }).click(); + await expect(picker).toBeHidden(); + await expect(page.getByRole('button', { name: '정욱', exact: true })).toBeVisible(); + + await page.getByRole('button', { name: '추방', exact: true }).click(); + const confirmation = page.getByTestId('game-notice-dialog'); + await expect(confirmation).toContainText('정욱을 추방하시겠습니까?'); + await confirmation.getByRole('button', { name: '확인', exact: true }).click(); + await expect(confirmation).toContainText('정욱은 유저장입니다. 그래도 추방하시겠습니까?'); + await confirmation.getByRole('button', { name: '취소', exact: true }).click(); + await expect(confirmation).toBeHidden(); + expect(state.kickedGeneralId).toBeUndefined(); + + await page.getByRole('button', { name: '추방', exact: true }).click(); + await expect(confirmation).toContainText('정욱을 추방하시겠습니까?'); + await confirmation.getByRole('button', { name: '확인', exact: true }).click(); + await expect(confirmation).toContainText('정욱은 유저장입니다. 그래도 추방하시겠습니까?'); + await confirmation.getByRole('button', { name: '확인', exact: true }).click(); + await expect(confirmation).toBeHidden(); + await expect(page.getByTestId('game-toast')).toContainText('정욱을 추방했습니다.'); + expect(state.kickedGeneralId).toBe(5); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(390); + await screenshot(page, 'core-personnel-mobile-kick-picker.png'); +}); + test('@ios-webkit iPhone touch appoints a city officer after the picker closes', async ({ browser }, testInfo) => { const configuredBaseUrl = testInfo.project.use.baseURL; if (typeof configuredBaseUrl !== 'string') throw new Error('Playwright baseURL is required'); @@ -589,7 +643,7 @@ test('personnel hides every mutation control for an ordinary member and exposes await gotoOffice(page, 'nation/personnel'); await expect(page.getByRole('button', { name: /변경하기/ })).toHaveCount(0); await expect(page.getByText('외 교 권 자 임 명')).toHaveCount(0); - await expect(page.getByRole('combobox', { name: '추방 대상 장수' })).toHaveCount(0); + await expect(page.getByRole('button', { name: '추방 대상 선택하기' })).toHaveCount(0); await expect(page.getByRole('button', { name: '추방', exact: true })).toHaveCount(0); const auditorCell = page.locator('.city-officer-cell').filter({ hasText: '곽가' }); await expect(auditorCell).toContainText('10년 · 허창'); diff --git a/app/game-frontend/src/views/NationPersonnelView.vue b/app/game-frontend/src/views/NationPersonnelView.vue index cb7c6784..dbf972dd 100644 --- a/app/game-frontend/src/views/NationPersonnelView.vue +++ b/app/game-frontend/src/views/NationPersonnelView.vue @@ -8,6 +8,7 @@ import PermissionMultiSelect from '../components/personnel/PermissionMultiSelect import PersonnelSelectionDialog from '../components/personnel/PersonnelSelectionDialog.vue'; import { useGameFeedback } from '../composables/useGameFeedback'; import { resolveGeneralIconBackgroundImage } from '../utils/generalIcon'; +import { sortGeneralsByTypeThenName } from '../utils/generalOrder'; import { trpc } from '../utils/trpc'; import { cityLevelMap, formatOfficerLevelText, getNationChiefLevel, regionMap } from '../utils/nationFormat'; import { legacyNationTextColor } from '../utils/legacyNationColor'; @@ -27,7 +28,9 @@ type SelectionDialogItem = { details: Array<{ label: string; value: string }>; }; type SelectionContext = - { kind: 'chief-general'; level: number } | { kind: 'city-general'; level: OfficerLevel; cityId: number }; + | { kind: 'chief-general'; level: number } + | { kind: 'city-general'; level: OfficerLevel; cityId: number } + | { kind: 'kick-general' }; const officerLabels: Record = { 4: '태수', 3: '군사', 2: '종사' }; const cityOfficerLevels: OfficerLevel[] = [4, 3, 2]; @@ -88,20 +91,26 @@ const currentOfficeText = (general: GeneralEntry): string => { const chiefCandidates = (level: number): GeneralEntry[] => { const minimum = data.value?.chiefStatMin ?? 0; const candidates = (data.value?.generals ?? []).filter((general) => general.officerLevel !== 12); - if (level === 11) return candidates; - if (level % 2 === 0) return candidates.filter((general) => general.stats.strength >= minimum); - return candidates.filter((general) => general.stats.intelligence >= minimum); + if (level === 11) return sortGeneralsByTypeThenName(candidates); + if (level % 2 === 0) + return sortGeneralsByTypeThenName(candidates.filter((general) => general.stats.strength >= minimum)); + return sortGeneralsByTypeThenName(candidates.filter((general) => general.stats.intelligence >= minimum)); }; const cityCandidates = (level: OfficerLevel): GeneralEntry[] => { const minimum = data.value?.chiefStatMin ?? 0; const candidates = (data.value?.generals ?? []).filter((general) => general.officerLevel !== 12); - if (level === 4) return candidates.filter((general) => general.stats.strength >= minimum); - if (level === 3) return candidates.filter((general) => general.stats.intelligence >= minimum); - return candidates; + if (level === 4) + return sortGeneralsByTypeThenName(candidates.filter((general) => general.stats.strength >= minimum)); + if (level === 3) + return sortGeneralsByTypeThenName(candidates.filter((general) => general.stats.intelligence >= minimum)); + return sortGeneralsByTypeThenName(candidates); }; const kickCandidates = computed(() => - (data.value?.generals ?? []).filter( - (general) => general.id !== data.value?.me.id && general.officerLevel < 5 && general.permission !== 'ambassador' + sortGeneralsByTypeThenName( + (data.value?.generals ?? []).filter( + (general) => + general.id !== data.value?.me.id && general.officerLevel < 5 && general.permission !== 'ambassador' + ) ) ); const awardText = (entries: PersonnelResponse['awards']['tigers']): string => @@ -208,6 +217,7 @@ const selectionTitle = computed(() => { if (context.kind === 'chief-general') { return `${formatOfficerLevelText(context.level, nationLevel.value)} 임명 대상 선택`; } + if (context.kind === 'kick-general') return '추방 대상 선택'; const city = data.value?.cityAssignments.find((entry) => entry.id === context.cityId); return `${city?.name ?? ''} ${officerLabels[context.level]} 변경`; }); @@ -219,6 +229,9 @@ const selectionDescription = computed(() => { const stat = context.level % 2 === 0 ? '무력' : '지력'; return `${stat} ${data.value?.chiefStatMin ?? 0} 이상인 장수만 표시합니다. 현재 관직과 주요 능력치를 함께 확인하세요.`; } + if (context.kind === 'kick-general') { + return '현재 관직과 소재지, 장수 유형을 확인한 뒤 추방할 장수를 선택하세요.'; + } if (context.level === 4) { return `무력 ${data.value?.chiefStatMin ?? 0} 이상인 장수만 표시합니다. 현재 관직과 소재지를 함께 확인하세요.`; } @@ -234,6 +247,9 @@ const selectionItems = computed(() => { const currentGeneralId = chiefAssignments.value[context.level]?.id ?? 0; return chiefCandidates(context.level).map((general) => generalSelectionItem(general, currentGeneralId)); } + if (context.kind === 'kick-general') { + return kickCandidates.value.map((general) => generalSelectionItem(general, kickTargetId.value)); + } const city = data.value?.cityAssignments.find((entry) => entry.id === context.cityId); const currentGeneralId = city?.officers[context.level]?.id ?? 0; return cityCandidates(context.level).map((general) => generalSelectionItem(general, currentGeneralId)); @@ -242,6 +258,7 @@ const selectionId = computed(() => { const context = selectionContext.value; if (!context) return 0; if (context.kind === 'chief-general') return chiefAssignments.value[context.level]?.id ?? 0; + if (context.kind === 'kick-general') return kickTargetId.value; return data.value?.cityAssignments.find((entry) => entry.id === context.cityId)?.officers[context.level]?.id ?? 0; }); const applySelection = async (id: number): Promise => { @@ -250,7 +267,8 @@ const applySelection = async (id: number): Promise => { selectionContext.value = null; await nextTick(); if (context.kind === 'chief-general') await appointChief(context.level, id); - else await appointCityOfficer(context.level, context.cityId, id); + else if (context.kind === 'city-general') await appointCityOfficer(context.level, context.cityId, id); + else kickTargetId.value = id; }; const reportPermissionLimit = () => { @@ -269,6 +287,12 @@ const changePermissions = async (isAmbassador: boolean) => { const kickGeneral = async () => { const target = generalMap.value.get(kickTargetId.value); if (!target || !(await showConfirm(`${JosaUtil.put(target.name, '을')} 추방하시겠습니까?`))) return; + if ( + target.npcState === 0 && + !(await showConfirm(`${JosaUtil.put(target.name, '은')} 유저장입니다. 그래도 추방하시겠습니까?`)) + ) { + return; + } await runMutation( () => trpc.nation.kick.mutate({ destGeneralId: target.id }), `${JosaUtil.put(target.name, '을')} 추방했습니다.` @@ -520,18 +544,13 @@ onMounted(() => void loadPersonnel()); 대상 장수