import { describe, expect, it } from 'vitest'; import type { ConstraintContext, LogEntryDraft, RequirementKey, StateView, TurnSchedule, UnitSetDefinition, } from '@sammo-ts/logic'; import { DEFAULT_TURN_COMMAND_PROFILE, LogCategory, evaluateConstraints } from '@sammo-ts/logic'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; import type { GeneralAiDebugState } from '../src/turn/ai/generalAi.js'; import type { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; import { GeneralAI } from '../src/turn/ai/generalAi.js'; import { do징병 } from '../src/turn/ai/generalAiGeneralActions.js'; import { buildCommandEnv, buildReservedTurnDefinitions } from '../src/turn/reservedTurnCommands.js'; import { LARGE_TEST_MAP, buildLargeTestCities } from './fixtures/largeTestMap.js'; import { round } from 'es-toolkit'; import { createTurnTestHarness, createWorldDebugger } from './helpers/turnTestHarness.js'; const mockDate = new Date('0179-08-01T00:00:00Z'); const createNpcGeneral = ( id: number, cityId: number, stats: { leadership: number; strength: number; intelligence: number }, npcState: number ): TurnGeneral => ({ id, name: `NPC_${id}`, nationId: 0, cityId, troopId: 0, stats, turnTime: mockDate, role: { items: { horse: null, weapon: null, book: null, item: null }, personality: null, specialDomestic: null, specialWar: null, }, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, meta: { killturn: 800 }, officerLevel: 1, experience: 0, dedication: 0, injury: 0, gold: 5000, rice: 5000, crew: 0, crewTypeId: 0, train: 0, atmos: 0, age: 30, npcState, }); describe('NPC 대형 시뮬레이션', () => { it('NPC 국가 성장 기준을 만족해야 한다', async () => { const smallMediumCityIds = LARGE_TEST_MAP.cities .filter((city) => [4, 5].includes(city.level)) .map((city) => city.id); const npcPerType = smallMediumCityIds.length * 20; const generals: TurnGeneral[] = []; for (let i = 0; i < npcPerType; i += 1) { const cityId = smallMediumCityIds[i % smallMediumCityIds.length]; generals.push( createNpcGeneral(generals.length + 1, cityId, { leadership: 75, strength: 75, intelligence: 10 }, 2) ); } for (let i = 0; i < npcPerType; i += 1) { const cityId = smallMediumCityIds[i % smallMediumCityIds.length]; generals.push( createNpcGeneral(generals.length + 1, cityId, { leadership: 75, strength: 10, intelligence: 75 }, 2) ); } const cities = buildLargeTestCities(); const initialCityStats = new Map( cities.map((city) => [ city.id, { population: city.population, agriculture: city.agriculture, commerce: city.commerce, security: city.security, defence: city.defence, wall: city.wall, }, ]) ); const unitSet: UnitSetDefinition = { id: 'test_unit_set', name: 'TestUnitSet', defaultCrewTypeId: 1100, crewTypes: [ { id: 1100, armType: 1, name: '보병', attack: 10, defence: 10, speed: 10, avoid: 0, magicCoef: 0, cost: 10, rice: 1, requirements: [], attackCoef: {}, defenceCoef: {}, info: [], initSkillTrigger: null, phaseSkillTrigger: null, iActionList: null, }, ], }; const snapshot: TurnWorldSnapshot = { generals: generals as any, cities: cities as any, nations: [], troops: [], diplomacy: [], events: [], initialEvents: [], map: LARGE_TEST_MAP as any, scenarioConfig: { stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, iconPath: '', map: {}, const: { openingPartYear: 3, develCost: 10, baseGold: 1000, baseRice: 1000, maxResourceActionAmount: 10000, minAvailableRecruitPop: 0, }, environment: { mapName: 'large_test_map', unitSet: 'default' }, }, scenarioMeta: { startYear: 180, } as any, unitSet: unitSet as any, }; const state: TurnWorldState = { id: 1, currentYear: 179, currentMonth: 8, tickSeconds: 600, lastTurnTime: mockDate, meta: { seed: 1, initYear: 179, initMonth: 8 }, }; const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }], }; const worldRef = { current: null as InMemoryTurnWorld | null }; type TurnTrace = { year: number; month: number; nationId: number; generalId: number; gold: number; rice: number; crew: number; train: number; atmos: number; actionText: string; actionKey: string; requestedAction: string; usedFallback: boolean; blockedReason?: string; ok: boolean; error?: unknown; logs: LogEntryDraft[]; aiState?: GeneralAiDebugState; }; const turnTraces: TurnTrace[] = []; const traceByGeneralId = new Map(); const commandEnv = buildCommandEnv(snapshot.scenarioConfig, snapshot.unitSet); const { world, reservedTurnStore, runUntil } = await createTurnTestHarness({ snapshot, state, schedule, map: LARGE_TEST_MAP, worldRef, 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; trace.aiState = payload.aiState; }, wrapGeneralTurnHandler: (handler) => ({ execute: (ctx) => { const trace: TurnTrace = { year: ctx.world.currentYear, month: ctx.world.currentMonth, nationId: ctx.general.nationId, generalId: ctx.general.id, gold: ctx.general.gold, rice: ctx.general.rice, crew: ctx.general.crew, train: ctx.general.train, atmos: ctx.general.atmos, 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 ?? []; if (ctx.general.nationId === 0) { return result; } turnTraces.push(trace); return result; }, }), turnProcessorOptions: { 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')} N${trace.nationId} G${trace.generalId} g${round(trace.gold)}/r${round(trace.rice)} c${trace.crew}/t${trace.train}/a${trace.atmos} ` + `${trace.ok ? 'OK' : 'FAIL'} ${action}${extra}` ); } }; const debug = createWorldDebugger(() => worldRef.current, { includeNationSummary: true, }); const assertUprisingCount = (minCount: number) => { const nations = world.listNations(); expect(nations.length).toBeGreaterThanOrEqual(minCount); }; const assertFoundedCount = (minCount: number) => { const nations = world.listNations().filter((nation) => nation.level >= 1 && nation.capitalCityId); expect(nations.length).toBeGreaterThanOrEqual(minCount); }; const assertTaxRateUnder = (limit: number) => { const founded = world.listNations().filter((nation) => nation.level >= 1 && nation.capitalCityId); for (const nation of founded) { const rate = typeof nation.meta.rate === 'number' ? nation.meta.rate : 20; expect(rate).toBeLessThan(limit); } }; const assertSmallMediumCitiesFounded = () => { const targetCities = world.listCities().filter((city) => [4, 5].includes(city.level)); for (const city of targetCities) { expect(city.nationId).toBeGreaterThan(0); } }; const assertCityTrust = (minTrust: number) => { const targetCities = world.listCities().filter((city) => city.nationId > 0); for (const city of targetCities) { const trust = typeof city.meta.trust === 'number' ? city.meta.trust : 0; expect(trust).toBeGreaterThanOrEqual(minTrust); } }; const assertNationGeneralCount = (targetCount: number) => { const nations = world.listNations().filter((nation) => nation.level >= 1 && nation.capitalCityId); const generals = world.listGenerals(); for (const nation of nations) { const nationGenerals = generals.filter((general) => general.nationId === nation.id); expect(nationGenerals.length).toBeGreaterThanOrEqual(targetCount); } }; 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(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_출병' ); expect(matches.length).toBeGreaterThanOrEqual(minCount); }; const assertNoNeutralCities = () => { const neutralCities = world.listCities().filter((city) => city.nationId <= 0); expect(neutralCities.length).toBe(0); }; const maybeSnapshotGold = () => { checkpointGoldByGeneral.clear(); for (const general of world.listGenerals()) { if (general.nationId > 0) { checkpointGoldByGeneral.set(general.id, general.gold); } } }; const assertGoldIncome = () => { const generals = world.listGenerals().filter((general) => general.nationId > 0); let beforeTotal = 0; let afterTotal = 0; for (const general of generals) { beforeTotal += checkpointGoldByGeneral.get(general.id) ?? 0; afterTotal += general.gold; } expect(afterTotal).toBeGreaterThan(beforeTotal); }; const assertDomesticGrowth = () => { const citiesNow = world.listCities().filter((city) => city.nationId > 0); for (const city of citiesNow) { const baseline = initialCityStats.get(city.id); if (!baseline) { continue; } // 182년 시점에는 징병이 병행되어 인구가 순감할 수 있으므로, 대규모 붕괴만 방지한다. expect(city.population).toBeGreaterThan(baseline.population - 15000); expect(city.agriculture).toBeGreaterThan(baseline.agriculture); expect(city.commerce).toBeGreaterThan(baseline.commerce); expect(city.security).toBeGreaterThan(baseline.security); expect(city.defence).toBeGreaterThan(baseline.defence); expect(city.wall).toBeGreaterThan(baseline.wall); } }; const targetChecks = new Map void>([ ['179-09', () => assertUprisingCount(1)], ['179-10', () => assertUprisingCount(2)], ['179-11', () => assertFoundedCount(1)], [ '179-12', () => { assertTaxRateUnder(15); maybeSnapshotGold(); }, ], ['180-01', () => assertGoldIncome()], ['180-07', () => assertSmallMediumCitiesFounded()], ['180-11', () => assertCityTrust(90)], ['181-01', () => assertNationGeneralCount(10)], ['182-01', () => assertDomesticGrowth()], ['183-01', () => assertWarReadiness(10, 70, 70)], ['183-02', () => assertDispatchRecorded(183, 1, 1)], ['183-07', () => assertNoNeutralCities()], ]); const toKey = (year: number, month: number) => `${year}-${String(month).padStart(2, '0')}`; const debugRecruitConstraints = async (generalId: number) => { const general = world.getGeneralById(generalId); if (!general) { console.log('[DEBUG] no general for recruit check', generalId); return; } const city = world.getCityById(general.cityId); const nation = general.nationId > 0 ? world.getNationById(general.nationId) : null; const { general: generalDefinitions, nation: nationDefinitions } = await buildReservedTurnDefinitions({ env: commandEnv, commandProfile: DEFAULT_TURN_COMMAND_PROFILE, defaultActionKey: '휴식', }); const definition = generalDefinitions.get('che_징병'); if (!definition) { console.log('[DEBUG] no che_징병 definition'); return; } const args = { crewType: snapshot.unitSet?.defaultCrewTypeId ?? 1100, amount: general.stats.leadership * 100, }; const parsedArgs = definition.parseArgs(args); if (!parsedArgs) { console.log('[DEBUG] che_징병 args invalid', args); return; } const state = world.getState(); const startYear = snapshot.scenarioMeta?.startYear ?? 0; const constraintEnv: Record = { currentYear: state.currentYear, currentMonth: state.currentMonth, year: state.currentYear, month: state.currentMonth, startYear, relYear: state.currentYear - startYear, openingPartYear: commandEnv.openingPartYear, minAvailableRecruitPop: commandEnv.minAvailableRecruitPop, cities: world.listCities(), nations: world.listNations(), map: LARGE_TEST_MAP, unitSet: snapshot.unitSet, }; const ctx: ConstraintContext = { actorId: general.id, cityId: general.cityId, nationId: general.nationId, args: parsedArgs as Record, env: constraintEnv, mode: 'full', }; const view: StateView = { has: (req: RequirementKey) => { if (req.kind === 'general') return world.getGeneralById(req.id) !== null; if (req.kind === 'city') return world.getCityById(req.id) !== null; if (req.kind === 'nation') return world.getNationById(req.id) !== null; if (req.kind === 'generalList') return true; if (req.kind === 'nationList') return true; if (req.kind === 'env') return true; return false; }, get: (req: RequirementKey) => { if (req.kind === 'general') return world.getGeneralById(req.id); if (req.kind === 'city') return world.getCityById(req.id); if (req.kind === 'nation') return world.getNationById(req.id); if (req.kind === 'generalList') return world.listGenerals(); if (req.kind === 'nationList') return world.listNations(); if (req.kind === 'env') return constraintEnv[req.key] ?? null; return null; }, }; const constraints = definition.buildConstraints(ctx, parsedArgs as never); const result = evaluateConstraints(constraints, ctx, view); console.log('[DEBUG] che_징병 constraint result:', result); for (const constraint of constraints) { const single = evaluateConstraints([constraint], ctx, view); if (single.kind !== 'allow') { console.log('[DEBUG] constraint blocked:', constraint.name, single); } } const generalFallback = generalDefinitions.get('휴식')!; const nationFallback = nationDefinitions.get('휴식')!; const ai = new GeneralAI({ general, city: city ?? undefined, nation, world: state, worldRef: world, reservedTurnProvider: reservedTurnStore, scenarioConfig: snapshot.scenarioConfig, scenarioMeta: snapshot.scenarioMeta, map: LARGE_TEST_MAP, unitSet: snapshot.unitSet, commandEnv, generalDefinitions, nationDefinitions, generalFallback, nationFallback, }); console.log('[DEBUG] can 징병:', ai.generalPolicy.can('징병')); console.log('[DEBUG] priority includes 징병:', ai.generalPolicy.priority.includes('징병')); console.log('[DEBUG] do징병 candidate:', do징병(ai)); }; try { await runUntil( (current) => current.currentYear > 183 || (current.currentYear === 183 && current.currentMonth >= 7), undefined, (current) => { const key = toKey(current.currentYear, current.currentMonth); const checker = targetChecks.get(key); if (checker) { checker(); } } ); } catch (error) { const lastAiTrace = [...turnTraces].reverse().find((trace) => trace.aiState); if (lastAiTrace?.aiState) { console.log('[DEBUG] last aiState:', lastAiTrace.aiState); } const debugGeneralId = lastAiTrace?.generalId ?? world.listGenerals()[0]?.id; if (debugGeneralId) { await debugRecruitConstraints(debugGeneralId); } dumpTraceSummary('NPC 대형 시뮬레이션 실패', 200); debug.dumpWorldSummary('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); debug.dumpNation(sampleNation.id, 'NPC 샘플 국가'); 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, }); debug.dumpCity(sampleGeneral.cityId, 'NPC 샘플 도시'); } 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; } }); });