커맨드의 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;
}
export interface ActionContextOptions {
export interface ActionContextOptions<TArgs extends Record<string, unknown> = Record<string, unknown>> {
world: ActionContextWorldState;
scenarioConfig: ScenarioConfig;
scenarioMeta?: ScenarioMeta;
map?: MapDefinition;
unitSet?: UnitSetDefinition;
worldRef: ActionContextWorldRef | null;
actionArgs: Record<string, unknown>;
actionArgs: TArgs;
createGeneralId: () => number;
createNationId: () => number;
seedBase: string;
}
// 예약 턴 처리에서 커맨드별로 필요한 컨텍스트를 확장한다.
export type ActionContextBuilder = (
base: ActionContextBase,
options: ActionContextOptions
) => ActionResolveContext | null;
export type ActionContextBuilder<TArgs extends Record<string, unknown> = Record<string, unknown>> = {
bivarianceHack(base: ActionContextBase, options: ActionContextOptions<TArgs>): ActionResolveContext | null;
}['bivarianceHack'];
export const defaultActionContextBuilder: ActionContextBuilder = (base) => base;
@@ -3,16 +3,35 @@ import type { ZodType } from 'zod';
import type { ActionContextBuilder } from './actionContext.js';
import type { TurnCommandEnv } from './commandEnv.js';
export interface TurnCommandSpecBase<TKey extends string = string> {
export interface TurnCommandSpecWithArgs<TKey extends string = string> {
key: TKey;
category: string;
reqArg: boolean;
reqArg: true;
args: Record<string, unknown>;
argsSchema?: ZodType<unknown>;
argsSchema: ZodType<Record<string, unknown>>;
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> {
commandSpec: TSpec;
actionContextBuilder?: ActionContextBuilder;
actionContextBuilder?: ActionContextBuilder<TurnCommandArgs<TSpec>>;
}
@@ -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 ?? []),
};
@@ -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;
@@ -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<typeof ARGS_SCHEMA>;
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionDefinition<TriggerState, ChangeFlagArgs> {
@@ -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(),
};
@@ -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<typeof ARGS_SCHEMA>;
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(),
};
@@ -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<typeof ARGS_SCHEMA>;
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<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
private readonly pipeline: GeneralActionPipeline<TriggerState>;
@@ -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<RaidArgs> = (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 ?? []),
};
@@ -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<typeof ARGS_SCHEMA>;
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<SeizureArgs> = (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),
};
@@ -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<typeof ARGS_SCHEMA>;
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<MaterialAidArgs> = (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),
};
@@ -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<typeof ARGS_SCHEMA>;
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<AssignmentArgs> = (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({}),
};
@@ -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<typeof ARGS_SCHEMA>;
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<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
private readonly pipeline: GeneralActionPipeline<TriggerState>;
@@ -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<MobilizePeopleArgs> = (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 ?? []),
};
@@ -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<typeof ARGS_SCHEMA>;
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<TroopKickArgs> = (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(),
};
@@ -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<typeof ARGS_SCHEMA>;
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(),
};
@@ -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<typeof ARGS_SCHEMA>;
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(),
};
@@ -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<typeof ARGS_SCHEMA>;
// 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<DeclareWarArgs> = (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(),
};
@@ -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<typeof ARGS_SCHEMA>;
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<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
private readonly pipeline: GeneralActionPipeline<TriggerState>;
@@ -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<FloodArgs> = (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 ?? []),
};
@@ -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<typeof ARGS_SCHEMA>;
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<DegradeRelationsArgs> = (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 ?? []),
};
@@ -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<typeof ARGS_SCHEMA>;
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(),
};
@@ -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<typeof ARGS_SCHEMA>;
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<MoveCapitalArgs> = (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),
};
@@ -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<typeof ARGS_SCHEMA>;
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<ScorchedEarthArgs> = (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(),
};
@@ -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<typeof ARGS_SCHEMA>;
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<AwardArgs> = (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);
@@ -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<typeof ARGS_SCHEMA>;
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<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_수몰: '수몰',
@@ -51,26 +61,15 @@ const STRATEGIC_COMMANDS: Record<string, string> = {
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<TriggerState>();
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<CounterStrategyArgs> = (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(),
};
@@ -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<typeof ARGS_SCHEMA>;
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<DeceptionArgs> = (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 ?? []),
};
@@ -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<typeof ARGS_SCHEMA>;
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<PopulationMoveArgs> = (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),
};
@@ -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);