From c9ab8e425acdc5db3b308254b4f4acd40a2ef2e1 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Sat, 24 Jan 2026 09:11:15 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20NPC=20=EC=9D=BC=EB=B0=98=20=EB=82=B4?= =?UTF-8?q?=EC=A0=95=20=ED=84=B4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EC=98=88=EC=95=BD=20=ED=84=B4=20?= =?UTF-8?q?=ED=95=B8=EB=93=A4=EB=9F=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test/npcGeneralDomesticTurn.test.ts | 299 ++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 app/game-engine/test/npcGeneralDomesticTurn.test.ts diff --git a/app/game-engine/test/npcGeneralDomesticTurn.test.ts b/app/game-engine/test/npcGeneralDomesticTurn.test.ts new file mode 100644 index 0000000..ddf6ad4 --- /dev/null +++ b/app/game-engine/test/npcGeneralDomesticTurn.test.ts @@ -0,0 +1,299 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { TurnSchedule } 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'; + +const mockDate = new Date('0189-01-01T00:00:00Z'); + +const MINIMAL_MAP = { + id: 'npc_domestic_map', + name: 'NPC내정맵', + cities: [ + { + id: 1, + name: '소성A', + level: 1, + region: 1, + position: { x: 10, y: 10 }, + connections: [2], + 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: 1, + region: 2, + position: { x: 20, y: 10 }, + connections: [1], + 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: 50, trade: 100, supplyState: 1, frontState: 0 }, +}; + +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); + +describe('NPC 일반 내정 턴', () => { + it('예약 턴이 없어도 내정 수치가 증가한다', async () => { + const generals: TurnGeneral[] = [ + { + id: 1, + name: 'NPC_무장', + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 75, strength: 75, intelligence: 15 }, + 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: 999 }, + officerLevel: 4, + experience: 0, + dedication: 0, + injury: 0, + gold: 0, + rice: 0, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + age: 30, + npcState: 2, + }, + ]; + + const cities = [ + { + id: 1, + name: '소성A', + nationId: 1, + level: 1, + state: 0, + population: 10000, + populationMax: 20000, + agriculture: 1000, + agricultureMax: 2000, + commerce: 1000, + commerceMax: 2000, + security: 1000, + securityMax: 2000, + supplyState: 1, + frontState: 0, + defence: 500, + defenceMax: 1000, + wall: 500, + wallMax: 1000, + meta: { trust: 98 }, + }, + { + id: 2, + name: '중성B', + nationId: 2, + level: 1, + state: 0, + population: 12000, + populationMax: 20000, + agriculture: 1200, + agricultureMax: 2000, + commerce: 1200, + commerceMax: 2000, + security: 1200, + securityMax: 2000, + supplyState: 1, + frontState: 0, + defence: 600, + defenceMax: 1000, + wall: 600, + wallMax: 1000, + meta: { trust: 98 }, + }, + ]; + + const nations = [ + { + id: 1, + name: 'NPC국가', + color: '#FF0000', + capitalCityId: 1, + chiefGeneralId: 1, + gold: 50000, + rice: 50000, + power: 0, + level: 1, + typeCode: 'che_def', + meta: {}, + }, + { + id: 2, + name: '경쟁국', + color: '#0000FF', + capitalCityId: 2, + chiefGeneralId: null, + gold: 50000, + rice: 50000, + power: 0, + level: 1, + typeCode: 'che_def', + meta: {}, + }, + ]; + + const snapshot: TurnWorldSnapshot = { + generals: generals as any, + cities: cities as any, + nations: nations as any, + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + map: MINIMAL_MAP as any, + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: 'npc_domestic_map', unitSet: 'default' }, + }, + scenarioMeta: { + startYear: 189, + } as any, + unitSet: {} as any, + }; + + const state: TurnWorldState = { + id: 1, + currentYear: 189, + currentMonth: 1, + 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: MINIMAL_MAP as any, + unitSet: snapshot.unitSet, + getWorld: () => wrapper.world, + }); + + const world = new InMemoryTurnWorld(state, snapshot, { + schedule, + generalTurnHandler: handler, + }); + wrapper.world = world; + + const beforeCity = world.getCityById(1)!; + const beforeStats = { + population: beforeCity.population, + agriculture: beforeCity.agriculture, + commerce: beforeCity.commerce, + security: beforeCity.security, + defence: beforeCity.defence, + wall: beforeCity.wall, + }; + + const processor = new InMemoryTurnProcessor(world, { tickMinutes: 10 }); + await processor.run(addMinutes(mockDate, 10), { + budgetMs: 1000, + maxGenerals: 10, + catchUpCap: 1, + }); + + const afterCity = world.getCityById(1)!; + const increased = + afterCity.population > beforeStats.population || + afterCity.agriculture > beforeStats.agriculture || + afterCity.commerce > beforeStats.commerce || + afterCity.security > beforeStats.security || + afterCity.defence > beforeStats.defence || + afterCity.wall > beforeStats.wall; + + expect(increased).toBe(true); + expect(world.getGeneralById(1)!.turnTime.getTime()).toBeGreaterThan(mockDate.getTime()); + }); +});