feat(wip): 시나리오 로더 및 파서 구현, 관련 타입 추가

This commit is contained in:
2025-12-28 14:42:23 +00:00
parent fab128943c
commit afe5cadd9f
11 changed files with 507 additions and 2 deletions
+1
View File
@@ -1,4 +1,5 @@
export * from './domain/entities.js';
export type { RandomGenerator } from '@sammo-ts/common';
export * from './ports/world.js';
export * from './scenario/index.js';
export * from './triggers/index.js';
+2
View File
@@ -0,0 +1,2 @@
export * from './types.js';
export * from './parseScenario.js';
@@ -0,0 +1,263 @@
import type {
ScenarioConfig,
ScenarioDefaults,
ScenarioDefinition,
ScenarioDiplomacy,
ScenarioEnvironment,
ScenarioGeneral,
ScenarioNation,
ScenarioStatBlock,
} from './types.js';
type UnknownRecord = Record<string, unknown>;
const FALLBACK_STAT: ScenarioStatBlock = {
total: 0,
min: 0,
max: 0,
npcTotal: 0,
npcMax: 0,
npcMin: 0,
chiefMin: 0,
};
const isRecord = (value: unknown): value is UnknownRecord =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const asArray = (value: unknown): unknown[] =>
Array.isArray(value) ? value : [];
const asRecord = (value: unknown): UnknownRecord =>
isRecord(value) ? value : {};
const asNumber = (value: unknown, fallback: number): number =>
typeof value === 'number' ? value : fallback;
const asString = (value: unknown, fallback: string): string =>
typeof value === 'string' ? value : fallback;
const asNullableNumber = (value: unknown): number | null =>
typeof value === 'number' ? value : null;
const asNullableString = (value: unknown): string | null =>
typeof value === 'string' ? value : null;
const asStringArray = (value: unknown): string[] =>
asArray(value).filter((item): item is string => typeof item === 'string');
const parseScenarioStatBlock = (
value: unknown,
fallback: ScenarioStatBlock
): ScenarioStatBlock => {
const data = asRecord(value);
return {
total: asNumber(data.total, fallback.total),
min: asNumber(data.min, fallback.min),
max: asNumber(data.max, fallback.max),
npcTotal: asNumber(data.npcTotal, fallback.npcTotal),
npcMax: asNumber(data.npcMax, fallback.npcMax),
npcMin: asNumber(data.npcMin, fallback.npcMin),
chiefMin: asNumber(data.chiefMin, fallback.chiefMin),
};
};
const parseScenarioEnvironment = (
mapConfig: UnknownRecord,
constConfig: UnknownRecord
): ScenarioEnvironment => {
const merged = { ...mapConfig, ...constConfig };
const mapName = asString(merged.mapName, 'che');
const unitSet = asString(merged.unitSet, 'che');
const scenarioEffect =
typeof merged.scenarioEffect === 'string' || merged.scenarioEffect === null
? merged.scenarioEffect
: undefined;
return { mapName, unitSet, scenarioEffect };
};
const ensureTitle = (value: unknown): string => {
if (typeof value !== 'string') {
throw new Error('Scenario title must be a string.');
}
return value;
};
const parseNationRow = (row: unknown, index: number): ScenarioNation => {
if (!Array.isArray(row)) {
throw new Error(`Scenario nation row ${index} is not an array.`);
}
const [
name,
color,
gold,
rice,
infoText,
tech,
type,
level,
cities,
] = row;
const nationName = asString(name, '');
if (!nationName) {
throw new Error(`Scenario nation row ${index} has no name.`);
}
return {
id: index + 1,
name: nationName,
color: asString(color, '#000000'),
gold: asNumber(gold, 0),
rice: asNumber(rice, 0),
infoText: asNullableString(infoText),
tech: asNumber(tech, 0),
type: asString(type, ''),
level: asNumber(level, 0),
cities: asStringArray(cities),
};
};
const parseDiplomacyRow = (row: unknown, index: number): ScenarioDiplomacy => {
if (!Array.isArray(row)) {
throw new Error(`Scenario diplomacy row ${index} is not an array.`);
}
const [fromNationId, toNationId, state, durationMonths] = row;
return {
fromNationId: asNumber(fromNationId, 0),
toNationId: asNumber(toNationId, 0),
state: asNumber(state, 0),
durationMonths: asNumber(durationMonths, 0),
};
};
const parseGeneralRow = (
row: unknown,
index: number,
label: string
): ScenarioGeneral => {
if (!Array.isArray(row)) {
throw new Error(`Scenario ${label} row ${index} is not an array.`);
}
const values = [...row];
while (values.length < 14) {
values.push(null);
}
const [
affinity,
name,
picture,
nation,
city,
leadership,
strength,
intelligence,
officerLevel,
birthYear,
deathYear,
personality,
special,
text,
] = values;
if (typeof name !== 'string') {
throw new Error(`Scenario ${label} row ${index} has no name.`);
}
return {
affinity: asNullableNumber(affinity),
name,
picture:
typeof picture === 'number' || typeof picture === 'string'
? picture
: null,
nation:
typeof nation === 'number' || typeof nation === 'string'
? nation
: null,
city: asNullableString(city),
leadership: asNumber(leadership, 0),
strength: asNumber(strength, 0),
intelligence: asNumber(intelligence, 0),
officerLevel: asNumber(officerLevel, 0),
birthYear: asNumber(birthYear, 0),
deathYear: asNumber(deathYear, 0),
personality: asNullableString(personality),
special: asNullableString(special),
text: asNullableString(text),
};
};
const parseGeneralRows = (rows: unknown[], label: string): ScenarioGeneral[] =>
rows.map((row, index) => parseGeneralRow(row, index, label));
const parseNationRows = (rows: unknown[]): ScenarioNation[] =>
rows.map((row, index) => parseNationRow(row, index));
const parseDiplomacyRows = (rows: unknown[]): ScenarioDiplomacy[] =>
rows.map((row, index) => parseDiplomacyRow(row, index));
export const parseScenarioDefaults = (raw: unknown): ScenarioDefaults => {
// 기본 시나리오 설정값을 안전하게 읽는다.
const data = asRecord(raw);
const stat = parseScenarioStatBlock(data.stat, FALLBACK_STAT);
const iconPath = asString(data.iconPath, '.');
return { stat, iconPath };
};
export const parseScenarioDefinition = (
raw: unknown,
defaults: ScenarioDefaults
): ScenarioDefinition => {
// 시나리오 JSON을 런타임에서 쓰는 구조로 정규화한다.
const data = asRecord(raw);
const stat = parseScenarioStatBlock(data.stat, defaults.stat);
const mapConfig = asRecord(data.map);
const constConfig = asRecord(data.const);
const config: ScenarioConfig = {
stat,
iconPath: asString(data.iconPath, defaults.iconPath),
map: mapConfig,
const: constConfig,
environment: parseScenarioEnvironment(mapConfig, constConfig),
};
const title = ensureTitle(data.title);
const startYear =
typeof data.startYear === 'number' ? data.startYear : null;
const life = typeof data.life === 'number' ? data.life : null;
const fiction = typeof data.fiction === 'number' ? data.fiction : null;
const history = asStringArray(data.history);
const ignoreDefaultEvents = Boolean(data.ignoreDefaultEvents);
const nations = parseNationRows(asArray(data.nation));
const diplomacy = parseDiplomacyRows(asArray(data.diplomacy));
const generals = parseGeneralRows(asArray(data.general), 'general');
const generalsEx = parseGeneralRows(asArray(data.general_ex), 'general_ex');
const generalsNeutral = parseGeneralRows(
asArray(data.general_neutral),
'general_neutral'
);
const events = asArray(data.events);
const initialEvents = asArray(
data.initialEvents ?? data.initialActions ?? []
);
return {
title,
startYear,
life,
fiction,
history,
config,
nations,
diplomacy,
generals,
generalsEx,
generalsNeutral,
cities: asArray(data.cities),
events,
initialEvents,
ignoreDefaultEvents,
};
};
+83
View File
@@ -0,0 +1,83 @@
export interface ScenarioStatBlock {
total: number;
min: number;
max: number;
npcTotal: number;
npcMax: number;
npcMin: number;
chiefMin: number;
}
export interface ScenarioDefaults {
stat: ScenarioStatBlock;
iconPath: string;
}
export interface ScenarioEnvironment {
mapName: string;
unitSet: string;
scenarioEffect?: string | null;
}
export interface ScenarioConfig {
stat: ScenarioStatBlock;
iconPath: string;
map: Record<string, unknown>;
const: Record<string, unknown>;
environment: ScenarioEnvironment;
}
export interface ScenarioNation {
id: number;
name: string;
color: string;
gold: number;
rice: number;
infoText: string | null;
tech: number;
type: string;
level: number;
cities: string[];
}
export interface ScenarioDiplomacy {
fromNationId: number;
toNationId: number;
state: number;
durationMonths: number;
}
export interface ScenarioGeneral {
affinity: number | null;
name: string;
picture: number | string | null;
nation: number | string | null;
city: string | null;
leadership: number;
strength: number;
intelligence: number;
officerLevel: number;
birthYear: number;
deathYear: number;
personality: string | null;
special: string | null;
text: string | null;
}
export interface ScenarioDefinition {
title: string;
startYear: number | null;
life: number | null;
fiction: number | null;
history: string[];
config: ScenarioConfig;
nations: ScenarioNation[];
diplomacy: ScenarioDiplomacy[];
generals: ScenarioGeneral[];
generalsEx: ScenarioGeneral[];
generalsNeutral: ScenarioGeneral[];
cities: unknown[];
events: unknown[];
initialEvents: unknown[];
ignoreDefaultEvents: boolean;
}