fix: NPC 사용자 장수 인사 조건을 Ref와 맞춘다
첫 예약턴과 부대장 턴 순서, 징집 가능 점수, 후방 도시 조건을 반영한다. 징집 후 준비도를 갖춘 뒤 전방 발령하고 구출·포상·몰수 후보와 RNG 순서를 회귀 테스트로 고정한다.
This commit is contained in:
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user