fix(game): 외교권자와 조언자 복수 임명 UI를 복원한다
Ref처럼 역할별 후보를 분리하고 클릭과 터치만으로 최대 두 명을 선택할 수 있게 한다. API 후보·엔진 payload·모바일 Chromium 회귀를 함께 고정한다.
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
type PermissionCandidate = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: number[];
|
||||
candidates: PermissionCandidate[];
|
||||
label: string;
|
||||
max?: number;
|
||||
}>(),
|
||||
{ max: 2 }
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number[]];
|
||||
limit: [];
|
||||
}>();
|
||||
|
||||
const root = ref<HTMLElement | null>(null);
|
||||
const open = ref(false);
|
||||
const selectedCandidates = computed(() => {
|
||||
const selected = new Set(props.modelValue);
|
||||
return props.candidates.filter((candidate) => selected.has(candidate.id));
|
||||
});
|
||||
|
||||
const toggleOpen = (): void => {
|
||||
open.value = !open.value;
|
||||
};
|
||||
|
||||
const toggleCandidate = (id: number): void => {
|
||||
if (props.modelValue.includes(id)) {
|
||||
emit(
|
||||
'update:modelValue',
|
||||
props.modelValue.filter((selectedId) => selectedId !== id)
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (props.modelValue.length >= props.max) {
|
||||
emit('limit');
|
||||
return;
|
||||
}
|
||||
emit('update:modelValue', [...props.modelValue, id]);
|
||||
};
|
||||
|
||||
const handleDocumentPointerDown = (event: PointerEvent): void => {
|
||||
if (!root.value?.contains(event.target as Node)) open.value = false;
|
||||
};
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
open.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => document.addEventListener('pointerdown', handleDocumentPointerDown));
|
||||
onBeforeUnmount(() => document.removeEventListener('pointerdown', handleDocumentPointerDown));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="permission-multiselect" @keydown="handleKeydown">
|
||||
<button
|
||||
type="button"
|
||||
class="permission-multiselect-trigger"
|
||||
:aria-label="`${label} 선택, 현재 ${modelValue.length}명`"
|
||||
aria-haspopup="listbox"
|
||||
:aria-expanded="open"
|
||||
@click="toggleOpen"
|
||||
>
|
||||
<span v-if="selectedCandidates.length" class="permission-multiselect-values">
|
||||
<span v-for="candidate in selectedCandidates" :key="candidate.id">{{ candidate.name }}</span>
|
||||
</span>
|
||||
<span v-else class="permission-multiselect-placeholder">선택 안 함</span>
|
||||
<span class="permission-multiselect-arrow" aria-hidden="true">▾</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="open"
|
||||
class="permission-multiselect-options"
|
||||
role="listbox"
|
||||
aria-multiselectable="true"
|
||||
:aria-label="`${label} 후보`"
|
||||
>
|
||||
<button
|
||||
v-for="candidate in candidates"
|
||||
:key="candidate.id"
|
||||
type="button"
|
||||
class="permission-multiselect-option"
|
||||
role="option"
|
||||
:aria-selected="modelValue.includes(candidate.id)"
|
||||
@click="toggleCandidate(candidate.id)"
|
||||
>
|
||||
<span class="permission-multiselect-check" aria-hidden="true">
|
||||
{{ modelValue.includes(candidate.id) ? '✓' : '' }}
|
||||
</span>
|
||||
<span>{{ candidate.name }}</span>
|
||||
</button>
|
||||
<p v-if="candidates.length === 0" class="permission-multiselect-empty">임명 가능한 장수가 없습니다.</p>
|
||||
<p class="permission-multiselect-help">클릭해서 선택·해제 · 최대 {{ max }}명</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.permission-multiselect {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 300px;
|
||||
max-width: calc(100% - 58px);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.permission-multiselect-trigger {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
border: 1px solid #858585;
|
||||
border-radius: 4px;
|
||||
padding: 3px 7px;
|
||||
color: #fff;
|
||||
background: #000;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.permission-multiselect-trigger:hover,
|
||||
.permission-multiselect-trigger[aria-expanded='true'] {
|
||||
border-color: #b9b9b9;
|
||||
}
|
||||
.permission-multiselect-trigger:focus-visible,
|
||||
.permission-multiselect-option:focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.permission-multiselect-values {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
.permission-multiselect-values > span {
|
||||
overflow: hidden;
|
||||
max-width: 126px;
|
||||
border-radius: 3px;
|
||||
padding: 2px 5px;
|
||||
color: #fff;
|
||||
background: #4d4d4d;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.permission-multiselect-placeholder {
|
||||
color: #aaa;
|
||||
}
|
||||
.permission-multiselect-arrow {
|
||||
margin-left: 5px;
|
||||
color: #ccc;
|
||||
}
|
||||
.permission-multiselect-options {
|
||||
position: absolute;
|
||||
z-index: 40;
|
||||
top: calc(100% + 2px);
|
||||
left: 0;
|
||||
width: 100%;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #858585;
|
||||
border-radius: 3px;
|
||||
color: #fff;
|
||||
background: #101010;
|
||||
box-shadow: 0 5px 14px rgb(0 0 0 / 70%);
|
||||
}
|
||||
.permission-multiselect-option {
|
||||
display: grid;
|
||||
grid-template-columns: 20px minmax(0, 1fr);
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #353535;
|
||||
border-radius: 0;
|
||||
padding: 7px 8px;
|
||||
color: #fff;
|
||||
background: #101010;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.permission-multiselect-option:hover,
|
||||
.permission-multiselect-option[aria-selected='true'] {
|
||||
background: #424242;
|
||||
}
|
||||
.permission-multiselect-check {
|
||||
display: grid;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
place-items: center;
|
||||
border: 1px solid #aaa;
|
||||
border-radius: 2px;
|
||||
color: #111;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
.permission-multiselect-empty,
|
||||
.permission-multiselect-help {
|
||||
margin: 0;
|
||||
padding: 6px 8px;
|
||||
color: #bbb;
|
||||
font-size: 11px;
|
||||
}
|
||||
.permission-multiselect-help {
|
||||
border-top: 1px solid #454545;
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.permission-multiselect {
|
||||
width: calc(100% - 54px);
|
||||
max-width: none;
|
||||
}
|
||||
.permission-multiselect-values > span {
|
||||
max-width: 82px;
|
||||
}
|
||||
.permission-multiselect-option {
|
||||
min-height: 38px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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';
|
||||
@@ -249,9 +250,7 @@ const applySelection = async (id: number): Promise<void> => {
|
||||
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명까지 설정 가능합니다.');
|
||||
};
|
||||
|
||||
@@ -388,23 +387,16 @@ onMounted(() => void loadPersonnel());
|
||||
<tr>
|
||||
<td class="green-cell permission-label">외교권자</td>
|
||||
<td>
|
||||
<select
|
||||
<PermissionMultiSelect
|
||||
v-model="ambassadorSelection"
|
||||
multiple
|
||||
aria-label="외교권자"
|
||||
@change="enforcePermissionLimit(ambassadorSelection)"
|
||||
>
|
||||
<option
|
||||
v-for="candidate in data.permissionCandidates.ambassadors"
|
||||
:key="candidate.id"
|
||||
:value="candidate.id"
|
||||
>
|
||||
{{ candidate.name }}
|
||||
</option>
|
||||
</select>
|
||||
label="외교권자"
|
||||
:candidates="data.permissionCandidates.ambassadors"
|
||||
@limit="reportPermissionLimit"
|
||||
/>
|
||||
<button
|
||||
class="legacy-button legacy-button--primary"
|
||||
type="button"
|
||||
aria-label="외교권자 임명 반영"
|
||||
@click="changePermissions(true)"
|
||||
>
|
||||
임명
|
||||
@@ -412,23 +404,16 @@ onMounted(() => void loadPersonnel());
|
||||
</td>
|
||||
<td class="green-cell permission-label">조언자</td>
|
||||
<td>
|
||||
<select
|
||||
<PermissionMultiSelect
|
||||
v-model="auditorSelection"
|
||||
multiple
|
||||
aria-label="조언자"
|
||||
@change="enforcePermissionLimit(auditorSelection)"
|
||||
>
|
||||
<option
|
||||
v-for="candidate in data.permissionCandidates.auditors"
|
||||
:key="candidate.id"
|
||||
:value="candidate.id"
|
||||
>
|
||||
{{ candidate.name }}
|
||||
</option>
|
||||
</select>
|
||||
label="조언자"
|
||||
:candidates="data.permissionCandidates.auditors"
|
||||
@limit="reportPermissionLimit"
|
||||
/>
|
||||
<button
|
||||
class="legacy-button legacy-button--primary"
|
||||
type="button"
|
||||
aria-label="조언자 임명 반영"
|
||||
@click="changePermissions(false)"
|
||||
>
|
||||
임명
|
||||
@@ -664,10 +649,6 @@ select {
|
||||
background: #000;
|
||||
font: inherit;
|
||||
}
|
||||
select[multiple] {
|
||||
width: 300px;
|
||||
height: 34px;
|
||||
}
|
||||
.nation-heading {
|
||||
height: 32px;
|
||||
text-align: center;
|
||||
|
||||
Reference in New Issue
Block a user