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.
This commit is contained in:
2026-02-06 18:55:43 +00:00
parent d7cb8c77e3
commit 15730feafe
35 changed files with 1004 additions and 298 deletions
@@ -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 type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import { import {
beChief, beChief,
destGeneralInDestNation,
disallowDiplomacyBetweenStatus, disallowDiplomacyBetweenStatus,
existsDestGeneral, existsDestGeneral,
existsDestNation, existsDestNation,
notBeNeutral, notBeNeutral,
occupiedCity, occupiedCity,
reqDestNationValue,
suppliedCity, suppliedCity,
} from '@sammo-ts/logic/constraints/presets.js'; } from '@sammo-ts/logic/constraints/presets.js';
import { allow, unknownOrDeny } from '@sammo-ts/logic/constraints/helpers.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 resolveMonthIndex = (year: number, month: number): number => year * 12 + month - 1;
const requireFutureTerm = (): Constraint => ({ const reqFutureTreatyTerm = (): Constraint => ({
name: 'RequireNonAggressionFutureTerm', name: 'reqFutureTreatyTerm',
requires: () => [ requires: () => [
{ kind: 'arg', key: 'year' }, { kind: 'arg', key: 'year' },
{ kind: 'arg', key: 'month' }, { 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< export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState, TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -161,9 +146,8 @@ export class ActionDefinition<
suppliedCity(), suppliedCity(),
existsDestNation(), existsDestNation(),
existsDestGeneral(), existsDestGeneral(),
destGeneralInDestNation(), reqDestNationValue('level', '국가규모', '>', 0, '상대국 정보가 없습니다.'),
notSameDestGeneral(), reqFutureTreatyTerm(),
requireFutureTerm(),
disallowDiplomacyBetweenStatus({ disallowDiplomacyBetweenStatus({
0: '아국과 이미 교전중입니다.', 0: '아국과 이미 교전중입니다.',
1: '아국과 이미 선포중입니다.', 1: '아국과 이미 선포중입니다.',
@@ -3,12 +3,11 @@ import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/
import { import {
allowDiplomacyBetweenStatus, allowDiplomacyBetweenStatus,
beChief, beChief,
destGeneralInDestNation,
existsDestGeneral, existsDestGeneral,
existsDestNation, existsDestNation,
notBeNeutral, notBeNeutral,
reqDestNationValue,
} from '@sammo-ts/logic/constraints/presets.js'; } 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 { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js'; import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
import { createDiplomacyPatchEffect, createLogEffect } 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; 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< export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState, TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -75,8 +59,7 @@ export class ActionDefinition<
notBeNeutral(), notBeNeutral(),
existsDestNation(), existsDestNation(),
existsDestGeneral(), existsDestGeneral(),
destGeneralInDestNation(), reqDestNationValue('level', '국가규모', '>', 0, '상대국 정보가 없습니다.'),
notSameDestGeneral(),
allowDiplomacyBetweenStatus([DIPLOMACY_NON_AGGRESSION], '불가침 중인 상대국에게만 가능합니다.'), allowDiplomacyBetweenStatus([DIPLOMACY_NON_AGGRESSION], '불가침 중인 상대국에게만 가능합니다.'),
]; ];
} }
@@ -3,12 +3,11 @@ import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/
import { import {
allowDiplomacyBetweenStatus, allowDiplomacyBetweenStatus,
beChief, beChief,
destGeneralInDestNation,
existsDestGeneral, existsDestGeneral,
existsDestNation, existsDestNation,
notBeNeutral, notBeNeutral,
reqDestNationValue,
} from '@sammo-ts/logic/constraints/presets.js'; } 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 { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js'; import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
import { createDiplomacyPatchEffect, createLogEffect } 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; 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< export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState, TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -75,8 +59,7 @@ export class ActionDefinition<
notBeNeutral(), notBeNeutral(),
existsDestNation(), existsDestNation(),
existsDestGeneral(), existsDestGeneral(),
destGeneralInDestNation(), reqDestNationValue('level', '국가규모', '>', 0, '상대국 정보가 없습니다.'),
notSameDestGeneral(),
allowDiplomacyBetweenStatus([0, 1], '상대국과 선포, 전쟁중이지 않습니다.'), allowDiplomacyBetweenStatus([0, 1], '상대국과 선포, 전쟁중이지 않습니다.'),
]; ];
} }
@@ -1,6 +1,5 @@
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.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 { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type { import type {
GeneralActionOutcome, GeneralActionOutcome,
@@ -40,6 +39,9 @@ export class ActionResolver<
resolve(context: NPCSelfResolveContext<TriggerState>, args: NPCSelfArgs): GeneralActionOutcome<TriggerState> { resolve(context: NPCSelfResolveContext<TriggerState>, args: NPCSelfArgs): GeneralActionOutcome<TriggerState> {
const general = context.general; const general = context.general;
const effects: GeneralActionEffect<TriggerState>[] = []; const effects: GeneralActionEffect<TriggerState>[] = [];
if (general.npcState < 2) {
throw new Error('NPC가 아닙니다.');
}
if (args.optionText === '순간이동') { if (args.optionText === '순간이동') {
if (args.destCityId === undefined) { if (args.destCityId === undefined) {
@@ -98,13 +100,9 @@ export class ActionDefinition<
} }
buildConstraints(_ctx: ConstraintContext, args: NPCSelfArgs): Constraint[] { buildConstraints(_ctx: ConstraintContext, args: NPCSelfArgs): Constraint[] {
const constraints = [mustBeNPC()]; void _ctx;
void args;
if (args.optionText === '순간이동') { return [];
constraints.push(existsDestCity());
}
return constraints;
} }
resolve(context: NPCSelfResolveContext<TriggerState>, args: NPCSelfArgs): GeneralActionOutcome<TriggerState> { resolve(context: NPCSelfResolveContext<TriggerState>, args: NPCSelfArgs): GeneralActionOutcome<TriggerState> {
@@ -1,13 +1,14 @@
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import { import {
beMonarch, beLord,
beWanderingNation, wanderingNation,
beNeutralCity, reqNationValue,
reqCityLevel,
reqNationGeneralCount,
checkNationNameDuplicate, checkNationNameDuplicate,
beOpeningPart, beOpeningPart,
constructableCity,
allowJoinAction,
noPenalty,
} from '@sammo-ts/logic/constraints/presets.js'; } from '@sammo-ts/logic/constraints/presets.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.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[] { buildMinConstraints(_ctx: ConstraintContext, _args: FoundingArgs): Constraint[] {
return [beOpeningPart(), beWanderingNation()]; return [beOpeningPart(), reqNationValue('level', '국가규모', '==', 0, '정식 국가가 아니어야합니다.'), noPenalty('noFoundNation')];
} }
buildConstraints(_ctx: ConstraintContext, args: FoundingArgs): Constraint[] { buildConstraints(_ctx: ConstraintContext, args: FoundingArgs): Constraint[] {
return [ return [
beOpeningPart(), beOpeningPart(),
beMonarch(), beLord(),
beWanderingNation(), wanderingNation(),
reqNationGeneralCount(2), reqNationValue('gennum', '수하 장수', '>=', 2),
beNeutralCity(),
reqCityLevel([5, 6]), // 소, 중 도시
checkNationNameDuplicate(args.nationName), checkNationNameDuplicate(args.nationName),
allowJoinAction(),
constructableCity(),
noPenalty('noFoundNation'),
]; ];
} }
@@ -6,6 +6,9 @@ import {
suppliedCity, suppliedCity,
existsDestGeneral, existsDestGeneral,
resolveDestGeneralId, resolveDestGeneralId,
reqGeneralGold,
reqGeneralRice,
reqEnvValue,
} from '@sammo-ts/logic/constraints/presets.js'; } from '@sammo-ts/logic/constraints/presets.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type { import type {
@@ -61,8 +64,8 @@ const differentNationDestGeneral = (): Constraint => ({
}, },
}); });
const notDestGeneralLord = (): Constraint => ({ const targetMustNotBeLord = (): Constraint => ({
name: 'NotDestGeneralLord', name: 'targetMustNotBeLord',
requires: (ctx) => { requires: (ctx) => {
const destGeneralId = resolveDestGeneralId(ctx); const destGeneralId = resolveDestGeneralId(ctx);
if (destGeneralId === undefined) return []; if (destGeneralId === undefined) return [];
@@ -73,8 +76,8 @@ const notDestGeneralLord = (): Constraint => ({
const destGeneral = const destGeneral =
destId !== undefined ? (view.get({ kind: 'destGeneral', id: destId }) as General | null) : null; destId !== undefined ? (view.get({ kind: 'destGeneral', id: destId }) as General | null) : null;
if (!destGeneral) return { kind: 'deny', reason: '장수 정보가 없습니다.' }; if (!destGeneral) return { kind: 'deny', reason: '장수 정보가 없습니다.' };
if (destGeneral.officerLevel !== 12) return { kind: 'allow' }; if (destGeneral.officerLevel === 12) return { kind: 'deny', reason: '군주에게는 등용장을 보낼 수 없습니다.' };
return { kind: 'deny', reason: '군주에게는 등용장을 보낼 수 없습니다.' }; return { kind: 'allow' };
}, },
}); });
@@ -160,38 +163,37 @@ export class ActionDefinition<
} }
buildMinConstraints(_ctx: ConstraintContext, _args: EmployArgs): Constraint[] { buildMinConstraints(_ctx: ConstraintContext, _args: EmployArgs): Constraint[] {
return [notBeNeutral(), occupiedCity(), suppliedCity()]; return [
reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다'),
notBeNeutral(),
occupiedCity(),
suppliedCity(),
];
} }
buildConstraints(_ctx: ConstraintContext, args: EmployArgs): Constraint[] { buildConstraints(_ctx: ConstraintContext, args: EmployArgs): Constraint[] {
const develCost = this.env.develCost ?? 100; const develCost = this.env.develCost ?? 100;
const reqCostConstraint: Constraint = { const constraints: Constraint[] = [
name: 'ReqScoutCost', reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다'),
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 [
notBeNeutral(), notBeNeutral(),
occupiedCity(), occupiedCity(),
suppliedCity(), suppliedCity(),
existsDestGeneral(), existsDestGeneral(),
differentNationDestGeneral(), differentNationDestGeneral(),
notDestGeneralLord(), reqGeneralGold(
reqCostConstraint, (_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<TriggerState>, args: EmployArgs): GeneralActionOutcome<TriggerState> { resolve(context: GeneralActionResolveContext<TriggerState>, args: EmployArgs): GeneralActionOutcome<TriggerState> {
@@ -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 type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import { import {
existsDestNation, existsDestNation,
existsDestGeneral, beNeutral,
notSameDestNation, allowJoinDestNation,
destGeneralInDestNation, reqDestNationValue,
notLord, differentDestNation,
reqGeneralValue,
reqEnvValue,
readMetaNumberFromUnknown, readMetaNumberFromUnknown,
} from '@sammo-ts/logic/constraints/presets.js'; } from '@sammo-ts/logic/constraints/presets.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
@@ -186,7 +188,20 @@ export class ActionDefinition<
} }
buildConstraints(_ctx: ConstraintContext, _args: AcceptScoutArgs): Constraint[] { 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( resolve(
@@ -2,7 +2,7 @@ import type { RandomGenerator } from '@sammo-ts/common';
import { asRecord, JosaUtil } 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 { General, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.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 { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type { import type {
GeneralActionEffect, GeneralActionEffect,
@@ -134,11 +134,11 @@ export class ActionDefinition<
} }
buildMinConstraints(_ctx: ConstraintContext, _args: RandomAppointmentArgs): Constraint[] { buildMinConstraints(_ctx: ConstraintContext, _args: RandomAppointmentArgs): Constraint[] {
return [beNeutral()]; return [];
} }
buildConstraints(_ctx: ConstraintContext, _args: RandomAppointmentArgs): Constraint[] { buildConstraints(_ctx: ConstraintContext, _args: RandomAppointmentArgs): Constraint[] {
return [beNeutral()]; return [beNeutral(), allowJoinAction()];
} }
resolve( resolve(
@@ -1,6 +1,12 @@
import type { City, General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; 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 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 { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type { import type {
GeneralActionOutcome, GeneralActionOutcome,
@@ -184,7 +190,14 @@ export class ActionDefinition<
} }
buildConstraints(_ctx: ConstraintContext, _args: WanderArgs): Constraint[] { 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<TriggerState>, args: WanderArgs): GeneralActionOutcome<TriggerState> { resolve(context: WanderResolveContext<TriggerState>, args: WanderArgs): GeneralActionOutcome<TriggerState> {
@@ -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 type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import { import {
notBeNeutral, notBeNeutral,
occupiedCity,
suppliedCity,
notOccupiedDestCity, notOccupiedDestCity,
notNeutralDestCity,
reqGeneralGold, reqGeneralGold,
reqGeneralRice, reqGeneralRice,
existsDestCity, disallowDiplomacyBetweenStatus,
} from '@sammo-ts/logic/constraints/presets.js'; } from '@sammo-ts/logic/constraints/presets.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type { import type {
@@ -136,15 +139,26 @@ export class ActionDefinition<
return parseArgsWithSchema(ARGS_SCHEMA, raw); 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[] { buildConstraints(ctx: ConstraintContext, _args: AgitateArgs): Constraint[] {
const env = ctx.env; const env = ctx.env;
const cost = (env.develCost as number) ?? 100; const cost = (env.develCost as number) ?? 100;
return [ return [
notBeNeutral(), notBeNeutral(),
existsDestCity(), occupiedCity(),
suppliedCity(),
notOccupiedDestCity(),
notNeutralDestCity(),
reqGeneralGold(() => cost), reqGeneralGold(() => cost),
reqGeneralRice(() => cost), reqGeneralRice(() => cost),
notOccupiedDestCity(), disallowDiplomacyBetweenStatus({
7: '불가침국입니다.',
}),
]; ];
} }
@@ -1,11 +1,10 @@
import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; 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 { import {
notSameDestCity, notSameDestCity,
existsDestCity, nearCity,
reqGeneralGold, reqGeneralGold,
unknownOrDeny, reqGeneralRice,
resolveDestCityId,
} from '@sammo-ts/logic/constraints/presets.js'; } from '@sammo-ts/logic/constraints/presets.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type { import type {
@@ -40,38 +39,6 @@ const ARGS_SCHEMA = z.object({
}); });
export type MoveArgs = z.infer<typeof ARGS_SCHEMA>; export type MoveArgs = z.infer<typeof ARGS_SCHEMA>;
// 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< export class ActionResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState, TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionResolver<TriggerState, MoveArgs> { > implements GeneralActionResolver<TriggerState, MoveArgs> {
@@ -169,20 +136,20 @@ export class ActionDefinition<
const develCost = ctx.env.develCost as number; const develCost = ctx.env.develCost as number;
return develCost ?? 0; return develCost ?? 0;
}), }),
reqGeneralRice(() => 0),
]; ];
} }
buildConstraints(ctx: ConstraintContext, _args: MoveArgs): Constraint[] { buildConstraints(ctx: ConstraintContext, _args: MoveArgs): Constraint[] {
const constraints = [ return [
existsDestCity(),
notSameDestCity(), notSameDestCity(),
connectedCity(), nearCity(1),
reqGeneralGold(() => { reqGeneralGold(() => {
const develCost = ctx.env.develCost as number; const develCost = ctx.env.develCost as number;
return develCost ?? 0; return develCost ?? 0;
}), }),
reqGeneralRice(() => 0),
]; ];
return constraints;
} }
resolve(context: MoveResolveContext<TriggerState>, args: MoveArgs): GeneralActionOutcome<TriggerState> { resolve(context: MoveResolveContext<TriggerState>, args: MoveArgs): GeneralActionOutcome<TriggerState> {
@@ -1,6 +1,13 @@
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.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 { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js'; import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
import { createGeneralPatchEffect } 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[] { buildMinConstraints(_ctx: ConstraintContext, _args: AppointmentArgs): Constraint[] {
return [beNeutral()]; return [
reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다'),
beNeutral(),
allowJoinAction(),
noPenalty('noChosenAssignment'),
];
} }
buildConstraints(_ctx: ConstraintContext, _args: AppointmentArgs): Constraint[] { 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( resolve(
@@ -4,7 +4,6 @@ import {
notOccupiedDestCity, notOccupiedDestCity,
reqGeneralGold, reqGeneralGold,
reqGeneralRice, reqGeneralRice,
existsDestCity,
notBeNeutral, notBeNeutral,
} from '@sammo-ts/logic/constraints/presets.js'; } from '@sammo-ts/logic/constraints/presets.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
@@ -297,11 +296,9 @@ export class ActionDefinition<
const env = ctx.env; const env = ctx.env;
const cost = ((env.develCost as number) ?? 100) * 3; const cost = ((env.develCost as number) ?? 100) * 3;
return [ return [
notBeNeutral(), notOccupiedDestCity(),
existsDestCity(),
reqGeneralGold(() => cost), reqGeneralGold(() => cost),
reqGeneralRice(() => cost), reqGeneralRice(() => cost),
notOccupiedDestCity(),
]; ];
} }
@@ -1,16 +1,14 @@
import type { City, General, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; 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 type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js';
import { import {
existsDestCity,
hasRouteWithEnemy, hasRouteWithEnemy,
notBeNeutral, notBeNeutral,
notOccupiedDestCity,
notOpeningPart, notOpeningPart,
notSameDestCity, notSameDestCity,
occupiedCity, occupiedCity,
reqGeneralCrew, reqGeneralCrew,
reqGeneralRice, reqGeneralRice,
suppliedCity, allowWar,
} from '@sammo-ts/logic/constraints/presets.js'; } from '@sammo-ts/logic/constraints/presets.js';
import { readGeneral } from '@sammo-ts/logic/constraints/helpers.js'; import { readGeneral } from '@sammo-ts/logic/constraints/helpers.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
@@ -268,7 +266,6 @@ export class ActionDefinition<
notOpeningPart(relYear + 2, openingPartYear), notOpeningPart(relYear + 2, openingPartYear),
notBeNeutral(), notBeNeutral(),
occupiedCity(), occupiedCity(),
suppliedCity(),
reqGeneralCrew(), reqGeneralCrew(),
reqGeneralRice(getRequiredRice), reqGeneralRice(getRequiredRice),
]; ];
@@ -282,11 +279,9 @@ export class ActionDefinition<
notSameDestCity(), notSameDestCity(),
notBeNeutral(), notBeNeutral(),
occupiedCity(), occupiedCity(),
suppliedCity(),
reqGeneralCrew(), reqGeneralCrew(),
reqGeneralRice(getRequiredRice), reqGeneralRice(getRequiredRice),
existsDestCity(), allowWar(),
notOccupiedDestCity(),
hasRouteWithEnemy(), hasRouteWithEnemy(),
]; ];
} }
@@ -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 type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import { import {
notBeNeutral, notBeNeutral,
occupiedCity,
suppliedCity,
notOccupiedDestCity, notOccupiedDestCity,
notNeutralDestCity,
reqGeneralGold, reqGeneralGold,
reqGeneralRice, reqGeneralRice,
existsDestCity, disallowDiplomacyBetweenStatus,
} from '@sammo-ts/logic/constraints/presets.js'; } from '@sammo-ts/logic/constraints/presets.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type { import type {
@@ -200,15 +203,26 @@ export class ActionDefinition<
return parseArgsWithSchema(ARGS_SCHEMA, raw); 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[] { buildConstraints(ctx: ConstraintContext, _args: SeizeArgs): Constraint[] {
const env = ctx.env; const env = ctx.env;
const cost = (env.develCost as number) ?? 100; const cost = (env.develCost as number) ?? 100;
return [ return [
notBeNeutral(), notBeNeutral(),
existsDestCity(), occupiedCity(),
suppliedCity(),
notOccupiedDestCity(),
notNeutralDestCity(),
reqGeneralGold(() => cost), reqGeneralGold(() => cost),
reqGeneralRice(() => cost), reqGeneralRice(() => cost),
notOccupiedDestCity(), disallowDiplomacyBetweenStatus({
7: '불가침국입니다.',
}),
]; ];
} }
@@ -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 type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import { import {
notBeNeutral, notBeNeutral,
occupiedCity,
suppliedCity,
notOccupiedDestCity, notOccupiedDestCity,
notNeutralDestCity,
reqGeneralGold, reqGeneralGold,
reqGeneralRice, reqGeneralRice,
existsDestCity, disallowDiplomacyBetweenStatus,
} from '@sammo-ts/logic/constraints/presets.js'; } from '@sammo-ts/logic/constraints/presets.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type { import type {
@@ -133,15 +136,26 @@ export class ActionDefinition<
return parseArgsWithSchema(ARGS_SCHEMA, raw); 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[] { buildConstraints(ctx: ConstraintContext, _args: DestroyArgs): Constraint[] {
const env = ctx.env; const env = ctx.env;
const cost = (env.develCost as number) ?? 100; const cost = (env.develCost as number) ?? 100;
return [ return [
notBeNeutral(), notBeNeutral(),
existsDestCity(), occupiedCity(),
suppliedCity(),
notOccupiedDestCity(),
notNeutralDestCity(),
reqGeneralGold(() => cost), reqGeneralGold(() => cost),
reqGeneralRice(() => cost), reqGeneralRice(() => cost),
notOccupiedDestCity(), disallowDiplomacyBetweenStatus({
7: '불가침국입니다.',
}),
]; ];
} }
@@ -4,8 +4,9 @@ import {
notBeNeutral, notBeNeutral,
notWanderingNation, notWanderingNation,
occupiedCity, occupiedCity,
remainCityCapacityByMax, remainCityCapacity,
reqGeneralGold, reqGeneralGold,
reqGeneralRice,
suppliedCity, suppliedCity,
} from '@sammo-ts/logic/constraints/presets.js'; } from '@sammo-ts/logic/constraints/presets.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
@@ -61,8 +62,9 @@ export class CityDevelopmentActionDefinition<
notWanderingNation(), notWanderingNation(),
occupiedCity(), occupiedCity(),
suppliedCity(), suppliedCity(),
remainCityCapacityByMax(this.config.statKey, this.config.maxKey, this.config.label), remainCityCapacity(this.config.statKey, this.config.label),
reqGeneralGold(getRequiredGold), reqGeneralGold(getRequiredGold),
reqGeneralRice(() => 0),
]; ];
} }
@@ -1,6 +1,6 @@
import type { GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import { beChief, occupiedCity, suppliedCity } from '@sammo-ts/logic/constraints/presets.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 { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type { import type {
GeneralActionEffect, GeneralActionEffect,
@@ -68,22 +68,21 @@ export class ActionDefinition<
return parseArgsWithSchema(ARGS_SCHEMA, raw); 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[] { buildConstraints(_ctx: ConstraintContext, _args: ChangeFlagArgs): Constraint[] {
return [ return [
occupiedCity(), occupiedCity(),
beChief(), beChief(),
suppliedCity(), suppliedCity(),
{ reqNationAuxValue(`can_${ACTION_NAME}`, 0, '>', 0, '더이상 변경이 불가능합니다.'),
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' };
},
},
]; ];
} }
@@ -1,6 +1,6 @@
import type { GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import { beChief, occupiedCity, suppliedCity, checkNationNameDuplicate } from '@sammo-ts/logic/constraints/presets.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 { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type { import type {
GeneralActionEffect, GeneralActionEffect,
@@ -37,37 +37,17 @@ export class ActionDefinition<
occupiedCity(), occupiedCity(),
beChief(), beChief(),
suppliedCity(), suppliedCity(),
{ reqNationAuxValue(`can_${ACTION_NAME}`, 0, '>', 0, '더이상 변경이 불가능합니다.'),
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' };
},
},
]; ];
} }
buildConstraints(_ctx: ConstraintContext, args: ChangeNationNameArgs): Constraint[] { buildConstraints(_ctx: ConstraintContext, _args: ChangeNationNameArgs): Constraint[] {
void _args;
return [ return [
occupiedCity(), occupiedCity(),
beChief(), beChief(),
suppliedCity(), suppliedCity(),
checkNationNameDuplicate(args.nationName), reqNationAuxValue(`can_${ACTION_NAME}`, 0, '>', 0, '더이상 변경이 불가능합니다.'),
{
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' };
},
},
]; ];
} }
@@ -80,7 +80,7 @@ export class ActionDefinition<
existsDestGeneral(), existsDestGeneral(),
friendlyDestGeneral(), friendlyDestGeneral(),
{ {
name: 'notSelf', name: 'notSelfDestGeneral',
requires: () => [], requires: () => [],
test: (ctx: ConstraintContext) => { test: (ctx: ConstraintContext) => {
if (ctx.actorId === args.destGeneralID) { if (ctx.actorId === args.destGeneralID) {
@@ -1,6 +1,6 @@
import type { GeneralTriggerState, Nation, City, General } from '@sammo-ts/logic/domain/entities.js'; import type { GeneralTriggerState, City, General } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import { beMonarch, occupiedCity, suppliedCity } from '@sammo-ts/logic/constraints/presets.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 { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type { import type {
GeneralActionEffect, GeneralActionEffect,
@@ -45,21 +45,15 @@ export class ActionDefinition<
} }
buildConstraints(_ctx: ConstraintContext, _args: RandomMoveCapitalArgs): Constraint[] { buildConstraints(_ctx: ConstraintContext, _args: RandomMoveCapitalArgs): Constraint[] {
void _ctx;
void _args;
return [ return [
occupiedCity(), occupiedCity(),
beMonarch(), beLord(),
suppliedCity(), suppliedCity(),
{ beOpeningPart(),
name: 'canRandomMoveCapital', reqNationAuxValue('can_무작위수도이전', 0, '>', 0, '더이상 변경이 불가능합니다.'),
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' };
},
},
]; ];
} }
@@ -7,7 +7,7 @@ import type {
} from '@sammo-ts/logic/domain/entities.js'; } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import { import {
alwaysFail, denyWithReason,
beChief, beChief,
existsDestGeneral, existsDestGeneral,
friendlyDestGeneral, friendlyDestGeneral,
@@ -161,7 +161,7 @@ export class ActionDefinition<
buildConstraints(ctx: ConstraintContext, _args: AssignmentArgs): Constraint[] { buildConstraints(ctx: ConstraintContext, _args: AssignmentArgs): Constraint[] {
void _args; void _args;
if (ctx.destGeneralId === ctx.actorId) { if (ctx.destGeneralId === ctx.actorId) {
return [alwaysFail('본인입니다')]; return [denyWithReason('본인입니다')];
} }
return [ return [
beChief(), beChief(),
@@ -1,7 +1,7 @@
import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import { import {
alwaysFail, denyWithReason,
beChief, beChief,
existsDestGeneral, existsDestGeneral,
friendlyDestGeneral, friendlyDestGeneral,
@@ -54,7 +54,7 @@ export class ActionDefinition<
buildConstraints(ctx: ConstraintContext, _args: TroopKickArgs): Constraint[] { buildConstraints(ctx: ConstraintContext, _args: TroopKickArgs): Constraint[] {
if (ctx.destGeneralId !== undefined && ctx.destGeneralId === ctx.actorId) { if (ctx.destGeneralId !== undefined && ctx.destGeneralId === ctx.actorId) {
return [alwaysFail('본인입니다')]; return [denyWithReason('본인입니다')];
} }
return [notBeNeutral(), beChief(), existsDestGeneral(), friendlyDestGeneral()]; return [notBeNeutral(), beChief(), existsDestGeneral(), friendlyDestGeneral()];
} }
@@ -37,8 +37,8 @@ const MIN_TERM_MONTHS = 6;
const resolveMonthIndex = (year: number, month: number): number => year * 12 + month - 1; const resolveMonthIndex = (year: number, month: number): number => year * 12 + month - 1;
const requireMinimumTerm = (minMonths: number): Constraint => ({ const reqMinimumTreatyTerm = (minMonths: number): Constraint => ({
name: 'RequireNonAggressionMinimumTerm', name: 'reqMinimumTreatyTerm',
requires: () => [ requires: () => [
{ kind: 'arg', key: 'year' }, { kind: 'arg', key: 'year' },
{ kind: 'arg', key: 'month' }, { kind: 'arg', key: 'month' },
@@ -108,7 +108,7 @@ export class ActionDefinition<
notBeNeutral(), notBeNeutral(),
existsDestNation(), existsDestNation(),
differentDestNation(), differentDestNation(),
requireMinimumTerm(MIN_TERM_MONTHS), reqMinimumTreatyTerm(MIN_TERM_MONTHS),
disallowDiplomacyBetweenStatus({ disallowDiplomacyBetweenStatus({
0: '아국과 이미 교전중입니다.', 0: '아국과 이미 교전중입니다.',
1: '아국과 이미 선포중입니다.', 1: '아국과 이미 선포중입니다.',
@@ -61,7 +61,7 @@ export class ActionDefinition<
suppliedCity(), suppliedCity(),
beChief(), beChief(),
{ {
name: 'declareWarYearLimit', name: 'reqEnvValue',
requires: () => [], requires: () => [],
test: () => { test: () => {
if (relYear >= 1) return { kind: 'allow' }; if (relYear >= 1) return { kind: 'allow' };
@@ -83,7 +83,7 @@ export class ActionDefinition<
nearNation(), nearNation(),
// legacy: startYear + 1. 0-indexed relYear로는 1 // legacy: startYear + 1. 0-indexed relYear로는 1
{ {
name: 'declareWarYearLimit', name: 'reqEnvValue',
requires: () => [], requires: () => [],
test: () => { test: () => {
if (relYear >= 1) return { kind: 'allow' }; if (relYear >= 1) return { kind: 'allow' };
@@ -48,6 +48,12 @@ const PRE_REQ_TURN = 2;
const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1); const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1);
const DAMAGE_RATE = 0.2; 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<TriggerState extends GeneralTriggerState = GeneralTriggerState> { export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
private readonly pipeline: GeneralActionPipeline<TriggerState>; private readonly pipeline: GeneralActionPipeline<TriggerState>;
@@ -189,7 +195,7 @@ export class ActionDefinition<
beChief(), beChief(),
notNeutralDestCity(), notNeutralDestCity(),
notOccupiedDestCity(), notOccupiedDestCity(),
allowDiplomacyBetweenStatus([0], '교전중인 국가의 도시가 아닙니다.'), battleGroundCity(),
availableStrategicCommand(), availableStrategicCommand(),
]; ];
} }
@@ -4,6 +4,9 @@ import {
beChief, beChief,
occupiedCity, occupiedCity,
occupiedDestCity, occupiedDestCity,
reqNationGold,
reqNationRice,
reqNationValue,
suppliedCity, suppliedCity,
suppliedDestCity, suppliedDestCity,
} from '@sammo-ts/logic/constraints/presets.js'; } from '@sammo-ts/logic/constraints/presets.js';
@@ -37,6 +40,30 @@ export interface MoveCapitalResolveContext<
const ACTION_NAME = '천도'; 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 => { const calcDistance = (fromCityId: number, toCityId: number, map: MapDefinition): number => {
if (fromCityId === toCityId) return 0; if (fromCityId === toCityId) return 0;
@@ -83,6 +110,21 @@ export class ActionDefinition<
buildConstraints(_ctx: ConstraintContext, args: MoveCapitalArgs): Constraint[] { buildConstraints(_ctx: ConstraintContext, args: MoveCapitalArgs): Constraint[] {
const develcost = this.env.develCost; 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 [ return [
occupiedCity(), occupiedCity(),
@@ -90,40 +132,10 @@ export class ActionDefinition<
beChief(), beChief(),
suppliedCity(), suppliedCity(),
suppliedDestCity(), suppliedDestCity(),
{ hasRouteToDestCity(args.destCityID, develcost),
name: 'notCurrentCapital', reqNationValue('capitalCityId', '수도', '!=', args.destCityID, '이미 수도입니다.'),
requires: (ctx) => [{ kind: 'nation', id: ctx.nationId! }], reqNationGold((ctx, view) => baseGold + getRequiredCost(ctx, view), [{ kind: 'env', key: 'map' }]),
test: (ctx: ConstraintContext, view: StateView) => { reqNationRice((ctx, view) => baseRice + getRequiredCost(ctx, view), [{ kind: 'env', key: 'map' }]),
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' };
},
},
]; ];
} }
@@ -1,7 +1,7 @@
import type { General, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; 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 type { Constraint, ConstraintContext, RequirementKey, StateView } from '@sammo-ts/logic/constraints/types.js';
import { import {
alwaysFail, denyWithReason,
beChief, beChief,
existsDestGeneral, existsDestGeneral,
friendlyDestGeneral, friendlyDestGeneral,
@@ -200,7 +200,7 @@ export class ActionDefinition<
} }
if (ctx.destGeneralId === ctx.actorId) { if (ctx.destGeneralId === ctx.actorId) {
return [alwaysFail('본인입니다')]; return [denyWithReason('본인입니다')];
} }
const getRequired = (_ctx: ConstraintContext, _view: StateView): number => const getRequired = (_ctx: ConstraintContext, _view: StateView): number =>
@@ -71,8 +71,8 @@ const ARGS_SCHEMA = z.object({
.refine((value) => value !== 'che_피장파장', '같은 전략은 선택할 수 없습니다.'), .refine((value) => value !== 'che_피장파장', '같은 전략은 선택할 수 없습니다.'),
}); });
const requireCommandType = (): Constraint => ({ const reqValidStrategicCommandType = (): Constraint => ({
name: 'requireStrategicCommandType', name: 'reqValidStrategicCommandType',
requires: () => [{ kind: 'arg', key: 'commandType' }], requires: () => [{ kind: 'arg', key: 'commandType' }],
test: (ctx) => { test: (ctx) => {
const commandType = ctx.args.commandType; const commandType = ctx.args.commandType;
@@ -225,7 +225,7 @@ export class ActionDefinition<
existsDestNation(), existsDestNation(),
allowDiplomacyBetweenStatus([0, 1], '선포, 전쟁중인 상대국에게만 가능합니다.'), allowDiplomacyBetweenStatus([0, 1], '선포, 전쟁중인 상대국에게만 가능합니다.'),
availableStrategicCommand(), availableStrategicCommand(),
requireCommandType(), reqValidStrategicCommandType(),
]; ];
} }
+22
View File
@@ -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 => ({ export const reqCityLevel = (levels: number[]): Constraint => ({
name: 'reqCityLevel', name: 'reqCityLevel',
requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []), requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []),
+154 -1
View File
@@ -1,7 +1,37 @@
import type { General } from '@sammo-ts/logic/domain/entities.js'; 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'; import type { Constraint, ConstraintContext, RequirementKey, StateView } from './types.js';
const readNumericField = (source: Record<string, unknown>, key: string): number | null => {
const value = source[key];
return typeof value === 'number' ? value : null;
};
const readStringField = (source: Record<string, unknown>, key: string): string | null => {
const value = source[key];
return typeof value === 'string' ? value : null;
};
const readNationNumeric = (nation: Record<string, unknown>, 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<string, unknown>, key);
};
export const notBeNeutral = (): Constraint => ({ export const notBeNeutral = (): Constraint => ({
name: 'notBeNeutral', name: 'notBeNeutral',
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }], 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 = ( export const reqGeneralGold = (
getRequiredGold: (ctx: ConstraintContext, view: StateView) => number, getRequiredGold: (ctx: ConstraintContext, view: StateView) => number,
requirements: RequirementKey[] = [] 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<string, unknown>;
let target = source[key];
if (target === undefined && source.meta && typeof source.meta === 'object' && !Array.isArray(source.meta)) {
target = (source.meta as Record<string, unknown>)[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<string, unknown> | 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 = ( export const reqGeneralCrewMargin = (
getCrewTypeId: (ctx: ConstraintContext, view: StateView) => number | null, getCrewTypeId: (ctx: ConstraintContext, view: StateView) => number | null,
requirements: RequirementKey[] = [] requirements: RequirementKey[] = []
+25
View File
@@ -120,3 +120,28 @@ export const parsePercent = (value: string): number | null => {
} }
return Number(match[1]) / 100; 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;
}
};
+27 -3
View File
@@ -1,12 +1,15 @@
import { allow } from './helpers.js'; import { allow, compareValues, type CompareOperator } from './helpers.js';
import type { Constraint } from './types.js'; import type { Constraint } from './types.js';
export const alwaysFail = (reason: string): Constraint => ({ export const denyWithReason = (reason: string): Constraint => ({
name: 'alwaysFail', name: 'denyWithReason',
requires: () => [], requires: () => [],
test: () => ({ kind: 'deny', reason }), test: () => ({ kind: 'deny', reason }),
}); });
// TODO: 점진 이전을 위해 유지. 신규 코드에서는 denyWithReason을 사용한다.
export const alwaysFail = denyWithReason;
export const notOpeningPart = (relYear: number, openingPartYear: number): Constraint => ({ export const notOpeningPart = (relYear: number, openingPartYear: number): Constraint => ({
name: 'notOpeningPart', name: 'notOpeningPart',
requires: () => [], requires: () => [],
@@ -43,3 +46,24 @@ export const beOpeningPart = (): Constraint => ({
return { kind: 'deny', reason: '초반 제한 중에는 불가능합니다.' }; 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 };
},
});
+189 -1
View File
@@ -1,7 +1,34 @@
import type { City, General, Nation } from '@sammo-ts/logic/domain/entities.js'; 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'; import type { Constraint, ConstraintContext, RequirementKey, StateView } from './types.js';
const readNationField = (nation: Nation, key: string): unknown => {
const source = nation as unknown as Record<string, unknown>;
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<string, unknown>;
if (Object.prototype.hasOwnProperty.call(source, maxKey)) {
return source[maxKey];
}
return nation.meta[maxKey];
};
export const notWanderingNation = (): Constraint => ({ export const notWanderingNation = (): Constraint => ({
name: 'notWanderingNation', name: 'notWanderingNation',
requires: (ctx) => (ctx.nationId !== undefined ? [{ kind: 'nation', id: ctx.nationId }] : []), 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 => ({ export const availableStrategicCommand = (allowTurnCnt = 0): Constraint => ({
name: 'availableStrategicCommand', name: 'availableStrategicCommand',
requires: (ctx) => { 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<string, unknown>;
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 => ({ export const nearNation = (): Constraint => ({
name: 'nearNation', name: 'nearNation',
requires: (ctx) => { requires: (ctx) => {
+305 -9
View File
@@ -73,6 +73,82 @@ const collectFiles = async (dir) => {
return files; 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 buildLineIndex = (text) => {
const starts = [0]; const starts = [0];
for (let i = 0; i < text.length; i += 1) { for (let i = 0; i < text.length; i += 1) {
@@ -226,6 +302,23 @@ const normalizeConstraintName = (name) => {
return base[0]?.toLowerCase() + base.slice(1); 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) => { const splitNameTokens = (name) => {
if (!name) { if (!name) {
return []; return [];
@@ -361,7 +454,7 @@ const normalizeTsExpression = (expr) => {
}; };
const makeConstraintEntry = ({ side, kind, name, args, file, line, raw }) => { 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('|') : ''; const argsKey = args?.length ? args.join('|') : '';
return { return {
side, side,
@@ -435,6 +528,10 @@ const extractPhpFileConstraints = (file, text) => {
full: [], full: [],
min: [], min: [],
}; };
const assigned = {
full: false,
min: false,
};
const regex = /\$this->(fullConditionConstraints|minConditionConstraints)\s*(\[\s*\])?\s*=/g; const regex = /\$this->(fullConditionConstraints|minConditionConstraints)\s*(\[\s*\])?\s*=/g;
while (true) { while (true) {
@@ -443,6 +540,7 @@ const extractPhpFileConstraints = (file, text) => {
break; break;
} }
const target = match[1] === 'fullConditionConstraints' ? 'full' : 'min'; const target = match[1] === 'fullConditionConstraints' ? 'full' : 'min';
assigned[target] = true;
const start = match.index + match[0].length; const start = match.index + match[0].length;
const { value, endIndex } = scanToDelimiter(text, start, ';'); const { value, endIndex } = scanToDelimiter(text, start, ';');
const expr = value.trim(); const expr = value.trim();
@@ -470,7 +568,71 @@ const extractPhpFileConstraints = (file, text) => {
regex.lastIndex = endIndex; 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) => { const readStringLikeProperty = (objLiteral, keyName) => {
@@ -863,24 +1025,98 @@ const extractTsMethodConstraints = (methodDecl, sourceFile, side, kind, file, fa
return dedupeEntries(results); 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 extractTsFileConstraints = (file, text) => {
const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
const factorySet = collectTsConstraintFactories(sourceFile); const factorySet = collectTsConstraintFactories(sourceFile);
const relFile = path.relative(ROOT_DIR, file); 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 = { const byKind = {
full: [], full: [],
min: [], min: [],
}; };
const assigned = {
full: false,
min: false,
};
let parentFile = null;
const visit = (node) => { 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)) { if (ts.isMethodDeclaration(node) && ts.isIdentifier(node.name)) {
const name = node.name.text; const name = node.name.text;
if (name === 'buildConstraints') { if (name === 'buildConstraints') {
assigned.full = true;
byKind.full.push( byKind.full.push(
...extractTsMethodConstraints(node, sourceFile, 'ts', 'full', relFile, factorySet) ...extractTsMethodConstraints(node, sourceFile, 'ts', 'full', relFile, factorySet)
); );
} }
if (name === 'buildMinConstraints') { if (name === 'buildMinConstraints') {
assigned.min = true;
byKind.min.push( byKind.min.push(
...extractTsMethodConstraints(node, sourceFile, 'ts', 'min', relFile, factorySet) ...extractTsMethodConstraints(node, sourceFile, 'ts', 'min', relFile, factorySet)
); );
@@ -892,7 +1128,11 @@ const extractTsFileConstraints = (file, text) => {
byKind.full = dedupeEntries(byKind.full); byKind.full = dedupeEntries(byKind.full);
byKind.min = dedupeEntries(byKind.min); byKind.min = dedupeEntries(byKind.min);
return byKind; return {
...byKind,
assigned,
parentFile,
};
}; };
const includeCommand = (key) => { const includeCommand = (key) => {
@@ -922,18 +1162,23 @@ const loadPhpConstraints = async () => {
} }
const text = await fs.readFile(file, 'utf-8'); const text = await fs.readFile(file, 'utf-8');
const extracted = extractPhpFileConstraints(file, text); const extracted = extractPhpFileConstraints(file, maskPhpComments(text));
byCommand.set(key, extracted); byCommand.set(key, extracted);
} }
return byCommand; return resolvePhpInheritance(byCommand);
}; };
const loadTsConstraints = async () => { const loadTsConstraints = async () => {
const files = (await collectFiles(TS_ROOT)).filter((file) => file.endsWith('.ts')); 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) { 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'); const baseName = path.basename(file, '.ts');
if (!/^che_|^cr_|^event_|^휴식$/.test(baseName)) { if (!/^che_|^cr_|^event_|^휴식$/.test(baseName)) {
continue; continue;
@@ -948,10 +1193,61 @@ const loadTsConstraints = async () => {
if (!key || !includeCommand(key)) { if (!key || !includeCommand(key)) {
continue; continue;
} }
commandFileByKey.set(key, file);
}
const text = await fs.readFile(file, 'utf-8'); const resolvedByFile = new Map();
const extracted = extractTsFileConstraints(file, text); const resolving = new Set();
byCommand.set(key, extracted);
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; return byCommand;