feat: add non-aggression proposal and acceptance actions with constraints

This commit is contained in:
2026-01-02 18:33:24 +00:00
parent cb86a47f94
commit 10ff64946e
8 changed files with 524 additions and 5 deletions
@@ -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<TriggerState> {
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<TriggerState>
> {
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<TriggerState>,
args: NonAggressionAcceptArgs
): GeneralActionOutcome<TriggerState> {
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,
}
),
],
};
}
}
@@ -1 +1 @@
export {};
export * from './che_불가침수락.js';
@@ -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<TriggerState, NonAggressionProposalArgs> {
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<TriggerState>,
args: NonAggressionProposalArgs
): GeneralActionOutcome<TriggerState> {
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(),
};
@@ -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<NationTurnCommandModule>;
@@ -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<string, NationTurnCommandImporter>;
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';