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
+147 -10
View File
@@ -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<WorldStateRow, 'config'> | null, fallbac
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,
fallback: string
): Promise<Map<number, string>> => {
): Promise<Map<number, CrewTypeDisplayDetails>> => {
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<number, string>());
crewTypeNameCache.set(unitSetName, pending);
.then(
(definition) =>
new Map(
(definition.crewTypes ?? []).map((crewType) => [crewType.id, toCrewTypeDisplayDetails(crewType)])
)
)
.catch(() => new Map<number, CrewTypeDisplayDetails>());
crewTypeDetailsCache.set(unitSetName, 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();
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 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<string | null | undefined>): Promise<Map<string, string>> => {
const details = await loadItemDisplayDetails(values);
return new Map(Array.from(details, ([key, detail]) => [key, detail.name]));
};