diff --git a/app/game-api/src/router/nation/endpoints/getPersonnelInfo.ts b/app/game-api/src/router/nation/endpoints/getPersonnelInfo.ts index 7d4f46b6..219a8f6e 100644 --- a/app/game-api/src/router/nation/endpoints/getPersonnelInfo.ts +++ b/app/game-api/src/router/nation/endpoints/getPersonnelInfo.ts @@ -153,11 +153,17 @@ export const getPersonnelInfo = accessAuthedProcedure.query(async ({ ctx }) => { const canChangePermissions = me.officerLevel === 12; const ambassadors = canChangePermissions ? permissionCandidates.filter( - (candidate) => candidate.permission === 'ambassador' || candidate.maxPermission === 4 + (candidate) => + candidate.permission === 'ambassador' || + (candidate.permission === 'normal' && candidate.maxPermission === 4) ) : []; const auditors = canChangePermissions - ? permissionCandidates.filter((candidate) => candidate.permission === 'auditor' || candidate.maxPermission >= 3) + ? permissionCandidates.filter( + (candidate) => + candidate.permission === 'auditor' || + (candidate.permission === 'normal' && candidate.maxPermission >= 3) + ) : []; const generalNameMap = new Map(mappedGenerals.map((general) => [general.id, general.name])); const awards = { diff --git a/app/game-api/test/nationPersonnelRouter.test.ts b/app/game-api/test/nationPersonnelRouter.test.ts index b9319798..59424490 100644 --- a/app/game-api/test/nationPersonnelRouter.test.ts +++ b/app/game-api/test/nationPersonnelRouter.test.ts @@ -230,6 +230,45 @@ describe('nation personnel router', () => { expect(result.awards.eagles).toEqual([{ id: 30, name: '군사', value: 7 }]); }); + it('keeps ambassador and auditor candidate pools mutually exclusive like Ref', async () => { + const me = { ...baseGeneral, officerLevel: 12 }; + const rows = [ + listRow({ id: 22, name: '군주', officerLevel: 12 }), + listRow({ id: 30, name: '현 외교권자', meta: { belong: 5, permission: 'ambassador' } }), + listRow({ id: 31, name: '현 조언자', meta: { belong: 5, permission: 'auditor' } }), + listRow({ id: 32, name: '일반 후보' }), + listRow({ id: 33, name: '외교 금지', penalty: { noAmbassador: true } }), + ]; + const context = createContext({ + me, + db: { + nation: { + findUnique: vi.fn(async () => ({ + id: 1, + name: '위', + color: '#777777', + level: 3, + typeCode: 'che_법가', + capitalCityId: 1, + meta: { chief_set: 0 }, + })), + }, + city: { findMany: vi.fn(async () => []) }, + troop: { findMany: vi.fn(async () => []) }, + general: { + findFirst: vi.fn(async () => me), + findMany: vi.fn(async () => rows), + }, + worldState: { findFirst: vi.fn(async () => ({ config: { stat: { chiefMin: 65 } } })) }, + rankData: { findMany: vi.fn(async () => []) }, + }, + }); + + const result = await appRouter.createCaller(context).nation.getPersonnelInfo(); + expect(result.permissionCandidates.ambassadors.map((candidate) => candidate.id)).toEqual([30, 32]); + expect(result.permissionCandidates.auditors.map((candidate) => candidate.id)).toEqual([31, 32]); + }); + it('allows finance mutations only for a head officer or an eligible ambassador', async () => { const nationDb = { nation: { diff --git a/app/game-frontend/e2e/nationOffices.spec.ts b/app/game-frontend/e2e/nationOffices.spec.ts index 8d098d47..6b4d5e3d 100644 --- a/app/game-frontend/e2e/nationOffices.spec.ts +++ b/app/game-frontend/e2e/nationOffices.spec.ts @@ -14,6 +14,7 @@ type FixtureState = { appointedGeneralId?: number; appointedCityId?: number; appointedOfficerLevel?: number; + permissionMutationInput?: { isAmbassador: boolean; targetGeneralIds: number[] }; noticeMutationInput?: string; scoutMutationInput?: string; uploadDataUrl?: string; @@ -101,6 +102,8 @@ const personnelFixture = (state: FixtureState) => { general(5, '정욱', 2), general(6, '장료', 1), general(7, '허저', 1, { permission: 'ambassador' }), + general(8, '가후', 1, { permission: 'auditor' }), + general(9, '전위', 1), ]; const visibleGenerals = state.role === 'member' ? fullGenerals.filter((entry) => entry.officerLevel >= 2) : fullGenerals; @@ -145,8 +148,13 @@ const personnelFixture = (state: FixtureState) => { ambassadors: [ { id: 6, name: '장료', npcState: 0, permission: 'normal', maxPermission: 4 }, { id: 7, name: '허저', npcState: 0, permission: 'ambassador', maxPermission: 4 }, + { id: 9, name: '전위', npcState: 0, permission: 'normal', maxPermission: 4 }, + ], + auditors: [ + { id: 6, name: '장료', npcState: 0, permission: 'normal', maxPermission: 4 }, + { id: 8, name: '가후', npcState: 0, permission: 'auditor', maxPermission: 4 }, + { id: 9, name: '전위', npcState: 0, permission: 'normal', maxPermission: 4 }, ], - auditors: [{ id: 6, name: '장료', npcState: 0, permission: 'normal', maxPermission: 4 }], } : { ambassadors: [], auditors: [] }, }; @@ -238,7 +246,16 @@ const installFixture = async (page: Page, state: FixtureState) => { state.appointedOfficerLevel = Number(jsonInput.officerLevel ?? 0); return response({ ok: true }); } - if (operation === 'nation.kick' || operation === 'nation.changePermission') return response({ ok: true }); + if (operation === 'nation.changePermission') { + state.permissionMutationInput = { + isAmbassador: jsonInput.isAmbassador === true, + targetGeneralIds: Array.isArray(jsonInput.targetGeneralIds) + ? jsonInput.targetGeneralIds.map((id) => Number(id)) + : [], + }; + return response({ ok: true }); + } + if (operation === 'nation.kick') return response({ ok: true }); if (operation === 'nation.setRate') { if (state.failNextRate) { state.failNextRate = false; @@ -347,6 +364,62 @@ test('personnel keeps the desktop frame while exposing row-level appointment con await screenshot(page, 'core-personnel-desktop-leader.png'); }); +test('leader can grant two ambassador and auditor permissions by click or touch without modifier keys', async ({ + page, +}) => { + const state: FixtureState = { role: 'leader', rate: 20 }; + await installFixture(page, state); + await page.setViewportSize({ width: 390, height: 844 }); + await gotoOffice(page, 'nation/personnel'); + + const ambassadorTrigger = page.locator('.permission-multiselect-trigger').first(); + await expect(ambassadorTrigger).toHaveAccessibleName('외교권자 선택, 현재 1명'); + await ambassadorTrigger.click(); + const ambassadorOptions = page.getByRole('listbox', { name: '외교권자 후보' }); + await expect(ambassadorOptions).toBeVisible(); + await expect(ambassadorOptions.getByRole('option', { name: '허저' })).toHaveAttribute('aria-selected', 'true'); + await ambassadorOptions.getByRole('option', { name: '장료' }).click(); + await expect(ambassadorOptions.getByRole('option', { name: '장료' })).toHaveAttribute('aria-selected', 'true'); + await expect(ambassadorTrigger).toHaveAccessibleName('외교권자 선택, 현재 2명'); + + await ambassadorOptions.getByRole('option', { name: '전위' }).click(); + await expect(page.getByTestId('game-toast')).toContainText('최대 2명까지 설정 가능합니다.'); + await expect(ambassadorOptions.getByRole('option', { name: '전위' })).toHaveAttribute('aria-selected', 'false'); + const ambassadorGeometry = await ambassadorOptions.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { left: rect.left, right: rect.right, width: rect.width }; + }); + expect(ambassadorGeometry.left).toBeGreaterThanOrEqual(0); + expect(ambassadorGeometry.right).toBeLessThanOrEqual(390); + expect(ambassadorGeometry.width).toBeGreaterThanOrEqual(100); + await screenshot(page, 'core-personnel-mobile-permission-picker-open.png'); + + page.once('dialog', async (dialog) => { + expect(dialog.message()).toBe('외교권자를 변경할까요?'); + await dialog.accept(); + }); + await page.getByRole('button', { name: '외교권자 임명 반영' }).click(); + await expect(page.getByTestId('game-toast').filter({ hasText: '권한을 변경했습니다.' })).toBeVisible(); + await expect.poll(() => state.permissionMutationInput).toEqual({ isAmbassador: true, targetGeneralIds: [7, 6] }); + + const auditorTrigger = page.locator('.permission-multiselect-trigger').nth(1); + await expect(auditorTrigger).toHaveAccessibleName('조언자 선택, 현재 1명'); + await auditorTrigger.click(); + const auditorOptions = page.getByRole('listbox', { name: '조언자 후보' }); + await expect(auditorOptions.getByRole('option', { name: '허저' })).toHaveCount(0); + await auditorOptions.getByRole('option', { name: '장료' }).click(); + await expect(auditorTrigger).toHaveAccessibleName('조언자 선택, 현재 2명'); + page.once('dialog', async (dialog) => { + expect(dialog.message()).toBe('조언자를 변경할까요?'); + await dialog.accept(); + }); + await page.getByRole('button', { name: '조언자 임명 반영' }).click(); + await expect(page.getByTestId('game-toast').filter({ hasText: '권한을 변경했습니다.' })).toBeVisible(); + await expect.poll(() => state.permissionMutationInput).toEqual({ isAmbassador: false, targetGeneralIds: [8, 6] }); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(390); + await screenshot(page, 'core-personnel-mobile-permission-picker.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); diff --git a/app/game-frontend/src/components/personnel/PermissionMultiSelect.vue b/app/game-frontend/src/components/personnel/PermissionMultiSelect.vue new file mode 100644 index 00000000..05c8b1a2 --- /dev/null +++ b/app/game-frontend/src/components/personnel/PermissionMultiSelect.vue @@ -0,0 +1,232 @@ + + + + + diff --git a/app/game-frontend/src/views/NationPersonnelView.vue b/app/game-frontend/src/views/NationPersonnelView.vue index 7a3af80d..e95cff24 100644 --- a/app/game-frontend/src/views/NationPersonnelView.vue +++ b/app/game-frontend/src/views/NationPersonnelView.vue @@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'; import { JosaUtil } from '@sammo-ts/common/util/JosaUtil'; +import PermissionMultiSelect from '../components/personnel/PermissionMultiSelect.vue'; import PersonnelSelectionDialog from '../components/personnel/PersonnelSelectionDialog.vue'; import { useGameFeedback } from '../composables/useGameFeedback'; import { resolveGeneralIconBackgroundImage } from '../utils/generalIcon'; @@ -251,9 +252,7 @@ const applySelection = async (id: number): Promise => { else await appointCityOfficer(context.level, context.cityId, id); }; -const enforcePermissionLimit = (selection: number[]) => { - if (selection.length <= 2) return; - selection.splice(0, selection.length - 2); +const reportPermissionLimit = () => { showErrorToast('최대 2명까지 설정 가능합니다.'); }; @@ -390,23 +389,16 @@ onMounted(() => void loadPersonnel()); 외교권자 - + label="외교권자" + :candidates="data.permissionCandidates.ambassadors" + @limit="reportPermissionLimit" + />