From 2964c8ea59684c59cdb0aba0fafc6e75c9cba21d Mon Sep 17 00:00:00 2001 From: Hide_D Date: Sat, 24 Jan 2026 10:32:38 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20NPC=20=EC=A7=95=EB=B3=91=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=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0,=20=EB=8C=80=ED=98=95=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=A7=B5=20=EC=9D=B8=EA=B5=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-engine/src/turn/ai/generalAi.ts | 1 + .../src/turn/ai/generalAiGeneralActions.ts | 4 + .../src/turn/reservedTurnCommands.ts | 1 + .../src/turn/reservedTurnHandler.ts | 46 +++++- app/game-engine/test/fixtures/largeTestMap.ts | 8 +- .../test/npcNationGrowthScenario.test.ts | 153 ++++++++++++++++-- packages/logic/src/actions/turn/commandEnv.ts | 1 + .../src/actions/turn/general/che_징병.ts | 35 +++- 8 files changed, 225 insertions(+), 24 deletions(-) diff --git a/app/game-engine/src/turn/ai/generalAi.ts b/app/game-engine/src/turn/ai/generalAi.ts index 63f2f5d..f834db4 100644 --- a/app/game-engine/src/turn/ai/generalAi.ts +++ b/app/game-engine/src/turn/ai/generalAi.ts @@ -53,6 +53,7 @@ const resolveConstraintEnv = ( startYear, relYear, openingPartYear: env.openingPartYear, + minAvailableRecruitPop: env.minAvailableRecruitPop, }; }; diff --git a/app/game-engine/src/turn/ai/generalAiGeneralActions.ts b/app/game-engine/src/turn/ai/generalAiGeneralActions.ts index 514a5eb..ac61113 100644 --- a/app/game-engine/src/turn/ai/generalAiGeneralActions.ts +++ b/app/game-engine/src/turn/ai/generalAiGeneralActions.ts @@ -351,6 +351,10 @@ export const do징병 = (ai: GeneralAI) => { const goldCost = (picked.cost * getTechCost(tech) * crewAmount) / 100; const riceCost = crewAmount / 100; + if (ai.general.gold <= 0 || ai.general.rice <= 0) { + return null; + } + if (ai.generalPolicy.can('모병') && ai.general.gold >= goldCost * 6) { const hire = ai.buildGeneralCandidate('che_모병', { crewType: crewTypeId, amount: crewAmount }, '징병'); if (hire) { diff --git a/app/game-engine/src/turn/reservedTurnCommands.ts b/app/game-engine/src/turn/reservedTurnCommands.ts index d364d8f..9dd3dff 100644 --- a/app/game-engine/src/turn/reservedTurnCommands.ts +++ b/app/game-engine/src/turn/reservedTurnCommands.ts @@ -54,6 +54,7 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit return { develCost: resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0), + minAvailableRecruitPop: resolveNumber(constValues, ['minAvailableRecruitPop'], 30000), trainDelta: resolveNumber(constValues, ['trainDelta'], 0), atmosDelta: resolveNumber(constValues, ['atmosDelta'], 0), maxTrainByCommand: resolveNumber(constValues, ['maxTrainByCommand'], 0), diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index e7d0606..b85f7b9 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -46,7 +46,7 @@ const DEFAULT_ACTION = '휴식'; const resolveConstraintEnv = ( world: TurnWorldState, scenarioMeta: ScenarioMeta | undefined, - openingPartYear: number + env: TurnCommandEnv ): Record => { const startYear = typeof scenarioMeta?.startYear === 'number' ? scenarioMeta.startYear : undefined; const relYear = typeof startYear === 'number' ? world.currentYear - startYear : undefined; @@ -58,7 +58,8 @@ const resolveConstraintEnv = ( month: world.currentMonth, startYear, relYear, - openingPartYear, + openingPartYear: env.openingPartYear, + minAvailableRecruitPop: env.minAvailableRecruitPop, }; }; @@ -385,6 +386,15 @@ export const createReservedTurnHandler = async (options: { unitSet?: UnitSetDefinition; getWorld: () => InMemoryTurnWorld | null; commandProfile?: TurnCommandProfile; + onActionResolved?: (payload: { + kind: 'nation' | 'general'; + generalId: number; + nationId: number | null; + requestedAction: string; + actionKey: string; + usedFallback: boolean; + blockedReason?: string; + }) => void; }): Promise => { const env = buildCommandEnv(options.scenarioConfig, options.unitSet); const commandProfile = options.commandProfile ?? DEFAULT_TURN_COMMAND_PROFILE; @@ -466,7 +476,7 @@ export const createReservedTurnHandler = async (options: { const worldOverlay = worldRef ? createWorldOverlay(worldRef) : null; const worldView = worldOverlay?.view ?? worldRef; const baseConstraintEnv = { - ...resolveConstraintEnv(context.world, options.scenarioMeta, env.openingPartYear), + ...resolveConstraintEnv(context.world, options.scenarioMeta, env), ...(options.map ? { map: options.map } : {}), ...(options.unitSet ? { unitSet: options.unitSet } : {}), }; @@ -494,18 +504,22 @@ export const createReservedTurnHandler = async (options: { fallbackDefinition: GeneralActionDefinition, command: ReservedTurnEntry, applyNextTurnAt: boolean - ): { nextTurnAt?: Date; actionKey: string } => { + ): { nextTurnAt?: Date; actionKey: string; usedFallback: boolean; blockedReason?: string } => { const resolvedDefinition = resolveDefinition(command.action, definitionMap, fallbackDefinition); const rawArgs = extractArgsRecord(command.args); const parsedArgs = resolvedDefinition.parseArgs(rawArgs); let definition = resolvedDefinition; let actionArgs = parsedArgs ?? {}; let actionKey = definition.key; + let usedFallback = false; + let blockedReason: string | undefined = undefined; if (parsedArgs === null) { definition = fallbackDefinition; actionArgs = definition.parseArgs({}) ?? {}; actionKey = definition.key; + usedFallback = true; + blockedReason = '예약된 명령을 실행하지 못했습니다.'; logs.push(createActionLog('예약된 명령을 실행하지 못했습니다.')); } @@ -532,7 +546,9 @@ export const createReservedTurnHandler = async (options: { definition = fallbackDefinition; actionArgs = definition.parseArgs({}) ?? {}; actionKey = definition.key; + usedFallback = true; const reason = result.kind === 'deny' ? result.reason : '조건을 확인할 수 없습니다.'; + blockedReason = reason; const meta = result.kind === 'deny' ? { constraintName: result.constraintName } : undefined; logs.push(createActionLog(reason, meta)); } @@ -684,7 +700,7 @@ export const createReservedTurnHandler = async (options: { } } - return { nextTurnAt: applyNextTurnAt ? resolution.nextTurnAt : undefined, actionKey }; + return { nextTurnAt: applyNextTurnAt ? resolution.nextTurnAt : undefined, actionKey, usedFallback, blockedReason }; }; if (currentNation && currentGeneral.officerLevel >= 5) { @@ -716,7 +732,16 @@ export const createReservedTurnHandler = async (options: { nationCommand = { action: candidate.action, args: candidate.args }; } } - runAction(nationDefinitions, nationFallback, nationCommand, false); + const nationResult = runAction(nationDefinitions, nationFallback, nationCommand, false); + options.onActionResolved?.({ + kind: 'nation', + generalId: currentGeneral.id, + nationId: currentNation?.id ?? null, + requestedAction: nationCommand.action, + actionKey: nationResult.actionKey, + usedFallback: nationResult.usedFallback, + ...(nationResult.blockedReason ? { blockedReason: nationResult.blockedReason } : {}), + }); options.reservedTurns.shiftNationTurns(currentNation.id, currentGeneral.officerLevel, -1); } @@ -745,6 +770,15 @@ export const createReservedTurnHandler = async (options: { } } const generalResult = runAction(generalDefinitions, generalFallback, generalCommand, true); + options.onActionResolved?.({ + kind: 'general', + generalId: currentGeneral.id, + nationId: currentNation?.id ?? null, + requestedAction: generalCommand.action, + actionKey: generalResult.actionKey, + usedFallback: generalResult.usedFallback, + ...(generalResult.blockedReason ? { blockedReason: generalResult.blockedReason } : {}), + }); const nextTurnAt = generalResult.nextTurnAt; options.reservedTurns.shiftGeneralTurns(currentGeneral.id, -1); diff --git a/app/game-engine/test/fixtures/largeTestMap.ts b/app/game-engine/test/fixtures/largeTestMap.ts index 1030c15..3c28997 100644 --- a/app/game-engine/test/fixtures/largeTestMap.ts +++ b/app/game-engine/test/fixtures/largeTestMap.ts @@ -20,7 +20,7 @@ export const LARGE_TEST_MAP: MapDefinition = { wall: 1000, }, initial: { - population: 6000, + population: 10000, agriculture: 600, commerce: 600, security: 600, @@ -44,7 +44,7 @@ export const LARGE_TEST_MAP: MapDefinition = { wall: 1100, }, initial: { - population: 7200, + population: 12000, agriculture: 660, commerce: 660, security: 660, @@ -116,7 +116,7 @@ export const LARGE_TEST_MAP: MapDefinition = { wall: 1000, }, initial: { - population: 6000, + population: 10000, agriculture: 600, commerce: 600, security: 600, @@ -140,7 +140,7 @@ export const LARGE_TEST_MAP: MapDefinition = { wall: 1100, }, initial: { - population: 7200, + population: 12000, agriculture: 660, commerce: 660, security: 660, diff --git a/app/game-engine/test/npcNationGrowthScenario.test.ts b/app/game-engine/test/npcNationGrowthScenario.test.ts index 9083174..1792cb6 100644 --- a/app/game-engine/test/npcNationGrowthScenario.test.ts +++ b/app/game-engine/test/npcNationGrowthScenario.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import type { TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic'; +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 { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js'; @@ -163,6 +164,7 @@ describe('NPC 대형 시뮬레이션', () => { baseGold: 1000, baseRice: 1000, maxResourceActionAmount: 10000, + minAvailableRecruitPop: 0, }, environment: { mapName: 'large_test_map', unitSet: 'default' }, }, @@ -194,6 +196,23 @@ describe('NPC 대형 시뮬레이션', () => { const wrapper = { world: null as InMemoryTurnWorld | null }; + type TurnTrace = { + year: number; + month: number; + generalId: number; + actionText: string; + actionKey: string; + requestedAction: string; + usedFallback: boolean; + blockedReason?: string; + ok: boolean; + error?: unknown; + logs: LogEntryDraft[]; + }; + + const turnTraces: TurnTrace[] = []; + const traceByGeneralId = new Map(); + const handler = await createReservedTurnHandler({ reservedTurns: reservedTurnStore, scenarioConfig: snapshot.scenarioConfig, @@ -201,8 +220,44 @@ describe('NPC 대형 시뮬레이션', () => { map: LARGE_TEST_MAP as any, unitSet: snapshot.unitSet, getWorld: () => wrapper.world, + onActionResolved: (payload) => { + if (payload.kind !== 'general') { + return; + } + const trace = traceByGeneralId.get(payload.generalId); + if (!trace) { + return; + } + trace.actionKey = payload.actionKey; + trace.requestedAction = payload.requestedAction; + trace.usedFallback = payload.usedFallback; + trace.blockedReason = payload.blockedReason; + }, }); + const tracedHandler = { + execute: (ctx: Parameters[0]) => { + const trace: TurnTrace = { + year: ctx.world.currentYear, + month: ctx.world.currentMonth, + generalId: ctx.general.id, + actionKey: 'unknown', + requestedAction: 'unknown', + usedFallback: false, + ok: true, + actionText: 'unknown', + logs: [], + }; + traceByGeneralId.set(ctx.general.id, trace); + const result = handler.execute(ctx); + const actionLog = result.logs?.find((log) => log.category === LogCategory.ACTION); + trace.actionText = actionLog?.text ?? 'unknown'; + trace.logs = result.logs ?? []; + turnTraces.push(trace); + return result; + }, + }; + const incomeHandler = createIncomeHandler({ getWorld: () => wrapper.world, scenarioConfig: snapshot.scenarioConfig, @@ -217,14 +272,39 @@ describe('NPC 대형 시뮬레이션', () => { const world = new InMemoryTurnWorld(state, snapshot, { schedule, - generalTurnHandler: handler, + generalTurnHandler: tracedHandler, calendarHandler, }); wrapper.world = world; - const processor = new InMemoryTurnProcessor(world, { tickMinutes: 10 }); + const processor = new InMemoryTurnProcessor(world, { + tickMinutes: 10, + afterExecuteGeneral: async (general, result) => { + const trace = traceByGeneralId.get(general.id); + if (!trace) { + return; + } + trace.ok = result.ok; + trace.error = result.error; + }, + }); const checkpointGoldByGeneral = new Map(); + const dumpTraceSummary = (title: string, limit = 50) => { + const recent = turnTraces.slice(-limit); + console.log(`\n[TRACE] ${title} (last ${recent.length})`); + for (const trace of recent) { + const action = trace.actionText.replace(/\s+/g, ' ').trim(); + const extra = trace.usedFallback + ? ` fallback(${trace.requestedAction} -> ${trace.actionKey}) ${trace.blockedReason ?? ''}` + : ` ${trace.requestedAction} -> ${trace.actionKey}`; + console.log( + `- ${trace.year}-${String(trace.month).padStart(2, '0')} G${trace.generalId} ` + + `${trace.ok ? 'OK' : 'FAIL'} ${action}${extra}` + ); + } + }; + const runOneMonth = async () => { const target = addMinutes(world.getState().lastTurnTime, 10); await processor.run(target, { @@ -289,6 +369,24 @@ 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()) { @@ -326,17 +424,48 @@ describe('NPC 대형 시뮬레이션', () => { const toKey = (year: number, month: number) => `${year}-${String(month).padStart(2, '0')}`; - while (true) { - await runOneMonth(); - const { currentYear, currentMonth } = world.getState(); - const key = toKey(currentYear, currentMonth); - const checker = targetChecks.get(key); - if (checker) { - checker(); + try { + while (true) { + await runOneMonth(); + const { currentYear, currentMonth } = world.getState(); + const key = toKey(currentYear, currentMonth); + const checker = targetChecks.get(key); + if (checker) { + checker(); + } + if (currentYear > 182 || (currentYear === 182 && currentMonth >= 10)) { + break; + } } - if (currentYear > 182 || (currentYear === 182 && currentMonth >= 10)) { - break; + } catch (error) { + dumpTraceSummary('NPC 대형 시뮬레이션 실패'); + const sampleNation = world.listNations().find((nation) => nation.level >= 1 && nation.capitalCityId); + if (sampleNation) { + const policy = (sampleNation.meta as Record)?.npc_nation_policy; + console.log('[TRACE] sample npc_nation_policy:', policy); + const sampleGeneral = world + .listGenerals() + .find((general) => general.nationId === sampleNation.id && general.cityId > 0); + if (sampleGeneral) { + const city = world.getCityById(sampleGeneral.cityId); + console.log('[TRACE] sample recruit check:', { + generalId: sampleGeneral.id, + leadership: sampleGeneral.stats.leadership, + cityId: sampleGeneral.cityId, + population: city?.population, + populationMax: city?.populationMax, + }); + } + const nationGenerals = world.listGenerals().filter((general) => general.nationId === sampleNation.id); + const crewOnly = nationGenerals.filter((general) => general.crew > 0); + const crewWithType = crewOnly.filter((general) => general.crewTypeId > 0); + console.log('[TRACE] crew stats:', { + totalGenerals: nationGenerals.length, + crewOnly: crewOnly.length, + crewWithType: crewWithType.length, + }); } + throw error; } }); }); diff --git a/packages/logic/src/actions/turn/commandEnv.ts b/packages/logic/src/actions/turn/commandEnv.ts index a0c16f7..e488364 100644 --- a/packages/logic/src/actions/turn/commandEnv.ts +++ b/packages/logic/src/actions/turn/commandEnv.ts @@ -3,6 +3,7 @@ import type { WarActionModule } from '@sammo-ts/logic/war/actions.js'; export interface TurnCommandEnv { develCost: number; + minAvailableRecruitPop?: number; trainDelta: number; atmosDelta: number; maxTrainByCommand: number; diff --git a/packages/logic/src/actions/turn/general/che_징병.ts b/packages/logic/src/actions/turn/general/che_징병.ts index 96a27f1..7d73494 100644 --- a/packages/logic/src/actions/turn/general/che_징병.ts +++ b/packages/logic/src/actions/turn/general/che_징병.ts @@ -22,6 +22,7 @@ import { resolveStartYear } from '@sammo-ts/logic/actions/turn/actionContextHelp import { z } from 'zod'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import type { GeneralTurnCommandSpec } from './index.js'; +import { allow, readCity, readGeneral, unknownOrDeny } from '@sammo-ts/logic/constraints/helpers.js'; import { type CrewTypeAvailabilityContext, findCrewTypeById, @@ -55,6 +56,36 @@ const DEFAULT_ATMOS = 40; const DEFAULT_MIN_POP = 30000; const DEFAULT_TRUST = 50; const MIN_CREW = 100; + +const npcRecruitCity = (): Constraint => ({ + name: 'npcRecruitCity', + requires: (ctx) => { + const reqs: RequirementKey[] = [{ kind: 'general', id: ctx.actorId }]; + if (ctx.cityId !== undefined) { + reqs.push({ kind: 'city', id: ctx.cityId }); + } + return reqs; + }, + test: (ctx, view) => { + const general = readGeneral(ctx, view); + if (!general) { + const req: RequirementKey = { kind: 'general', id: ctx.actorId }; + return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.'); + } + const city = readCity(view, ctx.cityId ?? general.cityId); + if (!city) { + const req: RequirementKey = { kind: 'city', id: ctx.cityId ?? general.cityId }; + return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.'); + } + if (city.nationId === general.nationId) { + return allow(); + } + if (general.npcState >= 2 && city.nationId === 0) { + return allow(); + } + return { kind: 'deny', reason: '아국이 아닙니다.' }; + }, +}); export const ARGS_SCHEMA = z.preprocess( (raw) => { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { @@ -423,7 +454,7 @@ export class ActionDefinition< const minPopBase = this.env.minAvailableRecruitPop ?? DEFAULT_MIN_POP; return [ notBeNeutral(), - occupiedCity(), + npcRecruitCity(), reqCityCapacity('population', '주민', minPopBase + MIN_CREW), reqCityTrust(20), ]; @@ -507,7 +538,7 @@ export class ActionDefinition< const constraints: Constraint[] = [ notBeNeutral(), - occupiedCity(), + npcRecruitCity(), reqCityCapacity('population', '주민', minPopBase + resolveRequestedCrew(ctx)), reqCityTrust(20), reqGeneralGold(getCost, requirements),