diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index 23f90c3b..ab1b9f8d 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -17,8 +17,8 @@ import { ConflictingTurnDaemonCommandError } from '../../daemon/databaseTranspor import { resolveAccessWindows } from '../../services/generalAccess.js'; import { adjustAccountIconForUser } from '../../services/accountIconSync.js'; import { - loadCrewTypeDisplayNames, - loadItemDisplayNames, + loadCrewTypeDisplayDetails, + loadItemDisplayDetails, resolveCityLevelName, resolveDedicationLevelName, resolveNationLevelName, @@ -428,13 +428,13 @@ export const getGeneralContext = async (ctx: GameApiContext) => { } } } - const [personalityNames, domesticNames, warNames, nationTypeNames, crewTypeNames, itemNames] = await Promise.all([ + const [personalityNames, domesticNames, warNames, nationTypeNames, crewTypeDetails, itemDetails] = await Promise.all([ loadTraitNames([general.personalCode], 'personality'), loadTraitNames([general.specialCode], 'domestic'), loadTraitNames([general.special2Code], 'war'), loadTraitNames([nation.typeCode], 'nation'), - loadCrewTypeDisplayNames(worldState, ctx.profile.id), - loadItemDisplayNames([general.horseCode, general.weaponCode, general.bookCode, general.itemCode]), + loadCrewTypeDisplayDetails(worldState, ctx.profile.id), + loadItemDisplayDetails([general.horseCode, general.weaponCode, general.bookCode, general.itemCode]), ]); const worldConfig = asRecord(worldState?.config); @@ -461,7 +461,11 @@ export const getGeneralContext = async (ctx: GameApiContext) => { ); const itemName = (code: string | null): string | null => { const normalized = normalizeItemCode(code); - return normalized ? (itemNames.get(normalized) ?? sanitizeInternalDisplayCode(normalized)) : null; + return normalized ? (itemDetails.get(normalized)?.name ?? sanitizeInternalDisplayCode(normalized)) : null; + }; + const itemInfo = (code: string | null): string | null => { + const normalized = normalizeItemCode(code); + return normalized ? (itemDetails.get(normalized)?.info ?? '') : null; }; const worldMeta = asRecord(worldState?.meta); const rawLastExecuted = worldMeta.lastTurnTime ?? worldMeta.turntime; @@ -523,12 +527,18 @@ export const getGeneralContext = async (ctx: GameApiContext) => { worldState?.tickSeconds ?? 0 ), crewTypeId: general.crewTypeId, - crewTypeName: crewTypeNames.get(general.crewTypeId) ?? '-', + crewTypeName: crewTypeDetails.get(general.crewTypeId)?.name ?? '-', + crewTypeInfo: crewTypeDetails.get(general.crewTypeId) ?? null, traits: { personal: resolveTraitDisplayName(general.personalCode, personalityNames), specialDomestic: resolveTraitDisplayName(general.specialCode, domesticNames), specialWar: resolveTraitDisplayName(general.special2Code, warNames), }, + traitInfo: { + personal: personalityNames.get(general.personalCode)?.info ?? '', + specialDomestic: domesticNames.get(general.specialCode)?.info ?? '', + specialWar: warNames.get(general.special2Code)?.info ?? '', + }, progression: { experienceLevel: readNumber(metaRecord.explevel, 0), dedicationLevel, @@ -562,6 +572,12 @@ export const getGeneralContext = async (ctx: GameApiContext) => { book: itemName(general.bookCode), item: itemName(general.itemCode), }, + itemInfo: { + horse: itemInfo(general.horseCode), + weapon: itemInfo(general.weaponCode), + book: itemInfo(general.bookCode), + item: itemInfo(general.itemCode), + }, troop: troop ? { name: troop.name, @@ -603,6 +619,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => { capitalCityId: nation.capitalCityId, levelName: resolveNationLevelName(nation.level), typeName: nation.id === 0 ? '-' : (nationType?.name ?? sanitizeInternalDisplayCode(nation.typeCode)), + typeInfo: nation.id === 0 ? '' : (nationType?.info ?? ''), typePros: nationTypeEffects.pros, typeCons: nationTypeEffects.cons, capitalCityName: nation.id === 0 ? null : (capitalCity?.name ?? null), diff --git a/app/game-api/src/services/gameDisplayNames.ts b/app/game-api/src/services/gameDisplayNames.ts index 29d8e900..44e26ec0 100644 --- a/app/game-api/src/services/gameDisplayNames.ts +++ b/app/game-api/src/services/gameDisplayNames.ts @@ -1,6 +1,7 @@ import { asRecord } from '@sammo-ts/common'; import { isItemKey, ItemLoader } from '@sammo-ts/logic'; import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unitSetLoader.js'; +import type { CrewTypeDefinition, CrewTypeRequirement } from '@sammo-ts/logic/world/types.js'; import type { WorldStateRow } from '../context.js'; @@ -103,36 +104,172 @@ const resolveUnitSetName = (world: Pick | null, fallbac return typeof environment.unitSet === 'string' && environment.unitSet.trim() ? environment.unitSet : fallback; }; -const crewTypeNameCache = new Map>>(); +export interface CrewTypeDisplayDetails { + name: string; + info: string[]; + requirements: string[]; + stats: { + attack: number; + defence: number; + speed: number; + avoid: number; + magicCoef: number; + cost: number; + rice: number; + }; +} -export const loadCrewTypeDisplayNames = ( +const KNOWN_NATION_AUX_LABELS: Readonly> = { + can_대검병사용: '대검병 연구', + can_극병사용: '극병 연구', + can_화시병사용: '화시병 연구', + can_원융노병사용: '원융노병 연구', + can_산저병사용: '산저병 연구', + can_상병사용: '상병 연구', + can_음귀병사용: '음귀병 연구', + can_무희사용: '무희 연구', + can_화륜차사용: '화륜차 연구', + did_특성초토화: '특성 초토화', +}; + +const formatNationAuxRequirement = (requirement: { + type: 'ReqNationAux'; + key: string; + op: string; + value: number | string; +}): string => { + const knownLabel = KNOWN_NATION_AUX_LABELS[requirement.key]; + if (knownLabel && requirement.key !== 'did_특성초토화' && requirement.op === '==' && requirement.value === 1) { + return `${knownLabel} 시 가능`; + } + if (requirement.key === 'did_특성초토화' && requirement.op === '>=' && requirement.value === 1) { + return `${knownLabel ?? requirement.key} 시 가능`; + } + if (requirement.op === '==' && requirement.value === 0) return `${requirement.key} 없을 때`; + if (requirement.op === '==' && requirement.value === 1) return `${requirement.key} 있을 때`; + if (requirement.op === '!=' && requirement.value === 0) return `${requirement.key} 없을 때`; + if (requirement.op === '!=' && requirement.value === 1) return `${requirement.key} 있을 때`; + const operator = requirement.op === '==' ? '=' : requirement.op; + return `${requirement.key} ${operator} ${String(requirement.value)} 일 때`; +}; + +export const formatCrewTypeRequirement = (requirement: CrewTypeRequirement): string => { + switch (requirement.type) { + case 'ReqTech': { + const detail = requirement as { type: 'ReqTech'; tech: number }; + return `기술력 ${detail.tech} 이상 필요`; + } + case 'ReqRegions': { + const detail = requirement as { type: 'ReqRegions'; regions: string[] }; + return `${detail.regions.join(', ')} 지역 소유시 가능`; + } + case 'ReqCities': { + const detail = requirement as { type: 'ReqCities'; cities: string[] }; + return `${detail.cities.join(', ')} 소유시 가능`; + } + case 'ReqCitiesWithCityLevel': { + const detail = requirement as { type: 'ReqCitiesWithCityLevel'; level: number; cities: string[] }; + return `${detail.cities.join(', ')} ${resolveCityLevelName(detail.level)}성 소유시 가능`; + } + case 'ReqHighLevelCities': { + const detail = requirement as { type: 'ReqHighLevelCities'; level: number; count: number }; + return `${resolveCityLevelName(detail.level)}성 ${detail.count}개 이상 소유시 가능`; + } + case 'ReqNationAux': + return formatNationAuxRequirement( + requirement as { type: 'ReqNationAux'; key: string; op: string; value: number | string } + ); + case 'ReqMinRelYear': { + const detail = requirement as { type: 'ReqMinRelYear'; year: number }; + return `${detail.year}년 경과 후 사용 가능`; + } + case 'ReqChief': + return '군주 및 수뇌부만 가능'; + case 'ReqNotChief': + return '군주 및 수뇌부는 불가'; + case 'Impossible': + return '불가능'; + default: + return ''; + } +}; + +const crewTypeDetailsCache = new Map>>(); + +const toCrewTypeDisplayDetails = (crewType: CrewTypeDefinition): CrewTypeDisplayDetails => ({ + name: crewType.name, + info: crewType.info.filter(Boolean), + requirements: crewType.requirements.map(formatCrewTypeRequirement).filter(Boolean), + stats: { + attack: crewType.attack, + defence: crewType.defence, + speed: crewType.speed, + avoid: crewType.avoid, + magicCoef: crewType.magicCoef, + cost: crewType.cost, + rice: crewType.rice, + }, +}); + +export const loadCrewTypeDisplayDetails = ( world: Pick | null, fallback: string -): Promise> => { +): Promise> => { const unitSetName = resolveUnitSetName(world, fallback); - const cached = crewTypeNameCache.get(unitSetName); + const cached = crewTypeDetailsCache.get(unitSetName); if (cached) { return cached; } const pending = loadUnitSetDefinitionByName(unitSetName) - .then((definition) => new Map((definition.crewTypes ?? []).map((crewType) => [crewType.id, crewType.name]))) - .catch(() => new Map()); - crewTypeNameCache.set(unitSetName, pending); + .then( + (definition) => + new Map( + (definition.crewTypes ?? []).map((crewType) => [crewType.id, toCrewTypeDisplayDetails(crewType)]) + ) + ) + .catch(() => new Map()); + crewTypeDetailsCache.set(unitSetName, pending); return pending; }; +export const loadCrewTypeDisplayNames = ( + world: Pick | null, + fallback: string +): Promise> => + loadCrewTypeDisplayDetails(world, fallback).then( + (details) => new Map(Array.from(details, ([id, detail]) => [id, detail.name])) + ); + const itemLoader = new ItemLoader(); -export const loadItemDisplayNames = async (values: Array): Promise> => { +export interface ItemDisplayDetails { + name: string; + info: string; +} + +export const loadItemDisplayDetails = async ( + values: Array +): Promise> => { const keys = Array.from(new Set(values.filter((value): value is string => Boolean(value) && value !== 'None'))); const entries = await Promise.all( keys.map(async (key) => { if (!isItemKey(key)) { - return [key, sanitizeInternalDisplayCode(key)] as const; + return [key, { name: sanitizeInternalDisplayCode(key), info: '' }] as const; } const item = await itemLoader.load(key).catch(() => null); - return [key, item?.name ?? sanitizeInternalDisplayCode(key)] as const; + return [ + key, + { + name: item?.name ?? sanitizeInternalDisplayCode(key), + info: item?.info ?? '', + }, + ] as const; }) ); return new Map(entries); }; + +export const loadItemDisplayNames = async (values: Array): Promise> => { + const details = await loadItemDisplayDetails(values); + return new Map(Array.from(details, ([key, detail]) => [key, detail.name])); +}; diff --git a/app/game-api/test/gameDisplayNames.test.ts b/app/game-api/test/gameDisplayNames.test.ts index 62a576e6..955d8cfa 100644 --- a/app/game-api/test/gameDisplayNames.test.ts +++ b/app/game-api/test/gameDisplayNames.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + formatCrewTypeRequirement, resolveCityLevelName, resolveDedicationLevelName, resolveNationLevelName, @@ -29,4 +30,27 @@ describe('Ref GUI display names', () => { expect(resolveCityLevelName(99)).toBe('-'); expect(resolveRegionName(99)).toBe('-'); }); + + it('formats crew-type requirements with the same text as Ref tooltips', () => { + expect(formatCrewTypeRequirement({ type: 'ReqTech', tech: 1_000 })).toBe('기술력 1000 이상 필요'); + expect(formatCrewTypeRequirement({ type: 'ReqRegions', regions: ['중원', '오월'] })).toBe( + '중원, 오월 지역 소유시 가능' + ); + expect(formatCrewTypeRequirement({ type: 'ReqCitiesWithCityLevel', level: 8, cities: ['완'] })).toBe( + '완 특성 소유시 가능' + ); + expect(formatCrewTypeRequirement({ type: 'ReqHighLevelCities', level: 7, count: 4 })).toBe( + '대성 4개 이상 소유시 가능' + ); + expect(formatCrewTypeRequirement({ type: 'ReqNationAux', key: 'can_대검병사용', op: '==', value: 1 })).toBe( + '대검병 연구 시 가능' + ); + expect(formatCrewTypeRequirement({ type: 'ReqNationAux', key: 'did_특성초토화', op: '>=', value: 1 })).toBe( + '특성 초토화 시 가능' + ); + expect(formatCrewTypeRequirement({ type: 'ReqMinRelYear', year: 3 })).toBe('3년 경과 후 사용 가능'); + expect(formatCrewTypeRequirement({ type: 'ReqChief' })).toBe('군주 및 수뇌부만 가능'); + expect(formatCrewTypeRequirement({ type: 'ReqNotChief' })).toBe('군주 및 수뇌부는 불가'); + expect(formatCrewTypeRequirement({ type: 'Impossible' })).toBe('불가능'); + }); }); diff --git a/app/game-api/test/inGameMenuPermissions.test.ts b/app/game-api/test/inGameMenuPermissions.test.ts index 55ec58b4..33b4be24 100644 --- a/app/game-api/test/inGameMenuPermissions.test.ts +++ b/app/game-api/test/inGameMenuPermissions.test.ts @@ -424,13 +424,21 @@ describe('in-game my information ownership', () => { general: { officerLevelText: '간의대부', crewTypeName: '보병', + crewTypeInfo: { + name: '보병', + info: ['표준적인 보병입니다.', '보병은 방어특화이며,', '상대가 회피하기 어렵습니다.'], + requirements: [], + stats: { attack: 100, defence: 150, speed: 7, avoid: 10, magicCoef: 0, cost: 9, rice: 9 }, + }, progression: { experienceLevel: 4, dedicationLevel: 2, dedicationText: '29품관' }, itemNames: { horse: '노새(+3)' }, + itemInfo: { horse: '통솔 +3' }, }, city: { levelName: '특', regionName: '중원', nationName: '위' }, nation: { levelName: '주자사', typeName: '법가', + typeInfo: '금수입↑ 치안↑ 인구↓ 민심↓', capitalCityName: '업', }, }); @@ -508,6 +516,12 @@ describe('in-game my information ownership', () => { specialDomestic: '상재', specialWar: '신산', }, + traitInfo: { + personal: '사기 -5, 징·모병 비용 -20%', + specialDomestic: '[내정] 상업 투자 : 기본 보정 +10%, 성공률 +10%p, 비용 -20%', + specialWar: + '[계략] 화계·탈취·파괴·선동 : 성공률 +10%p
[전투] 계략 시도 확률 +20%p, 계략 성공 확률 +20%p', + }, }, nation: { id: 0, @@ -518,6 +532,7 @@ describe('in-game my information ownership', () => { rice: 0, tech: 0, typeCode: 'None', + typeInfo: '', capitalCityId: null, }, }); diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index a7e6c5e5..d9b80cb3 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -64,6 +64,7 @@ type FixtureState = { joinConfig?: Record; createGeneralInputs?: Array>; mainTraits?: { personal: string; specialDomestic: string; specialWar: string }; + richMyInfo?: boolean; hiddenSeedLogText?: string; recentRecords?: { global: Array<{ id: number; text: string; createdAt?: string }>; @@ -103,7 +104,22 @@ const myGeneral = (state: FixtureState) => ({ turnTime: '2026-01-01 00:10:00', crewTypeId: 1, crewTypeName: '보병', + crewTypeInfo: state.richMyInfo + ? { + name: '보병', + info: ['표준적인 보병입니다.', '보병은 방어특화이며,', '상대가 회피하기 어렵습니다.'], + requirements: ['기술력 1000 이상 필요'], + stats: { attack: 100, defence: 150, speed: 7, avoid: 10, magicCoef: 0, cost: 9, rice: 9 }, + } + : null, traits: state.mainTraits ?? { personal: '-', specialDomestic: '-', specialWar: '-' }, + traitInfo: state.richMyInfo + ? { + personal: '부상당할 확률이 감소합니다.', + specialDomestic: '상업 내정 효율이 증가합니다.', + specialWar: '계략 성공률이 증가합니다.
발동 순서는 레거시와 같습니다.', + } + : { personal: '', specialDomestic: '', specialWar: '' }, progression: { experienceLevel: 1, dedicationLevel: 2, @@ -121,8 +137,20 @@ const myGeneral = (state: FixtureState) => ({ killedCrew: 12_345, lostCrew: 6_789, }, - items: { horse: 'che_명마', weapon: null, book: null, item: null }, - itemNames: { horse: '명마', weapon: null, book: null, item: null }, + items: state.richMyInfo + ? { horse: 'che_명마', weapon: 'che_단도', book: 'che_효경전', item: 'che_납금박산로' } + : { horse: 'che_명마', weapon: null, book: null, item: null }, + itemNames: state.richMyInfo + ? { horse: '명마', weapon: '단도', book: '효경전', item: '납금박산로' } + : { horse: '명마', weapon: null, book: null, item: null }, + itemInfo: state.richMyInfo + ? { + horse: '통솔 +3', + weapon: '무력 +1', + book: '지력 +1', + item: '내정 실행 시 성공률이 증가합니다.
소모되지 않습니다.', + } + : { horse: null, weapon: null, book: null, item: null }, }, city: { id: 1, @@ -201,6 +229,7 @@ const myGeneral = (state: FixtureState) => ({ capitalCityName: '업', typePros: '금수입↑ 치안↑', typeCons: '인구↓ 민심↓', + typeInfo: state.richMyInfo ? '법과 질서를 중시하여 국가 운영을 안정시킵니다.' : '', population: { cityCount: 1, current: 1_000, max: 2_000 }, crew: { generalCount: 2, current: 500, max: 7_000 }, power: 1_234, @@ -1349,6 +1378,106 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide await persistParityArtifact(page, 'core-my-page-mobile', mobile); }); +test('내 정보 항목과 국가 성향은 HTML 리치 툴팁을 마우스와 키보드로 표시한다', async ({ page }) => { + const state: FixtureState = { + permission: 'head', + myset: 3, + richMyInfo: true, + mainTraits: { personal: '안전', specialDomestic: '상재', specialWar: '신산' }, + settingMutations: [], + accessPages: [], + }; + await install(page, state); + + const visibleTooltip = page.locator('.tippy-box[data-theme~="sammo-rich"][data-state="visible"]'); + const showWithMouse = async (testId: string, expectedTexts: readonly string[]) => { + const trigger = page.locator(`[data-rich-tooltip="${testId}"]`); + await expect(trigger).toHaveAttribute('tabindex', '0'); + await trigger.hover(); + await expect(visibleTooltip).toHaveCount(1); + await expect(visibleTooltip).toHaveAttribute('role', 'tooltip'); + for (const expectedText of expectedTexts) { + await expect(visibleTooltip).toContainText(expectedText); + } + await expect(trigger).toHaveAttribute('aria-describedby', /tippy-/u); + await page.mouse.move(1, 1); + await expect(visibleTooltip).toHaveCount(0); + }; + + await page.setViewportSize({ width: 1000, height: 900 }); + await page.goto('my-page'); + await expect(page.locator('[data-general-basic-card]')).toBeVisible(); + + const cardBefore = await page.locator('[data-general-basic-card]').boundingBox(); + await showWithMouse('horse', ['명마', '통솔 +3']); + await showWithMouse('weapon', ['단도', '무력 +1']); + await showWithMouse('book', ['효경전', '지력 +1']); + await showWithMouse('item', ['납금박산로', '내정 실행 시 성공률이 증가합니다.', '소모되지 않습니다.']); + await showWithMouse('crew-type', [ + '보병', + '표준적인 보병입니다.', + '전투 정보', + '공격 100 · 방어 150', + '병사 100명 기준 금 9 · 쌀 9', + '생성 조건', + '기술력 1000 이상 필요', + ]); + await showWithMouse('personality', ['안전', '부상당할 확률이 감소합니다.']); + await showWithMouse('special-domestic', ['내정특기 · 상재', '상업 내정 효율이 증가합니다.']); + await showWithMouse('special-war', [ + '전투특기 · 신산', + '계략 성공률이 증가합니다.', + '발동 순서는 레거시와 같습니다.', + ]); + expect(await page.locator('[data-general-basic-card]').boundingBox()).toEqual(cardBefore); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(1000); + + const warTrigger = page.locator('[data-rich-tooltip="special-war"]'); + await warTrigger.focus(); + await expect(warTrigger).toBeFocused(); + await expect(visibleTooltip).toHaveCount(1); + await expect(visibleTooltip.locator('.rich-tooltip-content__line')).toHaveCount(2); + expect(await visibleTooltip.locator('.tippy-content').innerHTML()).not.toContain('<br'); + await persistParityArtifact(page, 'core-my-info-rich-tooltip-desktop', { + trigger: await warTrigger.boundingBox(), + tooltip: await visibleTooltip.boundingBox(), + scrollWidth: await page.evaluate(() => document.documentElement.scrollWidth), + }); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.reload(); + const crewTrigger = page.locator('[data-rich-tooltip="crew-type"]'); + await crewTrigger.focus(); + await expect(visibleTooltip).toHaveCount(1); + const mobileTooltip = await visibleTooltip.boundingBox(); + expect(mobileTooltip).not.toBeNull(); + expect(mobileTooltip!.x).toBeGreaterThanOrEqual(0); + expect(mobileTooltip!.x + mobileTooltip!.width).toBeLessThanOrEqual(390); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(390); + await persistParityArtifact(page, 'core-my-info-rich-tooltip-mobile', { + trigger: await crewTrigger.boundingBox(), + tooltip: mobileTooltip, + scrollWidth: await page.evaluate(() => document.documentElement.scrollWidth), + }); + + await page.setViewportSize({ width: 1000, height: 900 }); + await page.goto(''); + const nationType = page.locator('[data-rich-tooltip="nation-type"]'); + await nationType.hover(); + await expect(visibleTooltip).toHaveCount(1); + await expect(visibleTooltip).toContainText('국가 성향 · 법가'); + await expect(visibleTooltip).toContainText('법과 질서를 중시하여 국가 운영을 안정시킵니다.'); + await expect(visibleTooltip).toContainText('장점 금수입↑ 치안↑'); + await expect(visibleTooltip).toContainText('단점 인구↓ 민심↓'); + await nationType.focus(); + await expect(nationType).toBeFocused(); + await expect(visibleTooltip).toHaveCount(1); + await persistParityArtifact(page, 'core-nation-type-rich-tooltip-desktop', { + trigger: await nationType.boundingBox(), + tooltip: await visibleTooltip.boundingBox(), + }); +}); + test('내 정보&설정의 지난 플레이는 기본 탐색과 분리되어 오른쪽에 정렬된다', async ({ page }) => { const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] }; await install(page, state); @@ -1528,7 +1657,9 @@ test('실제 모바일 터치로 메인 패널 순서를 재정렬한다', async await dialog.screenshot({ path: testInfo.outputPath('mobile-main-panel-touch-dialog.png') }); await dialog.getByRole('button', { name: '적용', exact: true }).click(); await expect - .poll(() => mobilePage.evaluate(() => JSON.parse(localStorage.getItem('sam.mobileMainPanelOrder.v1') ?? '[]'))) + .poll(() => + mobilePage.evaluate(() => JSON.parse(localStorage.getItem('sam.mobileMainPanelOrder.v1') ?? '[]')) + ) .toEqual([ 'nation-menu', 'commands', diff --git a/app/game-frontend/e2e/tournamentBracket.spec.ts b/app/game-frontend/e2e/tournamentBracket.spec.ts index 4cab47b0..1b539f13 100644 --- a/app/game-frontend/e2e/tournamentBracket.spec.ts +++ b/app/game-frontend/e2e/tournamentBracket.spec.ts @@ -142,7 +142,12 @@ const persistScreenshot = async (page: Page, name: string, fallbackPath: string) const installFixture = async ( page: Page, - options: { applicationOpen?: boolean; tournamentType?: number; tournamentStage?: number } = {} + options: { + applicationOpen?: boolean; + tournamentType?: number; + tournamentStage?: number; + joinedGroupId?: number; + } = {} ) => { let joined = false; const placedBets: Array<{ targetId: number; amount: number }> = []; @@ -166,9 +171,11 @@ const installFixture = async ( if (operation === 'general.me') return response({ general: { id: 1, name: names[0] } }); if (operation === 'tournament.getAdminStatus') return response({ ok: false }); if (operation === 'tournament.getSnapshot') { + const tournamentStage = options.tournamentStage ?? (options.applicationOpen ? 1 : 0); + const joinedGroupId = options.joinedGroupId ?? 0; return response({ state: { - stage: options.tournamentStage ?? (options.applicationOpen ? 1 : 0), + stage: tournamentStage, phase: 0, type: options.tournamentType ?? 0, auto: false, @@ -176,7 +183,7 @@ const installFixture = async ( openMonth: 1, termSeconds: 60, nextAt: '2026-08-02T00:00:00.000Z', - winnerId: 1, + winnerId: tournamentStage === 0 ? 1 : undefined, }, participants: options.applicationOpen && !joined @@ -185,8 +192,10 @@ const installFixture = async ( ? [ { ...participants[0], - groupId: 0, + groupId: joinedGroupId, groupNo: 0, + preliminaryGroupId: joinedGroupId, + preliminaryGroupNo: 0, win: 0, draw: 0, lose: 0, @@ -347,20 +356,36 @@ test('desktop bracket connects every real general slot to the next round', async await persistScreenshot(page, 'tournament-desktop', testInfo.outputPath('tournament-bracket-desktop.webp')); }); -test('join refresh shows the assigned preliminary group immediately with accessible controls', async ({ page }) => { +test('join refresh shows the assigned preliminary group immediately with accessible controls', async ({ + page, +}, testInfo) => { await page.setViewportSize({ width: 390, height: 844 }); - await installFixture(page, { applicationOpen: true }); + await installFixture(page, { applicationOpen: true, joinedGroupId: 5 }); await page.goto('tournament'); const refresh = page.getByRole('button', { name: '갱신' }); const join = page.getByRole('button', { name: '참가' }); const close = page.getByRole('button', { name: '창 닫기' }).first(); await expect(join).toBeEnabled(); + await expect(page.getByText('조별 예선 순위')).toBeVisible(); + await expect(page.getByText('조별 본선 순위')).toHaveCount(0); + await expect(page.getByLabel('토너먼트 대진표')).toHaveCount(0); await join.click(); - await expect(page.getByRole('status')).toHaveText('참가 신청이 반영되었습니다.'); + await expect(page.getByRole('status')).toHaveText('참가 신청이 반영되었습니다. 六조에 배정되었습니다.'); await expect(join).toBeDisabled(); - await expect(page.locator('.preliminary-grid .general-identity', { hasText: names[0] })).toBeVisible(); + const preliminaryTabs = page.getByRole('tablist', { name: '예선 조 선택' }); + await expect(preliminaryTabs.getByRole('tab').nth(5)).toHaveAttribute('aria-selected', 'true'); + const assignedGroup = page.locator('[data-preliminary-group="5"]'); + await expect(assignedGroup.locator('.general-identity', { hasText: names[0] })).toBeVisible(); + const assignedGroupBounds = await assignedGroup.boundingBox(); + expect(assignedGroupBounds?.y).toBeLessThan(844); + expect((assignedGroupBounds?.y ?? 0) + (assignedGroupBounds?.height ?? 0)).toBeGreaterThan(0); + await persistScreenshot( + page, + 'tournament-joined-group-mobile', + testInfo.outputPath('tournament-joined-group.webp') + ); for (const control of [refresh, join, close]) { const box = await control.boundingBox(); @@ -374,6 +399,41 @@ test('join refresh shows the assigned preliminary group immediately with accessi expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); }); +test('desktop join scrolls the assigned preliminary group into view without future sections', async ({ + page, +}, testInfo) => { + await page.setViewportSize({ width: 1365, height: 900 }); + await installFixture(page, { applicationOpen: true, joinedGroupId: 7 }); + await page.goto('tournament'); + + await expect(page.getByText('조별 본선 순위')).toHaveCount(0); + await expect(page.getByLabel('토너먼트 대진표')).toHaveCount(0); + await page.getByRole('button', { name: '참가' }).click(); + + await expect(page.getByRole('status')).toHaveText('참가 신청이 반영되었습니다. 八조에 배정되었습니다.'); + const assignedGroup = page.locator('[data-preliminary-group="7"]'); + await expect(assignedGroup.locator('.general-identity', { hasText: names[0] })).toBeVisible(); + const bounds = await assignedGroup.boundingBox(); + expect(bounds?.y).toBeLessThan(900); + expect((bounds?.y ?? 0) + (bounds?.height ?? 0)).toBeGreaterThan(0); + await persistScreenshot( + page, + 'tournament-joined-group-desktop', + testInfo.outputPath('tournament-joined-group.webp') + ); +}); + +test('final group section appears before the later knockout section', async ({ page }, testInfo) => { + await page.setViewportSize({ width: 390, height: 844 }); + await installFixture(page, { tournamentStage: 3 }); + await page.goto('tournament'); + + await expect(page.getByText('조별 예선 순위')).toBeVisible(); + await expect(page.getByText('조별 본선 순위')).toBeVisible(); + await expect(page.getByLabel('토너먼트 대진표')).toHaveCount(0); + await persistScreenshot(page, 'tournament-final-stage-mobile', testInfo.outputPath('tournament-final-stage.webp')); +}); + test('mobile bracket exposes every round through tabs with standard horizontal identities', async ({ page, }, testInfo) => { @@ -595,13 +655,13 @@ test('desktop betting presents icon-and-name cards and all four rankings without const firstCardCorner = await page .locator('.desktop-bracket-name.betting-target[data-general-id="1"]') .evaluate((card) => { - const own = card.getBoundingClientRect(); - const button = card.querySelector('.bracket-bet-button')!.getBoundingClientRect(); - return { - topOffset: button.top - own.top, - rightOffset: own.right - button.right, - contained: button.top >= own.top && button.right <= own.right && button.bottom <= own.bottom, - }; + const own = card.getBoundingClientRect(); + const button = card.querySelector('.bracket-bet-button')!.getBoundingClientRect(); + return { + topOffset: button.top - own.top, + rightOffset: own.right - button.right, + contained: button.top >= own.top && button.right <= own.right && button.bottom <= own.bottom, + }; }); expect(firstCardCorner.topOffset).toBeGreaterThanOrEqual(2); expect(firstCardCorner.topOffset).toBeLessThanOrEqual(4); diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index efe54a59..f8913320 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -48,6 +48,7 @@ "es-toolkit": "^1.43.0", "mitt": "^3.0.1", "pinia": "^3.0.4", + "tippy.js": "6.3.7", "vue": "^3.5.26", "vue-draggable-plus": "0.6.1", "vue-router": "^4.6.4", diff --git a/app/game-frontend/src/components/main/GeneralBasicCard.vue b/app/game-frontend/src/components/main/GeneralBasicCard.vue index 3903bdf8..227111f1 100644 --- a/app/game-frontend/src/components/main/GeneralBasicCard.vue +++ b/app/game-frontend/src/components/main/GeneralBasicCard.vue @@ -3,6 +3,7 @@ import { computed } from 'vue'; import SkeletonLines from '../ui/SkeletonLines.vue'; import LegacyProgressBar from '../ui/LegacyProgressBar.vue'; +import RichTooltip from '../ui/RichTooltip.vue'; import { formatLocalTimeSeconds } from '../../utils/legacyDateTime'; import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress'; import { DEFAULT_GENERAL_ICON_URL, resolveGeneralIconBackgroundImage } from '../../utils/generalIcon'; @@ -30,6 +31,28 @@ interface ItemDisplayNames { item?: string | null; } +interface ItemDisplayInfo { + horse?: string | null; + weapon?: string | null; + book?: string | null; + item?: string | null; +} + +interface CrewTypeDisplayInfo { + name: string; + info: string[]; + requirements: string[]; + stats: { + attack: number; + defence: number; + speed: number; + avoid: number; + magicCoef: number; + cost: number; + rice: number; + }; +} + interface GeneralTroopDisplay { name: string; status: 'inactive' | 'present' | 'away'; @@ -73,9 +96,12 @@ interface GeneralInfo { refreshScore?: GeneralRefreshScore; crewTypeId?: number; crewTypeName?: string; + crewTypeInfo?: CrewTypeDisplayInfo | null; traits?: { personal: string; specialWar: string; specialDomestic: string }; + traitInfo?: { personal: string; specialWar: string; specialDomestic: string }; progression?: GeneralProgression; itemNames?: ItemDisplayNames; + itemInfo?: ItemDisplayInfo; equipmentNames?: ItemDisplayNames; } @@ -243,9 +269,36 @@ const specialText = computed(() => { - 명마{{ itemNames.horse ?? '-' }} - 무기{{ itemNames.weapon ?? '-' }} - 서적{{ itemNames.book ?? '-' }} + 명마 + + + {{ itemNames.horse ?? '-' }} + + + 무기 + + + {{ itemNames.weapon ?? '-' }} + + + 서적 + + + {{ itemNames.book ?? '-' }} + + { /> 자금{{ props.general.gold.toLocaleString('ko-KR') }} 군량{{ props.general.rice.toLocaleString('ko-KR') }} - 도구{{ itemNames.item ?? '-' }} + 도구 + + + {{ itemNames.item ?? '-' }} + + - 병종{{ props.general.crewTypeName ?? '-' }} + 병종 + + + {{ props.general.crewTypeName ?? '-' }} + + + 병사{{ props.general.crew.toLocaleString('ko-KR') }} - 성격{{ props.general.traits?.personal ?? '-' }} + 성격 + + + {{ props.general.traits?.personal ?? '-' }} + + 훈련{{ props.general.train }} 사기{{ props.general.atmos }} - 특기{{ specialText }} + 특기 + + + {{ props.general.traits?.specialDomestic ?? '-' }} + + / + + {{ props.general.traits?.specialWar ?? '-' }} + + Lv {{ props.general.progression?.experienceLevel ?? 0 }} diff --git a/app/game-frontend/src/components/main/NationBasicCard.vue b/app/game-frontend/src/components/main/NationBasicCard.vue index ba738b29..478df508 100644 --- a/app/game-frontend/src/components/main/NationBasicCard.vue +++ b/app/game-frontend/src/components/main/NationBasicCard.vue @@ -1,5 +1,6 @@ + + + + diff --git a/app/game-frontend/src/utils/tournamentStatus.ts b/app/game-frontend/src/utils/tournamentStatus.ts index 138501e7..529e8941 100644 --- a/app/game-frontend/src/utils/tournamentStatus.ts +++ b/app/game-frontend/src/utils/tournamentStatus.ts @@ -13,3 +13,18 @@ export const tournamentStageNames = [ ] as const; export const resolveTournamentStageName = (stage: number): string => tournamentStageNames[stage] ?? '상태 확인 중'; + +export interface TournamentSectionVisibility { + preliminary: boolean; + final: boolean; + knockout: boolean; +} + +export const resolveTournamentSectionVisibility = (stage: number, winnerId?: number): TournamentSectionVisibility => { + const completed = stage === 0 && winnerId !== undefined; + return { + preliminary: stage >= 1 || completed, + final: stage >= 3 || completed, + knockout: stage >= 5 || completed, + }; +}; diff --git a/app/game-frontend/src/views/TournamentView.vue b/app/game-frontend/src/views/TournamentView.vue index e21b9b16..c1ea2f71 100644 --- a/app/game-frontend/src/views/TournamentView.vue +++ b/app/game-frontend/src/views/TournamentView.vue @@ -1,11 +1,11 @@