diff --git a/packages/logic/src/actions/instant/nation/che_불가침파기수락.ts b/packages/logic/src/actions/instant/nation/che_불가침파기수락.ts new file mode 100644 index 0000000..2f6890f --- /dev/null +++ b/packages/logic/src/actions/instant/nation/che_불가침파기수락.ts @@ -0,0 +1,140 @@ +import type { GeneralTriggerState } from '../../../domain/entities.js'; +import type { Constraint, ConstraintContext } from '../../../constraints/types.js'; +import { + allowDiplomacyBetweenStatus, + beChief, + destGeneralInDestNation, + existsDestGeneral, + existsDestNation, + notBeNeutral, +} from '../../../constraints/presets.js'; +import { allow, unknownOrDeny } from '../../../constraints/helpers.js'; +import type { GeneralActionDefinition } from '../../definition.js'; +import type { + GeneralActionOutcome, + GeneralActionResolveContext, +} from '../../engine.js'; +import { createDiplomacyPatchEffect, createLogEffect } from '../../engine.js'; +import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; + +export interface NonAggressionCancelAcceptArgs { + destNationId: number; + destGeneralId: number; +} + +const ACTION_NAME = '불가침 파기 수락'; +const DIPLOMACY_NEUTRAL = 2; +const DIPLOMACY_NON_AGGRESSION = 7; + +const parseNationId = (raw: unknown): number | null => { + if (typeof raw !== 'number' || !Number.isFinite(raw)) { + return null; + } + return raw > 0 ? Math.floor(raw) : null; +}; + +const parseGeneralId = (raw: unknown): number | null => { + if (typeof raw !== 'number' || !Number.isFinite(raw)) { + return null; + } + return raw > 0 ? Math.floor(raw) : null; +}; + +const notSameDestGeneral = (): Constraint => ({ + name: 'NotSameDestGeneral', + requires: () => [{ kind: 'arg', key: 'destGeneralId' }], + test: (ctx) => { + const destGeneralId = ctx.args.destGeneralId; + if (typeof destGeneralId !== 'number') { + return unknownOrDeny( + ctx, + [{ kind: 'arg', key: 'destGeneralId' }], + '장수 정보가 없습니다.' + ); + } + if (destGeneralId === ctx.actorId) { + return { kind: 'deny', reason: '대상이 올바르지 않습니다.' }; + } + return allow(); + }, +}); + +// 불가침 파기 수락은 메시지와 연결되는 즉시 국가 커맨드로 사용한다. +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements + GeneralActionDefinition +{ + public readonly key = 'che_불가침파기수락'; + public readonly name = ACTION_NAME; + + parseArgs(raw: unknown): NonAggressionCancelAcceptArgs | null { + const data = raw as { destNationId?: unknown; destGeneralId?: unknown }; + const destNationId = parseNationId(data?.destNationId); + const destGeneralId = parseGeneralId(data?.destGeneralId); + if (destNationId === null || destGeneralId === null) { + return null; + } + return { destNationId, destGeneralId }; + } + + buildConstraints( + _ctx: ConstraintContext, + _args: NonAggressionCancelAcceptArgs + ): Constraint[] { + return [ + beChief(), + notBeNeutral(), + existsDestNation(), + existsDestGeneral(), + destGeneralInDestNation(), + notSameDestGeneral(), + allowDiplomacyBetweenStatus( + [DIPLOMACY_NON_AGGRESSION], + '불가침 중인 상대국에게만 가능합니다.' + ), + ]; + } + + resolve( + context: GeneralActionResolveContext, + args: NonAggressionCancelAcceptArgs + ): GeneralActionOutcome { + const nationId = context.nation?.id; + if (nationId === undefined || nationId <= 0) { + return { + effects: [ + createLogEffect( + `${ACTION_NAME}을 준비했지만 국가 정보가 없습니다.`, + { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + } + ), + ], + }; + } + + return { + effects: [ + createDiplomacyPatchEffect(nationId, args.destNationId, { + state: DIPLOMACY_NEUTRAL, + term: 0, + }), + createDiplomacyPatchEffect(args.destNationId, nationId, { + state: DIPLOMACY_NEUTRAL, + term: 0, + }), + createLogEffect( + `${ACTION_NAME}을 실행했습니다. (국가 ${args.destNationId})`, + { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + } + ), + ], + }; + } +} diff --git a/packages/logic/src/actions/instant/nation/index.ts b/packages/logic/src/actions/instant/nation/index.ts index 8df5867..5d70a51 100644 --- a/packages/logic/src/actions/instant/nation/index.ts +++ b/packages/logic/src/actions/instant/nation/index.ts @@ -1 +1,9 @@ -export * from './che_불가침수락.js'; +export { + ActionDefinition as NonAggressionAcceptActionDefinition, + type NonAggressionAcceptArgs, + type NonAggressionAcceptContext, +} from './che_불가침수락.js'; +export { + ActionDefinition as NonAggressionCancelAcceptActionDefinition, + type NonAggressionCancelAcceptArgs, +} from './che_불가침파기수락.js'; 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..73e9a92 --- /dev/null +++ b/packages/logic/src/actions/turn/nation/che_불가침파기제의.ts @@ -0,0 +1,95 @@ +import type { GeneralTriggerState } from '../../../domain/entities.js'; +import type { Constraint, ConstraintContext } from '../../../constraints/types.js'; +import { + allowDiplomacyBetweenStatus, + 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'; +import type { TurnCommandEnv } from '../commandEnv.js'; +import type { NationTurnCommandSpec } from './index.js'; + +export interface NonAggressionCancelProposalArgs { + destNationId: number; +} + +const ACTION_NAME = '불가침 파기 제의'; +const DIPLOMACY_NON_AGGRESSION = 7; + +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): NonAggressionCancelProposalArgs | null { + const data = raw as { destNationId?: unknown }; + const destNationId = parseNationId(data?.destNationId); + if (destNationId === null) { + return null; + } + return { destNationId }; + } + + buildConstraints( + _ctx: ConstraintContext, + _args: NonAggressionCancelProposalArgs + ): Constraint[] { + return [ + beChief(), + notBeNeutral(), + occupiedCity(), + suppliedCity(), + existsDestNation(), + allowDiplomacyBetweenStatus( + [DIPLOMACY_NON_AGGRESSION], + '불가침 중인 상대국에게만 가능합니다.' + ), + ]; + } + + resolve( + _context: GeneralActionResolveContext, + args: NonAggressionCancelProposalArgs + ): GeneralActionOutcome { + return { + effects: [ + createLogEffect( + `${ACTION_NAME}을 준비했습니다. (국가 ${args.destNationId})`, + { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + } + ), + ], + }; + } +} + +export const commandSpec: NationTurnCommandSpec = { + key: 'che_불가침파기제의', + category: '외교', + reqArg: true, + args: { destNationId: 0 }, + createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), +}; diff --git a/packages/logic/src/actions/turn/nation/index.ts b/packages/logic/src/actions/turn/nation/index.ts index 0f7e224..c61b1c8 100644 --- a/packages/logic/src/actions/turn/nation/index.ts +++ b/packages/logic/src/actions/turn/nation/index.ts @@ -6,6 +6,7 @@ export const NATION_TURN_COMMAND_KEYS = [ 'che_발령', 'che_선전포고', 'che_불가침제의', + 'che_불가침파기제의', 'che_의병모집', ] as const; @@ -29,6 +30,7 @@ const defaultImporters: Record< 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'), }; diff --git a/packages/logic/src/constraints/diplomacy.ts b/packages/logic/src/constraints/diplomacy.ts index 0b5560a..1ef9256 100644 --- a/packages/logic/src/constraints/diplomacy.ts +++ b/packages/logic/src/constraints/diplomacy.ts @@ -63,3 +63,58 @@ export const disallowDiplomacyBetweenStatus = ( return allow(); }, }); + +export const allowDiplomacyBetweenStatus = ( + allowList: number[], + reason: string +): Constraint => ({ + name: 'AllowDiplomacyBetweenStatus', + requires: (ctx) => { + const reqs: RequirementKey[] = []; + if (ctx.nationId !== undefined) { + reqs.push({ kind: 'nation', id: ctx.nationId }); + } + const destNationId = resolveDestNationId(ctx); + if (destNationId !== undefined) { + reqs.push({ kind: 'destNation', id: destNationId }); + if (ctx.nationId !== undefined) { + reqs.push({ + kind: 'diplomacy', + srcNationId: ctx.nationId, + destNationId, + }); + } + } + const destCityId = resolveDestCityId(ctx); + if (destCityId !== undefined) { + reqs.push({ kind: 'destCity', id: destCityId }); + } + return reqs; + }, + test: (ctx, view) => { + const general = readGeneral(ctx, view); + const baseNationId = ctx.nationId ?? general?.nationId; + if (baseNationId === undefined) { + return unknownOrDeny(ctx, [], '국가 정보가 없습니다.'); + } + const destCity = readDestCity(ctx, view); + const destNationId = + resolveDestNationId(ctx) ?? destCity?.nationId; + if (destNationId === undefined) { + return unknownOrDeny(ctx, [], '상대 국가 정보가 없습니다.'); + } + const state = readDiplomacyState(view, baseNationId, destNationId); + if (state === null) { + const req: RequirementKey = { + kind: 'diplomacy', + srcNationId: baseNationId, + destNationId, + }; + return unknownOrDeny(ctx, [req], '외교 정보가 없습니다.'); + } + if (!allowList.includes(state)) { + return { kind: 'deny', reason }; + } + return allow(); + }, +}); diff --git a/resources/turn-commands/default.json b/resources/turn-commands/default.json index fe35262..d23b696 100644 --- a/resources/turn-commands/default.json +++ b/resources/turn-commands/default.json @@ -25,6 +25,7 @@ "che_발령", "che_선전포고", "che_불가침제의", + "che_불가침파기제의", "che_의병모집" ] }