diff --git a/app/game-engine/src/turn/monthlySemiAnnualAction.ts b/app/game-engine/src/turn/monthlySemiAnnualAction.ts new file mode 100644 index 0000000..cf4a811 --- /dev/null +++ b/app/game-engine/src/turn/monthlySemiAnnualAction.ts @@ -0,0 +1,153 @@ +import { asNumber } from '@sammo-ts/common'; +import { createIncomeActionContext, type NationTraitModule } from '@sammo-ts/logic'; + +import type { InMemoryTurnWorld } from './inMemoryWorld.js'; +import type { MonthlyEventActionHandler } from './monthlyEventHandler.js'; + +type SemiAnnualResource = 'gold' | 'rice'; + +const roundLegacyIntegerColumn = (value: number): number => Math.round(value); + +const parseResource = (args: readonly unknown[]): SemiAnnualResource => { + const resource = args[0]; + if (resource !== 'gold' && resource !== 'rice') { + throw new Error('ProcessSemiAnnual requires resource "gold" or "rice".'); + } + return resource; +}; + +const resolveBasePopulationIncrease = (world: InMemoryTurnWorld): number => { + const value = world.getScenarioConfig().const.basePopIncreaseAmount; + return typeof value === 'number' && Number.isFinite(value) ? value : 5_000; +}; + +const resolveNationRate = (meta: Record): number => asNumber(meta.rate, 20); + +const decayDomesticValue = (value: number): number => roundLegacyIntegerColumn(value * 0.99); + +const applyResourceMaintenance = (value: number, ratios: readonly [number, number][]): number => { + if (value <= 1_000) { + return value; + } + for (const [threshold, ratio] of ratios) { + if (value > threshold) { + return roundLegacyIntegerColumn(value * ratio); + } + } + return roundLegacyIntegerColumn(value * 0.99); +}; + +export const createProcessSemiAnnualHandler = (options: { + getWorld: () => InMemoryTurnWorld | null; + nationTraits?: ReadonlyMap; +}): MonthlyEventActionHandler => { + return (args) => { + const world = options.getWorld(); + if (!world) { + return; + } + const resource = parseResource(args); + + // 레거시의 첫 UPDATE는 모든 도시의 사상자를 초기화하고 내정치를 + // 1% 감소시킨다. 각 SQL 문 사이의 정수 column 반올림도 보존한다. + for (const city of world.listCities()) { + world.updateCity(city.id, { + agriculture: decayDomesticValue(city.agriculture), + commerce: decayDomesticValue(city.commerce), + security: decayDomesticValue(city.security), + defence: decayDomesticValue(city.defence), + wall: decayDomesticValue(city.wall), + meta: { + ...city.meta, + dead: 0, + }, + }); + } + + // run()은 같은 이름의 class method가 아니라 전역 popIncrease()를 + // 호출한다. 이 때문에 중립 도시는 내정치 1% 감소를 한 번 더 받는다. + for (const city of world.listCities().filter((candidate) => candidate.nationId === 0)) { + world.updateCity(city.id, { + agriculture: decayDomesticValue(city.agriculture), + commerce: decayDomesticValue(city.commerce), + security: decayDomesticValue(city.security), + defence: decayDomesticValue(city.defence), + wall: decayDomesticValue(city.wall), + meta: { + ...city.meta, + trust: 50, + }, + }); + } + + const basePopulationIncrease = resolveBasePopulationIncrease(world); + for (const nation of world.listNations()) { + const rate = resolveNationRate(nation.meta); + let populationRatio = (30 - rate) / 200; + const trait = options.nationTraits?.get(nation.typeCode); + if (trait?.onCalcNationalIncome) { + populationRatio = trait.onCalcNationalIncome( + createIncomeActionContext(nation), + 'pop', + populationRatio + ); + } + const genericRatio = (20 - rate) / 200; + const trustDiff = 20 - rate; + + for (const city of world + .listCities() + .filter((candidate) => candidate.nationId === nation.id && candidate.supplyState === 1)) { + if (city.securityMax <= 0) { + throw new Error(`ProcessSemiAnnual requires positive securityMax (cityId=${city.id}).`); + } + const securityRatio = city.security / city.securityMax / 10; + const populationFactor = + populationRatio >= 0 + ? 1 + populationRatio * (1 + securityRatio) + : 1 + populationRatio * (1 - securityRatio); + const trust = asNumber(city.meta.trust, 50); + world.updateCity(city.id, { + population: roundLegacyIntegerColumn( + Math.min(city.populationMax, basePopulationIncrease + city.population * populationFactor) + ), + agriculture: roundLegacyIntegerColumn( + Math.min(city.agricultureMax, city.agriculture * (1 + genericRatio)) + ), + commerce: roundLegacyIntegerColumn( + Math.min(city.commerceMax, city.commerce * (1 + genericRatio)) + ), + security: roundLegacyIntegerColumn( + Math.min(city.securityMax, city.security * (1 + genericRatio)) + ), + defence: roundLegacyIntegerColumn( + Math.min(city.defenceMax, city.defence * (1 + genericRatio)) + ), + wall: roundLegacyIntegerColumn(Math.min(city.wallMax, city.wall * (1 + genericRatio))), + meta: { + ...city.meta, + trust: Math.max(0, Math.min(100, trust + trustDiff)), + }, + }); + } + } + + for (const general of world.listGenerals()) { + const current = general[resource]; + const next = applyResourceMaintenance(current, [[10_000, 0.97]]); + if (next !== current) { + world.updateGeneral(general.id, { [resource]: next }); + } + } + for (const nation of world.listNations()) { + const current = nation[resource]; + const next = applyResourceMaintenance(current, [ + [100_000, 0.95], + [10_000, 0.97], + ]); + if (next !== current) { + world.updateNation(nation.id, { [resource]: next }); + } + } + }; +}; diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 8b76587..84179c1 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -48,6 +48,7 @@ import { import { createRaiseDisasterHandler } from './monthlyDisasterAction.js'; import { createUpdateCitySupplyHandler } from './monthlyCitySupplyAction.js'; import { createUpdateNationLevelHandler } from './monthlyNationLevelAction.js'; +import { createProcessSemiAnnualHandler } from './monthlySemiAnnualAction.js'; import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js'; export interface TurnDaemonRuntimeOptions { @@ -201,6 +202,13 @@ const createTurnDaemonRuntimeWithLease = async ( map: snapshot.map, }) ); + eventActions.set( + 'ProcessSemiAnnual', + createProcessSemiAnnualHandler({ + getWorld: () => worldRef, + nationTraits: nationTraitMap, + }) + ); if (reservedTurnStoreHandle) { eventActions.set( 'UpdateNationLevel', diff --git a/app/game-engine/test/monthlySemiAnnualAction.test.ts b/app/game-engine/test/monthlySemiAnnualAction.test.ts new file mode 100644 index 0000000..e4477d2 --- /dev/null +++ b/app/game-engine/test/monthlySemiAnnualAction.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it } from 'vitest'; +import type { City, Nation, NationTraitModule } from '@sammo-ts/logic'; + +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { createProcessSemiAnnualHandler } from '../src/turn/monthlySemiAnnualAction.js'; +import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const buildCity = (id: number, patch: Partial = {}): City => ({ + id, + name: `도시${id}`, + nationId: 1, + level: 4, + state: 0, + population: 10_000, + populationMax: 50_000, + agriculture: 1_001, + agricultureMax: 5_000, + commerce: 1_001, + commerceMax: 5_000, + security: 1_001, + securityMax: 2_000, + supplyState: 1, + frontState: 0, + defence: 1_001, + defenceMax: 5_000, + wall: 1_001, + wallMax: 5_000, + conflict: {}, + meta: { trust: 55, dead: 123, 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: { rate: 20 }, + ...patch, +}); + +const buildGeneral = (id: number, patch: Partial = {}): TurnGeneral => ({ + id, + name: `장수${id}`, + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + experience: 0, + dedication: 0, + officerLevel: 0, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + injury: 0, + gold: 1_000, + rice: 1_000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + age: 20, + npcState: 0, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 1_000 }, + turnTime: new Date('0193-01-01T00:00:00.000Z'), + ...patch, +}); + +const event: TurnEvent = { + id: 1, + targetCode: 'month', + priority: 1_000, + condition: true, + action: [['ProcessSemiAnnual', 'gold']], + meta: {}, +}; + +const populationTrait: NationTraitModule = { + key: 'test-population', + name: '인구 보정 시험', + info: '', + kind: 'nation', + onCalcNationalIncome: (_context, type, amount) => (type === 'pop' ? amount * 1.2 : amount), +}; + +const buildHarness = (options: { + cities?: City[]; + nations?: Nation[]; + generals?: TurnGeneral[]; + configConst?: Record; + traits?: ReadonlyMap; +} = {}) => { + 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: options.configConst ?? {}, + environment: { mapName: 'test', unitSet: 'default' }, + }, + map: { + id: 'test', + name: 'test', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + generals: options.generals ?? [], + cities: options.cities ?? [], + nations: options.nations ?? [], + troops: [], + diplomacy: [], + events: [event], + initialEvents: [], + }; + const world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + }); + const handler = createProcessSemiAnnualHandler({ + getWorld: () => world, + nationTraits: options.traits, + }); + return { world, handler }; +}; + +const environment = { + year: 193, + month: 1, + startyear: 190, + currentEventID: 1, + turnTime: new Date('0193-01-01T00:00:00.000Z'), +}; + +describe('ProcessSemiAnnual monthly action', () => { + it('preserves the global popIncrease order, neutral double decay, supplied filtering, and nation trait', async () => { + const { world, handler } = buildHarness({ + configConst: { basePopIncreaseAmount: 15_000 }, + cities: [ + buildCity(1), + buildCity(2, { supplyState: 0 }), + buildCity(3, { nationId: 0 }), + ], + nations: [buildNation(1, { typeCode: populationTrait.key })], + traits: new Map([[populationTrait.key, populationTrait]]), + }); + + await handler(['gold'], environment, event); + + expect(world.getCityById(1)).toMatchObject({ + population: 25_630, + agriculture: 991, + commerce: 991, + security: 991, + defence: 991, + wall: 991, + meta: { trust: 55, dead: 0, marker: 1 }, + }); + expect(world.getCityById(2)).toMatchObject({ + population: 10_000, + agriculture: 991, + meta: { trust: 55, dead: 0, marker: 2 }, + }); + expect(world.getCityById(3)).toMatchObject({ + population: 10_000, + agriculture: 981, + commerce: 981, + security: 981, + defence: 981, + wall: 981, + meta: { trust: 50, dead: 0, marker: 3 }, + }); + expect(world.peekDirtyState().logs).toEqual([]); + }); + + it('uses the negative population security branch and clamps population, domestic stats, and trust', async () => { + const { world, handler } = buildHarness({ + cities: [ + buildCity(1, { + population: 49_000, + populationMax: 50_000, + agriculture: 4_999, + agricultureMax: 5_000, + commerce: 4_999, + commerceMax: 5_000, + security: 1_500, + securityMax: 2_000, + defence: 4_999, + defenceMax: 5_000, + wall: 4_999, + wallMax: 5_000, + meta: { trust: 5, dead: 1 }, + }), + ], + nations: [buildNation(1, { meta: { rate: 50 } })], + }); + + await handler(['gold'], environment, event); + + expect(world.getCityById(1)).toMatchObject({ + population: 49_464, + agriculture: 4_207, + commerce: 4_207, + security: 1_262, + defence: 4_207, + wall: 4_207, + meta: { trust: 0, dead: 0 }, + }); + }); + + it('applies strict legacy resource thresholds to generals and nations for either resource', async () => { + const amounts = [1_000, 1_001, 10_000, 10_001, 100_000, 100_001]; + const { world, handler } = buildHarness({ + generals: amounts.map((amount, index) => buildGeneral(index + 1, { gold: amount, rice: amount })), + nations: amounts.map((amount, index) => + buildNation(index + 1, { gold: amount, rice: amount, meta: { rate: 20 } }) + ), + }); + + await handler(['gold'], environment, event); + await handler(['rice'], environment, event); + + expect(world.listGenerals().map((general) => general.gold)).toEqual([ + 1_000, 991, 9_900, 9_701, 97_000, 97_001, + ]); + expect(world.listGenerals().map((general) => general.rice)).toEqual([ + 1_000, 991, 9_900, 9_701, 97_000, 97_001, + ]); + expect(world.listNations().map((nation) => nation.gold)).toEqual([ + 1_000, 991, 9_900, 9_701, 97_000, 95_001, + ]); + expect(world.listNations().map((nation) => nation.rice)).toEqual([ + 1_000, 991, 9_900, 9_701, 97_000, 95_001, + ]); + }); + + it('rejects resources outside the legacy enum', () => { + const { handler } = buildHarness(); + + expect(() => { + void handler(['tech'], environment, event); + }).toThrow('ProcessSemiAnnual requires resource "gold" or "rice".'); + }); +}); diff --git a/app/game-engine/test/monthlySemiAnnualPersistence.integration.test.ts b/app/game-engine/test/monthlySemiAnnualPersistence.integration.test.ts new file mode 100644 index 0000000..8179d98 --- /dev/null +++ b/app/game-engine/test/monthlySemiAnnualPersistence.integration.test.ts @@ -0,0 +1,269 @@ +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 { createProcessSemiAnnualHandler } from '../src/turn/monthlySemiAnnualAction.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const nationId = 990_071; +const generalId = 990_071; +const cityIds = [990_071, 990_072] as const; + +const nation: Nation = { + id: nationId, + name: '반기저장국', + color: '#777777', + capitalCityId: cityIds[0], + chiefGeneralId: generalId, + gold: 100_001, + rice: 7_777, + power: 0, + level: 1, + typeCode: 'che_중립', + meta: { rate: 20 }, +}; + +const buildCity = (id: number, nationIdValue: number): City => ({ + id, + name: `반기저장도시${id}`, + nationId: nationIdValue, + level: 1, + state: 0, + population: 10_000, + populationMax: 50_000, + agriculture: 1_001, + agricultureMax: 5_000, + commerce: 1_001, + commerceMax: 5_000, + security: 1_001, + securityMax: 2_000, + supplyState: 1, + frontState: 0, + defence: 1_001, + defenceMax: 5_000, + wall: 1_001, + wallMax: 5_000, + conflict: {}, + meta: { trust: 55, trade: 100, region: 1, dead: 123 }, +}); + +const general: TurnGeneral = { + id: generalId, + name: '반기저장장수', + nationId, + cityId: cityIds[0], + troopId: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + experience: 0, + dedication: 0, + officerLevel: 1, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + injury: 0, + gold: 10_001, + rice: 8_888, + crew: 0, + crewTypeId: 1_100, + train: 0, + atmos: 0, + age: 20, + npcState: 0, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 1_000 }, + turnTime: new Date('0193-01-01T00:00:00.000Z'), +}; + +integration('monthly semi-annual 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.general.deleteMany({ where: { id: generalId } }); + await db.city.deleteMany({ where: { id: { in: [...cityIds] } } }); + await db.nation.deleteMany({ where: { id: nationId } }); + }); + + afterAll(async () => { + await db.general.deleteMany({ where: { id: generalId } }); + await db.city.deleteMany({ where: { id: { in: [...cityIds] } } }); + await db.nation.deleteMany({ where: { id: nationId } }); + await closeDb?.(); + }); + + it('persists population growth, neutral double decay, dead reset, and resource maintenance', async () => { + await db.nation.create({ + data: { + id: nation.id, + name: nation.name, + color: nation.color, + capitalCityId: nation.capitalCityId, + chiefGeneralId: nation.chiefGeneralId, + gold: nation.gold, + rice: nation.rice, + tech: 0, + level: nation.level, + typeCode: nation.typeCode, + meta: nation.meta, + }, + }); + const cities = [buildCity(cityIds[0], nationId), buildCity(cityIds[1], 0)]; + 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: city.meta.trust as number, + trade: 100, + defence: city.defence, + defenceMax: city.defenceMax, + wall: city.wall, + wallMax: city.wallMax, + region: 1, + conflict: {}, + meta: { dead: 123 }, + })), + }); + await db.general.create({ + data: { + id: general.id, + name: general.name, + nationId: general.nationId, + cityId: general.cityId, + troopId: general.troopId, + npcState: general.npcState, + leadership: general.stats.leadership, + strength: general.stats.strength, + intel: general.stats.intelligence, + officerLevel: general.officerLevel, + gold: general.gold, + rice: general.rice, + crewTypeId: general.crewTypeId, + age: general.age, + turnTime: general.turnTime, + meta: general.meta, + }, + }); + const row = await db.worldState.create({ + data: { + scenarioCode: 'monthly-semi-annual-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: [general], + 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 = createProcessSemiAnnualHandler({ getWorld: () => world }); + await handler( + ['gold'], + { + year: 193, + month: 1, + startyear: 190, + currentEventID: 1, + turnTime: state.lastTurnTime, + }, + { id: 1, targetCode: '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.city.findUniqueOrThrow({ where: { id: cityIds[0] } })).toMatchObject({ + population: 15_525, + agriculture: 991, + commerce: 991, + security: 991, + defence: 991, + wall: 991, + trust: 55, + meta: { dead: 0 }, + }); + expect(await db.city.findUniqueOrThrow({ where: { id: cityIds[1] } })).toMatchObject({ + population: 10_000, + agriculture: 981, + commerce: 981, + security: 981, + defence: 981, + wall: 981, + trust: 50, + meta: { dead: 0 }, + }); + expect(await db.general.findUniqueOrThrow({ where: { id: generalId } })).toMatchObject({ + gold: 9_701, + rice: 8_888, + }); + expect(await db.nation.findUniqueOrThrow({ where: { id: nationId } })).toMatchObject({ + gold: 95_001, + rice: 7_777, + meta: { rate: 20 }, + }); + } finally { + await dbHooks.close(); + await db.worldState.delete({ where: { id: row.id } }); + } + }); +});