327 lines
9.2 KiB
TypeScript
327 lines
9.2 KiB
TypeScript
import {
|
|
loadGeneralTurnCommandSpecs,
|
|
loadNationTurnCommandSpecs,
|
|
type GeneralTurnCommandSpec,
|
|
type NationTurnCommandSpec,
|
|
} from '@sammo-ts/logic';
|
|
import { asRecord, isRecord } from '@sammo-ts/common';
|
|
import { z } from 'zod';
|
|
|
|
import { loadTurnCommandProfile } 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;
|
|
}
|
|
|
|
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;
|
|
step?: number;
|
|
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;
|
|
};
|
|
}
|
|
|
|
// 레거시 및 명령 실행 모듈의 인덱스 순서와 동일해야 한다.
|
|
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,
|
|
};
|
|
}
|
|
|
|
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 () => {
|
|
const profile = await loadTurnCommandProfile();
|
|
const [general, nation] = await Promise.all([
|
|
loadGeneralTurnCommandSpecs(profile.general),
|
|
loadNationTurnCommandSpecs(profile.nation),
|
|
]);
|
|
return { general, nation };
|
|
};
|
|
|
|
export const parseReservedTurnArgs = async (
|
|
scope: 'general' | 'nation',
|
|
action: string,
|
|
rawArgs: unknown
|
|
): Promise<Record<string, unknown>> => {
|
|
const specs = await loadTurnCommandSpecs();
|
|
const spec = specs[scope].find((entry) => entry.key === action);
|
|
if (!spec) {
|
|
throw new Error(`Unknown ${scope} turn command: ${action}`);
|
|
}
|
|
if (!spec.reqArg) {
|
|
return {};
|
|
}
|
|
return spec.argsSchema.parse(rawArgs);
|
|
};
|