merge: 최신 main을 지도 계절 전환에 반영한다
# Conflicts: # app/game-frontend/src/components/main/MapViewer.vue
This commit is contained in:
@@ -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([
|
||||||
by: ['generalId'],
|
ctx.db.logEntry.groupBy({
|
||||||
where: {
|
by: ['generalId'],
|
||||||
generalId: { in: generalIds },
|
where: {
|
||||||
category: LogCategory.BATTLE_BRIEF,
|
generalId: { in: generalIds },
|
||||||
},
|
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 => {
|
||||||
const value = meta[key];
|
for (const key of Array.isArray(keys) ? keys : [keys]) {
|
||||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
const value = meta[key];
|
||||||
|
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' } }),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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(outgoingLayer).toHaveCSS('transition-duration', '0.48s');
|
||||||
|
let midpointOpacity = 0;
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
midpointOpacity = Number.parseFloat(
|
||||||
|
await outgoingLayer.evaluate((element) => getComputedStyle(element).opacity)
|
||||||
|
);
|
||||||
|
return midpointOpacity > 0 && midpointOpacity < 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(currentLayer.locator('img')).toHaveAttribute('src', /bg_summer\.jpg/u);
|
||||||
await expect(outgoingLayer.locator('img')).toHaveAttribute('src', /bg_spring\.jpg/u);
|
await expect(outgoingLayer.locator('img')).toHaveAttribute('src', /bg_spring\.jpg/u);
|
||||||
await page.waitForTimeout(180);
|
|
||||||
|
|
||||||
const midpointOpacity = Number.parseFloat(
|
|
||||||
await outgoingLayer.evaluate((element) => getComputedStyle(element).opacity)
|
|
||||||
);
|
|
||||||
expect(midpointOpacity).toBeGreaterThan(0);
|
|
||||||
expect(midpointOpacity).toBeLessThan(1);
|
|
||||||
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,18 +702,43 @@ 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
|
||||||
도시명 표기 {{ showCityName ? '끄기' : '켜기' }}
|
v-show="mapOptionsOpen"
|
||||||
</button>
|
:id="mapOptionsMenuId"
|
||||||
<button
|
class="map-options-menu"
|
||||||
v-if="hasTouchInput && !isSelectionMap && !props.readonly"
|
role="group"
|
||||||
class="map-toggle map-toggle-single-tap"
|
aria-label="지도 옵션 메뉴"
|
||||||
:class="{ active: singleTapNavigation }"
|
@click.stop
|
||||||
:aria-pressed="singleTapNavigation"
|
|
||||||
@click.stop="toggleSingleTapNavigation"
|
|
||||||
>
|
>
|
||||||
두번 탭 해 도시 이동 {{ singleTapNavigation ? '켜기' : '끄기' }}
|
<button
|
||||||
|
class="map-toggle"
|
||||||
|
:class="{ active: showCityName }"
|
||||||
|
:aria-pressed="showCityName"
|
||||||
|
@click="mapStore.toggleCityName"
|
||||||
|
>
|
||||||
|
도시명 표기 {{ showCityName ? '끄기' : '켜기' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="hasTouchInput && !isSelectionMap && !props.readonly"
|
||||||
|
class="map-toggle map-toggle-single-tap"
|
||||||
|
:class="{ active: singleTapNavigation }"
|
||||||
|
:aria-pressed="singleTapNavigation"
|
||||||
|
@click="toggleSingleTapNavigation"
|
||||||
|
>
|
||||||
|
두번 탭 해 도시 이동 {{ singleTapNavigation ? '켜기' : '끄기' }}
|
||||||
|
</button>
|
||||||
|
</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>
|
</button>
|
||||||
</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,30 +271,29 @@ onMounted(() => {
|
|||||||
</PanelCard>
|
</PanelCard>
|
||||||
|
|
||||||
<PanelCard title="장수 정보">
|
<PanelCard title="장수 정보">
|
||||||
<GeneralBasicCard
|
<GeneralInformationPanel
|
||||||
class="battle-general-card"
|
class="battle-general-card"
|
||||||
:general="selectedGeneral"
|
:general="selectedGeneral"
|
||||||
|
:summary="
|
||||||
|
selectedGeneral
|
||||||
|
? {
|
||||||
|
available: true,
|
||||||
|
experience: selectedGeneral.experience,
|
||||||
|
dedicationText: selectedGeneral.progression.dedicationText,
|
||||||
|
warnum: selectedGeneral.warnum,
|
||||||
|
wins: selectedGeneral.battleStats.kills,
|
||||||
|
losses: selectedGeneral.battleStats.deaths,
|
||||||
|
strategies: selectedGeneral.battleStats.fire,
|
||||||
|
serviceYears: selectedGeneral.serviceYears,
|
||||||
|
killCrew: selectedGeneral.battleStats.killCrew,
|
||||||
|
deathCrew: selectedGeneral.battleStats.deathCrew,
|
||||||
|
recentWar: selectedGeneral.recentWar,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:nation-color="data?.nation.color"
|
:nation-color="data?.nation.color"
|
||||||
>
|
/>
|
||||||
<template v-if="selectedGeneral" #details>
|
|
||||||
<GeneralBattleSummary
|
|
||||||
:summary="{
|
|
||||||
available: true,
|
|
||||||
experience: selectedGeneral.experience,
|
|
||||||
dedicationText: selectedGeneral.progression.dedicationText,
|
|
||||||
warnum: selectedGeneral.warnum,
|
|
||||||
wins: selectedGeneral.battleStats.kills,
|
|
||||||
losses: selectedGeneral.battleStats.deaths,
|
|
||||||
strategies: selectedGeneral.battleStats.fire,
|
|
||||||
killCrew: selectedGeneral.battleStats.killCrew,
|
|
||||||
deathCrew: selectedGeneral.battleStats.deathCrew,
|
|
||||||
recentWar: selectedGeneral.recentWar,
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
<LegacyGeneralProgress :general="selectedGeneral" :show-primary="false" />
|
|
||||||
</template>
|
|
||||||
</GeneralBasicCard>
|
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user