diff --git a/packages/logic/src/actions/domestic/che_상업투자.ts b/packages/logic/src/actions/domestic/che_상업투자.ts index 57b8f1e..956e14c 100644 --- a/packages/logic/src/actions/domestic/che_상업투자.ts +++ b/packages/logic/src/actions/domestic/che_상업투자.ts @@ -10,6 +10,17 @@ import { GeneralActionPipeline, type GeneralActionModule, } from '../../triggers/general-action.js'; +import type { + GeneralActionOutcome, + GeneralActionResolver, + GeneralActionResolveContext, + GeneralActionEffect, +} from '../engine.js'; +import { + createCityPatchEffect, + createGeneralPatchEffect, + createLogEffect, +} from '../engine.js'; export type DomesticCriticalPick = 'fail' | 'normal' | 'success'; @@ -51,6 +62,9 @@ export interface CommerceInvestmentResult { const DEFAULT_TRUST = 50; const DEFAULT_FRONT_DEBUFF = 0.5; const DEFAULT_FRONT_STATES = [1, 3]; +const ACTION_NAME = '상업 투자'; +const CITY_KEY = 'commerce'; +const STAT_EXP_KEY = 'intel_exp'; const getMetaNumber = ( meta: Record, @@ -85,6 +99,15 @@ const pickByWeight = ( return 'normal'; }; +const addMetaNumber = ( + meta: Record, + key: string, + delta: number +): Record => { + const current = getMetaNumber(meta, key) ?? 0; + return { ...meta, [key]: current + delta }; +}; + // 상업 투자 결과치를 계산하는 경로를 제공한다. export class CommandResolver< TriggerState extends GeneralTriggerState = GeneralTriggerState @@ -226,3 +249,77 @@ export class CommandResolver< }; } } + +export class ActionResolver< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionResolver { + readonly key = 'che_상업투자'; + private readonly command: CommandResolver; + + constructor( + modules: Array | null | undefined>, + env: InvestmentEnvironment + ) { + this.command = new CommandResolver(modules, env); + } + + resolve( + context: GeneralActionResolveContext + ): GeneralActionOutcome { + const general = context.general; + const city = context.city; + if (!city) { + throw new Error('Commerce investment requires a city context.'); + } + + const result = this.command.resolve( + { + ...context, + city, + nation: context.nation ?? null, + }, + context.rng + ); + + const updatedCommerce = clamp( + city.commerce + result.score, + 0, + city.commerceMax + ); + + const nextGold = Math.max(0, general.gold - result.costGold); + const nextRice = Math.max(0, general.rice - result.costRice); + const nextExperience = general.experience + result.exp; + const nextDedication = general.dedication + result.dedication; + + const metaWithStatExp = addMetaNumber(general.meta, STAT_EXP_KEY, 1); + const metaUpdated = + result.pick === 'success' + ? { ...metaWithStatExp, max_domestic_critical: result.score } + : { ...metaWithStatExp, max_domestic_critical: 0 }; + + const effects: Array> = [ + createCityPatchEffect({ + [CITY_KEY]: updatedCommerce, + } as Partial), + createGeneralPatchEffect({ + gold: nextGold, + rice: nextRice, + experience: nextExperience, + dedication: nextDedication, + meta: metaUpdated, + }), + ]; + + const pickLabel = + result.pick === 'success' + ? '성공' + : result.pick === 'fail' + ? '실패' + : '완료'; + const logMessage = `${ACTION_NAME} ${pickLabel}: +${Math.round(result.score)}`; + effects.push(createLogEffect(logMessage)); + + return { effects }; + } +} diff --git a/packages/logic/src/actions/engine.ts b/packages/logic/src/actions/engine.ts index 1a92869..93d7141 100644 --- a/packages/logic/src/actions/engine.ts +++ b/packages/logic/src/actions/engine.ts @@ -4,7 +4,10 @@ import type { General, GeneralRole, GeneralTriggerState, + CityId, + GeneralId, Nation, + NationId, StatBlock, } from '../domain/entities.js'; import type { GeneralActionContext } from '../triggers/general.js'; @@ -81,6 +84,14 @@ export interface GeneralActionResolution { nextTurnAt: Date; logs: string[]; effects: GeneralActionEffect[]; + dirty?: { + general: boolean; + city: boolean; + nation: boolean; + generalId?: GeneralId; + cityId?: CityId; + nationId?: NationId; + }; } const mergeStats = (base: StatBlock, patch: Partial): StatBlock => ({ @@ -139,6 +150,39 @@ const applyNationPatch = (base: Nation, patch: Partial): Nation => ({ meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta, }); +export const createGeneralPatchEffect = < + TriggerState extends GeneralTriggerState = GeneralTriggerState +>( + patch: Partial> +): GeneralPatchEffect => ({ + type: 'general:patch', + patch, +}); + +export const createCityPatchEffect = (patch: Partial): CityPatchEffect => ({ + type: 'city:patch', + patch, +}); + +export const createNationPatchEffect = ( + patch: Partial +): NationPatchEffect => ({ + type: 'nation:patch', + patch, +}); + +export const createLogEffect = (message: string): LogEffect => ({ + type: 'log', + message, +}); + +export const createNextTurnOverrideEffect = ( + nextTurnAt: Date +): NextTurnOverrideEffect => ({ + type: 'schedule:override', + nextTurnAt, +}); + // 행동 결과를 Effect로 모아 상태/턴 계산을 수행한다. export const resolveGeneralAction = < TriggerState extends GeneralTriggerState = GeneralTriggerState @@ -153,20 +197,35 @@ export const resolveGeneralAction = < let nextCity = context.city; let nextNation = context.nation ?? null; let nextTurnAtOverride: Date | null = null; + const dirty: NonNullable = { + general: false, + city: false, + nation: false, + generalId: context.general.id, + }; + if (context.city) { + dirty.cityId = context.city.id; + } + if (context.nation) { + dirty.nationId = context.nation.id; + } for (const effect of outcome.effects) { switch (effect.type) { case 'general:patch': nextGeneral = applyGeneralPatch(nextGeneral, effect.patch); + dirty.general = true; break; case 'city:patch': if (nextCity) { nextCity = applyCityPatch(nextCity, effect.patch); + dirty.city = true; } break; case 'nation:patch': if (nextNation) { nextNation = applyNationPatch(nextNation, effect.patch); + dirty.nation = true; } break; case 'log': @@ -194,6 +253,9 @@ export const resolveGeneralAction = < if (nextCity) { resolution.city = nextCity; } + if (dirty.general || dirty.city || dirty.nation) { + resolution.dirty = dirty; + } return resolution; };