From 377f8cae5bbcb0209d3338c69e6233bbda0e1ec4 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Sat, 24 Jan 2026 09:41:09 +0000 Subject: [PATCH] =?UTF-8?q?feat:=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=EB=B0=8F=20NPC=20=EA=B5=AD=EA=B0=80=20?= =?UTF-8?q?=EC=84=B1=EC=9E=A5=20=EC=8B=9C=EB=AE=AC=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-engine/test/fixtures/largeTestMap.ts | 249 +++++++++++++ .../test/npcNationGrowthScenario.test.ts | 334 ++++++++++++++++++ 2 files changed, 583 insertions(+) create mode 100644 app/game-engine/test/fixtures/largeTestMap.ts create mode 100644 app/game-engine/test/npcNationGrowthScenario.test.ts diff --git a/app/game-engine/test/fixtures/largeTestMap.ts b/app/game-engine/test/fixtures/largeTestMap.ts new file mode 100644 index 0000000..4a22779 --- /dev/null +++ b/app/game-engine/test/fixtures/largeTestMap.ts @@ -0,0 +1,249 @@ +import type { City, MapDefinition } from '@sammo-ts/logic'; + +export const LARGE_TEST_MAP: MapDefinition = { + id: 'large_test_map', + name: '대형 테스트 맵', + cities: [ + { + id: 1, + name: '소성A', + level: 4, + region: 1, + position: { x: 10, y: 10 }, + connections: [2, 3], + max: { + population: 20000, + agriculture: 2000, + commerce: 2000, + security: 2000, + defence: 1000, + wall: 1000, + }, + initial: { + population: 10000, + agriculture: 1000, + commerce: 1000, + security: 1000, + defence: 500, + wall: 500, + }, + }, + { + id: 2, + name: '중성B', + level: 5, + region: 1, + position: { x: 20, y: 10 }, + connections: [1, 4], + max: { + population: 24000, + agriculture: 2200, + commerce: 2200, + security: 2200, + defence: 1100, + wall: 1100, + }, + initial: { + population: 12000, + agriculture: 1100, + commerce: 1100, + security: 1100, + defence: 550, + wall: 550, + }, + }, + { + id: 3, + name: '대성C', + level: 6, + region: 2, + position: { x: 10, y: 20 }, + connections: [1, 5], + max: { + population: 30000, + agriculture: 2500, + commerce: 2500, + security: 2500, + defence: 1200, + wall: 1200, + }, + initial: { + population: 15000, + agriculture: 1200, + commerce: 1200, + security: 1200, + defence: 600, + wall: 600, + }, + }, + { + id: 4, + name: '특성D', + level: 7, + region: 2, + position: { x: 30, y: 10 }, + connections: [2, 5], + max: { + population: 32000, + agriculture: 2600, + commerce: 2600, + security: 2600, + defence: 1300, + wall: 1300, + }, + initial: { + population: 16000, + agriculture: 1300, + commerce: 1300, + security: 1300, + defence: 650, + wall: 650, + }, + }, + { + id: 5, + name: '소성E', + level: 4, + region: 3, + position: { x: 20, y: 20 }, + connections: [3, 4, 6], + max: { + population: 20000, + agriculture: 2000, + commerce: 2000, + security: 2000, + defence: 1000, + wall: 1000, + }, + initial: { + population: 10000, + agriculture: 1000, + commerce: 1000, + security: 1000, + defence: 500, + wall: 500, + }, + }, + { + id: 6, + name: '중성F', + level: 5, + region: 3, + position: { x: 30, y: 20 }, + connections: [5, 7], + max: { + population: 24000, + agriculture: 2200, + commerce: 2200, + security: 2200, + defence: 1100, + wall: 1100, + }, + initial: { + population: 12000, + agriculture: 1100, + commerce: 1100, + security: 1100, + defence: 550, + wall: 550, + }, + }, + { + id: 7, + name: '대성G', + level: 6, + region: 4, + position: { x: 40, y: 20 }, + connections: [6, 8], + max: { + population: 30000, + agriculture: 2500, + commerce: 2500, + security: 2500, + defence: 1200, + wall: 1200, + }, + initial: { + population: 15000, + agriculture: 1200, + commerce: 1200, + security: 1200, + defence: 600, + wall: 600, + }, + }, + { + id: 8, + name: '특성H', + level: 7, + region: 4, + position: { x: 50, y: 20 }, + connections: [7, 9], + max: { + population: 32000, + agriculture: 2600, + commerce: 2600, + security: 2600, + defence: 1300, + wall: 1300, + }, + initial: { + population: 16000, + agriculture: 1300, + commerce: 1300, + security: 1300, + defence: 650, + wall: 650, + }, + }, + { + id: 9, + name: '소성I', + level: 4, + region: 5, + position: { x: 60, y: 20 }, + connections: [8], + max: { + population: 20000, + agriculture: 2000, + commerce: 2000, + security: 2000, + defence: 1000, + wall: 1000, + }, + initial: { + population: 10000, + agriculture: 1000, + commerce: 1000, + security: 1000, + defence: 500, + wall: 500, + }, + }, + ], + defaults: { trust: 90, trade: 100, supplyState: 1, frontState: 0 }, +}; + +export const buildLargeTestCities = (): City[] => + LARGE_TEST_MAP.cities.map((city) => ({ + id: city.id, + name: city.name, + nationId: 0, + level: city.level, + state: 0, + population: city.initial.population, + populationMax: city.max.population, + agriculture: city.initial.agriculture, + agricultureMax: city.max.agriculture, + commerce: city.initial.commerce, + commerceMax: city.max.commerce, + security: city.initial.security, + securityMax: city.max.security, + supplyState: 1, + frontState: 0, + defence: city.initial.defence, + defenceMax: city.max.defence, + wall: city.initial.wall, + wallMax: city.max.wall, + meta: { trust: 95 }, + })); diff --git a/app/game-engine/test/npcNationGrowthScenario.test.ts b/app/game-engine/test/npcNationGrowthScenario.test.ts new file mode 100644 index 0000000..0bbba1f --- /dev/null +++ b/app/game-engine/test/npcNationGrowthScenario.test.ts @@ -0,0 +1,334 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { TurnSchedule, UnitSetDefinition } 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'; +import { createReservedTurnHandler } from '../src/turn/reservedTurnHandler.js'; +import { InMemoryTurnProcessor } from '../src/turn/inMemoryTurnProcessor.js'; +import { createIncomeHandler } from '../src/turn/incomeHandler.js'; +import { LARGE_TEST_MAP, buildLargeTestCities } from './fixtures/largeTestMap.js'; + +const mockDate = new Date('0179-08-01T00:00:00Z'); + +const createMockPrisma = (initialGeneralRows: any[] = []) => { + let generalRows = [...initialGeneralRows]; + return { + generalTurn: { + findMany: vi.fn(async ({ where } = {}) => { + if (where?.generalId) { + return generalRows + .filter((row) => row.generalId === where.generalId) + .sort((a, b) => a.turnIdx - b.turnIdx); + } + return generalRows; + }), + deleteMany: vi.fn(async ({ where } = {}) => { + if (where?.generalId) { + generalRows = generalRows.filter((row) => row.generalId !== where.generalId); + } + return { count: 0 }; + }), + createMany: vi.fn(async ({ data }) => { + if (Array.isArray(data)) { + generalRows.push(...data); + } + return { count: data.length }; + }), + }, + nationTurn: { + findMany: vi.fn(async () => []), + deleteMany: vi.fn(async () => ({ count: 0 })), + createMany: vi.fn(async () => ({ count: 0 })), + }, + }; +}; + +const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000); + +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: {}, + 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 }, + 3 + ) + ); + } + + const cities = buildLargeTestCities(); + + 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, + }, + 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 }, + }; + + const schedule: TurnSchedule = { + entries: [{ startMinute: 0, tickMinutes: 10 }], + }; + + const mockPrisma = createMockPrisma(); + const reservedTurnStore = new InMemoryReservedTurnStore(mockPrisma as any, { + maxGeneralTurns: 10, + maxNationTurns: 10, + }); + await reservedTurnStore.loadAll(); + + const wrapper = { world: null as InMemoryTurnWorld | null }; + + const handler = await createReservedTurnHandler({ + reservedTurns: reservedTurnStore, + scenarioConfig: snapshot.scenarioConfig, + scenarioMeta: snapshot.scenarioMeta, + map: LARGE_TEST_MAP as any, + unitSet: snapshot.unitSet, + getWorld: () => wrapper.world, + }); + + const incomeHandler = createIncomeHandler({ + getWorld: () => wrapper.world, + scenarioConfig: snapshot.scenarioConfig, + nationTraits: new Map(), + }); + + const world = new InMemoryTurnWorld(state, snapshot, { + schedule, + generalTurnHandler: handler, + calendarHandler: incomeHandler, + }); + wrapper.world = world; + + const processor = new InMemoryTurnProcessor(world, { tickMinutes: 10 }); + const checkpointGoldByGeneral = new Map(); + + const runOneMonth = async () => { + const target = addMinutes(world.getState().lastTurnTime, 10); + await processor.run(target, { + budgetMs: 10000, + maxGenerals: 100000, + catchUpCap: 1, + }); + }; + + 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).toBe(targetCount); + } + }; + + const assertNationRecruitCount = (minRecruit: number) => { + const nations = world.listNations().filter((nation) => nation.level >= 1 && nation.capitalCityId); + const generals = world.listGenerals(); + for (const nation of nations) { + const recruited = generals.filter( + (general) => general.nationId === nation.id && general.crew > 0 && general.crewTypeId > 0 + ); + expect(recruited.length).toBeGreaterThanOrEqual(minRecruit); + } + }; + + 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 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-10', () => assertNationRecruitCount(5)], + ]); + + 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(); + } + if (currentYear > 182 || (currentYear === 182 && currentMonth >= 10)) { + break; + } + } + }); +});