merge: NPC 자동 징병 통솔 상한 수정을 main에 통합

This commit is contained in:
2026-08-21 04:34:43 +00:00
2 changed files with 76 additions and 21 deletions
@@ -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));
@@ -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,