merge: 최신 main을 토너먼트 전투 로그 복원에 통합한다

This commit is contained in:
2026-08-22 04:52:24 +00:00
6 changed files with 380 additions and 112 deletions
+56 -25
View File
@@ -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_포상'] });