diff --git a/app/game-engine/src/turn/ai/generalAi/general/recruitActions.ts b/app/game-engine/src/turn/ai/generalAi/general/recruitActions.ts index a451faa0..084b3468 100644 --- a/app/game-engine/src/turn/ai/generalAi/general/recruitActions.ts +++ b/app/game-engine/src/turn/ai/generalAi/general/recruitActions.ts @@ -69,7 +69,31 @@ export const do징병 = (ai: GeneralAI) => { } const generalMeta = asRecord(ai.general.meta); - const fullLeadership = readMetaNumber(generalMeta, 'fullLeadership', ai.general.stats.leadership); + const recruitContext = { + general: ai.general, + nation, + ...(ai.worldRef + ? { + worldView: { + listGenerals: () => ai.worldRef!.listGenerals(), + listGeneralsByCity: (cityId: number) => + ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId), + listNations: () => ai.worldRef!.listNations(), + }, + } + : {}), + time: { + year: ai.world.currentYear, + month: ai.world.currentMonth, + startYear: ai.startYear, + }, + }; + const recruitment = new RecruitmentCommandResolver(ai.commandEnv.generalActionModules ?? [], ai.commandEnv); + // The cached AI stat follows the scenario/global classification cap, while + // che_징병 resolves the actual command capacity from the general's current + // stat and modules. NPC recruitment must request that same uncapped amount; + // otherwise a 300-leadership general can be stuck at a 100/140/255 cap. + const fullLeadership = recruitment.resolveFullLeadership(recruitContext); trace('population-policy', { population: city.population, populationMax: city.populationMax, @@ -175,26 +199,6 @@ export const do징병 = (ai: GeneralAI) => { // whether to halve the requested crew. In particular, that command caps // the charge at the actually refillable amount when the selected type is // already equipped, then applies traits/items and legacy rounding. - const recruitContext = { - general: ai.general, - nation, - ...(ai.worldRef - ? { - worldView: { - listGenerals: () => ai.worldRef!.listGenerals(), - listGeneralsByCity: (cityId: number) => - ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId), - listNations: () => ai.worldRef!.listNations(), - }, - } - : {}), - time: { - year: ai.world.currentYear, - month: ai.world.currentMonth, - startYear: ai.startYear, - }, - }; - const recruitment = new RecruitmentCommandResolver(ai.commandEnv.generalActionModules ?? [], ai.commandEnv); const goldCost = recruitment.getCost(recruitContext, crewTypeId, crewAmount, picked).gold; const killCrew = readMetaNumber(generalMeta, 'rank_killcrew', readMetaNumber(generalMeta, 'killcrew', 0)); const deathCrew = readMetaNumber(generalMeta, 'rank_deathcrew', readMetaNumber(generalMeta, 'deathcrew', 0)); diff --git a/app/game-engine/test/generalAiLegacyDecisionParity.test.ts b/app/game-engine/test/generalAiLegacyDecisionParity.test.ts index 82b76ed2..89c00d34 100644 --- a/app/game-engine/test/generalAiLegacyDecisionParity.test.ts +++ b/app/game-engine/test/generalAiLegacyDecisionParity.test.ts @@ -955,6 +955,57 @@ describe('legacy NPC AI final-decision parity', () => { }); }); + it.each([ + [101, 100, 10_100], + [140, 100, 14_000], + [256, 255, 25_600], + [300, 140, 30_000], + [300, 255, 30_000], + ])( + 'requests the command-resolved full crew above the cached AI cap (leadership=%i, cached=%i)', + (leadership, cachedLeadership, amount) => { + const ai = makeAi({ + dipState: 2, + city: { population: 100_000, populationMax: 100_000 }, + general: { + stats: { leadership, strength: 70, intelligence: 70 }, + gold: 100_000, + rice: 100_000, + meta: { killturn: 100, fullLeadership: cachedLeadership, rank_killcrew: 0, rank_deathcrew: 1 }, + }, + rng: makeRng([], [0, 0]), + }); + + expect(do징병(ai)).toMatchObject({ + action: 'che_징병', + args: { crewType: 1, amount }, + }); + } + ); + + it('uses recruitment stat modules when resolving uncapped NPC crew capacity', () => { + const ai = makeAi({ + dipState: 2, + city: { population: 100_000, populationMax: 100_000 }, + general: { + stats: { leadership: 240, strength: 70, intelligence: 70 }, + gold: 100_000, + rice: 100_000, + meta: { killturn: 100, fullLeadership: 100, rank_killcrew: 0, rank_deathcrew: 1 }, + }, + generalActionModules: singleActionModuleStack({ + eventHandlers: {}, + onCalcStat: (_context, statName, value) => (statName === 'leadership' ? value * 1.25 : value), + }), + rng: makeRng([], [0, 0]), + }); + + expect(do징병(ai)).toMatchObject({ + action: 'che_징병', + args: { crewType: 1, amount: 30_000 }, + }); + }); + it('uses the refillable same-type crew amount for the legacy gold-cost halving threshold', () => { const ai = makeAi({ dipState: 2, diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index a9580002..d9ae12af 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -2291,6 +2291,7 @@ test('keeps Ref command briefs and autonomous-action state after a turn mutation test('uses drag selection, clipboard paste, and a stored template in advanced mode', async ({ page }) => { const requests = await install(page); + await page.setViewportSize({ width: 1200, height: 900 }); await page.goto('/'); const editor = page.locator('[data-command-scope="general"]'); @@ -2318,6 +2319,41 @@ test('uses drag selection, clipboard paste, and a stored template in advanced mo await picker.getByRole('button', { name: '입력', exact: true }).click(); await expect(editor.locator('.action-column > div').nth(2)).toHaveText('【허창】에 화계실행'); + const recentMenu = editor.locator('details').filter({ has: page.getByText('최근 실행', { exact: true }) }); + await recentMenu.locator('summary').click(); + const recentBriefButton = recentMenu.getByRole('button', { name: '【허창】에 화계실행', exact: true }); + await expect(recentBriefButton).toBeVisible(); + await recentBriefButton.hover(); + await page.screenshot({ + path: test.info().outputPath('advanced-recent-command-brief-desktop-1200.png'), + fullPage: true, + }); + await recentMenu.locator('summary').click(); + await page.setViewportSize({ width: 500, height: 900 }); + await recentMenu.locator('summary').click(); + await expect(recentBriefButton).toBeVisible(); + await recentBriefButton.focus(); + await expect(recentBriefButton).toBeFocused(); + const mobileRecentGeometry = await editor.evaluate((element) => { + const menu = element.querySelector('details[open] .menu-items'); + const recentButton = menu?.querySelector('button'); + if (!menu || !recentButton) throw new Error('advanced recent command menu is missing'); + return { + horizontalOverflow: element.scrollWidth - element.clientWidth, + menuRight: menu.getBoundingClientRect().right, + buttonRight: recentButton.getBoundingClientRect().right, + }; + }); + expect(mobileRecentGeometry.horizontalOverflow).toBeLessThanOrEqual(0); + expect(mobileRecentGeometry.menuRight).toBeLessThanOrEqual(500); + expect(mobileRecentGeometry.buttonRight).toBeLessThanOrEqual(500); + await page.screenshot({ + path: test.info().outputPath('advanced-recent-command-brief-mobile-500.png'), + fullPage: true, + }); + await recentMenu.locator('summary').click(); + await page.setViewportSize({ width: 1200, height: 900 }); + await drag(0, 2); await editor.locator('details.selected-menu > summary').click(); await editor.getByRole('button', { name: '복사하기', exact: true }).click(); diff --git a/app/game-frontend/src/components/command/ReservedCommandEditor.vue b/app/game-frontend/src/components/command/ReservedCommandEditor.vue index 10ac3c95..d46f5791 100644 --- a/app/game-frontend/src/components/command/ReservedCommandEditor.vue +++ b/app/game-frontend/src/components/command/ReservedCommandEditor.vue @@ -142,11 +142,12 @@ const isRecruitmentCommand = computed( () => selectedCommand.value?.key === 'che_징병' || selectedCommand.value?.key === 'che_모병' ); const isRecruitmentOverlayOpen = computed(() => pickerOpen.value && isRecruitmentCommand.value); -const rowLabel = (row: ReservedCommandRow): string => - formatReservedCommandBrief(props.scope, row.action, row.args, props.commandTable) || - row.label || - labelMap.value.get(row.action) || - row.action; +const commandBrief = (entry: { action: string; args: unknown; label?: string }): string => + formatReservedCommandBrief(props.scope, entry.action, entry.args, props.commandTable) || + entry.label || + labelMap.value.get(entry.action) || + entry.action; +const rowLabel = (row: ReservedCommandRow): string => commandBrief(row); const selectedIndices = () => normalizedSelection(selected.value, previousSelected.value, props.rows.length); const pattern = () => extractPattern(props.rows, selectedIndices()); const touchMenus = () => (menuRevision.value += 1); @@ -467,7 +468,7 @@ const clickOutsideMenu = (event: Event) => { clickOutsideMenu($event); " > - {{ entry.label ?? labelMap.get(entry.action) ?? entry.action }} + {{ commandBrief(entry) }} 비어 있음