장수 목록 기본 정렬 순서를 통일

This commit is contained in:
2026-08-30 12:19:17 +00:00
parent c56610b179
commit dd66da14f4
9 changed files with 110 additions and 44 deletions
+26 -17
View File
@@ -109,14 +109,14 @@ const installFixture = async (page: Page, state: FixtureState): Promise<void> =>
state.generalRequests += 1; state.generalRequests += 1;
return response([ return response([
{ {
id: 1, id: 3,
name: '유비', name: '황건장수',
npcState: 0, npcState: 2,
nationId: 1, nationId: 0,
nationName: '', nationName: '무주',
leadership: 72, leadership: 55,
strength: 67, strength: 65,
intelligence: 76, intelligence: 45,
}, },
{ {
id: 2, id: 2,
@@ -129,14 +129,14 @@ const installFixture = async (page: Page, state: FixtureState): Promise<void> =>
intelligence: 80, intelligence: 80,
}, },
{ {
id: 3, id: 1,
name: '황건장수', name: '유비',
npcState: 2, npcState: 0,
nationId: 0, nationId: 1,
nationName: '무주', nationName: '',
leadership: 55, leadership: 72,
strength: 65, strength: 67,
intelligence: 45, intelligence: 76,
}, },
]); ]);
} }
@@ -336,7 +336,11 @@ test('prioritizes core general fields and keeps context and inheritance progress
const generalTab = page.getByRole('tab', { name: '장수 목록' }); const generalTab = page.getByRole('tab', { name: '장수 목록' });
await generalTab.click(); await generalTab.click();
await expect(page.locator('.context-general-table tbody tr')).toHaveCount(3); const generalRows = page.locator('.context-general-table tbody tr');
await expect(generalRows).toHaveCount(3);
await expect(generalRows.nth(0)).toContainText('유비');
await expect(generalRows.nth(1)).toContainText('조조');
await expect(generalRows.nth(2)).toContainText('황건장수');
expect(state.generalRequests).toBe(1); expect(state.generalRequests).toBe(1);
await page.getByPlaceholder('장수명 또는 국가 검색').fill('촉'); await page.getByPlaceholder('장수명 또는 국가 검색').fill('촉');
await expect(page.locator('.context-general-table tbody tr')).toHaveCount(1); await expect(page.locator('.context-general-table tbody tr')).toHaveCount(1);
@@ -386,6 +390,11 @@ test('keeps the primary creation flow readable without horizontal overflow on mo
expect(mobileStatButtons).toHaveLength(4); expect(mobileStatButtons).toHaveLength(4);
expect(mobileStatButtons.every(({ width, height }) => width >= 160 && height === 40)).toBe(true); expect(mobileStatButtons.every(({ width, height }) => width >= 160 && height === 40)).toBe(true);
await expect(page.locator('.advanced-options')).not.toHaveAttribute('open'); await expect(page.locator('.advanced-options')).not.toHaveAttribute('open');
await page.getByRole('tab', { name: '장수 목록' }).click();
const mobileGeneralRows = page.locator('.context-general-table tbody tr');
await expect(mobileGeneralRows.nth(0)).toContainText('유비');
await expect(mobileGeneralRows.nth(1)).toContainText('조조');
await expect(mobileGeneralRows.nth(2)).toContainText('황건장수');
await page.getByRole('tab', { name: '임관 권유' }).focus(); await page.getByRole('tab', { name: '임관 권유' }).focus();
await expect(page.getByRole('tab', { name: '임관 권유' })).toBeFocused(); await expect(page.getByRole('tab', { name: '임관 권유' })).toBeFocused();
await page.screenshot({ path: testInfo.outputPath('join-layout-mobile.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('join-layout-mobile.png'), fullPage: true });
@@ -0,0 +1,17 @@
export type GeneralOrderIdentity = {
id: number;
name: string;
npcState: number;
};
const koreanNameCollator = new Intl.Collator('ko-KR', { numeric: true, sensitivity: 'base' });
/**
* Ref의 npc 값은 0 유저장, 1 빙의장, 2 N장, 3 M장, 4 의병장,
* 5 부대장 순으로 장수 종류 자체의 표시 우선순위를 표현한다.
*/
export const compareGeneralTypeThenName = <T extends GeneralOrderIdentity>(left: T, right: T): number =>
left.npcState - right.npcState || koreanNameCollator.compare(left.name, right.name) || left.id - right.id;
export const sortGeneralsByTypeThenName = <T extends GeneralOrderIdentity>(generals: readonly T[]): T[] =>
[...generals].sort(compareGeneralTypeThenName);
@@ -7,6 +7,7 @@ import BattleGeneralCard from '../components/battle/BattleGeneralCard.vue';
import { useGameFeedback } from '../composables/useGameFeedback'; import { useGameFeedback } from '../composables/useGameFeedback';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
import { getNpcColor } from '../utils/npcColor'; import { getNpcColor } from '../utils/npcColor';
import { compareGeneralTypeThenName } from '../utils/generalOrder';
import type { BattleSimOptions, GeneralDraft, InheritBuff } from '../utils/battleSimulatorTypes'; import type { BattleSimOptions, GeneralDraft, InheritBuff } from '../utils/battleSimulatorTypes';
import { BattleSimulatorWorkerClient } from '../utils/battleSimulatorWorkerClient'; import { BattleSimulatorWorkerClient } from '../utils/battleSimulatorWorkerClient';
import { formatSeoulDateTime } from '../utils/legacyDateTime'; import { formatSeoulDateTime } from '../utils/legacyDateTime';
@@ -953,12 +954,7 @@ const generalGroups = computed(() => {
.map((nationId) => { .map((nationId) => {
const nation = nationMap.get(nationId) ?? { id: nationId, name: '재야', color: '#000000' }; const nation = nationMap.get(nationId) ?? { id: nationId, name: '재야', color: '#000000' };
const generals = [...(generalList.value?.generalsByNation[nationId] ?? [])]; const generals = [...(generalList.value?.generalsByNation[nationId] ?? [])];
generals.sort((lhs, rhs) => { generals.sort(compareGeneralTypeThenName);
if (lhs.npcState !== rhs.npcState) {
return lhs.npcState - rhs.npcState;
}
return lhs.name.localeCompare(rhs.name);
});
return { nation, generals }; return { nation, generals };
}); });
}); });
+9 -8
View File
@@ -6,6 +6,7 @@ import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue'; import SkeletonLines from '../components/ui/SkeletonLines.vue';
import MapViewer from '../components/main/MapViewer.vue'; import MapViewer from '../components/main/MapViewer.vue';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
import { sortGeneralsByTypeThenName } from '../utils/generalOrder';
import { useSessionStore } from '../stores/session'; import { useSessionStore } from '../stores/session';
import { cityLevelMap, formatOfficerLevelText, regionMap } from '../utils/nationFormat'; import { cityLevelMap, formatOfficerLevelText, regionMap } from '../utils/nationFormat';
import { getNpcColor } from '../utils/npcColor'; import { getNpcColor } from '../utils/npcColor';
@@ -247,14 +248,14 @@ const npcGeneralRows = computed<NpcGeneralRow[]>(() => {
const visibleNpcGeneralRows = computed(() => npcGeneralRows.value.slice(0, npcGeneralListVisibleCount.value)); const visibleNpcGeneralRows = computed(() => npcGeneralRows.value.slice(0, npcGeneralListVisibleCount.value));
const filteredPublicGenerals = computed(() => { const filteredPublicGenerals = computed(() => {
const keyword = publicGeneralFilter.value.trim().toLocaleLowerCase('ko-KR'); const keyword = publicGeneralFilter.value.trim().toLocaleLowerCase('ko-KR');
if (!keyword) { const filtered = keyword
return publicGenerals.value; ? publicGenerals.value.filter(
} (general) =>
return publicGenerals.value.filter( general.name.toLocaleLowerCase('ko-KR').includes(keyword) ||
(general) => general.nationName.toLocaleLowerCase('ko-KR').includes(keyword)
general.name.toLocaleLowerCase('ko-KR').includes(keyword) || )
general.nationName.toLocaleLowerCase('ko-KR').includes(keyword) : publicGenerals.value;
); return sortGeneralsByTypeThenName(filtered);
}); });
const npcValidColor = computed(() => { const npcValidColor = computed(() => {
const remaining = npcValidUntilMs.value - nowMs.value; const remaining = npcValidUntilMs.value - nowMs.value;
@@ -8,6 +8,7 @@ import type { CommandTable } from '../components/command/types';
import LegacySortControls from '../components/ui/LegacySortControls.vue'; import LegacySortControls from '../components/ui/LegacySortControls.vue';
import { useGameFeedback } from '../composables/useGameFeedback'; import { useGameFeedback } from '../composables/useGameFeedback';
import { getNpcColor } from '../utils/npcColor'; import { getNpcColor } from '../utils/npcColor';
import { sortGeneralsByTypeThenName } from '../utils/generalOrder';
import { legacyNationTextColor } from '../utils/legacyNationColor'; import { legacyNationTextColor } from '../utils/legacyNationColor';
import { cityLevelMap, regionMap } from '../utils/nationFormat'; import { cityLevelMap, regionMap } from '../utils/nationFormat';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
@@ -62,9 +63,10 @@ const sortOptions = [
const officerLabels: Record<OfficerLevel, string> = { 4: '태수', 3: '군사', 2: '종사' }; const officerLabels: Record<OfficerLevel, string> = { 4: '태수', 3: '군사', 2: '종사' };
const appointmentDescription = (city: City, general: SecretGeneral, level: OfficerLevel): string => const appointmentDescription = (city: City, general: SecretGeneral, level: OfficerLevel): string =>
`${JosaUtil.put(general.name, '을')} ${city.name} ${JosaUtil.put(officerLabels[level], '으로')} 임명`; `${JosaUtil.put(general.name, '을')} ${city.name} ${JosaUtil.put(officerLabels[level], '으로')} 임명`;
const generalsForCity = (cityId: number) => data.value?.generals.filter((general) => general.cityId === cityId) ?? []; const generalsForCity = (cityId: number) =>
sortGeneralsByTypeThenName(data.value?.generals.filter((general) => general.cityId === cityId) ?? []);
const secretGeneralsForCity = (cityId: number) => const secretGeneralsForCity = (cityId: number) =>
secretData.value?.generals.filter((general) => general.cityId === cityId) ?? []; sortGeneralsByTypeThenName(secretData.value?.generals.filter((general) => general.cityId === cityId) ?? []);
const displayGeneralName = (general: Result['generals'][number]) => const displayGeneralName = (general: Result['generals'][number]) =>
general.npcState > 0 && !/^[ⓜⓝ]/u.test(general.name) ? `${general.name}` : general.name; general.npcState > 0 && !/^[ⓜⓝ]/u.test(general.name) ? `${general.name}` : general.name;
const displaySecretGeneralName = (general: SecretGeneral) => const displaySecretGeneralName = (general: SecretGeneral) =>
@@ -6,6 +6,7 @@ import { formatReservedCommandBrief } from '../components/command/reservedComman
import type { CommandTable } from '../components/command/types'; import type { CommandTable } from '../components/command/types';
import { formatOfficerLevelText } from '../utils/nationFormat'; import { formatOfficerLevelText } from '../utils/nationFormat';
import { getNpcColor } from '../utils/npcColor'; import { getNpcColor } from '../utils/npcColor';
import { compareGeneralTypeThenName } from '../utils/generalOrder';
import { resolveGeneralIconUrl } from '../utils/generalIcon'; import { resolveGeneralIconUrl } from '../utils/generalIcon';
import { import {
DISPLAY_SETTINGS_KEY, DISPLAY_SETTINGS_KEY,
@@ -587,7 +588,7 @@ const generals = computed(() => {
const compared = compareGridValues(sortValue(left, sort.colId), sortValue(right, sort.colId)); const compared = compareGridValues(sortValue(left, sort.colId), sortValue(right, sort.colId));
if (compared) return sort.sort === 'asc' ? compared : -compared; if (compared) return sort.sort === 'asc' ? compared : -compared;
} }
return left.id - right.id; return compareGeneralTypeThenName(left, right);
}); });
}); });
@@ -3,6 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import GeneralDirectoryTable from '../components/directory/GeneralDirectoryTable.vue'; import GeneralDirectoryTable from '../components/directory/GeneralDirectoryTable.vue';
import type { GeneralDirectoryGeneral } from '../types/directory'; import type { GeneralDirectoryGeneral } from '../types/directory';
import { sortGeneralsByTypeThenName } from '../utils/generalOrder';
import { formatNationLevelText, formatOfficerLevelText } from '../utils/nationFormat'; import { formatNationLevelText, formatOfficerLevelText } from '../utils/nationFormat';
import { getNpcColor } from '../utils/npcColor'; import { getNpcColor } from '../utils/npcColor';
import { legacyNationTextColor } from '../utils/legacyNationColor'; import { legacyNationTextColor } from '../utils/legacyNationColor';
@@ -46,6 +47,7 @@ const officerName = (nation: Nation, officerLevel: number) =>
nation.officers.find((officer) => officer.officerLevel === officerLevel)?.general; nation.officers.find((officer) => officer.officerLevel === officerLevel)?.general;
const displayGeneralName = (general: { name: string; npcState: number }) => const displayGeneralName = (general: { name: string; npcState: number }) =>
general.npcState > 0 && !/^[ⓜⓝ㉥]/u.test(general.name) ? `${general.name}` : general.name; general.npcState > 0 && !/^[ⓜⓝ㉥]/u.test(general.name) ? `${general.name}` : general.name;
const orderedNationGenerals = (nation: Nation) => sortGeneralsByTypeThenName(nation.generals);
const displayAnalyzedGeneralColor = (general: { npcState: number; accessGrade: 'normal' | 'medium' | 'high' }) => { const displayAnalyzedGeneralColor = (general: { npcState: number; accessGrade: 'normal' | 'medium' | 'high' }) => {
if (general.accessGrade === 'high') return 'yellow'; if (general.accessGrade === 'high') return 'yellow';
if (general.accessGrade === 'medium') return 'lightgreen'; if (general.accessGrade === 'medium') return 'lightgreen';
@@ -344,7 +346,7 @@ onBeforeUnmount(() => {
<tr> <tr>
<td colspan="5"> <td colspan="5">
장수 일람 : 장수 일람 :
<template v-for="general in nation.generals" :key="general.id"> <template v-for="general in orderedNationGenerals(nation)" :key="general.id">
<button <button
type="button" type="button"
class="general-preview-trigger" class="general-preview-trigger"
+7 -9
View File
@@ -6,6 +6,7 @@ import SkeletonLines from '../components/ui/SkeletonLines.vue';
import MapViewer from '../components/main/MapViewer.vue'; import MapViewer from '../components/main/MapViewer.vue';
import RecentLogList from '../components/main/RecentLogList.vue'; import RecentLogList from '../components/main/RecentLogList.vue';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
import { sortGeneralsByTypeThenName } from '../utils/generalOrder';
import { useSessionStore } from '../stores/session'; import { useSessionStore } from '../stores/session';
type MapData = Awaited<ReturnType<typeof trpc.public.getCachedMap.query>>; type MapData = Awaited<ReturnType<typeof trpc.public.getCachedMap.query>>;
@@ -30,15 +31,12 @@ const generalFilter = ref('');
const filteredGenerals = computed(() => { const filteredGenerals = computed(() => {
const keyword = generalFilter.value.trim().toLowerCase(); const keyword = generalFilter.value.trim().toLowerCase();
if (!keyword) { const filtered = keyword
return generalList.value; ? generalList.value.filter((general) => {
} return general.name.toLowerCase().includes(keyword) || general.nationName.toLowerCase().includes(keyword);
return generalList.value.filter((general) => { })
return ( : generalList.value;
general.name.toLowerCase().includes(keyword) || return sortGeneralsByTypeThenName(filtered);
general.nationName.toLowerCase().includes(keyword)
);
});
}); });
const resolveErrorMessage = (value: unknown): string => { const resolveErrorMessage = (value: unknown): string => {
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { compareGeneralTypeThenName, sortGeneralsByTypeThenName } from '../src/utils/generalOrder.ts';
void describe('general display order', () => {
void it('orders general types before Korean names and ids', () => {
const source = [
{ id: 8, name: '㉥부대장', npcState: 5 },
{ id: 7, name: 'ⓖ의병장', npcState: 4 },
{ id: 6, name: 'ⓜ마초', npcState: 3 },
{ id: 5, name: 'ⓝ조조', npcState: 2 },
{ id: 4, name: 'ⓝ빙의장', npcState: 1 },
{ id: 3, name: '하후돈', npcState: 0 },
{ id: 2, name: '가후', npcState: 0 },
{ id: 1, name: '가후', npcState: 0 },
];
assert.deepEqual(
sortGeneralsByTypeThenName(source).map((general) => general.id),
[1, 2, 3, 4, 5, 6, 7, 8]
);
assert.deepEqual(
source.map((general) => general.id),
[8, 7, 6, 5, 4, 3, 2, 1]
);
});
void it('keeps later special NPC types in their persisted numeric order', () => {
assert.deepEqual(
[
{ id: 9, name: 'ⓞ오랑캐', npcState: 9 },
{ id: 6, name: 'ⓤ중립장', npcState: 6 },
]
.sort(compareGeneralTypeThenName)
.map((general) => general.npcState),
[6, 9]
);
});
});