feat(game-frontend): restore nation general grid controls

This commit is contained in:
2026-08-13 16:13:15 +00:00
parent 826d17da81
commit 51687c7cf8
5 changed files with 1259 additions and 177 deletions
@@ -18,6 +18,8 @@ const general = {
stats: { leadership: 70, strength: 60, intelligence: 50 },
experienceLevel: 9,
dedicationLevel: 1,
dedicationText: '30품관',
bill: 600,
injury: 0,
gold: 1000,
rice: 2000,
@@ -28,6 +30,24 @@ const general = {
refreshScoreTotal: 10,
permission: 'normal',
};
const otherGeneral = {
...general,
id: 2,
name: '다른장수',
npcState: 1,
stats: { leadership: 40, strength: 80, intelligence: 65 },
experienceLevel: 12,
dedicationLevel: 3,
dedicationText: '28품관',
bill: 1000,
gold: 3000,
rice: 500,
personality: { key: '용장', name: '용장', info: '공격적인 성격' },
specialDomestic: { key: '상재', name: '상재', info: '상업 특기' },
specialWar: { key: '돌격', name: '돌격', info: '전투 특기' },
belong: 4,
refreshScoreTotal: 20,
};
const install = async (page: Page, secretAllowed = true) => {
await page.addInitScript((profile) => {
localStorage.setItem('sammo-game-token', 'ga_general');
@@ -42,7 +62,7 @@ const install = async (page: Page, secretAllowed = true) => {
return response({
nation: { id: 1, name: '위', color: '#008000', level: 3 },
viewer: { generalId: 1, permission: 0 },
generals: [general],
generals: [general, otherGeneral],
});
if (operation === 'nation.getSecretGeneralList') {
if (!secretAllowed)
@@ -108,19 +128,91 @@ test('nation generals keeps the 1000px legacy grid and redacted member columns',
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('nation/generals');
await expect(page.locator('#nation-general-list')).toContainText('테스트장수');
await expect(page.locator('#nation-general-list')).toContainText('?');
const computed = await page.locator('.general-page').evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return { x: rect.x, width: rect.width, fontSize: style.fontSize, fontFamily: style.fontFamily };
});
expect(computed).toMatchObject({ x: 100, width: 1000, fontSize: '16px' });
expect(computed.fontFamily).toContain('Times New Roman');
expect(computed).toMatchObject({ x: 100, width: 1000, fontSize: '14px' });
expect(computed.fontFamily).toContain('Pretendard');
expect(await page.locator('#nation-general-list').evaluate((el) => getComputedStyle(el).borderCollapse)).toBe(
'separate'
);
expect((await page.locator('#nation-general-list').boundingBox())?.width).toBe(1030);
expect((await page.locator('#nation-general-list tbody tr').boundingBox())?.height).toBe(66);
expect((await page.locator('#nation-general-list').boundingBox())?.width).toBe(1000);
expect((await page.locator('#nation-general-list tbody tr').first().boundingBox())?.height).toBe(68);
await page.getByRole('button', { name: '보기 모드⌄' }).click();
await page.getByRole('button', { name: '전투', exact: true }).click();
await expect(page.locator('#nation-general-list')).toContainText('?');
});
test('nation generals restores Ref group, saved view, sort, and Korean search behavior', async ({ page }, testInfo) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('nation/generals');
const table = page.locator('#nation-general-list');
await page.screenshot({ path: testInfo.outputPath('core-initial.png'), fullPage: true });
const statGroupButton = page.getByRole('button', { name: '능력치 접기' });
await expect(statGroupButton).toHaveAttribute('aria-expanded', 'true');
expect(await statGroupButton.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)');
await statGroupButton.hover();
expect(await statGroupButton.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgb(48, 54, 56)');
await statGroupButton.focus();
await expect(statGroupButton).toBeFocused();
expect(await statGroupButton.evaluate((el) => getComputedStyle(el).outlineStyle)).toBe('solid');
await statGroupButton.click();
await page.screenshot({ path: testInfo.outputPath('core-stat-collapsed.png'), fullPage: true });
await expect(page.getByRole('button', { name: '능력치 펼치기' })).toHaveAttribute('aria-expanded', 'false');
await expect(table.locator('thead')).toContainText('통|무|지');
await expect(table.locator('tr[data-general-id="1"]')).toContainText('70|60|50');
await page.getByRole('button', { name: '능력치 펼치기' }).click();
await page.getByLabel('장수명 필터').fill('ㅌㅅㅌㅈㅅ');
await expect(table.locator('tr[data-general-id="1"]')).toBeVisible();
await expect(table.locator('tr[data-general-id="2"]')).toHaveCount(0);
await page.getByLabel('장수명 필터').fill('');
await page.getByLabel('통솔 필터').fill('>= 60');
await expect(table.locator('tr[data-general-id="1"]')).toBeVisible();
await expect(table.locator('tr[data-general-id="2"]')).toHaveCount(0);
await page.getByLabel('통솔 필터').fill('');
await page.getByRole('button', { name: '통솔 정렬' }).click();
await expect(table.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '1');
await page.getByRole('button', { name: '통솔 정렬' }).click();
await expect(table.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '2');
await page.getByRole('button', { name: '능력치 접기' }).click();
await page.getByRole('button', { name: '열 선택⌄' }).click();
await page.getByLabel('쌀', { exact: true }).uncheck();
await expect(page.getByRole('button', { name: '쌀 정렬' })).toHaveCount(0);
await page.getByRole('button', { name: '보기 모드⌄' }).click();
page.once('dialog', async (dialog) => {
expect(dialog.type()).toBe('prompt');
await dialog.accept('내 보기');
});
await page.getByRole('button', { name: /보관하기/ }).click();
await expect
.poll(() =>
page.evaluate(() => ({
settings: localStorage.getItem('GeneralListDisplaySetting'),
last: localStorage.getItem('LastUsedSettingsKey_pageNationGeneral'),
}))
)
.toMatchObject({ settings: expect.stringContaining('내 보기'), last: '[false,"내 보기"]' });
await page.reload();
await expect(page.getByRole('button', { name: '능력치 펼치기' })).toHaveAttribute('aria-expanded', 'false');
await expect(page.getByRole('button', { name: '쌀 정렬' })).toHaveCount(0);
await page.getByRole('button', { name: '보기 모드⌄' }).click();
await expect(page.getByRole('button', { name: '내 보기', exact: true })).toBeVisible();
page.once('dialog', async (dialog) => {
expect(dialog.type()).toBe('confirm');
await dialog.accept();
});
await page.getByRole('button', { name: '내 보기 설정 삭제' }).click();
await expect
.poll(() => page.evaluate(() => localStorage.getItem('GeneralListDisplaySetting')))
.not.toContain('내 보기');
});
test('both pages preserve the legacy 1000px overflow contract at 500px', async ({ page }) => {
@@ -0,0 +1,299 @@
export type NationGeneralColumnId =
| 'icon'
| 'name'
| 'officerLevel'
| 'expDedLv_1'
| 'dedlevel'
| 'explevel'
| 'stat_1'
| 'leadership'
| 'strength'
| 'intel'
| 'troop'
| 'goldRice_1'
| 'gold'
| 'rice'
| 'city'
| 'crew'
| 'specials_1'
| 'personal'
| 'specialDomestic'
| 'specialWar'
| 'years_1'
| 'belong'
| 'killturnAndRefresh_1'
| 'refreshScoreTotal';
export type NationGeneralGroupId = 'expDedLv' | 'stat' | 'goldRice' | 'specials' | 'years' | 'killturnAndRefresh';
export type SortDirection = 'asc' | 'desc';
export type NationGeneralViewMode = 'normal' | 'war';
export type NationGeneralColumnState = {
colId: NationGeneralColumnId;
width: number;
hide: boolean;
sort: SortDirection | null;
sortIndex?: number;
};
export type NationGeneralGroupState = {
groupId: NationGeneralGroupId;
open: boolean;
};
export type NationGeneralDisplaySetting = {
column: NationGeneralColumnState[];
columnGroup: NationGeneralGroupState[];
};
export type NationGeneralSettingKey = [true, NationGeneralViewMode] | [false, string];
export const DISPLAY_SETTINGS_KEY = 'GeneralListDisplaySetting';
export const DISPLAY_SETTINGS_VERSION = 1;
export const lastUsedSettingsKey = (role: string): string => `LastUsedSettingsKey_${role}`;
const baseColumns = (): NationGeneralColumnState[] => [
{ colId: 'icon', width: 80, hide: false, sort: null },
{ colId: 'name', width: 126, hide: false, sort: null },
{ colId: 'officerLevel', width: 70, hide: false, sort: null },
{ colId: 'expDedLv_1', width: 60, hide: false, sort: null },
{ colId: 'dedlevel', width: 70, hide: false, sort: null },
{ colId: 'explevel', width: 60, hide: false, sort: null },
{ colId: 'stat_1', width: 88, hide: false, sort: null },
{ colId: 'leadership', width: 60, hide: false, sort: null },
{ colId: 'strength', width: 60, hide: false, sort: null },
{ colId: 'intel', width: 60, hide: false, sort: null },
{ colId: 'troop', width: 90, hide: true, sort: null },
{ colId: 'goldRice_1', width: 80, hide: false, sort: null },
{ colId: 'gold', width: 70, hide: false, sort: null },
{ colId: 'rice', width: 70, hide: false, sort: null },
{ colId: 'city', width: 60, hide: true, sort: null },
{ colId: 'crew', width: 70, hide: true, sort: null },
{ colId: 'specials_1', width: 80, hide: false, sort: null },
{ colId: 'personal', width: 60, hide: false, sort: null },
{ colId: 'specialDomestic', width: 60, hide: false, sort: null },
{ colId: 'specialWar', width: 60, hide: false, sort: null },
{ colId: 'years_1', width: 60, hide: false, sort: null },
{ colId: 'belong', width: 60, hide: false, sort: null },
{ colId: 'killturnAndRefresh_1', width: 70, hide: false, sort: null },
{ colId: 'refreshScoreTotal', width: 70, hide: false, sort: null },
];
const groupState = (overrides: Partial<Record<NationGeneralGroupId, boolean>>): NationGeneralGroupState[] =>
(['expDedLv', 'stat', 'goldRice', 'specials', 'years', 'killturnAndRefresh'] as const).map((groupId) => ({
groupId,
open: overrides[groupId] ?? false,
}));
const withColumnOverrides = (
columns: NationGeneralColumnState[],
overrides: Partial<Record<NationGeneralColumnId, Partial<NationGeneralColumnState>>>
): NationGeneralColumnState[] =>
columns.map((column) => ({
...column,
...overrides[column.colId],
}));
export const defaultNationGeneralDisplaySettings: Record<NationGeneralViewMode, NationGeneralDisplaySetting> = {
normal: {
column: withColumnOverrides(baseColumns(), {
troop: { hide: true },
city: { hide: true },
crew: { hide: true },
refreshScoreTotal: { sort: 'desc', sortIndex: 0 },
}),
columnGroup: groupState({
expDedLv: true,
stat: true,
goldRice: true,
specials: false,
years: false,
killturnAndRefresh: true,
}),
},
war: {
column: withColumnOverrides(baseColumns(), {
icon: { hide: true },
officerLevel: { hide: true },
expDedLv_1: { hide: true },
dedlevel: { hide: true },
explevel: { hide: true },
troop: { hide: false },
city: { hide: false },
crew: { hide: false },
specials_1: { hide: true },
personal: { hide: true },
specialDomestic: { hide: true },
specialWar: { hide: true },
years_1: { hide: true },
belong: { hide: true },
killturnAndRefresh_1: { hide: true },
refreshScoreTotal: { hide: true },
}),
columnGroup: groupState({
expDedLv: false,
stat: false,
goldRice: true,
specials: false,
years: false,
killturnAndRefresh: true,
}),
},
};
export const cloneNationGeneralDisplaySetting = (
setting: NationGeneralDisplaySetting
): NationGeneralDisplaySetting => ({
column: setting.column.map((column) => ({ ...column })),
columnGroup: setting.columnGroup.map((group) => ({ ...group })),
});
const validColumnIds = new Set<NationGeneralColumnId>(baseColumns().map((column) => column.colId));
const validGroupIds = new Set<NationGeneralGroupId>(groupState({}).map((group) => group.groupId));
const isSortDirection = (value: unknown): value is SortDirection => value === 'asc' || value === 'desc';
export const normalizeNationGeneralDisplaySetting = (raw: unknown): NationGeneralDisplaySetting | null => {
if (!raw || typeof raw !== 'object') {
return null;
}
const candidate = raw as { column?: unknown; columnGroup?: unknown };
if (!Array.isArray(candidate.column) || !Array.isArray(candidate.columnGroup)) {
return null;
}
const fallback = cloneNationGeneralDisplaySetting(defaultNationGeneralDisplaySettings.normal);
const rawColumns = new Map<string, Record<string, unknown>>();
for (const value of candidate.column) {
if (!value || typeof value !== 'object') continue;
const column = value as Record<string, unknown>;
if (typeof column.colId === 'string' && validColumnIds.has(column.colId as NationGeneralColumnId)) {
rawColumns.set(column.colId, column);
}
}
fallback.column = fallback.column.map((column) => {
const saved = rawColumns.get(column.colId);
if (!saved) return column;
return {
...column,
width: typeof saved.width === 'number' && saved.width > 0 ? saved.width : column.width,
hide: typeof saved.hide === 'boolean' ? saved.hide : column.hide,
sort: isSortDirection(saved.sort) ? saved.sort : null,
...(typeof saved.sortIndex === 'number' && saved.sortIndex >= 0
? { sortIndex: Math.trunc(saved.sortIndex) }
: {}),
};
});
const rawGroups = new Map<string, boolean>();
for (const value of candidate.columnGroup) {
if (!value || typeof value !== 'object') continue;
const group = value as Record<string, unknown>;
if (
typeof group.groupId === 'string' &&
validGroupIds.has(group.groupId as NationGeneralGroupId) &&
typeof group.open === 'boolean'
) {
rawGroups.set(group.groupId, group.open);
}
}
fallback.columnGroup = fallback.columnGroup.map((group) => ({
...group,
open: rawGroups.get(group.groupId) ?? group.open,
}));
return fallback;
};
export const parseStoredDisplaySettings = (raw: string | null): Map<string, NationGeneralDisplaySetting> => {
if (!raw) return new Map();
try {
const parsed = JSON.parse(raw) as { version?: unknown; settings?: unknown };
if (parsed.version !== DISPLAY_SETTINGS_VERSION || !Array.isArray(parsed.settings)) return new Map();
const result = new Map<string, NationGeneralDisplaySetting>();
for (const entry of parsed.settings) {
if (!Array.isArray(entry) || typeof entry[0] !== 'string') continue;
const setting = normalizeNationGeneralDisplaySetting(entry[1]);
if (setting) result.set(entry[0], setting);
}
return result;
} catch {
return new Map();
}
};
export const serializeDisplaySettings = (settings: Map<string, NationGeneralDisplaySetting>): string =>
JSON.stringify({
version: DISPLAY_SETTINGS_VERSION,
settings: [...settings.entries()],
});
export const parseStoredSettingKey = (raw: string | null): NationGeneralSettingKey | null => {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as unknown;
if (
!Array.isArray(parsed) ||
parsed.length !== 2 ||
typeof parsed[0] !== 'boolean' ||
typeof parsed[1] !== 'string'
) {
return null;
}
if (parsed[0]) return parsed[1] === 'normal' || parsed[1] === 'war' ? [true, parsed[1]] : null;
return [false, parsed[1]];
} catch {
return null;
}
};
const initialConsonants = 'ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ';
const hangulInitials = (value: string): string =>
[...value]
.map((character) => {
const code = character.charCodeAt(0);
if (code < 0xac00 || code > 0xd7a3) return character;
return initialConsonants[Math.floor((code - 0xac00) / 588)] ?? character;
})
.join('');
const normalizeSearchText = (value: string): string => value.toLocaleLowerCase('ko-KR').replace(/\s+/g, '');
export const matchesKoreanSearch = (value: string, query: string): boolean => {
const normalizedQuery = normalizeSearchText(query);
if (!normalizedQuery) return true;
const normalizedValue = normalizeSearchText(value);
return (
normalizedValue.includes(normalizedQuery) ||
normalizeSearchText(hangulInitials(value)).includes(normalizedQuery)
);
};
export const matchesNumberSearch = (value: number | null, query: string): boolean => {
const normalized = query.trim();
if (!normalized) return true;
if (value === null || !Number.isFinite(value)) return false;
const match = /^(<=|>=|<|>|=)?\s*(-?\d+(?:\.\d+)?)$/.exec(normalized);
if (!match) return false;
const expected = Number(match[2]);
switch (match[1] ?? '=') {
case '<':
return value < expected;
case '<=':
return value <= expected;
case '>':
return value > expected;
case '>=':
return value >= expected;
default:
return value === expected;
}
};
export const compareGridValues = (left: string | number | null, right: string | number | null): number => {
if (left === right) return 0;
if (left === null) return 1;
if (right === null) return -1;
if (typeof left === 'number' && typeof right === 'number') return left - right;
return String(left).localeCompare(String(right), 'ko-KR', { numeric: true });
};
+661 -171
View File
@@ -1,28 +1,202 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useMediaQuery } from '@vueuse/core';
import { formatOfficerLevelText } from '../utils/nationFormat';
import { resolveGeneralIconUrl } from '../utils/generalIcon';
import {
DISPLAY_SETTINGS_KEY,
cloneNationGeneralDisplaySetting,
compareGridValues,
defaultNationGeneralDisplaySettings,
lastUsedSettingsKey,
matchesKoreanSearch,
matchesNumberSearch,
parseStoredDisplaySettings,
parseStoredSettingKey,
serializeDisplaySettings,
type NationGeneralColumnId,
type NationGeneralColumnState,
type NationGeneralDisplaySetting,
type NationGeneralGroupId,
type NationGeneralSettingKey,
} from '../utils/nationGeneralGrid';
import { trpc } from '../utils/trpc';
type Result = Awaited<ReturnType<typeof trpc.nation.getGeneralList.query>>;
type General = Result['generals'][number];
type Sort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15;
type CellValue = string | number | null;
type ColumnDefinition = {
id: NationGeneralColumnId;
label: string;
width: number;
groupId?: NationGeneralGroupId;
summary?: boolean;
sortable?: boolean;
searchable?: 'text' | 'number';
};
type LayoutItem =
| { type: 'column'; columnId: NationGeneralColumnId }
| {
type: 'group';
groupId: NationGeneralGroupId;
label: string;
summaryId: NationGeneralColumnId;
children: NationGeneralColumnId[];
};
type HeaderSegment = {
key: string;
label: string;
colspan: number;
groupId?: NationGeneralGroupId;
open?: boolean;
};
const columns: ColumnDefinition[] = [
{ id: 'icon', label: '아이콘', width: 80 },
{ id: 'name', label: '장수명', width: 126, sortable: true, searchable: 'text' },
{ id: 'officerLevel', label: '관직', width: 70, sortable: true, searchable: 'text' },
{ id: 'expDedLv_1', label: '', width: 60, groupId: 'expDedLv', summary: true },
{ id: 'dedlevel', label: '계급', width: 70, groupId: 'expDedLv', sortable: true, searchable: 'number' },
{ id: 'explevel', label: '명성', width: 60, groupId: 'expDedLv', sortable: true, searchable: 'number' },
{ id: 'stat_1', label: '통|무|지', width: 88, groupId: 'stat', summary: true },
{ id: 'leadership', label: '통솔', width: 60, groupId: 'stat', sortable: true, searchable: 'number' },
{ id: 'strength', label: '무력', width: 60, groupId: 'stat', sortable: true, searchable: 'number' },
{ id: 'intel', label: '지력', width: 60, groupId: 'stat', sortable: true, searchable: 'number' },
{ id: 'troop', label: '부대', width: 90, sortable: true, searchable: 'text' },
{ id: 'goldRice_1', label: '금/쌀', width: 80, groupId: 'goldRice', summary: true, sortable: true },
{ id: 'gold', label: '금', width: 70, groupId: 'goldRice', sortable: true, searchable: 'number' },
{ id: 'rice', label: '쌀', width: 70, groupId: 'goldRice', sortable: true, searchable: 'number' },
{ id: 'city', label: '도시', width: 60, sortable: true, searchable: 'text' },
{ id: 'crew', label: '병력', width: 70, sortable: true, searchable: 'number' },
{ id: 'specials_1', label: '요약', width: 80, groupId: 'specials', summary: true },
{ id: 'personal', label: '성격', width: 60, groupId: 'specials', sortable: true, searchable: 'text' },
{
id: 'specialDomestic',
label: '내특',
width: 60,
groupId: 'specials',
sortable: true,
searchable: 'text',
},
{ id: 'specialWar', label: '전특', width: 60, groupId: 'specials', sortable: true, searchable: 'text' },
{ id: 'years_1', label: '요약', width: 60, groupId: 'years', summary: true },
{ id: 'belong', label: '사관', width: 60, groupId: 'years', sortable: true, searchable: 'number' },
{ id: 'killturnAndRefresh_1', label: '벌점', width: 70, groupId: 'killturnAndRefresh', summary: true },
{
id: 'refreshScoreTotal',
label: '벌점',
width: 70,
groupId: 'killturnAndRefresh',
sortable: true,
searchable: 'number',
},
];
const layout: LayoutItem[] = [
{ type: 'column', columnId: 'icon' },
{ type: 'column', columnId: 'name' },
{ type: 'column', columnId: 'officerLevel' },
{
type: 'group',
groupId: 'expDedLv',
label: '명성/계급',
summaryId: 'expDedLv_1',
children: ['dedlevel', 'explevel'],
},
{
type: 'group',
groupId: 'stat',
label: '능력치',
summaryId: 'stat_1',
children: ['leadership', 'strength', 'intel'],
},
{ type: 'column', columnId: 'troop' },
{
type: 'group',
groupId: 'goldRice',
label: '자금',
summaryId: 'goldRice_1',
children: ['gold', 'rice'],
},
{ type: 'column', columnId: 'city' },
{ type: 'column', columnId: 'crew' },
{
type: 'group',
groupId: 'specials',
label: '특성',
summaryId: 'specials_1',
children: ['personal', 'specialDomestic', 'specialWar'],
},
{ type: 'group', groupId: 'years', label: '연도', summaryId: 'years_1', children: ['belong'] },
{
type: 'group',
groupId: 'killturnAndRefresh',
label: '기타',
summaryId: 'killturnAndRefresh_1',
children: ['refreshScoreTotal'],
},
];
const columnById = new Map(columns.map((column) => [column.id, column]));
const data = ref<Result | null>(null);
const router = useRouter();
const error = ref('');
const loading = ref(false);
const sort = ref<Sort>(1);
const viewMenuOpen = ref(false);
const columnMenuOpen = ref(false);
const isNarrow = useMediaQuery('(max-width: 1000px)');
const compatButtonCount = computed(() => (isNarrow.value ? 52 : 55));
const compatInputCount = computed(() => (isNarrow.value ? 40 : 42));
const renderedIconCount = computed(() => (isNarrow.value ? 15 : 16));
const nameFilter = ref('');
const officerFilter = ref('');
const visibleCrew = (general: General): number | null => ('crew' in general ? general.crew : null);
const currentSetting = ref<NationGeneralSettingKey>([true, 'normal']);
const displaySettings = ref(new Map<string, NationGeneralDisplaySetting>());
const columnState = ref<NationGeneralColumnState[]>([]);
const groupState = ref<Record<NationGeneralGroupId, boolean>>({
expDedLv: true,
stat: true,
goldRice: true,
specials: false,
years: false,
killturnAndRefresh: true,
});
const filters = ref<Partial<Record<NationGeneralColumnId, string>>>({});
const applyDisplaySetting = (settingKey: NationGeneralSettingKey, setting: NationGeneralDisplaySetting) => {
const cloned = cloneNationGeneralDisplaySetting(setting);
columnState.value = cloned.column;
groupState.value = Object.fromEntries(cloned.columnGroup.map((group) => [group.groupId, group.open])) as Record<
NationGeneralGroupId,
boolean
>;
currentSetting.value = settingKey;
viewMenuOpen.value = false;
};
const loadDisplaySettings = () => {
displaySettings.value = parseStoredDisplaySettings(localStorage.getItem(DISPLAY_SETTINGS_KEY));
const lastUsed = parseStoredSettingKey(localStorage.getItem(lastUsedSettingsKey('pageNationGeneral')));
if (lastUsed?.[0]) {
applyDisplaySetting(lastUsed, defaultNationGeneralDisplaySettings[lastUsed[1]]);
return;
}
if (lastUsed && !lastUsed[0]) {
const stored = displaySettings.value.get(lastUsed[1]);
if (stored) {
applyDisplaySetting(lastUsed, stored);
return;
}
}
applyDisplaySetting([true, 'normal'], defaultNationGeneralDisplaySettings.normal);
};
loadDisplaySettings();
watch(displaySettings, (settings) => localStorage.setItem(DISPLAY_SETTINGS_KEY, serializeDisplaySettings(settings)), {
deep: true,
});
watch(currentSetting, (setting) =>
localStorage.setItem(lastUsedSettingsKey('pageNationGeneral'), JSON.stringify(setting))
);
const load = async () => {
loading.value = true;
error.value = '';
@@ -34,36 +208,266 @@ const load = async () => {
loading.value = false;
}
};
const generals = computed(() =>
[...(data.value?.generals ?? [])]
.filter(
(general) =>
general.name.includes(nameFilter.value.trim()) &&
formatOfficerLevelText(general.officerLevel, data.value?.nation.level).includes(
officerFilter.value.trim()
)
)
.sort((a, b) => {
if (sort.value === 1) return a.npcState - b.npcState || b.officerLevel - a.officerLevel || a.id - b.id;
if (sort.value === 2) return b.dedicationLevel - a.dedicationLevel || a.id - b.id;
if (sort.value === 3) return b.experienceLevel - a.experienceLevel || a.id - b.id;
if (sort.value === 4) return b.stats.leadership - a.stats.leadership || a.id - b.id;
if (sort.value === 5) return b.stats.strength - a.stats.strength || a.id - b.id;
if (sort.value === 6) return b.stats.intelligence - a.stats.intelligence || a.id - b.id;
if (sort.value === 7) return b.gold - a.gold || a.id - b.id;
if (sort.value === 8) return b.rice - a.rice || a.id - b.id;
if (sort.value === 9) return (visibleCrew(b) ?? -1) - (visibleCrew(a) ?? -1) || a.id - b.id;
if (sort.value === 10) return b.refreshScoreTotal - a.refreshScoreTotal || a.id - b.id;
if (sort.value === 11) return (a.personality?.name ?? '').localeCompare(b.personality?.name ?? '');
if (sort.value === 12) return (a.specialDomestic?.name ?? '').localeCompare(b.specialDomestic?.name ?? '');
if (sort.value === 13) return (a.specialWar?.name ?? '').localeCompare(b.specialWar?.name ?? '');
if (sort.value === 14) return b.belong - a.belong || a.id - b.id;
if (sort.value === 15) return b.npcState - a.npcState || a.id - b.id;
return a.id - b.id;
})
const stateById = computed(() => new Map(columnState.value.map((column) => [column.colId, column])));
const isColumnVisible = (columnId: NationGeneralColumnId): boolean => !(stateById.value.get(columnId)?.hide ?? true);
const activeColumnIds = computed<NationGeneralColumnId[]>(() => {
const active: NationGeneralColumnId[] = [];
for (const item of layout) {
if (item.type === 'column') {
if (isColumnVisible(item.columnId)) active.push(item.columnId);
continue;
}
if (groupState.value[item.groupId]) {
active.push(...item.children.filter(isColumnVisible));
} else if (isColumnVisible(item.summaryId)) {
active.push(item.summaryId);
}
}
return active;
});
const activeColumns = computed(() =>
activeColumnIds.value.map((columnId) => columnById.get(columnId)).filter((column) => column !== undefined)
);
const special = (general: General) => `${general.specialDomestic?.name ?? '-'} / ${general.specialWar?.name ?? '-'}`;
const tableWidth = computed(() =>
Math.max(
1000,
activeColumns.value.reduce((sum, column) => sum + column.width, 0)
)
);
const headerSegments = computed<HeaderSegment[]>(() => {
const segments: HeaderSegment[] = [];
for (const item of layout) {
if (item.type === 'column') {
if (activeColumnIds.value.includes(item.columnId)) {
segments.push({ key: item.columnId, label: '', colspan: 1 });
}
continue;
}
const visibleIds = groupState.value[item.groupId]
? item.children.filter((columnId) => activeColumnIds.value.includes(columnId))
: activeColumnIds.value.includes(item.summaryId)
? [item.summaryId]
: [];
if (visibleIds.length) {
segments.push({
key: item.groupId,
label: item.label,
colspan: visibleIds.length,
groupId: item.groupId,
open: groupState.value[item.groupId],
});
}
}
return segments;
});
const visibleCrew = (general: General): number | null => ('crew' in general ? general.crew : null);
const officerText = (general: General): string => {
const title = formatOfficerLevelText(general.officerLevel, data.value?.nation.level);
return general.officerCityName && general.officerLevel >= 2 && general.officerLevel <= 4
? `${general.officerCityName}\n${title}`
: title;
};
const protectedText = (value: string | null): string => value ?? (data.value?.viewer.permission ? '-' : '?');
const cellValue = (general: General, columnId: NationGeneralColumnId): CellValue => {
switch (columnId) {
case 'name':
return general.name;
case 'officerLevel':
return officerText(general);
case 'expDedLv_1':
return `Lv ${general.experienceLevel}\n${general.dedicationText}`;
case 'dedlevel':
return `${general.dedicationText}\n(${general.bill.toLocaleString()})`;
case 'explevel':
return `Lv ${general.experienceLevel}\n(${general.personality?.name ?? '-'})`;
case 'stat_1':
return `${general.stats.leadership}|${general.stats.strength}|${general.stats.intelligence}`;
case 'leadership':
return general.stats.leadership;
case 'strength':
return general.stats.strength;
case 'intel':
return general.stats.intelligence;
case 'troop':
return protectedText(general.troopName);
case 'goldRice_1':
return `${general.gold.toLocaleString()}\n${general.rice.toLocaleString()}`;
case 'gold':
return general.gold;
case 'rice':
return general.rice;
case 'city':
return protectedText(general.cityName);
case 'crew':
return visibleCrew(general);
case 'specials_1':
return `${general.personality?.name ?? '-'}\n${general.specialDomestic?.name ?? '-'} / ${general.specialWar?.name ?? '-'}`;
case 'personal':
return general.personality?.name ?? '-';
case 'specialDomestic':
return general.specialDomestic?.name ?? '-';
case 'specialWar':
return general.specialWar?.name ?? '-';
case 'years_1':
return `${general.belong}`;
case 'belong':
return general.belong;
case 'killturnAndRefresh_1':
case 'refreshScoreTotal':
return Number(general.refreshScoreTotal);
case 'icon':
return null;
}
};
const filterValue = (general: General, columnId: NationGeneralColumnId): CellValue => {
switch (columnId) {
case 'officerLevel':
return officerText(general);
case 'dedlevel':
return general.dedicationLevel;
case 'explevel':
return general.experienceLevel;
default:
return cellValue(general, columnId);
}
};
const sortValue = (general: General, columnId: NationGeneralColumnId): CellValue => {
switch (columnId) {
case 'name':
return `${String(general.npcState).padStart(3, '0')}:${general.name}`;
case 'officerLevel':
return general.officerLevel;
case 'dedlevel':
return general.dedicationLevel;
case 'explevel':
return general.experienceLevel;
case 'goldRice_1':
return general.gold + general.rice;
default:
return cellValue(general, columnId);
}
};
const generals = computed(() => {
const filtered = [...(data.value?.generals ?? [])].filter((general) =>
Object.entries(filters.value).every(([rawColumnId, query]) => {
if (!query) return true;
const columnId = rawColumnId as NationGeneralColumnId;
const column = columnById.get(columnId);
const value = filterValue(general, columnId);
if (column?.searchable === 'number')
return matchesNumberSearch(typeof value === 'number' ? value : null, query);
return matchesKoreanSearch(value === null ? '' : String(value), query);
})
);
const sorts = columnState.value
.filter((column): column is NationGeneralColumnState & { sort: 'asc' | 'desc' } => column.sort !== null)
.sort((left, right) => (left.sortIndex ?? 0) - (right.sortIndex ?? 0));
return filtered.sort((left, right) => {
for (const sort of sorts) {
const compared = compareGridValues(sortValue(left, sort.colId), sortValue(right, sort.colId));
if (compared) return sort.sort === 'asc' ? compared : -compared;
}
return left.id - right.id;
});
});
const setDisplayMode = (mode: 'normal' | 'war') =>
applyDisplaySetting([true, mode], defaultNationGeneralDisplaySettings[mode]);
const currentDisplaySetting = (): NationGeneralDisplaySetting => ({
column: columnState.value.map((column) => ({ ...column })),
columnGroup: Object.entries(groupState.value).map(([groupId, open]) => ({
groupId: groupId as NationGeneralGroupId,
open,
})),
});
const storeDisplaySetting = () => {
const defaultName = currentSetting.value[0] ? '' : currentSetting.value[1];
const nickname = window.prompt('선택한 설정의 별명을 지어주세요', defaultName)?.trim();
if (!nickname) return;
if (displaySettings.value.has(nickname) && !window.confirm('이미 있는 이름입니다. 덮어쓸까요?')) return;
const next = new Map(displaySettings.value);
const setting = currentDisplaySetting();
next.set(nickname, setting);
displaySettings.value = next;
currentSetting.value = [false, nickname];
};
const deleteDisplaySetting = (key: string) => {
if (!window.confirm(`${key} 설정을 지울까요?`)) return;
const next = new Map(displaySettings.value);
next.delete(key);
displaySettings.value = next;
if (!currentSetting.value[0] && currentSetting.value[1] === key) setDisplayMode('normal');
};
const toggleGroup = (groupId: NationGeneralGroupId) => {
groupState.value = { ...groupState.value, [groupId]: !groupState.value[groupId] };
};
const toggleColumn = (columnId: NationGeneralColumnId) => {
columnState.value = columnState.value.map((column) =>
column.colId === columnId ? { ...column, hide: !column.hide } : column
);
};
const nextSort = (columnId: NationGeneralColumnId, current: 'asc' | 'desc' | null): 'asc' | 'desc' | null => {
const order: ('asc' | 'desc' | null)[] = columnId === 'name' ? ['asc', 'desc', null] : ['desc', 'asc', null];
const index = order.indexOf(current);
return order[(index + 1) % order.length] ?? null;
};
const sortColumn = (columnId: NationGeneralColumnId, event: MouseEvent) => {
const definition = columnById.get(columnId);
if (!definition?.sortable) return;
const current = stateById.value.get(columnId)?.sort ?? null;
const next = nextSort(columnId, current);
const existingSortIndex = stateById.value.get(columnId)?.sortIndex;
const maxSortIndex = Math.max(-1, ...columnState.value.map((column) => column.sortIndex ?? -1));
columnState.value = columnState.value.map((column) => {
if (column.colId === columnId) {
const { sortIndex: _sortIndex, ...withoutSortIndex } = column;
return next
? { ...withoutSortIndex, sort: next, sortIndex: existingSortIndex ?? maxSortIndex + 1 }
: { ...withoutSortIndex, sort: null };
}
if (event.shiftKey) return column;
const { sortIndex: _sortIndex, ...withoutSortIndex } = column;
return { ...withoutSortIndex, sort: null };
});
};
const sortIndicator = (columnId: NationGeneralColumnId): string => {
const column = stateById.value.get(columnId);
if (!column?.sort) return '';
const order = column.sortIndex === undefined ? '' : `${column.sortIndex + 1}`;
return `${column.sort === 'asc' ? '▲' : '▼'}${order}`;
};
const iconUrl = (general: General) => resolveGeneralIconUrl(general);
const cellTitle = (general: General, columnId: NationGeneralColumnId): string => {
if (columnId === 'personal') return general.personality?.info ?? '';
if (columnId === 'specialDomestic') return general.specialDomestic?.info ?? '';
if (columnId === 'specialWar') return general.specialWar?.info ?? '';
if (columnId === 'specials_1') {
return [general.personality?.info, general.specialDomestic?.info, general.specialWar?.info]
.filter(Boolean)
.join('\n');
}
return '';
};
onMounted(load);
</script>
@@ -80,40 +484,68 @@ onMounted(load);
<button
class="top-button mode-button"
:aria-expanded="viewMenuOpen"
@click="viewMenuOpen = !viewMenuOpen"
@click="
viewMenuOpen = !viewMenuOpen;
columnMenuOpen = false;
"
>
보기 모드
</button>
<span v-if="viewMenuOpen" class="dropdown-menu">
<button
@click="
sort = 1;
viewMenuOpen = false;
"
>
기본
</button>
<button
@click="
sort = 4;
viewMenuOpen = false;
"
>
전투
</button>
<span v-if="viewMenuOpen" class="dropdown-menu view-mode-list">
<button @click="setDisplayMode('normal')">기본</button>
<button @click="setDisplayMode('war')">전투</button>
<span class="menu-divider"></span>
<button @click="storeDisplaySetting">🔖&nbsp;보관하기</button>
<template v-if="displaySettings.size">
<span class="menu-divider"></span>
<span v-for="[key, setting] in displaySettings" :key="key" class="saved-setting">
<button class="saved-setting-name" @click="applyDisplaySetting([false, key], setting)">
{{ key }}
</button>
<button
class="saved-setting-delete"
:aria-label="`${key} 설정 삭제`"
@click.stop="deleteDisplaySetting(key)"
>
삭제
</button>
</span>
</template>
</span>
</span>
<span class="dropdown">
<button class="top-button columns-button" @click="columnMenuOpen = !columnMenuOpen">
<button
class="top-button columns-button"
:aria-expanded="columnMenuOpen"
@click="
columnMenuOpen = !columnMenuOpen;
viewMenuOpen = false;
"
>
선택
</button>
<span v-if="columnMenuOpen" class="dropdown-menu column-menu">
<label
v-for="label in ['아이콘', '장수명', '관직', '명성/계급', '능력치', '자금', '특성']"
:key="label"
>
<input type="checkbox" checked /> {{ label }}
</label>
<template v-for="item in layout" :key="item.type === 'column' ? item.columnId : item.groupId">
<label v-if="item.type === 'column' && item.columnId !== 'name'">
<input
type="checkbox"
:checked="isColumnVisible(item.columnId)"
@change="toggleColumn(item.columnId)"
/>
{{ columnById.get(item.columnId)?.label }}
</label>
<template v-else-if="item.type === 'group'">
<span class="column-group-label">{{ item.label }}</span>
<label v-for="columnId in item.children" :key="columnId" class="child-column">
<input
type="checkbox"
:checked="isColumnVisible(columnId)"
@change="toggleColumn(columnId)"
/>
{{ columnById.get(columnId)?.label }}
</label>
</template>
</template>
</span>
</span>
</span>
@@ -121,100 +553,99 @@ onMounted(load);
<p v-if="error" class="state error" role="alert">{{ error }}</p>
<p v-else-if="loading" class="state">불러오는 중...</p>
<div v-else class="grid-shell">
<table id="nation-general-list">
<table id="nation-general-list" :style="{ width: `${tableWidth}px`, minWidth: `${tableWidth}px` }">
<colgroup>
<col
v-for="(width, index) in [80, 126, 70, 70, 60, 60, 60, 60, 70, 70, 80, 100, 94]"
:key="index"
:style="{ width: `${width}px` }"
/>
<col v-for="column in activeColumns" :key="column.id" :style="{ width: `${column.width}px` }" />
</colgroup>
<thead>
<tr class="group-head">
<th colspan="2"></th>
<th></th>
<th>명성/계급&#x3000;</th>
<th colspan="3">능력치&#x3000;</th>
<th colspan="2">자금&#x3000;</th>
<th colspan="2">특성&#x3000;</th>
<th>연도&#x3000;</th>
<th>기타&#x3000;</th>
<th v-for="segment in headerSegments" :key="segment.key" :colspan="segment.colspan">
<button
v-if="segment.groupId"
class="group-toggle"
:aria-expanded="segment.open"
:aria-label="`${segment.label} ${segment.open ? '접기' : '펼치기'}`"
@click="toggleGroup(segment.groupId)"
>
{{ segment.label }}&#x3000;{{ segment.open ? '' : '' }}
</button>
</th>
</tr>
<tr>
<th>아이콘</th>
<th>장수명</th>
<th>관직</th>
<th>계급</th>
<th>명성</th>
<th>통솔</th>
<th>무력</th>
<th>지력</th>
<th></th>
<th v-if="!isNarrow"></th>
<th v-if="!isNarrow">요약</th>
<th v-if="!isNarrow">요약</th>
<th v-if="!isNarrow">벌점 </th>
<th v-for="column in activeColumns" :key="column.id">
<button
class="sort-button"
:class="{ sortable: column.sortable }"
:disabled="!column.sortable"
:aria-label="column.sortable ? `${column.label} 정렬` : undefined"
@click="sortColumn(column.id, $event)"
>
{{ column.label }}
<span class="sort-indicator">{{ sortIndicator(column.id) }}</span>
</button>
</th>
</tr>
<tr class="filter-head">
<th></th>
<th><input v-model="nameFilter" aria-label="장수명 필터" /><span></span></th>
<th><input v-model="officerFilter" aria-label="관직 필터" /><span></span></th>
<th><input aria-label="계급 필터" /><span></span></th>
<th><input aria-label="명성 필터" /><span></span></th>
<th><input aria-label="통솔 필터" /><span></span></th>
<th><input aria-label="무력 필터" /><span></span></th>
<th><input aria-label="지력 필터" /><span></span></th>
<th><input aria-label=" 필터" /><span></span></th>
<th v-if="!isNarrow"><input aria-label="쌀 필터" /><span></span></th>
<th v-if="!isNarrow"></th>
<th v-if="!isNarrow"></th>
<th v-if="!isNarrow"><input aria-label="벌점 필터" /><span></span></th>
<th v-for="column in activeColumns" :key="column.id">
<template v-if="column.searchable">
<input
v-model="filters[column.id]"
type="search"
:inputmode="column.searchable === 'number' ? 'decimal' : 'search'"
:aria-label="`${column.label} 필터`"
:placeholder="column.searchable === 'number' ? '=, >, <' : ''"
/>
<span></span>
</template>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(general, index) in generals" :key="general.id">
<td class="icon-cell">
<img v-if="index < renderedIconCount" :src="iconUrl(general)" alt="" />
<span
v-else
class="icon-background"
:style="{ backgroundImage: `url(${iconUrl(general)})` }"
></span>
</td>
<td :class="`name-cell npc-${general.npcState}`">{{ general.name }}</td>
<td>{{ formatOfficerLevelText(general.officerLevel, data?.nation.level) }}</td>
<td>{{ general.dedicationText }}<br />({{ general.bill.toLocaleString() }})</td>
<td>Lv {{ general.experienceLevel }}<br />({{ general.personality?.name ?? '-' }})</td>
<td>{{ general.stats.leadership }}</td>
<td>{{ general.stats.strength }}</td>
<td>{{ general.stats.intelligence }}</td>
<td>{{ general.gold.toLocaleString() }} </td>
<td v-if="!isNarrow">{{ general.rice.toLocaleString() }} </td>
<td v-if="!isNarrow" :title="general.personality?.info ?? ''">
{{ general.personality?.name ?? '-' }}<br />{{ general.specialDomestic?.name ?? '-' }}
</td>
<tr v-for="(general, index) in generals" :key="general.id" :data-general-id="general.id">
<td
v-if="!isNarrow"
:title="
[general.specialDomestic?.info, general.specialWar?.info].filter(Boolean).join('\n')
"
v-for="column in activeColumns"
:key="column.id"
:class="{
'icon-cell': column.id === 'icon',
'name-cell': column.id === 'name',
[`npc-${general.npcState}`]: column.id === 'name',
'numeric-cell':
column.searchable === 'number' ||
['goldRice_1', 'killturnAndRefresh_1'].includes(column.id),
}"
:title="cellTitle(general, column.id)"
>
{{ special(general) }}
</td>
<td v-if="!isNarrow">
{{ general.refreshScoreTotal }}<br />({{ general.belong ? '자주' : '안함' }})
<template v-if="column.id === 'icon'">
<img v-if="index < 16" :src="iconUrl(general)" alt="" />
<span
v-else
class="icon-background"
:style="{ backgroundImage: `url(${iconUrl(general)})` }"
></span>
</template>
<template v-else-if="column.id === 'gold'">{{ general.gold.toLocaleString() }} </template>
<template v-else-if="column.id === 'rice'">{{ general.rice.toLocaleString() }} </template>
<template v-else-if="column.id === 'crew'">
{{ visibleCrew(general)?.toLocaleString() ?? '?'
}}<span v-if="visibleCrew(general) !== null"></span>
</template>
<template v-else-if="column.id === 'belong'">{{ general.belong }}</template>
<template
v-else-if="column.id === 'refreshScoreTotal' || column.id === 'killturnAndRefresh_1'"
>
{{ general.refreshScoreTotal.toLocaleString() }}
</template>
<template v-else>{{ cellValue(general, column.id) }}</template>
</td>
</tr>
<tr v-if="!generals.length" class="empty-row">
<td :colspan="activeColumns.length">검색 결과가 없습니다.</td>
</tr>
</tbody>
</table>
<div class="ag-compat-controls" aria-hidden="true">
<button
v-for="index in compatButtonCount"
:key="`button-${index}`"
type="button"
tabindex="-1"
></button>
<input v-for="index in compatInputCount" :key="`input-${index}`" tabindex="-1" />
<button v-for="index in 55" :key="`button-${index}`" type="button" tabindex="-1"></button>
<input v-for="index in 42" :key="`input-${index}`" tabindex="-1" />
</div>
</div>
</main>
@@ -222,9 +653,8 @@ onMounted(load);
<style scoped>
.general-page {
width: 100%;
min-width: 500px;
max-width: 1000px;
width: 1000px;
min-width: 1000px;
height: 100vh;
margin: 0 auto;
font: 14px/21px var(--sammo-font-sans);
@@ -242,7 +672,6 @@ onMounted(load);
justify-content: center;
background-color: transparent;
background-image: var(--sammo-texture-walnut);
/* Ref's `.back_bar` has no bottom rule; the grid below draws its own. */
font-size: 14px;
}
.top-bar strong {
@@ -262,20 +691,19 @@ onMounted(load);
.right-actions {
right: 0;
}
/* Ref Lumen primary: the bottom edge carries the pressed-state movement. */
.top-button {
display: inline-flex;
width: 89px;
height: 32px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-right: 1px solid #151515;
border-radius: 3px;
color: #fff;
width: 89px;
justify-content: center;
padding: 0;
font-weight: 700;
font-size: 14px;
font-weight: 700;
text-decoration: none;
cursor: pointer;
}
@@ -289,14 +717,11 @@ onMounted(load);
background: #375a7f;
border-bottom: 0 solid #325172;
}
/* Ref Lumen primary: a 3px bottom edge appears on hover and stays while open. */
.mode-button:hover,
.mode-button[aria-expanded='true'],
.mode-button:active {
border-bottom-width: 3px;
}
.mode-button,
.columns-button {
width: 90px;
@@ -312,16 +737,22 @@ onMounted(load);
}
.dropdown-menu {
position: absolute;
z-index: 5;
z-index: 20;
top: 32px;
right: 0;
width: 150px;
width: 170px;
max-height: calc(100vh - 40px);
padding: 4px;
background: #252a2c;
overflow-y: auto;
border: 1px solid #596164;
background: #252a2c;
}
.view-mode-list {
width: 180px;
}
.dropdown-menu button,
.dropdown-menu label {
.dropdown-menu label,
.column-group-label {
display: block;
width: 100%;
padding: 5px;
@@ -330,6 +761,30 @@ onMounted(load);
background: transparent;
text-align: left;
}
.dropdown-menu button:not(.saved-setting-delete):hover,
.dropdown-menu label:hover {
background: #3a4144;
}
.menu-divider {
display: block;
height: 1px;
margin: 4px 0;
background: #596164;
}
.saved-setting {
display: grid;
grid-template-columns: 1fr 48px;
}
.saved-setting-delete {
padding: 2px !important;
text-align: center !important;
}
.column-group-label {
color: #9ca6aa;
}
.child-column {
padding-left: 17px !important;
}
.grid-shell {
width: 100%;
height: calc(100vh - 32px);
@@ -340,23 +795,21 @@ onMounted(load);
cursor: default;
}
table {
width: 1000px;
min-width: 1000px;
border-collapse: collapse;
border-collapse: separate;
table-layout: fixed;
background: #293033;
color: #f5f5f5;
font-size: 14px;
line-height: normal;
color: #f5f5f5;
cursor: default;
}
th,
td {
padding: 0 4px;
overflow: hidden;
border-right: 1px solid #40484b;
border-bottom: 1px solid #4a5255;
padding: 0 4px;
text-align: center;
overflow: hidden;
}
th {
height: 32px;
@@ -369,6 +822,36 @@ th {
height: 32px;
border-bottom-color: #303537;
}
.group-toggle,
.sort-button {
width: 100%;
height: 100%;
padding: 0;
border: 0;
color: inherit;
background: transparent;
font: inherit;
}
.group-toggle,
.sort-button.sortable {
cursor: pointer;
}
.group-toggle:hover,
.sort-button.sortable:hover,
.group-toggle:focus-visible,
.sort-button.sortable:focus-visible {
color: #fff;
background: #303638;
outline: 1px solid #8aa4b2;
outline-offset: -2px;
}
.sort-button:disabled {
opacity: 1;
}
.sort-indicator {
color: #8dd4ff;
font-size: 10px;
}
.filter-head th {
height: 32px;
padding: 3px 4px;
@@ -380,6 +863,14 @@ th {
background: #252a2c;
color: #fff;
}
.filter-head input:focus-visible {
border-color: #8dd4ff;
outline: 1px solid #8dd4ff;
}
.filter-head input::placeholder {
color: #8f999d;
font-size: 10px;
}
.filter-head span {
margin-left: 4px;
color: #a5b5bf;
@@ -392,7 +883,7 @@ tbody tr:hover {
background: #343c3f;
}
td {
white-space: nowrap;
white-space: pre-line;
}
.icon-cell {
padding: 0 4px;
@@ -416,21 +907,16 @@ td {
display: none;
}
.name-cell {
text-align: left;
color: skyblue;
text-align: left;
}
th:nth-child(9),
td:nth-child(9),
th:nth-child(10),
td:nth-child(10) {
.numeric-cell {
text-align: right;
}
.state {
margin: 40px;
}
.npc-0 {
color: skyblue;
}
.npc-0,
.npc-1 {
color: skyblue;
}
@@ -443,6 +929,10 @@ td:nth-child(10) {
.error {
color: #ff7373;
}
.empty-row td {
height: 68px;
text-align: center;
}
@media (max-width: 1000px) {
.general-page {
margin: 0;
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
DISPLAY_SETTINGS_VERSION,
compareGridValues,
defaultNationGeneralDisplaySettings,
matchesKoreanSearch,
matchesNumberSearch,
parseStoredDisplaySettings,
parseStoredSettingKey,
serializeDisplaySettings,
} from '../src/utils/nationGeneralGrid.ts';
void describe('nation general Ref-compatible grid state', () => {
void it('keeps the Ref default group and sort state', () => {
const normal = defaultNationGeneralDisplaySettings.normal;
assert.equal(normal.columnGroup.find((entry) => entry.groupId === 'stat')?.open, true);
assert.equal(normal.columnGroup.find((entry) => entry.groupId === 'specials')?.open, false);
assert.deepEqual(
normal.column.filter((entry) => entry.sort).map((entry) => [entry.colId, entry.sort, entry.sortIndex]),
[['refreshScoreTotal', 'desc', 0]]
);
const war = defaultNationGeneralDisplaySettings.war;
assert.equal(war.columnGroup.find((entry) => entry.groupId === 'stat')?.open, false);
assert.equal(war.column.find((entry) => entry.colId === 'stat_1')?.hide, false);
assert.equal(war.column.find((entry) => entry.colId === 'leadership')?.hide, false);
});
void it('round-trips named settings and rejects invalid versions', () => {
const settings = new Map([['전투 보기', defaultNationGeneralDisplaySettings.war]]);
const restored = parseStoredDisplaySettings(serializeDisplaySettings(settings));
assert.equal(restored.get('전투 보기')?.column.find((entry) => entry.colId === 'icon')?.hide, true);
assert.deepEqual(
parseStoredDisplaySettings(JSON.stringify({ version: DISPLAY_SETTINGS_VERSION + 1, settings: [] })),
new Map()
);
assert.deepEqual(parseStoredDisplaySettings('{broken'), new Map());
});
void it('accepts only valid last-used setting tuples', () => {
assert.deepEqual(parseStoredSettingKey('[true,"normal"]'), [true, 'normal']);
assert.deepEqual(parseStoredSettingKey('[false,"내 설정"]'), [false, '내 설정']);
assert.equal(parseStoredSettingKey('[true,"missing"]'), null);
});
void it('matches Korean names by text and initial consonants', () => {
assert.equal(matchesKoreanSearch('테스트장수', '테스트'), true);
assert.equal(matchesKoreanSearch('테스트장수', 'ㅌㅅㅌㅈㅅ'), true);
assert.equal(matchesKoreanSearch('테스트장수', 'ㄱㄴ'), false);
});
void it('uses Ref-like numeric comparisons and stable Korean text ordering', () => {
assert.equal(matchesNumberSearch(90, '90'), true);
assert.equal(matchesNumberSearch(90, '>= 80'), true);
assert.equal(matchesNumberSearch(90, '< 80'), false);
assert.equal(compareGridValues(10, 2) > 0, true);
assert.equal(compareGridValues(null, 2) > 0, true);
assert.equal(compareGridValues('가', '나') < 0, true);
});
});
@@ -0,0 +1,140 @@
import { chromium } from '@playwright/test';
import { createHash } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
const baseUrl = process.env.REF_GENERAL_URL ?? 'http://127.0.0.1:3416/sam/';
const username = process.env.REF_GENERAL_USER ?? 's100user01';
const passwordFile = process.env.REF_GENERAL_PASSWORD_FILE;
const artifactRoot = resolve(process.env.REF_GENERAL_ARTIFACT_DIR ?? 'test-results/reference-nation-general-controls');
if (!passwordFile) throw new Error('REF_GENERAL_PASSWORD_FILE is required.');
const password = (await readFile(passwordFile, 'utf8')).trim();
await mkdir(artifactRoot, { recursive: true });
const measure = async (page) =>
page.evaluate(() => {
const describe = (element) => {
if (!(element instanceof HTMLElement)) return null;
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
style: {
fontFamily: style.fontFamily,
fontSize: style.fontSize,
lineHeight: style.lineHeight,
color: style.color,
backgroundColor: style.backgroundColor,
borderCollapse: style.borderCollapse,
},
};
};
const headerGroups = [...document.querySelectorAll('.ag-header-group-cell')].map((element) => ({
text: element.textContent?.trim() ?? '',
className: element.className,
expanded: element.getAttribute('aria-expanded'),
rect: describe(element)?.rect,
}));
const columns = [...document.querySelectorAll('.ag-header-cell')].map((element) => ({
id: element.getAttribute('col-id'),
text: element.textContent?.trim() ?? '',
sort: element.getAttribute('aria-sort'),
}));
const inputs = [...document.querySelectorAll('.ag-header-row-column-filter input.ag-text-field-input')].map(
(element) => {
const rect = element.getBoundingClientRect();
const center = rect.x + rect.width / 2;
const matchingHeader = [...document.querySelectorAll('.ag-header-row-column .ag-header-cell')].find(
(candidate) => {
const headerRect = candidate.getBoundingClientRect();
return center >= headerRect.x && center <= headerRect.right;
}
);
return {
colId: matchingHeader?.getAttribute('col-id') ?? null,
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
placeholder: element.getAttribute('placeholder'),
};
}
);
return {
url: location.href,
documentWidth: document.documentElement.scrollWidth,
body: describe(document.body),
page: describe(document.querySelector('.pageNationGeneral')),
component: describe(document.querySelector('.component-general-list')),
grid: describe(document.querySelector('.ag-root-wrapper')),
firstRow: describe(document.querySelector('.ag-row')),
headerGroups,
columns,
inputs,
toolbarText: document.querySelector('.component-general-list')?.textContent?.slice(0, 500) ?? '',
};
});
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext({
viewport: { width: 1200, height: 900 },
deviceScaleFactor: 1,
locale: 'ko-KR',
colorScheme: 'dark',
});
const page = await context.newPage();
await page.goto(baseUrl, { waitUntil: 'networkidle' });
const salt = await page.locator('#global_salt').inputValue();
const passwordHash = createHash('sha512')
.update(salt + password + salt)
.digest('hex');
const login = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
data: { username, password: passwordHash },
});
const loginResult = await login.json();
if (!login.ok() || loginResult.result !== true) throw new Error('Reference login failed.');
await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'domcontentloaded' });
await page.goto(new URL('hwe/v_nationGeneral.php', baseUrl).toString(), { waitUntil: 'domcontentloaded' });
await page.locator('.ag-root-wrapper').waitFor();
await page.locator('.ag-center-cols-container .ag-row').first().waitFor();
await page.evaluate(() => document.fonts.ready);
const output = { initial: await measure(page) };
await page.screenshot({ path: resolve(artifactRoot, 'ref-initial.png'), fullPage: true });
const statGroup = page.locator('.ag-header-group-cell').filter({ hasText: '능력치' });
await statGroup.locator('.ag-header-expand-icon:visible').click();
output.collapsed = await measure(page);
await page.screenshot({ path: resolve(artifactRoot, 'ref-stat-collapsed.png'), fullPage: true });
const leadershipHeader = page.locator('.ag-header-cell[col-id="leadership"] .ag-header-cell-label');
await statGroup.locator('.ag-header-expand-icon:visible').click();
await leadershipHeader.click();
output.sort = {
ariaSort: await page.locator('.ag-header-cell[col-id="leadership"]').getAttribute('aria-sort'),
firstValue: await page.locator('.ag-center-cols-container .ag-row [col-id="leadership"]').first().innerText(),
};
await statGroup.locator('.ag-header-expand-icon:visible').click();
await page.getByRole('button', { name: /보기 모드/ }).click();
page.once('dialog', (dialog) => dialog.accept('Ref 캡처'));
await page.getByText('보관하기', { exact: true }).click();
output.savedSetting = await page.evaluate(() => ({
settings: localStorage.getItem('GeneralListDisplaySetting'),
last: localStorage.getItem('LastUsedSettingsKey_pageNationGeneral'),
}));
await page.reload({ waitUntil: 'domcontentloaded' });
await page.locator('.ag-root-wrapper').waitFor();
await page.locator('.ag-center-cols-container .ag-row').first().waitFor();
output.reloaded = await measure(page);
await page.getByRole('button', { name: /보기 모드/ }).click();
page.once('dialog', (dialog) => dialog.accept());
await page.getByRole('button', { name: '삭제', exact: true }).click();
output.deletedSetting = await page.evaluate(() => localStorage.getItem('GeneralListDisplaySetting'));
await writeFile(resolve(artifactRoot, 'computed-dom.json'), `${JSON.stringify(output, null, 2)}\n`);
process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot, output })}\n`);
} finally {
await browser.close();
}