fix: NPC 사용자 장수 인사 조건을 Ref와 맞춘다
첫 예약턴과 부대장 턴 순서, 징집 가능 점수, 후방 도시 조건을 반영한다. 징집 후 준비도를 갖춘 뒤 전방 발령하고 구출·포상·몰수 후보와 RNG 순서를 회귀 테스트로 고정한다.
This commit is contained in:
@@ -673,10 +673,53 @@ export class GeneralAI {
|
|||||||
this.promotionNationMeta = nextMeta;
|
this.promotionNationMeta = nextMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
getReservedTurn(generalId: number): ReservedTurnEntry {
|
getFirstReservedGeneralTurn(generalId: number): ReservedTurnEntry {
|
||||||
return this.reservedTurnProvider.getGeneralTurn(generalId, 0);
|
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> {
|
calcNationDevelopedRate(): Record<string, number> {
|
||||||
if (this.devRate) {
|
if (this.devRate) {
|
||||||
return this.devRate;
|
return this.devRate;
|
||||||
@@ -850,7 +893,8 @@ export class GeneralAI {
|
|||||||
|
|
||||||
const isTroopLeader =
|
const isTroopLeader =
|
||||||
npcType === 5 ||
|
npcType === 5 ||
|
||||||
(candidate.troopId === candidate.id && this.getReservedTurn(candidate.id).action === 'che_집합');
|
(candidate.troopId === candidate.id &&
|
||||||
|
this.getFirstReservedGeneralTurn(candidate.id).action === 'che_집합');
|
||||||
if (isTroopLeader) {
|
if (isTroopLeader) {
|
||||||
troopLeaders[candidate.id] = candidate;
|
troopLeaders[candidate.id] = candidate;
|
||||||
continue;
|
continue;
|
||||||
@@ -875,18 +919,7 @@ export class GeneralAI {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullLeadership = this.commandEnv.generalActionModules
|
const fullLeadership = this.resolveGeneralAiStats(candidate).fullLeadership;
|
||||||
? 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;
|
|
||||||
if (fullLeadership >= this.nationPolicy.minNpcWarLeadership) {
|
if (fullLeadership >= this.nationPolicy.minNpcWarLeadership) {
|
||||||
npcWarGenerals[candidate.id] = candidate;
|
npcWarGenerals[candidate.id] = candidate;
|
||||||
} else {
|
} else {
|
||||||
@@ -1055,17 +1088,7 @@ export class GeneralAI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private refreshLegacyFullStats(): void {
|
private refreshLegacyFullStats(): void {
|
||||||
const stats = this.commandEnv.generalActionModules
|
const stats = this.resolveGeneralAiStats(this.general);
|
||||||
? 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);
|
|
||||||
this.general.meta = {
|
this.general.meta = {
|
||||||
...this.general.meta,
|
...this.general.meta,
|
||||||
...stats,
|
...stats,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { GeneralAI } from '../../core.js';
|
import type { GeneralAI } from '../../core.js';
|
||||||
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
|
|
||||||
import { buildAssignmentCandidate, pickFrontCityWeight, pickRandomCityId, resolveCityPopRatio } from '../helpers.js';
|
import { buildAssignmentCandidate, pickFrontCityWeight, pickRandomCityId, resolveCityPopRatio } from '../helpers.js';
|
||||||
|
|
||||||
export const doNPC후방발령 = (ai: GeneralAI) => {
|
export const doNPC후방발령 = (ai: GeneralAI) => {
|
||||||
@@ -13,26 +12,6 @@ export const doNPC후방발령 = (ai: GeneralAI) => {
|
|||||||
return null;
|
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) => {
|
const candidates = Object.values(ai.npcWarGenerals).filter((general) => {
|
||||||
if (general.id === ai.general.id) {
|
if (general.id === ai.general.id) {
|
||||||
return false;
|
return false;
|
||||||
@@ -50,7 +29,7 @@ export const doNPC후방발령 = (ai: GeneralAI) => {
|
|||||||
if (general.crew >= ai.nationPolicy.minWarCrew) {
|
if (general.crew >= ai.nationPolicy.minWarCrew) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (actionPipeline.onCalcDomestic(actionContext(general), '징집인구', 'score', 100) <= 1) {
|
if (ai.calculateRecruitPopulationScore(general) <= 1) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -64,11 +43,7 @@ export const doNPC후방발령 = (ai: GeneralAI) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const picked = ai.rng.choice(candidates);
|
const picked = ai.rng.choice(candidates);
|
||||||
const fullLeadership = actionPipeline.onCalcStat(
|
const fullLeadership = ai.resolveGeneralAiStats(picked).fullLeadership;
|
||||||
actionContext(picked),
|
|
||||||
'leadership',
|
|
||||||
picked.stats.leadership
|
|
||||||
) as number;
|
|
||||||
const minPop = Math.max(
|
const minPop = Math.max(
|
||||||
fullLeadership * 100 + ai.aiConst.minAvailableRecruitPop,
|
fullLeadership * 100 + ai.aiConst.minAvailableRecruitPop,
|
||||||
fullLeadership * 100 + ai.nationPolicy.minNpcRecruitCityPopulation
|
fullLeadership * 100 + ai.nationPolicy.minNpcRecruitCityPopulation
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import type { GeneralAI } from '../../core.js';
|
import type { GeneralAI } from '../../core.js';
|
||||||
|
import { asRecord, readMetaNumber } from '../../../aiUtils.js';
|
||||||
import {
|
import {
|
||||||
buildAssignmentCandidate,
|
buildAssignmentCandidate,
|
||||||
|
isGeneralTurnBefore,
|
||||||
pickFrontCityWeight,
|
pickFrontCityWeight,
|
||||||
pickRandomCityId,
|
pickRandomCityId,
|
||||||
resolveCityPopRatio,
|
resolveCityPopRatio,
|
||||||
selectRecruitableCity,
|
selectSafeRearCity,
|
||||||
|
selectUserRecruitmentRearCity,
|
||||||
} from '../helpers.js';
|
} from '../helpers.js';
|
||||||
|
|
||||||
export const do부대유저장후방발령 = (ai: GeneralAI) => {
|
export const do부대유저장후방발령 = (ai: GeneralAI) => {
|
||||||
@@ -46,7 +49,13 @@ export const do부대유저장후방발령 = (ai: GeneralAI) => {
|
|||||||
if (general.crew >= ai.nationPolicy.minWarCrew) {
|
if (general.crew >= ai.nationPolicy.minWarCrew) {
|
||||||
return false;
|
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_징병') {
|
if (reserved.action !== 'che_징병') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -57,16 +66,18 @@ export const do부대유저장후방발령 = (ai: GeneralAI) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const destCityCandidates = selectRecruitableCity(ai, ai.nationPolicy.minNpcRecruitCityPopulation);
|
const destCityCandidates = selectSafeRearCity(ai);
|
||||||
if (Object.keys(destCityCandidates).length === 0) {
|
if (Object.keys(destCityCandidates).length === 0) {
|
||||||
return null;
|
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));
|
const destCityId = Number(ai.rng.choiceUsingWeight(destCityCandidates));
|
||||||
if (!Number.isFinite(destCityId)) {
|
if (!Number.isFinite(destCityId)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const destGeneral = ai.rng.choice(candidates);
|
|
||||||
return buildAssignmentCandidate(ai, destGeneral.id, destCityId, '부대유저장후방발령');
|
return buildAssignmentCandidate(ai, destGeneral.id, destCityId, '부대유저장후방발령');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -98,6 +109,9 @@ export const do유저장후방발령 = (ai: GeneralAI) => {
|
|||||||
if (general.crew >= ai.nationPolicy.minWarCrew) {
|
if (general.crew >= ai.nationPolicy.minWarCrew) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (ai.calculateRecruitPopulationScore(general) <= 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -106,8 +120,8 @@ export const do유저장후방발령 = (ai: GeneralAI) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const picked = ai.rng.choice(candidates);
|
const picked = ai.rng.choice(candidates);
|
||||||
const minPop = picked.stats.leadership * 100 + ai.aiConst.minAvailableRecruitPop;
|
const minPop = ai.resolveGeneralAiStats(picked).fullLeadership * 100 + ai.aiConst.minAvailableRecruitPop;
|
||||||
const destCityCandidates = selectRecruitableCity(ai, minPop);
|
const destCityCandidates = selectUserRecruitmentRearCity(ai, minPop);
|
||||||
if (Object.keys(destCityCandidates).length === 0) {
|
if (Object.keys(destCityCandidates).length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -123,16 +137,35 @@ export const do유저장구출발령 = (ai: GeneralAI) => {
|
|||||||
if (!ai.nation || !ai.nation.capitalCityId) {
|
if (!ai.nation || !ai.nation.capitalCityId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const lostCandidates = Object.values(ai.lostGenerals).filter((general) => general.npcState < 2);
|
const useFrontCities = [3, 4].includes(ai.dipState) && Object.keys(ai.frontCities).length > 2;
|
||||||
if (lostCandidates.length === 0) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
const destCityId = pickRandomCityId(ai, ai.frontCities) ?? pickRandomCityId(ai, ai.supplyCities);
|
// Ref consumes one city draw per eligible isolated user before choosing a
|
||||||
if (destCityId === null) {
|
// completed (user, city) pair.
|
||||||
return null;
|
const picked = ai.rng.choice(candidates);
|
||||||
}
|
return buildAssignmentCandidate(ai, picked.general.id, picked.destCityId, '유저장구출발령');
|
||||||
const destGeneral = ai.rng.choice(lostCandidates);
|
|
||||||
return buildAssignmentCandidate(ai, destGeneral.id, destCityId, '유저장구출발령');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const do유저장전방발령 = (ai: GeneralAI) => {
|
export const do유저장전방발령 = (ai: GeneralAI) => {
|
||||||
@@ -159,6 +192,12 @@ export const do유저장전방발령 = (ai: GeneralAI) => {
|
|||||||
if (general.crew < ai.nationPolicy.minWarCrew) {
|
if (general.crew < ai.nationPolicy.minWarCrew) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (general.troopId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (Math.max(general.train, general.atmos) < ai.nationPolicy.properWarTrainAtmos) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -166,12 +205,12 @@ export const do유저장전방발령 = (ai: GeneralAI) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cityCandidates = pickFrontCityWeight(ai);
|
// Ref selects the prepared user before drawing the weighted front city.
|
||||||
const destCityId = Number(ai.rng.choiceUsingWeight(cityCandidates));
|
const destGeneral = ai.rng.choice(candidates);
|
||||||
|
const destCityId = Number(ai.rng.choiceUsingWeight(pickFrontCityWeight(ai)));
|
||||||
if (!Number.isFinite(destCityId)) {
|
if (!Number.isFinite(destCityId)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const destGeneral = ai.rng.choice(candidates);
|
|
||||||
return buildAssignmentCandidate(ai, destGeneral.id, destCityId, '유저장전방발령');
|
return buildAssignmentCandidate(ai, destGeneral.id, destCityId, '유저장전방발령');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -37,24 +37,56 @@ export const resolveLastAssignment = (general: GeneralAI['general'], yearMonth:
|
|||||||
return last >= 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> = {};
|
const candidates: Record<number, number> = {};
|
||||||
for (const city of Object.values(ai.backupCities)) {
|
for (const city of Object.values(ai.backupCities)) {
|
||||||
if (city.population < minPop) {
|
const ratio = resolveCityPopRatio(city);
|
||||||
|
if (ratio < ai.nationPolicy.safeRecruitCityPopulationRatio) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const ratio = resolveCityPopRatio(city);
|
|
||||||
candidates[city.id] = ratio;
|
candidates[city.id] = ratio;
|
||||||
}
|
}
|
||||||
if (Object.keys(candidates).length > 0) {
|
if (Object.keys(candidates).length > 0) {
|
||||||
return candidates;
|
return candidates;
|
||||||
}
|
}
|
||||||
for (const city of Object.values(ai.supplyCities)) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
const ratio = resolveCityPopRatio(city);
|
const ratio = resolveCityPopRatio(city);
|
||||||
candidates[city.id] = ratio;
|
candidates[city.id] = ratio < ai.nationPolicy.safeRecruitCityPopulationRatio ? ratio / 2 : ratio;
|
||||||
}
|
}
|
||||||
return candidates;
|
return candidates;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import type { GeneralAI } from '../core.js';
|
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 { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js';
|
||||||
import type { TurnGeneral } from '../../../types.js';
|
import type { TurnGeneral } from '../../../types.js';
|
||||||
import { asRecord, readMetaNumber, readRequiredMetaNumber } from '../../aiUtils.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 getFullLeadership = (ai: GeneralAI, general: TurnGeneral): number => {
|
||||||
const modules = ai.commandEnv.generalActionModules;
|
return ai.resolveGeneralAiStats(general).fullLeadership;
|
||||||
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));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCrewGoldCost = (ai: GeneralAI, general: TurnGeneral, baseMultiplier: number, finalMultiplier = 1): number => {
|
const getCrewGoldCost = (ai: GeneralAI, general: TurnGeneral, baseMultiplier: number, finalMultiplier = 1): number => {
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ import {
|
|||||||
doNPC후방발령,
|
doNPC후방발령,
|
||||||
} from '../src/turn/ai/generalAi/nation/assignments/npcAssignments.js';
|
} from '../src/turn/ai/generalAi/nation/assignments/npcAssignments.js';
|
||||||
import { do부대구출발령, do부대후방발령 } from '../src/turn/ai/generalAi/nation/assignments/troopAssignments.js';
|
import { do부대구출발령, do부대후방발령 } from '../src/turn/ai/generalAi/nation/assignments/troopAssignments.js';
|
||||||
|
import {
|
||||||
|
do부대유저장후방발령,
|
||||||
|
do유저장구출발령,
|
||||||
|
do유저장전방발령,
|
||||||
|
do유저장후방발령,
|
||||||
|
} from '../src/turn/ai/generalAi/nation/assignments/userAssignments.js';
|
||||||
|
|
||||||
type Candidate = {
|
type Candidate = {
|
||||||
action: string;
|
action: string;
|
||||||
@@ -203,6 +209,7 @@ const makeAi = (
|
|||||||
generals?: General[];
|
generals?: General[];
|
||||||
disabledPolicyActions?: string[];
|
disabledPolicyActions?: string[];
|
||||||
generalActionModules?: NonNullable<GeneralAI['commandEnv']['generalActionModules']>;
|
generalActionModules?: NonNullable<GeneralAI['commandEnv']['generalActionModules']>;
|
||||||
|
reservedTurns?: Record<number, { action: string; args?: Record<string, unknown> }>;
|
||||||
} = {}
|
} = {}
|
||||||
): GeneralAI => {
|
): GeneralAI => {
|
||||||
const general = {
|
const general = {
|
||||||
@@ -223,7 +230,7 @@ const makeAi = (
|
|||||||
const generals = overrides.generals ?? [general];
|
const generals = overrides.generals ?? [general];
|
||||||
const candidates: Candidate[] = [];
|
const candidates: Candidate[] = [];
|
||||||
|
|
||||||
return {
|
return Object.assign(Object.create(GeneralAI.prototype), {
|
||||||
general,
|
general,
|
||||||
city,
|
city,
|
||||||
nation,
|
nation,
|
||||||
@@ -346,6 +353,12 @@ const makeAi = (
|
|||||||
genType: overrides.genType ?? 7,
|
genType: overrides.genType ?? 7,
|
||||||
rng,
|
rng,
|
||||||
maxResourceActionAmount: 10_000,
|
maxResourceActionAmount: 10_000,
|
||||||
|
reservedTurnProvider: {
|
||||||
|
getGeneralTurn: (generalId: number) => {
|
||||||
|
const reserved = overrides.reservedTurns?.[generalId];
|
||||||
|
return reserved ? { action: reserved.action, args: reserved.args ?? {} } : { action: '휴식', args: {} };
|
||||||
|
},
|
||||||
|
},
|
||||||
generalPolicy: {
|
generalPolicy: {
|
||||||
can: (action: string) =>
|
can: (action: string) =>
|
||||||
!disabledPolicyActions.has(action) && !['모병', '고급병종', '한계징병'].includes(action),
|
!disabledPolicyActions.has(action) && !['모병', '고급병종', '한계징병'].includes(action),
|
||||||
@@ -392,7 +405,7 @@ const makeAi = (
|
|||||||
candidates.push(candidate);
|
candidates.push(candidate);
|
||||||
return candidate;
|
return candidate;
|
||||||
},
|
},
|
||||||
} as unknown as GeneralAI;
|
}) as GeneralAI;
|
||||||
};
|
};
|
||||||
|
|
||||||
const makePromotionGeneral = (overrides: Partial<TurnGeneral>): TurnGeneral => ({
|
const makePromotionGeneral = (overrides: Partial<TurnGeneral>): TurnGeneral => ({
|
||||||
@@ -1363,6 +1376,200 @@ describe('legacy NPC AI final-decision parity', () => {
|
|||||||
expect(do내정워프(ai)?.action).toBe('che_NPC능동');
|
expect(do내정워프(ai)?.action).toBe('che_NPC능동');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('moves a mounted user rear only for an earlier first recruitment turn', () => {
|
||||||
|
const run = (options: {
|
||||||
|
reservedAction: string;
|
||||||
|
userTurnTick: number;
|
||||||
|
leaderTurnTick: number;
|
||||||
|
recruitmentScore?: number;
|
||||||
|
}) => {
|
||||||
|
const rng = makeRng([], [0, 0]);
|
||||||
|
const user = {
|
||||||
|
...baseGeneral(),
|
||||||
|
id: 2,
|
||||||
|
cityId: 2,
|
||||||
|
troopId: 10,
|
||||||
|
npcState: 0,
|
||||||
|
turnTick: options.userTurnTick,
|
||||||
|
};
|
||||||
|
const leader = {
|
||||||
|
...baseGeneral(),
|
||||||
|
id: 10,
|
||||||
|
cityId: 2,
|
||||||
|
troopId: 10,
|
||||||
|
npcState: 5,
|
||||||
|
turnTick: options.leaderTurnTick,
|
||||||
|
};
|
||||||
|
const ai = makeAi({
|
||||||
|
dipState: 4,
|
||||||
|
rng,
|
||||||
|
generals: [baseGeneral(), user, leader],
|
||||||
|
reservedTurns: { 2: { action: options.reservedAction } },
|
||||||
|
generalActionModules:
|
||||||
|
options.recruitmentScore === undefined
|
||||||
|
? undefined
|
||||||
|
: singleActionModuleStack({
|
||||||
|
eventHandlers: {},
|
||||||
|
onCalcDomestic: (_context, turnType, varType, value) =>
|
||||||
|
turnType === '징집인구' && varType === 'score' ? options.recruitmentScore! : value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
ai.userWarGenerals = { 2: user };
|
||||||
|
ai.troopLeaders = { 10: leader };
|
||||||
|
ai.nationCities = {
|
||||||
|
2: {
|
||||||
|
...baseCity(),
|
||||||
|
id: 2,
|
||||||
|
population: 10_000,
|
||||||
|
frontState: 3,
|
||||||
|
dev: 1,
|
||||||
|
important: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ai.frontCities = { 2: ai.nationCities[2]! };
|
||||||
|
ai.supplyCities = {
|
||||||
|
2: ai.nationCities[2]!,
|
||||||
|
3: { ...baseCity(), id: 3, dev: 1, important: 1 },
|
||||||
|
};
|
||||||
|
ai.backupCities = { 3: ai.supplyCities[3]! };
|
||||||
|
return { result: do부대유저장후방발령(ai), rng };
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(run({ reservedAction: 'che_징병', userTurnTick: 100, leaderTurnTick: 200 }).result).toMatchObject({
|
||||||
|
action: 'che_발령',
|
||||||
|
args: { destGeneralId: 2, destCityId: 3 },
|
||||||
|
});
|
||||||
|
expect(run({ reservedAction: 'che_모병', userTurnTick: 100, leaderTurnTick: 200 }).result).toBeNull();
|
||||||
|
expect(run({ reservedAction: 'che_징병', userTurnTick: 200, leaderTurnTick: 100 }).result).toBeNull();
|
||||||
|
expect(
|
||||||
|
run({ reservedAction: 'che_징병', userTurnTick: 100, leaderTurnTick: 200, recruitmentScore: 0 }).result
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses full leadership and the chief city exclusion for a user rear assignment', () => {
|
||||||
|
const user = { ...baseGeneral(), id: 2, cityId: 2, npcState: 0 };
|
||||||
|
const build = (withLeadershipBonus: boolean) => {
|
||||||
|
const ai = makeAi({
|
||||||
|
dipState: 4,
|
||||||
|
generals: [baseGeneral(), user],
|
||||||
|
generalActionModules: withLeadershipBonus
|
||||||
|
? singleActionModuleStack({
|
||||||
|
eventHandlers: {},
|
||||||
|
onCalcStat: (_context, statName, value) =>
|
||||||
|
statName === 'leadership' ? Number(value) + 30 : value,
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
ai.userWarGenerals = { 2: user };
|
||||||
|
ai.supplyCities = {
|
||||||
|
1: { ...baseCity(), id: 1, dev: 1, important: 1 },
|
||||||
|
2: { ...baseCity(), id: 2, population: 10_000, dev: 1, important: 1 },
|
||||||
|
3: { ...baseCity(), id: 3, population: 37_000, dev: 1, important: 1 },
|
||||||
|
};
|
||||||
|
ai.backupCities = {
|
||||||
|
1: ai.supplyCities[1]!,
|
||||||
|
3: ai.supplyCities[3]!,
|
||||||
|
};
|
||||||
|
return do유저장후방발령(ai);
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(build(false)).toMatchObject({
|
||||||
|
action: 'che_발령',
|
||||||
|
args: { destGeneralId: 2, destCityId: 3 },
|
||||||
|
});
|
||||||
|
expect(build(true)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits through recruitment and preparation before sending a user to the front', () => {
|
||||||
|
const run = (crew: number, train: number, atmos: number) => {
|
||||||
|
const user = { ...baseGeneral(), id: 2, cityId: 2, npcState: 0, crew, train, atmos };
|
||||||
|
const ai = makeAi({ dipState: 4, generals: [baseGeneral(), user] });
|
||||||
|
ai.userWarGenerals = { 2: user };
|
||||||
|
ai.nationCities = {
|
||||||
|
2: { ...baseCity(), id: 2, population: 10_000, dev: 1, important: 1 },
|
||||||
|
};
|
||||||
|
ai.supplyCities = {
|
||||||
|
2: ai.nationCities[2]!,
|
||||||
|
3: { ...baseCity(), id: 3, dev: 1, important: 1 },
|
||||||
|
};
|
||||||
|
ai.backupCities = { 3: ai.supplyCities[3]! };
|
||||||
|
ai.frontCities = {
|
||||||
|
20: { ...baseCity(), id: 20, frontState: 3, dev: 1, important: 1 },
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
rear: do유저장후방발령(ai),
|
||||||
|
front: do유저장전방발령(ai),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(run(0, 0, 0).rear).toMatchObject({ args: { destGeneralId: 2, destCityId: 3 } });
|
||||||
|
expect(run(1_500, 0, 0)).toEqual({ rear: null, front: null });
|
||||||
|
expect(run(1_500, 90, 0).front).toMatchObject({
|
||||||
|
action: 'che_발령',
|
||||||
|
args: { destGeneralId: 2, destCityId: 20 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps mounted users out of front assignment and draws the prepared user first', () => {
|
||||||
|
const mounted = {
|
||||||
|
...baseGeneral(),
|
||||||
|
id: 2,
|
||||||
|
cityId: 2,
|
||||||
|
npcState: 0,
|
||||||
|
troopId: 10,
|
||||||
|
crew: 2_000,
|
||||||
|
train: 100,
|
||||||
|
atmos: 100,
|
||||||
|
};
|
||||||
|
const first = { ...mounted, troopId: 0 };
|
||||||
|
const second = { ...mounted, id: 3, troopId: 0 };
|
||||||
|
const rng = makeRng([], [1, 20]);
|
||||||
|
const ai = makeAi({ dipState: 4, rng, generals: [baseGeneral(), first, second] });
|
||||||
|
ai.userWarGenerals = { 2: first, 3: second };
|
||||||
|
ai.nationCities = { 2: { ...baseCity(), id: 2, dev: 1, important: 1 } };
|
||||||
|
ai.frontCities = { 20: { ...baseCity(), id: 20, frontState: 3, dev: 1, important: 1 } };
|
||||||
|
|
||||||
|
expect(do유저장전방발령(ai)).toMatchObject({
|
||||||
|
action: 'che_발령',
|
||||||
|
args: { destGeneralId: 3, destCityId: 20 },
|
||||||
|
});
|
||||||
|
|
||||||
|
ai.userWarGenerals = { 2: mounted };
|
||||||
|
expect(do유저장전방발령(ai)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves intentional defenders and earlier troop escapes during user rescue assignment', () => {
|
||||||
|
const ready = {
|
||||||
|
...baseGeneral(),
|
||||||
|
id: 2,
|
||||||
|
npcState: 0,
|
||||||
|
crew: 2_000,
|
||||||
|
train: 80,
|
||||||
|
atmos: 80,
|
||||||
|
meta: { ...baseGeneral().meta, defence_train: 80 },
|
||||||
|
};
|
||||||
|
const rider = { ...baseGeneral(), id: 3, npcState: 0, troopId: 10, turnTick: 200 };
|
||||||
|
const leader = { ...baseGeneral(), id: 10, npcState: 5, cityId: 5, troopId: 10, turnTick: 100 };
|
||||||
|
const first = { ...baseGeneral(), id: 4, npcState: 0 };
|
||||||
|
const second = { ...baseGeneral(), id: 5, npcState: 0 };
|
||||||
|
const rng = makeRng([], [1, 2, 1]);
|
||||||
|
const ai = makeAi({ dipState: 4, rng, generals: [baseGeneral(), ready, rider, leader, first, second] });
|
||||||
|
ai.lostGenerals = { 2: ready, 3: rider, 4: first, 5: second };
|
||||||
|
ai.troopLeaders = { 10: leader };
|
||||||
|
ai.supplyCities = { 5: { ...baseCity(), id: 5, dev: 1, important: 1 } };
|
||||||
|
ai.frontCities = {
|
||||||
|
20: { ...baseCity(), id: 20, frontState: 3, dev: 1, important: 1 },
|
||||||
|
21: { ...baseCity(), id: 21, frontState: 3, dev: 1, important: 1 },
|
||||||
|
22: { ...baseCity(), id: 22, frontState: 3, dev: 1, important: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(do유저장구출발령(ai)).toMatchObject({
|
||||||
|
action: 'che_발령',
|
||||||
|
args: { destGeneralId: 5, destCityId: 22 },
|
||||||
|
});
|
||||||
|
expect(rng.choices).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('awards a resource-poor civil user general like the legacy nation AI', () => {
|
it('awards a resource-poor civil user general like the legacy nation AI', () => {
|
||||||
const ai = makeAi();
|
const ai = makeAi();
|
||||||
const civilGeneral = {
|
const civilGeneral = {
|
||||||
@@ -1379,6 +1586,23 @@ describe('legacy NPC AI final-decision parity', () => {
|
|||||||
expect(do유저장포상(ai)?.action).toBe('che_포상');
|
expect(do유저장포상(ai)?.action).toBe('che_포상');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('never includes user generals in the legacy NPC seizure pool', () => {
|
||||||
|
const ai = makeAi({ nation: { gold: 1_000, rice: 1_000 } });
|
||||||
|
const richUser = {
|
||||||
|
...baseGeneral(),
|
||||||
|
id: 2,
|
||||||
|
npcState: 0,
|
||||||
|
gold: 100_000,
|
||||||
|
rice: 100_000,
|
||||||
|
};
|
||||||
|
ai.userGenerals = { 2: richUser };
|
||||||
|
ai.userWarGenerals = { 2: richUser };
|
||||||
|
ai.npcWarGenerals = {};
|
||||||
|
ai.npcCivilGenerals = {};
|
||||||
|
|
||||||
|
expect(doNPC몰수(ai)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('consumes the legacy reward draw before a selected command fails constraints', () => {
|
it('consumes the legacy reward draw before a selected command fails constraints', () => {
|
||||||
const rng = makeRng();
|
const rng = makeRng();
|
||||||
const ai = makeAi({ rng, blockedActions: ['che_포상'] });
|
const ai = makeAi({ rng, blockedActions: ['che_포상'] });
|
||||||
|
|||||||
Reference in New Issue
Block a user