merge: 최신 main을 지도 계절 전환에 반영한다

# Conflicts:
#	app/game-frontend/src/components/main/MapViewer.vue
This commit is contained in:
2026-08-21 16:42:44 +00:00
21 changed files with 1075 additions and 248 deletions
+2
View File
@@ -265,6 +265,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
dedication: true, dedication: true,
age: true, age: true,
turnTime: true, turnTime: true,
recentWarTime: true,
crewTypeId: true, crewTypeId: true,
personalCode: true, personalCode: true,
specialCode: true, specialCode: true,
@@ -519,6 +520,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
age: general.age, age: general.age,
retirementYear, retirementYear,
turnTime: general.turnTime.toISOString(), turnTime: general.turnTime.toISOString(),
recentWar: general.recentWarTime?.toISOString() ?? null,
defenceTrain: settings.defence_train, defenceTrain: settings.defence_train,
killTurn: readNumber(metaRecord.killturn ?? metaRecord.killTurn, 0), killTurn: readNumber(metaRecord.killturn ?? metaRecord.killTurn, 0),
remainingMinutes: resolveRemainingMinutes( remainingMinutes: resolveRemainingMinutes(
@@ -1,6 +1,6 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { asRecord } from '@sammo-ts/common'; import { asRecord, type RankDataType } from '@sammo-ts/common';
import { LogCategory } from '@sammo-ts/logic'; import { LogCategory } from '@sammo-ts/logic';
import { accessAuthedProcedure } from '../../../trpc.js'; import { accessAuthedProcedure } from '../../../trpc.js';
@@ -14,6 +14,15 @@ import {
import { getMyGeneral } from '../../shared/general.js'; import { getMyGeneral } from '../../shared/general.js';
import { assertNationAccess, formatDateTime, loadTraitNames, resolveNationPermission } from '../shared.js'; import { assertNationAccess, formatDateTime, loadTraitNames, resolveNationPermission } from '../shared.js';
const BATTLE_CENTER_RECORD_TYPES = [
'firenum',
'warnum',
'killnum',
'deathnum',
'killcrew',
'deathcrew',
] as const satisfies readonly RankDataType[];
export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => { export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx); const me = await getMyGeneral(ctx);
assertNationAccess(me); assertNationAccess(me);
@@ -81,23 +90,38 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
} }
const generalIds = generalRows.map((general) => general.id); const generalIds = generalRows.map((general) => general.id);
const battleCounts = const [battleCounts, rankRows] =
generalIds.length > 0 generalIds.length > 0
? await ctx.db.logEntry.groupBy({ ? await Promise.all([
ctx.db.logEntry.groupBy({
by: ['generalId'], by: ['generalId'],
where: { where: {
generalId: { in: generalIds }, generalId: { in: generalIds },
category: LogCategory.BATTLE_BRIEF, category: LogCategory.BATTLE_BRIEF,
}, },
_count: { _all: true }, _count: { _all: true },
}) }),
: []; ctx.db.rankData.findMany({
where: {
generalId: { in: generalIds },
type: { in: [...BATTLE_CENTER_RECORD_TYPES] },
},
select: { generalId: true, type: true, value: true },
}),
])
: [[], []];
const battleCountMap = new Map<number, number>(); const battleCountMap = new Map<number, number>();
for (const row of battleCounts) { for (const row of battleCounts) {
if (row.generalId !== null) { if (row.generalId !== null) {
battleCountMap.set(row.generalId, row._count._all); battleCountMap.set(row.generalId, row._count._all);
} }
} }
const rankValueMap = new Map<number, Map<RankDataType, number>>();
for (const row of rankRows) {
const values = rankValueMap.get(row.generalId) ?? new Map<RankDataType, number>();
values.set(row.type as (typeof BATTLE_CENTER_RECORD_TYPES)[number], row.value);
rankValueMap.set(row.generalId, values);
}
const worldConfig = asRecord(worldState.config); const worldConfig = asRecord(worldState.config);
const constValues = asRecord(worldConfig.const ?? worldConfig.consts); const constValues = asRecord(worldConfig.const ?? worldConfig.consts);
@@ -141,10 +165,18 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
general.meta && typeof general.meta === 'object' && !Array.isArray(general.meta) general.meta && typeof general.meta === 'object' && !Array.isArray(general.meta)
? (general.meta as Record<string, unknown>) ? (general.meta as Record<string, unknown>)
: {}; : {};
const metaNumber = (key: string): number => { const metaNumber = (keys: string | string[], fallback = 0): number => {
for (const key of Array.isArray(keys) ? keys : [keys]) {
const value = meta[key]; const value = meta[key];
return typeof value === 'number' && Number.isFinite(value) ? value : 0; if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
}
return fallback;
}; };
const rankValue = (type: (typeof BATTLE_CENTER_RECORD_TYPES)[number], fallback = 0): number =>
rankValueMap.get(general.id)?.get(type) ?? fallback;
const warnum = rankValue('warnum', metaNumber(['rank_warnum', 'warnum'], battleCountMap.get(general.id) ?? 0));
const storedDedicationLevel = metaNumber('dedlevel'); const storedDedicationLevel = metaNumber('dedlevel');
const dedicationLevel = const dedicationLevel =
storedDedicationLevel > 0 storedDedicationLevel > 0
@@ -161,7 +193,7 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
cityId: general.cityId, cityId: general.cityId,
turnTime: formatDateTime(general.turnTime), turnTime: formatDateTime(general.turnTime),
recentWar: formatDateTime(general.recentWarTime), recentWar: formatDateTime(general.recentWarTime),
warnum: battleCountMap.get(general.id) ?? 0, warnum,
stats: { stats: {
leadership: general.leadership, leadership: general.leadership,
strength: general.strength, strength: general.strength,
@@ -176,6 +208,8 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
train: general.train, train: general.train,
atmos: general.atmos, atmos: general.atmos,
age: general.age, age: general.age,
defenceTrain: metaNumber('defence_train', 80),
killTurn: metaNumber(['killturn', 'killTurn']),
crewTypeId: general.crewTypeId, crewTypeId: general.crewTypeId,
crewTypeName: crewTypeNames.get(general.crewTypeId) ?? '-', crewTypeName: crewTypeNames.get(general.crewTypeId) ?? '-',
equipment: { equipment: {
@@ -207,12 +241,13 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
statUpgradeLimit, statUpgradeLimit,
dex: [1, 2, 3, 4, 5].map((index) => metaNumber(`dex${index}`)), dex: [1, 2, 3, 4, 5].map((index) => metaNumber(`dex${index}`)),
}, },
serviceYears: metaNumber('belong'),
battleStats: { battleStats: {
kills: metaNumber('rank_killnum') || metaNumber('killnum'), kills: rankValue('killnum', metaNumber(['rank_killnum', 'killnum'])),
deaths: metaNumber('deathnum'), deaths: rankValue('deathnum', metaNumber(['rank_deathnum', 'deathnum'])),
fire: metaNumber('firenum'), fire: rankValue('firenum', metaNumber(['rank_firenum', 'firenum'])),
killCrew: metaNumber('killcrew'), killCrew: rankValue('killcrew', metaNumber(['rank_killcrew', 'killcrew'])),
deathCrew: metaNumber('deathcrew'), deathCrew: rankValue('deathcrew', metaNumber(['rank_deathcrew', 'deathcrew'])),
dex: [1, 2, 3, 4, 5].map((index) => metaNumber(`dex${index}`)), dex: [1, 2, 3, 4, 5].map((index) => metaNumber(`dex${index}`)),
}, },
}; };
@@ -85,7 +85,7 @@ const createContext = (options: {
troopLeaderAction?: string | null; troopLeaderAction?: string | null;
refreshScore?: number; refreshScore?: number;
refreshScoreTotal?: number; refreshScoreTotal?: number;
rankRows?: Array<{ type: string; value: number }>; rankRows?: Array<{ generalId?: number; type: string; value: number }>;
requestId?: string; requestId?: string;
transaction?: ReturnType<typeof vi.fn>; transaction?: ReturnType<typeof vi.fn>;
}) => { }) => {
@@ -130,7 +130,9 @@ const createContext = (options: {
})), })),
}, },
rankData: { rankData: {
findMany: vi.fn(async () => options.rankRows ?? []), findMany: vi.fn(async () =>
(options.rankRows ?? []).map((row) => ({ generalId: row.generalId ?? me?.id ?? 0, ...row }))
),
}, },
city: { city: {
findUnique: vi.fn(async () => options.city ?? null), findUnique: vi.fn(async () => options.city ?? null),
@@ -812,7 +814,23 @@ describe('battle-center general and user permissions', () => {
}); });
const tenured = createContext({ const tenured = createContext({
me: buildGeneral({ officerLevel: 1, meta: { belong: 3, permission: 'normal' } }), me: buildGeneral({
officerLevel: 1,
meta: {
belong: 3,
permission: 'normal',
killturn: 6,
defence_train: 80,
},
}),
rankRows: [
{ type: 'warnum', value: 8 },
{ type: 'killnum', value: 5 },
{ type: 'deathnum', value: 3 },
{ type: 'firenum', value: 12 },
{ type: 'killcrew', value: 12_345 },
{ type: 'deathcrew', value: 6_789 },
],
nationMeta: { secretlimit: 3 }, nationMeta: { secretlimit: 3 },
}); });
await expect(appRouter.createCaller(tenured.context).nation.getBattleCenter()).resolves.toMatchObject({ await expect(appRouter.createCaller(tenured.context).nation.getBattleCenter()).resolves.toMatchObject({
@@ -823,6 +841,9 @@ describe('battle-center general and user permissions', () => {
picture: 'default.jpg', picture: 'default.jpg',
imageServer: 0, imageServer: 0,
officerLevelText: '일반', officerLevelText: '일반',
warnum: 8,
defenceTrain: 80,
killTurn: 6,
crewTypeName: '-', crewTypeName: '-',
equipmentNames: { weapon: '-', book: '-', horse: '-', item: '-' }, equipmentNames: { weapon: '-', book: '-', horse: '-', item: '-' },
traits: { personal: '-', specialDomestic: '-', specialWar: '-' }, traits: { personal: '-', specialDomestic: '-', specialWar: '-' },
@@ -834,10 +855,25 @@ describe('battle-center general and user permissions', () => {
statUpgradeLimit: 20, statUpgradeLimit: 20,
dex: [0, 0, 0, 0, 0], dex: [0, 0, 0, 0, 0],
}, },
battleStats: { kills: 0, deaths: 0, fire: 0, killCrew: 0, deathCrew: 0, dex: [0, 0, 0, 0, 0] }, serviceYears: 3,
battleStats: {
kills: 5,
deaths: 3,
fire: 12,
killCrew: 12_345,
deathCrew: 6_789,
dex: [0, 0, 0, 0, 0],
},
}, },
], ],
}); });
expect(tenured.db.rankData.findMany).toHaveBeenCalledWith({
where: {
generalId: { in: [7] },
type: { in: ['firenum', 'warnum', 'killnum', 'deathnum', 'killcrew', 'deathcrew'] },
},
select: { generalId: true, type: true, value: true },
});
const auditor = createContext({ const auditor = createContext({
me: buildGeneral({ officerLevel: 1, meta: { belong: 0, permission: 'auditor' } }), me: buildGeneral({ officerLevel: 1, meta: { belong: 0, permission: 'auditor' } }),
+113 -9
View File
@@ -431,13 +431,20 @@ test('nation and general directories preserve the fixed legacy Chromium geometry
} }
}); });
test('general directory submits the legacy sort selector and keeps wounded/bonus rendering', async ({ page }) => { test('general directory sorts the loaded rows locally in a stable three-state cycle', async ({ page }) => {
await install(page); const requestedOperations: string[] = [];
await install(page, 'general', [], requestedOperations);
await page.goto('general-list'); await page.goto('general-list');
await expect(page.locator('tbody tr[data-general-id]')).toHaveCount(2); await expect(page.locator('tbody tr[data-general-id]')).toHaveCount(2);
await page.selectOption('#viewType', '8'); await page.selectOption('#viewType', '8');
await page.getByRole('button', { name: '정렬하기' }).click(); await page.getByRole('button', { name: '정렬하기' }).click();
await expect(page.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '10');
await page.getByRole('button', { name: '정렬하기' }).click();
await expect(page.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '20'); await expect(page.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '20');
await page.getByRole('button', { name: '정렬하기' }).click();
await expect(page.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '10');
expect(requestedOperations.filter((operation) => operation === 'world.getGeneralDirectory')).toHaveLength(1);
await expect(page.locator('tbody tr[data-general-id="10"] .wounded').first()).toHaveText('81'); await expect(page.locator('tbody tr[data-general-id="10"] .wounded').first()).toHaveText('81');
await expect(page.locator('tbody tr[data-general-id="10"] .leadership-bonus')).toHaveText('+6'); await expect(page.locator('tbody tr[data-general-id="10"] .leadership-bonus')).toHaveText('+6');
@@ -489,10 +496,10 @@ test('directory sort controls stay legible in dark mode and sortable headers app
expect(await submit.evaluate((element) => getComputedStyle(element).borderBottomWidth)).toBe('1px'); expect(await submit.evaluate((element) => getComputedStyle(element).borderBottomWidth)).toBe('1px');
await page.mouse.up(); await page.mouse.up();
await page.getByRole('button', { name: '삭턴 기준 정렬' }).click(); await page.getByRole('button', { name: /^ /u }).click();
await expect(select).toHaveValue('8'); await expect(select).toHaveValue('8');
await expect(page.locator('th[aria-sort="ascending"]')).toContainText('삭턴'); await expect(page.locator('th[aria-sort="descending"]')).toContainText('삭턴');
await expect(page.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '20'); await expect(page.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '10');
await page.screenshot({ path: testInfo.outputPath('directory-sort-controls-desktop.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('directory-sort-controls-desktop.png'), fullPage: true });
await page.setViewportSize({ width: 500, height: 844 }); await page.setViewportSize({ width: 500, height: 844 });
@@ -501,6 +508,82 @@ test('directory sort controls stay legible in dark mode and sortable headers app
await page.screenshot({ path: testInfo.outputPath('directory-sort-controls-mobile.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('directory-sort-controls-mobile.png'), fullPage: true });
}); });
test('name and mixed-direction stable sorting reuse one response until explicit refresh', async ({ page }) => {
const requestedOperations: string[] = [];
await install(page, 'general', [], requestedOperations);
await page.goto('general-list');
const rows = page.locator('tbody tr[data-general-id]');
await page.getByRole('button', { name: /^ /u }).click();
await expect(rows.first()).toHaveAttribute('data-general-id', '10');
await page.getByRole('button', { name: /^ /u }).click();
await expect(rows.first()).toHaveAttribute('data-general-id', '20');
await page.getByRole('button', { name: /^ /u }).click();
await expect(rows.first()).toHaveAttribute('data-general-id', '10');
await page.getByRole('button', { name: /^ /u }).click();
await page.getByRole('button', { name: /^ /u }).click();
await expect(rows.first()).toHaveAttribute('data-general-id', '20');
await expect(
page.getByRole('columnheader').filter({ hasText: '통솔' }).locator('.legacy-sort-indicator')
).toHaveText('▼2');
await page.getByRole('button', { name: /^ /u }).click();
await expect(rows.first()).toHaveAttribute('data-general-id', '10');
expect(requestedOperations.filter((operation) => operation === 'world.getGeneralDirectory')).toHaveLength(1);
await page.getByRole('button', { name: '갱 신' }).click();
await expect
.poll(() => requestedOperations.filter((operation) => operation === 'world.getGeneralDirectory').length)
.toBe(2);
await expect(rows.first()).toHaveAttribute('data-general-id', '10');
});
test('trait and injury explanations appear on pointer hover and keyboard focus', async ({ page }, testInfo) => {
await install(page);
await page.goto('general-list');
const personality = page.locator('[data-directory-tooltip="personality-10"]');
await personality.hover();
await expect(personality.getByRole('tooltip')).toContainText('성격 · 대담');
await expect(personality.getByRole('tooltip')).toContainText('대담한 성격');
const domestic = page.locator('[data-directory-tooltip="special-domestic-10"]');
await domestic.hover();
await expect(domestic.getByRole('tooltip')).toContainText('내정 특기 · 상재');
await expect(domestic.getByRole('tooltip')).toContainText('상업 특기');
const war = page.locator('[data-directory-tooltip="special-war-10"]');
await war.focus();
await expect(war.getByRole('tooltip')).toContainText('전투 특기 · 귀모');
await expect(war.getByRole('tooltip')).toContainText('전투 특기');
const injury = page.locator('[data-directory-tooltip="injury-leadership-10"]');
await injury.hover();
await expect(injury.getByRole('tooltip')).toContainText('부상 10%');
await expect(injury.getByRole('tooltip')).toContainText('원래 통솔 90 → 적용 81');
const tooltipGeometry = await injury.getByRole('tooltip').evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
left: rect.left,
right: window.innerWidth - rect.right,
display: style.display,
background: style.backgroundColor,
color: style.color,
fontSize: style.fontSize,
};
});
expect(tooltipGeometry.left).toBeGreaterThanOrEqual(8);
expect(tooltipGeometry.right).toBeGreaterThanOrEqual(8);
expect(tooltipGeometry).toMatchObject({
display: 'block',
background: 'rgb(16, 16, 16)',
color: 'rgb(245, 245, 245)',
fontSize: '12.5px',
});
await page.screenshot({ path: testInfo.outputPath('directory-trait-injury-tooltips.png'), fullPage: true });
});
test('npc directory reuses the dark sort controls and sorts from a table header', async ({ page }, testInfo) => { test('npc directory reuses the dark sort controls and sorts from a table header', async ({ page }, testInfo) => {
await install(page); await install(page);
await page.goto('npc-list'); await page.goto('npc-list');
@@ -544,7 +627,8 @@ test('nation directory reuses only the public general-directory row on hover and
await expect(preview.locator('[data-general-card-id]')).toHaveCount(1); await expect(preview.locator('[data-general-card-id]')).toHaveCount(1);
await expect(preview.locator('[data-general-card-id="10"]')).toContainText('조조'); await expect(preview.locator('[data-general-card-id="10"]')).toContainText('조조');
await expect(preview.locator('[data-general-card-id="10"]')).toContainText('대담'); await expect(preview.locator('[data-general-card-id="10"]')).toContainText('대담');
await expect(preview.locator('[data-general-card-id="10"]')).toContainText('상재 / 귀모'); await expect(preview.locator('[data-directory-tooltip="card-special-domestic-10"]')).toContainText('상재');
await expect(preview.locator('[data-directory-tooltip="card-special-war-10"]')).toContainText('귀모');
await expect(preview).not.toContainText('user-'); await expect(preview).not.toContainText('user-');
await expect(preview).not.toContainText('secret'); await expect(preview).not.toContainText('secret');
@@ -687,8 +771,27 @@ test('nation and general directories rearrange for mobile and keep the tapped pr
await expect(page.locator('.general-table')).toBeHidden(); await expect(page.locator('.general-table')).toBeHidden();
await expect(page.locator('.general-card-list')).toBeVisible(); await expect(page.locator('.general-card-list')).toBeVisible();
await expect(page.locator('.general-card-list [data-general-card-id]')).toHaveCount(2); await expect(page.locator('.general-card-list [data-general-card-id]')).toHaveCount(2);
await expect(page.locator('[data-general-card-id="10"]')).toContainText('상재 / 귀모'); await expect(page.locator('[data-directory-tooltip="card-special-domestic-10"]')).toContainText('상재');
await expect(page.locator('[data-general-card-id="10"]')).toContainText('통솔81+6'); await expect(page.locator('[data-directory-tooltip="card-special-war-10"]')).toContainText('귀모');
await expect(page.locator('[data-directory-tooltip="card-injury-leadership-10"] > .wounded')).toHaveText(
'81'
);
await expect(page.locator('[data-general-card-id="10"] .leadership-bonus')).toHaveText('+6');
const mobileInjury = page.locator('[data-directory-tooltip="card-injury-leadership-10"]');
await mobileInjury.focus();
await expect(mobileInjury.getByRole('tooltip')).toBeVisible();
const mobileTooltipGeometry = await mobileInjury.getByRole('tooltip').evaluate((element) => {
const rect = element.getBoundingClientRect();
return {
left: rect.left,
right: window.innerWidth - rect.right,
width: rect.width,
innerWidth: window.innerWidth,
};
});
expect(mobileTooltipGeometry.left).toBeGreaterThanOrEqual(8);
expect(mobileTooltipGeometry.right).toBeGreaterThanOrEqual(8);
expect(mobileTooltipGeometry.width).toBeLessThanOrEqual(mobileTooltipGeometry.innerWidth - 16);
const generalMetrics = await page.locator('.directory-page').evaluate((element) => { const generalMetrics = await page.locator('.directory-page').evaluate((element) => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
@@ -760,11 +863,12 @@ test('a reused image element falls back for each newly broken account icon', asy
await expect.poll(() => icon.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBe(64); await expect.poll(() => icon.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBe(64);
}); });
test('a failed resort retains the selected value and existing rows', async ({ page }) => { test('a failed explicit refresh retains the local sort selection and existing rows', async ({ page }) => {
await install(page, 'error-after-load'); await install(page, 'error-after-load');
await page.goto('general-list'); await page.goto('general-list');
await page.selectOption('#viewType', '8'); await page.selectOption('#viewType', '8');
await page.getByRole('button', { name: '정렬하기' }).click(); await page.getByRole('button', { name: '정렬하기' }).click();
await page.getByRole('button', { name: '갱 신' }).click();
await expect(page.getByRole('alert')).toContainText('권한 확인 실패'); await expect(page.getByRole('alert')).toContainText('권한 확인 실패');
await expect(page.locator('#viewType')).toHaveValue('8'); await expect(page.locator('#viewType')).toHaveValue('8');
await expect(page.locator('tbody tr[data-general-id]')).toHaveCount(2); await expect(page.locator('tbody tr[data-general-id]')).toHaveCount(2);
+75 -12
View File
@@ -475,6 +475,27 @@ test('map keeps desktop hover navigation and lets touch users choose one-tap or
await page.setViewportSize({ width: 1200, height: 900 }); await page.setViewportSize({ width: 1200, height: 900 });
await go(page, 'global-info'); await go(page, 'global-info');
const desktopOptionsTrigger = page.getByRole('button', { name: '지도 옵션' });
await expect(desktopOptionsTrigger).toBeVisible();
await expect(desktopOptionsTrigger).toHaveAttribute('aria-expanded', 'false');
await expect(desktopOptionsTrigger).toHaveCSS('background-color', 'rgb(52, 92, 133)');
await desktopOptionsTrigger.hover();
await expect(desktopOptionsTrigger).toHaveCSS('background-color', 'rgb(40, 73, 105)');
await expect(page.getByRole('button', { name: '도시명 표기 끄기' })).not.toBeVisible();
await desktopOptionsTrigger.click();
const cityNameToggle = page.getByRole('button', { name: '도시명 표기 끄기' });
await expect(cityNameToggle).toBeVisible();
await expect(desktopOptionsTrigger).toHaveAttribute('aria-expanded', 'true');
await expect(page.locator('.map-options-menu .map-toggle')).toHaveCount(1);
await page.keyboard.press('Tab');
await desktopOptionsTrigger.focus();
await expect(desktopOptionsTrigger).toHaveCSS('outline-style', 'solid');
await page.screenshot({ path: testInfo.outputPath('desktop-map-options-open.png'), fullPage: true });
await cityNameToggle.click();
await expect(page.locator('.map-area .city-name')).toHaveCount(0);
await page.keyboard.press('Escape');
await expect(desktopOptionsTrigger).toHaveAttribute('aria-expanded', 'false');
const desktopCity = page.locator('.city-base').first(); const desktopCity = page.locator('.city-base').first();
await expect(page.locator('.map-toggle-single-tap')).toHaveCount(0); await expect(page.locator('.map-toggle-single-tap')).toHaveCount(0);
await desktopCity.hover(); await desktopCity.hover();
@@ -508,20 +529,48 @@ test('map keeps desktop hover navigation and lets touch users choose one-tap or
await install(mobilePage); await install(mobilePage);
await go(mobilePage, 'global-info'); await go(mobilePage, 'global-info');
const mobileOptionsTrigger = mobilePage.getByRole('button', { name: '지도 옵션' });
await expect(mobileOptionsTrigger).toBeVisible();
await expect(mobileOptionsTrigger).toHaveAttribute('aria-expanded', 'false');
await mobileOptionsTrigger.tap();
await expect(mobileOptionsTrigger).toHaveAttribute('aria-expanded', 'true');
await expect(mobilePage.locator('.map-options-menu .map-toggle')).toHaveCount(2);
const twoTapButton = mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 끄기' }); const twoTapButton = mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 끄기' });
await expect(twoTapButton).toBeVisible(); await expect(twoTapButton).toBeVisible();
await expect(twoTapButton).toHaveAttribute('aria-pressed', 'false'); await expect(twoTapButton).toHaveAttribute('aria-pressed', 'false');
const controlGeometry = await twoTapButton.evaluate((element) => { const controlGeometry = await mobilePage.locator('.map-controls').evaluate((element) => {
const rect = element.getBoundingClientRect(); const triggerRect = element.querySelector('.map-options-trigger')!.getBoundingClientRect();
const menuRect = element.querySelector('.map-options-menu')!.getBoundingClientRect();
const optionRects = Array.from(element.querySelectorAll('.map-options-menu .map-toggle')).map((option) => {
const rect = option.getBoundingClientRect();
return { top: rect.top, bottom: rect.bottom };
});
const mapRect = element.closest('.map-area')?.getBoundingClientRect(); const mapRect = element.closest('.map-area')?.getBoundingClientRect();
const style = getComputedStyle(element); const optionStyle = getComputedStyle(element.querySelector('.map-toggle')!);
return { return {
right: rect.right, trigger: {
bottom: rect.bottom, right: triggerRect.right,
mapRight: mapRect?.right, bottom: triggerRect.bottom,
mapBottom: mapRect?.bottom, top: triggerRect.top,
fontSize: style.fontSize, },
lineHeight: style.lineHeight, menu: {
left: menuRect.left,
right: menuRect.right,
top: menuRect.top,
bottom: menuRect.bottom,
},
optionRects,
map: mapRect
? {
left: mapRect.left,
right: mapRect.right,
top: mapRect.top,
bottom: mapRect.bottom,
}
: null,
fontSize: optionStyle.fontSize,
lineHeight: optionStyle.lineHeight,
documentWidth: document.documentElement.scrollWidth, documentWidth: document.documentElement.scrollWidth,
viewportWidth: document.documentElement.clientWidth, viewportWidth: document.documentElement.clientWidth,
overflowing: Array.from(document.querySelectorAll<HTMLElement>('body *')) overflowing: Array.from(document.querySelectorAll<HTMLElement>('body *'))
@@ -543,8 +592,20 @@ test('map keeps desktop hover navigation and lets touch users choose one-tap or
overflowing: [], overflowing: [],
}); });
expect(controlGeometry.documentWidth).toBeLessThanOrEqual(controlGeometry.viewportWidth + 1); expect(controlGeometry.documentWidth).toBeLessThanOrEqual(controlGeometry.viewportWidth + 1);
expect(controlGeometry.mapRight! - controlGeometry.right).toBeCloseTo(4, 1); expect(controlGeometry.map).not.toBeNull();
expect(controlGeometry.mapBottom! - controlGeometry.bottom).toBeCloseTo(4, 1); expect(controlGeometry.map!.right - controlGeometry.trigger.right).toBeCloseTo(4, 1);
expect(controlGeometry.map!.bottom - controlGeometry.trigger.bottom).toBeCloseTo(4, 1);
expect(controlGeometry.menu.left).toBeGreaterThanOrEqual(controlGeometry.map!.left);
expect(controlGeometry.menu.right).toBeLessThanOrEqual(controlGeometry.map!.right);
expect(controlGeometry.menu.top).toBeGreaterThanOrEqual(controlGeometry.map!.top);
expect(controlGeometry.menu.bottom).toBeLessThan(controlGeometry.trigger.top);
expect(controlGeometry.optionRects).toHaveLength(2);
expect(controlGeometry.optionRects[0]!.bottom).toBeLessThanOrEqual(controlGeometry.optionRects[1]!.top);
expect(controlGeometry.optionRects[1]!.bottom).toBeLessThanOrEqual(controlGeometry.menu.bottom);
await mobilePage.screenshot({ path: testInfo.outputPath('mobile-map-options-open.png'), fullPage: true });
await mobilePage.locator('.map-area').tap({ position: { x: 10, y: 10 } });
await expect(mobileOptionsTrigger).toHaveAttribute('aria-expanded', 'false');
const mobileCities = mobilePage.locator('.city-base'); const mobileCities = mobilePage.locator('.city-base');
await mobileCities.nth(0).tap(); await mobileCities.nth(0).tap();
@@ -560,17 +621,19 @@ test('map keeps desktop hover navigation and lets touch users choose one-tap or
await expect(mobilePage).toHaveURL(/\/current-city\?cityId=2$/u); await expect(mobilePage).toHaveURL(/\/current-city\?cityId=2$/u);
await go(mobilePage, 'global-info'); await go(mobilePage, 'global-info');
await mobilePage.getByRole('button', { name: '지도 옵션' }).tap();
await mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 끄기' }).click(); await mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 끄기' }).click();
const singleTapButton = mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 켜기' }); const singleTapButton = mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 켜기' });
await expect(singleTapButton).toHaveAttribute('aria-pressed', 'true'); await expect(singleTapButton).toHaveAttribute('aria-pressed', 'true');
expect(await mobilePage.evaluate(() => localStorage.getItem('sam.toggleSingleTap'))).toBe('yes'); expect(await mobilePage.evaluate(() => localStorage.getItem('sam.toggleSingleTap'))).toBe('yes');
await mobilePage.reload(); await mobilePage.reload();
await expect(singleTapButton).toBeVisible(); await expect(singleTapButton).not.toBeVisible();
await mobilePage.locator('.city-base').nth(2).tap(); await mobilePage.locator('.city-base').nth(2).tap();
await expect(mobilePage).toHaveURL(/\/current-city\?cityId=3$/u); await expect(mobilePage).toHaveURL(/\/current-city\?cityId=3$/u);
await go(mobilePage, 'global-info'); await go(mobilePage, 'global-info');
await mobilePage.getByRole('button', { name: '지도 옵션' }).tap();
await mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 켜기' }).click(); await mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 켜기' }).click();
expect(await mobilePage.evaluate(() => localStorage.getItem('sam.toggleSingleTap'))).toBe('no'); expect(await mobilePage.evaluate(() => localStorage.getItem('sam.toggleSingleTap'))).toBe('no');
await mobilePage.locator('.city-base').first().tap(); await mobilePage.locator('.city-base').first().tap();
+58 -7
View File
@@ -102,6 +102,9 @@ const myGeneral = (state: FixtureState) => ({
dedication: 200, dedication: 200,
age: 30, age: 30,
turnTime: '2026-01-01 00:10:00', turnTime: '2026-01-01 00:10:00',
recentWar: '2026-01-01 00:00:00',
defenceTrain: 80,
killTurn: 6,
crewTypeId: 1, crewTypeId: 1,
crewTypeName: '보병', crewTypeName: '보병',
crewTypeInfo: state.richMyInfo crewTypeInfo: state.richMyInfo
@@ -277,7 +280,7 @@ const battleCenter = (state: FixtureState) => ({
cityId: 1, cityId: 1,
turnTime: '2026-01-01 00:10:00', turnTime: '2026-01-01 00:10:00',
recentWar: '2026-01-01 00:00:00', recentWar: '2026-01-01 00:00:00',
warnum: 3, warnum: 8,
stats: { leadership: 70, strength: 60, intelligence: 50 }, stats: { leadership: 70, strength: 60, intelligence: 50 },
experience: 100, experience: 100,
dedication: 200, dedication: 200,
@@ -288,6 +291,8 @@ const battleCenter = (state: FixtureState) => ({
train: 80, train: 80,
atmos: 90, atmos: 90,
age: 30, age: 30,
defenceTrain: 80,
killTurn: 6,
crewTypeId: 1, crewTypeId: 1,
crewTypeName: '보병', crewTypeName: '보병',
equipment: { weapon: 'None', book: 'None', horse: 'None', item: 'None' }, equipment: { weapon: 'None', book: 'None', horse: 'None', item: 'None' },
@@ -301,7 +306,8 @@ const battleCenter = (state: FixtureState) => ({
statUpgradeLimit: 20, statUpgradeLimit: 20,
dex: [350, 1_375, 3_500, 7_125, 1_275_975], dex: [350, 1_375, 3_500, 7_125, 1_275_975],
}, },
battleStats: { kills: 1, deaths: 2, fire: 0, killCrew: 300, deathCrew: 100, dex: [] }, serviceYears: 4,
battleStats: { kills: 5, deaths: 3, fire: 12, killCrew: 12_345, deathCrew: 6_789, dex: [] },
}, },
{ {
id: 8, id: 8,
@@ -323,6 +329,8 @@ const battleCenter = (state: FixtureState) => ({
train: 60, train: 60,
atmos: 60, atmos: 60,
age: 20, age: 20,
defenceTrain: 80,
killTurn: 4,
crewTypeId: 1, crewTypeId: 1,
crewTypeName: '보병', crewTypeName: '보병',
equipment: { weapon: 'None', book: 'None', horse: 'None', item: 'None' }, equipment: { weapon: 'None', book: 'None', horse: 'None', item: 'None' },
@@ -336,6 +344,7 @@ const battleCenter = (state: FixtureState) => ({
statUpgradeLimit: 20, statUpgradeLimit: 20,
dex: [0, 0, 0, 0, 0], dex: [0, 0, 0, 0, 0],
}, },
serviceYears: 1,
battleStats: { kills: 0, deaths: 0, fire: 0, killCrew: 0, deathCrew: 0, dex: [] }, battleStats: { kills: 0, deaths: 0, fire: 0, killCrew: 0, deathCrew: 0, dex: [] },
}, },
], ],
@@ -1213,6 +1222,7 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
await page.setViewportSize({ width: 1000, height: 900 }); await page.setViewportSize({ width: 1000, height: 900 });
await page.goto('my-page'); await page.goto('my-page');
await expect(page.locator('.general-table')).toHaveAttribute('data-general-basic-card', ''); await expect(page.locator('.general-table')).toHaveAttribute('data-general-basic-card', '');
await expect(page.locator('.general-table')).toHaveAttribute('data-general-information-panel', '');
const myPageImages = await readGeneralPanelImages(page.locator('.general-table')); const myPageImages = await readGeneralPanelImages(page.locator('.general-table'));
expect(myPageImages.map(({ width, height }) => ({ width, height }))).toEqual([ expect(myPageImages.map(({ width, height }) => ({ width, height }))).toEqual([
{ width: 64, height: 64 }, { width: 64, height: 64 },
@@ -1220,11 +1230,32 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
]); ]);
expect(myPageImages[0]?.backgroundImage).toContain('/icons/default.jpg'); expect(myPageImages[0]?.backgroundImage).toContain('/icons/default.jpg');
expect(myPageImages[1]?.backgroundImage).toContain('/game/crewtype1.png'); expect(myPageImages[1]?.backgroundImage).toContain('/game/crewtype1.png');
await expect(page.locator('.legacy-general-details')).toContainText('계급 29품관'); await expect(page.locator('.general-table')).toContainText('병종보병');
await expect(page.locator('.legacy-general-details')).toContainText('병종 보병'); await expect(page.locator('.general-table')).toContainText('삭턴6 턴');
await expect(page.locator('.legacy-general-details')).toContainText('전투 8 · 계략 12 · 사관 4년'); await expect(page.locator('.battle-general-extra')).toContainText('계급29품관');
await expect(page.locator('.legacy-general-details')).toContainText('승률 62.50% · 승리 5 · 패배 3'); await expect(page.locator('.battle-general-extra')).toContainText('전투8회');
await expect(page.locator('.legacy-general-details')).toContainText('살상률 181.84% · 사살 12,345 · 피살 6,789'); await expect(page.locator('.battle-general-extra')).toContainText('계략12');
await expect(page.locator('.battle-general-extra')).toContainText('사관4년');
await expect(page.locator('.battle-general-extra')).toContainText('승률62.50%');
await expect(page.locator('.battle-general-extra')).toContainText('살상률181.84%');
await expect(page.locator('.battle-general-extra')).toContainText('사살12,345');
await expect(page.locator('.battle-general-extra')).toContainText('피살6,789');
await expect(page.locator('.battle-general-extra__recent-value')).toHaveText('01-01 00:00');
await expect(page.locator('.legacy-general-details')).toHaveCount(0);
await expect(page.locator('.battle-general-extra > span')).toHaveText([
'명성',
'계급',
'전투',
'승리',
'패배',
'계략',
'사관',
'사살',
'피살',
'승률',
'살상률',
'최근 전투',
]);
await expect(page.locator('.item-group')).toContainText('명마'); await expect(page.locator('.item-group')).toContainText('명마');
await expect(page.locator('#container')).not.toContainText('che_'); await expect(page.locator('#container')).not.toContainText('che_');
await expect(page.locator('.title-row')).toContainText('내 정 보'); await expect(page.locator('.title-row')).toContainText('내 정 보');
@@ -2006,6 +2037,26 @@ test('감찰부 keeps the selector interaction and shows the permission error pa
await expect(page.locator('.battle-general-card')).toContainText('병종보병'); await expect(page.locator('.battle-general-card')).toContainText('병종보병');
await expect(page.locator('.battle-general-card')).not.toContainText('che_'); await expect(page.locator('.battle-general-card')).not.toContainText('che_');
await expect(page.locator('.battle-general-card')).toHaveAttribute('data-general-basic-card', ''); await expect(page.locator('.battle-general-card')).toHaveAttribute('data-general-basic-card', '');
await expect(page.locator('.battle-general-card')).toHaveAttribute('data-general-information-panel', '');
await expect(page.locator('.battle-general-card')).toContainText('삭턴6 턴');
await expect(page.locator('.battle-general-extra')).toContainText('전투8회');
await expect(page.locator('.battle-general-extra')).toContainText('사관4년');
await expect(page.locator('.battle-general-extra')).toContainText('승률62.50%');
await expect(page.locator('.battle-general-extra')).toContainText('살상률181.84%');
await expect(page.locator('.battle-general-extra > span')).toHaveText([
'명성',
'계급',
'전투',
'승리',
'패배',
'계략',
'사관',
'사살',
'피살',
'승률',
'살상률',
'최근 전투',
]);
const battleImages = await readGeneralPanelImages(page.locator('.battle-general-card')); const battleImages = await readGeneralPanelImages(page.locator('.battle-general-card'));
expect(battleImages).toHaveLength(2); expect(battleImages).toHaveLength(2);
expect(battleImages[0]?.backgroundImage).toContain('/icons/default.jpg'); expect(battleImages[0]?.backgroundImage).toContain('/icons/default.jpg');
+13 -7
View File
@@ -4275,15 +4275,21 @@ test('seasonal map decodes the next background and crossfades it without remount
await emitReadModelInvalidation(page, readModelInvalidation({ lobby: true, map: true })); await emitReadModelInvalidation(page, readModelInvalidation({ lobby: true, map: true }));
await expect(map).toContainText('185年 4月'); await expect(map).toContainText('185年 4月');
await expect(outgoingLayer).toHaveClass(/is-transitioning/u); await expect(outgoingLayer).toHaveClass(/is-transitioning/u);
await expect(currentLayer.locator('img')).toHaveAttribute('src', /bg_summer\.jpg/u); await expect(outgoingLayer).toHaveCSS('transition-duration', '0.48s');
await expect(outgoingLayer.locator('img')).toHaveAttribute('src', /bg_spring\.jpg/u); let midpointOpacity = 0;
await page.waitForTimeout(180); await expect
.poll(
const midpointOpacity = Number.parseFloat( async () => {
midpointOpacity = Number.parseFloat(
await outgoingLayer.evaluate((element) => getComputedStyle(element).opacity) await outgoingLayer.evaluate((element) => getComputedStyle(element).opacity)
); );
expect(midpointOpacity).toBeGreaterThan(0); return midpointOpacity > 0 && midpointOpacity < 1;
expect(midpointOpacity).toBeLessThan(1); },
{ intervals: [16, 16, 16, 16, 16, 16], timeout: 350 }
)
.toBe(true);
await expect(currentLayer.locator('img')).toHaveAttribute('src', /bg_summer\.jpg/u);
await expect(outgoingLayer.locator('img')).toHaveAttribute('src', /bg_spring\.jpg/u);
if (mapSeasonArtifactRoot) { if (mapSeasonArtifactRoot) {
await map.screenshot({ path: resolve(mapSeasonArtifactRoot, 'season-spring-to-summer-midpoint.png') }); await map.screenshot({ path: resolve(mapSeasonArtifactRoot, 'season-spring-to-summer-midpoint.png') });
} }
@@ -0,0 +1,89 @@
<script setup lang="ts">
defineProps<{
title: string;
description?: string | null;
testId?: string;
}>();
</script>
<template>
<span
class="directory-tooltip"
:class="{ 'directory-tooltip--enabled': description }"
:tabindex="description ? 0 : undefined"
:data-directory-tooltip="testId"
>
<slot />
<span v-if="description" class="directory-tooltip__content" role="tooltip">
<strong>{{ title }}</strong>
<span>{{ description }}</span>
</span>
</span>
</template>
<style scoped>
.directory-tooltip {
position: relative;
display: inline;
min-width: 0;
}
.directory-tooltip--enabled {
cursor: help;
text-decoration: underline dotted rgb(150 210 255 / 85%);
text-underline-offset: 2px;
}
.directory-tooltip--enabled:focus-visible {
border-radius: 2px;
outline: 1px solid #6fc7ff;
outline-offset: 1px;
}
.directory-tooltip__content {
display: none;
position: absolute;
z-index: 30;
left: 50%;
bottom: calc(100% + 5px);
box-sizing: border-box;
width: max-content;
max-width: min(280px, calc(100vw - 16px));
transform: translateX(-50%);
border: 1px solid #8c8c8c;
border-radius: 3px;
padding: 7px 9px;
background: #101010;
box-shadow: 0 3px 12px rgb(0 0 0 / 65%);
color: #f5f5f5;
font-family: var(--sammo-font-sans);
font-size: 12.5px;
font-weight: 400;
line-height: 1.45;
text-align: left;
white-space: normal;
word-break: keep-all;
}
.directory-tooltip__content strong,
.directory-tooltip__content span {
display: block;
}
.directory-tooltip__content strong {
margin-bottom: 4px;
color: #7fd4ff;
font-size: 13px;
}
.directory-tooltip--enabled:hover > .directory-tooltip__content,
.directory-tooltip--enabled:focus > .directory-tooltip__content {
display: block;
}
@media (max-width: 600px) {
.directory-tooltip__content {
position: fixed;
right: 8px;
bottom: 8px;
left: 8px;
width: auto;
max-width: none;
transform: none;
}
}
</style>
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { computed } from 'vue';
import DirectoryTooltip from './DirectoryTooltip.vue';
const props = withDefaults(
defineProps<{
label: string;
value: number;
injury: number;
bonus?: number;
testId?: string;
}>(),
{ bonus: 0, testId: undefined }
);
const displayedValue = computed(() =>
props.injury > 0 ? Math.trunc((props.value * (100 - props.injury)) / 100) : props.value
);
const injuryDescription = computed(() =>
props.injury > 0
? `부상 ${props.injury}% · 원래 ${props.label} ${props.value} → 적용 ${displayedValue.value}`
: null
);
</script>
<template>
<DirectoryTooltip :title="`${label} 부상`" :description="injuryDescription" :test-id="testId">
<span :class="{ wounded: injury > 0 }">{{ displayedValue }}</span>
</DirectoryTooltip>
<span v-if="bonus > 0" class="leadership-bonus">+{{ bonus }}</span>
</template>
<style scoped>
.wounded {
color: red;
}
.leadership-bonus {
color: cyan;
}
</style>
@@ -3,13 +3,14 @@ import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../../utils/genera
import { formatOfficerLevelText } from '../../utils/nationFormat'; import { formatOfficerLevelText } from '../../utils/nationFormat';
import { getNpcColor } from '../../utils/npcColor'; import { getNpcColor } from '../../utils/npcColor';
import type { GeneralDirectoryGeneral } from '../../types/directory'; import type { GeneralDirectoryGeneral } from '../../types/directory';
import type { GeneralDirectorySortCriterion, GeneralDirectorySortKey } from '../../utils/generalDirectorySort';
import DirectoryTooltip from './DirectoryTooltip.vue';
import GeneralDirectoryStat from './GeneralDirectoryStat.vue';
type SortDirection = 'ascending' | 'descending'; type SortDirection = 'ascending' | 'descending';
type Header = { type Header = {
label: string; label: string;
sort?: number; sort?: GeneralDirectorySortKey;
direction?: SortDirection;
title?: string;
}; };
const props = withDefaults( const props = withDefaults(
@@ -17,12 +18,12 @@ const props = withDefaults(
generals: GeneralDirectoryGeneral[]; generals: GeneralDirectoryGeneral[];
loading?: boolean; loading?: boolean;
layout?: 'responsive' | 'card'; layout?: 'responsive' | 'card';
activeSort?: number; sortCriteria?: readonly GeneralDirectorySortCriterion[];
}>(), }>(),
{ {
loading: false, loading: false,
layout: 'responsive', layout: 'responsive',
activeSort: undefined, sortCriteria: () => [],
} }
); );
@@ -30,26 +31,43 @@ const emit = defineEmits<{ sort: [value: number] }>();
const headers: ReadonlyArray<Header> = [ const headers: ReadonlyArray<Header> = [
{ label: '얼 굴' }, { label: '얼 굴' },
{ label: '이 름' }, { label: '이 름', sort: 0 },
{ label: '연령', sort: 14, direction: 'descending' }, { label: '연령', sort: 14 },
{ label: '성격', sort: 11, direction: 'descending' }, { label: '성격', sort: 11 },
{ label: '특기' }, { label: '특기' },
{ label: '레 벨', sort: 10, direction: 'descending' }, { label: '레 벨', sort: 10 },
{ label: '국 가', sort: 1, direction: 'ascending' }, { label: '국 가', sort: 1 },
{ label: '명 성', sort: 5, direction: 'descending' }, { label: '명 성', sort: 5 },
{ label: '계 급', sort: 6, direction: 'descending' }, { label: '계 급', sort: 6 },
{ label: '관 직', sort: 7, direction: 'descending' }, { label: '관 직', sort: 7 },
{ label: '통솔', sort: 2, direction: 'descending' }, { label: '통솔', sort: 2 },
{ label: '무력', sort: 3, direction: 'descending' }, { label: '무력', sort: 3 },
{ label: '지력', sort: 4, direction: 'descending' }, { label: '지력', sort: 4 },
{ label: '삭턴', sort: 8, direction: 'ascending' }, { label: '삭턴', sort: 8 },
{ label: '벌점', sort: 9, direction: 'descending' }, { label: '벌점', sort: 9 },
]; ];
const criterionIndex = (header: Header): number =>
header.sort === undefined ? -1 : props.sortCriteria.findIndex(({ key }) => key === header.sort);
const criterionFor = (header: Header): GeneralDirectorySortCriterion | undefined => {
const index = criterionIndex(header);
return index < 0 ? undefined : props.sortCriteria[index];
};
const ariaSort = (header: Header): SortDirection | undefined => const ariaSort = (header: Header): SortDirection | undefined =>
header.sort === props.activeSort ? header.direction : undefined; criterionIndex(header) === 0 ? criterionFor(header)?.direction : undefined;
const sortIndicator = (header: Header): string => {
const injuredStat = (value: number, injury: number): number => Math.trunc((value * (100 - injury)) / 100); const index = criterionIndex(header);
if (index < 0) return '↕';
const arrow = criterionFor(header)?.direction === 'ascending' ? '▲' : '▼';
return props.sortCriteria.length > 1 ? `${arrow}${index + 1}` : arrow;
};
const nextSortAction = (header: Header): string => {
const direction = criterionFor(header)?.direction;
if (!direction) return '내림차순';
return direction === 'descending' ? '오름차순' : '정렬 해제';
};
const sortHelp = (header: Header): string =>
`${header.label.replaceAll(' ', '')} ${nextSortAction(header)}. 같은 값은 이전 정렬 순서를 유지합니다.`;
</script> </script>
<template> <template>
@@ -81,17 +99,14 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value
:aria-sort="ariaSort(header)" :aria-sort="ariaSort(header)"
> >
<button <button
v-if="header.sort !== undefined && activeSort !== undefined" v-if="header.sort !== undefined"
class="legacy-sort-header" class="legacy-sort-header"
type="button" type="button"
:aria-label="`${header.label.replaceAll(' ', '')} 기준 정렬`" :aria-label="sortHelp(header)"
:title="header.title ?? `${header.label.replaceAll(' ', '')} 기준 정렬`" :title="sortHelp(header)"
@click="emit('sort', header.sort)" @click="emit('sort', header.sort)"
> >
{{ header.label {{ header.label }}<span class="legacy-sort-indicator">{{ sortIndicator(header) }}</span>
}}<span class="legacy-sort-indicator">{{
header.sort === activeSort ? (header.direction === 'ascending' ? '▲' : '▼') : '↕'
}}</span>
</button> </button>
<template v-else>{{ header.label }}</template> <template v-else>{{ header.label }}</template>
</th> </th>
@@ -132,11 +147,30 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value
</td> </td>
<td class="center">{{ general.age }}세</td> <td class="center">{{ general.age }}세</td>
<td class="center"> <td class="center">
<span :title="general.personality.info">{{ general.personality.name }}</span> <DirectoryTooltip
:title="`성격 · ${general.personality.name}`"
:description="general.personality.info"
:test-id="`personality-${general.id}`"
>
{{ general.personality.name }}
</DirectoryTooltip>
</td> </td>
<td class="center"> <td class="center">
<span :title="general.specialDomestic.info">{{ general.specialDomestic.name }}</span> / <DirectoryTooltip
<span :title="general.specialWar.info">{{ general.specialWar.name }}</span> :title="`내정 특기 · ${general.specialDomestic.name}`"
:description="general.specialDomestic.info"
:test-id="`special-domestic-${general.id}`"
>
{{ general.specialDomestic.name }}
</DirectoryTooltip>
/
<DirectoryTooltip
:title="`전투 특기 · ${general.specialWar.name}`"
:description="general.specialWar.info"
:test-id="`special-war-${general.id}`"
>
{{ general.specialWar.name }}
</DirectoryTooltip>
</td> </td>
<td class="center">Lv {{ general.experienceLevel }}</td> <td class="center">Lv {{ general.experienceLevel }}</td>
<td class="center">{{ general.nationName }}</td> <td class="center">{{ general.nationName }}</td>
@@ -144,22 +178,29 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value
<td class="center">{{ general.dedicationText }}</td> <td class="center">{{ general.dedicationText }}</td>
<td class="center">{{ formatOfficerLevelText(general.officerLevel, general.nationLevel) }}</td> <td class="center">{{ formatOfficerLevelText(general.officerLevel, general.nationLevel) }}</td>
<td class="center"> <td class="center">
<span :class="{ wounded: general.injury > 0 }">{{ <GeneralDirectoryStat
general.injury > 0 ? injuredStat(general.leadership, general.injury) : general.leadership label="통솔"
}}</span :value="general.leadership"
><span v-if="general.leadershipBonus > 0" class="leadership-bonus" :injury="general.injury"
>+{{ general.leadershipBonus }}</span :bonus="general.leadershipBonus"
> :test-id="`injury-leadership-${general.id}`"
/>
</td> </td>
<td class="center"> <td class="center">
<span :class="{ wounded: general.injury > 0 }">{{ <GeneralDirectoryStat
general.injury > 0 ? injuredStat(general.strength, general.injury) : general.strength label="무력"
}}</span> :value="general.strength"
:injury="general.injury"
:test-id="`injury-strength-${general.id}`"
/>
</td> </td>
<td class="center"> <td class="center">
<span :class="{ wounded: general.injury > 0 }">{{ <GeneralDirectoryStat
general.injury > 0 ? injuredStat(general.intelligence, general.injury) : general.intelligence label="지력"
}}</span> :value="general.intelligence"
:injury="general.injury"
:test-id="`injury-intelligence-${general.id}`"
/>
</td> </td>
<td class="center">{{ general.killturn }}</td> <td class="center">{{ general.killturn }}</td>
<td class="center">{{ general.refreshScoreTotal }}<br />【{{ general.refreshText }}】</td> <td class="center">{{ general.refreshScoreTotal }}<br />【{{ general.refreshText }}】</td>
@@ -207,13 +248,33 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value
</div> </div>
<div class="general-card-field"> <div class="general-card-field">
<span class="field-label">성격</span> <span class="field-label">성격</span>
<span :title="general.personality.info">{{ general.personality.name }}</span> <DirectoryTooltip
:title="`성격 · ${general.personality.name}`"
:description="general.personality.info"
:test-id="`card-personality-${general.id}`"
>
{{ general.personality.name }}
</DirectoryTooltip>
</div> </div>
<div class="general-card-field"> <div class="general-card-field">
<span class="field-label">특기</span> <span class="field-label">특기</span>
<span :title="`${general.specialDomestic.info} / ${general.specialWar.info}`" <span>
>{{ general.specialDomestic.name }} / {{ general.specialWar.name }}</span <DirectoryTooltip
:title="`내정 특기 · ${general.specialDomestic.name}`"
:description="general.specialDomestic.info"
:test-id="`card-special-domestic-${general.id}`"
> >
{{ general.specialDomestic.name }}
</DirectoryTooltip>
/
<DirectoryTooltip
:title="`전투 특기 · ${general.specialWar.name}`"
:description="general.specialWar.info"
:test-id="`card-special-war-${general.id}`"
>
{{ general.specialWar.name }}
</DirectoryTooltip>
</span>
</div> </div>
<div class="general-card-field"> <div class="general-card-field">
<span class="field-label">레벨</span><span>Lv {{ general.experienceLevel }}</span> <span class="field-label">레벨</span><span>Lv {{ general.experienceLevel }}</span>
@@ -233,26 +294,31 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value
</div> </div>
<div class="general-card-field"> <div class="general-card-field">
<span class="field-label">통솔</span> <span class="field-label">통솔</span>
<span> <GeneralDirectoryStat
<span :class="{ wounded: general.injury > 0 }">{{ label="통솔"
general.injury > 0 ? injuredStat(general.leadership, general.injury) : general.leadership :value="general.leadership"
}}</span :injury="general.injury"
><span v-if="general.leadershipBonus > 0" class="leadership-bonus" :bonus="general.leadershipBonus"
>+{{ general.leadershipBonus }}</span :test-id="`card-injury-leadership-${general.id}`"
> />
</span>
</div> </div>
<div class="general-card-field"> <div class="general-card-field">
<span class="field-label">무력</span> <span class="field-label">무력</span>
<span :class="{ wounded: general.injury > 0 }">{{ <GeneralDirectoryStat
general.injury > 0 ? injuredStat(general.strength, general.injury) : general.strength label="무력"
}}</span> :value="general.strength"
:injury="general.injury"
:test-id="`card-injury-strength-${general.id}`"
/>
</div> </div>
<div class="general-card-field"> <div class="general-card-field">
<span class="field-label">지력</span> <span class="field-label">지력</span>
<span :class="{ wounded: general.injury > 0 }">{{ <GeneralDirectoryStat
general.injury > 0 ? injuredStat(general.intelligence, general.injury) : general.intelligence label="지력"
}}</span> :value="general.intelligence"
:injury="general.injury"
:test-id="`card-injury-intelligence-${general.id}`"
/>
</div> </div>
<div class="general-card-field penalty-field"> <div class="general-card-field penalty-field">
<span class="field-label">벌점</span> <span class="field-label">벌점</span>
@@ -298,12 +364,6 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value
.center { .center {
text-align: center; text-align: center;
} }
.wounded {
color: red;
}
.leadership-bonus {
color: cyan;
}
.loading-cell { .loading-cell {
height: 64px; height: 64px;
text-align: center; text-align: center;
@@ -22,6 +22,7 @@ interface GeneralProgression {
dedicationText?: string; dedicationText?: string;
statExperience?: { leadership: number; strength: number; intelligence: number }; statExperience?: { leadership: number; strength: number; intelligence: number };
statUpgradeLimit?: number; statUpgradeLimit?: number;
dex?: number[];
} }
interface ItemDisplayNames { interface ItemDisplayNames {
@@ -65,7 +66,7 @@ interface GeneralRefreshScore {
text: string; text: string;
} }
interface GeneralInfo { export interface GeneralBasicCardData {
id: number; id: number;
name: string; name: string;
picture?: string | null; picture?: string | null;
@@ -107,7 +108,7 @@ interface GeneralInfo {
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
general: GeneralInfo | null; general: GeneralBasicCardData | null;
loading: boolean; loading: boolean;
nationColor?: string | null; nationColor?: string | null;
defenceText?: string | null; defenceText?: string | null;
@@ -11,6 +11,7 @@ export type GeneralBattleSummaryData = {
wins?: number | null; wins?: number | null;
losses?: number | null; losses?: number | null;
strategies?: number | null; strategies?: number | null;
serviceYears?: number | null;
killCrew?: number | null; killCrew?: number | null;
deathCrew?: number | null; deathCrew?: number | null;
winRate?: number | null; winRate?: number | null;
@@ -30,7 +31,7 @@ const props = withDefaults(
const numberText = (value: number | null | undefined): string => const numberText = (value: number | null | undefined): string =>
typeof value === 'number' && Number.isFinite(value) ? value.toLocaleString('ko-KR') : '-'; typeof value === 'number' && Number.isFinite(value) ? value.toLocaleString('ko-KR') : '-';
const rateText = (value: number): string => `${(props.rateScale === 'percent' ? value : value * 100).toFixed(1)}%`; const rateText = (value: number): string => `${(props.rateScale === 'percent' ? value : value * 100).toFixed(2)}%`;
const winRate = computed(() => { const winRate = computed(() => {
if (typeof props.summary.winRate === 'number' && Number.isFinite(props.summary.winRate)) { if (typeof props.summary.winRate === 'number' && Number.isFinite(props.summary.winRate)) {
@@ -39,7 +40,7 @@ const winRate = computed(() => {
const battles = props.summary.warnum; const battles = props.summary.warnum;
const wins = props.summary.wins; const wins = props.summary.wins;
if (typeof battles !== 'number' || battles <= 0 || typeof wins !== 'number') return '-'; if (typeof battles !== 'number' || battles <= 0 || typeof wins !== 'number') return '-';
return `${((wins / battles) * 100).toFixed(1)}%`; return `${((wins / battles) * 100).toFixed(2)}%`;
}); });
const killRate = computed(() => { const killRate = computed(() => {
@@ -49,7 +50,7 @@ const killRate = computed(() => {
const killed = props.summary.killCrew; const killed = props.summary.killCrew;
const lost = props.summary.deathCrew; const lost = props.summary.deathCrew;
if (typeof killed !== 'number' || typeof lost !== 'number' || lost <= 0) return '-'; if (typeof killed !== 'number' || typeof lost !== 'number' || lost <= 0) return '-';
return `${((killed / lost) * 100).toFixed(1)}%`; return `${((killed / lost) * 100).toFixed(2)}%`;
}); });
</script> </script>
@@ -64,8 +65,15 @@ const killRate = computed(() => {
><strong>{{ numberText(summary.warnum) }}<template v-if="summary.warnum != null"></template></strong> ><strong>{{ numberText(summary.warnum) }}<template v-if="summary.warnum != null"></template></strong>
<span>승리</span><strong>{{ numberText(summary.wins) }}</strong> <span>패배</span <span>승리</span><strong>{{ numberText(summary.wins) }}</strong> <span>패배</span
><strong>{{ numberText(summary.losses) }}</strong> <span>계략</span ><strong>{{ numberText(summary.losses) }}</strong> <span>계략</span
><strong>{{ numberText(summary.strategies) }}</strong> <span>사살</span ><strong>{{ numberText(summary.strategies) }}</strong>
><strong>{{ numberText(summary.killCrew) }}</strong> <span>피살</span <template v-if="summary.serviceYears !== undefined">
<span>사관</span
><strong
>{{ numberText(summary.serviceYears)
}}<template v-if="summary.serviceYears != null"></template></strong
>
</template>
<span>사살</span><strong>{{ numberText(summary.killCrew) }}</strong> <span>피살</span
><strong>{{ numberText(summary.deathCrew) }}</strong> ><strong>{{ numberText(summary.deathCrew) }}</strong>
<template v-if="showWinRate"> <template v-if="showWinRate">
<span>승률</span><strong>{{ winRate }}</strong> <span>살상률</span><strong>{{ killRate }}</strong> <span>승률</span><strong>{{ winRate }}</strong> <span>살상률</span><strong>{{ killRate }}</strong>
@@ -0,0 +1,56 @@
<script setup lang="ts">
import GeneralBasicCard, { type GeneralBasicCardData } from './GeneralBasicCard.vue';
import GeneralBattleSummary, { type GeneralBattleSummaryData } from './GeneralBattleSummary.vue';
import LegacyGeneralProgress from '../ui/LegacyGeneralProgress.vue';
type BasicProgression = NonNullable<GeneralBasicCardData['progression']>;
export type GeneralInformationPanelData = GeneralBasicCardData & {
progression: BasicProgression & {
statExperience: NonNullable<BasicProgression['statExperience']>;
statUpgradeLimit: number;
dex: number[];
};
};
const props = withDefaults(
defineProps<{
general: GeneralInformationPanelData | null;
summary: GeneralBattleSummaryData | null;
loading: boolean;
nationColor?: string | null;
defenceText?: string | null;
killTurn?: number | null;
remainingMinutes?: number | null;
troopText?: string | null;
penaltyText?: string | number | null;
}>(),
{
nationColor: '#173d27',
defenceText: null,
killTurn: null,
remainingMinutes: null,
troopText: null,
penaltyText: null,
}
);
</script>
<template>
<GeneralBasicCard
data-general-information-panel
:general="props.general"
:loading="props.loading"
:nation-color="props.nationColor"
:defence-text="props.defenceText"
:kill-turn="props.killTurn"
:remaining-minutes="props.remainingMinutes"
:troop-text="props.troopText"
:penalty-text="props.penaltyText"
>
<template v-if="props.general" #details>
<GeneralBattleSummary v-if="props.summary" :summary="props.summary" show-win-rate />
<LegacyGeneralProgress :general="props.general" :show-primary="false" />
</template>
</GeneralBasicCard>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, ref, useId, watch } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useElementSize, useMediaQuery, useMouseInElement } from '@vueuse/core'; import { onClickOutside, useElementSize, useMediaQuery, useMouseInElement } from '@vueuse/core';
import SkeletonLines from '../ui/SkeletonLines.vue'; import SkeletonLines from '../ui/SkeletonLines.vue';
import MapCityBasic from './MapCityBasic.vue'; import MapCityBasic from './MapCityBasic.vue';
import MapCityDetail from './MapCityDetail.vue'; import MapCityDetail from './MapCityDetail.vue';
@@ -145,6 +145,9 @@ const reduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
const mapArea = ref<HTMLElement | null>(null); const mapArea = ref<HTMLElement | null>(null);
const mapBody = ref<HTMLElement | null>(null); const mapBody = ref<HTMLElement | null>(null);
const mapControls = ref<HTMLElement | null>(null);
const mapOptionsOpen = ref(false);
const mapOptionsMenuId = `map-options-${useId()}`;
const { width: mapBodyWidth } = useElementSize(mapBody); const { width: mapBodyWidth } = useElementSize(mapBody);
const { elementX, elementY } = useMouseInElement(mapArea); const { elementX, elementY } = useMouseInElement(mapArea);
@@ -583,6 +586,16 @@ const clearTouchPreview = () => {
setHoveredCity(null); setHoveredCity(null);
}; };
const closeMapOptions = () => {
mapOptionsOpen.value = false;
};
const toggleMapOptions = () => {
mapOptionsOpen.value = !mapOptionsOpen.value;
};
onClickOutside(mapControls, closeMapOptions);
const touchCity = (cityId: number, event: TouchEvent) => { const touchCity = (cityId: number, event: TouchEvent) => {
if (touchPreviewCityId.value !== cityId) { if (touchPreviewCityId.value !== cityId) {
touchPreviewCityId.value = cityId; touchPreviewCityId.value = cityId;
@@ -627,7 +640,10 @@ const selectCity = (cityId: number) => {
class="map-area" class="map-area"
:class="[mapThemeClass, mapSeasonClass]" :class="[mapThemeClass, mapSeasonClass]"
:style="{ width: mapWidth, height: mapHeight }" :style="{ width: mapWidth, height: mapHeight }"
@click="clearTouchPreview" @click="
clearTouchPreview();
closeMapOptions();
"
> >
<div class="map-layer map-bglayer1" data-map-background-layer="current"> <div class="map-layer map-bglayer1" data-map-background-layer="current">
<img <img
@@ -686,8 +702,21 @@ const selectCity = (cityId: number) => {
<div class="tooltip-title">{{ hoveredCityTitle }}</div> <div class="tooltip-title">{{ hoveredCityTitle }}</div>
<div class="tooltip-body">{{ hoveredCity.nationId > 0 ? hoveredCity.nationName : '' }}</div> <div class="tooltip-body">{{ hoveredCity.nationId > 0 ? hoveredCity.nationName : '' }}</div>
</div> </div>
<div class="map-controls"> <div ref="mapControls" class="map-controls" @keydown.esc.stop="closeMapOptions">
<button class="map-toggle" :class="{ active: showCityName }" @click.stop="mapStore.toggleCityName"> <div
v-show="mapOptionsOpen"
:id="mapOptionsMenuId"
class="map-options-menu"
role="group"
aria-label="지도 옵션 메뉴"
@click.stop
>
<button
class="map-toggle"
:class="{ active: showCityName }"
:aria-pressed="showCityName"
@click="mapStore.toggleCityName"
>
도시명 표기 {{ showCityName ? '끄기' : '켜기' }} 도시명 표기 {{ showCityName ? '끄기' : '켜기' }}
</button> </button>
<button <button
@@ -695,11 +724,23 @@ const selectCity = (cityId: number) => {
class="map-toggle map-toggle-single-tap" class="map-toggle map-toggle-single-tap"
:class="{ active: singleTapNavigation }" :class="{ active: singleTapNavigation }"
:aria-pressed="singleTapNavigation" :aria-pressed="singleTapNavigation"
@click.stop="toggleSingleTapNavigation" @click="toggleSingleTapNavigation"
> >
두번 도시 이동 {{ singleTapNavigation ? '켜기' : '끄기' }} 두번 도시 이동 {{ singleTapNavigation ? '켜기' : '끄기' }}
</button> </button>
</div> </div>
<button
type="button"
class="map-options-trigger"
aria-label="지도 옵션"
title="지도 옵션"
:aria-controls="mapOptionsMenuId"
:aria-expanded="mapOptionsOpen"
@click.stop="toggleMapOptions"
>
<span aria-hidden="true"></span>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -782,14 +823,68 @@ const selectCity = (cityId: number) => {
.map-controls { .map-controls {
position: absolute; position: absolute;
z-index: 4; z-index: 4;
right: 4px; inset: 4px;
bottom: 4px; pointer-events: none;
}
.map-options-trigger {
position: absolute;
right: 0;
bottom: 0;
display: grid;
box-sizing: border-box;
width: 30px;
height: 28px;
place-items: center;
border: 1px solid #6c757d;
border-radius: 2px;
padding: 0;
background: #345c85;
color: #fff;
cursor: pointer;
font-size: 17px;
line-height: 1;
pointer-events: auto;
}
.map-options-trigger:hover,
.map-options-trigger:focus-visible,
.map-options-trigger[aria-expanded='true'] {
border-color: #b7cadc;
background: #284969;
}
.map-options-trigger:active {
background: #1f3a54;
}
.map-options-trigger:focus-visible {
outline: 2px solid #fff;
outline-offset: 1px;
}
.map-options-menu {
position: absolute;
right: 0;
bottom: 32px;
display: flex; display: flex;
max-width: 100%;
max-height: calc(100% - 32px);
flex-direction: column; flex-direction: column;
align-items: flex-end; align-items: stretch;
overflow-y: auto;
border: 1px solid #6c757d;
border-radius: 2px;
padding: 3px;
background: rgba(11, 11, 11, 0.94);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.55);
pointer-events: auto;
} }
.map-toggle { .map-toggle {
box-sizing: border-box;
width: max-content;
max-width: 100%;
border: 1px solid #6c757d; border: 1px solid #6c757d;
border-radius: 2px; border-radius: 2px;
padding: 3px 7px; padding: 3px 7px;
@@ -798,6 +893,12 @@ const selectCity = (cityId: number) => {
font-size: 11px; font-size: 11px;
line-height: 18px; line-height: 18px;
cursor: pointer; cursor: pointer;
text-align: left;
white-space: nowrap;
}
.map-toggle + .map-toggle {
margin-top: 3px;
} }
.map-toggle.active { .map-toggle.active {
@@ -113,7 +113,7 @@ watch(
background: #101010; background: #101010;
box-shadow: 0 3px 12px rgb(0 0 0 / 65%); box-shadow: 0 3px 12px rgb(0 0 0 / 65%);
color: #f5f5f5; color: #f5f5f5;
font-family: Pretendard, sans-serif; font-family: var(--sammo-font-sans);
font-size: 12.5px; font-size: 12.5px;
line-height: 1.45; line-height: 1.45;
text-align: left; text-align: left;
@@ -0,0 +1,100 @@
export type GeneralDirectorySortKey = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15;
export type GeneralDirectorySortDirection = 'ascending' | 'descending';
export type GeneralDirectorySortCriterion = {
key: GeneralDirectorySortKey;
direction: GeneralDirectorySortDirection;
};
type TraitValue = { key: string };
export type GeneralDirectorySortable = {
name: string;
nationId: number;
leadership: number;
strength: number;
intelligence: number;
experience: number;
dedication: number;
officerLevel: number;
killturn: number;
refreshScoreTotal: number;
personality: TraitValue;
specialDomestic: TraitValue;
specialWar: TraitValue;
age: number;
npcState: number;
};
const koreanNameCollator = new Intl.Collator('ko-KR', { numeric: true, sensitivity: 'base' });
const compareString = (left: string, right: string): number => {
if (left === right) return 0;
return left < right ? -1 : 1;
};
const compareByKey = <T extends GeneralDirectorySortable>(left: T, right: T, key: GeneralDirectorySortKey): number => {
switch (key) {
case 0:
return koreanNameCollator.compare(left.name, right.name);
case 1:
return left.nationId - right.nationId;
case 2:
return left.leadership - right.leadership;
case 3:
return left.strength - right.strength;
case 4:
return left.intelligence - right.intelligence;
case 5:
case 10:
return left.experience - right.experience;
case 6:
return left.dedication - right.dedication;
case 7:
return left.officerLevel - right.officerLevel;
case 8:
return left.killturn - right.killturn;
case 9:
return left.refreshScoreTotal - right.refreshScoreTotal;
case 11:
return compareString(left.personality.key, right.personality.key);
case 12:
return compareString(left.specialDomestic.key, right.specialDomestic.key);
case 13:
return compareString(left.specialWar.key, right.specialWar.key);
case 14:
return left.age - right.age;
case 15:
return left.npcState - right.npcState;
}
};
export const advanceGeneralDirectorySort = (
current: readonly GeneralDirectorySortCriterion[],
key: GeneralDirectorySortKey
): GeneralDirectorySortCriterion[] => {
const existing = current.find((criterion) => criterion.key === key);
const remaining = current.filter((criterion) => criterion.key !== key);
if (!existing) return [{ key, direction: 'descending' }, ...remaining];
if (existing.direction === 'descending') return [{ key, direction: 'ascending' }, ...remaining];
return remaining;
};
export const sortGeneralDirectory = <T extends GeneralDirectorySortable>(
source: readonly T[],
criteria: readonly GeneralDirectorySortCriterion[]
): T[] => {
if (criteria.length === 0) return [...source];
return source
.map((general, originalIndex) => ({ general, originalIndex }))
.sort((left, right) => {
for (const criterion of criteria) {
const compared = compareByKey(left.general, right.general, criterion.key);
if (compared !== 0) return criterion.direction === 'ascending' ? compared : -compared;
}
return left.originalIndex - right.originalIndex;
})
.map(({ general }) => general);
};
@@ -3,9 +3,7 @@ import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, onMounted, reactive, ref, watch } from 'vue'; import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import PanelCard from '../components/ui/PanelCard.vue'; import PanelCard from '../components/ui/PanelCard.vue';
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue'; import GeneralInformationPanel from '../components/main/GeneralInformationPanel.vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import GeneralBattleSummary from '../components/main/GeneralBattleSummary.vue';
import GeneralRecordPanels from '../components/main/GeneralRecordPanels.vue'; import GeneralRecordPanels from '../components/main/GeneralRecordPanels.vue';
import { import {
GENERAL_RECORD_TYPES, GENERAL_RECORD_TYPES,
@@ -273,15 +271,12 @@ onMounted(() => {
</PanelCard> </PanelCard>
<PanelCard title="장수 정보"> <PanelCard title="장수 정보">
<GeneralBasicCard <GeneralInformationPanel
class="battle-general-card" class="battle-general-card"
:general="selectedGeneral" :general="selectedGeneral"
:loading="loading" :summary="
:nation-color="data?.nation.color" selectedGeneral
> ? {
<template v-if="selectedGeneral" #details>
<GeneralBattleSummary
:summary="{
available: true, available: true,
experience: selectedGeneral.experience, experience: selectedGeneral.experience,
dedicationText: selectedGeneral.progression.dedicationText, dedicationText: selectedGeneral.progression.dedicationText,
@@ -289,14 +284,16 @@ onMounted(() => {
wins: selectedGeneral.battleStats.kills, wins: selectedGeneral.battleStats.kills,
losses: selectedGeneral.battleStats.deaths, losses: selectedGeneral.battleStats.deaths,
strategies: selectedGeneral.battleStats.fire, strategies: selectedGeneral.battleStats.fire,
serviceYears: selectedGeneral.serviceYears,
killCrew: selectedGeneral.battleStats.killCrew, killCrew: selectedGeneral.battleStats.killCrew,
deathCrew: selectedGeneral.battleStats.deathCrew, deathCrew: selectedGeneral.battleStats.deathCrew,
recentWar: selectedGeneral.recentWar, recentWar: selectedGeneral.recentWar,
}" }
: null
"
:loading="loading"
:nation-color="data?.nation.color"
/> />
<LegacyGeneralProgress :general="selectedGeneral" :show-primary="false" />
</template>
</GeneralBasicCard>
</PanelCard> </PanelCard>
</div> </div>
+41 -15
View File
@@ -1,15 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import GeneralDirectoryTable from '../components/directory/GeneralDirectoryTable.vue'; import GeneralDirectoryTable from '../components/directory/GeneralDirectoryTable.vue';
import LegacySortControls from '../components/ui/LegacySortControls.vue'; import LegacySortControls from '../components/ui/LegacySortControls.vue';
import { useGameFeedback } from '../composables/useGameFeedback';
import type { GeneralDirectoryGeneral } from '../types/directory'; import type { GeneralDirectoryGeneral } from '../types/directory';
import {
advanceGeneralDirectorySort,
sortGeneralDirectory,
type GeneralDirectorySortCriterion,
type GeneralDirectorySortKey,
} from '../utils/generalDirectorySort';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
type SortKey = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15; const sortOptions: Array<{ value: GeneralDirectorySortKey; label: string }> = [
{ value: 0, label: '이름' },
const sortOptions: Array<{ value: SortKey; label: string }> = [
{ value: 1, label: '국가' }, { value: 1, label: '국가' },
{ value: 2, label: '통솔' }, { value: 2, label: '통솔' },
{ value: 3, label: '무력' }, { value: 3, label: '무력' },
@@ -27,18 +33,26 @@ const sortOptions: Array<{ value: SortKey; label: string }> = [
{ value: 15, label: 'NPC' }, { value: 15, label: 'NPC' },
]; ];
const sort = ref<SortKey>(9); const selectedSort = ref<GeneralDirectorySortKey>(9);
const generals = ref<GeneralDirectoryGeneral[]>([]); const sourceGenerals = ref<GeneralDirectoryGeneral[]>([]);
const sortCriteria = ref<GeneralDirectorySortCriterion[]>([]);
const loading = ref(false); const loading = ref(false);
const error = ref(''); const error = ref('');
const router = useRouter(); const router = useRouter();
const { info: showInfoToast } = useGameFeedback();
const generals = computed(() => sortGeneralDirectory(sourceGenerals.value, sortCriteria.value));
const loadDirectory = async () => { const loadDirectory = async () => {
if (loading.value) {
showInfoToast('이미 장수 일람을 갱신하고 있습니다.');
return;
}
loading.value = true; loading.value = true;
error.value = ''; error.value = '';
try { try {
const result = await trpc.world.getGeneralDirectory.query({ sort: sort.value }); const result = await trpc.world.getGeneralDirectory.query({ sort: 9 });
generals.value = result.generals; sourceGenerals.value = Array.isArray(result.generals) ? result.generals : [];
} catch (cause) { } catch (cause) {
error.value = cause instanceof Error ? cause.message : '장수일람을 불러오지 못했습니다.'; error.value = cause instanceof Error ? cause.message : '장수일람을 불러오지 못했습니다.';
} finally { } finally {
@@ -47,12 +61,12 @@ const loadDirectory = async () => {
}; };
const updateSort = (value: number): void => { const updateSort = (value: number): void => {
sort.value = value as SortKey; selectedSort.value = value as GeneralDirectorySortKey;
}; };
const sortByHeader = (value: number): void => { const sortByHeader = (value: number): void => {
updateSort(value); updateSort(value);
void loadDirectory(); sortCriteria.value = advanceGeneralDirectorySort(sortCriteria.value, selectedSort.value);
}; };
onMounted(() => { onMounted(() => {
@@ -66,8 +80,15 @@ onMounted(() => {
<tbody> <tbody>
<tr> <tr>
<td> <td>
<br /><button class="legacy-button" type="button" @click="router.push('/')"> <br />
닫기 <button class="legacy-button" type="button" @click="router.push('/')"> 닫기</button>
<button
class="legacy-button"
type="button"
:aria-busy="loading || undefined"
@click="loadDirectory"
>
</button> </button>
</td> </td>
</tr> </tr>
@@ -75,11 +96,11 @@ onMounted(() => {
<td> <td>
<LegacySortControls <LegacySortControls
control-id="viewType" control-id="viewType"
:model-value="sort" :model-value="selectedSort"
:options="sortOptions" :options="sortOptions"
:busy="loading" :busy="loading"
@update:model-value="updateSort" @update:model-value="updateSort"
@submit="loadDirectory" @submit="sortByHeader(selectedSort)"
/> />
</td> </td>
</tr> </tr>
@@ -87,7 +108,12 @@ 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>
<GeneralDirectoryTable :generals="generals" :loading="loading" :active-sort="sort" @sort="sortByHeader" /> <GeneralDirectoryTable
:generals="generals"
:loading="loading"
:sort-criteria="sortCriteria"
@sort="sortByHeader"
/>
<table class="directory-table title-table legacy-bg0"> <table class="directory-table title-table legacy-bg0">
<tbody> <tbody>
+20 -56
View File
@@ -7,8 +7,7 @@ import { formatSeoulDateTime } from '../utils/legacyDateTime';
import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic/scenario/scenarioEffect.js'; import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic/scenario/scenarioEffect.js';
import { useSessionStore } from '../stores/session'; import { useSessionStore } from '../stores/session';
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon'; import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue'; import GeneralInformationPanel from '../components/main/GeneralInformationPanel.vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import { useGameFeedback } from '../composables/useGameFeedback'; import { useGameFeedback } from '../composables/useGameFeedback';
import { SCREEN_MODE_CHANGE_EVENT, SCREEN_MODE_KEY, type ScreenMode } from '../utils/screenModeViewport'; import { SCREEN_MODE_CHANGE_EVENT, SCREEN_MODE_KEY, type ScreenMode } from '../utils/screenModeViewport';
import { import {
@@ -136,9 +135,6 @@ const statusLine = computed(() =>
const canSave = computed(() => (data.value?.settings.myset ?? 1) > 0); const canSave = computed(() => (data.value?.settings.myset ?? 1) > 0);
const penalties = computed(() => Object.entries(data.value?.penalties ?? {})); const penalties = computed(() => Object.entries(data.value?.penalties ?? {}));
const numberText = (value: number): string => value.toLocaleString('ko-KR');
const percentText = (numerator: number, denominator: number): string =>
`${((numerator / Math.max(denominator, 1)) * 100).toFixed(2)}%`;
const noDefencePenaltyWaived = computed(() => { const noDefencePenaltyWaived = computed(() => {
const environment = asRecord(world.value?.config.environment); const environment = asRecord(world.value?.config.environment);
return isDefenceTrainPenaltyWaivedByScenarioEffect( return isDefenceTrainPenaltyWaivedByScenarioEffect(
@@ -416,55 +412,31 @@ onMounted(() => {
<section class="top-grid"> <section class="top-grid">
<div class="general-column"> <div class="general-column">
<div class="section-title sky">장수 정보</div> <div class="section-title sky">장수 정보</div>
<GeneralBasicCard <GeneralInformationPanel
class="general-table" class="general-table"
:general="data?.general ?? null" :general="data?.general ?? null"
:summary="
data
? {
available: true,
experience: data.general.experience,
dedicationText: data.general.progression?.dedicationText,
warnum: data.general.records.battles,
wins: data.general.records.wins,
losses: data.general.records.losses,
strategies: data.general.records.strategies,
serviceYears: data.general.records.serviceYears,
killCrew: data.general.records.killedCrew,
deathCrew: data.general.records.lostCrew,
recentWar: data.general.recentWar,
}
: null
"
:loading="loading" :loading="loading"
:nation-color="data?.nation?.color" :nation-color="data?.nation?.color"
:defence-text="form.defence_train === 999 ? '수비 안함' : `수비 (훈사${form.defence_train})`" :defence-text="form.defence_train === 999 ? '수비 안함' : `수비 (훈사${form.defence_train})`"
:penalty-text="penalties.length || '-'" :penalty-text="penalties.length || '-'"
> />
<template v-if="data" #details>
<div class="legacy-general-details">
<div>
명망
<strong
>Lv {{ data.general.progression?.experienceLevel ?? 0 }} ({{
data.general.experience
}})</strong
>
· 계급
<strong
>{{ data.general.progression?.dedicationText ?? '무품관' }} ({{
data.general.dedication
}})</strong
>
</div>
<div>
전투 {{ numberText(data.general.records.battles) }} · 계략
{{ numberText(data.general.records.strategies) }} · 사관
{{ numberText(data.general.records.serviceYears) }}
</div>
<div>
승률 {{ percentText(data.general.records.wins, data.general.records.battles) }} · 승리
{{ numberText(data.general.records.wins) }} · 패배
{{ numberText(data.general.records.losses) }}
</div>
<div>
살상률
{{ percentText(data.general.records.killedCrew, data.general.records.lostCrew) }} · 사살
{{ numberText(data.general.records.killedCrew) }} · 피살
{{ numberText(data.general.records.lostCrew) }}
</div>
<div>
소속 {{ data.nation?.name ?? '재야' }} · 도시 {{ data.city?.name ?? '-' }} · 병종
{{ data.general.crewTypeName ?? '-' }} · 내정특기
{{ data.general.traits?.specialDomestic ?? '-' }} · 부상 {{ data.general.injury }}
</div>
<LegacyGeneralProgress :general="data.general" :show-primary="false" />
</div>
</template>
</GeneralBasicCard>
</div> </div>
<div class="settings-column"> <div class="settings-column">
@@ -875,14 +847,6 @@ button:disabled {
.legacy-general-info-compat { .legacy-general-info-compat {
display: none; display: none;
} }
.legacy-general-details {
background: #172a52 var(--sammo-texture-blue);
line-height: 20px;
text-align: center;
}
.legacy-general-details > div {
border-top: 1px solid #557;
}
.legacy-credit { .legacy-credit {
max-width: 100%; max-width: 100%;
overflow: hidden; overflow: hidden;
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
advanceGeneralDirectorySort,
sortGeneralDirectory,
type GeneralDirectorySortable,
} from '../src/utils/generalDirectorySort.ts';
type Row = GeneralDirectorySortable & { id: number };
const row = (id: number, name: string, leadership: number, strength: number): Row => ({
id,
name,
nationId: id,
leadership,
strength,
intelligence: 0,
experience: 0,
dedication: 0,
officerLevel: 0,
killturn: 0,
refreshScoreTotal: 0,
personality: { key: '' },
specialDomestic: { key: '' },
specialWar: { key: '' },
age: 0,
npcState: 0,
});
void describe('general directory client sorting', () => {
void it('cycles each column through descending, ascending, and reset', () => {
const descending = advanceGeneralDirectorySort([], 2);
assert.deepEqual(descending, [{ key: 2, direction: 'descending' }]);
const ascending = advanceGeneralDirectorySort(descending, 2);
assert.deepEqual(ascending, [{ key: 2, direction: 'ascending' }]);
assert.deepEqual(advanceGeneralDirectorySort(ascending, 2), []);
});
void it('keeps the previous descending key as a tie-breaker for a later ascending key', () => {
const source = [row(1, '갑', 70, 20), row(2, '을', 90, 20), row(3, '병', 80, 10)];
const leadership = advanceGeneralDirectorySort([], 2);
const strengthDescending = advanceGeneralDirectorySort(leadership, 3);
const strengthAscending = advanceGeneralDirectorySort(strengthDescending, 3);
assert.deepEqual(
sortGeneralDirectory(source, strengthAscending).map(({ id }) => id),
[3, 2, 1]
);
});
void it('removes only the reset column and restores the older stable ordering', () => {
const source = [row(1, '갑', 70, 20), row(2, '을', 90, 20), row(3, '병', 80, 10)];
const leadership = advanceGeneralDirectorySort([], 2);
const strengthDescending = advanceGeneralDirectorySort(leadership, 3);
const strengthAscending = advanceGeneralDirectorySort(strengthDescending, 3);
const resetStrength = advanceGeneralDirectorySort(strengthAscending, 3);
assert.deepEqual(resetStrength, leadership);
assert.deepEqual(
sortGeneralDirectory(source, resetStrength).map(({ id }) => id),
[2, 3, 1]
);
});
void it('sorts Korean names in both directions without mutating the source', () => {
const source = [row(1, '조조', 0, 0), row(2, '가후', 0, 0), row(3, '유비', 0, 0)];
const descending = advanceGeneralDirectorySort([], 0);
const ascending = advanceGeneralDirectorySort(descending, 0);
assert.deepEqual(
sortGeneralDirectory(source, descending).map(({ name }) => name),
['조조', '유비', '가후']
);
assert.deepEqual(
sortGeneralDirectory(source, ascending).map(({ name }) => name),
['가후', '유비', '조조']
);
assert.deepEqual(
source.map(({ id }) => id),
[1, 2, 3]
);
});
});
+6 -3
View File
@@ -31,9 +31,12 @@ The Ref-style directory pages share a second, deliberately compact control
family through `LegacySortControls.vue`. Its `.legacy-sort-*` rules own the family through `LegacySortControls.vue`. Its `.legacy-sort-*` rules own the
explicit dark select/option palette, the raised submit button, and the explicit dark select/option palette, the raised submit button, and the
focus/active states for sortable table headers. A page supplies only the focus/active states for sortable table headers. A page supplies only the
available legacy sort keys, their fixed directions, and placement. Columns available sort keys and placement. NPC·암행부·세력도시는 Ref의 고정 방향을
without an unambiguous legacy sort key remain plain headers rather than 유지합니다. 장수 일람은 사용자 조작 계약에 따라 로드한 snapshot 위에서
inventing a new ordering contract. `내림차순 → 오름차순 → 해제`를 순환하고, 여러 열의 방향과 우선순위를 scoped
SFC indicator로 표시합니다. 장수 일람의 성격·특기·부상 설명은 많은 행에서
eager popup instance를 만들지 않는 `DirectoryTooltip.vue` scoped CSS가 소유하며,
mobile에서는 viewport 가장자리 8px 안의 고정 설명판으로 전환합니다.
## Button composition ## Button composition