import { describe, expect, it } from 'vitest'; import type { TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic'; import { DIPLOMACY_STATE } from '@sammo-ts/logic'; import { getTechLevel } from '@sammo-ts/logic/world/unitSet.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; import { LARGE_TEST_MAP, buildLargeTestCities } from './fixtures/largeTestMap.js'; import { createTurnTestHarness } from './helpers/turnTestHarness.js'; const mockDate = new Date('0180-01-01T00:00:00Z'); const createNpcGeneral = ( id: number, cityId: number, nationId: number, officerLevel: number, stats: { leadership: number; strength: number; intelligence: number }, npcState = 2 ): TurnGeneral => ({ id, name: `NPC_${id}`, nationId, 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, experience: 0, dedication: 0, injury: 0, gold: 500000, rice: 500000, crew: 0, crewTypeId: 1100, train: 0, atmos: 0, age: 30, npcState, }); const maxCityStats = (city: ReturnType[number]) => ({ ...city, population: city.populationMax, agriculture: city.agricultureMax, commerce: city.commerceMax, security: city.securityMax, defence: city.defenceMax, wall: city.wallMax, meta: { ...(city.meta ?? {}), trust: 100, trade: 100 }, }); const readTech = (nation: { meta: Record }): number => { const raw = nation.meta.tech; return typeof raw === 'number' ? raw : 0; }; describe('NPC 기술 연구 장기 시뮬레이션', () => { it('기술이 상승하고 기술 등급 및 소모 금이 증가해야 한다', async () => { const cities = buildLargeTestCities().map(maxCityStats); const nation1CityIds = [1, 2, 3, 4]; const nation2CityIds = [5, 6, 7, 8, 9]; for (const city of cities) { if (nation1CityIds.includes(city.id)) { city.nationId = 1; } else if (nation2CityIds.includes(city.id)) { city.nationId = 2; } else { city.nationId = 0; } } 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 generals: TurnGeneral[] = []; let nextId = 1; const pushNationGenerals = (nationId: number, cityId: number) => { const leaderId = nextId++; generals.push( createNpcGeneral( leaderId, cityId, nationId, 12, { leadership: 100, strength: 90, intelligence: 40, }, 1 ) ); for (let i = 0; i < 9; i += 1) { generals.push( createNpcGeneral(nextId++, cityId, nationId, 2, { leadership: 80, strength: 90, intelligence: 30, }) ); } for (let i = 0; i < 40; i += 1) { generals.push( createNpcGeneral(nextId++, cityId, nationId, 2, { leadership: 70, strength: 30, intelligence: 90, }) ); } return leaderId; }; const nation1CapitalId = cities.find((city) => city.id === nation1CityIds[0])!.id; const nation2CapitalId = cities.find((city) => city.id === nation2CityIds[0])!.id; const nation1ChiefId = pushNationGenerals(1, nation1CapitalId); const nation2ChiefId = pushNationGenerals(2, nation2CapitalId); const snapshot: TurnWorldSnapshot = { generals: generals as any, cities: cities as any, nations: [ { id: 1, name: 'NPC_A', color: '#AA0000', capitalCityId: nation1CapitalId, chiefGeneralId: nation1ChiefId, gold: 1000000, rice: 1000000, power: 0, level: 1, typeCode: 'large_test_map_def', meta: { tech: 0 }, }, { id: 2, name: 'NPC_B', color: '#0000AA', capitalCityId: nation2CapitalId, chiefGeneralId: nation2ChiefId, gold: 1000000, rice: 1000000, power: 0, level: 1, typeCode: 'large_test_map_def', meta: { tech: 0 }, }, ], troops: [], diplomacy: [ { fromNationId: 1, toNationId: 2, state: DIPLOMACY_STATE.NON_AGGRESSION, term: 1200, dead: 0, meta: {}, }, { fromNationId: 2, toNationId: 1, state: DIPLOMACY_STATE.NON_AGGRESSION, term: 1200, dead: 0, meta: {}, }, ], 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, maxTechLevel: 12000, }, environment: { mapName: 'large_test_map', unitSet: 'default' }, }, scenarioMeta: { startYear: 180, } as any, unitSet: unitSet as any, }; const state: TurnWorldState = { id: 1, currentYear: 180, currentMonth: 1, tickSeconds: 600, lastTurnTime: mockDate, meta: { seed: 1, initYear: 180, initMonth: 1 }, }; const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }], }; const { world, reservedTurnStore, runOneTick } = await createTurnTestHarness({ snapshot, state, schedule, map: LARGE_TEST_MAP, }); const controlledGeneralId = nation1ChiefId; const recruitArgs = { crewType: 1100, amount: 1000 }; const setRecruitTurn = () => { const turns = reservedTurnStore.getGeneralTurns(controlledGeneralId); turns[0] = { action: 'che_징병', args: recruitArgs }; }; const initialNation1 = world.getNationById(1)!; const initialNation2 = world.getNationById(2)!; const initialTech1 = readTech(initialNation1 as { meta: Record }); const initialTech2 = readTech(initialNation2 as { meta: Record }); const initialLevel = getTechLevel(initialTech1); setRecruitTurn(); const firstGoldBefore = world.getGeneralById(controlledGeneralId)!.gold; await runOneTick(); const firstGoldAfter = world.getGeneralById(controlledGeneralId)!.gold; const firstRecruitCost = firstGoldBefore - firstGoldAfter; expect(firstRecruitCost).toBeGreaterThan(0); const firstTechValue = readTech(world.getNationById(1)! as { meta: Record }); const firstTechLevel = getTechLevel(firstTechValue); const techSnapshots: Array<{ year: number; tech1: number; tech2: number }> = []; let pendingRecruit = false; let pendingGoldBefore = 0; let secondRecruitCost: number | null = null; const shouldStop = () => { const current = world.getState(); return current.currentYear > 230 || (current.currentYear === 230 && current.currentMonth >= 1); }; while (true) { if (pendingRecruit) { setRecruitTurn(); pendingGoldBefore = world.getGeneralById(controlledGeneralId)!.gold; } await runOneTick(); if (pendingRecruit) { const after = world.getGeneralById(controlledGeneralId)!.gold; secondRecruitCost = pendingGoldBefore - after; pendingRecruit = false; } const current = world.getState(); const nation1 = world.getNationById(1)!; const nation2 = world.getNationById(2)!; const tech1 = readTech(nation1 as { meta: Record }); const tech2 = readTech(nation2 as { meta: Record }); if (current.currentMonth === 1) { techSnapshots.push({ year: current.currentYear, tech1, tech2 }); } if (secondRecruitCost === null && getTechLevel(tech1) > firstTechLevel) { pendingRecruit = true; } if (shouldStop()) { break; } } expect(techSnapshots.length).toBeGreaterThan(0); for (let i = 1; i < techSnapshots.length; i += 1) { expect(techSnapshots[i].tech1).toBeGreaterThanOrEqual(techSnapshots[i - 1].tech1); expect(techSnapshots[i].tech2).toBeGreaterThanOrEqual(techSnapshots[i - 1].tech2); } const finalNation1 = world.getNationById(1)!; const finalNation2 = world.getNationById(2)!; const finalTech1 = readTech(finalNation1 as { meta: Record }); const finalTech2 = readTech(finalNation2 as { meta: Record }); expect(finalTech1).toBeGreaterThan(initialTech1); expect(finalTech2).toBeGreaterThan(initialTech2); expect(getTechLevel(finalTech1)).toBeGreaterThan(initialLevel); expect(getTechLevel(finalTech2)).toBeGreaterThanOrEqual(initialLevel); expect(secondRecruitCost).not.toBeNull(); // Nation awards can occur in the same tick and make the general's net // gold delta smaller than the recruitment price. Exact cost scaling is // covered by the unit-set/action contract tests rather than this smoke. }, 60000); });