fix: NPC 사용자 장수 인사 조건을 Ref와 맞춘다

첫 예약턴과 부대장 턴 순서, 징집 가능 점수, 후방 도시 조건을 반영한다.

징집 후 준비도를 갖춘 뒤 전방 발령하고 구출·포상·몰수 후보와 RNG 순서를 회귀 테스트로 고정한다.
This commit is contained in:
2026-08-22 04:42:08 +00:00
parent a41eb16bb9
commit 8b5fe4e72c
6 changed files with 370 additions and 111 deletions
+48 -25
View File
@@ -673,10 +673,53 @@ export class GeneralAI {
this.promotionNationMeta = nextMeta;
}
getReservedTurn(generalId: number): ReservedTurnEntry {
getFirstReservedGeneralTurn(generalId: number): ReservedTurnEntry {
return this.reservedTurnProvider.getGeneralTurn(generalId, 0);
}
resolveGeneralAiStats(general: TurnGeneral): ReturnType<typeof resolveLegacyAiStats> {
if (this.commandEnv.generalActionModules) {
return resolveLegacyAiStatsWithModules(
general,
this.nation,
this.commandEnv.maxStatLevel ?? LEGACY_DEFAULT_MAX_LEVEL,
this.commandEnv.generalActionModules,
this.worldRef,
this.world,
this.startYear
);
}
return resolveLegacyAiStats(general, this.nation, this.commandEnv.maxStatLevel ?? LEGACY_DEFAULT_MAX_LEVEL);
}
calculateRecruitPopulationScore(general: TurnGeneral): number {
const pipeline = new GeneralActionPipeline(this.commandEnv.generalActionModules ?? []);
return pipeline.onCalcDomestic(
{
general,
nation: this.nation,
...(this.worldRef
? {
worldView: {
listGenerals: () => this.worldRef!.listGenerals(),
listGeneralsByCity: (cityId: number) =>
this.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId),
listNations: () => this.worldRef!.listNations(),
},
}
: {}),
time: {
year: this.world.currentYear,
month: this.world.currentMonth,
startYear: this.startYear,
},
},
'징집인구',
'score',
100
);
}
calcNationDevelopedRate(): Record<string, number> {
if (this.devRate) {
return this.devRate;
@@ -850,7 +893,8 @@ export class GeneralAI {
const isTroopLeader =
npcType === 5 ||
(candidate.troopId === candidate.id && this.getReservedTurn(candidate.id).action === 'che_집합');
(candidate.troopId === candidate.id &&
this.getFirstReservedGeneralTurn(candidate.id).action === 'che_집합');
if (isTroopLeader) {
troopLeaders[candidate.id] = candidate;
continue;
@@ -875,18 +919,7 @@ export class GeneralAI {
continue;
}
const fullLeadership = this.commandEnv.generalActionModules
? resolveLegacyAiStatsWithModules(
candidate,
this.nation,
this.commandEnv.maxStatLevel ?? LEGACY_DEFAULT_MAX_LEVEL,
this.commandEnv.generalActionModules,
this.worldRef,
this.world,
this.startYear
).fullLeadership
: resolveLegacyAiStats(candidate, this.nation, this.commandEnv.maxStatLevel ?? LEGACY_DEFAULT_MAX_LEVEL)
.fullLeadership;
const fullLeadership = this.resolveGeneralAiStats(candidate).fullLeadership;
if (fullLeadership >= this.nationPolicy.minNpcWarLeadership) {
npcWarGenerals[candidate.id] = candidate;
} else {
@@ -1055,17 +1088,7 @@ export class GeneralAI {
}
private refreshLegacyFullStats(): void {
const stats = this.commandEnv.generalActionModules
? resolveLegacyAiStatsWithModules(
this.general,
this.nation,
this.commandEnv.maxStatLevel ?? LEGACY_DEFAULT_MAX_LEVEL,
this.commandEnv.generalActionModules,
this.worldRef,
this.world,
this.startYear
)
: resolveLegacyAiStats(this.general, this.nation, this.commandEnv.maxStatLevel ?? LEGACY_DEFAULT_MAX_LEVEL);
const stats = this.resolveGeneralAiStats(this.general);
this.general.meta = {
...this.general.meta,
...stats,
@@ -1,5 +1,4 @@
import type { GeneralAI } from '../../core.js';
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
import { buildAssignmentCandidate, pickFrontCityWeight, pickRandomCityId, resolveCityPopRatio } from '../helpers.js';
export const doNPC후방발령 = (ai: GeneralAI) => {
@@ -13,26 +12,6 @@ export const doNPC후방발령 = (ai: GeneralAI) => {
return null;
}
const actionPipeline = new GeneralActionPipeline(ai.commandEnv.generalActionModules ?? []);
const actionContext = (general: GeneralAI['general']) => ({
general,
nation: ai.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 candidates = Object.values(ai.npcWarGenerals).filter((general) => {
if (general.id === ai.general.id) {
return false;
@@ -50,7 +29,7 @@ export const doNPC후방발령 = (ai: GeneralAI) => {
if (general.crew >= ai.nationPolicy.minWarCrew) {
return false;
}
if (actionPipeline.onCalcDomestic(actionContext(general), '징집인구', 'score', 100) <= 1) {
if (ai.calculateRecruitPopulationScore(general) <= 1) {
return false;
}
return true;
@@ -64,11 +43,7 @@ export const doNPC후방발령 = (ai: GeneralAI) => {
}
const picked = ai.rng.choice(candidates);
const fullLeadership = actionPipeline.onCalcStat(
actionContext(picked),
'leadership',
picked.stats.leadership
) as number;
const fullLeadership = ai.resolveGeneralAiStats(picked).fullLeadership;
const minPop = Math.max(
fullLeadership * 100 + ai.aiConst.minAvailableRecruitPop,
fullLeadership * 100 + ai.nationPolicy.minNpcRecruitCityPopulation
@@ -1,10 +1,13 @@
import type { GeneralAI } from '../../core.js';
import { asRecord, readMetaNumber } from '../../../aiUtils.js';
import {
buildAssignmentCandidate,
isGeneralTurnBefore,
pickFrontCityWeight,
pickRandomCityId,
resolveCityPopRatio,
selectRecruitableCity,
selectSafeRearCity,
selectUserRecruitmentRearCity,
} from '../helpers.js';
export const do부대유저장후방발령 = (ai: GeneralAI) => {
@@ -46,7 +49,13 @@ export const do부대유저장후방발령 = (ai: GeneralAI) => {
if (general.crew >= ai.nationPolicy.minWarCrew) {
return false;
}
const reserved = ai.getReservedTurn(general.id);
if (ai.calculateRecruitPopulationScore(general) <= 1) {
return false;
}
if (!isGeneralTurnBefore(general, troopLeader)) {
return false;
}
const reserved = ai.getFirstReservedGeneralTurn(general.id);
if (reserved.action !== 'che_징병') {
return false;
}
@@ -57,16 +66,18 @@ export const do부대유저장후방발령 = (ai: GeneralAI) => {
return null;
}
const destCityCandidates = selectRecruitableCity(ai, ai.nationPolicy.minNpcRecruitCityPopulation);
const destCityCandidates = selectSafeRearCity(ai);
if (Object.keys(destCityCandidates).length === 0) {
return null;
}
// Ref chooses the user first, then the destination city. Both consume the
// shared nation AI RNG even when a later command constraint rejects it.
const destGeneral = ai.rng.choice(candidates);
const destCityId = Number(ai.rng.choiceUsingWeight(destCityCandidates));
if (!Number.isFinite(destCityId)) {
return null;
}
const destGeneral = ai.rng.choice(candidates);
return buildAssignmentCandidate(ai, destGeneral.id, destCityId, '부대유저장후방발령');
};
@@ -98,6 +109,9 @@ export const do유저장후방발령 = (ai: GeneralAI) => {
if (general.crew >= ai.nationPolicy.minWarCrew) {
return false;
}
if (ai.calculateRecruitPopulationScore(general) <= 1) {
return false;
}
return true;
});
@@ -106,8 +120,8 @@ export const do유저장후방발령 = (ai: GeneralAI) => {
}
const picked = ai.rng.choice(candidates);
const minPop = picked.stats.leadership * 100 + ai.aiConst.minAvailableRecruitPop;
const destCityCandidates = selectRecruitableCity(ai, minPop);
const minPop = ai.resolveGeneralAiStats(picked).fullLeadership * 100 + ai.aiConst.minAvailableRecruitPop;
const destCityCandidates = selectUserRecruitmentRearCity(ai, minPop);
if (Object.keys(destCityCandidates).length === 0) {
return null;
}
@@ -123,16 +137,35 @@ export const do유저장구출발령 = (ai: GeneralAI) => {
if (!ai.nation || !ai.nation.capitalCityId) {
return null;
}
const lostCandidates = Object.values(ai.lostGenerals).filter((general) => general.npcState < 2);
if (lostCandidates.length === 0) {
const useFrontCities = [3, 4].includes(ai.dipState) && Object.keys(ai.frontCities).length > 2;
const candidates = Object.values(ai.lostGenerals).flatMap((general) => {
if (general.npcState >= 2) {
return [];
}
const defenceTrain = readMetaNumber(asRecord(general.meta), 'defence_train', 80);
if (
general.crew >= ai.nationPolicy.minWarCrew &&
general.train >= defenceTrain &&
general.atmos >= defenceTrain
) {
// A battle-ready user may be intentionally holding an isolated city.
return [];
}
const troopLeader = general.troopId ? ai.troopLeaders[general.troopId] : undefined;
if (troopLeader && ai.supplyCities[troopLeader.cityId] && isGeneralTurnBefore(troopLeader, general)) {
// The mounted user can already escape with the leader's earlier turn.
return [];
}
const destCityId = pickRandomCityId(ai, useFrontCities ? ai.frontCities : ai.supplyCities);
return destCityId === null ? [] : [{ general, destCityId }];
});
if (candidates.length === 0) {
return null;
}
const destCityId = pickRandomCityId(ai, ai.frontCities) ?? pickRandomCityId(ai, ai.supplyCities);
if (destCityId === null) {
return null;
}
const destGeneral = ai.rng.choice(lostCandidates);
return buildAssignmentCandidate(ai, destGeneral.id, destCityId, '유저장구출발령');
// Ref consumes one city draw per eligible isolated user before choosing a
// completed (user, city) pair.
const picked = ai.rng.choice(candidates);
return buildAssignmentCandidate(ai, picked.general.id, picked.destCityId, '유저장구출발령');
};
export const do유저장전방발령 = (ai: GeneralAI) => {
@@ -159,6 +192,12 @@ export const do유저장전방발령 = (ai: GeneralAI) => {
if (general.crew < ai.nationPolicy.minWarCrew) {
return false;
}
if (general.troopId) {
return false;
}
if (Math.max(general.train, general.atmos) < ai.nationPolicy.properWarTrainAtmos) {
return false;
}
return true;
});
@@ -166,12 +205,12 @@ export const do유저장전방발령 = (ai: GeneralAI) => {
return null;
}
const cityCandidates = pickFrontCityWeight(ai);
const destCityId = Number(ai.rng.choiceUsingWeight(cityCandidates));
// Ref selects the prepared user before drawing the weighted front city.
const destGeneral = ai.rng.choice(candidates);
const destCityId = Number(ai.rng.choiceUsingWeight(pickFrontCityWeight(ai)));
if (!Number.isFinite(destCityId)) {
return null;
}
const destGeneral = ai.rng.choice(candidates);
return buildAssignmentCandidate(ai, destGeneral.id, destCityId, '유저장전방발령');
};
@@ -37,24 +37,56 @@ export const resolveLastAssignment = (general: GeneralAI['general'], yearMonth:
return last >= yearMonth;
};
export const selectRecruitableCity = (ai: GeneralAI, minPop: number): Record<number, number> => {
export const isGeneralTurnBefore = (lhs: GeneralAI['general'], rhs: GeneralAI['general']): boolean => {
if (lhs.turnTick !== undefined && rhs.turnTick !== undefined) {
return lhs.turnTick < rhs.turnTick;
}
return lhs.turnTime.getTime() < rhs.turnTime.getTime();
};
export const selectSafeRearCity = (ai: GeneralAI): Record<number, number> => {
const candidates: Record<number, number> = {};
for (const city of Object.values(ai.backupCities)) {
if (city.population < minPop) {
const ratio = resolveCityPopRatio(city);
if (ratio < ai.nationPolicy.safeRecruitCityPopulationRatio) {
continue;
}
const ratio = resolveCityPopRatio(city);
candidates[city.id] = ratio;
}
if (Object.keys(candidates).length > 0) {
return candidates;
}
for (const city of Object.values(ai.supplyCities)) {
if (city.population < minPop) {
const ratio = resolveCityPopRatio(city);
if (ratio < ai.nationPolicy.safeRecruitCityPopulationRatio) {
continue;
}
candidates[city.id] = ratio;
}
return candidates;
};
export const selectUserRecruitmentRearCity = (ai: GeneralAI, minPopulation: number): Record<number, number> => {
const candidates: Record<number, number> = {};
for (const city of Object.values(ai.backupCities)) {
if (city.id === ai.city?.id || city.population < minPopulation) {
continue;
}
let ratio = resolveCityPopRatio(city);
if (ratio < ai.nationPolicy.safeRecruitCityPopulationRatio) {
ratio /= 4;
}
candidates[city.id] = ratio;
}
if (Object.keys(candidates).length > 0) {
return candidates;
}
for (const city of Object.values(ai.supplyCities)) {
if (city.id === ai.city?.id || city.population <= minPopulation) {
continue;
}
const ratio = resolveCityPopRatio(city);
candidates[city.id] = ratio;
candidates[city.id] = ratio < ai.nationPolicy.safeRecruitCityPopulationRatio ? ratio / 2 : ratio;
}
return candidates;
};
@@ -1,6 +1,4 @@
import type { GeneralAI } from '../core.js';
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
import { LEGACY_DEFAULT_MAX_LEVEL } from '@sammo-ts/logic/scenario/constants.js';
import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js';
import type { TurnGeneral } from '../../../types.js';
import { asRecord, readMetaNumber, readRequiredMetaNumber } from '../../aiUtils.js';
@@ -47,39 +45,7 @@ const clampLegacy = (value: number, min: number | null, max: number | null): num
};
const getFullLeadership = (ai: GeneralAI, general: TurnGeneral): number => {
const modules = ai.commandEnv.generalActionModules;
if (modules && modules.length > 0) {
const pipeline = new GeneralActionPipeline(modules);
const adjusted = pipeline.onCalcStat(
{
general,
nation: ai.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,
},
},
'leadership',
general.stats.leadership
);
const maxStat = ai.commandEnv.maxStatLevel ?? LEGACY_DEFAULT_MAX_LEVEL;
return Math.trunc(Math.max(0, Math.min(Number(adjusted), maxStat)));
}
const nationLevel = ai.nation?.level ?? 0;
const officerBonus = general.officerLevel === 12 ? nationLevel * 2 : general.officerLevel >= 5 ? nationLevel : 0;
const maxStat = ai.commandEnv.maxStatLevel ?? LEGACY_DEFAULT_MAX_LEVEL;
return Math.max(0, Math.min(general.stats.leadership + officerBonus, maxStat));
return ai.resolveGeneralAiStats(general).fullLeadership;
};
const getCrewGoldCost = (ai: GeneralAI, general: TurnGeneral, baseMultiplier: number, finalMultiplier = 1): number => {