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..5e71182 --- /dev/null +++ b/packages/logic/src/actions/turn/nation/che_발령.ts @@ -0,0 +1,205 @@ +import type { + City, + General, + GeneralTriggerState, + TriggerValue, +} from '../../../domain/entities.js'; +import type { + Constraint, + ConstraintContext, +} from '../../../constraints/types.js'; +import { + alwaysFail, + beChief, + existsDestGeneral, + friendlyDestGeneral, + notBeNeutral, + occupiedCity, + occupiedDestCity, + suppliedCity, + suppliedDestCity, +} from '../../../constraints/presets.js'; +import type { GeneralActionDefinition } from '../../definition.js'; +import type { + GeneralActionEffect, + GeneralActionOutcome, + GeneralActionResolveContext, +} from '../../engine.js'; +import { createGeneralPatchEffect, createLogEffect } from '../../engine.js'; +import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; + +export interface AssignmentArgs { + destGeneralId: number; + destCityId: number; +} + +export interface AssignmentResolveContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> extends GeneralActionResolveContext { + destGeneral: General; + destCity: City; + currentYear: number; + currentMonth: number; + turnTermMinutes?: number; + generalTurnTime?: Date; + destGeneralTurnTime?: Date; +} + +export interface AssignmentEnvironment { + formatCityName?: (city: City) => string; +} + +const ACTION_NAME = '발령'; + +const joinYearMonth = (year: number, month: number): number => + year * 12 + month - 1; + +const cutTurn = (time: Date, turnTermMinutes: number): number => { + const turnMs = turnTermMinutes * 60 * 1000; + return Math.floor(time.getTime() / turnMs); +}; + +const resolveLastAssignment = ( + context: AssignmentResolveContext +): number => { + let yearMonth = joinYearMonth(context.currentYear, context.currentMonth); + const term = context.turnTermMinutes; + const srcTime = context.generalTurnTime; + const destTime = context.destGeneralTurnTime; + if (term && srcTime && destTime) { + if (cutTurn(srcTime, term) !== cutTurn(destTime, term)) { + yearMonth += 1; + } + } + return yearMonth; +}; + +const addMetaValue = ( + meta: Record, + key: string, + value: TriggerValue +): Record => ({ + ...meta, + [key]: value, +}); + +// 발령 결과를 계산한다. +export class ActionResolver< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> { + private readonly env: AssignmentEnvironment; + + constructor(env: AssignmentEnvironment) { + this.env = env; + } + + resolve( + context: AssignmentResolveContext, + _args: AssignmentArgs + ): GeneralActionOutcome { + void _args; + const destGeneral = context.destGeneral; + const destCity = context.destCity; + const cityName = this.env.formatCityName + ? this.env.formatCityName(destCity) + : destCity.name; + const yearMonth = resolveLastAssignment(context); + + const effects: Array> = [ + createGeneralPatchEffect( + { + cityId: destCity.id, + meta: addMetaValue(destGeneral.meta, 'last발령', yearMonth), + }, + destGeneral.id + ), + ]; + + effects.push( + createLogEffect( + `${context.general.name}에 의해 ${cityName}로 발령됐습니다.`, + { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: destGeneral.id, + format: LogFormat.MONTH, + } + ) + ); + effects.push( + createLogEffect( + `${destGeneral.name}을 ${cityName}로 발령했습니다.`, + { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + } + ) + ); + + return { effects }; + } +} + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionDefinition< + TriggerState, + AssignmentArgs, + AssignmentResolveContext + > { + public readonly key = 'che_발령'; + public readonly name = ACTION_NAME; + private readonly resolver: ActionResolver; + + constructor(env: AssignmentEnvironment) { + this.resolver = new ActionResolver(env); + } + + parseArgs(raw: unknown): AssignmentArgs | null { + if (!raw || typeof raw !== 'object') { + return null; + } + const data = raw as { + destGeneralId?: unknown; + destCityId?: unknown; + }; + if (typeof data.destGeneralId !== 'number') { + return null; + } + if (typeof data.destCityId !== 'number') { + return null; + } + return { + destGeneralId: data.destGeneralId, + destCityId: data.destCityId, + }; + } + + buildConstraints( + ctx: ConstraintContext, + _args: AssignmentArgs + ): Constraint[] { + void _args; + if (ctx.destGeneralId === ctx.actorId) { + return [alwaysFail('본인입니다')]; + } + return [ + beChief(), + notBeNeutral(), + occupiedCity(), + suppliedCity(), + existsDestGeneral(), + friendlyDestGeneral(), + occupiedDestCity(), + suppliedDestCity(), + ]; + } + + resolve( + context: AssignmentResolveContext, + args: AssignmentArgs + ): GeneralActionOutcome { + return this.resolver.resolve(context, args); + } +} 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..448d327 --- /dev/null +++ b/packages/logic/src/actions/turn/nation/che_포상.ts @@ -0,0 +1,266 @@ +import type { + General, + GeneralTriggerState, + Nation, +} from '../../../domain/entities.js'; +import type { + Constraint, + ConstraintContext, + RequirementKey, + StateView, +} from '../../../constraints/types.js'; +import { + alwaysFail, + beChief, + existsDestGeneral, + friendlyDestGeneral, + notBeNeutral, + occupiedCity, + reqNationGold, + reqNationRice, + suppliedCity, +} from '../../../constraints/presets.js'; +import type { GeneralActionDefinition } from '../../definition.js'; +import type { + GeneralActionEffect, + GeneralActionOutcome, + GeneralActionResolveContext, +} from '../../engine.js'; +import { + createGeneralPatchEffect, + createLogEffect, + createNationPatchEffect, +} from '../../engine.js'; +import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; + +export interface AwardArgs { + isGold: boolean; + amount: number; + destGeneralId: number; +} + +export interface AwardResolveContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> extends GeneralActionResolveContext { + destGeneral: General; +} + +export interface AwardEnvironment { + baseGold: number; + baseRice: number; + minAmount?: number; + maxAmount: number; + amountUnit?: number; +} + +const ACTION_NAME = '포상'; +const DEFAULT_MIN_AMOUNT = 100; +const DEFAULT_AMOUNT_UNIT = 100; + +const roundToUnit = (value: number, unit: number): number => + Math.round(value / unit) * unit; + +const formatNumber = (value: number): string => + value.toLocaleString('en-US'); + +const clamp = (value: number, min: number, max: number): number => + Math.min(Math.max(value, min), max); + +const normalizeAmount = ( + amount: number, + env: AwardEnvironment +): number => { + const unit = env.amountUnit ?? DEFAULT_AMOUNT_UNIT; + const min = env.minAmount ?? DEFAULT_MIN_AMOUNT; + const max = env.maxAmount; + return clamp(roundToUnit(amount, unit), min, max); +}; + +const resolveNationResource = ( + nation: Nation, + isGold: boolean +): { current: number; base: number; key: 'gold' | 'rice'; label: string } => ({ + current: isGold ? nation.gold : nation.rice, + base: isGold ? 0 : 0, + key: isGold ? 'gold' : 'rice', + label: isGold ? '금' : '쌀', +}); + +// 포상 비용 및 유효 범위를 계산한다. +export class CommandResolver { + private readonly env: AwardEnvironment; + + constructor(env: AwardEnvironment) { + this.env = env; + } + + getRequiredResource(isGold: boolean): number { + return 1 + (isGold ? this.env.baseGold : this.env.baseRice); + } + + normalizeAmount(amount: number): number { + return normalizeAmount(amount, this.env); + } +} + +// 포상 결과를 계산한다. +export class ActionResolver< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> { + private readonly env: AwardEnvironment; + private readonly command: CommandResolver; + + constructor(env: AwardEnvironment) { + this.env = env; + this.command = new CommandResolver(env); + } + + resolve( + context: AwardResolveContext, + args: AwardArgs + ): GeneralActionOutcome { + const nation = context.nation; + if (!nation) { + return { effects: [] }; + } + const { key, label } = resolveNationResource(nation, args.isGold); + const base = args.isGold ? this.env.baseGold : this.env.baseRice; + const available = Math.max(nation[key] - base, 0); + const amount = clamp( + this.command.normalizeAmount(args.amount), + 0, + available + ); + if (amount <= 0) { + return { effects: [] }; + } + + const amountText = formatNumber(amount); + const effects: Array> = [ + createGeneralPatchEffect( + { [key]: context.destGeneral[key] + amount } as Partial< + General + >, + context.destGeneral.id + ), + createNationPatchEffect({ + [key]: nation[key] - amount, + } as Partial, nation.id), + ]; + + effects.push( + createLogEffect( + `${label} ${amountText} 포상으로 받았습니다.`, + { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: context.destGeneral.id, + format: LogFormat.PLAIN, + } + ) + ); + effects.push( + createLogEffect( + `${context.destGeneral.name}에게 ${label} ${amountText} 수여했습니다.`, + { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + } + ) + ); + + return { effects }; + } +} + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionDefinition< + TriggerState, + AwardArgs, + AwardResolveContext + > { + public readonly key = 'che_포상'; + public readonly name = ACTION_NAME; + private readonly command: CommandResolver; + private readonly resolver: ActionResolver; + + constructor(env: AwardEnvironment) { + this.command = new CommandResolver(env); + this.resolver = new ActionResolver(env); + } + + parseArgs(raw: unknown): AwardArgs | null { + if (!raw || typeof raw !== 'object') { + return null; + } + const data = raw as { + isGold?: unknown; + amount?: unknown; + destGeneralId?: unknown; + }; + if (typeof data.isGold !== 'boolean') { + return null; + } + if (typeof data.amount !== 'number' || Number.isNaN(data.amount)) { + return null; + } + if (typeof data.destGeneralId !== 'number') { + return null; + } + const amount = this.command.normalizeAmount(data.amount); + if (amount <= 0) { + return null; + } + return { + isGold: data.isGold, + amount, + destGeneralId: data.destGeneralId, + }; + } + + buildConstraints( + ctx: ConstraintContext, + args: AwardArgs + ): Constraint[] { + const requirements: RequirementKey[] = []; + if (ctx.cityId !== undefined) { + requirements.push({ kind: 'city', id: ctx.cityId }); + } + if (ctx.nationId !== undefined) { + requirements.push({ kind: 'nation', id: ctx.nationId }); + } + if (ctx.destGeneralId !== undefined) { + requirements.push({ kind: 'destGeneral', id: ctx.destGeneralId }); + } + + if (ctx.destGeneralId === ctx.actorId) { + return [alwaysFail('본인입니다')]; + } + + const getRequired = (_ctx: ConstraintContext, _view: StateView): number => + this.command.getRequiredResource(args.isGold); + + const resourceConstraint = args.isGold + ? reqNationGold(getRequired, requirements) + : reqNationRice(getRequired, requirements); + + return [ + notBeNeutral(), + occupiedCity(), + beChief(), + suppliedCity(), + existsDestGeneral(), + friendlyDestGeneral(), + resourceConstraint, + ]; + } + + resolve( + context: AwardResolveContext, + args: AwardArgs + ): GeneralActionOutcome { + return this.resolver.resolve(context, args); + } +} diff --git a/packages/logic/src/actions/turn/nation/index.ts b/packages/logic/src/actions/turn/nation/index.ts index cb0ff5c..aef1c75 100644 --- a/packages/logic/src/actions/turn/nation/index.ts +++ b/packages/logic/src/actions/turn/nation/index.ts @@ -1 +1,50 @@ -export {}; +export type NationTurnCommandKey = '휴식' | 'che_포상' | 'che_발령'; + +export type NationTurnCommandModule = + | typeof import('./휴식.js') + | typeof import('./che_포상.js') + | typeof import('./che_발령.js'); + +export type NationTurnCommandImporter = () => Promise; + +const defaultImporters: Record< + NationTurnCommandKey, + NationTurnCommandImporter +> = { + 휴식: async () => import('./휴식.js'), + che_포상: async () => import('./che_포상.js'), + che_발령: async () => import('./che_발령.js'), +}; + +export class NationTurnCommandLoader { + constructor( + private readonly importers: Record< + NationTurnCommandKey, + NationTurnCommandImporter + > = defaultImporters + ) {} + + async load( + key: NationTurnCommandKey + ): Promise { + const importer = this.importers[key]; + if (!importer) { + throw new Error(`Unknown nation turn command key: ${key}`); + } + return importer(); + } +} + +export { + ActionDefinition as NationRestActionDefinition, + ActionResolver as NationRestActionResolver, +} from './휴식.js'; +export { + ActionDefinition as AwardActionDefinition, + ActionResolver as AwardActionResolver, + CommandResolver as AwardCommandResolver, +} from './che_포상.js'; +export { + ActionDefinition as AssignmentActionDefinition, + ActionResolver as AssignmentActionResolver, +} from './che_발령.js'; diff --git a/packages/logic/src/actions/turn/nation/휴식.ts b/packages/logic/src/actions/turn/nation/휴식.ts new file mode 100644 index 0000000..3f09ed6 --- /dev/null +++ b/packages/logic/src/actions/turn/nation/휴식.ts @@ -0,0 +1,58 @@ +import type { + GeneralTriggerState, +} from '../../../domain/entities.js'; +import type { + Constraint, + ConstraintContext, +} from '../../../constraints/types.js'; +import type { GeneralActionDefinition } from '../../definition.js'; +import type { + GeneralActionOutcome, + GeneralActionResolveContext, +} from '../../engine.js'; + +export interface NationRestArgs {} + +const ACTION_NAME = '휴식'; + +export class ActionResolver< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> { + resolve( + _context: GeneralActionResolveContext, + _args: NationRestArgs + ): GeneralActionOutcome { + void _context; + void _args; + return { effects: [] }; + } +} + +export class ActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> implements GeneralActionDefinition { + public readonly key = '휴식'; + public readonly name = ACTION_NAME; + private readonly resolver = new ActionResolver(); + + parseArgs(_raw: unknown): NationRestArgs | null { + void _raw; + return {}; + } + + buildConstraints( + _ctx: ConstraintContext, + _args: NationRestArgs + ): Constraint[] { + void _ctx; + void _args; + return []; + } + + resolve( + context: GeneralActionResolveContext, + args: NationRestArgs + ): GeneralActionOutcome { + return this.resolver.resolve(context, args); + } +} diff --git a/packages/logic/src/constraints/presets.ts b/packages/logic/src/constraints/presets.ts index 3377faf..2d0aaac 100644 --- a/packages/logic/src/constraints/presets.ts +++ b/packages/logic/src/constraints/presets.ts @@ -40,6 +40,21 @@ const readCity = (view: StateView, id?: number): City | null => { return view.get(req) as City | null; }; +const readDestGeneral = ( + ctx: ConstraintContext, + view: StateView +): General | null => { + const destGeneralId = resolveDestGeneralId(ctx); + if (destGeneralId === undefined) { + return null; + } + const req: RequirementKey = { kind: 'destGeneral', id: destGeneralId }; + if (!view.has(req)) { + return null; + } + return view.get(req) as General | null; +}; + const resolveDestCityId = (ctx: ConstraintContext): number | undefined => { if (ctx.destCityId !== undefined) { return ctx.destCityId; @@ -48,6 +63,14 @@ const resolveDestCityId = (ctx: ConstraintContext): number | undefined => { return typeof raw === 'number' ? raw : undefined; }; +const resolveDestGeneralId = (ctx: ConstraintContext): number | undefined => { + if (ctx.destGeneralId !== undefined) { + return ctx.destGeneralId; + } + const raw = ctx.args?.destGeneralId; + return typeof raw === 'number' ? raw : undefined; +}; + const resolveDestNationId = (ctx: ConstraintContext): number | undefined => { if (ctx.destNationId !== undefined) { return ctx.destNationId; @@ -135,6 +158,12 @@ export const notBeNeutral = (): Constraint => ({ }, }); +export const alwaysFail = (reason: string): Constraint => ({ + name: 'AlwaysFail', + requires: () => [], + test: () => ({ kind: 'deny', reason }), +}); + export const beChief = (): Constraint => ({ name: 'BeChief', requires: (ctx) => [{ kind: 'general', id: ctx.actorId }], @@ -219,6 +248,78 @@ export const availableStrategicCommand = ( }, }); +export const reqNationGold = ( + getRequiredGold: (ctx: ConstraintContext, view: StateView) => number, + requirements: RequirementKey[] = [] +): Constraint => ({ + name: 'ReqNationGold', + requires: (ctx) => { + const reqs: RequirementKey[] = [...requirements]; + if (ctx.nationId !== undefined) { + reqs.push({ kind: 'nation', id: ctx.nationId }); + } + return reqs; + }, + test: (ctx, view) => { + const nationId = ctx.nationId; + if (nationId === undefined) { + return unknownOrDeny(ctx, [], '국가 정보가 없습니다.'); + } + const nationReq: RequirementKey = { kind: 'nation', id: nationId }; + const missing = [nationReq, ...requirements].filter( + (req) => !view.has(req) + ); + if (missing.length > 0) { + return unknownOrDeny(ctx, missing, '국가 정보가 없습니다.'); + } + const nation = view.get(nationReq) as Nation | null; + if (!nation) { + return unknownOrDeny(ctx, [nationReq], '국가 정보가 없습니다.'); + } + const required = getRequiredGold(ctx, view); + if (nation.gold >= required) { + return allow(); + } + return { kind: 'deny', reason: '자금이 모자랍니다.' }; + }, +}); + +export const reqNationRice = ( + getRequiredRice: (ctx: ConstraintContext, view: StateView) => number, + requirements: RequirementKey[] = [] +): Constraint => ({ + name: 'ReqNationRice', + requires: (ctx) => { + const reqs: RequirementKey[] = [...requirements]; + if (ctx.nationId !== undefined) { + reqs.push({ kind: 'nation', id: ctx.nationId }); + } + return reqs; + }, + test: (ctx, view) => { + const nationId = ctx.nationId; + if (nationId === undefined) { + return unknownOrDeny(ctx, [], '국가 정보가 없습니다.'); + } + const nationReq: RequirementKey = { kind: 'nation', id: nationId }; + const missing = [nationReq, ...requirements].filter( + (req) => !view.has(req) + ); + if (missing.length > 0) { + return unknownOrDeny(ctx, missing, '국가 정보가 없습니다.'); + } + const nation = view.get(nationReq) as Nation | null; + if (!nation) { + return unknownOrDeny(ctx, [nationReq], '국가 정보가 없습니다.'); + } + const required = getRequiredRice(ctx, view); + if (nation.rice >= required) { + return allow(); + } + return { kind: 'deny', reason: '군량이 모자랍니다.' }; + }, +}); + export const notOpeningPart = ( relYear: number, openingPartYear: number @@ -272,6 +373,45 @@ export const occupiedCity = ( }, }); +export const occupiedDestCity = (): Constraint => ({ + name: 'OccupiedDestCity', + requires: (ctx) => { + const reqs: RequirementKey[] = [{ kind: 'general', id: ctx.actorId }]; + const destCityId = resolveDestCityId(ctx); + if (destCityId !== undefined) { + reqs.push({ kind: 'destCity', id: destCityId }); + } + return reqs; + }, + test: (ctx, view) => { + const generalReq: RequirementKey = { kind: 'general', id: ctx.actorId }; + if (!view.has(generalReq)) { + return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.'); + } + const general = view.get(generalReq) as General | null; + if (!general) { + return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.'); + } + const destCity = readDestCity(ctx, view); + if (!destCity) { + const destCityId = resolveDestCityId(ctx); + if (destCityId === undefined) { + return unknownOrDeny(ctx, [], '도시 정보가 없습니다.'); + } + const req: RequirementKey = { + kind: 'destCity', + id: destCityId, + }; + return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.'); + } + const baseNationId = ctx.nationId ?? general.nationId; + if (destCity.nationId === baseNationId) { + return allow(); + } + return { kind: 'deny', reason: '아국이 아닙니다.' }; + }, +}); + export const suppliedCity = (): Constraint => ({ name: 'SuppliedCity', requires: (ctx) => @@ -292,6 +432,37 @@ export const suppliedCity = (): Constraint => ({ }, }); +export const suppliedDestCity = (): Constraint => ({ + name: 'SuppliedDestCity', + requires: (ctx) => + resolveDestCityId(ctx) !== undefined + ? [ + { + kind: 'destCity', + id: resolveDestCityId(ctx) ?? 0, + }, + ] + : [], + test: (ctx, view) => { + const destCity = readDestCity(ctx, view); + if (!destCity) { + const destCityId = resolveDestCityId(ctx); + if (destCityId === undefined) { + return unknownOrDeny(ctx, [], '도시 정보가 없습니다.'); + } + const req: RequirementKey = { + kind: 'destCity', + id: destCityId, + }; + return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.'); + } + if (destCity.supplyState) { + return allow(); + } + return { kind: 'deny', reason: '고립된 도시입니다.' }; + }, +}); + export const reqGeneralGold = ( getRequiredGold: (ctx: ConstraintContext, view: StateView) => number, requirements: RequirementKey[] = [] @@ -402,6 +573,72 @@ export const existsDestCity = (): Constraint => ({ }, }); +export const existsDestGeneral = (): Constraint => ({ + name: 'ExistsDestGeneral', + requires: (ctx) => + resolveDestGeneralId(ctx) !== undefined + ? [ + { + kind: 'destGeneral', + id: resolveDestGeneralId(ctx) ?? 0, + }, + ] + : [], + test: (ctx, view) => { + const destGeneralId = resolveDestGeneralId(ctx); + if (destGeneralId === undefined) { + return unknownOrDeny(ctx, [], '장수 정보가 없습니다.'); + } + const req: RequirementKey = { kind: 'destGeneral', id: destGeneralId }; + if (!view.has(req)) { + return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.'); + } + const general = view.get(req) as General | null; + if (!general) { + return { kind: 'deny', reason: '장수 정보가 없습니다.' }; + } + return allow(); + }, +}); + +export const friendlyDestGeneral = (): Constraint => ({ + name: 'FriendlyDestGeneral', + requires: (ctx) => { + const reqs: RequirementKey[] = [{ kind: 'general', id: ctx.actorId }]; + const destGeneralId = resolveDestGeneralId(ctx); + if (destGeneralId !== undefined) { + reqs.push({ kind: 'destGeneral', id: destGeneralId }); + } + return reqs; + }, + test: (ctx, view) => { + const generalReq: RequirementKey = { kind: 'general', id: ctx.actorId }; + if (!view.has(generalReq)) { + return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.'); + } + const general = view.get(generalReq) as General | null; + if (!general) { + return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.'); + } + 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], '장수 정보가 없습니다.'); + } + if (destGeneral.nationId === general.nationId) { + return allow(); + } + return { kind: 'deny', reason: '아군이 아닙니다.' }; + }, +}); + export const notOccupiedDestCity = (): Constraint => ({ name: 'NotOccupiedDestCity', requires: (ctx) => {