feat(game-ui): 장수 일람 안정 정렬과 설명을 추가한다
This commit is contained in:
@@ -431,13 +431,20 @@ test('nation and general directories preserve the fixed legacy Chromium geometry
|
||||
}
|
||||
});
|
||||
|
||||
test('general directory submits the legacy sort selector and keeps wounded/bonus rendering', async ({ page }) => {
|
||||
await install(page);
|
||||
test('general directory sorts the loaded rows locally in a stable three-state cycle', async ({ page }) => {
|
||||
const requestedOperations: string[] = [];
|
||||
await install(page, 'general', [], requestedOperations);
|
||||
await page.goto('general-list');
|
||||
await expect(page.locator('tbody tr[data-general-id]')).toHaveCount(2);
|
||||
await page.selectOption('#viewType', '8');
|
||||
await page.getByRole('button', { name: '정렬하기' }).click();
|
||||
await expect(page.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '10');
|
||||
await page.getByRole('button', { name: '정렬하기' }).click();
|
||||
await expect(page.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '20');
|
||||
await page.getByRole('button', { name: '정렬하기' }).click();
|
||||
await expect(page.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '10');
|
||||
expect(requestedOperations.filter((operation) => operation === 'world.getGeneralDirectory')).toHaveLength(1);
|
||||
|
||||
await expect(page.locator('tbody tr[data-general-id="10"] .wounded').first()).toHaveText('81');
|
||||
await expect(page.locator('tbody tr[data-general-id="10"] .leadership-bonus')).toHaveText('+6');
|
||||
|
||||
@@ -489,10 +496,10 @@ test('directory sort controls stay legible in dark mode and sortable headers app
|
||||
expect(await submit.evaluate((element) => getComputedStyle(element).borderBottomWidth)).toBe('1px');
|
||||
await page.mouse.up();
|
||||
|
||||
await page.getByRole('button', { name: '삭턴 기준 정렬' }).click();
|
||||
await page.getByRole('button', { name: /^삭턴 내림차순/u }).click();
|
||||
await expect(select).toHaveValue('8');
|
||||
await expect(page.locator('th[aria-sort="ascending"]')).toContainText('삭턴');
|
||||
await expect(page.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '20');
|
||||
await expect(page.locator('th[aria-sort="descending"]')).toContainText('삭턴');
|
||||
await expect(page.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '10');
|
||||
await page.screenshot({ path: testInfo.outputPath('directory-sort-controls-desktop.png'), fullPage: true });
|
||||
|
||||
await page.setViewportSize({ width: 500, height: 844 });
|
||||
@@ -501,6 +508,82 @@ test('directory sort controls stay legible in dark mode and sortable headers app
|
||||
await page.screenshot({ path: testInfo.outputPath('directory-sort-controls-mobile.png'), fullPage: true });
|
||||
});
|
||||
|
||||
test('name and mixed-direction stable sorting reuse one response until explicit refresh', async ({ page }) => {
|
||||
const requestedOperations: string[] = [];
|
||||
await install(page, 'general', [], requestedOperations);
|
||||
await page.goto('general-list');
|
||||
|
||||
const rows = page.locator('tbody tr[data-general-id]');
|
||||
await page.getByRole('button', { name: /^이름 내림차순/u }).click();
|
||||
await expect(rows.first()).toHaveAttribute('data-general-id', '10');
|
||||
await page.getByRole('button', { name: /^이름 오름차순/u }).click();
|
||||
await expect(rows.first()).toHaveAttribute('data-general-id', '20');
|
||||
await page.getByRole('button', { name: /^이름 정렬 해제/u }).click();
|
||||
await expect(rows.first()).toHaveAttribute('data-general-id', '10');
|
||||
|
||||
await page.getByRole('button', { name: /^통솔 내림차순/u }).click();
|
||||
await page.getByRole('button', { name: /^무력 내림차순/u }).click();
|
||||
await expect(rows.first()).toHaveAttribute('data-general-id', '20');
|
||||
await expect(
|
||||
page.getByRole('columnheader').filter({ hasText: '통솔' }).locator('.legacy-sort-indicator')
|
||||
).toHaveText('▼2');
|
||||
await page.getByRole('button', { name: /^무력 오름차순/u }).click();
|
||||
await expect(rows.first()).toHaveAttribute('data-general-id', '10');
|
||||
expect(requestedOperations.filter((operation) => operation === 'world.getGeneralDirectory')).toHaveLength(1);
|
||||
|
||||
await page.getByRole('button', { name: '갱 신' }).click();
|
||||
await expect
|
||||
.poll(() => requestedOperations.filter((operation) => operation === 'world.getGeneralDirectory').length)
|
||||
.toBe(2);
|
||||
await expect(rows.first()).toHaveAttribute('data-general-id', '10');
|
||||
});
|
||||
|
||||
test('trait and injury explanations appear on pointer hover and keyboard focus', async ({ page }, testInfo) => {
|
||||
await install(page);
|
||||
await page.goto('general-list');
|
||||
|
||||
const personality = page.locator('[data-directory-tooltip="personality-10"]');
|
||||
await personality.hover();
|
||||
await expect(personality.getByRole('tooltip')).toContainText('성격 · 대담');
|
||||
await expect(personality.getByRole('tooltip')).toContainText('대담한 성격');
|
||||
|
||||
const domestic = page.locator('[data-directory-tooltip="special-domestic-10"]');
|
||||
await domestic.hover();
|
||||
await expect(domestic.getByRole('tooltip')).toContainText('내정 특기 · 상재');
|
||||
await expect(domestic.getByRole('tooltip')).toContainText('상업 특기');
|
||||
|
||||
const war = page.locator('[data-directory-tooltip="special-war-10"]');
|
||||
await war.focus();
|
||||
await expect(war.getByRole('tooltip')).toContainText('전투 특기 · 귀모');
|
||||
await expect(war.getByRole('tooltip')).toContainText('전투 특기');
|
||||
|
||||
const injury = page.locator('[data-directory-tooltip="injury-leadership-10"]');
|
||||
await injury.hover();
|
||||
await expect(injury.getByRole('tooltip')).toContainText('부상 10%');
|
||||
await expect(injury.getByRole('tooltip')).toContainText('원래 통솔 90 → 적용 81');
|
||||
const tooltipGeometry = await injury.getByRole('tooltip').evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
left: rect.left,
|
||||
right: window.innerWidth - rect.right,
|
||||
display: style.display,
|
||||
background: style.backgroundColor,
|
||||
color: style.color,
|
||||
fontSize: style.fontSize,
|
||||
};
|
||||
});
|
||||
expect(tooltipGeometry.left).toBeGreaterThanOrEqual(8);
|
||||
expect(tooltipGeometry.right).toBeGreaterThanOrEqual(8);
|
||||
expect(tooltipGeometry).toMatchObject({
|
||||
display: 'block',
|
||||
background: 'rgb(16, 16, 16)',
|
||||
color: 'rgb(245, 245, 245)',
|
||||
fontSize: '12.5px',
|
||||
});
|
||||
await page.screenshot({ path: testInfo.outputPath('directory-trait-injury-tooltips.png'), fullPage: true });
|
||||
});
|
||||
|
||||
test('npc directory reuses the dark sort controls and sorts from a table header', async ({ page }, testInfo) => {
|
||||
await install(page);
|
||||
await page.goto('npc-list');
|
||||
@@ -544,7 +627,8 @@ test('nation directory reuses only the public general-directory row on hover and
|
||||
await expect(preview.locator('[data-general-card-id]')).toHaveCount(1);
|
||||
await expect(preview.locator('[data-general-card-id="10"]')).toContainText('조조');
|
||||
await expect(preview.locator('[data-general-card-id="10"]')).toContainText('대담');
|
||||
await expect(preview.locator('[data-general-card-id="10"]')).toContainText('상재 / 귀모');
|
||||
await expect(preview.locator('[data-directory-tooltip="card-special-domestic-10"]')).toContainText('상재');
|
||||
await expect(preview.locator('[data-directory-tooltip="card-special-war-10"]')).toContainText('귀모');
|
||||
await expect(preview).not.toContainText('user-');
|
||||
await expect(preview).not.toContainText('secret');
|
||||
|
||||
@@ -687,8 +771,27 @@ test('nation and general directories rearrange for mobile and keep the tapped pr
|
||||
await expect(page.locator('.general-table')).toBeHidden();
|
||||
await expect(page.locator('.general-card-list')).toBeVisible();
|
||||
await expect(page.locator('.general-card-list [data-general-card-id]')).toHaveCount(2);
|
||||
await expect(page.locator('[data-general-card-id="10"]')).toContainText('상재 / 귀모');
|
||||
await expect(page.locator('[data-general-card-id="10"]')).toContainText('통솔81+6');
|
||||
await expect(page.locator('[data-directory-tooltip="card-special-domestic-10"]')).toContainText('상재');
|
||||
await expect(page.locator('[data-directory-tooltip="card-special-war-10"]')).toContainText('귀모');
|
||||
await expect(page.locator('[data-directory-tooltip="card-injury-leadership-10"] > .wounded')).toHaveText(
|
||||
'81'
|
||||
);
|
||||
await expect(page.locator('[data-general-card-id="10"] .leadership-bonus')).toHaveText('+6');
|
||||
const mobileInjury = page.locator('[data-directory-tooltip="card-injury-leadership-10"]');
|
||||
await mobileInjury.focus();
|
||||
await expect(mobileInjury.getByRole('tooltip')).toBeVisible();
|
||||
const mobileTooltipGeometry = await mobileInjury.getByRole('tooltip').evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left,
|
||||
right: window.innerWidth - rect.right,
|
||||
width: rect.width,
|
||||
innerWidth: window.innerWidth,
|
||||
};
|
||||
});
|
||||
expect(mobileTooltipGeometry.left).toBeGreaterThanOrEqual(8);
|
||||
expect(mobileTooltipGeometry.right).toBeGreaterThanOrEqual(8);
|
||||
expect(mobileTooltipGeometry.width).toBeLessThanOrEqual(mobileTooltipGeometry.innerWidth - 16);
|
||||
|
||||
const generalMetrics = await page.locator('.directory-page').evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
@@ -760,11 +863,12 @@ test('a reused image element falls back for each newly broken account icon', asy
|
||||
await expect.poll(() => icon.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBe(64);
|
||||
});
|
||||
|
||||
test('a failed resort retains the selected value and existing rows', async ({ page }) => {
|
||||
test('a failed explicit refresh retains the local sort selection and existing rows', async ({ page }) => {
|
||||
await install(page, 'error-after-load');
|
||||
await page.goto('general-list');
|
||||
await page.selectOption('#viewType', '8');
|
||||
await page.getByRole('button', { name: '정렬하기' }).click();
|
||||
await page.getByRole('button', { name: '갱 신' }).click();
|
||||
await expect(page.getByRole('alert')).toContainText('권한 확인 실패');
|
||||
await expect(page.locator('#viewType')).toHaveValue('8');
|
||||
await expect(page.locator('tbody tr[data-general-id]')).toHaveCount(2);
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string;
|
||||
description?: string | null;
|
||||
testId?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="directory-tooltip"
|
||||
:class="{ 'directory-tooltip--enabled': description }"
|
||||
:tabindex="description ? 0 : undefined"
|
||||
:data-directory-tooltip="testId"
|
||||
>
|
||||
<slot />
|
||||
<span v-if="description" class="directory-tooltip__content" role="tooltip">
|
||||
<strong>{{ title }}</strong>
|
||||
<span>{{ description }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.directory-tooltip {
|
||||
position: relative;
|
||||
display: inline;
|
||||
min-width: 0;
|
||||
}
|
||||
.directory-tooltip--enabled {
|
||||
cursor: help;
|
||||
text-decoration: underline dotted rgb(150 210 255 / 85%);
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.directory-tooltip--enabled:focus-visible {
|
||||
border-radius: 2px;
|
||||
outline: 1px solid #6fc7ff;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.directory-tooltip__content {
|
||||
display: none;
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 5px);
|
||||
box-sizing: border-box;
|
||||
width: max-content;
|
||||
max-width: min(280px, calc(100vw - 16px));
|
||||
transform: translateX(-50%);
|
||||
border: 1px solid #8c8c8c;
|
||||
border-radius: 3px;
|
||||
padding: 7px 9px;
|
||||
background: #101010;
|
||||
box-shadow: 0 3px 12px rgb(0 0 0 / 65%);
|
||||
color: #f5f5f5;
|
||||
font-family: var(--sammo-font-sans);
|
||||
font-size: 12.5px;
|
||||
font-weight: 400;
|
||||
line-height: 1.45;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
word-break: keep-all;
|
||||
}
|
||||
.directory-tooltip__content strong,
|
||||
.directory-tooltip__content span {
|
||||
display: block;
|
||||
}
|
||||
.directory-tooltip__content strong {
|
||||
margin-bottom: 4px;
|
||||
color: #7fd4ff;
|
||||
font-size: 13px;
|
||||
}
|
||||
.directory-tooltip--enabled:hover > .directory-tooltip__content,
|
||||
.directory-tooltip--enabled:focus > .directory-tooltip__content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.directory-tooltip__content {
|
||||
position: fixed;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import DirectoryTooltip from './DirectoryTooltip.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string;
|
||||
value: number;
|
||||
injury: number;
|
||||
bonus?: number;
|
||||
testId?: string;
|
||||
}>(),
|
||||
{ bonus: 0, testId: undefined }
|
||||
);
|
||||
|
||||
const displayedValue = computed(() =>
|
||||
props.injury > 0 ? Math.trunc((props.value * (100 - props.injury)) / 100) : props.value
|
||||
);
|
||||
const injuryDescription = computed(() =>
|
||||
props.injury > 0
|
||||
? `부상 ${props.injury}% · 원래 ${props.label} ${props.value} → 적용 ${displayedValue.value}`
|
||||
: null
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DirectoryTooltip :title="`${label} 부상`" :description="injuryDescription" :test-id="testId">
|
||||
<span :class="{ wounded: injury > 0 }">{{ displayedValue }}</span>
|
||||
</DirectoryTooltip>
|
||||
<span v-if="bonus > 0" class="leadership-bonus">+{{ bonus }}</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wounded {
|
||||
color: red;
|
||||
}
|
||||
.leadership-bonus {
|
||||
color: cyan;
|
||||
}
|
||||
</style>
|
||||
@@ -3,13 +3,14 @@ import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../../utils/genera
|
||||
import { formatOfficerLevelText } from '../../utils/nationFormat';
|
||||
import { getNpcColor } from '../../utils/npcColor';
|
||||
import type { GeneralDirectoryGeneral } from '../../types/directory';
|
||||
import type { GeneralDirectorySortCriterion, GeneralDirectorySortKey } from '../../utils/generalDirectorySort';
|
||||
import DirectoryTooltip from './DirectoryTooltip.vue';
|
||||
import GeneralDirectoryStat from './GeneralDirectoryStat.vue';
|
||||
|
||||
type SortDirection = 'ascending' | 'descending';
|
||||
type Header = {
|
||||
label: string;
|
||||
sort?: number;
|
||||
direction?: SortDirection;
|
||||
title?: string;
|
||||
sort?: GeneralDirectorySortKey;
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -17,12 +18,12 @@ const props = withDefaults(
|
||||
generals: GeneralDirectoryGeneral[];
|
||||
loading?: boolean;
|
||||
layout?: 'responsive' | 'card';
|
||||
activeSort?: number;
|
||||
sortCriteria?: readonly GeneralDirectorySortCriterion[];
|
||||
}>(),
|
||||
{
|
||||
loading: false,
|
||||
layout: 'responsive',
|
||||
activeSort: undefined,
|
||||
sortCriteria: () => [],
|
||||
}
|
||||
);
|
||||
|
||||
@@ -30,26 +31,43 @@ const emit = defineEmits<{ sort: [value: number] }>();
|
||||
|
||||
const headers: ReadonlyArray<Header> = [
|
||||
{ label: '얼 굴' },
|
||||
{ label: '이 름' },
|
||||
{ label: '연령', sort: 14, direction: 'descending' },
|
||||
{ label: '성격', sort: 11, direction: 'descending' },
|
||||
{ label: '이 름', sort: 0 },
|
||||
{ label: '연령', sort: 14 },
|
||||
{ label: '성격', sort: 11 },
|
||||
{ label: '특기' },
|
||||
{ label: '레 벨', sort: 10, direction: 'descending' },
|
||||
{ label: '국 가', sort: 1, direction: 'ascending' },
|
||||
{ label: '명 성', sort: 5, direction: 'descending' },
|
||||
{ label: '계 급', sort: 6, direction: 'descending' },
|
||||
{ label: '관 직', sort: 7, direction: 'descending' },
|
||||
{ label: '통솔', sort: 2, direction: 'descending' },
|
||||
{ label: '무력', sort: 3, direction: 'descending' },
|
||||
{ label: '지력', sort: 4, direction: 'descending' },
|
||||
{ label: '삭턴', sort: 8, direction: 'ascending' },
|
||||
{ label: '벌점', sort: 9, direction: 'descending' },
|
||||
{ label: '레 벨', sort: 10 },
|
||||
{ label: '국 가', sort: 1 },
|
||||
{ label: '명 성', sort: 5 },
|
||||
{ label: '계 급', sort: 6 },
|
||||
{ label: '관 직', sort: 7 },
|
||||
{ label: '통솔', sort: 2 },
|
||||
{ label: '무력', sort: 3 },
|
||||
{ label: '지력', sort: 4 },
|
||||
{ label: '삭턴', sort: 8 },
|
||||
{ label: '벌점', sort: 9 },
|
||||
];
|
||||
|
||||
const criterionIndex = (header: Header): number =>
|
||||
header.sort === undefined ? -1 : props.sortCriteria.findIndex(({ key }) => key === header.sort);
|
||||
const criterionFor = (header: Header): GeneralDirectorySortCriterion | undefined => {
|
||||
const index = criterionIndex(header);
|
||||
return index < 0 ? undefined : props.sortCriteria[index];
|
||||
};
|
||||
const ariaSort = (header: Header): SortDirection | undefined =>
|
||||
header.sort === props.activeSort ? header.direction : undefined;
|
||||
|
||||
const injuredStat = (value: number, injury: number): number => Math.trunc((value * (100 - injury)) / 100);
|
||||
criterionIndex(header) === 0 ? criterionFor(header)?.direction : undefined;
|
||||
const sortIndicator = (header: Header): string => {
|
||||
const index = criterionIndex(header);
|
||||
if (index < 0) return '↕';
|
||||
const arrow = criterionFor(header)?.direction === 'ascending' ? '▲' : '▼';
|
||||
return props.sortCriteria.length > 1 ? `${arrow}${index + 1}` : arrow;
|
||||
};
|
||||
const nextSortAction = (header: Header): string => {
|
||||
const direction = criterionFor(header)?.direction;
|
||||
if (!direction) return '내림차순';
|
||||
return direction === 'descending' ? '오름차순' : '정렬 해제';
|
||||
};
|
||||
const sortHelp = (header: Header): string =>
|
||||
`${header.label.replaceAll(' ', '')} ${nextSortAction(header)}. 같은 값은 이전 정렬 순서를 유지합니다.`;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -81,17 +99,14 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value
|
||||
:aria-sort="ariaSort(header)"
|
||||
>
|
||||
<button
|
||||
v-if="header.sort !== undefined && activeSort !== undefined"
|
||||
v-if="header.sort !== undefined"
|
||||
class="legacy-sort-header"
|
||||
type="button"
|
||||
:aria-label="`${header.label.replaceAll(' ', '')} 기준 정렬`"
|
||||
:title="header.title ?? `${header.label.replaceAll(' ', '')} 기준 정렬`"
|
||||
:aria-label="sortHelp(header)"
|
||||
:title="sortHelp(header)"
|
||||
@click="emit('sort', header.sort)"
|
||||
>
|
||||
{{ header.label
|
||||
}}<span class="legacy-sort-indicator">{{
|
||||
header.sort === activeSort ? (header.direction === 'ascending' ? '▲' : '▼') : '↕'
|
||||
}}</span>
|
||||
{{ header.label }}<span class="legacy-sort-indicator">{{ sortIndicator(header) }}</span>
|
||||
</button>
|
||||
<template v-else>{{ header.label }}</template>
|
||||
</th>
|
||||
@@ -132,11 +147,30 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value
|
||||
</td>
|
||||
<td class="center">{{ general.age }}세</td>
|
||||
<td class="center">
|
||||
<span :title="general.personality.info">{{ general.personality.name }}</span>
|
||||
<DirectoryTooltip
|
||||
:title="`성격 · ${general.personality.name}`"
|
||||
:description="general.personality.info"
|
||||
:test-id="`personality-${general.id}`"
|
||||
>
|
||||
{{ general.personality.name }}
|
||||
</DirectoryTooltip>
|
||||
</td>
|
||||
<td class="center">
|
||||
<span :title="general.specialDomestic.info">{{ general.specialDomestic.name }}</span> /
|
||||
<span :title="general.specialWar.info">{{ general.specialWar.name }}</span>
|
||||
<DirectoryTooltip
|
||||
:title="`내정 특기 · ${general.specialDomestic.name}`"
|
||||
:description="general.specialDomestic.info"
|
||||
:test-id="`special-domestic-${general.id}`"
|
||||
>
|
||||
{{ general.specialDomestic.name }}
|
||||
</DirectoryTooltip>
|
||||
/
|
||||
<DirectoryTooltip
|
||||
:title="`전투 특기 · ${general.specialWar.name}`"
|
||||
:description="general.specialWar.info"
|
||||
:test-id="`special-war-${general.id}`"
|
||||
>
|
||||
{{ general.specialWar.name }}
|
||||
</DirectoryTooltip>
|
||||
</td>
|
||||
<td class="center">Lv {{ general.experienceLevel }}</td>
|
||||
<td class="center">{{ general.nationName }}</td>
|
||||
@@ -144,22 +178,29 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value
|
||||
<td class="center">{{ general.dedicationText }}</td>
|
||||
<td class="center">{{ formatOfficerLevelText(general.officerLevel, general.nationLevel) }}</td>
|
||||
<td class="center">
|
||||
<span :class="{ wounded: general.injury > 0 }">{{
|
||||
general.injury > 0 ? injuredStat(general.leadership, general.injury) : general.leadership
|
||||
}}</span
|
||||
><span v-if="general.leadershipBonus > 0" class="leadership-bonus"
|
||||
>+{{ general.leadershipBonus }}</span
|
||||
>
|
||||
<GeneralDirectoryStat
|
||||
label="통솔"
|
||||
:value="general.leadership"
|
||||
:injury="general.injury"
|
||||
:bonus="general.leadershipBonus"
|
||||
:test-id="`injury-leadership-${general.id}`"
|
||||
/>
|
||||
</td>
|
||||
<td class="center">
|
||||
<span :class="{ wounded: general.injury > 0 }">{{
|
||||
general.injury > 0 ? injuredStat(general.strength, general.injury) : general.strength
|
||||
}}</span>
|
||||
<GeneralDirectoryStat
|
||||
label="무력"
|
||||
:value="general.strength"
|
||||
:injury="general.injury"
|
||||
:test-id="`injury-strength-${general.id}`"
|
||||
/>
|
||||
</td>
|
||||
<td class="center">
|
||||
<span :class="{ wounded: general.injury > 0 }">{{
|
||||
general.injury > 0 ? injuredStat(general.intelligence, general.injury) : general.intelligence
|
||||
}}</span>
|
||||
<GeneralDirectoryStat
|
||||
label="지력"
|
||||
:value="general.intelligence"
|
||||
:injury="general.injury"
|
||||
:test-id="`injury-intelligence-${general.id}`"
|
||||
/>
|
||||
</td>
|
||||
<td class="center">{{ general.killturn }}</td>
|
||||
<td class="center">{{ general.refreshScoreTotal }}<br />【{{ general.refreshText }}】</td>
|
||||
@@ -207,13 +248,33 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value
|
||||
</div>
|
||||
<div class="general-card-field">
|
||||
<span class="field-label">성격</span>
|
||||
<span :title="general.personality.info">{{ general.personality.name }}</span>
|
||||
<DirectoryTooltip
|
||||
:title="`성격 · ${general.personality.name}`"
|
||||
:description="general.personality.info"
|
||||
:test-id="`card-personality-${general.id}`"
|
||||
>
|
||||
{{ general.personality.name }}
|
||||
</DirectoryTooltip>
|
||||
</div>
|
||||
<div class="general-card-field">
|
||||
<span class="field-label">특기</span>
|
||||
<span :title="`${general.specialDomestic.info} / ${general.specialWar.info}`"
|
||||
>{{ general.specialDomestic.name }} / {{ general.specialWar.name }}</span
|
||||
<span>
|
||||
<DirectoryTooltip
|
||||
:title="`내정 특기 · ${general.specialDomestic.name}`"
|
||||
:description="general.specialDomestic.info"
|
||||
:test-id="`card-special-domestic-${general.id}`"
|
||||
>
|
||||
{{ general.specialDomestic.name }}
|
||||
</DirectoryTooltip>
|
||||
/
|
||||
<DirectoryTooltip
|
||||
:title="`전투 특기 · ${general.specialWar.name}`"
|
||||
:description="general.specialWar.info"
|
||||
:test-id="`card-special-war-${general.id}`"
|
||||
>
|
||||
{{ general.specialWar.name }}
|
||||
</DirectoryTooltip>
|
||||
</span>
|
||||
</div>
|
||||
<div class="general-card-field">
|
||||
<span class="field-label">레벨</span><span>Lv {{ general.experienceLevel }}</span>
|
||||
@@ -233,26 +294,31 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value
|
||||
</div>
|
||||
<div class="general-card-field">
|
||||
<span class="field-label">통솔</span>
|
||||
<span>
|
||||
<span :class="{ wounded: general.injury > 0 }">{{
|
||||
general.injury > 0 ? injuredStat(general.leadership, general.injury) : general.leadership
|
||||
}}</span
|
||||
><span v-if="general.leadershipBonus > 0" class="leadership-bonus"
|
||||
>+{{ general.leadershipBonus }}</span
|
||||
>
|
||||
</span>
|
||||
<GeneralDirectoryStat
|
||||
label="통솔"
|
||||
:value="general.leadership"
|
||||
:injury="general.injury"
|
||||
:bonus="general.leadershipBonus"
|
||||
:test-id="`card-injury-leadership-${general.id}`"
|
||||
/>
|
||||
</div>
|
||||
<div class="general-card-field">
|
||||
<span class="field-label">무력</span>
|
||||
<span :class="{ wounded: general.injury > 0 }">{{
|
||||
general.injury > 0 ? injuredStat(general.strength, general.injury) : general.strength
|
||||
}}</span>
|
||||
<GeneralDirectoryStat
|
||||
label="무력"
|
||||
:value="general.strength"
|
||||
:injury="general.injury"
|
||||
:test-id="`card-injury-strength-${general.id}`"
|
||||
/>
|
||||
</div>
|
||||
<div class="general-card-field">
|
||||
<span class="field-label">지력</span>
|
||||
<span :class="{ wounded: general.injury > 0 }">{{
|
||||
general.injury > 0 ? injuredStat(general.intelligence, general.injury) : general.intelligence
|
||||
}}</span>
|
||||
<GeneralDirectoryStat
|
||||
label="지력"
|
||||
:value="general.intelligence"
|
||||
:injury="general.injury"
|
||||
:test-id="`card-injury-intelligence-${general.id}`"
|
||||
/>
|
||||
</div>
|
||||
<div class="general-card-field penalty-field">
|
||||
<span class="field-label">벌점</span>
|
||||
@@ -298,12 +364,6 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
.wounded {
|
||||
color: red;
|
||||
}
|
||||
.leadership-bonus {
|
||||
color: cyan;
|
||||
}
|
||||
.loading-cell {
|
||||
height: 64px;
|
||||
text-align: center;
|
||||
|
||||
@@ -113,7 +113,7 @@ watch(
|
||||
background: #101010;
|
||||
box-shadow: 0 3px 12px rgb(0 0 0 / 65%);
|
||||
color: #f5f5f5;
|
||||
font-family: Pretendard, sans-serif;
|
||||
font-family: var(--sammo-font-sans);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
text-align: left;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
export type GeneralDirectorySortKey = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15;
|
||||
export type GeneralDirectorySortDirection = 'ascending' | 'descending';
|
||||
|
||||
export type GeneralDirectorySortCriterion = {
|
||||
key: GeneralDirectorySortKey;
|
||||
direction: GeneralDirectorySortDirection;
|
||||
};
|
||||
|
||||
type TraitValue = { key: string };
|
||||
|
||||
export type GeneralDirectorySortable = {
|
||||
name: string;
|
||||
nationId: number;
|
||||
leadership: number;
|
||||
strength: number;
|
||||
intelligence: number;
|
||||
experience: number;
|
||||
dedication: number;
|
||||
officerLevel: number;
|
||||
killturn: number;
|
||||
refreshScoreTotal: number;
|
||||
personality: TraitValue;
|
||||
specialDomestic: TraitValue;
|
||||
specialWar: TraitValue;
|
||||
age: number;
|
||||
npcState: number;
|
||||
};
|
||||
|
||||
const koreanNameCollator = new Intl.Collator('ko-KR', { numeric: true, sensitivity: 'base' });
|
||||
|
||||
const compareString = (left: string, right: string): number => {
|
||||
if (left === right) return 0;
|
||||
return left < right ? -1 : 1;
|
||||
};
|
||||
|
||||
const compareByKey = <T extends GeneralDirectorySortable>(left: T, right: T, key: GeneralDirectorySortKey): number => {
|
||||
switch (key) {
|
||||
case 0:
|
||||
return koreanNameCollator.compare(left.name, right.name);
|
||||
case 1:
|
||||
return left.nationId - right.nationId;
|
||||
case 2:
|
||||
return left.leadership - right.leadership;
|
||||
case 3:
|
||||
return left.strength - right.strength;
|
||||
case 4:
|
||||
return left.intelligence - right.intelligence;
|
||||
case 5:
|
||||
case 10:
|
||||
return left.experience - right.experience;
|
||||
case 6:
|
||||
return left.dedication - right.dedication;
|
||||
case 7:
|
||||
return left.officerLevel - right.officerLevel;
|
||||
case 8:
|
||||
return left.killturn - right.killturn;
|
||||
case 9:
|
||||
return left.refreshScoreTotal - right.refreshScoreTotal;
|
||||
case 11:
|
||||
return compareString(left.personality.key, right.personality.key);
|
||||
case 12:
|
||||
return compareString(left.specialDomestic.key, right.specialDomestic.key);
|
||||
case 13:
|
||||
return compareString(left.specialWar.key, right.specialWar.key);
|
||||
case 14:
|
||||
return left.age - right.age;
|
||||
case 15:
|
||||
return left.npcState - right.npcState;
|
||||
}
|
||||
};
|
||||
|
||||
export const advanceGeneralDirectorySort = (
|
||||
current: readonly GeneralDirectorySortCriterion[],
|
||||
key: GeneralDirectorySortKey
|
||||
): GeneralDirectorySortCriterion[] => {
|
||||
const existing = current.find((criterion) => criterion.key === key);
|
||||
const remaining = current.filter((criterion) => criterion.key !== key);
|
||||
|
||||
if (!existing) return [{ key, direction: 'descending' }, ...remaining];
|
||||
if (existing.direction === 'descending') return [{ key, direction: 'ascending' }, ...remaining];
|
||||
return remaining;
|
||||
};
|
||||
|
||||
export const sortGeneralDirectory = <T extends GeneralDirectorySortable>(
|
||||
source: readonly T[],
|
||||
criteria: readonly GeneralDirectorySortCriterion[]
|
||||
): T[] => {
|
||||
if (criteria.length === 0) return [...source];
|
||||
|
||||
return source
|
||||
.map((general, originalIndex) => ({ general, originalIndex }))
|
||||
.sort((left, right) => {
|
||||
for (const criterion of criteria) {
|
||||
const compared = compareByKey(left.general, right.general, criterion.key);
|
||||
if (compared !== 0) return criterion.direction === 'ascending' ? compared : -compared;
|
||||
}
|
||||
return left.originalIndex - right.originalIndex;
|
||||
})
|
||||
.map(({ general }) => general);
|
||||
};
|
||||
@@ -1,15 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import GeneralDirectoryTable from '../components/directory/GeneralDirectoryTable.vue';
|
||||
import LegacySortControls from '../components/ui/LegacySortControls.vue';
|
||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||
import type { GeneralDirectoryGeneral } from '../types/directory';
|
||||
import {
|
||||
advanceGeneralDirectorySort,
|
||||
sortGeneralDirectory,
|
||||
type GeneralDirectorySortCriterion,
|
||||
type GeneralDirectorySortKey,
|
||||
} from '../utils/generalDirectorySort';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type SortKey = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15;
|
||||
|
||||
const sortOptions: Array<{ value: SortKey; label: string }> = [
|
||||
const sortOptions: Array<{ value: GeneralDirectorySortKey; label: string }> = [
|
||||
{ value: 0, label: '이름' },
|
||||
{ value: 1, label: '국가' },
|
||||
{ value: 2, label: '통솔' },
|
||||
{ value: 3, label: '무력' },
|
||||
@@ -27,18 +33,26 @@ const sortOptions: Array<{ value: SortKey; label: string }> = [
|
||||
{ value: 15, label: 'NPC' },
|
||||
];
|
||||
|
||||
const sort = ref<SortKey>(9);
|
||||
const generals = ref<GeneralDirectoryGeneral[]>([]);
|
||||
const selectedSort = ref<GeneralDirectorySortKey>(9);
|
||||
const sourceGenerals = ref<GeneralDirectoryGeneral[]>([]);
|
||||
const sortCriteria = ref<GeneralDirectorySortCriterion[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const router = useRouter();
|
||||
const { info: showInfoToast } = useGameFeedback();
|
||||
|
||||
const generals = computed(() => sortGeneralDirectory(sourceGenerals.value, sortCriteria.value));
|
||||
|
||||
const loadDirectory = async () => {
|
||||
if (loading.value) {
|
||||
showInfoToast('이미 장수 일람을 갱신하고 있습니다.');
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const result = await trpc.world.getGeneralDirectory.query({ sort: sort.value });
|
||||
generals.value = result.generals;
|
||||
const result = await trpc.world.getGeneralDirectory.query({ sort: 9 });
|
||||
sourceGenerals.value = Array.isArray(result.generals) ? result.generals : [];
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : '장수일람을 불러오지 못했습니다.';
|
||||
} finally {
|
||||
@@ -47,12 +61,12 @@ const loadDirectory = async () => {
|
||||
};
|
||||
|
||||
const updateSort = (value: number): void => {
|
||||
sort.value = value as SortKey;
|
||||
selectedSort.value = value as GeneralDirectorySortKey;
|
||||
};
|
||||
|
||||
const sortByHeader = (value: number): void => {
|
||||
updateSort(value);
|
||||
void loadDirectory();
|
||||
sortCriteria.value = advanceGeneralDirectorySort(sortCriteria.value, selectedSort.value);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
@@ -66,8 +80,15 @@ onMounted(() => {
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
장 수 일 람<br /><button class="legacy-button" type="button" @click="router.push('/')">
|
||||
창 닫기
|
||||
장 수 일 람<br />
|
||||
<button class="legacy-button" type="button" @click="router.push('/')">창 닫기</button>
|
||||
<button
|
||||
class="legacy-button"
|
||||
type="button"
|
||||
:aria-busy="loading || undefined"
|
||||
@click="loadDirectory"
|
||||
>
|
||||
갱 신
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -75,11 +96,11 @@ onMounted(() => {
|
||||
<td>
|
||||
<LegacySortControls
|
||||
control-id="viewType"
|
||||
:model-value="sort"
|
||||
:model-value="selectedSort"
|
||||
:options="sortOptions"
|
||||
:busy="loading"
|
||||
@update:model-value="updateSort"
|
||||
@submit="loadDirectory"
|
||||
@submit="sortByHeader(selectedSort)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -87,7 +108,12 @@ onMounted(() => {
|
||||
</table>
|
||||
|
||||
<p v-if="error" class="directory-error" role="alert">{{ error }}</p>
|
||||
<GeneralDirectoryTable :generals="generals" :loading="loading" :active-sort="sort" @sort="sortByHeader" />
|
||||
<GeneralDirectoryTable
|
||||
:generals="generals"
|
||||
:loading="loading"
|
||||
:sort-criteria="sortCriteria"
|
||||
@sort="sortByHeader"
|
||||
/>
|
||||
|
||||
<table class="directory-table title-table legacy-bg0">
|
||||
<tbody>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
advanceGeneralDirectorySort,
|
||||
sortGeneralDirectory,
|
||||
type GeneralDirectorySortable,
|
||||
} from '../src/utils/generalDirectorySort.ts';
|
||||
|
||||
type Row = GeneralDirectorySortable & { id: number };
|
||||
|
||||
const row = (id: number, name: string, leadership: number, strength: number): Row => ({
|
||||
id,
|
||||
name,
|
||||
nationId: id,
|
||||
leadership,
|
||||
strength,
|
||||
intelligence: 0,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
officerLevel: 0,
|
||||
killturn: 0,
|
||||
refreshScoreTotal: 0,
|
||||
personality: { key: '' },
|
||||
specialDomestic: { key: '' },
|
||||
specialWar: { key: '' },
|
||||
age: 0,
|
||||
npcState: 0,
|
||||
});
|
||||
|
||||
void describe('general directory client sorting', () => {
|
||||
void it('cycles each column through descending, ascending, and reset', () => {
|
||||
const descending = advanceGeneralDirectorySort([], 2);
|
||||
assert.deepEqual(descending, [{ key: 2, direction: 'descending' }]);
|
||||
const ascending = advanceGeneralDirectorySort(descending, 2);
|
||||
assert.deepEqual(ascending, [{ key: 2, direction: 'ascending' }]);
|
||||
assert.deepEqual(advanceGeneralDirectorySort(ascending, 2), []);
|
||||
});
|
||||
|
||||
void it('keeps the previous descending key as a tie-breaker for a later ascending key', () => {
|
||||
const source = [row(1, '갑', 70, 20), row(2, '을', 90, 20), row(3, '병', 80, 10)];
|
||||
const leadership = advanceGeneralDirectorySort([], 2);
|
||||
const strengthDescending = advanceGeneralDirectorySort(leadership, 3);
|
||||
const strengthAscending = advanceGeneralDirectorySort(strengthDescending, 3);
|
||||
|
||||
assert.deepEqual(
|
||||
sortGeneralDirectory(source, strengthAscending).map(({ id }) => id),
|
||||
[3, 2, 1]
|
||||
);
|
||||
});
|
||||
|
||||
void it('removes only the reset column and restores the older stable ordering', () => {
|
||||
const source = [row(1, '갑', 70, 20), row(2, '을', 90, 20), row(3, '병', 80, 10)];
|
||||
const leadership = advanceGeneralDirectorySort([], 2);
|
||||
const strengthDescending = advanceGeneralDirectorySort(leadership, 3);
|
||||
const strengthAscending = advanceGeneralDirectorySort(strengthDescending, 3);
|
||||
const resetStrength = advanceGeneralDirectorySort(strengthAscending, 3);
|
||||
|
||||
assert.deepEqual(resetStrength, leadership);
|
||||
assert.deepEqual(
|
||||
sortGeneralDirectory(source, resetStrength).map(({ id }) => id),
|
||||
[2, 3, 1]
|
||||
);
|
||||
});
|
||||
|
||||
void it('sorts Korean names in both directions without mutating the source', () => {
|
||||
const source = [row(1, '조조', 0, 0), row(2, '가후', 0, 0), row(3, '유비', 0, 0)];
|
||||
const descending = advanceGeneralDirectorySort([], 0);
|
||||
const ascending = advanceGeneralDirectorySort(descending, 0);
|
||||
|
||||
assert.deepEqual(
|
||||
sortGeneralDirectory(source, descending).map(({ name }) => name),
|
||||
['조조', '유비', '가후']
|
||||
);
|
||||
assert.deepEqual(
|
||||
sortGeneralDirectory(source, ascending).map(({ name }) => name),
|
||||
['가후', '유비', '조조']
|
||||
);
|
||||
assert.deepEqual(
|
||||
source.map(({ id }) => id),
|
||||
[1, 2, 3]
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -31,9 +31,12 @@ The Ref-style directory pages share a second, deliberately compact control
|
||||
family through `LegacySortControls.vue`. Its `.legacy-sort-*` rules own the
|
||||
explicit dark select/option palette, the raised submit button, and the
|
||||
focus/active states for sortable table headers. A page supplies only the
|
||||
available legacy sort keys, their fixed directions, and placement. Columns
|
||||
without an unambiguous legacy sort key remain plain headers rather than
|
||||
inventing a new ordering contract.
|
||||
available sort keys and placement. NPC·암행부·세력도시는 Ref의 고정 방향을
|
||||
유지합니다. 장수 일람은 사용자 조작 계약에 따라 로드한 snapshot 위에서
|
||||
`내림차순 → 오름차순 → 해제`를 순환하고, 여러 열의 방향과 우선순위를 scoped
|
||||
SFC indicator로 표시합니다. 장수 일람의 성격·특기·부상 설명은 많은 행에서
|
||||
eager popup instance를 만들지 않는 `DirectoryTooltip.vue` scoped CSS가 소유하며,
|
||||
mobile에서는 viewport 가장자리 8px 안의 고정 설명판으로 전환합니다.
|
||||
|
||||
## Button composition
|
||||
|
||||
|
||||
Reference in New Issue
Block a user