diff --git a/app/game-frontend/e2e/nationOffices.spec.ts b/app/game-frontend/e2e/nationOffices.spec.ts index 1aff2cc9..0ed26756 100644 --- a/app/game-frontend/e2e/nationOffices.spec.ts +++ b/app/game-frontend/e2e/nationOffices.spec.ts @@ -11,6 +11,8 @@ type FixtureState = { failPersonnelLoad?: boolean; rate: number; appointedGeneralId?: number; + appointedCityId?: number; + appointedOfficerLevel?: number; noticeMutationInput?: string; scoutMutationInput?: string; uploadDataUrl?: string; @@ -230,7 +232,9 @@ const installFixture = async (page: Page, state: FixtureState) => { } if (operation === 'nation.getStratFinan') return response(financeFixture(state)); if (operation === 'nation.appoint') { - state.appointedGeneralId = 6; + state.appointedGeneralId = Number(jsonInput.destGeneralId ?? 0); + state.appointedCityId = Number(jsonInput.destCityId ?? 0); + state.appointedOfficerLevel = Number(jsonInput.officerLevel ?? 0); return response({ ok: true }); } if (operation === 'nation.kick' || operation === 'nation.changePermission') return response({ ok: true }); @@ -281,7 +285,7 @@ const screenshot = async (page: Page, name: string) => { await page.screenshot({ path: resolve(artifactRoot, name), fullPage: true }); }; -test('personnel matches the 1000px legacy table geometry, textures, image, and interaction states', async ({ +test('personnel keeps the legacy frame while presenting modern appointment cards and interaction states', async ({ page, }) => { await installFixture(page, { role: 'leader', rate: 20 }); @@ -309,6 +313,8 @@ test('personnel matches the 1000px legacy table geometry, textures, image, and i heading: box('.heading-table'), status: box('.chief-status'), icon: box('.general-icon'), + appointmentCard: box('.appointment-card'), + selectionTrigger: box('.selection-trigger'), documentWidth: document.documentElement.scrollWidth, }; }); @@ -318,16 +324,17 @@ test('personnel matches the 1000px legacy table geometry, textures, image, and i expect(computed.status.width).toBe(1000); expect(computed.icon.width).toBeCloseTo(64.7, 0); expect(computed.icon.height).toBeCloseTo(64, 0); + expect(computed.appointmentCard.width).toBeGreaterThan(450); + expect(computed.appointmentCard.height).toBeGreaterThan(140); + expect(computed.selectionTrigger.height).toBeGreaterThanOrEqual(70); expect(computed.container.fontFamily).toContain('Pretendard'); expect(computed.container.fontSize).toBe('14px'); expect(computed.container.lineHeight).toBe('18.2px'); expect(computed.status.backgroundImage).toContain('back_walnut.jpg'); expect(computed.documentWidth).toBe(1000); - const appointButton = page.getByRole('button', { name: '임명' }).first(); - expect(await appointButton.evaluate((button) => getComputedStyle(button).backgroundColor)).toBe( - 'rgb(108, 117, 125)' - ); + const appointButton = page.getByRole('button', { name: '주부 임명', exact: true }); + expect(await appointButton.evaluate((button) => getComputedStyle(button).backgroundColor)).toBe('rgb(55, 104, 70)'); await appointButton.hover(); expect(await appointButton.evaluate((button) => getComputedStyle(button).cursor)).toBe('pointer'); await appointButton.focus(); @@ -335,6 +342,44 @@ test('personnel matches the 1000px legacy table geometry, textures, image, and i await screenshot(page, 'core-personnel-desktop-leader.png'); }); +test('personnel selects an informed general and reports the JosaUtil-composed result in a toast', async ({ page }) => { + const state: FixtureState = { role: 'head', rate: 20 }; + await installFixture(page, state); + await page.setViewportSize({ width: 1000, height: 900 }); + await gotoOffice(page, 'nation/personnel'); + + await page.getByRole('button', { name: '주부 장수 선택', exact: true }).click(); + const picker = page.getByTestId('personnel-selection-dialog'); + await expect(picker).toBeVisible(); + await expect(picker.getByRole('heading', { name: '주부 임명 대상 선택' })).toBeVisible(); + await picker.getByPlaceholder('장수명·도시·관직·특성 검색').fill('장료'); + const candidate = picker.getByRole('button', { name: /장료/ }); + await expect(candidate).toContainText('허창 · 일반 장수'); + await expect(candidate).toContainText('통솔70'); + await expect(candidate).toContainText('소속10년'); + await expect(candidate).toContainText('병력100'); + await candidate.hover(); + expect(await candidate.evaluate((button) => getComputedStyle(button).cursor)).toBe('pointer'); + await candidate.focus(); + expect(await candidate.evaluate((button) => getComputedStyle(button).outlineStyle)).not.toBe('none'); + await screenshot(page, 'core-personnel-desktop-general-picker.png'); + await candidate.click(); + + await expect(page.getByRole('button', { name: '주부 장수 선택', exact: true })).toContainText('장료'); + page.once('dialog', async (dialog) => { + expect(dialog.message()).toBe('장료를 주부직에 임명하시겠습니까?'); + await dialog.accept(); + }); + await page.getByRole('button', { name: '주부 임명', exact: true }).click(); + + await expect(page.getByTestId('game-toast')).toContainText('장료를 임명했습니다.'); + expect(state.appointedGeneralId).toBe(6); + expect(state.appointedCityId).toBe(0); + expect(state.appointedOfficerLevel).toBe(11); + await expect(page.locator('.feedback.status')).toHaveCount(0); + await screenshot(page, 'core-personnel-appointment-toast.png'); +}); + test('personnel preserves the legacy fixed 1000px document on a 500px viewport', async ({ page }) => { await installFixture(page, { role: 'head', rate: 20 }); await page.setViewportSize({ width: 500, height: 900 }); @@ -346,6 +391,38 @@ test('personnel preserves the legacy fixed 1000px document on a 500px viewport', expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(1000); await expect(page.getByRole('combobox', { name: '외교권자' })).toHaveCount(0); await expect(page.getByRole('combobox', { name: '추방 대상 장수' })).toBeVisible(); + + await page.getByRole('button', { name: '태수 도시 선택', exact: true }).click(); + const picker = page.getByTestId('personnel-selection-dialog'); + await expect(picker).toBeVisible(); + expect(await picker.evaluate((element) => getComputedStyle(element).transitionDuration)).toContain('0.15s'); + await expect(picker).toHaveCSS('transform', 'none'); + const pickerGeometry = await picker.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + left: rect.left, + right: rect.right, + bottom: rect.bottom, + width: rect.width, + maxHeight: style.maxHeight, + borderTopLeftRadius: style.borderTopLeftRadius, + }; + }); + expect(pickerGeometry.left).toBeGreaterThanOrEqual(0); + expect(pickerGeometry.right).toBeLessThanOrEqual(500); + expect(pickerGeometry.bottom).toBe(900); + expect(pickerGeometry.width).toBeGreaterThan(480); + expect(pickerGeometry.borderTopLeftRadius).toBe('16px'); + await expect(picker.getByRole('button', { name: /낙양/ })).toContainText('중원 · 중도시'); + await expect(picker.getByRole('button', { name: /허창/ })).toContainText('현재 태수하후돈'); + await picker.getByRole('button', { name: /낙양/ }).focus(); + expect( + await picker.getByRole('button', { name: /낙양/ }).evaluate((button) => getComputedStyle(button).outlineStyle) + ).not.toBe('none'); + await screenshot(page, 'core-personnel-mobile-city-picker.png'); + await picker.getByRole('button', { name: /낙양/ }).click(); + await expect(page.getByRole('button', { name: '태수 도시 선택', exact: true })).toContainText('낙양'); await screenshot(page, 'core-personnel-mobile-head.png'); }); diff --git a/app/game-frontend/src/components/personnel/PersonnelSelectionDialog.vue b/app/game-frontend/src/components/personnel/PersonnelSelectionDialog.vue new file mode 100644 index 00000000..173ba8aa --- /dev/null +++ b/app/game-frontend/src/components/personnel/PersonnelSelectionDialog.vue @@ -0,0 +1,528 @@ + + + + + diff --git a/app/game-frontend/src/views/NationPersonnelView.vue b/app/game-frontend/src/views/NationPersonnelView.vue index 4fd68a0a..146d2555 100644 --- a/app/game-frontend/src/views/NationPersonnelView.vue +++ b/app/game-frontend/src/views/NationPersonnelView.vue @@ -2,6 +2,9 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'; import { useRouter } from 'vue-router'; +import { JosaUtil } from '@sammo-ts/common'; + +import PersonnelSelectionDialog from '../components/personnel/PersonnelSelectionDialog.vue'; import { useGameFeedback } from '../composables/useGameFeedback'; import { resolveGeneralIconBackgroundImage } from '../utils/generalIcon'; import { trpc } from '../utils/trpc'; @@ -11,13 +14,28 @@ import { legacyNationTextColor } from '../utils/legacyNationColor'; type PersonnelResponse = Awaited>; type GeneralEntry = PersonnelResponse['generals'][number]; type OfficerLevel = 2 | 3 | 4; +type SelectionDialogItem = { + id: number; + name: string; + subtitle: string; + searchText: string; + accent: 'current' | 'assigned' | 'available'; + iconBackground?: string; + badges: string[]; + stats?: Array<{ label: string; value: string }>; + details: Array<{ label: string; value: string }>; +}; +type SelectionContext = + | { kind: 'chief-general'; level: number } + | { kind: 'city'; level: OfficerLevel } + | { kind: 'city-general'; level: OfficerLevel }; const officerLabels: Record = { 4: '태수', 3: '군사', 2: '종사' }; const cityOfficerLevels: OfficerLevel[] = [4, 3, 2]; const loading = ref(false); const error = ref(null); -const status = ref(null); const data = ref(null); +const selectionContext = ref(null); const chiefAppointmentDraft = reactive>({}); const cityDraft = reactive>({ 4: { cityId: 0, generalId: 0 }, @@ -28,7 +46,7 @@ const kickTargetId = ref(0); const ambassadorSelection = ref([]); const auditorSelection = ref([]); const router = useRouter(); -const { error: showErrorToast } = useGameFeedback(); +const { success: showSuccessToast, error: showErrorToast } = useGameFeedback(); const resolveErrorMessage = (value: unknown): string => value instanceof Error ? value.message : typeof value === 'string' ? value : 'unknown_error'; @@ -39,7 +57,6 @@ const loadPersonnel = async () => { error.value = null; try { data.value = await trpc.nation.getPersonnelInfo.query(); - status.value = null; } catch (err) { error.value = resolveErrorMessage(err); } finally { @@ -72,8 +89,14 @@ const officerLocked = (value: number, level: number): boolean => (value & (1 << const chiefLocked = (level: number): boolean => officerLocked(data.value?.nation.chiefSet ?? 0, level); const cityOfficerLocked = (city: PersonnelResponse['cityAssignments'][number], level: number): boolean => officerLocked(city.officerSet, level); -const candidateLabel = (general: GeneralEntry): string => - `${general.name} 【${cityNameMap.value.get(general.cityId) ?? '-'}】`; +const currentOfficeText = (general: GeneralEntry): string => { + if (general.officerLevel <= 1) return '일반 장수'; + const office = formatOfficerLevelText(general.officerLevel, nationLevel.value); + if (general.officerLevel >= 2 && general.officerLevel <= 4 && general.officerCityName) { + return `${general.officerCityName} ${office}`; + } + return office; +}; const chiefCandidates = (level: number): GeneralEntry[] => { const minimum = data.value?.chiefStatMin ?? 0; const candidates = (data.value?.generals ?? []).filter((general) => general.officerLevel !== 12); @@ -90,6 +113,12 @@ const cityCandidates = (level: OfficerLevel): GeneralEntry[] => { }; const openCities = (level: OfficerLevel) => (data.value?.cityAssignments ?? []).filter((city) => !cityOfficerLocked(city, level)); +const selectedChief = (level: number): GeneralEntry | undefined => + generalMap.value.get(chiefAppointmentDraft[level] ?? 0); +const selectedCity = (level: OfficerLevel): PersonnelResponse['cityAssignments'][number] | undefined => + data.value?.cityAssignments.find((city) => city.id === cityDraft[level].cityId); +const selectedCityGeneral = (level: OfficerLevel): GeneralEntry | undefined => + generalMap.value.get(cityDraft[level].generalId); const kickCandidates = computed(() => (data.value?.generals ?? []).filter((general) => general.id !== data.value?.me.id) ); @@ -114,14 +143,12 @@ watch(data, initializeDrafts); const runMutation = async (action: () => Promise, successMessage: string) => { error.value = null; - status.value = null; try { await action(); - status.value = successMessage; await loadPersonnel(); - status.value = successMessage; + showSuccessToast(successMessage); } catch (err) { - error.value = resolveErrorMessage(err); + showErrorToast(resolveErrorMessage(err)); } }; @@ -129,11 +156,13 @@ const appointChief = async (level: number) => { const targetId = chiefAppointmentDraft[level] ?? 0; const target = generalMap.value.get(targetId); const office = formatOfficerLevelText(level, nationLevel.value); - const prompt = target ? `${target.name}을(를) ${office}직에 임명하시겠습니까?` : `${office}직을 비우시겠습니까?`; + const prompt = target + ? `${JosaUtil.put(target.name, '을')} ${office}직에 임명하시겠습니까?` + : `${office}직을 비우시겠습니까?`; if (!window.confirm(prompt)) return; await runMutation( () => trpc.nation.appoint.mutate({ destGeneralId: targetId, destCityId: 0, officerLevel: level }), - target ? `${target.name}을(를) 임명했습니다.` : '관직을 비웠습니다.' + target ? `${JosaUtil.put(target.name, '을')} 임명했습니다.` : '관직을 비웠습니다.' ); }; @@ -142,7 +171,7 @@ const appointCityOfficer = async (level: OfficerLevel) => { const city = data.value?.cityAssignments.find((entry) => entry.id === draft.cityId); const target = generalMap.value.get(draft.generalId); const prompt = target - ? `${target.name}을(를) ${city?.name ?? ''} ${officerLabels[level]}직에 임명하시겠습니까?` + ? `${JosaUtil.put(target.name, '을')} ${city?.name ?? ''} ${officerLabels[level]}직에 임명하시겠습니까?` : `${city?.name ?? ''} ${officerLabels[level]}직을 비우시겠습니까?`; if (!window.confirm(prompt)) return; await runMutation( @@ -152,10 +181,130 @@ const appointCityOfficer = async (level: OfficerLevel) => { destCityId: draft.cityId, officerLevel: level, }), - target ? `${target.name}을(를) 임명했습니다.` : '관직을 비웠습니다.' + target ? `${JosaUtil.put(target.name, '을')} 임명했습니다.` : '관직을 비웠습니다.' ); }; +const generalSelectionItem = (general: GeneralEntry, targetLevel: number): SelectionDialogItem => { + const isCurrent = general.officerLevel === targetLevel; + const isAssigned = general.officerLevel > 1 && !isCurrent; + const office = currentOfficeText(general); + const city = general.cityName ?? cityNameMap.value.get(general.cityId) ?? '-'; + const badges = [isCurrent ? '현재 임명 중' : isAssigned ? '다른 관직 재직' : '일반 장수']; + if (general.npcState > 0) badges.push('NPC'); + if (general.personality?.name) badges.push(general.personality.name); + if (general.specialDomestic?.name) badges.push(general.specialDomestic.name); + if (general.specialWar?.name) badges.push(general.specialWar.name); + return { + id: general.id, + name: general.name, + subtitle: `${city} · ${office}`, + searchText: [ + general.name, + city, + office, + general.personality?.name, + general.specialDomestic?.name, + general.specialWar?.name, + general.troopName, + ] + .filter(Boolean) + .join(' '), + accent: isCurrent ? 'current' : isAssigned ? 'assigned' : 'available', + iconBackground: imageBackground(general), + badges, + stats: [ + { label: '통솔', value: general.stats.leadership.toLocaleString('ko-KR') }, + { label: '무력', value: general.stats.strength.toLocaleString('ko-KR') }, + { label: '지력', value: general.stats.intelligence.toLocaleString('ko-KR') }, + ], + details: [ + { label: '소속', value: `${general.belong.toLocaleString('ko-KR')}년` }, + { label: '병력', value: general.crew.toLocaleString('ko-KR') }, + { label: '부대', value: general.troopName ?? '없음' }, + { label: '부상', value: general.injury > 0 ? `${general.injury}%` : '없음' }, + ], + }; +}; + +const citySelectionItem = ( + city: PersonnelResponse['cityAssignments'][number], + level: OfficerLevel +): SelectionDialogItem => { + const current = city.officers[level]; + const region = regionMap[city.region] ?? '-'; + const scale = cityLevelMap[city.level] ?? '-'; + return { + id: city.id, + name: city.name, + subtitle: `${region} · ${scale}도시`, + searchText: `${city.name} ${region} ${scale} ${current?.name ?? '공석'}`, + accent: current ? 'assigned' : 'available', + badges: [current ? `${officerLabels[level]} 재직 중` : `${officerLabels[level]} 공석`], + details: [ + { label: '지역', value: region }, + { label: '규모', value: `${scale}도시` }, + { label: `현재 ${officerLabels[level]}`, value: current?.name ?? '공석' }, + { label: '소재지', value: current?.cityName ?? '-' }, + ], + }; +}; + +const selectionTitle = computed(() => { + const context = selectionContext.value; + if (!context) return ''; + if (context.kind === 'chief-general') { + return `${formatOfficerLevelText(context.level, nationLevel.value)} 임명 대상 선택`; + } + if (context.kind === 'city') return `${officerLabels[context.level]} 임명 도시 선택`; + return `${officerLabels[context.level]} 임명 대상 선택`; +}); +const selectionDescription = computed(() => { + const context = selectionContext.value; + if (!context) return ''; + if (context.kind === 'city') { + return '지역과 도시 규모, 현재 재직자를 확인한 뒤 임명할 도시를 선택하세요.'; + } + if (context.kind === 'chief-general') { + if (context.level === 11) return '군주를 제외한 장수 중에서 임명할 수 있습니다.'; + const stat = context.level % 2 === 0 ? '무력' : '지력'; + return `${stat} ${data.value?.chiefStatMin ?? 0} 이상인 장수만 표시합니다. 현재 관직과 주요 능력치를 함께 확인하세요.`; + } + if (context.level === 4) { + return `무력 ${data.value?.chiefStatMin ?? 0} 이상인 장수만 표시합니다. 현재 관직과 소재지를 함께 확인하세요.`; + } + if (context.level === 3) { + return `지력 ${data.value?.chiefStatMin ?? 0} 이상인 장수만 표시합니다. 현재 관직과 소재지를 함께 확인하세요.`; + } + return '현재 관직과 소재지, 주요 능력치를 확인한 뒤 임명할 장수를 선택하세요.'; +}); +const selectionItems = computed(() => { + const context = selectionContext.value; + if (!context) return []; + if (context.kind === 'chief-general') { + return chiefCandidates(context.level).map((general) => generalSelectionItem(general, context.level)); + } + if (context.kind === 'city') { + return openCities(context.level).map((city) => citySelectionItem(city, context.level)); + } + return cityCandidates(context.level).map((general) => generalSelectionItem(general, context.level)); +}); +const selectionId = computed(() => { + const context = selectionContext.value; + if (!context) return 0; + if (context.kind === 'chief-general') return chiefAppointmentDraft[context.level] ?? 0; + if (context.kind === 'city') return cityDraft[context.level].cityId; + return cityDraft[context.level].generalId; +}); +const applySelection = (id: number): void => { + const context = selectionContext.value; + if (!context) return; + if (context.kind === 'chief-general') chiefAppointmentDraft[context.level] = id; + else if (context.kind === 'city') cityDraft[context.level].cityId = id; + else cityDraft[context.level].generalId = id; + selectionContext.value = null; +}; + const enforcePermissionLimit = (selection: number[]) => { if (selection.length <= 2) return; selection.splice(0, selection.length - 2); @@ -173,10 +322,10 @@ const changePermissions = async (isAmbassador: boolean) => { const kickGeneral = async () => { const target = generalMap.value.get(kickTargetId.value); - if (!target || !window.confirm(`${target.name}을(를) 추방하시겠습니까?`)) return; + if (!target || !window.confirm(`${JosaUtil.put(target.name, '을')} 추방하시겠습니까?`)) return; await runMutation( () => trpc.nation.kick.mutate({ destGeneralId: target.id }), - `${target.name}을(를) 추방했습니다.` + `${JosaUtil.put(target.name, '을')} 추방했습니다.` ); }; @@ -198,7 +347,6 @@ onMounted(() => void loadPersonnel()); -
불러오는 중...
@@ -514,6 +765,20 @@ onMounted(() => void loadPersonnel()); + +