fix(game): 외교권자와 조언자 복수 임명 UI를 복원한다

Ref처럼 역할별 후보를 분리하고 클릭과 터치만으로 최대 두 명을 선택할 수 있게 한다. API 후보·엔진 payload·모바일 Chromium 회귀를 함께 고정한다.
This commit is contained in:
2026-08-24 08:38:38 +00:00
parent 69e34e3c95
commit 87e565ae72
5 changed files with 368 additions and 37 deletions
@@ -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: {
+75 -2
View File
@@ -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;