feat: 세력일람에 공개 장수 기본정보 hover 추가
장수일람 행을 공용 컴포넌트로 분리하고 세력일람 장수명 hover와 keyboard focus에서 공개 DTO 한 행만 표시한다. 국가 기밀 endpoint를 호출하지 않는 경계와 desktop/mobile Chromium geometry를 회귀 테스트로 고정한다.
This commit is contained in:
@@ -250,7 +250,7 @@ describe('legacy global nation/general directories', () => {
|
|||||||
await expect(adminWithoutGeneral.world.getNationDirectory()).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
await expect(adminWithoutGeneral.world.getNationDirectory()).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the same read-only result for wandering, ordinary, chief, and possessed actors', async () => {
|
it('keeps hover-safe public rows identical for wandering, ordinary, chief, and possessed actors', async () => {
|
||||||
const viewers = [
|
const viewers = [
|
||||||
actor({ nationId: 0, officerLevel: 0 }),
|
actor({ nationId: 0, officerLevel: 0 }),
|
||||||
actor({ nationId: 1, officerLevel: 1 }),
|
actor({ nationId: 1, officerLevel: 1 }),
|
||||||
@@ -270,6 +270,23 @@ describe('legacy global nation/general directories', () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(results.slice(1)).toEqual([results[0], results[0], results[0]]);
|
expect(results.slice(1)).toEqual([results[0], results[0], results[0]]);
|
||||||
|
for (const result of results) {
|
||||||
|
const serializedGeneralRows = JSON.stringify(result.generals);
|
||||||
|
for (const privateField of [
|
||||||
|
'userId',
|
||||||
|
'meta',
|
||||||
|
'penalty',
|
||||||
|
'gold',
|
||||||
|
'rice',
|
||||||
|
'crew',
|
||||||
|
'train',
|
||||||
|
'atmos',
|
||||||
|
'turnTime',
|
||||||
|
'lastTurn',
|
||||||
|
]) {
|
||||||
|
expect(serializedGeneralRows).not.toContain(`"${privateField}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves power/dedication order, synthesizes neutral, and applies target-general permission penalties', async () => {
|
it('preserves power/dedication order, synthesizes neutral, and applies target-general permission penalties', async () => {
|
||||||
|
|||||||
@@ -134,11 +134,10 @@ const parseSort = (route: Route): number => {
|
|||||||
try {
|
try {
|
||||||
const request = route.request();
|
const request = route.request();
|
||||||
const queryInput = new URL(request.url()).searchParams.get('input');
|
const queryInput = new URL(request.url()).searchParams.get('input');
|
||||||
const input = (request.postData()
|
const input = (request.postData() ? request.postDataJSON() : queryInput ? JSON.parse(queryInput) : {}) as {
|
||||||
? request.postDataJSON()
|
0?: { json?: { sort?: number }; sort?: number };
|
||||||
: queryInput
|
json?: { sort?: number };
|
||||||
? JSON.parse(queryInput)
|
};
|
||||||
: {}) as { 0?: { json?: { sort?: number }; sort?: number }; json?: { sort?: number } };
|
|
||||||
return input[0]?.json?.sort ?? input[0]?.sort ?? input.json?.sort ?? 9;
|
return input[0]?.json?.sort ?? input[0]?.sort ?? input.json?.sort ?? 9;
|
||||||
} catch {
|
} catch {
|
||||||
return 9;
|
return 9;
|
||||||
@@ -148,7 +147,8 @@ const parseSort = (route: Route): number => {
|
|||||||
const install = async (
|
const install = async (
|
||||||
page: Page,
|
page: Page,
|
||||||
mode: 'general' | 'no-general' | 'error-after-load' = 'general',
|
mode: 'general' | 'no-general' | 'error-after-load' = 'general',
|
||||||
accessPages: string[] = []
|
accessPages: string[] = [],
|
||||||
|
requestedOperations: string[] = []
|
||||||
) => {
|
) => {
|
||||||
let generalDirectoryCalls = 0;
|
let generalDirectoryCalls = 0;
|
||||||
await page.addInitScript((profile) => {
|
await page.addInitScript((profile) => {
|
||||||
@@ -176,13 +176,22 @@ const install = async (
|
|||||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#777"/></svg>',
|
body: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#777"/></svg>',
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
await page.route('https://sam-image.hided.net/icons/**', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'image/svg+xml',
|
||||||
|
body: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#777"/></svg>',
|
||||||
|
})
|
||||||
|
);
|
||||||
await page.route('**/image/game/**', (route) =>
|
await page.route('**/image/game/**', (route) =>
|
||||||
route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') })
|
route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') })
|
||||||
);
|
);
|
||||||
await page.route(`**/${gameBasePath}/api/trpc/**`, async (route) => {
|
await page.route(`**/${gameBasePath}/api/trpc/**`, async (route) => {
|
||||||
const requestBody = route.request().postDataJSON() as
|
const requestBody = route.request().postDataJSON() as
|
||||||
Record<string, { json?: { page?: unknown }; page?: unknown }> | undefined;
|
Record<string, { json?: { page?: unknown }; page?: unknown }> | undefined;
|
||||||
const results = operationNames(route).map((operation, operationIndex) => {
|
const operations = operationNames(route);
|
||||||
|
requestedOperations.push(...operations);
|
||||||
|
const results = operations.map((operation, operationIndex) => {
|
||||||
if (operation === 'auth.status') return response({ ok: true });
|
if (operation === 'auth.status') return response({ ok: true });
|
||||||
if (operation === 'lobby.info') {
|
if (operation === 'lobby.info') {
|
||||||
return response({ myGeneral: mode === 'no-general' ? null : { id: 1, name: '조회자' } });
|
return response({ myGeneral: mode === 'no-general' ? null : { id: 1, name: '조회자' } });
|
||||||
@@ -335,7 +344,7 @@ test('nation and general directories preserve the fixed legacy Chromium geometry
|
|||||||
expect(await header.evaluate((element) => getComputedStyle(element).backgroundImage)).toContain('back_green.jpg');
|
expect(await header.evaluate((element) => getComputedStyle(element).backgroundImage)).toContain('back_green.jpg');
|
||||||
const icon = page.locator('.general-icon').first();
|
const icon = page.locator('.general-icon').first();
|
||||||
await expect(icon).toBeVisible();
|
await expect(icon).toBeVisible();
|
||||||
await expect(icon).toHaveAttribute('src', '/gateway/api/user-icons/%EA%B3%84%EC%A0%95%20icon.png');
|
await expect(icon).toHaveAttribute('src', 'https://sam-image.hided.net/icons/%EA%B3%84%EC%A0%95%20icon.png');
|
||||||
expect(
|
expect(
|
||||||
await icon.evaluate((element) => {
|
await icon.evaluate((element) => {
|
||||||
const image = element as HTMLImageElement;
|
const image = element as HTMLImageElement;
|
||||||
@@ -350,7 +359,10 @@ test('nation and general directories preserve the fixed legacy Chromium geometry
|
|||||||
})
|
})
|
||||||
).toEqual({ width: 64, height: 64, naturalWidth: 64, naturalHeight: 64, objectFit: 'fill' });
|
).toEqual({ width: 64, height: 64, naturalWidth: 64, naturalHeight: 64, objectFit: 'fill' });
|
||||||
const nestedLegacyIcon = page.locator('.general-icon').nth(1);
|
const nestedLegacyIcon = page.locator('.general-icon').nth(1);
|
||||||
await expect(nestedLegacyIcon).toHaveAttribute('src', '/image/general/%EC%9E%A5%EC%88%98/%EC%9C%A0%EB%B9%84%201.png');
|
await expect(nestedLegacyIcon).toHaveAttribute(
|
||||||
|
'src',
|
||||||
|
'https://sam-image.hided.net/icons/%EC%9E%A5%EC%88%98/%EC%9C%A0%EB%B9%84%201.png'
|
||||||
|
);
|
||||||
await expect
|
await expect
|
||||||
.poll(() => nestedLegacyIcon.evaluate((element) => (element as HTMLImageElement).naturalWidth))
|
.poll(() => nestedLegacyIcon.evaluate((element) => (element as HTMLImageElement).naturalWidth))
|
||||||
.toBe(64);
|
.toBe(64);
|
||||||
@@ -380,20 +392,115 @@ test('general directory submits the legacy sort selector and keeps wounded/bonus
|
|||||||
expect(await page.locator('#viewType').evaluate((element) => document.activeElement === element)).toBe(true);
|
expect(await page.locator('#viewType').evaluate((element) => document.activeElement === element)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('nation directory reuses only the public general-directory row on hover and keyboard focus', async ({ page }) => {
|
||||||
|
const requestedOperations: string[] = [];
|
||||||
|
await install(page, 'general', [], requestedOperations);
|
||||||
|
|
||||||
|
const artifactRoot = process.env.DIRECTORY_PARITY_ARTIFACT_DIR;
|
||||||
|
for (const viewport of [
|
||||||
|
{ name: 'desktop', width: 1200, height: 900 },
|
||||||
|
{ name: 'mobile', width: 500, height: 844 },
|
||||||
|
] as const) {
|
||||||
|
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||||
|
await page.goto('nation-list');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await expect(page.getByRole('button', { name: '장수 일람 연동' })).toHaveCount(0);
|
||||||
|
await expect(page.locator('[data-general-preview-trigger]')).toHaveCount(4);
|
||||||
|
expect(requestedOperations.filter((operation) => operation === 'world.getGeneralDirectory')).toHaveLength(
|
||||||
|
viewport.name === 'desktop' ? 0 : 1
|
||||||
|
);
|
||||||
|
|
||||||
|
const documentHeightBefore = await page.evaluate(() => document.documentElement.scrollHeight);
|
||||||
|
const firstTrigger = page.locator('[data-general-preview-trigger="10"]');
|
||||||
|
await firstTrigger.hover();
|
||||||
|
|
||||||
|
const preview = page.locator('#nation-general-preview');
|
||||||
|
await expect(preview).toBeVisible();
|
||||||
|
await expect(preview.locator('tr[data-general-id]')).toHaveCount(1);
|
||||||
|
await expect(preview.locator('tr[data-general-id="10"]')).toContainText('조조');
|
||||||
|
await expect(preview.locator('tr[data-general-id="10"]')).toContainText('대담');
|
||||||
|
await expect(preview.locator('tr[data-general-id="10"]')).toContainText('상재 / 귀모');
|
||||||
|
await expect(preview).not.toContainText('user-');
|
||||||
|
await expect(preview).not.toContainText('secret');
|
||||||
|
|
||||||
|
const previewGeometry = await preview.evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
const row = element.querySelector('tr[data-general-id]')!.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: rect.x,
|
||||||
|
width: rect.width,
|
||||||
|
bottom: window.innerHeight - rect.bottom,
|
||||||
|
position: style.position,
|
||||||
|
rowHeight: row.height,
|
||||||
|
documentWidth: document.documentElement.scrollWidth,
|
||||||
|
documentHeight: document.documentElement.scrollHeight,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(previewGeometry).toEqual({
|
||||||
|
x: viewport.name === 'desktop' ? 100 : 0,
|
||||||
|
width: 1000,
|
||||||
|
bottom: 12,
|
||||||
|
position: 'fixed',
|
||||||
|
rowHeight: 65,
|
||||||
|
documentWidth: Math.max(viewport.width, 1000),
|
||||||
|
documentHeight: documentHeightBefore,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.locator('.nation-title').first().hover();
|
||||||
|
await expect(preview).toHaveCount(0);
|
||||||
|
|
||||||
|
const foreignTrigger = page.locator('[data-general-preview-trigger="20"]');
|
||||||
|
await foreignTrigger.focus();
|
||||||
|
await expect(preview.locator('tr[data-general-id="20"]')).toContainText('유비');
|
||||||
|
expect(await foreignTrigger.getAttribute('aria-expanded')).toBe('true');
|
||||||
|
expect(await foreignTrigger.evaluate((element) => getComputedStyle(element).outlineStyle)).toBe('dashed');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '창 닫기' }).first().focus();
|
||||||
|
await expect(preview).toHaveCount(0);
|
||||||
|
|
||||||
|
if (artifactRoot) {
|
||||||
|
const output = resolve(artifactRoot);
|
||||||
|
await mkdir(output, { recursive: true });
|
||||||
|
await firstTrigger.hover();
|
||||||
|
await expect(preview).toBeVisible();
|
||||||
|
await page.screenshot({
|
||||||
|
path: resolve(output, `nation-directory-hover-${viewport.name}.png`),
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(requestedOperations.filter((operation) => operation === 'world.getGeneralDirectory')).toHaveLength(2);
|
||||||
|
expect(requestedOperations).not.toContain('nation.getSecretGeneralList');
|
||||||
|
expect(requestedOperations).not.toContain('nation.getPersonnelInfo');
|
||||||
|
expect(requestedOperations).not.toContain('general.me');
|
||||||
|
});
|
||||||
|
|
||||||
test('a reused image element falls back for each newly broken account icon', async ({ page }) => {
|
test('a reused image element falls back for each newly broken account icon', async ({ page }) => {
|
||||||
await install(page);
|
await install(page);
|
||||||
|
await page.route('https://sam-image.hided.net/icons/**', (route) => {
|
||||||
|
if (route.request().url().endsWith('/default.jpg')) {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'image/svg+xml',
|
||||||
|
body: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#777"/></svg>',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.fulfill({ status: 404, contentType: 'text/plain', body: 'missing' });
|
||||||
|
});
|
||||||
await page.route('**/gateway/api/user-icons/**', (route) =>
|
await page.route('**/gateway/api/user-icons/**', (route) =>
|
||||||
route.fulfill({ status: 404, contentType: 'text/plain', body: 'missing' })
|
route.fulfill({ status: 404, contentType: 'text/plain', body: 'missing' })
|
||||||
);
|
);
|
||||||
await page.goto('general-list');
|
await page.goto('general-list');
|
||||||
|
|
||||||
const icon = page.locator('.general-icon').first();
|
const icon = page.locator('.general-icon').first();
|
||||||
await expect(icon).toHaveAttribute('src', /\/image\/icons\/default\.jpg$/);
|
await expect(icon).toHaveAttribute('src', 'https://sam-image.hided.net/icons/default.jpg');
|
||||||
await expect.poll(() => icon.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBe(64);
|
await expect.poll(() => icon.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBe(64);
|
||||||
await icon.evaluate((element) => {
|
await icon.evaluate((element) => {
|
||||||
(element as HTMLImageElement).src = '/gateway/api/user-icons/second-missing.png';
|
(element as HTMLImageElement).src = '/gateway/api/user-icons/second-missing.png';
|
||||||
});
|
});
|
||||||
await expect(icon).toHaveAttribute('src', /\/image\/icons\/default\.jpg$/);
|
await expect(icon).toHaveAttribute('src', 'https://sam-image.hided.net/icons/default.jpg');
|
||||||
await expect.poll(() => icon.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBe(64);
|
await expect.poll(() => icon.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBe(64);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../../utils/generalIcon';
|
||||||
|
import { formatOfficerLevelText } from '../../utils/nationFormat';
|
||||||
|
import { getNpcColor } from '../../utils/npcColor';
|
||||||
|
import type { GeneralDirectoryGeneral } from '../../types/directory';
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
generals: GeneralDirectoryGeneral[];
|
||||||
|
loading?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
loading: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const injuredStat = (value: number, injury: number): number => Math.trunc((value * (100 - injury)) / 100);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<table class="directory-table general-table legacy-bg0">
|
||||||
|
<colgroup>
|
||||||
|
<col style="width: 64px" />
|
||||||
|
<col style="width: 140px" />
|
||||||
|
<col style="width: 45px" />
|
||||||
|
<col style="width: 45px" />
|
||||||
|
<col style="width: 80px" />
|
||||||
|
<col style="width: 45px" />
|
||||||
|
<col style="width: 140px" />
|
||||||
|
<col style="width: 55px" />
|
||||||
|
<col style="width: 55px" />
|
||||||
|
<col style="width: 75px" />
|
||||||
|
<col style="width: 45px" />
|
||||||
|
<col style="width: 45px" />
|
||||||
|
<col style="width: 45px" />
|
||||||
|
<col style="width: 45px" />
|
||||||
|
<col style="width: 70px" />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<td class="header-cell">얼 굴</td>
|
||||||
|
<td class="header-cell">이 름</td>
|
||||||
|
<td class="header-cell">연령</td>
|
||||||
|
<td class="header-cell">성격</td>
|
||||||
|
<td class="header-cell">특기</td>
|
||||||
|
<td class="header-cell">레 벨</td>
|
||||||
|
<td class="header-cell">국 가</td>
|
||||||
|
<td class="header-cell">명 성</td>
|
||||||
|
<td class="header-cell">계 급</td>
|
||||||
|
<td class="header-cell">관 직</td>
|
||||||
|
<td class="header-cell">통솔</td>
|
||||||
|
<td class="header-cell">무력</td>
|
||||||
|
<td class="header-cell">지력</td>
|
||||||
|
<td class="header-cell">삭턴</td>
|
||||||
|
<td class="header-cell">벌점</td>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="loading">
|
||||||
|
<td colspan="15" class="loading-cell">불러오는 중...</td>
|
||||||
|
</tr>
|
||||||
|
<tr
|
||||||
|
v-for="general in generals"
|
||||||
|
v-else
|
||||||
|
:key="general.id"
|
||||||
|
:data-general-id="general.id"
|
||||||
|
:data-general-wounded="general.injury"
|
||||||
|
:data-general-leadership="general.leadership"
|
||||||
|
:data-general-leadership-bonus="general.leadershipBonus"
|
||||||
|
:data-general-strength="general.strength"
|
||||||
|
:data-general-intel="general.intelligence"
|
||||||
|
:data-is-npc="general.npcState >= 2"
|
||||||
|
:data-npc-type="general.npcState"
|
||||||
|
>
|
||||||
|
<td class="center">
|
||||||
|
<img
|
||||||
|
class="general-icon"
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
:src="resolveGeneralIconUrl(general)"
|
||||||
|
alt=""
|
||||||
|
@error="useDefaultGeneralIcon"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="center">
|
||||||
|
<span :style="{ color: getNpcColor(general.npcState) }">{{ general.name }}</span>
|
||||||
|
<template v-if="general.ownerName"
|
||||||
|
><br /><small>({{ general.ownerName }})</small></template
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td class="center">{{ general.age }}세</td>
|
||||||
|
<td class="center">
|
||||||
|
<span :title="general.personality.info">{{ general.personality.name }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="center">
|
||||||
|
<span :title="general.specialDomestic.info">{{ general.specialDomestic.name }}</span> /
|
||||||
|
<span :title="general.specialWar.info">{{ general.specialWar.name }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="center">Lv {{ general.experienceLevel }}</td>
|
||||||
|
<td class="center">{{ general.nationName }}</td>
|
||||||
|
<td class="center">{{ general.honorText }}</td>
|
||||||
|
<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
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td class="center">
|
||||||
|
<span :class="{ wounded: general.injury > 0 }">{{
|
||||||
|
general.injury > 0 ? injuredStat(general.strength, general.injury) : general.strength
|
||||||
|
}}</span>
|
||||||
|
</td>
|
||||||
|
<td class="center">
|
||||||
|
<span :class="{ wounded: general.injury > 0 }">{{
|
||||||
|
general.injury > 0 ? injuredStat(general.intelligence, general.injury) : general.intelligence
|
||||||
|
}}</span>
|
||||||
|
</td>
|
||||||
|
<td class="center">{{ general.killturn }}</td>
|
||||||
|
<td class="center">{{ general.refreshScoreTotal }}<br />【{{ general.refreshText }}】</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.directory-table {
|
||||||
|
width: 1000px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: auto;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.3;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.directory-table td {
|
||||||
|
border: 1px solid gray;
|
||||||
|
padding: 0;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.header-cell {
|
||||||
|
height: 18px;
|
||||||
|
text-align: center;
|
||||||
|
background-color: #14241b;
|
||||||
|
background-image: var(--sammo-texture-green);
|
||||||
|
}
|
||||||
|
.general-icon {
|
||||||
|
display: inline;
|
||||||
|
width: 64px;
|
||||||
|
min-width: 64px;
|
||||||
|
max-width: none;
|
||||||
|
height: 64px;
|
||||||
|
object-fit: fill;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.wounded {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
.leadership-bonus {
|
||||||
|
color: cyan;
|
||||||
|
}
|
||||||
|
.loading-cell {
|
||||||
|
height: 64px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import type { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
|
export type GeneralDirectory = Awaited<ReturnType<typeof trpc.world.getGeneralDirectory.query>>;
|
||||||
|
export type GeneralDirectoryGeneral = GeneralDirectory['generals'][number];
|
||||||
@@ -2,13 +2,10 @@
|
|||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
import GeneralDirectoryTable from '../components/directory/GeneralDirectoryTable.vue';
|
||||||
import { formatOfficerLevelText } from '../utils/nationFormat';
|
import type { GeneralDirectoryGeneral } from '../types/directory';
|
||||||
import { getNpcColor } from '../utils/npcColor';
|
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
type Directory = Awaited<ReturnType<typeof trpc.world.getGeneralDirectory.query>>;
|
|
||||||
type General = Directory['generals'][number];
|
|
||||||
type SortKey = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15;
|
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: SortKey; label: string }> = [
|
||||||
@@ -30,7 +27,7 @@ const sortOptions: Array<{ value: SortKey; label: string }> = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const sort = ref<SortKey>(9);
|
const sort = ref<SortKey>(9);
|
||||||
const generals = ref<General[]>([]);
|
const generals = ref<GeneralDirectoryGeneral[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -48,9 +45,6 @@ const loadDirectory = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const imageUrl = (general: General): string => resolveGeneralIconUrl(general);
|
|
||||||
const injuredStat = (value: number, injury: number): number => Math.trunc((value * (100 - injury)) / 100);
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadDirectory();
|
void loadDirectory();
|
||||||
});
|
});
|
||||||
@@ -84,114 +78,7 @@ onMounted(() => {
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
<p v-if="error" class="directory-error" role="alert">{{ error }}</p>
|
<p v-if="error" class="directory-error" role="alert">{{ error }}</p>
|
||||||
<table class="directory-table general-table legacy-bg0">
|
<GeneralDirectoryTable :generals="generals" :loading="loading" />
|
||||||
<colgroup>
|
|
||||||
<col style="width: 64px" />
|
|
||||||
<col style="width: 140px" />
|
|
||||||
<col style="width: 45px" />
|
|
||||||
<col style="width: 45px" />
|
|
||||||
<col style="width: 80px" />
|
|
||||||
<col style="width: 45px" />
|
|
||||||
<col style="width: 140px" />
|
|
||||||
<col style="width: 55px" />
|
|
||||||
<col style="width: 55px" />
|
|
||||||
<col style="width: 75px" />
|
|
||||||
<col style="width: 45px" />
|
|
||||||
<col style="width: 45px" />
|
|
||||||
<col style="width: 45px" />
|
|
||||||
<col style="width: 45px" />
|
|
||||||
<col style="width: 70px" />
|
|
||||||
</colgroup>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<td class="header-cell">얼 굴</td>
|
|
||||||
<td class="header-cell">이 름</td>
|
|
||||||
<td class="header-cell">연령</td>
|
|
||||||
<td class="header-cell">성격</td>
|
|
||||||
<td class="header-cell">특기</td>
|
|
||||||
<td class="header-cell">레 벨</td>
|
|
||||||
<td class="header-cell">국 가</td>
|
|
||||||
<td class="header-cell">명 성</td>
|
|
||||||
<td class="header-cell">계 급</td>
|
|
||||||
<td class="header-cell">관 직</td>
|
|
||||||
<td class="header-cell">통솔</td>
|
|
||||||
<td class="header-cell">무력</td>
|
|
||||||
<td class="header-cell">지력</td>
|
|
||||||
<td class="header-cell">삭턴</td>
|
|
||||||
<td class="header-cell">벌점</td>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-if="loading">
|
|
||||||
<td colspan="15" class="loading-cell">불러오는 중...</td>
|
|
||||||
</tr>
|
|
||||||
<tr
|
|
||||||
v-for="general in generals"
|
|
||||||
v-else
|
|
||||||
:key="general.id"
|
|
||||||
:data-general-id="general.id"
|
|
||||||
:data-general-wounded="general.injury"
|
|
||||||
:data-general-leadership="general.leadership"
|
|
||||||
:data-general-leadership-bonus="general.leadershipBonus"
|
|
||||||
:data-general-strength="general.strength"
|
|
||||||
:data-general-intel="general.intelligence"
|
|
||||||
:data-is-npc="general.npcState >= 2"
|
|
||||||
:data-npc-type="general.npcState"
|
|
||||||
>
|
|
||||||
<td class="center">
|
|
||||||
<img
|
|
||||||
class="general-icon"
|
|
||||||
width="64"
|
|
||||||
height="64"
|
|
||||||
:src="imageUrl(general)"
|
|
||||||
alt=""
|
|
||||||
@error="useDefaultGeneralIcon"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td class="center">
|
|
||||||
<span :style="{ color: getNpcColor(general.npcState) }">{{ general.name }}</span>
|
|
||||||
<template v-if="general.ownerName"
|
|
||||||
><br /><small>({{ general.ownerName }})</small></template
|
|
||||||
>
|
|
||||||
</td>
|
|
||||||
<td class="center">{{ general.age }}세</td>
|
|
||||||
<td class="center">
|
|
||||||
<span :title="general.personality.info">{{ general.personality.name }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="center">
|
|
||||||
<span :title="general.specialDomestic.info">{{ general.specialDomestic.name }}</span> /
|
|
||||||
<span :title="general.specialWar.info">{{ general.specialWar.name }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="center">Lv {{ general.experienceLevel }}</td>
|
|
||||||
<td class="center">{{ general.nationName }}</td>
|
|
||||||
<td class="center">{{ general.honorText }}</td>
|
|
||||||
<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
|
|
||||||
>
|
|
||||||
</td>
|
|
||||||
<td class="center">
|
|
||||||
<span :class="{ wounded: general.injury > 0 }">{{
|
|
||||||
general.injury > 0 ? injuredStat(general.strength, general.injury) : general.strength
|
|
||||||
}}</span>
|
|
||||||
</td>
|
|
||||||
<td class="center">
|
|
||||||
<span :class="{ wounded: general.injury > 0 }">{{
|
|
||||||
general.injury > 0
|
|
||||||
? injuredStat(general.intelligence, general.injury)
|
|
||||||
: general.intelligence
|
|
||||||
}}</span>
|
|
||||||
</td>
|
|
||||||
<td class="center">{{ general.killturn }}</td>
|
|
||||||
<td class="center">{{ general.refreshScoreTotal }}<br />【{{ general.refreshText }}】</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<table class="directory-table title-table legacy-bg0">
|
<table class="directory-table title-table legacy-bg0">
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -241,34 +128,6 @@ onMounted(() => {
|
|||||||
.sort-form button {
|
.sort-form button {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.header-cell {
|
|
||||||
height: 18px;
|
|
||||||
text-align: center;
|
|
||||||
background-color: #14241b;
|
|
||||||
background-image: var(--sammo-texture-green);
|
|
||||||
}
|
|
||||||
.general-icon {
|
|
||||||
display: inline;
|
|
||||||
width: 64px;
|
|
||||||
min-width: 64px;
|
|
||||||
max-width: none;
|
|
||||||
height: 64px;
|
|
||||||
object-fit: fill;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
.center {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.wounded {
|
|
||||||
color: red;
|
|
||||||
}
|
|
||||||
.leadership-bonus {
|
|
||||||
color: cyan;
|
|
||||||
}
|
|
||||||
.loading-cell {
|
|
||||||
height: 64px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.directory-error {
|
.directory-error {
|
||||||
width: 998px;
|
width: 998px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
import GeneralDirectoryTable from '../components/directory/GeneralDirectoryTable.vue';
|
||||||
|
import type { GeneralDirectoryGeneral } from '../types/directory';
|
||||||
import { formatNationLevelText, formatOfficerLevelText } from '../utils/nationFormat';
|
import { formatNationLevelText, formatOfficerLevelText } from '../utils/nationFormat';
|
||||||
import { getNpcColor } from '../utils/npcColor';
|
import { getNpcColor } from '../utils/npcColor';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
@@ -11,6 +13,19 @@ type Nation = Directory[number];
|
|||||||
const nations = ref<Directory>([]);
|
const nations = ref<Directory>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
|
const generalDetails = ref<GeneralDirectoryGeneral[]>([]);
|
||||||
|
const generalDetailsLoaded = ref(false);
|
||||||
|
const generalDetailsLoading = ref(false);
|
||||||
|
const generalDetailsError = ref('');
|
||||||
|
const activeGeneralId = ref<number | null>(null);
|
||||||
|
let generalDetailsRequest: Promise<void> | null = null;
|
||||||
|
|
||||||
|
const generalDetailsById = computed(
|
||||||
|
() => new Map(generalDetails.value.map((general) => [general.id, general] as const))
|
||||||
|
);
|
||||||
|
const activeGeneral = computed(() =>
|
||||||
|
activeGeneralId.value === null ? null : (generalDetailsById.value.get(activeGeneralId.value) ?? null)
|
||||||
|
);
|
||||||
|
|
||||||
const whiteTextColors = new Set([
|
const whiteTextColors = new Set([
|
||||||
'',
|
'',
|
||||||
@@ -62,6 +77,49 @@ const roamingCityName = (nation: Nation): string => {
|
|||||||
};
|
};
|
||||||
const closeWindow = () => window.close();
|
const closeWindow = () => window.close();
|
||||||
|
|
||||||
|
const loadGeneralDetails = async (): Promise<void> => {
|
||||||
|
if (generalDetailsLoaded.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!generalDetailsRequest) {
|
||||||
|
generalDetailsLoading.value = true;
|
||||||
|
generalDetailsError.value = '';
|
||||||
|
generalDetailsRequest = trpc.world.getGeneralDirectory
|
||||||
|
.query({ sort: 9 })
|
||||||
|
.then((result) => {
|
||||||
|
generalDetails.value = result.generals;
|
||||||
|
generalDetailsLoaded.value = true;
|
||||||
|
})
|
||||||
|
.catch((cause: unknown) => {
|
||||||
|
generalDetailsError.value =
|
||||||
|
cause instanceof Error ? cause.message : '장수 기본 정보를 불러오지 못했습니다.';
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
generalDetailsLoading.value = false;
|
||||||
|
generalDetailsRequest = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await generalDetailsRequest;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showGeneralDetails = (generalId: number): void => {
|
||||||
|
activeGeneralId.value = generalId;
|
||||||
|
void loadGeneralDetails();
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideGeneralDetails = (generalId: number): void => {
|
||||||
|
if (activeGeneralId.value === generalId) {
|
||||||
|
activeGeneralId.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideGeneralDetailsFromPointer = (event: PointerEvent, generalId: number): void => {
|
||||||
|
if (document.activeElement === event.currentTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hideGeneralDetails(generalId);
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadDirectory();
|
void loadDirectory();
|
||||||
});
|
});
|
||||||
@@ -76,7 +134,6 @@ onMounted(() => {
|
|||||||
세 력 일 람<br /><button class="legacy-button" type="button" @click="closeWindow">
|
세 력 일 람<br /><button class="legacy-button" type="button" @click="closeWindow">
|
||||||
창 닫기
|
창 닫기
|
||||||
</button>
|
</button>
|
||||||
<input type="button" value="장수 일람 연동" />
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -155,9 +212,21 @@ onMounted(() => {
|
|||||||
<td colspan="8">
|
<td colspan="8">
|
||||||
장수 일람 :
|
장수 일람 :
|
||||||
<template v-for="general in nation.generals" :key="general.id">
|
<template v-for="general in nation.generals" :key="general.id">
|
||||||
<span :style="{ color: getNpcColor(general.npcState) }">{{
|
<button
|
||||||
displayGeneralName(general)
|
type="button"
|
||||||
}}</span
|
class="general-preview-trigger"
|
||||||
|
:data-general-preview-trigger="general.id"
|
||||||
|
:style="{ color: getNpcColor(general.npcState) }"
|
||||||
|
:aria-expanded="activeGeneralId === general.id"
|
||||||
|
:aria-describedby="
|
||||||
|
activeGeneralId === general.id ? 'nation-general-preview' : undefined
|
||||||
|
"
|
||||||
|
@pointerenter="showGeneralDetails(general.id)"
|
||||||
|
@pointerleave="hideGeneralDetailsFromPointer($event, general.id)"
|
||||||
|
@focus="showGeneralDetails(general.id)"
|
||||||
|
@blur="hideGeneralDetails(general.id)"
|
||||||
|
>
|
||||||
|
{{ displayGeneralName(general) }}</button
|
||||||
>,
|
>,
|
||||||
</template>
|
</template>
|
||||||
</td>
|
</td>
|
||||||
@@ -188,9 +257,21 @@ onMounted(() => {
|
|||||||
<td colspan="5">
|
<td colspan="5">
|
||||||
장수 일람 :
|
장수 일람 :
|
||||||
<template v-for="general in nation.generals" :key="general.id">
|
<template v-for="general in nation.generals" :key="general.id">
|
||||||
<span :style="{ color: getNpcColor(general.npcState) }">{{
|
<button
|
||||||
displayGeneralName(general)
|
type="button"
|
||||||
}}</span
|
class="general-preview-trigger"
|
||||||
|
:data-general-preview-trigger="general.id"
|
||||||
|
:style="{ color: getNpcColor(general.npcState) }"
|
||||||
|
:aria-expanded="activeGeneralId === general.id"
|
||||||
|
:aria-describedby="
|
||||||
|
activeGeneralId === general.id ? 'nation-general-preview' : undefined
|
||||||
|
"
|
||||||
|
@pointerenter="showGeneralDetails(general.id)"
|
||||||
|
@pointerleave="hideGeneralDetailsFromPointer($event, general.id)"
|
||||||
|
@focus="showGeneralDetails(general.id)"
|
||||||
|
@blur="hideGeneralDetails(general.id)"
|
||||||
|
>
|
||||||
|
{{ displayGeneralName(general) }}</button
|
||||||
>,
|
>,
|
||||||
</template>
|
</template>
|
||||||
</td>
|
</td>
|
||||||
@@ -199,6 +280,21 @@ onMounted(() => {
|
|||||||
</table>
|
</table>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="activeGeneralId !== null"
|
||||||
|
id="nation-general-preview"
|
||||||
|
class="general-hover-preview"
|
||||||
|
role="tooltip"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<GeneralDirectoryTable v-if="activeGeneral" :generals="[activeGeneral]" />
|
||||||
|
<div v-else class="general-hover-status">
|
||||||
|
<template v-if="generalDetailsLoading">장수 기본 정보를 불러오는 중...</template>
|
||||||
|
<template v-else-if="generalDetailsError">{{ generalDetailsError }}</template>
|
||||||
|
<template v-else>표시할 수 있는 장수 기본 정보가 없습니다.</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="legacy-analysis-helper" aria-hidden="true">
|
<div class="legacy-analysis-helper" aria-hidden="true">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -306,6 +402,40 @@ onMounted(() => {
|
|||||||
.center {
|
.center {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
.general-preview-trigger {
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
.general-preview-trigger:focus-visible {
|
||||||
|
outline: 1px dashed cyan;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.general-hover-preview {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 30;
|
||||||
|
bottom: 12px;
|
||||||
|
left: max(0px, calc(50% - 500px));
|
||||||
|
width: 1000px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: #000;
|
||||||
|
box-shadow: 0 0 7px 3px rgb(255 255 255 / 50%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.general-hover-status {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 1000px;
|
||||||
|
min-height: 65px;
|
||||||
|
border: 1px solid gray;
|
||||||
|
padding: 22px 8px;
|
||||||
|
background: #000;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
.footer-table {
|
.footer-table {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user