diff --git a/app/game-engine/src/turn/ai/generalAi.ts b/app/game-engine/src/turn/ai/generalAi.ts index f834db4..2212db0 100644 --- a/app/game-engine/src/turn/ai/generalAi.ts +++ b/app/game-engine/src/turn/ai/generalAi.ts @@ -141,6 +141,28 @@ export interface GeneralAIOptions { nationFallback: GeneralActionDefinition; } +export type GeneralAiDebugState = { + generalId: number; + nationId: number | null; + cityId: number | null; + yearMonth: number; + startYear: number; + startYearMonth: number; + dipState: number; + attackable: boolean; + warTargetNation: Record; + genType: number; + lastAttackable: number; + frontCities: Array<{ id: number; frontState: number; supplyState: number }>; + supplyCities: Array<{ id: number; frontState: number; supplyState: number }>; + policy: { + minAvailableRecruitPop: number; + minNpcWarLeadership: number; + minWarCrew: number; + cureThreshold: number; + }; +}; + export class GeneralAI { public readonly general: TurnGeneral; public readonly city?: City; @@ -423,6 +445,45 @@ export class GeneralAI { return neutral ?? this.buildGeneralCandidate(ACTION_REST, {}, 'neutral'); } + getDebugState(): GeneralAiDebugState { + this.updateInstance(); + this.categorizeNationCities(); + const yearMonth = joinYearMonth(this.world.currentYear, this.world.currentMonth); + const startYearMonth = joinYearMonth(this.startYear + 2, 5); + const meta = asRecord(this.nation?.meta ?? {}); + const lastAttackable = readMetaNumber(meta, 'last_attackable', 0); + + return { + generalId: this.general.id, + nationId: this.nation?.id ?? null, + cityId: this.city?.id ?? null, + yearMonth, + startYear: this.startYear, + startYearMonth, + dipState: this.dipState, + attackable: this.attackable, + warTargetNation: { ...this.warTargetNation }, + genType: this.genType, + lastAttackable, + frontCities: Object.values(this.frontCities).map((city) => ({ + id: city.id, + frontState: city.frontState, + supplyState: city.supplyState, + })), + supplyCities: Object.values(this.supplyCities).map((city) => ({ + id: city.id, + frontState: city.frontState, + supplyState: city.supplyState, + })), + policy: { + minAvailableRecruitPop: this.aiConst.minAvailableRecruitPop, + minNpcWarLeadership: this.nationPolicy.minNpcWarLeadership, + minWarCrew: this.nationPolicy.minWarCrew, + cureThreshold: this.nationPolicy.cureThreshold, + }, + }; + } + buildGeneralCandidate(action: string, args: Record, reason: string): AiCommandCandidate | null { return this.buildCandidate(this.generalDefinitions, this.generalFallback, action, args, reason); } diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index b85f7b9..3ce8079 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -394,6 +394,7 @@ export const createReservedTurnHandler = async (options: { actionKey: string; usedFallback: boolean; blockedReason?: string; + aiState?: ReturnType; }) => void; }): Promise => { const env = buildCommandEnv(options.scenarioConfig, options.unitSet); @@ -709,6 +710,7 @@ export const createReservedTurnHandler = async (options: { currentGeneral.officerLevel, 0 ); + let nationAiState: ReturnType | undefined; if (worldView && shouldUseAi(currentGeneral, context.world)) { const ai = new GeneralAI({ general: currentGeneral, @@ -731,6 +733,7 @@ export const createReservedTurnHandler = async (options: { if (candidate) { nationCommand = { action: candidate.action, args: candidate.args }; } + nationAiState = ai.getDebugState(); } const nationResult = runAction(nationDefinitions, nationFallback, nationCommand, false); options.onActionResolved?.({ @@ -741,11 +744,13 @@ export const createReservedTurnHandler = async (options: { actionKey: nationResult.actionKey, usedFallback: nationResult.usedFallback, ...(nationResult.blockedReason ? { blockedReason: nationResult.blockedReason } : {}), + ...(nationAiState ? { aiState: nationAiState } : {}), }); options.reservedTurns.shiftNationTurns(currentNation.id, currentGeneral.officerLevel, -1); } let generalCommand = options.reservedTurns.getGeneralTurn(currentGeneral.id, 0); + let generalAiState: ReturnType | undefined; if (worldView && shouldUseAi(currentGeneral, context.world)) { const ai = new GeneralAI({ general: currentGeneral, @@ -768,6 +773,7 @@ export const createReservedTurnHandler = async (options: { if (candidate) { generalCommand = { action: candidate.action, args: candidate.args }; } + generalAiState = ai.getDebugState(); } const generalResult = runAction(generalDefinitions, generalFallback, generalCommand, true); options.onActionResolved?.({ @@ -778,6 +784,7 @@ export const createReservedTurnHandler = async (options: { actionKey: generalResult.actionKey, usedFallback: generalResult.usedFallback, ...(generalResult.blockedReason ? { blockedReason: generalResult.blockedReason } : {}), + ...(generalAiState ? { aiState: generalAiState } : {}), }); const nextTurnAt = generalResult.nextTurnAt; options.reservedTurns.shiftGeneralTurns(currentGeneral.id, -1); diff --git a/app/game-engine/test/npcNationGrowthScenario.test.ts b/app/game-engine/test/npcNationGrowthScenario.test.ts index 1792cb6..83604e0 100644 --- a/app/game-engine/test/npcNationGrowthScenario.test.ts +++ b/app/game-engine/test/npcNationGrowthScenario.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it, vi } from 'vitest'; import type { LogEntryDraft, TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic'; import { LogCategory } from '@sammo-ts/logic'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; +import type { GeneralAiDebugState } from '../src/turn/ai/generalAi.js'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { joinYearMonth } from '../src/turn/ai/aiUtils.js'; import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js'; import { createReservedTurnHandler } from '../src/turn/reservedTurnHandler.js'; import { InMemoryTurnProcessor } from '../src/turn/inMemoryTurnProcessor.js'; @@ -208,6 +210,7 @@ describe('NPC 대형 시뮬레이션', () => { ok: boolean; error?: unknown; logs: LogEntryDraft[]; + aiState?: GeneralAiDebugState; }; const turnTraces: TurnTrace[] = []; @@ -232,6 +235,7 @@ describe('NPC 대형 시뮬레이션', () => { trace.requestedAction = payload.requestedAction; trace.usedFallback = payload.usedFallback; trace.blockedReason = payload.blockedReason; + trace.aiState = payload.aiState; }, }); @@ -369,24 +373,6 @@ describe('NPC 대형 시뮬레이션', () => { } }; - const scheduleNpcRecruitment = () => { - const nations = world.listNations().filter((nation) => nation.level >= 1 && nation.capitalCityId); - const generals = world.listGenerals(); - for (const nation of nations) { - const targetGenerals = generals.filter((general) => general.nationId === nation.id); - for (const general of targetGenerals) { - if (general.crew > 0 && general.crewTypeId > 0) { - continue; - } - const amount = Math.max(100, Math.floor(general.stats.leadership * 50)); - world.updateGeneral(general.id, { - crew: amount, - crewTypeId: unitSet.defaultCrewTypeId, - }); - } - } - }; - const maybeSnapshotGold = () => { checkpointGoldByGeneral.clear(); for (const general of world.listGenerals()) { @@ -438,6 +424,10 @@ describe('NPC 대형 시뮬레이션', () => { } } } catch (error) { + const lastAiTrace = [...turnTraces].reverse().find((trace) => trace.aiState); + if (lastAiTrace?.aiState) { + console.log('[DEBUG] last aiState:', lastAiTrace.aiState); + } dumpTraceSummary('NPC 대형 시뮬레이션 실패'); const sampleNation = world.listNations().find((nation) => nation.level >= 1 && nation.capitalCityId); if (sampleNation) {