fix reserved turn permission constraints
This commit is contained in:
@@ -7,10 +7,12 @@ export interface GeneralActionDefinition<
|
||||
Args = unknown,
|
||||
Context extends GeneralActionResolveContext<TriggerState> = GeneralActionResolveContext<TriggerState>,
|
||||
> {
|
||||
// TODO: legacy permissionConstraints(예약 권한) 모델링 필요.
|
||||
key: string;
|
||||
name: string;
|
||||
parseArgs(raw: unknown): Args | null;
|
||||
// 레거시 testPermissionToReserve()와 같은 예약 입력 전용 제약이다.
|
||||
// 정의하지 않은 명령은 레거시처럼 인자 검증 외 상태 제약 없이 예약한다.
|
||||
buildPermissionConstraints?(ctx: ConstraintContext, args: Args): Constraint[];
|
||||
// 커맨드 입력 단계에서 최소 조건만 평가할 때 사용한다.
|
||||
buildMinConstraints?(ctx: ConstraintContext, args: Args): Constraint[];
|
||||
buildConstraints(ctx: ConstraintContext, args: Args): Constraint[];
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
|
||||
import type { GeneralTurnCommandSpec } from './index.js';
|
||||
import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
|
||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||
import { mustBeNPC } from '@sammo-ts/logic/constraints/presets.js';
|
||||
|
||||
export type NPCSelfResolveContext<TriggerState extends GeneralTriggerState = GeneralTriggerState> =
|
||||
GeneralActionResolveContext<TriggerState> & {
|
||||
@@ -99,6 +100,10 @@ export class ActionDefinition<
|
||||
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
||||
}
|
||||
|
||||
buildPermissionConstraints(_ctx: ConstraintContext, _args: NPCSelfArgs): Constraint[] {
|
||||
return [mustBeNPC()];
|
||||
}
|
||||
|
||||
buildConstraints(_ctx: ConstraintContext, args: NPCSelfArgs): Constraint[] {
|
||||
void _ctx;
|
||||
void args;
|
||||
|
||||
@@ -161,6 +161,10 @@ export class ActionDefinition<
|
||||
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
||||
}
|
||||
|
||||
buildPermissionConstraints(_ctx: ConstraintContext, _args: EmployArgs): Constraint[] {
|
||||
return [reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다')];
|
||||
}
|
||||
|
||||
buildMinConstraints(_ctx: ConstraintContext, _args: EmployArgs): Constraint[] {
|
||||
return [
|
||||
reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다'),
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
reqGeneralValue,
|
||||
reqEnvValue,
|
||||
readMetaNumberFromUnknown,
|
||||
alwaysFail,
|
||||
} from '@sammo-ts/logic/constraints/presets.js';
|
||||
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
||||
import type {
|
||||
@@ -313,6 +314,10 @@ export class ActionDefinition<
|
||||
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
||||
}
|
||||
|
||||
buildPermissionConstraints(_ctx: ConstraintContext, _args: AcceptScoutArgs): Constraint[] {
|
||||
return [alwaysFail('예약 불가능 커맨드')];
|
||||
}
|
||||
|
||||
buildConstraints(_ctx: ConstraintContext, _args: AcceptScoutArgs): Constraint[] {
|
||||
const env = _ctx.env;
|
||||
const year = typeof env.year === 'number' ? env.year : 0;
|
||||
|
||||
@@ -53,6 +53,10 @@ export class ActionDefinition<
|
||||
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
||||
}
|
||||
|
||||
buildPermissionConstraints(_ctx: ConstraintContext, _args: AppointmentArgs): Constraint[] {
|
||||
return [reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다')];
|
||||
}
|
||||
|
||||
buildMinConstraints(_ctx: ConstraintContext, _args: AppointmentArgs): Constraint[] {
|
||||
return [
|
||||
reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다'),
|
||||
|
||||
@@ -138,6 +138,10 @@ export class ActionDefinition<
|
||||
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
||||
}
|
||||
|
||||
buildPermissionConstraints(_ctx: ConstraintContext, _args: FollowAppointmentArgs): Constraint[] {
|
||||
return [reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다')];
|
||||
}
|
||||
|
||||
buildMinConstraints(_ctx: ConstraintContext, _args: FollowAppointmentArgs): Constraint[] {
|
||||
return [reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다'), beNeutral(), allowJoinAction()];
|
||||
}
|
||||
|
||||
@@ -116,6 +116,10 @@ export class ActionDefinition<
|
||||
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
||||
}
|
||||
|
||||
buildPermissionConstraints(_ctx: ConstraintContext, _args: NonAggressionProposalArgs): Constraint[] {
|
||||
return [reqMinimumTreatyTerm(MIN_TERM_MONTHS)];
|
||||
}
|
||||
|
||||
buildMinConstraints(_ctx: ConstraintContext, _args: NonAggressionProposalArgs): Constraint[] {
|
||||
return [beChief(), notBeNeutral()];
|
||||
}
|
||||
|
||||
@@ -596,13 +596,10 @@ export const mustBeNPC = (): Constraint => ({
|
||||
if (!general) {
|
||||
return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.');
|
||||
}
|
||||
// Assuming npcState >= 2 means NPC. Need to verify exact logic if possible,
|
||||
// but typically 0=human, 1=?, 2=NPC.
|
||||
// Legacy: $general->getNPC() where 0:User, 1:Virtual User(unused?), 2:NPC ...
|
||||
if (general.npcState >= 2) {
|
||||
return allow();
|
||||
}
|
||||
return { kind: 'deny', reason: 'NPC가 아닙니다.' };
|
||||
return { kind: 'deny', reason: 'NPC여야 합니다.' };
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { evaluateConstraints } from '../../../src/constraints/evaluate.js';
|
||||
import type { Constraint, ConstraintContext, RequirementKey, StateView } from '../../../src/constraints/types.js';
|
||||
import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js';
|
||||
import { ActionDefinition as EmployAction } from '../../../src/actions/turn/general/che_등용.js';
|
||||
import { ActionDefinition as AcceptScoutAction } from '../../../src/actions/turn/general/che_등용수락.js';
|
||||
import { ActionDefinition as AppointmentAction } from '../../../src/actions/turn/general/che_임관.js';
|
||||
import { ActionDefinition as FollowAppointmentAction } from '../../../src/actions/turn/general/che_장수대상임관.js';
|
||||
import { ActionDefinition as NpcSelfAction } from '../../../src/actions/turn/general/che_NPC능동.js';
|
||||
import { ActionDefinition as NonAggressionProposalAction } from '../../../src/actions/turn/nation/che_불가침제의.js';
|
||||
|
||||
const commandEnv: TurnCommandEnv = {
|
||||
develCost: 100,
|
||||
trainDelta: 35,
|
||||
atmosDelta: 35,
|
||||
maxTrainByCommand: 100,
|
||||
maxAtmosByCommand: 100,
|
||||
sabotageDefaultProb: 0.5,
|
||||
sabotageProbCoefByStat: 0.1,
|
||||
sabotageDefenceCoefByGeneralCount: 0.1,
|
||||
sabotageDamageMin: 10,
|
||||
sabotageDamageMax: 30,
|
||||
openingPartYear: 0,
|
||||
maxGeneral: 10,
|
||||
defaultNpcGold: 1000,
|
||||
defaultNpcRice: 1000,
|
||||
defaultCrewTypeId: 1100,
|
||||
defaultSpecialDomestic: null,
|
||||
defaultSpecialWar: null,
|
||||
initialNationGenLimit: 10,
|
||||
maxTechLevel: 10,
|
||||
baseGold: 1000,
|
||||
baseRice: 1000,
|
||||
maxResourceActionAmount: 1000,
|
||||
};
|
||||
|
||||
class PermissionStateView implements StateView {
|
||||
constructor(
|
||||
private readonly actor: { npcState: number },
|
||||
private readonly env: Record<string, unknown>
|
||||
) {}
|
||||
|
||||
has(req: RequirementKey): boolean {
|
||||
if (req.kind === 'general') {
|
||||
return req.id === 1;
|
||||
}
|
||||
if (req.kind === 'env') {
|
||||
return Object.hasOwn(this.env, req.key);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
get(req: RequirementKey): unknown | null {
|
||||
if (req.kind === 'general' && req.id === 1) {
|
||||
return this.actor;
|
||||
}
|
||||
if (req.kind === 'env') {
|
||||
return this.env[req.key] ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const evaluatePermission = (
|
||||
constraints: Constraint[],
|
||||
args: Record<string, unknown>,
|
||||
options?: { joinMode?: string; npcState?: number }
|
||||
) => {
|
||||
const env = {
|
||||
join_mode: options?.joinMode ?? 'full',
|
||||
year: 190,
|
||||
month: 1,
|
||||
startYear: 180,
|
||||
};
|
||||
const ctx: ConstraintContext = {
|
||||
actorId: 1,
|
||||
args,
|
||||
env,
|
||||
mode: 'full',
|
||||
};
|
||||
return evaluateConstraints(constraints, ctx, new PermissionStateView({ npcState: options?.npcState ?? 0 }, env));
|
||||
};
|
||||
|
||||
const permissionContext: ConstraintContext = {
|
||||
actorId: 1,
|
||||
args: {},
|
||||
env: {},
|
||||
mode: 'full',
|
||||
};
|
||||
|
||||
describe('legacy reservation permission constraints', () => {
|
||||
it('applies only join_mode to the three reservable join commands', () => {
|
||||
const cases = [
|
||||
{
|
||||
constraints: new EmployAction(commandEnv).buildPermissionConstraints(permissionContext, {
|
||||
destGeneralId: 2,
|
||||
}),
|
||||
args: { destGeneralId: 2 },
|
||||
},
|
||||
{
|
||||
constraints: new AppointmentAction(commandEnv).buildPermissionConstraints(permissionContext, {
|
||||
destNationId: 2,
|
||||
}),
|
||||
args: { destNationId: 2 },
|
||||
},
|
||||
{
|
||||
constraints: new FollowAppointmentAction().buildPermissionConstraints(permissionContext, {
|
||||
destGeneralID: 2,
|
||||
}),
|
||||
args: { destGeneralID: 2 },
|
||||
},
|
||||
];
|
||||
|
||||
for (const { constraints, args } of cases) {
|
||||
const randomOnly = evaluatePermission(constraints, args, { joinMode: 'onlyRandom' });
|
||||
expect(randomOnly).toMatchObject({ kind: 'deny', reason: '랜덤 임관만 가능합니다' });
|
||||
|
||||
const full = evaluatePermission(constraints, args);
|
||||
expect(full).toEqual({ kind: 'allow' });
|
||||
}
|
||||
});
|
||||
|
||||
it('blocks scout acceptance reservations and restricts NPC self-action to NPC actors', () => {
|
||||
const accept = new AcceptScoutAction(commandEnv);
|
||||
const acceptArgs = { destNationId: 2, destGeneralId: 7 };
|
||||
expect(
|
||||
evaluatePermission(accept.buildPermissionConstraints(permissionContext, acceptArgs), acceptArgs)
|
||||
).toMatchObject({
|
||||
kind: 'deny',
|
||||
reason: '예약 불가능 커맨드',
|
||||
});
|
||||
|
||||
const npcAction = new NpcSelfAction();
|
||||
const npcArgs = { optionText: '순간이동' as const, destCityId: 2 };
|
||||
expect(
|
||||
evaluatePermission(npcAction.buildPermissionConstraints(permissionContext, npcArgs), npcArgs)
|
||||
).toMatchObject({
|
||||
kind: 'deny',
|
||||
reason: 'NPC여야 합니다.',
|
||||
});
|
||||
expect(
|
||||
evaluatePermission(npcAction.buildPermissionConstraints(permissionContext, npcArgs), npcArgs, {
|
||||
npcState: 2,
|
||||
})
|
||||
).toEqual({ kind: 'allow' });
|
||||
});
|
||||
|
||||
it('checks only the six-month minimum when reserving a non-aggression proposal', () => {
|
||||
const definition = new NonAggressionProposalAction();
|
||||
const shortArgs = { destNationId: 2, year: 190, month: 6 };
|
||||
expect(
|
||||
evaluatePermission(definition.buildPermissionConstraints(permissionContext, shortArgs), shortArgs)
|
||||
).toMatchObject({
|
||||
kind: 'deny',
|
||||
reason: '기한은 6개월 이상이어야 합니다.',
|
||||
});
|
||||
|
||||
const validArgs = { destNationId: 2, year: 190, month: 7 };
|
||||
expect(
|
||||
evaluatePermission(definition.buildPermissionConstraints(permissionContext, validArgs), validArgs)
|
||||
).toEqual({
|
||||
kind: 'allow',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user