Files
core2026/app/game-api/src/turns/commandInput.ts
T

595 lines
18 KiB
TypeScript

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<string, TurnCommandOption[]>;
generals: TurnCommandOption[];
generalTargets?: Record<string, TurnCommandOption[]>;
crewTypes: TurnCommandOption[];
armTypes: TurnCommandOption[];
nationTypes: TurnCommandOption[];
colors: TurnCommandOption[];
items: Record<string, TurnCommandOption[]>;
recruitment: TurnCommandRecruitmentInfo | null;
amountPresets?: Record<string, TurnCommandAmountPreset>;
context?: {
actorGold: number;
actorRice: number;
citySecurity?: number;
nationGold?: number;
nationRice?: number;
nationLevel?: number;
};
}
type EquipmentTradeItemModule = Pick<ItemModule, 'key' | 'slot' | 'name' | 'info' | 'cost' | 'reqSecu' | 'buyable'>;
const plainLegacyInfo = (value: string): string =>
value
.replace(/<br\s*\/?>/giu, ' · ')
.replace(/<[^>]+>/gu, '')
.replace(/\s+/gu, ' ')
.trim();
export const buildEquipmentTradeItemOptions = (options: {
configConst: Record<string, unknown>;
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<string, string> = {
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<string, TurnCommandOptionSource> = {
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<string, Record<string, string>> = {
itemType: {
horse: '명마',
weapon: '무기',
book: '서적',
item: '도구',
},
commandType: {
che_선전포고: '선전포고',
che_불가침제의: '불가침 제의',
che_불가침파기제의: '불가침 파기 제의',
che_종전제의: '종전 제의',
},
};
type JsonSchema = Record<string, unknown>;
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<Record<string, unknown>> => {
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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.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<string, unknown>;
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<void> => {
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<Record<string, unknown>> => {
await assertReservedTurnActionAvailable(scope, action, scenarioConst);
return parseRegisteredTurnArgs(scope, action, sanitizeReservedTurnArgs(rawArgs));
};