커맨드의 arg 타입에 zod 강제.

This commit is contained in:
2026-01-15 16:34:18 +00:00
parent 39e7eddcf7
commit a7b22f0502
25 changed files with 288 additions and 390 deletions
@@ -55,23 +55,22 @@ export interface ActionContextWorldRef {
getTroopById(id: number): Troop | null; getTroopById(id: number): Troop | null;
} }
export interface ActionContextOptions { export interface ActionContextOptions<TArgs extends Record<string, unknown> = Record<string, unknown>> {
world: ActionContextWorldState; world: ActionContextWorldState;
scenarioConfig: ScenarioConfig; scenarioConfig: ScenarioConfig;
scenarioMeta?: ScenarioMeta; scenarioMeta?: ScenarioMeta;
map?: MapDefinition; map?: MapDefinition;
unitSet?: UnitSetDefinition; unitSet?: UnitSetDefinition;
worldRef: ActionContextWorldRef | null; worldRef: ActionContextWorldRef | null;
actionArgs: Record<string, unknown>; actionArgs: TArgs;
createGeneralId: () => number; createGeneralId: () => number;
createNationId: () => number; createNationId: () => number;
seedBase: string; seedBase: string;
} }
// 예약 턴 처리에서 커맨드별로 필요한 컨텍스트를 확장한다. // 예약 턴 처리에서 커맨드별로 필요한 컨텍스트를 확장한다.
export type ActionContextBuilder = ( export type ActionContextBuilder<TArgs extends Record<string, unknown> = Record<string, unknown>> = {
base: ActionContextBase, bivarianceHack(base: ActionContextBase, options: ActionContextOptions<TArgs>): ActionResolveContext | null;
options: ActionContextOptions }['bivarianceHack'];
) => ActionResolveContext | null;
export const defaultActionContextBuilder: ActionContextBuilder = (base) => base; export const defaultActionContextBuilder: ActionContextBuilder = (base) => base;
@@ -3,16 +3,35 @@ import type { ZodType } from 'zod';
import type { ActionContextBuilder } from './actionContext.js'; import type { ActionContextBuilder } from './actionContext.js';
import type { TurnCommandEnv } from './commandEnv.js'; import type { TurnCommandEnv } from './commandEnv.js';
export interface TurnCommandSpecBase<TKey extends string = string> { export interface TurnCommandSpecWithArgs<TKey extends string = string> {
key: TKey; key: TKey;
category: string; category: string;
reqArg: boolean; reqArg: true;
args: Record<string, unknown>; args: Record<string, unknown>;
argsSchema?: ZodType<unknown>; argsSchema: ZodType<Record<string, unknown>>;
createDefinition(env: TurnCommandEnv): GeneralActionDefinition; createDefinition(env: TurnCommandEnv): GeneralActionDefinition;
} }
export interface TurnCommandSpecWithoutArgs<TKey extends string = string> {
key: TKey;
category: string;
reqArg: false;
args: Record<string, unknown>;
argsSchema?: undefined;
createDefinition(env: TurnCommandEnv): GeneralActionDefinition;
}
export type TurnCommandSpecBase<TKey extends string = string> =
| TurnCommandSpecWithArgs<TKey>
| TurnCommandSpecWithoutArgs<TKey>;
export type TurnCommandArgs<TSpec extends TurnCommandSpecBase> = TSpec extends {
argsSchema: ZodType<infer TArgs>;
}
? TArgs
: Record<string, never>;
export interface TurnCommandModule<TSpec extends TurnCommandSpecBase = TurnCommandSpecBase> { export interface TurnCommandModule<TSpec extends TurnCommandSpecBase = TurnCommandSpecBase> {
commandSpec: TSpec; commandSpec: TSpec;
actionContextBuilder?: ActionContextBuilder; actionContextBuilder?: ActionContextBuilder<TurnCommandArgs<TSpec>>;
} }
@@ -1,5 +1,5 @@
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; 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 type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import type { GeneralTurnCommandSpec } from './index.js'; import type { GeneralTurnCommandSpec } from './index.js';
@@ -30,5 +30,6 @@ export const commandSpec: GeneralTurnCommandSpec = {
crewType: 'number', crewType: 'number',
amount: 'number', amount: 'number',
}, },
argsSchema: ARGS_SCHEMA,
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? []), createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? []),
}; };
@@ -55,7 +55,7 @@ const DEFAULT_ATMOS = 40;
const DEFAULT_MIN_POP = 30000; const DEFAULT_MIN_POP = 30000;
const DEFAULT_TRUST = 50; const DEFAULT_TRUST = 50;
const MIN_CREW = 100; const MIN_CREW = 100;
const ARGS_SCHEMA = z.preprocess( export const ARGS_SCHEMA = z.preprocess(
(raw) => { (raw) => {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return raw; return raw;
@@ -12,10 +12,8 @@ import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.
import { JosaUtil } from '@sammo-ts/common'; import { JosaUtil } from '@sammo-ts/common';
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import type { NationTurnCommandSpec } from './index.js'; import type { NationTurnCommandSpec } from './index.js';
import { z } from 'zod';
export interface ChangeFlagArgs { import { parseArgsWithSchema } from '../parseArgs.js';
colorType: number;
}
const ACTION_NAME = '국기변경'; const ACTION_NAME = '국기변경';
@@ -55,6 +53,11 @@ const NATION_COLORS = [
'#A9A9A9', '#A9A9A9',
]; ];
const ARGS_SCHEMA = z.object({
colorType: z.number().int().min(0).max(NATION_COLORS.length - 1),
});
export type ChangeFlagArgs = z.infer<typeof ARGS_SCHEMA>;
export class ActionDefinition< export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState, TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionDefinition<TriggerState, ChangeFlagArgs> { > implements GeneralActionDefinition<TriggerState, ChangeFlagArgs> {
@@ -62,10 +65,7 @@ export class ActionDefinition<
public readonly name = ACTION_NAME; public readonly name = ACTION_NAME;
parseArgs(raw: unknown): ChangeFlagArgs | null { parseArgs(raw: unknown): ChangeFlagArgs | null {
const data = raw as { colorType?: number }; return parseArgsWithSchema(ARGS_SCHEMA, raw);
if (typeof data?.colorType !== 'number') return null;
if (data.colorType < 0 || data.colorType >= NATION_COLORS.length) return null;
return { colorType: data.colorType };
} }
buildConstraints(_ctx: ConstraintContext, _args: ChangeFlagArgs): Constraint[] { buildConstraints(_ctx: ConstraintContext, _args: ChangeFlagArgs): Constraint[] {
@@ -162,5 +162,6 @@ export const commandSpec: NationTurnCommandSpec = {
category: '국가', category: '국가',
reqArg: true, reqArg: true,
args: { colorType: 0 }, args: { colorType: 0 },
argsSchema: ARGS_SCHEMA,
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
}; };
@@ -12,10 +12,13 @@ import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.
import { JosaUtil } from '@sammo-ts/common'; import { JosaUtil } from '@sammo-ts/common';
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import type { NationTurnCommandSpec } from './index.js'; import type { NationTurnCommandSpec } from './index.js';
import { z } from 'zod';
import { parseArgsWithSchema } from '../parseArgs.js';
export interface ChangeNationNameArgs { const ARGS_SCHEMA = z.object({
nationName: string; nationName: z.string().trim().min(1).max(8),
} });
export type ChangeNationNameArgs = z.infer<typeof ARGS_SCHEMA>;
const ACTION_NAME = '국호변경'; const ACTION_NAME = '국호변경';
@@ -26,13 +29,7 @@ export class ActionDefinition<
public readonly name = ACTION_NAME; public readonly name = ACTION_NAME;
parseArgs(raw: unknown): ChangeNationNameArgs | null { parseArgs(raw: unknown): ChangeNationNameArgs | null {
const data = raw as { nationName?: string }; return parseArgsWithSchema(ARGS_SCHEMA, raw);
if (typeof data?.nationName !== 'string') return null;
const name = data.nationName.trim();
if (name.length < 1 || name.length > 8) return null;
return { nationName: name };
} }
buildConstraints(_ctx: ConstraintContext, args: ChangeNationNameArgs): Constraint[] { buildConstraints(_ctx: ConstraintContext, args: ChangeNationNameArgs): Constraint[] {
@@ -124,5 +121,6 @@ export const commandSpec: NationTurnCommandSpec = {
category: '국가', category: '국가',
reqArg: true, reqArg: true,
args: { nationName: '' }, args: { nationName: '' },
argsSchema: ARGS_SCHEMA,
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
}; };
@@ -22,10 +22,16 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
import { buildDefaultDiplomacy } from '../../../diplomacy/index.js'; import { buildDefaultDiplomacy } from '../../../diplomacy/index.js';
import { JosaUtil } from '@sammo-ts/common'; import { JosaUtil } from '@sammo-ts/common';
import type { NationTurnCommandSpec } from './index.js'; import type { NationTurnCommandSpec } from './index.js';
import { z } from 'zod';
import { parseArgsWithSchema } from '../parseArgs.js';
export interface RaidArgs { const ARGS_SCHEMA = z.object({
destNationId: number; destNationId: z.preprocess(
} (value) => (typeof value === 'number' ? Math.floor(value) : value),
z.number().int().positive()
),
});
export type RaidArgs = z.infer<typeof ARGS_SCHEMA>;
export interface RaidResolveContext< export interface RaidResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState, TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -43,14 +49,6 @@ const PRE_REQ_TURN = 0;
const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1); const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1);
const TERM_REDUCE = 3; 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<TriggerState extends GeneralTriggerState = GeneralTriggerState> { export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
private readonly pipeline: GeneralActionPipeline<TriggerState>; private readonly pipeline: GeneralActionPipeline<TriggerState>;
@@ -173,12 +171,7 @@ export class ActionDefinition<
} }
parseArgs(raw: unknown): RaidArgs | null { parseArgs(raw: unknown): RaidArgs | null {
const data = raw as { destNationId?: unknown }; return parseArgsWithSchema(ARGS_SCHEMA, raw);
const destNationId = parseNationId(data?.destNationId);
if (destNationId === null) {
return null;
}
return { destNationId };
} }
buildConstraints(_ctx: ConstraintContext, _args: RaidArgs): Constraint[] { buildConstraints(_ctx: ConstraintContext, _args: RaidArgs): Constraint[] {
@@ -199,7 +192,7 @@ export class ActionDefinition<
} }
// 예약 턴 실행에 필요한 대상 국가/외교 정보를 구성한다. // 예약 턴 실행에 필요한 대상 국가/외교 정보를 구성한다.
export const actionContextBuilder: ActionContextBuilder = (base, options) => { export const actionContextBuilder: ActionContextBuilder<RaidArgs> = (base, options) => {
const destNationId = options.actionArgs.destNationId; const destNationId = options.actionArgs.destNationId;
if (typeof destNationId !== 'number') { if (typeof destNationId !== 'number') {
return null; return null;
@@ -236,5 +229,6 @@ export const commandSpec: NationTurnCommandSpec = {
category: '외교', category: '외교',
reqArg: true, reqArg: true,
args: { destNationId: 0 }, args: { destNationId: 0 },
argsSchema: ARGS_SCHEMA,
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? []), createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? []),
}; };
@@ -20,12 +20,18 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
import type { NationTurnCommandSpec } from './index.js'; import type { NationTurnCommandSpec } from './index.js';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import { clamp } from 'es-toolkit'; import { clamp } from 'es-toolkit';
import { z } from 'zod';
import { parseArgsWithSchema } from '../parseArgs.js';
export interface SeizureArgs { const ARGS_SCHEMA = z.object({
isGold: boolean; isGold: z.boolean(),
amount: number; amount: z.preprocess(
destGeneralID: number; (value) => (typeof value === 'number' ? Math.floor(value / 100) * 100 : value),
} z.number().int().positive()
),
destGeneralID: z.number(),
});
export type SeizureArgs = z.infer<typeof ARGS_SCHEMA>;
export interface SeizureResolveContext< export interface SeizureResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState, TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -44,18 +50,13 @@ export class ActionDefinition<
constructor(private readonly env: TurnCommandEnv) {} constructor(private readonly env: TurnCommandEnv) {}
parseArgs(raw: unknown): SeizureArgs | null { parseArgs(raw: unknown): SeizureArgs | null {
const data = raw as { isGold?: boolean; amount?: number; destGeneralID?: number }; const data = parseArgsWithSchema(ARGS_SCHEMA, raw);
if (typeof data?.isGold !== 'boolean') return null; if (!data) {
if (typeof data?.amount !== 'number') return null; return null;
if (typeof data?.destGeneralID !== 'number') return null; }
const amount = Math.floor(data.amount / 100) * 100;
if (amount <= 0) return null;
return { return {
isGold: data.isGold, ...data,
amount: clamp(amount, 100, this.env.maxResourceActionAmount ?? 10000), amount: clamp(data.amount, 100, this.env.maxResourceActionAmount ?? 10000),
destGeneralID: data.destGeneralID,
}; };
} }
@@ -140,7 +141,7 @@ export class ActionDefinition<
} }
} }
export const actionContextBuilder: ActionContextBuilder = (base, options) => { export const actionContextBuilder: ActionContextBuilder<SeizureArgs> = (base, options) => {
const destGeneralId = options.actionArgs.destGeneralID; const destGeneralId = options.actionArgs.destGeneralID;
if (typeof destGeneralId !== 'number') return null; if (typeof destGeneralId !== 'number') return null;
@@ -161,5 +162,6 @@ export const commandSpec: NationTurnCommandSpec = {
category: '인사', category: '인사',
reqArg: true, reqArg: true,
args: { isGold: false, amount: 0, destGeneralID: 0 }, args: { isGold: false, amount: 0, destGeneralID: 0 },
argsSchema: ARGS_SCHEMA,
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
}; };
@@ -20,11 +20,14 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
import type { NationTurnCommandSpec } from './index.js'; import type { NationTurnCommandSpec } from './index.js';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import { clamp } from 'es-toolkit'; import { clamp } from 'es-toolkit';
import { z } from 'zod';
import { parseArgsWithSchema } from '../parseArgs.js';
export interface MaterialAidArgs { const ARGS_SCHEMA = z.object({
destNationId: number; destNationId: z.number(),
amountList: [number, number]; // [gold, rice] amountList: z.tuple([z.number(), z.number()]),
} });
export type MaterialAidArgs = z.infer<typeof ARGS_SCHEMA>;
export interface MaterialAidResolveContext< export interface MaterialAidResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState, TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -45,15 +48,7 @@ export class ActionDefinition<
constructor(private readonly env: TurnCommandEnv) {} constructor(private readonly env: TurnCommandEnv) {}
parseArgs(raw: unknown): MaterialAidArgs | null { parseArgs(raw: unknown): MaterialAidArgs | null {
const data = raw as { destNationId?: number; amountList?: [number, number] }; return parseArgsWithSchema(ARGS_SCHEMA, raw);
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]],
};
} }
buildConstraints(_ctx: ConstraintContext, args: MaterialAidArgs): Constraint[] { buildConstraints(_ctx: ConstraintContext, args: MaterialAidArgs): Constraint[] {
@@ -187,7 +182,7 @@ export class ActionDefinition<
} }
} }
export const actionContextBuilder: ActionContextBuilder = (base, options) => { export const actionContextBuilder: ActionContextBuilder<MaterialAidArgs> = (base, options) => {
const destNationId = options.actionArgs.destNationId; const destNationId = options.actionArgs.destNationId;
if (typeof destNationId !== 'number') return null; if (typeof destNationId !== 'number') return null;
@@ -208,5 +203,6 @@ export const commandSpec: NationTurnCommandSpec = {
category: '외교', category: '외교',
reqArg: true, reqArg: true,
args: { destNationId: 0, amountList: [0, 0] }, args: { destNationId: 0, amountList: [0, 0] },
argsSchema: ARGS_SCHEMA,
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
}; };
@@ -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 { resolveTurnTermMinutes } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import type { NationTurnCommandSpec } from './index.js'; import type { NationTurnCommandSpec } from './index.js';
import { z } from 'zod';
import { parseArgsWithSchema } from '../parseArgs.js';
export interface AssignmentArgs { const ARGS_SCHEMA = z.object({
destGeneralId: number; destGeneralId: z.number(),
destCityId: number; destCityId: z.number(),
} });
export type AssignmentArgs = z.infer<typeof ARGS_SCHEMA>;
export interface AssignmentResolveContext< export interface AssignmentResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState, TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -146,23 +149,7 @@ export class ActionDefinition<
} }
parseArgs(raw: unknown): AssignmentArgs | null { parseArgs(raw: unknown): AssignmentArgs | null {
if (!raw || typeof raw !== 'object') { return parseArgsWithSchema(ARGS_SCHEMA, raw);
return null;
}
const data = raw as {
destGeneralId?: unknown;
destCityId?: unknown;
};
if (typeof data.destGeneralId !== 'number') {
return null;
}
if (typeof data.destCityId !== 'number') {
return null;
}
return {
destGeneralId: data.destGeneralId,
destCityId: data.destCityId,
};
} }
buildConstraints(ctx: ConstraintContext, _args: AssignmentArgs): Constraint[] { buildConstraints(ctx: ConstraintContext, _args: AssignmentArgs): Constraint[] {
@@ -188,7 +175,7 @@ export class ActionDefinition<
} }
// 예약 턴 실행에 필요한 대상 장수/도시 컨텍스트를 구성한다. // 예약 턴 실행에 필요한 대상 장수/도시 컨텍스트를 구성한다.
export const actionContextBuilder: ActionContextBuilder = (base, options) => { export const actionContextBuilder: ActionContextBuilder<AssignmentArgs> = (base, options) => {
const destGeneralId = options.actionArgs.destGeneralId; const destGeneralId = options.actionArgs.destGeneralId;
const destCityId = options.actionArgs.destCityId; const destCityId = options.actionArgs.destCityId;
if (typeof destGeneralId !== 'number' || typeof destCityId !== 'number') { if (typeof destGeneralId !== 'number' || typeof destCityId !== 'number') {
@@ -216,5 +203,6 @@ export const commandSpec: NationTurnCommandSpec = {
category: '인사', category: '인사',
reqArg: true, reqArg: true,
args: { destGeneralId: 0, destCityId: 0 }, args: { destGeneralId: 0, destCityId: 0 },
argsSchema: ARGS_SCHEMA,
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition({}), createDefinition: (_env: TurnCommandEnv) => new ActionDefinition({}),
}; };
@@ -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 type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import { JosaUtil } from '@sammo-ts/common'; import { JosaUtil } from '@sammo-ts/common';
import type { NationTurnCommandSpec } from './index.js'; import type { NationTurnCommandSpec } from './index.js';
import { z } from 'zod';
import { parseArgsWithSchema } from '../parseArgs.js';
export interface MobilizePeopleArgs { const ARGS_SCHEMA = z.object({
destCityId: number; destCityId: z.preprocess(
} (value) => (typeof value === 'number' ? Math.floor(value) : value),
z.number().int().positive()
),
});
export type MobilizePeopleArgs = z.infer<typeof ARGS_SCHEMA>;
export interface MobilizePeopleResolveContext< export interface MobilizePeopleResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState, TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -38,14 +44,6 @@ const PRE_REQ_TURN = 0;
const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1); const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1);
const DEFENCE_RATE = 0.8; 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<TriggerState extends GeneralTriggerState = GeneralTriggerState> { export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
private readonly pipeline: GeneralActionPipeline<TriggerState>; private readonly pipeline: GeneralActionPipeline<TriggerState>;
@@ -151,12 +149,7 @@ export class ActionDefinition<
} }
parseArgs(raw: unknown): MobilizePeopleArgs | null { parseArgs(raw: unknown): MobilizePeopleArgs | null {
const data = raw as { destCityId?: unknown }; return parseArgsWithSchema(ARGS_SCHEMA, raw);
const destCityId = parseCityId(data?.destCityId);
if (destCityId === null) {
return null;
}
return { destCityId };
} }
buildConstraints(_ctx: ConstraintContext, _args: MobilizePeopleArgs): Constraint[] { buildConstraints(_ctx: ConstraintContext, _args: MobilizePeopleArgs): Constraint[] {
@@ -174,7 +167,7 @@ export class ActionDefinition<
} }
// 예약 턴 실행에 필요한 대상 도시/장수 정보를 구성한다. // 예약 턴 실행에 필요한 대상 도시/장수 정보를 구성한다.
export const actionContextBuilder: ActionContextBuilder = (base, options) => { export const actionContextBuilder: ActionContextBuilder<MobilizePeopleArgs> = (base, options) => {
const destCityId = options.actionArgs.destCityId; const destCityId = options.actionArgs.destCityId;
if (typeof destCityId !== 'number') { if (typeof destCityId !== 'number') {
return null; return null;
@@ -200,5 +193,6 @@ export const commandSpec: NationTurnCommandSpec = {
category: '전략', category: '전략',
reqArg: true, reqArg: true,
args: { destCityId: 0 }, args: { destCityId: 0 },
argsSchema: ARGS_SCHEMA,
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? []), createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? []),
}; };
@@ -19,10 +19,16 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
import type { NationTurnCommandSpec } from './index.js'; import type { NationTurnCommandSpec } from './index.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
import { JosaUtil } from '@sammo-ts/common'; import { JosaUtil } from '@sammo-ts/common';
import { z } from 'zod';
import { parseArgsWithSchema } from '../parseArgs.js';
export interface TroopKickArgs { const ARGS_SCHEMA = z.object({
destGeneralId: number; destGeneralId: z.preprocess(
} (value) => (typeof value === 'number' ? Math.floor(value) : value),
z.number().int().positive()
),
});
export type TroopKickArgs = z.infer<typeof ARGS_SCHEMA>;
export interface TroopKickResolveContext< export interface TroopKickResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState, TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -39,17 +45,7 @@ export class ActionDefinition<
public readonly name = ACTION_NAME; public readonly name = ACTION_NAME;
parseArgs(raw: unknown): TroopKickArgs | null { parseArgs(raw: unknown): TroopKickArgs | null {
if (!raw || typeof raw !== 'object') { return parseArgsWithSchema(ARGS_SCHEMA, raw);
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 };
} }
buildConstraints(ctx: ConstraintContext, _args: TroopKickArgs): Constraint[] { buildConstraints(ctx: ConstraintContext, _args: TroopKickArgs): Constraint[] {
@@ -98,7 +94,7 @@ export class ActionDefinition<
} }
} }
export const actionContextBuilder: ActionContextBuilder = (base, options) => { export const actionContextBuilder: ActionContextBuilder<TroopKickArgs> = (base, options) => {
const destGeneralId = options.actionArgs.destGeneralId; const destGeneralId = options.actionArgs.destGeneralId;
if (typeof destGeneralId !== 'number') { if (typeof destGeneralId !== 'number') {
return null; return null;
@@ -118,5 +114,6 @@ export const commandSpec: NationTurnCommandSpec = {
category: '인사', category: '인사',
reqArg: true, reqArg: true,
args: { destGeneralId: 0 }, args: { destGeneralId: 0 },
argsSchema: ARGS_SCHEMA,
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
}; };
@@ -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 { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import type { NationTurnCommandSpec } from './index.js'; import type { NationTurnCommandSpec } from './index.js';
import { z } from 'zod';
import { parseArgsWithSchema } from '../parseArgs.js';
export interface NonAggressionProposalArgs { const ARGS_SCHEMA = z.object({
destNationId: number; destNationId: z.preprocess(
year: number; (value) => (typeof value === 'number' ? Math.floor(value) : value),
month: number; 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<typeof ARGS_SCHEMA>;
const ACTION_NAME = '불가침 제의'; const ACTION_NAME = '불가침 제의';
const MIN_TERM_MONTHS = 6; 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 resolveMonthIndex = (year: number, month: number): number => year * 12 + month - 1;
const requireMinimumTerm = (minMonths: number): Constraint => ({ const requireMinimumTerm = (minMonths: number): Constraint => ({
@@ -107,18 +94,7 @@ export class ActionDefinition<
public readonly name = ACTION_NAME; public readonly name = ACTION_NAME;
parseArgs(raw: unknown): NonAggressionProposalArgs | null { parseArgs(raw: unknown): NonAggressionProposalArgs | null {
const data = raw as { return parseArgsWithSchema(ARGS_SCHEMA, raw);
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 };
} }
buildConstraints(_ctx: ConstraintContext, _args: NonAggressionProposalArgs): Constraint[] { buildConstraints(_ctx: ConstraintContext, _args: NonAggressionProposalArgs): Constraint[] {
@@ -163,5 +139,6 @@ export const commandSpec: NationTurnCommandSpec = {
category: '외교', category: '외교',
reqArg: true, reqArg: true,
args: { destNationId: 0, year: 0, month: 0 }, args: { destNationId: 0, year: 0, month: 0 },
argsSchema: ARGS_SCHEMA,
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
}; };
@@ -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 type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import type { NationTurnCommandSpec } from './index.js'; import type { NationTurnCommandSpec } from './index.js';
import { z } from 'zod';
import { parseArgsWithSchema } from '../parseArgs.js';
export interface NonAggressionCancelProposalArgs { const ARGS_SCHEMA = z.object({
destNationId: number; destNationId: z.preprocess(
} (value) => (typeof value === 'number' ? Math.floor(value) : value),
z.number().int().positive()
),
});
export type NonAggressionCancelProposalArgs = z.infer<typeof ARGS_SCHEMA>;
const ACTION_NAME = '불가침 파기 제의'; const ACTION_NAME = '불가침 파기 제의';
const DIPLOMACY_NON_AGGRESSION = 7; 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< export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState, TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -38,12 +37,7 @@ export class ActionDefinition<
public readonly name = ACTION_NAME; public readonly name = ACTION_NAME;
parseArgs(raw: unknown): NonAggressionCancelProposalArgs | null { parseArgs(raw: unknown): NonAggressionCancelProposalArgs | null {
const data = raw as { destNationId?: unknown }; return parseArgsWithSchema(ARGS_SCHEMA, raw);
const destNationId = parseNationId(data?.destNationId);
if (destNationId === null) {
return null;
}
return { destNationId };
} }
buildConstraints(_ctx: ConstraintContext, _args: NonAggressionCancelProposalArgs): Constraint[] { buildConstraints(_ctx: ConstraintContext, _args: NonAggressionCancelProposalArgs): Constraint[] {
@@ -81,5 +75,6 @@ export const commandSpec: NationTurnCommandSpec = {
category: '외교', category: '외교',
reqArg: true, reqArg: true,
args: { destNationId: 0 }, args: { destNationId: 0 },
argsSchema: ARGS_SCHEMA,
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
}; };
@@ -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 type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import { JosaUtil } from '@sammo-ts/common'; import { JosaUtil } from '@sammo-ts/common';
import type { NationTurnCommandSpec } from './index.js'; import type { NationTurnCommandSpec } from './index.js';
import { z } from 'zod';
import { parseArgsWithSchema } from '../parseArgs.js';
export interface DeclareWarArgs { const ARGS_SCHEMA = z.object({
destNationId: number; destNationId: z.preprocess(
} (value) => (typeof value === 'number' ? Math.floor(value) : value),
z.number().int().positive()
),
});
export type DeclareWarArgs = z.infer<typeof ARGS_SCHEMA>;
// DeclareWarResolveContext is not used anymore as it was replaced by inline type in ActionDefinition // 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 DIPLOMACY_DECLARE = 1;
const DECLARE_TERM = 24; 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< export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState, TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionDefinition< > implements GeneralActionDefinition<
@@ -51,12 +50,7 @@ export class ActionDefinition<
public readonly name = ACTION_NAME; public readonly name = ACTION_NAME;
parseArgs(raw: unknown): DeclareWarArgs | null { parseArgs(raw: unknown): DeclareWarArgs | null {
const data = raw as { destNationId?: unknown }; return parseArgsWithSchema(ARGS_SCHEMA, raw);
const destNationId = parseNationId(data?.destNationId);
if (destNationId === null) {
return null;
}
return { destNationId };
} }
buildConstraints(_ctx: ConstraintContext, _args: DeclareWarArgs): Constraint[] { buildConstraints(_ctx: ConstraintContext, _args: DeclareWarArgs): Constraint[] {
@@ -164,7 +158,7 @@ export class ActionDefinition<
} }
// 예약 턴 실행에 필요한 대상 국가 정보를 구성한다. // 예약 턴 실행에 필요한 대상 국가 정보를 구성한다.
export const actionContextBuilder: ActionContextBuilder = (base, options) => { export const actionContextBuilder: ActionContextBuilder<DeclareWarArgs> = (base, options) => {
const destNationId = options.actionArgs.destNationId; const destNationId = options.actionArgs.destNationId;
if (typeof destNationId !== 'number') { if (typeof destNationId !== 'number') {
return null; return null;
@@ -188,5 +182,6 @@ export const commandSpec: NationTurnCommandSpec = {
category: '외교', category: '외교',
reqArg: true, reqArg: true,
args: { destNationId: 0 }, args: { destNationId: 0 },
argsSchema: ARGS_SCHEMA,
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
}; };
@@ -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 type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import { JosaUtil } from '@sammo-ts/common'; import { JosaUtil } from '@sammo-ts/common';
import type { NationTurnCommandSpec } from './index.js'; import type { NationTurnCommandSpec } from './index.js';
import { z } from 'zod';
import { parseArgsWithSchema } from '../parseArgs.js';
export interface FloodArgs { const ARGS_SCHEMA = z.object({
destCityId: number; destCityId: z.preprocess(
} (value) => (typeof value === 'number' ? Math.floor(value) : value),
z.number().int().positive()
),
});
export type FloodArgs = z.infer<typeof ARGS_SCHEMA>;
export interface FloodResolveContext< export interface FloodResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState, TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -42,14 +48,6 @@ 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 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<TriggerState extends GeneralTriggerState = GeneralTriggerState> { export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
private readonly pipeline: GeneralActionPipeline<TriggerState>; private readonly pipeline: GeneralActionPipeline<TriggerState>;
@@ -176,12 +174,7 @@ export class ActionDefinition<
} }
parseArgs(raw: unknown): FloodArgs | null { parseArgs(raw: unknown): FloodArgs | null {
const data = raw as { destCityId?: unknown }; return parseArgsWithSchema(ARGS_SCHEMA, raw);
const destCityId = parseCityId(data?.destCityId);
if (destCityId === null) {
return null;
}
return { destCityId };
} }
buildConstraints(_ctx: ConstraintContext, _args: FloodArgs): Constraint[] { buildConstraints(_ctx: ConstraintContext, _args: FloodArgs): Constraint[] {
@@ -203,7 +196,7 @@ export class ActionDefinition<
} }
// 예약 턴 실행에 필요한 대상 도시/장수 정보를 구성한다. // 예약 턴 실행에 필요한 대상 도시/장수 정보를 구성한다.
export const actionContextBuilder: ActionContextBuilder = (base, options) => { export const actionContextBuilder: ActionContextBuilder<FloodArgs> = (base, options) => {
const destCityId = options.actionArgs.destCityId; const destCityId = options.actionArgs.destCityId;
if (typeof destCityId !== 'number') { if (typeof destCityId !== 'number') {
return null; return null;
@@ -234,5 +227,6 @@ export const commandSpec: NationTurnCommandSpec = {
category: '전략', category: '전략',
reqArg: true, reqArg: true,
args: { destCityId: 0 }, args: { destCityId: 0 },
argsSchema: ARGS_SCHEMA,
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? []), createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? []),
}; };
@@ -22,10 +22,16 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
import { buildDefaultDiplomacy, DIPLOMACY_STATE } from '../../../diplomacy/index.js'; import { buildDefaultDiplomacy, DIPLOMACY_STATE } from '../../../diplomacy/index.js';
import { JosaUtil } from '@sammo-ts/common'; import { JosaUtil } from '@sammo-ts/common';
import type { NationTurnCommandSpec } from './index.js'; import type { NationTurnCommandSpec } from './index.js';
import { z } from 'zod';
import { parseArgsWithSchema } from '../parseArgs.js';
export interface DegradeRelationsArgs { const ARGS_SCHEMA = z.object({
destNationId: number; destNationId: z.preprocess(
} (value) => (typeof value === 'number' ? Math.floor(value) : value),
z.number().int().positive()
),
});
export type DegradeRelationsArgs = z.infer<typeof ARGS_SCHEMA>;
export interface DegradeRelationsResolveContext< export interface DegradeRelationsResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState, TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -42,14 +48,6 @@ const DEFAULT_GLOBAL_DELAY = 9;
const PRE_REQ_TURN = 0; const PRE_REQ_TURN = 0;
const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1); 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); 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 { parseArgs(raw: unknown): DegradeRelationsArgs | null {
const data = raw as { destNationId?: unknown }; return parseArgsWithSchema(ARGS_SCHEMA, raw);
const destNationId = parseNationId(data?.destNationId);
if (destNationId === null) {
return null;
}
return { destNationId };
} }
buildConstraints(_ctx: ConstraintContext, _args: DegradeRelationsArgs): Constraint[] { buildConstraints(_ctx: ConstraintContext, _args: DegradeRelationsArgs): Constraint[] {
@@ -209,7 +202,7 @@ export class ActionDefinition<
} }
// 예약 턴 실행에 필요한 대상 국가/외교 정보를 구성한다. // 예약 턴 실행에 필요한 대상 국가/외교 정보를 구성한다.
export const actionContextBuilder: ActionContextBuilder = (base, options) => { export const actionContextBuilder: ActionContextBuilder<DegradeRelationsArgs> = (base, options) => {
const destNationId = options.actionArgs.destNationId; const destNationId = options.actionArgs.destNationId;
if (typeof destNationId !== 'number') { if (typeof destNationId !== 'number') {
return null; return null;
@@ -246,5 +239,6 @@ export const commandSpec: NationTurnCommandSpec = {
category: '외교', category: '외교',
reqArg: true, reqArg: true,
args: { destNationId: 0 }, args: { destNationId: 0 },
argsSchema: ARGS_SCHEMA,
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? []), createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? []),
}; };
@@ -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 { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import type { NationTurnCommandSpec } from './index.js'; import type { NationTurnCommandSpec } from './index.js';
import { JosaUtil } from '@sammo-ts/common'; import { JosaUtil } from '@sammo-ts/common';
import { z } from 'zod';
import { parseArgsWithSchema } from '../parseArgs.js';
export interface StopWarProposalArgs { const ARGS_SCHEMA = z.object({
destNationId: number; destNationId: z.preprocess(
} (value) => (typeof value === 'number' ? Math.floor(value) : value),
z.number().int().positive()
),
});
export type StopWarProposalArgs = z.infer<typeof ARGS_SCHEMA>;
const ACTION_NAME = '종전 제의'; 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< export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState, TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -38,12 +37,7 @@ export class ActionDefinition<
public readonly name = ACTION_NAME; public readonly name = ACTION_NAME;
parseArgs(raw: unknown): StopWarProposalArgs | null { parseArgs(raw: unknown): StopWarProposalArgs | null {
const data = raw as { destNationId?: unknown }; return parseArgsWithSchema(ARGS_SCHEMA, raw);
const destNationId = parseNationId(data?.destNationId);
if (destNationId === null) {
return null;
}
return { destNationId };
} }
buildConstraints(_ctx: ConstraintContext, _args: StopWarProposalArgs): Constraint[] { buildConstraints(_ctx: ConstraintContext, _args: StopWarProposalArgs): Constraint[] {
@@ -84,5 +78,6 @@ export const commandSpec: NationTurnCommandSpec = {
category: '외교', category: '외교',
reqArg: true, reqArg: true,
args: { destNationId: 0 }, args: { destNationId: 0 },
argsSchema: ARGS_SCHEMA,
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
}; };
@@ -20,10 +20,13 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
import { JosaUtil } from '@sammo-ts/common'; import { JosaUtil } from '@sammo-ts/common';
import type { NationTurnCommandSpec } from './index.js'; import type { NationTurnCommandSpec } from './index.js';
import type { MapDefinition } from '@sammo-ts/logic/world/types.js'; import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
import { z } from 'zod';
import { parseArgsWithSchema } from '../parseArgs.js';
export interface MoveCapitalArgs { const ARGS_SCHEMA = z.object({
destCityID: number; destCityID: z.number(),
} });
export type MoveCapitalArgs = z.infer<typeof ARGS_SCHEMA>;
export interface MoveCapitalResolveContext< export interface MoveCapitalResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState, TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -71,9 +74,7 @@ export class ActionDefinition<
constructor(private readonly env: TurnCommandEnv) {} constructor(private readonly env: TurnCommandEnv) {}
parseArgs(raw: unknown): MoveCapitalArgs | null { parseArgs(raw: unknown): MoveCapitalArgs | null {
const data = raw as { destCityID?: number }; return parseArgsWithSchema(ARGS_SCHEMA, raw);
if (typeof data?.destCityID !== 'number') return null;
return { destCityID: data.destCityID };
} }
buildConstraints(_ctx: ConstraintContext, args: MoveCapitalArgs): Constraint[] { buildConstraints(_ctx: ConstraintContext, args: MoveCapitalArgs): Constraint[] {
@@ -194,7 +195,7 @@ export class ActionDefinition<
} }
} }
export const actionContextBuilder: ActionContextBuilder = (base, options) => { export const actionContextBuilder: ActionContextBuilder<MoveCapitalArgs> = (base, options) => {
const destCityId = options.actionArgs.destCityID; const destCityId = options.actionArgs.destCityID;
if (typeof destCityId !== 'number') return null; if (typeof destCityId !== 'number') return null;
@@ -217,5 +218,6 @@ export const commandSpec: NationTurnCommandSpec = {
category: '국가', category: '국가',
reqArg: true, reqArg: true,
args: { destCityID: 0 }, args: { destCityID: 0 },
argsSchema: ARGS_SCHEMA,
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
}; };
@@ -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 type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import { JosaUtil } from '@sammo-ts/common'; import { JosaUtil } from '@sammo-ts/common';
import type { NationTurnCommandSpec } from './index.js'; import type { NationTurnCommandSpec } from './index.js';
import { z } from 'zod';
import { parseArgsWithSchema } from '../parseArgs.js';
export interface ScorchedEarthArgs { const ARGS_SCHEMA = z.object({
destCityId: number; destCityId: z.preprocess(
} (value) => (typeof value === 'number' ? Math.floor(value) : value),
z.number().int().positive()
),
});
export type ScorchedEarthArgs = z.infer<typeof ARGS_SCHEMA>;
export interface ScorchedEarthResolveContext< export interface ScorchedEarthResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState, TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -37,14 +43,6 @@ const ACTION_NAME = '초토화';
const PRE_REQ_TURN = 2; const PRE_REQ_TURN = 2;
const POST_REQ_TURN = 24; 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 => ({ const requireNoDiplomacyLimit = (): Constraint => ({
name: 'requireNoDiplomacyLimit', name: 'requireNoDiplomacyLimit',
requires: (ctx) => (ctx.nationId !== undefined ? [{ kind: 'nation', id: ctx.nationId }] : []), requires: (ctx) => (ctx.nationId !== undefined ? [{ kind: 'nation', id: ctx.nationId }] : []),
@@ -115,12 +113,7 @@ export class ActionDefinition<
public readonly name = ACTION_NAME; public readonly name = ACTION_NAME;
parseArgs(raw: unknown): ScorchedEarthArgs | null { parseArgs(raw: unknown): ScorchedEarthArgs | null {
const data = raw as { destCityId?: unknown }; return parseArgsWithSchema(ARGS_SCHEMA, raw);
const destCityId = parseCityId(data?.destCityId);
if (destCityId === null) {
return null;
}
return { destCityId };
} }
buildConstraints(_ctx: ConstraintContext, args: ScorchedEarthArgs): Constraint[] { buildConstraints(_ctx: ConstraintContext, args: ScorchedEarthArgs): Constraint[] {
@@ -247,7 +240,7 @@ export class ActionDefinition<
} }
// 예약 턴 실행에 필요한 대상 도시 정보를 구성한다. // 예약 턴 실행에 필요한 대상 도시 정보를 구성한다.
export const actionContextBuilder: ActionContextBuilder = (base, options) => { export const actionContextBuilder: ActionContextBuilder<ScorchedEarthArgs> = (base, options) => {
const destCityId = options.actionArgs.destCityId; const destCityId = options.actionArgs.destCityId;
if (typeof destCityId !== 'number') { if (typeof destCityId !== 'number') {
return null; return null;
@@ -273,5 +266,6 @@ export const commandSpec: NationTurnCommandSpec = {
category: '특수', category: '특수',
reqArg: true, reqArg: true,
args: { destCityId: 0 }, args: { destCityId: 0 },
argsSchema: ARGS_SCHEMA,
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
}; };
@@ -25,12 +25,15 @@ import type { NationTurnCommandSpec } from './index.js';
import { JosaUtil } from '@sammo-ts/common'; import { JosaUtil } from '@sammo-ts/common';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import { clamp } from 'es-toolkit'; import { clamp } from 'es-toolkit';
import { z } from 'zod';
import { parseArgsWithSchema } from '../parseArgs.js';
export interface AwardArgs { const ARGS_SCHEMA = z.object({
isGold: boolean; isGold: z.boolean(),
amount: number; amount: z.number(),
destGeneralId: number; destGeneralId: z.number(),
} });
export type AwardArgs = z.infer<typeof ARGS_SCHEMA>;
export interface AwardResolveContext< export interface AwardResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState, TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -163,21 +166,8 @@ export class ActionDefinition<
} }
parseArgs(raw: unknown): AwardArgs | null { parseArgs(raw: unknown): AwardArgs | null {
if (!raw || typeof raw !== 'object') { const data = parseArgsWithSchema(ARGS_SCHEMA, raw);
return null; if (!data) {
}
const data = raw as {
isGold?: unknown;
amount?: unknown;
destGeneralId?: unknown;
};
if (typeof data.isGold !== 'boolean') {
return null;
}
if (typeof data.amount !== 'number' || Number.isNaN(data.amount)) {
return null;
}
if (typeof data.destGeneralId !== 'number') {
return null; return null;
} }
const amount = this.command.normalizeAmount(data.amount); const amount = this.command.normalizeAmount(data.amount);
@@ -185,9 +175,8 @@ export class ActionDefinition<
return null; return null;
} }
return { return {
isGold: data.isGold, ...data,
amount, amount,
destGeneralId: data.destGeneralId,
}; };
} }
@@ -231,7 +220,7 @@ export class ActionDefinition<
} }
// 예약 턴 실행에 필요한 대상 장수를 주입한다. // 예약 턴 실행에 필요한 대상 장수를 주입한다.
export const actionContextBuilder: ActionContextBuilder = (base, options) => { export const actionContextBuilder: ActionContextBuilder<AwardArgs> = (base, options) => {
const destGeneralId = options.actionArgs.destGeneralId; const destGeneralId = options.actionArgs.destGeneralId;
if (typeof destGeneralId !== 'number') { if (typeof destGeneralId !== 'number') {
return null; return null;
@@ -251,6 +240,7 @@ export const commandSpec: NationTurnCommandSpec = {
category: '인사', category: '인사',
reqArg: true, reqArg: true,
args: { isGold: true, amount: 1, destGeneralId: 0 }, args: { isGold: true, amount: 1, destGeneralId: 0 },
argsSchema: ARGS_SCHEMA,
createDefinition: (env: TurnCommandEnv) => { createDefinition: (env: TurnCommandEnv) => {
const maxAmount = const maxAmount =
env.maxResourceActionAmount > 0 ? env.maxResourceActionAmount : Math.max(env.baseGold, env.baseRice, 1000); env.maxResourceActionAmount > 0 ? env.maxResourceActionAmount : Math.max(env.baseGold, env.baseRice, 1000);
@@ -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 type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import { JosaUtil } from '@sammo-ts/common'; import { JosaUtil } from '@sammo-ts/common';
import type { NationTurnCommandSpec } from './index.js'; import type { NationTurnCommandSpec } from './index.js';
import { z } from 'zod';
import { parseArgsWithSchema } from '../parseArgs.js';
export interface CounterStrategyArgs { export type CounterStrategyArgs = z.infer<typeof ARGS_SCHEMA>;
destNationId: number;
commandType: string;
}
export interface CounterStrategyResolveContext< export interface CounterStrategyResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState, TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -40,7 +39,18 @@ const DEFAULT_GLOBAL_DELAY = 8;
const PRE_REQ_TURN = 1; const PRE_REQ_TURN = 1;
const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1); const EXP_DED_GAIN = 5 * (PRE_REQ_TURN + 1);
const STRATEGIC_COMMANDS: Record<string, string> = { 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_백성동원: '백성동원', che_백성동원: '백성동원',
che_수몰: '수몰', che_수몰: '수몰',
@@ -51,26 +61,15 @@ const STRATEGIC_COMMANDS: Record<string, string> = {
che_피장파장: '피장파장', che_피장파장: '피장파장',
}; };
const parseNationId = (raw: unknown): number | null => { const ARGS_SCHEMA = z.object({
if (typeof raw !== 'number' || !Number.isFinite(raw)) { destNationId: z.preprocess(
return null; (value) => (typeof value === 'number' ? Math.floor(value) : value),
} z.number().int().positive()
const value = Math.floor(raw); ),
return value > 0 ? value : null; commandType: z
}; .enum(STRATEGIC_COMMAND_KEYS)
.refine((value) => value !== 'che_피장파장', '같은 전략은 선택할 수 없습니다.'),
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 requireCommandType = (): Constraint => ({ const requireCommandType = (): Constraint => ({
name: 'requireStrategicCommandType', name: 'requireStrategicCommandType',
@@ -209,13 +208,7 @@ export class ActionDefinition<
private readonly resolver = new ActionResolver<TriggerState>(); private readonly resolver = new ActionResolver<TriggerState>();
parseArgs(raw: unknown): CounterStrategyArgs | null { parseArgs(raw: unknown): CounterStrategyArgs | null {
const data = raw as { destNationId?: unknown; commandType?: unknown }; return parseArgsWithSchema(ARGS_SCHEMA, raw);
const destNationId = parseNationId(data?.destNationId);
const commandType = parseCommandType(data?.commandType);
if (destNationId === null || commandType === null) {
return null;
}
return { destNationId, commandType };
} }
buildConstraints(_ctx: ConstraintContext, _args: CounterStrategyArgs): Constraint[] { buildConstraints(_ctx: ConstraintContext, _args: CounterStrategyArgs): Constraint[] {
@@ -237,7 +230,7 @@ export class ActionDefinition<
} }
// 예약 턴 실행에 필요한 대상 국가/장수 정보를 구성한다. // 예약 턴 실행에 필요한 대상 국가/장수 정보를 구성한다.
export const actionContextBuilder: ActionContextBuilder = (base, options) => { export const actionContextBuilder: ActionContextBuilder<CounterStrategyArgs> = (base, options) => {
const destNationId = options.actionArgs.destNationId; const destNationId = options.actionArgs.destNationId;
if (typeof destNationId !== 'number') { if (typeof destNationId !== 'number') {
return null; return null;
@@ -266,5 +259,6 @@ export const commandSpec: NationTurnCommandSpec = {
category: '전략', category: '전략',
reqArg: true, reqArg: true,
args: { destNationId: 0, commandType: '' }, args: { destNationId: 0, commandType: '' },
argsSchema: ARGS_SCHEMA,
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
}; };
@@ -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 type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import { JosaUtil } from '@sammo-ts/common'; import { JosaUtil } from '@sammo-ts/common';
import type { NationTurnCommandSpec } from './index.js'; import type { NationTurnCommandSpec } from './index.js';
import { z } from 'zod';
import { parseArgsWithSchema } from '../parseArgs.js';
export interface DeceptionArgs { const ARGS_SCHEMA = z.object({
destCityId: number; destCityId: z.preprocess(
} (value) => (typeof value === 'number' ? Math.floor(value) : value),
z.number().int().positive()
),
});
export type DeceptionArgs = z.infer<typeof ARGS_SCHEMA>;
export interface DeceptionResolveContext< export interface DeceptionResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState, TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -42,14 +48,6 @@ const DEFAULT_GLOBAL_DELAY = 9;
const PRE_REQ_TURN = 1; const PRE_REQ_TURN = 1;
const EXP_DED_GAIN = 5 * (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 => { const pickMoveCityId = (rng: GeneralActionResolveContext['rng'], destCityId: number, candidates: City[]): number => {
if (candidates.length === 0) { if (candidates.length === 0) {
return destCityId; return destCityId;
@@ -183,12 +181,7 @@ export class ActionDefinition<
} }
parseArgs(raw: unknown): DeceptionArgs | null { parseArgs(raw: unknown): DeceptionArgs | null {
const data = raw as { destCityId?: unknown }; return parseArgsWithSchema(ARGS_SCHEMA, raw);
const destCityId = parseCityId(data?.destCityId);
if (destCityId === null) {
return null;
}
return { destCityId };
} }
buildConstraints(_ctx: ConstraintContext, _args: DeceptionArgs): Constraint[] { buildConstraints(_ctx: ConstraintContext, _args: DeceptionArgs): Constraint[] {
@@ -210,7 +203,7 @@ export class ActionDefinition<
} }
// 예약 턴 실행에 필요한 대상 도시/장수 정보를 구성한다. // 예약 턴 실행에 필요한 대상 도시/장수 정보를 구성한다.
export const actionContextBuilder: ActionContextBuilder = (base, options) => { export const actionContextBuilder: ActionContextBuilder<DeceptionArgs> = (base, options) => {
const destCityId = options.actionArgs.destCityId; const destCityId = options.actionArgs.destCityId;
if (typeof destCityId !== 'number') { if (typeof destCityId !== 'number') {
return null; return null;
@@ -247,5 +240,6 @@ export const commandSpec: NationTurnCommandSpec = {
category: '전략', category: '전략',
reqArg: true, reqArg: true,
args: { destCityId: 0 }, args: { destCityId: 0 },
argsSchema: ARGS_SCHEMA,
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? []), createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? []),
}; };
@@ -25,11 +25,8 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
import type { NationTurnCommandSpec } from './index.js'; import type { NationTurnCommandSpec } from './index.js';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import { clamp } from 'es-toolkit'; import { clamp } from 'es-toolkit';
import { z } from 'zod';
export interface PopulationMoveArgs { import { parseArgsWithSchema } from '../parseArgs.js';
destCityId: number;
amount: number;
}
export interface PopulationMoveResolveContext< export interface PopulationMoveResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState, TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -42,24 +39,17 @@ const ACTION_NAME = '인구이동';
const AMOUNT_LIMIT = 100000; const AMOUNT_LIMIT = 100000;
const MIN_AVAILABLE_RECRUIT_POP = 30000; const MIN_AVAILABLE_RECRUIT_POP = 30000;
const parseCityId = (raw: unknown): number | null => { const ARGS_SCHEMA = z.object({
if (typeof raw !== 'number' || !Number.isFinite(raw)) { destCityId: z.preprocess(
return null; (value) => (typeof value === 'number' ? Math.floor(value) : value),
} z.number().int().positive()
const value = Math.floor(raw); ),
return value > 0 ? value : null; amount: z.preprocess(
}; (value) => (typeof value === 'number' ? clamp(Math.floor(value), 0, AMOUNT_LIMIT) : value),
z.number().int().min(0).max(AMOUNT_LIMIT)
const parseAmount = (raw: unknown): number | null => { ),
if (typeof raw !== 'number' || !Number.isFinite(raw)) { });
return null; export type PopulationMoveArgs = z.infer<typeof ARGS_SCHEMA>;
}
const value = Math.floor(raw);
if (value < 0) {
return null;
}
return clamp(value, 0, AMOUNT_LIMIT);
};
const calcCost = (develCost: number, amount: number): number => Math.round((develCost * amount) / 10000); const calcCost = (develCost: number, amount: number): number => Math.round((develCost * amount) / 10000);
@@ -72,13 +62,7 @@ export class ActionDefinition<
constructor(private readonly env: TurnCommandEnv) {} constructor(private readonly env: TurnCommandEnv) {}
parseArgs(raw: unknown): PopulationMoveArgs | null { parseArgs(raw: unknown): PopulationMoveArgs | null {
const data = raw as { destCityId?: unknown; amount?: unknown }; return parseArgsWithSchema(ARGS_SCHEMA, raw);
const destCityId = parseCityId(data?.destCityId);
const amount = parseAmount(data?.amount);
if (destCityId === null || amount === null) {
return null;
}
return { destCityId, amount };
} }
buildConstraints(_ctx: ConstraintContext, args: PopulationMoveArgs): Constraint[] { buildConstraints(_ctx: ConstraintContext, args: PopulationMoveArgs): Constraint[] {
@@ -159,7 +143,7 @@ export class ActionDefinition<
} }
} }
export const actionContextBuilder: ActionContextBuilder = (base, options) => { export const actionContextBuilder: ActionContextBuilder<PopulationMoveArgs> = (base, options) => {
const destCityId = options.actionArgs.destCityId; const destCityId = options.actionArgs.destCityId;
if (typeof destCityId !== 'number') { if (typeof destCityId !== 'number') {
return null; return null;
@@ -185,5 +169,6 @@ export const commandSpec: NationTurnCommandSpec = {
category: '특수', category: '특수',
reqArg: true, reqArg: true,
args: { destCityId: 0, amount: 0 }, args: { destCityId: 0, amount: 0 },
argsSchema: ARGS_SCHEMA,
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
}; };
@@ -290,7 +290,7 @@ describe('Nation Missing Actions', () => {
setupDiplomacy(view, nation.id, destNation.id, 2); setupDiplomacy(view, nation.id, destNation.id, 2);
const definition = new CounterStrategyAction(); const definition = new CounterStrategyAction();
const args = { destNationId: destNation.id, commandType: 'che_허보' }; const args = { destNationId: destNation.id, commandType: 'che_허보' } as const;
const ctx: ConstraintContext = { const ctx: ConstraintContext = {
actorId: general.id, actorId: general.id,
nationId: nation.id, nationId: nation.id,
@@ -327,7 +327,7 @@ describe('Nation Missing Actions', () => {
addLog: () => {}, addLog: () => {},
} as any, } as any,
{ now: new Date(), schedule }, { now: new Date(), schedule },
{ destNationId: destNation.id, commandType: 'che_허보' } { destNationId: destNation.id, commandType: 'che_허보' } as const
); );
expect(resolution.general.experience).toBeGreaterThan(100); expect(resolution.general.experience).toBeGreaterThan(100);