diff --git a/app/game-engine/test/npcNationTechResearch.test.ts b/app/game-engine/test/npcNationTechResearch.test.ts new file mode 100644 index 0000000..26eb8ae --- /dev/null +++ b/app/game-engine/test/npcNationTechResearch.test.ts @@ -0,0 +1,322 @@ +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: {}, + 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(); + expect(secondRecruitCost ?? 0).toBeGreaterThan(firstRecruitCost); + }, 60000); +});