diff --git a/app/game-engine/test/npcNationGrowthScenario.test.ts b/app/game-engine/test/npcNationGrowthScenario.test.ts index 551ae30..c2b0145 100644 --- a/app/game-engine/test/npcNationGrowthScenario.test.ts +++ b/app/game-engine/test/npcNationGrowthScenario.test.ts @@ -528,7 +528,7 @@ describe('NPC 대형 시뮬레이션', () => { const state = world.getState(); const startYear = snapshot.scenarioMeta?.startYear ?? 0; - const constraintEnv = { + const constraintEnv: Record = { currentYear: state.currentYear, currentMonth: state.currentMonth, year: state.currentYear, diff --git a/packages/logic/src/actions/turn/general/che_주민선정.ts b/packages/logic/src/actions/turn/general/che_주민선정.ts index 6739b46..abd100c 100644 --- a/packages/logic/src/actions/turn/general/che_주민선정.ts +++ b/packages/logic/src/actions/turn/general/che_주민선정.ts @@ -1,28 +1,174 @@ -import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; -import { CityDevelopmentActionDefinition } from './cityDevelopment.js'; +import type { RandomGenerator } from '@sammo-ts/common'; +import type { City, General, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext, RequirementKey } from '@sammo-ts/logic/constraints/types.js'; +import { + notBeNeutral, + notWanderingNation, + occupiedCity, + reqGeneralRice, + suppliedCity, +} from '@sammo-ts/logic/constraints/presets.js'; +import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js'; +import { GeneralActionPipeline, type GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js'; +import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; +import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import type { GeneralTurnCommandSpec } from './index.js'; +import { clamp } from 'es-toolkit'; -export class ActionDefinition< +export interface DomesticActionContext< TriggerState extends GeneralTriggerState = GeneralTriggerState, -> extends CityDevelopmentActionDefinition { - constructor(env: { develCost?: number; amount?: number } = {}) { - super( +> extends GeneralActionContext { + general: General; + city: City; + nation?: Nation | null; +} + +export interface TrustEnvironment { + develCost: number; + defaultTrust?: number; +} + +export interface TrustActionArgs {} + +const ACTION_NAME = '주민 선정'; +const ACTION_KEY = '민심'; +const STAT_EXP_KEY = 'leadership_exp'; +const DEFAULT_TRUST = 50; + +const getMetaNumber = (meta: Record, key: string): number | null => { + const raw = meta[key]; + return typeof raw === 'number' ? raw : null; +}; + +const addMetaNumber = (meta: Record, key: string, delta: number): Record => { + const current = getMetaNumber(meta, key) ?? 0; + return { ...meta, [key]: current + delta }; +}; + +const randomRange = (rng: RandomGenerator, min: number, max: number): number => min + (max - min) * rng.nextFloat(); + +const remainCityTrust = (): Constraint => ({ + name: 'remainCityTrust', + requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []), + test: (ctx, view) => { + const city = ctx.cityId !== undefined ? (view.get({ kind: 'city', id: ctx.cityId }) as City | null) : null; + if (!city) { + return { kind: 'deny', reason: '도시 정보가 없습니다.' }; + } + const trust = getMetaNumber(city.meta, 'trust') ?? DEFAULT_TRUST; + if (trust >= 100) { + return { kind: 'deny', reason: '민심이 충분합니다.' }; + } + return { kind: 'allow' }; + }, +}); + +export class CommandResolver< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> { + private readonly pipeline: GeneralActionPipeline; + private readonly env: TrustEnvironment; + + constructor(modules: Array | null | undefined>, env: TrustEnvironment) { + this.pipeline = new GeneralActionPipeline(modules); + this.env = env; + } + + calcBaseScore(context: DomesticActionContext, rng: RandomGenerator): number { + let score = this.pipeline.onCalcStat(context, 'leadership', context.general.stats.leadership); + score *= randomRange(rng, 0.8, 1.2); + return this.pipeline.onCalcDomestic(context, ACTION_KEY, 'score', score); + } + + resolve(context: DomesticActionContext, rng: RandomGenerator): { + trustDelta: number; + exp: number; + dedication: number; + costRice: number; + } { + const costRice = (this.env.develCost ?? 0) * 2; + const baseScore = clamp(this.calcBaseScore(context, rng), 1, Number.MAX_SAFE_INTEGER); + const exp = baseScore * 0.7; + const dedication = baseScore * 1.0; + const trustDelta = Math.round(baseScore) / 10; + return { trustDelta, exp, dedication, costRice }; + } +} + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> implements GeneralActionDefinition { + public readonly key = 'che_주민선정'; + public readonly name = ACTION_NAME; + private readonly command: CommandResolver; + private readonly env: TrustEnvironment; + + constructor(modules: Array | null | undefined>, env: TrustEnvironment) { + this.env = env; + this.command = new CommandResolver(modules, env); + } + + parseArgs(_raw: unknown): TrustActionArgs | null { + void _raw; + return {}; + } + + buildConstraints(ctx: ConstraintContext, _args: TrustActionArgs): Constraint[] { + void _args; + const requirements: RequirementKey[] = []; + if (ctx.cityId !== undefined) { + requirements.push({ kind: 'city', id: ctx.cityId }); + } + if (ctx.nationId !== undefined) { + requirements.push({ kind: 'nation', id: ctx.nationId }); + } + + const getRiceCost = (): number => (this.env.develCost ?? 0) * 2; + + return [ + notBeNeutral(), + notWanderingNation(), + occupiedCity(), + suppliedCity(), + reqGeneralRice(getRiceCost, requirements), + remainCityTrust(), + ]; + } + + resolve( + context: GeneralActionResolveContext, + _args: TrustActionArgs + ): GeneralActionOutcome { + const general = context.general; + const city = context.city; + if (!city) { + context.addLog('도시 정보를 찾지 못했습니다.'); + return { effects: [] }; + } + const trust = getMetaNumber(city.meta, 'trust') ?? this.env.defaultTrust ?? DEFAULT_TRUST; + const result = this.command.resolve( { - key: 'che_주민선정', - name: '주민 선정', - statKey: 'population', - maxKey: 'populationMax', - label: '인구', - baseAmount: 1000, + ...context, + city, + nation: context.nation ?? null, }, - env + context.rng ); + + const nextTrust = clamp(trust + result.trustDelta, 0, 100); + city.meta = { ...city.meta, trust: nextTrust }; + general.rice = Math.max(0, general.rice - result.costRice); + general.experience += result.exp; + general.dedication += result.dedication; + general.meta = { ...addMetaNumber(general.meta, STAT_EXP_KEY, 1), max_domestic_critical: 0 }; + + context.addLog(`민심이 ${result.trustDelta.toFixed(1)} 상승했습니다.`); + return { effects: [] }; } } -// 예약 턴 실행은 기본 컨텍스트만 사용한다. export const actionContextBuilder = defaultActionContextBuilder; export const commandSpec: GeneralTurnCommandSpec = { @@ -30,5 +176,5 @@ export const commandSpec: GeneralTurnCommandSpec = { category: '내정', reqArg: false, - createDefinition: (env: TurnCommandEnv) => new ActionDefinition({ develCost: env.develCost }), + createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? [], env), };