From 9c8f901f7b447f9d65a6c48c4c97f9ba741a2695 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Sat, 24 Jan 2026 10:57:30 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20GeneralAI=20=EB=94=94=EB=B2=84=EA=B7=B8?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EC=98=88=EC=95=BD=20=ED=84=B4=20=ED=95=B8=EB=93=A4=EB=9F=AC?= =?UTF-8?q?=EC=97=90=20AI=20=EC=83=81=ED=83=9C=20=EC=A0=84=EB=8B=AC=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-engine/src/turn/ai/generalAi.ts | 61 +++++++++++++++++++ .../src/turn/reservedTurnHandler.ts | 7 +++ .../test/npcNationGrowthScenario.test.ts | 26 +++----- 3 files changed, 76 insertions(+), 18 deletions(-) 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) {