From 753abdaf47db6ed313d569547cb1b7088d5b6b93 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Sat, 24 Jan 2026 10:06:55 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20NPC=20=EC=84=B8=EA=B8=88=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=ED=95=B8=EB=93=A4=EB=9F=AC=20=EC=B6=94=EA=B0=80,=20?= =?UTF-8?q?=EB=8C=80=ED=98=95=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=A7=B5=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-engine/src/turn/ai/generalAi.ts | 6 +- .../src/turn/ai/generalAiGeneralActions.ts | 2 +- app/game-engine/src/turn/npcTaxHandler.ts | 99 +++++++++++++++++++ .../src/turn/reservedTurnHandler.ts | 26 +++++ app/game-engine/test/fixtures/largeTestMap.ts | 66 ++++++------- .../test/npcNationGrowthScenario.test.ts | 16 ++- .../src/actions/turn/general/che_거병.ts | 10 ++ 7 files changed, 185 insertions(+), 40 deletions(-) create mode 100644 app/game-engine/src/turn/npcTaxHandler.ts diff --git a/app/game-engine/src/turn/ai/generalAi.ts b/app/game-engine/src/turn/ai/generalAi.ts index d4b0066..63f2f5d 100644 --- a/app/game-engine/src/turn/ai/generalAi.ts +++ b/app/game-engine/src/turn/ai/generalAi.ts @@ -387,9 +387,11 @@ export class GeneralAI { } if (this.general.npcState >= 2 && this.general.officerLevel === 12 && !this.nation?.capitalCityId) { + const worldMeta = asRecord(this.world.meta); + const initYear = readNumber(worldMeta.initYear, this.scenarioMeta?.startYear ?? this.startYear); + const initMonth = readNumber(worldMeta.initMonth, 1); const relYearMonth = - joinYearMonth(this.world.currentYear, this.world.currentMonth) - - joinYearMonth(this.scenarioMeta?.startYear ?? this.startYear, 1); + joinYearMonth(this.world.currentYear, this.world.currentMonth) - joinYearMonth(initYear, initMonth); if (relYearMonth > 1) { const establish = generalActionHandlers['건국']?.(this); if (establish) { diff --git a/app/game-engine/src/turn/ai/generalAiGeneralActions.ts b/app/game-engine/src/turn/ai/generalAiGeneralActions.ts index 8d510ae..514a5eb 100644 --- a/app/game-engine/src/turn/ai/generalAiGeneralActions.ts +++ b/app/game-engine/src/turn/ai/generalAiGeneralActions.ts @@ -298,7 +298,7 @@ export const do징병 = (ai: GeneralAI) => { if (!city || !nation || !ai.unitSet || !ai.map) { return null; } - if ([0, 1].includes(ai.dipState)) { + if ([0, 1].includes(ai.dipState) && ai.general.npcState < 2) { return null; } if (!(ai.genType & t통솔장)) { diff --git a/app/game-engine/src/turn/npcTaxHandler.ts b/app/game-engine/src/turn/npcTaxHandler.ts new file mode 100644 index 0000000..b2c9748 --- /dev/null +++ b/app/game-engine/src/turn/npcTaxHandler.ts @@ -0,0 +1,99 @@ +import type { TurnCalendarContext, TurnCalendarHandler, InMemoryTurnWorld } from './inMemoryWorld.js'; +import type { City, Nation } from '@sammo-ts/logic'; +import { joinYearMonth, readNumber } from './ai/aiUtils.js'; + +const calcNationDevelopedRate = (cities: City[]): { pop: number; all: number } => { + if (cities.length === 0) { + return { pop: 0, all: 0 }; + } + let pop = 0; + let agri = 0; + let comm = 0; + let secu = 0; + let def = 0; + let wall = 0; + for (const city of cities) { + pop += city.populationMax > 0 ? city.population / city.populationMax : 0; + agri += city.agricultureMax > 0 ? city.agriculture / city.agricultureMax : 0; + comm += city.commerceMax > 0 ? city.commerce / city.commerceMax : 0; + secu += city.securityMax > 0 ? city.security / city.securityMax : 0; + def += city.defenceMax > 0 ? city.defence / city.defenceMax : 0; + wall += city.wallMax > 0 ? city.wall / city.wallMax : 0; + } + const count = cities.length; + pop /= count; + agri /= count; + comm /= count; + secu /= count; + def /= count; + wall /= count; + const all = (pop + agri + comm + secu + def + wall) / 6; + return { pop, all }; +}; + +const resolveNpcTaxRate = (cities: City[]): number => { + if (cities.length === 0) { + return 15; + } + const devRate = calcNationDevelopedRate(cities); + const avg = (devRate.pop + devRate.all) / 2; + if (avg > 0.95) { + return 25; + } + if (avg > 0.7) { + return 20; + } + if (avg > 0.5) { + return 15; + } + return 10; +}; + +const shouldUpdateRate = (month: number): boolean => month === 6 || month === 12; + +const isNpcMonarchNation = (nation: Nation, world: InMemoryTurnWorld): boolean => { + if (!nation.capitalCityId || !nation.chiefGeneralId) { + return false; + } + const chief = world.getGeneralById(nation.chiefGeneralId); + return Boolean(chief && chief.npcState >= 2); +}; + +export const createNpcTaxHandler = (options: { getWorld: () => InMemoryTurnWorld | null }): TurnCalendarHandler => { + return { + onMonthChanged: (context: TurnCalendarContext) => { + if (!shouldUpdateRate(context.currentMonth)) { + return; + } + const world = options.getWorld(); + if (!world) { + return; + } + const worldState = world.getState(); + const meta = worldState.meta ?? {}; + const initYear = readNumber(meta.initYear, NaN); + const initMonth = readNumber(meta.initMonth, NaN); + const hasInit = Number.isFinite(initYear) && Number.isFinite(initMonth); + const monthsSinceStart = hasInit + ? joinYearMonth(context.currentYear, context.currentMonth) - joinYearMonth(initYear, initMonth) + : null; + const cities = world.listCities(); + for (const nation of world.listNations()) { + if (!isNpcMonarchNation(nation, world)) { + continue; + } + const nationCities = cities.filter((city) => city.nationId === nation.id && city.supplyState > 0); + let rate = resolveNpcTaxRate(nationCities); + if (monthsSinceStart !== null && monthsSinceStart <= 4) { + rate = Math.min(rate, 10); + } + world.updateNation(nation.id, { + meta: { + ...nation.meta, + rate, + }, + }); + } + }, + }; +}; diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 6948171..e7d0606 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -351,6 +351,32 @@ const resolveDefinition = ( fallback: GeneralActionDefinition ): GeneralActionDefinition => definitions.get(actionKey) ?? fallback; +const resolveNpcTaxRate = (cities: City[]): number => { + if (cities.length === 0) { + return 15; + } + let pop = 0; + let all = 0; + for (const city of cities) { + const popRatio = city.populationMax > 0 ? city.population / city.populationMax : 0; + pop += popRatio; + all += calcCityDevRatio(city); + } + pop /= cities.length; + all /= cities.length; + const avg = (pop + all) / 2; + if (avg > 0.95) { + return 25; + } + if (avg > 0.7) { + return 20; + } + if (avg > 0.5) { + return 15; + } + return 10; +}; + export const createReservedTurnHandler = async (options: { reservedTurns: InMemoryReservedTurnStore; scenarioConfig: ScenarioConfig; diff --git a/app/game-engine/test/fixtures/largeTestMap.ts b/app/game-engine/test/fixtures/largeTestMap.ts index 4a22779..1030c15 100644 --- a/app/game-engine/test/fixtures/largeTestMap.ts +++ b/app/game-engine/test/fixtures/largeTestMap.ts @@ -7,7 +7,7 @@ export const LARGE_TEST_MAP: MapDefinition = { { id: 1, name: '소성A', - level: 4, + level: 5, region: 1, position: { x: 10, y: 10 }, connections: [2, 3], @@ -20,18 +20,18 @@ export const LARGE_TEST_MAP: MapDefinition = { wall: 1000, }, initial: { - population: 10000, - agriculture: 1000, - commerce: 1000, - security: 1000, - defence: 500, - wall: 500, + population: 6000, + agriculture: 600, + commerce: 600, + security: 600, + defence: 300, + wall: 300, }, }, { id: 2, name: '중성B', - level: 5, + level: 6, region: 1, position: { x: 20, y: 10 }, connections: [1, 4], @@ -44,18 +44,18 @@ export const LARGE_TEST_MAP: MapDefinition = { wall: 1100, }, initial: { - population: 12000, - agriculture: 1100, - commerce: 1100, - security: 1100, - defence: 550, - wall: 550, + population: 7200, + agriculture: 660, + commerce: 660, + security: 660, + defence: 330, + wall: 330, }, }, { id: 3, name: '대성C', - level: 6, + level: 7, region: 2, position: { x: 10, y: 20 }, connections: [1, 5], @@ -79,7 +79,7 @@ export const LARGE_TEST_MAP: MapDefinition = { { id: 4, name: '특성D', - level: 7, + level: 8, region: 2, position: { x: 30, y: 10 }, connections: [2, 5], @@ -103,7 +103,7 @@ export const LARGE_TEST_MAP: MapDefinition = { { id: 5, name: '소성E', - level: 4, + level: 5, region: 3, position: { x: 20, y: 20 }, connections: [3, 4, 6], @@ -116,18 +116,18 @@ export const LARGE_TEST_MAP: MapDefinition = { wall: 1000, }, initial: { - population: 10000, - agriculture: 1000, - commerce: 1000, - security: 1000, - defence: 500, - wall: 500, + population: 6000, + agriculture: 600, + commerce: 600, + security: 600, + defence: 300, + wall: 300, }, }, { id: 6, name: '중성F', - level: 5, + level: 6, region: 3, position: { x: 30, y: 20 }, connections: [5, 7], @@ -140,18 +140,18 @@ export const LARGE_TEST_MAP: MapDefinition = { wall: 1100, }, initial: { - population: 12000, - agriculture: 1100, - commerce: 1100, - security: 1100, - defence: 550, - wall: 550, + population: 7200, + agriculture: 660, + commerce: 660, + security: 660, + defence: 330, + wall: 330, }, }, { id: 7, name: '대성G', - level: 6, + level: 7, region: 4, position: { x: 40, y: 20 }, connections: [6, 8], @@ -175,7 +175,7 @@ export const LARGE_TEST_MAP: MapDefinition = { { id: 8, name: '특성H', - level: 7, + level: 8, region: 4, position: { x: 50, y: 20 }, connections: [7, 9], @@ -199,7 +199,7 @@ export const LARGE_TEST_MAP: MapDefinition = { { id: 9, name: '소성I', - level: 4, + level: 5, region: 5, position: { x: 60, y: 20 }, connections: [8], diff --git a/app/game-engine/test/npcNationGrowthScenario.test.ts b/app/game-engine/test/npcNationGrowthScenario.test.ts index 0bbba1f..9083174 100644 --- a/app/game-engine/test/npcNationGrowthScenario.test.ts +++ b/app/game-engine/test/npcNationGrowthScenario.test.ts @@ -6,6 +6,8 @@ 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 { createNpcTaxHandler } from '../src/turn/npcTaxHandler.js'; +import { composeCalendarHandlers } from '../src/turn/calendarHandlers.js'; import { LARGE_TEST_MAP, buildLargeTestCities } from './fixtures/largeTestMap.js'; const mockDate = new Date('0179-08-01T00:00:00Z'); @@ -108,7 +110,7 @@ describe('NPC 대형 시뮬레이션', () => { generals.length + 1, cityId, { leadership: 75, strength: 10, intelligence: 75 }, - 3 + 2 ) ); } @@ -176,7 +178,7 @@ describe('NPC 대형 시뮬레이션', () => { currentMonth: 8, tickSeconds: 600, lastTurnTime: mockDate, - meta: { seed: 1 }, + meta: { seed: 1, initYear: 179, initMonth: 8 }, }; const schedule: TurnSchedule = { @@ -207,10 +209,16 @@ describe('NPC 대형 시뮬레이션', () => { nationTraits: new Map(), }); + const npcTaxHandler = createNpcTaxHandler({ + getWorld: () => wrapper.world, + }); + + const calendarHandler = composeCalendarHandlers(incomeHandler, npcTaxHandler); + const world = new InMemoryTurnWorld(state, snapshot, { schedule, generalTurnHandler: handler, - calendarHandler: incomeHandler, + calendarHandler, }); wrapper.world = world; @@ -266,7 +274,7 @@ describe('NPC 대형 시뮬레이션', () => { const generals = world.listGenerals(); for (const nation of nations) { const nationGenerals = generals.filter((general) => general.nationId === nation.id); - expect(nationGenerals.length).toBe(targetCount); + expect(nationGenerals.length).toBeGreaterThanOrEqual(targetCount); } }; diff --git a/packages/logic/src/actions/turn/general/che_거병.ts b/packages/logic/src/actions/turn/general/che_거병.ts index 41c49c0..4786028 100644 --- a/packages/logic/src/actions/turn/general/che_거병.ts +++ b/packages/logic/src/actions/turn/general/che_거병.ts @@ -61,6 +61,15 @@ export class ActionDefinition< nationName = '㉥' + nationName; } + const npcNationPolicy = + general.npcState >= 2 + ? { + values: { + minNPCRecruitCityPopulation: 0, + }, + } + : undefined; + const newNation: Nation = { id: newNationId, name: nationName, @@ -79,6 +88,7 @@ export class ActionDefinition< surlimit: 72, secretlimit: 3, gennum: 1, + ...(npcNationPolicy ? { npc_nation_policy: npcNationPolicy } : {}), }, };