import { isGeneralTurnCommandKey, isNationTurnCommandKey, getLegacyStringWidth, loadGeneralTurnCommandSpecs, loadNationTurnCommandSpecs, type GeneralTurnCommandSpec, type NationTurnCommandSpec, } from '@sammo-ts/logic'; import { asRecord, isRecord } from '@sammo-ts/common'; import type { ItemModule } from '@sammo-ts/logic/items/types.js'; import { resolveLegacyPurchasableItemKeys } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; import { z } from 'zod'; import { loadScenarioTurnCommandProfile } from '@sammo-ts/game-engine/turn/turnCommandProfile.js'; export type TurnCommandOptionValue = string | number; export interface TurnCommandOption { value: TurnCommandOptionValue; label: string; color?: string; description?: string; availableNow?: boolean; gold?: number; rice?: number; crew?: number; troopId?: number; npcState?: number; } export interface TurnCommandAmountPreset { values: number[]; defaultValue: number; min: number; max: number; step: number; } export interface TurnCommandRecruitmentCrewType { id: number; armType: number; name: string; available: boolean; special: boolean; attack: number; defence: number; speed: number; avoid: number; baseCost: number; baseRice: number; info: string[]; } export interface TurnCommandRecruitmentGroup { armType: number; armName: string; values: TurnCommandRecruitmentCrewType[]; } export interface TurnCommandRecruitmentInfo { techLevel: number; leadership: number; fullLeadership: number; currentCrewTypeId: number; currentCrewTypeName: string; crew: number; gold: number; groups: TurnCommandRecruitmentGroup[]; } export type TurnCommandOptionSource = 'cities' | 'nations' | 'generals' | 'crewTypes' | 'armTypes' | 'nationTypes' | 'colors' | 'items'; export interface TurnCommandInputField { key: string; label: string; kind: 'text' | 'number' | 'boolean' | 'select' | 'numberTuple' | 'hidden'; required: boolean; min?: number; max?: number; legacyWidthMax?: number; step?: number; defaultValue?: TurnCommandOptionValue | boolean; constValue?: TurnCommandOptionValue; options?: TurnCommandOption[]; optionSource?: TurnCommandOptionSource; tupleLabels?: string[]; } export interface TurnCommandInputOptions { cities: TurnCommandOption[]; nations: TurnCommandOption[]; nationTargets?: Record; generals: TurnCommandOption[]; generalTargets?: Record; crewTypes: TurnCommandOption[]; armTypes: TurnCommandOption[]; nationTypes: TurnCommandOption[]; colors: TurnCommandOption[]; items: Record; recruitment: TurnCommandRecruitmentInfo | null; amountPresets?: Record; context?: { actorGold: number; actorRice: number; citySecurity?: number; nationGold?: number; nationRice?: number; nationLevel?: number; }; } type EquipmentTradeItemModule = Pick; const plainLegacyInfo = (value: string): string => value .replace(//giu, ' · ') .replace(/<[^>]+>/gu, '') .replace(/\s+/gu, ' ') .trim(); export const buildEquipmentTradeItemOptions = (options: { configConst: Record; itemModules: readonly EquipmentTradeItemModule[]; currentSecurity: number; generalGold: number; }): TurnCommandInputOptions['items'] => { const purchasableItemKeys = resolveLegacyPurchasableItemKeys(options.configConst); const items: TurnCommandInputOptions['items'] = { horse: [{ value: 'None', label: '판매/해제' }], weapon: [{ value: 'None', label: '판매/해제' }], book: [{ value: 'None', label: '판매/해제' }], item: [{ value: 'None', label: '판매/해제' }], }; for (const item of options.itemModules) { if (!item.buyable || !purchasableItemKeys.has(item.key)) { continue; } const cost = item.cost ?? 0; const availability = options.currentSecurity < item.reqSecu ? `현재 구입 불가: 치안 ${item.reqSecu.toLocaleString()} 필요` : options.generalGold < cost ? `현재 구입 불가: 자금 ${cost.toLocaleString()} 필요` : '현재 구입 가능'; items[item.slot].push({ value: item.key, label: item.name, description: `${availability} · 가격 ${cost.toLocaleString()} · ${plainLegacyInfo(item.info)}`, }); } return items; }; // 레거시 및 명령 실행 모듈의 인덱스 순서와 동일해야 한다. export const TURN_COMMAND_NATION_COLORS = [ '#FF0000', '#800000', '#A0522D', '#FF6347', '#FFA500', '#FFDAB9', '#FFD700', '#FFFF00', '#7CFC00', '#00FF00', '#808000', '#008000', '#2E8B57', '#008080', '#20B2AA', '#6495ED', '#7FFFD4', '#AFEEEE', '#87CEEB', '#00FFFF', '#00BFFF', '#0000FF', '#000080', '#483D8B', '#7B68EE', '#BA55D3', '#800080', '#FF00FF', '#FFC0CB', '#F5F5DC', '#E0FFFF', '#FFFFFF', '#A9A9A9', ] as const; const FIELD_LABELS: Record = { destCityId: '대상 도시', destCityID: '대상 도시', destGeneralId: '대상 장수', destGeneralID: '대상 장수', destNationId: '대상 국가', nationName: '국가명', nationType: '국가 성향', colorType: '국기 색상', srcArmType: '기존 병과', destArmType: '변경 병과', itemType: '장비 종류', itemCode: '장비', crewType: '병종', amount: '수량', buyRice: '거래', isGold: '물자', optionText: '행동', year: '기간(년)', month: '기간(월)', commandType: '대응 명령', amountList: '지원 물자', }; const OPTION_SOURCES: Record = { destCityId: 'cities', destCityID: 'cities', destGeneralId: 'generals', destGeneralID: 'generals', destNationId: 'nations', nationType: 'nationTypes', colorType: 'colors', srcArmType: 'armTypes', destArmType: 'armTypes', itemCode: 'items', crewType: 'crewTypes', }; const STATIC_LABELS: Record> = { itemType: { horse: '명마', weapon: '무기', book: '서적', item: '도구', }, commandType: { che_선전포고: '선전포고', che_불가침제의: '불가침 제의', che_불가침파기제의: '불가침 파기 제의', che_종전제의: '종전 제의', }, }; type JsonSchema = Record; const collectObjectSchema = (schema: JsonSchema): JsonSchema => { const branches = schema.oneOf ?? schema.anyOf; if (Array.isArray(branches)) { const objectBranches = branches.filter(isRecord).map((branch) => collectObjectSchema(branch)); const properties = Object.assign({}, ...objectBranches.map((branch) => asRecord(branch.properties))); const required = Array.from( new Set( objectBranches.flatMap((branch) => Array.isArray(branch.required) ? branch.required.filter((entry): entry is string => typeof entry === 'string') : [] ) ) ); return { ...schema, properties, required }; } return schema; }; const enumOptions = (fieldKey: string, values: unknown[]): TurnCommandOption[] => values .filter((value): value is TurnCommandOptionValue => typeof value === 'string' || typeof value === 'number') .filter((value) => fieldKey !== 'commandType' || value !== 'che_피장파장') .map((value) => ({ value, label: STATIC_LABELS[fieldKey]?.[String(value)] ?? String(value), })); const buildField = (key: string, rawSchema: unknown, required: boolean): TurnCommandInputField => { const schema = asRecord(rawSchema); const label = FIELD_LABELS[key] ?? key; const constValue = schema.const; if (typeof constValue === 'string' || typeof constValue === 'number') { return { key, label, kind: 'hidden', required, constValue }; } if (key === 'amountList') { return { key, label, kind: 'numberTuple', required, tupleLabels: ['금', '쌀'], min: 0, step: 1, }; } const optionSource = OPTION_SOURCES[key]; const enumValues = Array.isArray(schema.enum) ? enumOptions(key, schema.enum) : undefined; if (optionSource || enumValues) { return { key, label, kind: 'select', required, optionSource, options: enumValues, }; } if (schema.type === 'boolean') { return { key, label, kind: 'boolean', required }; } if (schema.type === 'number' || schema.type === 'integer') { return { key, label, kind: 'number', required, min: typeof schema.minimum === 'number' ? schema.minimum : undefined, max: typeof schema.maximum === 'number' ? schema.maximum : undefined, step: schema.type === 'integer' ? 1 : undefined, }; } if (schema.type === 'string') { return { key, label, kind: 'text', required, min: typeof schema.minLength === 'number' ? schema.minLength : undefined, max: typeof schema.maxLength === 'number' ? schema.maxLength : undefined, legacyWidthMax: key === 'nationName' ? 18 : undefined, }; } throw new Error(`Unsupported turn command argument schema: ${key}`); }; export const buildTurnCommandInputFields = ( spec: GeneralTurnCommandSpec | NationTurnCommandSpec ): TurnCommandInputField[] => { if (!spec.reqArg) { return []; } const jsonSchema = collectObjectSchema(asRecord(z.toJSONSchema(spec.argsSchema))); const properties = asRecord(jsonSchema.properties); const required = new Set( Array.isArray(jsonSchema.required) ? jsonSchema.required.filter((entry): entry is string => typeof entry === 'string') : [] ); return Object.entries(properties).map(([key, schema]) => buildField(key, schema, required.has(key))); }; export const loadTurnCommandSpecs = async (scenarioConst?: unknown) => { const resolution = await loadScenarioTurnCommandProfile({ scenarioConst }); const profile = resolution.profile; const [general, nation] = await Promise.all([ loadGeneralTurnCommandSpecs(profile.general), loadNationTurnCommandSpecs(profile.nation), ]); return { general, nation, generalGroups: resolution.generalGroups, nationGroups: resolution.nationGroups, }; }; const parseRegisteredTurnArgs = async ( scope: 'general' | 'nation', action: string, rawArgs: unknown ): Promise> => { const specs = scope === 'general' ? isGeneralTurnCommandKey(action) ? await loadGeneralTurnCommandSpecs([action]) : [] : isNationTurnCommandKey(action) ? await loadNationTurnCommandSpecs([action]) : []; const spec = specs[0]; if (!spec) { throw new Error(`Unknown ${scope} turn command: ${action}`); } if (!spec.reqArg) { return {}; } return spec.argsSchema.parse(rawArgs); }; const LEGACY_REMOVED_TURN_ARG_CHARACTERS = new Set([ '"', "'", 'ⓝ', 'ⓜ', 'ⓖ', 'ⓞ', 'ⓧ', '㉥', '\\', '/', '`', '#', '-', '|', ]); const sanitizeLegacyTurnArgString = (value: string): string => { // Ref StringUtil::neutralize() treats the string "0" as empty because of // PHP truthiness, both before and after removeSpecialCharacter(). if (value === '' || value === '0') { return ''; } const stripped = Array.from(value) .filter((character) => !LEGACY_REMOVED_TURN_ARG_CHARACTERS.has(character)) .join(''); if (stripped === '' || stripped === '0') { return ''; } return stripped .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replace(/^[\p{Z}\p{C}]+|[\p{Z}\p{C}]+$/gu, ''); }; export const sanitizeReservedTurnArgs = (value: unknown): unknown => { if (typeof value === 'string') { return sanitizeLegacyTurnArgString(value); } if (Array.isArray(value)) { return value.map((entry) => sanitizeReservedTurnArgs(entry)); } if (value !== null && typeof value === 'object') { return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, sanitizeReservedTurnArgs(entry)])); } return value; }; const LEGACY_INTEGER_TURN_ARG_KEYS = new Set([ 'crewType', 'destGeneralId', 'destGeneralID', 'destCityId', 'destCityID', 'destNationId', 'destNationID', 'amount', 'colorType', 'srcArmType', 'destArmType', ]); const LEGACY_BOOLEAN_TURN_ARG_KEYS = new Set(['isGold', 'buyRice']); const LEGACY_INTEGER_ARRAY_TURN_ARG_KEYS = new Set([ 'destNationIdList', 'destNationIDList', 'destGeneralIdList', 'destGeneralIDList', 'amountList', ]); const LEGACY_NUMERIC_PATTERN = /^[\t\n\r\f\v ]*[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?[\t\n\r\f\v ]*$/; const skipsLegacyOptionalValidation = (value: unknown): boolean => value === null || value === ''; const isLegacyNumeric = (value: unknown): boolean => { if (typeof value === 'number') { return Number.isFinite(value); } if (typeof value !== 'string' || !LEGACY_NUMERIC_PATTERN.test(value)) { return false; } return Number.isFinite(Number(value)); }; const throwLegacyBasicTurnArgError = (): never => { throw new Error('턴이 입력되지 않았습니다.'); }; /** * Ref checkCommandArg() only validates common fields that are present. Missing * command-specific fields are deliberately left for command construction, so * nation penalties and officer checks retain their legacy error priority. */ export const assertReservedTurnArgsPassLegacyBasicValidation = (rawArgs: unknown): void => { const sanitizedArgs = sanitizeReservedTurnArgs(rawArgs); if (sanitizedArgs === null || sanitizedArgs === undefined) { return; } if (typeof sanitizedArgs !== 'object') { throwLegacyBasicTurnArgError(); } const args = sanitizedArgs as Record; for (const key of LEGACY_INTEGER_TURN_ARG_KEYS) { if ( Object.prototype.hasOwnProperty.call(args, key) && !skipsLegacyOptionalValidation(args[key]) && !Number.isInteger(args[key]) ) { throwLegacyBasicTurnArgError(); } } for (const key of LEGACY_BOOLEAN_TURN_ARG_KEYS) { if ( Object.prototype.hasOwnProperty.call(args, key) && !skipsLegacyOptionalValidation(args[key]) && typeof args[key] !== 'boolean' ) { throwLegacyBasicTurnArgError(); } } for (const key of LEGACY_INTEGER_ARRAY_TURN_ARG_KEYS) { if (!Object.prototype.hasOwnProperty.call(args, key)) { continue; } const value = args[key]; if (skipsLegacyOptionalValidation(value)) { continue; } if (!Array.isArray(value) || value.some((entry) => !Number.isInteger(entry))) { throwLegacyBasicTurnArgError(); } } const month = args.month; if ( Object.prototype.hasOwnProperty.call(args, 'month') && !skipsLegacyOptionalValidation(month) && (!isLegacyNumeric(month) || Number(month) < 1 || Number(month) > 12) ) { throwLegacyBasicTurnArgError(); } const year = args.year; if ( Object.prototype.hasOwnProperty.call(args, 'year') && !skipsLegacyOptionalValidation(year) && (!isLegacyNumeric(year) || Number(year) < 0) ) { throwLegacyBasicTurnArgError(); } for (const [key, minimum] of [ ['destGeneralId', 1], ['destGeneralID', 1], ['destCityId', 1], ['destCityID', 1], ['destNationId', 1], ['destNationID', 1], ['amount', 1], ['crewType', 0], ] as const) { if ( Object.prototype.hasOwnProperty.call(args, key) && !skipsLegacyOptionalValidation(args[key]) && Number(args[key]) < minimum ) { throwLegacyBasicTurnArgError(); } } if (Object.prototype.hasOwnProperty.call(args, 'nationName')) { const nationName = args.nationName; if ( !skipsLegacyOptionalValidation(nationName) && (typeof nationName !== 'string' || getLegacyStringWidth(nationName) < 1 || getLegacyStringWidth(nationName) > 18) ) { throw new Error('국가명은 전각 9자 또는 반각 18자 이하여야 합니다.'); } } }; export const assertReservedTurnActionAvailable = async ( scope: 'general' | 'nation', action: string, scenarioConst?: unknown ): Promise => { const specs = await loadTurnCommandSpecs(scenarioConst); if (!specs[scope].some((entry) => entry.key === action)) { throw new Error(`Unknown ${scope} turn command: ${action}`); } }; export const parseReservedTurnArgs = async ( scope: 'general' | 'nation', action: string, rawArgs: unknown, scenarioConst?: unknown ): Promise> => { await assertReservedTurnActionAvailable(scope, action, scenarioConst); return parseRegisteredTurnArgs(scope, action, sanitizeReservedTurnArgs(rawArgs)); };