diff --git a/app/game-engine/src/turn/incomeHandler.ts b/app/game-engine/src/turn/incomeHandler.ts index ce2cee5..bbbb846 100644 --- a/app/game-engine/src/turn/incomeHandler.ts +++ b/app/game-engine/src/turn/incomeHandler.ts @@ -18,6 +18,7 @@ import { import type { ScenarioConfig } from '@sammo-ts/logic'; import type { InMemoryTurnWorld, TurnCalendarHandler, TurnCalendarContext } from './inMemoryWorld.js'; +import { resolveAppliedNationRate } from './nationTaxRate.js'; import type { TurnGeneral } from './types.js'; const resolveNumber = (source: Record, keys: string[], fallback: number): number => { @@ -30,8 +31,6 @@ const resolveNumber = (source: Record, keys: string[], fallback return fallback; }; -const resolveNationRate = (nation: Nation): number => asNumber(nation.meta.rate, 20); - const resolveNationBill = (nation: Nation): number => asNumber(nation.meta.bill, 100); const resolveOfficerCity = (meta: Record): number => { @@ -72,7 +71,7 @@ const buildNationIncomeContext = (nation: Nation, trait: NationTraitModule | nul ? (type: TriggerNationalIncomeType, amount: number) => trait.onCalcNationalIncome!(actionContext, type, amount) : undefined; return { - rate: resolveNationRate(nation), + rate: resolveAppliedNationRate(nation.meta), modifyIncome, }; }; diff --git a/app/game-engine/src/turn/monthlySemiAnnualAction.ts b/app/game-engine/src/turn/monthlySemiAnnualAction.ts index cf4a811..595b4e7 100644 --- a/app/game-engine/src/turn/monthlySemiAnnualAction.ts +++ b/app/game-engine/src/turn/monthlySemiAnnualAction.ts @@ -3,6 +3,7 @@ import { createIncomeActionContext, type NationTraitModule } from '@sammo-ts/log import type { InMemoryTurnWorld } from './inMemoryWorld.js'; import type { MonthlyEventActionHandler } from './monthlyEventHandler.js'; +import { resolveAppliedNationRate } from './nationTaxRate.js'; type SemiAnnualResource = 'gold' | 'rice'; @@ -21,8 +22,6 @@ const resolveBasePopulationIncrease = (world: InMemoryTurnWorld): number => { 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 => { @@ -82,15 +81,11 @@ export const createProcessSemiAnnualHandler = (options: { const basePopulationIncrease = resolveBasePopulationIncrease(world); for (const nation of world.listNations()) { - const rate = resolveNationRate(nation.meta); + const rate = resolveAppliedNationRate(nation.meta); let populationRatio = (30 - rate) / 200; const trait = options.nationTraits?.get(nation.typeCode); if (trait?.onCalcNationalIncome) { - populationRatio = trait.onCalcNationalIncome( - createIncomeActionContext(nation), - 'pop', - populationRatio - ); + populationRatio = trait.onCalcNationalIncome(createIncomeActionContext(nation), 'pop', populationRatio); } const genericRatio = (20 - rate) / 200; const trustDiff = 20 - rate; @@ -114,15 +109,9 @@ export const createProcessSemiAnnualHandler = (options: { 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)) - ), + 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, diff --git a/app/game-engine/src/turn/nationTaxRate.ts b/app/game-engine/src/turn/nationTaxRate.ts new file mode 100644 index 0000000..e00e14c --- /dev/null +++ b/app/game-engine/src/turn/nationTaxRate.ts @@ -0,0 +1,11 @@ +import { asNumber } from '@sammo-ts/common'; + +const DEFAULT_NATION_RATE = 20; + +export const resolveAppliedNationRate = (meta: Record): number => { + const stagedRate = meta.rate_tmp; + if (typeof stagedRate === 'number' && Number.isFinite(stagedRate)) { + return stagedRate; + } + return asNumber(meta.rate, DEFAULT_NATION_RATE); +}; diff --git a/app/game-engine/test/monthlyCoreEventAction.test.ts b/app/game-engine/test/monthlyCoreEventAction.test.ts index 8a4eb0d..3e9b096 100644 --- a/app/game-engine/test/monthlyCoreEventAction.test.ts +++ b/app/game-engine/test/monthlyCoreEventAction.test.ts @@ -258,4 +258,60 @@ describe('core monthly event actions at the real month boundary', () => { }) ); }); + + it('uses the staged rate_tmp for income when the desired rate changes mid-period', async () => { + const nation = buildNation(); + nation.meta = { ...nation.meta, rate: 35, rate_tmp: 10 }; + const world = buildWorld( + [ + { + id: 3, + targetCode: 'month', + priority: 1, + condition: true, + action: [['ProcessIncome', 'gold']], + meta: {}, + }, + ], + (getWorld) => { + const incomeHandler = createIncomeHandler({ + getWorld, + scenarioConfig: { + stat: { + total: 300, + min: 10, + max: 100, + npcTotal: 150, + npcMax: 50, + npcMin: 10, + chiefMin: 70, + }, + iconPath: '', + map: {}, + const: { baseGold: 0, baseRice: 0 }, + environment: { mapName: map.id, unitSet: 'default' }, + }, + nationTraits: new Map(), + }); + return new Map([['ProcessIncome', createProcessIncomeActionHandler(incomeHandler)]]); + }, + { + currentMonth: 6, + generals: [buildGeneral(1, 1, 3)], + nations: [nation], + cities: [buildCity()], + } + ); + + await world.advanceMonth(new Date('0190-07-01T00:00:00.000Z')); + + expect(world.getNationById(1)).toMatchObject({ + gold: 10_105, + meta: { + rate: 35, + rate_tmp: 10, + prev_income_gold: 105, + }, + }); + }); }); diff --git a/app/game-engine/test/monthlyCoreEventPersistence.integration.test.ts b/app/game-engine/test/monthlyCoreEventPersistence.integration.test.ts index 1044582..9b1d435 100644 --- a/app/game-engine/test/monthlyCoreEventPersistence.integration.test.ts +++ b/app/game-engine/test/monthlyCoreEventPersistence.integration.test.ts @@ -65,7 +65,7 @@ integration('core monthly event action persistence', () => { power: 0, level: 1, typeCode: 'che_중립', - meta: { rate: 20, bill: 0, chief_set: 1 }, + meta: { rate: 35, rate_tmp: 10, bill: 0, chief_set: 1 }, }; const city: City = { id: cityId, @@ -287,9 +287,14 @@ integration('core monthly event action persistence', () => { expect( await db.nation.findUnique({ where: { id: nationId }, select: { gold: true, rice: true, meta: true } }) ).toEqual({ - gold: 10_210, + gold: 10_105, rice: 20_000, - meta: expect.objectContaining({ chief_set: 0, prev_income_gold: 210 }), + meta: expect.objectContaining({ + rate: 35, + rate_tmp: 10, + chief_set: 0, + prev_income_gold: 105, + }), }); expect(await db.city.findUnique({ where: { id: cityId }, select: { meta: true } })).toEqual({ meta: expect.objectContaining({ officer_set: 0 }), diff --git a/app/game-engine/test/monthlySemiAnnualAction.test.ts b/app/game-engine/test/monthlySemiAnnualAction.test.ts index e4477d2..9d11a9d 100644 --- a/app/game-engine/test/monthlySemiAnnualAction.test.ts +++ b/app/game-engine/test/monthlySemiAnnualAction.test.ts @@ -93,13 +93,15 @@ const populationTrait: NationTraitModule = { onCalcNationalIncome: (_context, type, amount) => (type === 'pop' ? amount * 1.2 : amount), }; -const buildHarness = (options: { - cities?: City[]; - nations?: Nation[]; - generals?: TurnGeneral[]; - configConst?: Record; - traits?: ReadonlyMap; -} = {}) => { +const buildHarness = ( + options: { + cities?: City[]; + nations?: Nation[]; + generals?: TurnGeneral[]; + configConst?: Record; + traits?: ReadonlyMap; + } = {} +) => { const state: TurnWorldState = { id: 1, currentYear: 193, @@ -152,11 +154,7 @@ 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 }), - ], + cities: [buildCity(1), buildCity(2, { supplyState: 0 }), buildCity(3, { nationId: 0 })], nations: [buildNation(1, { typeCode: populationTrait.key })], traits: new Map([[populationTrait.key, populationTrait]]), }); @@ -224,6 +222,25 @@ describe('ProcessSemiAnnual monthly action', () => { }); }); + it('uses the staged rate_tmp when the desired rate changes mid-period', async () => { + const { world, handler } = buildHarness({ + cities: [buildCity(1)], + nations: [buildNation(1, { meta: { rate: 50, rate_tmp: 20 } })], + }); + + await handler(['gold'], environment, event); + + expect(world.getCityById(1)).toMatchObject({ + population: 15_525, + agriculture: 991, + commerce: 991, + security: 991, + defence: 991, + wall: 991, + meta: { trust: 55, 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({ @@ -236,18 +253,10 @@ describe('ProcessSemiAnnual monthly action', () => { 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, - ]); + 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', () => { diff --git a/app/game-engine/test/monthlySemiAnnualPersistence.integration.test.ts b/app/game-engine/test/monthlySemiAnnualPersistence.integration.test.ts index 5769686..ad9e8ac 100644 --- a/app/game-engine/test/monthlySemiAnnualPersistence.integration.test.ts +++ b/app/game-engine/test/monthlySemiAnnualPersistence.integration.test.ts @@ -25,7 +25,7 @@ const nation: Nation = { power: 0, level: 1, typeCode: 'che_중립', - meta: { rate: 20 }, + meta: { rate: 50, rate_tmp: 20 }, }; const buildCity = (id: number, nationIdValue: number): City => ({ @@ -265,7 +265,7 @@ integration('monthly semi-annual database persistence', () => { expect(await db.nation.findUniqueOrThrow({ where: { id: nationId } })).toMatchObject({ gold: 95_001, rice: 7_777, - meta: { rate: 20 }, + meta: { rate: 50, rate_tmp: 20 }, }); } finally { await dbHooks.close();