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
@@ -216,6 +216,11 @@ const ACTION_CONTEXT_BUILDERS: Record<string, ActionContextBuilder> = {
startYear: resolveStartYear(options.world, options.scenarioMeta),
};
},
che_불가침제의: (base, options) => ({
...base,
currentYear: options.world.currentYear,
currentMonth: options.world.currentMonth,
}),
};
export const buildActionContext = (
@@ -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';
+48 -1
View File
@@ -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) => {
+47 -1
View File
@@ -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();
},
});
+2 -1
View File
@@ -24,6 +24,7 @@
"휴식",
"che_포상",
"che_발령",
"che_선전포고"
"che_선전포고",
"che_불가침제의"
]
}