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

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

징집 후 준비도를 갖춘 뒤 전방 발령하고 구출·포상·몰수 후보와 RNG 순서를 회귀 테스트로 고정한다.
This commit is contained in:
2026-08-22 04:42:08 +00:00
parent a41eb16bb9
commit 8b5fe4e72c
6 changed files with 370 additions and 111 deletions
@@ -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,200 @@ 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 };
};
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', () => {
const ai = makeAi();
const civilGeneral = {
@@ -1379,6 +1586,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_포상'] });