Files
core2026/packages/logic/src/actions/turn/commandProfile.ts
T
Hide_D c63a49bd07 fix: 커맨드 차등 생명주기와 로그 그래프를 보강
장수 55종과 수뇌 35종의 상태, 로그, 메시지, 예약 턴 비교를 닫습니다.

실제 시나리오 프로필과 대표 PostgreSQL 수명주기, 즉시 외교와 출병 회귀를 추가하고 발견된 Ref 로그 및 생성 장수 저장 차이를 교정합니다.
2026-08-23 21:48:29 +00:00

154 lines
5.6 KiB
TypeScript

import { GENERAL_TURN_COMMAND_KEYS, isGeneralTurnCommandKey, type GeneralTurnCommandKey } from './general/index.js';
import { NATION_TURN_COMMAND_KEYS, isNationTurnCommandKey, type NationTurnCommandKey } from './nation/index.js';
import { asStringArray, isRecord } from '@sammo-ts/common';
import { TurnCommandProfileInputSchema } from '../../resources/turnCommandSchema.js';
export interface TurnCommandProfile {
general: GeneralTurnCommandKey[];
nation: NationTurnCommandKey[];
}
export interface TurnCommandGroup<Key extends string> {
category: string;
commands: Key[];
}
export interface ScenarioTurnCommandProfileResolution {
profile: TurnCommandProfile;
generalGroups: Array<TurnCommandGroup<GeneralTurnCommandKey>> | null;
nationGroups: Array<TurnCommandGroup<NationTurnCommandKey>> | null;
}
const parseKeyList = <T extends string>(options: {
raw: unknown;
isKey: (value: string) => value is T;
label: string;
}): T[] => {
if (!Array.isArray(options.raw) || options.raw.length === 0) {
throw new Error(`${options.label} command profile must be a non-empty array.`);
}
const parsed: T[] = [];
const seen = new Set<T>();
for (const value of asStringArray(options.raw)) {
if (!options.isKey(value)) {
throw new Error(`Unknown ${options.label} command key: ${value}`);
}
if (seen.has(value)) {
throw new Error(`Duplicate ${options.label} command key: ${value}`);
}
seen.add(value);
parsed.push(value);
}
if (parsed.length !== options.raw.length) {
throw new Error(`${options.label} command profile contains a non-string key.`);
}
if (!parsed.includes('휴식' as T)) {
throw new Error(`${options.label} command profile must include 휴식.`);
}
return parsed;
};
export const DEFAULT_TURN_COMMAND_PROFILE: TurnCommandProfile = {
general: [...GENERAL_TURN_COMMAND_KEYS],
nation: [...NATION_TURN_COMMAND_KEYS],
};
export const parseTurnCommandProfile = (raw: unknown): TurnCommandProfile => {
const parsed = TurnCommandProfileInputSchema.safeParse(raw);
if (!parsed.success) {
throw new Error(`Invalid turn command profile: ${parsed.error.message}`);
}
const data = parsed.data;
return {
general: parseKeyList({
raw: data.general,
isKey: isGeneralTurnCommandKey,
label: 'general',
}),
nation: parseKeyList({
raw: data.nation,
isKey: isNationTurnCommandKey,
label: 'nation',
}),
};
};
const parseScenarioCommandGroups = <Key extends string>(options: {
raw: unknown;
isKey: (value: string) => value is Key;
label: string;
}): Array<TurnCommandGroup<Key>> | null => {
if (options.raw === undefined || options.raw === null) {
return null;
}
if (!isRecord(options.raw)) {
throw new Error(`Scenario ${options.label} command groups must be an object.`);
}
const groups: Array<TurnCommandGroup<Key>> = [];
const seen = new Set<Key>();
for (const [category, rawCommands] of Object.entries(options.raw)) {
if (!category.trim()) {
throw new Error(`Scenario ${options.label} command category must be non-empty.`);
}
if (!Array.isArray(rawCommands)) {
throw new Error(`Scenario ${options.label} command category ${category} must be an array.`);
}
const commands: Key[] = [];
for (const value of asStringArray(rawCommands)) {
if (!options.isKey(value)) {
throw new Error(`Unknown scenario ${options.label} command key: ${value}`);
}
if (seen.has(value)) {
throw new Error(`Duplicate scenario ${options.label} command key: ${value}`);
}
seen.add(value);
commands.push(value);
}
if (commands.length !== rawCommands.length) {
throw new Error(`Scenario ${options.label} command category ${category} contains a non-string key.`);
}
groups.push({ category, commands });
}
const flattened = groups.flatMap((group) => group.commands);
if (!flattened.includes('휴식' as Key)) {
throw new Error(`Scenario ${options.label} command groups must include 휴식.`);
}
return groups;
};
/**
* Ref replaces GameConst::$availableGeneralCommand/$availableChiefCommand with
* the scenario const when present. Resolve that same public/executable profile
* here so the API and daemon cannot silently use the default profile instead.
*/
export const resolveScenarioTurnCommandProfile = (
scenarioConst: unknown,
fallback: TurnCommandProfile
): ScenarioTurnCommandProfileResolution => {
if (scenarioConst !== undefined && scenarioConst !== null && !isRecord(scenarioConst)) {
throw new Error('Scenario const must be an object.');
}
const config = isRecord(scenarioConst) ? scenarioConst : {};
const generalGroups = parseScenarioCommandGroups({
raw: config.availableGeneralCommand,
isKey: isGeneralTurnCommandKey,
label: 'general',
});
const nationGroups = parseScenarioCommandGroups({
raw: config.availableChiefCommand,
isKey: isNationTurnCommandKey,
label: 'nation',
});
return {
profile: {
general: generalGroups ? generalGroups.flatMap((group) => group.commands) : [...fallback.general],
nation: nationGroups ? nationGroups.flatMap((group) => group.commands) : [...fallback.nation],
},
generalGroups,
nationGroups,
};
};