diff --git a/app/game-engine/src/turn/reservedTurnActionContext.ts b/app/game-engine/src/turn/reservedTurnActionContext.ts index e1f14c7..f7a1657 100644 --- a/app/game-engine/src/turn/reservedTurnActionContext.ts +++ b/app/game-engine/src/turn/reservedTurnActionContext.ts @@ -216,6 +216,11 @@ const ACTION_CONTEXT_BUILDERS: Record = { startYear: resolveStartYear(options.world, options.scenarioMeta), }; }, + che_불가침제의: (base, options) => ({ + ...base, + currentYear: options.world.currentYear, + currentMonth: options.world.currentMonth, + }), }; export const buildActionContext = ( 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..9df899e --- /dev/null +++ b/packages/logic/src/actions/instant/nation/che_불가침수락.ts @@ -0,0 +1,244 @@ +import type { GeneralTriggerState } from '../../../domain/entities.js'; +import type { Constraint, ConstraintContext } from '../../../constraints/types.js'; +import { + beChief, + destGeneralInDestNation, + disallowDiplomacyBetweenStatus, + existsDestGeneral, + existsDestNation, + notBeNeutral, + occupiedCity, + suppliedCity, +} 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 NonAggressionAcceptArgs { + destNationId: number; + destGeneralId: number; + year: number; + month: number; +} + +export interface NonAggressionAcceptContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> extends GeneralActionResolveContext { + currentYear: number; + currentMonth: 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; +}; + +const parseGeneralId = (raw: unknown): number | null => { + if (typeof raw !== 'number' || !Number.isFinite(raw)) { + return null; + } + return raw > 0 ? Math.floor(raw) : null; +}; + +const parseYear = (raw: unknown): number | null => { + if (typeof raw !== 'number' || !Number.isFinite(raw)) { + return null; + } + return raw >= 0 ? Math.floor(raw) : null; +}; + +const parseMonth = (raw: unknown): number | null => { + if (typeof raw !== 'number' || !Number.isFinite(raw)) { + return null; + } + const month = Math.floor(raw); + return month >= 1 && month <= 12 ? month : null; +}; + +const resolveMonthIndex = (year: number, month: number): number => + year * 12 + month - 1; + +const requireFutureTerm = (): Constraint => ({ + name: 'RequireNonAggressionFutureTerm', + requires: () => [ + { kind: 'arg', key: 'year' }, + { kind: 'arg', key: 'month' }, + { kind: 'env', key: 'year' }, + { kind: 'env', key: 'month' }, + ], + test: (ctx) => { + const yearValue = typeof ctx.args.year === 'number' ? ctx.args.year : null; + const monthValue = + typeof ctx.args.month === 'number' ? ctx.args.month : null; + const envYearValue = typeof ctx.env.year === 'number' ? ctx.env.year : null; + const envMonthValue = + typeof ctx.env.month === 'number' ? ctx.env.month : null; + const missing = []; + + if (yearValue === null) { + missing.push({ kind: 'arg', key: 'year' } as const); + } + if (monthValue === null) { + missing.push({ kind: 'arg', key: 'month' } as const); + } + if (envYearValue === null) { + missing.push({ kind: 'env', key: 'year' } as const); + } + if (envMonthValue === null) { + missing.push({ kind: 'env', key: 'month' } as const); + } + + if ( + missing.length > 0 || + yearValue === null || + monthValue === null || + envYearValue === null || + envMonthValue === null + ) { + return unknownOrDeny(ctx, missing, '기한 정보가 없습니다.'); + } + + const currentMonth = resolveMonthIndex(envYearValue, envMonthValue); + const targetMonth = yearValue * 12 + monthValue; + if (targetMonth <= currentMonth) { + return { + kind: 'deny', + reason: '이미 기한이 지났습니다.', + }; + } + return allow(); + }, +}); + +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< + TriggerState, + NonAggressionAcceptArgs, + NonAggressionAcceptContext + > { + public readonly key = 'che_불가침수락'; + public readonly name = ACTION_NAME; + + parseArgs(raw: unknown): NonAggressionAcceptArgs | null { + const data = raw as { + destNationId?: unknown; + destGeneralId?: unknown; + year?: unknown; + month?: unknown; + }; + const destNationId = parseNationId(data?.destNationId); + const destGeneralId = parseGeneralId(data?.destGeneralId); + const year = parseYear(data?.year); + const month = parseMonth(data?.month); + if ( + destNationId === null || + destGeneralId === null || + year === null || + month === null + ) { + return null; + } + return { destNationId, destGeneralId, year, month }; + } + + buildConstraints( + _ctx: ConstraintContext, + _args: NonAggressionAcceptArgs + ): Constraint[] { + return [ + beChief(), + notBeNeutral(), + occupiedCity(), + suppliedCity(), + existsDestNation(), + existsDestGeneral(), + destGeneralInDestNation(), + notSameDestGeneral(), + requireFutureTerm(), + disallowDiplomacyBetweenStatus({ + 0: '아국과 이미 교전중입니다.', + 1: '아국과 이미 선포중입니다.', + }), + ]; + } + + resolve( + context: NonAggressionAcceptContext, + args: NonAggressionAcceptArgs + ): 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, + } + ), + ], + }; + } + + const currentMonth = resolveMonthIndex( + context.currentYear, + context.currentMonth + ); + const targetMonth = args.year * 12 + args.month; + const term = Math.max(0, targetMonth - currentMonth); + + return { + effects: [ + createDiplomacyPatchEffect(nationId, args.destNationId, { + state: DIPLOMACY_NON_AGGRESSION, + term, + }), + createDiplomacyPatchEffect(args.destNationId, nationId, { + state: DIPLOMACY_NON_AGGRESSION, + term, + }), + 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 cb0ff5c..8df5867 100644 --- a/packages/logic/src/actions/instant/nation/index.ts +++ b/packages/logic/src/actions/instant/nation/index.ts @@ -1 +1 @@ -export {}; +export * 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..20089bb --- /dev/null +++ b/packages/logic/src/actions/turn/nation/che_불가침제의.ts @@ -0,0 +1,170 @@ +import type { GeneralTriggerState } from '../../../domain/entities.js'; +import type { Constraint, ConstraintContext } from '../../../constraints/types.js'; +import { + beChief, + differentDestNation, + disallowDiplomacyBetweenStatus, + 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 { 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 NonAggressionProposalArgs { + destNationId: number; + year: number; + month: number; +} + +const ACTION_NAME = '불가침 제의'; +const MIN_TERM_MONTHS = 6; + +const parseNationId = (raw: unknown): number | null => { + if (typeof raw !== 'number' || !Number.isFinite(raw)) { + return null; + } + return raw > 0 ? Math.floor(raw) : null; +}; + +const parseYear = (raw: unknown): number | null => { + if (typeof raw !== 'number' || !Number.isFinite(raw)) { + return null; + } + return raw >= 0 ? Math.floor(raw) : null; +}; + +const parseMonth = (raw: unknown): number | null => { + if (typeof raw !== 'number' || !Number.isFinite(raw)) { + return null; + } + const month = Math.floor(raw); + return month >= 1 && month <= 12 ? month : null; +}; + +const resolveMonthIndex = (year: number, month: number): number => + year * 12 + month - 1; + +const requireMinimumTerm = (minMonths: number): Constraint => ({ + name: 'RequireNonAggressionMinimumTerm', + requires: () => [ + { kind: 'arg', key: 'year' }, + { kind: 'arg', key: 'month' }, + { kind: 'env', key: 'year' }, + { kind: 'env', key: 'month' }, + ], + test: (ctx) => { + const yearValue = typeof ctx.args.year === 'number' ? ctx.args.year : null; + const monthValue = typeof ctx.args.month === 'number' ? ctx.args.month : null; + const envYearValue = typeof ctx.env.year === 'number' ? ctx.env.year : null; + const envMonthValue = + typeof ctx.env.month === 'number' ? ctx.env.month : null; + const missing = []; + + if (yearValue === null) { + missing.push({ kind: 'arg', key: 'year' } as const); + } + if (monthValue === null) { + missing.push({ kind: 'arg', key: 'month' } as const); + } + if (envYearValue === null) { + missing.push({ kind: 'env', key: 'year' } as const); + } + if (envMonthValue === null) { + missing.push({ kind: 'env', key: 'month' } as const); + } + + if ( + missing.length > 0 || + yearValue === null || + monthValue === null || + envYearValue === null || + envMonthValue === null + ) { + return unknownOrDeny(ctx, missing, '기한 정보가 없습니다.'); + } + + const currentMonth = resolveMonthIndex(envYearValue, envMonthValue); + const targetMonth = resolveMonthIndex(yearValue, monthValue); + if (targetMonth < currentMonth + minMonths) { + return { + kind: 'deny', + reason: `기한은 ${minMonths}개월 이상이어야 합니다.`, + }; + } + return allow(); + }, +}); + +// 불가침 제의를 처리하는 국가 커맨드. +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionDefinition { + public readonly key = 'che_불가침제의'; + public readonly name = ACTION_NAME; + + parseArgs(raw: unknown): NonAggressionProposalArgs | null { + const data = raw as { + destNationId?: unknown; + year?: unknown; + month?: unknown; + }; + const destNationId = parseNationId(data?.destNationId); + const year = parseYear(data?.year); + const month = parseMonth(data?.month); + if (destNationId === null || year === null || month === null) { + return null; + } + return { destNationId, year, month }; + } + + buildConstraints( + _ctx: ConstraintContext, + _args: NonAggressionProposalArgs + ): Constraint[] { + return [ + beChief(), + notBeNeutral(), + existsDestNation(), + differentDestNation(), + requireMinimumTerm(MIN_TERM_MONTHS), + disallowDiplomacyBetweenStatus({ + 0: '아국과 이미 교전중입니다.', + 1: '아국과 이미 선포중입니다.', + }), + ]; + } + + resolve( + _context: GeneralActionResolveContext, + args: NonAggressionProposalArgs + ): 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, year: 0, month: 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 61f8cbf..703d949 100644 --- a/packages/logic/src/actions/turn/nation/index.ts +++ b/packages/logic/src/actions/turn/nation/index.ts @@ -4,6 +4,7 @@ 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'; +import type * as NonAggressionProposalModule from './che_불가침제의.js'; @@ -11,7 +12,8 @@ export type NationTurnCommandModule = | typeof NationRestModule | typeof AwardModule | typeof AssignmentModule - | typeof DeclarationModule; + | typeof DeclarationModule + | typeof NonAggressionProposalModule; export type NationTurnCommandImporter = () => Promise; @@ -20,6 +22,7 @@ const defaultImporters = { che_포상: async () => import('./che_포상.js'), che_발령: async () => import('./che_발령.js'), che_선전포고: async () => import('./che_선전포고.js'), + che_불가침제의: async () => import('./che_불가침제의.js'), } as const satisfies Record; export type NationTurnCommandKey = keyof typeof defaultImporters; @@ -95,3 +98,6 @@ export { export { ActionDefinition as DeclarationActionDefinition, } from './che_선전포고.js'; +export { + ActionDefinition as NonAggressionProposalActionDefinition, +} from './che_불가침제의.js'; diff --git a/packages/logic/src/constraints/general.ts b/packages/logic/src/constraints/general.ts index fee6268..49969d2 100644 --- a/packages/logic/src/constraints/general.ts +++ b/packages/logic/src/constraints/general.ts @@ -1,5 +1,11 @@ import type { General } from '../domain/entities.js'; -import { allow, readDestGeneral, resolveDestGeneralId, unknownOrDeny } from './helpers.js'; +import { + allow, + readDestGeneral, + resolveDestGeneralId, + resolveDestNationId, + unknownOrDeny, +} from './helpers.js'; import type { Constraint, ConstraintContext, RequirementKey, StateView } from './types.js'; export const notBeNeutral = (): Constraint => ({ @@ -172,6 +178,47 @@ export const existsDestGeneral = (): Constraint => ({ }, }); +export const destGeneralInDestNation = (): Constraint => ({ + name: 'DestGeneralInDestNation', + requires: (ctx) => { + const reqs: RequirementKey[] = []; + const destGeneralId = resolveDestGeneralId(ctx); + if (destGeneralId !== undefined) { + reqs.push({ kind: 'destGeneral', id: destGeneralId }); + } + const destNationId = resolveDestNationId(ctx); + if (destNationId !== undefined) { + reqs.push({ kind: 'destNation', id: destNationId }); + } + return reqs; + }, + test: (ctx, view) => { + const destGeneral = readDestGeneral(ctx, view); + if (!destGeneral) { + const destGeneralId = resolveDestGeneralId(ctx); + if (destGeneralId === undefined) { + return unknownOrDeny(ctx, [], '장수 정보가 없습니다.'); + } + const req: RequirementKey = { + kind: 'destGeneral', + id: destGeneralId, + }; + return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.'); + } + const destNationId = resolveDestNationId(ctx); + if (destNationId === undefined) { + return unknownOrDeny(ctx, [], '국가 정보가 없습니다.'); + } + if (destGeneral.nationId !== destNationId) { + return { + kind: 'deny', + reason: '제의 장수가 국가 소속이 아닙니다.', + }; + } + return allow(); + }, +}); + export const friendlyDestGeneral = (): Constraint => ({ name: 'FriendlyDestGeneral', requires: (ctx) => { diff --git a/packages/logic/src/constraints/nation.ts b/packages/logic/src/constraints/nation.ts index fa00399..412ec1d 100644 --- a/packages/logic/src/constraints/nation.ts +++ b/packages/logic/src/constraints/nation.ts @@ -1,5 +1,12 @@ import type { General, Nation } from '../domain/entities.js'; -import { allow, readMetaNumber, readNation, resolveDestNationId, unknownOrDeny } from './helpers.js'; +import { + allow, + readGeneral, + readMetaNumber, + readNation, + resolveDestNationId, + unknownOrDeny, +} from './helpers.js'; import type { Constraint, ConstraintContext, RequirementKey, StateView } from './types.js'; export const notWanderingNation = (): Constraint => ({ @@ -166,3 +173,42 @@ export const existsDestNation = (): Constraint => ({ return allow(); }, }); + +export const differentDestNation = (): Constraint => ({ + name: 'DifferentDestNation', + requires: (ctx) => { + const reqs: RequirementKey[] = []; + if (ctx.nationId !== undefined) { + reqs.push({ kind: 'nation', id: ctx.nationId }); + } else { + reqs.push({ kind: 'general', id: ctx.actorId }); + } + const destNationId = resolveDestNationId(ctx); + if (destNationId !== undefined) { + reqs.push({ kind: 'destNation', id: destNationId }); + } + return reqs; + }, + test: (ctx, view) => { + const destNationId = resolveDestNationId(ctx); + if (destNationId === undefined) { + return unknownOrDeny(ctx, [], '국가 정보가 없습니다.'); + } + let baseNationId = ctx.nationId; + if (baseNationId === undefined) { + const general = readGeneral(ctx, view); + if (!general) { + const req: RequirementKey = { + kind: 'general', + id: ctx.actorId, + }; + return unknownOrDeny(ctx, [req], '국가 정보가 없습니다.'); + } + baseNationId = general.nationId; + } + if (baseNationId === destNationId) { + return { kind: 'deny', reason: '같은 국가입니다.' }; + } + return allow(); + }, +}); diff --git a/resources/turn-commands/default.json b/resources/turn-commands/default.json index c9cc037..9ff164c 100644 --- a/resources/turn-commands/default.json +++ b/resources/turn-commands/default.json @@ -24,6 +24,7 @@ "휴식", "che_포상", "che_발령", - "che_선전포고" + "che_선전포고", + "che_불가침제의" ] }