merge: 최신 main을 토너먼트 전투 로그 복원에 통합한다
This commit is contained in:
@@ -673,10 +673,61 @@ export class GeneralAI {
|
||||
this.promotionNationMeta = nextMeta;
|
||||
}
|
||||
|
||||
getReservedTurn(generalId: number): ReservedTurnEntry {
|
||||
getFirstReservedGeneralTurn(generalId: number): ReservedTurnEntry {
|
||||
return this.reservedTurnProvider.getGeneralTurn(generalId, 0);
|
||||
}
|
||||
|
||||
hasFirstReservedGeneralRecruitmentTurn(generalId: number): boolean {
|
||||
const action = this.getFirstReservedGeneralTurn(generalId).action;
|
||||
// Ref checks `instanceof che_징병`; che_모병 inherits che_징병 there.
|
||||
// Core persists the concrete command key, so preserve that subtype
|
||||
// relationship explicitly at the serialized reserved-turn boundary.
|
||||
return action === 'che_징병' || action === 'che_모병';
|
||||
}
|
||||
|
||||
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 +901,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 +927,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 +1096,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,8 +49,13 @@ export const do부대유저장후방발령 = (ai: GeneralAI) => {
|
||||
if (general.crew >= ai.nationPolicy.minWarCrew) {
|
||||
return false;
|
||||
}
|
||||
const reserved = ai.getReservedTurn(general.id);
|
||||
if (reserved.action !== 'che_징병') {
|
||||
if (ai.calculateRecruitPopulationScore(general) <= 1) {
|
||||
return false;
|
||||
}
|
||||
if (!isGeneralTurnBefore(general, troopLeader)) {
|
||||
return false;
|
||||
}
|
||||
if (!ai.hasFirstReservedGeneralRecruitmentTurn(general.id)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -57,16 +65,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 +108,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 +119,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 +136,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 +191,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 +204,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 => {
|
||||
|
||||
@@ -25,6 +25,12 @@ import {
|
||||
doNPC후방발령,
|
||||
} from '../src/turn/ai/generalAi/nation/assignments/npcAssignments.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 = {
|
||||
action: string;
|
||||
@@ -203,6 +209,7 @@ const makeAi = (
|
||||
generals?: General[];
|
||||
disabledPolicyActions?: string[];
|
||||
generalActionModules?: NonNullable<GeneralAI['commandEnv']['generalActionModules']>;
|
||||
reservedTurns?: Record<number, { action: string; args?: Record<string, unknown> }>;
|
||||
} = {}
|
||||
): GeneralAI => {
|
||||
const general = {
|
||||
@@ -223,7 +230,7 @@ const makeAi = (
|
||||
const generals = overrides.generals ?? [general];
|
||||
const candidates: Candidate[] = [];
|
||||
|
||||
return {
|
||||
return Object.assign(Object.create(GeneralAI.prototype), {
|
||||
general,
|
||||
city,
|
||||
nation,
|
||||
@@ -346,6 +353,12 @@ const makeAi = (
|
||||
genType: overrides.genType ?? 7,
|
||||
rng,
|
||||
maxResourceActionAmount: 10_000,
|
||||
reservedTurnProvider: {
|
||||
getGeneralTurn: (generalId: number) => {
|
||||
const reserved = overrides.reservedTurns?.[generalId];
|
||||
return reserved ? { action: reserved.action, args: reserved.args ?? {} } : { action: '휴식', args: {} };
|
||||
},
|
||||
},
|
||||
generalPolicy: {
|
||||
can: (action: string) =>
|
||||
!disabledPolicyActions.has(action) && !['모병', '고급병종', '한계징병'].includes(action),
|
||||
@@ -392,7 +405,7 @@ const makeAi = (
|
||||
candidates.push(candidate);
|
||||
return candidate;
|
||||
},
|
||||
} as unknown as GeneralAI;
|
||||
}) as GeneralAI;
|
||||
};
|
||||
|
||||
const makePromotionGeneral = (overrides: Partial<TurnGeneral>): TurnGeneral => ({
|
||||
@@ -1363,6 +1376,202 @@ describe('legacy NPC AI final-decision parity', () => {
|
||||
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 };
|
||||
};
|
||||
|
||||
for (const reservedAction of ['che_징병', 'che_모병']) {
|
||||
expect(run({ reservedAction, 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', () => {
|
||||
const ai = makeAi();
|
||||
const civilGeneral = {
|
||||
@@ -1379,6 +1588,23 @@ describe('legacy NPC AI final-decision parity', () => {
|
||||
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', () => {
|
||||
const rng = makeRng();
|
||||
const ai = makeAi({ rng, blockedActions: ['che_포상'] });
|
||||
|
||||
Reference in New Issue
Block a user