merge: 최신 main을 장수 일람 안정 정렬에 반영한다

This commit is contained in:
2026-08-21 16:33:38 +00:00
11 changed files with 460 additions and 146 deletions
+2
View File
@@ -265,6 +265,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
dedication: true, dedication: true,
age: true, age: true,
turnTime: true, turnTime: true,
recentWarTime: true,
crewTypeId: true, crewTypeId: true,
personalCode: true, personalCode: true,
specialCode: true, specialCode: true,
@@ -519,6 +520,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
age: general.age, age: general.age,
retirementYear, retirementYear,
turnTime: general.turnTime.toISOString(), turnTime: general.turnTime.toISOString(),
recentWar: general.recentWarTime?.toISOString() ?? null,
defenceTrain: settings.defence_train, defenceTrain: settings.defence_train,
killTurn: readNumber(metaRecord.killturn ?? metaRecord.killTurn, 0), killTurn: readNumber(metaRecord.killturn ?? metaRecord.killTurn, 0),
remainingMinutes: resolveRemainingMinutes( remainingMinutes: resolveRemainingMinutes(
@@ -1,6 +1,6 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { asRecord } from '@sammo-ts/common'; import { asRecord, type RankDataType } from '@sammo-ts/common';
import { LogCategory } from '@sammo-ts/logic'; import { LogCategory } from '@sammo-ts/logic';
import { accessAuthedProcedure } from '../../../trpc.js'; import { accessAuthedProcedure } from '../../../trpc.js';
@@ -14,6 +14,15 @@ import {
import { getMyGeneral } from '../../shared/general.js'; import { getMyGeneral } from '../../shared/general.js';
import { assertNationAccess, formatDateTime, loadTraitNames, resolveNationPermission } from '../shared.js'; import { assertNationAccess, formatDateTime, loadTraitNames, resolveNationPermission } from '../shared.js';
const BATTLE_CENTER_RECORD_TYPES = [
'firenum',
'warnum',
'killnum',
'deathnum',
'killcrew',
'deathcrew',
] as const satisfies readonly RankDataType[];
export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => { export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx); const me = await getMyGeneral(ctx);
assertNationAccess(me); assertNationAccess(me);
@@ -81,23 +90,38 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
} }
const generalIds = generalRows.map((general) => general.id); const generalIds = generalRows.map((general) => general.id);
const battleCounts = const [battleCounts, rankRows] =
generalIds.length > 0 generalIds.length > 0
? await ctx.db.logEntry.groupBy({ ? await Promise.all([
ctx.db.logEntry.groupBy({
by: ['generalId'], by: ['generalId'],
where: { where: {
generalId: { in: generalIds }, generalId: { in: generalIds },
category: LogCategory.BATTLE_BRIEF, category: LogCategory.BATTLE_BRIEF,
}, },
_count: { _all: true }, _count: { _all: true },
}) }),
: []; ctx.db.rankData.findMany({
where: {
generalId: { in: generalIds },
type: { in: [...BATTLE_CENTER_RECORD_TYPES] },
},
select: { generalId: true, type: true, value: true },
}),
])
: [[], []];
const battleCountMap = new Map<number, number>(); const battleCountMap = new Map<number, number>();
for (const row of battleCounts) { for (const row of battleCounts) {
if (row.generalId !== null) { if (row.generalId !== null) {
battleCountMap.set(row.generalId, row._count._all); battleCountMap.set(row.generalId, row._count._all);
} }
} }
const rankValueMap = new Map<number, Map<RankDataType, number>>();
for (const row of rankRows) {
const values = rankValueMap.get(row.generalId) ?? new Map<RankDataType, number>();
values.set(row.type as (typeof BATTLE_CENTER_RECORD_TYPES)[number], row.value);
rankValueMap.set(row.generalId, values);
}
const worldConfig = asRecord(worldState.config); const worldConfig = asRecord(worldState.config);
const constValues = asRecord(worldConfig.const ?? worldConfig.consts); const constValues = asRecord(worldConfig.const ?? worldConfig.consts);
@@ -141,10 +165,18 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
general.meta && typeof general.meta === 'object' && !Array.isArray(general.meta) general.meta && typeof general.meta === 'object' && !Array.isArray(general.meta)
? (general.meta as Record<string, unknown>) ? (general.meta as Record<string, unknown>)
: {}; : {};
const metaNumber = (key: string): number => { const metaNumber = (keys: string | string[], fallback = 0): number => {
for (const key of Array.isArray(keys) ? keys : [keys]) {
const value = meta[key]; const value = meta[key];
return typeof value === 'number' && Number.isFinite(value) ? value : 0; if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
}
return fallback;
}; };
const rankValue = (type: (typeof BATTLE_CENTER_RECORD_TYPES)[number], fallback = 0): number =>
rankValueMap.get(general.id)?.get(type) ?? fallback;
const warnum = rankValue('warnum', metaNumber(['rank_warnum', 'warnum'], battleCountMap.get(general.id) ?? 0));
const storedDedicationLevel = metaNumber('dedlevel'); const storedDedicationLevel = metaNumber('dedlevel');
const dedicationLevel = const dedicationLevel =
storedDedicationLevel > 0 storedDedicationLevel > 0
@@ -161,7 +193,7 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
cityId: general.cityId, cityId: general.cityId,
turnTime: formatDateTime(general.turnTime), turnTime: formatDateTime(general.turnTime),
recentWar: formatDateTime(general.recentWarTime), recentWar: formatDateTime(general.recentWarTime),
warnum: battleCountMap.get(general.id) ?? 0, warnum,
stats: { stats: {
leadership: general.leadership, leadership: general.leadership,
strength: general.strength, strength: general.strength,
@@ -176,6 +208,8 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
train: general.train, train: general.train,
atmos: general.atmos, atmos: general.atmos,
age: general.age, age: general.age,
defenceTrain: metaNumber('defence_train', 80),
killTurn: metaNumber(['killturn', 'killTurn']),
crewTypeId: general.crewTypeId, crewTypeId: general.crewTypeId,
crewTypeName: crewTypeNames.get(general.crewTypeId) ?? '-', crewTypeName: crewTypeNames.get(general.crewTypeId) ?? '-',
equipment: { equipment: {
@@ -207,12 +241,13 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
statUpgradeLimit, statUpgradeLimit,
dex: [1, 2, 3, 4, 5].map((index) => metaNumber(`dex${index}`)), dex: [1, 2, 3, 4, 5].map((index) => metaNumber(`dex${index}`)),
}, },
serviceYears: metaNumber('belong'),
battleStats: { battleStats: {
kills: metaNumber('rank_killnum') || metaNumber('killnum'), kills: rankValue('killnum', metaNumber(['rank_killnum', 'killnum'])),
deaths: metaNumber('deathnum'), deaths: rankValue('deathnum', metaNumber(['rank_deathnum', 'deathnum'])),
fire: metaNumber('firenum'), fire: rankValue('firenum', metaNumber(['rank_firenum', 'firenum'])),
killCrew: metaNumber('killcrew'), killCrew: rankValue('killcrew', metaNumber(['rank_killcrew', 'killcrew'])),
deathCrew: metaNumber('deathcrew'), deathCrew: rankValue('deathcrew', metaNumber(['rank_deathcrew', 'deathcrew'])),
dex: [1, 2, 3, 4, 5].map((index) => metaNumber(`dex${index}`)), dex: [1, 2, 3, 4, 5].map((index) => metaNumber(`dex${index}`)),
}, },
}; };
@@ -85,7 +85,7 @@ const createContext = (options: {
troopLeaderAction?: string | null; troopLeaderAction?: string | null;
refreshScore?: number; refreshScore?: number;
refreshScoreTotal?: number; refreshScoreTotal?: number;
rankRows?: Array<{ type: string; value: number }>; rankRows?: Array<{ generalId?: number; type: string; value: number }>;
requestId?: string; requestId?: string;
transaction?: ReturnType<typeof vi.fn>; transaction?: ReturnType<typeof vi.fn>;
}) => { }) => {
@@ -130,7 +130,9 @@ const createContext = (options: {
})), })),
}, },
rankData: { rankData: {
findMany: vi.fn(async () => options.rankRows ?? []), findMany: vi.fn(async () =>
(options.rankRows ?? []).map((row) => ({ generalId: row.generalId ?? me?.id ?? 0, ...row }))
),
}, },
city: { city: {
findUnique: vi.fn(async () => options.city ?? null), findUnique: vi.fn(async () => options.city ?? null),
@@ -812,7 +814,23 @@ describe('battle-center general and user permissions', () => {
}); });
const tenured = createContext({ const tenured = createContext({
me: buildGeneral({ officerLevel: 1, meta: { belong: 3, permission: 'normal' } }), me: buildGeneral({
officerLevel: 1,
meta: {
belong: 3,
permission: 'normal',
killturn: 6,
defence_train: 80,
},
}),
rankRows: [
{ type: 'warnum', value: 8 },
{ type: 'killnum', value: 5 },
{ type: 'deathnum', value: 3 },
{ type: 'firenum', value: 12 },
{ type: 'killcrew', value: 12_345 },
{ type: 'deathcrew', value: 6_789 },
],
nationMeta: { secretlimit: 3 }, nationMeta: { secretlimit: 3 },
}); });
await expect(appRouter.createCaller(tenured.context).nation.getBattleCenter()).resolves.toMatchObject({ await expect(appRouter.createCaller(tenured.context).nation.getBattleCenter()).resolves.toMatchObject({
@@ -823,6 +841,9 @@ describe('battle-center general and user permissions', () => {
picture: 'default.jpg', picture: 'default.jpg',
imageServer: 0, imageServer: 0,
officerLevelText: '일반', officerLevelText: '일반',
warnum: 8,
defenceTrain: 80,
killTurn: 6,
crewTypeName: '-', crewTypeName: '-',
equipmentNames: { weapon: '-', book: '-', horse: '-', item: '-' }, equipmentNames: { weapon: '-', book: '-', horse: '-', item: '-' },
traits: { personal: '-', specialDomestic: '-', specialWar: '-' }, traits: { personal: '-', specialDomestic: '-', specialWar: '-' },
@@ -834,10 +855,25 @@ describe('battle-center general and user permissions', () => {
statUpgradeLimit: 20, statUpgradeLimit: 20,
dex: [0, 0, 0, 0, 0], dex: [0, 0, 0, 0, 0],
}, },
battleStats: { kills: 0, deaths: 0, fire: 0, killCrew: 0, deathCrew: 0, dex: [0, 0, 0, 0, 0] }, serviceYears: 3,
battleStats: {
kills: 5,
deaths: 3,
fire: 12,
killCrew: 12_345,
deathCrew: 6_789,
dex: [0, 0, 0, 0, 0],
},
}, },
], ],
}); });
expect(tenured.db.rankData.findMany).toHaveBeenCalledWith({
where: {
generalId: { in: [7] },
type: { in: ['firenum', 'warnum', 'killnum', 'deathnum', 'killcrew', 'deathcrew'] },
},
select: { generalId: true, type: true, value: true },
});
const auditor = createContext({ const auditor = createContext({
me: buildGeneral({ officerLevel: 1, meta: { belong: 0, permission: 'auditor' } }), me: buildGeneral({ officerLevel: 1, meta: { belong: 0, permission: 'auditor' } }),
+75 -12
View File
@@ -475,6 +475,27 @@ test('map keeps desktop hover navigation and lets touch users choose one-tap or
await page.setViewportSize({ width: 1200, height: 900 }); await page.setViewportSize({ width: 1200, height: 900 });
await go(page, 'global-info'); await go(page, 'global-info');
const desktopOptionsTrigger = page.getByRole('button', { name: '지도 옵션' });
await expect(desktopOptionsTrigger).toBeVisible();
await expect(desktopOptionsTrigger).toHaveAttribute('aria-expanded', 'false');
await expect(desktopOptionsTrigger).toHaveCSS('background-color', 'rgb(52, 92, 133)');
await desktopOptionsTrigger.hover();
await expect(desktopOptionsTrigger).toHaveCSS('background-color', 'rgb(40, 73, 105)');
await expect(page.getByRole('button', { name: '도시명 표기 끄기' })).not.toBeVisible();
await desktopOptionsTrigger.click();
const cityNameToggle = page.getByRole('button', { name: '도시명 표기 끄기' });
await expect(cityNameToggle).toBeVisible();
await expect(desktopOptionsTrigger).toHaveAttribute('aria-expanded', 'true');
await expect(page.locator('.map-options-menu .map-toggle')).toHaveCount(1);
await page.keyboard.press('Tab');
await desktopOptionsTrigger.focus();
await expect(desktopOptionsTrigger).toHaveCSS('outline-style', 'solid');
await page.screenshot({ path: testInfo.outputPath('desktop-map-options-open.png'), fullPage: true });
await cityNameToggle.click();
await expect(page.locator('.map-area .city-name')).toHaveCount(0);
await page.keyboard.press('Escape');
await expect(desktopOptionsTrigger).toHaveAttribute('aria-expanded', 'false');
const desktopCity = page.locator('.city-base').first(); const desktopCity = page.locator('.city-base').first();
await expect(page.locator('.map-toggle-single-tap')).toHaveCount(0); await expect(page.locator('.map-toggle-single-tap')).toHaveCount(0);
await desktopCity.hover(); await desktopCity.hover();
@@ -508,20 +529,48 @@ test('map keeps desktop hover navigation and lets touch users choose one-tap or
await install(mobilePage); await install(mobilePage);
await go(mobilePage, 'global-info'); await go(mobilePage, 'global-info');
const mobileOptionsTrigger = mobilePage.getByRole('button', { name: '지도 옵션' });
await expect(mobileOptionsTrigger).toBeVisible();
await expect(mobileOptionsTrigger).toHaveAttribute('aria-expanded', 'false');
await mobileOptionsTrigger.tap();
await expect(mobileOptionsTrigger).toHaveAttribute('aria-expanded', 'true');
await expect(mobilePage.locator('.map-options-menu .map-toggle')).toHaveCount(2);
const twoTapButton = mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 끄기' }); const twoTapButton = mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 끄기' });
await expect(twoTapButton).toBeVisible(); await expect(twoTapButton).toBeVisible();
await expect(twoTapButton).toHaveAttribute('aria-pressed', 'false'); await expect(twoTapButton).toHaveAttribute('aria-pressed', 'false');
const controlGeometry = await twoTapButton.evaluate((element) => { const controlGeometry = await mobilePage.locator('.map-controls').evaluate((element) => {
const rect = element.getBoundingClientRect(); const triggerRect = element.querySelector('.map-options-trigger')!.getBoundingClientRect();
const menuRect = element.querySelector('.map-options-menu')!.getBoundingClientRect();
const optionRects = Array.from(element.querySelectorAll('.map-options-menu .map-toggle')).map((option) => {
const rect = option.getBoundingClientRect();
return { top: rect.top, bottom: rect.bottom };
});
const mapRect = element.closest('.map-area')?.getBoundingClientRect(); const mapRect = element.closest('.map-area')?.getBoundingClientRect();
const style = getComputedStyle(element); const optionStyle = getComputedStyle(element.querySelector('.map-toggle')!);
return { return {
right: rect.right, trigger: {
bottom: rect.bottom, right: triggerRect.right,
mapRight: mapRect?.right, bottom: triggerRect.bottom,
mapBottom: mapRect?.bottom, top: triggerRect.top,
fontSize: style.fontSize, },
lineHeight: style.lineHeight, menu: {
left: menuRect.left,
right: menuRect.right,
top: menuRect.top,
bottom: menuRect.bottom,
},
optionRects,
map: mapRect
? {
left: mapRect.left,
right: mapRect.right,
top: mapRect.top,
bottom: mapRect.bottom,
}
: null,
fontSize: optionStyle.fontSize,
lineHeight: optionStyle.lineHeight,
documentWidth: document.documentElement.scrollWidth, documentWidth: document.documentElement.scrollWidth,
viewportWidth: document.documentElement.clientWidth, viewportWidth: document.documentElement.clientWidth,
overflowing: Array.from(document.querySelectorAll<HTMLElement>('body *')) overflowing: Array.from(document.querySelectorAll<HTMLElement>('body *'))
@@ -543,8 +592,20 @@ test('map keeps desktop hover navigation and lets touch users choose one-tap or
overflowing: [], overflowing: [],
}); });
expect(controlGeometry.documentWidth).toBeLessThanOrEqual(controlGeometry.viewportWidth + 1); expect(controlGeometry.documentWidth).toBeLessThanOrEqual(controlGeometry.viewportWidth + 1);
expect(controlGeometry.mapRight! - controlGeometry.right).toBeCloseTo(4, 1); expect(controlGeometry.map).not.toBeNull();
expect(controlGeometry.mapBottom! - controlGeometry.bottom).toBeCloseTo(4, 1); expect(controlGeometry.map!.right - controlGeometry.trigger.right).toBeCloseTo(4, 1);
expect(controlGeometry.map!.bottom - controlGeometry.trigger.bottom).toBeCloseTo(4, 1);
expect(controlGeometry.menu.left).toBeGreaterThanOrEqual(controlGeometry.map!.left);
expect(controlGeometry.menu.right).toBeLessThanOrEqual(controlGeometry.map!.right);
expect(controlGeometry.menu.top).toBeGreaterThanOrEqual(controlGeometry.map!.top);
expect(controlGeometry.menu.bottom).toBeLessThan(controlGeometry.trigger.top);
expect(controlGeometry.optionRects).toHaveLength(2);
expect(controlGeometry.optionRects[0]!.bottom).toBeLessThanOrEqual(controlGeometry.optionRects[1]!.top);
expect(controlGeometry.optionRects[1]!.bottom).toBeLessThanOrEqual(controlGeometry.menu.bottom);
await mobilePage.screenshot({ path: testInfo.outputPath('mobile-map-options-open.png'), fullPage: true });
await mobilePage.locator('.map-area').tap({ position: { x: 10, y: 10 } });
await expect(mobileOptionsTrigger).toHaveAttribute('aria-expanded', 'false');
const mobileCities = mobilePage.locator('.city-base'); const mobileCities = mobilePage.locator('.city-base');
await mobileCities.nth(0).tap(); await mobileCities.nth(0).tap();
@@ -560,17 +621,19 @@ test('map keeps desktop hover navigation and lets touch users choose one-tap or
await expect(mobilePage).toHaveURL(/\/current-city\?cityId=2$/u); await expect(mobilePage).toHaveURL(/\/current-city\?cityId=2$/u);
await go(mobilePage, 'global-info'); await go(mobilePage, 'global-info');
await mobilePage.getByRole('button', { name: '지도 옵션' }).tap();
await mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 끄기' }).click(); await mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 끄기' }).click();
const singleTapButton = mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 켜기' }); const singleTapButton = mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 켜기' });
await expect(singleTapButton).toHaveAttribute('aria-pressed', 'true'); await expect(singleTapButton).toHaveAttribute('aria-pressed', 'true');
expect(await mobilePage.evaluate(() => localStorage.getItem('sam.toggleSingleTap'))).toBe('yes'); expect(await mobilePage.evaluate(() => localStorage.getItem('sam.toggleSingleTap'))).toBe('yes');
await mobilePage.reload(); await mobilePage.reload();
await expect(singleTapButton).toBeVisible(); await expect(singleTapButton).not.toBeVisible();
await mobilePage.locator('.city-base').nth(2).tap(); await mobilePage.locator('.city-base').nth(2).tap();
await expect(mobilePage).toHaveURL(/\/current-city\?cityId=3$/u); await expect(mobilePage).toHaveURL(/\/current-city\?cityId=3$/u);
await go(mobilePage, 'global-info'); await go(mobilePage, 'global-info');
await mobilePage.getByRole('button', { name: '지도 옵션' }).tap();
await mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 켜기' }).click(); await mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 켜기' }).click();
expect(await mobilePage.evaluate(() => localStorage.getItem('sam.toggleSingleTap'))).toBe('no'); expect(await mobilePage.evaluate(() => localStorage.getItem('sam.toggleSingleTap'))).toBe('no');
await mobilePage.locator('.city-base').first().tap(); await mobilePage.locator('.city-base').first().tap();
+58 -7
View File
@@ -102,6 +102,9 @@ const myGeneral = (state: FixtureState) => ({
dedication: 200, dedication: 200,
age: 30, age: 30,
turnTime: '2026-01-01 00:10:00', turnTime: '2026-01-01 00:10:00',
recentWar: '2026-01-01 00:00:00',
defenceTrain: 80,
killTurn: 6,
crewTypeId: 1, crewTypeId: 1,
crewTypeName: '보병', crewTypeName: '보병',
crewTypeInfo: state.richMyInfo crewTypeInfo: state.richMyInfo
@@ -277,7 +280,7 @@ const battleCenter = (state: FixtureState) => ({
cityId: 1, cityId: 1,
turnTime: '2026-01-01 00:10:00', turnTime: '2026-01-01 00:10:00',
recentWar: '2026-01-01 00:00:00', recentWar: '2026-01-01 00:00:00',
warnum: 3, warnum: 8,
stats: { leadership: 70, strength: 60, intelligence: 50 }, stats: { leadership: 70, strength: 60, intelligence: 50 },
experience: 100, experience: 100,
dedication: 200, dedication: 200,
@@ -288,6 +291,8 @@ const battleCenter = (state: FixtureState) => ({
train: 80, train: 80,
atmos: 90, atmos: 90,
age: 30, age: 30,
defenceTrain: 80,
killTurn: 6,
crewTypeId: 1, crewTypeId: 1,
crewTypeName: '보병', crewTypeName: '보병',
equipment: { weapon: 'None', book: 'None', horse: 'None', item: 'None' }, equipment: { weapon: 'None', book: 'None', horse: 'None', item: 'None' },
@@ -301,7 +306,8 @@ const battleCenter = (state: FixtureState) => ({
statUpgradeLimit: 20, statUpgradeLimit: 20,
dex: [350, 1_375, 3_500, 7_125, 1_275_975], dex: [350, 1_375, 3_500, 7_125, 1_275_975],
}, },
battleStats: { kills: 1, deaths: 2, fire: 0, killCrew: 300, deathCrew: 100, dex: [] }, serviceYears: 4,
battleStats: { kills: 5, deaths: 3, fire: 12, killCrew: 12_345, deathCrew: 6_789, dex: [] },
}, },
{ {
id: 8, id: 8,
@@ -323,6 +329,8 @@ const battleCenter = (state: FixtureState) => ({
train: 60, train: 60,
atmos: 60, atmos: 60,
age: 20, age: 20,
defenceTrain: 80,
killTurn: 4,
crewTypeId: 1, crewTypeId: 1,
crewTypeName: '보병', crewTypeName: '보병',
equipment: { weapon: 'None', book: 'None', horse: 'None', item: 'None' }, equipment: { weapon: 'None', book: 'None', horse: 'None', item: 'None' },
@@ -336,6 +344,7 @@ const battleCenter = (state: FixtureState) => ({
statUpgradeLimit: 20, statUpgradeLimit: 20,
dex: [0, 0, 0, 0, 0], dex: [0, 0, 0, 0, 0],
}, },
serviceYears: 1,
battleStats: { kills: 0, deaths: 0, fire: 0, killCrew: 0, deathCrew: 0, dex: [] }, battleStats: { kills: 0, deaths: 0, fire: 0, killCrew: 0, deathCrew: 0, dex: [] },
}, },
], ],
@@ -1213,6 +1222,7 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
await page.setViewportSize({ width: 1000, height: 900 }); await page.setViewportSize({ width: 1000, height: 900 });
await page.goto('my-page'); await page.goto('my-page');
await expect(page.locator('.general-table')).toHaveAttribute('data-general-basic-card', ''); await expect(page.locator('.general-table')).toHaveAttribute('data-general-basic-card', '');
await expect(page.locator('.general-table')).toHaveAttribute('data-general-information-panel', '');
const myPageImages = await readGeneralPanelImages(page.locator('.general-table')); const myPageImages = await readGeneralPanelImages(page.locator('.general-table'));
expect(myPageImages.map(({ width, height }) => ({ width, height }))).toEqual([ expect(myPageImages.map(({ width, height }) => ({ width, height }))).toEqual([
{ width: 64, height: 64 }, { width: 64, height: 64 },
@@ -1220,11 +1230,32 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
]); ]);
expect(myPageImages[0]?.backgroundImage).toContain('/icons/default.jpg'); expect(myPageImages[0]?.backgroundImage).toContain('/icons/default.jpg');
expect(myPageImages[1]?.backgroundImage).toContain('/game/crewtype1.png'); expect(myPageImages[1]?.backgroundImage).toContain('/game/crewtype1.png');
await expect(page.locator('.legacy-general-details')).toContainText('계급 29품관'); await expect(page.locator('.general-table')).toContainText('병종보병');
await expect(page.locator('.legacy-general-details')).toContainText('병종 보병'); await expect(page.locator('.general-table')).toContainText('삭턴6 턴');
await expect(page.locator('.legacy-general-details')).toContainText('전투 8 · 계략 12 · 사관 4년'); await expect(page.locator('.battle-general-extra')).toContainText('계급29품관');
await expect(page.locator('.legacy-general-details')).toContainText('승률 62.50% · 승리 5 · 패배 3'); await expect(page.locator('.battle-general-extra')).toContainText('전투8회');
await expect(page.locator('.legacy-general-details')).toContainText('살상률 181.84% · 사살 12,345 · 피살 6,789'); await expect(page.locator('.battle-general-extra')).toContainText('계략12');
await expect(page.locator('.battle-general-extra')).toContainText('사관4년');
await expect(page.locator('.battle-general-extra')).toContainText('승률62.50%');
await expect(page.locator('.battle-general-extra')).toContainText('살상률181.84%');
await expect(page.locator('.battle-general-extra')).toContainText('사살12,345');
await expect(page.locator('.battle-general-extra')).toContainText('피살6,789');
await expect(page.locator('.battle-general-extra__recent-value')).toHaveText('01-01 00:00');
await expect(page.locator('.legacy-general-details')).toHaveCount(0);
await expect(page.locator('.battle-general-extra > span')).toHaveText([
'명성',
'계급',
'전투',
'승리',
'패배',
'계략',
'사관',
'사살',
'피살',
'승률',
'살상률',
'최근 전투',
]);
await expect(page.locator('.item-group')).toContainText('명마'); await expect(page.locator('.item-group')).toContainText('명마');
await expect(page.locator('#container')).not.toContainText('che_'); await expect(page.locator('#container')).not.toContainText('che_');
await expect(page.locator('.title-row')).toContainText('내 정 보'); await expect(page.locator('.title-row')).toContainText('내 정 보');
@@ -2006,6 +2037,26 @@ test('감찰부 keeps the selector interaction and shows the permission error pa
await expect(page.locator('.battle-general-card')).toContainText('병종보병'); await expect(page.locator('.battle-general-card')).toContainText('병종보병');
await expect(page.locator('.battle-general-card')).not.toContainText('che_'); await expect(page.locator('.battle-general-card')).not.toContainText('che_');
await expect(page.locator('.battle-general-card')).toHaveAttribute('data-general-basic-card', ''); await expect(page.locator('.battle-general-card')).toHaveAttribute('data-general-basic-card', '');
await expect(page.locator('.battle-general-card')).toHaveAttribute('data-general-information-panel', '');
await expect(page.locator('.battle-general-card')).toContainText('삭턴6 턴');
await expect(page.locator('.battle-general-extra')).toContainText('전투8회');
await expect(page.locator('.battle-general-extra')).toContainText('사관4년');
await expect(page.locator('.battle-general-extra')).toContainText('승률62.50%');
await expect(page.locator('.battle-general-extra')).toContainText('살상률181.84%');
await expect(page.locator('.battle-general-extra > span')).toHaveText([
'명성',
'계급',
'전투',
'승리',
'패배',
'계략',
'사관',
'사살',
'피살',
'승률',
'살상률',
'최근 전투',
]);
const battleImages = await readGeneralPanelImages(page.locator('.battle-general-card')); const battleImages = await readGeneralPanelImages(page.locator('.battle-general-card'));
expect(battleImages).toHaveLength(2); expect(battleImages).toHaveLength(2);
expect(battleImages[0]?.backgroundImage).toContain('/icons/default.jpg'); expect(battleImages[0]?.backgroundImage).toContain('/icons/default.jpg');
@@ -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, ref } from 'vue'; import { computed, ref, useId } 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';
@@ -104,6 +104,9 @@ const hasTouchInput = useMediaQuery('(any-pointer: coarse)');
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);
@@ -416,6 +419,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;
@@ -460,7 +473,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" :style="mapBackgroundStyle" /> <div class="map-layer map-bglayer1" :style="mapBackgroundStyle" />
<div class="map-layer map-bglayer2" /> <div class="map-layer map-bglayer2" />
@@ -495,8 +511,21 @@ const selectCity = (cityId: number) => {
<div class="tooltip-title">{{ hoveredCityTitle }}</div> <div class="tooltip-title">{{ hoveredCityTitle }}</div>
<div class="tooltip-body">{{ hoveredCity.nationId > 0 ? hoveredCity.nationName : '' }}</div> <div class="tooltip-body">{{ hoveredCity.nationId > 0 ? hoveredCity.nationName : '' }}</div>
</div> </div>
<div class="map-controls"> <div ref="mapControls" class="map-controls" @keydown.esc.stop="closeMapOptions">
<button class="map-toggle" :class="{ active: showCityName }" @click.stop="mapStore.toggleCityName"> <div
v-show="mapOptionsOpen"
:id="mapOptionsMenuId"
class="map-options-menu"
role="group"
aria-label="지도 옵션 메뉴"
@click.stop
>
<button
class="map-toggle"
:class="{ active: showCityName }"
:aria-pressed="showCityName"
@click="mapStore.toggleCityName"
>
도시명 표기 {{ showCityName ? '끄기' : '켜기' }} 도시명 표기 {{ showCityName ? '끄기' : '켜기' }}
</button> </button>
<button <button
@@ -504,11 +533,23 @@ const selectCity = (cityId: number) => {
class="map-toggle map-toggle-single-tap" class="map-toggle map-toggle-single-tap"
:class="{ active: singleTapNavigation }" :class="{ active: singleTapNavigation }"
:aria-pressed="singleTapNavigation" :aria-pressed="singleTapNavigation"
@click.stop="toggleSingleTapNavigation" @click="toggleSingleTapNavigation"
> >
두번 도시 이동 {{ singleTapNavigation ? '켜기' : '끄기' }} 두번 도시 이동 {{ singleTapNavigation ? '켜기' : '끄기' }}
</button> </button>
</div> </div>
<button
type="button"
class="map-options-trigger"
aria-label="지도 옵션"
title="지도 옵션"
:aria-controls="mapOptionsMenuId"
:aria-expanded="mapOptionsOpen"
@click.stop="toggleMapOptions"
>
<span aria-hidden="true"></span>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -591,14 +632,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;
@@ -607,6 +702,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 {
@@ -3,9 +3,7 @@ import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, onMounted, reactive, ref, watch } from 'vue'; import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import PanelCard from '../components/ui/PanelCard.vue'; import PanelCard from '../components/ui/PanelCard.vue';
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue'; import GeneralInformationPanel from '../components/main/GeneralInformationPanel.vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import GeneralBattleSummary from '../components/main/GeneralBattleSummary.vue';
import GeneralRecordPanels from '../components/main/GeneralRecordPanels.vue'; import GeneralRecordPanels from '../components/main/GeneralRecordPanels.vue';
import { import {
GENERAL_RECORD_TYPES, GENERAL_RECORD_TYPES,
@@ -273,15 +271,12 @@ onMounted(() => {
</PanelCard> </PanelCard>
<PanelCard title="장수 정보"> <PanelCard title="장수 정보">
<GeneralBasicCard <GeneralInformationPanel
class="battle-general-card" class="battle-general-card"
:general="selectedGeneral" :general="selectedGeneral"
:loading="loading" :summary="
:nation-color="data?.nation.color" selectedGeneral
> ? {
<template v-if="selectedGeneral" #details>
<GeneralBattleSummary
:summary="{
available: true, available: true,
experience: selectedGeneral.experience, experience: selectedGeneral.experience,
dedicationText: selectedGeneral.progression.dedicationText, dedicationText: selectedGeneral.progression.dedicationText,
@@ -289,14 +284,16 @@ onMounted(() => {
wins: selectedGeneral.battleStats.kills, wins: selectedGeneral.battleStats.kills,
losses: selectedGeneral.battleStats.deaths, losses: selectedGeneral.battleStats.deaths,
strategies: selectedGeneral.battleStats.fire, strategies: selectedGeneral.battleStats.fire,
serviceYears: selectedGeneral.serviceYears,
killCrew: selectedGeneral.battleStats.killCrew, killCrew: selectedGeneral.battleStats.killCrew,
deathCrew: selectedGeneral.battleStats.deathCrew, deathCrew: selectedGeneral.battleStats.deathCrew,
recentWar: selectedGeneral.recentWar, recentWar: selectedGeneral.recentWar,
}" }
: null
"
:loading="loading"
:nation-color="data?.nation.color"
/> />
<LegacyGeneralProgress :general="selectedGeneral" :show-primary="false" />
</template>
</GeneralBasicCard>
</PanelCard> </PanelCard>
</div> </div>
+20 -56
View File
@@ -7,8 +7,7 @@ import { formatSeoulDateTime } from '../utils/legacyDateTime';
import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic/scenario/scenarioEffect.js'; import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic/scenario/scenarioEffect.js';
import { useSessionStore } from '../stores/session'; import { useSessionStore } from '../stores/session';
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon'; import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue'; import GeneralInformationPanel from '../components/main/GeneralInformationPanel.vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import { useGameFeedback } from '../composables/useGameFeedback'; import { useGameFeedback } from '../composables/useGameFeedback';
import { SCREEN_MODE_CHANGE_EVENT, SCREEN_MODE_KEY, type ScreenMode } from '../utils/screenModeViewport'; import { SCREEN_MODE_CHANGE_EVENT, SCREEN_MODE_KEY, type ScreenMode } from '../utils/screenModeViewport';
import { import {
@@ -136,9 +135,6 @@ const statusLine = computed(() =>
const canSave = computed(() => (data.value?.settings.myset ?? 1) > 0); const canSave = computed(() => (data.value?.settings.myset ?? 1) > 0);
const penalties = computed(() => Object.entries(data.value?.penalties ?? {})); const penalties = computed(() => Object.entries(data.value?.penalties ?? {}));
const numberText = (value: number): string => value.toLocaleString('ko-KR');
const percentText = (numerator: number, denominator: number): string =>
`${((numerator / Math.max(denominator, 1)) * 100).toFixed(2)}%`;
const noDefencePenaltyWaived = computed(() => { const noDefencePenaltyWaived = computed(() => {
const environment = asRecord(world.value?.config.environment); const environment = asRecord(world.value?.config.environment);
return isDefenceTrainPenaltyWaivedByScenarioEffect( return isDefenceTrainPenaltyWaivedByScenarioEffect(
@@ -416,55 +412,31 @@ onMounted(() => {
<section class="top-grid"> <section class="top-grid">
<div class="general-column"> <div class="general-column">
<div class="section-title sky">장수 정보</div> <div class="section-title sky">장수 정보</div>
<GeneralBasicCard <GeneralInformationPanel
class="general-table" class="general-table"
:general="data?.general ?? null" :general="data?.general ?? null"
:summary="
data
? {
available: true,
experience: data.general.experience,
dedicationText: data.general.progression?.dedicationText,
warnum: data.general.records.battles,
wins: data.general.records.wins,
losses: data.general.records.losses,
strategies: data.general.records.strategies,
serviceYears: data.general.records.serviceYears,
killCrew: data.general.records.killedCrew,
deathCrew: data.general.records.lostCrew,
recentWar: data.general.recentWar,
}
: null
"
:loading="loading" :loading="loading"
:nation-color="data?.nation?.color" :nation-color="data?.nation?.color"
:defence-text="form.defence_train === 999 ? '수비 안함' : `수비 (훈사${form.defence_train})`" :defence-text="form.defence_train === 999 ? '수비 안함' : `수비 (훈사${form.defence_train})`"
:penalty-text="penalties.length || '-'" :penalty-text="penalties.length || '-'"
> />
<template v-if="data" #details>
<div class="legacy-general-details">
<div>
명망
<strong
>Lv {{ data.general.progression?.experienceLevel ?? 0 }} ({{
data.general.experience
}})</strong
>
· 계급
<strong
>{{ data.general.progression?.dedicationText ?? '무품관' }} ({{
data.general.dedication
}})</strong
>
</div>
<div>
전투 {{ numberText(data.general.records.battles) }} · 계략
{{ numberText(data.general.records.strategies) }} · 사관
{{ numberText(data.general.records.serviceYears) }}
</div>
<div>
승률 {{ percentText(data.general.records.wins, data.general.records.battles) }} · 승리
{{ numberText(data.general.records.wins) }} · 패배
{{ numberText(data.general.records.losses) }}
</div>
<div>
살상률
{{ percentText(data.general.records.killedCrew, data.general.records.lostCrew) }} · 사살
{{ numberText(data.general.records.killedCrew) }} · 피살
{{ numberText(data.general.records.lostCrew) }}
</div>
<div>
소속 {{ data.nation?.name ?? '재야' }} · 도시 {{ data.city?.name ?? '-' }} · 병종
{{ data.general.crewTypeName ?? '-' }} · 내정특기
{{ data.general.traits?.specialDomestic ?? '-' }} · 부상 {{ data.general.injury }}
</div>
<LegacyGeneralProgress :general="data.general" :show-primary="false" />
</div>
</template>
</GeneralBasicCard>
</div> </div>
<div class="settings-column"> <div class="settings-column">
@@ -875,14 +847,6 @@ button:disabled {
.legacy-general-info-compat { .legacy-general-info-compat {
display: none; display: none;
} }
.legacy-general-details {
background: #172a52 var(--sammo-texture-blue);
line-height: 20px;
text-align: center;
}
.legacy-general-details > div {
border-top: 1px solid #557;
}
.legacy-credit { .legacy-credit {
max-width: 100%; max-width: 100%;
overflow: hidden; overflow: hidden;