From cce57d0fea09f8f24089532c8bd9ca0bbc073cc4 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Thu, 1 Jan 2026 11:49:15 +0000 Subject: [PATCH] feat: Add new general actions and constraints for game mechanics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduced new general turn commands: 'che_거병', 'che_임관', 'che_건국', 'che_훈련', 'che_사기진작', 'che_요양', 'che_출병', 'che_주민선정', 'che_농지개간', 'che_기술연구', 'che_치안강화', 'che_수비강화', 'che_성벽보수'. - Implemented action definitions for each new command, including constraints and resolution logic. - Added new constraints: 'beNeutral', 'remainCityCapacityByMax', 'existsDestNation'. - Enhanced city development actions for '농지개간', '성벽보수', and '수비강화'. - Created a new action for declaring war: 'che_선전포고' with appropriate constraints. - Updated existing action definitions to accommodate new game mechanics and ensure proper functionality. --- app/game-api/src/turns/commandTable.ts | 130 +++++++++++++++ .../src/turn/reservedTurnHandler.ts | 74 +++++++++ .../src/actions/turn/general/che_거병.ts | 55 +++++++ .../src/actions/turn/general/che_건국.ts | 55 +++++++ .../src/actions/turn/general/che_기술연구.ts | 129 +++++++++++++++ .../src/actions/turn/general/che_농지개간.ts | 20 +++ .../src/actions/turn/general/che_사기진작.ts | 91 +++++++++++ .../src/actions/turn/general/che_성벽보수.ts | 20 +++ .../src/actions/turn/general/che_수비강화.ts | 20 +++ .../src/actions/turn/general/che_요양.ts | 79 +++++++++ .../src/actions/turn/general/che_임관.ts | 65 ++++++++ .../src/actions/turn/general/che_주민선정.ts | 20 +++ .../src/actions/turn/general/che_출병.ts | 71 +++++++++ .../src/actions/turn/general/che_치안강화.ts | 20 +++ .../src/actions/turn/general/che_훈련.ts | 91 +++++++++++ .../actions/turn/general/cityDevelopment.ts | 150 ++++++++++++++++++ .../logic/src/actions/turn/general/index.ts | 91 +++++++++++ .../src/actions/turn/nation/che_선전포고.ts | 74 +++++++++ .../logic/src/actions/turn/nation/index.ts | 14 +- packages/logic/src/constraints/presets.ts | 77 +++++++++ 20 files changed, 1344 insertions(+), 2 deletions(-) create mode 100644 packages/logic/src/actions/turn/general/che_거병.ts create mode 100644 packages/logic/src/actions/turn/general/che_건국.ts create mode 100644 packages/logic/src/actions/turn/general/che_기술연구.ts create mode 100644 packages/logic/src/actions/turn/general/che_농지개간.ts create mode 100644 packages/logic/src/actions/turn/general/che_사기진작.ts create mode 100644 packages/logic/src/actions/turn/general/che_성벽보수.ts create mode 100644 packages/logic/src/actions/turn/general/che_수비강화.ts create mode 100644 packages/logic/src/actions/turn/general/che_요양.ts create mode 100644 packages/logic/src/actions/turn/general/che_임관.ts create mode 100644 packages/logic/src/actions/turn/general/che_주민선정.ts create mode 100644 packages/logic/src/actions/turn/general/che_출병.ts create mode 100644 packages/logic/src/actions/turn/general/che_치안강화.ts create mode 100644 packages/logic/src/actions/turn/general/che_훈련.ts create mode 100644 packages/logic/src/actions/turn/general/cityDevelopment.ts create mode 100644 packages/logic/src/actions/turn/nation/che_선전포고.ts diff --git a/app/game-api/src/turns/commandTable.ts b/app/game-api/src/turns/commandTable.ts index 3475182..8eaac53 100644 --- a/app/game-api/src/turns/commandTable.ts +++ b/app/game-api/src/turns/commandTable.ts @@ -10,15 +10,28 @@ import type { TriggerValue, } from '@sammo-ts/logic'; import { + AppointmentActionDefinition, AssignmentActionDefinition, + BoostMoraleActionDefinition, AwardActionDefinition, CommerceInvestmentActionDefinition, + DeclarationActionDefinition, + DefenceUpgradeActionDefinition, + DispatchActionDefinition, evaluateConstraints, FireAttackActionDefinition, + FarmingActionDefinition, + FoundingActionDefinition, NationRestActionDefinition, + RecoveryActionDefinition, + ResidentsSelectionActionDefinition, RecruitActionDefinition, RestActionDefinition, + SecurityUpgradeActionDefinition, + TechResearchActionDefinition, TalentScoutActionDefinition, + TrainingActionDefinition, + UprisingActionDefinition, VolunteerRecruitActionDefinition, } from '@sammo-ts/logic'; @@ -52,6 +65,10 @@ export interface TurnCommandTable { interface CommandEnv { develCost: number; + trainDelta: number; + atmosDelta: number; + maxTrainByCommand: number; + maxAtmosByCommand: number; sabotageDefaultProb: number; sabotageProbCoefByStat: number; sabotageDefenceCoefByGeneralCount: number; @@ -65,6 +82,7 @@ interface CommandEnv { defaultSpecialDomestic: string | null; defaultSpecialWar: string | null; initialNationGenLimit: number; + maxTechLevel: number; baseGold: number; baseRice: number; maxResourceActionAmount: number; @@ -197,6 +215,14 @@ const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => { ['develCost', 'develcost', 'develrate'], 0 ), + trainDelta: resolveNumber(constValues, ['trainDelta'], 0), + atmosDelta: resolveNumber(constValues, ['atmosDelta'], 0), + maxTrainByCommand: resolveNumber(constValues, ['maxTrainByCommand'], 0), + maxAtmosByCommand: resolveNumber( + constValues, + ['maxAtmosByCommand'], + 0 + ), sabotageDefaultProb: resolveNumber( constValues, ['sabotageDefaultProb'], @@ -260,6 +286,7 @@ const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => { ['initialNationGenLimit'], 0 ), + maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 0), baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], 0), baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0), maxResourceActionAmount: resolveNumber( @@ -467,6 +494,70 @@ const buildEntries = (env: CommandEnv): { reqArg: false, args: {}, }, + { + category: '개인', + definition: new RecoveryActionDefinition(), + reqArg: false, + args: {}, + }, + { + category: '전략', + definition: new UprisingActionDefinition(), + reqArg: false, + args: {}, + }, + { + category: '전략', + definition: new AppointmentActionDefinition(), + reqArg: true, + args: { destNationId: 0 }, + }, + { + category: '전략', + definition: new FoundingActionDefinition(), + reqArg: false, + args: {}, + }, + { + category: '군사', + definition: new TrainingActionDefinition({ + trainDelta: env.trainDelta, + maxTrainByCommand: env.maxTrainByCommand, + }), + reqArg: false, + args: {}, + }, + { + category: '군사', + definition: new BoostMoraleActionDefinition({ + atmosDelta: env.atmosDelta, + maxAtmosByCommand: env.maxAtmosByCommand, + }), + reqArg: false, + args: {}, + }, + { + category: '군사', + definition: new DispatchActionDefinition(), + reqArg: true, + args: { destCityId: 0 }, + }, + { + category: '내정', + definition: new ResidentsSelectionActionDefinition({ + develCost: env.develCost, + }), + reqArg: false, + args: {}, + }, + { + category: '내정', + definition: new FarmingActionDefinition({ + develCost: env.develCost, + }), + reqArg: false, + args: {}, + }, { category: '내정', definition: new CommerceInvestmentActionDefinition(emptyModules, { @@ -475,6 +566,39 @@ const buildEntries = (env: CommandEnv): { reqArg: false, args: {}, }, + { + category: '내정', + definition: new TechResearchActionDefinition({ + costGold: env.develCost, + maxTechLevel: env.maxTechLevel, + }), + reqArg: false, + args: {}, + }, + { + category: '내정', + definition: new SecurityUpgradeActionDefinition({ + develCost: env.develCost, + }), + reqArg: false, + args: {}, + }, + { + category: '내정', + definition: new DefenceUpgradeActionDefinition({ + develCost: env.develCost, + }), + reqArg: false, + args: {}, + }, + { + category: '내정', + definition: new WallRepairActionDefinition({ + develCost: env.develCost, + }), + reqArg: false, + args: {}, + }, { category: '내정', definition: new RecruitActionDefinition(emptyModules, {}), @@ -539,6 +663,12 @@ const buildEntries = (env: CommandEnv): { reqArg: false, args: {}, }, + { + category: '외교', + definition: new DeclarationActionDefinition(), + reqArg: true, + args: { destNationId: 0 }, + }, { category: '인사', definition: assignmentDefinition, diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index c5fe92e..71d3b1b 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -13,16 +13,29 @@ import type { UnitSetDefinition, } from '@sammo-ts/logic'; import { + AppointmentActionDefinition, AssignmentActionDefinition, + BoostMoraleActionDefinition, AwardActionDefinition, CommerceInvestmentActionDefinition, + DeclarationActionDefinition, + DefenceUpgradeActionDefinition, + DispatchActionDefinition, evaluateConstraints, FireAttackActionDefinition, + FarmingActionDefinition, + FoundingActionDefinition, NationRestActionDefinition, + RecoveryActionDefinition, + ResidentsSelectionActionDefinition, RecruitActionDefinition, resolveGeneralAction, RestActionDefinition, + SecurityUpgradeActionDefinition, + TechResearchActionDefinition, TalentScoutActionDefinition, + TrainingActionDefinition, + UprisingActionDefinition, VolunteerRecruitActionDefinition, } from '@sammo-ts/logic'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic'; @@ -38,6 +51,10 @@ import type { InMemoryReservedTurnStore } from './reservedTurnStore.js'; interface CommandEnv { develCost: number; + trainDelta: number; + atmosDelta: number; + maxTrainByCommand: number; + maxAtmosByCommand: number; sabotageDefaultProb: number; sabotageProbCoefByStat: number; sabotageDefenceCoefByGeneralCount: number; @@ -51,6 +68,7 @@ interface CommandEnv { defaultSpecialDomestic: string | null; defaultSpecialWar: string | null; initialNationGenLimit: number; + maxTechLevel: number; baseGold: number; baseRice: number; maxResourceActionAmount: number; @@ -125,6 +143,14 @@ const buildCommandEnv = ( ['develCost', 'develcost', 'develrate'], 0 ), + trainDelta: resolveNumber(constValues, ['trainDelta'], 0), + atmosDelta: resolveNumber(constValues, ['atmosDelta'], 0), + maxTrainByCommand: resolveNumber(constValues, ['maxTrainByCommand'], 0), + maxAtmosByCommand: resolveNumber( + constValues, + ['maxAtmosByCommand'], + 0 + ), sabotageDefaultProb: resolveNumber( constValues, ['sabotageDefaultProb'], @@ -188,6 +214,7 @@ const buildCommandEnv = ( ['initialNationGenLimit'], 0 ), + maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 0), baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], 0), baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0), maxResourceActionAmount: resolveNumber( @@ -421,10 +448,56 @@ const buildGeneralDefinitions = ( env: CommandEnv ): Map => { const definitions = new Map(); + definitions.set('che_거병', new UprisingActionDefinition()); + definitions.set('che_임관', new AppointmentActionDefinition()); + definitions.set('che_건국', new FoundingActionDefinition()); + definitions.set( + 'che_훈련', + new TrainingActionDefinition({ + trainDelta: env.trainDelta, + maxTrainByCommand: env.maxTrainByCommand, + }) + ); + definitions.set( + 'che_사기진작', + new BoostMoraleActionDefinition({ + atmosDelta: env.atmosDelta, + maxAtmosByCommand: env.maxAtmosByCommand, + }) + ); + definitions.set('che_요양', new RecoveryActionDefinition()); + definitions.set('che_출병', new DispatchActionDefinition()); + definitions.set( + 'che_주민선정', + new ResidentsSelectionActionDefinition({ develCost: env.develCost }) + ); + definitions.set( + 'che_농지개간', + new FarmingActionDefinition({ develCost: env.develCost }) + ); definitions.set( 'che_상업투자', new CommerceInvestmentActionDefinition([], env) ); + definitions.set( + 'che_기술연구', + new TechResearchActionDefinition({ + costGold: env.develCost, + maxTechLevel: env.maxTechLevel, + }) + ); + definitions.set( + 'che_치안강화', + new SecurityUpgradeActionDefinition({ develCost: env.develCost }) + ); + definitions.set( + 'che_수비강화', + new DefenceUpgradeActionDefinition({ develCost: env.develCost }) + ); + definitions.set( + 'che_성벽보수', + new WallRepairActionDefinition({ develCost: env.develCost }) + ); definitions.set('che_화계', new FireAttackActionDefinition([], env)); definitions.set('che_인재탐색', new TalentScoutActionDefinition([], env)); definitions.set('che_의병모집', new VolunteerRecruitActionDefinition([], env)); @@ -442,6 +515,7 @@ const buildNationDefinitions = ( ? env.maxResourceActionAmount : Math.max(env.baseGold, env.baseRice, 1000); definitions.set('휴식', new NationRestActionDefinition()); + definitions.set('che_선전포고', new DeclarationActionDefinition()); definitions.set( 'che_포상', new AwardActionDefinition({ diff --git a/packages/logic/src/actions/turn/general/che_거병.ts b/packages/logic/src/actions/turn/general/che_거병.ts new file mode 100644 index 0000000..0b67065 --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_거병.ts @@ -0,0 +1,55 @@ +import type { GeneralTriggerState, TriggerValue } from '../../../domain/entities.js'; +import type { Constraint, ConstraintContext } from '../../../constraints/types.js'; +import { beNeutral } from '../../../constraints/presets.js'; +import type { GeneralActionDefinition } from '../../definition.js'; +import type { + GeneralActionOutcome, + GeneralActionResolveContext, +} from '../../engine.js'; +import { createGeneralPatchEffect, createLogEffect } from '../../engine.js'; +import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; + +export interface UprisingArgs {} + +const ACTION_NAME = '거병'; + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionDefinition { + public readonly key = 'che_거병'; + public readonly name = ACTION_NAME; + + parseArgs(_raw: unknown): UprisingArgs | null { + void _raw; + return {}; + } + + buildConstraints(_ctx: ConstraintContext, _args: UprisingArgs): Constraint[] { + return [beNeutral()]; + } + + resolve( + context: GeneralActionResolveContext, + _args: UprisingArgs + ): GeneralActionOutcome { + const general = context.general; + const meta = { + ...general.meta, + uprising: true as TriggerValue, + }; + + return { + effects: [ + createGeneralPatchEffect( + { meta } as Partial, + general.id + ), + createLogEffect(`${ACTION_NAME}을 준비했습니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }), + ], + }; + } +} diff --git a/packages/logic/src/actions/turn/general/che_건국.ts b/packages/logic/src/actions/turn/general/che_건국.ts new file mode 100644 index 0000000..97e39e4 --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_건국.ts @@ -0,0 +1,55 @@ +import type { GeneralTriggerState, TriggerValue } from '../../../domain/entities.js'; +import type { Constraint, ConstraintContext } from '../../../constraints/types.js'; +import { beNeutral } from '../../../constraints/presets.js'; +import type { GeneralActionDefinition } from '../../definition.js'; +import type { + GeneralActionOutcome, + GeneralActionResolveContext, +} from '../../engine.js'; +import { createGeneralPatchEffect, createLogEffect } from '../../engine.js'; +import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; + +export interface FoundingArgs {} + +const ACTION_NAME = '건국'; + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionDefinition { + public readonly key = 'che_건국'; + public readonly name = ACTION_NAME; + + parseArgs(_raw: unknown): FoundingArgs | null { + void _raw; + return {}; + } + + buildConstraints(_ctx: ConstraintContext, _args: FoundingArgs): Constraint[] { + return [beNeutral()]; + } + + resolve( + context: GeneralActionResolveContext, + _args: FoundingArgs + ): GeneralActionOutcome { + const general = context.general; + const meta = { + ...general.meta, + founding: true as TriggerValue, + }; + + return { + effects: [ + createGeneralPatchEffect( + { meta } as Partial, + general.id + ), + createLogEffect(`${ACTION_NAME}을 준비했습니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }), + ], + }; + } +} diff --git a/packages/logic/src/actions/turn/general/che_기술연구.ts b/packages/logic/src/actions/turn/general/che_기술연구.ts new file mode 100644 index 0000000..c109d49 --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_기술연구.ts @@ -0,0 +1,129 @@ +import type { + General, + GeneralTriggerState, + Nation, +} from '../../../domain/entities.js'; +import type { + Constraint, + ConstraintContext, + StateView, +} from '../../../constraints/types.js'; +import { + notBeNeutral, + notWanderingNation, + occupiedCity, + reqGeneralGold, + suppliedCity, +} from '../../../constraints/presets.js'; +import type { GeneralActionDefinition } from '../../definition.js'; +import type { + GeneralActionOutcome, + GeneralActionResolveContext, +} from '../../engine.js'; +import { + createGeneralPatchEffect, + createLogEffect, + createNationPatchEffect, +} from '../../engine.js'; +import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; + +export interface TechResearchArgs {} + +export interface TechResearchEnvironment { + costGold?: number; + techDelta?: number; + maxTechLevel?: number; +} + +const ACTION_NAME = '기술 연구'; +const DEFAULT_TECH_DELTA = 1; + +const readTech = (nation: Nation | null | undefined): number => { + if (!nation) { + return 0; + } + const tech = nation.meta.tech; + return typeof tech === 'number' ? tech : 0; +}; + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionDefinition { + public readonly key = 'che_기술연구'; + public readonly name = ACTION_NAME; + private readonly env: TechResearchEnvironment; + + constructor(env: TechResearchEnvironment = {}) { + this.env = env; + } + + parseArgs(_raw: unknown): TechResearchArgs | null { + void _raw; + return {}; + } + + buildConstraints( + _ctx: ConstraintContext, + _args: TechResearchArgs + ): Constraint[] { + const getRequiredGold = (_context: ConstraintContext, _view: StateView): number => + this.env.costGold ?? 0; + return [ + notBeNeutral(), + notWanderingNation(), + occupiedCity(), + suppliedCity(), + reqGeneralGold(getRequiredGold), + ]; + } + + resolve( + context: GeneralActionResolveContext, + _args: TechResearchArgs + ): GeneralActionOutcome { + const general = context.general; + const nation = context.nation ?? null; + if (!nation) { + return { + effects: [ + createLogEffect('국가 정보를 찾지 못했습니다.', { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }), + ], + }; + } + + const delta = this.env.techDelta ?? DEFAULT_TECH_DELTA; + const currentTech = readTech(nation); + const maxTech = + typeof this.env.maxTechLevel === 'number' && + this.env.maxTechLevel > 0 + ? this.env.maxTechLevel + : currentTech + delta; + const nextTech = Math.min(currentTech + delta, maxTech); + const applied = nextTech - currentTech; + const costGold = this.env.costGold ?? 0; + const generalPatch: Partial> = { + gold: Math.max(0, general.gold - costGold), + }; + + return { + effects: [ + createNationPatchEffect( + { + meta: { ...nation.meta, tech: nextTech }, + } as Partial, + nation.id + ), + createGeneralPatchEffect(generalPatch, general.id), + createLogEffect(`${ACTION_NAME}로 기술이 ${applied} 상승했습니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }), + ], + }; + } +} diff --git a/packages/logic/src/actions/turn/general/che_농지개간.ts b/packages/logic/src/actions/turn/general/che_농지개간.ts new file mode 100644 index 0000000..11a4a01 --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_농지개간.ts @@ -0,0 +1,20 @@ +import type { GeneralTriggerState } from '../../../domain/entities.js'; +import { CityDevelopmentActionDefinition } from './cityDevelopment.js'; + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> extends CityDevelopmentActionDefinition { + constructor(env: { develCost?: number; amount?: number } = {}) { + super( + { + key: 'che_농지개간', + name: '농지 개간', + statKey: 'agriculture', + maxKey: 'agricultureMax', + label: '농업', + baseAmount: 100, + }, + env + ); + } +} diff --git a/packages/logic/src/actions/turn/general/che_사기진작.ts b/packages/logic/src/actions/turn/general/che_사기진작.ts new file mode 100644 index 0000000..5654cef --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_사기진작.ts @@ -0,0 +1,91 @@ +import type { + General, + GeneralTriggerState, +} from '../../../domain/entities.js'; +import type { + Constraint, + ConstraintContext, + StateView, +} from '../../../constraints/types.js'; +import { notBeNeutral, reqGeneralGold } from '../../../constraints/presets.js'; +import type { GeneralActionDefinition } from '../../definition.js'; +import type { + GeneralActionOutcome, + GeneralActionResolveContext, +} from '../../engine.js'; +import { createGeneralPatchEffect, createLogEffect } from '../../engine.js'; +import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; + +export interface BoostMoraleArgs {} + +export interface BoostMoraleEnvironment { + atmosDelta?: number; + maxAtmosByCommand?: number; + costGold?: number; +} + +const ACTION_NAME = '사기 진작'; +const DEFAULT_ATMOS_DELTA = 5; +const DEFAULT_MAX_ATMOS = 100; + +const clamp = (value: number, min: number, max: number): number => + Math.min(Math.max(value, min), max); + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionDefinition { + public readonly key = 'che_사기진작'; + public readonly name = ACTION_NAME; + private readonly env: BoostMoraleEnvironment; + + constructor(env: BoostMoraleEnvironment = {}) { + this.env = env; + } + + parseArgs(_raw: unknown): BoostMoraleArgs | null { + void _raw; + return {}; + } + + buildConstraints( + _ctx: ConstraintContext, + _args: BoostMoraleArgs + ): Constraint[] { + const getRequiredGold = (_context: ConstraintContext, _view: StateView): number => + this.env.costGold ?? 0; + return [notBeNeutral(), reqGeneralGold(getRequiredGold)]; + } + + resolve( + context: GeneralActionResolveContext, + _args: BoostMoraleArgs + ): GeneralActionOutcome { + const general = context.general; + const maxAtmos = + this.env.maxAtmosByCommand && this.env.maxAtmosByCommand > 0 + ? this.env.maxAtmosByCommand + : DEFAULT_MAX_ATMOS; + const delta = + this.env.atmosDelta && this.env.atmosDelta > 0 + ? this.env.atmosDelta + : DEFAULT_ATMOS_DELTA; + const nextAtmos = clamp(general.atmos + delta, 0, maxAtmos); + const applied = nextAtmos - general.atmos; + const costGold = this.env.costGold ?? 0; + const patch: Partial> = { + atmos: nextAtmos, + gold: Math.max(0, general.gold - costGold), + }; + + return { + effects: [ + createGeneralPatchEffect(patch, general.id), + createLogEffect(`${ACTION_NAME}로 사기가 ${applied} 증가했습니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }), + ], + }; + } +} diff --git a/packages/logic/src/actions/turn/general/che_성벽보수.ts b/packages/logic/src/actions/turn/general/che_성벽보수.ts new file mode 100644 index 0000000..5eb7e43 --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_성벽보수.ts @@ -0,0 +1,20 @@ +import type { GeneralTriggerState } from '../../../domain/entities.js'; +import { CityDevelopmentActionDefinition } from './cityDevelopment.js'; + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> extends CityDevelopmentActionDefinition { + constructor(env: { develCost?: number; amount?: number } = {}) { + super( + { + key: 'che_성벽보수', + name: '성벽 보수', + statKey: 'wall', + maxKey: 'wallMax', + label: '성벽', + baseAmount: 50, + }, + env + ); + } +} diff --git a/packages/logic/src/actions/turn/general/che_수비강화.ts b/packages/logic/src/actions/turn/general/che_수비강화.ts new file mode 100644 index 0000000..6d976ac --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_수비강화.ts @@ -0,0 +1,20 @@ +import type { GeneralTriggerState } from '../../../domain/entities.js'; +import { CityDevelopmentActionDefinition } from './cityDevelopment.js'; + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> extends CityDevelopmentActionDefinition { + constructor(env: { develCost?: number; amount?: number } = {}) { + super( + { + key: 'che_수비강화', + name: '수비 강화', + statKey: 'defence', + maxKey: 'defenceMax', + label: '수비', + baseAmount: 50, + }, + env + ); + } +} diff --git a/packages/logic/src/actions/turn/general/che_요양.ts b/packages/logic/src/actions/turn/general/che_요양.ts new file mode 100644 index 0000000..e92b87e --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_요양.ts @@ -0,0 +1,79 @@ +import type { + General, + GeneralTriggerState, +} from '../../../domain/entities.js'; +import type { + Constraint, + ConstraintContext, + StateView, +} from '../../../constraints/types.js'; +import { notBeNeutral, reqGeneralGold } from '../../../constraints/presets.js'; +import type { GeneralActionDefinition } from '../../definition.js'; +import type { + GeneralActionOutcome, + GeneralActionResolveContext, +} from '../../engine.js'; +import { createGeneralPatchEffect, createLogEffect } from '../../engine.js'; +import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; + +export interface RecoveryArgs {} + +export interface RecoveryEnvironment { + injuryDelta?: number; + costGold?: number; +} + +const ACTION_NAME = '요양'; +const DEFAULT_INJURY_DELTA = 10; + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionDefinition { + public readonly key = 'che_요양'; + public readonly name = ACTION_NAME; + private readonly env: RecoveryEnvironment; + + constructor(env: RecoveryEnvironment = {}) { + this.env = env; + } + + parseArgs(_raw: unknown): RecoveryArgs | null { + void _raw; + return {}; + } + + buildConstraints( + _ctx: ConstraintContext, + _args: RecoveryArgs + ): Constraint[] { + const getRequiredGold = (_context: ConstraintContext, _view: StateView): number => + this.env.costGold ?? 0; + return [notBeNeutral(), reqGeneralGold(getRequiredGold)]; + } + + resolve( + context: GeneralActionResolveContext, + _args: RecoveryArgs + ): GeneralActionOutcome { + const general = context.general; + const delta = this.env.injuryDelta ?? DEFAULT_INJURY_DELTA; + const nextInjury = Math.max(0, general.injury - delta); + const applied = general.injury - nextInjury; + const costGold = this.env.costGold ?? 0; + const patch: Partial> = { + injury: nextInjury, + gold: Math.max(0, general.gold - costGold), + }; + + return { + effects: [ + createGeneralPatchEffect(patch, general.id), + createLogEffect(`${ACTION_NAME}으로 부상이 ${applied} 회복되었습니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }), + ], + }; + } +} diff --git a/packages/logic/src/actions/turn/general/che_임관.ts b/packages/logic/src/actions/turn/general/che_임관.ts new file mode 100644 index 0000000..ffe9258 --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_임관.ts @@ -0,0 +1,65 @@ +import type { GeneralTriggerState } from '../../../domain/entities.js'; +import type { + Constraint, + ConstraintContext, +} from '../../../constraints/types.js'; +import { beNeutral, existsDestNation } from '../../../constraints/presets.js'; +import type { GeneralActionDefinition } from '../../definition.js'; +import type { + GeneralActionOutcome, + GeneralActionResolveContext, +} from '../../engine.js'; +import { createLogEffect } from '../../engine.js'; +import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; + +export interface AppointmentArgs { + destNationId: number; +} + +const ACTION_NAME = '임관'; + +const parseNationId = (raw: unknown): number | null => { + if (typeof raw !== 'number' || !Number.isFinite(raw)) { + return null; + } + return raw > 0 ? Math.floor(raw) : null; +}; + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionDefinition { + public readonly key = 'che_임관'; + public readonly name = ACTION_NAME; + + parseArgs(raw: unknown): AppointmentArgs | null { + const data = raw as { destNationId?: unknown }; + const destNationId = parseNationId(data?.destNationId); + if (destNationId === null) { + return null; + } + return { destNationId }; + } + + buildConstraints(_ctx: ConstraintContext, _args: AppointmentArgs): Constraint[] { + return [beNeutral(), existsDestNation()]; + } + + resolve( + _context: GeneralActionResolveContext, + args: AppointmentArgs + ): GeneralActionOutcome { + void _context; + return { + effects: [ + createLogEffect( + `${ACTION_NAME}을 신청했습니다. (국가 ${args.destNationId})`, + { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + } + ), + ], + }; + } +} diff --git a/packages/logic/src/actions/turn/general/che_주민선정.ts b/packages/logic/src/actions/turn/general/che_주민선정.ts new file mode 100644 index 0000000..c10b732 --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_주민선정.ts @@ -0,0 +1,20 @@ +import type { GeneralTriggerState } from '../../../domain/entities.js'; +import { CityDevelopmentActionDefinition } from './cityDevelopment.js'; + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> extends CityDevelopmentActionDefinition { + constructor(env: { develCost?: number; amount?: number } = {}) { + super( + { + key: 'che_주민선정', + name: '주민 선정', + statKey: 'population', + maxKey: 'populationMax', + label: '인구', + baseAmount: 1000, + }, + env + ); + } +} diff --git a/packages/logic/src/actions/turn/general/che_출병.ts b/packages/logic/src/actions/turn/general/che_출병.ts new file mode 100644 index 0000000..9617159 --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_출병.ts @@ -0,0 +1,71 @@ +import type { GeneralTriggerState } from '../../../domain/entities.js'; +import type { Constraint, ConstraintContext } from '../../../constraints/types.js'; +import { + existsDestCity, + notBeNeutral, + notOccupiedDestCity, + occupiedCity, + suppliedCity, +} from '../../../constraints/presets.js'; +import type { GeneralActionDefinition } from '../../definition.js'; +import type { + GeneralActionOutcome, + GeneralActionResolveContext, +} from '../../engine.js'; +import { createLogEffect } from '../../engine.js'; +import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; + +export interface DispatchArgs { + destCityId: number; +} + +const ACTION_NAME = '출병'; + +const parseCityId = (raw: unknown): number | null => { + if (typeof raw !== 'number' || !Number.isFinite(raw)) { + return null; + } + return raw > 0 ? Math.floor(raw) : null; +}; + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionDefinition { + public readonly key = 'che_출병'; + public readonly name = ACTION_NAME; + + parseArgs(raw: unknown): DispatchArgs | null { + const data = raw as { destCityId?: unknown }; + const destCityId = parseCityId(data?.destCityId); + if (destCityId === null) { + return null; + } + return { destCityId }; + } + + buildConstraints(_ctx: ConstraintContext, _args: DispatchArgs): Constraint[] { + return [ + notBeNeutral(), + occupiedCity(), + suppliedCity(), + existsDestCity(), + notOccupiedDestCity(), + ]; + } + + resolve( + _context: GeneralActionResolveContext, + args: DispatchArgs + ): GeneralActionOutcome { + void _context; + return { + effects: [ + createLogEffect(`${ACTION_NAME}을 준비했습니다. (목표 도시 ${args.destCityId})`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }), + ], + }; + } +} diff --git a/packages/logic/src/actions/turn/general/che_치안강화.ts b/packages/logic/src/actions/turn/general/che_치안강화.ts new file mode 100644 index 0000000..9f2975c --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_치안강화.ts @@ -0,0 +1,20 @@ +import type { GeneralTriggerState } from '../../../domain/entities.js'; +import { CityDevelopmentActionDefinition } from './cityDevelopment.js'; + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> extends CityDevelopmentActionDefinition { + constructor(env: { develCost?: number; amount?: number } = {}) { + super( + { + key: 'che_치안강화', + name: '치안 강화', + statKey: 'security', + maxKey: 'securityMax', + label: '치안', + baseAmount: 50, + }, + env + ); + } +} diff --git a/packages/logic/src/actions/turn/general/che_훈련.ts b/packages/logic/src/actions/turn/general/che_훈련.ts new file mode 100644 index 0000000..b0f12e2 --- /dev/null +++ b/packages/logic/src/actions/turn/general/che_훈련.ts @@ -0,0 +1,91 @@ +import type { + General, + GeneralTriggerState, +} from '../../../domain/entities.js'; +import type { + Constraint, + ConstraintContext, + StateView, +} from '../../../constraints/types.js'; +import { notBeNeutral, reqGeneralGold } from '../../../constraints/presets.js'; +import type { GeneralActionDefinition } from '../../definition.js'; +import type { + GeneralActionOutcome, + GeneralActionResolveContext, +} from '../../engine.js'; +import { createGeneralPatchEffect, createLogEffect } from '../../engine.js'; +import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; + +export interface TrainingArgs {} + +export interface TrainingEnvironment { + trainDelta?: number; + maxTrainByCommand?: number; + costGold?: number; +} + +const ACTION_NAME = '훈련'; +const DEFAULT_TRAIN_DELTA = 5; +const DEFAULT_MAX_TRAIN = 100; + +const clamp = (value: number, min: number, max: number): number => + Math.min(Math.max(value, min), max); + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionDefinition { + public readonly key = 'che_훈련'; + public readonly name = ACTION_NAME; + private readonly env: TrainingEnvironment; + + constructor(env: TrainingEnvironment = {}) { + this.env = env; + } + + parseArgs(_raw: unknown): TrainingArgs | null { + void _raw; + return {}; + } + + buildConstraints( + _ctx: ConstraintContext, + _args: TrainingArgs + ): Constraint[] { + const getRequiredGold = (_context: ConstraintContext, _view: StateView): number => + this.env.costGold ?? 0; + return [notBeNeutral(), reqGeneralGold(getRequiredGold)]; + } + + resolve( + context: GeneralActionResolveContext, + _args: TrainingArgs + ): GeneralActionOutcome { + const general = context.general; + const maxTrain = + this.env.maxTrainByCommand && this.env.maxTrainByCommand > 0 + ? this.env.maxTrainByCommand + : DEFAULT_MAX_TRAIN; + const delta = + this.env.trainDelta && this.env.trainDelta > 0 + ? this.env.trainDelta + : DEFAULT_TRAIN_DELTA; + const nextTrain = clamp(general.train + delta, 0, maxTrain); + const applied = nextTrain - general.train; + const costGold = this.env.costGold ?? 0; + const patch: Partial> = { + train: nextTrain, + gold: Math.max(0, general.gold - costGold), + }; + + return { + effects: [ + createGeneralPatchEffect(patch, general.id), + createLogEffect(`${ACTION_NAME}을 통해 훈련도가 ${applied} 증가했습니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }), + ], + }; + } +} diff --git a/packages/logic/src/actions/turn/general/cityDevelopment.ts b/packages/logic/src/actions/turn/general/cityDevelopment.ts new file mode 100644 index 0000000..5c69b8d --- /dev/null +++ b/packages/logic/src/actions/turn/general/cityDevelopment.ts @@ -0,0 +1,150 @@ +import type { + City, + General, + GeneralTriggerState, +} from '../../../domain/entities.js'; +import type { + Constraint, + ConstraintContext, + StateView, +} from '../../../constraints/types.js'; +import { + notBeNeutral, + notWanderingNation, + occupiedCity, + remainCityCapacityByMax, + reqGeneralGold, + suppliedCity, +} from '../../../constraints/presets.js'; +import type { GeneralActionDefinition } from '../../definition.js'; +import type { + GeneralActionOutcome, + GeneralActionResolveContext, +} from '../../engine.js'; +import { + createCityPatchEffect, + createGeneralPatchEffect, + createLogEffect, +} from '../../engine.js'; +import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; + +export interface CityDevelopmentArgs {} + +export interface CityDevelopmentEnvironment { + develCost?: number; + amount?: number; +} + +export interface CityDevelopmentConfig { + key: string; + name: string; + statKey: keyof City; + maxKey: keyof City; + label: string; + baseAmount: number; +} + +const clamp = (value: number, min: number, max: number): number => + Math.min(Math.max(value, min), max); + +const readNumber = (value: unknown): number | null => + typeof value === 'number' && Number.isFinite(value) ? value : null; + +export class CityDevelopmentActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionDefinition { + public readonly key: string; + public readonly name: string; + private readonly config: CityDevelopmentConfig; + private readonly env: CityDevelopmentEnvironment; + + constructor(config: CityDevelopmentConfig, env: CityDevelopmentEnvironment) { + this.key = config.key; + this.name = config.name; + this.config = config; + this.env = env; + } + + parseArgs(_raw: unknown): CityDevelopmentArgs | null { + void _raw; + return {}; + } + + buildConstraints( + _ctx: ConstraintContext, + _args: CityDevelopmentArgs + ): Constraint[] { + const getRequiredGold = (_context: ConstraintContext, _view: StateView): number => + this.env.develCost ?? 0; + + return [ + notBeNeutral(), + notWanderingNation(), + occupiedCity(), + suppliedCity(), + remainCityCapacityByMax( + String(this.config.statKey), + String(this.config.maxKey), + this.config.label + ), + reqGeneralGold(getRequiredGold), + ]; + } + + resolve( + context: GeneralActionResolveContext, + _args: CityDevelopmentArgs + ): GeneralActionOutcome { + const general = context.general; + const city = context.city; + if (!city) { + return { + effects: [ + createLogEffect('도시 정보를 찾지 못했습니다.', { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }), + ], + }; + } + + const baseAmount = this.env.amount ?? this.config.baseAmount; + const current = readNumber(city[this.config.statKey]); + const max = readNumber(city[this.config.maxKey]); + if (current === null || max === null) { + return { + effects: [ + createLogEffect('도시 정보를 찾지 못했습니다.', { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }), + ], + }; + } + + const nextValue = clamp(current + baseAmount, 0, max); + const costGold = this.env.develCost ?? 0; + const generalPatch: Partial> = { + gold: Math.max(0, general.gold - costGold), + }; + + const logMessage = `${this.config.label}이 ${nextValue - current} 증가했습니다.`; + + return { + effects: [ + createCityPatchEffect( + { [this.config.statKey]: nextValue } as Partial, + city.id + ), + createGeneralPatchEffect(generalPatch, general.id), + createLogEffect(logMessage, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }), + ], + }; + } +} diff --git a/packages/logic/src/actions/turn/general/index.ts b/packages/logic/src/actions/turn/general/index.ts index 9e4974d..51116dd 100644 --- a/packages/logic/src/actions/turn/general/index.ts +++ b/packages/logic/src/actions/turn/general/index.ts @@ -1,12 +1,38 @@ export type GeneralTurnCommandKey = + | 'che_거병' + | 'che_임관' + | 'che_건국' + | 'che_훈련' + | 'che_사기진작' + | 'che_요양' + | 'che_출병' + | 'che_주민선정' + | 'che_농지개간' | 'che_상업투자' + | 'che_기술연구' + | 'che_치안강화' + | 'che_수비강화' + | 'che_성벽보수' | 'che_화계' | 'che_인재탐색' | 'che_의병모집' | 'che_징병' | '휴식'; +import type * as UprisingModule from './che_거병.js'; +import type * as AppointmentModule from './che_임관.js'; +import type * as FoundingModule from './che_건국.js'; +import type * as TrainingModule from './che_훈련.js'; +import type * as BoostMoraleModule from './che_사기진작.js'; +import type * as RecoveryModule from './che_요양.js'; +import type * as DispatchModule from './che_출병.js'; +import type * as ResidentsSelectionModule from './che_주민선정.js'; +import type * as FarmingModule from './che_농지개간.js'; import type * as CommerceInvestmentModule from './che_상업투자.js'; +import type * as TechResearchModule from './che_기술연구.js'; +import type * as SecurityUpgradeModule from './che_치안강화.js'; +import type * as DefenceUpgradeModule from './che_수비강화.js'; +import type * as WallRepairModule from './che_성벽보수.js'; import type * as FireAttackModule from './che_화계.js'; import type * as TalentScoutModule from './che_인재탐색.js'; import type * as VolunteerRecruitModule from './che_의병모집.js'; @@ -14,7 +40,20 @@ import type * as RecruitModule from './che_징병.js'; import type * as RestModule from './휴식.js'; export type GeneralTurnCommandModule = + | typeof UprisingModule + | typeof AppointmentModule + | typeof FoundingModule + | typeof TrainingModule + | typeof BoostMoraleModule + | typeof RecoveryModule + | typeof DispatchModule + | typeof ResidentsSelectionModule + | typeof FarmingModule | typeof CommerceInvestmentModule + | typeof TechResearchModule + | typeof SecurityUpgradeModule + | typeof DefenceUpgradeModule + | typeof WallRepairModule | typeof FireAttackModule | typeof TalentScoutModule | typeof VolunteerRecruitModule @@ -27,7 +66,20 @@ const defaultImporters: Record< GeneralTurnCommandKey, GeneralTurnCommandImporter > = { + che_거병: async () => import('./che_거병.js'), + che_임관: async () => import('./che_임관.js'), + che_건국: async () => import('./che_건국.js'), + che_훈련: async () => import('./che_훈련.js'), + che_사기진작: async () => import('./che_사기진작.js'), + che_요양: async () => import('./che_요양.js'), + che_출병: async () => import('./che_출병.js'), + che_주민선정: async () => import('./che_주민선정.js'), + che_농지개간: async () => import('./che_농지개간.js'), che_상업투자: async () => import('./che_상업투자.js'), + che_기술연구: async () => import('./che_기술연구.js'), + che_치안강화: async () => import('./che_치안강화.js'), + che_수비강화: async () => import('./che_수비강화.js'), + che_성벽보수: async () => import('./che_성벽보수.js'), che_화계: async () => import('./che_화계.js'), che_인재탐색: async () => import('./che_인재탐색.js'), che_의병모집: async () => import('./che_의병모집.js'), @@ -54,11 +106,50 @@ export class GeneralTurnCommandLoader { } } +export { + ActionDefinition as UprisingActionDefinition, +} from './che_거병.js'; +export { + ActionDefinition as AppointmentActionDefinition, +} from './che_임관.js'; +export { + ActionDefinition as FoundingActionDefinition, +} from './che_건국.js'; +export { + ActionDefinition as TrainingActionDefinition, +} from './che_훈련.js'; +export { + ActionDefinition as BoostMoraleActionDefinition, +} from './che_사기진작.js'; +export { + ActionDefinition as RecoveryActionDefinition, +} from './che_요양.js'; +export { + ActionDefinition as DispatchActionDefinition, +} from './che_출병.js'; +export { + ActionDefinition as ResidentsSelectionActionDefinition, +} from './che_주민선정.js'; +export { + ActionDefinition as FarmingActionDefinition, +} from './che_농지개간.js'; export { ActionDefinition as CommerceInvestmentActionDefinition, ActionResolver as CommerceInvestmentActionResolver, CommandResolver as CommerceInvestmentCommandResolver, } from './che_상업투자.js'; +export { + ActionDefinition as TechResearchActionDefinition, +} from './che_기술연구.js'; +export { + ActionDefinition as SecurityUpgradeActionDefinition, +} from './che_치안강화.js'; +export { + ActionDefinition as DefenceUpgradeActionDefinition, +} from './che_수비강화.js'; +export { + ActionDefinition as WallRepairActionDefinition, +} from './che_성벽보수.js'; export { ActionDefinition as FireAttackActionDefinition, ActionResolver as FireAttackActionResolver, diff --git a/packages/logic/src/actions/turn/nation/che_선전포고.ts b/packages/logic/src/actions/turn/nation/che_선전포고.ts new file mode 100644 index 0000000..4dd79ae --- /dev/null +++ b/packages/logic/src/actions/turn/nation/che_선전포고.ts @@ -0,0 +1,74 @@ +import type { GeneralTriggerState } from '../../../domain/entities.js'; +import type { Constraint, ConstraintContext } from '../../../constraints/types.js'; +import { + beChief, + existsDestNation, + notBeNeutral, + occupiedCity, + suppliedCity, +} from '../../../constraints/presets.js'; +import type { GeneralActionDefinition } from '../../definition.js'; +import type { + GeneralActionOutcome, + GeneralActionResolveContext, +} from '../../engine.js'; +import { createLogEffect } from '../../engine.js'; +import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; + +export interface DeclareWarArgs { + destNationId: number; +} + +const ACTION_NAME = '선전포고'; + +const parseNationId = (raw: unknown): number | null => { + if (typeof raw !== 'number' || !Number.isFinite(raw)) { + return null; + } + return raw > 0 ? Math.floor(raw) : null; +}; + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionDefinition { + public readonly key = 'che_선전포고'; + public readonly name = ACTION_NAME; + + parseArgs(raw: unknown): DeclareWarArgs | null { + const data = raw as { destNationId?: unknown }; + const destNationId = parseNationId(data?.destNationId); + if (destNationId === null) { + return null; + } + return { destNationId }; + } + + buildConstraints(_ctx: ConstraintContext, _args: DeclareWarArgs): Constraint[] { + return [ + notBeNeutral(), + occupiedCity(), + suppliedCity(), + beChief(), + existsDestNation(), + ]; + } + + resolve( + _context: GeneralActionResolveContext, + args: DeclareWarArgs + ): GeneralActionOutcome { + void _context; + return { + effects: [ + createLogEffect( + `${ACTION_NAME}을 준비했습니다. (국가 ${args.destNationId})`, + { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + } + ), + ], + }; + } +} diff --git a/packages/logic/src/actions/turn/nation/index.ts b/packages/logic/src/actions/turn/nation/index.ts index af1e9e1..89c3e91 100644 --- a/packages/logic/src/actions/turn/nation/index.ts +++ b/packages/logic/src/actions/turn/nation/index.ts @@ -1,13 +1,19 @@ -export type NationTurnCommandKey = '휴식' | 'che_포상' | 'che_발령'; +export type NationTurnCommandKey = + | '휴식' + | 'che_포상' + | 'che_발령' + | 'che_선전포고'; import type * as NationRestModule from './휴식.js'; import type * as AwardModule from './che_포상.js'; import type * as AssignmentModule from './che_발령.js'; +import type * as DeclarationModule from './che_선전포고.js'; export type NationTurnCommandModule = | typeof NationRestModule | typeof AwardModule - | typeof AssignmentModule; + | typeof AssignmentModule + | typeof DeclarationModule; export type NationTurnCommandImporter = () => Promise; @@ -18,6 +24,7 @@ const defaultImporters: Record< 휴식: async () => import('./휴식.js'), che_포상: async () => import('./che_포상.js'), che_발령: async () => import('./che_발령.js'), + che_선전포고: async () => import('./che_선전포고.js'), }; export class NationTurnCommandLoader { @@ -52,3 +59,6 @@ export { ActionDefinition as AssignmentActionDefinition, ActionResolver as AssignmentActionResolver, } from './che_발령.js'; +export { + ActionDefinition as DeclarationActionDefinition, +} from './che_선전포고.js'; diff --git a/packages/logic/src/constraints/presets.ts b/packages/logic/src/constraints/presets.ts index ac1eb29..64d52d6 100644 --- a/packages/logic/src/constraints/presets.ts +++ b/packages/logic/src/constraints/presets.ts @@ -174,6 +174,25 @@ export const notBeNeutral = (): Constraint => ({ }, }); +export const beNeutral = (): Constraint => ({ + name: 'BeNeutral', + requires: (ctx) => [{ kind: 'general', id: ctx.actorId }], + test: (ctx, view) => { + const req: RequirementKey = { kind: 'general', id: ctx.actorId }; + if (!view.has(req)) { + return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.'); + } + const general = view.get(req) as General | null; + if (!general) { + return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.'); + } + if (general.nationId === 0) { + return allow(); + } + return { kind: 'deny', reason: '이미 국가에 속해 있습니다.' }; + }, +}); + export const alwaysFail = (reason: string): Constraint => ({ name: 'AlwaysFail', requires: () => [], @@ -561,6 +580,36 @@ export const remainCityCapacity = ( }, }); +export const remainCityCapacityByMax = ( + key: string, + maxKey: string, + label: string +): Constraint => ({ + name: 'RemainCityCapacityByMax', + requires: (ctx) => + ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : [], + test: (ctx, view) => { + const city = readCity(view, ctx.cityId); + if (!city) { + if (ctx.cityId === undefined) { + return unknownOrDeny(ctx, [], '도시 정보가 없습니다.'); + } + const req: RequirementKey = { kind: 'city', id: ctx.cityId }; + return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.'); + } + const record = city as unknown as Record; + const current = record[key]; + const max = record[maxKey]; + if (current === undefined || max === undefined) { + return unknownOrDeny(ctx, [], '도시 정보가 없습니다.'); + } + if (current < max) { + return allow(); + } + return { kind: 'deny', reason: `${label}이 충분합니다.` }; + }, +}); + export const reqCityCapacity = ( key: string, label: string, @@ -689,6 +738,34 @@ export const existsDestCity = (): Constraint => ({ }, }); +export const existsDestNation = (): Constraint => ({ + name: 'ExistsDestNation', + requires: (ctx) => + resolveDestNationId(ctx) !== undefined + ? [ + { + kind: 'destNation', + id: resolveDestNationId(ctx) ?? 0, + }, + ] + : [], + test: (ctx, view) => { + const destNationId = resolveDestNationId(ctx); + if (destNationId === undefined) { + return unknownOrDeny(ctx, [], '국가 정보가 없습니다.'); + } + const req: RequirementKey = { kind: 'destNation', id: destNationId }; + if (!view.has(req)) { + return unknownOrDeny(ctx, [req], '국가 정보가 없습니다.'); + } + const nation = view.get(req) as Nation | null; + if (!nation) { + return unknownOrDeny(ctx, [req], '국가 정보가 없습니다.'); + } + return allow(); + }, +}); + export const existsDestGeneral = (): Constraint => ({ name: 'ExistsDestGeneral', requires: (ctx) =>