diff --git a/app/game-engine/src/turn/monthlyWarIncomeAction.ts b/app/game-engine/src/turn/monthlyWarIncomeAction.ts new file mode 100644 index 0000000..9733cb4 --- /dev/null +++ b/app/game-engine/src/turn/monthlyWarIncomeAction.ts @@ -0,0 +1,80 @@ +import { asNumber, asRecord } from '@sammo-ts/common'; +import { + createIncomeActionContext, + getWarGoldIncome, + type CityIncomeSource, + type NationIncomeContext, + type NationTraitModule, + type TriggerNationalIncomeType, +} from '@sammo-ts/logic'; + +import type { InMemoryTurnWorld } from './inMemoryWorld.js'; +import type { MonthlyEventActionHandler } from './monthlyEventHandler.js'; + +const toIncomeCity = (city: ReturnType[number]): CityIncomeSource => ({ + id: city.id, + population: city.population, + populationMax: city.populationMax, + agriculture: city.agriculture, + agricultureMax: city.agricultureMax, + commerce: city.commerce, + commerceMax: city.commerceMax, + security: city.security, + securityMax: city.securityMax, + trust: asNumber(city.meta.trust, 50), + supplyState: city.supplyState, + defence: city.defence, + defenceMax: city.defenceMax, + wall: city.wall, + wallMax: city.wallMax, + meta: asRecord(city.meta), +}); + +export const createProcessWarIncomeHandler = (options: { + getWorld: () => InMemoryTurnWorld | null; + nationTraits?: ReadonlyMap; +}): MonthlyEventActionHandler => { + return () => { + const world = options.getWorld(); + if (!world) { + return; + } + + // 레거시는 부상병을 인구로 돌려보내기 전에 각 국가의 공급 도시에서 + // dead/10을 합산한다. 국가 타입의 금 수입 modifier도 도시별로 적용된다. + const cities = world.listCities(); + for (const nation of world.listNations()) { + if (nation.level <= 0) { + continue; + } + const trait = options.nationTraits?.get(nation.typeCode); + const actionContext = createIncomeActionContext(nation); + const modifyIncome = trait?.onCalcNationalIncome + ? (type: TriggerNationalIncomeType, amount: number) => + trait.onCalcNationalIncome!(actionContext, type, amount) + : undefined; + const incomeContext: NationIncomeContext = { + rate: asNumber(nation.meta.rate, 20), + modifyIncome, + }; + const income = getWarGoldIncome( + incomeContext, + cities.filter((city) => city.nationId === nation.id).map(toIncomeCity) + ); + world.updateNation(nation.id, { gold: nation.gold + income }); + } + + // 수입 계산이 끝난 뒤 모든 도시가 부상병 20%를 인구로 회복하고 + // dead를 초기화한다. 레거시는 pop_max로 제한하지 않는다. + for (const city of cities) { + const dead = asNumber(city.meta.dead, 0); + world.updateCity(city.id, { + population: Math.round(city.population + dead * 0.2), + meta: { + ...city.meta, + dead: 0, + }, + }); + } + }; +}; diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 84179c1..ae19cf7 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -49,6 +49,7 @@ import { createRaiseDisasterHandler } from './monthlyDisasterAction.js'; import { createUpdateCitySupplyHandler } from './monthlyCitySupplyAction.js'; import { createUpdateNationLevelHandler } from './monthlyNationLevelAction.js'; import { createProcessSemiAnnualHandler } from './monthlySemiAnnualAction.js'; +import { createProcessWarIncomeHandler } from './monthlyWarIncomeAction.js'; import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js'; export interface TurnDaemonRuntimeOptions { @@ -209,6 +210,13 @@ const createTurnDaemonRuntimeWithLease = async ( nationTraits: nationTraitMap, }) ); + eventActions.set( + 'ProcessWarIncome', + createProcessWarIncomeHandler({ + getWorld: () => worldRef, + nationTraits: nationTraitMap, + }) + ); if (reservedTurnStoreHandle) { eventActions.set( 'UpdateNationLevel', diff --git a/app/game-engine/test/monthlyWarIncomeAction.test.ts b/app/game-engine/test/monthlyWarIncomeAction.test.ts new file mode 100644 index 0000000..62ed0a7 --- /dev/null +++ b/app/game-engine/test/monthlyWarIncomeAction.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest'; +import type { City, Nation, NationTraitModule } from '@sammo-ts/logic'; + +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { createProcessWarIncomeHandler } from '../src/turn/monthlyWarIncomeAction.js'; +import type { TurnEvent, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const buildCity = (id: number, patch: Partial = {}): City => ({ + id, + name: `도시${id}`, + nationId: 1, + level: 1, + state: 0, + population: 1_000, + populationMax: 2_000, + agriculture: 100, + agricultureMax: 200, + commerce: 100, + commerceMax: 200, + security: 100, + securityMax: 200, + supplyState: 1, + frontState: 0, + defence: 100, + defenceMax: 200, + wall: 100, + wallMax: 200, + conflict: {}, + meta: { trust: 50, dead: 0, marker: id }, + ...patch, +}); + +const buildNation = (id: number, patch: Partial = {}): Nation => ({ + id, + name: `국가${id}`, + color: '#777777', + capitalCityId: null, + chiefGeneralId: null, + gold: 1_000, + rice: 1_000, + power: 0, + level: 1, + typeCode: 'che_중립', + meta: {}, + ...patch, +}); + +const goldTrait: NationTraitModule = { + key: 'test-gold', + name: '금 수입 시험', + info: '', + kind: 'nation', + onCalcNationalIncome: (_context, type, amount) => (type === 'gold' ? amount * 1.1 : amount), +}; + +const event: TurnEvent = { + id: 1, + targetCode: 'pre_month', + priority: 9_000, + condition: true, + action: [['ProcessWarIncome']], + meta: {}, +}; + +const buildHarness = () => { + const state: TurnWorldState = { + id: 1, + currentYear: 193, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0193-01-01T00:00:00.000Z'), + meta: {}, + }; + const snapshot: TurnWorldSnapshot = { + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'default' }, + }, + map: { + id: 'test', + name: 'test', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + generals: [], + cities: [ + buildCity(1, { meta: { trust: 50, dead: 101, marker: 1 } }), + buildCity(2, { supplyState: 0, meta: { trust: 50, dead: 999, marker: 2 } }), + buildCity(3, { nationId: 2, meta: { trust: 50, dead: 1_000, marker: 3 } }), + buildCity(4, { nationId: 3, meta: { trust: 50, dead: 105, marker: 4 } }), + buildCity(5, { + nationId: 0, + population: 999, + populationMax: 1_000, + meta: { trust: 50, dead: 10, marker: 5 }, + }), + ], + nations: [ + buildNation(1), + buildNation(2, { level: 0 }), + buildNation(3, { typeCode: goldTrait.key }), + ], + troops: [], + diplomacy: [], + events: [event], + initialEvents: [], + }; + const world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + }); + const handler = createProcessWarIncomeHandler({ + getWorld: () => world, + nationTraits: new Map([[goldTrait.key, goldTrait]]), + }); + return { world, handler }; +}; + +describe('ProcessWarIncome monthly action', () => { + it('credits supplied-city casualties before global recovery and preserves level/type rules', async () => { + const { world, handler } = buildHarness(); + + await handler( + [], + { + year: 193, + month: 1, + startyear: 190, + currentEventID: 1, + turnTime: new Date('0193-01-01T00:00:00.000Z'), + }, + event + ); + + expect(world.getNationById(1)?.gold).toBe(1_010); + expect(world.getNationById(2)?.gold).toBe(1_000); + expect(world.getNationById(3)?.gold).toBe(1_012); + expect(world.listCities().map((city) => [city.id, city.population, city.meta.dead])).toEqual([ + [1, 1_020, 0], + [2, 1_200, 0], + [3, 1_200, 0], + [4, 1_021, 0], + [5, 1_001, 0], + ]); + expect(world.getCityById(1)?.meta.marker).toBe(1); + expect(world.peekDirtyState().logs).toEqual([]); + }); +}); diff --git a/app/game-engine/test/monthlyWarIncomePersistence.integration.test.ts b/app/game-engine/test/monthlyWarIncomePersistence.integration.test.ts new file mode 100644 index 0000000..a6224bc --- /dev/null +++ b/app/game-engine/test/monthlyWarIncomePersistence.integration.test.ts @@ -0,0 +1,199 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { City, Nation } from '@sammo-ts/logic'; +import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; + +import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { createProcessWarIncomeHandler } from '../src/turn/monthlyWarIncomeAction.js'; +import type { TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const nationId = 990_081; +const cityIds = [990_081, 990_082] as const; + +const nation: Nation = { + id: nationId, + name: '전쟁수입저장국', + color: '#777777', + capitalCityId: cityIds[0], + chiefGeneralId: null, + gold: 1_000, + rice: 1_000, + power: 0, + level: 1, + typeCode: 'che_중립', + meta: {}, +}; + +const buildCity = (id: number, supplyState: number, dead: number): City => ({ + id, + name: `전쟁수입저장도시${id}`, + nationId, + level: 1, + state: 0, + population: 1_000, + populationMax: 2_000, + agriculture: 100, + agricultureMax: 200, + commerce: 100, + commerceMax: 200, + security: 100, + securityMax: 200, + supplyState, + frontState: 0, + defence: 100, + defenceMax: 200, + wall: 100, + wallMax: 200, + conflict: {}, + meta: { trust: 50, trade: 100, region: 1, dead }, +}); + +integration('monthly war income database persistence', () => { + let db: GamePrismaClient; + let closeDb: (() => Promise) | undefined; + + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + closeDb = () => connector.disconnect(); + await db.city.deleteMany({ where: { id: { in: [...cityIds] } } }); + await db.nation.deleteMany({ where: { id: nationId } }); + }); + + afterAll(async () => { + await db.city.deleteMany({ where: { id: { in: [...cityIds] } } }); + await db.nation.deleteMany({ where: { id: nationId } }); + await closeDb?.(); + }); + + it('persists supplied-city income before recovering every city casualty pool', async () => { + await db.nation.create({ + data: { + id: nation.id, + name: nation.name, + color: nation.color, + capitalCityId: nation.capitalCityId, + chiefGeneralId: null, + gold: nation.gold, + rice: nation.rice, + tech: 0, + level: nation.level, + typeCode: nation.typeCode, + meta: {}, + }, + }); + const cities = [buildCity(cityIds[0], 1, 101), buildCity(cityIds[1], 0, 999)]; + await db.city.createMany({ + data: cities.map((city) => ({ + id: city.id, + name: city.name, + level: city.level, + nationId: city.nationId, + supplyState: city.supplyState, + frontState: city.frontState, + population: city.population, + populationMax: city.populationMax, + agriculture: city.agriculture, + agricultureMax: city.agricultureMax, + commerce: city.commerce, + commerceMax: city.commerceMax, + security: city.security, + securityMax: city.securityMax, + trust: 50, + trade: 100, + defence: city.defence, + defenceMax: city.defenceMax, + wall: city.wall, + wallMax: city.wallMax, + region: 1, + conflict: {}, + meta: { dead: city.meta.dead }, + })), + }); + const row = await db.worldState.create({ + data: { + scenarioCode: 'monthly-war-income-persistence', + currentYear: 193, + currentMonth: 1, + tickSeconds: 600, + config: {}, + meta: {}, + }, + }); + const state: TurnWorldState = { + id: row.id, + currentYear: 193, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('2026-07-25T00:10:00.000Z'), + meta: {}, + }; + const snapshot: TurnWorldSnapshot = { + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'default' }, + }, + map: { + id: 'test', + name: 'test', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + generals: [], + cities, + nations: [nation], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + }; + const world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + }); + const dbHooks = await createDatabaseTurnHooks(databaseUrl!, world); + + try { + const handler = createProcessWarIncomeHandler({ getWorld: () => world }); + await handler( + [], + { + year: 193, + month: 1, + startyear: 190, + currentEventID: 1, + turnTime: state.lastTurnTime, + }, + { id: 1, targetCode: 'pre_month', priority: 0, condition: true, action: [], meta: {} } + ); + await dbHooks.hooks.flushChanges?.({ + lastTurnTime: state.lastTurnTime.toISOString(), + processedGenerals: 0, + processedTurns: 1, + durationMs: 0, + partial: false, + }); + + expect(await db.nation.findUniqueOrThrow({ where: { id: nationId } })).toMatchObject({ + gold: 1_010, + rice: 1_000, + }); + expect(await db.city.findUniqueOrThrow({ where: { id: cityIds[0] } })).toMatchObject({ + population: 1_020, + meta: { dead: 0 }, + }); + expect(await db.city.findUniqueOrThrow({ where: { id: cityIds[1] } })).toMatchObject({ + population: 1_200, + meta: { dead: 0 }, + }); + } finally { + await dbHooks.close(); + await db.worldState.delete({ where: { id: row.id } }); + } + }); +});