fix: 빈 특기의 획득 예정 나이를 표시한다

메인 장수 read model에 내정·전투 특기 예정 나이를 투영하고, Ref와 같은 현재 나이 다음 해 하한을 적용한다. API 회귀와 Chromium 데스크톱·모바일 검증을 추가한다.
This commit is contained in:
2026-08-22 05:03:25 +00:00
parent 0c75b96908
commit b8e9e6b513
4 changed files with 94 additions and 6 deletions
+4
View File
@@ -553,6 +553,10 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
specialDomestic: resolveTraitDisplayName(general.specialCode, domesticNames), specialDomestic: resolveTraitDisplayName(general.specialCode, domesticNames),
specialWar: resolveTraitDisplayName(general.special2Code, warNames), specialWar: resolveTraitDisplayName(general.special2Code, warNames),
}, },
traitAges: {
specialDomestic: readNumber(metaRecord.specage, 0),
specialWar: readNumber(metaRecord.specage2, 0),
},
traitInfo: { traitInfo: {
personal: personalityNames.get(general.personalCode)?.info ?? '', personal: personalityNames.get(general.personalCode)?.info ?? '',
specialDomestic: domesticNames.get(general.specialCode)?.info ?? '', specialDomestic: domesticNames.get(general.specialCode)?.info ?? '',
@@ -510,6 +510,7 @@ describe('in-game my information ownership', () => {
personalCode: 'che_안전', personalCode: 'che_안전',
specialCode: 'che_상재', specialCode: 'che_상재',
special2Code: 'che_신산', special2Code: 'che_신산',
meta: { specage: 31, specage2: 35 },
}), }),
}); });
@@ -520,6 +521,10 @@ describe('in-game my information ownership', () => {
specialDomestic: '상재', specialDomestic: '상재',
specialWar: '신산', specialWar: '신산',
}, },
traitAges: {
specialDomestic: 31,
specialWar: 35,
},
traitInfo: { traitInfo: {
personal: '사기 -5, 징·모병 비용 -20%', personal: '사기 -5, 징·모병 비용 -20%',
specialDomestic: '[내정] 상업 투자 : 기본 보정 +10%, 성공률 +10%p, 비용 -20%', specialDomestic: '[내정] 상업 투자 : 기본 보정 +10%, 성공률 +10%p, 비용 -20%',
@@ -543,6 +548,31 @@ describe('in-game my information ownership', () => {
expect(fixture.db.nation.findUnique).not.toHaveBeenCalled(); expect(fixture.db.nation.findUnique).not.toHaveBeenCalled();
}); });
it('returns the scheduled acquisition ages when the owned general has no domestic or war trait', async () => {
const fixture = createContext({
me: buildGeneral({
age: 30,
specialCode: 'None',
special2Code: 'None',
meta: { specage: 35, specage2: 29 },
}),
});
await expect(appRouter.createCaller(fixture.context).general.me()).resolves.toMatchObject({
general: {
age: 30,
traits: {
specialDomestic: '-',
specialWar: '-',
},
traitAges: {
specialDomestic: 35,
specialWar: 29,
},
},
});
});
it('reads legacy top-level settings and dispatches only the session-owned general', async () => { it('reads legacy top-level settings and dispatches only the session-owned general', async () => {
const requestCommand = vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: 7 })); const requestCommand = vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: 7 }));
const fixture = createContext({ requestCommand }); const fixture = createContext({ requestCommand });
+43
View File
@@ -77,6 +77,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 };
mainTraitAges?: { specialDomestic: number; specialWar: number };
richMyInfo?: boolean; richMyInfo?: boolean;
hiddenSeedLogText?: string; hiddenSeedLogText?: string;
recentRecords?: { recentRecords?: {
@@ -130,6 +131,7 @@ const myGeneral = (state: FixtureState) => ({
} }
: null, : null,
traits: state.mainTraits ?? { personal: '-', specialDomestic: '-', specialWar: '-' }, traits: state.mainTraits ?? { personal: '-', specialDomestic: '-', specialWar: '-' },
traitAges: state.mainTraitAges ?? { specialDomestic: 31, specialWar: 31 },
traitInfo: state.richMyInfo traitInfo: state.richMyInfo
? { ? {
personal: '부상당할 확률이 감소합니다.', personal: '부상당할 확률이 감소합니다.',
@@ -725,6 +727,47 @@ test('재야 메인은 국가 틀과 성격·특기 표기명을 Chromium에 표
await persistParityArtifact(page, 'main-neutral-trait-display', geometry); await persistParityArtifact(page, 'main-neutral-trait-display', geometry);
}); });
test('메인 장수 정보는 없는 내정·전투 특기의 Ref 획득 나이를 Chromium에 표시한다', async ({ page }) => {
const state: FixtureState = {
permission: 'member',
myset: 0,
mainTraits: { personal: '안전', specialDomestic: '-', specialWar: '-' },
mainTraitAges: { specialDomestic: 35, specialWar: 29 },
settingMutations: [],
accessPages: [],
};
await install(page, state);
for (const viewport of [
{ width: 1000, height: 900 },
{ width: 390, height: 844 },
]) {
await page.setViewportSize(viewport);
await page.goto('');
const specialValue = page.locator('.general-card .special-value');
await expect(specialValue).toHaveText(/35\s*\/\s*31/u);
await expect(specialValue).toHaveAttribute('aria-label', '35세 / 31세');
const geometry = await specialValue.evaluate((element) => {
const rect = element.getBoundingClientRect();
const documentWidth = document.documentElement.scrollWidth;
return {
text: element.textContent?.replace(/\s+/gu, ' ').trim(),
left: rect.left,
right: rect.right,
width: rect.width,
documentWidth,
viewportWidth: window.innerWidth,
};
});
expect(geometry.width).toBeGreaterThan(0);
expect(geometry.left).toBeGreaterThanOrEqual(0);
expect(geometry.right).toBeLessThanOrEqual(geometry.documentWidth);
expect(geometry.documentWidth).toBe(Math.max(viewport.width, 500));
await persistParityArtifact(page, `main-speciality-age-${viewport.width}`, geometry);
}
});
test('메인 카드의 국가·수도·관직·계급·병종은 Ref 출력명으로 표시된다', async ({ page }) => { test('메인 카드의 국가·수도·관직·계급·병종은 Ref 출력명으로 표시된다', async ({ page }) => {
const state: FixtureState = { const state: FixtureState = {
permission: 'head', permission: 'head',
@@ -99,6 +99,7 @@ export interface GeneralBasicCardData {
crewTypeName?: string; crewTypeName?: string;
crewTypeInfo?: CrewTypeDisplayInfo | null; crewTypeInfo?: CrewTypeDisplayInfo | null;
traits?: { personal: string; specialWar: string; specialDomestic: string }; traits?: { personal: string; specialWar: string; specialDomestic: string };
traitAges?: { specialWar: number; specialDomestic: number };
traitInfo?: { personal: string; specialWar: string; specialDomestic: string }; traitInfo?: { personal: string; specialWar: string; specialDomestic: string };
progression?: GeneralProgression; progression?: GeneralProgression;
itemNames?: ItemDisplayNames; itemNames?: ItemDisplayNames;
@@ -226,9 +227,19 @@ const displayDefence = computed(() => {
}); });
const displayKillTurn = computed(() => props.general?.killTurn ?? props.killTurn); const displayKillTurn = computed(() => props.general?.killTurn ?? props.killTurn);
const displayRemainingMinutes = computed(() => props.general?.remainingMinutes ?? props.remainingMinutes); const displayRemainingMinutes = computed(() => props.general?.remainingMinutes ?? props.remainingMinutes);
const resolveSpecialDisplayName = (kind: 'specialDomestic' | 'specialWar') => {
const general = props.general;
if (!general) return '-';
const traitName = general.traits?.[kind];
if (traitName && traitName !== '-') return traitName;
const scheduledAge = general.traitAges?.[kind];
if (general.age === undefined || scheduledAge === undefined) return '-';
return `${Math.max(general.age + 1, scheduledAge)}`;
};
const specialDomesticText = computed(() => resolveSpecialDisplayName('specialDomestic'));
const specialWarText = computed(() => resolveSpecialDisplayName('specialWar'));
const specialText = computed(() => { const specialText = computed(() => {
const traits = props.general?.traits; return `${specialDomesticText.value} / ${specialWarText.value}`;
return traits ? `${traits.specialDomestic || '-'} / ${traits.specialWar || '-'}` : '-';
}); });
</script> </script>
@@ -379,19 +390,19 @@ const specialText = computed(() => {
<span class="cell-label">특기</span> <span class="cell-label">특기</span>
<strong class="special-value" :aria-label="specialText"> <strong class="special-value" :aria-label="specialText">
<RichTooltip <RichTooltip
:title="`내정특기 · ${props.general.traits?.specialDomestic ?? '-'}`" :title="`내정특기 · ${specialDomesticText}`"
:description="props.general.traitInfo?.specialDomestic" :description="props.general.traitInfo?.specialDomestic"
test-id="special-domestic" test-id="special-domestic"
> >
{{ props.general.traits?.specialDomestic ?? '-' }} {{ specialDomesticText }}
</RichTooltip> </RichTooltip>
/ /
<RichTooltip <RichTooltip
:title="`전투특기 · ${props.general.traits?.specialWar ?? '-'}`" :title="`전투특기 · ${specialWarText}`"
:description="props.general.traitInfo?.specialWar" :description="props.general.traitInfo?.specialWar"
test-id="special-war" test-id="special-war"
> >
{{ props.general.traits?.specialWar ?? '-' }} {{ specialWarText }}
</RichTooltip> </RichTooltip>
</strong> </strong>