feat: 내정보 상세 설명 툴팁 이관

말·무기·서적·도구·병종·특기 설명과 국가 성향 정보를 API에 투영하고 Tippy.js 기반 공용 툴팁으로 표시한다. 실제 Chromium의 hover·focus와 모바일 경계를 회귀 테스트한다.
This commit is contained in:
2026-08-21 15:55:44 +00:00
parent 9e0f1f6003
commit a0e64a6f74
10 changed files with 708 additions and 30 deletions
+24 -7
View File
@@ -17,8 +17,8 @@ import { ConflictingTurnDaemonCommandError } from '../../daemon/databaseTranspor
import { resolveAccessWindows } from '../../services/generalAccess.js'; import { resolveAccessWindows } from '../../services/generalAccess.js';
import { adjustAccountIconForUser } from '../../services/accountIconSync.js'; import { adjustAccountIconForUser } from '../../services/accountIconSync.js';
import { import {
loadCrewTypeDisplayNames, loadCrewTypeDisplayDetails,
loadItemDisplayNames, loadItemDisplayDetails,
resolveCityLevelName, resolveCityLevelName,
resolveDedicationLevelName, resolveDedicationLevelName,
resolveNationLevelName, 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.personalCode], 'personality'),
loadTraitNames([general.specialCode], 'domestic'), loadTraitNames([general.specialCode], 'domestic'),
loadTraitNames([general.special2Code], 'war'), loadTraitNames([general.special2Code], 'war'),
loadTraitNames([nation.typeCode], 'nation'), loadTraitNames([nation.typeCode], 'nation'),
loadCrewTypeDisplayNames(worldState, ctx.profile.id), loadCrewTypeDisplayDetails(worldState, ctx.profile.id),
loadItemDisplayNames([general.horseCode, general.weaponCode, general.bookCode, general.itemCode]), loadItemDisplayDetails([general.horseCode, general.weaponCode, general.bookCode, general.itemCode]),
]); ]);
const worldConfig = asRecord(worldState?.config); const worldConfig = asRecord(worldState?.config);
@@ -461,7 +461,11 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
); );
const itemName = (code: string | null): string | null => { const itemName = (code: string | null): string | null => {
const normalized = normalizeItemCode(code); 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 worldMeta = asRecord(worldState?.meta);
const rawLastExecuted = worldMeta.lastTurnTime ?? worldMeta.turntime; const rawLastExecuted = worldMeta.lastTurnTime ?? worldMeta.turntime;
@@ -523,12 +527,18 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
worldState?.tickSeconds ?? 0 worldState?.tickSeconds ?? 0
), ),
crewTypeId: general.crewTypeId, crewTypeId: general.crewTypeId,
crewTypeName: crewTypeNames.get(general.crewTypeId) ?? '-', crewTypeName: crewTypeDetails.get(general.crewTypeId)?.name ?? '-',
crewTypeInfo: crewTypeDetails.get(general.crewTypeId) ?? null,
traits: { traits: {
personal: resolveTraitDisplayName(general.personalCode, personalityNames), personal: resolveTraitDisplayName(general.personalCode, personalityNames),
specialDomestic: resolveTraitDisplayName(general.specialCode, domesticNames), specialDomestic: resolveTraitDisplayName(general.specialCode, domesticNames),
specialWar: resolveTraitDisplayName(general.special2Code, warNames), 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: { progression: {
experienceLevel: readNumber(metaRecord.explevel, 0), experienceLevel: readNumber(metaRecord.explevel, 0),
dedicationLevel, dedicationLevel,
@@ -562,6 +572,12 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
book: itemName(general.bookCode), book: itemName(general.bookCode),
item: itemName(general.itemCode), item: itemName(general.itemCode),
}, },
itemInfo: {
horse: itemInfo(general.horseCode),
weapon: itemInfo(general.weaponCode),
book: itemInfo(general.bookCode),
item: itemInfo(general.itemCode),
},
troop: troop troop: troop
? { ? {
name: troop.name, name: troop.name,
@@ -603,6 +619,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
capitalCityId: nation.capitalCityId, capitalCityId: nation.capitalCityId,
levelName: resolveNationLevelName(nation.level), levelName: resolveNationLevelName(nation.level),
typeName: nation.id === 0 ? '-' : (nationType?.name ?? sanitizeInternalDisplayCode(nation.typeCode)), typeName: nation.id === 0 ? '-' : (nationType?.name ?? sanitizeInternalDisplayCode(nation.typeCode)),
typeInfo: nation.id === 0 ? '' : (nationType?.info ?? ''),
typePros: nationTypeEffects.pros, typePros: nationTypeEffects.pros,
typeCons: nationTypeEffects.cons, typeCons: nationTypeEffects.cons,
capitalCityName: nation.id === 0 ? null : (capitalCity?.name ?? null), capitalCityName: nation.id === 0 ? null : (capitalCity?.name ?? null),
+147 -10
View File
@@ -1,6 +1,7 @@
import { asRecord } from '@sammo-ts/common'; import { asRecord } from '@sammo-ts/common';
import { isItemKey, ItemLoader } from '@sammo-ts/logic'; import { isItemKey, ItemLoader } from '@sammo-ts/logic';
import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unitSetLoader.js'; 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'; import type { WorldStateRow } from '../context.js';
@@ -103,36 +104,172 @@ const resolveUnitSetName = (world: Pick<WorldStateRow, 'config'> | null, fallbac
return typeof environment.unitSet === 'string' && environment.unitSet.trim() ? environment.unitSet : fallback; return typeof environment.unitSet === 'string' && environment.unitSet.trim() ? environment.unitSet : fallback;
}; };
const crewTypeNameCache = new Map<string, Promise<Map<number, string>>>(); 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<Record<string, string>> = {
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<string, Promise<Map<number, CrewTypeDisplayDetails>>>();
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<WorldStateRow, 'config'> | null, world: Pick<WorldStateRow, 'config'> | null,
fallback: string fallback: string
): Promise<Map<number, string>> => { ): Promise<Map<number, CrewTypeDisplayDetails>> => {
const unitSetName = resolveUnitSetName(world, fallback); const unitSetName = resolveUnitSetName(world, fallback);
const cached = crewTypeNameCache.get(unitSetName); const cached = crewTypeDetailsCache.get(unitSetName);
if (cached) { if (cached) {
return cached; return cached;
} }
const pending = loadUnitSetDefinitionByName(unitSetName) const pending = loadUnitSetDefinitionByName(unitSetName)
.then((definition) => new Map((definition.crewTypes ?? []).map((crewType) => [crewType.id, crewType.name]))) .then(
.catch(() => new Map<number, string>()); (definition) =>
crewTypeNameCache.set(unitSetName, pending); new Map(
(definition.crewTypes ?? []).map((crewType) => [crewType.id, toCrewTypeDisplayDetails(crewType)])
)
)
.catch(() => new Map<number, CrewTypeDisplayDetails>());
crewTypeDetailsCache.set(unitSetName, pending);
return pending; return pending;
}; };
export const loadCrewTypeDisplayNames = (
world: Pick<WorldStateRow, 'config'> | null,
fallback: string
): Promise<Map<number, string>> =>
loadCrewTypeDisplayDetails(world, fallback).then(
(details) => new Map(Array.from(details, ([id, detail]) => [id, detail.name]))
);
const itemLoader = new ItemLoader(); const itemLoader = new ItemLoader();
export const loadItemDisplayNames = async (values: Array<string | null | undefined>): Promise<Map<string, string>> => { export interface ItemDisplayDetails {
name: string;
info: string;
}
export const loadItemDisplayDetails = async (
values: Array<string | null | undefined>
): Promise<Map<string, ItemDisplayDetails>> => {
const keys = Array.from(new Set(values.filter((value): value is string => Boolean(value) && value !== 'None'))); const keys = Array.from(new Set(values.filter((value): value is string => Boolean(value) && value !== 'None')));
const entries = await Promise.all( const entries = await Promise.all(
keys.map(async (key) => { keys.map(async (key) => {
if (!isItemKey(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); 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); return new Map(entries);
}; };
export const loadItemDisplayNames = async (values: Array<string | null | undefined>): Promise<Map<string, string>> => {
const details = await loadItemDisplayDetails(values);
return new Map(Array.from(details, ([key, detail]) => [key, detail.name]));
};
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
formatCrewTypeRequirement,
resolveCityLevelName, resolveCityLevelName,
resolveDedicationLevelName, resolveDedicationLevelName,
resolveNationLevelName, resolveNationLevelName,
@@ -29,4 +30,27 @@ describe('Ref GUI display names', () => {
expect(resolveCityLevelName(99)).toBe('-'); expect(resolveCityLevelName(99)).toBe('-');
expect(resolveRegionName(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('불가능');
});
}); });
@@ -424,13 +424,21 @@ describe('in-game my information ownership', () => {
general: { general: {
officerLevelText: '간의대부', officerLevelText: '간의대부',
crewTypeName: '보병', 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품관' }, progression: { experienceLevel: 4, dedicationLevel: 2, dedicationText: '29품관' },
itemNames: { horse: '노새(+3)' }, itemNames: { horse: '노새(+3)' },
itemInfo: { horse: '통솔 +3' },
}, },
city: { levelName: '특', regionName: '중원', nationName: '위' }, city: { levelName: '특', regionName: '중원', nationName: '위' },
nation: { nation: {
levelName: '주자사', levelName: '주자사',
typeName: '법가', typeName: '법가',
typeInfo: '금수입↑ 치안↑ 인구↓ 민심↓',
capitalCityName: '업', capitalCityName: '업',
}, },
}); });
@@ -508,6 +516,12 @@ describe('in-game my information ownership', () => {
specialDomestic: '상재', specialDomestic: '상재',
specialWar: '신산', specialWar: '신산',
}, },
traitInfo: {
personal: '사기 -5, 징·모병 비용 -20%',
specialDomestic: '[내정] 상업 투자 : 기본 보정 +10%, 성공률 +10%p, 비용 -20%',
specialWar:
'[계략] 화계·탈취·파괴·선동 : 성공률 +10%p<br>[전투] 계략 시도 확률 +20%p, 계략 성공 확률 +20%p',
},
}, },
nation: { nation: {
id: 0, id: 0,
@@ -518,6 +532,7 @@ describe('in-game my information ownership', () => {
rice: 0, rice: 0,
tech: 0, tech: 0,
typeCode: 'None', typeCode: 'None',
typeInfo: '',
capitalCityId: null, capitalCityId: null,
}, },
}); });
+134 -3
View File
@@ -64,6 +64,7 @@ type FixtureState = {
joinConfig?: Record<string, unknown>; joinConfig?: Record<string, unknown>;
createGeneralInputs?: Array<Record<string, unknown>>; createGeneralInputs?: Array<Record<string, unknown>>;
mainTraits?: { personal: string; specialDomestic: string; specialWar: string }; mainTraits?: { personal: string; specialDomestic: string; specialWar: string };
richMyInfo?: boolean;
hiddenSeedLogText?: string; hiddenSeedLogText?: string;
recentRecords?: { recentRecords?: {
global: Array<{ id: number; text: string; createdAt?: string }>; global: Array<{ id: number; text: string; createdAt?: string }>;
@@ -103,7 +104,22 @@ const myGeneral = (state: FixtureState) => ({
turnTime: '2026-01-01 00:10:00', turnTime: '2026-01-01 00:10:00',
crewTypeId: 1, crewTypeId: 1,
crewTypeName: '보병', 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: '-' }, traits: state.mainTraits ?? { personal: '-', specialDomestic: '-', specialWar: '-' },
traitInfo: state.richMyInfo
? {
personal: '부상당할 확률이 감소합니다.',
specialDomestic: '상업 내정 효율이 증가합니다.',
specialWar: '계략 성공률이 증가합니다.<br>발동 순서는 레거시와 같습니다.',
}
: { personal: '', specialDomestic: '', specialWar: '' },
progression: { progression: {
experienceLevel: 1, experienceLevel: 1,
dedicationLevel: 2, dedicationLevel: 2,
@@ -121,8 +137,20 @@ const myGeneral = (state: FixtureState) => ({
killedCrew: 12_345, killedCrew: 12_345,
lostCrew: 6_789, lostCrew: 6_789,
}, },
items: { horse: 'che_명마', weapon: null, book: null, item: null }, items: state.richMyInfo
itemNames: { horse: '명마', weapon: null, book: null, item: null }, ? { 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: '내정 실행 시 성공률이 증가합니다.<br>소모되지 않습니다.',
}
: { horse: null, weapon: null, book: null, item: null },
}, },
city: { city: {
id: 1, id: 1,
@@ -201,6 +229,7 @@ const myGeneral = (state: FixtureState) => ({
capitalCityName: '업', capitalCityName: '업',
typePros: '금수입↑ 치안↑', typePros: '금수입↑ 치안↑',
typeCons: '인구↓ 민심↓', typeCons: '인구↓ 민심↓',
typeInfo: state.richMyInfo ? '법과 질서를 중시하여 국가 운영을 안정시킵니다.' : '',
population: { cityCount: 1, current: 1_000, max: 2_000 }, population: { cityCount: 1, current: 1_000, max: 2_000 },
crew: { generalCount: 2, current: 500, max: 7_000 }, crew: { generalCount: 2, current: 500, max: 7_000 },
power: 1_234, 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); 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('&lt;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 }) => { test('내 정보&설정의 지난 플레이는 기본 탐색과 분리되어 오른쪽에 정렬된다', async ({ page }) => {
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] }; const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
await install(page, state); await install(page, state);
@@ -1528,7 +1657,9 @@ test('실제 모바일 터치로 메인 패널 순서를 재정렬한다', async
await dialog.screenshot({ path: testInfo.outputPath('mobile-main-panel-touch-dialog.png') }); await dialog.screenshot({ path: testInfo.outputPath('mobile-main-panel-touch-dialog.png') });
await dialog.getByRole('button', { name: '적용', exact: true }).click(); await dialog.getByRole('button', { name: '적용', exact: true }).click();
await expect await expect
.poll(() => mobilePage.evaluate(() => JSON.parse(localStorage.getItem('sam.mobileMainPanelOrder.v1') ?? '[]'))) .poll(() =>
mobilePage.evaluate(() => JSON.parse(localStorage.getItem('sam.mobileMainPanelOrder.v1') ?? '[]'))
)
.toEqual([ .toEqual([
'nation-menu', 'nation-menu',
'commands', 'commands',
+1
View File
@@ -48,6 +48,7 @@
"es-toolkit": "^1.43.0", "es-toolkit": "^1.43.0",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"tippy.js": "6.3.7",
"vue": "^3.5.26", "vue": "^3.5.26",
"vue-draggable-plus": "0.6.1", "vue-draggable-plus": "0.6.1",
"vue-router": "^4.6.4", "vue-router": "^4.6.4",
@@ -3,6 +3,7 @@ import { computed } from 'vue';
import SkeletonLines from '../ui/SkeletonLines.vue'; import SkeletonLines from '../ui/SkeletonLines.vue';
import LegacyProgressBar from '../ui/LegacyProgressBar.vue'; import LegacyProgressBar from '../ui/LegacyProgressBar.vue';
import RichTooltip from '../ui/RichTooltip.vue';
import { formatLocalTimeSeconds } from '../../utils/legacyDateTime'; import { formatLocalTimeSeconds } from '../../utils/legacyDateTime';
import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress'; import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress';
import { DEFAULT_GENERAL_ICON_URL, resolveGeneralIconBackgroundImage } from '../../utils/generalIcon'; import { DEFAULT_GENERAL_ICON_URL, resolveGeneralIconBackgroundImage } from '../../utils/generalIcon';
@@ -30,6 +31,28 @@ interface ItemDisplayNames {
item?: string | null; 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 { interface GeneralTroopDisplay {
name: string; name: string;
status: 'inactive' | 'present' | 'away'; status: 'inactive' | 'present' | 'away';
@@ -73,9 +96,12 @@ interface GeneralInfo {
refreshScore?: GeneralRefreshScore; refreshScore?: GeneralRefreshScore;
crewTypeId?: number; crewTypeId?: number;
crewTypeName?: string; crewTypeName?: string;
crewTypeInfo?: CrewTypeDisplayInfo | null;
traits?: { personal: string; specialWar: string; specialDomestic: string }; traits?: { personal: string; specialWar: string; specialDomestic: string };
traitInfo?: { personal: string; specialWar: string; specialDomestic: string };
progression?: GeneralProgression; progression?: GeneralProgression;
itemNames?: ItemDisplayNames; itemNames?: ItemDisplayNames;
itemInfo?: ItemDisplayInfo;
equipmentNames?: ItemDisplayNames; equipmentNames?: ItemDisplayNames;
} }
@@ -243,9 +269,36 @@ const specialText = computed(() => {
</strong> </strong>
</template> </template>
<span class="cell-label">명마</span><strong>{{ itemNames.horse ?? '-' }}</strong> <span class="cell-label">명마</span>
<span class="cell-label">무기</span><strong>{{ itemNames.weapon ?? '-' }}</strong> <strong>
<span class="cell-label">서적</span><strong>{{ itemNames.book ?? '-' }}</strong> <RichTooltip
:title="itemNames.horse ?? ''"
:description="props.general.itemInfo?.horse"
test-id="horse"
>
{{ itemNames.horse ?? '-' }}
</RichTooltip>
</strong>
<span class="cell-label">무기</span>
<strong>
<RichTooltip
:title="itemNames.weapon ?? ''"
:description="props.general.itemInfo?.weapon"
test-id="weapon"
>
{{ itemNames.weapon ?? '-' }}
</RichTooltip>
</strong>
<span class="cell-label">서적</span>
<strong>
<RichTooltip
:title="itemNames.book ?? ''"
:description="props.general.itemInfo?.book"
test-id="book"
>
{{ itemNames.book ?? '-' }}
</RichTooltip>
</strong>
<span <span
class="general-image general-crew-type-icon" class="general-image general-crew-type-icon"
@@ -255,15 +308,91 @@ const specialText = computed(() => {
/> />
<span class="cell-label">자금</span><strong>{{ props.general.gold.toLocaleString('ko-KR') }}</strong> <span class="cell-label">자금</span><strong>{{ props.general.gold.toLocaleString('ko-KR') }}</strong>
<span class="cell-label">군량</span><strong>{{ props.general.rice.toLocaleString('ko-KR') }}</strong> <span class="cell-label">군량</span><strong>{{ props.general.rice.toLocaleString('ko-KR') }}</strong>
<span class="cell-label">도구</span><strong>{{ itemNames.item ?? '-' }}</strong> <span class="cell-label">도구</span>
<strong>
<RichTooltip
:title="itemNames.item ?? ''"
:description="props.general.itemInfo?.item"
test-id="item"
>
{{ itemNames.item ?? '-' }}
</RichTooltip>
</strong>
<span class="cell-label">병종</span><strong>{{ props.general.crewTypeName ?? '-' }}</strong> <span class="cell-label">병종</span>
<strong>
<RichTooltip
:title="props.general.crewTypeName ?? ''"
:description="props.general.crewTypeInfo?.info"
test-id="crew-type"
>
{{ props.general.crewTypeName ?? '-' }}
<template v-if="props.general.crewTypeInfo" #content>
<span class="rich-tooltip-content__title">{{ props.general.crewTypeInfo.name }}</span>
<span
v-for="(line, index) in props.general.crewTypeInfo.info"
:key="`crew-info:${index}`"
class="rich-tooltip-content__line"
>
{{ line }}
</span>
<span class="rich-tooltip-content__section">전투 정보</span>
<span class="rich-tooltip-content__meta">
공격 {{ props.general.crewTypeInfo.stats.attack }} · 방어
{{ props.general.crewTypeInfo.stats.defence }} · 속도
{{ props.general.crewTypeInfo.stats.speed }} · 회피
{{ props.general.crewTypeInfo.stats.avoid }}% · 계략
{{ props.general.crewTypeInfo.stats.magicCoef }}%
</span>
<span class="rich-tooltip-content__meta">
병사 100 기준 {{ props.general.crewTypeInfo.stats.cost }} ·
{{ props.general.crewTypeInfo.stats.rice }}
</span>
<template v-if="props.general.crewTypeInfo.requirements.length">
<span class="rich-tooltip-content__section">생성 조건</span>
<span
v-for="(requirement, index) in props.general.crewTypeInfo.requirements"
:key="`crew-requirement:${index}`"
class="rich-tooltip-content__line"
>
{{ requirement }}
</span>
</template>
</template>
</RichTooltip>
</strong>
<span class="cell-label">병사</span><strong>{{ props.general.crew.toLocaleString('ko-KR') }}</strong> <span class="cell-label">병사</span><strong>{{ props.general.crew.toLocaleString('ko-KR') }}</strong>
<span class="cell-label">성격</span><strong>{{ props.general.traits?.personal ?? '-' }}</strong> <span class="cell-label">성격</span>
<strong>
<RichTooltip
:title="props.general.traits?.personal ?? ''"
:description="props.general.traitInfo?.personal"
test-id="personality"
>
{{ props.general.traits?.personal ?? '-' }}
</RichTooltip>
</strong>
<span class="cell-label">훈련</span><strong>{{ props.general.train }}</strong> <span class="cell-label">훈련</span><strong>{{ props.general.train }}</strong>
<span class="cell-label">사기</span><strong>{{ props.general.atmos }}</strong> <span class="cell-label">사기</span><strong>{{ props.general.atmos }}</strong>
<span class="cell-label">특기</span><strong :title="specialText">{{ specialText }}</strong> <span class="cell-label">특기</span>
<strong class="special-value" :aria-label="specialText">
<RichTooltip
:title="`내정특기 · ${props.general.traits?.specialDomestic ?? '-'}`"
:description="props.general.traitInfo?.specialDomestic"
test-id="special-domestic"
>
{{ props.general.traits?.specialDomestic ?? '-' }}
</RichTooltip>
/
<RichTooltip
:title="`전투특기 · ${props.general.traits?.specialWar ?? '-'}`"
:description="props.general.traitInfo?.specialWar"
test-id="special-war"
>
{{ props.general.traits?.specialWar ?? '-' }}
</RichTooltip>
</strong>
<span class="cell-label level-label">Lv</span> <span class="cell-label level-label">Lv</span>
<strong class="level-value">{{ props.general.progression?.experienceLevel ?? 0 }}</strong> <strong class="level-value">{{ props.general.progression?.experienceLevel ?? 0 }}</strong>
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import SkeletonLines from '../ui/SkeletonLines.vue'; import SkeletonLines from '../ui/SkeletonLines.vue';
import RichTooltip from '../ui/RichTooltip.vue';
import { legacyLuminanceTextColor } from '../../utils/legacyNationColor'; import { legacyLuminanceTextColor } from '../../utils/legacyNationColor';
import { formatOfficerLevelText } from '../../utils/nationFormat'; import { formatOfficerLevelText } from '../../utils/nationFormat';
import { getNpcColor } from '../../utils/npcColor'; import { getNpcColor } from '../../utils/npcColor';
@@ -19,6 +20,7 @@ interface NationInfo {
rice: number; rice: number;
tech: number; tech: number;
typeName: string; typeName: string;
typeInfo?: string;
typePros: string; typePros: string;
typeCons: string; typeCons: string;
population: { cityCount: number; current: number; max: number }; population: { cityCount: number; current: number; max: number };
@@ -67,9 +69,37 @@ const displayChiefName = (chief: NationChief | undefined): string => {
<span class="head">성향</span> <span class="head">성향</span>
<strong class="body type-body"> <strong class="body type-body">
<RichTooltip
v-if="props.nation.typeInfo"
:title="`국가 성향 · ${props.nation.typeName}`"
:description="props.nation.typeInfo"
test-id="nation-type"
>
{{ props.nation.typeName }} (<span class="pros">{{ props.nation.typePros }}</span> {{ props.nation.typeName }} (<span class="pros">{{ props.nation.typePros }}</span>
<span class="cons">{{ props.nation.typeCons }}</span <span class="cons">{{ props.nation.typeCons }}</span
>) >)
<template #content="{ descriptionLines }">
<span class="rich-tooltip-content__title">국가 성향 · {{ props.nation.typeName }}</span>
<span
v-for="(line, index) in descriptionLines"
:key="`nation-type-info:${index}`"
class="rich-tooltip-content__line"
>
{{ line }}
</span>
<span class="rich-tooltip-content__line rich-tooltip-content__pros">
장점 {{ props.nation.typePros || '-' }}
</span>
<span class="rich-tooltip-content__line rich-tooltip-content__cons">
단점 {{ props.nation.typeCons || '-' }}
</span>
</template>
</RichTooltip>
<template v-else>
{{ props.nation.typeName }} (<span class="pros">{{ props.nation.typePros }}</span>
<span class="cons">{{ props.nation.typeCons }}</span
>)
</template>
</strong> </strong>
<span class="head">{{ formatOfficerLevelText(12, props.nation.level) }}</span> <span class="head">{{ formatOfficerLevelText(12, props.nation.level) }}</span>
@@ -0,0 +1,179 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useSlots, watch } from 'vue';
import tippy, { type Instance, type Placement } from 'tippy.js';
import 'tippy.js/dist/tippy.css';
const props = withDefaults(
defineProps<{
title?: string;
description?: string | readonly string[] | null;
placement?: Placement;
maxWidth?: number;
testId?: string;
}>(),
{
title: '',
description: null,
placement: 'top',
maxWidth: 360,
testId: undefined,
}
);
const slots = useSlots();
const triggerElement = ref<HTMLElement | null>(null);
const contentElement = ref<HTMLElement | null>(null);
let instance: Instance | null = null;
const descriptionLines = computed(() => {
const source = Array.isArray(props.description) ? props.description : [props.description ?? ''];
return source
.flatMap((line) => line.split(/<br\s*\/?\s*>|\r?\n/giu))
.map((line) => line.trim())
.filter(Boolean);
});
const hasContent = computed(() => Boolean(slots.content) || descriptionLines.value.length > 0);
const destroyTooltip = () => {
instance?.destroy();
instance = null;
};
const installTooltip = async () => {
destroyTooltip();
await nextTick();
if (!hasContent.value || !triggerElement.value || !contentElement.value) return;
instance = tippy(triggerElement.value, {
allowHTML: true,
appendTo: () => document.body,
content: () => contentElement.value?.innerHTML ?? '',
maxWidth: props.maxWidth,
placement: props.placement,
theme: 'sammo-rich',
trigger: 'mouseenter focus',
});
};
onMounted(() => void installTooltip());
onBeforeUnmount(destroyTooltip);
watch(
() => [props.title, props.description, props.placement, props.maxWidth],
() => void installTooltip(),
{ deep: true }
);
</script>
<template>
<span
ref="triggerElement"
class="rich-tooltip-trigger"
:class="{ 'rich-tooltip-trigger--enabled': hasContent }"
:tabindex="hasContent ? 0 : undefined"
:data-rich-tooltip="props.testId"
>
<slot />
</span>
<span ref="contentElement" class="rich-tooltip-template" hidden aria-hidden="true">
<slot name="content" :description-lines="descriptionLines">
<span v-if="props.title" class="rich-tooltip-content__title">{{ props.title }}</span>
<span
v-for="(line, index) in descriptionLines"
:key="`${index}:${line}`"
class="rich-tooltip-content__line"
>
{{ line }}
</span>
</slot>
</span>
</template>
<style>
.rich-tooltip-trigger {
display: inline;
min-width: 0;
}
.rich-tooltip-trigger--enabled {
cursor: help;
text-decoration: underline dotted rgb(150 210 255 / 85%);
text-underline-offset: 2px;
}
.rich-tooltip-trigger--enabled:focus-visible {
border-radius: 2px;
outline: 1px solid #6fc7ff;
outline-offset: 1px;
}
.tippy-box[data-theme~='sammo-rich'] {
border: 1px solid #8c8c8c;
border-radius: 3px;
background: #101010;
box-shadow: 0 3px 12px rgb(0 0 0 / 65%);
color: #f5f5f5;
font-family: Pretendard, sans-serif;
font-size: 12.5px;
line-height: 1.45;
text-align: left;
}
.tippy-box[data-theme~='sammo-rich'][data-placement^='top'] > .tippy-arrow::before {
border-top-color: #101010;
}
.tippy-box[data-theme~='sammo-rich'][data-placement^='bottom'] > .tippy-arrow::before {
border-bottom-color: #101010;
}
.tippy-box[data-theme~='sammo-rich'][data-placement^='left'] > .tippy-arrow::before {
border-left-color: #101010;
}
.tippy-box[data-theme~='sammo-rich'][data-placement^='right'] > .tippy-arrow::before {
border-right-color: #101010;
}
.tippy-box[data-theme~='sammo-rich'] .tippy-content {
padding: 7px 9px;
}
.rich-tooltip-content__title,
.rich-tooltip-content__line,
.rich-tooltip-content__section,
.rich-tooltip-content__meta {
display: block;
}
.rich-tooltip-content__title {
margin-bottom: 4px;
color: #7fd4ff;
font-size: 13px;
font-weight: 700;
}
.rich-tooltip-content__line + .rich-tooltip-content__line {
margin-top: 2px;
}
.rich-tooltip-content__section {
margin-top: 5px;
border-top: 1px solid #4d4d4d;
padding-top: 4px;
color: #ffdc76;
font-weight: 700;
}
.rich-tooltip-content__meta {
color: #d5d5d5;
}
.rich-tooltip-content__pros {
color: cyan;
}
.rich-tooltip-content__cons {
color: magenta;
}
</style>
+15
View File
@@ -207,6 +207,9 @@ importers:
pinia: pinia:
specifier: ^3.0.4 specifier: ^3.0.4
version: 3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)) version: 3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
tippy.js:
specifier: 6.3.7
version: 6.3.7
vue: vue:
specifier: ^3.5.26 specifier: ^3.5.26
version: 3.5.41(typescript@6.0.3) version: 3.5.41(typescript@6.0.3)
@@ -1347,6 +1350,9 @@ packages:
'@pm2/pm2-version-check@1.0.4': '@pm2/pm2-version-check@1.0.4':
resolution: {integrity: sha512-SXsM27SGH3yTWKc2fKR4SYNxsmnvuBQ9dd6QHtEWmiZ/VqaOYPAIlS8+vMcn27YLtAEBGvNRSh3TPNvtjZgfqA==} resolution: {integrity: sha512-SXsM27SGH3yTWKc2fKR4SYNxsmnvuBQ9dd6QHtEWmiZ/VqaOYPAIlS8+vMcn27YLtAEBGvNRSh3TPNvtjZgfqA==}
'@popperjs/core@2.11.8':
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
'@prisma/adapter-pg@7.9.1': '@prisma/adapter-pg@7.9.1':
resolution: {integrity: sha512-Ho2RK1KanQxLNSC0sR5bpiiVep10sWPLXCcxK+KXfI/Q69TMRbiafSvLPv3V9snimX72rMCqGlyJ4sBO4lKTAw==} resolution: {integrity: sha512-Ho2RK1KanQxLNSC0sR5bpiiVep10sWPLXCcxK+KXfI/Q69TMRbiafSvLPv3V9snimX72rMCqGlyJ4sBO4lKTAw==}
@@ -4489,6 +4495,9 @@ packages:
resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
tippy.js@6.3.7:
resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==}
to-regex-range@5.0.1: to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'} engines: {node: '>=8.0'}
@@ -5496,6 +5505,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@popperjs/core@2.11.8': {}
'@prisma/adapter-pg@7.9.1': '@prisma/adapter-pg@7.9.1':
dependencies: dependencies:
'@prisma/driver-adapter-utils': 7.9.1 '@prisma/driver-adapter-utils': 7.9.1
@@ -8517,6 +8528,10 @@ snapshots:
tinyrainbow@3.1.1: {} tinyrainbow@3.1.1: {}
tippy.js@6.3.7:
dependencies:
'@popperjs/core': 2.11.8
to-regex-range@5.0.1: to-regex-range@5.0.1:
dependencies: dependencies:
is-number: 7.0.0 is-number: 7.0.0