diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index ab1b9f8d..29f39cbb 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -265,6 +265,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => { dedication: true, age: true, turnTime: true, + recentWarTime: true, crewTypeId: true, personalCode: true, specialCode: true, @@ -519,6 +520,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => { age: general.age, retirementYear, turnTime: general.turnTime.toISOString(), + recentWar: general.recentWarTime?.toISOString() ?? null, defenceTrain: settings.defence_train, killTurn: readNumber(metaRecord.killturn ?? metaRecord.killTurn, 0), remainingMinutes: resolveRemainingMinutes( diff --git a/app/game-api/src/router/nation/endpoints/getBattleCenter.ts b/app/game-api/src/router/nation/endpoints/getBattleCenter.ts index 5ce0cb83..0601df8a 100644 --- a/app/game-api/src/router/nation/endpoints/getBattleCenter.ts +++ b/app/game-api/src/router/nation/endpoints/getBattleCenter.ts @@ -1,6 +1,6 @@ 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 { accessAuthedProcedure } from '../../../trpc.js'; @@ -14,6 +14,15 @@ import { import { getMyGeneral } from '../../shared/general.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 }) => { const me = await getMyGeneral(ctx); assertNationAccess(me); @@ -81,23 +90,38 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => { } const generalIds = generalRows.map((general) => general.id); - const battleCounts = + const [battleCounts, rankRows] = generalIds.length > 0 - ? await ctx.db.logEntry.groupBy({ - by: ['generalId'], - where: { - generalId: { in: generalIds }, - category: LogCategory.BATTLE_BRIEF, - }, - _count: { _all: true }, - }) - : []; + ? await Promise.all([ + ctx.db.logEntry.groupBy({ + by: ['generalId'], + where: { + generalId: { in: generalIds }, + category: LogCategory.BATTLE_BRIEF, + }, + _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(); for (const row of battleCounts) { if (row.generalId !== null) { battleCountMap.set(row.generalId, row._count._all); } } + const rankValueMap = new Map>(); + for (const row of rankRows) { + const values = rankValueMap.get(row.generalId) ?? new Map(); + values.set(row.type as (typeof BATTLE_CENTER_RECORD_TYPES)[number], row.value); + rankValueMap.set(row.generalId, values); + } const worldConfig = asRecord(worldState.config); 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 as Record) : {}; - const metaNumber = (key: string): number => { - const value = meta[key]; - return typeof value === 'number' && Number.isFinite(value) ? value : 0; + const metaNumber = (keys: string | string[], fallback = 0): number => { + for (const key of Array.isArray(keys) ? keys : [keys]) { + 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 dedicationLevel = storedDedicationLevel > 0 @@ -161,7 +193,7 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => { cityId: general.cityId, turnTime: formatDateTime(general.turnTime), recentWar: formatDateTime(general.recentWarTime), - warnum: battleCountMap.get(general.id) ?? 0, + warnum, stats: { leadership: general.leadership, strength: general.strength, @@ -176,6 +208,8 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => { train: general.train, atmos: general.atmos, age: general.age, + defenceTrain: metaNumber('defence_train', 80), + killTurn: metaNumber(['killturn', 'killTurn']), crewTypeId: general.crewTypeId, crewTypeName: crewTypeNames.get(general.crewTypeId) ?? '-', equipment: { @@ -207,12 +241,13 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => { statUpgradeLimit, dex: [1, 2, 3, 4, 5].map((index) => metaNumber(`dex${index}`)), }, + serviceYears: metaNumber('belong'), battleStats: { - kills: metaNumber('rank_killnum') || metaNumber('killnum'), - deaths: metaNumber('deathnum'), - fire: metaNumber('firenum'), - killCrew: metaNumber('killcrew'), - deathCrew: metaNumber('deathcrew'), + kills: rankValue('killnum', metaNumber(['rank_killnum', 'killnum'])), + deaths: rankValue('deathnum', metaNumber(['rank_deathnum', 'deathnum'])), + fire: rankValue('firenum', metaNumber(['rank_firenum', 'firenum'])), + killCrew: rankValue('killcrew', metaNumber(['rank_killcrew', 'killcrew'])), + deathCrew: rankValue('deathcrew', metaNumber(['rank_deathcrew', 'deathcrew'])), dex: [1, 2, 3, 4, 5].map((index) => metaNumber(`dex${index}`)), }, }; diff --git a/app/game-api/test/inGameMenuPermissions.test.ts b/app/game-api/test/inGameMenuPermissions.test.ts index 33b4be24..4ed4e01f 100644 --- a/app/game-api/test/inGameMenuPermissions.test.ts +++ b/app/game-api/test/inGameMenuPermissions.test.ts @@ -85,7 +85,7 @@ const createContext = (options: { troopLeaderAction?: string | null; refreshScore?: number; refreshScoreTotal?: number; - rankRows?: Array<{ type: string; value: number }>; + rankRows?: Array<{ generalId?: number; type: string; value: number }>; requestId?: string; transaction?: ReturnType; }) => { @@ -130,7 +130,9 @@ const createContext = (options: { })), }, rankData: { - findMany: vi.fn(async () => options.rankRows ?? []), + findMany: vi.fn(async () => + (options.rankRows ?? []).map((row) => ({ generalId: row.generalId ?? me?.id ?? 0, ...row })) + ), }, city: { findUnique: vi.fn(async () => options.city ?? null), @@ -812,7 +814,23 @@ describe('battle-center general and user permissions', () => { }); 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 }, }); await expect(appRouter.createCaller(tenured.context).nation.getBattleCenter()).resolves.toMatchObject({ @@ -823,6 +841,9 @@ describe('battle-center general and user permissions', () => { picture: 'default.jpg', imageServer: 0, officerLevelText: '일반', + warnum: 8, + defenceTrain: 80, + killTurn: 6, crewTypeName: '-', equipmentNames: { weapon: '-', book: '-', horse: '-', item: '-' }, traits: { personal: '-', specialDomestic: '-', specialWar: '-' }, @@ -834,10 +855,25 @@ describe('battle-center general and user permissions', () => { statUpgradeLimit: 20, 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({ me: buildGeneral({ officerLevel: 1, meta: { belong: 0, permission: 'auditor' } }), diff --git a/app/game-frontend/e2e/directoryLists.spec.ts b/app/game-frontend/e2e/directoryLists.spec.ts index 560e2b67..bcd7b16b 100644 --- a/app/game-frontend/e2e/directoryLists.spec.ts +++ b/app/game-frontend/e2e/directoryLists.spec.ts @@ -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 }) => { - await install(page); +test('general directory sorts the loaded rows locally in a stable three-state cycle', async ({ page }) => { + const requestedOperations: string[] = []; + await install(page, 'general', [], requestedOperations); await page.goto('general-list'); await expect(page.locator('tbody tr[data-general-id]')).toHaveCount(2); await page.selectOption('#viewType', '8'); await page.getByRole('button', { name: '정렬하기' }).click(); + await expect(page.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '10'); + await page.getByRole('button', { name: '정렬하기' }).click(); 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"] .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'); await page.mouse.up(); - await page.getByRole('button', { name: '삭턴 기준 정렬' }).click(); + await page.getByRole('button', { name: /^삭턴 내림차순/u }).click(); await expect(select).toHaveValue('8'); - await expect(page.locator('th[aria-sort="ascending"]')).toContainText('삭턴'); - await expect(page.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '20'); + await expect(page.locator('th[aria-sort="descending"]')).toContainText('삭턴'); + 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.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 }); }); +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) => { await install(page); 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="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('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-card-list')).toBeVisible(); 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-general-card-id="10"]')).toContainText('통솔81+6'); + await expect(page.locator('[data-directory-tooltip="card-special-domestic-10"]')).toContainText('상재'); + 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 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); }); -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 page.goto('general-list'); await page.selectOption('#viewType', '8'); await page.getByRole('button', { name: '정렬하기' }).click(); + await page.getByRole('button', { name: '갱 신' }).click(); await expect(page.getByRole('alert')).toContainText('권한 확인 실패'); await expect(page.locator('#viewType')).toHaveValue('8'); await expect(page.locator('tbody tr[data-general-id]')).toHaveCount(2); diff --git a/app/game-frontend/e2e/inGameInfo.spec.ts b/app/game-frontend/e2e/inGameInfo.spec.ts index 9ef6d6c5..e90843f6 100644 --- a/app/game-frontend/e2e/inGameInfo.spec.ts +++ b/app/game-frontend/e2e/inGameInfo.spec.ts @@ -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 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(); await expect(page.locator('.map-toggle-single-tap')).toHaveCount(0); 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 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: '두번 탭 해 도시 이동 끄기' }); await expect(twoTapButton).toBeVisible(); await expect(twoTapButton).toHaveAttribute('aria-pressed', 'false'); - const controlGeometry = await twoTapButton.evaluate((element) => { - const rect = element.getBoundingClientRect(); + const controlGeometry = await mobilePage.locator('.map-controls').evaluate((element) => { + 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 style = getComputedStyle(element); + const optionStyle = getComputedStyle(element.querySelector('.map-toggle')!); return { - right: rect.right, - bottom: rect.bottom, - mapRight: mapRect?.right, - mapBottom: mapRect?.bottom, - fontSize: style.fontSize, - lineHeight: style.lineHeight, + trigger: { + right: triggerRect.right, + bottom: triggerRect.bottom, + top: triggerRect.top, + }, + 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, viewportWidth: document.documentElement.clientWidth, overflowing: Array.from(document.querySelectorAll('body *')) @@ -543,8 +592,20 @@ test('map keeps desktop hover navigation and lets touch users choose one-tap or overflowing: [], }); expect(controlGeometry.documentWidth).toBeLessThanOrEqual(controlGeometry.viewportWidth + 1); - expect(controlGeometry.mapRight! - controlGeometry.right).toBeCloseTo(4, 1); - expect(controlGeometry.mapBottom! - controlGeometry.bottom).toBeCloseTo(4, 1); + expect(controlGeometry.map).not.toBeNull(); + 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'); 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 go(mobilePage, 'global-info'); + await mobilePage.getByRole('button', { name: '지도 옵션' }).tap(); await mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 끄기' }).click(); const singleTapButton = mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 켜기' }); await expect(singleTapButton).toHaveAttribute('aria-pressed', 'true'); expect(await mobilePage.evaluate(() => localStorage.getItem('sam.toggleSingleTap'))).toBe('yes'); await mobilePage.reload(); - await expect(singleTapButton).toBeVisible(); + await expect(singleTapButton).not.toBeVisible(); await mobilePage.locator('.city-base').nth(2).tap(); await expect(mobilePage).toHaveURL(/\/current-city\?cityId=3$/u); await go(mobilePage, 'global-info'); + await mobilePage.getByRole('button', { name: '지도 옵션' }).tap(); await mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 켜기' }).click(); expect(await mobilePage.evaluate(() => localStorage.getItem('sam.toggleSingleTap'))).toBe('no'); await mobilePage.locator('.city-base').first().tap(); diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index d9b80cb3..3ed880fc 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -102,6 +102,9 @@ const myGeneral = (state: FixtureState) => ({ dedication: 200, age: 30, turnTime: '2026-01-01 00:10:00', + recentWar: '2026-01-01 00:00:00', + defenceTrain: 80, + killTurn: 6, crewTypeId: 1, crewTypeName: '보병', crewTypeInfo: state.richMyInfo @@ -277,7 +280,7 @@ const battleCenter = (state: FixtureState) => ({ cityId: 1, turnTime: '2026-01-01 00:10:00', recentWar: '2026-01-01 00:00:00', - warnum: 3, + warnum: 8, stats: { leadership: 70, strength: 60, intelligence: 50 }, experience: 100, dedication: 200, @@ -288,6 +291,8 @@ const battleCenter = (state: FixtureState) => ({ train: 80, atmos: 90, age: 30, + defenceTrain: 80, + killTurn: 6, crewTypeId: 1, crewTypeName: '보병', equipment: { weapon: 'None', book: 'None', horse: 'None', item: 'None' }, @@ -301,7 +306,8 @@ const battleCenter = (state: FixtureState) => ({ statUpgradeLimit: 20, 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, @@ -323,6 +329,8 @@ const battleCenter = (state: FixtureState) => ({ train: 60, atmos: 60, age: 20, + defenceTrain: 80, + killTurn: 4, crewTypeId: 1, crewTypeName: '보병', equipment: { weapon: 'None', book: 'None', horse: 'None', item: 'None' }, @@ -336,6 +344,7 @@ const battleCenter = (state: FixtureState) => ({ statUpgradeLimit: 20, dex: [0, 0, 0, 0, 0], }, + serviceYears: 1, 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.goto('my-page'); 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')); expect(myPageImages.map(({ width, height }) => ({ width, height }))).toEqual([ { 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[1]?.backgroundImage).toContain('/game/crewtype1.png'); - await expect(page.locator('.legacy-general-details')).toContainText('계급 29품관'); - await expect(page.locator('.legacy-general-details')).toContainText('병종 보병'); - await expect(page.locator('.legacy-general-details')).toContainText('전투 8 · 계략 12 · 사관 4년'); - await expect(page.locator('.legacy-general-details')).toContainText('승률 62.50% · 승리 5 · 패배 3'); - await expect(page.locator('.legacy-general-details')).toContainText('살상률 181.84% · 사살 12,345 · 피살 6,789'); + await expect(page.locator('.general-table')).toContainText('병종보병'); + await expect(page.locator('.general-table')).toContainText('삭턴6 턴'); + await expect(page.locator('.battle-general-extra')).toContainText('계급29품관'); + await expect(page.locator('.battle-general-extra')).toContainText('전투8회'); + 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('#container')).not.toContainText('che_'); 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')).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-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')); expect(battleImages).toHaveLength(2); expect(battleImages[0]?.backgroundImage).toContain('/icons/default.jpg'); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 014575a8..03fd3d7a 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -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 expect(map).toContainText('185年 4月'); 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(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) { await map.screenshot({ path: resolve(mapSeasonArtifactRoot, 'season-spring-to-summer-midpoint.png') }); } diff --git a/app/game-frontend/src/components/directory/DirectoryTooltip.vue b/app/game-frontend/src/components/directory/DirectoryTooltip.vue new file mode 100644 index 00000000..9106f8e3 --- /dev/null +++ b/app/game-frontend/src/components/directory/DirectoryTooltip.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/app/game-frontend/src/components/directory/GeneralDirectoryStat.vue b/app/game-frontend/src/components/directory/GeneralDirectoryStat.vue new file mode 100644 index 00000000..a753c651 --- /dev/null +++ b/app/game-frontend/src/components/directory/GeneralDirectoryStat.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/app/game-frontend/src/components/directory/GeneralDirectoryTable.vue b/app/game-frontend/src/components/directory/GeneralDirectoryTable.vue index 141d09cb..0d219719 100644 --- a/app/game-frontend/src/components/directory/GeneralDirectoryTable.vue +++ b/app/game-frontend/src/components/directory/GeneralDirectoryTable.vue @@ -3,13 +3,14 @@ import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../../utils/genera import { formatOfficerLevelText } from '../../utils/nationFormat'; import { getNpcColor } from '../../utils/npcColor'; 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 Header = { label: string; - sort?: number; - direction?: SortDirection; - title?: string; + sort?: GeneralDirectorySortKey; }; const props = withDefaults( @@ -17,12 +18,12 @@ const props = withDefaults( generals: GeneralDirectoryGeneral[]; loading?: boolean; layout?: 'responsive' | 'card'; - activeSort?: number; + sortCriteria?: readonly GeneralDirectorySortCriterion[]; }>(), { loading: false, layout: 'responsive', - activeSort: undefined, + sortCriteria: () => [], } ); @@ -30,26 +31,43 @@ const emit = defineEmits<{ sort: [value: number] }>(); const headers: ReadonlyArray
= [ { label: '얼 굴' }, - { label: '이 름' }, - { label: '연령', sort: 14, direction: 'descending' }, - { label: '성격', sort: 11, direction: 'descending' }, + { label: '이 름', sort: 0 }, + { label: '연령', sort: 14 }, + { label: '성격', sort: 11 }, { label: '특기' }, - { label: '레 벨', sort: 10, direction: 'descending' }, - { label: '국 가', sort: 1, direction: 'ascending' }, - { label: '명 성', sort: 5, direction: 'descending' }, - { label: '계 급', sort: 6, direction: 'descending' }, - { label: '관 직', sort: 7, direction: 'descending' }, - { label: '통솔', sort: 2, direction: 'descending' }, - { label: '무력', sort: 3, direction: 'descending' }, - { label: '지력', sort: 4, direction: 'descending' }, - { label: '삭턴', sort: 8, direction: 'ascending' }, - { label: '벌점', sort: 9, direction: 'descending' }, + { label: '레 벨', sort: 10 }, + { label: '국 가', sort: 1 }, + { label: '명 성', sort: 5 }, + { label: '계 급', sort: 6 }, + { label: '관 직', sort: 7 }, + { label: '통솔', sort: 2 }, + { label: '무력', sort: 3 }, + { label: '지력', sort: 4 }, + { label: '삭턴', sort: 8 }, + { 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 => - header.sort === props.activeSort ? header.direction : undefined; - -const injuredStat = (value: number, injury: number): number => Math.trunc((value * (100 - injury)) / 100); + criterionIndex(header) === 0 ? criterionFor(header)?.direction : undefined; +const sortIndicator = (header: Header): string => { + 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)}. 같은 값은 이전 정렬 순서를 유지합니다.`;