feat: 내정보 상세 설명 툴팁 이관
말·무기·서적·도구·병종·특기 설명과 국가 성향 정보를 API에 투영하고 Tippy.js 기반 공용 툴팁으로 표시한다. 실제 Chromium의 hover·focus와 모바일 경계를 회귀 테스트한다.
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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]));
|
||||
};
|
||||
|
||||
@@ -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('불가능');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<br>[전투] 계략 시도 확률 +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,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user