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,