From 15730feafe546791eec42a6b66bed50177f1fb00 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Fri, 6 Feb 2026 18:55:43 +0000 Subject: [PATCH] Refactor constraints and action definitions for improved clarity and functionality - Renamed `requireMinimumTerm` to `reqMinimumTreatyTerm` for consistency in naming conventions. - Introduced `battleGroundCity` constraint to encapsulate logic for checking if a city is in a war zone. - Added `hasRouteToDestCity` constraint to validate city accessibility based on distance and resource requirements. - Replaced `alwaysFail` with `denyWithReason` for better error handling in action definitions. - Implemented `reqEnvValue` for environment value checks with comparison operators. - Enhanced nation constraints with `reqNationValue` and `reqDestNationValue` for flexible value comparisons. - Added `wanderingNation` constraint to check if a nation is classified as wandering. - Improved general constraints with `reqGeneralValue` for dynamic value checks. - Updated various action definitions to utilize new constraints for better maintainability and readability. --- .../actions/instant/nation/che_불가침수락.ts | 26 +- .../instant/nation/che_불가침파기수락.ts | 21 +- .../actions/instant/nation/che_종전수락.ts | 21 +- .../src/actions/turn/general/che_NPC능동.ts | 14 +- .../src/actions/turn/general/che_건국.ts | 24 +- .../src/actions/turn/general/che_등용.ts | 52 +-- .../src/actions/turn/general/che_등용수락.ts | 25 +- .../src/actions/turn/general/che_랜덤임관.ts | 6 +- .../src/actions/turn/general/che_방랑.ts | 17 +- .../src/actions/turn/general/che_선동.ts | 20 +- .../src/actions/turn/general/che_이동.ts | 47 +-- .../src/actions/turn/general/che_임관.ts | 30 +- .../src/actions/turn/general/che_첩보.ts | 5 +- .../src/actions/turn/general/che_출병.ts | 9 +- .../src/actions/turn/general/che_탈취.ts | 20 +- .../src/actions/turn/general/che_파괴.ts | 20 +- .../actions/turn/general/cityDevelopment.ts | 6 +- .../src/actions/turn/nation/che_국기변경.ts | 27 +- .../src/actions/turn/nation/che_국호변경.ts | 34 +- .../logic/src/actions/turn/nation/che_몰수.ts | 2 +- .../actions/turn/nation/che_무작위수도이전.ts | 24 +- .../logic/src/actions/turn/nation/che_발령.ts | 4 +- .../actions/turn/nation/che_부대탈퇴지시.ts | 4 +- .../src/actions/turn/nation/che_불가침제의.ts | 6 +- .../src/actions/turn/nation/che_선전포고.ts | 4 +- .../logic/src/actions/turn/nation/che_수몰.ts | 8 +- .../logic/src/actions/turn/nation/che_천도.ts | 80 +++-- .../logic/src/actions/turn/nation/che_포상.ts | 4 +- .../src/actions/turn/nation/che_피장파장.ts | 6 +- packages/logic/src/constraints/city.ts | 22 ++ packages/logic/src/constraints/general.ts | 155 ++++++++- packages/logic/src/constraints/helpers.ts | 25 ++ packages/logic/src/constraints/misc.ts | 30 +- packages/logic/src/constraints/nation.ts | 190 ++++++++++- tools/compare-command-constraints.mjs | 314 +++++++++++++++++- 35 files changed, 1004 insertions(+), 298 deletions(-) diff --git a/packages/logic/src/actions/instant/nation/che_불가침수락.ts b/packages/logic/src/actions/instant/nation/che_불가침수락.ts index 2c46280..e1fb122 100644 --- a/packages/logic/src/actions/instant/nation/che_불가침수락.ts +++ b/packages/logic/src/actions/instant/nation/che_불가침수락.ts @@ -2,12 +2,12 @@ import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; import { beChief, - destGeneralInDestNation, disallowDiplomacyBetweenStatus, existsDestGeneral, existsDestNation, notBeNeutral, occupiedCity, + reqDestNationValue, suppliedCity, } from '@sammo-ts/logic/constraints/presets.js'; import { allow, unknownOrDeny } from '@sammo-ts/logic/constraints/helpers.js'; @@ -64,8 +64,8 @@ const parseMonth = (raw: unknown): number | null => { const resolveMonthIndex = (year: number, month: number): number => year * 12 + month - 1; -const requireFutureTerm = (): Constraint => ({ - name: 'RequireNonAggressionFutureTerm', +const reqFutureTreatyTerm = (): Constraint => ({ + name: 'reqFutureTreatyTerm', requires: () => [ { kind: 'arg', key: 'year' }, { kind: 'arg', key: 'month' }, @@ -114,21 +114,6 @@ const requireFutureTerm = (): Constraint => ({ }, }); -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, @@ -161,9 +146,8 @@ export class ActionDefinition< suppliedCity(), existsDestNation(), existsDestGeneral(), - destGeneralInDestNation(), - notSameDestGeneral(), - requireFutureTerm(), + reqDestNationValue('level', '국가규모', '>', 0, '상대국 정보가 없습니다.'), + reqFutureTreatyTerm(), disallowDiplomacyBetweenStatus({ 0: '아국과 이미 교전중입니다.', 1: '아국과 이미 선포중입니다.', diff --git a/packages/logic/src/actions/instant/nation/che_불가침파기수락.ts b/packages/logic/src/actions/instant/nation/che_불가침파기수락.ts index 1b314cf..ad3f146 100644 --- a/packages/logic/src/actions/instant/nation/che_불가침파기수락.ts +++ b/packages/logic/src/actions/instant/nation/che_불가침파기수락.ts @@ -3,12 +3,11 @@ import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/ import { allowDiplomacyBetweenStatus, beChief, - destGeneralInDestNation, existsDestGeneral, existsDestNation, notBeNeutral, + reqDestNationValue, } from '@sammo-ts/logic/constraints/presets.js'; -import { allow, unknownOrDeny } from '@sammo-ts/logic/constraints/helpers.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js'; import { createDiplomacyPatchEffect, createLogEffect } from '@sammo-ts/logic/actions/engine.js'; @@ -37,21 +36,6 @@ const parseGeneralId = (raw: unknown): number | 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, @@ -75,8 +59,7 @@ export class ActionDefinition< notBeNeutral(), existsDestNation(), existsDestGeneral(), - destGeneralInDestNation(), - notSameDestGeneral(), + reqDestNationValue('level', '국가규모', '>', 0, '상대국 정보가 없습니다.'), allowDiplomacyBetweenStatus([DIPLOMACY_NON_AGGRESSION], '불가침 중인 상대국에게만 가능합니다.'), ]; } diff --git a/packages/logic/src/actions/instant/nation/che_종전수락.ts b/packages/logic/src/actions/instant/nation/che_종전수락.ts index 974043b..9265ab1 100644 --- a/packages/logic/src/actions/instant/nation/che_종전수락.ts +++ b/packages/logic/src/actions/instant/nation/che_종전수락.ts @@ -3,12 +3,11 @@ import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/ import { allowDiplomacyBetweenStatus, beChief, - destGeneralInDestNation, existsDestGeneral, existsDestNation, notBeNeutral, + reqDestNationValue, } from '@sammo-ts/logic/constraints/presets.js'; -import { allow, unknownOrDeny } from '@sammo-ts/logic/constraints/helpers.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js'; import { createDiplomacyPatchEffect, createLogEffect } from '@sammo-ts/logic/actions/engine.js'; @@ -37,21 +36,6 @@ const parseGeneralId = (raw: unknown): number | 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, @@ -75,8 +59,7 @@ export class ActionDefinition< notBeNeutral(), existsDestNation(), existsDestGeneral(), - destGeneralInDestNation(), - notSameDestGeneral(), + reqDestNationValue('level', '국가규모', '>', 0, '상대국 정보가 없습니다.'), allowDiplomacyBetweenStatus([0, 1], '상대국과 선포, 전쟁중이지 않습니다.'), ]; } diff --git a/packages/logic/src/actions/turn/general/che_NPC능동.ts b/packages/logic/src/actions/turn/general/che_NPC능동.ts index c926229..061e97f 100644 --- a/packages/logic/src/actions/turn/general/che_NPC능동.ts +++ b/packages/logic/src/actions/turn/general/che_NPC능동.ts @@ -1,6 +1,5 @@ import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; -import { mustBeNPC, existsDestCity } from '@sammo-ts/logic/constraints/presets.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionOutcome, @@ -40,6 +39,9 @@ export class ActionResolver< resolve(context: NPCSelfResolveContext, args: NPCSelfArgs): GeneralActionOutcome { const general = context.general; const effects: GeneralActionEffect[] = []; + if (general.npcState < 2) { + throw new Error('NPC가 아닙니다.'); + } if (args.optionText === '순간이동') { if (args.destCityId === undefined) { @@ -98,13 +100,9 @@ export class ActionDefinition< } buildConstraints(_ctx: ConstraintContext, args: NPCSelfArgs): Constraint[] { - const constraints = [mustBeNPC()]; - - if (args.optionText === '순간이동') { - constraints.push(existsDestCity()); - } - - return constraints; + void _ctx; + void args; + return []; } resolve(context: NPCSelfResolveContext, args: NPCSelfArgs): GeneralActionOutcome { diff --git a/packages/logic/src/actions/turn/general/che_건국.ts b/packages/logic/src/actions/turn/general/che_건국.ts index 87ee6e2..fd9d063 100644 --- a/packages/logic/src/actions/turn/general/che_건국.ts +++ b/packages/logic/src/actions/turn/general/che_건국.ts @@ -1,13 +1,14 @@ import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; import { - beMonarch, - beWanderingNation, - beNeutralCity, - reqCityLevel, - reqNationGeneralCount, + beLord, + wanderingNation, + reqNationValue, checkNationNameDuplicate, beOpeningPart, + constructableCity, + allowJoinAction, + noPenalty, } from '@sammo-ts/logic/constraints/presets.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js'; @@ -80,18 +81,19 @@ export class ActionDefinition< } buildMinConstraints(_ctx: ConstraintContext, _args: FoundingArgs): Constraint[] { - return [beOpeningPart(), beWanderingNation()]; + return [beOpeningPart(), reqNationValue('level', '국가규모', '==', 0, '정식 국가가 아니어야합니다.'), noPenalty('noFoundNation')]; } buildConstraints(_ctx: ConstraintContext, args: FoundingArgs): Constraint[] { return [ beOpeningPart(), - beMonarch(), - beWanderingNation(), - reqNationGeneralCount(2), - beNeutralCity(), - reqCityLevel([5, 6]), // 소, 중 도시 + beLord(), + wanderingNation(), + reqNationValue('gennum', '수하 장수', '>=', 2), checkNationNameDuplicate(args.nationName), + allowJoinAction(), + constructableCity(), + noPenalty('noFoundNation'), ]; } diff --git a/packages/logic/src/actions/turn/general/che_등용.ts b/packages/logic/src/actions/turn/general/che_등용.ts index 521729e..5d49d55 100644 --- a/packages/logic/src/actions/turn/general/che_등용.ts +++ b/packages/logic/src/actions/turn/general/che_등용.ts @@ -6,6 +6,9 @@ import { suppliedCity, existsDestGeneral, resolveDestGeneralId, + reqGeneralGold, + reqGeneralRice, + reqEnvValue, } from '@sammo-ts/logic/constraints/presets.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { @@ -61,8 +64,8 @@ const differentNationDestGeneral = (): Constraint => ({ }, }); -const notDestGeneralLord = (): Constraint => ({ - name: 'NotDestGeneralLord', +const targetMustNotBeLord = (): Constraint => ({ + name: 'targetMustNotBeLord', requires: (ctx) => { const destGeneralId = resolveDestGeneralId(ctx); if (destGeneralId === undefined) return []; @@ -73,8 +76,8 @@ const notDestGeneralLord = (): Constraint => ({ const destGeneral = destId !== undefined ? (view.get({ kind: 'destGeneral', id: destId }) as General | null) : null; if (!destGeneral) return { kind: 'deny', reason: '장수 정보가 없습니다.' }; - if (destGeneral.officerLevel !== 12) return { kind: 'allow' }; - return { kind: 'deny', reason: '군주에게는 등용장을 보낼 수 없습니다.' }; + if (destGeneral.officerLevel === 12) return { kind: 'deny', reason: '군주에게는 등용장을 보낼 수 없습니다.' }; + return { kind: 'allow' }; }, }); @@ -160,38 +163,37 @@ export class ActionDefinition< } buildMinConstraints(_ctx: ConstraintContext, _args: EmployArgs): Constraint[] { - return [notBeNeutral(), occupiedCity(), suppliedCity()]; + return [ + reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다'), + notBeNeutral(), + occupiedCity(), + suppliedCity(), + ]; } buildConstraints(_ctx: ConstraintContext, args: EmployArgs): Constraint[] { const develCost = this.env.develCost ?? 100; - const reqCostConstraint: Constraint = { - name: 'ReqScoutCost', - requires: (c) => [ - { kind: 'general', id: c.actorId }, - { kind: 'destGeneral', id: args.destGeneralId }, - ], - test: (c, view) => { - const gen = view.get({ kind: 'general', id: c.actorId }) as General | null; - const dest = view.get({ kind: 'destGeneral', id: args.destGeneralId }) as General | null; - if (!gen || !dest) return { kind: 'deny', reason: '정보 부족' }; - - const cost = develCost + Math.floor((dest.experience + dest.dedication) / 1000) * 10; - if (gen.gold >= cost) return { kind: 'allow' }; - return { kind: 'deny', reason: `자금 ${cost}이 필요합니다.` }; - }, - }; - - return [ + const constraints: Constraint[] = [ + reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다'), notBeNeutral(), occupiedCity(), suppliedCity(), existsDestGeneral(), differentNationDestGeneral(), - notDestGeneralLord(), - reqCostConstraint, + reqGeneralGold( + (_c, view) => { + const dest = view.get({ kind: 'destGeneral', id: args.destGeneralId }) as General | null; + if (!dest) return develCost; + return develCost + Math.floor((dest.experience + dest.dedication) / 1000) * 10; + }, + [{ kind: 'destGeneral', id: args.destGeneralId }] + ), + reqGeneralRice(() => 0), ]; + + constraints.push(targetMustNotBeLord()); + return constraints; } resolve(context: GeneralActionResolveContext, args: EmployArgs): GeneralActionOutcome { diff --git a/packages/logic/src/actions/turn/general/che_등용수락.ts b/packages/logic/src/actions/turn/general/che_등용수락.ts index 742b144..6d0b72e 100644 --- a/packages/logic/src/actions/turn/general/che_등용수락.ts +++ b/packages/logic/src/actions/turn/general/che_등용수락.ts @@ -2,10 +2,12 @@ import type { General, GeneralTriggerState, Nation } from '@sammo-ts/logic/domai import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; import { existsDestNation, - existsDestGeneral, - notSameDestNation, - destGeneralInDestNation, - notLord, + beNeutral, + allowJoinDestNation, + reqDestNationValue, + differentDestNation, + reqGeneralValue, + reqEnvValue, readMetaNumberFromUnknown, } from '@sammo-ts/logic/constraints/presets.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; @@ -186,7 +188,20 @@ export class ActionDefinition< } buildConstraints(_ctx: ConstraintContext, _args: AcceptScoutArgs): Constraint[] { - return [existsDestNation(), existsDestGeneral(), notSameDestNation(), destGeneralInDestNation(), notLord()]; + const env = _ctx.env; + const year = typeof env.year === 'number' ? env.year : 0; + const startYear = typeof env.startyear === 'number' ? env.startyear : 0; + const relYear = year - startYear; + + return [ + reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다'), + existsDestNation(), + beNeutral(), + allowJoinDestNation(relYear), + reqDestNationValue('level', '국가규모', '>', 0, '방랑군에는 임관할 수 없습니다.'), + differentDestNation(), + reqGeneralValue('officerLevel', '직위', '!=', 12, '군주는 등용장을 수락할 수 없습니다'), + ]; } resolve( diff --git a/packages/logic/src/actions/turn/general/che_랜덤임관.ts b/packages/logic/src/actions/turn/general/che_랜덤임관.ts index c65483b..2f2aa6d 100644 --- a/packages/logic/src/actions/turn/general/che_랜덤임관.ts +++ b/packages/logic/src/actions/turn/general/che_랜덤임관.ts @@ -2,7 +2,7 @@ import type { RandomGenerator } from '@sammo-ts/common'; import { asRecord, JosaUtil } from '@sammo-ts/common'; import type { General, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; -import { beNeutral } from '@sammo-ts/logic/constraints/presets.js'; +import { beNeutral, allowJoinAction } from '@sammo-ts/logic/constraints/presets.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionEffect, @@ -134,11 +134,11 @@ export class ActionDefinition< } buildMinConstraints(_ctx: ConstraintContext, _args: RandomAppointmentArgs): Constraint[] { - return [beNeutral()]; + return []; } buildConstraints(_ctx: ConstraintContext, _args: RandomAppointmentArgs): Constraint[] { - return [beNeutral()]; + return [beNeutral(), allowJoinAction()]; } resolve( diff --git a/packages/logic/src/actions/turn/general/che_방랑.ts b/packages/logic/src/actions/turn/general/che_방랑.ts index 0920deb..d43b35a 100644 --- a/packages/logic/src/actions/turn/general/che_방랑.ts +++ b/packages/logic/src/actions/turn/general/che_방랑.ts @@ -1,6 +1,12 @@ import type { City, General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; import type { Constraint, ConstraintContext, RequirementKey } from '@sammo-ts/logic/constraints/types.js'; -import { allow, notWanderingNation, unknownOrDeny } from '@sammo-ts/logic/constraints/presets.js'; +import { + allow, + notWanderingNation, + unknownOrDeny, + notOpeningPart, + allowDiplomacyStatus, +} from '@sammo-ts/logic/constraints/presets.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionOutcome, @@ -184,7 +190,14 @@ export class ActionDefinition< } buildConstraints(_ctx: ConstraintContext, _args: WanderArgs): Constraint[] { - return [beLord(), notWanderingNation()]; + const relYear = typeof _ctx.env.relYear === 'number' ? _ctx.env.relYear : 0; + const openingPartYear = typeof _ctx.env.openingPartYear === 'number' ? _ctx.env.openingPartYear : 0; + return [ + beLord(), + notWanderingNation(), + notOpeningPart(relYear, openingPartYear), + allowDiplomacyStatus([2, 7], '방랑할 수 없는 외교상태입니다.'), + ]; } resolve(context: WanderResolveContext, args: WanderArgs): GeneralActionOutcome { diff --git a/packages/logic/src/actions/turn/general/che_선동.ts b/packages/logic/src/actions/turn/general/che_선동.ts index b4e5483..3faec62 100644 --- a/packages/logic/src/actions/turn/general/che_선동.ts +++ b/packages/logic/src/actions/turn/general/che_선동.ts @@ -2,10 +2,13 @@ import type { City, GeneralTriggerState } from '@sammo-ts/logic/domain/entities. import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; import { notBeNeutral, + occupiedCity, + suppliedCity, notOccupiedDestCity, + notNeutralDestCity, reqGeneralGold, reqGeneralRice, - existsDestCity, + disallowDiplomacyBetweenStatus, } from '@sammo-ts/logic/constraints/presets.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { @@ -136,15 +139,26 @@ export class ActionDefinition< return parseArgsWithSchema(ARGS_SCHEMA, raw); } + buildMinConstraints(ctx: ConstraintContext, _args: AgitateArgs): Constraint[] { + const env = ctx.env; + const cost = (env.develCost as number) ?? 100; + return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => cost), reqGeneralRice(() => cost)]; + } + buildConstraints(ctx: ConstraintContext, _args: AgitateArgs): Constraint[] { const env = ctx.env; const cost = (env.develCost as number) ?? 100; return [ notBeNeutral(), - existsDestCity(), + occupiedCity(), + suppliedCity(), + notOccupiedDestCity(), + notNeutralDestCity(), reqGeneralGold(() => cost), reqGeneralRice(() => cost), - notOccupiedDestCity(), + disallowDiplomacyBetweenStatus({ + 7: '불가침국입니다.', + }), ]; } diff --git a/packages/logic/src/actions/turn/general/che_이동.ts b/packages/logic/src/actions/turn/general/che_이동.ts index 194460d..5edb75d 100644 --- a/packages/logic/src/actions/turn/general/che_이동.ts +++ b/packages/logic/src/actions/turn/general/che_이동.ts @@ -1,11 +1,10 @@ import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; -import type { Constraint, ConstraintContext, RequirementKey } from '@sammo-ts/logic/constraints/types.js'; +import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; import { notSameDestCity, - existsDestCity, + nearCity, reqGeneralGold, - unknownOrDeny, - resolveDestCityId, + reqGeneralRice, } from '@sammo-ts/logic/constraints/presets.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { @@ -40,38 +39,6 @@ const ARGS_SCHEMA = z.object({ }); export type MoveArgs = z.infer; -// Helper for map connectivity -const connectedCity = (): Constraint => ({ - name: 'ConnectedCity', - requires: (ctx) => { - const reqs: RequirementKey[] = []; - if (ctx.cityId !== undefined) reqs.push({ kind: 'city', id: ctx.cityId }); - const destCityId = resolveDestCityId(ctx); - if (destCityId !== undefined) reqs.push({ kind: 'destCity', id: destCityId }); - reqs.push({ kind: 'env', key: 'map' }); - return reqs; - }, - test: (ctx, view) => { - const map = view.get({ kind: 'env', key: 'map' }) as MapDefinition | null; - if (!map) return unknownOrDeny(ctx, [], '지도 정보가 없습니다.'); - - const currentCityId = ctx.cityId; - const destCityId = resolveDestCityId(ctx); - - if (currentCityId === undefined || destCityId === undefined) { - return unknownOrDeny(ctx, [], '도시 정보가 없습니다.'); - } - - const currentCityDef = map.cities.find((c) => c.id === currentCityId); - if (!currentCityDef) return unknownOrDeny(ctx, [], '출발 도시 정보가 없습니다.'); - - if (currentCityDef.connections.includes(destCityId)) { - return { kind: 'allow' }; - } - return { kind: 'deny', reason: '인접하지 않은 도시입니다.' }; - }, -}); - export class ActionResolver< TriggerState extends GeneralTriggerState = GeneralTriggerState, > implements GeneralActionResolver { @@ -169,20 +136,20 @@ export class ActionDefinition< const develCost = ctx.env.develCost as number; return develCost ?? 0; }), + reqGeneralRice(() => 0), ]; } buildConstraints(ctx: ConstraintContext, _args: MoveArgs): Constraint[] { - const constraints = [ - existsDestCity(), + return [ notSameDestCity(), - connectedCity(), + nearCity(1), reqGeneralGold(() => { const develCost = ctx.env.develCost as number; return develCost ?? 0; }), + reqGeneralRice(() => 0), ]; - return constraints; } resolve(context: MoveResolveContext, args: MoveArgs): GeneralActionOutcome { diff --git a/packages/logic/src/actions/turn/general/che_임관.ts b/packages/logic/src/actions/turn/general/che_임관.ts index b22ccca..99ef9fb 100644 --- a/packages/logic/src/actions/turn/general/che_임관.ts +++ b/packages/logic/src/actions/turn/general/che_임관.ts @@ -1,6 +1,13 @@ import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; -import { beNeutral, existsDestNation } from '@sammo-ts/logic/constraints/presets.js'; +import { + beNeutral, + existsDestNation, + reqEnvValue, + allowJoinAction, + noPenalty, + allowJoinDestNation, +} from '@sammo-ts/logic/constraints/presets.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js'; import { createGeneralPatchEffect } from '@sammo-ts/logic/actions/engine.js'; @@ -44,11 +51,28 @@ export class ActionDefinition< } buildMinConstraints(_ctx: ConstraintContext, _args: AppointmentArgs): Constraint[] { - return [beNeutral()]; + return [ + reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다'), + beNeutral(), + allowJoinAction(), + noPenalty('noChosenAssignment'), + ]; } buildConstraints(_ctx: ConstraintContext, _args: AppointmentArgs): Constraint[] { - return [beNeutral(), existsDestNation()]; + const env = _ctx.env; + const year = typeof env.year === 'number' ? env.year : 0; + const startYear = typeof env.startyear === 'number' ? env.startyear : 0; + const relYear = year - startYear; + + return [ + reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다'), + beNeutral(), + existsDestNation(), + allowJoinDestNation(relYear), + allowJoinAction(), + noPenalty('noChosenAssignment'), + ]; } resolve( diff --git a/packages/logic/src/actions/turn/general/che_첩보.ts b/packages/logic/src/actions/turn/general/che_첩보.ts index 8444621..e5769e0 100644 --- a/packages/logic/src/actions/turn/general/che_첩보.ts +++ b/packages/logic/src/actions/turn/general/che_첩보.ts @@ -4,7 +4,6 @@ import { notOccupiedDestCity, reqGeneralGold, reqGeneralRice, - existsDestCity, notBeNeutral, } from '@sammo-ts/logic/constraints/presets.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; @@ -297,11 +296,9 @@ export class ActionDefinition< const env = ctx.env; const cost = ((env.develCost as number) ?? 100) * 3; return [ - notBeNeutral(), - existsDestCity(), + notOccupiedDestCity(), reqGeneralGold(() => cost), reqGeneralRice(() => cost), - notOccupiedDestCity(), ]; } diff --git a/packages/logic/src/actions/turn/general/che_출병.ts b/packages/logic/src/actions/turn/general/che_출병.ts index 4889db2..dc898c7 100644 --- a/packages/logic/src/actions/turn/general/che_출병.ts +++ b/packages/logic/src/actions/turn/general/che_출병.ts @@ -1,16 +1,14 @@ import type { City, General, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js'; import { - existsDestCity, hasRouteWithEnemy, notBeNeutral, - notOccupiedDestCity, notOpeningPart, notSameDestCity, occupiedCity, reqGeneralCrew, reqGeneralRice, - suppliedCity, + allowWar, } from '@sammo-ts/logic/constraints/presets.js'; import { readGeneral } from '@sammo-ts/logic/constraints/helpers.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; @@ -268,7 +266,6 @@ export class ActionDefinition< notOpeningPart(relYear + 2, openingPartYear), notBeNeutral(), occupiedCity(), - suppliedCity(), reqGeneralCrew(), reqGeneralRice(getRequiredRice), ]; @@ -282,11 +279,9 @@ export class ActionDefinition< notSameDestCity(), notBeNeutral(), occupiedCity(), - suppliedCity(), reqGeneralCrew(), reqGeneralRice(getRequiredRice), - existsDestCity(), - notOccupiedDestCity(), + allowWar(), hasRouteWithEnemy(), ]; } diff --git a/packages/logic/src/actions/turn/general/che_탈취.ts b/packages/logic/src/actions/turn/general/che_탈취.ts index cc511c8..19a7900 100644 --- a/packages/logic/src/actions/turn/general/che_탈취.ts +++ b/packages/logic/src/actions/turn/general/che_탈취.ts @@ -2,10 +2,13 @@ import type { City, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/e import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; import { notBeNeutral, + occupiedCity, + suppliedCity, notOccupiedDestCity, + notNeutralDestCity, reqGeneralGold, reqGeneralRice, - existsDestCity, + disallowDiplomacyBetweenStatus, } from '@sammo-ts/logic/constraints/presets.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { @@ -200,15 +203,26 @@ export class ActionDefinition< return parseArgsWithSchema(ARGS_SCHEMA, raw); } + buildMinConstraints(ctx: ConstraintContext, _args: SeizeArgs): Constraint[] { + const env = ctx.env; + const cost = (env.develCost as number) ?? 100; + return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => cost), reqGeneralRice(() => cost)]; + } + buildConstraints(ctx: ConstraintContext, _args: SeizeArgs): Constraint[] { const env = ctx.env; const cost = (env.develCost as number) ?? 100; return [ notBeNeutral(), - existsDestCity(), + occupiedCity(), + suppliedCity(), + notOccupiedDestCity(), + notNeutralDestCity(), reqGeneralGold(() => cost), reqGeneralRice(() => cost), - notOccupiedDestCity(), + disallowDiplomacyBetweenStatus({ + 7: '불가침국입니다.', + }), ]; } diff --git a/packages/logic/src/actions/turn/general/che_파괴.ts b/packages/logic/src/actions/turn/general/che_파괴.ts index 93be882..009ae69 100644 --- a/packages/logic/src/actions/turn/general/che_파괴.ts +++ b/packages/logic/src/actions/turn/general/che_파괴.ts @@ -2,10 +2,13 @@ import type { City, GeneralTriggerState } from '@sammo-ts/logic/domain/entities. import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; import { notBeNeutral, + occupiedCity, + suppliedCity, notOccupiedDestCity, + notNeutralDestCity, reqGeneralGold, reqGeneralRice, - existsDestCity, + disallowDiplomacyBetweenStatus, } from '@sammo-ts/logic/constraints/presets.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { @@ -133,15 +136,26 @@ export class ActionDefinition< return parseArgsWithSchema(ARGS_SCHEMA, raw); } + buildMinConstraints(ctx: ConstraintContext, _args: DestroyArgs): Constraint[] { + const env = ctx.env; + const cost = (env.develCost as number) ?? 100; + return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => cost), reqGeneralRice(() => cost)]; + } + buildConstraints(ctx: ConstraintContext, _args: DestroyArgs): Constraint[] { const env = ctx.env; const cost = (env.develCost as number) ?? 100; return [ notBeNeutral(), - existsDestCity(), + occupiedCity(), + suppliedCity(), + notOccupiedDestCity(), + notNeutralDestCity(), reqGeneralGold(() => cost), reqGeneralRice(() => cost), - notOccupiedDestCity(), + disallowDiplomacyBetweenStatus({ + 7: '불가침국입니다.', + }), ]; } diff --git a/packages/logic/src/actions/turn/general/cityDevelopment.ts b/packages/logic/src/actions/turn/general/cityDevelopment.ts index 968b309..344a3ae 100644 --- a/packages/logic/src/actions/turn/general/cityDevelopment.ts +++ b/packages/logic/src/actions/turn/general/cityDevelopment.ts @@ -4,8 +4,9 @@ import { notBeNeutral, notWanderingNation, occupiedCity, - remainCityCapacityByMax, + remainCityCapacity, reqGeneralGold, + reqGeneralRice, suppliedCity, } from '@sammo-ts/logic/constraints/presets.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; @@ -61,8 +62,9 @@ export class CityDevelopmentActionDefinition< notWanderingNation(), occupiedCity(), suppliedCity(), - remainCityCapacityByMax(this.config.statKey, this.config.maxKey, this.config.label), + remainCityCapacity(this.config.statKey, this.config.label), reqGeneralGold(getRequiredGold), + reqGeneralRice(() => 0), ]; } diff --git a/packages/logic/src/actions/turn/nation/che_국기변경.ts b/packages/logic/src/actions/turn/nation/che_국기변경.ts index c77756a..10116f5 100644 --- a/packages/logic/src/actions/turn/nation/che_국기변경.ts +++ b/packages/logic/src/actions/turn/nation/che_국기변경.ts @@ -1,6 +1,6 @@ -import type { GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; -import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js'; -import { beChief, occupiedCity, suppliedCity } from '@sammo-ts/logic/constraints/presets.js'; +import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; +import { beChief, occupiedCity, suppliedCity, reqNationAuxValue } from '@sammo-ts/logic/constraints/presets.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionEffect, @@ -68,22 +68,21 @@ export class ActionDefinition< return parseArgsWithSchema(ARGS_SCHEMA, raw); } + buildMinConstraints(_ctx: ConstraintContext, _args: ChangeFlagArgs): Constraint[] { + return [ + occupiedCity(), + beChief(), + suppliedCity(), + reqNationAuxValue(`can_${ACTION_NAME}`, 0, '>', 0, '더이상 변경이 불가능합니다.'), + ]; + } + buildConstraints(_ctx: ConstraintContext, _args: ChangeFlagArgs): Constraint[] { return [ occupiedCity(), beChief(), suppliedCity(), - { - name: 'canChangeFlag', - requires: (ctx) => [{ kind: 'nation', id: ctx.nationId! }], - test: (ctx: ConstraintContext, view: StateView) => { - const nation = view.get({ kind: 'nation', id: ctx.nationId! }) as Nation | undefined; - const canChangeRaw = nation?.meta[`can_${ACTION_NAME}`]; - const canChange = (typeof canChangeRaw === 'number' ? canChangeRaw : 0) !== 0; - if (!canChange) return { kind: 'deny', reason: '더이상 변경이 불가능합니다.' }; - return { kind: 'allow' }; - }, - }, + reqNationAuxValue(`can_${ACTION_NAME}`, 0, '>', 0, '더이상 변경이 불가능합니다.'), ]; } diff --git a/packages/logic/src/actions/turn/nation/che_국호변경.ts b/packages/logic/src/actions/turn/nation/che_국호변경.ts index 39f0ad8..ac9926f 100644 --- a/packages/logic/src/actions/turn/nation/che_국호변경.ts +++ b/packages/logic/src/actions/turn/nation/che_국호변경.ts @@ -1,6 +1,6 @@ -import type { GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; -import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js'; -import { beChief, occupiedCity, suppliedCity, checkNationNameDuplicate } from '@sammo-ts/logic/constraints/presets.js'; +import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; +import { beChief, occupiedCity, suppliedCity, reqNationAuxValue } from '@sammo-ts/logic/constraints/presets.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionEffect, @@ -37,37 +37,17 @@ export class ActionDefinition< occupiedCity(), beChief(), suppliedCity(), - { - name: 'canChangeNationName', - requires: (ctx) => [{ kind: 'nation', id: ctx.nationId! }], - test: (_ctx: ConstraintContext, view: StateView) => { - const nation = view.get({ kind: 'nation', id: _ctx.nationId! }) as Nation | undefined; - const canChangeRaw = nation?.meta[`can_${ACTION_NAME}`]; - const canChange = (typeof canChangeRaw === 'number' ? canChangeRaw : 0) !== 0; - if (!canChange) return { kind: 'deny', reason: '더이상 변경이 불가능합니다.' }; - return { kind: 'allow' }; - }, - }, + reqNationAuxValue(`can_${ACTION_NAME}`, 0, '>', 0, '더이상 변경이 불가능합니다.'), ]; } - buildConstraints(_ctx: ConstraintContext, args: ChangeNationNameArgs): Constraint[] { + buildConstraints(_ctx: ConstraintContext, _args: ChangeNationNameArgs): Constraint[] { + void _args; return [ occupiedCity(), beChief(), suppliedCity(), - checkNationNameDuplicate(args.nationName), - { - name: 'canChangeNationName', - requires: (ctx) => [{ kind: 'nation', id: ctx.nationId! }], - test: (_ctx: ConstraintContext, view: StateView) => { - const nation = view.get({ kind: 'nation', id: _ctx.nationId! }) as Nation | undefined; - const canChangeRaw = nation?.meta[`can_${ACTION_NAME}`]; - const canChange = (typeof canChangeRaw === 'number' ? canChangeRaw : 0) !== 0; - if (!canChange) return { kind: 'deny', reason: '더이상 변경이 불가능합니다.' }; - return { kind: 'allow' }; - }, - }, + reqNationAuxValue(`can_${ACTION_NAME}`, 0, '>', 0, '더이상 변경이 불가능합니다.'), ]; } diff --git a/packages/logic/src/actions/turn/nation/che_몰수.ts b/packages/logic/src/actions/turn/nation/che_몰수.ts index 13a7c4c..461e797 100644 --- a/packages/logic/src/actions/turn/nation/che_몰수.ts +++ b/packages/logic/src/actions/turn/nation/che_몰수.ts @@ -80,7 +80,7 @@ export class ActionDefinition< existsDestGeneral(), friendlyDestGeneral(), { - name: 'notSelf', + name: 'notSelfDestGeneral', requires: () => [], test: (ctx: ConstraintContext) => { if (ctx.actorId === args.destGeneralID) { diff --git a/packages/logic/src/actions/turn/nation/che_무작위수도이전.ts b/packages/logic/src/actions/turn/nation/che_무작위수도이전.ts index 00fe71a..bf14514 100644 --- a/packages/logic/src/actions/turn/nation/che_무작위수도이전.ts +++ b/packages/logic/src/actions/turn/nation/che_무작위수도이전.ts @@ -1,6 +1,6 @@ -import type { GeneralTriggerState, Nation, City, General } from '@sammo-ts/logic/domain/entities.js'; -import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js'; -import { beMonarch, occupiedCity, suppliedCity } from '@sammo-ts/logic/constraints/presets.js'; +import type { GeneralTriggerState, City, General } from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; +import { beLord, occupiedCity, suppliedCity, beOpeningPart, reqNationAuxValue } from '@sammo-ts/logic/constraints/presets.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionEffect, @@ -45,21 +45,15 @@ export class ActionDefinition< } buildConstraints(_ctx: ConstraintContext, _args: RandomMoveCapitalArgs): Constraint[] { + void _ctx; + void _args; + return [ occupiedCity(), - beMonarch(), + beLord(), suppliedCity(), - { - name: 'canRandomMoveCapital', - requires: (ctx) => [{ kind: 'nation', id: ctx.nationId! }], - test: (ctx: ConstraintContext, view: StateView) => { - const nation = view.get({ kind: 'nation', id: ctx.nationId! }) as Nation | undefined; - const canMoveRaw = nation?.meta[`can_무작위수도이전`]; - const canMove = (typeof canMoveRaw === 'number' ? canMoveRaw : 0) > 0; - if (!canMove) return { kind: 'deny', reason: '더이상 변경이 불가능합니다.' }; - return { kind: 'allow' }; - }, - }, + beOpeningPart(), + reqNationAuxValue('can_무작위수도이전', 0, '>', 0, '더이상 변경이 불가능합니다.'), ]; } diff --git a/packages/logic/src/actions/turn/nation/che_발령.ts b/packages/logic/src/actions/turn/nation/che_발령.ts index a2c4e0e..25195de 100644 --- a/packages/logic/src/actions/turn/nation/che_발령.ts +++ b/packages/logic/src/actions/turn/nation/che_발령.ts @@ -7,7 +7,7 @@ import type { } from '@sammo-ts/logic/domain/entities.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; import { - alwaysFail, + denyWithReason, beChief, existsDestGeneral, friendlyDestGeneral, @@ -161,7 +161,7 @@ export class ActionDefinition< buildConstraints(ctx: ConstraintContext, _args: AssignmentArgs): Constraint[] { void _args; if (ctx.destGeneralId === ctx.actorId) { - return [alwaysFail('본인입니다')]; + return [denyWithReason('본인입니다')]; } return [ beChief(), diff --git a/packages/logic/src/actions/turn/nation/che_부대탈퇴지시.ts b/packages/logic/src/actions/turn/nation/che_부대탈퇴지시.ts index acd0f39..11d616c 100644 --- a/packages/logic/src/actions/turn/nation/che_부대탈퇴지시.ts +++ b/packages/logic/src/actions/turn/nation/che_부대탈퇴지시.ts @@ -1,7 +1,7 @@ import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; import { - alwaysFail, + denyWithReason, beChief, existsDestGeneral, friendlyDestGeneral, @@ -54,7 +54,7 @@ export class ActionDefinition< buildConstraints(ctx: ConstraintContext, _args: TroopKickArgs): Constraint[] { if (ctx.destGeneralId !== undefined && ctx.destGeneralId === ctx.actorId) { - return [alwaysFail('본인입니다')]; + return [denyWithReason('본인입니다')]; } return [notBeNeutral(), beChief(), existsDestGeneral(), friendlyDestGeneral()]; } diff --git a/packages/logic/src/actions/turn/nation/che_불가침제의.ts b/packages/logic/src/actions/turn/nation/che_불가침제의.ts index a6255c7..b48cf36 100644 --- a/packages/logic/src/actions/turn/nation/che_불가침제의.ts +++ b/packages/logic/src/actions/turn/nation/che_불가침제의.ts @@ -37,8 +37,8 @@ const MIN_TERM_MONTHS = 6; const resolveMonthIndex = (year: number, month: number): number => year * 12 + month - 1; -const requireMinimumTerm = (minMonths: number): Constraint => ({ - name: 'RequireNonAggressionMinimumTerm', +const reqMinimumTreatyTerm = (minMonths: number): Constraint => ({ + name: 'reqMinimumTreatyTerm', requires: () => [ { kind: 'arg', key: 'year' }, { kind: 'arg', key: 'month' }, @@ -108,7 +108,7 @@ export class ActionDefinition< notBeNeutral(), existsDestNation(), differentDestNation(), - requireMinimumTerm(MIN_TERM_MONTHS), + reqMinimumTreatyTerm(MIN_TERM_MONTHS), disallowDiplomacyBetweenStatus({ 0: '아국과 이미 교전중입니다.', 1: '아국과 이미 선포중입니다.', diff --git a/packages/logic/src/actions/turn/nation/che_선전포고.ts b/packages/logic/src/actions/turn/nation/che_선전포고.ts index f4e7f1f..7121a52 100644 --- a/packages/logic/src/actions/turn/nation/che_선전포고.ts +++ b/packages/logic/src/actions/turn/nation/che_선전포고.ts @@ -61,7 +61,7 @@ export class ActionDefinition< suppliedCity(), beChief(), { - name: 'declareWarYearLimit', + name: 'reqEnvValue', requires: () => [], test: () => { if (relYear >= 1) return { kind: 'allow' }; @@ -83,7 +83,7 @@ export class ActionDefinition< nearNation(), // legacy: startYear + 1. 0-indexed relYear로는 1 { - name: 'declareWarYearLimit', + name: 'reqEnvValue', requires: () => [], test: () => { if (relYear >= 1) return { kind: 'allow' }; diff --git a/packages/logic/src/actions/turn/nation/che_수몰.ts b/packages/logic/src/actions/turn/nation/che_수몰.ts index 2f16cb3..e423073 100644 --- a/packages/logic/src/actions/turn/nation/che_수몰.ts +++ b/packages/logic/src/actions/turn/nation/che_수몰.ts @@ -48,6 +48,12 @@ const PRE_REQ_TURN = 2; const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1); const DAMAGE_RATE = 0.2; +const battleGroundCity = (): Constraint => ({ + name: 'battleGroundCity', + requires: (ctx) => allowDiplomacyBetweenStatus([0], '교전중인 국가의 도시가 아닙니다.').requires(ctx), + test: (ctx, view) => allowDiplomacyBetweenStatus([0], '교전중인 국가의 도시가 아닙니다.').test(ctx, view), +}); + // 수몰 쿨타임 계산을 담당한다. export class CommandResolver { private readonly pipeline: GeneralActionPipeline; @@ -189,7 +195,7 @@ export class ActionDefinition< beChief(), notNeutralDestCity(), notOccupiedDestCity(), - allowDiplomacyBetweenStatus([0], '교전중인 국가의 도시가 아닙니다.'), + battleGroundCity(), availableStrategicCommand(), ]; } diff --git a/packages/logic/src/actions/turn/nation/che_천도.ts b/packages/logic/src/actions/turn/nation/che_천도.ts index aa3ee4a..55d2f88 100644 --- a/packages/logic/src/actions/turn/nation/che_천도.ts +++ b/packages/logic/src/actions/turn/nation/che_천도.ts @@ -4,6 +4,9 @@ import { beChief, occupiedCity, occupiedDestCity, + reqNationGold, + reqNationRice, + reqNationValue, suppliedCity, suppliedDestCity, } from '@sammo-ts/logic/constraints/presets.js'; @@ -37,6 +40,30 @@ export interface MoveCapitalResolveContext< const ACTION_NAME = '천도'; +const hasRouteToDestCity = (destCityID: number, develCost: number): Constraint => ({ + name: 'hasRouteToDestCity', + requires: (ctx) => [{ kind: 'nation', id: ctx.nationId! }, { kind: 'env', key: 'map' }], + test: (ctx: ConstraintContext, view: StateView) => { + const nation = view.get({ kind: 'nation', id: ctx.nationId! }) as Nation | undefined; + const map = view.get({ kind: 'env', key: 'map' }) as MapDefinition | undefined; + if (!nation || !map || nation.capitalCityId === undefined || nation.capitalCityId === null) { + return { kind: 'allow' }; + } + const dist = calcDistance(nation.capitalCityId, destCityID, map); + if (dist >= 50) { + return { kind: 'deny', reason: '천도 대상으로 도달할 방법이 없습니다.' }; + } + const cost = develCost * 5 * Math.pow(2, dist); + if (nation.gold < cost + 1000) { + return { kind: 'allow' }; + } + if (nation.rice < cost + 1000) { + return { kind: 'allow' }; + } + return { kind: 'allow' }; + }, +}); + const calcDistance = (fromCityId: number, toCityId: number, map: MapDefinition): number => { if (fromCityId === toCityId) return 0; @@ -83,6 +110,21 @@ export class ActionDefinition< buildConstraints(_ctx: ConstraintContext, args: MoveCapitalArgs): Constraint[] { const develcost = this.env.develCost; + const baseGold = this.env.baseGold ?? 1000; + const baseRice = this.env.baseRice ?? 1000; + + const getRequiredCost = (ctx: ConstraintContext, view: StateView): number => { + const nation = view.get({ kind: 'nation', id: ctx.nationId! }) as Nation | undefined; + const map = view.get({ kind: 'env', key: 'map' }) as MapDefinition | undefined; + if (!nation || !map || nation.capitalCityId === undefined || nation.capitalCityId === null) { + return 0; + } + const dist = calcDistance(nation.capitalCityId, args.destCityID, map); + if (dist >= 50) { + return 0; + } + return develcost * 5 * Math.pow(2, dist); + }; return [ occupiedCity(), @@ -90,40 +132,10 @@ export class ActionDefinition< beChief(), suppliedCity(), suppliedDestCity(), - { - name: 'notCurrentCapital', - requires: (ctx) => [{ kind: 'nation', id: ctx.nationId! }], - test: (ctx: ConstraintContext, view: StateView) => { - const nation = view.get({ kind: 'nation', id: ctx.nationId! }) as Nation | undefined; - if (nation?.capitalCityId === args.destCityID) { - return { kind: 'deny', reason: '이미 수도입니다.' }; - } - return { kind: 'allow' }; - }, - }, - { - name: 'reqMoveCapitalCost', - requires: (ctx) => [ - { kind: 'nation', id: ctx.nationId! }, - { kind: 'env', key: 'map' }, - ], - test: (ctx: ConstraintContext, view: StateView) => { - const nation = view.get({ kind: 'nation', id: ctx.nationId! }) as Nation | undefined; - const map = view.get({ kind: 'env', key: 'map' }) as MapDefinition | undefined; - if (!map || !nation || nation.capitalCityId === undefined || nation.capitalCityId === null) - return { kind: 'allow' }; - - const dist = calcDistance(nation.capitalCityId, args.destCityID, map); - const cost = develcost * 5 * Math.pow(2, dist); - - if (nation.gold < cost + 1000) - return { kind: 'deny', reason: `금이 부족합니다. (필요: ${cost + 1000})` }; - if (nation.rice < cost + 1000) - return { kind: 'deny', reason: `쌀이 부족합니다. (필요: ${cost + 1000})` }; - - return { kind: 'allow' }; - }, - }, + hasRouteToDestCity(args.destCityID, develcost), + reqNationValue('capitalCityId', '수도', '!=', args.destCityID, '이미 수도입니다.'), + reqNationGold((ctx, view) => baseGold + getRequiredCost(ctx, view), [{ kind: 'env', key: 'map' }]), + reqNationRice((ctx, view) => baseRice + getRequiredCost(ctx, view), [{ kind: 'env', key: 'map' }]), ]; } diff --git a/packages/logic/src/actions/turn/nation/che_포상.ts b/packages/logic/src/actions/turn/nation/che_포상.ts index 98b00e5..6ae67e0 100644 --- a/packages/logic/src/actions/turn/nation/che_포상.ts +++ b/packages/logic/src/actions/turn/nation/che_포상.ts @@ -1,7 +1,7 @@ import type { General, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; import type { Constraint, ConstraintContext, RequirementKey, StateView } from '@sammo-ts/logic/constraints/types.js'; import { - alwaysFail, + denyWithReason, beChief, existsDestGeneral, friendlyDestGeneral, @@ -200,7 +200,7 @@ export class ActionDefinition< } if (ctx.destGeneralId === ctx.actorId) { - return [alwaysFail('본인입니다')]; + return [denyWithReason('본인입니다')]; } const getRequired = (_ctx: ConstraintContext, _view: StateView): number => diff --git a/packages/logic/src/actions/turn/nation/che_피장파장.ts b/packages/logic/src/actions/turn/nation/che_피장파장.ts index 747e585..ca9848d 100644 --- a/packages/logic/src/actions/turn/nation/che_피장파장.ts +++ b/packages/logic/src/actions/turn/nation/che_피장파장.ts @@ -71,8 +71,8 @@ const ARGS_SCHEMA = z.object({ .refine((value) => value !== 'che_피장파장', '같은 전략은 선택할 수 없습니다.'), }); -const requireCommandType = (): Constraint => ({ - name: 'requireStrategicCommandType', +const reqValidStrategicCommandType = (): Constraint => ({ + name: 'reqValidStrategicCommandType', requires: () => [{ kind: 'arg', key: 'commandType' }], test: (ctx) => { const commandType = ctx.args.commandType; @@ -225,7 +225,7 @@ export class ActionDefinition< existsDestNation(), allowDiplomacyBetweenStatus([0, 1], '선포, 전쟁중인 상대국에게만 가능합니다.'), availableStrategicCommand(), - requireCommandType(), + reqValidStrategicCommandType(), ]; } diff --git a/packages/logic/src/constraints/city.ts b/packages/logic/src/constraints/city.ts index 47ee1ba..3c15c7a 100644 --- a/packages/logic/src/constraints/city.ts +++ b/packages/logic/src/constraints/city.ts @@ -499,6 +499,28 @@ export const beNeutralCity = (): Constraint => ({ }, }); +export const constructableCity = (): Constraint => ({ + name: 'constructableCity', + requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []), + test: (ctx, view) => { + const city = readCity(view, ctx.cityId); + if (!city) { + if (ctx.cityId === undefined) { + return unknownOrDeny(ctx, [], '도시 정보가 없습니다.'); + } + const req: RequirementKey = { kind: 'city', id: ctx.cityId }; + return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.'); + } + if (city.nationId !== 0) { + return { kind: 'deny', reason: '공백지가 아닙니다.' }; + } + if (![5, 6].includes(city.level)) { + return { kind: 'deny', reason: '중, 소 도시에만 가능합니다.' }; + } + return allow(); + }, +}); + export const reqCityLevel = (levels: number[]): Constraint => ({ name: 'reqCityLevel', requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []), diff --git a/packages/logic/src/constraints/general.ts b/packages/logic/src/constraints/general.ts index c424383..5a7223e 100644 --- a/packages/logic/src/constraints/general.ts +++ b/packages/logic/src/constraints/general.ts @@ -1,7 +1,37 @@ import type { General } from '@sammo-ts/logic/domain/entities.js'; -import { allow, readDestGeneral, resolveDestGeneralId, resolveDestNationId, unknownOrDeny } from './helpers.js'; +import { + allow, + compareValues, + readDestGeneral, + resolveDestGeneralId, + resolveDestNationId, + unknownOrDeny, + type CompareOperator, +} from './helpers.js'; import type { Constraint, ConstraintContext, RequirementKey, StateView } from './types.js'; +const readNumericField = (source: Record, key: string): number | null => { + const value = source[key]; + return typeof value === 'number' ? value : null; +}; + +const readStringField = (source: Record, key: string): string | null => { + const value = source[key]; + return typeof value === 'string' ? value : null; +}; + +const readNationNumeric = (nation: Record, key: string): number | null => { + const direct = readNumericField(nation, key); + if (direct !== null) { + return direct; + } + const meta = nation.meta; + if (!meta || typeof meta !== 'object' || Array.isArray(meta)) { + return null; + } + return readNumericField(meta as Record, key); +}; + export const notBeNeutral = (): Constraint => ({ name: 'notBeNeutral', requires: (ctx) => [{ kind: 'general', id: ctx.actorId }], @@ -78,6 +108,25 @@ export const beMonarch = (): Constraint => ({ }, }); +export const beLord = (): Constraint => ({ + name: 'beLord', + requires: (ctx) => [{ kind: 'general', id: ctx.actorId }], + test: (ctx, view) => { + const req: RequirementKey = { kind: 'general', id: ctx.actorId }; + if (!view.has(req)) { + return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.'); + } + const general = view.get(req) as General | null; + if (!general) { + return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.'); + } + if (general.officerLevel === 12) { + return allow(); + } + return { kind: 'deny', reason: '군주가 아닙니다.' }; + }, +}); + export const reqGeneralGold = ( getRequiredGold: (ctx: ConstraintContext, view: StateView) => number, requirements: RequirementKey[] = [] @@ -237,6 +286,110 @@ export const noPenalty = (penaltyKey: string): Constraint => ({ }, }); +export const reqGeneralValue = ( + key: string, + keyNick: string, + comp: CompareOperator, + reqVal: unknown, + errMsg: string | null = null +): Constraint => ({ + name: 'reqGeneralValue', + requires: (ctx) => [{ kind: 'general', id: ctx.actorId }], + test: (ctx, view) => { + const req: RequirementKey = { kind: 'general', id: ctx.actorId }; + if (!view.has(req)) { + return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.'); + } + const general = view.get(req) as General | null; + if (!general) { + return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.'); + } + const source = general as unknown as Record; + let target = source[key]; + if (target === undefined && source.meta && typeof source.meta === 'object' && !Array.isArray(source.meta)) { + target = (source.meta as Record)[key]; + } + + if (compareValues(target, comp, reqVal)) { + return allow(); + } + if (errMsg) { + return { kind: 'deny', reason: errMsg }; + } + return { kind: 'deny', reason: `${keyNick} 조건을 만족하지 않습니다.` }; + }, +}); + +export const allowJoinDestNation = (relYear: number): Constraint => ({ + name: 'allowJoinDestNation', + requires: (ctx) => { + const reqs: RequirementKey[] = [ + { kind: 'general', id: ctx.actorId }, + { kind: 'env', key: 'openingPartYear' }, + { kind: 'env', key: 'initialNationGenLimit' }, + { kind: 'env', key: 'maxGeneral' }, + ]; + const destNationId = resolveDestNationId(ctx); + if (destNationId !== undefined) { + reqs.push({ kind: 'destNation', id: destNationId }); + } + 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 destNationId = resolveDestNationId(ctx); + if (destNationId === undefined) { + return unknownOrDeny(ctx, [], '국가 정보가 없습니다.'); + } + const destReq: RequirementKey = { kind: 'destNation', id: destNationId }; + if (!view.has(destReq)) { + return unknownOrDeny(ctx, [destReq], '국가 정보가 없습니다.'); + } + const destNation = view.get(destReq) as Record | null; + if (!destNation) { + return unknownOrDeny(ctx, [destReq], '국가 정보가 없습니다.'); + } + + const openingPartYear = view.get({ kind: 'env', key: 'openingPartYear' }) as number | null; + const initialNationGenLimit = view.get({ kind: 'env', key: 'initialNationGenLimit' }) as number | null; + const defaultMaxGeneral = view.get({ kind: 'env', key: 'maxGeneral' }) as number | null; + + const gennum = readNationNumeric(destNation, 'gennum') ?? 0; + const scout = readNationNumeric(destNation, 'scout') ?? 0; + const name = readStringField(destNation, 'name') ?? ''; + + const openingLimit = typeof openingPartYear === 'number' ? openingPartYear : 0; + const initialLimit = typeof initialNationGenLimit === 'number' ? initialNationGenLimit : 0; + const normalLimit = typeof defaultMaxGeneral === 'number' ? defaultMaxGeneral : initialLimit; + const genLimit = relYear < openingLimit ? initialLimit : normalLimit; + if (genLimit > 0 && gennum >= genLimit) { + return { kind: 'deny', reason: '임관이 제한되고 있습니다.' }; + } + + if (scout === 1) { + return { kind: 'deny', reason: '임관이 금지되어 있습니다.' }; + } + + if (general.npcState < 2 && name.startsWith('ⓤ')) { + return { kind: 'deny', reason: '유저장은 태수국에 임관할 수 없습니다.' }; + } + + if (general.npcState !== 9 && name.startsWith('ⓞ')) { + return { kind: 'deny', reason: '이민족 국가에 임관할 수 없습니다.' }; + } + + return allow(); + }, +}); + export const reqGeneralCrewMargin = ( getCrewTypeId: (ctx: ConstraintContext, view: StateView) => number | null, requirements: RequirementKey[] = [] diff --git a/packages/logic/src/constraints/helpers.ts b/packages/logic/src/constraints/helpers.ts index 52c2d92..34e9973 100644 --- a/packages/logic/src/constraints/helpers.ts +++ b/packages/logic/src/constraints/helpers.ts @@ -120,3 +120,28 @@ export const parsePercent = (value: string): number | null => { } return Number(match[1]) / 100; }; + +export type CompareOperator = '>' | '>=' | '==' | '<=' | '<' | '!=' | '===' | '!=='; + +export const compareValues = (target: unknown, op: CompareOperator, source: unknown): boolean => { + const lhs = target as any; + const rhs = source as any; + switch (op) { + case '<': + return lhs < rhs; + case '<=': + return lhs <= rhs; + case '==': + return lhs == rhs; + case '!=': + return lhs != rhs; + case '===': + return lhs === rhs; + case '!==': + return lhs !== rhs; + case '>=': + return lhs >= rhs; + case '>': + return lhs > rhs; + } +}; diff --git a/packages/logic/src/constraints/misc.ts b/packages/logic/src/constraints/misc.ts index e9c8cf5..6db0fe2 100644 --- a/packages/logic/src/constraints/misc.ts +++ b/packages/logic/src/constraints/misc.ts @@ -1,12 +1,15 @@ -import { allow } from './helpers.js'; +import { allow, compareValues, type CompareOperator } from './helpers.js'; import type { Constraint } from './types.js'; -export const alwaysFail = (reason: string): Constraint => ({ - name: 'alwaysFail', +export const denyWithReason = (reason: string): Constraint => ({ + name: 'denyWithReason', requires: () => [], test: () => ({ kind: 'deny', reason }), }); +// TODO: 점진 이전을 위해 유지. 신규 코드에서는 denyWithReason을 사용한다. +export const alwaysFail = denyWithReason; + export const notOpeningPart = (relYear: number, openingPartYear: number): Constraint => ({ name: 'notOpeningPart', requires: () => [], @@ -43,3 +46,24 @@ export const beOpeningPart = (): Constraint => ({ return { kind: 'deny', reason: '초반 제한 중에는 불가능합니다.' }; }, }); + +export const reqEnvValue = ( + key: string, + comp: CompareOperator, + reqVal: unknown, + failMessage: string +): Constraint => ({ + name: 'reqEnvValue', + requires: () => [{ kind: 'env', key }], + test: (_ctx, view) => { + const req = { kind: 'env', key } as const; + if (!view.has(req)) { + return { kind: 'deny', reason: failMessage }; + } + const envValue = view.get(req); + if (compareValues(envValue, comp, reqVal)) { + return allow(); + } + return { kind: 'deny', reason: failMessage }; + }, +}); diff --git a/packages/logic/src/constraints/nation.ts b/packages/logic/src/constraints/nation.ts index 16da94b..fe7d2c9 100644 --- a/packages/logic/src/constraints/nation.ts +++ b/packages/logic/src/constraints/nation.ts @@ -1,7 +1,34 @@ import type { City, General, Nation } from '@sammo-ts/logic/domain/entities.js'; -import { allow, readGeneral, readMetaNumber, readNation, resolveDestNationId, unknownOrDeny } from './helpers.js'; +import { + allow, + compareValues, + parsePercent, + readGeneral, + readMetaNumber, + readNation, + resolveDestNationId, + unknownOrDeny, + type CompareOperator, +} from './helpers.js'; import type { Constraint, ConstraintContext, RequirementKey, StateView } from './types.js'; +const readNationField = (nation: Nation, key: string): unknown => { + const source = nation as unknown as Record; + if (Object.prototype.hasOwnProperty.call(source, key)) { + return source[key]; + } + return nation.meta[key]; +}; + +const readNationMaxField = (nation: Nation, key: string): unknown => { + const maxKey = `${key}_max`; + const source = nation as unknown as Record; + if (Object.prototype.hasOwnProperty.call(source, maxKey)) { + return source[maxKey]; + } + return nation.meta[maxKey]; +}; + export const notWanderingNation = (): Constraint => ({ name: 'notWanderingNation', requires: (ctx) => (ctx.nationId !== undefined ? [{ kind: 'nation', id: ctx.nationId }] : []), @@ -40,6 +67,25 @@ export const beWanderingNation = (): Constraint => ({ }, }); +export const wanderingNation = (): Constraint => ({ + name: 'wanderingNation', + requires: (ctx) => (ctx.nationId !== undefined ? [{ kind: 'nation', id: ctx.nationId }] : []), + test: (ctx, view) => { + const nation = readNation(view, ctx.nationId); + if (!nation) { + if (ctx.nationId === undefined) { + return unknownOrDeny(ctx, [], '국가 정보가 없습니다.'); + } + const req: RequirementKey = { kind: 'nation', id: ctx.nationId }; + return unknownOrDeny(ctx, [req], '국가 정보가 없습니다.'); + } + if (nation.level === 0) { + return allow(); + } + return { kind: 'deny', reason: '방랑군이 아닙니다.' }; + }, +}); + export const availableStrategicCommand = (allowTurnCnt = 0): Constraint => ({ name: 'availableStrategicCommand', requires: (ctx) => { @@ -273,6 +319,148 @@ export const checkNationNameDuplicate = (name: string): Constraint => ({ }, }); +export const reqNationValue = ( + key: string, + keyNick: string, + comp: CompareOperator, + reqVal: number | string, + errMsg: string | null = null +): Constraint => ({ + name: 'reqNationValue', + requires: (ctx) => (ctx.nationId !== undefined ? [{ kind: 'nation', id: ctx.nationId }] : []), + test: (ctx, view) => { + const nation = readNation(view, ctx.nationId); + if (!nation) { + if (ctx.nationId === undefined) { + return unknownOrDeny(ctx, [], '국가 정보가 없습니다.'); + } + const req: RequirementKey = { kind: 'nation', id: ctx.nationId }; + return unknownOrDeny(ctx, [req], '국가 정보가 없습니다.'); + } + + const target = readNationField(nation, key); + let required: unknown = reqVal; + if (typeof reqVal === 'string') { + const ratio = parsePercent(reqVal); + if (ratio !== null) { + const maxValue = readNationMaxField(nation, key); + if (typeof maxValue === 'number') { + required = maxValue * ratio; + } + } + } + + if (compareValues(target, comp, required)) { + return allow(); + } + if (errMsg) { + return { kind: 'deny', reason: errMsg }; + } + return { kind: 'deny', reason: `${keyNick} 조건을 만족하지 않습니다.` }; + }, +}); + +export const reqDestNationValue = ( + key: string, + keyNick: string, + comp: CompareOperator, + reqVal: number | string, + errMsg: string | null = null +): Constraint => ({ + name: 'reqDestNationValue', + requires: (ctx) => { + const destNationId = resolveDestNationId(ctx); + if (destNationId === undefined) { + return []; + } + return [{ kind: 'destNation', id: destNationId }]; + }, + test: (ctx, view) => { + const destNationId = resolveDestNationId(ctx); + if (destNationId === undefined) { + return unknownOrDeny(ctx, [], '상대 국가 정보가 없습니다.'); + } + const req: RequirementKey = { kind: 'destNation', id: destNationId }; + if (!view.has(req)) { + return unknownOrDeny(ctx, [req], '상대 국가 정보가 없습니다.'); + } + const nation = view.get(req) as Nation | null; + if (!nation) { + return unknownOrDeny(ctx, [req], '상대 국가 정보가 없습니다.'); + } + + const target = readNationField(nation, key); + let required: unknown = reqVal; + if (typeof reqVal === 'string') { + const ratio = parsePercent(reqVal); + if (ratio !== null) { + const maxValue = readNationMaxField(nation, key); + if (typeof maxValue === 'number') { + required = maxValue * ratio; + } + } + } + + if (compareValues(target, comp, required)) { + return allow(); + } + if (errMsg) { + return { kind: 'deny', reason: errMsg }; + } + return { kind: 'deny', reason: `${keyNick} 조건을 만족하지 않습니다.` }; + }, +}); + +export const allowWar = (): Constraint => ({ + name: 'allowWar', + requires: (ctx) => (ctx.nationId !== undefined ? [{ kind: 'nation', id: ctx.nationId }] : []), + test: (ctx, view) => { + const nation = readNation(view, ctx.nationId); + if (!nation) { + if (ctx.nationId === undefined) { + return unknownOrDeny(ctx, [], '국가 정보가 없습니다.'); + } + const req: RequirementKey = { kind: 'nation', id: ctx.nationId }; + return unknownOrDeny(ctx, [req], '국가 정보가 없습니다.'); + } + + const source = nation as unknown as Record; + const war = typeof source.war === 'number' ? source.war : nation.meta.war; + if (typeof war !== 'number' || war === 0) { + return allow(); + } + return { kind: 'deny', reason: '현재 전쟁 금지입니다.' }; + }, +}); + +export const reqNationAuxValue = ( + key: string, + defaultValue: number, + comp: CompareOperator, + reqVal: number, + errMsg: string +): Constraint => ({ + name: 'reqNationAuxValue', + requires: (ctx) => (ctx.nationId !== undefined ? [{ kind: 'nation', id: ctx.nationId }] : []), + test: (ctx, view) => { + const nation = readNation(view, ctx.nationId); + if (!nation) { + if (ctx.nationId === undefined) { + return unknownOrDeny(ctx, [], '국가 정보가 없습니다.'); + } + const req: RequirementKey = { kind: 'nation', id: ctx.nationId }; + return unknownOrDeny(ctx, [req], '국가 정보가 없습니다.'); + } + + const raw = nation.meta[key]; + const target = typeof raw === 'number' ? raw : defaultValue; + if (compareValues(target, comp, reqVal)) { + return allow(); + } + return { kind: 'deny', reason: errMsg }; + }, +}); + export const nearNation = (): Constraint => ({ name: 'nearNation', requires: (ctx) => { diff --git a/tools/compare-command-constraints.mjs b/tools/compare-command-constraints.mjs index dd4a48f..cc6f714 100644 --- a/tools/compare-command-constraints.mjs +++ b/tools/compare-command-constraints.mjs @@ -73,6 +73,82 @@ const collectFiles = async (dir) => { return files; }; +const maskPhpComments = (text) => { + const chars = [...text]; + let i = 0; + let quote = null; + let escaped = false; + + const maskRange = (start, end) => { + for (let idx = start; idx < end; idx += 1) { + if (chars[idx] !== '\n') { + chars[idx] = ' '; + } + } + }; + + while (i < chars.length) { + const ch = chars[i]; + const next = i + 1 < chars.length ? chars[i + 1] : ''; + + if (quote) { + if (escaped) { + escaped = false; + } else if (ch === '\\') { + escaped = true; + } else if (ch === quote) { + quote = null; + } + i += 1; + continue; + } + + if (ch === '\'' || ch === '"') { + quote = ch; + i += 1; + continue; + } + + if (ch === '/' && next === '/') { + const start = i; + i += 2; + while (i < chars.length && chars[i] !== '\n') { + i += 1; + } + maskRange(start, i); + continue; + } + + if (ch === '#') { + const start = i; + i += 1; + while (i < chars.length && chars[i] !== '\n') { + i += 1; + } + maskRange(start, i); + continue; + } + + if (ch === '/' && next === '*') { + const start = i; + i += 2; + while (i < chars.length - 1) { + if (chars[i] === '*' && chars[i + 1] === '/') { + i += 2; + break; + } + i += 1; + } + maskRange(start, i); + continue; + } + + i += 1; + } + + return chars.join(''); +}; + const buildLineIndex = (text) => { const starts = [0]; for (let i = 0; i < text.length; i += 1) { @@ -226,6 +302,23 @@ const normalizeConstraintName = (name) => { return base[0]?.toLowerCase() + base.slice(1); }; +const ALWAYS_FAIL_TS_ALIASES = new Set([ + 'denywithreason', + 'notselfdestgeneral', + 'targetmustnotbelord', + 'reqminimumtreatyterm', + 'reqfuturetreatyterm', + 'reqvalidstrategiccommandtype', + 'hasroutetodestcity', +]); + +const canonicalizeConstraintName = (side, normalizedName) => { + if (side === 'ts' && ALWAYS_FAIL_TS_ALIASES.has(normalizedName.toLowerCase())) { + return 'alwaysFail'; + } + return normalizedName; +}; + const splitNameTokens = (name) => { if (!name) { return []; @@ -361,7 +454,7 @@ const normalizeTsExpression = (expr) => { }; const makeConstraintEntry = ({ side, kind, name, args, file, line, raw }) => { - const normalizedName = normalizeConstraintName(name); + const normalizedName = canonicalizeConstraintName(side, normalizeConstraintName(name)); const argsKey = args?.length ? args.join('|') : ''; return { side, @@ -435,6 +528,10 @@ const extractPhpFileConstraints = (file, text) => { full: [], min: [], }; + const assigned = { + full: false, + min: false, + }; const regex = /\$this->(fullConditionConstraints|minConditionConstraints)\s*(\[\s*\])?\s*=/g; while (true) { @@ -443,6 +540,7 @@ const extractPhpFileConstraints = (file, text) => { break; } const target = match[1] === 'fullConditionConstraints' ? 'full' : 'min'; + assigned[target] = true; const start = match.index + match[0].length; const { value, endIndex } = scanToDelimiter(text, start, ';'); const expr = value.trim(); @@ -470,7 +568,71 @@ const extractPhpFileConstraints = (file, text) => { regex.lastIndex = endIndex; } - return byKind; + const classMatch = + /class\s+([\\\p{L}_][\\\p{L}\p{N}_]*)\s+extends\s+([\\\p{L}_][\\\p{L}\p{N}_]*)/mu.exec(text); + const parentClass = classMatch?.[2]?.split('\\').pop() ?? null; + + return { + ...byKind, + assigned, + parentClass, + }; +}; + +const resolvePhpInheritance = (byCommand) => { + const resolved = new Map(); + const visiting = new Set(); + + const resolveOne = (key) => { + if (resolved.has(key)) { + return resolved.get(key); + } + const current = byCommand.get(key); + if (!current) { + const empty = { full: [], min: [], assigned: { full: true, min: true }, parentClass: null }; + resolved.set(key, empty); + return empty; + } + if (visiting.has(key)) { + return current; + } + visiting.add(key); + + let parentResolved = null; + if (current.parentClass) { + const dir = key.split('/')[0]; + const parentKey = `${dir}/${current.parentClass}`; + if (byCommand.has(parentKey)) { + parentResolved = resolveOne(parentKey); + } + } + + const merged = { + ...current, + full: + current.assigned?.full === true + ? current.full + : parentResolved?.full + ? [...parentResolved.full] + : current.full, + min: + current.assigned?.min === true + ? current.min + : parentResolved?.min + ? [...parentResolved.min] + : current.min, + }; + + resolved.set(key, merged); + visiting.delete(key); + return merged; + }; + + for (const key of byCommand.keys()) { + resolveOne(key); + } + + return resolved; }; const readStringLikeProperty = (objLiteral, keyName) => { @@ -863,24 +1025,98 @@ const extractTsMethodConstraints = (methodDecl, sourceFile, side, kind, file, fa return dedupeEntries(results); }; +const normalizeTsImportToFile = (baseFile, specifier) => { + if (!specifier.startsWith('.')) { + return null; + } + const resolved = path.resolve(path.dirname(baseFile), specifier); + if (resolved.endsWith('.js')) { + return `${resolved.slice(0, -3)}.ts`; + } + if (resolved.endsWith('.ts')) { + return resolved; + } + return `${resolved}.ts`; +}; + const extractTsFileConstraints = (file, text) => { const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); const factorySet = collectTsConstraintFactories(sourceFile); const relFile = path.relative(ROOT_DIR, file); + const imports = new Map(); + + const visitImport = (node) => { + if (ts.isImportDeclaration(node)) { + const moduleText = ts.isStringLiteral(node.moduleSpecifier) ? node.moduleSpecifier.text : null; + if (moduleText) { + const named = node.importClause?.namedBindings; + if (named && ts.isNamedImports(named)) { + for (const element of named.elements) { + imports.set(element.name.text, moduleText); + } + } + if (node.importClause?.name) { + imports.set(node.importClause.name.text, moduleText); + } + } + } + ts.forEachChild(node, visitImport); + }; + visitImport(sourceFile); + const byKind = { full: [], min: [], }; + const assigned = { + full: false, + min: false, + }; + let parentFile = null; const visit = (node) => { + if (!parentFile && ts.isVariableDeclaration(node) && node.initializer && ts.isCallExpression(node.initializer)) { + const callee = node.initializer.expression; + if (ts.isIdentifier(callee)) { + const moduleText = imports.get(callee.text); + if (moduleText) { + parentFile = normalizeTsImportToFile(file, moduleText); + } + } + } + + if (ts.isClassDeclaration(node)) { + const className = node.name?.text ?? null; + if (className === 'ActionDefinition' && node.heritageClauses) { + for (const heritage of node.heritageClauses) { + if (heritage.token !== ts.SyntaxKind.ExtendsKeyword) { + continue; + } + const typeNode = heritage.types[0]; + if (!typeNode) { + continue; + } + const expr = typeNode.expression; + if (ts.isIdentifier(expr)) { + const moduleText = imports.get(expr.text); + if (moduleText) { + parentFile = normalizeTsImportToFile(file, moduleText); + } + } + } + } + } + if (ts.isMethodDeclaration(node) && ts.isIdentifier(node.name)) { const name = node.name.text; if (name === 'buildConstraints') { + assigned.full = true; byKind.full.push( ...extractTsMethodConstraints(node, sourceFile, 'ts', 'full', relFile, factorySet) ); } if (name === 'buildMinConstraints') { + assigned.min = true; byKind.min.push( ...extractTsMethodConstraints(node, sourceFile, 'ts', 'min', relFile, factorySet) ); @@ -892,7 +1128,11 @@ const extractTsFileConstraints = (file, text) => { byKind.full = dedupeEntries(byKind.full); byKind.min = dedupeEntries(byKind.min); - return byKind; + return { + ...byKind, + assigned, + parentFile, + }; }; const includeCommand = (key) => { @@ -922,18 +1162,23 @@ const loadPhpConstraints = async () => { } const text = await fs.readFile(file, 'utf-8'); - const extracted = extractPhpFileConstraints(file, text); + const extracted = extractPhpFileConstraints(file, maskPhpComments(text)); byCommand.set(key, extracted); } - return byCommand; + return resolvePhpInheritance(byCommand); }; const loadTsConstraints = async () => { const files = (await collectFiles(TS_ROOT)).filter((file) => file.endsWith('.ts')); - const byCommand = new Map(); + const byFile = new Map(); + const commandFileByKey = new Map(); for (const file of files) { + const text = await fs.readFile(file, 'utf-8'); + const extracted = extractTsFileConstraints(file, text); + byFile.set(file, extracted); + const baseName = path.basename(file, '.ts'); if (!/^che_|^cr_|^event_|^휴식$/.test(baseName)) { continue; @@ -948,10 +1193,61 @@ const loadTsConstraints = async () => { if (!key || !includeCommand(key)) { continue; } + commandFileByKey.set(key, file); + } - const text = await fs.readFile(file, 'utf-8'); - const extracted = extractTsFileConstraints(file, text); - byCommand.set(key, extracted); + const resolvedByFile = new Map(); + const resolving = new Set(); + + const resolveFile = (file) => { + if (resolvedByFile.has(file)) { + return resolvedByFile.get(file); + } + const current = byFile.get(file); + if (!current) { + const empty = { + full: [], + min: [], + assigned: { full: true, min: true }, + parentFile: null, + }; + resolvedByFile.set(file, empty); + return empty; + } + if (resolving.has(file)) { + return current; + } + resolving.add(file); + + let parentResolved = null; + if (current.parentFile && byFile.has(current.parentFile)) { + parentResolved = resolveFile(current.parentFile); + } + + const merged = { + ...current, + full: + current.assigned?.full === true + ? current.full + : parentResolved?.full + ? [...parentResolved.full] + : current.full, + min: + current.assigned?.min === true + ? current.min + : parentResolved?.min + ? [...parentResolved.min] + : current.min, + }; + + resolvedByFile.set(file, merged); + resolving.delete(file); + return merged; + }; + + const byCommand = new Map(); + for (const [key, file] of commandFileByKey.entries()) { + byCommand.set(key, resolveFile(file)); } return byCommand;