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/BestGeneralView.vue b/app/game-frontend/src/views/BestGeneralView.vue
index 434f2d65..56d652cc 100644
--- a/app/game-frontend/src/views/BestGeneralView.vue
+++ b/app/game-frontend/src/views/BestGeneralView.vue
@@ -87,8 +87,20 @@ watch(viewMode, () => {
-
-
+
+
{{ errorMessage }}
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());
{{ error }}
- {{ status }}
불러오는 중...
@@ -263,39 +411,83 @@ onMounted(() => void loadPersonnel());
| 수 뇌 부 임 명 |
-
-
- | {{ formatOfficerLevelText(level, nationLevel) }} |
-
-
-
+
+ +
+
+ 임명 대상
+ {{ selectedChief(level)?.name ?? '공석으로 두기' }}
+
+ {{ selectedChief(level)?.cityName ?? '-' }} ·
+ {{ currentOfficeText(selectedChief(level)!) }}
+
+ 눌러서 장수를 선택하세요
+
+ ›
+
+
+ 통솔
+ {{ selectedChief(level)?.stats.leadership }}
+ 무력 {{ selectedChief(level)?.stats.strength }}
+ 지력
+ {{ selectedChief(level)?.stats.intelligence }}
+ 소속 {{ selectedChief(level)?.belong }}년
+
+
+ {{ formatOfficerLevelText(level, nationLevel) }} 임명
+
+
+
+
+ -
+
+ {{ chiefAssignments[level]?.name ?? '공석' }}
+ {{ chiefAssignments[level]?.cityName ?? '임명된 장수 없음' }}
+
+
+
+
+ |
|
- ※ 빨간색은 현재 임명중인 장수, 노란색은
- 다른 관직에 임명된 장수, 하얀색은 일반 장수를 뜻합니다.
+ ※ 장수 선택 창에서 현재 임명 중인 장수, 다른 관직 재직자와 일반 장수를 구분하고 주요
+ 능력치·소재지·부대 정보를 함께 확인할 수 있습니다.
|
@@ -372,38 +564,97 @@ onMounted(() => void loadPersonnel());
| 도 시 관 직 임 명 |
-
- | {{ officerLabels[level] }} 임명 |
-
-
- |
|
- ※ 빨간색은 현재 임명중인 장수,
- 노란색은 다른 관직에 임명된 장수, 하얀색은 일반 장수를
- 뜻합니다.
+ ※ 도시의 지역·규모·현재 재직자와 장수의 능력치·현재 관직을 확인한 뒤 임명할 수 있습니다.
|
@@ -514,6 +765,20 @@ onMounted(() => void loadPersonnel());
+
+