diff --git a/app/game-engine/src/turn/ai/generalAi/core.ts b/app/game-engine/src/turn/ai/generalAi/core.ts index a1ffd79f..9c30d58c 100644 --- a/app/game-engine/src/turn/ai/generalAi/core.ts +++ b/app/game-engine/src/turn/ai/generalAi/core.ts @@ -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 { + 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 { 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, diff --git a/app/game-engine/src/turn/ai/generalAi/nation/assignments/npcAssignments.ts b/app/game-engine/src/turn/ai/generalAi/nation/assignments/npcAssignments.ts index 8478d9b1..12757e60 100644 --- a/app/game-engine/src/turn/ai/generalAi/nation/assignments/npcAssignments.ts +++ b/app/game-engine/src/turn/ai/generalAi/nation/assignments/npcAssignments.ts @@ -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 diff --git a/app/game-engine/src/turn/ai/generalAi/nation/assignments/userAssignments.ts b/app/game-engine/src/turn/ai/generalAi/nation/assignments/userAssignments.ts index 9cf2c783..dd2827ee 100644 --- a/app/game-engine/src/turn/ai/generalAi/nation/assignments/userAssignments.ts +++ b/app/game-engine/src/turn/ai/generalAi/nation/assignments/userAssignments.ts @@ -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, '유저장전방발령'); }; diff --git a/app/game-engine/src/turn/ai/generalAi/nation/helpers.ts b/app/game-engine/src/turn/ai/generalAi/nation/helpers.ts index cfbbef0b..c8f710d9 100644 --- a/app/game-engine/src/turn/ai/generalAi/nation/helpers.ts +++ b/app/game-engine/src/turn/ai/generalAi/nation/helpers.ts @@ -37,24 +37,56 @@ export const resolveLastAssignment = (general: GeneralAI['general'], yearMonth: return last >= yearMonth; }; -export const selectRecruitableCity = (ai: GeneralAI, minPop: number): Record => { +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 => { const candidates: Record = {}; 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 => { + const candidates: Record = {}; + 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; }; diff --git a/app/game-engine/src/turn/ai/generalAi/nation/rewards.ts b/app/game-engine/src/turn/ai/generalAi/nation/rewards.ts index 877308fe..908f12a8 100644 --- a/app/game-engine/src/turn/ai/generalAi/nation/rewards.ts +++ b/app/game-engine/src/turn/ai/generalAi/nation/rewards.ts @@ -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 => { diff --git a/app/game-engine/test/generalAiLegacyDecisionParity.test.ts b/app/game-engine/test/generalAiLegacyDecisionParity.test.ts index 89c00d34..0a017430 100644 --- a/app/game-engine/test/generalAiLegacyDecisionParity.test.ts +++ b/app/game-engine/test/generalAiLegacyDecisionParity.test.ts @@ -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; + reservedTurns?: Record }>; } = {} ): 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 => ({ @@ -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_포상'] });