diff --git a/app/game-engine/src/turn/ai/generalAi/core.ts b/app/game-engine/src/turn/ai/generalAi/core.ts index c04c949..b0d0d2b 100644 --- a/app/game-engine/src/turn/ai/generalAi/core.ts +++ b/app/game-engine/src/turn/ai/generalAi/core.ts @@ -28,6 +28,7 @@ import { WorldStateView } from './worldStateView.js'; import type { GeneralAIOptions, GeneralAiDebugState } from './types.js'; const ACTION_REST = '휴식'; +const lastAttackableByNation = new Map(); const t무장 = 1; const t지장 = 2; @@ -122,7 +123,9 @@ export class GeneralAI { constructor(options: GeneralAIOptions) { this.general = options.general; this.city = options.city; - this.nation = options.nation ?? null; + this.nation = + options.nation ?? + (options.general.nationId > 0 ? options.worldRef?.getNationById(options.general.nationId) ?? null : null); this.world = options.world; this.worldRef = options.worldRef; this.map = options.map; @@ -746,6 +749,13 @@ export class GeneralAI { const declareTerms = warTargets.filter((entry) => entry.state === 1).map((entry) => entry.term); const minWarTerm = declareTerms.length > 0 ? Math.min(...declareTerms) : null; + let lastAttackable = lastAttackableByNation.get(nationId) ?? + readMetaNumber(asRecord(this.nation.meta), 'last_attackable', 0); + const markAttackable = () => { + lastAttackable = yearMonth; + lastAttackableByNation.set(nationId, yearMonth); + }; + if (minWarTerm === null) { this.dipState = d평화; } else if (minWarTerm > 8) { @@ -754,19 +764,29 @@ export class GeneralAI { this.dipState = d징병; } else { this.dipState = d직전; + markAttackable(); } - const meta = asRecord(this.nation.meta); - const lastAttackable = readMetaNumber(meta, 'last_attackable', 0); if (Object.prototype.hasOwnProperty.call(warTargetNation, 0) && this.attackable) { this.dipState = d전쟁; + markAttackable(); } else if (onWar > 0) { if (this.attackable) { this.dipState = d전쟁; + markAttackable(); } else if (lastAttackable >= yearMonth - 5) { this.dipState = d전쟁; } } + + if ((this.dipState === d평화 || this.dipState === d선포) && this.worldRef) { + const hasCrew = + this.general.crew > 0 || + this.worldRef.listGenerals().some((general) => general.nationId === nationId && general.crew > 0); + if (hasCrew) { + this.dipState = d징병; + } + } } private calcRecentWarTurn(general: TurnGeneral): number { diff --git a/app/game-engine/src/turn/ai/generalAi/general/warActions.ts b/app/game-engine/src/turn/ai/generalAi/general/warActions.ts index ce66d07..bdbc2aa 100644 --- a/app/game-engine/src/turn/ai/generalAi/general/warActions.ts +++ b/app/game-engine/src/turn/ai/generalAi/general/warActions.ts @@ -3,7 +3,7 @@ import { valueFit } from '../../aiUtils.js'; import { pickWeightedCandidate } from './helpers.js'; export const do전투준비 = (ai: GeneralAI) => { - if ([0, 1].includes(ai.dipState)) { + if ([0, 1].includes(ai.dipState) && ai.general.crew <= 0) { return null; } const cmdList: Array<[ReturnType, number]> = []; diff --git a/app/game-engine/src/turn/frontStateHandler.ts b/app/game-engine/src/turn/frontStateHandler.ts index a967db0..485f6d1 100644 --- a/app/game-engine/src/turn/frontStateHandler.ts +++ b/app/game-engine/src/turn/frontStateHandler.ts @@ -127,7 +127,7 @@ export const buildFrontStatePatches = (options: { .listNations() .filter((nation) => options.nationIds?.includes(nation.id)) : options.worldView.listNations(); - const nations = nationList.filter((nation) => nation.level > 0 && nation.capitalCityId); + const nations = nationList.filter((nation) => nation.level > 0); const patches: Array<{ id: number; patch: Partial }> = []; for (const nation of nations) { const frontStates = resolveNationFrontStates(nation.id, connectionMap, cityIdsByNation, diplomacy); diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 74806cd..ae243c4 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -267,7 +267,17 @@ export class InMemoryTurnWorld { } getDiplomacyEntry(srcNationId: number, destNationId: number): TurnDiplomacy | null { - const entry = this.diplomacy.get(buildDiplomacyKey(srcNationId, destNationId)); + if (srcNationId === destNationId) { + return null; + } + const key = buildDiplomacyKey(srcNationId, destNationId); + let entry = this.diplomacy.get(key); + if (!entry && this.nations.has(srcNationId) && this.nations.has(destNationId)) { + entry = buildDefaultDiplomacy(srcNationId, destNationId); + this.diplomacy.set(key, entry); + this.dirtyDiplomacyKeys.add(key); + this.createdDiplomacyKeys.add(key); + } if (!entry) { return null; } @@ -278,6 +288,7 @@ export class InMemoryTurnWorld { } listDiplomacy(): TurnDiplomacy[] { + this.ensureDiplomacyMatrix(); return Array.from(this.diplomacy.values()).map((entry) => ({ ...entry, meta: { ...entry.meta }, @@ -530,6 +541,7 @@ export class InMemoryTurnWorld { this.createdGeneralIds.add(createdGeneral.id); } if (result.created.nations) { + let addedNation = false; for (const createdNation of result.created.nations) { if (this.nations.has(createdNation.id)) { continue; @@ -537,6 +549,10 @@ export class InMemoryTurnWorld { this.nations.set(createdNation.id, { ...createdNation }); this.dirtyNationIds.add(createdNation.id); this.createdNationIds.add(createdNation.id); + addedNation = true; + } + if (addedNation) { + this.ensureDiplomacyMatrix(); } } if (result.created.troops) { diff --git a/app/game-engine/test/npcNationGrowthScenario.test.ts b/app/game-engine/test/npcNationGrowthScenario.test.ts index b5b9b16..3f6628a 100644 --- a/app/game-engine/test/npcNationGrowthScenario.test.ts +++ b/app/game-engine/test/npcNationGrowthScenario.test.ts @@ -239,6 +239,8 @@ describe('NPC 대형 시뮬레이션', () => { const turnTraces: TurnTrace[] = []; const traceByGeneralId = new Map(); + const commandEnv = buildCommandEnv(snapshot.scenarioConfig, snapshot.unitSet); + const handler = await createReservedTurnHandler({ reservedTurns: reservedTurnStore, scenarioConfig: snapshot.scenarioConfig, @@ -401,14 +403,20 @@ describe('NPC 대형 시뮬레이션', () => { } }; - const assertWarReadiness = (minCount: number, minTrain = 70, minAtmos = 70) => { + const assertWarReadiness = (minReadyCount: number, minTrain: number, minAtmos: number) => { const recruited = world .listGenerals() .filter((general) => general.nationId > 0 && general.crew > 0 && general.crewTypeId > 0); + const maxTrain = recruited.reduce((max, general) => Math.max(max, general.train), 0); + const maxAtmos = recruited.reduce((max, general) => Math.max(max, general.atmos), 0); const ready = recruited.filter((general) => general.train >= minTrain && general.atmos >= minAtmos); - expect(ready.length).toBeGreaterThanOrEqual(minCount); - }; + expect(recruited.length).toBeGreaterThan(0); + expect(maxTrain).toBeGreaterThan(0); + expect(maxAtmos).toBeGreaterThan(0); + expect(ready.length).toBeGreaterThanOrEqual(minReadyCount); + }; + const assertDispatchRecorded = (year: number, month: number, minCount = 1) => { const matches = turnTraces.filter( (trace) => trace.year === year && trace.month === month && trace.actionKey === 'che_출병' @@ -527,9 +535,8 @@ describe('NPC 대형 시뮬레이션', () => { } const city = world.getCityById(general.cityId); const nation = general.nationId > 0 ? world.getNationById(general.nationId) : null; - const env = buildCommandEnv(snapshot.scenarioConfig, snapshot.unitSet); const { general: generalDefinitions, nation: nationDefinitions } = await buildReservedTurnDefinitions({ - env, + env: commandEnv, commandProfile: DEFAULT_TURN_COMMAND_PROFILE, defaultActionKey: '휴식', }); @@ -557,8 +564,8 @@ describe('NPC 대형 시뮬레이션', () => { month: state.currentMonth, startYear, relYear: state.currentYear - startYear, - openingPartYear: env.openingPartYear, - minAvailableRecruitPop: env.minAvailableRecruitPop, + openingPartYear: commandEnv.openingPartYear, + minAvailableRecruitPop: commandEnv.minAvailableRecruitPop, cities: world.listCities(), nations: world.listNations(), map: LARGE_TEST_MAP, @@ -618,7 +625,7 @@ describe('NPC 대형 시뮬레이션', () => { scenarioMeta: snapshot.scenarioMeta, map: LARGE_TEST_MAP, unitSet: snapshot.unitSet, - commandEnv: env, + commandEnv, generalDefinitions, nationDefinitions, generalFallback,