feat: restore global nation and general directories

This commit is contained in:
2026-07-26 05:18:30 +00:00
parent 8fac491c9d
commit 52da582b74
11 changed files with 1727 additions and 1 deletions
@@ -0,0 +1,335 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { expect, test, type Page, type Route } from '@playwright/test';
const response = (data: unknown) => ({ result: { data } });
const operationNames = (route: Route) =>
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
const nationDirectory = [
{
id: 2,
name: '촉',
color: '#800000',
level: 2,
type: { key: 'che_법가', name: '법가' },
power: 2000,
capitalCityId: 2,
generalCount: 1,
cityCount: 1,
officers: Array.from({ length: 8 }, (_, index) => ({
officerLevel: 12 - index,
general: index === 0 ? { id: 20, name: '유비', npcState: 0, cityId: 2 } : null,
})),
ambassadorNames: ['유비'],
auditorCount: 0,
cities: [{ id: 2, name: '성도', capital: true }],
generals: [{ id: 20, name: '유비', npcState: 0 }],
},
{
id: 1,
name: '위',
color: '#008000',
level: 3,
type: { key: 'che_명가', name: '명가' },
power: 1000,
capitalCityId: 1,
generalCount: 2,
cityCount: 1,
officers: Array.from({ length: 8 }, (_, index) => ({
officerLevel: 12 - index,
general: index === 0 ? { id: 10, name: '조조', npcState: 0, cityId: 1 } : null,
})),
ambassadorNames: ['조조', '순욱'],
auditorCount: 1,
cities: [{ id: 1, name: '허창', capital: true }],
generals: [
{ id: 10, name: '조조', npcState: 0 },
{ id: 11, name: '순욱', npcState: 1 },
],
},
{
id: 0,
name: '재 야',
color: '#000000',
level: 0,
type: { key: 'None', name: '-' },
power: 0,
capitalCityId: 0,
generalCount: 1,
cityCount: 1,
officers: Array.from({ length: 8 }, (_, index) => ({ officerLevel: 12 - index, general: null })),
ambassadorNames: [],
auditorCount: 0,
cities: [{ id: 3, name: '낙양', capital: false }],
generals: [{ id: 30, name: '재야장', npcState: 2 }],
},
];
const generals = [
{
id: 10,
name: '조조',
ownerName: null,
picture: 'default.jpg',
imageServer: 0,
npcState: 0,
age: 35,
nationId: 1,
nationName: '위',
nationLevel: 3,
personality: { key: 'che_대담', name: '대담', info: '대담한 성격' },
specialDomestic: { key: 'che_상재', name: '상재', info: '상업 특기' },
specialWar: { key: 'che_귀모', name: '귀모', info: '전투 특기' },
injury: 10,
leadership: 90,
leadershipBonus: 6,
strength: 70,
intelligence: 95,
experience: 10_000,
experienceLevel: 31,
honorText: '약간',
dedication: 900,
dedicationText: '27품관',
officerLevel: 12,
killturn: 3,
refreshScoreTotal: 120,
refreshText: '가끔',
},
{
id: 20,
name: '유비',
ownerName: '통일유저',
picture: 'default.jpg',
imageServer: 0,
npcState: 0,
age: 34,
nationId: 2,
nationName: '촉',
nationLevel: 2,
personality: { key: 'che_인덕', name: '인덕', info: '인덕 있는 성격' },
specialDomestic: { key: 'None', name: '-', info: '' },
specialWar: { key: 'None', name: '-', info: '' },
injury: 0,
leadership: 80,
leadershipBonus: 4,
strength: 75,
intelligence: 70,
experience: 20_000,
experienceLevel: 44,
honorText: '지역적',
dedication: 800,
dedicationText: '27품관',
officerLevel: 12,
killturn: 1,
refreshScoreTotal: 210,
refreshText: '보통',
},
];
const parseSort = (route: Route): number => {
const raw = new URL(route.request().url()).searchParams.get('input');
if (!raw) return 9;
try {
const input = JSON.parse(raw) as { 0?: { sort?: number }; json?: { sort?: number } };
return input[0]?.sort ?? input.json?.sort ?? 9;
} catch {
return 9;
}
};
const install = async (
page: Page,
mode: 'general' | 'no-general' | 'error-after-load' = 'general'
) => {
let generalDirectoryCalls = 0;
await page.addInitScript(() => {
localStorage.setItem('sammo-game-token', 'ga_directory');
localStorage.setItem('sammo-game-profile', 'che:default');
});
await page.route('**/image/general/**', (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) =>
route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') })
);
await page.route('**/che/api/trpc/**', async (route) => {
const results = operationNames(route).map((operation) => {
if (operation === 'lobby.info') {
return response({ myGeneral: mode === 'no-general' ? null : { id: 1, name: '조회자' } });
}
if (operation === 'join.getConfig') return response({});
if (operation === 'world.getNationDirectory') return response(nationDirectory);
if (operation === 'world.getGeneralDirectory') {
generalDirectoryCalls += 1;
if (mode === 'error-after-load' && generalDirectoryCalls > 1) {
return {
error: {
message: '권한 확인 실패',
code: -32000,
data: {
code: 'FORBIDDEN',
httpStatus: 403,
path: 'world.getGeneralDirectory',
},
},
};
}
const sort = parseSort(route);
const rows = sort === 8 ? [...generals].sort((left, right) => left.killturn - right.killturn) : generals;
return response({ sort, generals: rows });
}
return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } };
});
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
});
};
test('nation and general directories preserve the fixed legacy Chromium geometry', async ({ page }) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
const measurements: Record<string, unknown> = {};
for (const path of ['nation-list', 'general-list']) {
await page.goto(path);
const root = page.locator('.directory-page');
await expect(root).toBeVisible();
const geometry = await root.evaluate((element, currentPath) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
const tableStyle = getComputedStyle(element.querySelector('table')!);
const cellStyle = getComputedStyle(element.querySelector('td')!);
const titleRect = element.querySelector('.title-table')!.getBoundingClientRect();
const contentTable =
currentPath === 'nation-list'
? element.querySelector('.nation-table')!
: element.querySelector('.general-table')!;
const contentRect = contentTable.getBoundingClientRect();
const contentFirstRow = contentTable.querySelector('tr')!.getBoundingClientRect();
return {
x: rect.x,
width: rect.width,
fontSize: style.fontSize,
lineHeight: style.lineHeight,
fontFamily: style.fontFamily,
borderCollapse: tableStyle.borderCollapse,
cellBorderWidth: cellStyle.borderTopWidth,
cellPadding: cellStyle.padding,
bodyBackgroundColor: getComputedStyle(document.body).backgroundColor,
title: { x: titleRect.x, y: titleRect.y, width: titleRect.width, height: titleRect.height },
content: {
x: contentRect.x,
y: contentRect.y,
width: contentRect.width,
firstRowHeight: contentFirstRow.height,
},
};
}, path);
measurements[path] = geometry;
expect(geometry).toMatchObject({
x: 100,
width: 1000,
fontSize: '14px',
lineHeight: '18.2px',
borderCollapse: 'collapse',
cellBorderWidth: '1px',
bodyBackgroundColor: 'rgb(0, 0, 0)',
});
expect(geometry.fontFamily).toContain('Pretendard');
if (path === 'nation-list') {
expect(geometry.title).toEqual({ x: 100, y: 0, width: 1000, height: 55.6875 });
expect(geometry.content).toEqual({
x: 100,
y: 55.6875,
width: 1000,
firstRowHeight: 19.1875,
});
expect(
await page
.locator('.nation-table .label-cell')
.first()
.evaluate((element) => element.getBoundingClientRect().width)
).toBe(79.921875);
} else {
expect(geometry.title).toEqual({ x: 100, y: 0, width: 1000, height: 80.875 });
expect(geometry.content).toEqual({
x: 100,
y: 80.875,
width: 1000,
firstRowHeight: 19.1875,
});
expect(
await page
.locator('.general-table tbody tr[data-general-id]')
.first()
.evaluate((element) => element.getBoundingClientRect().height)
).toBe(65);
}
}
const header = page.locator('.general-table thead td').first();
expect(await header.evaluate((element) => getComputedStyle(element).backgroundImage)).toContain('back_green.jpg');
const icon = page.locator('.general-icon').first();
await expect(icon).toBeVisible();
expect(
await icon.evaluate((element) => {
const image = element as HTMLImageElement;
const rect = image.getBoundingClientRect();
return {
width: rect.width,
height: rect.height,
naturalWidth: image.naturalWidth,
naturalHeight: image.naturalHeight,
objectFit: getComputedStyle(image).objectFit,
};
})
).toEqual({ width: 64, height: 64, naturalWidth: 64, naturalHeight: 64, objectFit: 'fill' });
const artifactRoot = process.env.DIRECTORY_PARITY_ARTIFACT_DIR;
if (artifactRoot) {
const output = resolve(artifactRoot);
await mkdir(output, { recursive: true });
await writeFile(resolve(output, 'computed-dom.json'), `${JSON.stringify(measurements, null, 2)}\n`);
await page.screenshot({ path: resolve(output, 'general-directory.png'), fullPage: true });
await page.goto('nation-list');
await page.screenshot({ path: resolve(output, 'nation-directory.png'), fullPage: true });
}
});
test('general directory submits the legacy sort selector and keeps wounded/bonus rendering', async ({ page }) => {
await install(page);
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', '20');
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');
await page.locator('#viewType').focus();
expect(await page.locator('#viewType').evaluate((element) => document.activeElement === element)).toBe(true);
});
test('a failed resort retains the selected value 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 expect(page.getByRole('alert')).toContainText('권한 확인 실패');
await expect(page.locator('#viewType')).toHaveValue('8');
await expect(page.locator('tbody tr[data-general-id]')).toHaveCount(2);
});
test('an authenticated account without a general is redirected away from both directories', async ({ page }) => {
await install(page, 'no-general');
for (const path of ['nation-list', 'general-list']) {
await page.goto(path);
await expect(page).toHaveURL(/\/che\/join$/);
}
});
+7 -1
View File
@@ -8,7 +8,13 @@ const baseURL = `http://127.0.0.1:${port}/che/`;
export default defineConfig({
testDir: '.',
testMatch: ['troop.spec.ts', 'board.spec.ts', 'inGameInfo.spec.ts', 'nationOffices.spec.ts'],
testMatch: [
'troop.spec.ts',
'board.spec.ts',
'inGameInfo.spec.ts',
'nationOffices.spec.ts',
'directoryLists.spec.ts',
],
fullyParallel: false,
workers: 1,
timeout: 30_000,
+1
View File
@@ -10,6 +10,7 @@
"test:e2e:troop": "playwright test troop.spec.ts --config e2e/playwright.config.mjs",
"test:e2e:nation-offices": "playwright test nationOffices.spec.ts --config e2e/playwright.config.mjs",
"test:e2e:board": "playwright test board.spec.ts --config e2e/playwright.config.mjs",
"test:e2e:directories": "playwright test directoryLists.spec.ts --config e2e/playwright.config.mjs",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "node -e \"console.log('test not configured')\"",
+14
View File
@@ -32,6 +32,8 @@ import TroopView from '../views/TroopView.vue';
import YearbookView from '../views/YearbookView.vue';
import NationBettingView from '../views/NationBettingView.vue';
import NpcListView from '../views/NpcListView.vue';
import NationListView from '../views/NationListView.vue';
import GeneralListView from '../views/GeneralListView.vue';
import { useSessionStore } from '../stores/session';
const routes = [
@@ -106,6 +108,18 @@ const routes = [
component: GlobalInfoView,
meta: { requiresAuth: true, requiresGeneral: true },
},
{
path: '/nation-list',
name: 'nation-list',
component: NationListView,
meta: { requiresAuth: true, requiresGeneral: true },
},
{
path: '/general-list',
name: 'general-list',
component: GeneralListView,
meta: { requiresAuth: true, requiresGeneral: true },
},
{
path: '/current-city',
name: 'current-city',
@@ -0,0 +1,274 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { formatOfficerLevelText } from '../utils/nationFormat';
import { getNpcColor } from '../utils/npcColor';
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;
const sortOptions: Array<{ value: SortKey; label: string }> = [
{ value: 1, label: '국가' },
{ value: 2, label: '통솔' },
{ value: 3, label: '무력' },
{ value: 4, label: '지력' },
{ value: 5, label: '명성' },
{ value: 6, label: '계급' },
{ value: 7, label: '관직' },
{ value: 8, label: '삭턴' },
{ value: 9, label: '벌점' },
{ value: 10, label: 'Lv' },
{ value: 11, label: '성격' },
{ value: 12, label: '내특' },
{ value: 13, label: '전특' },
{ value: 14, label: '연령' },
{ value: 15, label: 'NPC' },
];
const sort = ref<SortKey>(9);
const generals = ref<General[]>([]);
const loading = ref(false);
const error = ref('');
const loadDirectory = async () => {
loading.value = true;
error.value = '';
try {
const result = await trpc.world.getGeneralDirectory.query({ sort: sort.value });
generals.value = result.generals;
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '장수일람을 불러오지 못했습니다.';
} finally {
loading.value = false;
}
};
const imageUrl = (general: General): string => {
const picture = general.picture ?? 'default.jpg';
return general.imageServer ? `${import.meta.env.BASE_URL}d_pic/${picture}` : `/image/general/${picture}`;
};
const injuredStat = (value: number, injury: number): number => Math.trunc((value * (100 - injury)) / 100);
onMounted(() => {
void loadDirectory();
});
</script>
<template>
<main class="directory-page" data-page="general-directory">
<table class="directory-table title-table legacy-bg0">
<tbody>
<tr>
<td> <br /><RouterLink class="legacy-button" to="/"> 닫기</RouterLink></td>
</tr>
<tr>
<td>
<form class="sort-form" @submit.prevent="loadDirectory">
<label for="viewType">정렬순서 : </label>
<select id="viewType" v-model.number="sort" name="type" size="1">
<option v-for="option in sortOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<button type="submit">정렬하기</button>
</form>
</td>
</tr>
</tbody>
</table>
<p v-if="error" class="directory-error" role="alert">{{ error }}</p>
<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="imageUrl(general)" alt="" />
</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">
<tbody>
<tr>
<td><RouterLink class="legacy-button" to="/"> 닫기</RouterLink></td>
</tr>
<tr>
<td><small>삼국지 모의전투 HiDCHe</small></td>
</tr>
</tbody>
</table>
</main>
</template>
<style scoped>
.directory-page {
width: 1000px;
margin: 0 auto;
font-size: 14px;
line-height: 1.3;
}
.directory-table {
width: 1000px;
border-collapse: collapse;
table-layout: auto;
padding: 0;
font-size: 14px;
word-break: break-all;
}
.directory-table td {
border: 1px solid gray;
padding: 0;
word-break: break-all;
}
.title-table {
text-align: center;
}
.directory-page > .title-table:first-child {
height: 80.875px;
}
.title-table td {
padding: 1px;
}
.legacy-button {
padding: 5px 10px;
font-size: 14px;
}
.sort-form {
margin: 0;
}
.sort-form select,
.sort-form button {
font-size: 14px;
}
.header-cell {
height: 18px;
text-align: center;
background-color: #14241b;
background-image: url('/image/game/back_green.jpg');
}
.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 {
width: 998px;
margin: 0;
border: 1px solid gray;
padding: 8px 0;
text-align: center;
color: #ff7373;
}
</style>
+2
View File
@@ -102,6 +102,8 @@ watch(
<RouterLink class="ghost" to="/nation/info">세력 정보</RouterLink>
<RouterLink class="ghost" to="/nation/cities">세력 도시</RouterLink>
<RouterLink class="ghost" to="/global-info">중원 정보</RouterLink>
<RouterLink class="ghost" to="/nation-list">세력일람</RouterLink>
<RouterLink class="ghost" to="/general-list">장수일람</RouterLink>
<RouterLink class="ghost" to="/current-city">현재 도시</RouterLink>
<RouterLink class="ghost" to="/nation/generals">세력 장수</RouterLink>
<RouterLink class="ghost" to="/nation/personnel">인사부</RouterLink>
@@ -0,0 +1,295 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { formatOfficerLevelText } from '../utils/nationFormat';
import { getNpcColor } from '../utils/npcColor';
import { trpc } from '../utils/trpc';
type Directory = Awaited<ReturnType<typeof trpc.world.getNationDirectory.query>>;
type Nation = Directory[number];
const nations = ref<Directory>([]);
const loading = ref(false);
const error = ref('');
const nationLevelText: Record<number, string> = {
7: '황제',
6: '왕',
5: '공',
4: '주목',
3: '주자사',
2: '군벌',
1: '호족',
0: '방랑군',
};
const whiteTextColors = new Set([
'',
'#330000',
'#ff0000',
'#800000',
'#a0522d',
'#ff6347',
'#808000',
'#008000',
'#2e8b57',
'#008080',
'#6495ed',
'#0000ff',
'#000080',
'#483d8b',
'#7b68ee',
'#800080',
'#a9a9a9',
'#000000',
]);
const loadDirectory = async () => {
loading.value = true;
error.value = '';
try {
nations.value = await trpc.world.getNationDirectory.query();
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '세력일람을 불러오지 못했습니다.';
} finally {
loading.value = false;
}
};
const headerTextColor = (color: string): string =>
whiteTextColors.has(color.toLowerCase()) ? '#ffffff' : '#000000';
const officerName = (nation: Nation, officerLevel: number) =>
nation.officers.find((officer) => officer.officerLevel === officerLevel)?.general;
const roamingCityName = (nation: Nation): string => {
const chief = officerName(nation, 12);
return nation.cities.find((city) => city.id === chief?.cityId)?.name ?? '-';
};
onMounted(() => {
void loadDirectory();
});
</script>
<template>
<main class="directory-page" data-page="nation-directory">
<table class="directory-table title-table legacy-bg0">
<tbody>
<tr>
<td> <br /><RouterLink class="legacy-button" to="/"> 닫기</RouterLink></td>
</tr>
</tbody>
</table>
<p v-if="error" class="directory-error" role="alert">{{ error }}</p>
<p v-else-if="loading" class="directory-loading">불러오는 중...</p>
<template v-for="nation in nations" :key="nation.id">
<table
v-if="nation.id !== 0"
class="directory-table nation-table legacy-bg2"
:data-nation-id="nation.id"
>
<tbody>
<tr>
<td
colspan="8"
class="center nation-title"
:style="{ color: headerTextColor(nation.color), backgroundColor: nation.color }"
>
{{ nation.name }}
</td>
</tr>
<tr>
<td class="label-cell"> </td>
<td class="value-wide type-name">{{ Array.from(nation.type.name).join(' ') }}</td>
<td class="label-cell"> </td>
<td class="value-wide">{{ nationLevelText[nation.level] ?? '-' }}</td>
<td class="label-cell"> </td>
<td class="value-wide">{{ nation.power }}</td>
<td class="label-cell">장수 / 속령</td>
<td class="value-wide">{{ nation.generalCount }} / {{ nation.cityCount }}</td>
</tr>
<tr v-for="row in 2" :key="row">
<template v-for="column in 4" :key="column">
<td class="label-cell">
{{ formatOfficerLevelText(13 - ((row - 1) * 4 + column), nation.level) }}
</td>
<td class="value-wide">
<span
v-if="officerName(nation, 13 - ((row - 1) * 4 + column))"
:style="{
color: getNpcColor(
officerName(nation, 13 - ((row - 1) * 4 + column))?.npcState ?? 0
),
}"
>
{{ officerName(nation, 13 - ((row - 1) * 4 + column))?.name }}
</span>
<template v-else>-</template>
</td>
</template>
</tr>
<tr>
<td class="label-cell">외교권자</td>
<td colspan="5">{{ nation.ambassadorNames.join(', ') }}</td>
<td class="label-cell">조언자</td>
<td class="value-wide">{{ nation.auditorCount }}</td>
</tr>
<tr>
<td colspan="8">
<template v-if="nation.level > 0">
속령 일람 :
<template v-for="city in nation.cities" :key="city.id">
<span :class="{ capital: city.capital }">{{ city.capital ? `[${city.name}]` : city.name }}</span
>,
</template>
</template>
<template v-else>현재 위치 : <span class="roaming-city">{{ roamingCityName(nation) }}</span></template>
</td>
</tr>
<tr>
<td colspan="8">
장수 일람 :
<template v-for="general in nation.generals" :key="general.id">
<span :style="{ color: getNpcColor(general.npcState) }">{{ general.name }}</span
>,
</template>
</td>
</tr>
</tbody>
</table>
<br v-if="nation.id !== 0" />
<table v-else class="directory-table neutral-table legacy-bg2" data-nation-id="0">
<tbody>
<tr>
<td colspan="5" class="center"> </td>
</tr>
<tr>
<td class="neutral-spacer">&nbsp;</td>
<td class="neutral-label"> </td>
<td class="neutral-value">{{ nation.generalCount }}</td>
<td class="neutral-label"> </td>
<td class="neutral-value">{{ nation.cityCount }}</td>
</tr>
<tr>
<td colspan="5">
속령 일람 :
<template v-for="city in nation.cities" :key="city.id">{{ city.name }}, </template>
</td>
</tr>
<tr>
<td colspan="5">
장수 일람 :
<template v-for="general in nation.generals" :key="general.id">
<span :style="{ color: getNpcColor(general.npcState) }">{{ general.name }}</span
>,
</template>
</td>
</tr>
</tbody>
</table>
</template>
<table class="directory-table title-table footer-table legacy-bg0">
<tbody>
<tr>
<td><RouterLink class="legacy-button" to="/"> 닫기</RouterLink></td>
</tr>
<tr>
<td><small>삼국지 모의전투 HiDCHe</small></td>
</tr>
</tbody>
</table>
</main>
</template>
<style scoped>
.directory-page {
width: 1000px;
margin: 0 auto;
font-size: 14px;
line-height: 1.3;
}
.directory-table {
width: 1000px;
border-collapse: collapse;
table-layout: auto;
font-size: 14px;
word-break: break-all;
}
.directory-table td {
border: 1px solid gray;
padding: 0;
word-break: break-all;
}
.title-table {
text-align: center;
}
.directory-page > .title-table:first-child {
height: 55.6875px;
}
.title-table td {
padding: 1px;
}
.legacy-button {
padding: 5px 10px;
font-size: 14px;
}
.nation-table {
background-color: #172a52;
}
.nation-title {
height: 19px;
}
.label-cell {
width: 80px;
text-align: center;
background-color: #14241b;
background-image: url('/image/game/back_green.jpg');
}
.value-wide {
width: 170px;
text-align: center;
}
.type-name {
color: yellow;
}
.capital {
color: cyan;
}
.roaming-city {
color: yellow;
}
.neutral-spacer {
width: 498px;
text-align: center;
}
.neutral-label,
.neutral-value {
width: 123px;
text-align: center;
}
.neutral-label {
background-color: #14241b;
background-image: url('/image/game/back_green.jpg');
}
.center {
text-align: center;
}
.footer-table {
margin-top: 0;
}
.directory-error,
.directory-loading {
width: 998px;
margin: 0;
border: 1px solid gray;
padding: 8px 0;
text-align: center;
}
.directory-error {
color: #ff7373;
}
</style>