diff --git a/packages/logic/src/actions/turn/actionContext.ts b/packages/logic/src/actions/turn/actionContext.ts index 3584f0f..193fe82 100644 --- a/packages/logic/src/actions/turn/actionContext.ts +++ b/packages/logic/src/actions/turn/actionContext.ts @@ -55,23 +55,22 @@ export interface ActionContextWorldRef { getTroopById(id: number): Troop | null; } -export interface ActionContextOptions { +export interface ActionContextOptions = Record> { world: ActionContextWorldState; scenarioConfig: ScenarioConfig; scenarioMeta?: ScenarioMeta; map?: MapDefinition; unitSet?: UnitSetDefinition; worldRef: ActionContextWorldRef | null; - actionArgs: Record; + actionArgs: TArgs; createGeneralId: () => number; createNationId: () => number; seedBase: string; } // 예약 턴 처리에서 커맨드별로 필요한 컨텍스트를 확장한다. -export type ActionContextBuilder = ( - base: ActionContextBase, - options: ActionContextOptions -) => ActionResolveContext | null; +export type ActionContextBuilder = Record> = { + bivarianceHack(base: ActionContextBase, options: ActionContextOptions): ActionResolveContext | null; +}['bivarianceHack']; export const defaultActionContextBuilder: ActionContextBuilder = (base) => base; diff --git a/packages/logic/src/actions/turn/commandModule.ts b/packages/logic/src/actions/turn/commandModule.ts index a4506bd..c9f27c2 100644 --- a/packages/logic/src/actions/turn/commandModule.ts +++ b/packages/logic/src/actions/turn/commandModule.ts @@ -3,16 +3,35 @@ import type { ZodType } from 'zod'; import type { ActionContextBuilder } from './actionContext.js'; import type { TurnCommandEnv } from './commandEnv.js'; -export interface TurnCommandSpecBase { +export interface TurnCommandSpecWithArgs { key: TKey; category: string; - reqArg: boolean; + reqArg: true; args: Record; - argsSchema?: ZodType; + argsSchema: ZodType>; createDefinition(env: TurnCommandEnv): GeneralActionDefinition; } +export interface TurnCommandSpecWithoutArgs { + key: TKey; + category: string; + reqArg: false; + args: Record; + argsSchema?: undefined; + createDefinition(env: TurnCommandEnv): GeneralActionDefinition; +} + +export type TurnCommandSpecBase = + | TurnCommandSpecWithArgs + | TurnCommandSpecWithoutArgs; + +export type TurnCommandArgs = TSpec extends { + argsSchema: ZodType; +} + ? TArgs + : Record; + export interface TurnCommandModule { commandSpec: TSpec; - actionContextBuilder?: ActionContextBuilder; + actionContextBuilder?: ActionContextBuilder>; } diff --git a/packages/logic/src/actions/turn/general/che_모병.ts b/packages/logic/src/actions/turn/general/che_모병.ts index 48a6edd..c9796ad 100644 --- a/packages/logic/src/actions/turn/general/che_모병.ts +++ b/packages/logic/src/actions/turn/general/che_모병.ts @@ -1,5 +1,5 @@ import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; -import { ActionDefinition as RecruitActionDefinition } from './che_징병.js'; +import { ARGS_SCHEMA, ActionDefinition as RecruitActionDefinition } from './che_징병.js'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import type { GeneralTurnCommandSpec } from './index.js'; @@ -30,5 +30,6 @@ export const commandSpec: GeneralTurnCommandSpec = { crewType: 'number', amount: 'number', }, + argsSchema: ARGS_SCHEMA, createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? []), }; diff --git a/packages/logic/src/actions/turn/general/che_징병.ts b/packages/logic/src/actions/turn/general/che_징병.ts index fe2da1c..0b30597 100644 --- a/packages/logic/src/actions/turn/general/che_징병.ts +++ b/packages/logic/src/actions/turn/general/che_징병.ts @@ -55,7 +55,7 @@ const DEFAULT_ATMOS = 40; const DEFAULT_MIN_POP = 30000; const DEFAULT_TRUST = 50; const MIN_CREW = 100; -const ARGS_SCHEMA = z.preprocess( +export const ARGS_SCHEMA = z.preprocess( (raw) => { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { return raw; diff --git a/packages/logic/src/actions/turn/nation/che_국기변경.ts b/packages/logic/src/actions/turn/nation/che_국기변경.ts index fa56e05..6dfaf94 100644 --- a/packages/logic/src/actions/turn/nation/che_국기변경.ts +++ b/packages/logic/src/actions/turn/nation/che_국기변경.ts @@ -12,10 +12,8 @@ import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types. import { JosaUtil } from '@sammo-ts/common'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import type { NationTurnCommandSpec } from './index.js'; - -export interface ChangeFlagArgs { - colorType: number; -} +import { z } from 'zod'; +import { parseArgsWithSchema } from '../parseArgs.js'; const ACTION_NAME = '국기변경'; @@ -55,6 +53,11 @@ const NATION_COLORS = [ '#A9A9A9', ]; +const ARGS_SCHEMA = z.object({ + colorType: z.number().int().min(0).max(NATION_COLORS.length - 1), +}); +export type ChangeFlagArgs = z.infer; + export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, > implements GeneralActionDefinition { @@ -62,10 +65,7 @@ export class ActionDefinition< public readonly name = ACTION_NAME; parseArgs(raw: unknown): ChangeFlagArgs | null { - const data = raw as { colorType?: number }; - if (typeof data?.colorType !== 'number') return null; - if (data.colorType < 0 || data.colorType >= NATION_COLORS.length) return null; - return { colorType: data.colorType }; + return parseArgsWithSchema(ARGS_SCHEMA, raw); } buildConstraints(_ctx: ConstraintContext, _args: ChangeFlagArgs): Constraint[] { @@ -162,5 +162,6 @@ export const commandSpec: NationTurnCommandSpec = { category: '국가', reqArg: true, args: { colorType: 0 }, + argsSchema: ARGS_SCHEMA, createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), }; diff --git a/packages/logic/src/actions/turn/nation/che_국호변경.ts b/packages/logic/src/actions/turn/nation/che_국호변경.ts index ac55bb5..681a147 100644 --- a/packages/logic/src/actions/turn/nation/che_국호변경.ts +++ b/packages/logic/src/actions/turn/nation/che_국호변경.ts @@ -12,10 +12,13 @@ import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types. import { JosaUtil } from '@sammo-ts/common'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import type { NationTurnCommandSpec } from './index.js'; +import { z } from 'zod'; +import { parseArgsWithSchema } from '../parseArgs.js'; -export interface ChangeNationNameArgs { - nationName: string; -} +const ARGS_SCHEMA = z.object({ + nationName: z.string().trim().min(1).max(8), +}); +export type ChangeNationNameArgs = z.infer; const ACTION_NAME = '국호변경'; @@ -26,13 +29,7 @@ export class ActionDefinition< public readonly name = ACTION_NAME; parseArgs(raw: unknown): ChangeNationNameArgs | null { - const data = raw as { nationName?: string }; - if (typeof data?.nationName !== 'string') return null; - - const name = data.nationName.trim(); - if (name.length < 1 || name.length > 8) return null; - - return { nationName: name }; + return parseArgsWithSchema(ARGS_SCHEMA, raw); } buildConstraints(_ctx: ConstraintContext, args: ChangeNationNameArgs): Constraint[] { @@ -124,5 +121,6 @@ export const commandSpec: NationTurnCommandSpec = { category: '국가', reqArg: true, args: { nationName: '' }, + argsSchema: ARGS_SCHEMA, createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), }; diff --git a/packages/logic/src/actions/turn/nation/che_급습.ts b/packages/logic/src/actions/turn/nation/che_급습.ts index ec2957f..78443ce 100644 --- a/packages/logic/src/actions/turn/nation/che_급습.ts +++ b/packages/logic/src/actions/turn/nation/che_급습.ts @@ -22,10 +22,16 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js' import { buildDefaultDiplomacy } from '../../../diplomacy/index.js'; import { JosaUtil } from '@sammo-ts/common'; import type { NationTurnCommandSpec } from './index.js'; +import { z } from 'zod'; +import { parseArgsWithSchema } from '../parseArgs.js'; -export interface RaidArgs { - destNationId: number; -} +const ARGS_SCHEMA = z.object({ + destNationId: z.preprocess( + (value) => (typeof value === 'number' ? Math.floor(value) : value), + z.number().int().positive() + ), +}); +export type RaidArgs = z.infer; export interface RaidResolveContext< TriggerState extends GeneralTriggerState = GeneralTriggerState, @@ -43,14 +49,6 @@ const PRE_REQ_TURN = 0; const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1); const TERM_REDUCE = 3; -const parseNationId = (raw: unknown): number | null => { - if (typeof raw !== 'number' || !Number.isFinite(raw)) { - return null; - } - const value = Math.floor(raw); - return value > 0 ? value : null; -}; - // 급습 쿨타임 계산을 담당한다. export class CommandResolver { private readonly pipeline: GeneralActionPipeline; @@ -173,12 +171,7 @@ export class ActionDefinition< } parseArgs(raw: unknown): RaidArgs | null { - const data = raw as { destNationId?: unknown }; - const destNationId = parseNationId(data?.destNationId); - if (destNationId === null) { - return null; - } - return { destNationId }; + return parseArgsWithSchema(ARGS_SCHEMA, raw); } buildConstraints(_ctx: ConstraintContext, _args: RaidArgs): Constraint[] { @@ -199,7 +192,7 @@ export class ActionDefinition< } // 예약 턴 실행에 필요한 대상 국가/외교 정보를 구성한다. -export const actionContextBuilder: ActionContextBuilder = (base, options) => { +export const actionContextBuilder: ActionContextBuilder = (base, options) => { const destNationId = options.actionArgs.destNationId; if (typeof destNationId !== 'number') { return null; @@ -236,5 +229,6 @@ export const commandSpec: NationTurnCommandSpec = { category: '외교', reqArg: true, args: { destNationId: 0 }, + argsSchema: ARGS_SCHEMA, createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? []), }; diff --git a/packages/logic/src/actions/turn/nation/che_몰수.ts b/packages/logic/src/actions/turn/nation/che_몰수.ts index cc3e0a0..6eb8c33 100644 --- a/packages/logic/src/actions/turn/nation/che_몰수.ts +++ b/packages/logic/src/actions/turn/nation/che_몰수.ts @@ -20,12 +20,18 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js' import type { NationTurnCommandSpec } from './index.js'; import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import { clamp } from 'es-toolkit'; +import { z } from 'zod'; +import { parseArgsWithSchema } from '../parseArgs.js'; -export interface SeizureArgs { - isGold: boolean; - amount: number; - destGeneralID: number; -} +const ARGS_SCHEMA = z.object({ + isGold: z.boolean(), + amount: z.preprocess( + (value) => (typeof value === 'number' ? Math.floor(value / 100) * 100 : value), + z.number().int().positive() + ), + destGeneralID: z.number(), +}); +export type SeizureArgs = z.infer; export interface SeizureResolveContext< TriggerState extends GeneralTriggerState = GeneralTriggerState, @@ -44,18 +50,13 @@ export class ActionDefinition< constructor(private readonly env: TurnCommandEnv) {} parseArgs(raw: unknown): SeizureArgs | null { - const data = raw as { isGold?: boolean; amount?: number; destGeneralID?: number }; - if (typeof data?.isGold !== 'boolean') return null; - if (typeof data?.amount !== 'number') return null; - if (typeof data?.destGeneralID !== 'number') return null; - - const amount = Math.floor(data.amount / 100) * 100; - if (amount <= 0) return null; - + const data = parseArgsWithSchema(ARGS_SCHEMA, raw); + if (!data) { + return null; + } return { - isGold: data.isGold, - amount: clamp(amount, 100, this.env.maxResourceActionAmount ?? 10000), - destGeneralID: data.destGeneralID, + ...data, + amount: clamp(data.amount, 100, this.env.maxResourceActionAmount ?? 10000), }; } @@ -140,7 +141,7 @@ export class ActionDefinition< } } -export const actionContextBuilder: ActionContextBuilder = (base, options) => { +export const actionContextBuilder: ActionContextBuilder = (base, options) => { const destGeneralId = options.actionArgs.destGeneralID; if (typeof destGeneralId !== 'number') return null; @@ -161,5 +162,6 @@ export const commandSpec: NationTurnCommandSpec = { category: '인사', reqArg: true, args: { isGold: false, amount: 0, destGeneralID: 0 }, + argsSchema: ARGS_SCHEMA, createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), }; diff --git a/packages/logic/src/actions/turn/nation/che_물자원조.ts b/packages/logic/src/actions/turn/nation/che_물자원조.ts index cd46a8e..ca0967e 100644 --- a/packages/logic/src/actions/turn/nation/che_물자원조.ts +++ b/packages/logic/src/actions/turn/nation/che_물자원조.ts @@ -20,11 +20,14 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js' import type { NationTurnCommandSpec } from './index.js'; import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import { clamp } from 'es-toolkit'; +import { z } from 'zod'; +import { parseArgsWithSchema } from '../parseArgs.js'; -export interface MaterialAidArgs { - destNationId: number; - amountList: [number, number]; // [gold, rice] -} +const ARGS_SCHEMA = z.object({ + destNationId: z.number(), + amountList: z.tuple([z.number(), z.number()]), +}); +export type MaterialAidArgs = z.infer; export interface MaterialAidResolveContext< TriggerState extends GeneralTriggerState = GeneralTriggerState, @@ -45,15 +48,7 @@ export class ActionDefinition< constructor(private readonly env: TurnCommandEnv) {} parseArgs(raw: unknown): MaterialAidArgs | null { - const data = raw as { destNationId?: number; amountList?: [number, number] }; - if (typeof data?.destNationId !== 'number') return null; - if (!Array.isArray(data.amountList) || data.amountList.length !== 2) return null; - if (typeof data.amountList[0] !== 'number' || typeof data.amountList[1] !== 'number') return null; - - return { - destNationId: data.destNationId, - amountList: [data.amountList[0], data.amountList[1]], - }; + return parseArgsWithSchema(ARGS_SCHEMA, raw); } buildConstraints(_ctx: ConstraintContext, args: MaterialAidArgs): Constraint[] { @@ -187,7 +182,7 @@ export class ActionDefinition< } } -export const actionContextBuilder: ActionContextBuilder = (base, options) => { +export const actionContextBuilder: ActionContextBuilder = (base, options) => { const destNationId = options.actionArgs.destNationId; if (typeof destNationId !== 'number') return null; @@ -208,5 +203,6 @@ export const commandSpec: NationTurnCommandSpec = { category: '외교', reqArg: true, args: { destNationId: 0, amountList: [0, 0] }, + argsSchema: ARGS_SCHEMA, createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), }; diff --git a/packages/logic/src/actions/turn/nation/che_발령.ts b/packages/logic/src/actions/turn/nation/che_발령.ts index 12d0144..72924b5 100644 --- a/packages/logic/src/actions/turn/nation/che_발령.ts +++ b/packages/logic/src/actions/turn/nation/che_발령.ts @@ -25,11 +25,14 @@ import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionCo import { resolveTurnTermMinutes } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import type { NationTurnCommandSpec } from './index.js'; +import { z } from 'zod'; +import { parseArgsWithSchema } from '../parseArgs.js'; -export interface AssignmentArgs { - destGeneralId: number; - destCityId: number; -} +const ARGS_SCHEMA = z.object({ + destGeneralId: z.number(), + destCityId: z.number(), +}); +export type AssignmentArgs = z.infer; export interface AssignmentResolveContext< TriggerState extends GeneralTriggerState = GeneralTriggerState, @@ -146,23 +149,7 @@ export class ActionDefinition< } parseArgs(raw: unknown): AssignmentArgs | null { - if (!raw || typeof raw !== 'object') { - return null; - } - const data = raw as { - destGeneralId?: unknown; - destCityId?: unknown; - }; - if (typeof data.destGeneralId !== 'number') { - return null; - } - if (typeof data.destCityId !== 'number') { - return null; - } - return { - destGeneralId: data.destGeneralId, - destCityId: data.destCityId, - }; + return parseArgsWithSchema(ARGS_SCHEMA, raw); } buildConstraints(ctx: ConstraintContext, _args: AssignmentArgs): Constraint[] { @@ -188,7 +175,7 @@ export class ActionDefinition< } // 예약 턴 실행에 필요한 대상 장수/도시 컨텍스트를 구성한다. -export const actionContextBuilder: ActionContextBuilder = (base, options) => { +export const actionContextBuilder: ActionContextBuilder = (base, options) => { const destGeneralId = options.actionArgs.destGeneralId; const destCityId = options.actionArgs.destCityId; if (typeof destGeneralId !== 'number' || typeof destCityId !== 'number') { @@ -216,5 +203,6 @@ export const commandSpec: NationTurnCommandSpec = { category: '인사', reqArg: true, args: { destGeneralId: 0, destCityId: 0 }, + argsSchema: ARGS_SCHEMA, createDefinition: (_env: TurnCommandEnv) => new ActionDefinition({}), }; diff --git a/packages/logic/src/actions/turn/nation/che_백성동원.ts b/packages/logic/src/actions/turn/nation/che_백성동원.ts index 797a45c..03da8f2 100644 --- a/packages/logic/src/actions/turn/nation/che_백성동원.ts +++ b/packages/logic/src/actions/turn/nation/che_백성동원.ts @@ -20,10 +20,16 @@ import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionCo import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import { JosaUtil } from '@sammo-ts/common'; import type { NationTurnCommandSpec } from './index.js'; +import { z } from 'zod'; +import { parseArgsWithSchema } from '../parseArgs.js'; -export interface MobilizePeopleArgs { - destCityId: number; -} +const ARGS_SCHEMA = z.object({ + destCityId: z.preprocess( + (value) => (typeof value === 'number' ? Math.floor(value) : value), + z.number().int().positive() + ), +}); +export type MobilizePeopleArgs = z.infer; export interface MobilizePeopleResolveContext< TriggerState extends GeneralTriggerState = GeneralTriggerState, @@ -38,14 +44,6 @@ const PRE_REQ_TURN = 0; const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1); const DEFENCE_RATE = 0.8; -const parseCityId = (raw: unknown): number | null => { - if (typeof raw !== 'number' || !Number.isFinite(raw)) { - return null; - } - const value = Math.floor(raw); - return value > 0 ? value : null; -}; - // 백성동원 쿨타임 계산을 담당한다. export class CommandResolver { private readonly pipeline: GeneralActionPipeline; @@ -151,12 +149,7 @@ export class ActionDefinition< } parseArgs(raw: unknown): MobilizePeopleArgs | null { - const data = raw as { destCityId?: unknown }; - const destCityId = parseCityId(data?.destCityId); - if (destCityId === null) { - return null; - } - return { destCityId }; + return parseArgsWithSchema(ARGS_SCHEMA, raw); } buildConstraints(_ctx: ConstraintContext, _args: MobilizePeopleArgs): Constraint[] { @@ -174,7 +167,7 @@ export class ActionDefinition< } // 예약 턴 실행에 필요한 대상 도시/장수 정보를 구성한다. -export const actionContextBuilder: ActionContextBuilder = (base, options) => { +export const actionContextBuilder: ActionContextBuilder = (base, options) => { const destCityId = options.actionArgs.destCityId; if (typeof destCityId !== 'number') { return null; @@ -200,5 +193,6 @@ export const commandSpec: NationTurnCommandSpec = { category: '전략', reqArg: true, args: { destCityId: 0 }, + argsSchema: ARGS_SCHEMA, createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? []), }; diff --git a/packages/logic/src/actions/turn/nation/che_부대탈퇴지시.ts b/packages/logic/src/actions/turn/nation/che_부대탈퇴지시.ts index 4f8d93a..5814550 100644 --- a/packages/logic/src/actions/turn/nation/che_부대탈퇴지시.ts +++ b/packages/logic/src/actions/turn/nation/che_부대탈퇴지시.ts @@ -19,10 +19,16 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js' import type { NationTurnCommandSpec } from './index.js'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; import { JosaUtil } from '@sammo-ts/common'; +import { z } from 'zod'; +import { parseArgsWithSchema } from '../parseArgs.js'; -export interface TroopKickArgs { - destGeneralId: number; -} +const ARGS_SCHEMA = z.object({ + destGeneralId: z.preprocess( + (value) => (typeof value === 'number' ? Math.floor(value) : value), + z.number().int().positive() + ), +}); +export type TroopKickArgs = z.infer; export interface TroopKickResolveContext< TriggerState extends GeneralTriggerState = GeneralTriggerState, @@ -39,17 +45,7 @@ export class ActionDefinition< public readonly name = ACTION_NAME; parseArgs(raw: unknown): TroopKickArgs | null { - if (!raw || typeof raw !== 'object') { - return null; - } - const data = raw as { destGeneralId?: unknown }; - if (typeof data.destGeneralId !== 'number') { - return null; - } - if (data.destGeneralId <= 0) { - return null; - } - return { destGeneralId: data.destGeneralId }; + return parseArgsWithSchema(ARGS_SCHEMA, raw); } buildConstraints(ctx: ConstraintContext, _args: TroopKickArgs): Constraint[] { @@ -98,7 +94,7 @@ export class ActionDefinition< } } -export const actionContextBuilder: ActionContextBuilder = (base, options) => { +export const actionContextBuilder: ActionContextBuilder = (base, options) => { const destGeneralId = options.actionArgs.destGeneralId; if (typeof destGeneralId !== 'number') { return null; @@ -118,5 +114,6 @@ export const commandSpec: NationTurnCommandSpec = { category: '인사', reqArg: true, args: { destGeneralId: 0 }, + argsSchema: ARGS_SCHEMA, createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), }; diff --git a/packages/logic/src/actions/turn/nation/che_불가침제의.ts b/packages/logic/src/actions/turn/nation/che_불가침제의.ts index e3b1356..1c03218 100644 --- a/packages/logic/src/actions/turn/nation/che_불가침제의.ts +++ b/packages/logic/src/actions/turn/nation/che_불가침제의.ts @@ -15,38 +15,25 @@ import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types. import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import type { NationTurnCommandSpec } from './index.js'; +import { z } from 'zod'; +import { parseArgsWithSchema } from '../parseArgs.js'; -export interface NonAggressionProposalArgs { - destNationId: number; - year: number; - month: number; -} +const ARGS_SCHEMA = z.object({ + destNationId: z.preprocess( + (value) => (typeof value === 'number' ? Math.floor(value) : value), + z.number().int().positive() + ), + year: z.preprocess((value) => (typeof value === 'number' ? Math.floor(value) : value), z.number().int().min(0)), + month: z.preprocess( + (value) => (typeof value === 'number' ? Math.floor(value) : value), + z.number().int().min(1).max(12) + ), +}); +export type NonAggressionProposalArgs = z.infer; const ACTION_NAME = '불가침 제의'; const MIN_TERM_MONTHS = 6; -const parseNationId = (raw: unknown): number | null => { - if (typeof raw !== 'number' || !Number.isFinite(raw)) { - return null; - } - return raw > 0 ? Math.floor(raw) : null; -}; - -const parseYear = (raw: unknown): number | null => { - if (typeof raw !== 'number' || !Number.isFinite(raw)) { - return null; - } - return raw >= 0 ? Math.floor(raw) : null; -}; - -const parseMonth = (raw: unknown): number | null => { - if (typeof raw !== 'number' || !Number.isFinite(raw)) { - return null; - } - const month = Math.floor(raw); - return month >= 1 && month <= 12 ? month : null; -}; - const resolveMonthIndex = (year: number, month: number): number => year * 12 + month - 1; const requireMinimumTerm = (minMonths: number): Constraint => ({ @@ -107,18 +94,7 @@ export class ActionDefinition< public readonly name = ACTION_NAME; parseArgs(raw: unknown): NonAggressionProposalArgs | null { - const data = raw as { - destNationId?: unknown; - year?: unknown; - month?: unknown; - }; - const destNationId = parseNationId(data?.destNationId); - const year = parseYear(data?.year); - const month = parseMonth(data?.month); - if (destNationId === null || year === null || month === null) { - return null; - } - return { destNationId, year, month }; + return parseArgsWithSchema(ARGS_SCHEMA, raw); } buildConstraints(_ctx: ConstraintContext, _args: NonAggressionProposalArgs): Constraint[] { @@ -163,5 +139,6 @@ export const commandSpec: NationTurnCommandSpec = { category: '외교', reqArg: true, args: { destNationId: 0, year: 0, month: 0 }, + argsSchema: ARGS_SCHEMA, createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), }; diff --git a/packages/logic/src/actions/turn/nation/che_불가침파기제의.ts b/packages/logic/src/actions/turn/nation/che_불가침파기제의.ts index 5bdc724..334971f 100644 --- a/packages/logic/src/actions/turn/nation/che_불가침파기제의.ts +++ b/packages/logic/src/actions/turn/nation/che_불가침파기제의.ts @@ -15,21 +15,20 @@ import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types. import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import type { NationTurnCommandSpec } from './index.js'; +import { z } from 'zod'; +import { parseArgsWithSchema } from '../parseArgs.js'; -export interface NonAggressionCancelProposalArgs { - destNationId: number; -} +const ARGS_SCHEMA = z.object({ + destNationId: z.preprocess( + (value) => (typeof value === 'number' ? Math.floor(value) : value), + z.number().int().positive() + ), +}); +export type NonAggressionCancelProposalArgs = z.infer; const ACTION_NAME = '불가침 파기 제의'; const DIPLOMACY_NON_AGGRESSION = 7; -const parseNationId = (raw: unknown): number | null => { - if (typeof raw !== 'number' || !Number.isFinite(raw)) { - return null; - } - return raw > 0 ? Math.floor(raw) : null; -}; - // 불가침 파기 제의를 처리하는 국가 커맨드. export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, @@ -38,12 +37,7 @@ export class ActionDefinition< public readonly name = ACTION_NAME; parseArgs(raw: unknown): NonAggressionCancelProposalArgs | null { - const data = raw as { destNationId?: unknown }; - const destNationId = parseNationId(data?.destNationId); - if (destNationId === null) { - return null; - } - return { destNationId }; + return parseArgsWithSchema(ARGS_SCHEMA, raw); } buildConstraints(_ctx: ConstraintContext, _args: NonAggressionCancelProposalArgs): Constraint[] { @@ -81,5 +75,6 @@ export const commandSpec: NationTurnCommandSpec = { category: '외교', reqArg: true, args: { destNationId: 0 }, + argsSchema: ARGS_SCHEMA, createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), }; diff --git a/packages/logic/src/actions/turn/nation/che_선전포고.ts b/packages/logic/src/actions/turn/nation/che_선전포고.ts index 954ef6f..8a0ae28 100644 --- a/packages/logic/src/actions/turn/nation/che_선전포고.ts +++ b/packages/logic/src/actions/turn/nation/che_선전포고.ts @@ -21,10 +21,16 @@ import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionCo import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import { JosaUtil } from '@sammo-ts/common'; import type { NationTurnCommandSpec } from './index.js'; +import { z } from 'zod'; +import { parseArgsWithSchema } from '../parseArgs.js'; -export interface DeclareWarArgs { - destNationId: number; -} +const ARGS_SCHEMA = z.object({ + destNationId: z.preprocess( + (value) => (typeof value === 'number' ? Math.floor(value) : value), + z.number().int().positive() + ), +}); +export type DeclareWarArgs = z.infer; // DeclareWarResolveContext is not used anymore as it was replaced by inline type in ActionDefinition @@ -33,13 +39,6 @@ const ACTION_NAME = '선전포고'; const DIPLOMACY_DECLARE = 1; const DECLARE_TERM = 24; -const parseNationId = (raw: unknown): number | null => { - if (typeof raw !== 'number' || !Number.isFinite(raw)) { - return null; - } - return raw > 0 ? Math.floor(raw) : null; -}; - export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, > implements GeneralActionDefinition< @@ -51,12 +50,7 @@ export class ActionDefinition< public readonly name = ACTION_NAME; parseArgs(raw: unknown): DeclareWarArgs | null { - const data = raw as { destNationId?: unknown }; - const destNationId = parseNationId(data?.destNationId); - if (destNationId === null) { - return null; - } - return { destNationId }; + return parseArgsWithSchema(ARGS_SCHEMA, raw); } buildConstraints(_ctx: ConstraintContext, _args: DeclareWarArgs): Constraint[] { @@ -164,7 +158,7 @@ export class ActionDefinition< } // 예약 턴 실행에 필요한 대상 국가 정보를 구성한다. -export const actionContextBuilder: ActionContextBuilder = (base, options) => { +export const actionContextBuilder: ActionContextBuilder = (base, options) => { const destNationId = options.actionArgs.destNationId; if (typeof destNationId !== 'number') { return null; @@ -188,5 +182,6 @@ export const commandSpec: NationTurnCommandSpec = { category: '외교', reqArg: true, args: { destNationId: 0 }, + argsSchema: ARGS_SCHEMA, createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), }; diff --git a/packages/logic/src/actions/turn/nation/che_수몰.ts b/packages/logic/src/actions/turn/nation/che_수몰.ts index 5acf3a7..d3c0903 100644 --- a/packages/logic/src/actions/turn/nation/che_수몰.ts +++ b/packages/logic/src/actions/turn/nation/che_수몰.ts @@ -22,10 +22,16 @@ import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionCo import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import { JosaUtil } from '@sammo-ts/common'; import type { NationTurnCommandSpec } from './index.js'; +import { z } from 'zod'; +import { parseArgsWithSchema } from '../parseArgs.js'; -export interface FloodArgs { - destCityId: number; -} +const ARGS_SCHEMA = z.object({ + destCityId: z.preprocess( + (value) => (typeof value === 'number' ? Math.floor(value) : value), + z.number().int().positive() + ), +}); +export type FloodArgs = z.infer; export interface FloodResolveContext< TriggerState extends GeneralTriggerState = GeneralTriggerState, @@ -42,14 +48,6 @@ const PRE_REQ_TURN = 2; const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1); const DAMAGE_RATE = 0.2; -const parseCityId = (raw: unknown): number | null => { - if (typeof raw !== 'number' || !Number.isFinite(raw)) { - return null; - } - const value = Math.floor(raw); - return value > 0 ? value : null; -}; - // 수몰 쿨타임 계산을 담당한다. export class CommandResolver { private readonly pipeline: GeneralActionPipeline; @@ -176,12 +174,7 @@ export class ActionDefinition< } parseArgs(raw: unknown): FloodArgs | null { - const data = raw as { destCityId?: unknown }; - const destCityId = parseCityId(data?.destCityId); - if (destCityId === null) { - return null; - } - return { destCityId }; + return parseArgsWithSchema(ARGS_SCHEMA, raw); } buildConstraints(_ctx: ConstraintContext, _args: FloodArgs): Constraint[] { @@ -203,7 +196,7 @@ export class ActionDefinition< } // 예약 턴 실행에 필요한 대상 도시/장수 정보를 구성한다. -export const actionContextBuilder: ActionContextBuilder = (base, options) => { +export const actionContextBuilder: ActionContextBuilder = (base, options) => { const destCityId = options.actionArgs.destCityId; if (typeof destCityId !== 'number') { return null; @@ -234,5 +227,6 @@ export const commandSpec: NationTurnCommandSpec = { category: '전략', reqArg: true, args: { destCityId: 0 }, + argsSchema: ARGS_SCHEMA, createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? []), }; diff --git a/packages/logic/src/actions/turn/nation/che_이호경식.ts b/packages/logic/src/actions/turn/nation/che_이호경식.ts index 251ce2f..0c4cbf5 100644 --- a/packages/logic/src/actions/turn/nation/che_이호경식.ts +++ b/packages/logic/src/actions/turn/nation/che_이호경식.ts @@ -22,10 +22,16 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js' import { buildDefaultDiplomacy, DIPLOMACY_STATE } from '../../../diplomacy/index.js'; import { JosaUtil } from '@sammo-ts/common'; import type { NationTurnCommandSpec } from './index.js'; +import { z } from 'zod'; +import { parseArgsWithSchema } from '../parseArgs.js'; -export interface DegradeRelationsArgs { - destNationId: number; -} +const ARGS_SCHEMA = z.object({ + destNationId: z.preprocess( + (value) => (typeof value === 'number' ? Math.floor(value) : value), + z.number().int().positive() + ), +}); +export type DegradeRelationsArgs = z.infer; export interface DegradeRelationsResolveContext< TriggerState extends GeneralTriggerState = GeneralTriggerState, @@ -42,14 +48,6 @@ const DEFAULT_GLOBAL_DELAY = 9; const PRE_REQ_TURN = 0; const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1); -const parseNationId = (raw: unknown): number | null => { - if (typeof raw !== 'number' || !Number.isFinite(raw)) { - return null; - } - const value = Math.floor(raw); - return value > 0 ? value : null; -}; - const resolveNextTerm = (state: number, term: number): number => (state === DIPLOMACY_STATE.WAR ? 3 : term + 3); // 이호경식 쿨타임 계산을 담당한다. @@ -180,12 +178,7 @@ export class ActionDefinition< } parseArgs(raw: unknown): DegradeRelationsArgs | null { - const data = raw as { destNationId?: unknown }; - const destNationId = parseNationId(data?.destNationId); - if (destNationId === null) { - return null; - } - return { destNationId }; + return parseArgsWithSchema(ARGS_SCHEMA, raw); } buildConstraints(_ctx: ConstraintContext, _args: DegradeRelationsArgs): Constraint[] { @@ -209,7 +202,7 @@ export class ActionDefinition< } // 예약 턴 실행에 필요한 대상 국가/외교 정보를 구성한다. -export const actionContextBuilder: ActionContextBuilder = (base, options) => { +export const actionContextBuilder: ActionContextBuilder = (base, options) => { const destNationId = options.actionArgs.destNationId; if (typeof destNationId !== 'number') { return null; @@ -246,5 +239,6 @@ export const commandSpec: NationTurnCommandSpec = { category: '외교', reqArg: true, args: { destNationId: 0 }, + argsSchema: ARGS_SCHEMA, createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? []), }; diff --git a/packages/logic/src/actions/turn/nation/che_종전제의.ts b/packages/logic/src/actions/turn/nation/che_종전제의.ts index 25ccaf1..d4c316c 100644 --- a/packages/logic/src/actions/turn/nation/che_종전제의.ts +++ b/packages/logic/src/actions/turn/nation/che_종전제의.ts @@ -16,20 +16,19 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js' import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import type { NationTurnCommandSpec } from './index.js'; import { JosaUtil } from '@sammo-ts/common'; +import { z } from 'zod'; +import { parseArgsWithSchema } from '../parseArgs.js'; -export interface StopWarProposalArgs { - destNationId: number; -} +const ARGS_SCHEMA = z.object({ + destNationId: z.preprocess( + (value) => (typeof value === 'number' ? Math.floor(value) : value), + z.number().int().positive() + ), +}); +export type StopWarProposalArgs = z.infer; const ACTION_NAME = '종전 제의'; -const parseNationId = (raw: unknown): number | null => { - if (typeof raw !== 'number' || !Number.isFinite(raw)) { - return null; - } - return raw > 0 ? Math.floor(raw) : null; -}; - // 종전 제의를 처리하는 국가 커맨드. export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, @@ -38,12 +37,7 @@ export class ActionDefinition< public readonly name = ACTION_NAME; parseArgs(raw: unknown): StopWarProposalArgs | null { - const data = raw as { destNationId?: unknown }; - const destNationId = parseNationId(data?.destNationId); - if (destNationId === null) { - return null; - } - return { destNationId }; + return parseArgsWithSchema(ARGS_SCHEMA, raw); } buildConstraints(_ctx: ConstraintContext, _args: StopWarProposalArgs): Constraint[] { @@ -84,5 +78,6 @@ export const commandSpec: NationTurnCommandSpec = { category: '외교', reqArg: true, args: { destNationId: 0 }, + argsSchema: ARGS_SCHEMA, createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), }; diff --git a/packages/logic/src/actions/turn/nation/che_천도.ts b/packages/logic/src/actions/turn/nation/che_천도.ts index c8caa60..79d4953 100644 --- a/packages/logic/src/actions/turn/nation/che_천도.ts +++ b/packages/logic/src/actions/turn/nation/che_천도.ts @@ -20,10 +20,13 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js' import { JosaUtil } from '@sammo-ts/common'; import type { NationTurnCommandSpec } from './index.js'; import type { MapDefinition } from '@sammo-ts/logic/world/types.js'; +import { z } from 'zod'; +import { parseArgsWithSchema } from '../parseArgs.js'; -export interface MoveCapitalArgs { - destCityID: number; -} +const ARGS_SCHEMA = z.object({ + destCityID: z.number(), +}); +export type MoveCapitalArgs = z.infer; export interface MoveCapitalResolveContext< TriggerState extends GeneralTriggerState = GeneralTriggerState, @@ -71,9 +74,7 @@ export class ActionDefinition< constructor(private readonly env: TurnCommandEnv) {} parseArgs(raw: unknown): MoveCapitalArgs | null { - const data = raw as { destCityID?: number }; - if (typeof data?.destCityID !== 'number') return null; - return { destCityID: data.destCityID }; + return parseArgsWithSchema(ARGS_SCHEMA, raw); } buildConstraints(_ctx: ConstraintContext, args: MoveCapitalArgs): Constraint[] { @@ -194,7 +195,7 @@ export class ActionDefinition< } } -export const actionContextBuilder: ActionContextBuilder = (base, options) => { +export const actionContextBuilder: ActionContextBuilder = (base, options) => { const destCityId = options.actionArgs.destCityID; if (typeof destCityId !== 'number') return null; @@ -217,5 +218,6 @@ export const commandSpec: NationTurnCommandSpec = { category: '국가', reqArg: true, args: { destCityID: 0 }, + argsSchema: ARGS_SCHEMA, createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), }; diff --git a/packages/logic/src/actions/turn/nation/che_초토화.ts b/packages/logic/src/actions/turn/nation/che_초토화.ts index fd4f6a8..d4fcaa2 100644 --- a/packages/logic/src/actions/turn/nation/che_초토화.ts +++ b/packages/logic/src/actions/turn/nation/che_초토화.ts @@ -21,10 +21,16 @@ import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionCo import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import { JosaUtil } from '@sammo-ts/common'; import type { NationTurnCommandSpec } from './index.js'; +import { z } from 'zod'; +import { parseArgsWithSchema } from '../parseArgs.js'; -export interface ScorchedEarthArgs { - destCityId: number; -} +const ARGS_SCHEMA = z.object({ + destCityId: z.preprocess( + (value) => (typeof value === 'number' ? Math.floor(value) : value), + z.number().int().positive() + ), +}); +export type ScorchedEarthArgs = z.infer; export interface ScorchedEarthResolveContext< TriggerState extends GeneralTriggerState = GeneralTriggerState, @@ -37,14 +43,6 @@ const ACTION_NAME = '초토화'; const PRE_REQ_TURN = 2; const POST_REQ_TURN = 24; -const parseCityId = (raw: unknown): number | null => { - if (typeof raw !== 'number' || !Number.isFinite(raw)) { - return null; - } - const value = Math.floor(raw); - return value > 0 ? value : null; -}; - const requireNoDiplomacyLimit = (): Constraint => ({ name: 'requireNoDiplomacyLimit', requires: (ctx) => (ctx.nationId !== undefined ? [{ kind: 'nation', id: ctx.nationId }] : []), @@ -115,12 +113,7 @@ export class ActionDefinition< public readonly name = ACTION_NAME; parseArgs(raw: unknown): ScorchedEarthArgs | null { - const data = raw as { destCityId?: unknown }; - const destCityId = parseCityId(data?.destCityId); - if (destCityId === null) { - return null; - } - return { destCityId }; + return parseArgsWithSchema(ARGS_SCHEMA, raw); } buildConstraints(_ctx: ConstraintContext, args: ScorchedEarthArgs): Constraint[] { @@ -247,7 +240,7 @@ export class ActionDefinition< } // 예약 턴 실행에 필요한 대상 도시 정보를 구성한다. -export const actionContextBuilder: ActionContextBuilder = (base, options) => { +export const actionContextBuilder: ActionContextBuilder = (base, options) => { const destCityId = options.actionArgs.destCityId; if (typeof destCityId !== 'number') { return null; @@ -273,5 +266,6 @@ export const commandSpec: NationTurnCommandSpec = { category: '특수', reqArg: true, args: { destCityId: 0 }, + argsSchema: ARGS_SCHEMA, createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), }; diff --git a/packages/logic/src/actions/turn/nation/che_포상.ts b/packages/logic/src/actions/turn/nation/che_포상.ts index 0072c04..3b3ec6b 100644 --- a/packages/logic/src/actions/turn/nation/che_포상.ts +++ b/packages/logic/src/actions/turn/nation/che_포상.ts @@ -25,12 +25,15 @@ import type { NationTurnCommandSpec } from './index.js'; import { JosaUtil } from '@sammo-ts/common'; import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import { clamp } from 'es-toolkit'; +import { z } from 'zod'; +import { parseArgsWithSchema } from '../parseArgs.js'; -export interface AwardArgs { - isGold: boolean; - amount: number; - destGeneralId: number; -} +const ARGS_SCHEMA = z.object({ + isGold: z.boolean(), + amount: z.number(), + destGeneralId: z.number(), +}); +export type AwardArgs = z.infer; export interface AwardResolveContext< TriggerState extends GeneralTriggerState = GeneralTriggerState, @@ -163,21 +166,8 @@ export class ActionDefinition< } parseArgs(raw: unknown): AwardArgs | null { - if (!raw || typeof raw !== 'object') { - return null; - } - const data = raw as { - isGold?: unknown; - amount?: unknown; - destGeneralId?: unknown; - }; - if (typeof data.isGold !== 'boolean') { - return null; - } - if (typeof data.amount !== 'number' || Number.isNaN(data.amount)) { - return null; - } - if (typeof data.destGeneralId !== 'number') { + const data = parseArgsWithSchema(ARGS_SCHEMA, raw); + if (!data) { return null; } const amount = this.command.normalizeAmount(data.amount); @@ -185,9 +175,8 @@ export class ActionDefinition< return null; } return { - isGold: data.isGold, + ...data, amount, - destGeneralId: data.destGeneralId, }; } @@ -231,7 +220,7 @@ export class ActionDefinition< } // 예약 턴 실행에 필요한 대상 장수를 주입한다. -export const actionContextBuilder: ActionContextBuilder = (base, options) => { +export const actionContextBuilder: ActionContextBuilder = (base, options) => { const destGeneralId = options.actionArgs.destGeneralId; if (typeof destGeneralId !== 'number') { return null; @@ -251,6 +240,7 @@ export const commandSpec: NationTurnCommandSpec = { category: '인사', reqArg: true, args: { isGold: true, amount: 1, destGeneralId: 0 }, + argsSchema: ARGS_SCHEMA, createDefinition: (env: TurnCommandEnv) => { const maxAmount = env.maxResourceActionAmount > 0 ? env.maxResourceActionAmount : Math.max(env.baseGold, env.baseRice, 1000); diff --git a/packages/logic/src/actions/turn/nation/che_피장파장.ts b/packages/logic/src/actions/turn/nation/che_피장파장.ts index 301278a..849ac13 100644 --- a/packages/logic/src/actions/turn/nation/che_피장파장.ts +++ b/packages/logic/src/actions/turn/nation/che_피장파장.ts @@ -21,11 +21,10 @@ import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionCo import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import { JosaUtil } from '@sammo-ts/common'; import type { NationTurnCommandSpec } from './index.js'; +import { z } from 'zod'; +import { parseArgsWithSchema } from '../parseArgs.js'; -export interface CounterStrategyArgs { - destNationId: number; - commandType: string; -} +export type CounterStrategyArgs = z.infer; export interface CounterStrategyResolveContext< TriggerState extends GeneralTriggerState = GeneralTriggerState, @@ -40,7 +39,18 @@ const DEFAULT_GLOBAL_DELAY = 8; const PRE_REQ_TURN = 1; const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1); -const STRATEGIC_COMMANDS: Record = { +const STRATEGIC_COMMAND_KEYS = [ + 'che_필사즉생', + 'che_백성동원', + 'che_수몰', + 'che_허보', + 'che_의병모집', + 'che_이호경식', + 'che_급습', + 'che_피장파장', +] as const; + +const STRATEGIC_COMMANDS: Record<(typeof STRATEGIC_COMMAND_KEYS)[number], string> = { che_필사즉생: '필사즉생', che_백성동원: '백성동원', che_수몰: '수몰', @@ -51,26 +61,15 @@ const STRATEGIC_COMMANDS: Record = { che_피장파장: '피장파장', }; -const parseNationId = (raw: unknown): number | null => { - if (typeof raw !== 'number' || !Number.isFinite(raw)) { - return null; - } - const value = Math.floor(raw); - return value > 0 ? value : null; -}; - -const parseCommandType = (raw: unknown): string | null => { - if (typeof raw !== 'string') { - return null; - } - if (!Object.prototype.hasOwnProperty.call(STRATEGIC_COMMANDS, raw)) { - return null; - } - if (raw === 'che_피장파장') { - return null; - } - return raw; -}; +const ARGS_SCHEMA = z.object({ + destNationId: z.preprocess( + (value) => (typeof value === 'number' ? Math.floor(value) : value), + z.number().int().positive() + ), + commandType: z + .enum(STRATEGIC_COMMAND_KEYS) + .refine((value) => value !== 'che_피장파장', '같은 전략은 선택할 수 없습니다.'), +}); const requireCommandType = (): Constraint => ({ name: 'requireStrategicCommandType', @@ -209,13 +208,7 @@ export class ActionDefinition< private readonly resolver = new ActionResolver(); parseArgs(raw: unknown): CounterStrategyArgs | null { - const data = raw as { destNationId?: unknown; commandType?: unknown }; - const destNationId = parseNationId(data?.destNationId); - const commandType = parseCommandType(data?.commandType); - if (destNationId === null || commandType === null) { - return null; - } - return { destNationId, commandType }; + return parseArgsWithSchema(ARGS_SCHEMA, raw); } buildConstraints(_ctx: ConstraintContext, _args: CounterStrategyArgs): Constraint[] { @@ -237,7 +230,7 @@ export class ActionDefinition< } // 예약 턴 실행에 필요한 대상 국가/장수 정보를 구성한다. -export const actionContextBuilder: ActionContextBuilder = (base, options) => { +export const actionContextBuilder: ActionContextBuilder = (base, options) => { const destNationId = options.actionArgs.destNationId; if (typeof destNationId !== 'number') { return null; @@ -266,5 +259,6 @@ export const commandSpec: NationTurnCommandSpec = { category: '전략', reqArg: true, args: { destNationId: 0, commandType: '' }, + argsSchema: ARGS_SCHEMA, createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), }; diff --git a/packages/logic/src/actions/turn/nation/che_허보.ts b/packages/logic/src/actions/turn/nation/che_허보.ts index 317c213..36a946a 100644 --- a/packages/logic/src/actions/turn/nation/che_허보.ts +++ b/packages/logic/src/actions/turn/nation/che_허보.ts @@ -22,10 +22,16 @@ import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionCo import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import { JosaUtil } from '@sammo-ts/common'; import type { NationTurnCommandSpec } from './index.js'; +import { z } from 'zod'; +import { parseArgsWithSchema } from '../parseArgs.js'; -export interface DeceptionArgs { - destCityId: number; -} +const ARGS_SCHEMA = z.object({ + destCityId: z.preprocess( + (value) => (typeof value === 'number' ? Math.floor(value) : value), + z.number().int().positive() + ), +}); +export type DeceptionArgs = z.infer; export interface DeceptionResolveContext< TriggerState extends GeneralTriggerState = GeneralTriggerState, @@ -42,14 +48,6 @@ const DEFAULT_GLOBAL_DELAY = 9; const PRE_REQ_TURN = 1; const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1); -const parseCityId = (raw: unknown): number | null => { - if (typeof raw !== 'number' || !Number.isFinite(raw)) { - return null; - } - const value = Math.floor(raw); - return value > 0 ? value : null; -}; - const pickMoveCityId = (rng: GeneralActionResolveContext['rng'], destCityId: number, candidates: City[]): number => { if (candidates.length === 0) { return destCityId; @@ -183,12 +181,7 @@ export class ActionDefinition< } parseArgs(raw: unknown): DeceptionArgs | null { - const data = raw as { destCityId?: unknown }; - const destCityId = parseCityId(data?.destCityId); - if (destCityId === null) { - return null; - } - return { destCityId }; + return parseArgsWithSchema(ARGS_SCHEMA, raw); } buildConstraints(_ctx: ConstraintContext, _args: DeceptionArgs): Constraint[] { @@ -210,7 +203,7 @@ export class ActionDefinition< } // 예약 턴 실행에 필요한 대상 도시/장수 정보를 구성한다. -export const actionContextBuilder: ActionContextBuilder = (base, options) => { +export const actionContextBuilder: ActionContextBuilder = (base, options) => { const destCityId = options.actionArgs.destCityId; if (typeof destCityId !== 'number') { return null; @@ -247,5 +240,6 @@ export const commandSpec: NationTurnCommandSpec = { category: '전략', reqArg: true, args: { destCityId: 0 }, + argsSchema: ARGS_SCHEMA, createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? []), }; diff --git a/packages/logic/src/actions/turn/nation/cr_인구이동.ts b/packages/logic/src/actions/turn/nation/cr_인구이동.ts index 37219a9..6a20b2a 100644 --- a/packages/logic/src/actions/turn/nation/cr_인구이동.ts +++ b/packages/logic/src/actions/turn/nation/cr_인구이동.ts @@ -25,11 +25,8 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js' import type { NationTurnCommandSpec } from './index.js'; import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import { clamp } from 'es-toolkit'; - -export interface PopulationMoveArgs { - destCityId: number; - amount: number; -} +import { z } from 'zod'; +import { parseArgsWithSchema } from '../parseArgs.js'; export interface PopulationMoveResolveContext< TriggerState extends GeneralTriggerState = GeneralTriggerState, @@ -42,24 +39,17 @@ const ACTION_NAME = '인구이동'; const AMOUNT_LIMIT = 100000; const MIN_AVAILABLE_RECRUIT_POP = 30000; -const parseCityId = (raw: unknown): number | null => { - if (typeof raw !== 'number' || !Number.isFinite(raw)) { - return null; - } - const value = Math.floor(raw); - return value > 0 ? value : null; -}; - -const parseAmount = (raw: unknown): number | null => { - if (typeof raw !== 'number' || !Number.isFinite(raw)) { - return null; - } - const value = Math.floor(raw); - if (value < 0) { - return null; - } - return clamp(value, 0, AMOUNT_LIMIT); -}; +const ARGS_SCHEMA = z.object({ + destCityId: z.preprocess( + (value) => (typeof value === 'number' ? Math.floor(value) : value), + z.number().int().positive() + ), + amount: z.preprocess( + (value) => (typeof value === 'number' ? clamp(Math.floor(value), 0, AMOUNT_LIMIT) : value), + z.number().int().min(0).max(AMOUNT_LIMIT) + ), +}); +export type PopulationMoveArgs = z.infer; const calcCost = (develCost: number, amount: number): number => Math.round((develCost * amount) / 10000); @@ -72,13 +62,7 @@ export class ActionDefinition< constructor(private readonly env: TurnCommandEnv) {} parseArgs(raw: unknown): PopulationMoveArgs | null { - const data = raw as { destCityId?: unknown; amount?: unknown }; - const destCityId = parseCityId(data?.destCityId); - const amount = parseAmount(data?.amount); - if (destCityId === null || amount === null) { - return null; - } - return { destCityId, amount }; + return parseArgsWithSchema(ARGS_SCHEMA, raw); } buildConstraints(_ctx: ConstraintContext, args: PopulationMoveArgs): Constraint[] { @@ -159,7 +143,7 @@ export class ActionDefinition< } } -export const actionContextBuilder: ActionContextBuilder = (base, options) => { +export const actionContextBuilder: ActionContextBuilder = (base, options) => { const destCityId = options.actionArgs.destCityId; if (typeof destCityId !== 'number') { return null; @@ -185,5 +169,6 @@ export const commandSpec: NationTurnCommandSpec = { category: '특수', reqArg: true, args: { destCityId: 0, amount: 0 }, + argsSchema: ARGS_SCHEMA, createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), }; diff --git a/packages/logic/test/actions/turn/nationMissing.test.ts b/packages/logic/test/actions/turn/nationMissing.test.ts index 8a38860..3f2e0a6 100644 --- a/packages/logic/test/actions/turn/nationMissing.test.ts +++ b/packages/logic/test/actions/turn/nationMissing.test.ts @@ -290,7 +290,7 @@ describe('Nation Missing Actions', () => { setupDiplomacy(view, nation.id, destNation.id, 2); const definition = new CounterStrategyAction(); - const args = { destNationId: destNation.id, commandType: 'che_허보' }; + const args = { destNationId: destNation.id, commandType: 'che_허보' } as const; const ctx: ConstraintContext = { actorId: general.id, nationId: nation.id, @@ -327,7 +327,7 @@ describe('Nation Missing Actions', () => { addLog: () => {}, } as any, { now: new Date(), schedule }, - { destNationId: destNation.id, commandType: 'che_허보' } + { destNationId: destNation.id, commandType: 'che_허보' } as const ); expect(resolution.general.experience).toBeGreaterThan(100);